@taurgis/bonsai 3.2.0 → 4.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 +9 -6
- package/agents/agents/salesforce-docs-researcher.agent.md +17 -4
- package/agents/instructions/salesforce-research.instructions.md +2 -1
- package/bin/cli.mjs +13 -18
- package/dist/base-command.d.ts +59 -12
- package/dist/base-command.js +179 -51
- package/dist/commands/config/base.js +12 -2
- package/dist/commands/config/get.js +8 -12
- package/dist/commands/config/index.js +2 -2
- package/dist/commands/config/list.js +11 -17
- package/dist/commands/config/set.d.ts +2 -1
- package/dist/commands/config/set.js +35 -28
- package/dist/commands/config/unset.d.ts +2 -1
- package/dist/commands/config/unset.js +26 -25
- package/dist/commands/fetch.d.ts +7 -15
- package/dist/commands/fetch.js +61 -401
- package/dist/commands/import.d.ts +4 -9
- package/dist/commands/import.js +56 -284
- package/dist/commands/inspect.d.ts +7 -1
- package/dist/commands/inspect.js +112 -52
- package/dist/commands/list.d.ts +18 -3
- package/dist/commands/list.js +121 -46
- package/dist/commands/prune.d.ts +6 -1
- package/dist/commands/prune.js +89 -78
- package/dist/commands/status.d.ts +3 -2
- package/dist/commands/status.js +62 -79
- package/dist/hooks/command-not-found/suggest.d.ts +2 -1
- package/dist/hooks/command-not-found/suggest.js +78 -41
- package/dist/lib/argv.d.ts +29 -3
- package/dist/lib/argv.js +147 -54
- package/dist/lib/batch.d.ts +41 -0
- package/dist/lib/batch.js +39 -0
- package/dist/lib/cache-view.d.ts +21 -0
- package/dist/lib/cache-view.js +30 -0
- package/dist/lib/cli-emit.d.ts +28 -0
- package/dist/lib/cli-emit.js +44 -0
- package/dist/lib/cli-error-policy.d.ts +57 -0
- package/dist/lib/cli-error-policy.js +74 -0
- package/dist/lib/cli-flag-manifest.d.ts +12 -0
- package/dist/lib/cli-flag-manifest.js +15 -0
- package/dist/lib/cli-presentation.d.ts +41 -0
- package/dist/lib/cli-presentation.js +53 -0
- package/dist/lib/cli-result-types.d.ts +112 -0
- package/dist/lib/cli-result-types.js +2 -0
- package/dist/lib/config/index.d.ts +23 -5
- package/dist/lib/config/index.js +60 -5
- package/dist/lib/config/io.d.ts +9 -0
- package/dist/lib/config/io.js +71 -21
- package/dist/lib/config/schema.d.ts +2 -0
- package/dist/lib/config/schema.js +7 -0
- package/dist/lib/config-persist.d.ts +28 -0
- package/dist/lib/config-persist.js +29 -0
- package/dist/lib/envelope.d.ts +41 -3
- package/dist/lib/envelope.js +154 -4
- package/dist/lib/help-preflight.d.ts +5 -8
- package/dist/lib/help-preflight.js +13 -32
- package/dist/lib/json-meta.d.ts +0 -2
- package/dist/lib/json-meta.js +21 -25
- package/dist/lib/limit-flag.d.ts +11 -1
- package/dist/lib/limit-flag.js +19 -6
- package/dist/lib/parse-error-ux.d.ts +5 -0
- package/dist/lib/parse-error-ux.js +73 -0
- package/dist/lib/prune-flags.d.ts +30 -0
- package/dist/lib/prune-flags.js +86 -0
- package/dist/lib/research/artifact-index.d.ts +11 -2
- package/dist/lib/research/artifact-index.js +14 -5
- package/dist/lib/research/artifact.js +20 -2
- package/dist/lib/research/browser.d.ts +53 -2
- package/dist/lib/research/browser.js +202 -58
- package/dist/lib/research/capture.d.ts +24 -2
- package/dist/lib/research/capture.js +24 -4
- package/dist/lib/research/cli-io.d.ts +40 -0
- package/dist/lib/research/cli-io.js +27 -0
- package/dist/lib/research/compress.d.ts +7 -0
- package/dist/lib/research/compress.js +7 -3
- package/dist/lib/research/docs/capabilities.d.ts +15 -0
- package/dist/lib/research/docs/capabilities.js +5 -0
- package/dist/lib/research/docs/clean-dom.d.ts +2 -0
- package/dist/lib/research/docs/clean-dom.js +2 -0
- package/dist/lib/research/docs/code-blocks.d.ts +2 -0
- package/dist/lib/research/docs/code-blocks.js +2 -0
- package/dist/lib/research/docs/detect.d.ts +4 -0
- package/dist/lib/research/docs/detect.js +4 -0
- package/dist/lib/research/docs/machine-readable.d.ts +18 -0
- package/dist/lib/research/docs/machine-readable.js +10 -0
- package/dist/lib/research/docs/markdown-source.d.ts +20 -0
- package/dist/lib/research/docs/markdown-source.js +19 -2
- package/dist/lib/research/docs/quality-gates.d.ts +5 -0
- package/dist/lib/research/docs/quality-gates.js +4 -0
- package/dist/lib/research/docs/rsc.d.ts +9 -2
- package/dist/lib/research/docs/rsc.js +5 -2
- package/dist/lib/research/docs/section-artifacts.d.ts +12 -0
- package/dist/lib/research/docs/section-artifacts.js +12 -0
- package/dist/lib/research/docs/sections.d.ts +18 -0
- package/dist/lib/research/docs/sections.js +17 -1
- package/dist/lib/research/docs/source-map.d.ts +28 -0
- package/dist/lib/research/docs/source-map.js +27 -0
- package/dist/lib/research/docs/validate.d.ts +35 -2
- package/dist/lib/research/docs/validate.js +47 -2
- package/dist/lib/research/extract.d.ts +16 -0
- package/dist/lib/research/extract.js +12 -1
- package/dist/lib/research/fetch-command-service.d.ts +36 -0
- package/dist/lib/research/fetch-command-service.js +367 -0
- package/dist/lib/research/fetch-result.d.ts +114 -0
- package/dist/lib/research/fetch-result.js +212 -0
- package/dist/lib/research/fetcher.d.ts +46 -8
- package/dist/lib/research/fetcher.js +61 -31
- package/dist/lib/research/freshness.d.ts +54 -11
- package/dist/lib/research/freshness.js +91 -31
- package/dist/lib/research/import-command-service.d.ts +56 -0
- package/dist/lib/research/import-command-service.js +402 -0
- package/dist/lib/research/keywords.js +5 -1
- package/dist/lib/research/markdown.d.ts +12 -1
- package/dist/lib/research/markdown.js +13 -5
- package/dist/lib/research/metadata-filters.d.ts +18 -0
- package/dist/lib/research/metadata-filters.js +37 -0
- package/dist/lib/research/metadata-flags.d.ts +34 -0
- package/dist/lib/research/metadata-flags.js +45 -0
- package/dist/lib/research/persist-artifact.d.ts +23 -0
- package/dist/lib/research/persist-artifact.js +27 -0
- package/dist/lib/research/prompt-injection.d.ts +9 -0
- package/dist/lib/research/prompt-injection.js +59 -14
- package/dist/lib/research/proxy.d.ts +14 -8
- package/dist/lib/research/proxy.js +14 -10
- package/dist/lib/research/resolve-target.d.ts +2 -0
- package/dist/lib/research/resolve-target.js +1 -1
- package/dist/lib/research/revalidate.d.ts +49 -20
- package/dist/lib/research/revalidate.js +80 -15
- package/dist/lib/research/schema.d.ts +7 -0
- package/dist/lib/research/schema.js +2 -0
- package/dist/lib/research/secret-scan.d.ts +6 -0
- package/dist/lib/research/secret-scan.js +8 -0
- package/dist/lib/research/secure-write.d.ts +4 -0
- package/dist/lib/research/secure-write.js +31 -2
- package/dist/lib/research/storage.d.ts +9 -8
- package/dist/lib/research/storage.js +34 -28
- package/dist/lib/research/summarize.d.ts +15 -0
- package/dist/lib/research/summarize.js +11 -0
- package/dist/lib/research/url.d.ts +9 -6
- package/dist/lib/research/url.js +33 -21
- package/dist/lib/text.d.ts +19 -8
- package/dist/lib/text.js +32 -9
- package/dist/lib/value-flags.d.ts +14 -0
- package/dist/lib/value-flags.js +28 -0
- package/dist/sites/coveo.d.ts +10 -0
- package/dist/sites/coveo.js +10 -0
- package/dist/sites/index.d.ts +16 -0
- package/dist/sites/index.js +16 -4
- package/dist/sites/markdown-twin.d.ts +31 -0
- package/dist/sites/markdown-twin.js +56 -0
- package/dist/sites/salesforce/fetch-page.d.ts +8 -2
- package/dist/sites/salesforce/fetch-page.js +14 -3
- package/dist/sites/salesforce/help-tooling-markdown.d.ts +30 -0
- package/dist/sites/salesforce/help-tooling-markdown.js +74 -0
- package/dist/sites/salesforce/index.d.ts +1 -0
- package/dist/sites/salesforce/index.js +1 -0
- package/dist/sites/salesforce-developer/fetch-page.d.ts +6 -1
- package/dist/sites/salesforce-developer/fetch-page.js +6 -1
- package/dist/sites/salesforce-developer/index.d.ts +1 -0
- package/dist/sites/salesforce-developer/index.js +1 -0
- package/dist/sites/salesforce-developer/route-markdown.d.ts +18 -6
- package/dist/sites/salesforce-developer/route-markdown.js +29 -67
- package/dist/sites/salesforce-doc-fetch.d.ts +39 -5
- package/dist/sites/salesforce-doc-fetch.js +52 -7
- package/dist/sites/salesforce-dom-probe.d.ts +20 -2
- package/dist/sites/salesforce-dom-probe.js +20 -2
- package/dist/sites/types.d.ts +14 -8
- package/dist/sites/types.js +3 -0
- package/oclif.manifest.json +216 -102
- package/package.json +20 -7
- package/agents/agents/official-docs-researcher.agent.md +0 -87
- package/agents/instructions/web-research.instructions.md +0 -44
- package/agents/skills/web-research/SKILL.md +0 -118
|
@@ -39,7 +39,13 @@ function getOrUpdateIndexEntry(filePath, cached, onChanged) {
|
|
|
39
39
|
return cached;
|
|
40
40
|
}
|
|
41
41
|
try {
|
|
42
|
-
const
|
|
42
|
+
const artifact = parseArtifactShallow(readFileSync(filePath, 'utf-8'));
|
|
43
|
+
// Mirrors the same guard in storage.ts's scanCacheDir: parseArtifact never throws on a foreign
|
|
44
|
+
// or garbled *.md file that merely has `---`/`---` fences, it just defaults cache_key to ''. Treat
|
|
45
|
+
// that the same as a parse failure so it's skipped rather than indexed as a phantom entry.
|
|
46
|
+
if (!artifact.metadata.cache_key)
|
|
47
|
+
return undefined;
|
|
48
|
+
const entry = { sig, artifact };
|
|
43
49
|
onChanged();
|
|
44
50
|
return entry;
|
|
45
51
|
}
|
|
@@ -52,10 +58,13 @@ function getOrUpdateIndexEntry(filePath, cached, onChanged) {
|
|
|
52
58
|
* to skip unchanged files. Re-reads only files whose signature changed, then persists the refreshed
|
|
53
59
|
* index (best-effort; a write failure never breaks callers). Corrupt/unreadable files are skipped —
|
|
54
60
|
* the real read paths in storage.ts handle archiving them.
|
|
61
|
+
*
|
|
62
|
+
* Pass `{ persist: false }` under read-only/plan mode so the derived sidecar is never written.
|
|
55
63
|
*/
|
|
56
|
-
export function loadIndexedArtifactsForDir(researchDir) {
|
|
64
|
+
export function loadIndexedArtifactsForDir(researchDir, options = {}) {
|
|
57
65
|
if (!existsSync(researchDir))
|
|
58
66
|
return [];
|
|
67
|
+
const persist = options.persist !== false;
|
|
59
68
|
const indexPath = join(researchDir, INDEX_FILE);
|
|
60
69
|
const previous = loadIndex(indexPath);
|
|
61
70
|
const entries = {};
|
|
@@ -78,7 +87,7 @@ export function loadIndexedArtifactsForDir(researchDir) {
|
|
|
78
87
|
if (!changed) {
|
|
79
88
|
changed = Object.keys(previous.entries).some((key) => !(key in entries));
|
|
80
89
|
}
|
|
81
|
-
if (changed) {
|
|
90
|
+
if (changed && persist) {
|
|
82
91
|
try {
|
|
83
92
|
atomicWriteFile(indexPath, JSON.stringify({ version: INDEX_VERSION, entries }));
|
|
84
93
|
}
|
|
@@ -93,11 +102,11 @@ export function loadIndexedArtifactsForDir(researchDir) {
|
|
|
93
102
|
* key so a project entry shadows the same key in the global cache. Mirrors the dedup semantics of
|
|
94
103
|
* {@link scanCacheDirs} but reads through the per-root sidecar index.
|
|
95
104
|
*/
|
|
96
|
-
export function loadIndexedArtifacts(dataDirs) {
|
|
105
|
+
export function loadIndexedArtifacts(dataDirs, options = {}) {
|
|
97
106
|
const seen = new Set();
|
|
98
107
|
const all = [];
|
|
99
108
|
for (const dataDir of dataDirs) {
|
|
100
|
-
for (const located of loadIndexedArtifactsForDir(join(dataDir, 'research'))) {
|
|
109
|
+
for (const located of loadIndexedArtifactsForDir(join(dataDir, 'research'), options)) {
|
|
101
110
|
const key = located.artifact.metadata.cache_key;
|
|
102
111
|
if (seen.has(key))
|
|
103
112
|
continue;
|
|
@@ -1,12 +1,30 @@
|
|
|
1
|
+
import { CONTROL_CHAR_PATTERN } from '../text.js';
|
|
2
|
+
// Frontmatter is one field per line. An embedded newline (or tab, treated the same way as
|
|
3
|
+
// legitimate Markdown whitespace elsewhere in this codebase — see sanitizeForTerminal in
|
|
4
|
+
// ../text.ts and stripUnsafeControlChars in ./prompt-injection.ts) in a free-form value (e.g. an
|
|
5
|
+
// agent-supplied --topic or --tags) would inject a bogus extra line — at best a dropped stray
|
|
6
|
+
// line, at worst a fake `key: value` pair, or a bare `---` that closes the frontmatter fence early
|
|
7
|
+
// and splices the remaining metadata fields (and even the closing fence and body sections) into
|
|
8
|
+
// what parseArtifact then treats as free-text content. Collapsing that whitespace to a space
|
|
9
|
+
// (rather than deleting it, which would silently merge adjacent words) keeps every value confined
|
|
10
|
+
// to the single line the format requires, regardless of where the string originated. Other control
|
|
11
|
+
// characters (e.g. a raw ANSI escape byte, whether from an agent-supplied flag or extracted page
|
|
12
|
+
// text) are stripped outright: the cache file is plain text meant to be `cat`ed or displayed by
|
|
13
|
+
// `inspect`/`list`, and an uncaught escape sequence there would reach a real terminal — unlike
|
|
14
|
+
// --json output, which JSON-escapes control characters by construction.
|
|
15
|
+
function sanitizeFrontmatterLine(value) {
|
|
16
|
+
return value.replace(/[\t\r\n]+/g, ' ').replace(CONTROL_CHAR_PATTERN, '');
|
|
17
|
+
}
|
|
1
18
|
function serializeArrayField(lines, name, items) {
|
|
2
19
|
lines.push(`${name}:`);
|
|
3
20
|
for (const item of items) {
|
|
4
|
-
lines.push(` - ${item}`);
|
|
21
|
+
lines.push(` - ${sanitizeFrontmatterLine(item)}`);
|
|
5
22
|
}
|
|
6
23
|
}
|
|
7
24
|
// A scalar frontmatter line, blanking null/undefined. Using `??` (not `||`) preserves a numeric 0.
|
|
8
25
|
function serializeScalarField(lines, name, value) {
|
|
9
|
-
|
|
26
|
+
const safeValue = typeof value === 'string' ? sanitizeFrontmatterLine(value) : value;
|
|
27
|
+
lines.push(`${name}: ${safeValue ?? ''}`);
|
|
10
28
|
}
|
|
11
29
|
/**
|
|
12
30
|
* Serializes artifact metadata to standard YAML frontmatter text.
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ChildProcess } from 'node:child_process';
|
|
1
2
|
export interface BrowserFetchOptions {
|
|
2
3
|
timeoutMs?: number;
|
|
3
4
|
bodyLimitBytes?: number;
|
|
@@ -25,12 +26,27 @@ export declare class CdpClient {
|
|
|
25
26
|
}
|
|
26
27
|
export declare function findChromePath(): string;
|
|
27
28
|
export declare function buildChromeArgs(): string[];
|
|
29
|
+
/**
|
|
30
|
+
* Kill Chrome and every child process it spawned (zygote, gpu-process, renderer, crashpad).
|
|
31
|
+
* `chromeProcess.kill()` alone only signals that one PID — per Node's child_process docs, a signal
|
|
32
|
+
* sent to a child is not propagated to *that child's own* children on Linux, so Chrome's
|
|
33
|
+
* multi-process tree survives a plain kill on every rendered fetch, not just an interrupted one.
|
|
34
|
+
* Spawning with `detached: true` makes Chrome the leader of its own process group, so signaling the
|
|
35
|
+
* negative PID reaches the whole tree in one call. Falls back to a direct kill if group-kill fails
|
|
36
|
+
* (already exited, or a platform without POSIX process groups).
|
|
37
|
+
*/
|
|
38
|
+
export declare function killChromeTree(chromeProcess: ChildProcess): void;
|
|
28
39
|
export declare function waitForLoad(client: CdpClient, sessionId: string, timeoutMs: number, settleMs?: number): Promise<void>;
|
|
29
40
|
export declare const BLOCKED_ASSET_URLS: string[];
|
|
30
41
|
export interface CdpPage {
|
|
31
42
|
client: CdpClient;
|
|
32
43
|
sessionId: string;
|
|
33
44
|
close: () => Promise<void>;
|
|
45
|
+
/**
|
|
46
|
+
* Returns (and clears) the reason the most recent navigation request was blocked by the
|
|
47
|
+
* per-hop safety guard installed in {@link openCdpPage}, or `null` if none was blocked.
|
|
48
|
+
*/
|
|
49
|
+
takeBlockedNavigationError: () => Error | null;
|
|
34
50
|
}
|
|
35
51
|
/** Status line of the main-frame document response, captured from Network.responseReceived. */
|
|
36
52
|
export interface MainDocumentResponse {
|
|
@@ -45,8 +61,20 @@ export interface MainDocumentResponse {
|
|
|
45
61
|
*/
|
|
46
62
|
export declare function assertRenderedHttpOk(mainDoc: MainDocumentResponse | undefined): void;
|
|
47
63
|
/**
|
|
48
|
-
*
|
|
49
|
-
*
|
|
64
|
+
* Validates a navigated (or redirected) Document request against the same scheme/credentials/DNS
|
|
65
|
+
* safety `normalizeUrl` and `fetchWithRedirects` enforce per hop in url.ts/fetcher.ts. Chrome
|
|
66
|
+
* follows redirects internally with no hook back into that check, so a page that starts on a safe
|
|
67
|
+
* public host could otherwise redirect a rendered-fallback capture into a private/internal address
|
|
68
|
+
* (SSRF) or a non-http(s) scheme (e.g. `file:`) that the static fetcher would have rejected outright.
|
|
69
|
+
*/
|
|
70
|
+
export declare function describeUnsafeNavigationTarget(url: string): Promise<Error | null>;
|
|
71
|
+
/**
|
|
72
|
+
* Spawns a headless Chrome, attaches a flat CDP session, and enables the Page, Network, Runtime,
|
|
73
|
+
* and Fetch domains. Fetch domain interception guards every Document-type request (the initial
|
|
74
|
+
* navigation and each subsequent redirect) with {@link describeUnsafeNavigationTarget} before
|
|
75
|
+
* Chrome is allowed to send it; a blocked request's reason is recorded and can be read back via
|
|
76
|
+
* the returned page's `takeBlockedNavigationError()`. The caller drives navigation and must call
|
|
77
|
+
* close() when finished.
|
|
50
78
|
*/
|
|
51
79
|
export declare function openCdpPage(): Promise<CdpPage>;
|
|
52
80
|
export interface ResponseMatcher {
|
|
@@ -89,4 +117,27 @@ export declare const SANDBOX_EGRESS_ERROR_MARKER = "sandboxed execution environm
|
|
|
89
117
|
* against the environment directly and fall back to a manual fetch if it's still unreachable.
|
|
90
118
|
*/
|
|
91
119
|
export declare function describeNavigationFailure(errorText: string, url: string): Error;
|
|
120
|
+
export declare function isTransientBrowserError(err: unknown): boolean;
|
|
121
|
+
/**
|
|
122
|
+
* Navigates an already-open CDP page to `currentUrl` and captures the rendered HTML, honoring the
|
|
123
|
+
* openCdpPage Fetch-domain safety guard at every point a blocked navigation could otherwise be
|
|
124
|
+
* discarded in favor of a less specific (or entirely silent) failure:
|
|
125
|
+
*
|
|
126
|
+
* 1. Right after `Page.navigate` returns — a fast HTTP redirect chain can already be blocked and
|
|
127
|
+
* resolved by this point, surfacing as the generic `nav.errorText` (e.g. `net::ERR_BLOCKED_BY_CLIENT`)
|
|
128
|
+
* below if not checked first.
|
|
129
|
+
* 2. If `waitForLoad` throws — a client-side (JS-triggered) navigation into a blocked target after
|
|
130
|
+
* the initial navigation already settled can prevent the load/domContentLoaded events
|
|
131
|
+
* `waitForLoad` is waiting on, surfacing as a generic timeout instead of the specific reason.
|
|
132
|
+
* 3. After `waitForLoad` resolves — a client-side navigation blocked without otherwise disrupting
|
|
133
|
+
* the load signal would otherwise sail through silently, capturing whatever the page settled on.
|
|
134
|
+
*
|
|
135
|
+
* Exported (rather than folded into `fetchRenderedHtmlOnce`) so tests can drive it against a fake
|
|
136
|
+
* `CdpPage` without spawning a real browser.
|
|
137
|
+
*/
|
|
138
|
+
export declare function captureNavigatedPage(page: CdpPage, currentUrl: string, options: {
|
|
139
|
+
timeout: number;
|
|
140
|
+
limit: number;
|
|
141
|
+
settleMs: number;
|
|
142
|
+
}): Promise<BrowserFetchResult>;
|
|
92
143
|
export declare function fetchRenderedHtml(url: string, options?: BrowserFetchOptions): Promise<BrowserFetchResult>;
|
|
@@ -130,13 +130,63 @@ export function buildChromeArgs() {
|
|
|
130
130
|
// CI runners occasionally starve headless Chrome's startup (disk/CPU contention from concurrent
|
|
131
131
|
// jobs), so this is generous enough to absorb that rather than a "typical" local startup time.
|
|
132
132
|
const CHROME_STARTUP_TIMEOUT_MS = 20_000;
|
|
133
|
+
// Node's default SIGINT/SIGTERM disposition kills the process immediately, without waiting for the
|
|
134
|
+
// async `finally { page.close() }` in fetchRenderedHtmlOnce to run — so an interrupted --rendered
|
|
135
|
+
// fetch otherwise abandons a whole Chrome process tree (main + zygote/gpu/renderer children). The
|
|
136
|
+
// plain 'exit' event covers ordinary completion (belt-and-braces alongside close()'s own cleanup);
|
|
137
|
+
// a `once` signal listener runs the same cleanup, then re-sends the signal to this process. Because
|
|
138
|
+
// `once` already removed itself, that re-delivery has no listener left and Node's own default
|
|
139
|
+
// disposition takes over — the process still exits with the normal 128+signum code, with no exit-code
|
|
140
|
+
// bookkeeping of our own.
|
|
141
|
+
const liveChromeProcesses = new Set();
|
|
142
|
+
let chromeCleanupRegistered = false;
|
|
143
|
+
function killLiveChromeProcesses() {
|
|
144
|
+
for (const chromeProcess of liveChromeProcesses)
|
|
145
|
+
killChromeTree(chromeProcess);
|
|
146
|
+
}
|
|
147
|
+
function ensureChromeCleanupRegistered() {
|
|
148
|
+
if (chromeCleanupRegistered)
|
|
149
|
+
return;
|
|
150
|
+
chromeCleanupRegistered = true;
|
|
151
|
+
process.once('exit', killLiveChromeProcesses);
|
|
152
|
+
for (const signal of ['SIGINT', 'SIGTERM']) {
|
|
153
|
+
process.once(signal, () => {
|
|
154
|
+
killLiveChromeProcesses();
|
|
155
|
+
process.kill(process.pid, signal);
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Kill Chrome and every child process it spawned (zygote, gpu-process, renderer, crashpad).
|
|
161
|
+
* `chromeProcess.kill()` alone only signals that one PID — per Node's child_process docs, a signal
|
|
162
|
+
* sent to a child is not propagated to *that child's own* children on Linux, so Chrome's
|
|
163
|
+
* multi-process tree survives a plain kill on every rendered fetch, not just an interrupted one.
|
|
164
|
+
* Spawning with `detached: true` makes Chrome the leader of its own process group, so signaling the
|
|
165
|
+
* negative PID reaches the whole tree in one call. Falls back to a direct kill if group-kill fails
|
|
166
|
+
* (already exited, or a platform without POSIX process groups).
|
|
167
|
+
*/
|
|
168
|
+
export function killChromeTree(chromeProcess) {
|
|
169
|
+
if (typeof chromeProcess.pid === 'number') {
|
|
170
|
+
try {
|
|
171
|
+
process.kill(-chromeProcess.pid, 'SIGKILL');
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
// Fall through to a best-effort direct kill below.
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
chromeProcess.kill('SIGKILL');
|
|
179
|
+
}
|
|
133
180
|
async function spawnChrome(chromePath) {
|
|
134
|
-
|
|
181
|
+
ensureChromeCleanupRegistered();
|
|
182
|
+
const chromeProcess = spawn(chromePath, buildChromeArgs(), { detached: true });
|
|
183
|
+
liveChromeProcesses.add(chromeProcess);
|
|
184
|
+
chromeProcess.once('exit', () => liveChromeProcesses.delete(chromeProcess));
|
|
135
185
|
try {
|
|
136
186
|
const wsUrl = await new Promise((resolve, reject) => {
|
|
137
187
|
let output = '';
|
|
138
188
|
const timeoutId = setTimeout(() => {
|
|
139
|
-
chromeProcess
|
|
189
|
+
killChromeTree(chromeProcess);
|
|
140
190
|
reject(new Error('Timed out waiting for Chrome to start.'));
|
|
141
191
|
}, CHROME_STARTUP_TIMEOUT_MS);
|
|
142
192
|
const onData = (data) => {
|
|
@@ -168,7 +218,7 @@ async function spawnChrome(chromePath) {
|
|
|
168
218
|
return { chromeProcess, wsUrl };
|
|
169
219
|
}
|
|
170
220
|
catch (err) {
|
|
171
|
-
chromeProcess
|
|
221
|
+
killChromeTree(chromeProcess);
|
|
172
222
|
throw err;
|
|
173
223
|
}
|
|
174
224
|
}
|
|
@@ -220,13 +270,41 @@ export function assertRenderedHttpOk(mainDoc) {
|
|
|
220
270
|
}
|
|
221
271
|
}
|
|
222
272
|
/**
|
|
223
|
-
*
|
|
224
|
-
*
|
|
273
|
+
* Validates a navigated (or redirected) Document request against the same scheme/credentials/DNS
|
|
274
|
+
* safety `normalizeUrl` and `fetchWithRedirects` enforce per hop in url.ts/fetcher.ts. Chrome
|
|
275
|
+
* follows redirects internally with no hook back into that check, so a page that starts on a safe
|
|
276
|
+
* public host could otherwise redirect a rendered-fallback capture into a private/internal address
|
|
277
|
+
* (SSRF) or a non-http(s) scheme (e.g. `file:`) that the static fetcher would have rejected outright.
|
|
278
|
+
*/
|
|
279
|
+
export async function describeUnsafeNavigationTarget(url) {
|
|
280
|
+
let normalized;
|
|
281
|
+
try {
|
|
282
|
+
normalized = normalizeUrl(url);
|
|
283
|
+
}
|
|
284
|
+
catch (err) {
|
|
285
|
+
return err;
|
|
286
|
+
}
|
|
287
|
+
try {
|
|
288
|
+
await checkDnsSafety(new URL(normalized).hostname);
|
|
289
|
+
return null;
|
|
290
|
+
}
|
|
291
|
+
catch (err) {
|
|
292
|
+
return err;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* Spawns a headless Chrome, attaches a flat CDP session, and enables the Page, Network, Runtime,
|
|
297
|
+
* and Fetch domains. Fetch domain interception guards every Document-type request (the initial
|
|
298
|
+
* navigation and each subsequent redirect) with {@link describeUnsafeNavigationTarget} before
|
|
299
|
+
* Chrome is allowed to send it; a blocked request's reason is recorded and can be read back via
|
|
300
|
+
* the returned page's `takeBlockedNavigationError()`. The caller drives navigation and must call
|
|
301
|
+
* close() when finished.
|
|
225
302
|
*/
|
|
226
303
|
export async function openCdpPage() {
|
|
227
304
|
const chromePath = findChromePath();
|
|
228
305
|
const { chromeProcess, wsUrl } = await spawnChrome(chromePath);
|
|
229
306
|
const client = new CdpClient(wsUrl);
|
|
307
|
+
let blockedNavigationError = null;
|
|
230
308
|
try {
|
|
231
309
|
await client.connect();
|
|
232
310
|
const { targetId } = await client.send('Target.createTarget', { url: 'about:blank' });
|
|
@@ -235,19 +313,41 @@ export async function openCdpPage() {
|
|
|
235
313
|
await client.send('Network.enable', {}, sessionId);
|
|
236
314
|
await client.send('Runtime.enable', {}, sessionId);
|
|
237
315
|
await client.send('Network.setUserAgentOverride', { userAgent: DEFAULT_USER_AGENT }, sessionId);
|
|
316
|
+
await client.send('Fetch.enable', { patterns: [{ urlPattern: '*', resourceType: 'Document', requestStage: 'Request' }] }, sessionId);
|
|
317
|
+
client.on(`${sessionId}:Fetch.requestPaused`, (params) => {
|
|
318
|
+
// CdpClient.on's handler type is synchronous (CDP event dispatch isn't awaited), so the
|
|
319
|
+
// actual async validate-then-continue/fail work runs in this detached IIFE instead.
|
|
320
|
+
void (async () => {
|
|
321
|
+
const { requestId, request } = params;
|
|
322
|
+
const blockReason = await describeUnsafeNavigationTarget(request.url);
|
|
323
|
+
if (blockReason) {
|
|
324
|
+
blockedNavigationError = blockReason;
|
|
325
|
+
await client
|
|
326
|
+
.send('Fetch.failRequest', { requestId, errorReason: 'BlockedByClient' }, sessionId)
|
|
327
|
+
.catch(() => { });
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
await client.send('Fetch.continueRequest', { requestId }, sessionId).catch(() => { });
|
|
331
|
+
})();
|
|
332
|
+
});
|
|
238
333
|
const close = async () => {
|
|
239
334
|
try {
|
|
240
335
|
await client.send('Target.closeTarget', { targetId });
|
|
241
336
|
}
|
|
242
337
|
catch { }
|
|
243
338
|
client.close();
|
|
244
|
-
chromeProcess
|
|
339
|
+
killChromeTree(chromeProcess);
|
|
245
340
|
};
|
|
246
|
-
|
|
341
|
+
const takeBlockedNavigationError = () => {
|
|
342
|
+
const err = blockedNavigationError;
|
|
343
|
+
blockedNavigationError = null;
|
|
344
|
+
return err;
|
|
345
|
+
};
|
|
346
|
+
return { client, sessionId, close, takeBlockedNavigationError };
|
|
247
347
|
}
|
|
248
348
|
catch (err) {
|
|
249
349
|
client.close();
|
|
250
|
-
chromeProcess
|
|
350
|
+
killChromeTree(chromeProcess);
|
|
251
351
|
throw err;
|
|
252
352
|
}
|
|
253
353
|
}
|
|
@@ -332,8 +432,8 @@ const PROXY_BLOCKED_CHROME_NET_ERRORS = [
|
|
|
332
432
|
'ERR_PROXY_CONNECTION_FAILED',
|
|
333
433
|
'ERR_SOCKS_CONNECTION_FAILED',
|
|
334
434
|
];
|
|
335
|
-
// Shared substring fetch.ts's fetchFailureGuidance matches on to attach suggestions/ref to
|
|
336
|
-
// error, without duplicating the message text it needs to match against.
|
|
435
|
+
// Shared substring fetch-result.ts's fetchFailureGuidance matches on to attach suggestions/ref to
|
|
436
|
+
// this error, without duplicating the message text it needs to match against.
|
|
337
437
|
export const SANDBOX_EGRESS_ERROR_MARKER = 'sandboxed execution environment';
|
|
338
438
|
/**
|
|
339
439
|
* Builds the error thrown for a failed Page.navigate. When a proxy is configured (proxy.ts's
|
|
@@ -364,8 +464,11 @@ export function describeNavigationFailure(errorText, url) {
|
|
|
364
464
|
`or bypassing the proxy: confirm "${host}" is reachable from this environment (e.g. test the ` +
|
|
365
465
|
`proxy directly), or fetch the page from a network with access and import it instead.`);
|
|
366
466
|
}
|
|
367
|
-
// Chrome can emit
|
|
368
|
-
|
|
467
|
+
// ERR_CERT_VERIFIER_CHANGED: Chrome can emit this on first navigation in headless CI when the cert
|
|
468
|
+
// verifier reloads mid-run. ERR_CONNECTION_CLOSED is Chromium's code for a plain remote TCP FIN
|
|
469
|
+
// (net_error_list.h), which CI runners can hit as a one-off network blip against a real host
|
|
470
|
+
// rather than a real block.
|
|
471
|
+
const TRANSIENT_NAV_ERROR_CODES = ['ERR_CERT_VERIFIER_CHANGED', 'ERR_CONNECTION_CLOSED'];
|
|
369
472
|
// Resource contention in CI (concurrent jobs competing for CPU/disk) can make Chrome itself fail
|
|
370
473
|
// to come up in time, or die before its devtools websocket URL is printed. Neither reflects a
|
|
371
474
|
// problem with the target page, so it's worth a fresh attempt rather than failing the whole fetch.
|
|
@@ -373,11 +476,93 @@ const TRANSIENT_CHROME_STARTUP_MESSAGES = [
|
|
|
373
476
|
'Timed out waiting for Chrome to start.',
|
|
374
477
|
'Chrome exited prematurely',
|
|
375
478
|
];
|
|
376
|
-
|
|
479
|
+
// Exported so tests can assert the transient-error classification directly rather than only
|
|
480
|
+
// indirectly through a live fetchRenderedHtml retry, which needs a real Chrome navigation to fail.
|
|
481
|
+
export function isTransientBrowserError(err) {
|
|
377
482
|
const message = err instanceof Error ? err.message : String(err);
|
|
378
483
|
return (TRANSIENT_NAV_ERROR_CODES.some((code) => message.includes(code)) ||
|
|
379
484
|
TRANSIENT_CHROME_STARTUP_MESSAGES.some((text) => message.includes(text)));
|
|
380
485
|
}
|
|
486
|
+
/**
|
|
487
|
+
* Navigates an already-open CDP page to `currentUrl` and captures the rendered HTML, honoring the
|
|
488
|
+
* openCdpPage Fetch-domain safety guard at every point a blocked navigation could otherwise be
|
|
489
|
+
* discarded in favor of a less specific (or entirely silent) failure:
|
|
490
|
+
*
|
|
491
|
+
* 1. Right after `Page.navigate` returns — a fast HTTP redirect chain can already be blocked and
|
|
492
|
+
* resolved by this point, surfacing as the generic `nav.errorText` (e.g. `net::ERR_BLOCKED_BY_CLIENT`)
|
|
493
|
+
* below if not checked first.
|
|
494
|
+
* 2. If `waitForLoad` throws — a client-side (JS-triggered) navigation into a blocked target after
|
|
495
|
+
* the initial navigation already settled can prevent the load/domContentLoaded events
|
|
496
|
+
* `waitForLoad` is waiting on, surfacing as a generic timeout instead of the specific reason.
|
|
497
|
+
* 3. After `waitForLoad` resolves — a client-side navigation blocked without otherwise disrupting
|
|
498
|
+
* the load signal would otherwise sail through silently, capturing whatever the page settled on.
|
|
499
|
+
*
|
|
500
|
+
* Exported (rather than folded into `fetchRenderedHtmlOnce`) so tests can drive it against a fake
|
|
501
|
+
* `CdpPage` without spawning a real browser.
|
|
502
|
+
*/
|
|
503
|
+
export async function captureNavigatedPage(page, currentUrl, options) {
|
|
504
|
+
const { timeout, limit, settleMs } = options;
|
|
505
|
+
await page.client.send('Network.setBlockedURLs', { urls: BLOCKED_ASSET_URLS }, page.sessionId);
|
|
506
|
+
// Record the status of each frame's first Document response so we can reject HTTP errors after
|
|
507
|
+
// the page settles. Keyed by frameId; the navigated top frame is matched via the navigate result.
|
|
508
|
+
const documentResponses = new Map();
|
|
509
|
+
page.client.on(`${page.sessionId}:Network.responseReceived`, (params) => {
|
|
510
|
+
const { frameId } = params;
|
|
511
|
+
const status = params.response?.status;
|
|
512
|
+
// Only record real numeric statuses: a Document event without a usable status would
|
|
513
|
+
// otherwise store NaN, which silently slips past the `>= 400` guard and the `?? 200`
|
|
514
|
+
// fallback alike, leaking a NaN into BrowserFetchResult.status (declared `number`).
|
|
515
|
+
if (params.type !== 'Document' || !frameId || typeof status !== 'number')
|
|
516
|
+
return;
|
|
517
|
+
if (documentResponses.has(frameId))
|
|
518
|
+
return;
|
|
519
|
+
documentResponses.set(frameId, {
|
|
520
|
+
status,
|
|
521
|
+
statusText: params.response?.statusText ?? '',
|
|
522
|
+
});
|
|
523
|
+
});
|
|
524
|
+
const nav = await page.client.send('Page.navigate', { url: currentUrl }, page.sessionId);
|
|
525
|
+
// See point 1 above: prefer the safety guard's specific reason over the opaque errorText below.
|
|
526
|
+
const blockedNavigation = page.takeBlockedNavigationError();
|
|
527
|
+
if (blockedNavigation)
|
|
528
|
+
throw blockedNavigation;
|
|
529
|
+
// A network-level failure (DNS, refused/closed connection, TLS) never fires a load event, so
|
|
530
|
+
// fail fast here instead of waiting out the full navigation timeout on a dead page.
|
|
531
|
+
if (nav.errorText) {
|
|
532
|
+
throw describeNavigationFailure(nav.errorText, currentUrl);
|
|
533
|
+
}
|
|
534
|
+
try {
|
|
535
|
+
await waitForLoad(page.client, page.sessionId, timeout, settleMs);
|
|
536
|
+
}
|
|
537
|
+
catch (err) {
|
|
538
|
+
// See point 2 above: a blocked reason recorded during the wait outranks the generic timeout.
|
|
539
|
+
throw page.takeBlockedNavigationError() ?? err;
|
|
540
|
+
}
|
|
541
|
+
// See point 3 above.
|
|
542
|
+
const blockedNavigationAfterLoad = page.takeBlockedNavigationError();
|
|
543
|
+
if (blockedNavigationAfterLoad)
|
|
544
|
+
throw blockedNavigationAfterLoad;
|
|
545
|
+
const mainDoc = nav.frameId ? documentResponses.get(nav.frameId) : undefined;
|
|
546
|
+
assertRenderedHttpOk(mainDoc);
|
|
547
|
+
const evalResult = await page.client.send('Runtime.evaluate', { expression: 'document.documentElement.outerHTML', returnByValue: true }, page.sessionId);
|
|
548
|
+
const html = evalResult.result?.value;
|
|
549
|
+
if (typeof html !== 'string') {
|
|
550
|
+
throw new Error('Failed to retrieve rendered HTML from browser page.');
|
|
551
|
+
}
|
|
552
|
+
const byteLength = Buffer.byteLength(html);
|
|
553
|
+
if (byteLength > limit) {
|
|
554
|
+
throw new Error(`Response body size limit exceeded. Rendered HTML is ${byteLength} bytes.`);
|
|
555
|
+
}
|
|
556
|
+
return {
|
|
557
|
+
status: mainDoc?.status ?? 200,
|
|
558
|
+
contentType: 'text/html',
|
|
559
|
+
etag: null,
|
|
560
|
+
lastModified: null,
|
|
561
|
+
finalUrl: currentUrl,
|
|
562
|
+
responseSize: byteLength,
|
|
563
|
+
content: html,
|
|
564
|
+
};
|
|
565
|
+
}
|
|
381
566
|
async function fetchRenderedHtmlOnce(url, options = {}) {
|
|
382
567
|
const timeout = options.timeoutMs ?? 15_000;
|
|
383
568
|
const limit = options.bodyLimitBytes ?? 2 * 1024 * 1024;
|
|
@@ -385,52 +570,11 @@ async function fetchRenderedHtmlOnce(url, options = {}) {
|
|
|
385
570
|
await checkDnsSafety(new URL(currentUrl).hostname);
|
|
386
571
|
const page = await openCdpPage();
|
|
387
572
|
try {
|
|
388
|
-
await page
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
page.client.on(`${page.sessionId}:Network.responseReceived`, (params) => {
|
|
393
|
-
const { frameId } = params;
|
|
394
|
-
const status = params.response?.status;
|
|
395
|
-
// Only record real numeric statuses: a Document event without a usable status would
|
|
396
|
-
// otherwise store NaN, which silently slips past the `>= 400` guard and the `?? 200`
|
|
397
|
-
// fallback alike, leaking a NaN into BrowserFetchResult.status (declared `number`).
|
|
398
|
-
if (params.type !== 'Document' || !frameId || typeof status !== 'number')
|
|
399
|
-
return;
|
|
400
|
-
if (documentResponses.has(frameId))
|
|
401
|
-
return;
|
|
402
|
-
documentResponses.set(frameId, {
|
|
403
|
-
status,
|
|
404
|
-
statusText: params.response?.statusText ?? '',
|
|
405
|
-
});
|
|
573
|
+
return await captureNavigatedPage(page, currentUrl, {
|
|
574
|
+
timeout,
|
|
575
|
+
limit,
|
|
576
|
+
settleMs: options.settleMs ?? 1000,
|
|
406
577
|
});
|
|
407
|
-
const nav = await page.client.send('Page.navigate', { url: currentUrl }, page.sessionId);
|
|
408
|
-
// A network-level failure (DNS, refused/closed connection, TLS) never fires a load event, so
|
|
409
|
-
// fail fast here instead of waiting out the full navigation timeout on a dead page.
|
|
410
|
-
if (nav.errorText) {
|
|
411
|
-
throw describeNavigationFailure(nav.errorText, currentUrl);
|
|
412
|
-
}
|
|
413
|
-
await waitForLoad(page.client, page.sessionId, timeout, options.settleMs ?? 1000);
|
|
414
|
-
const mainDoc = nav.frameId ? documentResponses.get(nav.frameId) : undefined;
|
|
415
|
-
assertRenderedHttpOk(mainDoc);
|
|
416
|
-
const evalResult = await page.client.send('Runtime.evaluate', { expression: 'document.documentElement.outerHTML', returnByValue: true }, page.sessionId);
|
|
417
|
-
const html = evalResult.result?.value;
|
|
418
|
-
if (typeof html !== 'string') {
|
|
419
|
-
throw new Error('Failed to retrieve rendered HTML from browser page.');
|
|
420
|
-
}
|
|
421
|
-
const byteLength = Buffer.byteLength(html);
|
|
422
|
-
if (byteLength > limit) {
|
|
423
|
-
throw new Error(`Response body size limit exceeded. Rendered HTML is ${byteLength} bytes.`);
|
|
424
|
-
}
|
|
425
|
-
return {
|
|
426
|
-
status: mainDoc?.status ?? 200,
|
|
427
|
-
contentType: 'text/html',
|
|
428
|
-
etag: null,
|
|
429
|
-
lastModified: null,
|
|
430
|
-
finalUrl: currentUrl,
|
|
431
|
-
responseSize: byteLength,
|
|
432
|
-
content: html,
|
|
433
|
-
};
|
|
434
578
|
}
|
|
435
579
|
finally {
|
|
436
580
|
await page.close();
|
|
@@ -1,16 +1,19 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type FetchResult } from './fetcher.js';
|
|
2
2
|
import type { ExtractionResult } from './extract.js';
|
|
3
3
|
import type { CaptureMethod } from './schema.js';
|
|
4
4
|
import type { MachineReadableArtifact, SiteCapabilities } from './docs/capabilities.js';
|
|
5
5
|
import { type TextFetcher } from './docs/machine-readable.js';
|
|
6
|
+
/** Injected fetcher dependencies for `capturePage`. All three are required. */
|
|
6
7
|
export interface CaptureDeps {
|
|
7
8
|
fetchStatic: (url: string) => Promise<FetchResult>;
|
|
8
9
|
fetchRendered: (url: string) => Promise<FetchResult>;
|
|
9
10
|
fetchText: TextFetcher;
|
|
10
11
|
}
|
|
12
|
+
/** Options passed to `capturePage` by the caller. */
|
|
11
13
|
export interface CaptureOptions {
|
|
12
14
|
forceRendered?: boolean;
|
|
13
15
|
}
|
|
16
|
+
/** Full outcome of a `capturePage` call, including provenance and capability metadata. */
|
|
14
17
|
export interface CaptureOutcome {
|
|
15
18
|
fetchResult: FetchResult;
|
|
16
19
|
extraction: ExtractionResult;
|
|
@@ -20,10 +23,29 @@ export interface CaptureOutcome {
|
|
|
20
23
|
machineReadable: MachineReadableArtifact[];
|
|
21
24
|
attemptedMethods: string[];
|
|
22
25
|
}
|
|
23
|
-
|
|
26
|
+
/**
|
|
27
|
+
* Returns true when the static extraction result is missing or too weak to serve as the artifact,
|
|
28
|
+
* meaning the caller should attempt a rendered fallback.
|
|
29
|
+
*
|
|
30
|
+
* @param caps - Detected site capabilities; `recommendedCapture === 'rendered'` forces a fallback.
|
|
31
|
+
* @param extraction - Result of static extraction, or null when static fetch failed.
|
|
32
|
+
* @param staticError - The static fetch's failure, if any. A content-type policy rejection (JSON,
|
|
33
|
+
* PDF, binary) means the URL isn't a web page at all — a browser would just render its own
|
|
34
|
+
* built-in viewer for it, producing a corrupted-looking "extracted" artifact — so that specific
|
|
35
|
+
* failure is a hard stop, never a fallback trigger. `--rendered` still bypasses this entirely (it
|
|
36
|
+
* skips the static phase), so an informed retry remains available.
|
|
37
|
+
* @returns True when a rendered (browser) fallback should be attempted.
|
|
38
|
+
*/
|
|
39
|
+
export declare function renderedFallbackNeeded(caps: SiteCapabilities, extraction: ExtractionResult | null, staticError?: Error | null): boolean;
|
|
24
40
|
/**
|
|
25
41
|
* Captures a page's best-available content. Order: forced rendered → route/source Markdown → static
|
|
26
42
|
* HTML → rendered fallback. Returns the chosen fetch result, extraction, detected capabilities, and
|
|
27
43
|
* provenance (capture method, source URL, machine-readable artifacts, attempted methods).
|
|
44
|
+
*
|
|
45
|
+
* @param url - The page URL to capture.
|
|
46
|
+
* @param options - Capture options (e.g. `forceRendered`).
|
|
47
|
+
* @param deps - Injected fetchers for static, rendered, and text requests.
|
|
48
|
+
* @returns Full CaptureOutcome including the best extraction and capture provenance.
|
|
49
|
+
* @throws {Error} When all capture strategies fail and no usable content is produced.
|
|
28
50
|
*/
|
|
29
51
|
export declare function capturePage(url: string, options: CaptureOptions, deps: CaptureDeps): Promise<CaptureOutcome>;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isUnsupportedContentTypeError } from './fetcher.js';
|
|
1
2
|
import { extractHtmlContent } from './extract.js';
|
|
2
3
|
import { detectDocsEngine } from './docs/detect.js';
|
|
3
4
|
import { emptyCapabilities } from './docs/capabilities.js';
|
|
@@ -6,9 +7,22 @@ import { mapUrlToSource } from './docs/source-map.js';
|
|
|
6
7
|
import { extractFromSource } from './docs/markdown-source.js';
|
|
7
8
|
import { validateTextArtifact } from './docs/validate.js';
|
|
8
9
|
const MIN_USEFUL_CHARS = 600;
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
10
|
+
/**
|
|
11
|
+
* Returns true when the static extraction result is missing or too weak to serve as the artifact,
|
|
12
|
+
* meaning the caller should attempt a rendered fallback.
|
|
13
|
+
*
|
|
14
|
+
* @param caps - Detected site capabilities; `recommendedCapture === 'rendered'` forces a fallback.
|
|
15
|
+
* @param extraction - Result of static extraction, or null when static fetch failed.
|
|
16
|
+
* @param staticError - The static fetch's failure, if any. A content-type policy rejection (JSON,
|
|
17
|
+
* PDF, binary) means the URL isn't a web page at all — a browser would just render its own
|
|
18
|
+
* built-in viewer for it, producing a corrupted-looking "extracted" artifact — so that specific
|
|
19
|
+
* failure is a hard stop, never a fallback trigger. `--rendered` still bypasses this entirely (it
|
|
20
|
+
* skips the static phase), so an informed retry remains available.
|
|
21
|
+
* @returns True when a rendered (browser) fallback should be attempted.
|
|
22
|
+
*/
|
|
23
|
+
export function renderedFallbackNeeded(caps, extraction, staticError = null) {
|
|
24
|
+
if (staticError && isUnsupportedContentTypeError(staticError))
|
|
25
|
+
return false;
|
|
12
26
|
if (!extraction)
|
|
13
27
|
return true;
|
|
14
28
|
if (caps.recommendedCapture === 'rendered')
|
|
@@ -116,7 +130,7 @@ async function sourceOutcome(url, phase, deps, attemptedMethods) {
|
|
|
116
130
|
// is warranted; throws only when rendering fails and nothing usable was produced (preferring the
|
|
117
131
|
// earlier static error).
|
|
118
132
|
async function renderedFallbackOutcome(url, deps, attemptedMethods, phase, extraction) {
|
|
119
|
-
if (!renderedFallbackNeeded(phase.capabilities, extraction))
|
|
133
|
+
if (!renderedFallbackNeeded(phase.capabilities, extraction, phase.staticError))
|
|
120
134
|
return null;
|
|
121
135
|
try {
|
|
122
136
|
return await renderedOutcome(url, deps, attemptedMethods, phase.machineReadable);
|
|
@@ -131,6 +145,12 @@ async function renderedFallbackOutcome(url, deps, attemptedMethods, phase, extra
|
|
|
131
145
|
* Captures a page's best-available content. Order: forced rendered → route/source Markdown → static
|
|
132
146
|
* HTML → rendered fallback. Returns the chosen fetch result, extraction, detected capabilities, and
|
|
133
147
|
* provenance (capture method, source URL, machine-readable artifacts, attempted methods).
|
|
148
|
+
*
|
|
149
|
+
* @param url - The page URL to capture.
|
|
150
|
+
* @param options - Capture options (e.g. `forceRendered`).
|
|
151
|
+
* @param deps - Injected fetchers for static, rendered, and text requests.
|
|
152
|
+
* @returns Full CaptureOutcome including the best extraction and capture provenance.
|
|
153
|
+
* @throws {Error} When all capture strategies fail and no usable content is produced.
|
|
134
154
|
*/
|
|
135
155
|
export async function capturePage(url, options, deps) {
|
|
136
156
|
const attemptedMethods = [];
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/** Options accepted by {@link CliIo.error} — the subset of oclif's `this.error` options Bonsai uses. */
|
|
2
|
+
export interface CliErrorOptions {
|
|
3
|
+
exit: number;
|
|
4
|
+
code?: string;
|
|
5
|
+
suggestions?: string[];
|
|
6
|
+
ref?: string;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Narrow command-context port passed from oclif commands into command services, so the services
|
|
10
|
+
* stay free of oclif imports and are testable without a framework harness.
|
|
11
|
+
*/
|
|
12
|
+
export interface CliIo {
|
|
13
|
+
bin: string;
|
|
14
|
+
configDir: string | undefined;
|
|
15
|
+
dataDir: string;
|
|
16
|
+
cwd: string;
|
|
17
|
+
json: boolean;
|
|
18
|
+
warn(msg: string): void;
|
|
19
|
+
log(msg: string): void;
|
|
20
|
+
error(msg: string, opts: CliErrorOptions): never;
|
|
21
|
+
/**
|
|
22
|
+
* Print one row of a batch failure with the same "Error: … / Code: … / Try this: …" rendering as
|
|
23
|
+
* {@link error}, but without exiting the process — a batch (`fetch url1 url2`) must keep
|
|
24
|
+
* processing the remaining rows after one fails. Using `warn` for this mislabels a real failure
|
|
25
|
+
* as non-fatal even though the row still flips the command's exit code and JSON `ok` to false.
|
|
26
|
+
*/
|
|
27
|
+
errorRow(msg: string, opts: Omit<CliErrorOptions, 'exit'>): void;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Single exit point for a URL that failed normalization, shared by BaseCommand and the command
|
|
31
|
+
* services. A scheme-less but domain-shaped input reports MISSING_URL_SCHEME — the same code the
|
|
32
|
+
* root `bonsai <url>` shorthand uses — so an agent sees one stable code for "forgot https://"
|
|
33
|
+
* everywhere; truly unparseable input stays INVALID_URL.
|
|
34
|
+
*
|
|
35
|
+
* @param error - `this.error` (oclif) or `io.error`; must not return.
|
|
36
|
+
* @param url - Raw URL string the user supplied.
|
|
37
|
+
* @param message - Human-readable failure message.
|
|
38
|
+
* @returns Never — always exits via `error`.
|
|
39
|
+
*/
|
|
40
|
+
export declare function failInvalidUrl(error: (msg: string, opts: CliErrorOptions) => never, url: string, message: string): never;
|