o2o-site-AEO/solution/site/src/seo/robots.ts
Mina Choi 533de126fb [feat] solution/frontend,nginx: 랜딩·요금·사례를 프리렌더 — 크롤러가 빈 종이를 받던 것
실측(2026-09-07): `curl /` 가 3,021바이트에 본문 0자·`<a>` 0개였다. 같은 호스트의
발행본은 48,072바이트다. 구글은 JS 를 실행하지만 **렌더링 큐가 따로** 돌고 신규
도메인은 뒤로 밀린다 — 그동안 색인에는 "제목만 있고 내용 없는 페이지"로 들어가 있다.
서치콘솔이 "URL이 Google에 등록되어 있음"이라고 답하면서도 브랜드명 검색에조차 안
걸리던 이유다.

스크립트를 새로 짜지 않았다. react-router 7.17 에 프리렌더가 내장돼 있고
`ssr: false` 와 함께 쓰면 런타임 Node 서버 없이 지정한 경로만 HTML 로 굽는다 —
나머지는 지금까지처럼 SPA 폴백이다. 배포 구조가 그대로다.

- react-router.config.ts: `ssr:false` + `prerender: ['/', '/pricing', '/showcase']`.
  로그인 뒤에만 의미가 있는 화면은 굽지 않는다(구울 내용이 사용자별이다)
- src/root.tsx · src/routes.ts: 예전 index.html + app/router.tsx 가 하던 일.
  가드는 페이지마다 감싸지 않고 RequireAuthLayout 레이아웃 라우트 하나로 모았다
- 랜딩·요금·사례에 meta export: 제목을 브랜드가 아니라 **검색어**로 시작하게 바꿨다.
  예전 제목("Web4Ai · AI 웹 빌더")에는 사람이 치는 말이 한 단어도 없었다.
  랜딩에 Organization JSON-LD 추가 — 발행본에는 있는데 정작 랜딩엔 없었다
- src/lib/site.ts: 발행 호스트의 단일 출처. 모듈 최상위의 `window.location` 폴백을
  전부 걷었다 — 서버 번들은 라우트를 한 파일로 묶어서 프리렌더 대상이 아닌 화면의
  최상위 코드도 빌드 때 실행된다(실측: BuilderPage 에서 빌드가 죽었다)
- LoginPage: homePath 기본값 `/` → `/sites`. 예전엔 router.tsx 가 넘기던 값이라
  라우트 모듈로 옮기면서 그대로 두면 로그인 후 랜딩으로 갔다
- nginx: SPA 폴백을 `/index.html` → `/__spa-fallback.html`. 프리렌더 뒤로
  `/index.html` 은 **랜딩이 구워진 파일**이라, 그리로 넘기면 `/builder` 에 랜딩
  HTML 이 내려가고 클라이언트가 다른 주소로 하이드레이트한다
- nginx/Dockerfile: 산출물이 `dist` → `build/client`. 경로가 어긋나면 COPY 가
  조용히 빈 디렉토리를 만들고 컨테이너는 정상으로 뜬다
- site/seo/robots.ts: `/builder` `/login` `/signup` `/sites` `/account` Disallow.
  이 경로들은 빈 SPA 폴백을 받는다 — 긁히면 호스트 전체에 저품질 신호가 쌓인다

검증: tsc·eslint·react-router build 통과.
랜딩 3,021B → 21,799B, 본문 1,278자, 링크 6개.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019fteiJNvAEbTnUKq8fSqoj
2026-09-07 11:30:27 +09:00

77 lines
3.1 KiB
TypeScript

/**
* AI 크롤러를 **명시적으로 허용**하는 robots.txt.
*
* 보통 사이트들은 이 봇들을 막는다(학습 데이터로 쓰이는 게 싫어서).
* 우리는 반대다 — 이 서비스의 목표가 "AI 검색이 이 가게를 공식 홈페이지 기준으로
* 설명하게 만드는 것"이라, 읽히지 않으면 존재 이유가 없다.
* 기본값(=명시 없음)에 맡기지 않고 이름을 하나씩 적는 이유는,
* 일부 봇이 와일드카드보다 자기 이름 규칙을 우선으로 보기 때문이다.
*/
const AI_CRAWLERS = [
'GPTBot', // OpenAI 학습·검색
'OAI-SearchBot', // ChatGPT search
'ChatGPT-User', // ChatGPT 가 사용자를 대신해 여는 요청
'ClaudeBot', // Anthropic
'Claude-Web',
'anthropic-ai',
'PerplexityBot',
'Perplexity-User',
'Google-Extended', // Gemini / AI Overviews
'Applebot-Extended', // Apple Intelligence
'CCBot', // Common Crawl — 다수 모델의 상류 데이터
'Bytespider',
'Amazonbot',
'meta-externalagent',
'cohere-ai',
];
const SEARCH_CRAWLERS = [
'Googlebot',
'Bingbot',
'Yeti', // 네이버
'Daumoa', // 다음
];
/**
* 앱 전용 경로 — 크롤러가 볼 것이 없는 자리.
*
* ★ 로그인해야 내용이 생기거나(`/sites` `/account`), 도구 화면이라(`/builder`) 색인할
* 내용이 아예 없다. 프리렌더를 켠 뒤 이 경로들은 빈 SPA 폴백을 받는데, 그걸 긁히면
* 호스트 전체에 "내용 없는 페이지" 신호가 쌓인다.
* ★ `/showcase` 는 막지 않는다 — 프리렌더 대상이고 발행 사례를 담는다.
*/
const APP_ONLY_PATHS = ['/builder', '/login', '/signup', '/sites', '/account'];
/** 크롤러 허용 블록 — 루트용과 사이트용이 같은 목록을 쓴다. */
function allowBlocks(): string[] {
const deny = APP_ONLY_PATHS.map((path) => `Disallow: ${path}`);
const blocks: string[] = ['User-agent: *', 'Allow: /', ...deny, ''];
for (const bot of [...SEARCH_CRAWLERS, ...AI_CRAWLERS]) {
blocks.push(`User-agent: ${bot}`, 'Allow: /', ...deny, '');
}
return blocks;
}
/**
* **오리진 루트의** robots.txt — 실제로 읽히는 유일한 robots.txt.
*
* ★ robots.txt 는 오리진 루트(`https://host/robots.txt`)에서만 읽힌다(RFC 9309).
* 발행 사이트는 `<host>/s/<slug>/` 아래에 있어서 사이트별 robots.txt 는 **아무도 읽지 않는다** —
* AI 크롤러를 아무리 명시 허용해도 파일이 그 자리에 있으면 효과가 0이다.
* 그래서 프리렌더가 사이트를 다 구운 뒤 여기서 루트 파일을 한 장 더 쓴다.
*
* ★ Sitemap 도 여기서만 전달된다. 사이트별 sitemap.xml 은 사이트맵 인덱스가 묶는다.
*/
export function renderRootRobotsTxt(origin: string): string {
const base = origin.replace(/\/+$/, '');
return [
'# 이 호스트의 사이트는 검색·AI 답변에 인용되기 위해 존재합니다.',
'# 아래 크롤러를 명시적으로 허용합니다.',
'',
...allowBlocks(),
`Sitemap: ${base}/sitemap.xml`,
'',
].join('\n');
}