@lincoln504/pi-research 0.1.12 → 1.0.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 (248) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +64 -115
  3. package/dist/cli.mjs +23211 -0
  4. package/dist/prompts/research-knowledge-relevance-triage.md +35 -0
  5. package/dist/prompts/research-knowledge-search-extractor.md +41 -0
  6. package/dist/prompts/research-tool-usage.md +92 -0
  7. package/dist/prompts/researcher.md +89 -0
  8. package/dist/prompts/system-coordinator.md +75 -0
  9. package/dist/prompts/system-lead-evaluator.md +118 -0
  10. package/dist/thread-worker.mjs +2611 -0
  11. package/dist/webgpu-probe.mjs +80 -0
  12. package/docs/AGENT-SKILL.md +102 -0
  13. package/docs/ARCHITECTURE.md +306 -80
  14. package/docs/CONFIGURATION.md +249 -0
  15. package/docs/KNOWLEDGE-STORE.md +213 -0
  16. package/docs/PI-EXTENSION.md +91 -0
  17. package/docs/SDK.md +113 -0
  18. package/docs/THIRD-PARTY-NOTICES.md +64 -0
  19. package/package.json +102 -36
  20. package/scripts/build.cjs +140 -0
  21. package/scripts/cleanup.cjs +149 -0
  22. package/scripts/prepare.cjs +28 -0
  23. package/scripts/setup.cjs +134 -0
  24. package/skills/pi-research/README.md +90 -0
  25. package/skills/pi-research/SKILL.md +102 -0
  26. package/skills/pi-research/references/configuration.md +77 -0
  27. package/skills/pi-research/scripts/run.mjs +237 -0
  28. package/src/cleanup/research-cleanup.ts +140 -0
  29. package/src/cli.ts +1044 -0
  30. package/src/config.ts +1036 -147
  31. package/src/constants.ts +115 -84
  32. package/src/core/health-check-service.ts +265 -0
  33. package/src/core/interfaces/health-check-interfaces.ts +50 -0
  34. package/src/core/interfaces/knowledge-interfaces.ts +190 -0
  35. package/src/core/interfaces/observer-interfaces.ts +67 -0
  36. package/src/core/interfaces/orchestration-interfaces.ts +86 -0
  37. package/src/core/interfaces/planning-interfaces.ts +100 -0
  38. package/src/core/interfaces/process-interfaces.ts +37 -0
  39. package/src/core/interfaces/research-plan-types.ts +52 -0
  40. package/src/core/interfaces/scheduler-interfaces.ts +91 -0
  41. package/src/core/interfaces/service-names.ts +39 -0
  42. package/src/core/interfaces/state-manager-interfaces.ts +29 -0
  43. package/src/core/interfaces/steering-interfaces.ts +31 -0
  44. package/src/core/llm/agentic-repair.ts +169 -0
  45. package/src/core/llm/inject-date.ts +52 -0
  46. package/src/core/llm/llm-timeout.ts +64 -0
  47. package/src/core/llm/llm-utils.ts +86 -0
  48. package/src/core/llm/model-registry-factory.ts +287 -0
  49. package/src/core/llm/pi-ai-completion.ts +29 -0
  50. package/src/core/llm/prompts.ts +50 -0
  51. package/src/core/llm/research-model-resolver.ts +91 -0
  52. package/src/core/planning-constants.ts +13 -0
  53. package/src/core/planning-service.ts +649 -0
  54. package/src/core/planning-utils.ts +299 -0
  55. package/src/core/scheduler-factory.ts +48 -0
  56. package/src/core/scheduler-service.ts +123 -0
  57. package/src/core/service-initialization.ts +194 -0
  58. package/src/core/service-interfaces.ts +48 -0
  59. package/src/core/service-registry.ts +659 -0
  60. package/src/healthcheck/index.ts +271 -265
  61. package/src/healthcheck/registry.ts +130 -0
  62. package/src/index.ts +639 -50
  63. package/src/infrastructure/browser/browser-cleanup.ts +309 -0
  64. package/src/infrastructure/browser/browser-client.ts +199 -0
  65. package/src/infrastructure/browser/browser-error-utils.ts +160 -0
  66. package/src/infrastructure/browser/browser-lifecycle.ts +120 -0
  67. package/src/infrastructure/browser/browser-server.ts +174 -0
  68. package/src/infrastructure/browser/browser-task-scheduler.ts +461 -0
  69. package/src/infrastructure/browser/cleanup-utils.ts +155 -0
  70. package/src/infrastructure/browser/client-agent.ts +31 -0
  71. package/src/infrastructure/browser/config.ts +288 -0
  72. package/src/infrastructure/browser/ensure-browser.ts +205 -0
  73. package/src/infrastructure/browser/index.ts +37 -0
  74. package/src/infrastructure/browser/priority-task-queue.ts +226 -0
  75. package/src/infrastructure/browser/scheduler-factory.ts +354 -0
  76. package/src/infrastructure/browser/task-execution-service.ts +209 -0
  77. package/src/infrastructure/browser/thread-worker-browser.ts +282 -0
  78. package/src/infrastructure/browser/thread-worker-lifecycle.ts +323 -0
  79. package/src/infrastructure/browser/thread-worker-messaging.ts +662 -0
  80. package/src/infrastructure/browser/thread-worker-types.ts +25 -0
  81. package/src/infrastructure/browser/thread-worker.mjs +2611 -0
  82. package/src/infrastructure/browser/thread-worker.ts +168 -0
  83. package/src/infrastructure/browser/worker-pool-manager.ts +449 -0
  84. package/src/infrastructure/embedding/embedding-client.ts +205 -0
  85. package/src/infrastructure/embedding/embedding-factory.ts +324 -0
  86. package/src/infrastructure/embedding/embedding-server.ts +535 -0
  87. package/src/infrastructure/file-lock-service.ts +592 -0
  88. package/src/infrastructure/gpu-resource-service.ts +191 -0
  89. package/src/infrastructure/knowledge-store-service.ts +475 -0
  90. package/src/infrastructure/metrics-service.ts +129 -0
  91. package/src/infrastructure/process-lifecycle-service.ts +253 -0
  92. package/src/infrastructure/scheduler-factory-service.ts +58 -0
  93. package/src/infrastructure/service-initialization.ts +292 -0
  94. package/src/infrastructure/state/state-backup-manager.ts +290 -0
  95. package/src/infrastructure/state/state-browser-api.ts +62 -0
  96. package/src/infrastructure/state/state-browser-manager.ts +70 -0
  97. package/src/infrastructure/state/state-manager-service.ts +264 -0
  98. package/src/infrastructure/state/state-manager.ts +547 -0
  99. package/src/infrastructure/state/state-metrics.ts +92 -0
  100. package/src/infrastructure/state/state-migration.ts +103 -0
  101. package/src/infrastructure/state/state-path-configuration.ts +119 -0
  102. package/src/infrastructure/state/state-session-api.ts +94 -0
  103. package/src/infrastructure/state/state-session-manager.ts +127 -0
  104. package/src/infrastructure/state/state-validator.ts +93 -0
  105. package/src/infrastructure/types/state-types.ts +85 -0
  106. package/src/knowledge/chunker.ts +157 -0
  107. package/src/knowledge/embedder-init.ts +413 -0
  108. package/src/knowledge/embedder-utils.ts +296 -0
  109. package/src/knowledge/embedder.ts +592 -0
  110. package/src/knowledge/index.ts +126 -0
  111. package/src/knowledge/lancedb-loader.ts +35 -0
  112. package/src/knowledge/migration.ts +17 -0
  113. package/src/knowledge/model-config.ts +132 -0
  114. package/src/knowledge/onnx-env.ts +48 -0
  115. package/src/knowledge/store-operations.ts +291 -0
  116. package/src/knowledge/store-schema.ts +79 -0
  117. package/src/knowledge/store.ts +1251 -0
  118. package/src/knowledge/transformers-loader.ts +31 -0
  119. package/src/knowledge/webgpu-probe.mjs +80 -0
  120. package/src/knowledge/webgpu-viability.ts +255 -0
  121. package/src/knowledge/writer-queue.ts +252 -0
  122. package/src/logger.ts +316 -122
  123. package/src/observers/research-observer-impl.ts +459 -0
  124. package/src/orchestration/deep-research-orchestrator.ts +524 -773
  125. package/src/orchestration/deep-research-types.ts +47 -35
  126. package/src/orchestration/headless-observer.ts +114 -0
  127. package/src/orchestration/orchestration-types.ts +33 -0
  128. package/src/orchestration/quick-research-orchestrator.ts +463 -0
  129. package/src/orchestration/research-orchestration-service.ts +549 -0
  130. package/src/orchestration/research-session-service.ts +178 -0
  131. package/src/orchestration/research-synthesis-service.ts +348 -0
  132. package/src/orchestration/researcher-executor.ts +394 -0
  133. package/src/orchestration/researcher.ts +115 -42
  134. package/src/orchestration/service-initialization.ts +109 -0
  135. package/src/orchestration/session-context.ts +7 -3
  136. package/src/orchestration/session-state.ts +672 -0
  137. package/src/prompts/research-knowledge-relevance-triage.md +35 -0
  138. package/src/prompts/research-knowledge-search-extractor.md +41 -0
  139. package/src/prompts/research-tool-usage.md +63 -16
  140. package/src/prompts/researcher.md +59 -136
  141. package/src/prompts/system-coordinator.md +64 -13
  142. package/src/prompts/system-lead-evaluator.md +82 -189
  143. package/src/research-config.ts +872 -0
  144. package/src/sdk.ts +847 -0
  145. package/src/security/cisa-kev.ts +72 -9
  146. package/src/security/cvss.ts +92 -0
  147. package/src/security/ecosystem.ts +45 -0
  148. package/src/security/github-advisories.ts +168 -428
  149. package/src/security/github-advisory-types.ts +282 -0
  150. package/src/security/index.ts +63 -177
  151. package/src/security/nvd-client.ts +550 -0
  152. package/src/security/nvd-types.ts +179 -0
  153. package/src/security/osv-client.ts +246 -0
  154. package/src/security/osv-types.ts +285 -0
  155. package/src/security/types.ts +67 -53
  156. package/src/skill-install/skill-installer.ts +519 -0
  157. package/src/stackexchange/index.ts +81 -27
  158. package/src/stackexchange/output/compact.ts +7 -6
  159. package/src/stackexchange/output/table.ts +11 -7
  160. package/src/stackexchange/queries.ts +0 -10
  161. package/src/stackexchange/rest-client.ts +141 -66
  162. package/src/stackexchange/types.ts +0 -17
  163. package/src/tool.ts +15 -423
  164. package/src/tools/grep.ts +32 -310
  165. package/src/tools/health-tool-definition.ts +101 -0
  166. package/src/tools/index.ts +38 -9
  167. package/src/tools/research-knowledge-search.ts +974 -0
  168. package/src/tools/research-knowledge-types.ts +77 -0
  169. package/src/tools/research-tool-definition.ts +494 -0
  170. package/src/tools/scrape.ts +286 -388
  171. package/src/tools/search.ts +98 -153
  172. package/src/tools/security.ts +107 -80
  173. package/src/tools/stackexchange.ts +58 -35
  174. package/src/tools/youtube-transcript.ts +163 -0
  175. package/src/tui/knowledge-search-panel.ts +86 -0
  176. package/src/tui/pulse.ts +86 -0
  177. package/src/tui/research-health.ts +124 -0
  178. package/src/tui/research-panel-color-utils.ts +340 -0
  179. package/src/tui/research-panel-format.ts +54 -0
  180. package/src/tui/research-panel-state.ts +404 -0
  181. package/src/tui/research-panel-wave.ts +280 -0
  182. package/src/tui/research-panel.ts +339 -435
  183. package/src/tui/research-tui-manager.ts +233 -0
  184. package/src/tui/tui-controller.ts +137 -0
  185. package/src/types/extension-context.ts +18 -49
  186. package/src/types/index.ts +61 -0
  187. package/src/types/llm.ts +68 -29
  188. package/src/types/research-panel-types.ts +61 -0
  189. package/src/utils/circuit-breaker.ts +146 -0
  190. package/src/utils/citation-utils.ts +97 -0
  191. package/src/utils/disk-space-checker.ts +55 -0
  192. package/src/utils/error-tracker.ts +265 -0
  193. package/src/utils/host-config.ts +45 -0
  194. package/src/utils/input-validation.ts +8 -10
  195. package/src/utils/json-utils.ts +150 -16
  196. package/src/utils/llm-usage.ts +54 -0
  197. package/src/utils/log-rotation.ts +125 -0
  198. package/src/utils/log-utils.ts +159 -0
  199. package/src/utils/make-resource-loader.ts +3 -1
  200. package/src/utils/metrics-summary.ts +459 -0
  201. package/src/utils/metrics.ts +302 -0
  202. package/src/utils/research-export.ts +28 -12
  203. package/src/utils/safe-unref.ts +31 -0
  204. package/src/utils/shared-links.ts +195 -90
  205. package/src/utils/shutdown-manager.ts +65 -0
  206. package/src/utils/stdio-capture.ts +407 -0
  207. package/src/utils/text-utils.ts +264 -7
  208. package/src/utils/tool-usage-tracker.ts +83 -16
  209. package/src/utils/url-utils.ts +255 -0
  210. package/src/utils/user-agent.ts +28 -0
  211. package/src/web-research/browser-search.ts +179 -0
  212. package/src/web-research/retry-utils.ts +173 -77
  213. package/src/web-research/scraper-types.ts +72 -0
  214. package/src/web-research/scraper-utils.ts +434 -0
  215. package/src/web-research/search.ts +78 -218
  216. package/src/web-research/types.ts +9 -24
  217. package/src/web-research/utils.ts +2 -143
  218. package/src/web-research/web-scraper.ts +477 -0
  219. package/src/youtube/index.ts +154 -0
  220. package/src/youtube/potoken.ts +259 -0
  221. package/src/youtube/transcript-client.ts +390 -0
  222. package/src/youtube/video-id.ts +118 -0
  223. package/config/searxng/default-settings.yml +0 -189
  224. package/config/searxng/limiter.toml +0 -34
  225. package/config/tooling/eslint.config.js +0 -73
  226. package/config/tooling/vitest.config.integration.ts +0 -17
  227. package/config/tooling/vitest.config.ts +0 -40
  228. package/config/tooling/vitest.config.unit.ts +0 -12
  229. package/docs/assets/README-banner.jpg +0 -0
  230. package/scripts/setup.js +0 -174
  231. package/src/infrastructure/searxng-lifecycle.ts +0 -577
  232. package/src/infrastructure/searxng-manager.ts +0 -1183
  233. package/src/infrastructure/state-manager.ts +0 -918
  234. package/src/orchestration/deep-research-reducer.ts +0 -129
  235. package/src/orchestration/id-utils.ts +0 -63
  236. package/src/security/nvd.ts +0 -532
  237. package/src/security/osv.ts +0 -487
  238. package/src/stackexchange/cache.ts +0 -111
  239. package/src/stackexchange/output/index.ts +0 -7
  240. package/src/stackexchange/output/json.ts +0 -21
  241. package/src/tui/searxng-status.ts +0 -80
  242. package/src/utils/inject-date.ts +0 -29
  243. package/src/utils/research-error.ts +0 -205
  244. package/src/utils/searxng-config.ts +0 -94
  245. package/src/utils/session-state.ts +0 -295
  246. package/src/web-research/dependencies.ts +0 -20
  247. package/src/web-research/scrapers.ts +0 -935
  248. package/tsconfig.json +0 -40
