diff --git a/solution/site/scripts/prerender.ts b/solution/site/scripts/prerender.ts index 8342b9c..e9e3e70 100644 --- a/solution/site/scripts/prerender.ts +++ b/solution/site/scripts/prerender.ts @@ -23,6 +23,7 @@ import { collectJsonLd, homeMeta, readBakedLastmod, + readBakedNoindex, readBakedTitle, renderHead, renderLlmsTxt, @@ -766,9 +767,11 @@ function writeRootMachineFiles(outRoot: string, origin: string) { .map((entry) => ({slug: entry.name, file: join(sitesDir, entry.name, 'index.html')})) // index.html 이 없으면 발행이 끝나지 않은(또는 실패한) 디렉토리다. 사이트맵에 넣지 않는다. .filter((entry) => existsSync(entry.file)) - .map((entry) => { - // 제목과 lastmod 가 같은 HTML 에서 나온다 — 파일은 한 번만 읽는다. - const html = readFileSync(entry.file, 'utf-8'); + // 제목·lastmod·noindex 가 같은 HTML 에서 나온다 — 파일은 한 번만 읽는다. + .map((entry) => ({...entry, html: readFileSync(entry.file, 'utf-8')})) + // ★ noindex 를 선언한 페이지(목업·백업)는 싣지 않는다 — readBakedNoindex 주석 참조. + .filter((entry) => !readBakedNoindex(entry.html)) + .map(({html, ...entry}) => { return { // ★ 끝 슬래시를 붙이지 않는다. 페이지의 canonical 은 `/s/` 다(shared/lib/slug.ts // publishUrl). 사이트맵이 `/s//` 로 어긋나 있던 동안 서치콘솔은 제출한 URL 을 diff --git a/solution/site/src/seo/directory.test.ts b/solution/site/src/seo/directory.test.ts index 958cedf..cee3056 100644 --- a/solution/site/src/seo/directory.test.ts +++ b/solution/site/src/seo/directory.test.ts @@ -10,7 +10,7 @@ import {describe, expect, it} from 'vitest'; import {MOONLIGHT_STAY_PAYLOAD} from '../fixtures/moonlight-stay'; -import {readBakedLastmod, readBakedTitle} from './directory'; +import {readBakedLastmod, readBakedNoindex, readBakedTitle} from './directory'; import {renderHead} from './head'; import {homeMeta} from './meta'; @@ -44,3 +44,20 @@ describe('readBakedTitle', () => { expect(readBakedTitle('')).toBe(''); }); }); + +describe('readBakedNoindex', () => { + it('발행본 head 는 index 다 — 사이트맵에 남는다', () => { + expect(readBakedNoindex(bakedHead())).toBe(false); + }); + + it('noindex 를 선언한 목업은 걸러진다', () => { + const mockup = + ''; + expect(readBakedNoindex(mockup)).toBe(true); + expect(readBakedNoindex('')).toBe(true); + }); + + it('robots 메타가 없으면 색인 대상으로 친다', () => { + expect(readBakedNoindex('x')).toBe(false); + }); +}); diff --git a/solution/site/src/seo/directory.ts b/solution/site/src/seo/directory.ts index 0fa9c87..67c8948 100644 --- a/solution/site/src/seo/directory.ts +++ b/solution/site/src/seo/directory.ts @@ -20,6 +20,21 @@ export function readBakedTitle(html: string): string { 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 = /