@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.
- package/LICENSE +1 -1
- package/README.md +64 -115
- package/dist/cli.mjs +23211 -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 +102 -0
- package/docs/ARCHITECTURE.md +306 -80
- package/docs/CONFIGURATION.md +249 -0
- package/docs/KNOWLEDGE-STORE.md +213 -0
- package/docs/PI-EXTENSION.md +91 -0
- package/docs/SDK.md +113 -0
- package/docs/THIRD-PARTY-NOTICES.md +64 -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 +1044 -0
- package/src/config.ts +1036 -147
- 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 +264 -7
- 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 -174
- package/src/infrastructure/searxng-lifecycle.ts +0 -577
- 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
|
@@ -1,935 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Web Research Extension - Scrapers
|
|
3
|
-
*
|
|
4
|
-
* 2-layer scraping architecture:
|
|
5
|
-
* Layer 1: fetch (Node built-in, concurrent, no browser overhead)
|
|
6
|
-
* Layer 2: Playwright + Chromium (standard headless, for JS-heavy sites)
|
|
7
|
-
*
|
|
8
|
-
* Native HTML-to-Markdown conversion is preferred when available because it
|
|
9
|
-
* offers the best filtering control. On macOS, the upstream native package is
|
|
10
|
-
* currently published without the actual binary artifact, so we lazily fall
|
|
11
|
-
* back to a pure-JS converter instead of failing module load.
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
import {
|
|
15
|
-
PRIMARY_SCRAPER_TIMEOUT,
|
|
16
|
-
FALLBACK_SCRAPER_TIMEOUT,
|
|
17
|
-
type ScrapeLayerResult,
|
|
18
|
-
} from './types.ts';
|
|
19
|
-
import {
|
|
20
|
-
trackContext,
|
|
21
|
-
untrackContextById,
|
|
22
|
-
clearTrackedContexts,
|
|
23
|
-
checkModule,
|
|
24
|
-
} from './utils.ts';
|
|
25
|
-
import { logger } from '../logger.ts';
|
|
26
|
-
import { NodeHtmlMarkdown } from 'node-html-markdown';
|
|
27
|
-
import { createRequire } from 'module';
|
|
28
|
-
|
|
29
|
-
const require = createRequire(import.meta.url);
|
|
30
|
-
|
|
31
|
-
// ============================================================================
|
|
32
|
-
// Type Definitions
|
|
33
|
-
// ============================================================================
|
|
34
|
-
|
|
35
|
-
// AbortSignal.any() and AbortSignal.timeout() are Node.js 20+ features
|
|
36
|
-
|
|
37
|
-
// Playwright types (since module is optional/dynamically loaded)
|
|
38
|
-
interface LaunchOptions {
|
|
39
|
-
headless: boolean;
|
|
40
|
-
args: string[];
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
interface Browser {
|
|
44
|
-
isConnected(): boolean;
|
|
45
|
-
close(): Promise<void>;
|
|
46
|
-
|
|
47
|
-
newContext(options?: { viewport: unknown }): Promise<BrowserContext>;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
interface BrowserContext {
|
|
51
|
-
newPage(): Promise<Page>;
|
|
52
|
-
close(): Promise<void>;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
interface Page {
|
|
56
|
-
|
|
57
|
-
goto(url: string, options: { waitUntil: string; timeout: number }): Promise<void>;
|
|
58
|
-
content(): Promise<string>;
|
|
59
|
-
close(): Promise<void>;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
interface PlaywrightModule {
|
|
63
|
-
chromium: {
|
|
64
|
-
|
|
65
|
-
launch(options: LaunchOptions): Promise<Browser>;
|
|
66
|
-
};
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
import { shutdownManager } from '../utils/shutdown-manager.ts';
|
|
70
|
-
|
|
71
|
-
// ============================================================================
|
|
72
|
-
// Module State
|
|
73
|
-
// ============================================================================
|
|
74
|
-
|
|
75
|
-
let playwrightAvailable: boolean = false;
|
|
76
|
-
let markdownConverterPromise: Promise<(html: string) => Promise<string>> | null = null;
|
|
77
|
-
|
|
78
|
-
interface NativeJsNodeContext {
|
|
79
|
-
tagName: string;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
interface NativeHtmlToMarkdownModule {
|
|
83
|
-
convertWithVisitor(
|
|
84
|
-
html: string,
|
|
85
|
-
options: {
|
|
86
|
-
headingStyle: unknown;
|
|
87
|
-
codeBlockStyle: unknown;
|
|
88
|
-
wrap: boolean;
|
|
89
|
-
},
|
|
90
|
-
visitor: Record<string, (_ctxJson?: string) => Promise<string>>,
|
|
91
|
-
): Promise<string>;
|
|
92
|
-
JsHeadingStyle: { Atx: unknown };
|
|
93
|
-
JsCodeBlockStyle: { Backticks: unknown };
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
const FILTERED_TAGS = [
|
|
97
|
-
'nav', 'header', 'footer', 'aside',
|
|
98
|
-
'script', 'style', 'noscript',
|
|
99
|
-
'form', 'input', 'select', 'textarea', 'button',
|
|
100
|
-
'object', 'embed',
|
|
101
|
-
'svg', 'symbol', 'use', 'defs', 'path', 'circle', 'rect', 'line', 'polygon',
|
|
102
|
-
'img', 'iframe',
|
|
103
|
-
] as const;
|
|
104
|
-
|
|
105
|
-
const IMAGE_LINK_PATTERN = /\[([^\]]*)\]\((data:image\/[^)]+|[^)\s]+\.(?:svg|png|jpe?g|gif|webp|bmp|ico)(?:\?[^)]*)?)\)/gi;
|
|
106
|
-
const MARKDOWN_IMAGE_PATTERN = /!\[[^\]]*]\((?:data:image\/[^)]+|[^)\s]+)\)/gi;
|
|
107
|
-
let scraperDependenciesInitialized = false;
|
|
108
|
-
|
|
109
|
-
export function initScraperDependencies(): void {
|
|
110
|
-
if (scraperDependenciesInitialized) {
|
|
111
|
-
return;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
scraperDependenciesInitialized = true;
|
|
115
|
-
playwrightAvailable = checkModule('playwright');
|
|
116
|
-
shutdownManager.register(stopChromium);
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
initScraperDependencies();
|
|
120
|
-
|
|
121
|
-
// ============================================================================
|
|
122
|
-
// Chromium singleton browser
|
|
123
|
-
//
|
|
124
|
-
// One Chromium process is shared across all scrape calls for the lifetime of
|
|
125
|
-
// the session. Playwright manages the child process; each scrape opens its own
|
|
126
|
-
// isolated BrowserContext+Page and closes them when done — the browser itself
|
|
127
|
-
// stays alive. This eliminates the ~1-2s Chromium startup cost per tool call.
|
|
128
|
-
//
|
|
129
|
-
// Lifecycle:
|
|
130
|
-
// - Lazily started on first Layer 2 scrape attempt
|
|
131
|
-
// - stopChromium() is registered with the extension cleanup registry
|
|
132
|
-
// - pi triggers that cleanup from session_shutdown
|
|
133
|
-
// - On /reload, the old browser var is reset to null; the old Chromium process
|
|
134
|
-
// is an orphan but Playwright registers an on-exit handler that kills it when
|
|
135
|
-
// the Node process exits — acceptable bounded leak for the /reload case
|
|
136
|
-
// ============================================================================
|
|
137
|
-
|
|
138
|
-
let sharedBrowser: Browser | null = null;
|
|
139
|
-
let sharedBrowserLaunchPromise: Promise<Browser> | null = null;
|
|
140
|
-
|
|
141
|
-
function getBrowser(): Promise<Browser> {
|
|
142
|
-
// Return existing connected browser
|
|
143
|
-
if (sharedBrowser?.isConnected()) {
|
|
144
|
-
return Promise.resolve(sharedBrowser);
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
// Coalesce concurrent launch requests into one Promise
|
|
148
|
-
if (sharedBrowserLaunchPromise !== null) {
|
|
149
|
-
return sharedBrowserLaunchPromise;
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
if (sharedBrowser !== null) {
|
|
153
|
-
// Browser existed but lost connection — relaunch
|
|
154
|
-
logger.log('[Scrapers] Chromium disconnected, relaunching...');
|
|
155
|
-
sharedBrowser = null;
|
|
156
|
-
} else {
|
|
157
|
-
logger.log('[Scrapers] Launching Chromium...');
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
sharedBrowserLaunchPromise = (async (): Promise<Browser> => {
|
|
161
|
-
try {
|
|
162
|
-
const playwright = require('playwright') as PlaywrightModule;
|
|
163
|
-
sharedBrowser = await playwright.chromium.launch({
|
|
164
|
-
headless: true,
|
|
165
|
-
args: ['--no-sandbox', '--disable-setuid-sandbox'],
|
|
166
|
-
});
|
|
167
|
-
logger.log('[Scrapers] Chromium launched');
|
|
168
|
-
return sharedBrowser;
|
|
169
|
-
} finally {
|
|
170
|
-
// Always clear the promise — success or failure — so the next call
|
|
171
|
-
// retries the launch rather than returning a permanently cached rejection.
|
|
172
|
-
sharedBrowserLaunchPromise = null;
|
|
173
|
-
}
|
|
174
|
-
})();
|
|
175
|
-
|
|
176
|
-
return sharedBrowserLaunchPromise;
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
export async function stopChromium(): Promise<void> {
|
|
180
|
-
const b = sharedBrowser;
|
|
181
|
-
const p = sharedBrowserLaunchPromise;
|
|
182
|
-
|
|
183
|
-
if (b === null && p === null) {
|
|
184
|
-
return; // Nothing to stop
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
logger.log('[Scrapers] Stopping Chromium...');
|
|
188
|
-
|
|
189
|
-
// Capture and null both references before any await so concurrent calls
|
|
190
|
-
// cannot double-close.
|
|
191
|
-
sharedBrowser = null;
|
|
192
|
-
sharedBrowserLaunchPromise = null;
|
|
193
|
-
|
|
194
|
-
// Clear all tracked contexts since they become invalid when browser closes
|
|
195
|
-
// This prevents memory leaks and stale references
|
|
196
|
-
clearTrackedContexts();
|
|
197
|
-
|
|
198
|
-
if (b !== null) {
|
|
199
|
-
await b.close().catch(() => {});
|
|
200
|
-
} else if (p !== null) {
|
|
201
|
-
// A launch was in-progress; wait for it, then close what it produced.
|
|
202
|
-
const launched = await p.catch(() => null);
|
|
203
|
-
if (launched !== null) {
|
|
204
|
-
await launched.close().catch(() => {});
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
logger.log('[Scrapers] Chromium stopped');
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
/**
|
|
212
|
-
* Convert HTML to Markdown using visitor pattern for element-level filtering
|
|
213
|
-
* Removes: images (including base64 SVG), iframes, nav, header, footer, aside, form, script, style, object, embed
|
|
214
|
-
* Preserves: main content, headings, paragraphs, lists, code blocks, tables, links
|
|
215
|
-
*
|
|
216
|
-
* Uses @kreuzberg/html-to-markdown-node with proper JSON handling for visitor callbacks.
|
|
217
|
-
* The native NAPI bindings expect callbacks with signature: (jsonString: string) => Promise<string>
|
|
218
|
-
*/
|
|
219
|
-
async function convertToMarkdown(html: string): Promise<string> {
|
|
220
|
-
const converter = await getMarkdownConverter();
|
|
221
|
-
return converter(html);
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
async function getMarkdownConverter(): Promise<(html: string) => Promise<string>> {
|
|
225
|
-
if (markdownConverterPromise !== null) {
|
|
226
|
-
return markdownConverterPromise;
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
markdownConverterPromise = (async () => {
|
|
230
|
-
try {
|
|
231
|
-
const nativeModule = await import('@kreuzberg/html-to-markdown-node') as NativeHtmlToMarkdownModule;
|
|
232
|
-
logger.debug('[Scrapers] Using native HTML-to-Markdown converter');
|
|
233
|
-
return createNativeMarkdownConverter(nativeModule);
|
|
234
|
-
} catch (error) {
|
|
235
|
-
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
236
|
-
logger.warn(`[Scrapers] Native HTML-to-Markdown unavailable, falling back to pure JS converter: ${errorMessage}`);
|
|
237
|
-
return createJsMarkdownConverter();
|
|
238
|
-
}
|
|
239
|
-
})();
|
|
240
|
-
|
|
241
|
-
return markdownConverterPromise;
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
function createNativeMarkdownConverter(
|
|
245
|
-
nativeModule: NativeHtmlToMarkdownModule,
|
|
246
|
-
): (html: string) => Promise<string> {
|
|
247
|
-
// Visitor object for element-level filtering
|
|
248
|
-
// Each method receives JSON context string and must return JSON result string
|
|
249
|
-
const visitor = {
|
|
250
|
-
/**
|
|
251
|
-
* Skip all images (including base64-encoded SVG, PNG, etc.)
|
|
252
|
-
* Primary source of noise in web-scraped content
|
|
253
|
-
*/
|
|
254
|
-
|
|
255
|
-
async visitImage(): Promise<string> {
|
|
256
|
-
return JSON.stringify({ type: 'skip' });
|
|
257
|
-
},
|
|
258
|
-
|
|
259
|
-
/**
|
|
260
|
-
* Skip links that point to image data URIs (SVG, PNG, etc.)
|
|
261
|
-
* These appear as [text](data:image/svg+xml;base64,...) in markdown output
|
|
262
|
-
*/
|
|
263
|
-
|
|
264
|
-
async visitLink(ctxJson?: string): Promise<string> {
|
|
265
|
-
const parsed = JSON.parse(ctxJson ?? '{}') as { href?: string };
|
|
266
|
-
const href = parsed.href;
|
|
267
|
-
if (href !== undefined && (
|
|
268
|
-
href.startsWith('data:image/svg+xml') ||
|
|
269
|
-
href.startsWith('data:image/png') ||
|
|
270
|
-
href.startsWith('data:image/jpeg') ||
|
|
271
|
-
href.startsWith('data:image/gif') ||
|
|
272
|
-
href.startsWith('data:image/webp') ||
|
|
273
|
-
href.startsWith('data:image/') ||
|
|
274
|
-
href.match(/\.(svg|png|jpg|jpeg|gif|webp|bmp|ico)$/i) !== null
|
|
275
|
-
)) {
|
|
276
|
-
return JSON.stringify({ type: 'skip' });
|
|
277
|
-
}
|
|
278
|
-
return JSON.stringify({ type: 'continue' });
|
|
279
|
-
},
|
|
280
|
-
|
|
281
|
-
/**
|
|
282
|
-
* Skip all iframes (embedded content, videos, ads)
|
|
283
|
-
*/
|
|
284
|
-
|
|
285
|
-
async visitIframe(): Promise<string> {
|
|
286
|
-
return JSON.stringify({ type: 'skip' });
|
|
287
|
-
},
|
|
288
|
-
|
|
289
|
-
/**
|
|
290
|
-
* Filter structural elements before processing their content
|
|
291
|
-
* Removes navigation, headers, footers, sidebars, and other boilerplate
|
|
292
|
-
* CRITICAL: Also filters inline <svg> elements which are converted to base64 images by html-to-markdown
|
|
293
|
-
* visitImage callback is NOT triggered for inline <svg> elements
|
|
294
|
-
*/
|
|
295
|
-
|
|
296
|
-
async visitElementStart(ctxJson?: string): Promise<string> {
|
|
297
|
-
// Parse JSON context string to object
|
|
298
|
-
const ctx = JSON.parse(ctxJson ?? '{}') as Partial<NativeJsNodeContext>;
|
|
299
|
-
if (ctx.tagName !== undefined && (FILTERED_TAGS as readonly string[]).includes(ctx.tagName)) {
|
|
300
|
-
return JSON.stringify({ type: 'skip' });
|
|
301
|
-
}
|
|
302
|
-
return JSON.stringify({ type: 'continue' });
|
|
303
|
-
},
|
|
304
|
-
};
|
|
305
|
-
|
|
306
|
-
return async (html: string): Promise<string> => {
|
|
307
|
-
const markdown = await nativeModule.convertWithVisitor(html, {
|
|
308
|
-
headingStyle: nativeModule.JsHeadingStyle.Atx,
|
|
309
|
-
codeBlockStyle: nativeModule.JsCodeBlockStyle.Backticks,
|
|
310
|
-
wrap: false,
|
|
311
|
-
}, visitor);
|
|
312
|
-
return stripImageLinks(markdown);
|
|
313
|
-
};
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
function createJsMarkdownConverter(): (html: string) => Promise<string> {
|
|
317
|
-
const converter = new NodeHtmlMarkdown({
|
|
318
|
-
codeBlockStyle: 'fenced',
|
|
319
|
-
textReplace: [
|
|
320
|
-
[/\u00a0/g, ' '],
|
|
321
|
-
],
|
|
322
|
-
ignore: [...FILTERED_TAGS],
|
|
323
|
-
});
|
|
324
|
-
|
|
325
|
-
return async (html: string): Promise<string> => {
|
|
326
|
-
const markdown = converter.translate(html);
|
|
327
|
-
return stripImageLinks(markdown);
|
|
328
|
-
};
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
function stripImageLinks(markdown: string): string {
|
|
332
|
-
return markdown
|
|
333
|
-
.replace(MARKDOWN_IMAGE_PATTERN, '')
|
|
334
|
-
.replace(IMAGE_LINK_PATTERN, '$1')
|
|
335
|
-
.replace(/\n{3,}/g, '\n\n')
|
|
336
|
-
.trim();
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
// ============================================================================
|
|
340
|
-
// Fetch content quality validation
|
|
341
|
-
//
|
|
342
|
-
// fetch() succeeds on pages that are HTTP 200 but contain no useful content:
|
|
343
|
-
// bot-detection challenges, JS-required stubs, login redirects, or empty shells.
|
|
344
|
-
// These must fail fast so the URL falls through to Chromium rather than
|
|
345
|
-
// returning garbage markdown to the caller.
|
|
346
|
-
//
|
|
347
|
-
// Checks (in order):
|
|
348
|
-
// 1. Structural bot/challenge fingerprints in raw HTML — machine-generated
|
|
349
|
-
// CDN markers that cannot appear in legitimate content
|
|
350
|
-
// 2. Markdown word count < MIN_WORD_COUNT — the primary gate; catches
|
|
351
|
-
// challenge pages, JS stubs, and login redirects with zero maintenance
|
|
352
|
-
// ============================================================================
|
|
353
|
-
|
|
354
|
-
// Minimum word count after HTML→markdown conversion.
|
|
355
|
-
// Challenge pages, JS stubs, and login redirects produce almost nothing after
|
|
356
|
-
// our visitor strips nav/header/footer/script/style. Real content pages have
|
|
357
|
-
// hundreds to thousands of words. 100 catches most bad pages while being
|
|
358
|
-
// lenient enough for short API docs and brief documentation pages.
|
|
359
|
-
const MIN_WORD_COUNT = 100;
|
|
360
|
-
|
|
361
|
-
// Domain-specific minimum word counts for known trusted sources.
|
|
362
|
-
// These domains are known to produce legitimate content even with fewer words.
|
|
363
|
-
// For example, Wikipedia summaries, API docs, and technical references often
|
|
364
|
-
// have concise but valuable content.
|
|
365
|
-
const DOMAIN_THRESHOLDS: Readonly<Record<string, number>> = {
|
|
366
|
-
'wikipedia.org': 30,
|
|
367
|
-
'en.wikipedia.org': 30,
|
|
368
|
-
'docs.python.org': 40,
|
|
369
|
-
'developer.mozilla.org': 40,
|
|
370
|
-
'example.com': 20, // For testing purposes
|
|
371
|
-
'httpbin.org': 20, // Testing endpoints
|
|
372
|
-
};
|
|
373
|
-
|
|
374
|
-
// Helper: Extract hostname from URL and check for domain-specific threshold
|
|
375
|
-
function getDomainThreshold(url: string): number {
|
|
376
|
-
try {
|
|
377
|
-
const urlObj = new globalThis.URL(url);
|
|
378
|
-
const hostname = urlObj.hostname.toLowerCase();
|
|
379
|
-
|
|
380
|
-
// Check for exact match or subdomain match
|
|
381
|
-
for (const [domain, threshold] of Object.entries(DOMAIN_THRESHOLDS)) {
|
|
382
|
-
if (hostname === domain || hostname.endsWith(`.${domain}`)) {
|
|
383
|
-
logger.log(`[Scrapers] Using domain threshold ${threshold} for ${hostname}`);
|
|
384
|
-
return threshold;
|
|
385
|
-
}
|
|
386
|
-
}
|
|
387
|
-
} catch {
|
|
388
|
-
// Invalid URL, use default
|
|
389
|
-
}
|
|
390
|
-
return MIN_WORD_COUNT;
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
// Machine-generated structural fingerprints only — these are CDN/service
|
|
394
|
-
// infrastructure markers that cannot appear in legitimate page content and
|
|
395
|
-
// are stable across service updates. No human-readable text patterns here
|
|
396
|
-
// (false positive risk, English-only, covered by word count anyway).
|
|
397
|
-
const BOT_PATTERNS: ReadonlyArray<[string, string]> = [
|
|
398
|
-
['_cf_chl_', 'Cloudflare challenge'],
|
|
399
|
-
['cdn-cgi/challenge-platform', 'Cloudflare challenge platform'],
|
|
400
|
-
['ddos-guard', 'DDoS-Guard challenge'],
|
|
401
|
-
];
|
|
402
|
-
|
|
403
|
-
function validateContent(html: string, markdown: string, url: string): void {
|
|
404
|
-
// 1. Structural bot/challenge fingerprints in raw HTML
|
|
405
|
-
const htmlLow = html.toLowerCase();
|
|
406
|
-
for (const [pattern, reason] of BOT_PATTERNS) {
|
|
407
|
-
if (htmlLow.includes(pattern)) {
|
|
408
|
-
throw new Error(`Fetch blocked: ${reason} — page requires a real browser`);
|
|
409
|
-
}
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
// 2. Content too thin after markdown conversion — catches JS stubs, challenge
|
|
413
|
-
// pages, login redirects, and any other page that renders little real content
|
|
414
|
-
const words = markdown.trim().split(/\s+/).filter(w => w.length > 0);
|
|
415
|
-
const threshold = getDomainThreshold(url);
|
|
416
|
-
if (words.length < threshold) {
|
|
417
|
-
throw new Error(`Fetch returned stub: only ${words.length} words in markdown (min ${threshold} for this domain) — likely JS-rendered or gated`);
|
|
418
|
-
}
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
/**
|
|
422
|
-
* Layer 1: fetch (Node built-in)
|
|
423
|
-
*
|
|
424
|
-
* Fast, concurrent, zero browser overhead. Works for any static or server-rendered
|
|
425
|
-
* HTML page. Falls through to Chromium for JS-heavy or gated pages.
|
|
426
|
-
*
|
|
427
|
-
* Concurrency: each call is an independent async HTTP request on the event loop.
|
|
428
|
-
* No per-connection state is shared, so many can run in parallel safely.
|
|
429
|
-
*
|
|
430
|
-
* Timeout: uses AbortSignal.any() to combine the caller's abort signal with a
|
|
431
|
-
* per-request timeout, so cancellation is immediate regardless of which fires first.
|
|
432
|
-
*/
|
|
433
|
-
|
|
434
|
-
async function scrapeWithFetch(url: string, signal?: AbortSignal): Promise<ScrapeLayerResult> {
|
|
435
|
-
// Helper function to create timeout signal with Node.js compatibility
|
|
436
|
-
const createTimeoutSignal = (timeoutMs: number): AbortSignal => {
|
|
437
|
-
if (typeof AbortSignal.timeout === 'function') {
|
|
438
|
-
// Node.js 20+
|
|
439
|
-
return AbortSignal.timeout(timeoutMs);
|
|
440
|
-
} else {
|
|
441
|
-
// Fallback for older Node.js versions
|
|
442
|
-
const controller = new AbortController();
|
|
443
|
-
const timeoutId = setTimeout(() => {
|
|
444
|
-
controller.abort();
|
|
445
|
-
}, timeoutMs);
|
|
446
|
-
// Ensure timeout is cleared if signal is aborted externally
|
|
447
|
-
if (signal !== undefined) {
|
|
448
|
-
signal.addEventListener('abort', () => {
|
|
449
|
-
clearTimeout(timeoutId);
|
|
450
|
-
}, { once: true });
|
|
451
|
-
}
|
|
452
|
-
// Unref so it doesn't keep the process alive
|
|
453
|
-
if (typeof timeoutId.unref === 'function') {
|
|
454
|
-
timeoutId.unref();
|
|
455
|
-
}
|
|
456
|
-
return controller.signal;
|
|
457
|
-
}
|
|
458
|
-
};
|
|
459
|
-
|
|
460
|
-
const timeoutSignal = createTimeoutSignal(PRIMARY_SCRAPER_TIMEOUT);
|
|
461
|
-
const fetchSignal = signal !== undefined && typeof AbortSignal.any === 'function'
|
|
462
|
-
? AbortSignal.any([signal, timeoutSignal])
|
|
463
|
-
: timeoutSignal;
|
|
464
|
-
|
|
465
|
-
let response: Response;
|
|
466
|
-
try {
|
|
467
|
-
response = await fetch(url, {
|
|
468
|
-
signal: fetchSignal,
|
|
469
|
-
redirect: 'follow',
|
|
470
|
-
headers: {
|
|
471
|
-
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
|
|
472
|
-
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
|
473
|
-
'Accept-Language': 'en-US,en;q=0.5',
|
|
474
|
-
},
|
|
475
|
-
});
|
|
476
|
-
} catch (error) {
|
|
477
|
-
// Handle network errors, DNS failures, connection refused, etc.
|
|
478
|
-
if (error instanceof Error) {
|
|
479
|
-
if (error.name === 'AbortError') {
|
|
480
|
-
throw new Error(`Fetch timeout: ${error.message}`, { cause: error });
|
|
481
|
-
}
|
|
482
|
-
throw new Error(`Network error: ${error.message}`, { cause: error });
|
|
483
|
-
}
|
|
484
|
-
throw new Error(`Network error: ${String(error)}`, { cause: error });
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
if (!response.ok) {
|
|
488
|
-
throw new Error(`HTTP ${response.status} ${response.statusText}`);
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
const contentType = response.headers.get('content-type') ?? '';
|
|
492
|
-
if (!contentType.includes('text/html') && !contentType.includes('application/xhtml')) {
|
|
493
|
-
throw new Error(`Non-HTML content-type: ${contentType}`);
|
|
494
|
-
}
|
|
495
|
-
|
|
496
|
-
const html = await response.text();
|
|
497
|
-
let markdown: string;
|
|
498
|
-
try {
|
|
499
|
-
markdown = await convertToMarkdown(html);
|
|
500
|
-
} catch (error) {
|
|
501
|
-
throw new Error(`HTML-to-markdown conversion failed: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
validateContent(html, markdown, url);
|
|
505
|
-
|
|
506
|
-
return { source: 'fetch', layer: 'fetch', markdown };
|
|
507
|
-
}
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
/**
|
|
511
|
-
* Layer 2: Playwright + Chromium (shared singleton browser)
|
|
512
|
-
*
|
|
513
|
-
* Uses the module-level shared Chromium browser. Opens an isolated
|
|
514
|
-
* BrowserContext+Page, closes them when done, but leaves the browser running.
|
|
515
|
-
*/
|
|
516
|
-
|
|
517
|
-
async function scrapeWithChromium(_url: string, signal?: AbortSignal): Promise<ScrapeLayerResult> {
|
|
518
|
-
if (signal?.aborted) {
|
|
519
|
-
// Create an abort error using standard Error with the correct name
|
|
520
|
-
const error = new Error('Aborted') as Error & { name: string; cause?: unknown };
|
|
521
|
-
error.name = 'AbortError';
|
|
522
|
-
error.cause = signal;
|
|
523
|
-
throw error;
|
|
524
|
-
}
|
|
525
|
-
|
|
526
|
-
const browser = await getBrowser();
|
|
527
|
-
|
|
528
|
-
// Re-check after the async browser acquisition — signal may have fired during launch
|
|
529
|
-
if (signal?.aborted) {
|
|
530
|
-
const error = new Error('Aborted') as Error & { name: string; cause?: unknown };
|
|
531
|
-
error.name = 'AbortError';
|
|
532
|
-
error.cause = signal;
|
|
533
|
-
throw error;
|
|
534
|
-
}
|
|
535
|
-
|
|
536
|
-
let context: BrowserContext | null = null;
|
|
537
|
-
let page: Page | null = null;
|
|
538
|
-
let contextId: string | null = null;
|
|
539
|
-
let abortListener: (() => void) | null = null;
|
|
540
|
-
|
|
541
|
-
if (signal !== undefined) {
|
|
542
|
-
abortListener = (): void => {
|
|
543
|
-
// Close page then context to release browser resources on abort.
|
|
544
|
-
// Both may be null if abort fires before or during their creation.
|
|
545
|
-
if (page !== null) {
|
|
546
|
-
page.close().catch(() => {});
|
|
547
|
-
}
|
|
548
|
-
if (context !== null) {
|
|
549
|
-
context.close().catch(() => {});
|
|
550
|
-
}
|
|
551
|
-
};
|
|
552
|
-
signal.addEventListener('abort', abortListener, { once: true });
|
|
553
|
-
}
|
|
554
|
-
|
|
555
|
-
try {
|
|
556
|
-
context = await browser.newContext({ viewport: null });
|
|
557
|
-
page = await context.newPage();
|
|
558
|
-
|
|
559
|
-
contextId = trackContext(browser, context, page);
|
|
560
|
-
|
|
561
|
-
await page.goto(_url, { waitUntil: 'domcontentloaded', timeout: FALLBACK_SCRAPER_TIMEOUT });
|
|
562
|
-
|
|
563
|
-
const html = await page.content();
|
|
564
|
-
let markdown: string;
|
|
565
|
-
try {
|
|
566
|
-
markdown = await convertToMarkdown(html);
|
|
567
|
-
} catch (error) {
|
|
568
|
-
throw new Error(`HTML-to-markdown conversion failed: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
569
|
-
}
|
|
570
|
-
|
|
571
|
-
// Validate content quality — bot-challenge pages served to a real browser
|
|
572
|
-
// (cookie consent walls, DDoS-Guard JS challenges) still produce garbage
|
|
573
|
-
// markdown; better to surface an error than silently return a stub.
|
|
574
|
-
validateContent(html, markdown, _url);
|
|
575
|
-
|
|
576
|
-
return {
|
|
577
|
-
source: 'playwright',
|
|
578
|
-
layer: 'playwright+chromium',
|
|
579
|
-
markdown,
|
|
580
|
-
};
|
|
581
|
-
} finally {
|
|
582
|
-
// Remove abort listener if it was added and never fired
|
|
583
|
-
// Note: With { once: true }, the listener auto-removes when it fires
|
|
584
|
-
if (signal !== undefined && abortListener !== null) {
|
|
585
|
-
try {
|
|
586
|
-
signal.removeEventListener('abort', abortListener);
|
|
587
|
-
} catch {
|
|
588
|
-
// Listener might have already fired and auto-removed
|
|
589
|
-
}
|
|
590
|
-
}
|
|
591
|
-
|
|
592
|
-
if (contextId !== null) {
|
|
593
|
-
untrackContextById(contextId);
|
|
594
|
-
}
|
|
595
|
-
|
|
596
|
-
// Close page and context if they exist
|
|
597
|
-
if (page !== null) {
|
|
598
|
-
await page.close().catch(() => {});
|
|
599
|
-
}
|
|
600
|
-
if (context !== null) {
|
|
601
|
-
await context.close().catch(() => {});
|
|
602
|
-
}
|
|
603
|
-
// browser is intentionally NOT closed here
|
|
604
|
-
}
|
|
605
|
-
|
|
606
|
-
}
|
|
607
|
-
|
|
608
|
-
interface ScrapeUrlResult {
|
|
609
|
-
url: string;
|
|
610
|
-
source: string;
|
|
611
|
-
layer?: string;
|
|
612
|
-
markdown: string;
|
|
613
|
-
error?: string;
|
|
614
|
-
sourceCategory?: string;
|
|
615
|
-
}
|
|
616
|
-
|
|
617
|
-
/**
|
|
618
|
-
* Classify a URL into a human-readable source category.
|
|
619
|
-
*
|
|
620
|
-
* Categories (in priority order):
|
|
621
|
-
* github-pr, github-issue, github-discussion, github-repo
|
|
622
|
-
* official-docs — docs.* hostnames or /docs/ /reference/ /api/ /guide/ /manual/ paths
|
|
623
|
-
* release-notes — changelog / release paths or hostnames
|
|
624
|
-
* forum-community — Reddit, Stack Overflow, HN, community.* subdomains, /forum/ /questions/ paths
|
|
625
|
-
* vendor-blog — blog.* hostnames or /blog/ paths
|
|
626
|
-
* web — fallback
|
|
627
|
-
*/
|
|
628
|
-
export function classifySourceCategory(url: string): string {
|
|
629
|
-
try {
|
|
630
|
-
const parsed = new globalThis.URL(url);
|
|
631
|
-
const hostname = parsed.hostname.toLowerCase();
|
|
632
|
-
const path = parsed.pathname.toLowerCase();
|
|
633
|
-
|
|
634
|
-
// GitHub — check specific path patterns before the generic repo catch-all
|
|
635
|
-
if (hostname === 'github.com' || hostname.endsWith('.github.com')) {
|
|
636
|
-
if (/\/pull\/\d+/.test(path)) return 'github-pr';
|
|
637
|
-
if (/\/issues\/\d+/.test(path)) return 'github-issue';
|
|
638
|
-
if (/\/discussions\/\d+/.test(path)) return 'github-discussion';
|
|
639
|
-
return 'github-repo';
|
|
640
|
-
}
|
|
641
|
-
|
|
642
|
-
// Official documentation — hostname prefix or well-known path segments
|
|
643
|
-
if (
|
|
644
|
-
hostname.startsWith('docs.') ||
|
|
645
|
-
hostname.startsWith('developer.') ||
|
|
646
|
-
/\/(docs|reference|api|guide|guides|manual|documentation)(\/|$)/.test(path)
|
|
647
|
-
) {
|
|
648
|
-
return 'official-docs';
|
|
649
|
-
}
|
|
650
|
-
|
|
651
|
-
// Release notes / changelogs
|
|
652
|
-
if (
|
|
653
|
-
/\/(releases?|changelog|changelogs|release-notes|whatsnew|what-s-new)(\/|$)/.test(path) ||
|
|
654
|
-
/changelog|release-notes/.test(hostname)
|
|
655
|
-
) {
|
|
656
|
-
return 'release-notes';
|
|
657
|
-
}
|
|
658
|
-
|
|
659
|
-
// Forum / community sources
|
|
660
|
-
if (
|
|
661
|
-
hostname === 'reddit.com' || hostname.endsWith('.reddit.com') ||
|
|
662
|
-
hostname === 'stackoverflow.com' || hostname.endsWith('.stackoverflow.com') ||
|
|
663
|
-
hostname === 'stackexchange.com' || hostname.endsWith('.stackexchange.com') ||
|
|
664
|
-
hostname === 'news.ycombinator.com' ||
|
|
665
|
-
hostname === 'lobste.rs' ||
|
|
666
|
-
hostname.startsWith('community.') ||
|
|
667
|
-
hostname.startsWith('forum.') ||
|
|
668
|
-
/\/(forum|forums|questions|q\/|community)(\/|$)/.test(path)
|
|
669
|
-
) {
|
|
670
|
-
return 'forum-community';
|
|
671
|
-
}
|
|
672
|
-
|
|
673
|
-
// Vendor / personal blogs
|
|
674
|
-
if (hostname.startsWith('blog.') || /\/blog(\/|$)/.test(path)) {
|
|
675
|
-
return 'vendor-blog';
|
|
676
|
-
}
|
|
677
|
-
|
|
678
|
-
return 'web';
|
|
679
|
-
} catch {
|
|
680
|
-
return 'web';
|
|
681
|
-
}
|
|
682
|
-
}
|
|
683
|
-
|
|
684
|
-
function assertSafeUrl(rawUrl: string): void {
|
|
685
|
-
let parsed: URL;
|
|
686
|
-
try {
|
|
687
|
-
parsed = new URL(rawUrl);
|
|
688
|
-
} catch {
|
|
689
|
-
throw new Error(`Invalid URL: ${rawUrl}`);
|
|
690
|
-
}
|
|
691
|
-
|
|
692
|
-
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
693
|
-
throw new Error(`Blocked non-HTTP(S) URL: ${rawUrl}`);
|
|
694
|
-
}
|
|
695
|
-
|
|
696
|
-
const host = parsed.hostname.toLowerCase();
|
|
697
|
-
|
|
698
|
-
if (host === 'localhost' || host === '127.0.0.1' || host === '::1' || host.startsWith('127.')) {
|
|
699
|
-
throw new Error(`Blocked loopback URL: ${rawUrl}`);
|
|
700
|
-
}
|
|
701
|
-
|
|
702
|
-
if (host === '169.254.169.254' || host === 'metadata.google.internal' || host === 'metadata.internal') {
|
|
703
|
-
throw new Error(`Blocked cloud metadata URL: ${rawUrl}`);
|
|
704
|
-
}
|
|
705
|
-
|
|
706
|
-
if (host.endsWith('.local')) {
|
|
707
|
-
throw new Error(`Blocked .local hostname: ${rawUrl}`);
|
|
708
|
-
}
|
|
709
|
-
|
|
710
|
-
const ipv4 = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
|
711
|
-
if (ipv4) {
|
|
712
|
-
const a = parseInt(ipv4[1]!, 10);
|
|
713
|
-
const b = parseInt(ipv4[2]!, 10);
|
|
714
|
-
if (
|
|
715
|
-
a === 10 ||
|
|
716
|
-
(a === 172 && b >= 16 && b <= 31) ||
|
|
717
|
-
(a === 192 && b === 168) ||
|
|
718
|
-
(a === 169 && b === 254)
|
|
719
|
-
) {
|
|
720
|
-
throw new Error(`Blocked private/link-local URL: ${rawUrl}`);
|
|
721
|
-
}
|
|
722
|
-
}
|
|
723
|
-
}
|
|
724
|
-
|
|
725
|
-
/**
|
|
726
|
-
* Single URL scraping with 2-layer sequential fallback
|
|
727
|
-
*/
|
|
728
|
-
|
|
729
|
-
export async function scrapeSingle(url: string, signal?: AbortSignal): Promise<ScrapeUrlResult> {
|
|
730
|
-
try {
|
|
731
|
-
assertSafeUrl(url);
|
|
732
|
-
} catch (err) {
|
|
733
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
734
|
-
return { url, source: 'failed', markdown: '', error: msg, sourceCategory: 'web' };
|
|
735
|
-
}
|
|
736
|
-
|
|
737
|
-
const errors: Array<{ layer: string; error: string; errorType: string }> = [];
|
|
738
|
-
|
|
739
|
-
const sourceCategory = classifySourceCategory(url);
|
|
740
|
-
|
|
741
|
-
// Layer 1: fetch (fast, no browser overhead)
|
|
742
|
-
try {
|
|
743
|
-
const result = await scrapeWithFetch(url, signal);
|
|
744
|
-
return { url, source: result.source, layer: result.layer, markdown: result.markdown, sourceCategory };
|
|
745
|
-
} catch (error1) {
|
|
746
|
-
const errorInfo = extractErrorInfo(error1);
|
|
747
|
-
errors.push({ layer: 'fetch', error: errorInfo.message, errorType: errorInfo.type });
|
|
748
|
-
}
|
|
749
|
-
|
|
750
|
-
// Layer 2: Playwright + Chromium (shared singleton browser)
|
|
751
|
-
if (signal?.aborted) {
|
|
752
|
-
return { url, source: 'cancelled', markdown: '', error: 'Cancelled before Layer 2', sourceCategory };
|
|
753
|
-
}
|
|
754
|
-
if (playwrightAvailable) {
|
|
755
|
-
try {
|
|
756
|
-
const result = await scrapeWithChromium(url, signal);
|
|
757
|
-
return { url, source: result.source, layer: result.layer, markdown: result.markdown, sourceCategory };
|
|
758
|
-
} catch (error2) {
|
|
759
|
-
const errorInfo = extractErrorInfo(error2);
|
|
760
|
-
errors.push({ layer: 'Playwright+Chromium', error: errorInfo.message, errorType: errorInfo.type });
|
|
761
|
-
}
|
|
762
|
-
}
|
|
763
|
-
|
|
764
|
-
return {
|
|
765
|
-
url,
|
|
766
|
-
source: 'failed',
|
|
767
|
-
markdown: '',
|
|
768
|
-
error: `All layers failed: ${errors.map(e => `${e.layer}: ${e.error} [${e.errorType}]`).join('; ')}`,
|
|
769
|
-
sourceCategory,
|
|
770
|
-
};
|
|
771
|
-
|
|
772
|
-
}
|
|
773
|
-
|
|
774
|
-
interface LayerResult {
|
|
775
|
-
url: string;
|
|
776
|
-
success: boolean;
|
|
777
|
-
markdown?: string;
|
|
778
|
-
source?: string;
|
|
779
|
-
layer?: string;
|
|
780
|
-
error?: string;
|
|
781
|
-
errorType?: string;
|
|
782
|
-
}
|
|
783
|
-
|
|
784
|
-
interface ExtractedErrorInfo {
|
|
785
|
-
message: string;
|
|
786
|
-
name: string;
|
|
787
|
-
type: string;
|
|
788
|
-
}
|
|
789
|
-
|
|
790
|
-
/**
|
|
791
|
-
* Extract error information for better debugging
|
|
792
|
-
*/
|
|
793
|
-
function extractErrorInfo(error: unknown): ExtractedErrorInfo {
|
|
794
|
-
if (error instanceof Error) {
|
|
795
|
-
return {
|
|
796
|
-
message: error.message,
|
|
797
|
-
name: error.name,
|
|
798
|
-
type: error.constructor.name,
|
|
799
|
-
};
|
|
800
|
-
}
|
|
801
|
-
return {
|
|
802
|
-
message: String(error),
|
|
803
|
-
name: 'Unknown',
|
|
804
|
-
type: typeof error,
|
|
805
|
-
};
|
|
806
|
-
}
|
|
807
|
-
|
|
808
|
-
/**
|
|
809
|
-
* Scrape multiple URLs in a single layer with concurrency control
|
|
810
|
-
*/
|
|
811
|
-
|
|
812
|
-
async function scrapeUrlsInLayer(
|
|
813
|
-
urls: string[],
|
|
814
|
-
maxConcurrency: number,
|
|
815
|
-
|
|
816
|
-
scraperFn: (url: string, _signal?: AbortSignal) => Promise<ScrapeLayerResult>,
|
|
817
|
-
signal?: AbortSignal,
|
|
818
|
-
): Promise<LayerResult[]> {
|
|
819
|
-
const chunks: string[][] = [];
|
|
820
|
-
for (let i = 0; i < urls.length; i += maxConcurrency) {
|
|
821
|
-
chunks.push(urls.slice(i, i + maxConcurrency));
|
|
822
|
-
}
|
|
823
|
-
|
|
824
|
-
const allResults: LayerResult[] = [];
|
|
825
|
-
|
|
826
|
-
for (const chunk of chunks) {
|
|
827
|
-
if (signal?.aborted) {
|
|
828
|
-
break;
|
|
829
|
-
}
|
|
830
|
-
|
|
831
|
-
const promises = chunk.map((url) =>
|
|
832
|
-
scraperFn(url, signal)
|
|
833
|
-
.then((result) => ({ url, success: true, markdown: result.markdown, source: result.source, layer: result.layer }))
|
|
834
|
-
|
|
835
|
-
.catch((error: Error) => {
|
|
836
|
-
const errorInfo = extractErrorInfo(error);
|
|
837
|
-
return {
|
|
838
|
-
url,
|
|
839
|
-
success: false,
|
|
840
|
-
error: `${errorInfo.name}: ${errorInfo.message}`,
|
|
841
|
-
errorType: errorInfo.type,
|
|
842
|
-
};
|
|
843
|
-
}),
|
|
844
|
-
);
|
|
845
|
-
|
|
846
|
-
const chunkResults = await Promise.all(promises);
|
|
847
|
-
allResults.push(...chunkResults);
|
|
848
|
-
}
|
|
849
|
-
|
|
850
|
-
return allResults;
|
|
851
|
-
|
|
852
|
-
}
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
/**
|
|
856
|
-
* Bulk scraping with 2-layer simultaneous processing
|
|
857
|
-
*/
|
|
858
|
-
|
|
859
|
-
export async function scrape(urls: string[], maxConcurrency = 10, signal?: AbortSignal): Promise<ScrapeUrlResult[]> {
|
|
860
|
-
|
|
861
|
-
const results = new Map<string, ScrapeUrlResult>();
|
|
862
|
-
const errors = new Map<string, Array<{ layer: string; error: string; errorType?: string }>>();
|
|
863
|
-
let failedUrls: string[] = [...urls];
|
|
864
|
-
let aborted = false;
|
|
865
|
-
|
|
866
|
-
// Layer 1: fetch — concurrent, no browser overhead
|
|
867
|
-
const layer1Results = await scrapeUrlsInLayer(failedUrls, maxConcurrency, scrapeWithFetch, signal);
|
|
868
|
-
|
|
869
|
-
layer1Results.forEach(result => {
|
|
870
|
-
if (result.success && result.layer !== undefined) {
|
|
871
|
-
results.set(result.url, {
|
|
872
|
-
url: result.url,
|
|
873
|
-
source: result.source ?? 'fetch',
|
|
874
|
-
layer: result.layer,
|
|
875
|
-
markdown: result.markdown ?? '',
|
|
876
|
-
sourceCategory: classifySourceCategory(result.url),
|
|
877
|
-
});
|
|
878
|
-
} else if (!result.success) {
|
|
879
|
-
const urlErrors = errors.get(result.url) ?? [];
|
|
880
|
-
urlErrors.push({ layer: 'fetch', error: result.error ?? 'Unknown error', errorType: result.errorType ?? 'Error' });
|
|
881
|
-
errors.set(result.url, urlErrors);
|
|
882
|
-
}
|
|
883
|
-
});
|
|
884
|
-
|
|
885
|
-
failedUrls = layer1Results.filter(r => !r.success).map(r => r.url);
|
|
886
|
-
if (signal?.aborted) {
|
|
887
|
-
aborted = true;
|
|
888
|
-
}
|
|
889
|
-
|
|
890
|
-
// Layer 2: Playwright + Chromium (persistent singleton browser)
|
|
891
|
-
if (!aborted && playwrightAvailable && failedUrls.length > 0) {
|
|
892
|
-
const layer2Results = await scrapeUrlsInLayer(failedUrls, maxConcurrency, scrapeWithChromium, signal);
|
|
893
|
-
|
|
894
|
-
layer2Results.forEach(result => {
|
|
895
|
-
if (result.success && result.layer !== undefined) {
|
|
896
|
-
results.set(result.url, {
|
|
897
|
-
url: result.url,
|
|
898
|
-
source: result.source ?? 'playwright',
|
|
899
|
-
layer: result.layer,
|
|
900
|
-
markdown: result.markdown ?? '',
|
|
901
|
-
sourceCategory: classifySourceCategory(result.url),
|
|
902
|
-
});
|
|
903
|
-
} else if (!result.success) {
|
|
904
|
-
const urlErrors = errors.get(result.url) ?? [];
|
|
905
|
-
urlErrors.push({ layer: 'Playwright+Chromium', error: result.error ?? 'Unknown error', errorType: result.errorType ?? 'Error' });
|
|
906
|
-
errors.set(result.url, urlErrors);
|
|
907
|
-
}
|
|
908
|
-
});
|
|
909
|
-
|
|
910
|
-
failedUrls = layer2Results.filter(r => !r.success).map(r => r.url);
|
|
911
|
-
}
|
|
912
|
-
|
|
913
|
-
const finalResults: ScrapeUrlResult[] = Array.from(results.values());
|
|
914
|
-
|
|
915
|
-
failedUrls.forEach(url => {
|
|
916
|
-
const urlErrors = errors.get(url) ?? [];
|
|
917
|
-
const errorSummary = urlErrors.map(e => `${e.layer}: ${e.error} [${e.errorType ?? 'Error'}]`).join('; ');
|
|
918
|
-
finalResults.push({
|
|
919
|
-
url,
|
|
920
|
-
source: 'failed',
|
|
921
|
-
markdown: '',
|
|
922
|
-
error: `All layers failed: ${errorSummary}`,
|
|
923
|
-
});
|
|
924
|
-
});
|
|
925
|
-
|
|
926
|
-
return finalResults;
|
|
927
|
-
}
|
|
928
|
-
|
|
929
|
-
export interface DependencyStatus {
|
|
930
|
-
readonly playwrightAvailable: boolean;
|
|
931
|
-
}
|
|
932
|
-
|
|
933
|
-
export function getDependencyStatus(): DependencyStatus {
|
|
934
|
-
return { playwrightAvailable };
|
|
935
|
-
}
|