@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.
Files changed (248) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +55 -138
  3. package/dist/cli.mjs +23375 -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 +130 -0
  13. package/docs/ARCHITECTURE.md +306 -80
  14. package/docs/CONFIGURATION.md +249 -0
  15. package/docs/KNOWLEDGE-STORE.md +211 -0
  16. package/docs/PI-EXTENSION.md +91 -0
  17. package/docs/SDK.md +122 -0
  18. package/docs/THIRD-PARTY-NOTICES.md +66 -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 +1195 -0
  30. package/src/config.ts +1032 -155
  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 +258 -11
  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 -212
  231. package/src/infrastructure/searxng-lifecycle.ts +0 -644
  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
@@ -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
- }