@gobing-ai/knowledge-kit 0.0.37 → 0.1.1

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.
Files changed (157) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/dist/board/collect/index.css +654 -16
  3. package/dist/board/collect/index.js +191 -64
  4. package/dist/board/daily-voice/index.css +679 -12
  5. package/dist/board/daily-voice/index.js +185 -66
  6. package/dist/board/itc/index.css +679 -13
  7. package/dist/board/itc/index.js +188 -66
  8. package/dist/index.js +2864 -139
  9. package/package.json +3 -2
  10. package/plugins/generations/content-gen/dist/index.js +158 -12
  11. package/plugins/generations/core-facts-gen/dist/index.js +158 -12
  12. package/plugins/generations/daily-article-gen/dist/index.js +158 -12
  13. package/plugins/generations/dailynews-gen/dist/index.js +158 -12
  14. package/plugins/generations/episode-plan-gen/dist/index.js +158 -12
  15. package/plugins/generations/gemini-tts/README.md +4 -3
  16. package/plugins/generations/gemini-tts/dist/index.js +158 -12
  17. package/plugins/generations/image-gen/dist/index.js +158 -12
  18. package/plugins/generations/news-report-gen/dist/index.js +158 -12
  19. package/plugins/generations/omni-voice-gen/README.md +15 -4
  20. package/plugins/generations/omni-voice-gen/profiles.json +1 -2
  21. package/plugins/generations/voice-gen/dist/index.js +158 -12
  22. package/plugins/generations/voxcpm-tts/README.md +21 -1
  23. package/plugins/generations/voxcpm-tts/dist/index.js +173 -13
  24. package/plugins/generations/voxcpm-tts/scripts/audit-speaker-consistency.py +109 -0
  25. package/plugins/generations/voxcpm-tts/src/voxcpm-client.ts +28 -1
  26. package/plugins/ingestions/aihot-ingest/dist/index.js +158 -12
  27. package/plugins/ingestions/horizon-ingest/dist/index.js +158 -12
  28. package/plugins/ingestions/last30days-ingest/dist/index.js +158 -12
  29. package/plugins/ingestions/web-search/dist/index.js +158 -12
  30. package/plugins/kk/commands/workflow-run.md +56 -18
  31. package/plugins/kk/decisions/kk-itc.yaml +46 -0
  32. package/plugins/kk/decisions/kk-seo-keyword.yaml +57 -0
  33. package/plugins/kk/decisions/kk-seo-page.yaml +230 -0
  34. package/plugins/kk/decisions/kk-seo-pair.yaml +21 -0
  35. package/plugins/kk/decisions/kk-seo-site.yaml +90 -0
  36. package/plugins/kk/plugin.json +1 -1
  37. package/plugins/kk/publish-presets/chinese-article.json +14 -0
  38. package/plugins/kk/publish-presets/technical-article.json +14 -0
  39. package/plugins/kk/references/smart-seo-attribution.md +85 -0
  40. package/plugins/kk/scripts/daily-stages.ts +42 -2
  41. package/plugins/kk/scripts/itc-stages.ts +25 -118
  42. package/plugins/kk/scripts/kk-workflow-stages.ts +38 -1
  43. package/plugins/kk/scripts/seo-decisions.ts +491 -0
  44. package/plugins/kk/scripts/seo-enrich.ts +728 -0
  45. package/plugins/kk/scripts/seo-facts.ts +610 -0
  46. package/plugins/kk/scripts/seo-fetch.ts +766 -0
  47. package/plugins/kk/scripts/seo-report.ts +460 -0
  48. package/plugins/kk/scripts/seo-rules.ts +1882 -0
  49. package/plugins/kk/scripts/seo-stages.ts +1044 -0
  50. package/plugins/kk/skills/article-adapt/SKILL.md +2 -2
  51. package/plugins/kk/skills/smart-seo/SKILL.md +183 -0
  52. package/plugins/kk/skills/smart-seo/references/method.md +126 -0
  53. package/plugins/kk/skills/smart-seo/references/narrative.md +125 -0
  54. package/plugins/kk/skills/smart-seo/references/operations.md +209 -0
  55. package/plugins/kk/skills/smart-seo/references/seo-geo-sources.md +90 -0
  56. package/plugins/kk/workflows/feature-verification.yaml +28 -24
  57. package/plugins/kk/workflows/kk-daily-ai-voice2.yaml +85 -17
  58. package/plugins/kk/workflows/kk-itc-author.yaml +406 -0
  59. package/plugins/kk/workflows/kk-itc.yaml +208 -682
  60. package/plugins/kk/workflows/kk-smart-seo.yaml +251 -0
  61. package/plugins/publishings/bilibili-pub/dist/index.js +21782 -0
  62. package/plugins/publishings/bilibili-pub/dist/profile-check.js +20410 -0
  63. package/plugins/publishings/bilibili-pub/package.json +20 -0
  64. package/plugins/publishings/bilibili-pub/plugin.json +12 -0
  65. package/plugins/publishings/bilibili-pub/src/flow.ts +1296 -0
  66. package/plugins/publishings/bilibili-pub/src/index.ts +139 -0
  67. package/plugins/publishings/bilibili-pub/src/profile-check.ts +19 -0
  68. package/plugins/publishings/bilibili-pub/src/selectors.ts +222 -0
  69. package/plugins/publishings/bilibili-pub/tsconfig.json +4 -0
  70. package/plugins/publishings/cnblogs-pub/dist/index.js +21383 -0
  71. package/plugins/publishings/cnblogs-pub/dist/profile-check.js +20412 -0
  72. package/plugins/publishings/cnblogs-pub/package.json +19 -0
  73. package/plugins/publishings/cnblogs-pub/plugin.json +12 -0
  74. package/plugins/publishings/cnblogs-pub/src/flow.ts +708 -0
  75. package/plugins/publishings/cnblogs-pub/src/index.ts +172 -0
  76. package/plugins/publishings/cnblogs-pub/src/profile-check.ts +24 -0
  77. package/plugins/publishings/cnblogs-pub/src/selectors.ts +104 -0
  78. package/plugins/publishings/cnblogs-pub/tsconfig.json +4 -0
  79. package/plugins/publishings/csdn-pub/dist/index.js +21552 -0
  80. package/plugins/publishings/csdn-pub/dist/profile-check.js +20963 -0
  81. package/plugins/publishings/csdn-pub/package.json +19 -0
  82. package/plugins/publishings/csdn-pub/plugin.json +12 -0
  83. package/plugins/publishings/csdn-pub/src/index.ts +1178 -0
  84. package/plugins/publishings/csdn-pub/src/profile-check.ts +23 -0
  85. package/plugins/publishings/csdn-pub/tsconfig.json +4 -0
  86. package/plugins/publishings/emdash-pub/dist/index.js +158 -12
  87. package/plugins/publishings/emdash-pub/plugin.json +22 -1
  88. package/plugins/publishings/infoq-pub/dist/index.js +160 -12
  89. package/plugins/publishings/infoq-pub/dist/profile-check.js +160 -12
  90. package/plugins/publishings/infoq-pub/plugin.json +21 -0
  91. package/plugins/publishings/jike-pub/dist/index.js +21379 -0
  92. package/plugins/publishings/jike-pub/dist/profile-check.js +20951 -0
  93. package/plugins/publishings/jike-pub/package.json +19 -0
  94. package/plugins/publishings/jike-pub/plugin.json +12 -0
  95. package/plugins/publishings/jike-pub/src/index.ts +800 -0
  96. package/plugins/publishings/jike-pub/src/profile-check.ts +21 -0
  97. package/plugins/publishings/jike-pub/tsconfig.json +4 -0
  98. package/plugins/publishings/juejin-pub/dist/index.js +160 -12
  99. package/plugins/publishings/juejin-pub/dist/profile-check.js +160 -12
  100. package/plugins/publishings/juejin-pub/plugin.json +21 -0
  101. package/plugins/publishings/medium-pub/dist/index.js +158 -12
  102. package/plugins/publishings/medium-pub/plugin.json +22 -1
  103. package/plugins/publishings/podcast-pub/dist/index.js +158 -12
  104. package/plugins/publishings/qiita-pub/dist/index.js +158 -12
  105. package/plugins/publishings/qiita-pub/plugin.json +24 -1
  106. package/plugins/publishings/shipinhao-pub/dist/index.js +21715 -0
  107. package/plugins/publishings/shipinhao-pub/dist/profile-check.js +20431 -0
  108. package/plugins/publishings/shipinhao-pub/package.json +19 -0
  109. package/plugins/publishings/shipinhao-pub/plugin.json +12 -0
  110. package/plugins/publishings/shipinhao-pub/src/flow.ts +1323 -0
  111. package/plugins/publishings/shipinhao-pub/src/index.ts +173 -0
  112. package/plugins/publishings/shipinhao-pub/src/profile-check.ts +25 -0
  113. package/plugins/publishings/shipinhao-pub/src/selectors.ts +241 -0
  114. package/plugins/publishings/shipinhao-pub/tsconfig.json +4 -0
  115. package/plugins/publishings/sspai-pub/dist/index.js +21371 -0
  116. package/plugins/publishings/sspai-pub/dist/profile-check.js +20414 -0
  117. package/plugins/publishings/sspai-pub/package.json +19 -0
  118. package/plugins/publishings/sspai-pub/plugin.json +12 -0
  119. package/plugins/publishings/sspai-pub/src/flow.ts +624 -0
  120. package/plugins/publishings/sspai-pub/src/index.ts +167 -0
  121. package/plugins/publishings/sspai-pub/src/profile-check.ts +25 -0
  122. package/plugins/publishings/sspai-pub/src/selectors.ts +122 -0
  123. package/plugins/publishings/sspai-pub/tsconfig.json +4 -0
  124. package/plugins/publishings/substack-pub/dist/index.js +160 -12
  125. package/plugins/publishings/substack-pub/dist/profile-check.js +160 -12
  126. package/plugins/publishings/substack-pub/plugin.json +21 -0
  127. package/plugins/publishings/surfdash-pub/dist/index.js +158 -12
  128. package/plugins/publishings/surfdash-pub/plugin.json +22 -1
  129. package/plugins/publishings/threads-pub/dist/index.js +158 -12
  130. package/plugins/publishings/wechatmp-pub/dist/index.js +1669 -502
  131. package/plugins/publishings/wechatmp-pub/dist/profile-check.js +204 -21
  132. package/plugins/publishings/wechatmp-pub/plugin.json +32 -0
  133. package/plugins/publishings/wechatmp-pub/src/cgi-draft.ts +540 -0
  134. package/plugins/publishings/wechatmp-pub/src/cgi-media.ts +380 -0
  135. package/plugins/publishings/wechatmp-pub/src/cgi.ts +485 -0
  136. package/plugins/publishings/wechatmp-pub/src/flow.ts +104 -0
  137. package/plugins/publishings/weibo-pub/dist/index.js +160 -12
  138. package/plugins/publishings/weibo-pub/dist/profile-check.js +160 -12
  139. package/plugins/publishings/weibo-pub/plugin.json +35 -0
  140. package/plugins/publishings/x-pub/dist/index.js +160 -12
  141. package/plugins/publishings/x-pub/dist/profile-check.js +160 -12
  142. package/plugins/publishings/x-pub/plugin.json +40 -0
  143. package/plugins/publishings/xhs-pub/dist/index.js +160 -12
  144. package/plugins/publishings/xhs-pub/dist/profile-check.js +160 -12
  145. package/plugins/publishings/xhs-pub/plugin.json +37 -0
  146. package/plugins/publishings/xiaoyuzhou-pub/dist/index.js +21491 -0
  147. package/plugins/publishings/xiaoyuzhou-pub/dist/profile-check.js +20417 -0
  148. package/plugins/publishings/xiaoyuzhou-pub/package.json +19 -0
  149. package/plugins/publishings/xiaoyuzhou-pub/plugin.json +12 -0
  150. package/plugins/publishings/xiaoyuzhou-pub/src/flow.ts +826 -0
  151. package/plugins/publishings/xiaoyuzhou-pub/src/index.ts +174 -0
  152. package/plugins/publishings/xiaoyuzhou-pub/src/profile-check.ts +26 -0
  153. package/plugins/publishings/xiaoyuzhou-pub/src/selectors.ts +125 -0
  154. package/plugins/publishings/xiaoyuzhou-pub/tsconfig.json +4 -0
  155. package/plugins/publishings/zenn-pub/dist/index.js +158 -12
  156. package/plugins/publishings/zenn-pub/plugin.json +22 -1
  157. package/plugins/generations/omni-voice-gen/voices/robin-news.wav +0 -0
