@hraness/kb 0.17.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/DISCLOSURE +43 -0
- package/LICENSE +21 -0
- package/README.md +545 -0
- package/dist/agent-context.js +35 -0
- package/dist/agent-guide-audit.js +18 -0
- package/dist/attachments.js +19 -0
- package/dist/authoring.js +33 -0
- package/dist/benchmark.js +19 -0
- package/dist/capture.js +49 -0
- package/dist/cli.js +3646 -0
- package/dist/clip/acquire.js +57 -0
- package/dist/clip/args.js +21 -0
- package/dist/clip/bounded-byte-buffer.js +9 -0
- package/dist/clip/browser-profiles.js +17 -0
- package/dist/clip/bundle-reader.js +11 -0
- package/dist/clip/cli.js +27 -0
- package/dist/clip/cookies.js +21 -0
- package/dist/clip/defuddle-worker.js +84 -0
- package/dist/clip/doctor.js +32 -0
- package/dist/clip/jobs.js +784 -0
- package/dist/clip/network-proxy.js +9 -0
- package/dist/clip/network.js +28 -0
- package/dist/clip/persist.js +30 -0
- package/dist/clip/refresh.js +11 -0
- package/dist/clip/terminal.js +11 -0
- package/dist/evaluation-builder.js +12238 -0
- package/dist/evaluation-kb.js +26 -0
- package/dist/evaluation.js +35 -0
- package/dist/git.js +39 -0
- package/dist/graph.js +39 -0
- package/dist/index-01jj6rbv.js +1103 -0
- package/dist/index-06c9ctr6.js +738 -0
- package/dist/index-0kavxzqj.js +2480 -0
- package/dist/index-1gwbassd.js +743 -0
- package/dist/index-1n418kb9.js +827 -0
- package/dist/index-1vrd1rmn.js +56 -0
- package/dist/index-1xxnjn0d.js +171 -0
- package/dist/index-3rm7cz6h.js +373 -0
- package/dist/index-3v2z4f0q.js +328 -0
- package/dist/index-48pz4jpc.js +392 -0
- package/dist/index-4j3tt0c3.js +234 -0
- package/dist/index-5n05se68.js +307 -0
- package/dist/index-5vwpzb5a.js +764 -0
- package/dist/index-6g2pv9d2.js +370 -0
- package/dist/index-7fzc32gf.js +712 -0
- package/dist/index-84x0vjjp.js +432 -0
- package/dist/index-adx6khj5.js +488 -0
- package/dist/index-b88v3vtm.js +805 -0
- package/dist/index-cv6fh7z5.js +462 -0
- package/dist/index-cxfrakt7.js +1067 -0
- package/dist/index-d13v9ckt.js +150 -0
- package/dist/index-dycjbhw2.js +1943 -0
- package/dist/index-dyqwejk5.js +531 -0
- package/dist/index-e5fbsywq.js +658 -0
- package/dist/index-f984hw45.js +927 -0
- package/dist/index-gh719d91.js +63 -0
- package/dist/index-hgve9rh2.js +2046 -0
- package/dist/index-hya40gb2.js +474 -0
- package/dist/index-j4zgmzjr.js +152 -0
- package/dist/index-jsmvyyvf.js +1166 -0
- package/dist/index-kh0pwvdd.js +1974 -0
- package/dist/index-mqx4nd6v.js +119 -0
- package/dist/index-mxxxytys.js +1254 -0
- package/dist/index-n5dd7r0v.js +711 -0
- package/dist/index-npg9z1a4.js +431 -0
- package/dist/index-pj501bh1.js +140 -0
- package/dist/index-qry4vhxk.js +1041 -0
- package/dist/index-s2gw5aw9.js +199 -0
- package/dist/index-vvdj6kca.js +70 -0
- package/dist/index-w2zc0vwa.js +571 -0
- package/dist/index-x3fthpsc.js +585 -0
- package/dist/index-zr53sf63.js +44 -0
- package/dist/index-zxdy5pby.js +2728 -0
- package/dist/index-zzhgcwyt.js +966 -0
- package/dist/index.js +453 -0
- package/dist/navigation.js +13 -0
- package/dist/pdf.js +85 -0
- package/dist/percolate.js +24 -0
- package/dist/portfolio.js +77 -0
- package/dist/query.js +29 -0
- package/dist/repository-memory.js +60 -0
- package/dist/sdk.js +36 -0
- package/dist/search-rules.js +34 -0
- package/dist/search.js +22 -0
- package/dist/semantic.js +39 -0
- package/dist/source-inbox.js +17 -0
- package/dist/untrusted-content.js +32 -0
- package/dist/url-intelligence.js +74 -0
- package/dist/workflow.js +23 -0
- package/dist/workflows/decision-context.js +20 -0
- package/dist/workflows/explain-change.js +8 -0
- package/dist/workflows/index.js +28 -0
- package/dist/workflows/plan-radar.js +10 -0
- package/package.json +409 -0
- package/skills/kb/AGENTS.md +19 -0
- package/skills/kb/SKILL.md +82 -0
- package/skills/kb/agents/openai.yaml +4 -0
- package/skills/kb/references/pdf-review.md +51 -0
- package/skills/kb/references/percolate.md +144 -0
- package/skills/kb/references/plan-structure.md +105 -0
- package/skills/kb/references/plan.md +115 -0
- package/skills/kb/references/query.md +231 -0
- package/skills/kb/references/refresh.md +133 -0
- package/skills/kb/references/save-pdf.md +125 -0
- package/skills/kb/references/save-url.md +178 -0
- package/skills/kb/references/url-authentication.md +75 -0
- package/skills/kb/references/url-platforms.md +25 -0
- package/src/agent-context.ts +1241 -0
- package/src/agent-guide-audit.ts +750 -0
- package/src/attachments.ts +723 -0
- package/src/authoring.ts +1725 -0
- package/src/benchmark.ts +362 -0
- package/src/capture.ts +68 -0
- package/src/cli.ts +3660 -0
- package/src/clip/acquire.ts +1273 -0
- package/src/clip/archive-today.ts +402 -0
- package/src/clip/args.ts +423 -0
- package/src/clip/assets.ts +262 -0
- package/src/clip/bounded-byte-buffer.ts +75 -0
- package/src/clip/browser-profiles.ts +372 -0
- package/src/clip/bundle-reader.ts +608 -0
- package/src/clip/capture.ts +1054 -0
- package/src/clip/cli.ts +181 -0
- package/src/clip/cookies.ts +512 -0
- package/src/clip/doctor.ts +1110 -0
- package/src/clip/extract.ts +868 -0
- package/src/clip/jobs.ts +959 -0
- package/src/clip/lib.ts +861 -0
- package/src/clip/media.ts +1474 -0
- package/src/clip/metadata-search-tool/Cargo.lock +2498 -0
- package/src/clip/metadata-search-tool/Cargo.toml +18 -0
- package/src/clip/metadata-search-tool/runner.ts +685 -0
- package/src/clip/metadata-search-tool/src/main.rs +389 -0
- package/src/clip/metadata-search.ts +687 -0
- package/src/clip/network-proxy.ts +627 -0
- package/src/clip/network.ts +950 -0
- package/src/clip/package-root.ts +54 -0
- package/src/clip/persist.ts +1472 -0
- package/src/clip/platforms.ts +1824 -0
- package/src/clip/refresh.ts +197 -0
- package/src/clip/structured.ts +432 -0
- package/src/clip/terminal.ts +197 -0
- package/src/clip/url-intelligence.ts +581 -0
- package/src/clip/url-metadata-backfill.ts +616 -0
- package/src/clip/url-metadata-cli.ts +251 -0
- package/src/clip/url-metadata.ts +1397 -0
- package/src/evaluation-analysis-v2.ts +1440 -0
- package/src/evaluation-builder.ts +2539 -0
- package/src/evaluation-corpus-authoring.ts +2052 -0
- package/src/evaluation-evidence.ts +1147 -0
- package/src/evaluation-execution-v2.ts +426 -0
- package/src/evaluation-implementation.ts +131 -0
- package/src/evaluation-kb-closure.ts +1430 -0
- package/src/evaluation-kb-runner-v2.ts +735 -0
- package/src/evaluation-kb-v2.ts +2567 -0
- package/src/evaluation-kb.ts +1018 -0
- package/src/evaluation-measurement-v2.ts +1462 -0
- package/src/evaluation-packing-v2.ts +452 -0
- package/src/evaluation-redaction.ts +28 -0
- package/src/evaluation-v2.ts +3857 -0
- package/src/evaluation.ts +1220 -0
- package/src/git.ts +1276 -0
- package/src/graph.ts +1487 -0
- package/src/index.ts +20 -0
- package/src/init.ts +123 -0
- package/src/navigation.ts +277 -0
- package/src/note-lock.ts +529 -0
- package/src/pdf/args.ts +202 -0
- package/src/pdf/capture.ts +298 -0
- package/src/pdf/cli.ts +159 -0
- package/src/pdf/extract.ts +303 -0
- package/src/pdf/layout.ts +618 -0
- package/src/pdf/markdown.ts +154 -0
- package/src/pdf/model.ts +234 -0
- package/src/pdf/ocr.ts +190 -0
- package/src/pdf/persist.ts +314 -0
- package/src/pdf/source.ts +135 -0
- package/src/pdf/tools.ts +146 -0
- package/src/pdf.ts +92 -0
- package/src/percolate.ts +877 -0
- package/src/portfolio-audit.ts +574 -0
- package/src/portfolio-identity.ts +162 -0
- package/src/portfolio-registry.ts +473 -0
- package/src/portfolio.ts +765 -0
- package/src/query.ts +646 -0
- package/src/repository-memory.ts +1150 -0
- package/src/sdk.ts +1452 -0
- package/src/search-rules.ts +712 -0
- package/src/search.ts +710 -0
- package/src/semantic-runtime.ts +1139 -0
- package/src/semantic.ts +2480 -0
- package/src/source-inbox.ts +232 -0
- package/src/untrusted-content.ts +401 -0
- package/src/url-intelligence.ts +104 -0
- package/src/vault.ts +636 -0
- package/src/workflow.ts +653 -0
- package/src/workflows/decision-context.ts +81 -0
- package/src/workflows/explain-change.ts +73 -0
- package/src/workflows/index.ts +3 -0
- package/src/workflows/plan-radar.ts +101 -0
package/src/clip/args.ts
ADDED
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
export const captureModes = ["auto", "http", "browser", "file"] as const;
|
|
2
|
+
export type CaptureMode = (typeof captureModes)[number];
|
|
3
|
+
|
|
4
|
+
export const captureScopes = ["auto", "page", "thread", "comments"] as const;
|
|
5
|
+
export type CaptureScope = (typeof captureScopes)[number];
|
|
6
|
+
|
|
7
|
+
export const mediaModes = ["none", "images", "all"] as const;
|
|
8
|
+
export type MediaMode = (typeof mediaModes)[number];
|
|
9
|
+
|
|
10
|
+
export const evidenceModes = ["none", "source", "screenshot", "all"] as const;
|
|
11
|
+
export type EvidenceMode = (typeof evidenceModes)[number];
|
|
12
|
+
|
|
13
|
+
export const cookieSources = ["chrome", "arc", "brave", "chromium", "edge", "firefox", "safari"] as const;
|
|
14
|
+
export type CookieSource = (typeof cookieSources)[number];
|
|
15
|
+
|
|
16
|
+
export type CaptureArguments = {
|
|
17
|
+
readonly command: "capture" | "inspect";
|
|
18
|
+
/** Null only for the `current` target before its attached tab has been read. */
|
|
19
|
+
readonly url: URL | null;
|
|
20
|
+
readonly currentTab: boolean;
|
|
21
|
+
readonly slug: string | undefined;
|
|
22
|
+
readonly mode: CaptureMode;
|
|
23
|
+
readonly scope: CaptureScope;
|
|
24
|
+
readonly media: MediaMode;
|
|
25
|
+
readonly evidence: EvidenceMode;
|
|
26
|
+
readonly htmlFile: string | undefined;
|
|
27
|
+
readonly outputBase: string;
|
|
28
|
+
readonly force: boolean;
|
|
29
|
+
readonly stdout: boolean;
|
|
30
|
+
readonly json: boolean;
|
|
31
|
+
readonly quiet: boolean;
|
|
32
|
+
readonly browserProfile: string | undefined;
|
|
33
|
+
/** Internal embedding contract: the path is an owned, disposable clone. */
|
|
34
|
+
readonly browserProfileOwnership?: "borrowed" | "owned";
|
|
35
|
+
/** Internal Chromium profile selection inside an owned user-data clone. */
|
|
36
|
+
readonly browserProfileDirectory?: "Default";
|
|
37
|
+
/** Trusted embedding contract: exact browser binary paired with the selected profile. */
|
|
38
|
+
readonly browserExecutable?: string;
|
|
39
|
+
readonly browserLive: boolean;
|
|
40
|
+
readonly cdp: string | undefined;
|
|
41
|
+
readonly cookieSources: readonly CookieSource[];
|
|
42
|
+
readonly cookieProfile: string | undefined;
|
|
43
|
+
readonly cookiesFile: string | undefined;
|
|
44
|
+
readonly timeoutMs: number;
|
|
45
|
+
readonly maxItems: number;
|
|
46
|
+
readonly maxDepth: number;
|
|
47
|
+
readonly maxHtmlBytes: number;
|
|
48
|
+
readonly maxAssetBytes: number;
|
|
49
|
+
readonly maxTotalAssetBytes: number;
|
|
50
|
+
readonly allowPrivateNetwork: boolean;
|
|
51
|
+
readonly userAgent: string;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export type CliArguments =
|
|
55
|
+
| CaptureArguments
|
|
56
|
+
| { readonly command: "doctor"; readonly json: boolean }
|
|
57
|
+
| { readonly command: "adapters"; readonly json: boolean }
|
|
58
|
+
| { readonly command: "help" };
|
|
59
|
+
|
|
60
|
+
export type ParseArgumentsResult =
|
|
61
|
+
| { readonly ok: true; readonly value: CliArguments }
|
|
62
|
+
| { readonly ok: false; readonly message: string };
|
|
63
|
+
|
|
64
|
+
/** Require a URL after a `current` target has been resolved from its attached tab. */
|
|
65
|
+
export function captureUrl(options: Pick<CaptureArguments, "url">): URL {
|
|
66
|
+
if (options.url === null) throw new Error("the current browser target has not resolved its URL yet");
|
|
67
|
+
return options.url;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const DEFAULT_USER_AGENT =
|
|
71
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " +
|
|
72
|
+
"(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36";
|
|
73
|
+
|
|
74
|
+
const defaults = {
|
|
75
|
+
timeoutMs: 30_000,
|
|
76
|
+
maxItems: 500,
|
|
77
|
+
maxDepth: 16,
|
|
78
|
+
maxHtmlBytes: 25 * 1024 * 1024,
|
|
79
|
+
maxAssetBytes: 100 * 1024 * 1024,
|
|
80
|
+
maxTotalAssetBytes: 500 * 1024 * 1024,
|
|
81
|
+
} as const;
|
|
82
|
+
|
|
83
|
+
const valueOptions = new Set([
|
|
84
|
+
"--mode",
|
|
85
|
+
"--scope",
|
|
86
|
+
"--media",
|
|
87
|
+
"--evidence",
|
|
88
|
+
"--html",
|
|
89
|
+
"--output",
|
|
90
|
+
"--browser-profile",
|
|
91
|
+
"--cdp",
|
|
92
|
+
"--cookie-profile",
|
|
93
|
+
"--cookies-file",
|
|
94
|
+
"--user-agent",
|
|
95
|
+
"--cookie-source",
|
|
96
|
+
"--timeout-ms",
|
|
97
|
+
"--max-items",
|
|
98
|
+
"--max-depth",
|
|
99
|
+
"--max-html-bytes",
|
|
100
|
+
"--max-asset-bytes",
|
|
101
|
+
"--max-total-asset-bytes",
|
|
102
|
+
]);
|
|
103
|
+
|
|
104
|
+
function enumValue<T extends string>(
|
|
105
|
+
value: string,
|
|
106
|
+
values: readonly T[],
|
|
107
|
+
label: string,
|
|
108
|
+
): { readonly ok: true; readonly value: T } | { readonly ok: false; readonly message: string } {
|
|
109
|
+
const match = values.find((candidate) => candidate === value);
|
|
110
|
+
return match === undefined
|
|
111
|
+
? { ok: false, message: `${label} must be one of ${values.join(", ")}` }
|
|
112
|
+
: { ok: true, value: match };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function positiveInteger(value: string, label: string, maximum: number): number | string {
|
|
116
|
+
if (!/^\d+$/.test(value)) return `${label} must be a positive integer`;
|
|
117
|
+
const parsed = Number(value);
|
|
118
|
+
if (!Number.isSafeInteger(parsed) || parsed < 1 || parsed > maximum) {
|
|
119
|
+
return `${label} must be between 1 and ${maximum}`;
|
|
120
|
+
}
|
|
121
|
+
return parsed;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function byteSize(value: string, label: string): number | string {
|
|
125
|
+
const match = /^(\d+)(b|kb|mb|gb)?$/i.exec(value);
|
|
126
|
+
if (match === null || match[1] === undefined) {
|
|
127
|
+
return `${label} must be an integer byte size such as 500000, 25mb, or 1gb`;
|
|
128
|
+
}
|
|
129
|
+
const amount = Number(match[1]);
|
|
130
|
+
const unit = (match[2] ?? "b").toLowerCase();
|
|
131
|
+
const multiplier = unit === "gb" ? 1024 ** 3 : unit === "mb" ? 1024 ** 2 : unit === "kb" ? 1024 : 1;
|
|
132
|
+
const parsed = amount * multiplier;
|
|
133
|
+
if (!Number.isSafeInteger(parsed) || parsed < 1 || parsed > 8 * 1024 ** 3) {
|
|
134
|
+
return `${label} must be between 1 byte and 8gb`;
|
|
135
|
+
}
|
|
136
|
+
return parsed;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function readOptionValue(args: readonly string[], index: number, flag: string): string | ParseArgumentsResult {
|
|
140
|
+
const value = args[index + 1];
|
|
141
|
+
return value === undefined || value.startsWith("--")
|
|
142
|
+
? { ok: false, message: `${flag} requires a value` }
|
|
143
|
+
: value;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function parseUrl(value: string, label = "URL"): URL | string {
|
|
147
|
+
if (value.length > 64 * 1024) return `${label} exceeds the 65536 code-unit safety limit`;
|
|
148
|
+
try {
|
|
149
|
+
const url = new URL(value);
|
|
150
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") return `${label} must use http or https`;
|
|
151
|
+
if (url.username !== "" || url.password !== "") {
|
|
152
|
+
return `${label} must not contain embedded credentials; use --cookies-file or a browser session`;
|
|
153
|
+
}
|
|
154
|
+
return url;
|
|
155
|
+
} catch {
|
|
156
|
+
return `${label} is not a valid URL`;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Parse the stable CLI surface without touching the filesystem or network. */
|
|
161
|
+
export function parseArguments(
|
|
162
|
+
rawArgs: readonly string[],
|
|
163
|
+
environment: Readonly<Record<string, string | undefined>> = {},
|
|
164
|
+
): ParseArgumentsResult {
|
|
165
|
+
if (rawArgs.length === 0 || rawArgs[0] === "help" || rawArgs[0] === "--help" || rawArgs[0] === "-h") {
|
|
166
|
+
return { ok: true, value: { command: "help" } };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const first = rawArgs[0];
|
|
170
|
+
if (first === "doctor" || first === "adapters") {
|
|
171
|
+
const extra = rawArgs.slice(1);
|
|
172
|
+
if (extra.some((value) => value !== "--json")) {
|
|
173
|
+
return { ok: false, message: `${first} only accepts --json` };
|
|
174
|
+
}
|
|
175
|
+
return { ok: true, value: { command: first, json: extra.includes("--json") } };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
let command: "capture" | "inspect" = "capture";
|
|
179
|
+
let cursor = 0;
|
|
180
|
+
if (first === "capture" || first === "inspect") {
|
|
181
|
+
command = first;
|
|
182
|
+
cursor += 1;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const positional: string[] = [];
|
|
186
|
+
let mode: CaptureMode = "auto";
|
|
187
|
+
let scope: CaptureScope = "auto";
|
|
188
|
+
let media: MediaMode = command === "inspect" ? "none" : "images";
|
|
189
|
+
let evidence: EvidenceMode = "none";
|
|
190
|
+
let mediaExplicit = false;
|
|
191
|
+
let evidenceExplicit = false;
|
|
192
|
+
let htmlFile: string | undefined;
|
|
193
|
+
let outputBase = environment.KB_CLIP_OUTPUT ?? "kb/articles";
|
|
194
|
+
let force = false;
|
|
195
|
+
let stdout = command === "inspect";
|
|
196
|
+
let stdoutExplicit = false;
|
|
197
|
+
let json = false;
|
|
198
|
+
let quiet = false;
|
|
199
|
+
let browserProfile: string | undefined;
|
|
200
|
+
let browserLive = false;
|
|
201
|
+
let cdp: string | undefined;
|
|
202
|
+
const selectedCookieSources: CookieSource[] = [];
|
|
203
|
+
let cookieProfile: string | undefined;
|
|
204
|
+
let cookiesFile: string | undefined;
|
|
205
|
+
let timeoutMs: number = defaults.timeoutMs;
|
|
206
|
+
let maxItems: number = defaults.maxItems;
|
|
207
|
+
let maxDepth: number = defaults.maxDepth;
|
|
208
|
+
let maxHtmlBytes: number = defaults.maxHtmlBytes;
|
|
209
|
+
let maxAssetBytes: number = defaults.maxAssetBytes;
|
|
210
|
+
let maxTotalAssetBytes: number = defaults.maxTotalAssetBytes;
|
|
211
|
+
let allowPrivateNetwork = false;
|
|
212
|
+
let userAgent = environment.KB_CLIP_USER_AGENT ?? DEFAULT_USER_AGENT;
|
|
213
|
+
|
|
214
|
+
for (; cursor < rawArgs.length; cursor += 1) {
|
|
215
|
+
const argument = rawArgs[cursor];
|
|
216
|
+
if (argument === undefined) continue;
|
|
217
|
+
if (!argument.startsWith("--")) {
|
|
218
|
+
positional.push(argument);
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (argument === "--force") force = true;
|
|
223
|
+
else if (argument === "--stdout") {
|
|
224
|
+
stdout = true;
|
|
225
|
+
stdoutExplicit = true;
|
|
226
|
+
}
|
|
227
|
+
else if (argument === "--json") json = true;
|
|
228
|
+
else if (argument === "--quiet") quiet = true;
|
|
229
|
+
else if (argument === "--browser-live") browserLive = true;
|
|
230
|
+
else if (argument === "--allow-private-network") allowPrivateNetwork = true;
|
|
231
|
+
else {
|
|
232
|
+
if (!valueOptions.has(argument)) return { ok: false, message: `unknown option: ${argument}` };
|
|
233
|
+
const value = readOptionValue(rawArgs, cursor, argument);
|
|
234
|
+
if (typeof value !== "string") return value;
|
|
235
|
+
cursor += 1;
|
|
236
|
+
if (argument === "--mode") {
|
|
237
|
+
const parsed = enumValue(value, captureModes, "--mode");
|
|
238
|
+
if (!parsed.ok) return { ok: false, message: parsed.message };
|
|
239
|
+
mode = parsed.value;
|
|
240
|
+
} else if (argument === "--scope") {
|
|
241
|
+
const parsed = enumValue(value, captureScopes, "--scope");
|
|
242
|
+
if (!parsed.ok) return { ok: false, message: parsed.message };
|
|
243
|
+
scope = parsed.value;
|
|
244
|
+
} else if (argument === "--media") {
|
|
245
|
+
const parsed = enumValue(value, mediaModes, "--media");
|
|
246
|
+
if (!parsed.ok) return { ok: false, message: parsed.message };
|
|
247
|
+
media = parsed.value;
|
|
248
|
+
mediaExplicit = true;
|
|
249
|
+
} else if (argument === "--evidence") {
|
|
250
|
+
const parsed = enumValue(value, evidenceModes, "--evidence");
|
|
251
|
+
if (!parsed.ok) return { ok: false, message: parsed.message };
|
|
252
|
+
evidence = parsed.value;
|
|
253
|
+
evidenceExplicit = true;
|
|
254
|
+
} else if (argument === "--html") htmlFile = value;
|
|
255
|
+
else if (argument === "--output") outputBase = value;
|
|
256
|
+
else if (argument === "--browser-profile") browserProfile = value;
|
|
257
|
+
else if (argument === "--cdp") {
|
|
258
|
+
const parsed = positiveInteger(value, "--cdp", 65_535);
|
|
259
|
+
if (typeof parsed === "string") {
|
|
260
|
+
return { ok: false, message: "--cdp accepts only a local remote-debugging port between 1 and 65535" };
|
|
261
|
+
}
|
|
262
|
+
cdp = String(parsed);
|
|
263
|
+
}
|
|
264
|
+
else if (argument === "--cookie-profile") cookieProfile = value;
|
|
265
|
+
else if (argument === "--cookies-file") cookiesFile = value;
|
|
266
|
+
else if (argument === "--user-agent") userAgent = value;
|
|
267
|
+
else if (argument === "--cookie-source") {
|
|
268
|
+
const parsed = enumValue(value, cookieSources, "--cookie-source");
|
|
269
|
+
if (!parsed.ok) return { ok: false, message: parsed.message };
|
|
270
|
+
selectedCookieSources.push(parsed.value);
|
|
271
|
+
} else if (argument === "--timeout-ms") {
|
|
272
|
+
const parsed = positiveInteger(value, argument, 10 * 60_000);
|
|
273
|
+
if (typeof parsed === "string") return { ok: false, message: parsed };
|
|
274
|
+
timeoutMs = parsed;
|
|
275
|
+
} else if (argument === "--max-items") {
|
|
276
|
+
const parsed = positiveInteger(value, argument, 10_000);
|
|
277
|
+
if (typeof parsed === "string") return { ok: false, message: parsed };
|
|
278
|
+
maxItems = parsed;
|
|
279
|
+
} else if (argument === "--max-depth") {
|
|
280
|
+
const parsed = positiveInteger(value, argument, 64);
|
|
281
|
+
if (typeof parsed === "string") return { ok: false, message: parsed };
|
|
282
|
+
maxDepth = parsed;
|
|
283
|
+
} else if (argument === "--max-html-bytes") {
|
|
284
|
+
const parsed = byteSize(value, argument);
|
|
285
|
+
if (typeof parsed === "string") return { ok: false, message: parsed };
|
|
286
|
+
maxHtmlBytes = parsed;
|
|
287
|
+
} else if (argument === "--max-asset-bytes") {
|
|
288
|
+
const parsed = byteSize(value, argument);
|
|
289
|
+
if (typeof parsed === "string") return { ok: false, message: parsed };
|
|
290
|
+
maxAssetBytes = parsed;
|
|
291
|
+
} else if (argument === "--max-total-asset-bytes") {
|
|
292
|
+
const parsed = byteSize(value, argument);
|
|
293
|
+
if (typeof parsed === "string") return { ok: false, message: parsed };
|
|
294
|
+
maxTotalAssetBytes = parsed;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (positional.length === 0) return { ok: false, message: `${command} requires a URL or current` };
|
|
300
|
+
if (positional.length > 2) return { ok: false, message: `${command} accepts one URL/current target and one optional slug` };
|
|
301
|
+
const currentTab = positional[0] === "current";
|
|
302
|
+
const parsedUrl = currentTab ? null : parseUrl(positional[0] ?? "");
|
|
303
|
+
if (typeof parsedUrl === "string") return { ok: false, message: parsedUrl };
|
|
304
|
+
if (mode === "file" && htmlFile === undefined) return { ok: false, message: "--mode file requires --html <path|->" };
|
|
305
|
+
if (htmlFile !== undefined && mode !== "auto" && mode !== "file") {
|
|
306
|
+
return { ok: false, message: "--html can only be used with --mode auto or --mode file" };
|
|
307
|
+
}
|
|
308
|
+
if (browserLive && browserProfile !== undefined) {
|
|
309
|
+
return { ok: false, message: "--browser-live and --browser-profile are mutually exclusive" };
|
|
310
|
+
}
|
|
311
|
+
if (cdp !== undefined && (browserLive || browserProfile !== undefined)) {
|
|
312
|
+
return { ok: false, message: "--cdp cannot be combined with --browser-live or --browser-profile" };
|
|
313
|
+
}
|
|
314
|
+
if (currentTab && browserProfile !== undefined) {
|
|
315
|
+
return { ok: false, message: "the current target attaches with --browser-live or --cdp; it cannot use --browser-profile" };
|
|
316
|
+
}
|
|
317
|
+
if (currentTab && !browserLive && cdp === undefined) {
|
|
318
|
+
return { ok: false, message: "the current target requires --browser-live or --cdp <loopback-port>" };
|
|
319
|
+
}
|
|
320
|
+
const hasBrowserSelection = browserLive || cdp !== undefined || browserProfile !== undefined;
|
|
321
|
+
if (hasBrowserSelection && (mode === "http" || mode === "file" || htmlFile !== undefined)) {
|
|
322
|
+
return { ok: false, message: "browser selection requires --mode auto or --mode browser and cannot be combined with --html" };
|
|
323
|
+
}
|
|
324
|
+
if ((evidence === "screenshot" || evidence === "all") && (mode === "http" || mode === "file" || htmlFile !== undefined)) {
|
|
325
|
+
return { ok: false, message: "screenshot evidence requires --mode auto or --mode browser" };
|
|
326
|
+
}
|
|
327
|
+
if (selectedCookieSources.length > 1) {
|
|
328
|
+
return { ok: false, message: "select at most one --cookie-source so profile and media behavior stay unambiguous" };
|
|
329
|
+
}
|
|
330
|
+
if (selectedCookieSources.length > 0 && cookiesFile !== undefined) {
|
|
331
|
+
return { ok: false, message: "--cookie-source and --cookies-file are mutually exclusive authentication sources" };
|
|
332
|
+
}
|
|
333
|
+
if (cookieProfile !== undefined && selectedCookieSources.length === 0) {
|
|
334
|
+
return { ok: false, message: "--cookie-profile requires at least one --cookie-source" };
|
|
335
|
+
}
|
|
336
|
+
if (maxTotalAssetBytes < maxAssetBytes) {
|
|
337
|
+
return { ok: false, message: "--max-total-asset-bytes cannot be smaller than --max-asset-bytes" };
|
|
338
|
+
}
|
|
339
|
+
if (stdoutExplicit && json) return { ok: false, message: "--stdout and --json cannot be combined" };
|
|
340
|
+
if (stdout && force) return { ok: false, message: "--stdout and --force cannot be combined" };
|
|
341
|
+
if (command === "inspect" && media !== "none") {
|
|
342
|
+
return { ok: false, message: "inspect does not persist media; use capture or pass --media none" };
|
|
343
|
+
}
|
|
344
|
+
if (command === "inspect" && evidence !== "none") {
|
|
345
|
+
return { ok: false, message: "inspect does not persist evidence; use capture or pass --evidence none" };
|
|
346
|
+
}
|
|
347
|
+
if (stdout && mediaExplicit && media !== "none") {
|
|
348
|
+
return { ok: false, message: "--stdout does not persist media; use capture without --stdout or pass --media none" };
|
|
349
|
+
}
|
|
350
|
+
if (stdout && evidenceExplicit && evidence !== "none") {
|
|
351
|
+
return { ok: false, message: "--stdout does not persist evidence; use capture without --stdout or pass --evidence none" };
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
return {
|
|
355
|
+
ok: true,
|
|
356
|
+
value: {
|
|
357
|
+
command,
|
|
358
|
+
url: parsedUrl,
|
|
359
|
+
currentTab,
|
|
360
|
+
slug: positional[1],
|
|
361
|
+
mode: htmlFile === undefined ? (currentTab ? "browser" : mode) : "file",
|
|
362
|
+
scope,
|
|
363
|
+
media: stdout ? "none" : media,
|
|
364
|
+
evidence: stdout ? "none" : evidence,
|
|
365
|
+
htmlFile,
|
|
366
|
+
outputBase,
|
|
367
|
+
force,
|
|
368
|
+
stdout,
|
|
369
|
+
json,
|
|
370
|
+
quiet,
|
|
371
|
+
browserProfile,
|
|
372
|
+
browserLive,
|
|
373
|
+
cdp,
|
|
374
|
+
cookieSources: selectedCookieSources,
|
|
375
|
+
cookieProfile,
|
|
376
|
+
cookiesFile,
|
|
377
|
+
timeoutMs,
|
|
378
|
+
maxItems,
|
|
379
|
+
maxDepth,
|
|
380
|
+
maxHtmlBytes,
|
|
381
|
+
maxAssetBytes,
|
|
382
|
+
maxTotalAssetBytes,
|
|
383
|
+
allowPrivateNetwork,
|
|
384
|
+
userAgent,
|
|
385
|
+
},
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
export const usage = `Usage:
|
|
390
|
+
kb clip <url> [slug] [options]
|
|
391
|
+
kb clip current [slug] --browser-live [options]
|
|
392
|
+
kb clip current [slug] --cdp <loopback-port> [options]
|
|
393
|
+
kb clip capture <url> [slug] [options]
|
|
394
|
+
kb clip inspect <url> [options]
|
|
395
|
+
kb doctor [--json]
|
|
396
|
+
kb adapters [--json]
|
|
397
|
+
|
|
398
|
+
Capture options:
|
|
399
|
+
--mode auto|http|browser|file Acquisition strategy (default: auto)
|
|
400
|
+
--scope auto|page|thread|comments Content scope (default: platform-aware)
|
|
401
|
+
--html <path|-> Parse saved/rendered HTML; - reads stdin
|
|
402
|
+
--browser-profile <name|path> Use a signed-in or persistent Chrome profile
|
|
403
|
+
--browser-live Attach to a live Chrome session
|
|
404
|
+
--cdp <loopback-port> Attach to a local CDP-capable browser
|
|
405
|
+
--cookie-source <browser> chrome|arc|brave|chromium|edge|firefox|safari
|
|
406
|
+
--cookie-profile <name|path> Browser profile; Safari expects a Cookies.binarycookies path
|
|
407
|
+
--cookies-file <path> Cookie-Editor JSON/base64, Netscape, Cookie header, or cURL input
|
|
408
|
+
--media none|images|all Localize images or all supported media
|
|
409
|
+
--evidence none|source|screenshot|all
|
|
410
|
+
--output <directory> Output base (default: kb/articles)
|
|
411
|
+
--stdout Print Markdown without writing a clip
|
|
412
|
+
--json Print a machine-readable result summary
|
|
413
|
+
--force Atomically replace an existing clip
|
|
414
|
+
--timeout-ms <n> Per-request/process/extraction timeout
|
|
415
|
+
--max-items <n> Bound comments/thread items (default: 500)
|
|
416
|
+
--max-depth <n> Bound nested replies (default: 16)
|
|
417
|
+
--max-html-bytes <size> HTML cap (default: 25mb)
|
|
418
|
+
--max-asset-bytes <size> Per-asset cap (default: 100mb)
|
|
419
|
+
--max-total-asset-bytes <size> Total asset cap (default: 500mb)
|
|
420
|
+
--allow-private-network Permit private targets in every network lane
|
|
421
|
+
--user-agent <value> Override the browser-like user agent
|
|
422
|
+
--quiet Suppress progress output
|
|
423
|
+
`;
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
CONTENT_REWRITE_TRUNCATION_WARNING,
|
|
7
|
+
resolveRemote,
|
|
8
|
+
rewriteContentWithStatus,
|
|
9
|
+
scanImageSources,
|
|
10
|
+
} from "./lib.js";
|
|
11
|
+
import { FetchFailure, safeFetch, type SafeFetchResult } from "./network.js";
|
|
12
|
+
|
|
13
|
+
export type AssetRecord = {
|
|
14
|
+
readonly source: string;
|
|
15
|
+
readonly url: string;
|
|
16
|
+
readonly path: string;
|
|
17
|
+
readonly mimeType: string;
|
|
18
|
+
readonly bytes: number;
|
|
19
|
+
readonly sha256: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type LocalizeAssetsOptions = {
|
|
23
|
+
readonly assetsDirectory: string;
|
|
24
|
+
readonly baseUrl: URL;
|
|
25
|
+
readonly userAgent: string;
|
|
26
|
+
readonly timeoutMs: number;
|
|
27
|
+
readonly maxAssetBytes: number;
|
|
28
|
+
readonly maxTotalAssetBytes: number;
|
|
29
|
+
readonly allowPrivateNetwork: boolean;
|
|
30
|
+
readonly concurrency?: number;
|
|
31
|
+
/** Hard operational cap; intentionally separate from the network-byte budget. */
|
|
32
|
+
readonly maxSources?: number;
|
|
33
|
+
readonly fetchResource?: (url: URL, options: Parameters<typeof safeFetch>[1]) => Promise<SafeFetchResult>;
|
|
34
|
+
readonly cookieHeaderProvider?: (url: URL) => Promise<string | null>;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export type LocalizeAssetsResult = {
|
|
38
|
+
readonly content: string;
|
|
39
|
+
readonly assets: readonly AssetRecord[];
|
|
40
|
+
readonly warnings: readonly string[];
|
|
41
|
+
readonly truncated: boolean;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
type SniffedImage = { readonly mimeType: string; readonly extension: string };
|
|
45
|
+
|
|
46
|
+
function startsWith(bytes: Uint8Array, signature: readonly number[]): boolean {
|
|
47
|
+
return signature.every((byte, index) => bytes[index] === byte);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function ascii(bytes: Uint8Array, start: number, length: number): string {
|
|
51
|
+
return new TextDecoder().decode(bytes.slice(start, start + length));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Trust file signatures, not URL extensions or challenge-page Content-Type headers. */
|
|
55
|
+
export function sniffImage(bytes: Uint8Array): SniffedImage | null {
|
|
56
|
+
if (startsWith(bytes, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) {
|
|
57
|
+
return { mimeType: "image/png", extension: "png" };
|
|
58
|
+
}
|
|
59
|
+
if (startsWith(bytes, [0xff, 0xd8, 0xff])) return { mimeType: "image/jpeg", extension: "jpg" };
|
|
60
|
+
const prefix = ascii(bytes, 0, 6);
|
|
61
|
+
if (prefix === "GIF87a" || prefix === "GIF89a") return { mimeType: "image/gif", extension: "gif" };
|
|
62
|
+
if (ascii(bytes, 0, 4) === "RIFF" && ascii(bytes, 8, 4) === "WEBP") {
|
|
63
|
+
return { mimeType: "image/webp", extension: "webp" };
|
|
64
|
+
}
|
|
65
|
+
if (ascii(bytes, 4, 4) === "ftyp") {
|
|
66
|
+
const brand = ascii(bytes, 8, 4);
|
|
67
|
+
if (brand === "avif" || brand === "avis") return { mimeType: "image/avif", extension: "avif" };
|
|
68
|
+
}
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function sha256(bytes: Uint8Array): string {
|
|
73
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function inertAssetUrl(url: URL): string {
|
|
77
|
+
const inert = new URL(url);
|
|
78
|
+
inert.username = "";
|
|
79
|
+
inert.password = "";
|
|
80
|
+
inert.search = "";
|
|
81
|
+
inert.hash = "";
|
|
82
|
+
return inert.href;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function safeAssetFailure(error: unknown): string {
|
|
86
|
+
if (!(error instanceof FetchFailure)) return "image request failed";
|
|
87
|
+
switch (error.code) {
|
|
88
|
+
case "private-network": return "image request was blocked by the private-network boundary";
|
|
89
|
+
case "timeout": return "image request timed out";
|
|
90
|
+
case "too-large": return "image response exceeded its byte limit";
|
|
91
|
+
case "redirect": return "image redirect chain was rejected";
|
|
92
|
+
case "dns": return "image hostname could not be resolved";
|
|
93
|
+
case "http": return "image server returned an unsuccessful response";
|
|
94
|
+
case "invalid-url": return "image URL was rejected";
|
|
95
|
+
case "network": return "image request failed";
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
type DownloadResult =
|
|
100
|
+
| { readonly ok: true; readonly source: string; readonly url: URL; readonly bytes: Uint8Array; readonly image: SniffedImage; readonly networkBytes: number }
|
|
101
|
+
| { readonly ok: false; readonly source: string; readonly warning: string; readonly networkBytes: number };
|
|
102
|
+
|
|
103
|
+
async function downloadImage(source: string, options: LocalizeAssetsOptions, maxBytes: number): Promise<DownloadResult> {
|
|
104
|
+
const remote = resolveRemote(source, options.baseUrl);
|
|
105
|
+
if (remote === null) return { ok: false, source, warning: "Skipped a non-web image target.", networkBytes: 0 };
|
|
106
|
+
let authenticationWarning: string | null = null;
|
|
107
|
+
let cookieHeader: string | undefined;
|
|
108
|
+
if (options.cookieHeaderProvider !== undefined) {
|
|
109
|
+
try {
|
|
110
|
+
cookieHeader = (await options.cookieHeaderProvider(remote)) ?? undefined;
|
|
111
|
+
} catch {
|
|
112
|
+
authenticationWarning = "The explicitly selected cookie source could not provide origin-scoped image cookies.";
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
try {
|
|
116
|
+
const response = await (options.fetchResource ?? safeFetch)(remote, {
|
|
117
|
+
timeoutMs: options.timeoutMs,
|
|
118
|
+
maxBytes,
|
|
119
|
+
allowPrivateNetwork: options.allowPrivateNetwork,
|
|
120
|
+
userAgent: options.userAgent,
|
|
121
|
+
referer: options.baseUrl.href,
|
|
122
|
+
accept: "image/avif,image/webp,image/png,image/jpeg,image/gif;q=0.9,*/*;q=0.1",
|
|
123
|
+
...(cookieHeader === undefined ? {} : { cookieHeader }),
|
|
124
|
+
retries: 0,
|
|
125
|
+
});
|
|
126
|
+
const image = sniffImage(response.bytes);
|
|
127
|
+
if (image === null) {
|
|
128
|
+
const declared = response.contentType?.split(";")[0]?.trim() ?? "unknown content type";
|
|
129
|
+
return {
|
|
130
|
+
ok: false,
|
|
131
|
+
source,
|
|
132
|
+
warning: `${authenticationWarning === null ? "" : `${authenticationWarning} `}Kept remote ${inertAssetUrl(remote)}: response was not a supported raster image (${declared})`,
|
|
133
|
+
networkBytes: response.bytes.byteLength,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
return { ok: true, source, url: response.finalUrl, bytes: response.bytes, image, networkBytes: response.bytes.byteLength };
|
|
137
|
+
} catch (error) {
|
|
138
|
+
return {
|
|
139
|
+
ok: false,
|
|
140
|
+
source,
|
|
141
|
+
warning: `${authenticationWarning === null ? "" : `${authenticationWarning} `}Kept remote ${inertAssetUrl(remote)}: ${safeAssetFailure(error)}`,
|
|
142
|
+
networkBytes: maxBytes,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Download raster images with deterministic content-addressed names and bounded concurrency. */
|
|
148
|
+
export async function localizeAssets(content: string, options: LocalizeAssetsOptions): Promise<LocalizeAssetsResult> {
|
|
149
|
+
const maxSources = Math.max(1, Math.min(options.maxSources ?? 1_000, 10_000));
|
|
150
|
+
// One sentinel source preserves an exact warning for small overflows without
|
|
151
|
+
// ever materializing or sorting the attacker's complete candidate set.
|
|
152
|
+
const discovery = scanImageSources(content, maxSources + 1);
|
|
153
|
+
const discoveredSources = [...discovery.sources].sort((left, right) => left.localeCompare(right));
|
|
154
|
+
const sources = discoveredSources.slice(0, maxSources);
|
|
155
|
+
const warnings: string[] = discoveredSources.length > sources.length
|
|
156
|
+
? [`Image localization stopped at ${sources.length} sources; ${discovery.truncated ? "at least " : ""}${discoveredSources.length - sources.length} additional remote image(s) remain inert links.`]
|
|
157
|
+
: [];
|
|
158
|
+
if (discovery.truncated) {
|
|
159
|
+
warnings.push("Image discovery reached a safety limit; additional or over-limit image candidates remain inert.");
|
|
160
|
+
}
|
|
161
|
+
const rewrittenResult = (localBySource: ReadonlyMap<string, string>): Pick<LocalizeAssetsResult, "content" | "truncated"> => {
|
|
162
|
+
const rewritten = rewriteContentWithStatus(content, options.baseUrl, localBySource, {
|
|
163
|
+
maxImageSources: maxSources + 1,
|
|
164
|
+
});
|
|
165
|
+
if (rewritten.truncated) warnings.push(CONTENT_REWRITE_TRUNCATION_WARNING);
|
|
166
|
+
return rewritten;
|
|
167
|
+
};
|
|
168
|
+
if (discovery.requiresInertFallback) {
|
|
169
|
+
const rewritten = rewrittenResult(new Map());
|
|
170
|
+
return {
|
|
171
|
+
...rewritten,
|
|
172
|
+
assets: [],
|
|
173
|
+
warnings,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
if (discoveredSources.length === 0) {
|
|
177
|
+
const rewritten = rewrittenResult(new Map());
|
|
178
|
+
return {
|
|
179
|
+
...rewritten,
|
|
180
|
+
assets: [],
|
|
181
|
+
warnings,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
mkdirSync(options.assetsDirectory, { recursive: true });
|
|
185
|
+
|
|
186
|
+
const results = new Map<string, DownloadResult>();
|
|
187
|
+
const workerCount = Math.max(1, Math.min(options.concurrency ?? 4, sources.length, 16));
|
|
188
|
+
let remainingNetworkBytes = options.maxTotalAssetBytes;
|
|
189
|
+
const deadline = Date.now() + options.timeoutMs;
|
|
190
|
+
for (let cursor = 0; cursor < sources.length && remainingNetworkBytes > 0;) {
|
|
191
|
+
const remainingTime = deadline - Date.now();
|
|
192
|
+
if (remainingTime <= 0) break;
|
|
193
|
+
const batchSize = Math.min(workerCount, sources.length - cursor, remainingNetworkBytes);
|
|
194
|
+
const allocation = Math.min(options.maxAssetBytes, Math.floor(remainingNetworkBytes / batchSize));
|
|
195
|
+
const batch = sources.slice(cursor, cursor + batchSize);
|
|
196
|
+
cursor += batch.length;
|
|
197
|
+
remainingNetworkBytes -= allocation * batch.length;
|
|
198
|
+
const downloaded = await Promise.all(batch.map((source) => downloadImage(
|
|
199
|
+
source,
|
|
200
|
+
{ ...options, timeoutMs: Math.max(1, Math.min(options.timeoutMs, remainingTime)) },
|
|
201
|
+
allocation,
|
|
202
|
+
)));
|
|
203
|
+
for (const result of downloaded) {
|
|
204
|
+
results.set(result.source, result);
|
|
205
|
+
remainingNetworkBytes += Math.max(0, allocation - result.networkBytes);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const localBySource = new Map<string, string>();
|
|
210
|
+
const assetsByHash = new Map<string, AssetRecord>();
|
|
211
|
+
let totalBytes = 0;
|
|
212
|
+
let unattempted = 0;
|
|
213
|
+
for (const source of sources) {
|
|
214
|
+
const result = results.get(source);
|
|
215
|
+
if (result === undefined) {
|
|
216
|
+
unattempted += 1;
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
if (!result.ok) {
|
|
220
|
+
warnings.push(result.warning);
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
const digest = sha256(result.bytes);
|
|
224
|
+
const existing = assetsByHash.get(digest);
|
|
225
|
+
if (existing !== undefined) {
|
|
226
|
+
localBySource.set(source, existing.path);
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
if (totalBytes + result.bytes.byteLength > options.maxTotalAssetBytes) {
|
|
230
|
+
warnings.push(`Kept remote ${inertAssetUrl(result.url)}: total asset limit ${options.maxTotalAssetBytes} bytes would be exceeded`);
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
const filename = `${digest}.${result.image.extension}`;
|
|
234
|
+
const relativePath = `assets/${filename}`;
|
|
235
|
+
writeFileSync(join(options.assetsDirectory, filename), result.bytes, { mode: 0o644 });
|
|
236
|
+
totalBytes += result.bytes.byteLength;
|
|
237
|
+
const record: AssetRecord = {
|
|
238
|
+
source: (() => {
|
|
239
|
+
const resolved = resolveRemote(source, options.baseUrl);
|
|
240
|
+
return resolved === null ? source : inertAssetUrl(resolved);
|
|
241
|
+
})(),
|
|
242
|
+
url: inertAssetUrl(result.url),
|
|
243
|
+
path: relativePath,
|
|
244
|
+
mimeType: result.image.mimeType,
|
|
245
|
+
bytes: result.bytes.byteLength,
|
|
246
|
+
sha256: digest,
|
|
247
|
+
};
|
|
248
|
+
assetsByHash.set(digest, record);
|
|
249
|
+
localBySource.set(source, relativePath);
|
|
250
|
+
}
|
|
251
|
+
if (unattempted > 0) {
|
|
252
|
+
const reason = Date.now() >= deadline ? "total asset deadline" : "aggregate asset network-byte budget";
|
|
253
|
+
warnings.push(`${unattempted} remote image source(s) were not requested because the ${reason} was exhausted.`);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const rewritten = rewrittenResult(localBySource);
|
|
257
|
+
return {
|
|
258
|
+
...rewritten,
|
|
259
|
+
assets: [...assetsByHash.values()].sort((left, right) => left.path.localeCompare(right.path)),
|
|
260
|
+
warnings,
|
|
261
|
+
};
|
|
262
|
+
}
|