o2o-site-AEO/solution/site/src/seo/directory.ts
Mina Choi f008b24574 [fix] solution/site: 자산 보관 코드를 되살린다 — 목업은 재굽기가 안 되므로 자산이 지워지면 끝이다
되돌렸던 6f4e055 를 그대로 되살린다. out/s/ 에는 payload 가 없는 사이트(목업)가 있고,
그건 재굽기 대상이 아니라서 자산이 한 번 지워지면 영영 복구되지 않는다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018xTWrJ6Mrr6HhEN6hZEER4
2026-09-07 14:04:50 +09:00

207 lines
9.1 KiB
TypeScript

import {escapeHtml} from './head';
export interface DirectoryEntry {
/** 발행본 주소. 페이지 canonical 과 **같은 형태**여야 한다(끝 슬래시 없음). */
loc: string;
/** 구운 index.html 의 <title>. 디스크에 있는 것이 곧 정답이다. */
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)}</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');
}