@lincoln504/pi-research 0.1.13 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +1 -1
- package/README.md +55 -138
- package/dist/cli.mjs +23375 -0
- package/dist/prompts/research-knowledge-relevance-triage.md +35 -0
- package/dist/prompts/research-knowledge-search-extractor.md +41 -0
- package/dist/prompts/research-tool-usage.md +92 -0
- package/dist/prompts/researcher.md +89 -0
- package/dist/prompts/system-coordinator.md +75 -0
- package/dist/prompts/system-lead-evaluator.md +118 -0
- package/dist/thread-worker.mjs +2611 -0
- package/dist/webgpu-probe.mjs +80 -0
- package/docs/AGENT-SKILL.md +130 -0
- package/docs/ARCHITECTURE.md +306 -80
- package/docs/CONFIGURATION.md +249 -0
- package/docs/KNOWLEDGE-STORE.md +211 -0
- package/docs/PI-EXTENSION.md +91 -0
- package/docs/SDK.md +122 -0
- package/docs/THIRD-PARTY-NOTICES.md +66 -0
- package/package.json +102 -36
- package/scripts/build.cjs +140 -0
- package/scripts/cleanup.cjs +149 -0
- package/scripts/prepare.cjs +28 -0
- package/scripts/setup.cjs +134 -0
- package/skills/pi-research/README.md +90 -0
- package/skills/pi-research/SKILL.md +102 -0
- package/skills/pi-research/references/configuration.md +77 -0
- package/skills/pi-research/scripts/run.mjs +237 -0
- package/src/cleanup/research-cleanup.ts +140 -0
- package/src/cli.ts +1195 -0
- package/src/config.ts +1032 -155
- package/src/constants.ts +115 -84
- package/src/core/health-check-service.ts +265 -0
- package/src/core/interfaces/health-check-interfaces.ts +50 -0
- package/src/core/interfaces/knowledge-interfaces.ts +190 -0
- package/src/core/interfaces/observer-interfaces.ts +67 -0
- package/src/core/interfaces/orchestration-interfaces.ts +86 -0
- package/src/core/interfaces/planning-interfaces.ts +100 -0
- package/src/core/interfaces/process-interfaces.ts +37 -0
- package/src/core/interfaces/research-plan-types.ts +52 -0
- package/src/core/interfaces/scheduler-interfaces.ts +91 -0
- package/src/core/interfaces/service-names.ts +39 -0
- package/src/core/interfaces/state-manager-interfaces.ts +29 -0
- package/src/core/interfaces/steering-interfaces.ts +31 -0
- package/src/core/llm/agentic-repair.ts +169 -0
- package/src/core/llm/inject-date.ts +52 -0
- package/src/core/llm/llm-timeout.ts +64 -0
- package/src/core/llm/llm-utils.ts +86 -0
- package/src/core/llm/model-registry-factory.ts +287 -0
- package/src/core/llm/pi-ai-completion.ts +29 -0
- package/src/core/llm/prompts.ts +50 -0
- package/src/core/llm/research-model-resolver.ts +91 -0
- package/src/core/planning-constants.ts +13 -0
- package/src/core/planning-service.ts +649 -0
- package/src/core/planning-utils.ts +299 -0
- package/src/core/scheduler-factory.ts +48 -0
- package/src/core/scheduler-service.ts +123 -0
- package/src/core/service-initialization.ts +194 -0
- package/src/core/service-interfaces.ts +48 -0
- package/src/core/service-registry.ts +659 -0
- package/src/healthcheck/index.ts +271 -265
- package/src/healthcheck/registry.ts +130 -0
- package/src/index.ts +639 -50
- package/src/infrastructure/browser/browser-cleanup.ts +309 -0
- package/src/infrastructure/browser/browser-client.ts +199 -0
- package/src/infrastructure/browser/browser-error-utils.ts +160 -0
- package/src/infrastructure/browser/browser-lifecycle.ts +120 -0
- package/src/infrastructure/browser/browser-server.ts +174 -0
- package/src/infrastructure/browser/browser-task-scheduler.ts +461 -0
- package/src/infrastructure/browser/cleanup-utils.ts +155 -0
- package/src/infrastructure/browser/client-agent.ts +31 -0
- package/src/infrastructure/browser/config.ts +288 -0
- package/src/infrastructure/browser/ensure-browser.ts +205 -0
- package/src/infrastructure/browser/index.ts +37 -0
- package/src/infrastructure/browser/priority-task-queue.ts +226 -0
- package/src/infrastructure/browser/scheduler-factory.ts +354 -0
- package/src/infrastructure/browser/task-execution-service.ts +209 -0
- package/src/infrastructure/browser/thread-worker-browser.ts +282 -0
- package/src/infrastructure/browser/thread-worker-lifecycle.ts +323 -0
- package/src/infrastructure/browser/thread-worker-messaging.ts +662 -0
- package/src/infrastructure/browser/thread-worker-types.ts +25 -0
- package/src/infrastructure/browser/thread-worker.mjs +2611 -0
- package/src/infrastructure/browser/thread-worker.ts +168 -0
- package/src/infrastructure/browser/worker-pool-manager.ts +449 -0
- package/src/infrastructure/embedding/embedding-client.ts +205 -0
- package/src/infrastructure/embedding/embedding-factory.ts +324 -0
- package/src/infrastructure/embedding/embedding-server.ts +535 -0
- package/src/infrastructure/file-lock-service.ts +592 -0
- package/src/infrastructure/gpu-resource-service.ts +191 -0
- package/src/infrastructure/knowledge-store-service.ts +475 -0
- package/src/infrastructure/metrics-service.ts +129 -0
- package/src/infrastructure/process-lifecycle-service.ts +253 -0
- package/src/infrastructure/scheduler-factory-service.ts +58 -0
- package/src/infrastructure/service-initialization.ts +292 -0
- package/src/infrastructure/state/state-backup-manager.ts +290 -0
- package/src/infrastructure/state/state-browser-api.ts +62 -0
- package/src/infrastructure/state/state-browser-manager.ts +70 -0
- package/src/infrastructure/state/state-manager-service.ts +264 -0
- package/src/infrastructure/state/state-manager.ts +547 -0
- package/src/infrastructure/state/state-metrics.ts +92 -0
- package/src/infrastructure/state/state-migration.ts +103 -0
- package/src/infrastructure/state/state-path-configuration.ts +119 -0
- package/src/infrastructure/state/state-session-api.ts +94 -0
- package/src/infrastructure/state/state-session-manager.ts +127 -0
- package/src/infrastructure/state/state-validator.ts +93 -0
- package/src/infrastructure/types/state-types.ts +85 -0
- package/src/knowledge/chunker.ts +157 -0
- package/src/knowledge/embedder-init.ts +413 -0
- package/src/knowledge/embedder-utils.ts +296 -0
- package/src/knowledge/embedder.ts +592 -0
- package/src/knowledge/index.ts +126 -0
- package/src/knowledge/lancedb-loader.ts +35 -0
- package/src/knowledge/migration.ts +17 -0
- package/src/knowledge/model-config.ts +132 -0
- package/src/knowledge/onnx-env.ts +48 -0
- package/src/knowledge/store-operations.ts +291 -0
- package/src/knowledge/store-schema.ts +79 -0
- package/src/knowledge/store.ts +1251 -0
- package/src/knowledge/transformers-loader.ts +31 -0
- package/src/knowledge/webgpu-probe.mjs +80 -0
- package/src/knowledge/webgpu-viability.ts +255 -0
- package/src/knowledge/writer-queue.ts +252 -0
- package/src/logger.ts +316 -122
- package/src/observers/research-observer-impl.ts +459 -0
- package/src/orchestration/deep-research-orchestrator.ts +524 -773
- package/src/orchestration/deep-research-types.ts +47 -35
- package/src/orchestration/headless-observer.ts +114 -0
- package/src/orchestration/orchestration-types.ts +33 -0
- package/src/orchestration/quick-research-orchestrator.ts +463 -0
- package/src/orchestration/research-orchestration-service.ts +549 -0
- package/src/orchestration/research-session-service.ts +178 -0
- package/src/orchestration/research-synthesis-service.ts +348 -0
- package/src/orchestration/researcher-executor.ts +394 -0
- package/src/orchestration/researcher.ts +115 -42
- package/src/orchestration/service-initialization.ts +109 -0
- package/src/orchestration/session-context.ts +7 -3
- package/src/orchestration/session-state.ts +672 -0
- package/src/prompts/research-knowledge-relevance-triage.md +35 -0
- package/src/prompts/research-knowledge-search-extractor.md +41 -0
- package/src/prompts/research-tool-usage.md +63 -16
- package/src/prompts/researcher.md +59 -136
- package/src/prompts/system-coordinator.md +64 -13
- package/src/prompts/system-lead-evaluator.md +82 -189
- package/src/research-config.ts +872 -0
- package/src/sdk.ts +847 -0
- package/src/security/cisa-kev.ts +72 -9
- package/src/security/cvss.ts +92 -0
- package/src/security/ecosystem.ts +45 -0
- package/src/security/github-advisories.ts +168 -428
- package/src/security/github-advisory-types.ts +282 -0
- package/src/security/index.ts +63 -177
- package/src/security/nvd-client.ts +550 -0
- package/src/security/nvd-types.ts +179 -0
- package/src/security/osv-client.ts +246 -0
- package/src/security/osv-types.ts +285 -0
- package/src/security/types.ts +67 -53
- package/src/skill-install/skill-installer.ts +519 -0
- package/src/stackexchange/index.ts +81 -27
- package/src/stackexchange/output/compact.ts +7 -6
- package/src/stackexchange/output/table.ts +11 -7
- package/src/stackexchange/queries.ts +0 -10
- package/src/stackexchange/rest-client.ts +141 -66
- package/src/stackexchange/types.ts +0 -17
- package/src/tool.ts +15 -423
- package/src/tools/grep.ts +32 -310
- package/src/tools/health-tool-definition.ts +101 -0
- package/src/tools/index.ts +38 -9
- package/src/tools/research-knowledge-search.ts +974 -0
- package/src/tools/research-knowledge-types.ts +77 -0
- package/src/tools/research-tool-definition.ts +494 -0
- package/src/tools/scrape.ts +286 -388
- package/src/tools/search.ts +98 -153
- package/src/tools/security.ts +107 -80
- package/src/tools/stackexchange.ts +58 -35
- package/src/tools/youtube-transcript.ts +163 -0
- package/src/tui/knowledge-search-panel.ts +86 -0
- package/src/tui/pulse.ts +86 -0
- package/src/tui/research-health.ts +124 -0
- package/src/tui/research-panel-color-utils.ts +340 -0
- package/src/tui/research-panel-format.ts +54 -0
- package/src/tui/research-panel-state.ts +404 -0
- package/src/tui/research-panel-wave.ts +280 -0
- package/src/tui/research-panel.ts +339 -435
- package/src/tui/research-tui-manager.ts +233 -0
- package/src/tui/tui-controller.ts +137 -0
- package/src/types/extension-context.ts +18 -49
- package/src/types/index.ts +61 -0
- package/src/types/llm.ts +68 -29
- package/src/types/research-panel-types.ts +61 -0
- package/src/utils/circuit-breaker.ts +146 -0
- package/src/utils/citation-utils.ts +97 -0
- package/src/utils/disk-space-checker.ts +55 -0
- package/src/utils/error-tracker.ts +265 -0
- package/src/utils/host-config.ts +45 -0
- package/src/utils/input-validation.ts +8 -10
- package/src/utils/json-utils.ts +150 -16
- package/src/utils/llm-usage.ts +54 -0
- package/src/utils/log-rotation.ts +125 -0
- package/src/utils/log-utils.ts +159 -0
- package/src/utils/make-resource-loader.ts +3 -1
- package/src/utils/metrics-summary.ts +459 -0
- package/src/utils/metrics.ts +302 -0
- package/src/utils/research-export.ts +28 -12
- package/src/utils/safe-unref.ts +31 -0
- package/src/utils/shared-links.ts +195 -90
- package/src/utils/shutdown-manager.ts +65 -0
- package/src/utils/stdio-capture.ts +407 -0
- package/src/utils/text-utils.ts +258 -11
- package/src/utils/tool-usage-tracker.ts +83 -16
- package/src/utils/url-utils.ts +255 -0
- package/src/utils/user-agent.ts +28 -0
- package/src/web-research/browser-search.ts +179 -0
- package/src/web-research/retry-utils.ts +173 -77
- package/src/web-research/scraper-types.ts +72 -0
- package/src/web-research/scraper-utils.ts +434 -0
- package/src/web-research/search.ts +78 -218
- package/src/web-research/types.ts +9 -24
- package/src/web-research/utils.ts +2 -143
- package/src/web-research/web-scraper.ts +477 -0
- package/src/youtube/index.ts +154 -0
- package/src/youtube/potoken.ts +259 -0
- package/src/youtube/transcript-client.ts +390 -0
- package/src/youtube/video-id.ts +118 -0
- package/config/searxng/default-settings.yml +0 -189
- package/config/searxng/limiter.toml +0 -34
- package/config/tooling/eslint.config.js +0 -73
- package/config/tooling/vitest.config.integration.ts +0 -17
- package/config/tooling/vitest.config.ts +0 -40
- package/config/tooling/vitest.config.unit.ts +0 -12
- package/docs/assets/README-banner.jpg +0 -0
- package/scripts/setup.js +0 -212
- package/src/infrastructure/searxng-lifecycle.ts +0 -644
- package/src/infrastructure/searxng-manager.ts +0 -1183
- package/src/infrastructure/state-manager.ts +0 -918
- package/src/orchestration/deep-research-reducer.ts +0 -129
- package/src/orchestration/id-utils.ts +0 -63
- package/src/security/nvd.ts +0 -532
- package/src/security/osv.ts +0 -487
- package/src/stackexchange/cache.ts +0 -111
- package/src/stackexchange/output/index.ts +0 -7
- package/src/stackexchange/output/json.ts +0 -21
- package/src/tui/searxng-status.ts +0 -80
- package/src/utils/inject-date.ts +0 -29
- package/src/utils/research-error.ts +0 -205
- package/src/utils/searxng-config.ts +0 -94
- package/src/utils/session-state.ts +0 -295
- package/src/web-research/dependencies.ts +0 -20
- package/src/web-research/scrapers.ts +0 -935
- 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
|
+
}
|