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 에서
만 꺼낸다. 파서를 붙일 값어치가 없는 한 줄짜리 일이다. */
+function readTitle(file: string): string {
+ const match = /([^<]*)<\/title>/.exec(readFileSync(file, 'utf-8'));
+ return match ? match[1].trim() : '';
+}
+
/**
* IndexNow 키 파일 — `https:///.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: 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) =>
+ [
+ '
+
+
+
+
+`;
+}
+
+/**
+ * 오리진 루트의 `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';