@taurgis/bonsai 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/README.md +239 -0
- package/agents/agents/official-docs-researcher.agent.md +98 -0
- package/agents/instructions/web-research.instructions.md +29 -0
- package/agents/skills/web-research/SKILL.md +126 -0
- package/bin/cli.mjs +22 -0
- package/dist/base-command.d.ts +14 -0
- package/dist/base-command.js +56 -0
- package/dist/commands/config/base.d.ts +23 -0
- package/dist/commands/config/base.js +46 -0
- package/dist/commands/config/get.d.ts +20 -0
- package/dist/commands/config/get.js +48 -0
- package/dist/commands/config/list.d.ts +17 -0
- package/dist/commands/config/list.js +48 -0
- package/dist/commands/config/set.d.ts +31 -0
- package/dist/commands/config/set.js +94 -0
- package/dist/commands/config/unset.d.ts +21 -0
- package/dist/commands/config/unset.js +65 -0
- package/dist/commands/fetch.d.ts +38 -0
- package/dist/commands/fetch.js +311 -0
- package/dist/commands/import.d.ts +38 -0
- package/dist/commands/import.js +313 -0
- package/dist/commands/index.d.ts +35 -0
- package/dist/commands/index.js +306 -0
- package/dist/commands/inspect.d.ts +13 -0
- package/dist/commands/inspect.js +82 -0
- package/dist/commands/list.d.ts +25 -0
- package/dist/commands/list.js +143 -0
- package/dist/commands/prune.d.ts +24 -0
- package/dist/commands/prune.js +141 -0
- package/dist/commands/research/config/base.d.ts +23 -0
- package/dist/commands/research/config/base.js +46 -0
- package/dist/commands/research/config/get.d.ts +20 -0
- package/dist/commands/research/config/get.js +45 -0
- package/dist/commands/research/config/list.d.ts +17 -0
- package/dist/commands/research/config/list.js +42 -0
- package/dist/commands/research/config/set.d.ts +31 -0
- package/dist/commands/research/config/set.js +94 -0
- package/dist/commands/research/config/unset.d.ts +21 -0
- package/dist/commands/research/config/unset.js +59 -0
- package/dist/commands/research/import.d.ts +38 -0
- package/dist/commands/research/import.js +313 -0
- package/dist/commands/research/inspect.d.ts +13 -0
- package/dist/commands/research/inspect.js +86 -0
- package/dist/commands/research/list.d.ts +25 -0
- package/dist/commands/research/list.js +143 -0
- package/dist/commands/research/prune.d.ts +24 -0
- package/dist/commands/research/prune.js +141 -0
- package/dist/commands/research/search.d.ts +32 -0
- package/dist/commands/research/search.js +334 -0
- package/dist/commands/research/status.d.ts +17 -0
- package/dist/commands/research/status.js +108 -0
- package/dist/commands/research.d.ts +36 -0
- package/dist/commands/research.js +304 -0
- package/dist/commands/search.d.ts +32 -0
- package/dist/commands/search.js +334 -0
- package/dist/commands/status.d.ts +17 -0
- package/dist/commands/status.js +95 -0
- package/dist/commands.d.ts +24 -0
- package/dist/commands.js +24 -0
- package/dist/lib/config/index.d.ts +18 -0
- package/dist/lib/config/index.js +33 -0
- package/dist/lib/config/io.d.ts +8 -0
- package/dist/lib/config/io.js +75 -0
- package/dist/lib/config/resolve.d.ts +15 -0
- package/dist/lib/config/resolve.js +20 -0
- package/dist/lib/config/schema.d.ts +28 -0
- package/dist/lib/config/schema.js +32 -0
- package/dist/lib/research/artifact.d.ts +21 -0
- package/dist/lib/research/artifact.js +217 -0
- package/dist/lib/research/browser.d.ts +74 -0
- package/dist/lib/research/browser.js +359 -0
- package/dist/lib/research/cache-key.d.ts +5 -0
- package/dist/lib/research/cache-key.js +10 -0
- package/dist/lib/research/capture.d.ts +29 -0
- package/dist/lib/research/capture.js +181 -0
- package/dist/lib/research/compress.d.ts +5 -0
- package/dist/lib/research/compress.js +17 -0
- package/dist/lib/research/docs/__fixtures__/sphinx-searchindex.d.ts +1 -0
- package/dist/lib/research/docs/__fixtures__/sphinx-searchindex.js +2 -0
- package/dist/lib/research/docs/capabilities.d.ts +43 -0
- package/dist/lib/research/docs/capabilities.js +9 -0
- package/dist/lib/research/docs/clean-dom.d.ts +4 -0
- package/dist/lib/research/docs/clean-dom.js +41 -0
- package/dist/lib/research/docs/code-blocks.d.ts +5 -0
- package/dist/lib/research/docs/code-blocks.js +69 -0
- package/dist/lib/research/docs/detect.d.ts +6 -0
- package/dist/lib/research/docs/detect.js +239 -0
- package/dist/lib/research/docs/machine-readable.d.ts +21 -0
- package/dist/lib/research/docs/machine-readable.js +73 -0
- package/dist/lib/research/docs/markdown-source.d.ts +17 -0
- package/dist/lib/research/docs/markdown-source.js +194 -0
- package/dist/lib/research/docs/quality-gates.d.ts +9 -0
- package/dist/lib/research/docs/quality-gates.js +65 -0
- package/dist/lib/research/docs/remote-search-runner.d.ts +21 -0
- package/dist/lib/research/docs/remote-search-runner.js +54 -0
- package/dist/lib/research/docs/remote-search.d.ts +14 -0
- package/dist/lib/research/docs/remote-search.js +101 -0
- package/dist/lib/research/docs/rsc.d.ts +10 -0
- package/dist/lib/research/docs/rsc.js +30 -0
- package/dist/lib/research/docs/search-index.d.ts +13 -0
- package/dist/lib/research/docs/search-index.js +176 -0
- package/dist/lib/research/docs/section-artifacts.d.ts +12 -0
- package/dist/lib/research/docs/section-artifacts.js +75 -0
- package/dist/lib/research/docs/sections.d.ts +12 -0
- package/dist/lib/research/docs/sections.js +67 -0
- package/dist/lib/research/docs/source-map.d.ts +19 -0
- package/dist/lib/research/docs/source-map.js +87 -0
- package/dist/lib/research/docs/validate.d.ts +15 -0
- package/dist/lib/research/docs/validate.js +63 -0
- package/dist/lib/research/extract.d.ts +11 -0
- package/dist/lib/research/extract.js +83 -0
- package/dist/lib/research/fetcher.d.ts +29 -0
- package/dist/lib/research/fetcher.js +192 -0
- package/dist/lib/research/freshness.d.ts +21 -0
- package/dist/lib/research/freshness.js +82 -0
- package/dist/lib/research/markdown.d.ts +6 -0
- package/dist/lib/research/markdown.js +66 -0
- package/dist/lib/research/regression.d.ts +23 -0
- package/dist/lib/research/regression.js +93 -0
- package/dist/lib/research/resolve-target.d.ts +18 -0
- package/dist/lib/research/resolve-target.js +20 -0
- package/dist/lib/research/revalidate.d.ts +32 -0
- package/dist/lib/research/revalidate.js +152 -0
- package/dist/lib/research/schema.d.ts +47 -0
- package/dist/lib/research/schema.js +1 -0
- package/dist/lib/research/secret-scan.d.ts +5 -0
- package/dist/lib/research/secret-scan.js +37 -0
- package/dist/lib/research/secure-write.d.ts +16 -0
- package/dist/lib/research/secure-write.js +17 -0
- package/dist/lib/research/storage.d.ts +52 -0
- package/dist/lib/research/storage.js +182 -0
- package/dist/lib/research/store-roots.d.ts +29 -0
- package/dist/lib/research/store-roots.js +28 -0
- package/dist/lib/research/token-estimate.d.ts +5 -0
- package/dist/lib/research/token-estimate.js +7 -0
- package/dist/lib/research/url.d.ts +24 -0
- package/dist/lib/research/url.js +121 -0
- package/dist/sites/index.d.ts +4 -0
- package/dist/sites/index.js +28 -0
- package/dist/sites/salesforce/browser-search.d.ts +7 -0
- package/dist/sites/salesforce/browser-search.js +82 -0
- package/dist/sites/salesforce/coveo.d.ts +23 -0
- package/dist/sites/salesforce/coveo.js +161 -0
- package/dist/sites/salesforce/fetch-page.d.ts +6 -0
- package/dist/sites/salesforce/fetch-page.js +42 -0
- package/dist/sites/salesforce/index.d.ts +2 -0
- package/dist/sites/salesforce/index.js +16 -0
- package/dist/sites/salesforce/token.d.ts +26 -0
- package/dist/sites/salesforce/token.js +154 -0
- package/dist/sites/salesforce-developer/fetch-page.d.ts +3 -0
- package/dist/sites/salesforce-developer/fetch-page.js +18 -0
- package/dist/sites/salesforce-developer/index.d.ts +2 -0
- package/dist/sites/salesforce-developer/index.js +12 -0
- package/dist/sites/salesforce-doc-fetch.d.ts +14 -0
- package/dist/sites/salesforce-doc-fetch.js +321 -0
- package/dist/sites/types.d.ts +27 -0
- package/dist/sites/types.js +1 -0
- package/oclif.manifest.json +845 -0
- package/package.json +78 -0
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
export interface BrowserFetchOptions {
|
|
2
|
+
timeoutMs?: number;
|
|
3
|
+
bodyLimitBytes?: number;
|
|
4
|
+
settleMs?: number;
|
|
5
|
+
}
|
|
6
|
+
export interface BrowserFetchResult {
|
|
7
|
+
status: number;
|
|
8
|
+
contentType: string | null;
|
|
9
|
+
etag: string | null;
|
|
10
|
+
lastModified: string | null;
|
|
11
|
+
finalUrl: string;
|
|
12
|
+
responseSize: number;
|
|
13
|
+
content: string;
|
|
14
|
+
}
|
|
15
|
+
export declare class CdpClient {
|
|
16
|
+
private ws;
|
|
17
|
+
private nextId;
|
|
18
|
+
private pending;
|
|
19
|
+
private handlers;
|
|
20
|
+
constructor(url: string);
|
|
21
|
+
connect(): Promise<void>;
|
|
22
|
+
send(method: string, params?: any, sessionId?: string): Promise<any>;
|
|
23
|
+
on(event: string, handler: (params: any) => void): void;
|
|
24
|
+
close(): void;
|
|
25
|
+
}
|
|
26
|
+
export declare function findChromePath(): string;
|
|
27
|
+
export declare function waitForLoad(client: CdpClient, sessionId: string, timeoutMs: number, settleMs?: number): Promise<void>;
|
|
28
|
+
/**
|
|
29
|
+
* Polls until a content-container element holds enough RENDERED text, so SPAs that fetch their
|
|
30
|
+
* body after load are captured rendered rather than as a "Loading…" shell. Pierces open shadow
|
|
31
|
+
* roots (the container is matched across shadow boundaries) and measures `innerText`, which
|
|
32
|
+
* reflects shadow-rendered text — `document.querySelector` + `textContent` miss both. Returns
|
|
33
|
+
* without throwing on timeout; the caller captures whatever rendered and judges its quality.
|
|
34
|
+
*/
|
|
35
|
+
export declare function waitForContentReady(page: CdpPage, selectors: string[], minChars: number, timeoutMs: number): Promise<void>;
|
|
36
|
+
export declare const BLOCKED_ASSET_URLS: string[];
|
|
37
|
+
export interface CdpPage {
|
|
38
|
+
client: CdpClient;
|
|
39
|
+
sessionId: string;
|
|
40
|
+
close: () => Promise<void>;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Spawns a headless Chrome, attaches a flat CDP session, and enables the Page, Network,
|
|
44
|
+
* and Runtime domains. The caller drives navigation and must call close() when finished.
|
|
45
|
+
*/
|
|
46
|
+
export declare function openCdpPage(): Promise<CdpPage>;
|
|
47
|
+
export interface ResponseMatcher {
|
|
48
|
+
key: string;
|
|
49
|
+
test: (request: {
|
|
50
|
+
url: string;
|
|
51
|
+
postData?: string;
|
|
52
|
+
}) => boolean;
|
|
53
|
+
accept?: (body: string) => boolean;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Buffers response bodies for requests matching one of the supplied matchers, keyed by
|
|
57
|
+
* matcher key. Matching happens on requestWillBeSent so predicates can inspect post data
|
|
58
|
+
* (e.g. to tell two same-URL XHRs apart). Works around CdpClient's single-handler-per-event
|
|
59
|
+
* model so several waitFor() calls can track different responses from one navigation.
|
|
60
|
+
* Install before navigating.
|
|
61
|
+
*/
|
|
62
|
+
export declare class ResponseCapture {
|
|
63
|
+
private matched;
|
|
64
|
+
private bodies;
|
|
65
|
+
private waiters;
|
|
66
|
+
private page;
|
|
67
|
+
private matchers;
|
|
68
|
+
constructor(page: CdpPage, matchers: ResponseMatcher[]);
|
|
69
|
+
private onFinished;
|
|
70
|
+
private readBody;
|
|
71
|
+
/** Resolves with the response body text once a matching response finishes, or null on timeout. */
|
|
72
|
+
waitFor(key: string, timeoutMs: number): Promise<string | null>;
|
|
73
|
+
}
|
|
74
|
+
export declare function fetchRenderedHtml(url: string, options?: BrowserFetchOptions): Promise<BrowserFetchResult>;
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { execSync } from 'node:child_process';
|
|
4
|
+
import { checkDnsSafety } from './fetcher.js';
|
|
5
|
+
import { normalizeUrl } from './url.js';
|
|
6
|
+
export class CdpClient {
|
|
7
|
+
ws;
|
|
8
|
+
nextId = 1;
|
|
9
|
+
pending = new Map();
|
|
10
|
+
handlers = new Map();
|
|
11
|
+
constructor(url) {
|
|
12
|
+
this.ws = new WebSocket(url);
|
|
13
|
+
}
|
|
14
|
+
connect() {
|
|
15
|
+
return new Promise((resolve, reject) => {
|
|
16
|
+
this.ws.onopen = () => resolve();
|
|
17
|
+
this.ws.onerror = (err) => reject(err);
|
|
18
|
+
this.ws.onmessage = (event) => {
|
|
19
|
+
const msg = JSON.parse(event.data);
|
|
20
|
+
if (msg.id) {
|
|
21
|
+
const promise = this.pending.get(msg.id);
|
|
22
|
+
if (promise) {
|
|
23
|
+
this.pending.delete(msg.id);
|
|
24
|
+
if (msg.error) {
|
|
25
|
+
promise.reject(new Error(msg.error.message));
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
promise.resolve(msg.result);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
else if (msg.method) {
|
|
33
|
+
const key = msg.sessionId ? `${msg.sessionId}:${msg.method}` : msg.method;
|
|
34
|
+
const listeners = this.handlers.get(key) ?? this.handlers.get(msg.method);
|
|
35
|
+
if (listeners) {
|
|
36
|
+
listeners.forEach((handler) => handler(msg.params));
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
send(method, params, sessionId) {
|
|
43
|
+
return new Promise((resolve, reject) => {
|
|
44
|
+
const id = this.nextId++;
|
|
45
|
+
this.pending.set(id, { resolve, reject });
|
|
46
|
+
const payload = { id, method, params };
|
|
47
|
+
if (sessionId) {
|
|
48
|
+
payload.sessionId = sessionId;
|
|
49
|
+
}
|
|
50
|
+
this.ws.send(JSON.stringify(payload));
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
on(event, handler) {
|
|
54
|
+
const listeners = this.handlers.get(event) ?? [];
|
|
55
|
+
listeners.push(handler);
|
|
56
|
+
this.handlers.set(event, listeners);
|
|
57
|
+
}
|
|
58
|
+
close() {
|
|
59
|
+
this.ws.close();
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
export function findChromePath() {
|
|
63
|
+
if (process.env.CHROME_PATH && existsSync(process.env.CHROME_PATH)) {
|
|
64
|
+
return process.env.CHROME_PATH;
|
|
65
|
+
}
|
|
66
|
+
const paths = [];
|
|
67
|
+
if (process.platform === 'darwin') {
|
|
68
|
+
paths.push('/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', '/Applications/Chromium.app/Contents/MacOS/Chromium');
|
|
69
|
+
}
|
|
70
|
+
else if (process.platform === 'win32') {
|
|
71
|
+
paths.push('C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe', 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe');
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
paths.push('/usr/bin/google-chrome', '/usr/bin/google-chrome-stable', '/usr/bin/chromium', '/usr/bin/chromium-browser');
|
|
75
|
+
}
|
|
76
|
+
for (const p of paths) {
|
|
77
|
+
if (existsSync(p))
|
|
78
|
+
return p;
|
|
79
|
+
}
|
|
80
|
+
try {
|
|
81
|
+
const cmd = process.platform === 'win32'
|
|
82
|
+
? 'where chrome'
|
|
83
|
+
: 'which google-chrome || which chromium || which google-chrome-stable';
|
|
84
|
+
const resolved = execSync(cmd, { stdio: ['ignore', 'pipe', 'ignore'] })
|
|
85
|
+
.toString()
|
|
86
|
+
.trim()
|
|
87
|
+
.split('\n')[0];
|
|
88
|
+
if (resolved && existsSync(resolved)) {
|
|
89
|
+
return resolved;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
catch { }
|
|
93
|
+
throw new Error('Chrome executable not found. Please set CHROME_PATH environment variable.');
|
|
94
|
+
}
|
|
95
|
+
async function spawnChrome(chromePath) {
|
|
96
|
+
const chromeProcess = spawn(chromePath, [
|
|
97
|
+
'--headless=new',
|
|
98
|
+
'--remote-debugging-port=0',
|
|
99
|
+
'--disable-gpu',
|
|
100
|
+
'--no-sandbox',
|
|
101
|
+
'--disable-setuid-sandbox',
|
|
102
|
+
'--disable-extensions',
|
|
103
|
+
'--disable-dev-shm-usage',
|
|
104
|
+
]);
|
|
105
|
+
try {
|
|
106
|
+
const wsUrl = await new Promise((resolve, reject) => {
|
|
107
|
+
let output = '';
|
|
108
|
+
const timeoutId = setTimeout(() => {
|
|
109
|
+
chromeProcess.kill('SIGKILL');
|
|
110
|
+
reject(new Error('Timed out waiting for Chrome to start.'));
|
|
111
|
+
}, 10000);
|
|
112
|
+
const onData = (data) => {
|
|
113
|
+
output += data.toString();
|
|
114
|
+
const match = output.match(/ws:\/\/127\.0\.0\.1:\d+\/devtools\/browser\/[a-zA-Z0-9-]+/);
|
|
115
|
+
if (match) {
|
|
116
|
+
clearTimeout(timeoutId);
|
|
117
|
+
cleanup();
|
|
118
|
+
resolve(match[0]);
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
const cleanup = () => {
|
|
122
|
+
chromeProcess.stderr.off('data', onData);
|
|
123
|
+
chromeProcess.stdout.off('data', onData);
|
|
124
|
+
};
|
|
125
|
+
chromeProcess.stderr.on('data', onData);
|
|
126
|
+
chromeProcess.stdout.on('data', onData);
|
|
127
|
+
chromeProcess.on('error', (err) => {
|
|
128
|
+
clearTimeout(timeoutId);
|
|
129
|
+
cleanup();
|
|
130
|
+
reject(err);
|
|
131
|
+
});
|
|
132
|
+
chromeProcess.on('exit', (code) => {
|
|
133
|
+
clearTimeout(timeoutId);
|
|
134
|
+
cleanup();
|
|
135
|
+
reject(new Error(`Chrome exited prematurely with code ${code}`));
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
return { chromeProcess, wsUrl };
|
|
139
|
+
}
|
|
140
|
+
catch (err) {
|
|
141
|
+
chromeProcess.kill('SIGKILL');
|
|
142
|
+
throw err;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
export async function waitForLoad(client, sessionId, timeoutMs, settleMs = 1000) {
|
|
146
|
+
// Resolve on whichever fires first: DOMContentLoaded or the full load event. Heavy SPAs
|
|
147
|
+
// (e.g. Salesforce LWR) often never fire `load`, so requiring it would falsely time out.
|
|
148
|
+
let ready = false;
|
|
149
|
+
const markReady = () => {
|
|
150
|
+
ready = true;
|
|
151
|
+
};
|
|
152
|
+
client.on(`${sessionId}:Page.domContentEventFired`, markReady);
|
|
153
|
+
client.on(`${sessionId}:Page.loadEventFired`, markReady);
|
|
154
|
+
const start = Date.now();
|
|
155
|
+
while (Date.now() - start < timeoutMs) {
|
|
156
|
+
if (ready) {
|
|
157
|
+
const settle = Math.min(settleMs, timeoutMs - (Date.now() - start));
|
|
158
|
+
if (settle > 0)
|
|
159
|
+
await new Promise((r) => setTimeout(r, settle));
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
await new Promise((r) => setTimeout(r, Math.min(100, timeoutMs - (Date.now() - start))));
|
|
163
|
+
}
|
|
164
|
+
throw new Error(`Navigation timed out after ${timeoutMs}ms`);
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Polls until a content-container element holds enough RENDERED text, so SPAs that fetch their
|
|
168
|
+
* body after load are captured rendered rather than as a "Loading…" shell. Pierces open shadow
|
|
169
|
+
* roots (the container is matched across shadow boundaries) and measures `innerText`, which
|
|
170
|
+
* reflects shadow-rendered text — `document.querySelector` + `textContent` miss both. Returns
|
|
171
|
+
* without throwing on timeout; the caller captures whatever rendered and judges its quality.
|
|
172
|
+
*/
|
|
173
|
+
export async function waitForContentReady(page, selectors, minChars, timeoutMs) {
|
|
174
|
+
// Reports whether a content container has rendered (innerText pierces shadow), plus the total
|
|
175
|
+
// rendered text length so the caller can wait for the render to *settle* — heavy pages (e.g.
|
|
176
|
+
// the api-console) render the container early but keep filling it in.
|
|
177
|
+
const expression = `(() => {
|
|
178
|
+
const out = [];
|
|
179
|
+
const visit = (r) => { for (const c of Array.from(r.children || [])) { out.push(c); if (c.shadowRoot) visit(c.shadowRoot); visit(c); } };
|
|
180
|
+
visit(document);
|
|
181
|
+
const SELS = ${JSON.stringify(selectors)};
|
|
182
|
+
let has = false;
|
|
183
|
+
for (const el of out) {
|
|
184
|
+
if (!el.matches) continue;
|
|
185
|
+
if (!SELS.some((s) => { try { return el.matches(s); } catch (e) { return false; } })) continue;
|
|
186
|
+
if (((el.innerText) || '').trim().length >= ${minChars}) { has = true; break; }
|
|
187
|
+
}
|
|
188
|
+
return { has, len: document.body ? (document.body.innerText || '').length : 0 };
|
|
189
|
+
})()`;
|
|
190
|
+
const start = Date.now();
|
|
191
|
+
let previousLength = -1;
|
|
192
|
+
let stablePolls = 0;
|
|
193
|
+
while (Date.now() - start < timeoutMs) {
|
|
194
|
+
const value = (await page.client
|
|
195
|
+
.send('Runtime.evaluate', { expression, returnByValue: true }, page.sessionId)
|
|
196
|
+
.catch(() => null))?.result?.value;
|
|
197
|
+
if (value?.has) {
|
|
198
|
+
const grew = Math.abs((value.len || 0) - previousLength) > Math.max(40, previousLength * 0.02);
|
|
199
|
+
if (!grew && ++stablePolls >= 2)
|
|
200
|
+
return; // content present and rendering has settled
|
|
201
|
+
if (grew)
|
|
202
|
+
stablePolls = 0;
|
|
203
|
+
previousLength = value.len || 0;
|
|
204
|
+
}
|
|
205
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
export const BLOCKED_ASSET_URLS = [
|
|
209
|
+
'*.css',
|
|
210
|
+
'*.png',
|
|
211
|
+
'*.jpg',
|
|
212
|
+
'*.jpeg',
|
|
213
|
+
'*.gif',
|
|
214
|
+
'*.svg',
|
|
215
|
+
'*.woff',
|
|
216
|
+
'*.woff2',
|
|
217
|
+
'*.ttf',
|
|
218
|
+
'*.mp4',
|
|
219
|
+
'*.mp3',
|
|
220
|
+
'*.ico',
|
|
221
|
+
];
|
|
222
|
+
const DEFAULT_USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
|
|
223
|
+
/**
|
|
224
|
+
* Spawns a headless Chrome, attaches a flat CDP session, and enables the Page, Network,
|
|
225
|
+
* and Runtime domains. The caller drives navigation and must call close() when finished.
|
|
226
|
+
*/
|
|
227
|
+
export async function openCdpPage() {
|
|
228
|
+
const chromePath = findChromePath();
|
|
229
|
+
const { chromeProcess, wsUrl } = await spawnChrome(chromePath);
|
|
230
|
+
const client = new CdpClient(wsUrl);
|
|
231
|
+
try {
|
|
232
|
+
await client.connect();
|
|
233
|
+
const { targetId } = await client.send('Target.createTarget', { url: 'about:blank' });
|
|
234
|
+
const { sessionId } = await client.send('Target.attachToTarget', { targetId, flatten: true });
|
|
235
|
+
await client.send('Page.enable', {}, sessionId);
|
|
236
|
+
await client.send('Network.enable', {}, sessionId);
|
|
237
|
+
await client.send('Runtime.enable', {}, sessionId);
|
|
238
|
+
await client.send('Network.setUserAgentOverride', { userAgent: DEFAULT_USER_AGENT }, sessionId);
|
|
239
|
+
const close = async () => {
|
|
240
|
+
try {
|
|
241
|
+
await client.send('Target.closeTarget', { targetId });
|
|
242
|
+
}
|
|
243
|
+
catch { }
|
|
244
|
+
client.close();
|
|
245
|
+
chromeProcess.kill('SIGKILL');
|
|
246
|
+
};
|
|
247
|
+
return { client, sessionId, close };
|
|
248
|
+
}
|
|
249
|
+
catch (err) {
|
|
250
|
+
client.close();
|
|
251
|
+
chromeProcess.kill('SIGKILL');
|
|
252
|
+
throw err;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Buffers response bodies for requests matching one of the supplied matchers, keyed by
|
|
257
|
+
* matcher key. Matching happens on requestWillBeSent so predicates can inspect post data
|
|
258
|
+
* (e.g. to tell two same-URL XHRs apart). Works around CdpClient's single-handler-per-event
|
|
259
|
+
* model so several waitFor() calls can track different responses from one navigation.
|
|
260
|
+
* Install before navigating.
|
|
261
|
+
*/
|
|
262
|
+
export class ResponseCapture {
|
|
263
|
+
matched = new Map(); // requestId -> matcher keys
|
|
264
|
+
bodies = new Map(); // matcher key -> response body text
|
|
265
|
+
waiters = new Map();
|
|
266
|
+
page;
|
|
267
|
+
matchers;
|
|
268
|
+
constructor(page, matchers) {
|
|
269
|
+
this.page = page;
|
|
270
|
+
this.matchers = matchers;
|
|
271
|
+
const { client, sessionId } = page;
|
|
272
|
+
client.on(`${sessionId}:Network.requestWillBeSent`, (params) => {
|
|
273
|
+
const request = { url: params.request?.url ?? '', postData: params.request?.postData };
|
|
274
|
+
const keys = this.matchers.filter((m) => m.test(request)).map((m) => m.key);
|
|
275
|
+
if (keys.length)
|
|
276
|
+
this.matched.set(params.requestId, keys);
|
|
277
|
+
});
|
|
278
|
+
client.on(`${sessionId}:Network.loadingFinished`, (params) => {
|
|
279
|
+
void this.onFinished(params.requestId);
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
// Body is only available after loadingFinished, per the CDP Network contract.
|
|
283
|
+
async onFinished(requestId) {
|
|
284
|
+
const keys = this.matched.get(requestId);
|
|
285
|
+
if (!keys)
|
|
286
|
+
return;
|
|
287
|
+
const body = await this.readBody(requestId);
|
|
288
|
+
if (body === null)
|
|
289
|
+
return;
|
|
290
|
+
for (const key of keys) {
|
|
291
|
+
if (this.bodies.has(key))
|
|
292
|
+
continue; // already captured for this key
|
|
293
|
+
const matcher = this.matchers.find((m) => m.key === key);
|
|
294
|
+
if (matcher?.accept && !matcher.accept(body))
|
|
295
|
+
continue; // wait for a later matching response
|
|
296
|
+
this.bodies.set(key, body);
|
|
297
|
+
const waiters = this.waiters.get(key) ?? [];
|
|
298
|
+
this.waiters.delete(key);
|
|
299
|
+
waiters.forEach((resolve) => resolve(body));
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
async readBody(requestId) {
|
|
303
|
+
try {
|
|
304
|
+
const res = await this.page.client.send('Network.getResponseBody', { requestId }, this.page.sessionId);
|
|
305
|
+
return res.base64Encoded ? Buffer.from(res.body, 'base64').toString('utf8') : res.body;
|
|
306
|
+
}
|
|
307
|
+
catch {
|
|
308
|
+
return null;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
/** Resolves with the response body text once a matching response finishes, or null on timeout. */
|
|
312
|
+
waitFor(key, timeoutMs) {
|
|
313
|
+
const existing = this.bodies.get(key);
|
|
314
|
+
if (existing !== undefined)
|
|
315
|
+
return Promise.resolve(existing);
|
|
316
|
+
return new Promise((resolve) => {
|
|
317
|
+
const timer = setTimeout(() => resolve(null), timeoutMs);
|
|
318
|
+
const list = this.waiters.get(key) ?? [];
|
|
319
|
+
list.push((body) => {
|
|
320
|
+
clearTimeout(timer);
|
|
321
|
+
resolve(body);
|
|
322
|
+
});
|
|
323
|
+
this.waiters.set(key, list);
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
export async function fetchRenderedHtml(url, options = {}) {
|
|
328
|
+
const timeout = options.timeoutMs ?? 15_000;
|
|
329
|
+
const limit = options.bodyLimitBytes ?? 2 * 1024 * 1024;
|
|
330
|
+
const currentUrl = normalizeUrl(url);
|
|
331
|
+
await checkDnsSafety(new URL(currentUrl).hostname);
|
|
332
|
+
const page = await openCdpPage();
|
|
333
|
+
try {
|
|
334
|
+
await page.client.send('Network.setBlockedURLs', { urls: BLOCKED_ASSET_URLS }, page.sessionId);
|
|
335
|
+
await page.client.send('Page.navigate', { url: currentUrl }, page.sessionId);
|
|
336
|
+
await waitForLoad(page.client, page.sessionId, timeout, options.settleMs ?? 1000);
|
|
337
|
+
const evalResult = await page.client.send('Runtime.evaluate', { expression: 'document.documentElement.outerHTML', returnByValue: true }, page.sessionId);
|
|
338
|
+
const html = evalResult.result?.value;
|
|
339
|
+
if (typeof html !== 'string') {
|
|
340
|
+
throw new Error('Failed to retrieve rendered HTML from browser page.');
|
|
341
|
+
}
|
|
342
|
+
const byteLength = Buffer.byteLength(html);
|
|
343
|
+
if (byteLength > limit) {
|
|
344
|
+
throw new Error(`Response body size limit exceeded. Rendered HTML is ${byteLength} bytes.`);
|
|
345
|
+
}
|
|
346
|
+
return {
|
|
347
|
+
status: 200,
|
|
348
|
+
contentType: 'text/html',
|
|
349
|
+
etag: null,
|
|
350
|
+
lastModified: null,
|
|
351
|
+
finalUrl: currentUrl,
|
|
352
|
+
responseSize: byteLength,
|
|
353
|
+
content: html,
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
finally {
|
|
357
|
+
await page.close();
|
|
358
|
+
}
|
|
359
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { normalizeUrl } from './url.js';
|
|
3
|
+
/**
|
|
4
|
+
* Derives a deterministic hexadecimal SHA-256 cache key from a source URL.
|
|
5
|
+
* Normalizes the URL first before hashing.
|
|
6
|
+
*/
|
|
7
|
+
export function deriveCacheKey(url) {
|
|
8
|
+
const normalized = normalizeUrl(url);
|
|
9
|
+
return createHash('sha256').update(normalized).digest('hex');
|
|
10
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { FetchResult } from './fetcher.js';
|
|
2
|
+
import type { ExtractionResult } from './extract.js';
|
|
3
|
+
import type { CaptureMethod } from './schema.js';
|
|
4
|
+
import type { MachineReadableArtifact, SiteCapabilities } from './docs/capabilities.js';
|
|
5
|
+
import { type TextFetcher } from './docs/machine-readable.js';
|
|
6
|
+
export interface CaptureDeps {
|
|
7
|
+
fetchStatic: (url: string) => Promise<FetchResult>;
|
|
8
|
+
fetchRendered: (url: string) => Promise<FetchResult>;
|
|
9
|
+
fetchText: TextFetcher;
|
|
10
|
+
}
|
|
11
|
+
export interface CaptureOptions {
|
|
12
|
+
forceRendered?: boolean;
|
|
13
|
+
}
|
|
14
|
+
export interface CaptureOutcome {
|
|
15
|
+
fetchResult: FetchResult;
|
|
16
|
+
extraction: ExtractionResult;
|
|
17
|
+
capabilities: SiteCapabilities;
|
|
18
|
+
captureMethod: CaptureMethod;
|
|
19
|
+
sourceDocUrl: string | null;
|
|
20
|
+
machineReadable: MachineReadableArtifact[];
|
|
21
|
+
attemptedMethods: string[];
|
|
22
|
+
}
|
|
23
|
+
export declare function renderedFallbackNeeded(caps: SiteCapabilities, extraction: ExtractionResult | null): boolean;
|
|
24
|
+
/**
|
|
25
|
+
* Captures a page's best-available content. Order: forced rendered → route/source Markdown → static
|
|
26
|
+
* HTML → rendered fallback. Returns the chosen fetch result, extraction, detected capabilities, and
|
|
27
|
+
* provenance (capture method, source URL, machine-readable artifacts, attempted methods).
|
|
28
|
+
*/
|
|
29
|
+
export declare function capturePage(url: string, options: CaptureOptions, deps: CaptureDeps): Promise<CaptureOutcome>;
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { extractHtmlContent } from './extract.js';
|
|
2
|
+
import { detectDocsEngine } from './docs/detect.js';
|
|
3
|
+
import { emptyCapabilities } from './docs/capabilities.js';
|
|
4
|
+
import { probeRouteMarkdown, probeLlmsTxt } from './docs/machine-readable.js';
|
|
5
|
+
import { mapUrlToSource } from './docs/source-map.js';
|
|
6
|
+
import { extractFromSource } from './docs/markdown-source.js';
|
|
7
|
+
import { validateTextArtifact } from './docs/validate.js';
|
|
8
|
+
const MIN_USEFUL_CHARS = 600;
|
|
9
|
+
// A static extraction needs rendering help when it failed outright, the detector says the page is
|
|
10
|
+
// an SPA/client-rendered, or the extracted article is too thin/low-confidence to be useful.
|
|
11
|
+
export function renderedFallbackNeeded(caps, extraction) {
|
|
12
|
+
if (!extraction)
|
|
13
|
+
return true;
|
|
14
|
+
if (caps.recommendedCapture === 'rendered')
|
|
15
|
+
return true;
|
|
16
|
+
return extraction.confidence === 'low' && extraction.detailedMarkdown.length < MIN_USEFUL_CHARS;
|
|
17
|
+
}
|
|
18
|
+
async function resolveSource(url, caps, deps) {
|
|
19
|
+
const route = await probeRouteMarkdown(url, caps, deps.fetchText);
|
|
20
|
+
if (route) {
|
|
21
|
+
return {
|
|
22
|
+
extraction: extractFromSource(route.body, route.artifact.url),
|
|
23
|
+
method: 'route_markdown',
|
|
24
|
+
url: route.artifact.url,
|
|
25
|
+
mrType: 'route-markdown',
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
const candidates = [];
|
|
29
|
+
if (caps.source?.url)
|
|
30
|
+
candidates.push(caps.source.url);
|
|
31
|
+
const mapped = mapUrlToSource(url);
|
|
32
|
+
if (mapped)
|
|
33
|
+
candidates.push(mapped.rawUrl);
|
|
34
|
+
for (const candidate of candidates) {
|
|
35
|
+
try {
|
|
36
|
+
const res = await deps.fetchText(candidate);
|
|
37
|
+
if (res.status !== 304 && res.content && validateTextArtifact(res.content).ok) {
|
|
38
|
+
return {
|
|
39
|
+
extraction: extractFromSource(res.content, candidate),
|
|
40
|
+
method: 'github_source',
|
|
41
|
+
url: candidate,
|
|
42
|
+
mrType: 'source-edit-link',
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
// try next candidate
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
async function discoverLlmsTxt(url, caps, deps) {
|
|
53
|
+
try {
|
|
54
|
+
const llms = await probeLlmsTxt(url, caps, deps.fetchText);
|
|
55
|
+
return llms ? [llms.artifact] : [];
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return [];
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
async function captureRendered(url, deps) {
|
|
62
|
+
const fetchResult = await deps.fetchRendered(url);
|
|
63
|
+
return { fetchResult, extraction: extractHtmlContent(fetchResult.content, fetchResult.finalUrl) };
|
|
64
|
+
}
|
|
65
|
+
// Browser-rendered capture as a complete outcome. Shared by the forced-rendered path and the
|
|
66
|
+
// automatic fallback; appends 'browser_fallback' to attemptedMethods (mutated in place).
|
|
67
|
+
async function renderedOutcome(url, deps, attemptedMethods, machineReadable) {
|
|
68
|
+
attemptedMethods.push('browser_fallback');
|
|
69
|
+
const { fetchResult, extraction } = await captureRendered(url, deps);
|
|
70
|
+
return {
|
|
71
|
+
fetchResult,
|
|
72
|
+
extraction,
|
|
73
|
+
capabilities: detectDocsEngine(fetchResult.content, fetchResult.finalUrl),
|
|
74
|
+
captureMethod: 'browser_fallback',
|
|
75
|
+
sourceDocUrl: null,
|
|
76
|
+
machineReadable: dedupeArtifacts(machineReadable),
|
|
77
|
+
attemptedMethods,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
// Static fetch + engine detection + llms.txt discovery. Never throws.
|
|
81
|
+
async function runStaticPhase(url, deps) {
|
|
82
|
+
let staticResult = null;
|
|
83
|
+
let staticError = null;
|
|
84
|
+
try {
|
|
85
|
+
staticResult = await deps.fetchStatic(url);
|
|
86
|
+
}
|
|
87
|
+
catch (err) {
|
|
88
|
+
staticError = err;
|
|
89
|
+
}
|
|
90
|
+
const capabilities = staticResult
|
|
91
|
+
? detectDocsEngine(staticResult.content, staticResult.finalUrl)
|
|
92
|
+
: emptyCapabilities();
|
|
93
|
+
const machineReadable = staticResult ? await discoverLlmsTxt(url, capabilities, deps) : [];
|
|
94
|
+
return { staticResult, capabilities, machineReadable, staticError };
|
|
95
|
+
}
|
|
96
|
+
// Machine-readable / public-source capture, preferred over HTML extraction. Returns null (so the
|
|
97
|
+
// caller falls through to HTML/rendered) when there is no static page or no source validates.
|
|
98
|
+
async function sourceOutcome(url, phase, deps, attemptedMethods) {
|
|
99
|
+
if (!phase.staticResult)
|
|
100
|
+
return null;
|
|
101
|
+
const source = await resolveSource(url, phase.capabilities, deps);
|
|
102
|
+
if (!source)
|
|
103
|
+
return null;
|
|
104
|
+
phase.machineReadable.push({ type: source.mrType, url: source.url, evidence: 'verified' });
|
|
105
|
+
return {
|
|
106
|
+
fetchResult: phase.staticResult,
|
|
107
|
+
extraction: source.extraction,
|
|
108
|
+
capabilities: phase.capabilities,
|
|
109
|
+
captureMethod: source.method,
|
|
110
|
+
sourceDocUrl: source.url,
|
|
111
|
+
machineReadable: dedupeArtifacts(phase.machineReadable),
|
|
112
|
+
attemptedMethods: [...attemptedMethods, source.method],
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
// Rendered fallback when static extraction is missing or low quality. Returns null when no fallback
|
|
116
|
+
// is warranted; throws only when rendering fails and nothing usable was produced (preferring the
|
|
117
|
+
// earlier static error).
|
|
118
|
+
async function renderedFallbackOutcome(url, deps, attemptedMethods, phase, extraction) {
|
|
119
|
+
if (!renderedFallbackNeeded(phase.capabilities, extraction))
|
|
120
|
+
return null;
|
|
121
|
+
try {
|
|
122
|
+
return await renderedOutcome(url, deps, attemptedMethods, phase.machineReadable);
|
|
123
|
+
}
|
|
124
|
+
catch (renderErr) {
|
|
125
|
+
if (!extraction)
|
|
126
|
+
throw phase.staticError ?? renderErr;
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Captures a page's best-available content. Order: forced rendered → route/source Markdown → static
|
|
132
|
+
* HTML → rendered fallback. Returns the chosen fetch result, extraction, detected capabilities, and
|
|
133
|
+
* provenance (capture method, source URL, machine-readable artifacts, attempted methods).
|
|
134
|
+
*/
|
|
135
|
+
export async function capturePage(url, options, deps) {
|
|
136
|
+
const attemptedMethods = [];
|
|
137
|
+
if (options.forceRendered) {
|
|
138
|
+
return renderedOutcome(url, deps, attemptedMethods, []);
|
|
139
|
+
}
|
|
140
|
+
attemptedMethods.push('static_fetch');
|
|
141
|
+
const phase = await runStaticPhase(url, deps);
|
|
142
|
+
// Prefer machine-readable / public source before HTML extraction.
|
|
143
|
+
const fromSource = await sourceOutcome(url, phase, deps, attemptedMethods);
|
|
144
|
+
if (fromSource)
|
|
145
|
+
return fromSource;
|
|
146
|
+
let extraction = null;
|
|
147
|
+
if (phase.staticResult) {
|
|
148
|
+
try {
|
|
149
|
+
extraction = extractHtmlContent(phase.staticResult.content, phase.staticResult.finalUrl);
|
|
150
|
+
}
|
|
151
|
+
catch (err) {
|
|
152
|
+
phase.staticError = err;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
const fallback = await renderedFallbackOutcome(url, deps, attemptedMethods, phase, extraction);
|
|
156
|
+
if (fallback)
|
|
157
|
+
return fallback;
|
|
158
|
+
if (!extraction)
|
|
159
|
+
throw phase.staticError ?? new Error(`Could not capture ${url}`);
|
|
160
|
+
return {
|
|
161
|
+
fetchResult: phase.staticResult,
|
|
162
|
+
extraction,
|
|
163
|
+
capabilities: phase.capabilities,
|
|
164
|
+
captureMethod: 'static_fetch',
|
|
165
|
+
sourceDocUrl: null,
|
|
166
|
+
machineReadable: dedupeArtifacts(phase.machineReadable),
|
|
167
|
+
attemptedMethods,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
function dedupeArtifacts(items) {
|
|
171
|
+
const seen = new Set();
|
|
172
|
+
const out = [];
|
|
173
|
+
for (const item of items) {
|
|
174
|
+
const key = `${item.type}:${item.url}`;
|
|
175
|
+
if (seen.has(key))
|
|
176
|
+
continue;
|
|
177
|
+
seen.add(key);
|
|
178
|
+
out.push(item);
|
|
179
|
+
}
|
|
180
|
+
return out;
|
|
181
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compresses Markdown text by removing images, simplifying links from [text](url) to [text],
|
|
3
|
+
* and collapsing multiple consecutive blank lines.
|
|
4
|
+
*/
|
|
5
|
+
export function compressMarkdown(markdown) {
|
|
6
|
+
if (!markdown) {
|
|
7
|
+
return '';
|
|
8
|
+
}
|
|
9
|
+
let result = markdown;
|
|
10
|
+
// Remove markdown images:  -> ""
|
|
11
|
+
result = result.replace(/!\[.*?\]\(.*?\)/g, '');
|
|
12
|
+
// Simplify links: [text](url) -> [text]
|
|
13
|
+
result = result.replace(/\[(.*?)\]\(.*?\)/g, '[$1]');
|
|
14
|
+
// Collapse multiple consecutive newlines (3 or more) to maximum 2 newlines
|
|
15
|
+
result = result.replace(/\n{3,}/g, '\n\n');
|
|
16
|
+
return result.trim();
|
|
17
|
+
}
|