@@ -0,0 +1,728 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import * as http from 'node:http';
3
+ import * as https from 'node:https';
4
+ import { resolve } from 'node:path';
5
+ import type { CrawlArtifact, SeoOptions } from './seo-facts';
6
+ import {
7
+ assertSeoNotCancelled,
8
+ isPublicAddress,
9
+ onSeoCancel,
10
+ resolveAndPinDns,
11
+ SeoStageCancelledError,
12
+ seoCancelledSignal,
13
+ writeJsonAtomic,
14
+ } from './seo-fetch';
15
+
16
+ /** Fixed PSI lab endpoint (design §8/§13). No other origin may receive the PSI key. */
17
+ export const PSI_ENDPOINT = 'https://www.googleapis.com/pagespeedonline/v5/runPagespeed';
18
+ /** Fixed CrUX field endpoint. No other origin may receive the CrUX key. */
19
+ export const CRUX_ENDPOINT = 'https://chromeuxreport.googleapis.com/v1/records:queryRecord';
20
+
21
+ export const PSI_KEY_ENV = 'PAGESPEED_API_KEY';
22
+ export const CRUX_KEY_ENV = 'CRUX_API_KEY';
23
+
24
+ /** Provider response ceiling per response (design §13). */
25
+ const MAX_PROVIDER_BYTES = 2 * 1024 * 1024;
26
+ /** Stage budget shared by every provider attempt (§8: 300 s enrichment deadline). */
27
+ export const ENRICH_DEADLINE_MS = 300 * 1000;
28
+ /** §13 attempt cap: 3 pages × (2 PSI strategies + CrUX URL + ≤1 origin fallback). */
29
+ export const MAX_ENRICH_ATTEMPTS = 12;
30
+ /** §8: at most 3 deterministically selected pages request measured evidence. */
31
+ export const MAX_PERFORMANCE_PAGES = 3;
32
+ /** §8: field evidence older than 90 days is excluded from recommendations. */
33
+ export const STALE_EVIDENCE_DAYS = 90;
34
+ /** Visibility snapshots: §8 caps. */
35
+ const MAX_VISIBILITY_BYTES = 1024 * 1024;
36
+ const MAX_VISIBILITY_ROWS = 500;
37
+ const MAX_VISIBILITY_STRING = 2048;
38
+
39
+ export type Availability = 'disabled' | 'not-assessed' | 'available' | 'unavailable';
40
+
41
+ export interface LabMetricSet {
42
+ lcpMs: number | null;
43
+ cls: number | null;
44
+ tbtMs: number | null;
45
+ inpMs: number | null;
46
+ fetchTimeMs: number | null;
47
+ }
48
+
49
+ export interface LabEntry {
50
+ url: string;
51
+ strategy: 'mobile' | 'desktop';
52
+ metrics: LabMetricSet;
53
+ }
54
+
55
+ export interface FieldMetricSet {
56
+ lcpMs: number | null;
57
+ inpMs: number | null;
58
+ cls: number | null;
59
+ }
60
+
61
+ export interface FieldEntry {
62
+ url: string;
63
+ /** `url` when the record matched the exact page, `origin` when only the origin record existed. */
64
+ scope: 'url' | 'origin';
65
+ formFactor: 'PHONE';
66
+ collectionWindow: { start: string | null; end: string | null } | null;
67
+ metrics: FieldMetricSet;
68
+ /** All three p75 CWV thresholds met; null when any metric is missing. */
69
+ passedCoreWebVitals: boolean | null;
70
+ reason: string;
71
+ }
72
+
73
+ export interface PerformanceArtifact {
74
+ schemaVersion: 1;
75
+ methodVersion: string;
76
+ runId: string;
77
+ createdAt: string;
78
+ inputDigest: string;
79
+ predecessorDigests: Record<string, string>;
80
+ status: Availability;
81
+ lab: LabEntry[];
82
+ field: FieldEntry[];
83
+ attempts: number;
84
+ /**
85
+ * §13 lab/field separation: lab TBT can never satisfy an INP field requirement, so the two
86
+ * metric sets are reported in distinct collections and never merged.
87
+ */
88
+ labIsFieldAuthority: false;
89
+ unavailable: { url: string; scope: 'lab' | 'field'; reason: string }[];
90
+ }
91
+
92
+ export interface VisibilityRow {
93
+ query: string;
94
+ url: string | null;
95
+ clicks: number | null;
96
+ impressions: number | null;
97
+ position: number | null;
98
+ citations: number | null;
99
+ sampleCount: number | null;
100
+ platform: string | null;
101
+ }
102
+
103
+ export interface VisibilityArtifact {
104
+ schemaVersion: 1;
105
+ methodVersion: string;
106
+ runId: string;
107
+ createdAt: string;
108
+ inputDigest: string;
109
+ predecessorDigests: Record<string, string>;
110
+ status: Availability;
111
+ source: string;
112
+ siteOrigin: string;
113
+ locale: string;
114
+ collectedAt: string;
115
+ /** null when no snapshot was supplied: nothing was measured or estimated. */
116
+ measurementKind: 'measured' | 'estimated' | null;
117
+ /** Retained as evidence but never used to drive recommendations. */
118
+ stale: boolean;
119
+ rows: VisibilityRow[];
120
+ /** Rows excluded from recommendations, with the reason. */
121
+ excluded: { query: string; reason: string }[];
122
+ }
123
+
124
+ /** Injection seams so tests never touch the network or the operator's real keys. */
125
+ export interface EnrichDeps {
126
+ env?: Record<string, string | undefined>;
127
+ now?: Date;
128
+ requestJson?: (url: string, init: { method: 'GET' | 'POST'; body?: string }) => Promise<unknown>;
129
+ /** Test-only endpoint override; the shipped defaults are the two fixed https origins. */
130
+ endpoints?: { psi?: string; crux?: string };
131
+ /**
132
+ * Test-only transport seam. Production always resolves real DNS and rejects non-public
133
+ * addresses; a test injects a loopback resolver to exercise the client against a fixture
134
+ * server (same test-only loopback policy 0281 established for the crawler).
135
+ */
136
+ transport?: ProviderTransportSeam;
137
+ }
138
+
139
+ export interface ProviderTransportSeam {
140
+ allowLoopback?: boolean;
141
+ resolvePinned?: (hostname: string) => Promise<{ ip: string; family: 4 | 6 }>;
142
+ timeoutMs?: number;
143
+ }
144
+
145
+ /**
146
+ * Normalize a provider URL to its origin, or '' when it is malformed.
147
+ */
148
+ function originOf(url: string): string {
149
+ try {
150
+ return new URL(url).origin;
151
+ } catch {
152
+ return '';
153
+ }
154
+ }
155
+
156
+ /**
157
+ * Deterministic page selection: homepage first, then observed order (§8).
158
+ */
159
+ export function selectPerformancePages(crawl: CrawlArtifact, limit = MAX_PERFORMANCE_PAGES): string[] {
160
+ const available = crawl.pages.filter((p) => p.availability === 'available');
161
+ const seen = new Set<string>();
162
+ const ordered: string[] = [];
163
+ for (const page of available) {
164
+ const url = page.url;
165
+ if (!seen.has(url)) {
166
+ seen.add(url);
167
+ ordered.push(url);
168
+ }
169
+ }
170
+ return ordered.slice(0, limit);
171
+ }
172
+
173
+ /** A single pinned GET/POST JSON request used for the two fixed provider endpoints. */
174
+ export async function providerRequestJson(
175
+ url: string,
176
+ init: { method: 'GET' | 'POST'; body?: string },
177
+ timeoutMs: number,
178
+ seam: ProviderTransportSeam = {},
179
+ ): Promise<unknown> {
180
+ const parsed = new URL(url);
181
+ const isHttps = parsed.protocol === 'https:';
182
+ const client = isHttps ? https : http;
183
+ const pinned = seam.resolvePinned
184
+ ? await seam.resolvePinned(parsed.hostname)
185
+ : await resolveAndPinDns(parsed.hostname);
186
+
187
+ return new Promise<unknown>((resolvePromise, rejectPromise) => {
188
+ let settled = false;
189
+ const chunks: Buffer[] = [];
190
+ let bytes = 0;
191
+ let req: http.ClientRequest | null = null;
192
+ let timer: ReturnType<typeof setTimeout> | undefined;
193
+
194
+ const abort = (err: Error): void => {
195
+ if (settled) return;
196
+ settled = true;
197
+ clearTimeout(timer);
198
+ disposeCancel();
199
+ try {
200
+ req?.destroy();
201
+ } catch {
202
+ // ignore
203
+ }
204
+ rejectPromise(err);
205
+ };
206
+
207
+ // Cancellation aborts the in-flight provider socket (design §4).
208
+ const disposeCancel = onSeoCancel((signal) => {
209
+ abort(new SeoStageCancelledError(signal, url));
210
+ });
211
+ const alreadyCancelled = seoCancelledSignal();
212
+ if (alreadyCancelled !== null) {
213
+ abort(new SeoStageCancelledError(alreadyCancelled, url));
214
+ return;
215
+ }
216
+
217
+ timer = setTimeout(() => abort(new Error(`provider request timed out after ${timeoutMs}ms`)), timeoutMs);
218
+
219
+ const lookup = (
220
+ _h: string,
221
+ opts: unknown,
222
+ cb: (err: Error | null, res: unknown, family?: number) => void,
223
+ ): void => {
224
+ const wantsAll = typeof opts === 'object' && opts !== null && (opts as { all?: boolean }).all;
225
+ if (wantsAll) cb(null, [{ address: pinned.ip, family: pinned.family }]);
226
+ else cb(null, pinned.ip, pinned.family);
227
+ };
228
+
229
+ const requestOptions: http.RequestOptions = {
230
+ hostname: parsed.hostname,
231
+ port: parsed.port ? Number(parsed.port) : isHttps ? 443 : 80,
232
+ path: `${parsed.pathname}${parsed.search}`,
233
+ method: init.method,
234
+ headers: {
235
+ Host: parsed.host,
236
+ 'User-Agent': 'KnowledgeKit-SmartSEO/1.0 (+https://gobing.ai)',
237
+ Accept: 'application/json',
238
+ 'Accept-Encoding': 'identity',
239
+ Connection: 'close',
240
+ ...(init.body
241
+ ? { 'Content-Type': 'application/json', 'Content-Length': `${Buffer.byteLength(init.body)}` }
242
+ : {}),
243
+ },
244
+ agent: false,
245
+ lookup,
246
+ timeout: timeoutMs,
247
+ };
248
+ if (isHttps) (requestOptions as https.RequestOptions).servername = parsed.hostname;
249
+
250
+ req = client.request(requestOptions, (res) => {
251
+ // The timer stays armed until 'end': a body that stalls after the headers must still time out.
252
+ // §13: provider redirects are refused rather than followed to another origin.
253
+ if (res.statusCode !== undefined && res.statusCode >= 300 && res.statusCode < 400) {
254
+ abort(new Error(`provider redirect refused (status ${res.statusCode})`));
255
+ return;
256
+ }
257
+ const remote = req?.socket?.remoteAddress ?? null;
258
+ if (!remote || remote !== pinned.ip || !isPublicAddress(remote, seam.allowLoopback)) {
259
+ abort(new Error(`provider peer check failed: ${remote}`));
260
+ return;
261
+ }
262
+ res.on('data', (chunk: Buffer) => {
263
+ bytes += chunk.length;
264
+ if (bytes > MAX_PROVIDER_BYTES) {
265
+ abort(new Error(`provider response exceeded ${MAX_PROVIDER_BYTES} bytes`));
266
+ return;
267
+ }
268
+ chunks.push(chunk);
269
+ });
270
+ res.on('end', () => {
271
+ if (settled) return;
272
+ settled = true;
273
+ disposeCancel();
274
+ clearTimeout(timer);
275
+ const body = Buffer.concat(chunks).toString('utf-8');
276
+ if (res.statusCode !== undefined && res.statusCode !== 200) {
277
+ rejectPromise(new Error(`provider status ${res.statusCode}`));
278
+ return;
279
+ }
280
+ try {
281
+ resolvePromise(JSON.parse(body));
282
+ } catch (err: unknown) {
283
+ rejectPromise(
284
+ new Error(
285
+ `provider response was not JSON: ${err instanceof Error ? err.message : String(err)}`,
286
+ ),
287
+ );
288
+ }
289
+ });
290
+ res.on('error', abort);
291
+ });
292
+
293
+ req.on('error', (err) => {
294
+ clearTimeout(timer);
295
+ abort(err);
296
+ });
297
+ if (init.body) req.write(init.body);
298
+ req.end();
299
+ });
300
+ }
301
+
302
+ function numeric(value: unknown): number | null {
303
+ return typeof value === 'number' && Number.isFinite(value) ? value : null;
304
+ }
305
+
306
+ /**
307
+ * Parse one PSI response into lab metrics. Field data is deliberately ignored here (§13).
308
+ */
309
+ export function parsePsiResponse(payload: unknown, url: string, strategy: 'mobile' | 'desktop'): LabEntry {
310
+ const root = (payload ?? {}) as Record<string, unknown>;
311
+ const lighthouse = (root.lighthouseResult ?? {}) as Record<string, unknown>;
312
+ const audits = (lighthouse.audits ?? {}) as Record<string, Record<string, unknown>>;
313
+ const metric = (id: string): number | null => {
314
+ const audit = audits[id];
315
+ if (!audit || typeof audit !== 'object') return null;
316
+ return numeric(audit.numericValue);
317
+ };
318
+ return {
319
+ url,
320
+ strategy,
321
+ metrics: {
322
+ lcpMs: metric('largest-contentful-paint'),
323
+ cls: metric('cumulative-layout-shift'),
324
+ // TBT is lab-only evidence and is never treated as INP (§13).
325
+ tbtMs: metric('total-blocking-time'),
326
+ inpMs: metric('interaction-to-next-paint'),
327
+ fetchTimeMs: metric('speed-index'),
328
+ },
329
+ };
330
+ }
331
+
332
+ /**
333
+ * Parse one CrUX response into field metrics plus URL/origin scope (§13).
334
+ */
335
+ export function parseCruxResponse(payload: unknown, url: string, requestedScope: 'url' | 'origin'): FieldEntry {
336
+ const root = (payload ?? {}) as Record<string, unknown>;
337
+ const record = (root.record ?? {}) as Record<string, unknown>;
338
+ const metrics = (record.metrics ?? {}) as Record<string, Record<string, unknown>>;
339
+ const p75 = (name: string): number | null => {
340
+ const entry = metrics[name];
341
+ if (!entry || typeof entry !== 'object') return null;
342
+ const percentiles = entry.percentiles as Record<string, unknown> | undefined;
343
+ if (!percentiles || typeof percentiles !== 'object') return null;
344
+ return numeric(percentiles.p75);
345
+ };
346
+
347
+ const lcpMs = p75('largest_contentful_paint');
348
+ const inpMs = p75('interaction_to_next_paint');
349
+ const cls = p75('cumulative_layout_shift');
350
+ // The three thresholds must ALL be present and satisfied; a missing metric is unknown,
351
+ // never a pass and never a zero (§8).
352
+ const passed =
353
+ lcpMs === null || inpMs === null || cls === null ? null : lcpMs <= 2500 && inpMs <= 200 && cls <= 0.1;
354
+
355
+ const window = (record.collectionPeriod ?? null) as Record<string, unknown> | null;
356
+ return {
357
+ url,
358
+ scope: requestedScope,
359
+ formFactor: 'PHONE',
360
+ collectionWindow:
361
+ window && typeof window === 'object'
362
+ ? {
363
+ start: typeof window.firstDate === 'string' ? window.firstDate : null,
364
+ end: typeof window.lastDate === 'string' ? window.lastDate : null,
365
+ }
366
+ : null,
367
+ metrics: { lcpMs, inpMs, cls },
368
+ passedCoreWebVitals: passed,
369
+ reason:
370
+ passed === null
371
+ ? 'field evidence incomplete — one or more p75 metrics missing'
372
+ : passed
373
+ ? 'field p75 LCP/INP/CLS within thresholds'
374
+ : 'field p75 thresholds not met',
375
+ };
376
+ }
377
+
378
+ /**
379
+ * Validate a normalized visibility snapshot (§8). Throws on any contract violation —
380
+ * an invalid supplied snapshot is a required input error, never a silent `unavailable`.
381
+ */
382
+ export function validateVisibilitySnapshot(raw: unknown): {
383
+ siteOrigin: string;
384
+ locale: string;
385
+ source: string;
386
+ collectedAt: string;
387
+ measurementKind: 'measured' | 'estimated';
388
+ rows: VisibilityRow[];
389
+ } {
390
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
391
+ throw new Error('visibility snapshot must be a JSON object');
392
+ }
393
+ const obj = raw as Record<string, unknown>;
394
+ if (obj.schemaVersion !== 1) throw new Error('visibility snapshot schemaVersion must be 1');
395
+
396
+ const required = (key: string): string => {
397
+ const value = obj[key];
398
+ if (typeof value !== 'string' || value.trim() === '') {
399
+ throw new Error(`visibility snapshot is missing a non-empty "${key}"`);
400
+ }
401
+ if (value.length > MAX_VISIBILITY_STRING) {
402
+ throw new Error(`visibility snapshot "${key}" exceeds ${MAX_VISIBILITY_STRING} characters`);
403
+ }
404
+ return value.trim();
405
+ };
406
+
407
+ const siteOrigin = required('siteOrigin');
408
+ try {
409
+ const parsedOrigin = new URL(siteOrigin);
410
+ if (parsedOrigin.origin !== siteOrigin) {
411
+ throw new Error('not a bare origin');
412
+ }
413
+ } catch {
414
+ throw new Error(`visibility snapshot siteOrigin is not an origin: ${siteOrigin}`);
415
+ }
416
+ const locale = required('locale');
417
+ const source = required('source');
418
+ const collectedAt = required('collectedAt');
419
+ const collectedMs = Date.parse(collectedAt);
420
+ if (Number.isNaN(collectedMs)) {
421
+ throw new Error(`visibility snapshot collectedAt is not a valid instant: ${collectedAt}`);
422
+ }
423
+ const kind = obj.measurementKind;
424
+ if (kind !== 'measured' && kind !== 'estimated') {
425
+ throw new Error(`visibility snapshot measurementKind must be measured|estimated, got ${String(kind)}`);
426
+ }
427
+ const rawRows = obj.rows;
428
+ if (!Array.isArray(rawRows)) throw new Error('visibility snapshot rows must be an array');
429
+ if (rawRows.length > MAX_VISIBILITY_ROWS) {
430
+ throw new Error(`visibility snapshot has ${rawRows.length} rows (max ${MAX_VISIBILITY_ROWS})`);
431
+ }
432
+
433
+ const inScope = (candidate: string): boolean => {
434
+ try {
435
+ return new URL(candidate).origin === siteOrigin;
436
+ } catch {
437
+ return false;
438
+ }
439
+ };
440
+ const count = (value: unknown, key: string): number | null => {
441
+ if (value === undefined || value === null) return null;
442
+ if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) {
443
+ throw new Error(`visibility row "${key}" must be a nonnegative integer, got ${String(value)}`);
444
+ }
445
+ return value;
446
+ };
447
+
448
+ const rows: VisibilityRow[] = rawRows.map((entry, index) => {
449
+ if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
450
+ throw new Error(`visibility row ${index} must be an object`);
451
+ }
452
+ const row = entry as Record<string, unknown>;
453
+ const query = row.query;
454
+ if (typeof query !== 'string' || query.trim() === '') {
455
+ throw new Error(`visibility row ${index} is missing a non-empty "query"`);
456
+ }
457
+ if (query.length > MAX_VISIBILITY_STRING) {
458
+ throw new Error(`visibility row ${index} query exceeds ${MAX_VISIBILITY_STRING} characters`);
459
+ }
460
+ let url: string | null = null;
461
+ if (row.url !== undefined && row.url !== null) {
462
+ if (typeof row.url !== 'string') throw new Error(`visibility row ${index} "url" must be a string`);
463
+ if (!inScope(row.url)) {
464
+ throw new Error(`visibility row ${index} url is outside the declared site origin: ${row.url}`);
465
+ }
466
+ url = row.url;
467
+ }
468
+ let position: number | null = null;
469
+ if (row.position !== undefined && row.position !== null) {
470
+ if (typeof row.position !== 'number' || !Number.isFinite(row.position) || row.position < 1) {
471
+ throw new Error(`visibility row ${index} "position" must be >= 1, got ${String(row.position)}`);
472
+ }
473
+ position = row.position;
474
+ }
475
+ const sampleCount = count(row.sampleCount, 'sampleCount');
476
+ let citations: number | null = null;
477
+ if (row.citations !== undefined && row.citations !== null) {
478
+ citations = count(row.citations, 'citations');
479
+ const platform = row.platform;
480
+ if (typeof platform !== 'string' || platform.trim() === '') {
481
+ throw new Error(`visibility row ${index} citations require a "platform"`);
482
+ }
483
+ if (sampleCount === null) {
484
+ throw new Error(`visibility row ${index} citations require a "sampleCount" denominator`);
485
+ }
486
+ if (citations !== null && citations > sampleCount) {
487
+ throw new Error(`visibility row ${index} citations (${citations}) exceed sampleCount (${sampleCount})`);
488
+ }
489
+ }
490
+ return {
491
+ query: query.trim(),
492
+ url,
493
+ clicks: count(row.clicks, 'clicks'),
494
+ impressions: count(row.impressions, 'impressions'),
495
+ position,
496
+ citations,
497
+ sampleCount,
498
+ platform: typeof row.platform === 'string' ? row.platform.trim() : null,
499
+ };
500
+ });
501
+
502
+ return { siteOrigin, locale, source, collectedAt, measurementKind: kind, rows };
503
+ }
504
+
505
+ /**
506
+ * Build the visibility artifact, applying the future/stale dispositions (§8/§13).
507
+ */
508
+ export function buildVisibilityArtifact(
509
+ input: {
510
+ siteOrigin: string;
511
+ locale: string;
512
+ source: string;
513
+ collectedAt: string;
514
+ measurementKind: 'measured' | 'estimated';
515
+ rows: VisibilityRow[];
516
+ },
517
+ runId: string,
518
+ now: Date,
519
+ ): VisibilityArtifact {
520
+ const collectedMs = Date.parse(input.collectedAt);
521
+ if (collectedMs > now.getTime()) {
522
+ throw new Error(`visibility snapshot collectedAt is in the future: ${input.collectedAt}`);
523
+ }
524
+ const ageDays = (now.getTime() - collectedMs) / 86_400_000;
525
+ const stale = ageDays > STALE_EVIDENCE_DAYS;
526
+ const excluded = stale
527
+ ? input.rows.map((row) => ({
528
+ query: row.query,
529
+ reason: `evidence collected ${Math.floor(ageDays)} days ago (stale > ${STALE_EVIDENCE_DAYS} days)`,
530
+ }))
531
+ : [];
532
+ return {
533
+ schemaVersion: 1,
534
+ methodVersion: '1.0.0',
535
+ runId,
536
+ createdAt: now.toISOString(),
537
+ inputDigest: '',
538
+ predecessorDigests: {},
539
+ status: 'available',
540
+ source: input.source,
541
+ siteOrigin: input.siteOrigin,
542
+ locale: input.locale,
543
+ collectedAt: input.collectedAt,
544
+ measurementKind: input.measurementKind,
545
+ stale,
546
+ rows: input.rows,
547
+ excluded,
548
+ };
549
+ }
550
+
551
+ /**
552
+ * `seo-enrich <work_dir>` — optional measured evidence; disabled mode makes no provider request.
553
+ */
554
+ export async function runSeoEnrich(workDir: string, deps: EnrichDeps = {}): Promise<void> {
555
+ const optionsPath = resolve(workDir, 'options.json');
556
+ const crawlPath = resolve(workDir, 'crawl.json');
557
+ const optionsRaw = readFileSync(optionsPath, 'utf-8');
558
+ const crawlRaw = readFileSync(crawlPath, 'utf-8');
559
+ const options: SeoOptions = JSON.parse(optionsRaw);
560
+ const crawl: CrawlArtifact = JSON.parse(crawlRaw);
561
+ const env = deps.env ?? (process.env as Record<string, string | undefined>);
562
+ const now = deps.now ?? new Date();
563
+ const psiEndpoint = deps.endpoints?.psi ?? PSI_ENDPOINT;
564
+ const cruxEndpoint = deps.endpoints?.crux ?? CRUX_ENDPOINT;
565
+ const transport = deps.transport ?? {};
566
+ const requestJson =
567
+ deps.requestJson ??
568
+ ((url: string, init: { method: 'GET' | 'POST'; body?: string }) =>
569
+ providerRequestJson(url, init, transport.timeoutMs ?? ENRICH_DEADLINE_MS, transport));
570
+
571
+ const base = {
572
+ schemaVersion: 1 as const,
573
+ methodVersion: '1.0.0',
574
+ runId: crawl.runId,
575
+ createdAt: now.toISOString(),
576
+ inputDigest: crawl.inputDigest,
577
+ predecessorDigests: {
578
+ ...crawl.predecessorDigests,
579
+ 'crawl.json': crawl.predecessorDigests['crawl.json'] ?? '',
580
+ },
581
+ unavailable: [] as { url: string; scope: 'lab' | 'field'; reason: string }[],
582
+ };
583
+
584
+ // ── performance: disabled by default, so the default path issues no provider request ──
585
+ let performance: PerformanceArtifact;
586
+ if (!options.performance) {
587
+ performance = {
588
+ ...base,
589
+ status: 'disabled',
590
+ lab: [],
591
+ field: [],
592
+ attempts: 0,
593
+ labIsFieldAuthority: false,
594
+ unavailable: [],
595
+ };
596
+ } else {
597
+ const psiKey = env[PSI_KEY_ENV];
598
+ const cruxKey = env[CRUX_KEY_ENV];
599
+ const pages = selectPerformancePages(crawl);
600
+ // Monotonic budget: the 300 s enrichment deadline is measured from stage start, never from
601
+ // the (possibly injected) wall clock, so a supplied `now` cannot pre-exhaust the budget.
602
+ const startedAt = Date.now();
603
+ const lab: LabEntry[] = [];
604
+ const field: FieldEntry[] = [];
605
+ const unavailable: { url: string; scope: 'lab' | 'field'; reason: string }[] = [];
606
+ let attempts = 0;
607
+
608
+ const budgetLeft = (): boolean =>
609
+ attempts < MAX_ENRICH_ATTEMPTS &&
610
+ Date.now() - startedAt < ENRICH_DEADLINE_MS &&
611
+ seoCancelledSignal() === null;
612
+
613
+ for (const pageUrl of pages) {
614
+ // Cancellation stops new provider dispatch (design §4).
615
+ assertSeoNotCancelled();
616
+ if (psiKey) {
617
+ for (const strategy of ['mobile', 'desktop'] as const) {
618
+ if (!budgetLeft()) break;
619
+ attempts += 1;
620
+ const target = `${psiEndpoint}?url=${encodeURIComponent(pageUrl)}&strategy=${strategy}&key=${psiKey}`;
621
+ try {
622
+ lab.push(parsePsiResponse(await requestJson(target, { method: 'GET' }), pageUrl, strategy));
623
+ } catch (err: unknown) {
624
+ unavailable.push({
625
+ url: pageUrl,
626
+ scope: 'lab',
627
+ reason: err instanceof Error ? err.message : String(err),
628
+ });
629
+ }
630
+ }
631
+ } else {
632
+ unavailable.push({ url: pageUrl, scope: 'lab', reason: `${PSI_KEY_ENV} is not configured` });
633
+ }
634
+
635
+ if (cruxKey && budgetLeft()) {
636
+ attempts += 1;
637
+ try {
638
+ const record = await requestJson(cruxEndpoint, {
639
+ method: 'POST',
640
+ body: JSON.stringify({ url: pageUrl, formFactor: 'PHONE', key: cruxKey }),
641
+ });
642
+ field.push(parseCruxResponse(record, pageUrl, 'url'));
643
+ } catch (err: unknown) {
644
+ const reason = err instanceof Error ? err.message : String(err);
645
+ // §13: the origin fallback is attempted only for an unavailable URL record,
646
+ // and never substitutes a passing origin result for a failed URL result.
647
+ const origin = originOf(pageUrl);
648
+ if (origin && budgetLeft()) {
649
+ attempts += 1;
650
+ try {
651
+ const record = await requestJson(cruxEndpoint, {
652
+ method: 'POST',
653
+ body: JSON.stringify({ origin, formFactor: 'PHONE', key: cruxKey }),
654
+ });
655
+ field.push(parseCruxResponse(record, pageUrl, 'origin'));
656
+ } catch (originErr: unknown) {
657
+ unavailable.push({
658
+ url: pageUrl,
659
+ scope: 'field',
660
+ reason: `${reason}; origin fallback failed: ${originErr instanceof Error ? originErr.message : String(originErr)}`,
661
+ });
662
+ }
663
+ } else {
664
+ unavailable.push({ url: pageUrl, scope: 'field', reason });
665
+ }
666
+ }
667
+ } else if (!cruxKey) {
668
+ unavailable.push({ url: pageUrl, scope: 'field', reason: `${CRUX_KEY_ENV} is not configured` });
669
+ }
670
+ }
671
+
672
+ const anyEvidence = lab.length > 0 || field.length > 0;
673
+ performance = {
674
+ ...base,
675
+ status: anyEvidence ? 'available' : 'unavailable',
676
+ lab,
677
+ field,
678
+ attempts,
679
+ labIsFieldAuthority: false,
680
+ unavailable,
681
+ };
682
+ }
683
+ writeJsonAtomic(resolve(workDir, 'performance.json'), performance);
684
+
685
+ // ── visibility: explicit operator input, validated before it can drive keywords ──
686
+ let visibility: VisibilityArtifact;
687
+ if (!options.visibility_file || options.visibility_file.trim() === '') {
688
+ visibility = {
689
+ ...base,
690
+ status: 'not-assessed',
691
+ source: '',
692
+ siteOrigin: new URL(options.url).origin,
693
+ locale: options.locale,
694
+ collectedAt: '',
695
+ measurementKind: null,
696
+ stale: false,
697
+ rows: [],
698
+ excluded: [],
699
+ };
700
+ } else {
701
+ const visibilityPath = resolve(options.visibility_file);
702
+ if (!existsSync(visibilityPath)) {
703
+ throw new Error(`seo-enrich: visibility_file not found at ${visibilityPath}`);
704
+ }
705
+ const raw = readFileSync(visibilityPath, 'utf-8');
706
+ if (Buffer.byteLength(raw, 'utf-8') > MAX_VISIBILITY_BYTES) {
707
+ throw new Error(`seo-enrich: visibility snapshot exceeds ${MAX_VISIBILITY_BYTES} bytes`);
708
+ }
709
+ const validated = validateVisibilitySnapshot(JSON.parse(raw));
710
+ const expectedOrigin = new URL(options.url).origin;
711
+ if (validated.siteOrigin !== expectedOrigin) {
712
+ throw new Error(
713
+ `seo-enrich: visibility snapshot siteOrigin ${validated.siteOrigin} does not match the audit scope ${expectedOrigin}`,
714
+ );
715
+ }
716
+ if (validated.locale !== options.locale) {
717
+ throw new Error(
718
+ `seo-enrich: visibility snapshot locale ${validated.locale} does not match the audit locale ${options.locale}`,
719
+ );
720
+ }
721
+ visibility = {
722
+ ...buildVisibilityArtifact(validated, crawl.runId, now),
723
+ inputDigest: crawl.inputDigest,
724
+ predecessorDigests: base.predecessorDigests,
725
+ };
726
+ }
727
+ writeJsonAtomic(resolve(workDir, 'visibility.json'), visibility);
728
+ }