Revert "[fix] solution/site: 사이트맵 lastmod 를 파일 mtime 에서 뗀다 — 페이지의 dateModified 를 그대로 쓴다"

This reverts commit 09b0538c9c.
This commit is contained in:
Mina Choi 2026-09-07 13:33:34 +09:00
parent b797535b17
commit de5ff5186f
4 changed files with 18 additions and 122 deletions

View File

@ -5,33 +5,6 @@
--- ---
## 2026-09-07 — 사이트맵 lastmod 를 파일 mtime 에서 뗐다
**왜**
`lastmod` 를 구운 `index.html` 의 **파일 mtime** 에서 읽고 있었다. 그런데 렌더러를 배포하면
번들 해시가 바뀌어 **내용이 한 글자도 안 바뀐 사이트까지 전부 다시 구워진다** — mtime 은
그때마다 오늘이 되고, 사이트맵은 "전 사이트가 오늘 갱신됨" 을 통보한다.
구글은 lastmod 를 페이지의 실제 수정과 대조해 맞을 때만 쓰고, 어긋나면 **그 필드를 아예
무시한다**(Search Central: "the date and time of the last significant update" ·
"consistently and verifiably accurate"). 즉 이 오염은 지금 당장 뭘 깨뜨리는 게 아니라,
**사장님이 진짜로 내용을 고쳐 재발행한 날의 신호를 미리 죽여 두는** 종류다. 배포할 때마다
신뢰를 태우고 있었고, 사이트가 100개를 넘기면 되돌리는 데 시간이 걸린다.
**바꾼 것**
- `seo/directory.ts`: `readBakedTitle` · `readBakedLastmod` — 구운 HTML 에서 목록·사이트맵
값을 꺼낸다. lastmod 는 페이지가 head 에 선언한 `dateModified`(= `payload.site.updatedAt`)
**그 값 그대로**다. 구글이 대조하는 값과 글자 그대로 같아 어긋날 수가 없다
- `scripts/prerender.ts`: `readTitle` 을 위로 옮기고 사이트맵 항목에서 mtime 제거. 파일을
한 번만 읽어 제목과 lastmod 를 같이 꺼낸다. mtime 은 `dateModified` 메타가 없던 시절의
산출물에만 남는 폴백이다 — 그 사이트를 한 번 다시 구우면 제 값이 들어온다
- `seo/directory.test.ts`: head.ts 의 메타와 파서의 **커플링을 고정**한다. 태그 모양이 바뀌면
파서가 조용히 undefined 를 내고 mtime 으로 되돌아간다 — 빌드도 화면도 멀쩡한 회귀라서 붙였다
**검증** — `tsc·eslint` 통과, `vitest` 22 passed (신규 5건).
---
## 2026-09-03 — 레포·발행 호스트 교체 — `o2o-site-AEO` / `web4ai.o2osolution.ai` ## 2026-09-03 — 레포·발행 호스트 교체 — `o2o-site-AEO` / `web4ai.o2osolution.ai`
**왜** **왜**

View File

