@taurgis/bonsai 3.1.0 → 3.2.2
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 +21 -4
- package/agents/agents/salesforce-docs-researcher.agent.md +14 -1
- package/agents/instructions/salesforce-research.instructions.md +2 -1
- package/bin/cli.mjs +9 -17
- package/dist/base-command.d.ts +37 -10
- package/dist/base-command.js +113 -46
- package/dist/commands/config/base.js +11 -2
- package/dist/commands/config/get.js +8 -12
- package/dist/commands/config/index.js +2 -2
- package/dist/commands/config/list.js +10 -16
- package/dist/commands/config/set.d.ts +2 -1
- package/dist/commands/config/set.js +34 -27
- package/dist/commands/config/unset.d.ts +2 -1
- package/dist/commands/config/unset.js +25 -24
- package/dist/commands/fetch.d.ts +7 -15
- package/dist/commands/fetch.js +58 -401
- package/dist/commands/import.d.ts +4 -9
- package/dist/commands/import.js +56 -280
- package/dist/commands/inspect.d.ts +7 -1
- package/dist/commands/inspect.js +103 -52
- package/dist/commands/list.d.ts +13 -1
- package/dist/commands/list.js +79 -32
- package/dist/commands/prune.d.ts +4 -1
- package/dist/commands/prune.js +56 -65
- package/dist/commands/status.d.ts +3 -2
- package/dist/commands/status.js +52 -79
- package/dist/hooks/command-not-found/suggest.d.ts +1 -0
- package/dist/hooks/command-not-found/suggest.js +66 -39
- package/dist/lib/argv.d.ts +24 -3
- package/dist/lib/argv.js +124 -53
- 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 +5 -0
- package/dist/lib/cli-flag-manifest.js +8 -0
- package/dist/lib/cli-presentation.d.ts +39 -0
- package/dist/lib/cli-presentation.js +50 -0
- package/dist/lib/cli-result-types.d.ts +88 -0
- package/dist/lib/cli-result-types.js +2 -0
- package/dist/lib/config/index.d.ts +22 -4
- package/dist/lib/config/index.js +59 -4
- package/dist/lib/config/resolve.d.ts +21 -0
- package/dist/lib/config/resolve.js +55 -6
- 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 +147 -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 +28 -0
- package/dist/lib/prune-flags.js +74 -0
- package/dist/lib/research/artifact-index.d.ts +11 -2
- package/dist/lib/research/artifact-index.js +7 -4
- package/dist/lib/research/browser.d.ts +41 -2
- package/dist/lib/research/browser.js +139 -50
- 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 +33 -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 +310 -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 +58 -30
- 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 +381 -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/persist-artifact.d.ts +23 -0
- package/dist/lib/research/persist-artifact.js +27 -0
- package/dist/lib/research/prompt-injection.d.ts +8 -0
- package/dist/lib/research/prompt-injection.js +42 -13
- package/dist/lib/research/proxy.d.ts +14 -8
- package/dist/lib/research/proxy.js +14 -10
- 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 +11 -1
- package/dist/lib/research/secure-write.js +38 -7
- package/dist/lib/research/storage.d.ts +5 -1
- package/dist/lib/research/storage.js +8 -3
- 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 +7 -2
- package/dist/lib/text.js +13 -2
- 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 +192 -87
- package/package.json +19 -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
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { normalizeCliErrorMessage } from './envelope.js';
|
|
2
|
+
import { closestMatch, closestOptionValues, maxFuzzyDistance } from './text.js';
|
|
3
|
+
/** FlagInvalidOptionError body after {@link normalizeCliErrorMessage}. */
|
|
4
|
+
const EXPECTED_OPTION = /^Expected --([^=]+)=(\S+) to be one of: (.+)$/;
|
|
5
|
+
function asEnrichable(err) {
|
|
6
|
+
if (!err || typeof err !== 'object')
|
|
7
|
+
return null;
|
|
8
|
+
if (typeof err.message !== 'string')
|
|
9
|
+
return null;
|
|
10
|
+
return err;
|
|
11
|
+
}
|
|
12
|
+
function suggestableFlagNames(err) {
|
|
13
|
+
const defined = err.parse?.input?.flags;
|
|
14
|
+
if (!defined)
|
|
15
|
+
return [];
|
|
16
|
+
const names = [];
|
|
17
|
+
for (const [name, flag] of Object.entries(defined)) {
|
|
18
|
+
names.push(`--${name}`);
|
|
19
|
+
for (const alias of flag.aliases ?? [])
|
|
20
|
+
names.push(`--${alias}`);
|
|
21
|
+
if (flag.char)
|
|
22
|
+
names.push(`-${flag.char}`);
|
|
23
|
+
}
|
|
24
|
+
// enableJsonFlag is framework-level — it may be absent from parse.input.flags.
|
|
25
|
+
if (!names.includes('--json'))
|
|
26
|
+
names.push('--json');
|
|
27
|
+
return names;
|
|
28
|
+
}
|
|
29
|
+
function collectTips(err) {
|
|
30
|
+
const lines = [];
|
|
31
|
+
const suggestions = [];
|
|
32
|
+
if (err.flags?.length) {
|
|
33
|
+
const candidates = suggestableFlagNames(err);
|
|
34
|
+
for (const bad of err.flags) {
|
|
35
|
+
const match = closestMatch(bad, candidates, maxFuzzyDistance(bad.replace(/^-*/, '')));
|
|
36
|
+
if (!match || match === bad)
|
|
37
|
+
continue;
|
|
38
|
+
lines.push(`Did you mean ${match}?`);
|
|
39
|
+
suggestions.push(match);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
const option = err.message.match(EXPECTED_OPTION);
|
|
43
|
+
if (option) {
|
|
44
|
+
const [, flagName, input, optionsCsv] = option;
|
|
45
|
+
if (flagName && input && optionsCsv) {
|
|
46
|
+
const options = optionsCsv
|
|
47
|
+
.split(', ')
|
|
48
|
+
.map((part) => part.trim())
|
|
49
|
+
.filter(Boolean);
|
|
50
|
+
const values = closestOptionValues(input, options);
|
|
51
|
+
if (values.length > 0) {
|
|
52
|
+
lines.push(`Did you mean ${values.join(' or ')}?`);
|
|
53
|
+
suggestions.push(...values.map((value) => `--${flagName} ${value}`));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return { lines, suggestions };
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Improve oclif parse-time errors in place: unwrap wrapper text, then suggest flag/option typos.
|
|
61
|
+
* Accepts `unknown` because `catch` can receive non-Error throws (bare strings, etc.).
|
|
62
|
+
*/
|
|
63
|
+
export function enrichParseError(err) {
|
|
64
|
+
const enrichable = asEnrichable(err);
|
|
65
|
+
if (!enrichable)
|
|
66
|
+
return;
|
|
67
|
+
enrichable.message = normalizeCliErrorMessage(enrichable.message);
|
|
68
|
+
const { lines, suggestions } = collectTips(enrichable);
|
|
69
|
+
if (lines.length === 0)
|
|
70
|
+
return;
|
|
71
|
+
enrichable.message = [enrichable.message, ...lines].join('\n');
|
|
72
|
+
enrichable.suggestions = [...(enrichable.suggestions ?? []), ...suggestions];
|
|
73
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure prune-flag policy. Returns a usage/safety error descriptor, or null when flags are valid.
|
|
3
|
+
* Kept outside the command class so the branching stays testable without oclif and so
|
|
4
|
+
* `validatePruneFlags` stays a thin apply-error loop.
|
|
5
|
+
*/
|
|
6
|
+
export interface PruneFlagError {
|
|
7
|
+
message: string;
|
|
8
|
+
code: 'INVALID_FLAG_VALUE' | 'MISSING_FILTER' | 'READ_ONLY_MODE' | 'CONFLICTING_FLAGS' | 'SAFETY_CHECK_REQUIRED' | 'INVALID_DURATION';
|
|
9
|
+
suggestions?: string[];
|
|
10
|
+
}
|
|
11
|
+
/** Flag bag passed into {@link pruneFlagError}. */
|
|
12
|
+
export interface PruneFlagInput {
|
|
13
|
+
olderThan?: string;
|
|
14
|
+
inactive?: string;
|
|
15
|
+
artifactType?: string;
|
|
16
|
+
url?: string;
|
|
17
|
+
dryRun: boolean;
|
|
18
|
+
yes: boolean;
|
|
19
|
+
readOnly: boolean;
|
|
20
|
+
bin: string;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Return the first prune usage/safety error, or `null` when flags are valid.
|
|
24
|
+
*
|
|
25
|
+
* @param input - Parsed prune flags and binary name.
|
|
26
|
+
* @returns Error descriptor for `this.error`, or `null`.
|
|
27
|
+
*/
|
|
28
|
+
export declare function pruneFlagError(input: PruneFlagInput): PruneFlagError | null;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { emptyUrlFilterError } from './research/url.js';
|
|
2
|
+
import { durationFlagError } from './research/freshness.js';
|
|
3
|
+
function missingFilterError(input) {
|
|
4
|
+
// Treat an explicitly passed empty string as "filter was attempted" so `--older-than ''`
|
|
5
|
+
// is not misreported as MISSING_FILTER (durationError rejects it as INVALID_DURATION).
|
|
6
|
+
if (input.olderThan !== undefined ||
|
|
7
|
+
input.inactive !== undefined ||
|
|
8
|
+
input.artifactType !== undefined ||
|
|
9
|
+
input.url !== undefined) {
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
return {
|
|
13
|
+
message: 'Must specify at least one pruning filter: --older-than, --inactive, --artifact-type, or --url.',
|
|
14
|
+
code: 'MISSING_FILTER',
|
|
15
|
+
suggestions: [`Preview age-based pruning: ${input.bin} prune --older-than 30d --dry-run`],
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Mutation-gate checks. READ_ONLY before --dry-run/--yes conflict so
|
|
20
|
+
* `--dry-run --yes --read-only` reports the more specific READ_ONLY_MODE code.
|
|
21
|
+
*/
|
|
22
|
+
function mutationSafetyError(input) {
|
|
23
|
+
if (input.readOnly && input.yes) {
|
|
24
|
+
return {
|
|
25
|
+
message: '--yes cannot be used while read-only mode is active (--read-only/--plan or BONSAI_READ_ONLY/BONSAI_PLAN_MODE): mutations are disabled.',
|
|
26
|
+
code: 'READ_ONLY_MODE',
|
|
27
|
+
suggestions: [`Preview instead: ${input.bin} prune --dry-run`],
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
if (input.dryRun && input.yes) {
|
|
31
|
+
return {
|
|
32
|
+
message: '--dry-run and --yes are mutually exclusive: --dry-run previews without deleting, --yes confirms deletion. Choose one.',
|
|
33
|
+
code: 'CONFLICTING_FLAGS',
|
|
34
|
+
suggestions: [
|
|
35
|
+
`Preview without deleting: ${input.bin} prune --dry-run`,
|
|
36
|
+
`Or confirm deletion: ${input.bin} prune --yes`,
|
|
37
|
+
],
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
// Read-only mode implicitly previews, so the usual "pick one" safety check is redundant then.
|
|
41
|
+
if (!input.readOnly && !input.dryRun && !input.yes) {
|
|
42
|
+
const olderThanPart = input.olderThan ? ` --older-than ${input.olderThan}` : '';
|
|
43
|
+
const urlPart = input.url ? ` --url "${input.url}"` : '';
|
|
44
|
+
return {
|
|
45
|
+
message: 'Safety check: use --yes to confirm pruning, or --dry-run to preview files that would be deleted.',
|
|
46
|
+
code: 'SAFETY_CHECK_REQUIRED',
|
|
47
|
+
suggestions: [`Preview first: ${input.bin} prune --dry-run${olderThanPart}${urlPart}`],
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
function durationError(input) {
|
|
53
|
+
for (const msg of [
|
|
54
|
+
durationFlagError('--older-than', input.olderThan),
|
|
55
|
+
durationFlagError('--inactive', input.inactive),
|
|
56
|
+
]) {
|
|
57
|
+
if (msg)
|
|
58
|
+
return { message: msg, code: 'INVALID_DURATION' };
|
|
59
|
+
}
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Return the first prune usage/safety error, or `null` when flags are valid.
|
|
64
|
+
*
|
|
65
|
+
* @param input - Parsed prune flags and binary name.
|
|
66
|
+
* @returns Error descriptor for `this.error`, or `null`.
|
|
67
|
+
*/
|
|
68
|
+
export function pruneFlagError(input) {
|
|
69
|
+
const urlErr = emptyUrlFilterError(input.url);
|
|
70
|
+
if (urlErr)
|
|
71
|
+
return { message: urlErr, code: 'INVALID_FLAG_VALUE' };
|
|
72
|
+
// Duration before missing-filter so `--older-than ''` reports INVALID_DURATION, not MISSING_FILTER.
|
|
73
|
+
return durationError(input) ?? missingFilterError(input) ?? mutationSafetyError(input);
|
|
74
|
+
}
|
|
@@ -3,16 +3,25 @@ export interface IndexedArtifact {
|
|
|
3
3
|
artifact: ResearchArtifact;
|
|
4
4
|
filePath: string;
|
|
5
5
|
}
|
|
6
|
+
export interface LoadIndexedArtifactsOptions {
|
|
7
|
+
/**
|
|
8
|
+
* When false, skip writing `.search-index.json` (read-only/plan mode). Results are still
|
|
9
|
+
* computed from disk; only the derived sidecar is withheld. Defaults to true.
|
|
10
|
+
*/
|
|
11
|
+
persist?: boolean;
|
|
12
|
+
}
|
|
6
13
|
/**
|
|
7
14
|
* Returns the indexed view of every active artifact in one research dir, using the sidecar index
|
|
8
15
|
* to skip unchanged files. Re-reads only files whose signature changed, then persists the refreshed
|
|
9
16
|
* index (best-effort; a write failure never breaks callers). Corrupt/unreadable files are skipped —
|
|
10
17
|
* the real read paths in storage.ts handle archiving them.
|
|
18
|
+
*
|
|
19
|
+
* Pass `{ persist: false }` under read-only/plan mode so the derived sidecar is never written.
|
|
11
20
|
*/
|
|
12
|
-
export declare function loadIndexedArtifactsForDir(researchDir: string): IndexedArtifact[];
|
|
21
|
+
export declare function loadIndexedArtifactsForDir(researchDir: string, options?: LoadIndexedArtifactsOptions): IndexedArtifact[];
|
|
13
22
|
/**
|
|
14
23
|
* Multi-root variant: scans the `research/` dir of each data dir in order and deduplicates by cache
|
|
15
24
|
* key so a project entry shadows the same key in the global cache. Mirrors the dedup semantics of
|
|
16
25
|
* {@link scanCacheDirs} but reads through the per-root sidecar index.
|
|
17
26
|
*/
|
|
18
|
-
export declare function loadIndexedArtifacts(dataDirs: string[]): IndexedArtifact[];
|
|
27
|
+
export declare function loadIndexedArtifacts(dataDirs: string[], options?: LoadIndexedArtifactsOptions): IndexedArtifact[];
|
|
@@ -52,10 +52,13 @@ function getOrUpdateIndexEntry(filePath, cached, onChanged) {
|
|
|
52
52
|
* to skip unchanged files. Re-reads only files whose signature changed, then persists the refreshed
|
|
53
53
|
* index (best-effort; a write failure never breaks callers). Corrupt/unreadable files are skipped —
|
|
54
54
|
* the real read paths in storage.ts handle archiving them.
|
|
55
|
+
*
|
|
56
|
+
* Pass `{ persist: false }` under read-only/plan mode so the derived sidecar is never written.
|
|
55
57
|
*/
|
|
56
|
-
export function loadIndexedArtifactsForDir(researchDir) {
|
|
58
|
+
export function loadIndexedArtifactsForDir(researchDir, options = {}) {
|
|
57
59
|
if (!existsSync(researchDir))
|
|
58
60
|
return [];
|
|
61
|
+
const persist = options.persist !== false;
|
|
59
62
|
const indexPath = join(researchDir, INDEX_FILE);
|
|
60
63
|
const previous = loadIndex(indexPath);
|
|
61
64
|
const entries = {};
|
|
@@ -78,7 +81,7 @@ export function loadIndexedArtifactsForDir(researchDir) {
|
|
|
78
81
|
if (!changed) {
|
|
79
82
|
changed = Object.keys(previous.entries).some((key) => !(key in entries));
|
|
80
83
|
}
|
|
81
|
-
if (changed) {
|
|
84
|
+
if (changed && persist) {
|
|
82
85
|
try {
|
|
83
86
|
atomicWriteFile(indexPath, JSON.stringify({ version: INDEX_VERSION, entries }));
|
|
84
87
|
}
|
|
@@ -93,11 +96,11 @@ export function loadIndexedArtifactsForDir(researchDir) {
|
|
|
93
96
|
* key so a project entry shadows the same key in the global cache. Mirrors the dedup semantics of
|
|
94
97
|
* {@link scanCacheDirs} but reads through the per-root sidecar index.
|
|
95
98
|
*/
|
|
96
|
-
export function loadIndexedArtifacts(dataDirs) {
|
|
99
|
+
export function loadIndexedArtifacts(dataDirs, options = {}) {
|
|
97
100
|
const seen = new Set();
|
|
98
101
|
const all = [];
|
|
99
102
|
for (const dataDir of dataDirs) {
|
|
100
|
-
for (const located of loadIndexedArtifactsForDir(join(dataDir, 'research'))) {
|
|
103
|
+
for (const located of loadIndexedArtifactsForDir(join(dataDir, 'research'), options)) {
|
|
101
104
|
const key = located.artifact.metadata.cache_key;
|
|
102
105
|
if (seen.has(key))
|
|
103
106
|
continue;
|
|
@@ -31,6 +31,11 @@ export interface CdpPage {
|
|
|
31
31
|
client: CdpClient;
|
|
32
32
|
sessionId: string;
|
|
33
33
|
close: () => Promise<void>;
|
|
34
|
+
/**
|
|
35
|
+
* Returns (and clears) the reason the most recent navigation request was blocked by the
|
|
36
|
+
* per-hop safety guard installed in {@link openCdpPage}, or `null` if none was blocked.
|
|
37
|
+
*/
|
|
38
|
+
takeBlockedNavigationError: () => Error | null;
|
|
34
39
|
}
|
|
35
40
|
/** Status line of the main-frame document response, captured from Network.responseReceived. */
|
|
36
41
|
export interface MainDocumentResponse {
|
|
@@ -45,8 +50,20 @@ export interface MainDocumentResponse {
|
|
|
45
50
|
*/
|
|
46
51
|
export declare function assertRenderedHttpOk(mainDoc: MainDocumentResponse | undefined): void;
|
|
47
52
|
/**
|
|
48
|
-
*
|
|
49
|
-
*
|
|
53
|
+
* Validates a navigated (or redirected) Document request against the same scheme/credentials/DNS
|
|
54
|
+
* safety `normalizeUrl` and `fetchWithRedirects` enforce per hop in url.ts/fetcher.ts. Chrome
|
|
55
|
+
* follows redirects internally with no hook back into that check, so a page that starts on a safe
|
|
56
|
+
* public host could otherwise redirect a rendered-fallback capture into a private/internal address
|
|
57
|
+
* (SSRF) or a non-http(s) scheme (e.g. `file:`) that the static fetcher would have rejected outright.
|
|
58
|
+
*/
|
|
59
|
+
export declare function describeUnsafeNavigationTarget(url: string): Promise<Error | null>;
|
|
60
|
+
/**
|
|
61
|
+
* Spawns a headless Chrome, attaches a flat CDP session, and enables the Page, Network, Runtime,
|
|
62
|
+
* and Fetch domains. Fetch domain interception guards every Document-type request (the initial
|
|
63
|
+
* navigation and each subsequent redirect) with {@link describeUnsafeNavigationTarget} before
|
|
64
|
+
* Chrome is allowed to send it; a blocked request's reason is recorded and can be read back via
|
|
65
|
+
* the returned page's `takeBlockedNavigationError()`. The caller drives navigation and must call
|
|
66
|
+
* close() when finished.
|
|
50
67
|
*/
|
|
51
68
|
export declare function openCdpPage(): Promise<CdpPage>;
|
|
52
69
|
export interface ResponseMatcher {
|
|
@@ -89,4 +106,26 @@ export declare const SANDBOX_EGRESS_ERROR_MARKER = "sandboxed execution environm
|
|
|
89
106
|
* against the environment directly and fall back to a manual fetch if it's still unreachable.
|
|
90
107
|
*/
|
|
91
108
|
export declare function describeNavigationFailure(errorText: string, url: string): Error;
|
|
109
|
+
/**
|
|
110
|
+
* Navigates an already-open CDP page to `currentUrl` and captures the rendered HTML, honoring the
|
|
111
|
+
* openCdpPage Fetch-domain safety guard at every point a blocked navigation could otherwise be
|
|
112
|
+
* discarded in favor of a less specific (or entirely silent) failure:
|
|
113
|
+
*
|
|
114
|
+
* 1. Right after `Page.navigate` returns — a fast HTTP redirect chain can already be blocked and
|
|
115
|
+
* resolved by this point, surfacing as the generic `nav.errorText` (e.g. `net::ERR_BLOCKED_BY_CLIENT`)
|
|
116
|
+
* below if not checked first.
|
|
117
|
+
* 2. If `waitForLoad` throws — a client-side (JS-triggered) navigation into a blocked target after
|
|
118
|
+
* the initial navigation already settled can prevent the load/domContentLoaded events
|
|
119
|
+
* `waitForLoad` is waiting on, surfacing as a generic timeout instead of the specific reason.
|
|
120
|
+
* 3. After `waitForLoad` resolves — a client-side navigation blocked without otherwise disrupting
|
|
121
|
+
* the load signal would otherwise sail through silently, capturing whatever the page settled on.
|
|
122
|
+
*
|
|
123
|
+
* Exported (rather than folded into `fetchRenderedHtmlOnce`) so tests can drive it against a fake
|
|
124
|
+
* `CdpPage` without spawning a real browser.
|
|
125
|
+
*/
|
|
126
|
+
export declare function captureNavigatedPage(page: CdpPage, currentUrl: string, options: {
|
|
127
|
+
timeout: number;
|
|
128
|
+
limit: number;
|
|
129
|
+
settleMs: number;
|
|
130
|
+
}): Promise<BrowserFetchResult>;
|
|
92
131
|
export declare function fetchRenderedHtml(url: string, options?: BrowserFetchOptions): Promise<BrowserFetchResult>;
|
|
@@ -220,13 +220,41 @@ export function assertRenderedHttpOk(mainDoc) {
|
|
|
220
220
|
}
|
|
221
221
|
}
|
|
222
222
|
/**
|
|
223
|
-
*
|
|
224
|
-
*
|
|
223
|
+
* Validates a navigated (or redirected) Document request against the same scheme/credentials/DNS
|
|
224
|
+
* safety `normalizeUrl` and `fetchWithRedirects` enforce per hop in url.ts/fetcher.ts. Chrome
|
|
225
|
+
* follows redirects internally with no hook back into that check, so a page that starts on a safe
|
|
226
|
+
* public host could otherwise redirect a rendered-fallback capture into a private/internal address
|
|
227
|
+
* (SSRF) or a non-http(s) scheme (e.g. `file:`) that the static fetcher would have rejected outright.
|
|
228
|
+
*/
|
|
229
|
+
export async function describeUnsafeNavigationTarget(url) {
|
|
230
|
+
let normalized;
|
|
231
|
+
try {
|
|
232
|
+
normalized = normalizeUrl(url);
|
|
233
|
+
}
|
|
234
|
+
catch (err) {
|
|
235
|
+
return err;
|
|
236
|
+
}
|
|
237
|
+
try {
|
|
238
|
+
await checkDnsSafety(new URL(normalized).hostname);
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
catch (err) {
|
|
242
|
+
return err;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Spawns a headless Chrome, attaches a flat CDP session, and enables the Page, Network, Runtime,
|
|
247
|
+
* and Fetch domains. Fetch domain interception guards every Document-type request (the initial
|
|
248
|
+
* navigation and each subsequent redirect) with {@link describeUnsafeNavigationTarget} before
|
|
249
|
+
* Chrome is allowed to send it; a blocked request's reason is recorded and can be read back via
|
|
250
|
+
* the returned page's `takeBlockedNavigationError()`. The caller drives navigation and must call
|
|
251
|
+
* close() when finished.
|
|
225
252
|
*/
|
|
226
253
|
export async function openCdpPage() {
|
|
227
254
|
const chromePath = findChromePath();
|
|
228
255
|
const { chromeProcess, wsUrl } = await spawnChrome(chromePath);
|
|
229
256
|
const client = new CdpClient(wsUrl);
|
|
257
|
+
let blockedNavigationError = null;
|
|
230
258
|
try {
|
|
231
259
|
await client.connect();
|
|
232
260
|
const { targetId } = await client.send('Target.createTarget', { url: 'about:blank' });
|
|
@@ -235,6 +263,23 @@ export async function openCdpPage() {
|
|
|
235
263
|
await client.send('Network.enable', {}, sessionId);
|
|
236
264
|
await client.send('Runtime.enable', {}, sessionId);
|
|
237
265
|
await client.send('Network.setUserAgentOverride', { userAgent: DEFAULT_USER_AGENT }, sessionId);
|
|
266
|
+
await client.send('Fetch.enable', { patterns: [{ urlPattern: '*', resourceType: 'Document', requestStage: 'Request' }] }, sessionId);
|
|
267
|
+
client.on(`${sessionId}:Fetch.requestPaused`, (params) => {
|
|
268
|
+
// CdpClient.on's handler type is synchronous (CDP event dispatch isn't awaited), so the
|
|
269
|
+
// actual async validate-then-continue/fail work runs in this detached IIFE instead.
|
|
270
|
+
void (async () => {
|
|
271
|
+
const { requestId, request } = params;
|
|
272
|
+
const blockReason = await describeUnsafeNavigationTarget(request.url);
|
|
273
|
+
if (blockReason) {
|
|
274
|
+
blockedNavigationError = blockReason;
|
|
275
|
+
await client
|
|
276
|
+
.send('Fetch.failRequest', { requestId, errorReason: 'BlockedByClient' }, sessionId)
|
|
277
|
+
.catch(() => { });
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
await client.send('Fetch.continueRequest', { requestId }, sessionId).catch(() => { });
|
|
281
|
+
})();
|
|
282
|
+
});
|
|
238
283
|
const close = async () => {
|
|
239
284
|
try {
|
|
240
285
|
await client.send('Target.closeTarget', { targetId });
|
|
@@ -243,7 +288,12 @@ export async function openCdpPage() {
|
|
|
243
288
|
client.close();
|
|
244
289
|
chromeProcess.kill('SIGKILL');
|
|
245
290
|
};
|
|
246
|
-
|
|
291
|
+
const takeBlockedNavigationError = () => {
|
|
292
|
+
const err = blockedNavigationError;
|
|
293
|
+
blockedNavigationError = null;
|
|
294
|
+
return err;
|
|
295
|
+
};
|
|
296
|
+
return { client, sessionId, close, takeBlockedNavigationError };
|
|
247
297
|
}
|
|
248
298
|
catch (err) {
|
|
249
299
|
client.close();
|
|
@@ -332,8 +382,8 @@ const PROXY_BLOCKED_CHROME_NET_ERRORS = [
|
|
|
332
382
|
'ERR_PROXY_CONNECTION_FAILED',
|
|
333
383
|
'ERR_SOCKS_CONNECTION_FAILED',
|
|
334
384
|
];
|
|
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.
|
|
385
|
+
// Shared substring fetch-result.ts's fetchFailureGuidance matches on to attach suggestions/ref to
|
|
386
|
+
// this error, without duplicating the message text it needs to match against.
|
|
337
387
|
export const SANDBOX_EGRESS_ERROR_MARKER = 'sandboxed execution environment';
|
|
338
388
|
/**
|
|
339
389
|
* Builds the error thrown for a failed Page.navigate. When a proxy is configured (proxy.ts's
|
|
@@ -378,6 +428,86 @@ function isTransientBrowserError(err) {
|
|
|
378
428
|
return (TRANSIENT_NAV_ERROR_CODES.some((code) => message.includes(code)) ||
|
|
379
429
|
TRANSIENT_CHROME_STARTUP_MESSAGES.some((text) => message.includes(text)));
|
|
380
430
|
}
|
|
431
|
+
/**
|
|
432
|
+
* Navigates an already-open CDP page to `currentUrl` and captures the rendered HTML, honoring the
|
|
433
|
+
* openCdpPage Fetch-domain safety guard at every point a blocked navigation could otherwise be
|
|
434
|
+
* discarded in favor of a less specific (or entirely silent) failure:
|
|
435
|
+
*
|
|
436
|
+
* 1. Right after `Page.navigate` returns — a fast HTTP redirect chain can already be blocked and
|
|
437
|
+
* resolved by this point, surfacing as the generic `nav.errorText` (e.g. `net::ERR_BLOCKED_BY_CLIENT`)
|
|
438
|
+
* below if not checked first.
|
|
439
|
+
* 2. If `waitForLoad` throws — a client-side (JS-triggered) navigation into a blocked target after
|
|
440
|
+
* the initial navigation already settled can prevent the load/domContentLoaded events
|
|
441
|
+
* `waitForLoad` is waiting on, surfacing as a generic timeout instead of the specific reason.
|
|
442
|
+
* 3. After `waitForLoad` resolves — a client-side navigation blocked without otherwise disrupting
|
|
443
|
+
* the load signal would otherwise sail through silently, capturing whatever the page settled on.
|
|
444
|
+
*
|
|
445
|
+
* Exported (rather than folded into `fetchRenderedHtmlOnce`) so tests can drive it against a fake
|
|
446
|
+
* `CdpPage` without spawning a real browser.
|
|
447
|
+
*/
|
|
448
|
+
export async function captureNavigatedPage(page, currentUrl, options) {
|
|
449
|
+
const { timeout, limit, settleMs } = options;
|
|
450
|
+
await page.client.send('Network.setBlockedURLs', { urls: BLOCKED_ASSET_URLS }, page.sessionId);
|
|
451
|
+
// Record the status of each frame's first Document response so we can reject HTTP errors after
|
|
452
|
+
// the page settles. Keyed by frameId; the navigated top frame is matched via the navigate result.
|
|
453
|
+
const documentResponses = new Map();
|
|
454
|
+
page.client.on(`${page.sessionId}:Network.responseReceived`, (params) => {
|
|
455
|
+
const { frameId } = params;
|
|
456
|
+
const status = params.response?.status;
|
|
457
|
+
// Only record real numeric statuses: a Document event without a usable status would
|
|
458
|
+
// otherwise store NaN, which silently slips past the `>= 400` guard and the `?? 200`
|
|
459
|
+
// fallback alike, leaking a NaN into BrowserFetchResult.status (declared `number`).
|
|
460
|
+
if (params.type !== 'Document' || !frameId || typeof status !== 'number')
|
|
461
|
+
return;
|
|
462
|
+
if (documentResponses.has(frameId))
|
|
463
|
+
return;
|
|
464
|
+
documentResponses.set(frameId, {
|
|
465
|
+
status,
|
|
466
|
+
statusText: params.response?.statusText ?? '',
|
|
467
|
+
});
|
|
468
|
+
});
|
|
469
|
+
const nav = await page.client.send('Page.navigate', { url: currentUrl }, page.sessionId);
|
|
470
|
+
// See point 1 above: prefer the safety guard's specific reason over the opaque errorText below.
|
|
471
|
+
const blockedNavigation = page.takeBlockedNavigationError();
|
|
472
|
+
if (blockedNavigation)
|
|
473
|
+
throw blockedNavigation;
|
|
474
|
+
// A network-level failure (DNS, refused/closed connection, TLS) never fires a load event, so
|
|
475
|
+
// fail fast here instead of waiting out the full navigation timeout on a dead page.
|
|
476
|
+
if (nav.errorText) {
|
|
477
|
+
throw describeNavigationFailure(nav.errorText, currentUrl);
|
|
478
|
+
}
|
|
479
|
+
try {
|
|
480
|
+
await waitForLoad(page.client, page.sessionId, timeout, settleMs);
|
|
481
|
+
}
|
|
482
|
+
catch (err) {
|
|
483
|
+
// See point 2 above: a blocked reason recorded during the wait outranks the generic timeout.
|
|
484
|
+
throw page.takeBlockedNavigationError() ?? err;
|
|
485
|
+
}
|
|
486
|
+
// See point 3 above.
|
|
487
|
+
const blockedNavigationAfterLoad = page.takeBlockedNavigationError();
|
|
488
|
+
if (blockedNavigationAfterLoad)
|
|
489
|
+
throw blockedNavigationAfterLoad;
|
|
490
|
+
const mainDoc = nav.frameId ? documentResponses.get(nav.frameId) : undefined;
|
|
491
|
+
assertRenderedHttpOk(mainDoc);
|
|
492
|
+
const evalResult = await page.client.send('Runtime.evaluate', { expression: 'document.documentElement.outerHTML', returnByValue: true }, page.sessionId);
|
|
493
|
+
const html = evalResult.result?.value;
|
|
494
|
+
if (typeof html !== 'string') {
|
|
495
|
+
throw new Error('Failed to retrieve rendered HTML from browser page.');
|
|
496
|
+
}
|
|
497
|
+
const byteLength = Buffer.byteLength(html);
|
|
498
|
+
if (byteLength > limit) {
|
|
499
|
+
throw new Error(`Response body size limit exceeded. Rendered HTML is ${byteLength} bytes.`);
|
|
500
|
+
}
|
|
501
|
+
return {
|
|
502
|
+
status: mainDoc?.status ?? 200,
|
|
503
|
+
contentType: 'text/html',
|
|
504
|
+
etag: null,
|
|
505
|
+
lastModified: null,
|
|
506
|
+
finalUrl: currentUrl,
|
|
507
|
+
responseSize: byteLength,
|
|
508
|
+
content: html,
|
|
509
|
+
};
|
|
510
|
+
}
|
|
381
511
|
async function fetchRenderedHtmlOnce(url, options = {}) {
|
|
382
512
|
const timeout = options.timeoutMs ?? 15_000;
|
|
383
513
|
const limit = options.bodyLimitBytes ?? 2 * 1024 * 1024;
|
|
@@ -385,52 +515,11 @@ async function fetchRenderedHtmlOnce(url, options = {}) {
|
|
|
385
515
|
await checkDnsSafety(new URL(currentUrl).hostname);
|
|
386
516
|
const page = await openCdpPage();
|
|
387
517
|
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
|
-
});
|
|
518
|
+
return await captureNavigatedPage(page, currentUrl, {
|
|
519
|
+
timeout,
|
|
520
|
+
limit,
|
|
521
|
+
settleMs: options.settleMs ?? 1000,
|
|
406
522
|
});
|
|
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
523
|
}
|
|
435
524
|
finally {
|
|
436
525
|
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 = [];
|