@zalkera/client 0.11.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 +5 -2
- package/bin/check-aeo-surfaces.mjs +706 -0
- package/lib/site-crawl.mjs +227 -0
- package/llms.txt +7 -5
- package/package.json +10 -3
package/README.md
CHANGED
|
@@ -65,8 +65,11 @@ await zalkera.submitInquiry({name, email, subject, message}, {clientIp});
|
|
|
65
65
|
`node_modules/@zalkera/client/contracts/aeo-surface-guarantees.json`
|
|
66
66
|
패키지 하위 경로로도 열린다 — `require("@zalkera/client/contracts/aeo-surface-guarantees.json")`,
|
|
67
67
|
ESM 은 `import … with { type: "json" }`(실측 확인).
|
|
68
|
-
- 이 표를 읽어 **개시된 사이트를 크롤해** 판정하는
|
|
69
|
-
`
|
|
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 다.
|
|
70
73
|
- 정본은 잘커라 백엔드에 있고 여기 실린 것은 그 **발행 산출물**이다(발행 전 기계 대조).
|
|
71
74
|
|
|
72
75
|
## API 스펙 (메서드)
|
|
@@ -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,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
|
@@ -409,13 +409,15 @@ if (booking.orderNo) { // status=PENDING
|
|
|
409
409
|
|
|
410
410
|
**이 절의 규범은 기계로 잴 수 있다.** 이 패키지는 판정 잣대인 **보장표**를
|
|
411
411
|
`@zalkera/client/contracts/aeo-surface-guarantees.json` 으로 함께 배송한다(정본은 잘커라 백엔드에 있고 이것은
|
|
412
|
-
그 발행 산출물이다 — 내용이 갈라지지 않게 발행 전 기계가 대조한다).
|
|
413
|
-
`
|
|
414
|
-
산출물이
|
|
412
|
+
그 발행 산출물이다 — 내용이 갈라지지 않게 발행 전 기계가 대조한다). **검사기 자신도 이 패키지가 싣고
|
|
413
|
+
온다**(bin `zalkera-aeo-check`) — 그 표를 읽어 **개시된 사이트를 크롤해** 판정하므로, 소스가 아니라
|
|
414
|
+
산출물이 잣대다. 어떤 스택으로 짰든 같은 조건이다:
|
|
415
415
|
|
|
416
416
|
```bash
|
|
417
|
-
#
|
|
418
|
-
|
|
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
|
|
419
421
|
npm run check:aeo -- --print-guarantees # 잣대 해석만 확인(크롤 없음)
|
|
420
422
|
```
|
|
421
423
|
|
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.",
|
|
@@ -20,18 +20,25 @@
|
|
|
20
20
|
"files": [
|
|
21
21
|
"dist",
|
|
22
22
|
"llms.txt",
|
|
23
|
-
"contracts"
|
|
23
|
+
"contracts",
|
|
24
|
+
"bin",
|
|
25
|
+
"lib"
|
|
24
26
|
],
|
|
25
27
|
"main": "./dist/index.cjs",
|
|
26
28
|
"module": "./dist/index.js",
|
|
27
29
|
"types": "./dist/index.d.ts",
|
|
30
|
+
"bin": {
|
|
31
|
+
"zalkera-aeo-check": "./bin/check-aeo-surfaces.mjs"
|
|
32
|
+
},
|
|
28
33
|
"exports": {
|
|
29
34
|
".": {
|
|
30
35
|
"types": "./dist/index.d.ts",
|
|
31
36
|
"import": "./dist/index.js",
|
|
32
37
|
"require": "./dist/index.cjs"
|
|
33
38
|
},
|
|
34
|
-
"./contracts/aeo-surface-guarantees.json": "./contracts/aeo-surface-guarantees.json"
|
|
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"
|
|
35
42
|
},
|
|
36
43
|
"scripts": {
|
|
37
44
|
"build": "tsup",
|