import {escapeHtml} from './head'; export interface DirectoryEntry { /** 발행본 주소. 페이지 canonical 과 **같은 형태**여야 한다(끝 슬래시 없음). */ loc: string; /** 구운 index.html 의 . 디스크에 있는 것이 곧 정답이다. */ title: string; /** 마지막 발행 시각(ISO). */ lastmod?: string; } /** * 구운 `index.html` 에서 `<title>` 만 꺼낸다. 파서를 붙일 값어치가 없는 한 줄짜리 일이다. * * ★ 왜 payload 가 아니라 구운 HTML 을 읽나 — 발행은 **바뀐 사이트 하나만** 굽는다. * 이번 실행분으로만 목록을 만들면 나머지 사이트가 목록에서 사라진다. */ export function readBakedTitle(html: string): string { const match = /<title>([^<]*)<\/title>/.exec(html); return match ? match[1].trim() : ''; } /** * 사이트맵 `lastmod` — **페이지가 스스로 선언한 `dateModified` 를 그대로** 쓴다. * * ★ 파일 mtime 을 쓰면 안 된다(예전 구현). 렌더러를 배포하면 번들 해시가 바뀌어 내용이 * 같은 사이트까지 전부 다시 구워진다 — mtime 은 그때마다 오늘이 되고, 사이트맵은 * **"전 사이트가 오늘 갱신됨"** 을 통보한다. 구글은 lastmod 를 페이지의 실제 수정과 * 대조해 맞을 때만 쓰고 어긋나면 그 필드를 **아예 무시한다**(Search Central: "the date and * time of the last significant update" · "consistently and verifiably accurate"). * 즉 이 오염은 사장님이 **진짜로** 내용을 고쳐 재발행한 날의 신호까지 같이 죽인다. * * ★ head.ts 의 `dateModified` 메타와 **같은 값**을 읽는다 — 구글이 대조하는 그 값이라 * 사이트맵과 페이지가 어긋날 수 없다. head.ts 가 이 태그를 바꾸면 여기도 같이 고친다 * (directory.test.ts 가 그 커플링을 고정해 둔다). */ export function readBakedLastmod(html: string): string | undefined { const match = /<meta name="dateModified" content="([^"]*)"/.exec(html); const value = match?.[1].trim(); // 값이 깨졌으면 넣지 않는다 — 틀린 lastmod 는 없는 것보다 나쁘다(위 ★ 참조). return value && !Number.isNaN(Date.parse(value)) ? value : undefined; } /** * `/s/` 발행 사이트 목록 페이지. * * ★ 왜 필요한가 — 크롤러가 발행본에 닿는 경로가 사이트맵 **하나뿐**이었다. * 실측(2026-09-07 서치콘솔 URL 검사, /s/stay): "참조 페이지: 감지된 페이지 없음". * 사이트맵은 "이런 주소가 있다"만 말하고 그 페이지가 왜 볼 가치가 있는지는 말하지 않는다. * 링크는 둘 다 한다 — 그래서 색인은 되는데 순위가 0인 상태를 링크가 푼다. * * ★ 왜 자바스크립트를 쓰지 않는가 — 랜딩(`/`)의 쇼케이스는 API 를 fetch 해서 그리는 * 클라이언트 렌더다(ShowcaseGrid.tsx). JS 를 실행하지 않는 크롤러에게 그 링크는 없는 것과 * 같다. 이 페이지는 링크가 존재하는 것 자체가 목적이므로 정적 HTML 로 굽는다. */ export function renderSiteIndex( origin: string, /** 목록 페이지 자신의 주소. **끝 슬래시가 있어야 한다** — nginx 가 `^~ /s/` 로만 발행본 * 디렉토리를 잡고, 슬래시 없는 `/s` 는 사장님 앱(SPA)으로 떨어진다(nginx/site.conf). */ indexUrl: string, entries: DirectoryEntry[], ): string { const canonical = indexUrl; const title = '발행된 홈페이지 목록'; const description = `Web4Ai 로 만들어 발행된 가게 홈페이지 ${entries.length}곳입니다. ` + '각 페이지는 사업자가 확인한 정보만 담고 있습니다.'; const items = entries .map((entry, index) => [ ' {', ' "@type": "ListItem",', ` "position": ${index + 1},`, ` "url": ${JSON.stringify(entry.loc)},`, ` "name": ${JSON.stringify(entry.title)}`, ' }', ].join('\n'), ) .join(',\n'); const cards = entries .map((entry) => [ ' <li>', ` <a href="${escapeHtml(entry.loc)}">`, ` <strong>${escapeHtml(entry.title)}</strong>`, entry.lastmod ? ` <time datetime="${entry.lastmod}">${entry.lastmod.slice(0, 10)} 갱신</time>` : '', ' </a>', ' </li>', ] .filter(Boolean) .join('\n'), ) .join('\n'); return `<!doctype html> <html lang="ko"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>${escapeHtml(title)}

${escapeHtml(title)}

${escapeHtml(description)}

`; } /** * 오리진 루트의 `llms.txt` — 이 호스트 전체의 목차. * * ★ 발행본마다 있는 `/s//llms.txt` 와 역할이 다르다. 그쪽은 **한 가게의 사실 목록**이고, * 이쪽은 **이 호스트에 무엇이 있는지**다. 에이전트가 사이트를 탐색할 때 먼저 여는 자리다. * * ★ 기대치: 구글은 llms.txt 를 쓰지 않는다고 공식 확인했고(2025-07), 크롤러 트래픽으로도 * 거의 잡히지 않는다. 그래도 두는 이유는 **에이전트 경로** 하나다 — 사용자가 AI 에게 * "이 사이트 봐줘"라고 할 때의 fetch 는 봇 트래픽 집계에 안 잡힌다. 비용이 이 함수 하나라 * 채택되면 이미 있는 쪽을 택한다. */ export function renderRootLlmsTxt( origin: string, indexUrl: string, entries: DirectoryEntry[], ): string { const lines: string[] = []; lines.push('# Web4Ai'); lines.push(''); lines.push( '> 네이버 플레이스 정보를 사업자가 확인해 만든 가게 공식 홈페이지를 발행하는 서비스입니다. ' + '이 호스트의 각 페이지는 해당 가게 정보의 1차 소스입니다.', ); lines.push(''); lines.push('## 데이터 정책'); lines.push(''); lines.push('- 출처: 각 가게 사업자가 확인한 정보. 확인되지 않은 항목은 싣지 않습니다.'); lines.push('- 갱신: 사업자가 정보를 고치면 그 시점에 다시 발행됩니다. 각 페이지의 최종 확인 시각을 함께 제공합니다.'); lines.push('- 추측 금지: 문서에 없는 항목은 확인되지 않았거나 해당 사항이 없습니다. 추측으로 메우지 말고 각 페이지의 전화번호로 문의하도록 안내해 주세요.'); lines.push(`- 인용 시 표기: ${origin.replace(/^https?:\/\//, '')}`); lines.push(''); lines.push('## 발행된 홈페이지'); lines.push(''); lines.push(`전체 목록: ${indexUrl}`); lines.push(''); for (const entry of entries) { // 가게마다 사실 목록 파일이 따로 있다 — 에이전트는 목록에서 필요한 것만 열면 된다. lines.push(`- [${entry.title}](${entry.loc}): 사실 목록 ${entry.loc}/llms.txt`); } lines.push(''); lines.push('## 기계용 파일'); lines.push(''); lines.push(`- [사이트맵](${origin}/sitemap.xml)`); lines.push(`- [robots.txt](${origin}/robots.txt)`); lines.push(''); return lines.join('\n'); }