@zeldrisho/pi-web-fetch 0.8.2 → 0.9.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 +8 -1
- package/README.md +1 -1
- package/package.json +4 -6
- package/src/abort.ts +3 -0
- package/src/cache.ts +50 -0
- package/src/content.ts +16 -0
- package/src/evidence.ts +10 -0
- package/src/extract.ts +183 -1
- package/src/fetch.ts +54 -0
- package/src/focus.ts +17 -0
- package/src/index.ts +10 -1
- package/src/inflight.ts +10 -0
- package/src/network-policy.ts +17 -0
- package/src/network-redirects.ts +35 -0
- package/src/network-transport.ts +27 -0
- package/src/network.ts +1 -0
- package/src/outline.ts +38 -0
- package/src/redact.ts +7 -0
- package/src/render.ts +3 -0
- package/src/service.ts +156 -17
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.9.0] - 2026-09-17
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Support URL fragments for starting fetched content at matching headings or anchors; explicit offsets take precedence and fragment variants share cached page acquisitions
|
|
15
|
+
|
|
10
16
|
## [0.8.2] - 2026-09-12
|
|
11
17
|
|
|
12
18
|
### Fixed
|
|
@@ -142,7 +148,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
142
148
|
|
|
143
149
|
- Add bounded public page fetching ([b979496](https://github.com/zeldrisho/pi-packages/commit/b979496b32de1cead172ba570307e4a4a7b3421d))
|
|
144
150
|
|
|
145
|
-
[Unreleased]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.
|
|
151
|
+
[Unreleased]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.9.0...HEAD
|
|
152
|
+
[0.9.0]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.8.2...pi-web-fetch-v0.9.0
|
|
146
153
|
[0.8.2]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.8.1...pi-web-fetch-v0.8.2
|
|
147
154
|
[0.8.1]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.8.0...pi-web-fetch-v0.8.1
|
|
148
155
|
[0.8.0]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.7.1...pi-web-fetch-v0.8.0
|
package/README.md
CHANGED
|
@@ -14,7 +14,7 @@ pi install -l npm:@zeldrisho/pi-web-fetch
|
|
|
14
14
|
|
|
15
15
|
`web_fetch` accepts public HTTP and HTTPS URLs and supports HTML, Markdown, plain text, JSON, and XML. HTML is converted with Defuddle, with a basic text extractor as fallback. URLs with credentials, local/private/reserved targets, unsafe redirects, responses over 5 MiB, or unsupported content types are rejected.
|
|
16
16
|
|
|
17
|
-
`maxCharacters` bounds returned Markdown (default 6,000); it does not change the raw download limit. `offset` is a character offset into extracted content. For long pages, pass `query` to get a deterministic, source-ordered focused view selected locally after the complete page is fetched and cached. `details.focus` reports matched, total, and omitted sections plus character counts. Omit `query` to read the complete document. Use a returned `nextOffset` for continuation.
|
|
17
|
+
`maxCharacters` bounds returned Markdown (default 6,000); it does not change the raw download limit. `offset` is a character offset into extracted content. A URL fragment such as `https://github.com/shadcn-ui/lint#get-started` starts at the matching heading or anchor (the complete page is still fetched and cached); an explicit `offset` takes precedence. For long pages, pass `query` to get a deterministic, source-ordered focused view selected locally after the complete page is fetched and cached. `details.focus` reports matched, total, and omitted sections plus character counts. Omit `query` to read the complete document. Use a returned `nextOffset` for continuation.
|
|
18
18
|
|
|
19
19
|
Results include `details.truncation`: complete output has `{ truncated: false, strategy: "none" }`; continuation output has `strategy: "continuation"` and a valid `nextOffset`. Compatibility fields `details.truncated` and `details.nextOffset` remain available. Pi's interactive preview is collapsed by default (`Ctrl+O` expands it); tool output is also capped at 2,000 lines / 50 KiB.
|
|
20
20
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zeldrisho/pi-web-fetch",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Pi extension for secure, bounded public web page fetching and Markdown extraction",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi",
|
|
@@ -22,9 +22,7 @@
|
|
|
22
22
|
},
|
|
23
23
|
"files": [
|
|
24
24
|
"src",
|
|
25
|
-
"
|
|
26
|
-
"CHANGELOG.md",
|
|
27
|
-
"LICENSE"
|
|
25
|
+
"CHANGELOG.md"
|
|
28
26
|
],
|
|
29
27
|
"type": "module",
|
|
30
28
|
"publishConfig": {
|
|
@@ -39,8 +37,8 @@
|
|
|
39
37
|
"@earendil-works/pi-tui": "^0.84.2",
|
|
40
38
|
"typebox": "^1.3.16",
|
|
41
39
|
"typescript": "^7.0.2",
|
|
42
|
-
"vite": "npm:@voidzero-dev/vite-plus-core@0.3.
|
|
43
|
-
"vite-plus": "0.3.
|
|
40
|
+
"vite": "npm:@voidzero-dev/vite-plus-core@0.3.2",
|
|
41
|
+
"vite-plus": "0.3.2"
|
|
44
42
|
},
|
|
45
43
|
"peerDependencies": {
|
|
46
44
|
"@earendil-works/pi-coding-agent": "*",
|
package/src/abort.ts
CHANGED
|
@@ -8,12 +8,14 @@
|
|
|
8
8
|
export function awaitWithAbort<T>(operation: Promise<T>, signal: AbortSignal): Promise<T> {
|
|
9
9
|
return new Promise((resolve, reject) => {
|
|
10
10
|
let settled = false;
|
|
11
|
+
|
|
11
12
|
const finish = (callback: () => void): void => {
|
|
12
13
|
if (settled) return;
|
|
13
14
|
settled = true;
|
|
14
15
|
signal.removeEventListener("abort", abort);
|
|
15
16
|
callback();
|
|
16
17
|
};
|
|
18
|
+
|
|
17
19
|
const abort = (): void => {
|
|
18
20
|
const error = new Error("Operation aborted.");
|
|
19
21
|
error.name = "AbortError";
|
|
@@ -24,6 +26,7 @@ export function awaitWithAbort<T>(operation: Promise<T>, signal: AbortSignal): P
|
|
|
24
26
|
(value) => finish(() => resolve(value)),
|
|
25
27
|
(error) => finish(() => reject(error)),
|
|
26
28
|
);
|
|
29
|
+
|
|
27
30
|
if (signal.aborted) abort();
|
|
28
31
|
else signal.addEventListener("abort", abort, { once: true });
|
|
29
32
|
});
|
package/src/cache.ts
CHANGED
|
@@ -48,9 +48,11 @@ export function stableKeyHash(key: string): string {
|
|
|
48
48
|
export function resolveCachePath(directory: string, keyPath: string): string {
|
|
49
49
|
const base = resolve(directory);
|
|
50
50
|
const full = resolve(base, keyPath);
|
|
51
|
+
|
|
51
52
|
if (full !== base && !full.startsWith(base + sep)) {
|
|
52
53
|
throw new Error(`Refusing to write cache entry outside ${base}: ${keyPath}`);
|
|
53
54
|
}
|
|
55
|
+
|
|
54
56
|
return full;
|
|
55
57
|
}
|
|
56
58
|
|
|
@@ -63,10 +65,12 @@ export function resolveCachePath(directory: string, keyPath: string): string {
|
|
|
63
65
|
function encodeExpiresAt(expiresAt: number): Uint8Array {
|
|
64
66
|
const out = new Uint8Array(8);
|
|
65
67
|
let value = BigInt(Math.round(expiresAt));
|
|
68
|
+
|
|
66
69
|
for (let index = 7; index >= 0; index -= 1) {
|
|
67
70
|
out[index] = Number(value & 0xffn);
|
|
68
71
|
value >>= 8n;
|
|
69
72
|
}
|
|
73
|
+
|
|
70
74
|
return out;
|
|
71
75
|
}
|
|
72
76
|
|
|
@@ -78,7 +82,9 @@ function encodeExpiresAt(expiresAt: number): Uint8Array {
|
|
|
78
82
|
*/
|
|
79
83
|
function decodeExpiresAt(bytes: Uint8Array): number {
|
|
80
84
|
let value = 0n;
|
|
85
|
+
|
|
81
86
|
for (let index = 0; index < 8; index += 1) value = (value << 8n) | BigInt(bytes[index]);
|
|
87
|
+
|
|
82
88
|
return Number(value);
|
|
83
89
|
}
|
|
84
90
|
|
|
@@ -123,19 +129,26 @@ export class ExpiringLruCache<K, V> {
|
|
|
123
129
|
*/
|
|
124
130
|
get(key: K): V | undefined {
|
|
125
131
|
const entry = this.#entries.get(key);
|
|
132
|
+
|
|
126
133
|
if (entry) {
|
|
127
134
|
if (entry.expiresAt <= this.now()) {
|
|
128
135
|
this.#delete(key);
|
|
136
|
+
|
|
129
137
|
return undefined;
|
|
130
138
|
}
|
|
139
|
+
|
|
131
140
|
this.#entries.delete(key);
|
|
132
141
|
this.#entries.set(key, entry);
|
|
142
|
+
|
|
133
143
|
return entry.value;
|
|
134
144
|
}
|
|
145
|
+
|
|
135
146
|
if (this.persistence) {
|
|
136
147
|
const loaded = this.#loadFromDisk(key);
|
|
148
|
+
|
|
137
149
|
if (loaded !== undefined) return loaded.value;
|
|
138
150
|
}
|
|
151
|
+
|
|
139
152
|
return undefined;
|
|
140
153
|
}
|
|
141
154
|
|
|
@@ -150,13 +163,16 @@ export class ExpiringLruCache<K, V> {
|
|
|
150
163
|
set(key: K, value: V, expiresAt: number): boolean {
|
|
151
164
|
this.#delete(key);
|
|
152
165
|
const size = this.sizeOf(value);
|
|
166
|
+
|
|
153
167
|
if (size > this.maxBytes) return false;
|
|
154
168
|
|
|
155
169
|
this.#entries.set(key, { expiresAt, size, value });
|
|
156
170
|
this.#byteSize += size;
|
|
157
171
|
this.#evict();
|
|
158
172
|
const stored = this.#entries.has(key);
|
|
173
|
+
|
|
159
174
|
if (stored && this.persistence) this.#writeToDisk(key, value, expiresAt);
|
|
175
|
+
|
|
160
176
|
return stored;
|
|
161
177
|
}
|
|
162
178
|
|
|
@@ -164,6 +180,7 @@ export class ExpiringLruCache<K, V> {
|
|
|
164
180
|
let bytes: Uint8Array;
|
|
165
181
|
const path = resolveCachePath(this.persistence!.directory, this.persistence!.keyToPath(key));
|
|
166
182
|
let fileSize: number;
|
|
183
|
+
|
|
167
184
|
try {
|
|
168
185
|
// Serialized metadata can exceed the value budget, but never allow an
|
|
169
186
|
// unbounded cache file to be read into memory.
|
|
@@ -171,37 +188,50 @@ export class ExpiringLruCache<K, V> {
|
|
|
171
188
|
} catch {
|
|
172
189
|
return undefined;
|
|
173
190
|
}
|
|
191
|
+
|
|
174
192
|
if (fileSize < 8 || fileSize > this.maxBytes * 2 + 65_536) {
|
|
175
193
|
this.#removeFromDisk(key);
|
|
194
|
+
|
|
176
195
|
return undefined;
|
|
177
196
|
}
|
|
197
|
+
|
|
178
198
|
try {
|
|
179
199
|
bytes = readFileSync(path);
|
|
180
200
|
} catch {
|
|
181
201
|
return undefined;
|
|
182
202
|
}
|
|
203
|
+
|
|
183
204
|
let entry: ExpiringCacheEntry<V>;
|
|
205
|
+
|
|
184
206
|
try {
|
|
185
207
|
const expiresAt = decodeExpiresAt(bytes);
|
|
186
208
|
const value = this.persistence!.deserialize(bytes.subarray(8));
|
|
209
|
+
|
|
187
210
|
if (this.persistence!.validate && !this.persistence!.validate(value)) {
|
|
188
211
|
throw new Error("invalid cache entry");
|
|
189
212
|
}
|
|
213
|
+
|
|
190
214
|
const size = this.sizeOf(value);
|
|
215
|
+
|
|
191
216
|
if (!Number.isFinite(size) || size < 0 || size > this.maxBytes)
|
|
192
217
|
throw new Error("oversized cache entry");
|
|
193
218
|
entry = { expiresAt, size, value };
|
|
194
219
|
} catch {
|
|
195
220
|
this.#removeFromDisk(key);
|
|
221
|
+
|
|
196
222
|
return undefined;
|
|
197
223
|
}
|
|
224
|
+
|
|
198
225
|
if (entry.expiresAt <= this.now()) {
|
|
199
226
|
this.#removeFromDisk(key);
|
|
227
|
+
|
|
200
228
|
return undefined;
|
|
201
229
|
}
|
|
230
|
+
|
|
202
231
|
this.#entries.set(key, entry);
|
|
203
232
|
this.#byteSize += entry.size;
|
|
204
233
|
this.#evict();
|
|
234
|
+
|
|
205
235
|
return this.#entries.get(key);
|
|
206
236
|
}
|
|
207
237
|
|
|
@@ -211,10 +241,12 @@ export class ExpiringLruCache<K, V> {
|
|
|
211
241
|
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
212
242
|
chmodSync(directory, 0o700);
|
|
213
243
|
const payload = this.persistence!.serialize(value);
|
|
244
|
+
|
|
214
245
|
if (payload.byteLength > this.maxBytes * 2 + 65_536)
|
|
215
246
|
throw new Error("oversized cache payload");
|
|
216
247
|
const path = resolveCachePath(directory, this.persistence!.keyToPath(key));
|
|
217
248
|
const temp = `${path}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
|
|
249
|
+
|
|
218
250
|
try {
|
|
219
251
|
writeFileSync(temp, Buffer.concat([encodeExpiresAt(expiresAt), payload]), { mode: 0o600 });
|
|
220
252
|
chmodSync(temp, 0o600);
|
|
@@ -241,27 +273,35 @@ export class ExpiringLruCache<K, V> {
|
|
|
241
273
|
|
|
242
274
|
#maintainDisk(): void {
|
|
243
275
|
const persistence = this.persistence!;
|
|
276
|
+
|
|
244
277
|
try {
|
|
245
278
|
const files = readdirSync(persistence.directory);
|
|
246
279
|
const candidates: Array<{ path: string; size: number; mtime: number }> = [];
|
|
280
|
+
|
|
247
281
|
for (const file of files) {
|
|
248
282
|
const path = resolveCachePath(persistence.directory, file);
|
|
283
|
+
|
|
249
284
|
if (file.endsWith(".tmp")) {
|
|
250
285
|
try {
|
|
251
286
|
unlinkSync(path);
|
|
252
287
|
} catch {
|
|
253
288
|
// Best effort cleanup.
|
|
254
289
|
}
|
|
290
|
+
|
|
255
291
|
continue;
|
|
256
292
|
}
|
|
293
|
+
|
|
257
294
|
try {
|
|
258
295
|
const stat = statSync(path);
|
|
296
|
+
|
|
259
297
|
if (!stat.isFile() || stat.size < 8 || stat.size > this.maxBytes * 2 + 65_536) {
|
|
260
298
|
unlinkSync(path);
|
|
261
299
|
continue;
|
|
262
300
|
}
|
|
301
|
+
|
|
263
302
|
const descriptor = openSync(path, "r");
|
|
264
303
|
const header = new Uint8Array(8);
|
|
304
|
+
|
|
265
305
|
try {
|
|
266
306
|
if (
|
|
267
307
|
readSync(descriptor, header, 0, 8, 0) !== 8 ||
|
|
@@ -273,17 +313,22 @@ export class ExpiringLruCache<K, V> {
|
|
|
273
313
|
} finally {
|
|
274
314
|
closeSync(descriptor);
|
|
275
315
|
}
|
|
316
|
+
|
|
276
317
|
candidates.push({ path, size: stat.size, mtime: stat.mtimeMs });
|
|
277
318
|
} catch {
|
|
278
319
|
// Ignore races and inaccessible files in this best-effort sweep.
|
|
279
320
|
}
|
|
280
321
|
}
|
|
322
|
+
|
|
281
323
|
candidates.sort((a, b) => a.mtime - b.mtime);
|
|
282
324
|
let total = candidates.reduce((sum, candidate) => sum + candidate.size, 0);
|
|
325
|
+
|
|
283
326
|
while (candidates.length > this.maxEntries || total > this.maxBytes * 2 + 65_536) {
|
|
284
327
|
const oldest = candidates.shift();
|
|
328
|
+
|
|
285
329
|
if (!oldest) break;
|
|
286
330
|
total -= oldest.size;
|
|
331
|
+
|
|
287
332
|
try {
|
|
288
333
|
unlinkSync(oldest.path);
|
|
289
334
|
} catch {
|
|
@@ -298,6 +343,7 @@ export class ExpiringLruCache<K, V> {
|
|
|
298
343
|
#evict(): void {
|
|
299
344
|
while (this.#entries.size > this.maxEntries || this.#byteSize > this.maxBytes) {
|
|
300
345
|
const oldest = this.#entries.keys().next().value;
|
|
346
|
+
|
|
301
347
|
if (oldest === undefined) break;
|
|
302
348
|
this.#delete(oldest);
|
|
303
349
|
}
|
|
@@ -305,12 +351,16 @@ export class ExpiringLruCache<K, V> {
|
|
|
305
351
|
|
|
306
352
|
#delete(key: K): void {
|
|
307
353
|
const entry = this.#entries.get(key);
|
|
354
|
+
|
|
308
355
|
if (!entry) {
|
|
309
356
|
if (this.persistence) this.#removeFromDisk(key);
|
|
357
|
+
|
|
310
358
|
return;
|
|
311
359
|
}
|
|
360
|
+
|
|
312
361
|
this.#entries.delete(key);
|
|
313
362
|
this.#byteSize -= entry.size;
|
|
363
|
+
|
|
314
364
|
if (this.persistence) this.#removeFromDisk(key);
|
|
315
365
|
}
|
|
316
366
|
}
|
package/src/content.ts
CHANGED
|
@@ -2,7 +2,9 @@ import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES } from "@earendil-works/pi-coding-
|
|
|
2
2
|
import type { ExtractedLinks, ExtractionDiagnostics } from "./evidence";
|
|
3
3
|
|
|
4
4
|
const CONTENT_LINE_BUDGET = Math.max(1, DEFAULT_MAX_LINES - 10);
|
|
5
|
+
|
|
5
6
|
const CONTENT_BYTE_BUDGET = Math.max(1_024, DEFAULT_MAX_BYTES - 2_048);
|
|
7
|
+
|
|
6
8
|
const encoder = new TextEncoder();
|
|
7
9
|
|
|
8
10
|
/**
|
|
@@ -14,6 +16,8 @@ const encoder = new TextEncoder();
|
|
|
14
16
|
export interface CompleteDocument {
|
|
15
17
|
url: string;
|
|
16
18
|
contentType: string;
|
|
19
|
+
/** Character offsets for document fragments, derived from HTML anchors/headings. */
|
|
20
|
+
fragmentOffsets?: Record<string, number>;
|
|
17
21
|
markdown: string;
|
|
18
22
|
title?: string;
|
|
19
23
|
extractor: "defuddle" | "basic" | "raw";
|
|
@@ -52,27 +56,37 @@ export interface FetchResult extends CompleteDocument {
|
|
|
52
56
|
truncated: boolean;
|
|
53
57
|
}
|
|
54
58
|
|
|
59
|
+
/** Returns the longest prefix whose UTF-8 representation fits within the byte limit. */
|
|
55
60
|
function sliceByByteLength(value: string, maxBytes: number): string {
|
|
56
61
|
if (encoder.encode(value).byteLength <= maxBytes) return value;
|
|
57
62
|
let low = 0;
|
|
58
63
|
let high = value.length;
|
|
64
|
+
|
|
59
65
|
while (low < high) {
|
|
60
66
|
const middle = Math.ceil((low + high) / 2);
|
|
67
|
+
|
|
61
68
|
if (encoder.encode(value.slice(0, middle)).byteLength <= maxBytes) low = middle;
|
|
62
69
|
else high = middle - 1;
|
|
63
70
|
}
|
|
71
|
+
|
|
64
72
|
if (low > 0 && /[\uD800-\uDBFF]/.test(value[low - 1])) low -= 1;
|
|
73
|
+
|
|
65
74
|
return value.slice(0, low);
|
|
66
75
|
}
|
|
67
76
|
|
|
77
|
+
/** Slices content while enforcing the configured character, line, and byte budgets. */
|
|
68
78
|
function boundedContentChunk(value: string, offset: number, maxCharacters: number): string {
|
|
69
79
|
let chunk = value.slice(offset, offset + maxCharacters);
|
|
70
80
|
let newline = -1;
|
|
81
|
+
|
|
71
82
|
for (let lines = 1; lines < CONTENT_LINE_BUDGET; lines += 1) {
|
|
72
83
|
newline = chunk.indexOf("\n", newline + 1);
|
|
84
|
+
|
|
73
85
|
if (newline === -1) break;
|
|
74
86
|
}
|
|
87
|
+
|
|
75
88
|
if (newline !== -1) chunk = chunk.slice(0, newline);
|
|
89
|
+
|
|
76
90
|
return sliceByByteLength(chunk, CONTENT_BYTE_BUDGET);
|
|
77
91
|
}
|
|
78
92
|
|
|
@@ -97,11 +111,13 @@ export function sliceCompleteDocument(
|
|
|
97
111
|
let markdown = boundedContentChunk(document.markdown, offset, maxCharacters);
|
|
98
112
|
const end = offset + markdown.length;
|
|
99
113
|
const truncated = end < totalCharacters;
|
|
114
|
+
|
|
100
115
|
if (truncated) {
|
|
101
116
|
markdown += `\n\n[Content truncated. Continue with offset=${end} to read the next chunk.]`;
|
|
102
117
|
} else if (offset > 0) {
|
|
103
118
|
markdown += "\n\n[End of page content.]";
|
|
104
119
|
}
|
|
120
|
+
|
|
105
121
|
return {
|
|
106
122
|
...document,
|
|
107
123
|
markdown,
|
package/src/evidence.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { parseHTML } from "linkedom";
|
|
2
2
|
|
|
3
3
|
const MAX_LINKS = 16;
|
|
4
|
+
|
|
4
5
|
const MAX_ANCHOR_CHARACTERS = 120;
|
|
5
6
|
|
|
6
7
|
/** Bounded, machine-readable reasons that an HTML extraction may be incomplete. */
|
|
@@ -28,12 +29,14 @@ export interface ExtractedLinks {
|
|
|
28
29
|
}
|
|
29
30
|
|
|
30
31
|
const JAVASCRIPT_MARKERS = [/please\s+enable\s+javascript/i, /javascript\s+(?:is\s+)?required/i];
|
|
32
|
+
|
|
31
33
|
const BOT_MARKERS = [
|
|
32
34
|
/are\s+you\s+a\s+robot/i,
|
|
33
35
|
/verify\s+you\s+are\s+human/i,
|
|
34
36
|
/checking\s+your\s+browser/i,
|
|
35
37
|
/<title>\s*just\s+a\s+moment/i,
|
|
36
38
|
];
|
|
39
|
+
|
|
37
40
|
const CONSENT_MARKERS = [
|
|
38
41
|
/manage\s+(?:your\s+)?consent/i,
|
|
39
42
|
/your\s+(?:privacy\s+)?consent/i,
|
|
@@ -78,6 +81,7 @@ export function hasExtractionWarning(diagnostics: ExtractionDiagnostics): boolea
|
|
|
78
81
|
*/
|
|
79
82
|
function normalizedAnchor(value: string): string {
|
|
80
83
|
const text = value.replace(/\s+/g, " ").trim();
|
|
84
|
+
|
|
81
85
|
return text.length > MAX_ANCHOR_CHARACTERS
|
|
82
86
|
? `${text.slice(0, MAX_ANCHOR_CHARACTERS - 1)}…`
|
|
83
87
|
: text;
|
|
@@ -94,25 +98,31 @@ export function extractDocumentLinks(raw: string, pageUrl: URL): ExtractedLinks
|
|
|
94
98
|
|
|
95
99
|
for (const element of document.querySelectorAll<HTMLAnchorElement>("a[href]")) {
|
|
96
100
|
const href = element.getAttribute("href");
|
|
101
|
+
|
|
97
102
|
if (!href) continue;
|
|
98
103
|
let target: URL;
|
|
104
|
+
|
|
99
105
|
try {
|
|
100
106
|
target = new URL(href, pageUrl);
|
|
101
107
|
} catch {
|
|
102
108
|
continue;
|
|
103
109
|
}
|
|
110
|
+
|
|
104
111
|
if (!["http:", "https:"].includes(target.protocol) || target.username || target.password)
|
|
105
112
|
continue;
|
|
106
113
|
target.hash = "";
|
|
107
114
|
const anchorText = normalizedAnchor(element.textContent ?? "");
|
|
108
115
|
const key = `${target.href}\0${anchorText}`;
|
|
116
|
+
|
|
109
117
|
if (seen.has(key)) continue;
|
|
110
118
|
seen.add(key);
|
|
111
119
|
const isInternal = target.origin === pageUrl.origin;
|
|
112
120
|
const bucket = isInternal ? internal : external;
|
|
121
|
+
|
|
113
122
|
if (bucket.length < MAX_LINKS) bucket.push({ url: target.href, anchorText });
|
|
114
123
|
else if (isInternal) omittedInternal += 1;
|
|
115
124
|
else omittedExternal += 1;
|
|
116
125
|
}
|
|
126
|
+
|
|
117
127
|
return { internal, external, omittedInternal, omittedExternal };
|
|
118
128
|
}
|