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() : '';
}
/**
* 페이지가 스스로 `noindex` 를 선언했나. 그런 페이지는 사이트맵·`/s` 목록·llms.txt 에 싣지 않는다.
*
* ★ 왜 — `out/s/` 에는 payload 없는 목업·백업(`stay2` · `stay3` · `*.old`)이 섞여 있고, 목록은
* 디렉토리를 훑어 만든다. 목업은 운영본(`/s/stay`)의 복제라 제목·본문이 같은데 canonical 은
* 각자 자기를 가리켰다 — 구글은 같은 글 여러 벌 중 하나만 고르고, 실측(2026-09-11) 운영본이
* "스테이머뭄" 검색에서 통째로 빠졌다. 목업에 noindex 를 박는 것만으로는 절반이다 — 사이트맵에
* 남아 있으면 서치콘솔이 "제출된 URL 에 noindex" 오류를 계속 띄운다.
* ★ 슬러그 이름 규칙(`.old` 등)으로 거르지 않는다 — 페이지 자신의 선언이 유일한 출처다.
*/
export function readBakedNoindex(html: string): boolean {
const match = //s`). 발행본 주소와 **같은 형태**여야 한다 —
* 끝 슬래시 없음. nginx 가 `location = /s` 로 이 파일을 직접 준다(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');
}