o2o-site-AEO/solution/site/scripts/prerender.ts
Mina Choi 8410380769 [fix] solution/backend,shared,site: 에디터에 쓴 소개문이 발행에서 사라지던 구멍 — 계약에 body 추가
사장님이 소개 섹션에 본문을 써도 발행이 "고유 콘텐츠 0건"으로 거부됐다.
본문은 sites.theme 에 저장은 되는데 payload 경계에서 버려졌다 — _sections() 가
저장값에서 id·name·enabled·locked·variantId 다섯 개만 꺼내 새로 만들었다.
그래서 발행본에 안 나오고, 계수에도 안 잡혔다.

거부 문구도 틀렸다. 렌더러가 '고유 콘텐츠 0건'을 JSON-LD 불일치와 같은 VerifyError 의
mismatches 에 실어 던져서, 백엔드가 JSONLD_MISMATCH 로 판정하고 화면에는
"구조화 데이터와 화면 값이 다릅니다" 가 떴다. 구조화 데이터는 멀쩡했다.

- shared/site-payload: SectionSetting.body 추가 — variantId 와 같은 사연
- backend/site_payload: 저장된 body 를 payload 까지 실어 보낸다
- site/derive,AboutSection: 직접 쓴 본문을 그린다. 없으면 intro fact 로 떨어진다
- site/prerender: 켜진 소개 섹션의 8자 이상 본문을 고유 콘텐츠로 계수
- site/prerender: NoUniqueContentError 분리 — mismatches 를 비워 라벨이 안 섞이게.
  계수를 못 잰 실패는 null 로 보고한다(0 으로 적으면 디스크 오류가 같은 사유를 받는다)
- backend/build_service,publish_gate: 렌더 실패가 0건이면 NO_UNIQUE_CONTENT 라벨을 붙인다.
  evaluate() 는 안 건드렸다 — 얇은 콘텐츠로 발행을 막지 않기로 한 결정 그대로다
- backend/router: theme API 설명에 body 반영

테스트 8 failed / 511 passed. 실패 8건은 변경 전(508 passed)과 동일한 기존 실패다
(test_default_sections_match_the_editor 의 solution/front 경로 오타 등).
tsc·site·shared 통과. 실물 검증: 본문만 있는 payload → ok=true, uniqueContentCount=1,
발행 HTML 에 문장 포함. 같은 payload 에서 본문을 빼면 0건으로 거부.
2026-09-02 12:00:16 +09:00

633 lines
27 KiB
TypeScript

