[feat] solution/site,nginx: 발행본 목록 페이지와 루트 llms.txt — 크롤 경로를 둘로 늘린다

서치콘솔 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 의 <title>** 에서 읽는다 —
  발행은 바뀐 사이트 하나만 굽기 때문에 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
This commit is contained in:
Mina Choi 2026-09-07 09:45:00 +09:00
parent 34e5932fbc
commit 9aa93b282c
4 changed files with 203 additions and 5 deletions

View File

@ -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;

View File

@ -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` .
*

View File

@ -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)}</title>
<meta name="description" content="${escapeHtml(description)}" />
<link rel="canonical" href="${escapeHtml(canonical)}" />
<meta name="robots" content="index, follow, max-snippet:-1" />
<meta property="og:type" content="website" />
<meta property="og:title" content="${escapeHtml(title)}" />
<meta property="og:description" content="${escapeHtml(description)}" />
<meta property="og:url" content="${escapeHtml(canonical)}" />
<link rel="alternate" type="text/plain" href="/llms.txt" title="LLM 요약" />
<style>
:root { color-scheme: light dark; }
body { margin: 0; font: 16px/1.7 'Noto Sans KR', -apple-system, BlinkMacSystemFont, sans-serif; }
main { max-width: 44rem; margin: 0 auto; padding: 3rem 1.25rem 5rem; }
h1 { font-size: 1.6rem; letter-spacing: -0.02em; margin: 0 0 0.5rem; }
p.lede { margin: 0 0 2rem; opacity: 0.7; }
ul { list-style: none; padding: 0; margin: 0; display: grid; gap: 0.5rem; }
a { display: flex; justify-content: space-between; align-items: baseline; gap: 1rem;
padding: 0.9rem 1.1rem; border: 1px solid currentColor; border-radius: 0.4rem;
text-decoration: none; color: inherit; }
a:hover, a:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
time { font-size: 0.8rem; opacity: 0.6; white-space: nowrap; }
footer { margin-top: 2.5rem; font-size: 0.85rem; opacity: 0.6; }
</style>
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "CollectionPage",
"@id": ${JSON.stringify(canonical)},
"name": ${JSON.stringify(title)},
"description": ${JSON.stringify(description)},
"mainEntity": {
"@type": "ItemList",
"numberOfItems": ${entries.length},
"itemListElement": [
${items}
]
}
}
</script>
</head>
<body>
<main>
<h1>${escapeHtml(title)}</h1>
<p class="lede">${escapeHtml(description)}</p>
<ul>
${cards}
</ul>
<footer><a href="${escapeHtml(origin)}/">Web4Ai </a></footer>
</main>
</body>
</html>
`;
}
/**
* `llms.txt` .
*
* `/s/<slug>/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');
}

View File

@ -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';