@zalkera/client 0.10.0 → 0.12.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.
- package/README.md +13 -0
- package/bin/check-aeo-surfaces.mjs +706 -0
- package/contracts/aeo-surface-guarantees.json +189 -0
- package/lib/site-crawl.mjs +227 -0
- package/llms.txt +14 -0
- package/package.json +12 -3
package/README.md
CHANGED
|
@@ -59,6 +59,19 @@ await zalkera.submitInquiry({name, email, subject, message}, {clientIp});
|
|
|
59
59
|
- 설치 후 경로: `node_modules/@zalkera/client/llms.txt`.
|
|
60
60
|
- 사용법: 이 파일을 AI 컨텍스트로 주고 "상품·장바구니·결제 페이지를 만들어줘".
|
|
61
61
|
|
|
62
|
+
## AEO 보장표 (`contracts/aeo-surface-guarantees.json`)
|
|
63
|
+
|
|
64
|
+
- `llms.txt` §5.1 이 말하는 산출물 규범의 **기계 판독본**. 설치 후 경로:
|
|
65
|
+
`node_modules/@zalkera/client/contracts/aeo-surface-guarantees.json`
|
|
66
|
+
패키지 하위 경로로도 열린다 — `require("@zalkera/client/contracts/aeo-surface-guarantees.json")`,
|
|
67
|
+
ESM 은 `import … with { type: "json" }`(실측 확인).
|
|
68
|
+
- 이 표를 읽어 **개시된 사이트를 크롤해** 판정하는 **검사기도 이 패키지가 배송한다** —
|
|
69
|
+
`npx zalkera-aeo-check <사이트URL> --category BOOKING`(bin `zalkera-aeo-check`).
|
|
70
|
+
소스가 아니라 산출물을 재므로 스택·디자인을 가리지 않는다.
|
|
71
|
+
보장 주장이 없는 사이트는 `--category` 대신 `--site-wide-only`(robots·sitemap·JSON-LD 절대 URL 만).
|
|
72
|
+
스토어프론트 템플릿의 `npm run check:aeo` 는 이 bin 을 부르는 wrapper 다.
|
|
73
|
+
- 정본은 잘커라 백엔드에 있고 여기 실린 것은 그 **발행 산출물**이다(발행 전 기계 대조).
|
|
74
|
+
|
|
62
75
|
## API 스펙 (메서드)
|
|
63
76
|
|
|
64
77
|
### 콘텐츠·공개(무인증)
|
|
@@ -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);
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$comment": "AEO 보장 표면 계약 정본(memo119 §1.0·§4.2 · T4a). 형제 계약 section-vocabulary.json 과 같은 거처·같은 관례(contractRev·기계 대조·사람 주석은 이식에서 진다)를 따른다. 어휘가 '레이아웃 규격'이 아니라 '유형별 최소 AEO/SEO 보장 계약'이라는 재정의(오너 정정 B)의 기계 형태다. 구조화 데이터에는 생김새가 없어 디자인 자유를 하나도 깎지 않고 바닥만 깔 수 있다 — 이 파일이 그 바닥이다.",
|
|
3
|
+
"contractRev": 1,
|
|
4
|
+
|
|
5
|
+
"conventions": {
|
|
6
|
+
"canonicalHere": "이 파일이 정본이다. zalkera-client/llms.txt §5.1 은 같은 계약의 **사람/AI용 운반체**이고, 대조는 zalkera-client/scripts/sync-aeo-guarantees.mjs 가 기계로 센다. 채널이 늘어도 사본을 늘리지 않는다 — AGENTS.md·콘솔 문서·MCP 는 참조 링크만 든다(memo119 §5-13).",
|
|
7
|
+
"floorNotCeiling": "보장은 바닥이지 천장이 아니다. 이 표를 못 채우는 템플릿도 만들 수 있고 적재될 수 있다 — 못 거는 것은 **그 카테고리 간판**뿐이고, 그것도 결함 판정이 아니라 진열 축의 사실 판정이다(§4.1·오너 정정 A).",
|
|
8
|
+
"whereTheWeightIs": "보장의 무게는 **라우트**에 있다. 현행 보장(Product·Organization·BlogPosting·BreadcrumbList)은 라우트·렌더러의 속성이고, 섹션이 직접 산출하는 것은 FAQPage 하나다. **섹션 어휘를 0개 쓰는 사이트도 라우트 보장은 똑같이 받는다**(§1.0-a).",
|
|
9
|
+
"verdictFromArtifact": "판정 지점은 소스가 아니라 **산출물**이다(오너 정정 C). '이 섹션을 써라'는 자유를 깎지만 '개시된 페이지에서 이 그래프가 나오는가'는 아무것도 깎지 않는다. 검사기는 storefront-template/scripts/check-aeo-surfaces.mjs 이고, 실행 자리는 promote 전 스모크 개시다.",
|
|
10
|
+
"requiredVsPlanned": "`required` 는 **이 rev 가 지금 강제하는 것**이고, `planned` 는 같은 카테고리의 목표 표면 중 **아직 우리 본보기도 못 내는 것**이다. planned 는 게이트가 아니라 T6 작업 지시서이고, 검사기는 이를 실패가 아니라 PLANNED_MISSING 으로 보고한다. `realized` 는 **그 지시가 실제로 이행됐는가**를 적는 별개 축이다 — 이행됐다고 자동으로 required 가 되지는 않는다. required 로 올리는 것은 이 표의 rev 상향이고, 그 시점은 **이행분이 개시된 산출물에 실제로 나타나는 발행 단위**다 — 코드 병합이 아니다(판정 지점이 산출물이므로 잣대의 상향 시점도 산출물이어야 한다. 병합만으로 표를 올리면 아직 재빌드 안 된 팩·구 SDK 로 빌드된 팩이 통과 못 할 요구를 받는다). rev 2 가 그 실례다: `openingHours` 는 저장 자리(백엔드 컬럼)만 섰고 `@zalkera/client` 0.9.0 미발행·템플릿 소비 0 이라, rev 2 는 T6 병합이 아니라 **client 발행 + 본보기 팩 재빌드**가 끝난 단위에서만 설 수 있다. 이 분리가 없으면 둘 중 하나를 해야 한다 — 규범을 낮추거나(보장이 거짓말이 된다), 오늘 아무도 못 거는 표를 강제하거나(카탈로그가 빈 선반이 된다).",
|
|
11
|
+
"growth": "표는 자란다. rev 상향 시 **기존 등재물의 소급 탈락은 금지**다 — 구 rev 판정은 판정 당시 rev 를 병기한 배지로 유효하게 지속되고, 신 rev 요구는 신규 등재·재promote 부터 건다. 순수 라벨 카테고리가 나중에 이 표에 편입되면 기존 등재물은 유예한다(§1.0-c). 조용한 소급은 이 설계가 죽이려던 조용한 실패의 재생산이다.",
|
|
12
|
+
"publication": "`published` 는 그 표면의 규범이 **공개 문서(llms.txt §5.1)에 이미 나가 있는가**다. false = 내부 정본에만 있다 = 아직 공표 전. 내부 정본이 앞서 있는 것은 무해하다(작업 지시서다). 그러나 **공표가 본보기를 앞지르면 안 된다** — '레시피가 본보기를 앞지르지 않는다'(§1.5-a). false→true 뒤집기는 T6 그린과 같은 발행 단위 이후이고(T4b), 대조 스크립트가 양방향으로 센다: published 인데 §5.1 에 없으면 실패, published 가 아닌데 §5.1 에 이미 있으면 **역방향 실패**(공표가 표를 앞질렀다).",
|
|
13
|
+
"categoryStorage": "카테고리는 `theme.category` 자유 문자열이다 — enum 으로 못박지 않는다(memo119 §5-6). **여기 항목이 있는 이름만** 게이트가 걸리고, 없는 이름은 순수 라벨이다(예: '미니멀'). 값 이름을 업종어가 아니라 유형어로 두는 이유는 형제 계약의 명명 규약과 같다: 업종어를 박으면 재사용이 죽는다(DOCTOR_INTRO 교훈). 그래서 예약 칸은 `BEAUTY` 가 아니라 `BOOKING` 이다 — 예약을 파는 것은 뷰티만이 아니다."
|
|
14
|
+
},
|
|
15
|
+
|
|
16
|
+
"routes": {
|
|
17
|
+
"$comment": "표면이 가리키는 라우트 어휘. LITERAL 은 그 경로 자체가 있어야 하고, INSTANCE 는 그 패턴에 맞는 **실물 한 건**을 크롤에서 찾아 검사한다(패턴 자체는 응답하지 않는다). 없는 라우트는 red 이지 스킵이 아니다 — 라우트 부재가 곧 보장 부재다.",
|
|
18
|
+
"$notASurface": "`/policies` 는 여기 없다. 그 페이지는 JSON-LD 를 **0건** 낸다 — `parsePolicies`(정책 문구 파서)만 import 할 뿐 `<JsonLd>` 를 렌더하지 않는다. 파일명 grep 이 `JsonLd` 를 문자열로 잡아 \"산출한다\"로 잘못 읽히는 자리라 명시해 둔다(실제로 그 오독이 한 번 났다). `PostalAddress` 의 거처는 `/` 의 `organizationJsonLd` 안이고 그것도 address 가 설정됐을 때만 나간다.",
|
|
19
|
+
"home": {"kind": "LITERAL", "path": "/"},
|
|
20
|
+
"cmsPage": {"kind": "INSTANCE", "pattern": "/{slug}", "note": "콘솔·시드가 만든 고정 페이지(src/app/[slug]/page.tsx). 예약 세그먼트(products·blog·c·policies·contact 등)는 인스턴스 후보에서 뺀다."},
|
|
21
|
+
"productList": {"kind": "LITERAL", "path": "/products", "note": "**T6-ⓑ 가 신설했다**(src/app/products/page.tsx · ItemList+BreadcrumbList · sitemap 등재). 레시피는 그 전부터 있었다 — llms.txt §4.1 이 이 라우트를 그린다. 개시된 사이트에 나타나는 것은 템플릿 병합 뒤 **그 팩을 다시 빌드·개시한 다음**이므로, 낡은 산출물을 크롤하면 여전히 부재로 잡힌다 — 그건 라우트 부재가 아니라 산출물이 낡은 것이다."},
|
|
22
|
+
"productDetail": {"kind": "INSTANCE", "pattern": "/products/{slug}"},
|
|
23
|
+
"productCategory": {"kind": "INSTANCE", "pattern": "/c/{slug}", "note": "**오늘 부재. 그런데 막는 것은 라우트가 아니라 데이터층이다**(2026-07-28 실측 · 설계 §0-6 에 없던 사실). 경로는 발명하지 않았다 — llms.txt §4.1 레시피가 카테고리 링크를 `/c/{slug}` 로 이미 그리고 있다. 진짜 선행은 아래 `product-category-collectionpage` 를 보라: `product_category_map` 에 **행을 넣는 경로가 전 레포 0건**이라 라우트를 지어도 어느 카테고리든 상품 0건이 된다."},
|
|
24
|
+
"blogList": {"kind": "LITERAL", "path": "/blog"},
|
|
25
|
+
"blogDetail": {"kind": "INSTANCE", "pattern": "/blog/{slug}"}
|
|
26
|
+
},
|
|
27
|
+
|
|
28
|
+
"siteWide": {
|
|
29
|
+
"$comment": "카테고리 무관 최소 기계 가독. 라우트별 그래프가 아무리 훌륭해도 크롤러가 목록을 못 받으면 발견되지 않는다.",
|
|
30
|
+
"requirements": [
|
|
31
|
+
{
|
|
32
|
+
"id": "robots",
|
|
33
|
+
"check": "ROBOTS_PRESENT",
|
|
34
|
+
"published": true,
|
|
35
|
+
"llmsMarker": "`sitemap.ts`·`robots.ts` 필수",
|
|
36
|
+
"why": "공개 카탈로그를 열고 세션·쓰기 경로만 막는다. **AI 크롤러(GPTBot·ClaudeBot 등)를 막지 않는다** — 발견 경로를 우리 손으로 닫는 일이다."
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"id": "sitemap",
|
|
40
|
+
"check": "SITEMAP_PRESENT",
|
|
41
|
+
"published": true,
|
|
42
|
+
"llmsMarker": "`sitemap.ts`·`robots.ts` 필수",
|
|
43
|
+
"why": "존재하는 공개 라우트만 싣는다."
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
"id": "sitemap-covers-required-routes",
|
|
47
|
+
"check": "SITEMAP_COVERS",
|
|
48
|
+
"published": false,
|
|
49
|
+
"publishTranche": "T4b",
|
|
50
|
+
"llmsMarker": "sitemap 은 그 카테고리의 보장 라우트를 전부 싣는다",
|
|
51
|
+
"realized": "PARTIAL",
|
|
52
|
+
"realizedNote": "T6 이 /products 를 sitemap 에 등재했다. /blog 목록은 아직이고 카테고리는 라우트 자체가 없다 — 그래서 절반이다.",
|
|
53
|
+
"why": "현행 §5.1 의 sitemap 지시는 '홈·상품 상세·콘텐츠'라 **목록·카테고리를 모른다**(§0-11 ⓐ). 목록 라우트가 생겨도 sitemap 이 모르면 보장이 반쪽이다 — 쇼핑몰 칸이 못 서는 균열과 같은 뿌리다. 표에는 지금 넣고(작업 지시서), 공표는 본보기가 실제로 그렇게 된 뒤다."
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
"id": "absolute-urls",
|
|
57
|
+
"check": "ABSOLUTE_URLS_IN_JSONLD",
|
|
58
|
+
"published": true,
|
|
59
|
+
"llmsMarker": "절대 URL",
|
|
60
|
+
"why": "JSON-LD 의 url·item 이 상대경로면 크롤러가 해석하지 못한다."
|
|
61
|
+
}
|
|
62
|
+
]
|
|
63
|
+
},
|
|
64
|
+
|
|
65
|
+
"negative": {
|
|
66
|
+
"$comment": "**의도적 부정 보장** — 빠뜨린 것이 아니라 결정이다. 검사기가 이것들을 '누락'으로 잡으면 안 되고, 오히려 **나타나면 실패**로 잡아야 한다.",
|
|
67
|
+
"forbiddenTypes": [
|
|
68
|
+
{
|
|
69
|
+
"type": "Review",
|
|
70
|
+
"why": "자사 사이트의 자사 후기에 별점을 붙이는 것은 self-serving reviews 정책 위반이라 제재 대상이다(memo102 §2.3). TESTIMONIALS 섹션이 jsonLd 를 안 내는 이유가 이것이다 — 어휘 12종 중 jsonLd 비-null 이 FAQ_LIST 하나뿐인 것은 미완성이 아니라 판단이다."
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
"type": "AggregateRating",
|
|
74
|
+
"scope": "ROOT_ONLY",
|
|
75
|
+
"why": "Product 안에 중첩된 AggregateRating 은 실제 후기 집계라 정상이다(productJsonLd 가 reviewCount>0 일 때만 낸다). 금지 대상은 **루트 노드로 선 AggregateRating** — 상품에 붙지 않은 별점은 자사 별점 선언이다."
|
|
76
|
+
}
|
|
77
|
+
]
|
|
78
|
+
},
|
|
79
|
+
|
|
80
|
+
"categories": [
|
|
81
|
+
{
|
|
82
|
+
"category": "MARKETING",
|
|
83
|
+
"label": "비즈니스 홍보",
|
|
84
|
+
"shipState": "SHIPPABLE",
|
|
85
|
+
"required": [
|
|
86
|
+
{"id": "home-organization", "route": "home", "jsonLd": ["Organization", "LocalBusiness", "BeautySalon"], "ssr": true, "published": true, "llmsMarker": "홈 = `Organization`", "why": "사이트 주체가 누구인지 기계가 읽는 자리. 홈에 1회만 낸다."},
|
|
87
|
+
{"id": "cms-page-ssr", "route": "cmsPage", "jsonLd": [], "ssr": true, "published": true, "llmsMarker": "**ISR 유지.**", "why": "고정 페이지 본문이 JS 실행 없이 응답 본문에 실려야 한다. 이것이 없으면 그래프를 아무리 잘 내도 답변 엔진이 인용할 본문이 없다."}
|
|
88
|
+
],
|
|
89
|
+
"planned": [
|
|
90
|
+
{"id": "cms-page-webpage", "route": "cmsPage", "jsonLd": ["WebPage"], "tranche": "T6-ⓐ", "realized": "DONE", "realizedNote": "T6 이 CMS 라우트에 WebPage+BreadcrumbList 를 얹었다(0건→2종).", "why": "CMS 서브페이지의 JSON-LD 는 오늘 0건이다(src/app/[slug]/page.tsx 에 JsonLd 호출 없음). 홈만 그래프를 갖고 서브페이지가 비어 있는 것이 마케팅 사이트의 실제 구멍이다."},
|
|
91
|
+
{"id": "cms-page-breadcrumb", "route": "cmsPage", "jsonLd": ["BreadcrumbList"], "tranche": "T6-ⓐ", "realized": "DONE", "realizedNote": "T6-ⓐ 와 같은 커밋.", "why": "§4.2 가 이 칸의 목표 표면에 BreadcrumbList 를 넣었는데, 오늘 BreadcrumbList 는 상품·블로그 상세에만 있다. 순수 마케팅 사이트에는 그 두 라우트가 없으므로 CMS 페이지가 유일한 거처다."}
|
|
92
|
+
],
|
|
93
|
+
"why": "표면이 전부 홈·고정 페이지라 오늘의 렌더러로 성립한다. 시드도 오늘 이 칸을 채울 수 있다(page·page_section·menu·theme_colors)."
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
"category": "PORTFOLIO",
|
|
97
|
+
"label": "포트폴리오",
|
|
98
|
+
"shipState": "SHIPPABLE",
|
|
99
|
+
"sameAs": "MARKETING",
|
|
100
|
+
"required": [
|
|
101
|
+
{"id": "home-organization", "route": "home", "jsonLd": ["Organization", "LocalBusiness", "BeautySalon"], "ssr": true, "published": true, "llmsMarker": "홈 = `Organization`", "why": "MARKETING 과 같다."},
|
|
102
|
+
{"id": "cms-page-ssr", "route": "cmsPage", "jsonLd": [], "ssr": true, "published": true, "llmsMarker": "**ISR 유지.**", "why": "MARKETING 과 같다."}
|
|
103
|
+
],
|
|
104
|
+
"planned": [
|
|
105
|
+
{"id": "cms-page-webpage", "route": "cmsPage", "jsonLd": ["WebPage"], "tranche": "T6-ⓐ", "why": "MARKETING 과 같다."},
|
|
106
|
+
{"id": "cms-page-breadcrumb", "route": "cmsPage", "jsonLd": ["BreadcrumbList"], "tranche": "T6-ⓐ", "why": "MARKETING 과 같다."}
|
|
107
|
+
],
|
|
108
|
+
"why": "보장 표면이 MARKETING 과 같다. 그런데도 별 항목으로 두는 이유는 **진열 라벨이 다르기 때문**이다 — 표면이 같다고 칸을 합치면 카탈로그 탐색에서 '포트폴리오'를 찾는 사람이 못 찾는다. 같은 표면을 두 번 적는 비용은 대조 스크립트가 감당한다."
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
"category": "EVENT",
|
|
112
|
+
"label": "이벤트·프로젝트",
|
|
113
|
+
"shipState": "SHIPPABLE",
|
|
114
|
+
"required": [
|
|
115
|
+
{"id": "home-organization", "route": "home", "jsonLd": ["Organization", "LocalBusiness", "BeautySalon"], "ssr": true, "published": true, "llmsMarker": "홈 = `Organization`", "why": "MARKETING 과 같다."},
|
|
116
|
+
{"id": "cms-page-ssr", "route": "cmsPage", "jsonLd": [], "ssr": true, "published": true, "llmsMarker": "**ISR 유지.**", "why": "MARKETING 과 같다."}
|
|
117
|
+
],
|
|
118
|
+
"conditional": [
|
|
119
|
+
{"id": "faq-page", "route": "any", "jsonLd": ["FAQPage"], "when": "SECTION_PRESENT", "section": "FAQ_LIST", "published": false, "publishTranche": "T4b", "why": "**어휘 12종 중 유일하게 섹션이 직접 산출하는 그래프**(FaqListSection.tsx:36). 조건부인 이유: 크롤한 HTML 만 보고는 'FAQ_LIST 를 안 썼다'와 'FAQ_LIST 를 썼는데 그래프가 안 나온다'를 구분할 수 없다. 그래서 검사기는 FAQPage 가 보이면 통과로, 안 보이면 SKIPPED 로 적고 **실패로 세지 않는다** — 못 세는 것을 센 척하지 않는다."}
|
|
120
|
+
],
|
|
121
|
+
"planned": [
|
|
122
|
+
{"id": "cms-page-webpage", "route": "cmsPage", "jsonLd": ["WebPage"], "tranche": "T6-ⓐ", "why": "MARKETING 과 같다."},
|
|
123
|
+
{"id": "cms-page-breadcrumb", "route": "cmsPage", "jsonLd": ["BreadcrumbList"], "tranche": "T6-ⓐ", "why": "MARKETING 과 같다."}
|
|
124
|
+
],
|
|
125
|
+
"why": "MARKETING 표면 + FAQ 섹션을 쓸 때의 FAQPage."
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
"category": "BOOKING",
|
|
129
|
+
"label": "예약",
|
|
130
|
+
"shipState": "SHIPPABLE",
|
|
131
|
+
"shipNote": "**축소 보장표로 등재된 상태다**(§4.2). ItemList·openingHours 를 지금 required 로 올리면 오늘 아무 팩도 이 칸을 못 걸고, 낮춰 적으면 보장이 거짓이 된다. 그래서 지금 참인 것만 강제하고 나머지는 planned 로 rev 관리한다 — rev 2 에서 required 로 올라간다. **그 rev 2 는 T6 병합 시점이 아니다**: openingHours 는 저장 자리만 서 있고 `@zalkera/client` 0.9.0(타입 추가)이 미발행이라 템플릿이 그 값을 소비하지 못해 그래프에 아무것도 안 나간다. rev 2 가 서는 단위는 **client 발행 + 본보기 팩 재빌드**다(conventions.requiredVsPlanned).",
|
|
132
|
+
"required": [
|
|
133
|
+
{"id": "home-localbusiness", "route": "home", "jsonLd": ["LocalBusiness", "BeautySalon", "HealthAndBeautyBusiness"], "ssr": true, "published": false, "publishTranche": "T4b", "why": "예약을 파는 곳은 **물리 점포**다. Organization 으로 두면 '어디로 가면 되는가'가 기계에 안 읽힌다. organizationJsonLd 는 config.businessType==BEAUTY 일 때 BeautySalon 으로 좁힌다 — 즉 이 요구는 렌더러가 아니라 **테넌트 설정**이 만족시킨다."},
|
|
134
|
+
{"id": "service-detail-product", "route": "productDetail", "jsonLd": ["Product"], "requiredChildren": ["Offer"], "ssr": true, "published": true, "llmsMarker": "상품 상세 = `Product` + variant 마다 `Offer`", "why": "시술 한 건이 상품 한 행이다. 가격이 Offer 로 나가야 '얼마인가'가 답변 엔진에 읽힌다. **이 표면은 시드가 상품을 만들 수 있어야 빈 약속이 아니다**(T1 의 products 시드가 전제)."}
|
|
135
|
+
],
|
|
136
|
+
"planned": [
|
|
137
|
+
{"id": "service-menu-itemlist", "route": "any", "jsonLd": ["ItemList"], "tranche": "T6-ⓒ", "realized": "DONE", "realizedNote": "T6 이 SERVICE_MENU 의 ItemList 산출을 구현했고 정본 section-vocabulary.json 의 jsonLd 열이 null→ItemList(contractRev 2)로 함께 올라갔다. 여기서 required 로 올리는 것은 이 표의 **rev 2** 이고, 그 단위는 T6 병합이 아니라 **본보기 팩을 다시 빌드해 개시한 뒤**다 — 같은 rev 의 openingHours 는 거기에 더해 client 발행까지 필요하다.", "why": "시술 목록의 정위치는 **별도 라우트가 아니라 홈의 SERVICE_MENU 섹션**이다(FAQ_LIST↔FAQPage 선례). 뷰티 사이트에 /products 목록 라우트를 강제하는 것은 과잉이라 비대칭을 이렇게 해소했다(§4.2 W-F5). 구현 시 section-vocabulary.json 의 SERVICE_MENU jsonLd 열이 null→\"ItemList\" 로 올라가고 그 rev 가 동반된다."},
|
|
138
|
+
{"id": "opening-hours", "route": "home", "jsonLdProperty": "openingHours", "tranche": "T6-ⓓ", "realized": "PARTIAL", "realizedNote": "**절반이다.** 저장 자리는 섰다(백엔드 site_config 확장 + 전체교체 경로 4곳). 템플릿 렌더는 @zalkera/client 0.9.0 발행 뒤라 아직 못 나간다(발행은 2FA 라 오너 몫). 저장 자리만으로는 그래프가 안 나가므로 이 항목이 여전히 red 로 잡히는 것이 맞다.", "why": "**전 레포에 저장할 필드 자체가 없다** — 렌더러 문제가 아니라 스키마 문제다. 거처는 예약 척추 위저드의 영업시간 입력(§2.4)이고, site_config 확장 마이그레이션이 선행한다."}
|
|
139
|
+
],
|
|
140
|
+
"why": "§4.2 예약 칸. 상세는 지금 나가고, 목록(ItemList)·영업시간은 T6 이후다."
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
"category": "COMMERCE",
|
|
144
|
+
"label": "쇼핑몰",
|
|
145
|
+
"shipState": "BLOCKED",
|
|
146
|
+
"blockedReason": "**DON'T-SHIP**(memo119 §5-11). 없는 표면을 카탈로그 간판으로 거는 것은 빈 선반이다.",
|
|
147
|
+
"unblockGate": "**정정(2026-07-28) — 'T6-ⓑ 완료 후 그린'은 거짓이다. T6-ⓑ 는 끝났는데 게이트는 안 열린다.** 목록 라우트(`/products`+ItemList)는 실제로 지어졌지만 카테고리 라우트는 못 지었고, 이유가 라우트 부재가 아니었다: **카테고리 데이터층이 없다.** 진짜 선행 조건은 상품↔카테고리 매핑을 실제로 채우고 읽는 층이다 — 도메인 모델·쓰기 경로·공개 API 필터·SDK 파라미터. 그것이 선 뒤에 라우트를 짓고, 이 검사기가 우리 팩에서 그린이면 shipState 를 SHIPPABLE 로·published 를 true 로 함께 뒤집는다.",
|
|
148
|
+
"unblockOwner": "이 사실은 설계 §0-6(라우트 부재만 기록)에 없다 — memo119 §4.2 쇼핑몰 칸의 선행 조건 서술을 갱신할지는 설계가 판정한다.",
|
|
149
|
+
"required": [
|
|
150
|
+
{"id": "product-detail", "route": "productDetail", "jsonLd": ["Product"], "requiredChildren": ["Offer"], "ssr": true, "published": true, "llmsMarker": "상품 상세 = `Product` + variant 마다 `Offer`", "why": "오늘 유일하게 나가는 커머스 표면. variant 마다 Offer 를 낸다(항상 variant 단위 판매)."},
|
|
151
|
+
{"id": "product-detail-breadcrumb", "route": "productDetail", "jsonLd": ["BreadcrumbList"], "ssr": true, "published": true, "llmsMarker": "목록·상세엔 `BreadcrumbList`", "why": "검색결과에 '홈 > 상품 > 이름' 경로가 선다."},
|
|
152
|
+
{"id": "product-list-itemlist", "route": "productList", "jsonLd": ["ItemList"], "ssr": true, "published": true,
|
|
153
|
+
"llmsMarker": "목록 라우트도 그래프를 낸다 — `ItemList`", "publishTranche": "T4b", "realized": "DONE", "realizedNote": "T6 이 /products 목록 라우트를 신설했다(404→200 · ItemList+BreadcrumbList · sitemap 등재). 이 항목은 이제 통과 가능하다 — 그래도 COMMERCE 칸이 안 열리는 이유는 같은 칸의 CollectionPage 다.", "why": "**라우트 자체가 없다.** 목록이 없으면 '이 가게가 무엇을 파는가'를 기계가 한 번에 못 받는다 — 상세 N건을 개별로 발견해야 한다. 쇼핑몰 3표면 중 2개가 비는 이유가 여기서 시작한다."},
|
|
154
|
+
{"id": "product-category-collectionpage", "route": "productCategory", "jsonLd": ["CollectionPage"], "requiredChildren": ["ItemList"], "ssr": true, "published": false, "publishTranche": "T4b", "why": "오너가 든 세 표면(카테고리·상품 상세·상품 목록) 중 마지막 하나이고, **셋 중 유일하게 아직 안 선다**. 막는 것이 라우트가 아니라는 것이 실측의 요점이다(2026-07-28): `product_category_map` 은 V9 에 테이블로 존재하지만 **INSERT 경로가 전 레포 0건**이고(유일한 코드 접촉은 카테고리 삭제 가드의 COUNT 한 줄), 도메인 모델·공개 API 필터·SDK 파라미터가 전부 없다(`PublicProductSearchRequest` = productType·keyword 뿐이고 그 KDoc 이 매핑이 채워진 뒤 확장한다고 자인한다). 즉 지금 라우트를 지으면 **어느 카테고리를 열어도 상품 0건**이 나온다 — 그건 표면을 만든 것이 아니라 빈 선반을 하나 더 세운 것이다(§4.2 빈 선반 금지)."}
|
|
155
|
+
],
|
|
156
|
+
"why": "오너가 '쇼핑몰이라면 최소한 카테고리·상품 상세·상품 목록은 보장받아야 할 것 아니냐'고 든 그 세 표면이 이 칸의 정의다. 셋 중 하나만 나가고 있다."
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
"category": "BLOG",
|
|
160
|
+
"label": "블로그·미디어",
|
|
161
|
+
"shipState": "SHIPPABLE",
|
|
162
|
+
"seedReady": false,
|
|
163
|
+
"seedNote": "표면은 지금 서지만 **시드가 글을 못 만든다**(posts 키 없음·수요 실증 전 확장 금지·§5-5). 즉 '개시 즉시 완성'이 아니라 '개시 후 글을 쓰면 완성'이다. 카드 문구가 이 차이를 숨기면 안 된다.",
|
|
164
|
+
"required": [
|
|
165
|
+
{"id": "post-detail-blogposting", "route": "blogDetail", "jsonLd": ["BlogPosting"], "ssr": true, "published": false, "publishTranche": "T4b", "why": "글 하나가 답변 엔진에 인용되는 단위. 오늘 라이브다(blog/[slug]/page.tsx:55). §5.1 이 아직 이 규범을 안 적고 있을 뿐이라 published=false 다 — 본보기가 앞서고 레시피가 뒤따르는 방향이라 무해하다."},
|
|
166
|
+
{"id": "post-detail-breadcrumb", "route": "blogDetail", "jsonLd": ["BreadcrumbList"], "ssr": true, "published": true, "llmsMarker": "목록·상세엔 `BreadcrumbList`", "why": "상세 라우트의 경로 표시."},
|
|
167
|
+
{"id": "post-list-ssr", "route": "blogList", "jsonLd": [], "ssr": true, "published": true, "llmsMarker": "**ISR 유지.**", "why": "목록 라우트는 오늘 있다(force-static·revalidate 300)."}
|
|
168
|
+
],
|
|
169
|
+
"planned": [
|
|
170
|
+
{"id": "post-list-itemlist", "route": "blogList", "jsonLd": ["ItemList", "Blog"], "tranche": "T6", "realized": "DONE_DATA_PENDING", "realizedNote": "T6 이 /blog 목록에 BreadcrumbList 를 얹었다. ItemList 는 글이 0건이라 안 나간다 — **의도**다(negative 규율과 같다: 페이지에 없는 것을 JSON-LD 에 쓰지 않는다). 시드가 글을 못 만드는 한 이 항목의 그린은 데이터에 달려 있다.", "why": "목록 라우트는 있는데 그래프가 없다. 상품 목록과 같은 형태의 구멍이라 같이 메우는 것이 맞다."}
|
|
171
|
+
],
|
|
172
|
+
"why": "§4.2 블로그·미디어 칸. 표면은 지금·시드는 나중."
|
|
173
|
+
}
|
|
174
|
+
],
|
|
175
|
+
|
|
176
|
+
"notRegistered": [
|
|
177
|
+
{
|
|
178
|
+
"category": "COMMUNITY",
|
|
179
|
+
"label": "커뮤니티",
|
|
180
|
+
"why": "**등재하지 않는다**(§4.2 DON'T). 게시판 축 자체가 미래탐색이라 보장할 표면도 만들 데이터도 없다. 빈 항목으로 넣으면 이 파일이 '준비 중' 간판을 하나 더 세우는 셈이라, 침묵이 아니라 명시적 부재로 적는다."
|
|
181
|
+
}
|
|
182
|
+
],
|
|
183
|
+
|
|
184
|
+
"catalogRequirements": {
|
|
185
|
+
"$comment": "이 표를 게이트로 쓸 때 **카탈로그 UI 가 반드시 갖춰야 하는 것**(§4.1 W-F4). 계약 파일에 적는 이유: 이 요건이 빠지면 진열 게이트가 조용히 '부드러운 강제'로 변질돼 오너 정정 A(다양성 우선)를 뒤로 어긴다.",
|
|
186
|
+
"categoryAgnosticSurface": "카탈로그 탐색 UI 는 **카테고리 무관 표면(전체 목록·검색)을 반드시 갖는다.** 없으면 무카테고리 = 사실상 비가시가 되고, '어떤 템플릿이든 적재될 수 있다'가 말뿐이 된다.",
|
|
187
|
+
"noDefectWording": "미충족은 **결함이 아니라 다른 칸**이다. 카드 문구에 '~불가'·'미지원' 류 결함 어투를 쓰지 않는다(§3.9·오너 정정 D — 편집 축은 가격 단계이지 손실이 아니다)."
|
|
188
|
+
}
|
|
189
|
+
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 개시된 사이트를 훑는 최소 크롤러 — `snapshot-preview.mjs`(memo116 §3)와
|
|
3
|
+
* `check-aeo-surfaces.mjs`(memo119 T5)가 **같은 사본**을 쓴다.
|
|
4
|
+
*
|
|
5
|
+
* 원래 이 코드는 snapshot-preview 안에 살았다. 보장 검사기가 "산출물을 크롤해 판정한다"(memo119 오너
|
|
6
|
+
* 전제 C)를 구현하려면 같은 훑기가 필요한데, 복사하면 두 크롤러가 조용히 갈라진다 — 한쪽이 라우트 분류를
|
|
7
|
+
* 고치고 다른 쪽이 안 고치면 "미리보기에는 나오는데 검사기는 못 보는 페이지"가 생긴다. 그래서 옮겼다.
|
|
8
|
+
*
|
|
9
|
+
* ── 외부 의존 0 ────────────────────────────────────────────────────────────
|
|
10
|
+
* 이 레포의 팩 스크립트가 zip 을 손으로 쓰는 것과 같은 규율이다. HTML 은 정규식 태그 스캐너로,
|
|
11
|
+
* 다운로드는 Node 내장 `fetch` 로 처리한다. 상대하는 마크업이 **우리가 만든 것**이라(임의 웹이 아니다)
|
|
12
|
+
* 파서를 들일 만큼의 다양성이 없다.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
// ── HTML 태그 스캐너 ─────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
export const TAG_RE = /<([a-zA-Z][-\w:]*)((?:\s+[-\w:@.]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'=<>`]+))?)*)\s*(\/?)>/g;
|
|
18
|
+
const ATTR_RE = /([-\w:@.]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g;
|
|
19
|
+
export const DROP = Symbol("drop-element");
|
|
20
|
+
|
|
21
|
+
export function parseAttrs(text) {
|
|
22
|
+
const attrs = [];
|
|
23
|
+
if (!text) return attrs;
|
|
24
|
+
ATTR_RE.lastIndex = 0;
|
|
25
|
+
let m;
|
|
26
|
+
while ((m = ATTR_RE.exec(text)) !== null) {
|
|
27
|
+
attrs.push([m[1], m[2] ?? m[3] ?? m[4] ?? null]);
|
|
28
|
+
}
|
|
29
|
+
return attrs;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const attrOf = (attrs, name) => {
|
|
33
|
+
const hit = attrs.find(([k]) => k.toLowerCase() === name);
|
|
34
|
+
return hit ? hit[1] : undefined;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* `parseAttrs` 는 원문 조각을 **이스케이프된 그대로** 돌려준다. 여기서 `&` 를 다시 인코딩하면
|
|
39
|
+
* `&` → `&amp;` 가 되어 화면에 `&` 가 그대로 보인다(심의 실측 — alt 텍스트가 깨졌다).
|
|
40
|
+
* 그래서 값은 손대지 않고, 따옴표만 원문에 없던 경우에 대비해 감싼다.
|
|
41
|
+
*/
|
|
42
|
+
export function serializeAttrs(attrs) {
|
|
43
|
+
return attrs.map(([k, v]) => (v === null ? ` ${k}` : ` ${k}="${String(v).replace(/"/g, """)}"`)).join("");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* 태그 단위 변환. `transform` 이 [DROP] 을 돌려주면 그 **여는 태그만** 지운다 — 대상은 `<meta>`·`<link>`
|
|
48
|
+
* 같은 void 요소뿐이라 내용은 애초에 없다.
|
|
49
|
+
*/
|
|
50
|
+
export function rewriteTags(html, transform) {
|
|
51
|
+
return html.replace(TAG_RE, (full, name, attrText, selfClose) => {
|
|
52
|
+
const attrs = parseAttrs(attrText);
|
|
53
|
+
const out = transform(name.toLowerCase(), attrs);
|
|
54
|
+
if (out === DROP) return "";
|
|
55
|
+
if (!out) return full;
|
|
56
|
+
return `<${name}${serializeAttrs(out)}${selfClose ? " /" : ""}>`;
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** HTML 엔티티만 푼다 — 속성값에서 URL 을 꺼낼 때 필요한 최소셋. */
|
|
61
|
+
export const unescapeAttr = (v) =>
|
|
62
|
+
v.replace(/&/g, "&").replace(/"/g, '"').replace(/'/g, "'").replace(/</g, "<").replace(/>/g, ">");
|
|
63
|
+
|
|
64
|
+
// ── 라우트 정책 ──────────────────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
export const segmentsOf = (path) => path.split("/").filter(Boolean);
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* 정적으로 성립할 수 없는 기능 라우트. 세션·쓰기·결제다.
|
|
70
|
+
* validator 의 `SEO_EXCLUDE_SEGMENTS`(ISR 게이트 제외 목록)와 같은 어휘를 쓴다 — 그쪽이
|
|
71
|
+
* "정당하게 동적인 인터랙티브 경로"로 이미 판정해 둔 집합이고, 둘이 갈리면 한쪽이 틀린 것이다.
|
|
72
|
+
*/
|
|
73
|
+
export const FUNCTIONAL_SEGMENTS = new Set([
|
|
74
|
+
"api", "cart", "checkout", "mypage", "account", "login", "auth", "orders", "payment",
|
|
75
|
+
]);
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* 테넌트 **식별정보 표시면**. 미리보기 스냅샷은 이걸 크롤하지 않는다(가릴 것만 많다).
|
|
79
|
+
* 보장 검사기는 다르다 — 아무것도 공개하지 않고 판정만 하므로 여기를 훑어도 유출 표면이 없다.
|
|
80
|
+
* 그래서 이 집합은 상수가 아니라 **호출자가 고르는 값**이다.
|
|
81
|
+
*/
|
|
82
|
+
export const IDENTITY_ROUTES = new Set(["/policies", "/contact"]);
|
|
83
|
+
|
|
84
|
+
/** 페이지가 아닌 기계용 파일. */
|
|
85
|
+
export const MACHINE_FILES = new Set(["/robots.txt", "/sitemap.xml", "/favicon.ico"]);
|
|
86
|
+
|
|
87
|
+
/** 이 확장자는 페이지가 아니라 자산이다(경로만 보고 판정할 때). */
|
|
88
|
+
export const ASSET_EXT = new Set([
|
|
89
|
+
".css", ".js", ".mjs", ".map", ".png", ".jpg", ".jpeg", ".webp", ".gif", ".svg", ".avif",
|
|
90
|
+
".ico", ".woff", ".woff2", ".ttf", ".otf", ".mp4", ".webm", ".json", ".xml", ".txt",
|
|
91
|
+
]);
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* URL 하나를 분류하는 함수를 만든다. `origin` 은 크롤 대상 오리진, `skipRoutes` 는 페이지지만
|
|
95
|
+
* 훑지 않을 경로 집합(미리보기=식별정보 라우트, 검사기=빈 집합).
|
|
96
|
+
*/
|
|
97
|
+
export function makeClassifier({origin, skipRoutes = IDENTITY_ROUTES}) {
|
|
98
|
+
return function classify(rawUrl, fromPath) {
|
|
99
|
+
let u;
|
|
100
|
+
try {
|
|
101
|
+
u = new URL(unescapeAttr(rawUrl), origin + fromPath);
|
|
102
|
+
} catch {
|
|
103
|
+
return {kind: "opaque"};
|
|
104
|
+
}
|
|
105
|
+
if (u.protocol !== "http:" && u.protocol !== "https:") return {kind: "opaque"}; // mailto:·tel:·data:
|
|
106
|
+
if (u.origin !== origin) return {kind: "external", url: u};
|
|
107
|
+
const path = u.pathname;
|
|
108
|
+
if (path.startsWith("/_next/") || path.startsWith("/media/")) return {kind: "asset", url: u};
|
|
109
|
+
if (ASSET_EXT.has(extnameOf(path))) return {kind: "asset", url: u};
|
|
110
|
+
if (MACHINE_FILES.has(path)) return {kind: "blocked", url: u};
|
|
111
|
+
const segs = segmentsOf(path);
|
|
112
|
+
if (segs.some((s) => FUNCTIONAL_SEGMENTS.has(s))) return {kind: "blocked", url: u};
|
|
113
|
+
if (skipRoutes.has(path.replace(/\/+$/, "") || "/")) return {kind: "blocked", url: u};
|
|
114
|
+
return {kind: "page", path: path.replace(/\/+$/, "") || "/"};
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** `node:path` 의 extname 과 같되 소문자로. 경로 문자열만 상대하므로 의존을 안 들인다. */
|
|
119
|
+
function extnameOf(path) {
|
|
120
|
+
const base = path.slice(path.lastIndexOf("/") + 1);
|
|
121
|
+
const dot = base.lastIndexOf(".");
|
|
122
|
+
return dot <= 0 ? "" : base.slice(dot).toLowerCase();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ── 링크 수집 ────────────────────────────────────────────────────────────────
|
|
126
|
+
|
|
127
|
+
const splitSrcset = (v) => (v ? v.split(",").map((p) => p.trim().split(/\s+/)[0]).filter(Boolean) : []);
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* 페이지 HTML 에서 나가는 링크·자산을 수집한다(변형 없음 — 1차 통과).
|
|
131
|
+
* `sink` 는 blocked·external 을 받는 선택 콜백(미리보기 리포트가 쓴다).
|
|
132
|
+
*/
|
|
133
|
+
export function collectLinks(html, fromPath, classify, sink = {}) {
|
|
134
|
+
const links = new Set();
|
|
135
|
+
const assetUrls = new Set();
|
|
136
|
+
const absOrNull = (raw) => {
|
|
137
|
+
if (!raw) return null;
|
|
138
|
+
const c = classify(raw, fromPath);
|
|
139
|
+
return c.kind === "asset" ? c.url.toString() : null;
|
|
140
|
+
};
|
|
141
|
+
rewriteTags(html, (tag, attrs) => {
|
|
142
|
+
const push = (raw) => {
|
|
143
|
+
if (!raw) return;
|
|
144
|
+
const c = classify(raw, fromPath);
|
|
145
|
+
if (c.kind === "page") links.add(c.path);
|
|
146
|
+
else if (c.kind === "asset") assetUrls.add(c.url.toString());
|
|
147
|
+
else if (c.kind === "blocked") sink.blocked?.(c.url.pathname);
|
|
148
|
+
else if (c.kind === "external") sink.external?.(c.url.origin);
|
|
149
|
+
};
|
|
150
|
+
if (tag === "a") push(attrOf(attrs, "href"));
|
|
151
|
+
if (tag === "link") {
|
|
152
|
+
const rel = (attrOf(attrs, "rel") ?? "").toLowerCase();
|
|
153
|
+
if (rel.includes("stylesheet") || rel.includes("icon")) assetUrls.add(absOrNull(attrOf(attrs, "href")));
|
|
154
|
+
}
|
|
155
|
+
if (tag === "img" || tag === "source" || tag === "video" || tag === "audio") {
|
|
156
|
+
const src = absOrNull(attrOf(attrs, "src"));
|
|
157
|
+
if (src) assetUrls.add(src);
|
|
158
|
+
for (const s of splitSrcset(attrOf(attrs, "srcset"))) {
|
|
159
|
+
const abs = absOrNull(s);
|
|
160
|
+
if (abs) assetUrls.add(abs);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return null;
|
|
164
|
+
});
|
|
165
|
+
assetUrls.delete(null);
|
|
166
|
+
return {links, assetUrls};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ── 크롤 ─────────────────────────────────────────────────────────────────────
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* 링크를 따라 페이지 HTML 을 모은다. **JS 를 실행하지 않는다** — 그것이 요점이다: 여기서 보이는
|
|
173
|
+
* 것이 크롤러·답변 엔진이 실제로 받는 것이고, 하이드레이션 뒤에만 나타나는 내용은 보장이 아니다.
|
|
174
|
+
*
|
|
175
|
+
* 반환: `{pages: Map<routePath, html>, assetUrls: Set, fetchFailures: string[], blocked: Set, external: Set}`
|
|
176
|
+
*/
|
|
177
|
+
export async function crawlPages({
|
|
178
|
+
origin,
|
|
179
|
+
entryPath = "/",
|
|
180
|
+
extraRoutes = [],
|
|
181
|
+
maxPages = 60,
|
|
182
|
+
classify,
|
|
183
|
+
userAgent = "zalkera-crawler",
|
|
184
|
+
onPage = null,
|
|
185
|
+
}) {
|
|
186
|
+
const pages = new Map();
|
|
187
|
+
const assetUrls = new Set();
|
|
188
|
+
const fetchFailures = [];
|
|
189
|
+
const blocked = new Set();
|
|
190
|
+
const external = new Set();
|
|
191
|
+
const sink = {blocked: (p) => blocked.add(p), external: (o) => external.add(o)};
|
|
192
|
+
|
|
193
|
+
const queue = [entryPath, ...extraRoutes.map((r) => (r.startsWith("/") ? r : `/${r}`))];
|
|
194
|
+
const seen = new Set();
|
|
195
|
+
|
|
196
|
+
while (queue.length > 0 && pages.size < maxPages) {
|
|
197
|
+
const path = queue.shift();
|
|
198
|
+
if (seen.has(path)) continue;
|
|
199
|
+
seen.add(path);
|
|
200
|
+
|
|
201
|
+
const url = origin + (path === "/" ? "/" : path);
|
|
202
|
+
let res;
|
|
203
|
+
try {
|
|
204
|
+
res = await fetch(url, {headers: {"user-agent": userAgent}});
|
|
205
|
+
} catch (e) {
|
|
206
|
+
fetchFailures.push(`${url} — ${e.message}`);
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
if (!res.ok) {
|
|
210
|
+
fetchFailures.push(`${url} — HTTP ${res.status}`);
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
if (!(res.headers.get("content-type") ?? "").includes("html")) {
|
|
214
|
+
fetchFailures.push(`${url} — HTML 이 아닙니다(${res.headers.get("content-type")})`);
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
const html = await res.text();
|
|
218
|
+
pages.set(path, html);
|
|
219
|
+
onPage?.(path);
|
|
220
|
+
|
|
221
|
+
const collected = collectLinks(html, path, classify, sink);
|
|
222
|
+
for (const l of collected.links) if (!seen.has(l)) queue.push(l);
|
|
223
|
+
for (const a of collected.assetUrls) assetUrls.add(a);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return {pages, assetUrls, fetchFailures, blocked, external};
|
|
227
|
+
}
|
package/llms.txt
CHANGED
|
@@ -407,6 +407,20 @@ if (booking.orderNo) { // status=PENDING
|
|
|
407
407
|
- ❌ **`getMediaUrl` 의 presigned URL 을 마크업·JSON-LD 에 직접 쓰지 마라.** 수 분 뒤 죽는다 — ISR 로 캐시된
|
|
408
408
|
HTML 안에서(상세 revalidate 300s) 서명이 먼저 만료돼 이미지가 깨지고, 크롤러가 캐시하면 JSON-LD 도 죽는다.
|
|
409
409
|
|
|
410
|
+
**이 절의 규범은 기계로 잴 수 있다.** 이 패키지는 판정 잣대인 **보장표**를
|
|
411
|
+
`@zalkera/client/contracts/aeo-surface-guarantees.json` 으로 함께 배송한다(정본은 잘커라 백엔드에 있고 이것은
|
|
412
|
+
그 발행 산출물이다 — 내용이 갈라지지 않게 발행 전 기계가 대조한다). **검사기 자신도 이 패키지가 싣고
|
|
413
|
+
온다**(bin `zalkera-aeo-check`) — 그 표를 읽어 **개시된 사이트를 크롤해** 판정하므로, 소스가 아니라
|
|
414
|
+
산출물이 잣대다. 어떤 스택으로 짰든 같은 조건이다:
|
|
415
|
+
|
|
416
|
+
```bash
|
|
417
|
+
npx zalkera-aeo-check https://개시된사이트 --category BOOKING # 0=통과 · 1=미충족 · 2=실행 불가
|
|
418
|
+
npx zalkera-aeo-check https://개시된사이트 --site-wide-only # 보장 주장이 없는 사이트(사이트 축만)
|
|
419
|
+
# 템플릿에서 출발한 프로젝트라면 같은 검사기를 이렇게도 부른다:
|
|
420
|
+
npm run check:aeo -- https://개시된사이트 --category BOOKING
|
|
421
|
+
npm run check:aeo -- --print-guarantees # 잣대 해석만 확인(크롤 없음)
|
|
422
|
+
```
|
|
423
|
+
|
|
410
424
|
- ❌ 배송 전(PAID)에 후기 작성 시도 → `NOT_DELIVERED_YET`(409). ✅ 주문이 **DELIVERED 이상**일 때만
|
|
411
425
|
버튼을 낸다(상태로 게이트). orderItemId 는 주문 상세 items[].id 에서만 얻는다 — 상품 상세엔 없다.
|
|
412
426
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zalkera/client",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"description": "zalkera 헤드리스 CMS 공개 API 클라이언트 (테넌트 사이트용)",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Credium Co., Ltd.",
|
|
@@ -19,17 +19,26 @@
|
|
|
19
19
|
"homepage": "https://zalkera.com",
|
|
20
20
|
"files": [
|
|
21
21
|
"dist",
|
|
22
|
-
"llms.txt"
|
|
22
|
+
"llms.txt",
|
|
23
|
+
"contracts",
|
|
24
|
+
"bin",
|
|
25
|
+
"lib"
|
|
23
26
|
],
|
|
24
27
|
"main": "./dist/index.cjs",
|
|
25
28
|
"module": "./dist/index.js",
|
|
26
29
|
"types": "./dist/index.d.ts",
|
|
30
|
+
"bin": {
|
|
31
|
+
"zalkera-aeo-check": "./bin/check-aeo-surfaces.mjs"
|
|
32
|
+
},
|
|
27
33
|
"exports": {
|
|
28
34
|
".": {
|
|
29
35
|
"types": "./dist/index.d.ts",
|
|
30
36
|
"import": "./dist/index.js",
|
|
31
37
|
"require": "./dist/index.cjs"
|
|
32
|
-
}
|
|
38
|
+
},
|
|
39
|
+
"./contracts/aeo-surface-guarantees.json": "./contracts/aeo-surface-guarantees.json",
|
|
40
|
+
"./bin/check-aeo-surfaces.mjs": "./bin/check-aeo-surfaces.mjs",
|
|
41
|
+
"./lib/site-crawl.mjs": "./lib/site-crawl.mjs"
|
|
33
42
|
},
|
|
34
43
|
"scripts": {
|
|
35
44
|
"build": "tsup",
|