@gobing-ai/knowledge-kit 0.0.37 → 0.1.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.
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 +477 -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 +202 -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,610 @@
1
+ import { createHash } from 'node:crypto';
2
+
3
+ export interface SeoOptions {
4
+ url: string;
5
+ locale: string;
6
+ business_context: string;
7
+ max_pages: number;
8
+ max_depth: number;
9
+ max_requests: number;
10
+ deadline_seconds: number;
11
+ semantic_pages: number;
12
+ max_pairs: number;
13
+ max_keywords: number;
14
+ max_decision_calls: number;
15
+ performance: boolean;
16
+ visibility_file: string;
17
+ fixture: string;
18
+ allow_test_loopback?: boolean;
19
+ }
20
+
21
+ export interface RunEnvelope<T> {
22
+ schemaVersion: 1;
23
+ methodVersion: string;
24
+ runId: string;
25
+ createdAt: string;
26
+ inputDigest: string;
27
+ predecessorDigests: Record<string, string>;
28
+ status: 'complete' | 'partial';
29
+ payload: T;
30
+ }
31
+
32
+ export interface HeadingItem {
33
+ level: number;
34
+ text: string;
35
+ }
36
+
37
+ export interface ImageItem {
38
+ src: string;
39
+ alt: string | null;
40
+ width: number | null;
41
+ height: number | null;
42
+ }
43
+
44
+ export interface LinkItem {
45
+ href: string;
46
+ text: string;
47
+ rel: string | null;
48
+ isInternal: boolean;
49
+ }
50
+
51
+ export interface HreflangItem {
52
+ lang: string;
53
+ href: string;
54
+ }
55
+
56
+ export interface JsonLdItem {
57
+ raw: string;
58
+ parsed: unknown | null;
59
+ types: string[];
60
+ error: string | null;
61
+ }
62
+
63
+ export interface PageFacts {
64
+ url: string;
65
+ title: string | null;
66
+ titleCount: number;
67
+ metaDescription: string | null;
68
+ metaDescriptionCount: number;
69
+ canonical: string | null;
70
+ robotsDirectives: string[];
71
+ viewport: string | null;
72
+ lang: string | null;
73
+ headings: HeadingItem[];
74
+ h1Count: number;
75
+ images: ImageItem[];
76
+ links: LinkItem[];
77
+ openGraph: Record<string, string>;
78
+ hreflang: HreflangItem[];
79
+ jsonLd: JsonLdItem[];
80
+ wordCount: number;
81
+ textSample: string;
82
+ ttfbMs: number | null;
83
+ htmlByteLength: number;
84
+ isRawHtmlShell: boolean;
85
+ }
86
+
87
+ export interface PageRecord {
88
+ subjectId: string;
89
+ url: string;
90
+ status: number | null;
91
+ availability: 'available' | 'blocked' | 'unavailable';
92
+ redirectChain: string[];
93
+ evidencePath: string | null;
94
+ evidenceHash: string | null;
95
+ facts: PageFacts | null;
96
+ error: string | null;
97
+ }
98
+
99
+ export interface RequestRecord {
100
+ url: string;
101
+ method: string;
102
+ status: number | null;
103
+ durationMs: number;
104
+ bytesReceived: number;
105
+ error: string | null;
106
+ timestamp: string;
107
+ }
108
+
109
+ export interface LimitsRecord {
110
+ pagesVisited: number;
111
+ requestsMade: number;
112
+ totalBytes: number;
113
+ elapsedSeconds: number;
114
+ hitLimit: string | null;
115
+ }
116
+
117
+ export interface CrawlArtifact {
118
+ schemaVersion: 1;
119
+ methodVersion: string;
120
+ runId: string;
121
+ createdAt: string;
122
+ inputDigest: string;
123
+ predecessorDigests: Record<string, string>;
124
+ status: 'complete' | 'partial';
125
+ pages: PageRecord[];
126
+ requests: RequestRecord[];
127
+ limits: LimitsRecord;
128
+ }
129
+
130
+ export interface ManifestArtifact {
131
+ schemaVersion: 1;
132
+ methodVersion: string;
133
+ runId: string;
134
+ createdAt: string;
135
+ inputDigest: string;
136
+ stage: string;
137
+ options: SeoOptions;
138
+ }
139
+
140
+ /** Compute sha256 hex digest of string or buffer. */
141
+ export function sha256(content: string | Buffer): string {
142
+ return createHash('sha256').update(content).digest('hex');
143
+ }
144
+
145
+ /**
146
+ * Return normalized URL while strictly preserving:
147
+ * - pathname (e.g. `/a` and `/a/` remain distinct)
148
+ * - query parameters (sorted or preserved)
149
+ * - fragments are removed
150
+ */
151
+ export function normalizeUrl(rawUrl: string): string {
152
+ const trimmed = rawUrl.trim();
153
+ const hasScheme = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(trimmed);
154
+ const withScheme = hasScheme ? trimmed : `https://${trimmed}`;
155
+ const parsed = new URL(withScheme);
156
+ parsed.hash = '';
157
+ return parsed.toString();
158
+ }
159
+
160
+ /**
161
+ * Derives the permitted site scope:
162
+ * Exactly the normalized submitted hostname plus its exact www toggle.
163
+ * Example:
164
+ * example.com -> { "example.com", "www.example.com" }
165
+ * www.example.com -> { "www.example.com", "example.com" }
166
+ * shop.example.com -> { "shop.example.com", "www.shop.example.com" }
167
+ */
168
+ export function getScopeHostnames(submittedHostname: string): Set<string> {
169
+ const host = submittedHostname.toLowerCase();
170
+ const scope = new Set<string>();
171
+ scope.add(host);
172
+ if (host.startsWith('www.')) {
173
+ scope.add(host.slice(4));
174
+ } else {
175
+ scope.add(`www.${host}`);
176
+ }
177
+ return scope;
178
+ }
179
+
180
+ /**
181
+ * Check if target URL belongs to site scope.
182
+ */
183
+ export function isUrlInScope(targetUrl: string, scopeHostnames: Set<string>): boolean {
184
+ try {
185
+ const parsed = new URL(targetUrl);
186
+ return scopeHostnames.has(parsed.hostname.toLowerCase());
187
+ } catch {
188
+ return false;
189
+ }
190
+ }
191
+
192
+ /** BCP-47 tag validator: simple standard pattern. */
193
+ const BCP47_REGEX = /^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})*$/;
194
+
195
+ /**
196
+ * Strict validator for options JSON.
197
+ * Enforces design §3 caps and types.
198
+ */
199
+ export function validateOptions(raw: unknown): SeoOptions {
200
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
201
+ throw new Error('options must be a JSON object');
202
+ }
203
+ const obj = raw as Record<string, unknown>;
204
+
205
+ if (typeof obj.url !== 'string' || obj.url.trim() === '') {
206
+ throw new Error('options.url must be a non-empty string');
207
+ }
208
+ const rawUrl = obj.url.trim();
209
+ if (rawUrl.includes('\0')) {
210
+ throw new Error('options.url must not contain NUL bytes');
211
+ }
212
+
213
+ // Reject userinfo
214
+ const hasScheme = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(rawUrl);
215
+ const candidate = hasScheme ? rawUrl : `https://${rawUrl}`;
216
+ let parsedUrl: URL;
217
+ try {
218
+ parsedUrl = new URL(candidate);
219
+ } catch {
220
+ throw new Error(`options.url is malformed: ${rawUrl}`);
221
+ }
222
+
223
+ if (parsedUrl.username || parsedUrl.password) {
224
+ throw new Error('options.url must not contain user credentials');
225
+ }
226
+ if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
227
+ throw new Error(`options.url protocol must be http or https, got ${parsedUrl.protocol}`);
228
+ }
229
+ const allowTestLoopback = Boolean(obj.allow_test_loopback);
230
+
231
+ const rawHost = parsedUrl.hostname.toLowerCase().replace(/^\[|\]$/g, '');
232
+ const isLocalAddress =
233
+ rawHost === 'localhost' ||
234
+ rawHost.endsWith('.local') ||
235
+ rawHost.endsWith('.internal') ||
236
+ rawHost === '0.0.0.0' ||
237
+ rawHost === '127.0.0.1' ||
238
+ rawHost === '::1';
239
+
240
+ if (isLocalAddress && !allowTestLoopback) {
241
+ throw new Error(`options.url specifies local/reserved address: ${rawHost}`);
242
+ }
243
+
244
+ if (parsedUrl.port && parsedUrl.port !== '80' && parsedUrl.port !== '443') {
245
+ if (!allowTestLoopback || !isLocalAddress) {
246
+ throw new Error(`options.url has nonstandard port: ${parsedUrl.port}`);
247
+ }
248
+ }
249
+
250
+ const locale = typeof obj.locale === 'string' && obj.locale.trim() !== '' ? obj.locale.trim() : 'en-US';
251
+ if (!BCP47_REGEX.test(locale)) {
252
+ throw new Error(`options.locale must be a valid BCP-47 tag, got "${locale}"`);
253
+ }
254
+
255
+ const businessContext = typeof obj.business_context === 'string' ? obj.business_context : '';
256
+ if (businessContext.length > 2000) {
257
+ throw new Error(`options.business_context exceeds 2000 UTF-16 units (got ${businessContext.length})`);
258
+ }
259
+
260
+ const parseNum = (val: unknown, def: number, min: number, max: number, name: string): number => {
261
+ if (val === undefined || val === null) return def;
262
+ if (typeof val !== 'number' || !Number.isInteger(val) || val < min || val > max) {
263
+ throw new Error(`options.${name} must be integer between ${min} and ${max}, got ${String(val)}`);
264
+ }
265
+ return val;
266
+ };
267
+
268
+ const maxPages = parseNum(obj.max_pages, 60, 1, 200, 'max_pages');
269
+ const maxDepth = parseNum(obj.max_depth, 5, 1, 10, 'max_depth');
270
+ const maxRequests = parseNum(obj.max_requests, 200, 1, 500, 'max_requests');
271
+ const deadlineSeconds = parseNum(obj.deadline_seconds, 600, 10, 1800, 'deadline_seconds');
272
+ const semanticPages = parseNum(obj.semantic_pages, 20, 1, 60, 'semantic_pages');
273
+ const maxPairs = parseNum(obj.max_pairs, 10, 1, 40, 'max_pairs');
274
+ const maxKeywords = parseNum(obj.max_keywords, 10, 1, 30, 'max_keywords');
275
+ const maxDecisionCalls = parseNum(obj.max_decision_calls, 320, 1, 1000, 'max_decision_calls');
276
+
277
+ const performance = typeof obj.performance === 'boolean' ? obj.performance : false;
278
+ const visibilityFile = typeof obj.visibility_file === 'string' ? obj.visibility_file.trim() : '';
279
+ const fixture = typeof obj.fixture === 'string' ? obj.fixture.trim() : '';
280
+
281
+ return {
282
+ url: normalizeUrl(candidate),
283
+ locale,
284
+ business_context: businessContext,
285
+ max_pages: maxPages,
286
+ max_depth: maxDepth,
287
+ max_requests: maxRequests,
288
+ deadline_seconds: deadlineSeconds,
289
+ semantic_pages: semanticPages,
290
+ max_pairs: maxPairs,
291
+ max_keywords: maxKeywords,
292
+ max_decision_calls: maxDecisionCalls,
293
+ performance,
294
+ visibility_file: visibilityFile,
295
+ fixture,
296
+ ...(allowTestLoopback ? { allow_test_loopback: true } : {}),
297
+ };
298
+ }
299
+
300
+ /**
301
+ * Extract HTML facts from HTML string using Bun HTMLRewriter.
302
+ * Buffers text chunks per element.
303
+ */
304
+ export async function extractHtmlFacts(
305
+ html: string,
306
+ options: {
307
+ url: string;
308
+ scopeHostnames: Set<string>;
309
+ ttfbMs?: number | null;
310
+ },
311
+ ): Promise<PageFacts> {
312
+ const pageUrl = options.url;
313
+ const scopeHostnames = options.scopeHostnames;
314
+ let title: string | null = null;
315
+ let titleCount = 0;
316
+ let currentTitleBuffer = '';
317
+
318
+ let metaDescription: string | null = null;
319
+ let metaDescriptionCount = 0;
320
+ let canonical: string | null = null;
321
+ const robotsDirectives: string[] = [];
322
+ let viewport: string | null = null;
323
+ let lang: string | null = null;
324
+
325
+ const headings: HeadingItem[] = [];
326
+ let currentHeadingBuffer = '';
327
+
328
+ const images: ImageItem[] = [];
329
+ const links: LinkItem[] = [];
330
+ const openGraph: Record<string, string> = {};
331
+ const hreflang: HreflangItem[] = [];
332
+
333
+ const jsonLdRaw: string[] = [];
334
+ let currentJsonLdBuffer = '';
335
+ let insideJsonLd = false;
336
+
337
+ // Body text extraction
338
+ let bodyText = '';
339
+ let skipTextDepth = 0;
340
+
341
+ const rewriter = new HTMLRewriter();
342
+
343
+ // html tag lang
344
+ rewriter.on('html', {
345
+ element(el) {
346
+ const l = el.getAttribute('lang');
347
+ if (l && !lang) lang = l.trim();
348
+ },
349
+ });
350
+
351
+ // title tags
352
+ rewriter.on('title', {
353
+ element() {
354
+ titleCount += 1;
355
+ currentTitleBuffer = '';
356
+ },
357
+ text(chunk) {
358
+ currentTitleBuffer += chunk.text;
359
+ if (chunk.lastInTextNode) {
360
+ if (title === null) {
361
+ title = currentTitleBuffer.trim();
362
+ }
363
+ }
364
+ },
365
+ });
366
+
367
+ // meta tags
368
+ rewriter.on('meta', {
369
+ element(el) {
370
+ const name = el.getAttribute('name')?.toLowerCase();
371
+ const property = el.getAttribute('property')?.toLowerCase();
372
+ const httpEquiv = el.getAttribute('http-equiv')?.toLowerCase();
373
+ const content = el.getAttribute('content') ?? '';
374
+
375
+ if (name === 'description') {
376
+ metaDescriptionCount += 1;
377
+ if (metaDescription === null) {
378
+ metaDescription = content.trim();
379
+ }
380
+ } else if (name === 'robots' || httpEquiv === 'robots') {
381
+ for (const part of content.split(',')) {
382
+ const directive = part.trim().toLowerCase();
383
+ if (directive && !robotsDirectives.includes(directive)) {
384
+ robotsDirectives.push(directive);
385
+ }
386
+ }
387
+ } else if (name === 'viewport') {
388
+ if (!viewport) viewport = content.trim();
389
+ }
390
+
391
+ if (property?.startsWith('og:')) {
392
+ openGraph[property] = content;
393
+ }
394
+ },
395
+ });
396
+
397
+ // link tags
398
+ rewriter.on('link', {
399
+ element(el) {
400
+ const rel = el.getAttribute('rel')?.toLowerCase();
401
+ const href = el.getAttribute('href');
402
+ if (!href) return;
403
+
404
+ if (rel === 'canonical') {
405
+ if (!canonical) canonical = href.trim();
406
+ } else if (rel === 'alternate') {
407
+ const hl = el.getAttribute('hreflang');
408
+ if (hl) {
409
+ hreflang.push({ lang: hl.trim(), href: href.trim() });
410
+ }
411
+ }
412
+ },
413
+ });
414
+
415
+ // headings h1, h2, h3
416
+ for (let level = 1; level <= 3; level += 1) {
417
+ const tag = `h${level}`;
418
+ rewriter.on(tag, {
419
+ element() {
420
+ currentHeadingBuffer = '';
421
+ },
422
+ text(chunk) {
423
+ currentHeadingBuffer += chunk.text;
424
+ if (chunk.lastInTextNode) {
425
+ const text = currentHeadingBuffer.trim();
426
+ if (text) {
427
+ headings.push({ level, text });
428
+ }
429
+ }
430
+ },
431
+ });
432
+ }
433
+
434
+ // images
435
+ rewriter.on('img', {
436
+ element(el) {
437
+ const src = el.getAttribute('src');
438
+ if (src) {
439
+ const alt = el.getAttribute('alt');
440
+ const widthAttr = el.getAttribute('width');
441
+ const heightAttr = el.getAttribute('height');
442
+ const width = widthAttr && !Number.isNaN(Number(widthAttr)) ? Number(widthAttr) : null;
443
+ const height = heightAttr && !Number.isNaN(Number(heightAttr)) ? Number(heightAttr) : null;
444
+ images.push({
445
+ src: src.trim(),
446
+ alt: alt !== null ? alt.trim() : null,
447
+ width,
448
+ height,
449
+ });
450
+ }
451
+ },
452
+ });
453
+
454
+ // links <a>
455
+ let currentAnchorHref: string | null = null;
456
+ let currentAnchorRel: string | null = null;
457
+ let currentAnchorText = '';
458
+
459
+ rewriter.on('a', {
460
+ element(el) {
461
+ currentAnchorHref = el.getAttribute('href');
462
+ currentAnchorRel = el.getAttribute('rel');
463
+ currentAnchorText = '';
464
+ },
465
+ text(chunk) {
466
+ currentAnchorText += chunk.text;
467
+ if (chunk.lastInTextNode && currentAnchorHref) {
468
+ const href = currentAnchorHref.trim();
469
+ let isInternal = false;
470
+ try {
471
+ const resolved = new URL(href, pageUrl);
472
+ isInternal = scopeHostnames.has(resolved.hostname.toLowerCase());
473
+ } catch {
474
+ isInternal = false;
475
+ }
476
+ links.push({
477
+ href,
478
+ text: currentAnchorText.trim(),
479
+ rel: currentAnchorRel ? currentAnchorRel.trim() : null,
480
+ isInternal,
481
+ });
482
+ }
483
+ },
484
+ });
485
+
486
+ // json-ld
487
+ rewriter.on('script[type="application/ld+json"]', {
488
+ element() {
489
+ insideJsonLd = true;
490
+ currentJsonLdBuffer = '';
491
+ },
492
+ text(chunk) {
493
+ if (insideJsonLd) {
494
+ currentJsonLdBuffer += chunk.text;
495
+ if (chunk.lastInTextNode) {
496
+ jsonLdRaw.push(currentJsonLdBuffer.trim());
497
+ insideJsonLd = false;
498
+ }
499
+ }
500
+ },
501
+ });
502
+
503
+ // script/style/nav/header/footer exclusion for visible text
504
+ const skipTags = ['script', 'style', 'nav', 'header', 'footer', 'noscript', 'svg'];
505
+ for (const tag of skipTags) {
506
+ rewriter.on(tag, {
507
+ element(el) {
508
+ skipTextDepth += 1;
509
+ el.onEndTag(() => {
510
+ skipTextDepth = Math.max(0, skipTextDepth - 1);
511
+ });
512
+ },
513
+ });
514
+ }
515
+
516
+ rewriter.on('body', {
517
+ text(chunk) {
518
+ if (skipTextDepth === 0) {
519
+ const t = chunk.text.trim();
520
+ if (t) {
521
+ bodyText += ` ${t}`;
522
+ }
523
+ }
524
+ },
525
+ });
526
+
527
+ // Transform HTML
528
+ const response = rewriter.transform(new Response(html));
529
+ await response.text();
530
+
531
+ // Process JSON-LD items
532
+ const jsonLdItems: JsonLdItem[] = [];
533
+ for (const raw of jsonLdRaw) {
534
+ if (!raw) continue;
535
+ try {
536
+ const parsed = JSON.parse(raw);
537
+ const types: string[] = [];
538
+ const collectTypes = (val: unknown): void => {
539
+ if (typeof val === 'object' && val !== null) {
540
+ if (Array.isArray(val)) {
541
+ for (const item of val) collectTypes(item);
542
+ } else {
543
+ const rec = val as Record<string, unknown>;
544
+ if (typeof rec['@type'] === 'string') {
545
+ types.push(rec['@type']);
546
+ } else if (Array.isArray(rec['@type'])) {
547
+ for (const t of rec['@type']) {
548
+ if (typeof t === 'string') types.push(t);
549
+ }
550
+ }
551
+ for (const child of Object.values(rec)) {
552
+ collectTypes(child);
553
+ }
554
+ }
555
+ }
556
+ };
557
+ collectTypes(parsed);
558
+ jsonLdItems.push({
559
+ raw: raw.length > 4096 ? `${raw.slice(0, 4096)}...` : raw,
560
+ parsed,
561
+ types,
562
+ error: null,
563
+ });
564
+ } catch (err: unknown) {
565
+ jsonLdItems.push({
566
+ raw: raw.length > 1024 ? `${raw.slice(0, 1024)}...` : raw,
567
+ parsed: null,
568
+ types: [],
569
+ error: err instanceof Error ? err.message : String(err),
570
+ });
571
+ }
572
+ }
573
+
574
+ const cleanBodyText = bodyText.replace(/\s+/g, ' ').trim();
575
+ const words = cleanBodyText ? cleanBodyText.split(' ').filter(Boolean) : [];
576
+ const wordCount = words.length;
577
+ const textSample = cleanBodyText.slice(0, 500);
578
+
579
+ const h1Count = headings.filter((h) => h.level === 1).length;
580
+
581
+ // Raw HTML shell heuristic:
582
+ // small visible text (<50 words) while html contains root container or multiple scripts
583
+ const hasAppRoot = /id=["'](root|app|__next|__nuxt)["']/.test(html);
584
+ const hasScriptTags = /<script\b[^>]*src=/i.test(html);
585
+ const isRawHtmlShell = wordCount < 50 && (hasAppRoot || hasScriptTags);
586
+
587
+ return {
588
+ url: pageUrl,
589
+ title,
590
+ titleCount,
591
+ metaDescription,
592
+ metaDescriptionCount,
593
+ canonical,
594
+ robotsDirectives,
595
+ viewport,
596
+ lang,
597
+ headings,
598
+ h1Count,
599
+ images,
600
+ links,
601
+ openGraph,
602
+ hreflang,
603
+ jsonLd: jsonLdItems,
604
+ wordCount,
605
+ textSample,
606
+ ttfbMs: options.ttfbMs ?? null,
607
+ htmlByteLength: Buffer.byteLength(html, 'utf-8'),
608
+ isRawHtmlShell,
609
+ };
610
+ }