/** * 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). * 발행 사이트는 `/s//` 아래에 있어서 사이트별 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'); }