@zeldrisho/pi-web-fetch 0.10.2 → 0.11.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/CHANGELOG.md +19 -1
- package/LICENSE +1 -1
- package/README.md +15 -15
- package/package.json +3 -2
- package/src/{fetch.ts → acquisition/fetch.ts} +241 -71
- package/src/{llms.ts → acquisition/llms.ts} +12 -9
- package/src/challenges/anubis-solver.ts +255 -0
- package/src/challenges/anubis-worker.ts +18 -0
- package/src/challenges/anubis.ts +555 -0
- package/src/{content.ts → document/content.ts} +1 -1
- package/src/{evidence.ts → document/evidence.ts} +32 -1
- package/src/{extract.ts → document/extract.ts} +53 -3
- package/src/{fragments.ts → document/fragments.ts} +1 -1
- package/src/index.ts +24 -6
- package/src/network/cookies.ts +260 -0
- package/src/{network-redirects.ts → network/network-redirects.ts} +12 -4
- package/src/{network-transport.ts → network/network-transport.ts} +37 -1
- package/src/{network.ts → network/network.ts} +3 -2
- package/src/service.ts +7 -7
- package/workers/anubis-solver.js +164 -0
- package/workers/anubis-worker.js +15 -0
- /package/src/{fetch-metadata.ts → acquisition/fetch-metadata.ts} +0 -0
- /package/src/{focus.ts → document/focus.ts} +0 -0
- /package/src/{fragment-index.ts → document/fragment-index.ts} +0 -0
- /package/src/{markdown-cleanup.ts → document/markdown-cleanup.ts} +0 -0
- /package/src/{outline.ts → document/outline.ts} +0 -0
- /package/src/{network-policy.ts → network/network-policy.ts} +0 -0
|
@@ -0,0 +1,555 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import type { IncomingMessage } from "node:http";
|
|
3
|
+
import { Worker } from "node:worker_threads";
|
|
4
|
+
import { parseHTML } from "linkedom";
|
|
5
|
+
import {
|
|
6
|
+
requestFollowingRedirects,
|
|
7
|
+
type RedirectDependencies,
|
|
8
|
+
} from "../network/network-redirects.ts";
|
|
9
|
+
import type { ValidatedTarget } from "../network/network-policy.ts";
|
|
10
|
+
import {
|
|
11
|
+
decompressResponse,
|
|
12
|
+
readResponseBytes,
|
|
13
|
+
responseHeader,
|
|
14
|
+
} from "../network/network-transport.ts";
|
|
15
|
+
import {
|
|
16
|
+
ANUBIS_MAX_RANDOM_DATA_BYTES,
|
|
17
|
+
ANUBIS_MAX_WASM_BYTES,
|
|
18
|
+
type AnubisWorkerRequest,
|
|
19
|
+
type AnubisSolution,
|
|
20
|
+
} from "./anubis-solver.ts";
|
|
21
|
+
|
|
22
|
+
/** Matches the script markers every Anubis challenge, denial, and error page carries. */
|
|
23
|
+
const ANUBIS_SCRIPT_MARKER = /id=["']anubis_(?:challenge|version)["']/i;
|
|
24
|
+
|
|
25
|
+
/** In-process methods solved by searching a SHA-256 nonce. */
|
|
26
|
+
const SHA256_METHODS = new Set(["fast", "slow"]);
|
|
27
|
+
|
|
28
|
+
/** In-process methods solved by a server-provided WebAssembly module. */
|
|
29
|
+
const WASM_METHODS = new Set(["sha256", "hashx", "argon2id"]);
|
|
30
|
+
|
|
31
|
+
/** Challenge extensions this client performs; anything else is unsupported. */
|
|
32
|
+
const SUPPORTED_EXTENSIONS = new Set(["css-load"]);
|
|
33
|
+
|
|
34
|
+
/** Longest base prefix accepted from a challenge page. */
|
|
35
|
+
const MAX_BASE_PREFIX_CHARACTERS = 512;
|
|
36
|
+
|
|
37
|
+
/** Longest challenge identifier accepted from a challenge page. */
|
|
38
|
+
const MAX_CHALLENGE_ID_CHARACTERS = 256;
|
|
39
|
+
|
|
40
|
+
/** Longest Anubis version string accepted from a challenge page. */
|
|
41
|
+
const MAX_VERSION_CHARACTERS = 128;
|
|
42
|
+
|
|
43
|
+
/** Maximum number of extensions accepted from a challenge page. */
|
|
44
|
+
const MAX_EXTENSIONS = 8;
|
|
45
|
+
|
|
46
|
+
/** Largest passive-method wait, bounding hostile difficulty values. */
|
|
47
|
+
const MAX_PASSIVE_DELAY_MS = 15_000;
|
|
48
|
+
|
|
49
|
+
/** Hard stop for a worker solve; the caller's timeout normally fires first. */
|
|
50
|
+
const WORKER_DEADLINE_MS = 60_000;
|
|
51
|
+
|
|
52
|
+
/** Maximum number of extension stylesheets fetched before a pass attempt. */
|
|
53
|
+
const MAX_CSS_ASSETS = 4;
|
|
54
|
+
|
|
55
|
+
/** Maximum size of one extension stylesheet response. */
|
|
56
|
+
const MAX_CSS_ASSET_BYTES = 64 * 1024;
|
|
57
|
+
|
|
58
|
+
/** A JSON value decoded from an Anubis script tag. */
|
|
59
|
+
type AnubisScriptValue =
|
|
60
|
+
| string
|
|
61
|
+
| number
|
|
62
|
+
| boolean
|
|
63
|
+
| null
|
|
64
|
+
| AnubisScriptValue[]
|
|
65
|
+
| AnubisScriptObject;
|
|
66
|
+
|
|
67
|
+
/** A JSON object decoded from an Anubis script tag. */
|
|
68
|
+
interface AnubisScriptObject {
|
|
69
|
+
[key: string]: AnubisScriptValue;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Pass-challenge query parameters, limited to the fields this client sends. */
|
|
73
|
+
interface AnubisPassParameters {
|
|
74
|
+
response?: string;
|
|
75
|
+
nonce?: string;
|
|
76
|
+
result?: string;
|
|
77
|
+
challenge?: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** A parsed, bounded Anubis challenge that this client can attempt to solve. */
|
|
81
|
+
export interface AnubisChallenge {
|
|
82
|
+
id: string;
|
|
83
|
+
method: string;
|
|
84
|
+
randomData: string;
|
|
85
|
+
difficulty: number;
|
|
86
|
+
version: string;
|
|
87
|
+
basePrefix: string;
|
|
88
|
+
extensions: string[];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The result of a successful Anubis pass: the response for the original page. */
|
|
92
|
+
interface AnubisPassResult {
|
|
93
|
+
target: ValidatedTarget;
|
|
94
|
+
response: IncomingMessage;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Context required to solve one challenge and continue to the requested page. */
|
|
98
|
+
export interface AnubisSolveContext {
|
|
99
|
+
challenge: AnubisChallenge;
|
|
100
|
+
pageUrl: URL;
|
|
101
|
+
raw: string;
|
|
102
|
+
dependencies: RedirectDependencies;
|
|
103
|
+
signal: AbortSignal;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Creates the consistent abort error used by redirect and retry coordination. */
|
|
107
|
+
function abortError(): Error {
|
|
108
|
+
const error = new Error("Operation aborted.");
|
|
109
|
+
error.name = "AbortError";
|
|
110
|
+
|
|
111
|
+
return error;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Returns whether HTML contains Anubis challenge or version script markers. */
|
|
115
|
+
export function hasAnubisMarkers(raw: string): boolean {
|
|
116
|
+
return ANUBIS_SCRIPT_MARKER.test(raw);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Narrows a decoded JSON value to a string. */
|
|
120
|
+
function isString(value: AnubisScriptValue | undefined): value is string {
|
|
121
|
+
return typeof value === "string";
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Narrows a decoded JSON value to a finite number. */
|
|
125
|
+
function isNumber(value: AnubisScriptValue | undefined): value is number {
|
|
126
|
+
return typeof value === "number" && Number.isFinite(value);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Narrows a decoded JSON value to an object. */
|
|
130
|
+
function isScriptObject(value: AnubisScriptValue | undefined): value is AnubisScriptObject {
|
|
131
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Narrows a decoded JSON value to an array of strings. */
|
|
135
|
+
function isStringArray(value: AnubisScriptValue | undefined): value is string[] {
|
|
136
|
+
return Array.isArray(value) && value.every(isString);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Returns whether a string contains control characters unsafe for header or URL building. */
|
|
140
|
+
function hasControlCharacters(value: string): boolean {
|
|
141
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
142
|
+
const code = value.charCodeAt(index);
|
|
143
|
+
|
|
144
|
+
if (code <= 0x1f || code === 0x7f) return true;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Sanitizes a challenge base prefix into a leading path that stays on the
|
|
152
|
+
* challenge origin and cannot escape it through traversal or encoded segments.
|
|
153
|
+
*/
|
|
154
|
+
function sanitizeBasePrefix(value: AnubisScriptValue | undefined): string | undefined {
|
|
155
|
+
if (value === undefined) return "";
|
|
156
|
+
|
|
157
|
+
if (!isString(value) || value.length > MAX_BASE_PREFIX_CHARACTERS) return undefined;
|
|
158
|
+
|
|
159
|
+
if (value === "") return "";
|
|
160
|
+
|
|
161
|
+
if (!value.startsWith("/") || value.startsWith("//")) return undefined;
|
|
162
|
+
|
|
163
|
+
if (value.includes("\\") || value.includes("?") || value.includes("#") || value.includes("%"))
|
|
164
|
+
return undefined;
|
|
165
|
+
|
|
166
|
+
if (hasControlCharacters(value)) return undefined;
|
|
167
|
+
|
|
168
|
+
const segments = value.split("/").filter(Boolean);
|
|
169
|
+
|
|
170
|
+
if (segments.some((segment) => segment === "." || segment === "..")) return undefined;
|
|
171
|
+
|
|
172
|
+
return `/${segments.join("/")}`;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Decodes one JSON script tag by id, returning undefined when absent or malformed. */
|
|
176
|
+
function readJsonScript(
|
|
177
|
+
document: ReturnType<typeof parseHTML>["document"],
|
|
178
|
+
id: string,
|
|
179
|
+
): AnubisScriptValue | undefined {
|
|
180
|
+
const element = document.getElementById(id);
|
|
181
|
+
const text = element?.textContent;
|
|
182
|
+
|
|
183
|
+
if (!text) return undefined;
|
|
184
|
+
|
|
185
|
+
try {
|
|
186
|
+
return JSON.parse(text);
|
|
187
|
+
} catch {
|
|
188
|
+
return undefined;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Returns whether a PublicUrl script names a different origin than the challenge page. */
|
|
193
|
+
function isCrossOriginPublicUrl(
|
|
194
|
+
document: ReturnType<typeof parseHTML>["document"],
|
|
195
|
+
pageUrl: URL,
|
|
196
|
+
): boolean {
|
|
197
|
+
const value = readJsonScript(document, "anubis_public_url");
|
|
198
|
+
|
|
199
|
+
if (!isString(value) || value.length === 0) return false;
|
|
200
|
+
|
|
201
|
+
try {
|
|
202
|
+
return new URL(value).origin !== pageUrl.origin;
|
|
203
|
+
} catch {
|
|
204
|
+
// A malformed PublicUrl is ignored; the pass URL is always built from the page origin.
|
|
205
|
+
return false;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Parses an Anubis challenge page into a solvable challenge.
|
|
211
|
+
*
|
|
212
|
+
* Returns undefined when the page is not an Anubis interstitial, when the
|
|
213
|
+
* challenge payload is malformed, when required metadata is missing, or when
|
|
214
|
+
* `anubis_public_url` names a different origin than the page that served it.
|
|
215
|
+
*
|
|
216
|
+
* @param raw - The decoded HTML of the candidate page.
|
|
217
|
+
* @param pageUrl - The final URL that returned the page.
|
|
218
|
+
* @returns A validated challenge, or undefined when the page is not solvable.
|
|
219
|
+
*/
|
|
220
|
+
export function parseAnubisChallenge(raw: string, pageUrl: URL): AnubisChallenge | undefined {
|
|
221
|
+
if (!hasAnubisMarkers(raw)) return undefined;
|
|
222
|
+
|
|
223
|
+
const { document } = parseHTML(raw);
|
|
224
|
+
const version = readJsonScript(document, "anubis_version");
|
|
225
|
+
const challengePayload = readJsonScript(document, "anubis_challenge");
|
|
226
|
+
|
|
227
|
+
if (!isString(version) || version.length === 0 || version.length > MAX_VERSION_CHARACTERS)
|
|
228
|
+
return undefined;
|
|
229
|
+
|
|
230
|
+
if (isCrossOriginPublicUrl(document, pageUrl)) return undefined;
|
|
231
|
+
|
|
232
|
+
if (!isScriptObject(challengePayload)) return undefined;
|
|
233
|
+
|
|
234
|
+
const basePrefix = sanitizeBasePrefix(readJsonScript(document, "anubis_base_prefix"));
|
|
235
|
+
|
|
236
|
+
if (basePrefix === undefined) return undefined;
|
|
237
|
+
|
|
238
|
+
const challenge = challengePayload.challenge;
|
|
239
|
+
|
|
240
|
+
if (!isScriptObject(challenge)) return undefined;
|
|
241
|
+
|
|
242
|
+
const rulesValue = challengePayload.rules;
|
|
243
|
+
const rules = isScriptObject(rulesValue) ? rulesValue : undefined;
|
|
244
|
+
const id = challenge.id;
|
|
245
|
+
const rawMethod = challenge.method;
|
|
246
|
+
const ruleMethod = rules?.algorithm;
|
|
247
|
+
|
|
248
|
+
const method =
|
|
249
|
+
isString(rawMethod) && rawMethod.length > 0
|
|
250
|
+
? rawMethod
|
|
251
|
+
: isString(ruleMethod)
|
|
252
|
+
? ruleMethod
|
|
253
|
+
: undefined;
|
|
254
|
+
|
|
255
|
+
const difficulty = isNumber(challenge.difficulty) ? challenge.difficulty : rules?.difficulty;
|
|
256
|
+
const randomData = challenge.randomData;
|
|
257
|
+
const rawExtensions = challenge.extensions;
|
|
258
|
+
|
|
259
|
+
if (!isString(id) || id.length === 0 || id.length > MAX_CHALLENGE_ID_CHARACTERS) return undefined;
|
|
260
|
+
|
|
261
|
+
if (hasControlCharacters(id)) return undefined;
|
|
262
|
+
|
|
263
|
+
if (!isString(method) || method.length === 0 || method.length > MAX_CHALLENGE_ID_CHARACTERS)
|
|
264
|
+
return undefined;
|
|
265
|
+
|
|
266
|
+
if (hasControlCharacters(method)) return undefined;
|
|
267
|
+
|
|
268
|
+
if (
|
|
269
|
+
!isNumber(difficulty) ||
|
|
270
|
+
!Number.isInteger(difficulty) ||
|
|
271
|
+
difficulty < 0 ||
|
|
272
|
+
difficulty > 0xffff_ffff
|
|
273
|
+
) {
|
|
274
|
+
return undefined;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (
|
|
278
|
+
!isString(randomData) ||
|
|
279
|
+
randomData.length === 0 ||
|
|
280
|
+
randomData.length > ANUBIS_MAX_RANDOM_DATA_BYTES * 2 ||
|
|
281
|
+
hasControlCharacters(randomData)
|
|
282
|
+
) {
|
|
283
|
+
return undefined;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const extensions = rawExtensions === undefined ? [] : rawExtensions;
|
|
287
|
+
|
|
288
|
+
if (!isStringArray(extensions) || extensions.length > MAX_EXTENSIONS) return undefined;
|
|
289
|
+
|
|
290
|
+
if (extensions.some((extension) => extension.length === 0 || extension.length > 64))
|
|
291
|
+
return undefined;
|
|
292
|
+
|
|
293
|
+
return { id, method, randomData, difficulty, version, basePrefix, extensions };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** Returns whether every extension declared by a challenge is supported. */
|
|
297
|
+
function hasSupportedExtensions(challenge: AnubisChallenge): boolean {
|
|
298
|
+
return challenge.extensions.every((extension) => SUPPORTED_EXTENSIONS.has(extension));
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** Hashes a string with SHA-256, returning lowercase hexadecimal. */
|
|
302
|
+
function sha256Hex(value: string): string {
|
|
303
|
+
return createHash("sha256").update(value).digest("hex");
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** Waits for a bounded delay while remaining cancellable. */
|
|
307
|
+
function delay(milliseconds: number, signal: AbortSignal): Promise<void> {
|
|
308
|
+
return new Promise((resolve, reject) => {
|
|
309
|
+
const timer = setTimeout(done, milliseconds);
|
|
310
|
+
|
|
311
|
+
function done(): void {
|
|
312
|
+
signal.removeEventListener("abort", cancel);
|
|
313
|
+
resolve();
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function cancel(): void {
|
|
317
|
+
clearTimeout(timer);
|
|
318
|
+
reject(abortError());
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if (signal.aborted) cancel();
|
|
322
|
+
else signal.addEventListener("abort", cancel, { once: true });
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** Computes the passive wait for `preact` and `metarefresh`, bounded against hostile input. */
|
|
327
|
+
function passiveDelay(difficulty: number, perUnitMs: number, marginMs: number): number {
|
|
328
|
+
return Math.min(difficulty * perUnitMs + marginMs, MAX_PASSIVE_DELAY_MS);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** A message posted from the solve worker back to the parent. */
|
|
332
|
+
type AnubisWorkerMessage =
|
|
333
|
+
| { kind: "solved"; solution: AnubisSolution }
|
|
334
|
+
| { kind: "error"; message: string };
|
|
335
|
+
|
|
336
|
+
/** Runs one solve request in a sandboxed worker thread, terminating it on abort. */
|
|
337
|
+
function solveInWorker(request: AnubisWorkerRequest, signal: AbortSignal): Promise<AnubisSolution> {
|
|
338
|
+
const worker = new Worker(new URL("../../workers/anubis-worker.js", import.meta.url), {
|
|
339
|
+
workerData: request,
|
|
340
|
+
resourceLimits: { maxOldGenerationSizeMb: 64, maxYoungGenerationSizeMb: 16 },
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
return new Promise<AnubisSolution>((resolve, reject) => {
|
|
344
|
+
let settled = false;
|
|
345
|
+
|
|
346
|
+
const cleanup = (): void => {
|
|
347
|
+
signal.removeEventListener("abort", onAbort);
|
|
348
|
+
worker.removeAllListeners();
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
const finish = (callback: () => void): void => {
|
|
352
|
+
if (settled) return;
|
|
353
|
+
settled = true;
|
|
354
|
+
cleanup();
|
|
355
|
+
callback();
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
const onAbort = (): void => {
|
|
359
|
+
void worker.terminate();
|
|
360
|
+
finish(() => reject(abortError()));
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
worker.on("message", (message: AnubisWorkerMessage) => {
|
|
364
|
+
if (message.kind === "solved") finish(() => resolve(message.solution));
|
|
365
|
+
else finish(() => reject(new Error(message.message)));
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
worker.on("error", (error) => finish(() => reject(error)));
|
|
369
|
+
worker.on("exit", (code) => {
|
|
370
|
+
if (code !== 0)
|
|
371
|
+
finish(() => reject(new Error(`Anubis solve worker exited with code ${code}.`)));
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
if (signal.aborted) onAbort();
|
|
375
|
+
else signal.addEventListener("abort", onAbort, { once: true });
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** Fetches the WebAssembly module for a `sha256`, `hashx`, or `argon2id` challenge. */
|
|
380
|
+
async function fetchWasmModule(
|
|
381
|
+
method: string,
|
|
382
|
+
challenge: AnubisChallenge,
|
|
383
|
+
pageUrl: URL,
|
|
384
|
+
dependencies: RedirectDependencies,
|
|
385
|
+
signal: AbortSignal,
|
|
386
|
+
): Promise<Uint8Array> {
|
|
387
|
+
const asset = new URL(
|
|
388
|
+
`${challenge.basePrefix}/.within.website/x/cmd/anubis/static/wasm/baseline/${method}.wasm`,
|
|
389
|
+
pageUrl.origin,
|
|
390
|
+
);
|
|
391
|
+
|
|
392
|
+
asset.searchParams.set("cacheBuster", challenge.version);
|
|
393
|
+
const { response } = await requestFollowingRedirects(asset, signal, dependencies);
|
|
394
|
+
const status = response.statusCode ?? 0;
|
|
395
|
+
|
|
396
|
+
if (status < 200 || status >= 300) {
|
|
397
|
+
response.resume();
|
|
398
|
+
throw new Error(`web_fetch could not download the Anubis challenge module (HTTP ${status}).`);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const bytes = await readResponseBytes(response, ANUBIS_MAX_WASM_BYTES, signal);
|
|
402
|
+
const decoded = decompressResponse(bytes, responseHeader(response, "content-encoding"));
|
|
403
|
+
|
|
404
|
+
if (decoded.byteLength === 0 || decoded.byteLength > ANUBIS_MAX_WASM_BYTES)
|
|
405
|
+
throw new Error("Anubis challenge module exceeded the size limit.");
|
|
406
|
+
|
|
407
|
+
return decoded;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** Extracts same-origin stylesheet URLs injected by the `css-load` extension. */
|
|
411
|
+
function cssLoadUrls(raw: string, pageUrl: URL): URL[] {
|
|
412
|
+
const { document } = parseHTML(raw);
|
|
413
|
+
const urls: URL[] = [];
|
|
414
|
+
const seen = new Set<string>();
|
|
415
|
+
|
|
416
|
+
const consider = (candidate: string | null | undefined): void => {
|
|
417
|
+
if (!candidate || urls.length >= MAX_CSS_ASSETS) return;
|
|
418
|
+
let url: URL;
|
|
419
|
+
|
|
420
|
+
try {
|
|
421
|
+
url = new URL(candidate, pageUrl);
|
|
422
|
+
} catch {
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
if (!["http:", "https:"].includes(url.protocol)) return;
|
|
427
|
+
|
|
428
|
+
if (url.username || url.password) return;
|
|
429
|
+
|
|
430
|
+
if (url.origin !== pageUrl.origin) return;
|
|
431
|
+
|
|
432
|
+
if (seen.has(url.href)) return;
|
|
433
|
+
|
|
434
|
+
seen.add(url.href);
|
|
435
|
+
urls.push(url);
|
|
436
|
+
};
|
|
437
|
+
|
|
438
|
+
for (const element of document.querySelectorAll("style")) {
|
|
439
|
+
const css = element.textContent ?? "";
|
|
440
|
+
const matches = css.matchAll(/@import\s+(?:url\(\s*)?["']?([^"')\s;]+)/gi);
|
|
441
|
+
|
|
442
|
+
for (const match of matches) consider(match[1]);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
for (const element of document.querySelectorAll("link[rel=stylesheet][href]"))
|
|
446
|
+
consider(element.getAttribute("href"));
|
|
447
|
+
|
|
448
|
+
return urls;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** Performs the `css-load` extension requests so the server records the verification handshake. */
|
|
452
|
+
async function performCssLoad(
|
|
453
|
+
raw: string,
|
|
454
|
+
pageUrl: URL,
|
|
455
|
+
dependencies: RedirectDependencies,
|
|
456
|
+
signal: AbortSignal,
|
|
457
|
+
): Promise<void> {
|
|
458
|
+
const urls = cssLoadUrls(raw, pageUrl);
|
|
459
|
+
|
|
460
|
+
await Promise.all(
|
|
461
|
+
urls.map(async (url) => {
|
|
462
|
+
try {
|
|
463
|
+
const { response } = await requestFollowingRedirects(url, signal, dependencies);
|
|
464
|
+
|
|
465
|
+
await readResponseBytes(response, MAX_CSS_ASSET_BYTES, signal);
|
|
466
|
+
} catch (error) {
|
|
467
|
+
if (signal.aborted) throw error;
|
|
468
|
+
}
|
|
469
|
+
}),
|
|
470
|
+
);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Solves an Anubis challenge and returns the response for the originally
|
|
475
|
+
* requested page.
|
|
476
|
+
*
|
|
477
|
+
* Every challenge request travels through the same validation, DNS pinning,
|
|
478
|
+
* redirect, and concurrency path as a normal fetch, and uses only the cookie jar
|
|
479
|
+
* attached to the current acquisition.
|
|
480
|
+
*
|
|
481
|
+
* @param context - The parsed challenge, page URL, dependencies, and abort signal.
|
|
482
|
+
* @returns The final target and response after a successful pass.
|
|
483
|
+
* @throws When the method or an extension is unsupported, the method's solve fails, or the signal aborts.
|
|
484
|
+
*/
|
|
485
|
+
export async function solveAnubisChallenge(context: AnubisSolveContext): Promise<AnubisPassResult> {
|
|
486
|
+
const { challenge, pageUrl, raw, dependencies, signal } = context;
|
|
487
|
+
const started = Date.now();
|
|
488
|
+
|
|
489
|
+
if (!hasSupportedExtensions(challenge))
|
|
490
|
+
throw new Error("web_fetch does not support the extensions required by this Anubis challenge.");
|
|
491
|
+
|
|
492
|
+
if (challenge.extensions.includes("css-load"))
|
|
493
|
+
await performCssLoad(raw, pageUrl, dependencies, signal);
|
|
494
|
+
|
|
495
|
+
const deadline = Date.now() + WORKER_DEADLINE_MS;
|
|
496
|
+
let params: AnubisPassParameters;
|
|
497
|
+
|
|
498
|
+
if (SHA256_METHODS.has(challenge.method)) {
|
|
499
|
+
const solution = await solveInWorker(
|
|
500
|
+
{
|
|
501
|
+
kind: "sha256",
|
|
502
|
+
randomData: challenge.randomData,
|
|
503
|
+
difficulty: challenge.difficulty,
|
|
504
|
+
deadline,
|
|
505
|
+
},
|
|
506
|
+
signal,
|
|
507
|
+
);
|
|
508
|
+
|
|
509
|
+
params = { response: solution.hash, nonce: String(solution.nonce) };
|
|
510
|
+
} else if (WASM_METHODS.has(challenge.method)) {
|
|
511
|
+
const moduleBytes = await fetchWasmModule(
|
|
512
|
+
challenge.method,
|
|
513
|
+
challenge,
|
|
514
|
+
pageUrl,
|
|
515
|
+
dependencies,
|
|
516
|
+
signal,
|
|
517
|
+
);
|
|
518
|
+
|
|
519
|
+
const solution = await solveInWorker(
|
|
520
|
+
{
|
|
521
|
+
kind: "wasm",
|
|
522
|
+
moduleBytes,
|
|
523
|
+
randomData: challenge.randomData,
|
|
524
|
+
difficulty: challenge.difficulty,
|
|
525
|
+
deadline,
|
|
526
|
+
},
|
|
527
|
+
signal,
|
|
528
|
+
);
|
|
529
|
+
|
|
530
|
+
params = { response: solution.hash, nonce: String(solution.nonce) };
|
|
531
|
+
} else if (challenge.method === "preact") {
|
|
532
|
+
await delay(passiveDelay(challenge.difficulty, 125, 100), signal);
|
|
533
|
+
params = { result: sha256Hex(challenge.randomData) };
|
|
534
|
+
} else if (challenge.method === "metarefresh") {
|
|
535
|
+
await delay(passiveDelay(challenge.difficulty, 800, 250), signal);
|
|
536
|
+
params = { challenge: challenge.randomData };
|
|
537
|
+
} else {
|
|
538
|
+
throw new Error(
|
|
539
|
+
`web_fetch does not support the Anubis "${challenge.method}" challenge method.`,
|
|
540
|
+
);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
const passUrl = new URL(
|
|
544
|
+
`${challenge.basePrefix}/.within.website/x/cmd/anubis/api/pass-challenge`,
|
|
545
|
+
pageUrl.origin,
|
|
546
|
+
);
|
|
547
|
+
|
|
548
|
+
passUrl.searchParams.set("id", challenge.id);
|
|
549
|
+
passUrl.searchParams.set("redir", pageUrl.href);
|
|
550
|
+
passUrl.searchParams.set("elapsedTime", String(Math.max(1, Date.now() - started)));
|
|
551
|
+
|
|
552
|
+
for (const [key, value] of Object.entries(params)) passUrl.searchParams.set(key, value);
|
|
553
|
+
|
|
554
|
+
return requestFollowingRedirects(passUrl, signal, dependencies);
|
|
555
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import type { ExtractedLinks, ExtractionDiagnostics } from "./evidence";
|
|
2
|
+
import type { ExtractedLinks, ExtractionDiagnostics } from "./evidence.ts";
|
|
3
3
|
|
|
4
4
|
const CONTENT_LINE_BUDGET = Math.max(1, DEFAULT_MAX_LINES - 10);
|
|
5
5
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { parseHTML } from "linkedom";
|
|
2
|
+
import { hasAnubisMarkers } from "../challenges/anubis.ts";
|
|
2
3
|
|
|
3
4
|
const MAX_LINKS = 16;
|
|
4
5
|
|
|
@@ -28,7 +29,11 @@ export interface ExtractedLinks {
|
|
|
28
29
|
omittedExternal: number;
|
|
29
30
|
}
|
|
30
31
|
|
|
31
|
-
const JAVASCRIPT_MARKERS = [
|
|
32
|
+
const JAVASCRIPT_MARKERS = [
|
|
33
|
+
/(?:please|you\s+(?:need|must)\s+to)\s+enable\s+javascript/i,
|
|
34
|
+
/enable\s+javascript\s+to\s+(?:continue|run\s+this\s+app)/i,
|
|
35
|
+
/javascript\s+(?:is\s+)?required/i,
|
|
36
|
+
];
|
|
32
37
|
|
|
33
38
|
const BOT_MARKERS = [
|
|
34
39
|
/are\s+you\s+a\s+robot/i,
|
|
@@ -37,6 +42,25 @@ const BOT_MARKERS = [
|
|
|
37
42
|
/<title>\s*just\s+a\s+moment/i,
|
|
38
43
|
];
|
|
39
44
|
|
|
45
|
+
// CAPTCHA interstitials (Cap, reCAPTCHA, hCaptcha, Turnstile) are JavaScript-only
|
|
46
|
+
// challenge shells that carry no readable text. The loader/class names below appear
|
|
47
|
+
// only on challenge shells; generic widget markers additionally require an empty
|
|
48
|
+
// extraction so a content-rich page that merely embeds a CAPTCHA is not misreported.
|
|
49
|
+
const CAPTCHA_LOADER_MARKERS = [
|
|
50
|
+
/\bcap-loader\.js\b/i,
|
|
51
|
+
/\bcap-captcha\b/i,
|
|
52
|
+
/\bid=["']captcha-form["']/i,
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
const CAPTCHA_WIDGET_MARKERS = [
|
|
56
|
+
/\bid=["']captcha["']/i,
|
|
57
|
+
/\bclass=["'][^"']*\bg-recaptcha\b/i,
|
|
58
|
+
/\bclass=["'][^"']*\bh-captcha\b/i,
|
|
59
|
+
/\bclass=["'][^"']*\bcf-turnstile\b/i,
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
const CAPTCHA_CHALLENGE_MAX_MARKDOWN_CHARACTERS = 200;
|
|
63
|
+
|
|
40
64
|
const ANUBIS_MARKER = /\banubis\b/i;
|
|
41
65
|
|
|
42
66
|
const PROOF_OF_WORK_MARKER = /proof[\s-]+of[\s-]+work/i;
|
|
@@ -59,10 +83,17 @@ export function diagnoseExtraction(raw: string, markdown: string): ExtractionDia
|
|
|
59
83
|
const { document } = parseHTML(raw);
|
|
60
84
|
const anubisText = [...document.childNodes].map((node) => node.textContent ?? "").join(" ");
|
|
61
85
|
|
|
86
|
+
const captchaChallenge =
|
|
87
|
+
CAPTCHA_LOADER_MARKERS.some((marker) => marker.test(raw)) ||
|
|
88
|
+
(markdown.trim().length < CAPTCHA_CHALLENGE_MAX_MARKDOWN_CHARACTERS &&
|
|
89
|
+
CAPTCHA_WIDGET_MARKERS.some((marker) => marker.test(raw)));
|
|
90
|
+
|
|
62
91
|
return {
|
|
63
92
|
javascriptRequired: JAVASCRIPT_MARKERS.some((marker) => marker.test(raw)),
|
|
64
93
|
botWall:
|
|
94
|
+
hasAnubisMarkers(raw) ||
|
|
65
95
|
BOT_MARKERS.some((marker) => marker.test(raw)) ||
|
|
96
|
+
captchaChallenge ||
|
|
66
97
|
(ANUBIS_MARKER.test(anubisText) &&
|
|
67
98
|
ANUBIS_PROTECTION_MARKERS.some((marker) => marker.test(anubisText)) &&
|
|
68
99
|
PROOF_OF_WORK_MARKER.test(anubisText)),
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { parseHTML } from "linkedom";
|
|
2
2
|
import type { DefuddleResponse } from "defuddle/node";
|
|
3
|
-
import { stripExtractedCssCruft } from "./markdown-cleanup";
|
|
4
|
-
import { buildFragmentOffsets } from "./fragment-index";
|
|
3
|
+
import { stripExtractedCssCruft } from "./markdown-cleanup.ts";
|
|
4
|
+
import { buildFragmentOffsets } from "./fragment-index.ts";
|
|
5
5
|
|
|
6
|
-
export { stripExtractedCssCruft } from "./markdown-cleanup";
|
|
6
|
+
export { stripExtractedCssCruft } from "./markdown-cleanup.ts";
|
|
7
7
|
|
|
8
8
|
const RAW_ID_SELECTOR_SAFE = /^-?[_a-zA-Z][-_a-zA-Z0-9]*$/;
|
|
9
9
|
|
|
@@ -69,6 +69,54 @@ function normalizeSelectorUnsafeIds(document: Document): void {
|
|
|
69
69
|
}
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
/** Head-level elements that carry metadata rather than readable body content. */
|
|
73
|
+
const HEAD_TAGS = new Set(["TITLE", "BASE", "LINK", "META", "STYLE"]);
|
|
74
|
+
|
|
75
|
+
function isElementNode(node: ChildNode): node is Element {
|
|
76
|
+
return node.nodeType === 1;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* linkedom parses HTML that omits `<html>`/`<head>`/`<body>` as a fragment: the first
|
|
81
|
+
* element becomes the document root and `document.body` is left detached and empty.
|
|
82
|
+
* HTML5 permits those omissions, but body-based extraction (Defuddle and the plain-text
|
|
83
|
+
* fallback) then sees nothing. Rebuild a well-formed `<html>` document, moving the
|
|
84
|
+
* fragment's top-level nodes into `<head>` or `<body>` as appropriate.
|
|
85
|
+
*/
|
|
86
|
+
function normalizeFragmentDocument(document: Document): void {
|
|
87
|
+
const root = document.documentElement;
|
|
88
|
+
|
|
89
|
+
if (!root || root.tagName === "HTML") return;
|
|
90
|
+
|
|
91
|
+
const html = document.createElement("html");
|
|
92
|
+
const head = document.createElement("head");
|
|
93
|
+
const body = document.createElement("body");
|
|
94
|
+
html.appendChild(head);
|
|
95
|
+
html.appendChild(body);
|
|
96
|
+
|
|
97
|
+
const nodes = root.tagName === "BODY" ? [...root.childNodes] : [...document.childNodes];
|
|
98
|
+
|
|
99
|
+
for (const node of nodes) {
|
|
100
|
+
if (!isElementNode(node)) continue;
|
|
101
|
+
const tagName = node.tagName;
|
|
102
|
+
|
|
103
|
+
if (tagName === "HTML") continue;
|
|
104
|
+
|
|
105
|
+
if (tagName === "HEAD" || tagName === "BODY") {
|
|
106
|
+
const destination = tagName === "HEAD" ? head : body;
|
|
107
|
+
|
|
108
|
+
while (node.firstChild) destination.appendChild(node.firstChild);
|
|
109
|
+
|
|
110
|
+
node.remove();
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
(HEAD_TAGS.has(tagName) ? head : body).appendChild(node);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
document.insertBefore(html, document.firstChild);
|
|
118
|
+
}
|
|
119
|
+
|
|
72
120
|
/**
|
|
73
121
|
* Extracts normalized plain text from HTML when structured Markdown extraction is unavailable.
|
|
74
122
|
*
|
|
@@ -77,6 +125,7 @@ function normalizeSelectorUnsafeIds(document: Document): void {
|
|
|
77
125
|
*/
|
|
78
126
|
export function htmlToMarkdownFallback(html: string): string {
|
|
79
127
|
const { document } = parseHTML(html);
|
|
128
|
+
normalizeFragmentDocument(document);
|
|
80
129
|
|
|
81
130
|
for (const element of document.querySelectorAll(
|
|
82
131
|
"script, style, svg, noscript, template, iframe, nav, header, footer, aside, form",
|
|
@@ -352,6 +401,7 @@ export async function extractHtmlToMarkdown(
|
|
|
352
401
|
try {
|
|
353
402
|
assertAbsoluteHttpUrl(baseUrl);
|
|
354
403
|
const { document } = parseHTML(html);
|
|
404
|
+
normalizeFragmentDocument(document);
|
|
355
405
|
// Defuddle parses URL metadata without using its explicit page URL as the
|
|
356
406
|
// base. Resolve relative values first so it does not log ERR_INVALID_URL
|
|
357
407
|
// through Pi's TUI even though extraction succeeds.
|