@@ -0,0 +1,434 @@
1
+ /**
2
+ * Scraper Utilities
3
+ */
4
+
5
+ import * as dns from 'node:dns/promises';
6
+ import * as dnsCb from 'node:dns';
7
+ import * as net from 'node:net';
8
+ import crypto from 'node:crypto';
9
+ import { NodeHtmlMarkdown } from 'node-html-markdown';
10
+ import { errorTracker } from '../utils/error-tracker.ts';
11
+ import { metrics } from '../utils/metrics.ts';
12
+ import { logger } from '../logger.ts';
13
+ import {
14
+ USER_AGENTS,
15
+ FILTERED_TAGS,
16
+ IMAGE_LINK_PATTERN,
17
+ MARKDOWN_IMAGE_PATTERN,
18
+ BOT_PATTERNS,
19
+ INTERNAL_NETWORK_PATTERNS,
20
+ type NativeHtmlToMarkdownModule,
21
+ } from './scraper-types.ts';
22
+ import { isCloudflareBlockError, isTaskTimeoutError, isPoolShutdownError } from '../infrastructure/browser/browser-error-utils.ts';
23
+
24
+ /**
25
+ * True when a scrape failure is an EXPECTED per-URL outcome rather than an engine
26
+ * fault. Scraping the open web routinely hits bot-protection interstitials, thin
27
+ * "stub" pages, HTTP 4xx/5xx, slow/unresponsive remotes, and (on quit) pool-drain
28
+ * races. None of these is a pi-research bug: the URL is already accounted for as
29
+ * "not scraped" (scrape_results_total{outcome=total_failure}) and surfaced to the
30
+ * user that way, and no operator action follows. Callers use this to log such
31
+ * failures at debug instead of error — keeping the ERROR log (and, via
32
+ * logger.error's error-tracker re-track, the diagnostic error count) focused on
33
+ * genuine faults. The messages matched here are produced by validateContent
34
+ * (`Fetch blocked:` / `Fetch returned stub:`) and scrapeWithFetch (`HTTP <code>`);
35
+ * the remaining cases delegate to the shared browser-layer predicates.
36
+ */
37
+ export function isBenignScrapeFailure(error: unknown): boolean {
38
+ const msg = error instanceof Error ? error.message : String(error);
39
+ return (
40
+ msg.startsWith('Fetch blocked:') || // bot-protection interstitial (Cloudflare, DDoS-Guard, …)
41
+ msg.startsWith('Fetch returned stub:') || // nav-only / near-empty page
42
+ /^HTTP \d{3}\b/.test(msg) || // remote 4xx/5xx status
43
+ isCloudflareBlockError(error) ||
44
+ isTaskTimeoutError(error) ||
45
+ isPoolShutdownError(error)
46
+ );
47
+ }
48
+
49
+ /**
50
+ * Get a random user agent
51
+ */
52
+ export function getRandomUserAgent(): string {
53
+ try {
54
+ const index = crypto.randomInt(0, USER_AGENTS.length);
55
+ const userAgent = USER_AGENTS[index];
56
+ return userAgent ?? USER_AGENTS[0]!;
57
+ } catch {
58
+ return USER_AGENTS[0]!;
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Extract domain from URL for error tracking
64
+ */
65
+ export function extractDomain(url: string): string {
66
+ try {
67
+ return new URL(url).hostname;
68
+ } catch {
69
+ return 'unknown';
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Synchronous SSRF screen: protocol, localhost, internal-network patterns, and
75
+ * IP-literal private/reserved ranges. Performs NO DNS resolution, so it is cheap
76
+ * enough to run on every browser subresource request without adding latency.
77
+ *
78
+ * Returns `true` when the target was definitively ACCEPTED via the test-only
79
+ * loopback affordance (the caller must do no further checks — a DNS pass would
80
+ * reject localhost→127.0.0.1). Returns `false` when the URL passed the sync
81
+ * screen but a DNS-resolution pass is still warranted (validateUrlForSSRF adds
82
+ * it). Throws on a blocked target.
83
+ */
84
+ export function validateUrlForSSRFSync(url: string): boolean {
85
+ let parsed: URL;
86
+ try {
87
+ parsed = new URL(url);
88
+ } catch (e) {
89
+ metrics.increment('scrape_errors_total', 1, { error_type: 'invalid_url' });
90
+ throw new Error(`Invalid URL: ${url}`, { cause: e });
91
+ }
92
+
93
+ // Strip the IPv6 literal brackets that `new URL` preserves ("[::1]" → "::1").
94
+ // Without this every pattern/literal check below silently misses bracketed
95
+ // IPv6 literals — e.g. http://[::1]/ and http://[::ffff:127.0.0.1]/ (which Node
96
+ // normalizes to the hex form [::ffff:7f00:1]) would bypass the guard entirely.
97
+ const hostname = parsed.hostname.toLowerCase().replace(/^\[|\]$/g, '');
98
+
99
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
100
+ metrics.increment('scrape_ssrf_blocks_total', 1, { block_type: 'invalid_protocol' });
101
+ throw new Error('Only HTTP/HTTPS protocols are allowed');
102
+ }
103
+
104
+ // Test-only affordance: when PI_RESEARCH_ALLOW_LOOPBACK_SCRAPE=true, permit
105
+ // LOOPBACK targets (127.0.0.0/8, ::1, *.localhost) so integration tests can
106
+ // drive the real browser + scrape pipeline against a local HTTP server with
107
+ // no external network dependency. Default off; never enabled in production.
108
+ // Deliberately scoped to loopback ONLY — link-local 169.254.0.0/16 (cloud
109
+ // metadata) and RFC1918 LAN ranges stay blocked even when this flag is set,
110
+ // so the dangerous SSRF targets remain protected.
111
+ if (process.env['PI_RESEARCH_ALLOW_LOOPBACK_SCRAPE'] === 'true') {
112
+ const isLoopback =
113
+ hostname === 'localhost' || hostname.endsWith('.localhost') ||
114
+ /^127\./.test(hostname) || hostname === '::1' ||
115
+ (net.isIPv6(hostname) && isPrivateIpv6(hostname) && isMappedLoopback(hostname));
116
+ if (isLoopback) return true;
117
+ }
118
+
119
+ if (hostname === 'localhost' || hostname.endsWith('.localhost')) {
120
+ metrics.increment('scrape_ssrf_blocks_total', 1, { block_type: 'localhost' });
121
+ throw new Error('Access to localhost is not allowed');
122
+ }
123
+
124
+ for (const pattern of INTERNAL_NETWORK_PATTERNS) {
125
+ if (pattern.test(hostname)) {
126
+ metrics.increment('scrape_ssrf_blocks_total', 1, { block_type: 'internal_network' });
127
+ throw new Error('Access to internal networks is not allowed');
128
+ }
129
+ }
130
+
131
+ // Direct IP-literal check. dns.resolve4/6 REJECT IP literals (EBADNAME), so a
132
+ // raw-IP host would otherwise skip the resolved-address checks below and slip
133
+ // through. This is the gate that actually blocks http://[::ffff:127.0.0.1]/ and
134
+ // other IPv6 literals whose mapped IPv4 Node renders in hex.
135
+ const literalKind = net.isIP(hostname);
136
+ if (literalKind === 4 && isPrivateIp(hostname)) {
137
+ metrics.increment('scrape_ssrf_blocks_total', 1, { block_type: 'ip_literal_v4' });
138
+ throw new Error('Access to private/reserved IPv4 address is not allowed');
139
+ }
140
+ if (literalKind === 6 && isPrivateIpv6(hostname)) {
141
+ metrics.increment('scrape_ssrf_blocks_total', 1, { block_type: 'ip_literal_v6' });
142
+ throw new Error('Access to private/reserved IPv6 address is not allowed');
143
+ }
144
+
145
+ return false;
146
+ }
147
+
148
+ /**
149
+ * Validate URL to prevent SSRF attacks
150
+ *
151
+ * FIX (#8): In addition to hostname pattern checks, resolves the hostname
152
+ * via DNS and rejects any address that resolves to a private/reserved IP.
153
+ * This defends against DNS rebinding and decimal/octal IP representations.
154
+ */
155
+ export async function validateUrlForSSRF(url: string): Promise<void> {
156
+ // Synchronous protocol/localhost/internal-pattern/IP-literal screen first.
157
+ // A `true` return means the test-only loopback affordance accepted the target,
158
+ // so the DNS pass (which would reject localhost→127.0.0.1) must be skipped.
159
+ if (validateUrlForSSRFSync(url)) return;
160
+
161
+ const hostname = new URL(url).hostname.toLowerCase().replace(/^\[|\]$/g, '');
162
+
163
+ // Resolve hostname via DNS (both IPv4 and IPv6) and check all resulting addresses.
164
+ // This catches DNS rebinding, decimal IP representations (2130706433),
165
+ // AAAA-only hosts (::1, fc00::/7) and other hostname-based bypasses.
166
+ const [v4Results, v6Results] = await Promise.allSettled([
167
+ dns.resolve4(hostname),
168
+ dns.resolve6(hostname),
169
+ ]);
170
+
171
+ const v4Addresses = v4Results.status === 'fulfilled' ? v4Results.value : [];
172
+ const v6Addresses = v6Results.status === 'fulfilled' ? v6Results.value : [];
173
+
174
+ for (const ip of v4Addresses) {
175
+ if (isPrivateIp(ip)) {
176
+ metrics.increment('scrape_ssrf_blocks_total', 1, { block_type: 'dns_rebinding_v4' });
177
+ throw new Error('Hostname resolves to a private/reserved IPv4 address');
178
+ }
179
+ }
180
+ for (const ip of v6Addresses) {
181
+ if (isPrivateIpv6(ip)) {
182
+ metrics.increment('scrape_ssrf_blocks_total', 1, { block_type: 'dns_rebinding_v6' });
183
+ throw new Error('Hostname resolves to a private/reserved IPv6 address');
184
+ }
185
+ }
186
+ }
187
+
188
+ /**
189
+ * SSRF-safe DNS lookup for the connect layer.
190
+ *
191
+ * validateUrlForSSRF runs at request time, but Node's fetch independently
192
+ * re-resolves the hostname when it opens the socket. A DNS-rebinding attacker
193
+ * with a TTL-0 record can answer with a public IP during validation and a
194
+ * private one (e.g. the cloud metadata endpoint 169.254.169.254) at connect —
195
+ * a classic time-of-check/time-of-use gap. This lookup closes it: it is wired
196
+ * into the undici connector (see getSsrfSafeDispatcher) so the address the
197
+ * socket actually connects to is the address that gets validated. Any resolved
198
+ * private/reserved address is filtered out, and a host that resolves ONLY to
199
+ * private addresses is refused at connect time.
200
+ */
201
+ export function ssrfSafeLookup(
202
+ hostname: string,
203
+ options: dnsCb.LookupAllOptions | dnsCb.LookupOneOptions | number | undefined,
204
+ callback: (err: NodeJS.ErrnoException | null, address?: any, family?: number) => void,
205
+ // Resolver is injectable for tests; undici calls with the standard 3 args.
206
+ resolveImpl: (hostname: string, opts: dnsCb.LookupAllOptions, cb: (err: NodeJS.ErrnoException | null, addresses: dnsCb.LookupAddress[]) => void) => void = dnsCb.lookup as any,
207
+ ): void {
208
+ const family = typeof options === 'number' ? options : (options?.family ?? 0);
209
+ const wantsAll = typeof options === 'object' && options?.all === true;
210
+ // Mirror validateUrlForSSRF's loopback test affordance: when explicitly
211
+ // enabled, integration tests resolve to 127.0.0.1/::1 and must be allowed to
212
+ // connect. Scoped to loopback only — other private ranges stay blocked.
213
+ const allowLoopback = process.env['PI_RESEARCH_ALLOW_LOOPBACK_SCRAPE'] === 'true';
214
+ const isLoopback = (a: { address: string; family: number }): boolean =>
215
+ a.family === 6
216
+ ? (a.address.toLowerCase() === '::1' || isMappedLoopback(a.address))
217
+ : /^127\./.test(a.address);
218
+ resolveImpl(hostname, { all: true, verbatim: true, family }, (err, addresses) => {
219
+ if (err) return callback(err);
220
+ const list = Array.isArray(addresses) ? addresses : [];
221
+ const safe = list.filter((a) => {
222
+ if (!a) return false;
223
+ if (allowLoopback && isLoopback(a)) return true;
224
+ return a.family === 6 ? !isPrivateIpv6(a.address) : !isPrivateIp(a.address);
225
+ });
226
+ if (safe.length === 0) {
227
+ metrics.increment('scrape_ssrf_blocks_total', 1, { block_type: 'connect_time_private' });
228
+ return callback(
229
+ Object.assign(new Error(`SSRF blocked: ${hostname} resolved only to private/reserved addresses at connect time`), { code: 'ESSRFBLOCKED' }),
230
+ );
231
+ }
232
+ if (wantsAll) return callback(null, safe);
233
+ return callback(null, safe[0]!.address, safe[0]!.family);
234
+ });
235
+ }
236
+
237
+ // undici Agent used as the per-request dispatcher for scrape fetches. Cached
238
+ // after first construction. `undefined` = not yet attempted, `null` = undici
239
+ // unavailable (fall back to plain fetch with request-time validation only).
240
+ let cachedSsrfDispatcher: unknown | null | undefined = undefined;
241
+
242
+ /**
243
+ * Build (once) an undici Agent whose connector resolves via ssrfSafeLookup, so
244
+ * every scrape fetch — including each manually-followed redirect hop — connects
245
+ * only to a validated public address. Returns null if undici can't be loaded;
246
+ * callers then fall back to plain fetch (request-time validation still applies).
247
+ */
248
+ export async function getSsrfSafeDispatcher(): Promise<unknown | null> {
249
+ if (cachedSsrfDispatcher !== undefined) return cachedSsrfDispatcher as unknown | null;
250
+ try {
251
+ const { Agent } = await import('undici');
252
+ cachedSsrfDispatcher = new Agent({ connect: { lookup: ssrfSafeLookup } });
253
+ } catch (e) {
254
+ logger.warn(`[Scrapers] undici unavailable; connect-time SSRF pinning disabled (request-time validation still active): ${e instanceof Error ? e.message : String(e)}`);
255
+ cachedSsrfDispatcher = null;
256
+ }
257
+ return cachedSsrfDispatcher as unknown | null;
258
+ }
259
+
260
+ /**
261
+ * Check if an IPv4 address is private/reserved/loopback.
262
+ */
263
+ function isPrivateIp(ip: string): boolean {
264
+ const parts = ip.split('.').map(Number);
265
+ if (parts.length !== 4 || parts.some(p => isNaN(p))) return false;
266
+ const [a, b] = parts;
267
+ if (a === undefined || b === undefined) return false;
268
+ // 0.0.0.0/8, 10.0.0.0/8, 100.64.0.0/10 (RFC 6598 CGNAT / shared address space,
269
+ // routinely routed to internal infra by cloud/hosting providers), 127.0.0.0/8,
270
+ // 169.254.0.0/16, 172.16.0.0/12, 192.168.0.0/16, 224.0.0.0/4 (multicast),
271
+ // 240.0.0.0/4 (reserved)
272
+ return (
273
+ a === 0 || a === 10 || a === 127 ||
274
+ (a === 100 && b >= 64 && b <= 127) ||
275
+ (a === 169 && b === 254) ||
276
+ (a === 172 && b >= 16 && b <= 31) ||
277
+ (a === 192 && b === 168) ||
278
+ a >= 224
279
+ );
280
+ }
281
+
282
+ /**
283
+ * Check if an IPv6 address is private/reserved/loopback.
284
+ * Covers: ::1 (loopback), fc00::/7 (unique local), fe80::/10 (link-local),
285
+ * ::ffff:0:0/96 (IPv4-mapped — blocked if the mapped address is private).
286
+ */
287
+ function isPrivateIpv6(ip: string): boolean {
288
+ const normalized = ip.toLowerCase().replace(/^\[|\]$/g, '');
289
+ // Loopback
290
+ if (normalized === '::1') return true;
291
+ // Unique local (fc00::/7 covers fc:: and fd::)
292
+ if (normalized.startsWith('fc') || normalized.startsWith('fd')) return true;
293
+ // Link-local (fe80::/10)
294
+ if (normalized.startsWith('fe8') || normalized.startsWith('fe9') ||
295
+ normalized.startsWith('fea') || normalized.startsWith('feb')) return true;
296
+ // IPv4-mapped ::ffff:x.x.x.x — check the embedded IPv4 part. The mapped tail
297
+ // can arrive dotted-decimal (::ffff:127.0.0.1) OR hex (::ffff:7f00:1) — Node's
298
+ // URL parser normalizes literals to the hex form — and the prefix can be
299
+ // compressed (::ffff:) or full (0:0:0:0:0:ffff:). Decode all of them.
300
+ const mapped = extractMappedIpv4(normalized);
301
+ if (mapped) {
302
+ return isPrivateIp(mapped);
303
+ }
304
+ // 6to4 (2002::/16) and Teredo (2001:0::/32) tunnel IPv4 inside the IPv6 address,
305
+ // so a public-looking literal can carry a private/loopback IPv4 (e.g. 2002:7f00:1::
306
+ // → 127.0.0.x). Both are deprecated transitional ranges (RFC 7526) with no
307
+ // legitimate scrape target, so block the whole ranges rather than decode them.
308
+ if (normalized.startsWith('2002:')) return true;
309
+ if (/^2001:0{0,4}:/.test(normalized) || normalized.startsWith('2001::')) return true;
310
+ // All-zeros (unspecified address)
311
+ if (normalized === '::' || normalized === '0:0:0:0:0:0:0:0') return true;
312
+ return false;
313
+ }
314
+
315
+ /**
316
+ * Decode the embedded IPv4 of an IPv4-mapped IPv6 address (::ffff:0:0/96) to
317
+ * dotted-decimal, accepting dotted-decimal or hex tails and compressed or full
318
+ * prefixes. Returns null when `ip` is not an IPv4-mapped address.
319
+ */
320
+ function extractMappedIpv4(ip: string): string | null {
321
+ // ::ffff: prefix, compressed or full (0:0:0:0:0:ffff:). Anchor once at the
322
+ // start: a `^` inside the {5} group would only match the first repetition,
323
+ // making the full-form alternative dead.
324
+ const prefix = /^(?:::ffff:|(?:0:){5}ffff:)/i;
325
+ if (!prefix.test(ip)) return null;
326
+ const tail = ip.replace(prefix, '');
327
+ // Dotted-decimal tail: ::ffff:127.0.0.1
328
+ if (/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(tail)) return tail;
329
+ // Hex tail: ::ffff:7f00:1 (two 16-bit groups → four octets)
330
+ const hex = tail.match(/^([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i);
331
+ if (hex && hex[1] && hex[2]) {
332
+ const hi = parseInt(hex[1], 16);
333
+ const lo = parseInt(hex[2], 16);
334
+ return `${(hi >> 8) & 0xff}.${hi & 0xff}.${(lo >> 8) & 0xff}.${lo & 0xff}`;
335
+ }
336
+ return null;
337
+ }
338
+
339
+ /** True when the address is an IPv4-mapped IPv6 whose embedded IPv4 is loopback. */
340
+ function isMappedLoopback(ip: string): boolean {
341
+ const mapped = extractMappedIpv4(ip.toLowerCase().replace(/^\[|\]$/g, ''));
342
+ return mapped !== null && /^127\./.test(mapped);
343
+ }
344
+
345
+ /**
346
+ * Strip image links from markdown
347
+ */
348
+ export function stripImageLinks(markdown: string): string {
349
+ return markdown
350
+ .replace(MARKDOWN_IMAGE_PATTERN, '')
351
+ .replace(IMAGE_LINK_PATTERN, '$1')
352
+ .replace(/\n{3,}/g, '\n\n')
353
+ .trim();
354
+ }
355
+
356
+ /**
357
+ * Create native HTML to markdown converter
358
+ */
359
+ export function createNativeMarkdownConverter(
360
+ nativeModule: NativeHtmlToMarkdownModule | undefined,
361
+ ): (html: string) => Promise<string> {
362
+ return async (html: string): Promise<string> => {
363
+ if (!nativeModule || !nativeModule.convert || !nativeModule.HeadingStyle || !nativeModule.CodeBlockStyle) {
364
+ throw new Error('Native markdown converter not properly initialized');
365
+ }
366
+ const result = nativeModule.convert(html, {
367
+ headingStyle: nativeModule.HeadingStyle.Atx,
368
+ codeBlockStyle: nativeModule.CodeBlockStyle.Backticks,
369
+ wrap: false,
370
+ });
371
+ return stripImageLinks(result.content ?? '');
372
+ };
373
+ }
374
+
375
+ /**
376
+ * Create JS HTML to markdown converter (fallback)
377
+ */
378
+ export function createJsMarkdownConverter(): (html: string) => Promise<string> {
379
+ const converter = new NodeHtmlMarkdown({
380
+ codeBlockStyle: 'fenced',
381
+ textReplace: [[/\u00a0/g, ' ']],
382
+ ignore: [...FILTERED_TAGS],
383
+ });
384
+
385
+ return async (html: string): Promise<string> => {
386
+ const markdown = converter.translate(html);
387
+ return stripImageLinks(markdown);
388
+ };
389
+ }
390
+
391
+ /**
392
+ * Validate scraped content
393
+ */
394
+ export function validateContent(html: string, markdown: string, url: string): void {
395
+ const htmlLow = html.toLowerCase();
396
+ for (const [pattern, reason] of BOT_PATTERNS) {
397
+ // Compare against the lowercased pattern: htmlLow is already lowercased, so a
398
+ // mixed-case pattern (e.g. 'Just a moment...') would otherwise never match.
399
+ if (htmlLow.includes(pattern.toLowerCase())) {
400
+ metrics.increment('scrape_errors_total', 1, { error_type: 'bot_protection' });
401
+ const error = new Error(`Fetch blocked: ${reason}`);
402
+ errorTracker.trackError(error, {
403
+ component: 'scrapers',
404
+ operation: 'validate',
405
+ url,
406
+ domain: extractDomain(url),
407
+ errorType: 'bot_protection',
408
+ });
409
+ throw error;
410
+ }
411
+ }
412
+
413
+ const words = markdown.trim().split(/\s+/).filter(w => w.length > 0);
414
+ // Space-less scripts (Chinese/Japanese/Thai) have no inter-word whitespace, so a real article
415
+ // yields very few whitespace tokens. Gate the stub check on BOTH a low word count AND a low
416
+ // non-whitespace character count — otherwise a genuine CJK page was thrown away as a "stub",
417
+ // silently biasing research away from the non-Latin web. A true stub (nav-only / a few words)
418
+ // is short by both measures; a real CJK article clears the character threshold.
419
+ const charCount = markdown.replace(/\s+/g, '').length;
420
+ let stubCheckHostname = '';
421
+ try { stubCheckHostname = new URL(url).hostname; } catch { /* ignore */ }
422
+ if (words.length < 50 && charCount < 200 && stubCheckHostname !== 'example.com') {
423
+ metrics.increment('scrape_errors_total', 1, { error_type: 'stub_content' });
424
+ const error = new Error(`Fetch returned stub: only ${words.length} words / ${charCount} chars found.`);
425
+ errorTracker.trackError(error, {
426
+ component: 'scrapers',
427
+ operation: 'validate',
428
+ url,
429
+ domain: extractDomain(url),
430
+ errorType: 'stub_content',
431
+ });
432
+ throw error;
433
+ }
434
+ }