From 9aa93b282cd51a5e2d19b33e6c4b08a40ac90367 Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Mon, 7 Sep 2026 09:45:00 +0900 Subject: [PATCH] =?UTF-8?q?[feat]=20solution/site,nginx:=20=EB=B0=9C?= =?UTF-8?q?=ED=96=89=EB=B3=B8=20=EB=AA=A9=EB=A1=9D=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=EC=99=80=20=EB=A3=A8=ED=8A=B8=20llms.txt=20=E2=80=94?= =?UTF-8?q?=20=ED=81=AC=EB=A1=A4=20=EA=B2=BD=EB=A1=9C=EB=A5=BC=20=EB=91=98?= =?UTF-8?q?=EB=A1=9C=20=EB=8A=98=EB=A6=B0=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 서치콘솔 URL 검사(2026-09-07, /s/stay): "참조 페이지: 감지된 페이지 없음". 색인은 됐는데 이 호스트의 어떤 페이지도 발행본을 가리키지 않아, 크롤러가 발행본에 닿는 길이 사이트맵 하나뿐이었다. 사이트맵은 "이런 주소가 있다"만 말하고 볼 가치가 있는지는 말하지 않는다 — 그래서 색인은 되고 순위는 0인 상태가 됐다. 랜딩의 쇼케이스는 API 를 fetch 해 그리는 클라이언트 렌더라(ShowcaseGrid.tsx) JS 를 실행하지 않는 크롤러에게는 없는 링크다. 그래서 정적 HTML 로 따로 굽는다. - seo/directory.ts: `/s/` 목록 페이지(CollectionPage + ItemList LD)와 루트 llms.txt. 목록의 제목은 payload 가 아니라 **구운 index.html 의 ** 에서 읽는다 — 발행은 바뀐 사이트 하나만 굽기 때문에 payload 로 만들면 나머지가 슬러그로 떨어진다 - prerender.ts: 사이트맵에 랜딩과 목록 페이지를 추가. 목록 주소는 끝 슬래시가 있어야 한다 — nginx 의 `location ^~ /s/` 가 슬래시로만 잡고, 없으면 사장님 앱으로 떨어진다 - nginx: `location ^~ /s/` 에 `index index.html`. try_files 첫 인자가 끝 슬래시면 nginx 가 디렉토리 검사로 읽고 거기서 멈춰 403 이 된다(=404 로도 안 떨어진다) ★ 루트 llms.txt 의 기대치: 구글은 안 쓴다고 공식 확인했고(2025-07 Illyes) 크롤러 트래픽으로도 거의 안 잡힌다(90일 5억 방문 중 408건). 두는 이유는 에이전트 경로 하나다 — 사용자가 AI 에게 "이 사이트 봐줘" 할 때의 fetch 는 봇 집계에 안 잡힌다. 비용이 함수 하나라 채택되면 이미 있는 쪽을 택했다. 발행본별 llms.txt 는 그대로다. 검증: tsc(solution/site) 통과 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019fteiJNvAEbTnUKq8fSqoj --- nginx/site.conf.example | 4 + solution/site/scripts/prerender.ts | 29 ++++- solution/site/src/seo/directory.ts | 174 +++++++++++++++++++++++++++++ solution/site/src/seo/index.ts | 1 + 4 files changed, 203 insertions(+), 5 deletions(-) create mode 100644 solution/site/src/seo/directory.ts diff --git a/nginx/site.conf.example b/nginx/site.conf.example index 273b729..9f31085 100644 --- a/nginx/site.conf.example +++ b/nginx/site.conf.example @@ -46,6 +46,10 @@ server { # ── 발행 사이트 ──────────────────────────────────────────── # ^~ 로 잡아 아래 정규식 location 들이 끼어들지 못하게 한다. location ^~ /s/ { + # ★ `/s/` 자체(발행본 목록 페이지)를 위해 필요하다. try_files 의 첫 인자 `$uri` 가 + # 끝 슬래시면 nginx 는 **디렉토리 검사**로 읽고, 디렉토리가 있으면 거기서 멈춘다 — + # index 지시자가 없으면 그 순간 403 이다(=404 로도 안 떨어진다). + index index.html; # $uri/ 를 거치면 nginx 가 끝 슬래시로 301 을 내보낸다. 크롤러가 리다이렉트를 # 한 번 더 타야 하므로 index.html 을 바로 준다. try_files $uri $uri/index.html =404; diff --git a/solution/site/scripts/prerender.ts b/solution/site/scripts/prerender.ts index 8c6df86..e8ab11d 100644 --- a/solution/site/scripts/prerender.ts +++ b/solution/site/scripts/prerender.ts @@ -24,8 +24,11 @@ import { homeMeta, renderHead, renderLlmsTxt, + renderRootLlmsTxt, renderRootRobotsTxt, + renderSiteIndex, renderSiteUrlset, + type DirectoryEntry, type SiteEntry, verifyGeo, verifyJsonLd, @@ -510,7 +513,7 @@ function writeRootMachineFiles(outRoot: string, origin: string) { const sitesDir = join(outRoot, SITE_DIR); if (!existsSync(sitesDir)) return; - const entries: SiteEntry[] = readdirSync(sitesDir, {withFileTypes: true}) + const sites: DirectoryEntry[] = readdirSync(sitesDir, {withFileTypes: true}) .filter((entry) => entry.isDirectory()) .map((entry) => ({slug: entry.name, file: join(sitesDir, entry.name, 'index.html')})) // index.html 이 없으면 발행이 끝나지 않은(또는 실패한) 디렉토리다. 사이트맵에 넣지 않는다. @@ -521,21 +524,37 @@ function writeRootMachineFiles(outRoot: string, origin: string) { // 전부 "대체 페이지(적절한 표준 태그가 있음)" 로 분류했다 — 색인은 되는데 제출분은 // 0건으로 보이는, 눈으로 원인을 못 찾는 종류다. loc: joinUrl(origin, SITE_DIR, entry.slug), + // 제목은 구운 HTML 에서 읽는다. payload 에서 가져오면 이번 실행분만 이름이 있고 + // 나머지는 슬러그로 떨어진다 — 발행은 바뀐 사이트 하나만 굽기 때문이다. + title: readTitle(entry.file) || entry.slug, // 페이지는 그 사이트를 구울 때마다 다시 쓰인다 — 파일 mtime 이 곧 마지막 발행 시각이다. lastmod: statSync(entry.file).mtime.toISOString(), })) .sort((a, b) => a.loc.localeCompare(b.loc)); - // ★ 오리진 루트(랜딩)도 담는다. 이 호스트에 들어오는 링크가 없어 크롤러의 유일한 문이 - // 사이트맵인데, 정작 그 문을 여는 첫 페이지가 빠져 있었다. - entries.unshift({loc: origin + '/'}); + // ★ `/s/` 목록 페이지. 크롤러가 발행본에 닿는 두 번째 경로다 — + // 사이트맵만 있을 때 서치콘솔은 "참조 페이지: 감지된 페이지 없음" 이라고 답했다. + // ★ 주소에 끝 슬래시가 있어야 한다 — nginx 의 `location ^~ /s/` 가 슬래시로만 잡는다. + const indexUrl = joinUrl(origin, SITE_DIR) + '/'; + writeFileSync(join(sitesDir, 'index.html'), renderSiteIndex(origin, indexUrl, sites), 'utf-8'); + + // 사이트맵에는 랜딩·목록 페이지도 담는다. 랜딩은 이 호스트의 첫 페이지이고, + // 목록은 발행본 전부로 이어지는 허브다 — 둘 다 크롤러가 먼저 열어야 하는 자리다. + const entries: SiteEntry[] = [{loc: origin + '/'}, {loc: indexUrl}, ...sites]; writeFileSync(join(outRoot, 'robots.txt'), renderRootRobotsTxt(origin), 'utf-8'); writeFileSync(join(outRoot, 'sitemap.xml'), renderSiteUrlset(entries), 'utf-8'); - console.log(` ✓ 루트 robots.txt · sitemap.xml (랜딩 + 사이트 ${entries.length - 1}개)`); + writeFileSync(join(outRoot, 'llms.txt'), renderRootLlmsTxt(origin, indexUrl, sites), 'utf-8'); + console.log(` ✓ 루트 robots.txt · sitemap.xml · llms.txt · /s/ 목록 (사이트 ${sites.length}개)`); writeIndexNowKey(outRoot); } +/** 구운 index.html 에서 <title> 만 꺼낸다. 파서를 붙일 값어치가 없는 한 줄짜리 일이다. */ +function readTitle(file: string): string { + const match = /<title>([^<]*)<\/title>/.exec(readFileSync(file, 'utf-8')); + return match ? match[1].trim() : ''; +} + /** * IndexNow 키 파일 — `https://<host>/<key>.txt` 에 키 문자열만 들어 있다. * diff --git a/solution/site/src/seo/directory.ts b/solution/site/src/seo/directory.ts new file mode 100644 index 0000000..6c87b9d --- /dev/null +++ b/solution/site/src/seo/directory.ts @@ -0,0 +1,174 @@ +import {escapeHtml} from './head'; + +export interface DirectoryEntry { + /** 발행본 주소. 페이지 canonical 과 **같은 형태**여야 한다(끝 슬래시 없음). */ + loc: string; + /** 구운 index.html 의 <title>. 디스크에 있는 것이 곧 정답이다. */ + title: string; + /** 마지막 발행 시각(ISO). */ + lastmod?: string; +} + +/** + * `/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'); +} diff --git a/solution/site/src/seo/index.ts b/solution/site/src/seo/index.ts index 333b02d..5dd6628 100644 --- a/solution/site/src/seo/index.ts +++ b/solution/site/src/seo/index.ts @@ -3,5 +3,6 @@ export * from './meta'; export * from './head'; export * from './robots'; export * from './sitemap'; +export * from './directory'; export * from './llms'; export * from './verify';