@ -22,8 +22,6 @@ import {render} from '@/entry-server';
import { import {
collectJsonLd, collectJsonLd,
homeMeta, homeMeta,
readBakedLastmod,
readBakedTitle,
renderHead, renderHead,
renderLlmsTxt, renderLlmsTxt,
renderRootLlmsTxt, renderRootLlmsTxt,
@ -520,21 +518,18 @@ function writeRootMachineFiles(outRoot: string, origin: string) {
.map((entry) => ({slug: entry.name, file: join(sitesDir, entry.name, 'index.html')})) .map((entry) => ({slug: entry.name, file: join(sitesDir, entry.name, 'index.html')}))
// index.html 이 없으면 발행이 끝나지 않은(또는 실패한) 디렉토리다. 사이트맵에 넣지 않는다. // index.html 이 없으면 발행이 끝나지 않은(또는 실패한) 디렉토리다. 사이트맵에 넣지 않는다.
.filter((entry) => existsSync(entry.file)) .filter((entry) => existsSync(entry.file))
.map((entry) => { .map((entry) => ({
// 제목과 lastmod 가 같은 HTML 에서 나온다 — 파일은 한 번만 읽는다. // ★ 끝 슬래시를 붙이지 않는다. 페이지의 canonical 은 `/s/<slug>` 다(shared/lib/slug.ts
const html = readFileSync(entry.file, 'utf-8'); // publishUrl). 사이트맵이 `/s/<slug>/` 로 어긋나 있던 동안 서치콘솔은 제출한 URL 을
return { // 전부 "대체 페이지(적절한 표준 태그가 있음)" 로 분류했다 — 색인은 되는데 제출분은
// ★ 끝 슬래시를 붙이지 않는다. 페이지의 canonical 은 `/s/<slug>` 다(shared/lib/slug.ts // 0건으로 보이는, 눈으로 원인을 못 찾는 종류다.
// publishUrl). 사이트맵이 `/s/<slug>/` 로 어긋나 있던 동안 서치콘솔은 제출한 URL 을 loc: joinUrl(origin, SITE_DIR, entry.slug),
// 전부 "대체 페이지(적절한 표준 태그가 있음)" 로 분류했다 — 색인은 되는데 제출분은 // 제목은 구운 HTML 에서 읽는다. payload 에서 가져오면 이번 실행분만 이름이 있고
// 0건으로 보이는, 눈으로 원인을 못 찾는 종류다. // 나머지는 슬러그로 떨어진다 — 발행은 바뀐 사이트 하나만 굽기 때문이다.
loc: joinUrl(origin, SITE_DIR, entry.slug), title: readTitle(entry.file) || entry.slug,
title: readBakedTitle(html) || entry.slug, // 페이지는 그 사이트를 구울 때마다 다시 쓰인다 — 파일 mtime 이 곧 마지막 발행 시각이다.
// ★ mtime 으로 떨어지는 건 dateModified 메타가 없던 시절의 산출물뿐이다. lastmod: statSync(entry.file).mtime.toISOString(),
// 그 사이트를 한 번 다시 구우면 제 값이 들어온다(readBakedLastmod 주석 참조). }))
lastmod: readBakedLastmod(html) ?? statSync(entry.file).mtime.toISOString(),
};
})
.sort((a, b) => a.loc.localeCompare(b.loc)); .sort((a, b) => a.loc.localeCompare(b.loc));
// ★ `/s/` 목록 페이지. 크롤러가 발행본에 닿는 두 번째 경로다 — // ★ `/s/` 목록 페이지. 크롤러가 발행본에 닿는 두 번째 경로다 —
@ -554,6 +549,12 @@ function writeRootMachineFiles(outRoot: string, origin: string) {
writeIndexNowKey(outRoot); 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` 에 키 문자열만 들어 있다. * IndexNow 키 파일 — `https://<host>/<key>.txt` 에 키 문자열만 들어 있다.
* *

View File

@ -1,46 +0,0 @@
/**
* 구운 HTML → 사이트맵·목록 값.
*
* ★ 이 테스트가 지키는 것은 **커플링 하나**다. 사이트맵의 lastmod 는 페이지가 head 에
* 선언한 dateModified 와 같은 값이어야 한다 — 구글이 lastmod 를 신뢰하는 조건이
* "페이지의 실제 수정과 대조해 맞을 것" 이기 때문이다. head.ts 가 태그 모양을 바꾸면
* readBakedLastmod 는 조용히 undefined 를 내고 사이트맵은 mtime 으로 되돌아간다 —
* 빌드는 통과하고 화면도 멀쩡한, 눈으로 못 찾는 종류의 회귀다. 그래서 붙인다.
*/
import {describe, expect, it} from 'vitest';
import {MOONLIGHT_STAY_PAYLOAD} from '../fixtures/moonlight-stay';
import {readBakedLastmod, readBakedTitle} from './directory';
import {renderHead} from './head';
import {homeMeta} from './meta';
/** 실제 프리렌더와 같은 경로로 head 를 굽는다(prerender.ts prerenderSite). */
function bakedHead(): string {
const payload = MOONLIGHT_STAY_PAYLOAD;
return renderHead({payload, meta: homeMeta(payload)});
}
describe('readBakedLastmod', () => {
it('head 가 선언한 dateModified 를 그대로 돌려준다', () => {
expect(readBakedLastmod(bakedHead())).toBe(MOONLIGHT_STAY_PAYLOAD.site.updatedAt);
});
it('메타가 없으면 undefined — 호출부가 mtime 으로 떨어진다', () => {
expect(readBakedLastmod('<html><head><title>x</title></head></html>')).toBeUndefined();
});
it('날짜로 못 읽는 값은 버린다 — 틀린 lastmod 는 없는 것보다 나쁘다', () => {
expect(readBakedLastmod('<meta name="dateModified" content="어제" />')).toBeUndefined();
expect(readBakedLastmod('<meta name="dateModified" content="" />')).toBeUndefined();
});
});
describe('readBakedTitle', () => {
it('구운 HTML 의 title 을 꺼낸다', () => {
expect(readBakedTitle(bakedHead())).toBe(homeMeta(MOONLIGHT_STAY_PAYLOAD).title);
});
it('title 이 없으면 빈 문자열 — 호출부가 슬러그로 떨어진다', () => {
expect(readBakedTitle('<html><head></head></html>')).toBe('');
});
});

View File

@ -9,38 +9,6 @@ export interface DirectoryEntry {
lastmod?: string; 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/` 발행 사이트 목록 페이지. * `/s/` 발행 사이트 목록 페이지.
* *