import {
copyFileSync,
existsSync,
mkdirSync,
readFileSync,
readdirSync,
renameSync,
rmSync,
statSync,
writeFileSync,
} from 'node:fs';
import {basename, dirname, join, resolve} from 'node:path';
import {fileURLToPath} from 'node:url';
import type {SitePayload} from '@o2o/shared';
import {joinUrl, sanitizePayloadForPublish} from '@o2o/shared';
import {render} from '@/entry-server';
import {
collectJsonLd,
homeMeta,
renderHead,
renderLlmsTxt,
renderRootRobotsTxt,
renderSiteUrlset,
verifyGeo,
verifyJsonLd,
} from '@/seo';
import {MOONLIGHT_STAY_PAYLOAD} from '@/fixtures/moonlight-stay';
/**
* 발행 사이트 프리렌더.
*
* npm run prerender 데모 payload 로 굽는다
* npm run prerender -- --payload=a.json 파일 하나
* npm run prerender -- --payload=a.json --payload=b.json 여러 개(바뀐 것만)
* npm run prerender -- --payload=./payloads 디렉토리 안의 *.json 전부
* npm run prerender -- --out=../../dist 출력 위치 지정
*
* ★ --payload 은 여러 번 줄 수 있다. 사이트 1,000개에서 한 명이 발행했다고 전부 다시
* 구우면 못 쓴다 — 감시 프로세스가 바뀐 payload 만 골라 넘긴다.
*
* 산출물(사이트 1개당):
* out/s/<slug>/index.html 사이트 전체(한 장)
* out/s/<slug>/llms.txt 확인된 사실 목록(AEO)
* out/payloads/.status/<slug>.json 렌더 결과 보고서(백엔드가 읽는다)
*
* 오리진 루트(사이트 전체가 공유):
* out/robots.txt · out/sitemap.xml 크롤러가 읽는 유일한 자리
* out/<indexnow-key>.txt 색인 통보용 키 파일
*
* 공용 산출물(사이트 전체가 공유):
* out/assets/… 클라이언트 번들(하이드레이션용)
* out/fonts/… 폰트
*
* ★ 자산을 사이트마다 복사하지 않는다. 같은 해시의 번들을 1,000벌 복사하면 디스크도
* 낭비지만, 더 나쁜 건 브라우저 캐시가 사이트마다 따로 잡혀 매번 새로 받는다는 점이다.
* (커스텀 도메인 사이트는 예외 — 호스트가 달라 공용 경로를 공유할 수 없다.)
*
* ★ 이 스크립트는 백엔드 BUILD 잡이 부르는 자리다. 잡이 payload JSON 을 써 주고
* 이걸 실행하면 정적 파일이 나온다. 지금은 payload 를 파일에서 읽지만,
* API 가 열리면 --payload=https://... 를 지원하는 것으로 충분하다.
*/
/** 사이트별 산출물이 놓이는 디렉터리. 발행 주소 `<host>/s/<slug>` 와 같은 모양이다. */
const SITE_DIR = 's';
const HERE = dirname(fileURLToPath(import.meta.url));
const SITE_ROOT = resolve(HERE, '..', '..'); // dist/prerender → site/
const CLIENT_DIR = join(SITE_ROOT, 'dist', 'client');
interface Args {
/** --payload 은 여러 번 줄 수 있다. 비면 데모 payload 로 굽는다. */
payloads: string[];
out: string;
}
function parseArgs(): Args {
const args: Args = {payloads: [], out: join(SITE_ROOT, 'out')};
for (const arg of process.argv.slice(2)) {
const [key, value] = arg.replace(/^--/, '').split('=');
if (key === 'payload' && value) args.payloads.push(value);
if (key === 'out' && value) args.out = resolve(process.cwd(), value);
}
return args;
}
/** 경로(파일 또는 디렉토리)를 payload 파일 목록으로 편다. */
function expandPayloadPaths(paths: string[]): string[] {
const files: string[] = [];
for (const path of paths) {
const target = resolve(process.cwd(), path);
if (!existsSync(target)) {
throw new Error(`payload 를 찾을 수 없습니다: ${target}`);
}
if (target.endsWith('.json')) {
files.push(target);
continue;
}
for (const name of readdirSync(target)) {
// 백엔드가 원자적 rename 전에 쓰는 임시파일(.<slug>.json.tmp)을 집지 않는다.
if (name.endsWith('.json') && !name.startsWith('.')) files.push(join(target, name));
}
}
// 같은 파일을 두 번 굽지 않는다(--payload=dir --payload=dir/a.json 같은 조합).
return [...new Set(files)];
}
/**
* 읽어들인 payload 한 건. 읽기·검증이 실패했으면 payload 대신 error 가 담긴다.
*
* ★ 실패를 여기서 throw 하지 않는다. 예전에는 payload 하나가 깨지면 배치 전체가 죽어서,
* 멀쩡한 사이트까지 못 구웠다(게다가 아무 보고서도 남지 않았다). 실패는 값으로 옮기고
* main 이 사이트 단위로 격리한다.
*/
interface LoadedPayload {
payload?: SitePayload;
/** 렌더 결과 보고서를 이 파일 옆에 쓴다 — 백엔드가 보는 유일한 경로다. */
file?: string;
error?: string;
}
/** payload 파일 하나를 읽고 검증한다. */
function loadOne(file: string): LoadedPayload {
try {
const payload = JSON.parse(readFileSync(file, 'utf-8')) as SitePayload;
// 모르는 스키마 버전을 반쪽만 렌더해서 내보내는 게 최악이다 — 이 파일만 건너뛴다.
if (payload.schemaVersion !== 1) {
return {
file,
payload,
error: `지원하지 않는 schemaVersion=${payload.schemaVersion} (이 렌더러는 1만 처리합니다)`,
};
}
if (!payload.site?.slug) {
return {file, payload, error: 'payload 에 site.slug 이 없습니다'};
}
return {file, payload};
} catch (ex) {
return {file, error: ex instanceof Error ? ex.message : String(ex)};
}
}
/** payload 를 읽는다. 파일 / 디렉토리 / 미지정(데모) 셋을 받는다. */
function loadPayloads(paths: string[]): LoadedPayload[] {
if (paths.length === 0) {
console.log('[prerender] --payload 이 없어 데모 payload 로 굽습니다.');
return [{payload: MOONLIGHT_STAY_PAYLOAD}];
}
const files = expandPayloadPaths(paths);
if (files.length === 0) throw new Error(`payload JSON 이 없습니다: ${paths.join(', ')}`);
return files.map(loadOne);
}
/** 클라이언트 빌드 manifest 에서 엔트리 JS/CSS 경로를 뽑는다. */
function readAssets(): {script: string; css: string[]} {
const manifestPath = join(CLIENT_DIR, '.vite', 'manifest.json');
if (!existsSync(manifestPath)) {
throw new Error(
`클라이언트 빌드 결과가 없습니다: ${manifestPath}\n 먼저 \`npm run build:client\` 를 실행하세요.`,
);
}
const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8')) as Record<
string,
{file: string; css?: string[]; isEntry?: boolean}
>;
const entry = Object.values(manifest).find((chunk) => chunk.isEntry);
if (!entry) throw new Error('manifest 에 엔트리 청크가 없습니다.');
// ★ 파일명만 돌려준다. 앞에 붙일 경로는 사이트마다 다르다(`/s/<slug>`) —
// 여기서 `/assets/…` 로 굳히면 사이트가 `/s/mmg/` 아래 놓이는 순간 전부 404 가 되고,
// JS 가 안 붙어 하이드레이션이 죽는다(정적 HTML 만 남는다). 실측으로 그렇게 됐다.
return {
script: entry.file.replace(/^assets\//, ''),
css: (entry.css ?? []).map((file) => file.replace(/^assets\//, '')),
};
}
/**
* 구조화 데이터 대조 실패. ★ 재시도해도 소용없다 — 데이터가 고쳐져야 통과한다.
*
* 일반 렌더 오류(디스크·번들)와 구분해야 한다. 그쪽은 재시도가 의미 있지만
* 이건 사장님이 값을 고치기 전까지 몇 번을 구워도 같은 결과다.
*/
class VerifyError extends Error {
constructor(
readonly mismatches: string[],
/** ★ 실패해도 계수는 보고한다. 백엔드가 NO_UNIQUE_CONTENT 와 JSONLD_MISMATCH 를 갈라야 한다. */
readonly uniqueContentCount: number | null = null,
) {
super(`구조화 데이터가 화면 값과 다릅니다(${mismatches.length}건): ${mismatches.slice(0, 3).join(' · ')}`);
this.name = 'VerifyError';
}
}
/**
* 고유 콘텐츠 0건. **VerifyError 와 갈라 둔다.**
*
* ★ 예전에는 이 사유를 VerifyError 의 mismatches 에 실어 던졌다. 백엔드 게이트는
* mismatches 가 비지 않았다는 것만 보고 JSONLD_MISMATCH 로 판정했고, 사장님 화면에는
* "구조화 데이터와 화면 값이 다릅니다" 라는 **틀린 문구**가 떴다 — 구조화 데이터는
* 멀쩡했다. 사유가 다르면 예외도 갈라야 라벨이 안 섞인다.
*/
class NoUniqueContentError extends Error {
/** 백엔드가 JSONLD_MISMATCH 로 오인하지 않도록 늘 비어 있다. */
readonly mismatches: string[] = [];
constructor(readonly uniqueContentCount: number) {
super('고유 콘텐츠가 0건이다 — 이 가게에만 있는 내용이 없으면 발행하지 않는다');
this.name = 'NoUniqueContentError';
}
}
/**
* 사이트는 **한 장**이다(2026-08-31). 예전에는 홈·객실·객실상세·주변·오시는길·FAQ 로
* 라우트를 갈라 사이트 하나에 30개 안팎의 HTML 을 구웠다.
*
* ★ 왜 합쳤나 — 소상공인은 원래 내용이 적다. 쪼갤수록 페이지마다 얇아지고, 검색엔진은
* 그런 페이지를 색인에서 버린다. 한 장에 모으면 알찬 페이지 하나가 된다.
* 경로형(`/s/<slug>`)이라 얇은 페이지의 평가가 도메인 전체로 번지는 것도 막는다.
*/
/**
* payload 를 HTML 안에 심을 수 있게 직렬화한다.
*
* ★ `<`, `>`, `&` 를 유니코드 이스케이프한다. 안 하면 사장님이 소개문에 적은
* `</script>` 한 줄이 스크립트 태그를 닫아 버린다 — 그 뒤 내용이 마크업으로 새고,
* 최악의 경우 임의 스크립트가 된다. U+2028/2029 는 JS 문법상 줄바꿈이라 함께 막는다.
*/
function serializePayload(payload: SitePayload): string {
return JSON.stringify(payload)
.replace(/</g, '\\u003c')
.replace(/>/g, '\\u003e')
.replace(/&/g, '\\u0026')
.replace(/\u2028/g, '\\u2028')
.replace(/\u2029/g, '\\u2029');
}
function writeFile(outDir: string, relPath: string, content: string) {
const full = join(outDir, relPath);
mkdirSync(dirname(full), {recursive: true});
writeFileSync(full, content, 'utf-8');
}
/** Node 24의 recursive cpSync가 macOS Docker bind mount에서 0바이트·write-only 파일을
* 남기고 EACCES로 실패하는 경우가 있어, 빌드 자산은 파일 단위로 복사한다. */
function copyDirectoryFiles(source: string, destination: string) {
mkdirSync(destination, {recursive: true});
for (const entry of readdirSync(source, {withFileTypes: true})) {
const from = join(source, entry.name);
const to = join(destination, entry.name);
if (entry.isDirectory()) copyDirectoryFiles(from, to);
else if (entry.isFile()) {
// 이전 실패가 write-only 0바이트 파일을 남겼어도 덮어쓰기를 시도하지 말고 먼저 지운다.
rmSync(to, {force: true});
copyFileSync(from, to);
}
}
}
function assetPlan(payload: SitePayload, outRoot: string, siteDir: string) {
const basePath = payload.site.basePath.replace(/\/+$/, '');
const suffix = `/${SITE_DIR}/${payload.site.slug}`;
// out/ 이 도메인 루트가 아닌 곳에 마운트돼도 맞도록 basePath 에서 역산한다.
// ('/s/joy' → '' · '/sites/s/joy' → '/sites')
const rootPrefix = basePath.endsWith(suffix) ? basePath.slice(0, -suffix.length) : '';
const shared = basePath !== '';
return {
shared,
/** 자산을 실제로 복사해 넣을 디렉토리. */
dir: shared ? outRoot : siteDir,
/** HTML 이 참조할 접두사. 두 경우 모두 루트 절대경로가 된다. */
base: `${rootPrefix}/assets`,
};
}
function prerenderSite(input: SitePayload, outRoot: string, assets: ReturnType<typeof readAssets>) {
// ★ 여기서 한 번 깎고, 그 뒤로는 깎인 payload 만 쓴다 —
// 서버 렌더 · JSON-LD · llms.txt · HTML 에 심는 블롭이 전부 같은 객체를 본다.
// (블롭만 원본으로 두면 미검증 fact 가 HTML 소스로 새고, AI 크롤러는 그걸 읽는다.)
const payload = sanitizePayloadForPublish(input);
// ★ 발행 주소가 `<host>/s/<slug>` 이므로 파일도 같은 모양으로 놓는다.
// 그래야 out/ 을 그대로 정적 서빙하면 발행된 모든 사이트가 실제 주소로 열린다 —
// 사이트마다 서버를 따로 띄우거나 경로를 손으로 맞출 필요가 없다.
const siteDir = join(outRoot, SITE_DIR, payload.site.slug);
const plan = assetPlan(payload, outRoot, siteDir);
/** 브라우저가 자산을 찾아갈 접두사. 파일이 놓인 자리와 같아야 한다. */
const assetBase = plan.base;
/**
* ★ 먼저 메모리에 굽고, 검증을 통과한 뒤에야 파일로 쓴다.
* 렌더하면서 바로 쓰면 검증에 걸린 페이지가 이미 디스크에 나가 있게 된다 —
* 그 순간 방문자와 크롤러가 그걸 읽는다. 게이트가 있으나 마나가 된다.
* 실패하면 아무것도 쓰지 않으므로 **직전 버전이 그대로 서비스된다**(빈 사이트가 되지 않는다).
*/
const mismatches: string[] = [];
// 실패하더라도 보고서에 실어야 하므로 먼저 센다.
const uniqueContentCount = countUniqueContent(payload);
const meta = homeMeta(payload);
const appHtml = render(payload);
const jsonld = collectJsonLd(payload, {title: meta.title, description: meta.description});
const head = renderHead({
payload,
meta,
scriptSrc: `${assetBase}/${assets.script}`,
cssHrefs: assets.css.map((file) => `${assetBase}/${file}`),
});
const html = [
'<!doctype html>',
'<html lang="ko">',
' <head>',
head,
' </head>',
' <body>',
` <div id="root">${appHtml}</div>`,
` <script>window.__SITE_PAYLOAD__=${serializePayload(payload)}</script>`,
' </body>',
'</html>',
'',
].join('\n');
/** ★ 절대규칙 3 — 나갈 바로 그 HTML 에 대고 대조한다. */
for (const problem of verifyJsonLd(html, jsonld)) mismatches.push(problem);
for (const problem of verifyGeo(jsonld, payload.place.latitude, payload.place.longitude)) {
mismatches.push(problem);
}
if (mismatches.length > 0) {
throw new VerifyError(mismatches, uniqueContentCount);
}
/**
* ★ 절대규칙 2 — 이 가게에만 있는 콘텐츠가 0건이면 굽지 않는다.
*
* 같은 템플릿으로 대량 생성한 사이트는 스팸 판정을 받고, 판정되면 사이트가 통째로 무의미해진다.
* 이 검사도 **렌더러 안**에 있어야 한다 — 백엔드가 나중에 거부하더라도 그 전에 이미
* 페이지가 디스크에 나가 있으면 크롤러가 그걸 읽는다.
*/
if (uniqueContentCount <= 0) {
throw new NoUniqueContentError(uniqueContentCount);
}
writeFile(siteDir, 'index.html', html);
/**
* 기계용 파일은 `llms.txt` 하나만 남는다.
*
* ★ 사이트별 `sitemap.xml` 을 없앴다 — 한 장짜리 사이트의 사이트맵은 URL 이 하나뿐이라,
* 사이트가 1,000개면 URL 한 줄짜리 파일이 1,000개 생긴다. 루트 사이트맵 하나에
* 전부 담는다(사이트맵 하나에 URL 50,000개까지 들어간다).
*
* ★ 사이트별 `robots.txt` 도 없앴다 — `<host>/s/<slug>/robots.txt` 는 **아무도 읽지 않는다**.
* 크롤러는 오리진 루트에서만 읽는다(RFC 9309).
*/
writeFile(siteDir, 'llms.txt', renderLlmsTxt(payload));
// 하이드레이션용 번들. 공용 호스트면 out/ 루트 한 벌을 공유하므로 여기서는 아무것도 안 한다
// (main 이 사이트를 굽기 전에 한 번 깔아 둔다). 커스텀 도메인일 때만 사이트 안에 복사한다.
if (!plan.shared) {
writeSharedAssets(plan.dir);
}
// 이전 구현이 사이트마다 복사해 둔 자산이 남아 있으면 지운다 — 공용으로 바뀐 뒤에는
// 아무도 참조하지 않는 죽은 파일이고, 사이트 수만큼 디스크를 계속 먹는다.
if (plan.shared) {
rmSync(join(siteDir, 'assets'), {recursive: true, force: true});
rmSync(join(siteDir, 'fonts'), {recursive: true, force: true});
}
// 보고서용 — 실제로 나간 JSON-LD 를 그대로 담는다. 백엔드가 이걸
// site_versions.jsonld(파이썬 빌더 산출물)와 대조해 두 렌더러의 드리프트를 잡는다.
return {
siteDir,
payload,
jsonld,
uniqueContentCount,
};
}
/**
* 이 가게에만 있는 콘텐츠 건수 — 백엔드 `count_unique_content` 와 **같은 규칙**이다.
*
* ★ 두 숫자가 다르면 게이트가 통과시킨 근거와 실제 페이지가 어긋났다는 뜻이다.
* 백엔드가 보고서를 받아 대조한다. 규칙을 바꿀 땐 반드시 양쪽을 같이 고친다.
* (backend/services/builder/render.py 의 MIN_UNIQUE_TEXT · count_unique_content)
*/
const MIN_UNIQUE_TEXT = 8;
function countUniqueContent(payload: SitePayload): number {
const long = (value: unknown) => String(value ?? '').trim().length >= MIN_UNIQUE_TEXT;
let count = 0;
// 소개 섹션의 직접 입력 본문은 실제 AboutSection 에 표시되는 가게 고유 문장이다.
// ★ enabled 를 함께 본다. 꺼서 페이지에 없는 문장까지 세면 빈 페이지가 게이트를 통과한다.
const intro = payload.theme.sections.find((section) => section.id === 'intro');
if (intro?.enabled && long(intro.body)) count += 1;
// 문장형 fact 만 센다 — bool·number·time 은 전부 템플릿 값이라 가게를 구분하지 못한다.
for (const fact of [...payload.facts, ...payload.units.flatMap((unit) => unit.facts)]) {
if (fact.type === 'text' && long(fact.value)) count += 1;
}
for (const faq of payload.faqs) {
if (String(faq.question ?? '').trim() && long(faq.answer)) count += 1;
}
for (const item of payload.media) {
if (long(item.alt)) count += 1;
}
return count;
}
/**
* 렌더 결과 보고서. payload 파일 옆(`.status/<slug>.json`)에 쓴다.
*
* ★ 왜 필요한가
* 지금까지 프리렌더는 성공하든 실패하든 아무것도 남기지 않았다. 빌드가 깨지면
* DB 에는 "발행됨"으로 남고 페이지는 없는 상태가 되는데, 아무도 그걸 모른다.
* 백엔드에 마운트된 유일한 디렉토리가 payload 디렉토리라 보고서도 그 안에 쓴다.
*
* ★ 임시파일 → rename. 백엔드가 반쯤 쓰인 JSON 을 읽지 않게 한다.
*/
interface RenderReport {
schemaVersion: 1;
slug: string;
siteId: string;
placeId: string;
siteVersion: number;
ok: boolean;
renderedAt: string;
routes: number;
bundle: string;
uniqueContentCount: number | null;
jsonld: unknown[] | null;
/** ★ 절대규칙 3 위반 목록. 비어야 발행 가능하다 — 백엔드 게이트가 이걸 본다. */
mismatches: string[];
error: string | null;
}
function writeReport(payloadFile: string, report: RenderReport) {
const dir = join(dirname(payloadFile), '.status');
mkdirSync(dir, {recursive: true});
const target = join(dir, `${report.slug}.json`);
const tmp = join(dir, `.${report.slug}.json.tmp`);
writeFileSync(tmp, JSON.stringify(report, null, 2), 'utf-8');
renameSync(tmp, target);
}
/**
* 클라이언트 번들과 public/ 을 대상 디렉토리에 깐다.
*
* Docker bind mount 에서 이전 빌드가 남긴 파일 권한이 호스트와 어긋날 수 있어
* assets/ 는 통째로 교체한다(낡은 해시 파일과 권한을 함께 제거).
*/
function writeSharedAssets(destRoot: string) {
const assetsSrc = join(CLIENT_DIR, 'assets');
if (existsSync(assetsSrc)) {
const assetsDest = join(destRoot, 'assets');
rmSync(assetsDest, {recursive: true, force: true});
copyDirectoryFiles(assetsSrc, assetsDest);
}
const publicDir = join(SITE_ROOT, 'public');
if (existsSync(publicDir)) {
copyDirectoryFiles(publicDir, destRoot);
}
}
/**
* 오리진 루트의 `robots.txt` 와 사이트맵 인덱스.
*
* ★ 왜 필요한가
* 크롤러는 robots.txt 를 **오리진 루트에서만** 읽는다(RFC 9309). 발행 사이트는
* `<host>/s/<slug>/` 아래라, 지금까지 구워 온 사이트별 robots.txt 는 한 번도 읽힌 적이 없다 —
* AI 크롤러 명시 허용도, `Sitemap:` 지시도 전달되지 않았다. 사이트맵은 만들어 두고
* 그 존재를 알릴 방법이 없었으니 크롤러가 사이트를 찾아올 경로 자체가 없었다.
*
* ★ 왜 이번 실행에 온 payload 가 아니라 출력 디렉토리를 훑는가
* 발행은 **바뀐 사이트 하나만** 굽는다(scripts/watch-payloads.mjs). 이번 실행분만 인덱스에
* 담으면 나머지 사이트가 인덱스에서 사라진다. 디스크에 실제로 존재하는 발행본이 곧 정답이다.
*/
function writeRootMachineFiles(outRoot: string, origin: string) {
const sitesDir = join(outRoot, SITE_DIR);
if (!existsSync(sitesDir)) return;
const entries = readdirSync(sitesDir, {withFileTypes: true})
.filter((entry) => entry.isDirectory())
.map((entry) => ({slug: entry.name, file: join(sitesDir, entry.name, 'index.html')}))
// index.html 이 없으면 발행이 끝나지 않은(또는 실패한) 디렉토리다. 사이트맵에 넣지 않는다.
.filter((entry) => existsSync(entry.file))
.map((entry) => ({
loc: joinUrl(origin, SITE_DIR, entry.slug) + '/',
// 페이지는 그 사이트를 구울 때마다 다시 쓰인다 — 파일 mtime 이 곧 마지막 발행 시각이다.
lastmod: statSync(entry.file).mtime.toISOString(),
}))
.sort((a, b) => a.loc.localeCompare(b.loc));
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}개)`);
writeIndexNowKey(outRoot);
}
/**
* IndexNow 키 파일 — `https://<host>/<key>.txt` 에 키 문자열만 들어 있다.
*
* ★ 이게 없으면 백엔드의 통보가 403 으로 거절된다. 검색엔진은 통보를 받으면
* 이 URL 을 열어 같은 키가 있는지 보고, 그걸로 "이 호스트를 제어하는 쪽이 보냈다"를 확인한다.
* 비밀이 아니다 — 공개되어야 작동하는 값이다.
*
* ★ 백엔드와 **같은 env 를 본다**. 키를 두 군데 적으면 어긋나는 날 통보가 조용히 다 막힌다.
*/
function writeIndexNowKey(outRoot: string) {
const key = (process.env.INDEXNOW_KEY ?? '').trim();
if (!key) return;
// 규격: 8~128자, 영문·숫자·하이픈. 어긋나면 굽지 않는다(잘못된 파일이 있으면 원인 찾기가 더 어렵다).
if (!/^[A-Za-z0-9-]{8,128}$/.test(key)) {
console.warn(' ! INDEXNOW_KEY 형식이 규격(8~128자 영문·숫자·하이픈)에 맞지 않아 건너뛴다');
return;
}
writeFileSync(join(outRoot, `${key}.txt`), key, 'utf-8');
console.log(' ✓ IndexNow 키 파일');
}
function main() {
const args = parseArgs();
const loaded = loadPayloads(args.payloads);
const assets = readAssets();
console.log(`[prerender] 사이트 ${loaded.length}개 → ${args.out}`);
// ★ 공용 자산은 사이트를 굽기 전에 딱 한 번 깐다. 사이트마다 복사하던 걸 여기로 뺐다.
writeSharedAssets(args.out);
let failed = 0;
/** 루트 기계용 파일을 쓸 오리진. 이 호스트의 사이트는 전부 같은 오리진을 쓴다. */
let origin = '';
/** 보고서용 슬러그. payload 를 못 읽었으면 파일명에서 얻는다(백엔드가 `<slug>.json` 으로 쓴다). */
const slugOf = (entry: LoadedPayload) =>
entry.payload?.site?.slug ?? (entry.file ? basename(entry.file, '.json') : '');
const fail = (
entry: LoadedPayload,
message: string,
mismatches: string[] = [],
uniqueContentCount: number | null = null,
) => {
failed += 1;
console.error(`${slugOf(entry) || '(slug 없음)'}${message}`);
if (!entry.file) return;
writeReport(entry.file, {
schemaVersion: 1,
slug: slugOf(entry),
siteId: entry.payload?.site?.siteId ?? '',
placeId: entry.payload?.site?.placeId ?? '',
siteVersion: entry.payload?.site?.version ?? 0,
ok: false,
renderedAt: new Date().toISOString(),
routes: 0,
bundle: assets.script,
uniqueContentCount,
jsonld: null,
mismatches,
error: message,
});
};
for (const entry of loaded) {
// 읽기·검증 단계에서 이미 실패한 건 굽지 않는다. 다른 사이트는 계속 간다.
if (entry.error || !entry.payload) {
fail(entry, entry.error ?? 'payload 를 읽지 못했습니다');
continue;
}
try {
const result = prerenderSite(entry.payload, args.out, assets);
const payload = result.payload;
origin = origin || payload.site.origin;
const home = joinUrl(payload.site.origin, payload.site.basePath);
console.log(
`${payload.place.name} (${payload.site.slug}) — index.html + llms.txt`,
);
console.log(` ${result.siteDir}`);
console.log(` ${home}`);
if (entry.file) {
writeReport(entry.file, {
schemaVersion: 1,
slug: payload.site.slug,
siteId: payload.site.siteId,
placeId: payload.site.placeId,
siteVersion: payload.site.version,
ok: true,
renderedAt: new Date().toISOString(),
// 사이트당 한 장. 보고서 스키마는 백엔드가 읽으므로 필드는 남긴다.
routes: 1,
bundle: assets.script,
uniqueContentCount: result.uniqueContentCount,
jsonld: result.jsonld,
mismatches: [],
error: null,
});
}
} catch (ex) {
// ★ 한 사이트가 깨졌다고 나머지를 못 굽게 두지 않는다. 실패는 보고서로 남긴다 —
// 조용히 넘어가면 "발행했는데 페이지가 없다"가 다시 반복된다.
// ★ 계수를 못 잰 실패(디스크·번들·payload 파손)는 null 로 남긴다. 0 으로 적으면
// 백엔드가 "고유 콘텐츠 0건" 으로 읽어 또 엉뚱한 사유를 붙인다.
const counted = ex instanceof VerifyError || ex instanceof NoUniqueContentError ? ex : null;
fail(
entry,
ex instanceof Error ? ex.message : String(ex),
counted?.mismatches ?? [],
counted?.uniqueContentCount ?? null,
);
}
}
// ★ 실패한 사이트가 있어도 루트 파일은 갱신한다 — 성공한 사이트까지 색인에서 빠질 이유가 없다.
// (인덱스는 디스크를 훑으므로 실패한 사이트는 애초에 들어가지 않는다.)
if (origin) writeRootMachineFiles(args.out, origin);
if (failed > 0) {
console.error(`[prerender] ${loaded.length}개 중 ${failed}개 실패`);
process.exitCode = 1;
return;
}
console.log('[prerender] 완료');
}
main();