@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,766 @@
1
+ import * as dns from 'node:dns/promises';
2
+ import { existsSync, mkdirSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
3
+ import * as http from 'node:http';
4
+ import * as https from 'node:https';
5
+ import { dirname } from 'node:path';
6
+ import { normalizeUrl, sha256 } from './seo-facts';
7
+
8
+ /* ------------------------------------------------------------------ *
9
+ * Stage cancellation (design §4)
10
+ *
11
+ * A cancelled audit must abort its active sockets and child processes,
12
+ * dispatch no further request or decision, and preserve the artifacts it
13
+ * already wrote. The registry lives here because this module is the shared
14
+ * leaf every SEO stage imports — no cycle, one cancellation surface.
15
+ * ------------------------------------------------------------------ */
16
+
17
+ /** Signals that cancel a running SEO stage (a host interrupt or engine cancel). */
18
+ export const SEO_CANCEL_SIGNALS = ['SIGINT', 'SIGTERM', 'SIGHUP'] as const;
19
+
20
+ /** Raised when a stage observes cancellation; callers stop dispatching new work. */
21
+ export class SeoStageCancelledError extends Error {
22
+ readonly signal: NodeJS.Signals;
23
+ constructor(signal: NodeJS.Signals, detail = '') {
24
+ super(detail === '' ? `seo stage cancelled by ${signal}` : `seo stage cancelled by ${signal}: ${detail}`);
25
+ this.name = 'SeoStageCancelledError';
26
+ this.signal = signal;
27
+ }
28
+ }
29
+
30
+ type SeoCancelListener = (signal: NodeJS.Signals) => void;
31
+
32
+ let seoCancelledBy: NodeJS.Signals | null = null;
33
+ let seoHandlersInstalled = false;
34
+ const seoCancelListeners = new Set<SeoCancelListener>();
35
+ const seoSignalHandlers = new Map<NodeJS.Signals, () => void>();
36
+
37
+ /** The signal that cancelled this process, or null while it is still live. */
38
+ export function seoCancelledSignal(): NodeJS.Signals | null {
39
+ return seoCancelledBy;
40
+ }
41
+
42
+ /** Conventional shell exit status for a signal-terminated process. */
43
+ export function seoSignalExitCode(signal: NodeJS.Signals): number {
44
+ if (signal === 'SIGINT') return 130;
45
+ if (signal === 'SIGTERM') return 143;
46
+ if (signal === 'SIGHUP') return 129;
47
+ return 1;
48
+ }
49
+
50
+ /** Install the process handlers once; every later call only registers a listener. */
51
+ function ensureSeoCancelHandlers(): void {
52
+ if (seoHandlersInstalled) return;
53
+ seoHandlersInstalled = true;
54
+ for (const signal of SEO_CANCEL_SIGNALS) {
55
+ const handler = (): void => {
56
+ if (seoCancelledBy !== null) return; // first signal wins; a second never re-enters work
57
+ seoCancelledBy = signal;
58
+ for (const listener of [...seoCancelListeners]) {
59
+ try {
60
+ listener(signal);
61
+ } catch {
62
+ // a listener failure must not block the remaining cancellations
63
+ }
64
+ }
65
+ };
66
+ seoSignalHandlers.set(signal, handler);
67
+ process.on(signal, handler);
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Observe cancellation. Returns a disposer that removes the listener (and, when
73
+ * no listener remains, the process signal handlers) so a long-lived process does
74
+ * not accumulate handlers across stages.
75
+ */
76
+ export function onSeoCancel(listener: SeoCancelListener): () => void {
77
+ ensureSeoCancelHandlers();
78
+ seoCancelListeners.add(listener);
79
+ return () => {
80
+ seoCancelListeners.delete(listener);
81
+ if (seoCancelListeners.size === 0) {
82
+ for (const [signal, handler] of seoSignalHandlers) process.removeListener(signal, handler);
83
+ seoSignalHandlers.clear();
84
+ seoHandlersInstalled = false;
85
+ }
86
+ };
87
+ }
88
+
89
+ /** Throw when the process has been cancelled — the check every bounded loop makes. */
90
+ export function assertSeoNotCancelled(): void {
91
+ if (seoCancelledBy !== null) throw new SeoStageCancelledError(seoCancelledBy);
92
+ }
93
+
94
+ /**
95
+ * Keep cancellation observable for a whole stage, so a signal that lands between two
96
+ * bounded steps is still recorded instead of killing the process by default action.
97
+ * Returns a disposer; the shipped stages are one-shot processes and may keep it.
98
+ */
99
+ export function installSeoStageCancellation(): () => void {
100
+ return onSeoCancel(() => {
101
+ // State is recorded by the signal handler; in-flight requests have their own listeners.
102
+ });
103
+ }
104
+
105
+ /** Reset process-wide cancellation state. Tests only — a cancelled process never resumes. */
106
+ export function resetSeoStageCancellation(): void {
107
+ for (const [signal, handler] of seoSignalHandlers) process.removeListener(signal, handler);
108
+ seoSignalHandlers.clear();
109
+ seoCancelListeners.clear();
110
+ seoHandlersInstalled = false;
111
+ seoCancelledBy = null;
112
+ }
113
+
114
+ export interface TransportOptions {
115
+ allowLoopback?: boolean;
116
+ ca?: string | Buffer;
117
+ timeoutMs?: number;
118
+ maxBytes?: number;
119
+ }
120
+
121
+ export interface FetchResult {
122
+ url: string;
123
+ finalUrl: string;
124
+ status: number;
125
+ headers: Record<string, string>;
126
+ body: string;
127
+ bytesReceived: number;
128
+ ttfbMs: number;
129
+ redirectChain: string[];
130
+ peerAddress: string | null;
131
+ }
132
+
133
+ /** Check if string is IPv4 */
134
+ export function isIPv4(ip: string): boolean {
135
+ const parts = ip.split('.');
136
+ if (parts.length !== 4) return false;
137
+ for (const p of parts) {
138
+ if (!/^\d{1,3}$/.test(p)) return false;
139
+ const n = Number(p);
140
+ if (n < 0 || n > 255) return false;
141
+ if (p.length > 1 && p.startsWith('0')) return false; // no octal
142
+ }
143
+ return true;
144
+ }
145
+
146
+ /**
147
+ * Check if an IPv4 address is in non-public / private / reserved / loopback / multicast ranges.
148
+ */
149
+ export function isPrivateIPv4(ip: string): boolean {
150
+ const parts = ip.split('.').map(Number);
151
+ const [b0, b1] = parts;
152
+ if (b0 === undefined || b1 === undefined) return true;
153
+
154
+ // 0.0.0.0/8 (Current network)
155
+ if (b0 === 0) return true;
156
+ // 10.0.0.0/8 (Private)
157
+ if (b0 === 10) return true;
158
+ // 100.64.0.0/10 (Shared / Carrier-grade NAT)
159
+ if (b0 === 100 && b1 >= 64 && b1 <= 127) return true;
160
+ // 127.0.0.0/8 (Loopback)
161
+ if (b0 === 127) return true;
162
+ // 169.254.0.0/16 (Link-local)
163
+ if (b0 === 169 && b1 === 254) return true;
164
+ // 172.16.0.0/12 (Private)
165
+ if (b0 === 172 && b1 >= 16 && b1 <= 31) return true;
166
+ // 192.0.0.0/24 (IETF Protocol Assignments)
167
+ if (b0 === 192 && b1 === 0 && parts[2] === 0) return true;
168
+ // 192.0.2.0/24 (TEST-NET-1)
169
+ if (b0 === 192 && b1 === 0 && parts[2] === 2) return true;
170
+ // 192.88.99.0/24 (6to4 Relay)
171
+ if (b0 === 192 && b1 === 88 && parts[2] === 99) return true;
172
+ // 192.168.0.0/16 (Private)
173
+ if (b0 === 192 && b1 === 168) return true;
174
+ // 198.18.0.0/15 (Benchmarking)
175
+ if (b0 === 198 && (b1 === 18 || b1 === 19)) return true;
176
+ // 198.51.100.0/24 (TEST-NET-2)
177
+ if (b0 === 198 && b1 === 51 && parts[2] === 100) return true;
178
+ // 203.0.113.0/24 (TEST-NET-3)
179
+ if (b0 === 203 && b1 === 0 && parts[2] === 113) return true;
180
+ // 224.0.0.0/4 (Multicast) & 240.0.0.0/4 (Reserved / Broadcast)
181
+ if (b0 >= 224) return true;
182
+
183
+ return false;
184
+ }
185
+
186
+ /** Check if IPv6 address is private / reserved / loopback / mapped IPv4 */
187
+ export function isPrivateIPv6(ip: string): boolean {
188
+ const normalized = ip.toLowerCase();
189
+ // Loopback
190
+ if (normalized === '::1' || normalized === '0:0:0:0:0:0:0:1') return true;
191
+ // Unspecified
192
+ if (normalized === '::' || normalized === '0:0:0:0:0:0:0:0') return true;
193
+
194
+ // IPv4-mapped IPv6: ::ffff:w.x.y.z
195
+ if (normalized.startsWith('::ffff:')) {
196
+ const v4Part = normalized.slice(7);
197
+ if (isIPv4(v4Part)) {
198
+ return isPrivateIPv4(v4Part);
199
+ }
200
+ }
201
+
202
+ // ULA fc00::/7 (fc00... or fd00...)
203
+ if (/^f[cd][0-9a-f]{2}:/i.test(normalized)) return true;
204
+ // Link-local fe80::/10
205
+ if (/^fe[89ab][0-9a-f]:/i.test(normalized)) return true;
206
+ // Multicast ff00::/8
207
+ if (/^ff[0-9a-f]{2}:/i.test(normalized)) return true;
208
+ // Documentation 2001:db8::/32
209
+ if (/^2001:0?db8:/i.test(normalized)) return true;
210
+ // Discard 100::/64
211
+ if (/^0?100:/i.test(normalized)) return true;
212
+
213
+ return false;
214
+ }
215
+
216
+ /**
217
+ * Validate that an IP address is a public address.
218
+ * Rejects private, loopback, link-local, multicast, and reserved ranges.
219
+ */
220
+ export function isPublicAddress(ip: string, allowLoopback = false): boolean {
221
+ if (allowLoopback && (ip === '127.0.0.1' || ip === '::1' || ip === 'localhost')) {
222
+ return true;
223
+ }
224
+ if (isIPv4(ip)) {
225
+ return !isPrivateIPv4(ip);
226
+ }
227
+ return !isPrivateIPv6(ip);
228
+ }
229
+
230
+ /**
231
+ * Resolve DNS for hostname and validate all addresses against public policy.
232
+ * Returns the first validated IP address to pin, along with family.
233
+ */
234
+ export async function resolveAndPinDns(
235
+ hostname: string,
236
+ options: { allowLoopback?: boolean } = {},
237
+ ): Promise<{ ip: string; family: 4 | 6 }> {
238
+ const allowLoopback = options.allowLoopback ?? false;
239
+
240
+ // Direct IP given as hostname
241
+ if (isIPv4(hostname)) {
242
+ if (!isPublicAddress(hostname, allowLoopback)) {
243
+ throw new Error(`Forbidden non-public IP address: ${hostname}`);
244
+ }
245
+ return { ip: hostname, family: 4 };
246
+ }
247
+
248
+ // Resolve both IPv4 and IPv6
249
+ const [v4Results, v6Results] = await Promise.allSettled([
250
+ dns.resolve4(hostname).catch(() => []),
251
+ dns.resolve6(hostname).catch(() => []),
252
+ ]);
253
+
254
+ const v4 = v4Results.status === 'fulfilled' ? v4Results.value : [];
255
+ const v6 = v6Results.status === 'fulfilled' ? v6Results.value : [];
256
+ const all = [...v4.map((ip) => ({ ip, family: 4 as const })), ...v6.map((ip) => ({ ip, family: 6 as const }))];
257
+
258
+ if (all.length === 0) {
259
+ throw new Error(`DNS resolution failed for hostname: ${hostname}`);
260
+ }
261
+
262
+ // Every resolved IP MUST pass public address validation (prevents DNS rebinding / mixed answers)
263
+ for (const record of all) {
264
+ if (!isPublicAddress(record.ip, allowLoopback)) {
265
+ throw new Error(`DNS answer contains forbidden non-public IP: ${record.ip} for ${hostname}`);
266
+ }
267
+ }
268
+
269
+ const first = all[0];
270
+ if (!first) {
271
+ throw new Error(`No DNS answers found for ${hostname}`);
272
+ }
273
+ return first;
274
+ }
275
+
276
+ /**
277
+ * Single HTTP(S) request with pinned socket and peer verification.
278
+ */
279
+ export function pinnedSingleRequest(
280
+ targetUrl: string,
281
+ pinned: { ip: string; family: 4 | 6 },
282
+ options: TransportOptions = {},
283
+ ): Promise<FetchResult> {
284
+ const parsed = new URL(targetUrl);
285
+ const isHttps = parsed.protocol === 'https:';
286
+ const client = isHttps ? https : http;
287
+ const port = parsed.port ? Number(parsed.port) : isHttps ? 443 : 80;
288
+ const timeoutMs = options.timeoutMs ?? 20000;
289
+ const maxBytes = options.maxBytes ?? 2 * 1024 * 1024; // 2 MiB
290
+
291
+ return new Promise((resolvePromise, rejectPromise) => {
292
+ let settled = false;
293
+ let peerAddress: string | null = null;
294
+ let bytesReceived = 0;
295
+ let ttfbMs = 0;
296
+ const chunks: Buffer[] = [];
297
+ const startTime = Date.now();
298
+ let clientReq: http.ClientRequest | https.ClientRequest | null = null;
299
+ let timer: ReturnType<typeof setTimeout> | undefined;
300
+
301
+ const cleanupAndReject = (err: Error): void => {
302
+ if (settled) return;
303
+ settled = true;
304
+ clearTimeout(timer);
305
+ disposeCancel();
306
+ if (clientReq) {
307
+ try {
308
+ clientReq.destroy();
309
+ } catch {
310
+ // ignore
311
+ }
312
+ }
313
+ rejectPromise(err);
314
+ };
315
+
316
+ // Cancellation aborts the in-flight socket, not merely the loop that would have used it.
317
+ const disposeCancel = onSeoCancel((signal) => {
318
+ cleanupAndReject(new SeoStageCancelledError(signal, targetUrl));
319
+ });
320
+ if (seoCancelledBy !== null) {
321
+ cleanupAndReject(new SeoStageCancelledError(seoCancelledBy, targetUrl));
322
+ return;
323
+ }
324
+
325
+ timer = setTimeout(() => {
326
+ cleanupAndReject(new Error(`Request timed out after ${timeoutMs}ms for ${targetUrl}`));
327
+ }, timeoutMs);
328
+
329
+ const customLookup: (
330
+ hostname: string,
331
+ opts: unknown,
332
+ cb: (err: Error | null, res: unknown, family?: number) => void,
333
+ ) => void = (_h, opts, cb) => {
334
+ const hasAll = typeof opts === 'object' && opts !== null && (opts as { all?: boolean }).all;
335
+ if (hasAll) {
336
+ cb(null, [{ address: pinned.ip, family: pinned.family }]);
337
+ } else {
338
+ cb(null, pinned.ip, pinned.family);
339
+ }
340
+ };
341
+
342
+ const reqOptions: http.RequestOptions = {
343
+ hostname: parsed.hostname,
344
+ port,
345
+ path: `${parsed.pathname}${parsed.search}`,
346
+ method: 'GET',
347
+ headers: {
348
+ Host: parsed.host,
349
+ 'User-Agent': 'KnowledgeKit-SmartSEO/1.0 (+https://gobing.ai)',
350
+ Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
351
+ 'Accept-Encoding': 'identity',
352
+ Connection: 'close',
353
+ },
354
+ agent: false, // disable connection reuse
355
+ lookup: customLookup,
356
+ timeout: timeoutMs,
357
+ };
358
+
359
+ if (isHttps) {
360
+ const httpsOpts = reqOptions as https.RequestOptions;
361
+ httpsOpts.servername = parsed.hostname; // SNI
362
+ if (options.ca) {
363
+ httpsOpts.ca = options.ca;
364
+ }
365
+ }
366
+
367
+ clientReq = client.request(reqOptions, (res) => {
368
+ ttfbMs = Date.now() - startTime;
369
+ // The timer stays armed until 'end': a body that stalls after the headers must still time out.
370
+
371
+ // Peer check verification
372
+ const remote = clientReq?.socket?.remoteAddress ?? null;
373
+ peerAddress = remote;
374
+ if (!remote || remote !== pinned.ip) {
375
+ cleanupAndReject(new Error(`Peer address mismatch: expected ${pinned.ip}, got ${remote}`));
376
+ return;
377
+ }
378
+
379
+ // Verify remote address is public (or allowed loopback)
380
+ if (!isPublicAddress(remote, options.allowLoopback)) {
381
+ cleanupAndReject(new Error(`Peer address is non-public: ${remote}`));
382
+ return;
383
+ }
384
+
385
+ res.on('data', (chunk: Buffer) => {
386
+ bytesReceived += chunk.length;
387
+ if (bytesReceived > maxBytes) {
388
+ cleanupAndReject(new Error(`Response exceeded limit of ${maxBytes} bytes`));
389
+ return;
390
+ }
391
+ chunks.push(chunk);
392
+ });
393
+
394
+ res.on('end', () => {
395
+ if (settled) return;
396
+ settled = true;
397
+ disposeCancel();
398
+ clearTimeout(timer);
399
+ const rawHeaders: Record<string, string> = {};
400
+ for (const [k, v] of Object.entries(res.headers)) {
401
+ if (v !== undefined) {
402
+ rawHeaders[k.toLowerCase()] = Array.isArray(v) ? v.join(', ') : v;
403
+ }
404
+ }
405
+ const body = Buffer.concat(chunks).toString('utf-8');
406
+ resolvePromise({
407
+ url: targetUrl,
408
+ finalUrl: targetUrl,
409
+ status: res.statusCode ?? 0,
410
+ headers: rawHeaders,
411
+ body,
412
+ bytesReceived,
413
+ ttfbMs,
414
+ redirectChain: [],
415
+ peerAddress,
416
+ });
417
+ });
418
+
419
+ res.on('error', (err) => {
420
+ cleanupAndReject(err);
421
+ });
422
+ });
423
+
424
+ clientReq.on('socket', (socket) => {
425
+ socket.on('connect', () => {
426
+ const remote = socket.remoteAddress;
427
+ peerAddress = remote ?? null;
428
+ if (remote && remote !== pinned.ip) {
429
+ cleanupAndReject(new Error(`Socket peer mismatch: expected ${pinned.ip}, got ${remote}`));
430
+ }
431
+ });
432
+ });
433
+
434
+ clientReq.on('error', (err) => {
435
+ clearTimeout(timer);
436
+ cleanupAndReject(err);
437
+ });
438
+
439
+ // A signal that arrived while the request object was being built must not still open a socket.
440
+ if (settled) {
441
+ clientReq.destroy();
442
+ return;
443
+ }
444
+ clientReq.end();
445
+ });
446
+ }
447
+
448
+ /**
449
+ * Fetch URL with pinned DNS, public address check, peer verification,
450
+ * and scoped redirects (max 10 hops).
451
+ */
452
+ export async function fetchWithPinnedDns(
453
+ initialUrl: string,
454
+ scopeHostnames: Set<string>,
455
+ options: TransportOptions = {},
456
+ ): Promise<FetchResult> {
457
+ let currentUrl = normalizeUrl(initialUrl);
458
+ const redirectChain: string[] = [];
459
+ const maxRedirects = 10;
460
+
461
+ for (let hop = 0; hop <= maxRedirects; hop += 1) {
462
+ assertSeoNotCancelled();
463
+ const parsed = new URL(currentUrl);
464
+
465
+ // Scope check
466
+ if (!scopeHostnames.has(parsed.hostname.toLowerCase())) {
467
+ // Off-scope redirect: return with chain
468
+ throw new Error(`Off-scope redirect target refused: ${currentUrl}`);
469
+ }
470
+
471
+ // DNS pinning
472
+ const pinned = await resolveAndPinDns(parsed.hostname, { allowLoopback: options.allowLoopback });
473
+
474
+ // Single request
475
+ const res = await pinnedSingleRequest(currentUrl, pinned, options);
476
+
477
+ // Check for redirect status
478
+ const isRedirect = [301, 302, 303, 307, 308].includes(res.status);
479
+ const locationHeader = res.headers.location;
480
+
481
+ if (isRedirect && locationHeader) {
482
+ redirectChain.push(currentUrl);
483
+ let nextUrl: string;
484
+ try {
485
+ nextUrl = new URL(locationHeader, currentUrl).toString();
486
+ nextUrl = normalizeUrl(nextUrl);
487
+ } catch {
488
+ // Malformed location header: return current redirect response
489
+ res.redirectChain = redirectChain;
490
+ return res;
491
+ }
492
+
493
+ const nextParsed = new URL(nextUrl);
494
+ // Protocol must be http or https
495
+ if (nextParsed.protocol !== 'http:' && nextParsed.protocol !== 'https:') {
496
+ throw new Error(`Forbidden redirect protocol: ${nextParsed.protocol}`);
497
+ }
498
+
499
+ // Check if redirect target is within site scope
500
+ if (!scopeHostnames.has(nextParsed.hostname.toLowerCase())) {
501
+ // Record off-scope redirect without following
502
+ res.redirectChain = redirectChain;
503
+ res.finalUrl = currentUrl;
504
+ return res;
505
+ }
506
+
507
+ if (hop === maxRedirects) {
508
+ throw new Error(`Exceeded maximum redirect limit of ${maxRedirects} hops`);
509
+ }
510
+
511
+ currentUrl = nextUrl;
512
+ continue;
513
+ }
514
+
515
+ res.redirectChain = redirectChain;
516
+ res.finalUrl = currentUrl;
517
+ return res;
518
+ }
519
+
520
+ throw new Error(`Exceeded redirect limit for ${initialUrl}`);
521
+ }
522
+
523
+ /**
524
+ * Parsed robots.txt representation (RFC 9309).
525
+ */
526
+ export interface RobotsPolicy {
527
+ rawDigest: string;
528
+ isAvailable: boolean;
529
+ isBlocked: boolean; // 401/403
530
+ crawlDelaySeconds: number | null;
531
+ sitemaps: string[];
532
+ isPathAllowed(path: string, userAgent?: string): boolean;
533
+ }
534
+
535
+ interface RobotsRule {
536
+ pattern: string;
537
+ isAllow: boolean;
538
+ length: number;
539
+ }
540
+
541
+ interface RobotsGroup {
542
+ userAgents: string[];
543
+ rules: RobotsRule[];
544
+ crawlDelay: number | null;
545
+ }
546
+
547
+ /**
548
+ * Parse robots.txt according to RFC 9309 rules:
549
+ * - Case-insensitive User-agent matching
550
+ * - Allow / Disallow with longest match
551
+ * - Tie break: Allow wins
552
+ * - '*' wildcards and '$' end of path
553
+ * - Crawl-delay extraction
554
+ */
555
+ export function parseRobotsTxt(content: string): RobotsPolicy {
556
+ const rawDigest = sha256(content);
557
+ const groups: RobotsGroup[] = [];
558
+ const sitemaps: string[] = [];
559
+
560
+ let currentGroup: RobotsGroup = {
561
+ userAgents: [],
562
+ rules: [],
563
+ crawlDelay: null,
564
+ };
565
+
566
+ const lines = content.split(/\r?\n/);
567
+ for (const rawLine of lines) {
568
+ // Strip comment
569
+ const line = rawLine.replace(/#.*$/, '').trim();
570
+ if (!line) continue;
571
+
572
+ const colonIdx = line.indexOf(':');
573
+ if (colonIdx === -1) continue;
574
+
575
+ const field = line.slice(0, colonIdx).trim().toLowerCase();
576
+ const value = line.slice(colonIdx + 1).trim();
577
+
578
+ if (field === 'user-agent') {
579
+ if (currentGroup.rules.length > 0) {
580
+ groups.push(currentGroup);
581
+ currentGroup = { userAgents: [], rules: [], crawlDelay: null };
582
+ }
583
+ currentGroup.userAgents.push(value.toLowerCase());
584
+ } else if (field === 'disallow' || field === 'allow') {
585
+ const isAllow = field === 'allow';
586
+ const pattern = value;
587
+ if (pattern === '' && !isAllow) {
588
+ // Empty Disallow: Disallow nothing (same as Allow: /)
589
+ continue;
590
+ }
591
+ currentGroup.rules.push({
592
+ pattern,
593
+ isAllow,
594
+ length: pattern.length,
595
+ });
596
+ } else if (field === 'crawl-delay') {
597
+ const parsedDelay = Number.parseFloat(value);
598
+ if (!Number.isNaN(parsedDelay) && parsedDelay >= 0) {
599
+ currentGroup.crawlDelay = parsedDelay;
600
+ }
601
+ } else if (field === 'sitemap') {
602
+ if (value && !sitemaps.includes(value)) {
603
+ sitemaps.push(value);
604
+ }
605
+ }
606
+ }
607
+
608
+ if (currentGroup.userAgents.length > 0 || currentGroup.rules.length > 0) {
609
+ groups.push(currentGroup);
610
+ }
611
+
612
+ // Match helper for wildcard RFC 9309 patterns
613
+ const matchPattern = (path: string, pattern: string): boolean => {
614
+ if (pattern === '' || pattern === '/') return true;
615
+ // Escape regex special chars except * and $
616
+ let regexStr = '^';
617
+ for (let i = 0; i < pattern.length; i += 1) {
618
+ const char = pattern[i];
619
+ if (char === '*') {
620
+ regexStr += '.*';
621
+ } else if (char === '$' && i === pattern.length - 1) {
622
+ regexStr += '$';
623
+ } else {
624
+ regexStr += (char as string).replace(/[.+?^${}()|[\]\\]/g, '\\$&');
625
+ }
626
+ }
627
+ if (!pattern.endsWith('$')) {
628
+ // Prefix match
629
+ if (!regexStr.endsWith('.*')) regexStr += '.*';
630
+ }
631
+ try {
632
+ return new RegExp(regexStr).test(path);
633
+ } catch {
634
+ return path.startsWith(pattern);
635
+ }
636
+ };
637
+
638
+ const isPathAllowed = (path: string, ua = 'knowledgekit-smartseo'): boolean => {
639
+ const lowerUa = ua.toLowerCase();
640
+ // Find best matching group: specific first, then wildcard *
641
+ let selectedGroup = groups.find((g) => g.userAgents.some((u) => lowerUa.includes(u)));
642
+ if (!selectedGroup) {
643
+ selectedGroup = groups.find((g) => g.userAgents.includes('*'));
644
+ }
645
+ if (!selectedGroup || selectedGroup.rules.length === 0) {
646
+ return true;
647
+ }
648
+
649
+ // Find longest matching rule
650
+ let bestRule: RobotsRule | null = null;
651
+ for (const rule of selectedGroup.rules) {
652
+ if (matchPattern(path, rule.pattern)) {
653
+ if (!bestRule || rule.length > bestRule.length) {
654
+ bestRule = rule;
655
+ } else if (rule.length === bestRule.length) {
656
+ // Tie break: Allow wins over Disallow
657
+ if (rule.isAllow) {
658
+ bestRule = rule;
659
+ }
660
+ }
661
+ }
662
+ }
663
+
664
+ if (bestRule === null) return true;
665
+ return bestRule.isAllow;
666
+ };
667
+
668
+ // Find default crawl delay
669
+ let defaultDelay: number | null = null;
670
+ for (const g of groups) {
671
+ if (g.crawlDelay !== null) {
672
+ defaultDelay = g.crawlDelay;
673
+ break;
674
+ }
675
+ }
676
+
677
+ return {
678
+ rawDigest,
679
+ isAvailable: true,
680
+ isBlocked: false,
681
+ crawlDelaySeconds: defaultDelay,
682
+ sitemaps,
683
+ isPathAllowed,
684
+ };
685
+ }
686
+
687
+ /**
688
+ * Bounded non-expanding XML sitemap reader.
689
+ * Refuses DOCTYPE, custom entities, and malformed tags.
690
+ * Supports <urlset> / <url> / <loc> and <sitemapindex> / <sitemap> / <loc>.
691
+ */
692
+ export function parseSitemapXml(xmlContent: string): { urls: string[]; childSitemaps: string[] } {
693
+ if (xmlContent.length > 5 * 1024 * 1024) {
694
+ throw new Error('Sitemap XML exceeds 5 MiB size limit');
695
+ }
696
+
697
+ // Refuse DOCTYPE or custom entity expansion (XXE protection)
698
+ if (/<!DOCTYPE\b/i.test(xmlContent) || /<!ENTITY\b/i.test(xmlContent)) {
699
+ throw new Error('Forbidden XML construct: DOCTYPE and custom ENTITY definitions are rejected');
700
+ }
701
+
702
+ const unescapeXml = (str: string): string => {
703
+ return str
704
+ .replace(/&amp;/g, '&')
705
+ .replace(/&lt;/g, '<')
706
+ .replace(/&gt;/g, '>')
707
+ .replace(/&quot;/g, '"')
708
+ .replace(/&apos;/g, "'");
709
+ };
710
+
711
+ const urls: string[] = [];
712
+ const childSitemaps: string[] = [];
713
+
714
+ // Is it a sitemapindex?
715
+ const isIndex = /<sitemapindex\b/i.test(xmlContent);
716
+
717
+ if (isIndex) {
718
+ const sitemapMatches = xmlContent.matchAll(/<sitemap\b[\s\S]*?<\/sitemap>/gi);
719
+ for (const sm of sitemapMatches) {
720
+ const locMatch = sm[0].match(/<loc\b[^>]*>([\s\S]*?)<\/loc>/i);
721
+ if (locMatch?.[1]) {
722
+ const decoded = unescapeXml(locMatch[1].trim());
723
+ if (decoded && !childSitemaps.includes(decoded)) {
724
+ childSitemaps.push(decoded);
725
+ }
726
+ }
727
+ }
728
+ } else {
729
+ const urlMatches = xmlContent.matchAll(/<url\b[\s\S]*?<\/url>/gi);
730
+ for (const um of urlMatches) {
731
+ const locMatch = um[0].match(/<loc\b[^>]*>([\s\S]*?)<\/loc>/i);
732
+ if (locMatch?.[1]) {
733
+ const decoded = unescapeXml(locMatch[1].trim());
734
+ if (decoded && !urls.includes(decoded)) {
735
+ urls.push(decoded);
736
+ }
737
+ }
738
+ }
739
+ }
740
+
741
+ return { urls, childSitemaps };
742
+ }
743
+
744
+ /**
745
+ * Atomic file writer using sibling temp file + rename.
746
+ */
747
+ export function writeJsonAtomic(destPath: string, data: unknown): void {
748
+ const parent = dirname(destPath);
749
+ if (!existsSync(parent)) {
750
+ mkdirSync(parent, { recursive: true });
751
+ }
752
+ const rand = Math.random().toString(36).slice(2, 8);
753
+ const tmpPath = `${destPath}.tmp.${Date.now()}.${rand}`;
754
+ const serialized = JSON.stringify(data, null, 2);
755
+ try {
756
+ writeFileSync(tmpPath, serialized, 'utf-8');
757
+ renameSync(tmpPath, destPath);
758
+ } catch (err) {
759
+ try {
760
+ unlinkSync(tmpPath);
761
+ } catch {
762
+ // ignore
763
+ }
764
+ throw err;
765
+ }
766
+ }