@zeldrisho/pi-web-fetch 0.14.1 → 0.16.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 +28 -1
- package/README.md +3 -5
- package/package.json +5 -2
- package/src/acquisition/fetch.ts +167 -12
- package/src/acquisition/llms.ts +42 -11
- package/src/challenges/aws-waf-resolver.ts +806 -0
- package/src/challenges/aws-waf-solver.ts +65 -0
- package/src/challenges/aws-waf-worker.ts +14 -0
- package/src/challenges/aws-waf.ts +34 -0
- package/src/document/content.ts +4 -0
- package/src/document/focus.ts +117 -11
- package/src/document/openapi.ts +80 -0
- package/src/document/source-format.ts +180 -0
- package/src/index.ts +7 -0
- package/src/network/cookies.ts +41 -0
- package/src/network/network-redirects.ts +15 -2
- package/src/network/network-transport.ts +21 -3
- package/src/service.ts +23 -5
- package/workers/aws-waf-solver.js +56 -0
- package/workers/aws-waf-worker.js +14 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { createHash, scryptSync } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
/** Bounded proof-of-work request sent to the AWS WAF worker. */
|
|
4
|
+
export interface AwsWafWork {
|
|
5
|
+
type: string;
|
|
6
|
+
input: string;
|
|
7
|
+
checksum: string;
|
|
8
|
+
difficulty: number;
|
|
9
|
+
memory: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Returns whether the first `difficulty` bits of a hash are zero. */
|
|
13
|
+
export function hasLeadingZeroBits(hash: Uint8Array, difficulty: number): boolean {
|
|
14
|
+
for (let bit = 0; bit < difficulty; bit += 1) {
|
|
15
|
+
const byte = hash[Math.floor(bit / 8)];
|
|
16
|
+
|
|
17
|
+
if (byte === undefined || (byte & (1 << (7 - (bit % 8)))) !== 0) return false;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Solves the bandwidth challenge using a bounded zero-filled buffer. */
|
|
24
|
+
export function solveAwsWafBandwidth(difficulty: number): string {
|
|
25
|
+
const sizes = [1_024, 1_024, 10_240, 102_400, 1_048_576, 10_485_760];
|
|
26
|
+
|
|
27
|
+
return Buffer.alloc(sizes[Math.min(5, Math.max(0, difficulty))] ?? 1_024).toString("base64");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Computes bounded proof-of-work inside a disposable worker. */
|
|
31
|
+
export function solveAwsWafWork(work: AwsWafWork): string | undefined {
|
|
32
|
+
if (!Number.isInteger(work.difficulty) || work.difficulty < 1 || work.difficulty > 32)
|
|
33
|
+
return undefined;
|
|
34
|
+
|
|
35
|
+
if (work.type === "NetworkBandwidth") return solveAwsWafBandwidth(work.difficulty);
|
|
36
|
+
|
|
37
|
+
if (work.type === "SHA256") {
|
|
38
|
+
for (let nonce = 0; nonce < 2_000_000; nonce += 1) {
|
|
39
|
+
const digest = createHash("sha256").update(`${work.input}${work.checksum}${nonce}`).digest();
|
|
40
|
+
|
|
41
|
+
if (hasLeadingZeroBits(digest, work.difficulty)) return String(nonce);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (work.type === "HashcashScrypt") {
|
|
46
|
+
const cost = work.memory > 0 ? work.memory : 128;
|
|
47
|
+
|
|
48
|
+
// Reject large or invalid costs before invoking OpenSSL.
|
|
49
|
+
if (!Number.isInteger(cost) || cost < 2 || cost > 16_384 || (cost & (cost - 1)) !== 0)
|
|
50
|
+
return undefined;
|
|
51
|
+
|
|
52
|
+
for (let nonce = 0; nonce < 100_000; nonce += 1) {
|
|
53
|
+
const digest = scryptSync(`${work.input}${work.checksum}${nonce}`, work.checksum, 32, {
|
|
54
|
+
N: cost,
|
|
55
|
+
r: 8,
|
|
56
|
+
p: 1,
|
|
57
|
+
maxmem: 64 * 1_024 * 1_024,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
if (hasLeadingZeroBits(digest, work.difficulty)) return String(nonce);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/* v8 ignore file -- exercised through worker integration tests. */
|
|
2
|
+
import { parentPort, workerData } from "node:worker_threads";
|
|
3
|
+
import { solveAwsWafWork, type AwsWafWork } from "./aws-waf-solver.ts";
|
|
4
|
+
|
|
5
|
+
const work: AwsWafWork = workerData;
|
|
6
|
+
|
|
7
|
+
try {
|
|
8
|
+
parentPort?.postMessage({ kind: "solved", solution: solveAwsWafWork(work) });
|
|
9
|
+
} catch (error) {
|
|
10
|
+
parentPort?.postMessage({
|
|
11
|
+
kind: "error",
|
|
12
|
+
message: error instanceof Error ? error.message : "AWS WAF worker failed.",
|
|
13
|
+
});
|
|
14
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { IncomingMessage } from "node:http";
|
|
2
|
+
import type { RedirectDependencies } from "../network/network-redirects.ts";
|
|
3
|
+
|
|
4
|
+
/** Cookie that carries an AWS WAF challenge token. */
|
|
5
|
+
export const AWS_WAF_TOKEN_COOKIE = "aws-waf-token";
|
|
6
|
+
|
|
7
|
+
/** A resolved AWS WAF token and the browser identity it was issued for. */
|
|
8
|
+
export interface AwsWafSolution {
|
|
9
|
+
token: string;
|
|
10
|
+
userAgent?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** A request to resolve one AWS WAF challenge. */
|
|
14
|
+
export interface AwsWafResolveContext {
|
|
15
|
+
/** The page URL withheld by the challenge. */
|
|
16
|
+
url: URL;
|
|
17
|
+
/** Cancels the solver request. */
|
|
18
|
+
signal: AbortSignal;
|
|
19
|
+
/** Network dependencies used by the resolver. */
|
|
20
|
+
network?: RedirectDependencies;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Resolves an AWS WAF challenge to a token accepted by the target origin. */
|
|
24
|
+
export interface AwsWafResolver {
|
|
25
|
+
resolve(context: AwsWafResolveContext): Promise<AwsWafSolution | undefined>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Returns whether a response carries an AWS WAF challenge. */
|
|
29
|
+
export function isAwsWafChallengeResponse(response: Pick<IncomingMessage, "headers">): boolean {
|
|
30
|
+
const value = response.headers["x-amzn-waf-action"];
|
|
31
|
+
const action = Array.isArray(value) ? value[0] : value;
|
|
32
|
+
|
|
33
|
+
return action?.trim().toLowerCase() === "challenge";
|
|
34
|
+
}
|
package/src/document/content.ts
CHANGED
|
@@ -33,6 +33,10 @@ export interface CompleteDocument {
|
|
|
33
33
|
cachedAt?: number;
|
|
34
34
|
/** True when this document is the site's /llms.txt served instead of a low-quality page. */
|
|
35
35
|
llmsTxtFallback?: boolean;
|
|
36
|
+
/** Code-fence language when the body was returned as reformatted source. */
|
|
37
|
+
sourceLanguage?: string;
|
|
38
|
+
/** Whether the response is a standalone OpenAPI or Swagger document. */
|
|
39
|
+
openapiSpec?: boolean;
|
|
36
40
|
/** Set when the site publishes a usable /llms.txt index and this document is not that index. */
|
|
37
41
|
llmsTxtIndexUrl?: string;
|
|
38
42
|
/** Absolute URL advertised via `rel="describedby"` (header or HTML link) for this page. */
|
package/src/document/focus.ts
CHANGED
|
@@ -22,27 +22,130 @@ interface Section {
|
|
|
22
22
|
tokens: string[];
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
interface SplitSections {
|
|
26
|
+
sections: Section[];
|
|
27
|
+
/** Separator that rejoins selected sections; windowed source stays contiguous. */
|
|
28
|
+
separator: "\n" | "\n\n";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Target size of one section when a block has no headings and no blank lines. */
|
|
32
|
+
const WINDOW_TARGET_CHARACTERS = 8_000;
|
|
33
|
+
|
|
34
|
+
/** Fewest lines a headingless block needs before it is split into windows. */
|
|
35
|
+
const WINDOW_MIN_LINES = 80;
|
|
36
|
+
|
|
37
|
+
/** Fewest characters a headingless block needs before it is split into windows. */
|
|
38
|
+
const WINDOW_MIN_CHARACTERS = 12_000;
|
|
39
|
+
|
|
25
40
|
function tokens(value: string): string[] {
|
|
26
41
|
return (value.toLowerCase().match(WORD_PATTERN) ?? []).filter((token) => token.length > 1);
|
|
27
42
|
}
|
|
28
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Splits one headingless, blank-line-free block into line-aligned windows.
|
|
46
|
+
*
|
|
47
|
+
* Formatted source and log files have neither headings nor blank lines, so treating the
|
|
48
|
+
* whole body as a single section would make focus select everything. Windows are bounded
|
|
49
|
+
* by characters but never cut a line, and short blocks keep their single section.
|
|
50
|
+
*/
|
|
51
|
+
function splitIntoWindows(block: string): string[] {
|
|
52
|
+
const lines = block.split(/\r?\n/);
|
|
53
|
+
|
|
54
|
+
if (lines.length < WINDOW_MIN_LINES && block.length < WINDOW_MIN_CHARACTERS) return [block];
|
|
55
|
+
|
|
56
|
+
const windows: string[] = [];
|
|
57
|
+
let current: string[] = [];
|
|
58
|
+
let size = 0;
|
|
59
|
+
|
|
60
|
+
for (const line of lines) {
|
|
61
|
+
if (size > 0 && size + line.length + 1 > WINDOW_TARGET_CHARACTERS) {
|
|
62
|
+
windows.push(current.join("\n"));
|
|
63
|
+
current = [];
|
|
64
|
+
size = 0;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
current.push(line);
|
|
68
|
+
size += line.length + 1;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const last = current.join("\n");
|
|
72
|
+
|
|
73
|
+
if (last) windows.push(last);
|
|
74
|
+
|
|
75
|
+
return windows.filter(Boolean);
|
|
76
|
+
}
|
|
77
|
+
|
|
29
78
|
/**
|
|
30
79
|
* Splits Markdown into sections. Headings stay attached to their following content; a
|
|
31
|
-
* headingless document falls back to paragraphs
|
|
32
|
-
* plain-text
|
|
80
|
+
* headingless document falls back to paragraphs, and a headingless block without blank
|
|
81
|
+
* lines falls back to line windows so focused extraction remains useful for plain-text,
|
|
82
|
+
* log, and reformatted-source responses.
|
|
33
83
|
*/
|
|
34
|
-
function splitSections(markdown: string):
|
|
84
|
+
function splitSections(markdown: string): SplitSections {
|
|
85
|
+
// Window a standalone fenced source body without leaving partial Markdown fences.
|
|
86
|
+
const fenced = /^( {0,3})(`{3,}|~{3,})([^\n\r]*)\r?\n([\s\S]*?)\r?\n\1\2\s*$/.exec(markdown);
|
|
87
|
+
|
|
88
|
+
const hasInteriorClose = fenced?.[4].split(/\r?\n/).some((line) => {
|
|
89
|
+
const close = /^[ \t]{0,3}(`{3,}|~{3,})[ \t]*$/.exec(line);
|
|
90
|
+
|
|
91
|
+
return close && close[1][0] === fenced[2][0] && close[1].length >= fenced[2].length;
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
if (fenced && !hasInteriorClose) {
|
|
95
|
+
const windows = splitIntoWindows(fenced[4]);
|
|
96
|
+
|
|
97
|
+
if (windows.length > 1) {
|
|
98
|
+
return {
|
|
99
|
+
sections: windows.map((window) => ({
|
|
100
|
+
markdown: `${fenced[1]}${fenced[2]}${fenced[3]}\n${window}\n${fenced[1]}${fenced[2]}`,
|
|
101
|
+
tokens: tokens(window),
|
|
102
|
+
})),
|
|
103
|
+
separator: "\n\n",
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return { sections: [{ markdown, tokens: tokens(fenced[4]) }], separator: "\n\n" };
|
|
108
|
+
}
|
|
109
|
+
|
|
35
110
|
const blocks = markdown
|
|
36
111
|
.split(/\n{2,}/)
|
|
37
112
|
.map((block) => block.trim())
|
|
38
113
|
.filter(Boolean);
|
|
39
114
|
|
|
40
|
-
if (blocks.length === 0) return [];
|
|
115
|
+
if (blocks.length === 0) return { sections: [], separator: "\n\n" };
|
|
41
116
|
|
|
42
117
|
const lines = markdown.split(/\r?\n/);
|
|
43
|
-
|
|
118
|
+
let fence: { marker: string; length: number } | undefined;
|
|
119
|
+
|
|
120
|
+
const headingLines = lines.map((line) => {
|
|
121
|
+
const match = /^[ \t]{0,3}(`{3,}|~{3,})(.*)$/.exec(line);
|
|
122
|
+
|
|
123
|
+
if (match) {
|
|
124
|
+
if (!fence) fence = { marker: match[1][0], length: match[1].length };
|
|
125
|
+
else if (match[1][0] === fence.marker && match[1].length >= fence.length && !match[2].trim())
|
|
126
|
+
fence = undefined;
|
|
127
|
+
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return !fence && HEADING_PATTERN.test(line);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
const hasHeadings = headingLines.some(Boolean);
|
|
135
|
+
|
|
136
|
+
if (!hasHeadings) {
|
|
137
|
+
// Keep mixed fenced Markdown intact rather than splitting a fence at blank lines.
|
|
138
|
+
if (lines.some((line) => /^[ \t]{0,3}(`{3,}|~{3,})/.test(line))) {
|
|
139
|
+
return { sections: [{ markdown, tokens: tokens(markdown) }], separator: "\n\n" };
|
|
140
|
+
}
|
|
44
141
|
|
|
45
|
-
|
|
142
|
+
const windowed = blocks.flatMap(splitIntoWindows);
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
sections: windowed.map((block) => ({ markdown: block, tokens: tokens(block) })),
|
|
146
|
+
separator: blocks.length === 1 && windowed.length > 1 ? "\n" : "\n\n",
|
|
147
|
+
};
|
|
148
|
+
}
|
|
46
149
|
|
|
47
150
|
const sections: string[] = [];
|
|
48
151
|
let current: string[] = [];
|
|
@@ -54,14 +157,17 @@ function splitSections(markdown: string): Section[] {
|
|
|
54
157
|
current = [];
|
|
55
158
|
};
|
|
56
159
|
|
|
57
|
-
for (const line of lines) {
|
|
58
|
-
if (
|
|
160
|
+
for (const [index, line] of lines.entries()) {
|
|
161
|
+
if (headingLines[index]) flush();
|
|
59
162
|
current.push(line);
|
|
60
163
|
}
|
|
61
164
|
|
|
62
165
|
flush();
|
|
63
166
|
|
|
64
|
-
return
|
|
167
|
+
return {
|
|
168
|
+
sections: sections.map((section) => ({ markdown: section, tokens: tokens(section) })),
|
|
169
|
+
separator: "\n\n",
|
|
170
|
+
};
|
|
65
171
|
}
|
|
66
172
|
|
|
67
173
|
/**
|
|
@@ -72,7 +178,7 @@ function splitSections(markdown: string): Section[] {
|
|
|
72
178
|
export function focusMarkdown(markdown: string, query: string): FocusResult {
|
|
73
179
|
const normalizedQuery = query.trim();
|
|
74
180
|
const queryTerms = [...new Set(tokens(normalizedQuery))];
|
|
75
|
-
const sections = splitSections(markdown);
|
|
181
|
+
const { sections, separator } = splitSections(markdown);
|
|
76
182
|
const originalCharacters = markdown.length;
|
|
77
183
|
|
|
78
184
|
if (queryTerms.length === 0 || sections.length === 0) {
|
|
@@ -126,7 +232,7 @@ export function focusMarkdown(markdown: string, query: string): FocusResult {
|
|
|
126
232
|
return score > 0;
|
|
127
233
|
});
|
|
128
234
|
|
|
129
|
-
const focused = selected.map((section) => section.markdown).join(
|
|
235
|
+
const focused = selected.map((section) => section.markdown).join(separator);
|
|
130
236
|
|
|
131
237
|
return {
|
|
132
238
|
markdown: focused,
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { parse } from "acorn";
|
|
2
|
+
import { simple } from "acorn-walk";
|
|
3
|
+
import { parseDocument } from "yaml";
|
|
4
|
+
import { Type } from "typebox";
|
|
5
|
+
import { Check } from "typebox/value";
|
|
6
|
+
|
|
7
|
+
const schema = Type.Union([
|
|
8
|
+
Type.Object({
|
|
9
|
+
openapi: Type.String({ pattern: "^3\\.\\d+\\.\\d+$" }),
|
|
10
|
+
info: Type.Object({}),
|
|
11
|
+
paths: Type.Object({}),
|
|
12
|
+
}),
|
|
13
|
+
Type.Object({ swagger: Type.Literal("2.0"), info: Type.Object({}), paths: Type.Object({}) }),
|
|
14
|
+
]);
|
|
15
|
+
|
|
16
|
+
// Parsing is synchronous and untrusted. Larger inputs remain readable without classification.
|
|
17
|
+
const MAX_CLASSIFICATION_CHARACTERS = 512 * 1024;
|
|
18
|
+
|
|
19
|
+
/** Conservatively recognizes a document or a literal object embedded in JavaScript. Never executes source. */
|
|
20
|
+
export function isOpenapiSource(raw: string, contentType: string): boolean {
|
|
21
|
+
if (raw.length > MAX_CLASSIFICATION_CHARACTERS) return false;
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
if (contentType === "application/json") return Check(schema, JSON.parse(raw));
|
|
25
|
+
|
|
26
|
+
if (contentType === "text/yaml") {
|
|
27
|
+
const document = parseDocument(raw, { uniqueKeys: true });
|
|
28
|
+
|
|
29
|
+
return document.errors.length === 0 && Check(schema, document.toJS({ maxAliasCount: 0 }));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (contentType !== "text/javascript") return false;
|
|
33
|
+
const ast = parse(raw, { ecmaVersion: "latest", sourceType: "module" });
|
|
34
|
+
let found = false;
|
|
35
|
+
simple(ast, {
|
|
36
|
+
ObjectExpression(node) {
|
|
37
|
+
let version = false;
|
|
38
|
+
let info = false;
|
|
39
|
+
let paths = false;
|
|
40
|
+
|
|
41
|
+
for (const property of node.properties) {
|
|
42
|
+
if (property.type !== "Property" || property.computed || property.kind !== "init")
|
|
43
|
+
continue;
|
|
44
|
+
|
|
45
|
+
const key =
|
|
46
|
+
property.key.type === "Identifier"
|
|
47
|
+
? property.key.name
|
|
48
|
+
: property.key.type === "Literal"
|
|
49
|
+
? property.key.value
|
|
50
|
+
: undefined;
|
|
51
|
+
|
|
52
|
+
if ((key === "openapi" || key === "swagger") && property.value.type === "Literal") {
|
|
53
|
+
const value = property.value.value;
|
|
54
|
+
version ||=
|
|
55
|
+
key === "swagger"
|
|
56
|
+
? value === "2.0"
|
|
57
|
+
: Check(Type.String({ pattern: "^3\\.\\d+\\.\\d+$" }), value);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (key === "info" && property.value.type === "ObjectExpression") info = true;
|
|
61
|
+
|
|
62
|
+
if (key === "paths" && property.value.type === "ObjectExpression") paths = true;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (version && info && paths) found = true;
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
return found;
|
|
70
|
+
} catch {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Reconstructs detection from older cached canonical representations, without a network request. */
|
|
76
|
+
export function isCachedOpenapi(markdown: string, contentType: string): boolean {
|
|
77
|
+
const match = /^```(?:json|js|yaml)\r?\n([\s\S]*)\r?\n```\s*$/.exec(markdown);
|
|
78
|
+
|
|
79
|
+
return isOpenapiSource(match ? match[1] : markdown, contentType);
|
|
80
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Source text that is read as code rather than prose, mapped to the code-fence
|
|
3
|
+
* language used when one of these bodies is reformatted.
|
|
4
|
+
*/
|
|
5
|
+
const SOURCE_LANGUAGE_BY_CONTENT_TYPE = {
|
|
6
|
+
"application/json": "json",
|
|
7
|
+
"text/javascript": "js",
|
|
8
|
+
"text/yaml": "yaml",
|
|
9
|
+
} as const;
|
|
10
|
+
|
|
11
|
+
type SourceContentType = keyof typeof SOURCE_LANGUAGE_BY_CONTENT_TYPE;
|
|
12
|
+
|
|
13
|
+
/** Narrows a media type to the source types that are reformatted. */
|
|
14
|
+
function isSourceContentType(value: string): value is SourceContentType {
|
|
15
|
+
return Object.hasOwn(SOURCE_LANGUAGE_BY_CONTENT_TYPE, value);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Shortest body reformatted; below this a page is returned verbatim. */
|
|
19
|
+
const MINIFIED_MIN_CHARACTERS = 2_000;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Largest body reformatted. Reformatting triples transient memory for a document too
|
|
23
|
+
* large to cache, so bigger bodies keep their original single-line layout.
|
|
24
|
+
*/
|
|
25
|
+
const MINIFIED_MAX_CHARACTERS = 2 * 1_024 * 1_024;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Shortest average line that still counts as machine-generated. Bundled source is
|
|
29
|
+
* packed into very long lines even when it keeps a few real line breaks inside block
|
|
30
|
+
* scalars, while hand-written source keeps short lines.
|
|
31
|
+
*/
|
|
32
|
+
const MINIFIED_MIN_AVERAGE_LINE_CHARACTERS = 300;
|
|
33
|
+
|
|
34
|
+
/** Deepest nesting that still receives extra indentation. */
|
|
35
|
+
const MAX_FORMAT_DEPTH = 24;
|
|
36
|
+
|
|
37
|
+
const INDENT = " ";
|
|
38
|
+
|
|
39
|
+
const QUOTE_CHARACTERS = new Set(['"', "'", "`"]);
|
|
40
|
+
|
|
41
|
+
const OPENING_CHARACTERS = new Set(["{", "["]);
|
|
42
|
+
|
|
43
|
+
const CLOSING_CHARACTERS = new Set(["}", "]"]);
|
|
44
|
+
|
|
45
|
+
const LINE_ENDING_CHARACTERS = new Set([",", ";"]);
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Returns whether a body is machine-generated source rather than hand-written code.
|
|
49
|
+
*
|
|
50
|
+
* Only minified bodies are reformatted: a hand-written file already carries the
|
|
51
|
+
* author's line structure, which is more useful than any generated layout.
|
|
52
|
+
*/
|
|
53
|
+
function isMinified(value: string): boolean {
|
|
54
|
+
if (value.length < MINIFIED_MIN_CHARACTERS || value.length > MINIFIED_MAX_CHARACTERS)
|
|
55
|
+
return false;
|
|
56
|
+
|
|
57
|
+
let lineBreaks = 0;
|
|
58
|
+
|
|
59
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
60
|
+
if (value[index] === "\n") lineBreaks += 1;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return value.length / (lineBreaks + 1) >= MINIFIED_MIN_AVERAGE_LINE_CHARACTERS;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Returns the fence language for a content type, or undefined when it is not source. */
|
|
67
|
+
export function sourceLanguageFor(contentType: string): string | undefined {
|
|
68
|
+
return isSourceContentType(contentType)
|
|
69
|
+
? SOURCE_LANGUAGE_BY_CONTENT_TYPE[contentType]
|
|
70
|
+
: undefined;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Adds line breaks and indentation to minified source without interpreting it.
|
|
75
|
+
*
|
|
76
|
+
* Layout whitespace is normalized and every other character is preserved in order.
|
|
77
|
+
* String literals are tracked only far enough to keep their punctuation from changing
|
|
78
|
+
* the layout, so no value is parsed, evaluated, or re-serialized.
|
|
79
|
+
*
|
|
80
|
+
* @param value - The minified source
|
|
81
|
+
* @returns Indented source, or undefined when the input was not minified
|
|
82
|
+
*/
|
|
83
|
+
export function indentMinifiedSource(value: string): string | undefined {
|
|
84
|
+
if (!isMinified(value)) return undefined;
|
|
85
|
+
|
|
86
|
+
const lines: string[] = [];
|
|
87
|
+
let current = "";
|
|
88
|
+
let depth = 0;
|
|
89
|
+
let quote: string | undefined;
|
|
90
|
+
let escaped = false;
|
|
91
|
+
|
|
92
|
+
const indent = (): string => INDENT.repeat(Math.min(depth, MAX_FORMAT_DEPTH));
|
|
93
|
+
|
|
94
|
+
const flush = (): void => {
|
|
95
|
+
const line = current.trimEnd();
|
|
96
|
+
|
|
97
|
+
if (line) lines.push(line);
|
|
98
|
+
current = "";
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
for (const character of value) {
|
|
102
|
+
if (quote !== undefined) {
|
|
103
|
+
current += character;
|
|
104
|
+
|
|
105
|
+
if (escaped) escaped = false;
|
|
106
|
+
else if (character === "\\") escaped = true;
|
|
107
|
+
else if (character === quote) quote = undefined;
|
|
108
|
+
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (QUOTE_CHARACTERS.has(character)) {
|
|
113
|
+
quote = character;
|
|
114
|
+
current += character;
|
|
115
|
+
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (OPENING_CHARACTERS.has(character)) {
|
|
120
|
+
current += character;
|
|
121
|
+
flush();
|
|
122
|
+
depth += 1;
|
|
123
|
+
current = indent();
|
|
124
|
+
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (CLOSING_CHARACTERS.has(character)) {
|
|
129
|
+
flush();
|
|
130
|
+
depth = Math.max(0, depth - 1);
|
|
131
|
+
current = indent() + character;
|
|
132
|
+
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (LINE_ENDING_CHARACTERS.has(character)) {
|
|
137
|
+
current += character;
|
|
138
|
+
flush();
|
|
139
|
+
current = indent();
|
|
140
|
+
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (character === "\n") {
|
|
145
|
+
flush();
|
|
146
|
+
current = indent();
|
|
147
|
+
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (character !== "\r") current += character;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
flush();
|
|
155
|
+
|
|
156
|
+
return lines.join("\n");
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Reformats minified JavaScript, JSON, or YAML as a fenced code block.
|
|
161
|
+
*
|
|
162
|
+
* @param raw - The decoded response body
|
|
163
|
+
* @param contentType - The normalized response media type
|
|
164
|
+
* @returns A fenced, indented Markdown block, or undefined when the body is not
|
|
165
|
+
* source that benefits from reformatting
|
|
166
|
+
*/
|
|
167
|
+
export function formatMinifiedSource(
|
|
168
|
+
raw: string,
|
|
169
|
+
contentType: string,
|
|
170
|
+
): { markdown: string; language: string } | undefined {
|
|
171
|
+
const language = sourceLanguageFor(contentType);
|
|
172
|
+
|
|
173
|
+
if (language === undefined) return undefined;
|
|
174
|
+
|
|
175
|
+
const indented = indentMinifiedSource(raw);
|
|
176
|
+
|
|
177
|
+
if (indented === undefined) return undefined;
|
|
178
|
+
|
|
179
|
+
return { language, markdown: `\`\`\`${language}\n${indented}\n\`\`\`` };
|
|
180
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -20,6 +20,13 @@ export {
|
|
|
20
20
|
type AnubisSolveContext,
|
|
21
21
|
} from "./challenges/anubis.ts";
|
|
22
22
|
|
|
23
|
+
export {
|
|
24
|
+
AWS_WAF_TOKEN_COOKIE,
|
|
25
|
+
isAwsWafChallengeResponse,
|
|
26
|
+
type AwsWafResolveContext,
|
|
27
|
+
type AwsWafResolver,
|
|
28
|
+
} from "./challenges/aws-waf.ts";
|
|
29
|
+
|
|
23
30
|
export { CookieJar } from "./network/cookies.ts";
|
|
24
31
|
|
|
25
32
|
export type { FetchResult } from "./document/content.ts";
|
package/src/network/cookies.ts
CHANGED
|
@@ -211,6 +211,47 @@ export class CookieJar {
|
|
|
211
211
|
this.#evict();
|
|
212
212
|
}
|
|
213
213
|
|
|
214
|
+
/**
|
|
215
|
+
* Stores a challenge token returned by a resolver.
|
|
216
|
+
*
|
|
217
|
+
* The token is untrusted input. It is accepted only for the target origin,
|
|
218
|
+
* scoped to the whole origin, and held in memory for the current acquisition.
|
|
219
|
+
*
|
|
220
|
+
* @param name - Cookie name
|
|
221
|
+
* @param value - Cookie value
|
|
222
|
+
* @param url - The page URL the cookie belongs to
|
|
223
|
+
* @returns Whether the cookie was accepted
|
|
224
|
+
*/
|
|
225
|
+
set(name: string, value: string, url: URL): boolean {
|
|
226
|
+
const token = value.trim();
|
|
227
|
+
|
|
228
|
+
if (
|
|
229
|
+
name.length === 0 ||
|
|
230
|
+
name.length > MAX_COOKIE_NAME_CHARACTERS ||
|
|
231
|
+
!COOKIE_NAME.test(name) ||
|
|
232
|
+
token.length === 0 ||
|
|
233
|
+
token.length > MAX_COOKIE_VALUE_CHARACTERS ||
|
|
234
|
+
hasInvalidCookieCharacter(token) ||
|
|
235
|
+
/[\s";,\\]/u.test(token)
|
|
236
|
+
) {
|
|
237
|
+
return false;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const key = `${url.origin}\0${name}\0/`;
|
|
241
|
+
this.#cookies.delete(key);
|
|
242
|
+
this.#cookies.set(key, {
|
|
243
|
+
name,
|
|
244
|
+
value: token,
|
|
245
|
+
origin: url.origin,
|
|
246
|
+
path: "/",
|
|
247
|
+
secure: url.protocol === "https:",
|
|
248
|
+
createdAt: Date.now(),
|
|
249
|
+
});
|
|
250
|
+
this.#evict();
|
|
251
|
+
|
|
252
|
+
return true;
|
|
253
|
+
}
|
|
254
|
+
|
|
214
255
|
/**
|
|
215
256
|
* Returns a Cookie header value for a URL, excluding expired, insecure, and
|
|
216
257
|
* cross-origin cookies.
|
|
@@ -29,11 +29,16 @@ export interface RedirectDependencies {
|
|
|
29
29
|
target: ValidatedTarget,
|
|
30
30
|
signal: AbortSignal,
|
|
31
31
|
headers?: Readonly<Record<string, string>>,
|
|
32
|
+
options?: { method?: string; body?: string | Uint8Array },
|
|
32
33
|
) => Promise<IncomingMessage>;
|
|
33
34
|
random?: () => number;
|
|
34
35
|
sleep?: (milliseconds: number, signal: AbortSignal) => Promise<void>;
|
|
35
36
|
/** Origin-scoped cookies for the current acquisition, including challenge handshakes. */
|
|
36
37
|
cookies?: CookieJar;
|
|
38
|
+
/** User-Agent override that persists across redirects. */
|
|
39
|
+
userAgent?: string;
|
|
40
|
+
/** Headers that survive a redirect, used by challenge handshakes that need browser identity. */
|
|
41
|
+
persistentHeaders?: Readonly<Record<string, string>>;
|
|
37
42
|
}
|
|
38
43
|
|
|
39
44
|
/** Creates the consistent abort error used by redirect and retry coordination. */
|
|
@@ -144,7 +149,14 @@ async function coordinatedRequest(
|
|
|
144
149
|
const release = await acquireOrigin(target.url.origin, signal);
|
|
145
150
|
let response: IncomingMessage;
|
|
146
151
|
const cookieHeader = dependencies.cookies?.headerFor(target.url);
|
|
147
|
-
|
|
152
|
+
|
|
153
|
+
const headersWithUserAgent = dependencies.userAgent
|
|
154
|
+
? { "User-Agent": dependencies.userAgent, ...headers }
|
|
155
|
+
: headers;
|
|
156
|
+
|
|
157
|
+
const requestHeaders = cookieHeader
|
|
158
|
+
? { Cookie: cookieHeader, ...headersWithUserAgent }
|
|
159
|
+
: headersWithUserAgent;
|
|
148
160
|
|
|
149
161
|
try {
|
|
150
162
|
response = await request(target, signal, requestHeaders);
|
|
@@ -197,7 +209,8 @@ export async function requestFollowingRedirects(
|
|
|
197
209
|
response.resume();
|
|
198
210
|
target = await awaitWithAbort(validateUrl(new URL(location, target.url)), signal);
|
|
199
211
|
// Validators describe the originally cached resource and must not leak to a redirect target.
|
|
200
|
-
|
|
212
|
+
// Challenge handshakes opt into a fixed browser identity instead.
|
|
213
|
+
requestHeaders = dependencies.persistentHeaders ?? {};
|
|
201
214
|
}
|
|
202
215
|
|
|
203
216
|
throw new Error("web_fetch followed too many redirects.");
|