@zalkera/client 0.11.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,706 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * AEO 보장 표면 검사기 (memo119 §1.3 · T5).
4
+ *
5
+ * **개시된 사이트를 크롤해 보장 그래프가 실제로 나오는지 잰다.** 소스를 읽지 않는 것이 요점이다
6
+ * (오너 전제 C): "이 섹션을 써라"는 디자인 자유를 깎지만 "개시된 페이지에서 이 그래프가 나오는가"는
7
+ * 아무것도 깎지 않는다. 그래서 이 검사기는 어떤 스택으로 짰든·어떤 섹션을 썼든 묻지 않고, 나온 HTML 만 본다.
8
+ *
9
+ * 잣대는 백엔드 정본 `doc/contracts/aeo-surface-guarantees.json` 이고 이 파일은 그 정본의 **집행자**다.
10
+ * 규범을 여기서 새로 만들지 않는다 — 표에 없는 것은 검사하지 않고, 표에 있는 것은 봐주지 않는다.
11
+ *
12
+ * ── 이 파일이 왜 client 패키지에 사는가(memo123 §6.1) ────────────────────────
13
+ * 원래 이 검사기는 `storefront-template/scripts/` 에 살았다. 그런데 이제 **사람이 CLI 로 치는 것 말고도**
14
+ * 두 자리에서 돌아야 한다: ⑴ 고객 zip(원래 자리) ⑵ serving-orchestrator 가 발행 직후 자동으로 재는 자리.
15
+ * ⑵의 실행체는 "외부 의존 0 단일 `server.mjs`"라는 속성을 지켜야 해서 검사기를 자기 안에 넣을 수 없고,
16
+ * 대신 **툴킷 디렉터리에 `npm i @zalkera/client@<pin>` 해서 그 bin 을 child 로 spawn** 한다.
17
+ * 사본을 하나 더 두면 두 검사기가 조용히 갈라지므로(`lib/site-crawl.mjs` 의 KDoc 과 같은 논거) 정본을
18
+ * 여기로 옮기고 template 의 `scripts/check-aeo-surfaces.mjs` 는 이 bin 을 부르는 **얇은 wrapper** 다.
19
+ *
20
+ * ── 잣대를 어디서 찾는가(memo122 §1.3-2) ────────────────────────────────────
21
+ * 이 검사기는 고객 zip 에 실려 나간다. 그런데 **잣대는 실려 나가지 않아서** 고객 기계에서는 기본 경로
22
+ * (형제 `../backend`)가 없어 exit 2 로 죽어 있었다(2026-07-29 실측). 그래서 해석을 3단으로 둔다:
23
+ *
24
+ * ① `--guarantees <경로>` — 명시가 언제나 이긴다
25
+ * ② `@zalkera/client/contracts/…`(npm 운반본) — **고객의 기본 경로**. zip 에 이 의존이 이미 있어
26
+ * `npm i` 만 하면 잣대가 따라온다. 이 파일이 그 패키지
27
+ * 안에 살므로 **자기 패키지의 운반본**이 1순위 후보다
28
+ * ③ 형제 `../backend/doc/contracts/…`(정본) — 우리 개발 체크아웃 관례 폴백
29
+ *
30
+ * 정본은 여전히 백엔드 하나이고 ②는 그 **발행 산출물**이다(client 발행 전 `sync-aeo-guarantees.mjs` 가
31
+ * 바이트 동일을 강제한다). ②로 재는 자리에 ③도 함께 있으면 둘을 비교해 **갈라짐을 경고**한다 — 운반본이
32
+ * 낡았는데 그걸로 잰 판정을 정본 판정처럼 읽는 것이 이 3단 구조의 유일한 새 위험이라서다.
33
+ * ③의 후보 자리는 **이 파일 기준과 실행 위치(cwd) 기준 둘 다** 본다: 이 파일이 패키지 안으로 들어오면서
34
+ * "파일 기준 형제"는 `node_modules/backend` 라는 없는 경로가 됐고, 그것만 보면 개발 체크아웃에서
35
+ * 드리프트 경고가 조용히 꺼진다(옮기면서 잃을 뻔한 축이라 여기 적어 둔다).
36
+ *
37
+ * ── 사용 ────────────────────────────────────────────────────────────────────
38
+ * npx zalkera-aeo-check <사이트URL> --category BOOKING [--category BLOG] \
39
+ * [--code beauty-nail --version 1.0.0] \
40
+ * [--guarantees ../backend/doc/contracts/aeo-surface-guarantees.json] \
41
+ * [--out out/aeo-snapshot.json] [--max-pages 60] [--route /추가경로]…
42
+ * (스토어프론트 체크아웃·고객 zip 안에서는 `npm run check:aeo -- …` 가 같은 bin 을 부른다)
43
+ *
44
+ * npx zalkera-aeo-check <사이트URL> --site-wide-only
45
+ * **보장 주장이 없는 사이트**를 재는 모드(memo123 §6.3 B-1). `--category` 를 요구하지 않고
46
+ * siteWide 버킷(robots·sitemap·JSON-LD 절대 URL)만 잰다. 카테고리 required 는 아예 판정하지
47
+ * 않는다 — 어휘 준수를 주장한 적 없는 사이트에 그 잣대를 들이대는 것은 오너 전제 A(디자인 자유)를
48
+ * 어기는 일이다. 발행 직후 자동 검사(orchestrator)가 전 사이트에 쓰는 모드가 이것이다.
49
+ *
50
+ * npx zalkera-aeo-check --print-guarantees
51
+ * 잣대 해석만 해 보고 끝낸다(사이트 크롤 없음·네트워크 불요). 어떤 출처의 어느 rev 로 재는지와
52
+ * 고를 수 있는 `--category` 목록을 찍는다. `verify-zip.mjs` 가 zip 안에서 이걸 돌려 "검사기가
53
+ * 살아서 뜨는가"를 기계로 확인한다 — 조용히 죽는 상태가 다시 생기면 zip 게이트가 잡는다.
54
+ *
55
+ * 종료코드: 0=required 전부 통과 · 1=보장 미충족(red) · 2=실행 불가(인자·네트워크·표 부재)
56
+ *
57
+ * ── red 는 결함 보고이지 실패가 아니다 ──────────────────────────────────────
58
+ * 이 검사기를 처음 돌리면 **우리 팩부터 red 가 난다**(§0-6 실측: CMS JsonLd 0·상품 목록 라우트 부재·
59
+ * ItemList 0·openingHours 저장 자리 없음). 그건 검사기가 잘못 만들어진 신호가 아니라 **본보기 정합
60
+ * 작업(T6)의 지시서**다. 통과시키려고 표를 낮추면 보장이 거짓이 되고, 그 순간 이 파일은 쓸모가 없어진다.
61
+ *
62
+ * ── 무엇을 실패로 세지 않는가 ────────────────────────────────────────────────
63
+ * · `planned` — 표가 목표로 적어 뒀지만 아직 우리도 못 내는 표면. PLANNED_MISSING 으로 보고만 한다.
64
+ * · `conditional` — 섹션이 있을 때만 성립하는 표면(FAQPage). 크롤한 HTML 만으로는 "섹션을 안 썼다"와
65
+ * "썼는데 그래프가 없다"를 구분할 수 없으므로, 안 보이면 SKIPPED 다. 못 세는 것을 센 척하지 않는다.
66
+ * · `TESTIMONIALS` 의 Review·별점 — 이건 **의도적 부정 보장**이라 없는 것이 정답이고, 오히려 나오면
67
+ * 실패다(self-serving reviews 정책 위반). 누락으로 잡으면 검사기가 정책을 거꾸로 아는 것이다.
68
+ */
69
+ import {existsSync, mkdirSync, readFileSync, writeFileSync} from "node:fs";
70
+ import {createRequire} from "node:module";
71
+ import {dirname, join, resolve} from "node:path";
72
+ import {fileURLToPath} from "node:url";
73
+ import {FUNCTIONAL_SEGMENTS, crawlPages, makeClassifier} from "../lib/site-crawl.mjs";
74
+
75
+ /**
76
+ * 판정 형식이 바뀌면 올린다 — 스냅샷에 박혀서 "어느 잣대로 잰 판정인가"가 사후에도 남는다.
77
+ *
78
+ * 2 = `--site-wide-only` 모드와 스냅샷의 `mode` 필드 추가(memo123 §6.3). 기존 카테고리 판정의 잣대는
79
+ * 하나도 안 바뀌었지만, 스냅샷 형식에 필드가 늘었고 그 필드를 읽는 소비자(promote 게이트·백엔드 ingest)가
80
+ * "구판 스냅샷인가"를 이 수로 가른다.
81
+ */
82
+ const CHECKER_VERSION = 2;
83
+
84
+ const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
85
+
86
+ // ── 인자 ─────────────────────────────────────────────────────────────────────
87
+
88
+ const argv = process.argv.slice(2);
89
+ const flag = (name, fallback = undefined) => {
90
+ const i = argv.indexOf(`--${name}`);
91
+ return i >= 0 && argv[i + 1] && !argv[i + 1].startsWith("--") ? argv[i + 1] : fallback;
92
+ };
93
+ const flagAll = (name) => {
94
+ const out = [];
95
+ for (let i = 0; i < argv.length; i++) {
96
+ if (argv[i] === `--${name}` && argv[i + 1] && !argv[i + 1].startsWith("--")) out.push(argv[i + 1]);
97
+ }
98
+ return out;
99
+ };
100
+ const die = (msg) => {
101
+ console.error(msg);
102
+ process.exit(2);
103
+ };
104
+
105
+ const siteUrl = argv.find((a) => !a.startsWith("--") && /^https?:\/\//.test(a));
106
+ const categories = flagAll("category");
107
+ const printOnly = argv.includes("--print-guarantees");
108
+ /**
109
+ * 무주장 모드(memo123 §6.3 B-1). **카테고리 판정을 아예 하지 않는다** — 끄는 것이 아니라 안 하는 것이다.
110
+ * 보장 어휘를 주장한 적 없는 사이트에 그 잣대를 대면, 그 사이트가 어떤 스택으로 짰든 red 가 쏟아지고
111
+ * 그 red 는 사실이 아니라 **잣대를 잘못 고른 결과**다(오너 전제 A). 그래서 카테고리 축은 판정에서 빠지고
112
+ * 사이트 축(robots·sitemap·절대 URL)만 남는다 — 그 셋은 어떤 디자인 선택과도 무관한 기계 가독 최소치다.
113
+ */
114
+ const siteWideOnly = argv.includes("--site-wide-only");
115
+ if (!printOnly && !siteUrl) {
116
+ die("사용: zalkera-aeo-check <사이트URL> --category <코드> [--category …] [--code …] [--version …] [--out …]\n 무주장 사이트: zalkera-aeo-check <사이트URL> --site-wide-only\n (잣대 해석만 확인: zalkera-aeo-check --print-guarantees)");
117
+ }
118
+ if (!printOnly && siteWideOnly && categories.length > 0) {
119
+ // 둘 다 주면 어느 쪽을 원한 것인지 알 수 없다. 조용히 한쪽을 이기게 하면 "쟀다고 생각한 것"과
120
+ // "잰 것"이 갈라지고, 그 갈라짐은 스냅샷을 봐야만 보인다.
121
+ die("--site-wide-only 와 --category 는 같이 못 준다 — 카테고리 판정을 하든지, 사이트 축만 재든지 하나다.");
122
+ }
123
+ if (!printOnly && !siteWideOnly && categories.length === 0) {
124
+ die("--category 가 없다. 보장 주장이 있는 팩이면 그 코드를, 무주장 사이트면 --site-wide-only 를 주십시오.");
125
+ }
126
+
127
+ // ── 잣대 해석 — 명시 > @zalkera/client 운반본 > 형제 백엔드 정본(파일 머리말 참조) ──
128
+
129
+ const CARRIED_SPECIFIER = "@zalkera/client/contracts/aeo-surface-guarantees.json";
130
+ /** 이 파일이 사는 패키지 자신의 운반본. bin 으로 돌 때 **가장 확실한 자리**다(해석을 거칠 필요가 없다). */
131
+ const OWN_CARRIED = join(ROOT, "contracts", "aeo-surface-guarantees.json");
132
+ /**
133
+ * 형제 백엔드 정본 후보. **두 기준점을 다 본다** — 이 파일이 패키지 안으로 들어오면서 파일 기준 형제는
134
+ * `node_modules/backend` 라는 없는 경로가 됐고, 실행 위치(cwd) 기준이 개발 체크아웃의 실제 형상이다.
135
+ */
136
+ const SIBLING_CANDIDATES = [...new Set([join(ROOT, ".."), process.cwd()])].map((base) =>
137
+ join(base, "..", "backend", "doc/contracts/aeo-surface-guarantees.json"),
138
+ );
139
+ const siblingCanonical = () => SIBLING_CANDIDATES.find((p) => existsSync(p)) ?? null;
140
+ const requireFrom = createRequire(import.meta.url);
141
+
142
+ /**
143
+ * 설치된 `@zalkera/client` 안의 운반본 경로. **패키지 해석에 맡긴다** — 경로를 손으로 짜면
144
+ * (`node_modules/@zalkera/client/…`) 워크스페이스 호이스팅·pnpm 링크에서 조용히 빗나간다.
145
+ * 검사기 자리 기준으로 먼저 찾고, 그래도 없으면 실행 위치 기준으로 한 번 더 본다
146
+ * (검사기만 다른 데로 복사해 쓰는 경우 — 실제로 그렇게 돌려 본 적이 있다).
147
+ */
148
+ function resolveCarried() {
149
+ for (const from of [ROOT, process.cwd()]) {
150
+ try {
151
+ return requireFrom.resolve(CARRIED_SPECIFIER, {paths: [from]});
152
+ } catch {
153
+ /* 다음 후보 */
154
+ }
155
+ }
156
+ return null;
157
+ }
158
+
159
+ function resolveGuarantees() {
160
+ const explicit = flag("guarantees");
161
+ if (explicit) return {path: resolve(explicit), source: "--guarantees 인자"};
162
+ if (existsSync(OWN_CARRIED)) return {path: OWN_CARRIED, source: "@zalkera/client 운반본(자기 패키지)"};
163
+ const carried = resolveCarried();
164
+ if (carried) return {path: carried, source: `@zalkera/client 운반본(${CARRIED_SPECIFIER})`};
165
+ const sibling = siblingCanonical();
166
+ if (sibling) return {path: sibling, source: "형제 백엔드 정본(개발 체크아웃)"};
167
+ return {path: null, source: null};
168
+ }
169
+
170
+ /** 패키지 자체는 설치돼 있는가 — "안 깔림"과 "깔렸는데 운반본이 없는 구 버전"은 고칠 방법이 다르다. */
171
+ function clientInstalled() {
172
+ for (const from of [ROOT, process.cwd()]) {
173
+ try {
174
+ requireFrom.resolve("@zalkera/client", {paths: [from]});
175
+ return true;
176
+ } catch {
177
+ /* 다음 후보 */
178
+ }
179
+ }
180
+ return false;
181
+ }
182
+
183
+ const resolved = resolveGuarantees();
184
+ if (!resolved.path) {
185
+ die(
186
+ "보장표(잣대)를 못 찾았다 — 아무 판정도 하지 않는다. 없는 잣대로 통과시키는 것보다 안 재는 편이 정직하다.\n" +
187
+ " 찾아본 곳:\n" +
188
+ ` ① --guarantees 인자 — 안 줬다\n` +
189
+ ` ② ${CARRIED_SPECIFIER} — ${
190
+ clientInstalled()
191
+ ? "@zalkera/client 는 설치돼 있는데 그 안에 보장표가 없다(운반본을 싣지 않는 구 버전 — `npm i @zalkera/client@latest` 로 올리십시오)."
192
+ : "@zalkera/client 가 설치돼 있지 않다. 이 디렉터리에서 `npm install` 을 먼저 돌리십시오."
193
+ }\n` +
194
+ ` ③ ${SIBLING_CANDIDATES.join(" · ")} — 없다(우리 개발 체크아웃 관례라 고객 기계에는 원래 없다)`,
195
+ );
196
+ }
197
+ const specPath = resolved.path;
198
+ const themeCode = flag("code", null);
199
+ const themeVersion = flag("version", null);
200
+ const outPath = flag("out", "");
201
+ const maxPages = Number(flag("max-pages", "60"));
202
+ const extraRoutes = flagAll("route");
203
+
204
+ let specText;
205
+ let spec;
206
+ try {
207
+ specText = readFileSync(specPath, "utf8");
208
+ spec = JSON.parse(specText);
209
+ } catch (e) {
210
+ die(`보장표를 못 읽었다: ${specPath}\n ${e.message}\n --guarantees 로 다른 경로를 주십시오.`);
211
+ }
212
+
213
+ /**
214
+ * 운반본으로 재는데 정본도 곁에 있으면 대조한다. 갈라져 있으면 **판정 자체가 낡은 잣대의 판정**이므로
215
+ * 조용히 넘기지 않는다 — 막지는 않는다(정본이 있는 자리는 우리 개발 체크아웃뿐이고, 그때 필요한 것은
216
+ * 차단이 아니라 "client 발행이 밀렸다"는 신호다).
217
+ */
218
+ let carriedDrift = null;
219
+ const siblingForDrift = resolved.source?.startsWith("@zalkera/client") ? siblingCanonical() : null;
220
+ if (siblingForDrift) {
221
+ try {
222
+ if (readFileSync(siblingForDrift, "utf8") !== specText) {
223
+ carriedDrift = `운반본이 형제 백엔드 정본과 다르다 — client 발행이 밀렸을 수 있다(${siblingForDrift} 로 재려면 --guarantees 로 지정).`;
224
+ }
225
+ } catch {
226
+ /* 정본을 못 읽으면 대조를 포기한다 — 이 축은 경고이지 게이트가 아니다 */
227
+ }
228
+ }
229
+
230
+ const byCategory = new Map((spec.categories ?? []).map((c) => [c.category, c]));
231
+
232
+ if (printOnly) {
233
+ console.log(`· 잣대 출처 ${resolved.source}`);
234
+ console.log(`· 경로 ${specPath}`);
235
+ console.log(`· 보장표 rev ${spec.contractRev} · 카테고리 ${[...byCategory.keys()].join(", ")}`);
236
+ if (carriedDrift) console.warn(`⚠️ ${carriedDrift}`);
237
+ console.log(`· 사이트 축(무주장) 검사 항목 ${(spec.siteWide?.requirements ?? []).map((r) => r.id).join(", ")} — \`--site-wide-only\``);
238
+ console.log("· 잣대 해석 성공 — 이제 사이트 URL 과 --category(또는 --site-wide-only)를 주면 실제로 잽니다.");
239
+ process.exit(0);
240
+ }
241
+
242
+ for (const c of categories) {
243
+ if (!byCategory.has(c)) {
244
+ // 표에 없는 이름은 **순수 라벨**이다(예: "미니멀"). 보장 주장이 없으므로 잴 것도 없고,
245
+ // 억지로 통과시킨 스냅샷을 만들면 promote 게이트가 의미를 잃는다.
246
+ die(`'${c}' 는 보장표에 없는 이름이라 검사 대상이 아니다(순수 라벨). 등재된 카테고리: ${[...byCategory.keys()].join(", ")}`);
247
+ }
248
+ }
249
+
250
+ const origin = new URL(siteUrl).origin;
251
+ const classify = makeClassifier({origin, skipRoutes: new Set()}); // 검사기는 아무것도 공개하지 않는다 — /policies 도 훑는다
252
+
253
+ // ── JSON-LD 추출 ─────────────────────────────────────────────────────────────
254
+
255
+ const LD_RE = /<script[^>]+type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi;
256
+
257
+ /** 한 노드를 그 자신 + 중첩 노드 전부로 편다. `@graph`·배열·중첩 객체를 같은 방식으로 훑는다. */
258
+ function flattenNodes(value, out = []) {
259
+ if (Array.isArray(value)) {
260
+ for (const v of value) flattenNodes(v, out);
261
+ return out;
262
+ }
263
+ if (value && typeof value === "object") {
264
+ if (value["@type"] !== undefined) out.push(value);
265
+ for (const v of Object.values(value)) flattenNodes(v, out);
266
+ }
267
+ return out;
268
+ }
269
+
270
+ const typesOf = (node) => {
271
+ const t = node["@type"];
272
+ return Array.isArray(t) ? t.map(String) : t == null ? [] : [String(t)];
273
+ };
274
+
275
+ /**
276
+ * 페이지에서 JSON-LD 를 꺼낸다. 파싱 실패는 **버리지 않고 기록**한다 — 깨진 JSON-LD 는 없는 것보다
277
+ * 나쁘고(크롤러가 페이지 전체의 구조화 데이터를 버린다), 조용히 넘기면 그 사실이 안 보인다.
278
+ * `JsonLd.tsx` 가 `<` 를 `<` 로 이스케이프하므로 `</script>` 조기 종료는 나지 않는다.
279
+ */
280
+ function extractJsonLd(html) {
281
+ const roots = [];
282
+ const nodes = [];
283
+ const malformed = [];
284
+ LD_RE.lastIndex = 0;
285
+ let m;
286
+ while ((m = LD_RE.exec(html)) !== null) {
287
+ try {
288
+ const parsed = JSON.parse(m[1]);
289
+ for (const r of Array.isArray(parsed) ? parsed : [parsed]) roots.push(r);
290
+ flattenNodes(parsed, nodes);
291
+ } catch (e) {
292
+ malformed.push(e.message);
293
+ }
294
+ }
295
+ return {roots, nodes, malformed};
296
+ }
297
+
298
+ /** 태그를 걷어낸 순수 텍스트. */
299
+ function textOf(html) {
300
+ return html
301
+ .replace(/<script[\s\S]*?<\/script>/gi, " ")
302
+ .replace(/<style[\s\S]*?<\/style>/gi, " ")
303
+ .replace(/<[^>]+>/g, " ")
304
+ .replace(/&[a-z#0-9]+;/gi, " ")
305
+ .replace(/\s+/g, " ")
306
+ .trim();
307
+ }
308
+
309
+ /** 지정 태그의 요소를 **내용째** 걷어낸다. 상대하는 마크업이 우리가 낸 것이라(임의 웹이 아니다) 중첩 동명 태그는 없다. */
310
+ function stripElements(html, ...tags) {
311
+ return tags.reduce((acc, tag) => acc.replace(new RegExp(`<${tag}\\b[^>]*>[\\s\\S]*?<\\/${tag}>`, "gi"), " "), html);
312
+ }
313
+
314
+ /**
315
+ * SSR 판정 — **JS 실행 없이 그 페이지의 주제와 본문이 응답에 실려 있는가.**
316
+ *
317
+ * 계약이 `cms-page-ssr`·`post-list-ssr` 의 why 에 적은 요구가 그대로 잣대다: "본문이 JS 실행 없이 응답
318
+ * 본문에 실려야 한다." 그래서 **제목만으로는 통과가 아니다** — 제목 검증으로 줄이면 h1 만 서버에서 그리고
319
+ * 본문은 클라이언트가 fetch 하는 팩이 보장 배지를 받는다(이 검사기는 티어 B/C 서드파티 팩에 동등 배지를
320
+ * 주는 유일한 잣대라 그 순간 배지가 거짓이 된다).
321
+ *
322
+ * 판정 잣대를 "글자 수 N자 이상"으로 두려다 실측에서 **거짓 red** 를 냈다: 상품 상세가 104자, 글이
323
+ * 0건인 블로그 목록이 14자였는데 둘 다 서버가 정상 렌더한 페이지였다. 짧은 것은 렌더 실패가 아니라
324
+ * **콘텐츠가 짧은 것**이고, 그 둘을 길이로는 못 가른다. 그래서 길이가 아니라 **자리**로 가른다:
325
+ *
326
+ * ① `<h1>` 에 글자가 있는가 — 그 페이지가 무엇에 관한 것인지 서버가 이미 말했는가.
327
+ * 클라이언트에서만 그리는 셸은 여기가 비어 있다(레이아웃의 헤더·푸터에는 h1 이 없다).
328
+ * ② 본문 범위에서 **h1 자신을 뺀 잔여**에 글자가 하나라도 있는가. h1 을 빼지 않으면 제목 글자가
329
+ * 본문으로 세어져 ②가 ①의 되풀이가 된다(실측: `<main><h1>젤 네일</h1><div id="app"/></main>` 통과).
330
+ * 빈 상태 문구("게시글이 없습니다")는 h1 밖에 있으므로 여전히 통과다 — 그건 데이터가 없는 것이지
331
+ * 렌더가 없는 것이 아니다. 상품 상세도 h1 밖에 설명·가격이 있어 통과한다.
332
+ *
333
+ * 본문 범위는 `<main>` 안쪽이고, `<main>` 이 없으면 **head·header/nav/footer 를 걷어낸 나머지**다.
334
+ * 문서 전체를 폴백으로 쓰면 `<title>` 과 헤더 내비 글자만으로 셸이 통과한다(실측 확인).
335
+ *
336
+ * 그래프를 요구하는 라우트에는 더 강한 증거가 이미 있다 — **JSON-LD 가 원본 응답에 실려 왔다는 사실**
337
+ * 자체가 서버 렌더의 증명이다. 그래서 이 판정은 그래프가 없는 표면(목록·고정 페이지)에서 주로 일한다.
338
+ */
339
+ function judgeSsr(html) {
340
+ const h1 = /<h1[^>]*>([\s\S]*?)<\/h1>/i.exec(html);
341
+ const h1Text = h1 ? textOf(h1[1]) : "";
342
+ if (!h1Text) return {ok: false, why: "서버 응답에 글자 있는 <h1> 이 없다(클라이언트에서만 그리는 셸)"};
343
+ const main = /<main[^>]*>([\s\S]*?)<\/main>/i.exec(html);
344
+ const scope = main ? main[1] : stripElements(html, "head", "header", "nav", "footer");
345
+ const bodyText = textOf(stripElements(scope, "h1"));
346
+ if (!bodyText) {
347
+ return {
348
+ ok: false,
349
+ why: main
350
+ ? "<main> 에 제목뿐이고 본문이 없다 — 본문을 클라이언트에서 그리는 부분 CSR 이다"
351
+ : "<main> 이 없고 제목 밖 본문도 응답에 없다 — 클라이언트에서만 그리는 셸이다",
352
+ };
353
+ }
354
+ return {ok: true};
355
+ }
356
+
357
+ // ── 라우트 해석 ──────────────────────────────────────────────────────────────
358
+
359
+ const routeDefs = Object.fromEntries(Object.entries(spec.routes ?? {}).filter(([k]) => !k.startsWith("$")));
360
+
361
+ /**
362
+ * 예약 세그먼트 — `/{slug}` 인스턴스 후보에서 뺀다. **표에서 유도한다**(하드코딩 2벌 금지):
363
+ * 다른 라우트들의 리터럴 선두 세그먼트 + 기능 세그먼트 + 식별정보 라우트.
364
+ */
365
+ const reservedFirstSegments = new Set([...FUNCTIONAL_SEGMENTS, "policies", "contact"]);
366
+ for (const def of Object.values(routeDefs)) {
367
+ const p = def.path ?? def.pattern ?? "";
368
+ const first = p.split("/").filter(Boolean)[0];
369
+ if (first && !first.startsWith("{")) reservedFirstSegments.add(first);
370
+ }
371
+
372
+ function instanceMatcher(pattern) {
373
+ const segs = pattern.split("/").filter(Boolean);
374
+ return (path) => {
375
+ const got = path.split("/").filter(Boolean);
376
+ if (got.length !== segs.length) return false;
377
+ for (let i = 0; i < segs.length; i++) {
378
+ if (segs[i].startsWith("{")) {
379
+ if (i === 0 && reservedFirstSegments.has(got[i])) return false;
380
+ } else if (segs[i] !== got[i]) return false;
381
+ }
382
+ return true;
383
+ };
384
+ }
385
+
386
+ // ── 크롤 ─────────────────────────────────────────────────────────────────────
387
+
388
+ /** 표가 요구하는 리터럴 라우트는 링크가 없어도 직접 두드린다 — **라우트 부재가 곧 보장 부재**라서다. */
389
+ const literalRoutes = Object.values(routeDefs)
390
+ .filter((d) => d.kind === "LITERAL")
391
+ .map((d) => d.path);
392
+
393
+ /** 기계용 파일은 크롤러가 페이지로 안 받는다(blocked) — 따로 두드린다. */
394
+ async function fetchText(path) {
395
+ try {
396
+ const res = await fetch(origin + path, {headers: {"user-agent": "zalkera-aeo-check"}});
397
+ return res.ok ? await res.text() : null;
398
+ } catch {
399
+ return null;
400
+ }
401
+ }
402
+
403
+ console.log(`· 대상 ${siteUrl} · ${siteWideOnly ? "무주장(사이트 축만)" : `카테고리 ${categories.join(", ")}`} · 보장표 rev ${spec.contractRev}`);
404
+ console.log(`· 잣대 출처 ${resolved.source} — ${specPath}`);
405
+ if (carriedDrift) console.warn(`⚠️ ${carriedDrift}`);
406
+
407
+ const robotsTxt = await fetchText("/robots.txt");
408
+ const sitemapXml = await fetchText("/sitemap.xml");
409
+ const sitemapLocs = new Set(
410
+ [...(sitemapXml ?? "").matchAll(/<loc>\s*([^<]+?)\s*<\/loc>/gi)].map((m) => {
411
+ try {
412
+ return new URL(m[1]).pathname.replace(/\/+$/, "") || "/";
413
+ } catch {
414
+ return m[1];
415
+ }
416
+ }),
417
+ );
418
+
419
+ /**
420
+ * **sitemap 을 크롤 시드로 쓴다.** 링크 추적만으로는 목록 라우트가 없는 사이트에서 상품 상세를 영영 못
421
+ * 만난다(실측: 상품 5행이 있는데 /products 가 없어 상세가 0건으로 잡혔다) — 그건 목록 부재의 결과이지
422
+ * 상세 부재가 아닌데 검사기가 둘을 섞어 보고하면 T6 지시서가 흐려진다. sitemap 은 사이트 자신이 신고한
423
+ * 공개 라우트 목록이라 이 용도의 정당한 출처다. 없으면(sitemap 부재) 링크 추적만으로 돈다.
424
+ */
425
+ const sitemapSeed = [...sitemapLocs].filter((p) => p.startsWith("/"));
426
+
427
+ const crawled = await crawlPages({
428
+ origin,
429
+ entryPath: new URL(siteUrl).pathname.replace(/\/+$/, "") || "/",
430
+ extraRoutes: [...literalRoutes, ...sitemapSeed, ...extraRoutes],
431
+ maxPages,
432
+ classify,
433
+ userAgent: "zalkera-aeo-check",
434
+ onPage: (p) => console.log(` · 크롤 ${p}`),
435
+ });
436
+
437
+ if (crawled.pages.size === 0) {
438
+ die(`크롤한 페이지가 0건이다 — 사이트가 응답하지 않는다.\n${crawled.fetchFailures.map((f) => ` · ${f}`).join("\n")}`);
439
+ }
440
+
441
+ /** 라우트 → {status, html, ld}. 못 받은 라우트는 여기 없다(= 부재). */
442
+ const seen = new Map();
443
+ for (const [path, html] of crawled.pages) seen.set(path, {html, ld: extractJsonLd(html)});
444
+
445
+ // ── 판정 ─────────────────────────────────────────────────────────────────────
446
+
447
+ /** 라우트 이름 → 검사 대상 페이지. LITERAL 은 그 경로, INSTANCE 는 크롤에서 찾은 실물 한 건. */
448
+ function resolveRoute(name) {
449
+ if (name === "any") return {kind: "ANY"};
450
+ const def = routeDefs[name];
451
+ if (!def) return {kind: "UNKNOWN"};
452
+ if (def.kind === "LITERAL") {
453
+ const page = seen.get(def.path);
454
+ return page ? {kind: "PAGE", path: def.path, page} : {kind: "MISSING", path: def.path};
455
+ }
456
+ const match = instanceMatcher(def.pattern);
457
+ for (const [path, page] of seen) if (match(path)) return {kind: "PAGE", path, page};
458
+ return {kind: "MISSING", path: def.pattern};
459
+ }
460
+
461
+ const pageHasType = (page, type) => page.ld.nodes.some((n) => typesOf(n).includes(type));
462
+ const anyPageHasType = (type) => [...seen.values()].some((p) => pageHasType(p, type));
463
+
464
+ /** `requiredChildren` — 그 타입 노드 **안에** 자식 타입이 있는가(Product 안의 Offer 처럼). */
465
+ function nodeContainsType(node, type) {
466
+ return flattenNodes(node).some((n) => n !== node && typesOf(n).includes(type));
467
+ }
468
+
469
+ function judgeSurface(surface, bucket) {
470
+ const base = {id: surface.id, bucket, route: surface.route ?? null, jsonLd: surface.jsonLd ?? []};
471
+ const target = resolveRoute(surface.route ?? "any");
472
+
473
+ if (target.kind === "UNKNOWN") return {...base, status: "ERROR", detail: `모르는 라우트 참조 '${surface.route}'`};
474
+
475
+ // 라우트가 없다 = 표면이 없다. 스킵이 아니라 미충족이다.
476
+ if (target.kind === "MISSING") {
477
+ return {...base, status: "MISSING_ROUTE", detail: `라우트 부재 — ${target.path} 가 응답하지 않거나 실물이 0건이다`};
478
+ }
479
+
480
+ const types = surface.jsonLd ?? [];
481
+ const hasType = (t) => (target.kind === "ANY" ? anyPageHasType(t) : pageHasType(target.page, t));
482
+ const at = target.kind === "ANY" ? "(사이트 어디든)" : target.path;
483
+
484
+ // ① JSON-LD 타입 — 배열이면 그중 **하나라도** 있으면 통과(Organization|LocalBusiness|BeautySalon 처럼 좁힘 허용).
485
+ if (types.length > 0) {
486
+ const found = types.filter(hasType);
487
+ if (found.length === 0) {
488
+ return {...base, at, status: "MISSING_JSONLD", detail: `${at} 에 ${types.join("|")} 그래프가 없다`};
489
+ }
490
+ for (const child of surface.requiredChildren ?? []) {
491
+ const carriers =
492
+ target.kind === "ANY"
493
+ ? [...seen.values()].flatMap((p) => p.ld.nodes)
494
+ : target.page.ld.nodes;
495
+ const ok = carriers.some((n) => found.some((t) => typesOf(n).includes(t)) && nodeContainsType(n, child));
496
+ if (!ok) {
497
+ return {...base, at, status: "MISSING_CHILD", detail: `${at} 의 ${found[0]} 안에 ${child} 가 없다`};
498
+ }
499
+ }
500
+ }
501
+
502
+ // ①-b 그래프 **속성** 요구(openingHours 처럼 타입이 아니라 필드인 것).
503
+ if (surface.jsonLdProperty) {
504
+ const pool = target.kind === "ANY" ? [...seen.values()].flatMap((p) => p.ld.nodes) : target.page.ld.nodes;
505
+ if (!pool.some((n) => n[surface.jsonLdProperty] != null)) {
506
+ return {...base, at, status: "MISSING_PROPERTY", detail: `${at} 의 JSON-LD 에 ${surface.jsonLdProperty} 속성이 없다`};
507
+ }
508
+ }
509
+
510
+ // ② SSR — JS 실행 없이 본문이 실려 있는가.
511
+ if (surface.ssr && target.kind === "PAGE") {
512
+ const ssr = judgeSsr(target.page.html);
513
+ if (!ssr.ok) return {...base, at, status: "NOT_SSR", detail: `${at} — ${ssr.why}`};
514
+ }
515
+
516
+ // ③ 깨진 JSON-LD 는 없는 것보다 나쁘다.
517
+ if (target.kind === "PAGE" && target.page.ld.malformed.length > 0) {
518
+ return {...base, at, status: "MALFORMED_JSONLD", detail: `${at} 의 JSON-LD 파싱 실패 ${target.page.ld.malformed.length}건`};
519
+ }
520
+
521
+ return {...base, at, status: "PASS"};
522
+ }
523
+
524
+ /**
525
+ * siteWide 요건 — 라우트 축이 아니라 사이트 축이라 판정이 따로 산다.
526
+ *
527
+ * `requiredRoutePaths` 가 **null 이면 주장이 없는 것**(무주장 모드)이고, 빈 배열이면 주장은 있는데 그
528
+ * 라우트가 하나도 안 잡힌 것이다. 둘을 같게 다루면 `SITEMAP_COVERS` 가 "덮을 대상이 0건이라 통과"라는
529
+ * 공허한 PASS 를 찍는다 — 안 잰 것을 통과로 세지 않는 것이 이 검사기의 규율이다.
530
+ */
531
+ function judgeSiteWide(req, requiredRoutePaths) {
532
+ const base = {id: req.id, bucket: "siteWide", check: req.check};
533
+ if (req.check === "SITEMAP_COVERS" && requiredRoutePaths === null) {
534
+ return {...base, status: "SKIPPED", detail: "보장 카테고리를 주장하지 않아 sitemap 이 덮어야 할 라우트가 정해지지 않는다"};
535
+ }
536
+ switch (req.check) {
537
+ case "ROBOTS_PRESENT":
538
+ return robotsTxt == null
539
+ ? {...base, status: "MISSING", detail: "/robots.txt 가 응답하지 않는다"}
540
+ : {...base, status: "PASS"};
541
+ case "SITEMAP_PRESENT":
542
+ return sitemapXml == null
543
+ ? {...base, status: "MISSING", detail: "/sitemap.xml 이 응답하지 않는다"}
544
+ : {...base, status: "PASS"};
545
+ case "SITEMAP_COVERS": {
546
+ if (sitemapXml == null) return {...base, status: "MISSING", detail: "sitemap 자체가 없다"};
547
+ const missing = requiredRoutePaths.filter((p) => !sitemapLocs.has(p));
548
+ return missing.length
549
+ ? {...base, status: "MISSING", detail: `sitemap 이 보장 라우트를 안 싣는다 — ${missing.join(", ")}`}
550
+ : {...base, status: "PASS"};
551
+ }
552
+ case "ABSOLUTE_URLS_IN_JSONLD": {
553
+ const bad = [];
554
+ for (const [path, page] of seen) {
555
+ for (const node of page.ld.nodes) {
556
+ for (const key of ["url", "item", "@id"]) {
557
+ const v = node[key];
558
+ if (typeof v === "string" && v && !/^https?:\/\//i.test(v)) bad.push(`${path}: ${key}="${v}"`);
559
+ }
560
+ }
561
+ }
562
+ return bad.length
563
+ ? {...base, status: "RELATIVE_URL", detail: `절대 URL 이 아니다 — ${bad.slice(0, 5).join(" · ")}`}
564
+ : {...base, status: "PASS"};
565
+ }
566
+ default:
567
+ return {...base, status: "ERROR", detail: `모르는 검사 종류 '${req.check}'`};
568
+ }
569
+ }
570
+
571
+ /** 부정 보장 — 나오면 실패다. 없는 것이 정답인 항목이라 '누락'으로 세면 안 된다. */
572
+ function judgeNegative() {
573
+ const out = [];
574
+ for (const rule of spec.negative?.forbiddenTypes ?? []) {
575
+ const hits = [];
576
+ for (const [path, page] of seen) {
577
+ const pool = rule.scope === "ROOT_ONLY" ? page.ld.roots : page.ld.nodes;
578
+ for (const n of pool) {
579
+ if (n && typeof n === "object" && typesOf(n).includes(rule.type)) hits.push(path);
580
+ }
581
+ }
582
+ out.push(
583
+ hits.length
584
+ ? {id: `no-${rule.type}`, bucket: "negative", status: "FORBIDDEN_PRESENT", detail: `${rule.type} 가 나왔다 — ${[...new Set(hits)].join(", ")}`}
585
+ : {id: `no-${rule.type}`, bucket: "negative", status: "PASS"},
586
+ );
587
+ }
588
+ return out;
589
+ }
590
+
591
+ // ── 실행 ─────────────────────────────────────────────────────────────────────
592
+
593
+ const FAIL_STATUSES = new Set(["MISSING_ROUTE", "MISSING_JSONLD", "MISSING_CHILD", "MISSING_PROPERTY", "NOT_SSR", "MALFORMED_JSONLD", "MISSING", "RELATIVE_URL", "FORBIDDEN_PRESENT", "ERROR"]);
594
+
595
+ const results = [];
596
+
597
+ if (siteWideOnly) {
598
+ /**
599
+ * 무주장 판정 — **사이트 축만**(memo123 §6.3). 카테고리 required·conditional·planned 는 물론
600
+ * `negative`(부정 보장)도 여기서는 안 잰다: 부정 보장은 "우리 어휘를 쓰는 팩이 자사 별점을 달지
601
+ * 않는다"는 우리 팩의 규율이고, 남의 사이트가 자기 후기 마크업을 어떻게 다는지는 우리가 판정할
602
+ * 자리가 아니다(전제 A). B-2 에서 주장 앵커가 생기면 그 사이트에는 잰다.
603
+ */
604
+ const surfaces = (spec.siteWide?.requirements ?? []).map((req) => judgeSiteWide(req, null));
605
+ const failed = surfaces.filter((s) => FAIL_STATUSES.has(s.status));
606
+ results.push({
607
+ category: null,
608
+ label: "사이트 축(무주장)",
609
+ shipState: null,
610
+ verdict: failed.length === 0 ? "PASS" : "FAIL",
611
+ surfaces,
612
+ });
613
+ }
614
+
615
+ for (const code of categories) {
616
+ const cat = byCategory.get(code);
617
+ const surfaces = [];
618
+
619
+ for (const s of cat.required ?? []) surfaces.push(judgeSurface(s, "required"));
620
+ for (const s of cat.conditional ?? []) {
621
+ const r = judgeSurface(s, "conditional");
622
+ // 조건부는 못 찾으면 SKIPPED — "섹션을 안 썼다"와 구별할 수 없다(§EVENT/faq-page why).
623
+ surfaces.push(r.status === "PASS" ? r : {...r, status: "SKIPPED", detail: `${r.detail ?? ""} (섹션 미사용일 수 있어 실패로 세지 않는다)`.trim()});
624
+ }
625
+ for (const s of cat.planned ?? []) {
626
+ const r = judgeSurface(s, "planned");
627
+ // planned 는 게이트가 아니라 T6 작업 지시서다.
628
+ surfaces.push(r.status === "PASS" ? {...r, status: "PLANNED_PRESENT"} : {...r, status: "PLANNED_MISSING", tranche: s.tranche ?? null});
629
+ }
630
+
631
+ // 이 카테고리가 보장하는 라우트의 실제 경로 — sitemap 커버리지 판정 입력.
632
+ const requiredRoutePaths = [];
633
+ for (const s of cat.required ?? []) {
634
+ const t = resolveRoute(s.route ?? "any");
635
+ if (t.kind === "PAGE") requiredRoutePaths.push(t.path);
636
+ }
637
+ for (const req of spec.siteWide?.requirements ?? []) surfaces.push(judgeSiteWide(req, [...new Set(requiredRoutePaths)]));
638
+ surfaces.push(...judgeNegative());
639
+
640
+ const gating = surfaces.filter((s) => s.bucket !== "planned" && s.bucket !== "conditional");
641
+ const failed = gating.filter((s) => FAIL_STATUSES.has(s.status));
642
+ results.push({
643
+ category: code,
644
+ label: cat.label ?? code,
645
+ shipState: cat.shipState,
646
+ verdict: failed.length === 0 ? "PASS" : "FAIL",
647
+ surfaces,
648
+ });
649
+ }
650
+
651
+ const snapshot = {
652
+ checker: "check-aeo-surfaces.mjs",
653
+ checkerVersion: CHECKER_VERSION,
654
+ /**
655
+ * 무엇을 잰 판정인가. `site-wide-only` 스냅샷은 **카테고리 판정이 아예 없어서** promote 입력으로 쓰면
656
+ * 보장 분류가 `passedFor` 에서 떨어진다(그게 맞다 — 안 잰 것을 통과로 셀 수 없다). 이 필드는 그 거절이
657
+ * "미충족"인지 "애초에 다른 검사"인지를 사후에 읽을 수 있게 남기는 자리다.
658
+ */
659
+ mode: siteWideOnly ? "site-wide-only" : "category",
660
+ guaranteesRev: spec.contractRev,
661
+ // 어느 출처의 잣대로 잰 판정인가 — rev 만으로는 "정본으로 쟀는지 운반본으로 쟀는지"가 안 남는다.
662
+ guaranteesSource: resolved.source,
663
+ themeCode,
664
+ themeVersion,
665
+ siteUrl,
666
+ checkedAt: new Date().toISOString(),
667
+ pagesCrawled: [...seen.keys()].sort(),
668
+ fetchFailures: crawled.fetchFailures,
669
+ verdict: results.every((r) => r.verdict === "PASS") ? "PASS" : "FAIL",
670
+ categories: results,
671
+ };
672
+
673
+ // ── 보고 ─────────────────────────────────────────────────────────────────────
674
+
675
+ const MARK = {PASS: "✅", PLANNED_PRESENT: "✅", SKIPPED: "⏭️ ", PLANNED_MISSING: "📋"};
676
+ console.log("");
677
+ for (const r of results) {
678
+ console.log(`■ ${r.category ? `${r.category}(${r.label})` : r.label} — ${r.verdict}${r.shipState === "BLOCKED" ? " · 진열 차단(DON'T-SHIP)" : ""}`);
679
+ for (const s of r.surfaces) {
680
+ const mark = MARK[s.status] ?? "❌";
681
+ const tail = s.detail ? ` — ${s.detail}` : "";
682
+ const tranche = s.tranche ? ` [${s.tranche}]` : "";
683
+ console.log(` ${mark} ${s.bucket}/${s.id}${tranche}: ${s.status}${tail}`);
684
+ }
685
+ console.log("");
686
+ }
687
+
688
+ if (outPath) {
689
+ mkdirSync(dirname(resolve(outPath)), {recursive: true});
690
+ writeFileSync(resolve(outPath), JSON.stringify(snapshot, null, 2) + "\n", "utf8");
691
+ console.log(`· 검사 스냅샷 → ${outPath}`);
692
+ console.log(
693
+ siteWideOnly
694
+ ? " 이 스냅샷은 **promote 입력이 아니다**(카테고리 판정 0건 — 사이트 축만 잰 결과다)."
695
+ : " promote 입력으로 이 파일을 그대로 넘긴다(보장 카테고리 팩은 스냅샷 없이 promote 가 거부된다).",
696
+ );
697
+ } else if (!siteWideOnly) {
698
+ console.log("· --out 을 안 줬다 — 스냅샷을 파일로 남겨야 promote 입력으로 쓸 수 있다.");
699
+ }
700
+
701
+ const planned = results.flatMap((r) => r.surfaces.filter((s) => s.status === "PLANNED_MISSING"));
702
+ if (planned.length) {
703
+ console.log(`· 예정 표면 ${planned.length}건은 실패가 아니다 — 보장표가 목표로 적어 둔 T6 작업 목록이다.`);
704
+ }
705
+
706
+ process.exit(snapshot.verdict === "PASS" ? 0 : 1);