실측(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
77 lines
3.1 KiB
TypeScript
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');
|
|
}
|