import {escapeHtml} from './head';
export interface DirectoryEntry {
/** 발행본 주소. 페이지 canonical 과 **같은 형태**여야 한다(끝 슬래시 없음). */
loc: string;
/** 구운 index.html 의
. 디스크에 있는 것이 곧 정답이다. */
title: string;
/** 마지막 발행 시각(ISO). */
lastmod?: string;
}
/**
* 구운 `index.html` 에서 `` 만 꺼낸다. 파서를 붙일 값어치가 없는 한 줄짜리 일이다.
*
* ★ 왜 payload 가 아니라 구운 HTML 을 읽나 — 발행은 **바뀐 사이트 하나만** 굽는다.
* 이번 실행분으로만 목록을 만들면 나머지 사이트가 목록에서 사라진다.
*/
export function readBakedTitle(html: string): string {
const match = /([^<]*)<\/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 = /
[
' {',
' "@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');
}