@zeldrisho/pi-web-fetch 0.7.1 → 0.8.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/CHANGELOG.md +28 -1
- package/README.md +12 -2
- package/package.json +1 -1
- package/src/cache.ts +36 -0
- package/src/content.ts +10 -1
- package/src/evidence.ts +118 -0
- package/src/extract.ts +135 -2
- package/src/fetch.ts +62 -43
- package/src/focus.ts +125 -0
- package/src/index.ts +22 -1
- package/src/inflight.ts +22 -0
- package/src/limits.ts +3 -0
- package/src/network-redirects.ts +125 -10
- package/src/network-transport.ts +5 -1
- package/src/outline.ts +240 -0
- package/src/redact.ts +54 -0
- package/src/service.ts +113 -23
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.8.1] - 2026-09-07
|
|
11
|
+
|
|
12
|
+
### Security
|
|
13
|
+
|
|
14
|
+
- redact URL user information and common credential-bearing query parameters from progress messages, rendered calls, content source labels, and result URL metadata
|
|
15
|
+
|
|
16
|
+
## [0.8.0] - 2026-09-01
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
|
|
20
|
+
- Add optional deterministic query-focused extraction that selects matching source sections from the complete cached document, preserves source order, supports continuation over the focused view, and reports honest selection evidence in `details.focus`
|
|
21
|
+
- Add bounded, fence-aware document outlines to result details, including total words, section word counts, omitted-heading counts, and conservative fallback headings for structure lost during extraction
|
|
22
|
+
- Add an opt-in live extraction-quality corpus that checks expected markers and content size while reporting extractor choice and latency
|
|
23
|
+
- Revalidate stale cache entries with `ETag` and `Last-Modified`, reporting cache hits, HTTP 304 revalidations, and misses separately
|
|
24
|
+
- Add explicit bounded diagnostics for JavaScript requirements, bot walls, consent interstitials, and sparse extraction
|
|
25
|
+
- Expose bounded normalized internal and external links with anchor text and omitted counts without fetching linked pages
|
|
26
|
+
- Coordinate requests per origin and retry `429`/`503` responses with bounded `Retry-After` handling or jittered backoff
|
|
27
|
+
- Add focused-ranking baselines for heading, phrase, and stemming experiments
|
|
28
|
+
|
|
29
|
+
### Fixed
|
|
30
|
+
|
|
31
|
+
- Resolve relative Open Graph, Twitter, and canonical page URLs before Defuddle metadata extraction so successful GitHub release fetches no longer leak `ERR_INVALID_URL` warnings into Pi's TUI
|
|
32
|
+
- Remove leaked style elements, standalone CSS rules, and block at-rules from extracted Markdown while preserving fenced stylesheet examples
|
|
33
|
+
- Expand extraction regressions for GFM tables, malformed issue markup, and content-rich app shells
|
|
34
|
+
|
|
10
35
|
## [0.7.1] - 2026-08-28
|
|
11
36
|
|
|
12
37
|
### Fixed
|
|
@@ -110,7 +135,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
110
135
|
|
|
111
136
|
- Add bounded public page fetching ([b979496](https://github.com/zeldrisho/pi-packages/commit/b979496b32de1cead172ba570307e4a4a7b3421d))
|
|
112
137
|
|
|
113
|
-
[Unreleased]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.
|
|
138
|
+
[Unreleased]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.8.1...HEAD
|
|
139
|
+
[0.8.1]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.8.0...pi-web-fetch-v0.8.1
|
|
140
|
+
[0.8.0]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.7.1...pi-web-fetch-v0.8.0
|
|
114
141
|
[0.7.1]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.7.0...pi-web-fetch-v0.7.1
|
|
115
142
|
[0.7.0]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.6.1...pi-web-fetch-v0.7.0
|
|
116
143
|
[0.6.1]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.6.0...pi-web-fetch-v0.6.1
|
package/README.md
CHANGED
|
@@ -20,6 +20,8 @@ The `web_fetch` tool accepts public HTTP and HTTPS URLs. It supports textual con
|
|
|
20
20
|
|
|
21
21
|
For safety, the tool blocks URLs containing credentials, local hostnames, private or reserved network targets, unsafe redirects, raw responses larger than 5 MiB, and unsupported content types. The `maxCharacters` parameter controls returned Markdown length; it does not change the raw download limit.
|
|
22
22
|
|
|
23
|
+
For long pages, the optional `query` parameter returns a deterministic focused view containing source sections that match the query. Filtering is performed locally after the complete page has been fetched and cached; it does not use an LLM or alter the cached document. Matching sections retain source order, and `details.focus` reports matched, total, and omitted section counts plus original and focused character counts. Continuation offsets apply to the focused view when `query` is present. Omit `query` to read the complete document, especially when verifying context or when no sections match.
|
|
24
|
+
|
|
23
25
|
In Pi's interactive UI, fetched content uses Pi's standard collapsed preview; use the configured tool-expansion shortcut (`Ctrl+O` by default) to show all visible tool output. Output sent to the agent remains bounded: each call returns at most `maxCharacters` characters of extracted Markdown (default 6,000) and is additionally capped by Pi's 2,000-line / 50 KiB tool-output limit, so fetching cannot bloat the conversation context. The `offset` parameter is a character offset into extracted content, not a byte range into the remote response. When a result is truncated, call the tool again with the returned `nextOffset` as `offset` to continue reading. Fetched and extracted pages are cached in byte-bounded memory for a limited time so continuation requests can reuse the same content. Concurrent requests for the same URL share one fetch; cancelling one caller does not cancel work still needed by another.
|
|
24
26
|
|
|
25
27
|
Every result includes `details.truncation`. Complete output reports `{ truncated: false, strategy: "none" }`. Truncated output reports `strategy: "continuation"` and a valid `nextOffset`. The existing top-level `details.truncated` and `details.nextOffset` fields remain available.
|
|
@@ -54,9 +56,17 @@ Directory and tree listings (`https://github.com/<owner>/<repo>/tree/...`) are a
|
|
|
54
56
|
|
|
55
57
|
### Caching and evidence
|
|
56
58
|
|
|
57
|
-
Fetched and extracted pages are cached in byte-bounded memory and also persisted to a private, cross-session disk cache (
|
|
59
|
+
Fetched and extracted pages are cached in byte-bounded memory and also persisted to a private, cross-session disk cache (files created `0700`/`0600`). Entries are fresh for 24 hours and retained stale for bounded conditional revalidation. Stale entries use their `ETag` and `Last-Modified` validators through the same URL validation, DNS pinning, redirect, timeout, and response-limit boundary as an initial fetch. `details.cacheStatus` distinguishes `hit`, `revalidated` (HTTP 304), and `miss`; the compatibility `cached` flag is true for hits and revalidations.
|
|
60
|
+
|
|
61
|
+
Requests are coordinated per origin. Responses with status `429` or `503` are retried at most twice, honoring a bounded `Retry-After` value or using bounded jittered exponential backoff. Each caller retains independent cancellation while queued or backing off.
|
|
62
|
+
|
|
63
|
+
Each result includes honest-evidence `details`: `requestedUrl` and `finalUrl` (after any rewrite or redirect), `contentKind` (a coarse classification such as `article`, `code-file`, `repository-readme`, `raw-text`, or `markup-shell`), and `confidence` (`high`/`medium`/`low`). For HTML, `details.extractionDiagnostics` separately reports JavaScript requirements, bot walls, consent interstitials, and sparse extraction; `shellSuspected` remains only as a deprecated compatibility summary.
|
|
64
|
+
|
|
65
|
+
`details.links` exposes at most 16 normalized internal and 16 external HTTP(S) links with bounded anchor text and omitted counts. Unsafe schemes and credential-bearing URLs are rejected. Links are evidence from the fetched document only; `web_fetch` never fetches linked pages implicitly.
|
|
66
|
+
|
|
67
|
+
`details.outline` provides bounded document-shape metadata: total words, heading count, the first 12 headings with section word counts, and the number of omitted headings. It ignores headings inside fenced code and conservatively infers short title lines only when extraction produced no Markdown headings. Heading text is untrusted remote content and is capped before being exposed.
|
|
58
68
|
|
|
59
|
-
|
|
69
|
+
Extraction regressions cover tables, malformed issue-like markup, and app-shell false positives. Before changing focused-section ranking, run `vp exec jiti scripts/benchmark-focus.ts` to record heading, phrase, and stemming baselines.
|
|
60
70
|
|
|
61
71
|
## Uninstall
|
|
62
72
|
|
package/package.json
CHANGED
package/src/cache.ts
CHANGED
|
@@ -40,6 +40,12 @@ export function resolveCachePath(directory: string, keyPath: string): string {
|
|
|
40
40
|
return full;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Encodes an expiration timestamp as an 8-byte big-endian buffer.
|
|
45
|
+
*
|
|
46
|
+
* @param expiresAt - Expiration timestamp in milliseconds
|
|
47
|
+
* @returns 8-byte buffer containing the encoded timestamp
|
|
48
|
+
*/
|
|
43
49
|
function encodeExpiresAt(expiresAt: number): Uint8Array {
|
|
44
50
|
const out = new Uint8Array(8);
|
|
45
51
|
let value = BigInt(Math.round(expiresAt));
|
|
@@ -50,6 +56,12 @@ function encodeExpiresAt(expiresAt: number): Uint8Array {
|
|
|
50
56
|
return out;
|
|
51
57
|
}
|
|
52
58
|
|
|
59
|
+
/**
|
|
60
|
+
* Decodes an 8-byte big-endian buffer to an expiration timestamp.
|
|
61
|
+
*
|
|
62
|
+
* @param bytes - 8-byte buffer containing the encoded timestamp
|
|
63
|
+
* @returns Expiration timestamp in milliseconds
|
|
64
|
+
*/
|
|
53
65
|
function decodeExpiresAt(bytes: Uint8Array): number {
|
|
54
66
|
let value = 0n;
|
|
55
67
|
for (let index = 0; index < 8; index += 1) value = (value << 8n) | BigInt(bytes[index]);
|
|
@@ -69,14 +81,30 @@ export class ExpiringLruCache<K, V> {
|
|
|
69
81
|
readonly persistence?: CachePersistence<K, V>,
|
|
70
82
|
) {}
|
|
71
83
|
|
|
84
|
+
/**
|
|
85
|
+
* Gets the total byte size of all cached values.
|
|
86
|
+
*
|
|
87
|
+
* @returns Current aggregate byte size
|
|
88
|
+
*/
|
|
72
89
|
get byteSize(): number {
|
|
73
90
|
return this.#byteSize;
|
|
74
91
|
}
|
|
75
92
|
|
|
93
|
+
/**
|
|
94
|
+
* Gets the number of cached entries.
|
|
95
|
+
*
|
|
96
|
+
* @returns Current entry count
|
|
97
|
+
*/
|
|
76
98
|
get size(): number {
|
|
77
99
|
return this.#entries.size;
|
|
78
100
|
}
|
|
79
101
|
|
|
102
|
+
/**
|
|
103
|
+
* Retrieves a value from the cache, loading from disk if needed.
|
|
104
|
+
*
|
|
105
|
+
* @param key - Cache key
|
|
106
|
+
* @returns Cached value or undefined if not found or expired
|
|
107
|
+
*/
|
|
80
108
|
get(key: K): V | undefined {
|
|
81
109
|
const entry = this.#entries.get(key);
|
|
82
110
|
if (entry) {
|
|
@@ -95,6 +123,14 @@ export class ExpiringLruCache<K, V> {
|
|
|
95
123
|
return undefined;
|
|
96
124
|
}
|
|
97
125
|
|
|
126
|
+
/**
|
|
127
|
+
* Stores a value in the cache with an expiration time.
|
|
128
|
+
*
|
|
129
|
+
* @param key - Cache key
|
|
130
|
+
* @param value - Value to store
|
|
131
|
+
* @param expiresAt - Expiration timestamp in milliseconds
|
|
132
|
+
* @returns True if the value was stored, false if it was too large
|
|
133
|
+
*/
|
|
98
134
|
set(key: K, value: V, expiresAt: number): boolean {
|
|
99
135
|
this.#delete(key);
|
|
100
136
|
const size = this.sizeOf(value);
|
package/src/content.ts
CHANGED
|
@@ -1,4 +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
3
|
|
|
3
4
|
const CONTENT_LINE_BUDGET = Math.max(1, DEFAULT_MAX_LINES - 10);
|
|
4
5
|
const CONTENT_BYTE_BUDGET = Math.max(1_024, DEFAULT_MAX_BYTES - 2_048);
|
|
@@ -16,8 +17,16 @@ export interface CompleteDocument {
|
|
|
16
17
|
markdown: string;
|
|
17
18
|
title?: string;
|
|
18
19
|
extractor: "defuddle" | "basic" | "raw";
|
|
19
|
-
/**
|
|
20
|
+
/** @deprecated Use extractionDiagnostics for the individual quality signals. */
|
|
20
21
|
shellSuspected: boolean;
|
|
22
|
+
/** Explicit bounded extraction-quality evidence for HTML documents. */
|
|
23
|
+
extractionDiagnostics?: ExtractionDiagnostics;
|
|
24
|
+
/** Bounded normalized links extracted from this document; linked pages were not fetched. */
|
|
25
|
+
links?: ExtractedLinks;
|
|
26
|
+
/** HTTP validators retained for stale-cache revalidation. */
|
|
27
|
+
validators?: { etag?: string; lastModified?: string };
|
|
28
|
+
/** Time at which this representation was fetched or successfully revalidated. */
|
|
29
|
+
cachedAt?: number;
|
|
21
30
|
/** True when this document is the site's /llms.txt served instead of a low-quality page. */
|
|
22
31
|
llmsTxtFallback?: boolean;
|
|
23
32
|
/** Set when the site publishes a usable /llms.txt index and this document is not that index. */
|
package/src/evidence.ts
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { parseHTML } from "linkedom";
|
|
2
|
+
|
|
3
|
+
const MAX_LINKS = 16;
|
|
4
|
+
const MAX_ANCHOR_CHARACTERS = 120;
|
|
5
|
+
|
|
6
|
+
/** Bounded, machine-readable reasons that an HTML extraction may be incomplete. */
|
|
7
|
+
export interface ExtractionDiagnostics {
|
|
8
|
+
javascriptRequired: boolean;
|
|
9
|
+
botWall: boolean;
|
|
10
|
+
consentInterstitial: boolean;
|
|
11
|
+
sparseExtraction: boolean;
|
|
12
|
+
rawCharacters: number;
|
|
13
|
+
extractedCharacters: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** One normalized link found in the fetched document. */
|
|
17
|
+
export interface ExtractedLink {
|
|
18
|
+
url: string;
|
|
19
|
+
anchorText: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Bounded links from the fetched document. Linked pages are never fetched implicitly. */
|
|
23
|
+
export interface ExtractedLinks {
|
|
24
|
+
internal: ExtractedLink[];
|
|
25
|
+
external: ExtractedLink[];
|
|
26
|
+
omittedInternal: number;
|
|
27
|
+
omittedExternal: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const JAVASCRIPT_MARKERS = [/please\s+enable\s+javascript/i, /javascript\s+(?:is\s+)?required/i];
|
|
31
|
+
const BOT_MARKERS = [
|
|
32
|
+
/are\s+you\s+a\s+robot/i,
|
|
33
|
+
/verify\s+you\s+are\s+human/i,
|
|
34
|
+
/checking\s+your\s+browser/i,
|
|
35
|
+
/<title>\s*just\s+a\s+moment/i,
|
|
36
|
+
];
|
|
37
|
+
const CONSENT_MARKERS = [
|
|
38
|
+
/manage\s+(?:your\s+)?consent/i,
|
|
39
|
+
/your\s+(?:privacy\s+)?consent/i,
|
|
40
|
+
/consent\s+to\s+(?:our\s+use\s+of\s+cookies|cookies)/i,
|
|
41
|
+
/accept\s+(?:all\s+)?cookies/i,
|
|
42
|
+
/we\s+use\s+cookies/i,
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
/** Produces explicit, bounded extraction-quality signals instead of one opaque shell flag. */
|
|
46
|
+
export function diagnoseExtraction(raw: string, markdown: string): ExtractionDiagnostics {
|
|
47
|
+
return {
|
|
48
|
+
javascriptRequired: JAVASCRIPT_MARKERS.some((marker) => marker.test(raw)),
|
|
49
|
+
botWall: BOT_MARKERS.some((marker) => marker.test(raw)),
|
|
50
|
+
consentInterstitial: CONSENT_MARKERS.some((marker) => marker.test(raw)),
|
|
51
|
+
sparseExtraction:
|
|
52
|
+
raw.length > 4_000 && markdown.length < 1_024 && markdown.length < raw.length * 0.008,
|
|
53
|
+
rawCharacters: raw.length,
|
|
54
|
+
extractedCharacters: markdown.length,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Checks if extraction diagnostics indicate any warning conditions.
|
|
60
|
+
*
|
|
61
|
+
* @param diagnostics - Extraction diagnostics to check
|
|
62
|
+
* @returns True if any warning condition is present, false otherwise
|
|
63
|
+
*/
|
|
64
|
+
export function hasExtractionWarning(diagnostics: ExtractionDiagnostics): boolean {
|
|
65
|
+
return (
|
|
66
|
+
diagnostics.javascriptRequired ||
|
|
67
|
+
diagnostics.botWall ||
|
|
68
|
+
diagnostics.consentInterstitial ||
|
|
69
|
+
diagnostics.sparseExtraction
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Normalizes and truncates anchor text for link extraction.
|
|
75
|
+
*
|
|
76
|
+
* @param value - Raw anchor text
|
|
77
|
+
* @returns Normalized text, truncated to MAX_ANCHOR_CHARACTERS if needed
|
|
78
|
+
*/
|
|
79
|
+
function normalizedAnchor(value: string): string {
|
|
80
|
+
const text = value.replace(/\s+/g, " ").trim();
|
|
81
|
+
return text.length > MAX_ANCHOR_CHARACTERS
|
|
82
|
+
? `${text.slice(0, MAX_ANCHOR_CHARACTERS - 1)}…`
|
|
83
|
+
: text;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Extracts a bounded set of safe HTTP(S) links without making any network requests. */
|
|
87
|
+
export function extractDocumentLinks(raw: string, pageUrl: URL): ExtractedLinks {
|
|
88
|
+
const { document } = parseHTML(raw);
|
|
89
|
+
const internal: ExtractedLink[] = [];
|
|
90
|
+
const external: ExtractedLink[] = [];
|
|
91
|
+
let omittedInternal = 0;
|
|
92
|
+
let omittedExternal = 0;
|
|
93
|
+
const seen = new Set<string>();
|
|
94
|
+
|
|
95
|
+
for (const element of document.querySelectorAll<HTMLAnchorElement>("a[href]")) {
|
|
96
|
+
const href = element.getAttribute("href");
|
|
97
|
+
if (!href) continue;
|
|
98
|
+
let target: URL;
|
|
99
|
+
try {
|
|
100
|
+
target = new URL(href, pageUrl);
|
|
101
|
+
} catch {
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
if (!["http:", "https:"].includes(target.protocol) || target.username || target.password)
|
|
105
|
+
continue;
|
|
106
|
+
target.hash = "";
|
|
107
|
+
const anchorText = normalizedAnchor(element.textContent ?? "");
|
|
108
|
+
const key = `${target.href}\0${anchorText}`;
|
|
109
|
+
if (seen.has(key)) continue;
|
|
110
|
+
seen.add(key);
|
|
111
|
+
const isInternal = target.origin === pageUrl.origin;
|
|
112
|
+
const bucket = isInternal ? internal : external;
|
|
113
|
+
if (bucket.length < MAX_LINKS) bucket.push({ url: target.href, anchorText });
|
|
114
|
+
else if (isInternal) omittedInternal += 1;
|
|
115
|
+
else omittedExternal += 1;
|
|
116
|
+
}
|
|
117
|
+
return { internal, external, omittedInternal, omittedExternal };
|
|
118
|
+
}
|
package/src/extract.ts
CHANGED
|
@@ -85,6 +85,31 @@ export function assertAbsoluteHttpUrl(value: string | URL): URL {
|
|
|
85
85
|
return url;
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
+
/** Resolves page-URL metadata that Defuddle otherwise parses without a base URL. */
|
|
89
|
+
function resolveDocumentRelativeMetadataUrls(document: Document, pageUrl: URL): void {
|
|
90
|
+
for (const meta of document.querySelectorAll<HTMLMetaElement>("meta[content]")) {
|
|
91
|
+
const key = (meta.getAttribute("property") ?? meta.getAttribute("name"))?.toLowerCase();
|
|
92
|
+
if (key !== "og:url" && key !== "twitter:url") continue;
|
|
93
|
+
const content = meta.getAttribute("content");
|
|
94
|
+
if (!content) continue;
|
|
95
|
+
try {
|
|
96
|
+
meta.setAttribute("content", new URL(content, pageUrl).href);
|
|
97
|
+
} catch {
|
|
98
|
+
meta.removeAttribute("content");
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
for (const link of document.querySelectorAll<HTMLLinkElement>('link[rel="canonical"][href]')) {
|
|
103
|
+
const href = link.getAttribute("href");
|
|
104
|
+
if (!href) continue;
|
|
105
|
+
try {
|
|
106
|
+
link.setAttribute("href", new URL(href, pageUrl).href);
|
|
107
|
+
} catch {
|
|
108
|
+
link.removeAttribute("href");
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
88
113
|
/**
|
|
89
114
|
* Type guard that checks if an unknown value is a string.
|
|
90
115
|
*
|
|
@@ -148,6 +173,104 @@ function deduplicateAdjacentLinks(markdown: string): string {
|
|
|
148
173
|
return out.join("\n").replace(/(\[[^\n]+?\([^)]+\)[^\n]*\n)\1/g, "$1");
|
|
149
174
|
}
|
|
150
175
|
|
|
176
|
+
const CSS_BLOCK_AT_RULE = /^\s*@(container|font-face|keyframes|layer|media|page|supports)\b/i;
|
|
177
|
+
const STANDALONE_CSS_RULE =
|
|
178
|
+
/^\s*(?:[.#][-_a-zA-Z]|\*\s*[.#:[>+~]|(?:html|body|main|article|nav|header|footer|aside)(?:\b|[.#:[>+~]))[^{}]*\{[^{}]*\}\s*$/i;
|
|
179
|
+
const COMPLETE_STYLE_ELEMENT = /<style\b[^>]*>[\s\S]*?<\/style\s*>/gi;
|
|
180
|
+
const CLOSING_STYLE_ELEMENT = /<\/style\s*>/i;
|
|
181
|
+
const OPENING_STYLE_ELEMENT = /<style\b[^>]*>/i;
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Iteratively removes complete style elements from a string to prevent nested fragment recreation.
|
|
185
|
+
*
|
|
186
|
+
* @param value - The string to strip style elements from
|
|
187
|
+
* @returns The string with all complete style elements removed
|
|
188
|
+
*/
|
|
189
|
+
function stripCompleteStyleElements(value: string): string {
|
|
190
|
+
let cleaned = value;
|
|
191
|
+
while (true) {
|
|
192
|
+
const next = cleaned.replace(COMPLETE_STYLE_ELEMENT, "");
|
|
193
|
+
if (next === cleaned) return cleaned;
|
|
194
|
+
cleaned = next;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Calculates the net brace depth change in a string (opening braces minus closing braces).
|
|
200
|
+
*
|
|
201
|
+
* @param value - The string to analyze
|
|
202
|
+
* @returns The difference between opening and closing brace counts
|
|
203
|
+
*/
|
|
204
|
+
function braceDelta(value: string): number {
|
|
205
|
+
return (value.match(/{/g)?.length ?? 0) - (value.match(/}/g)?.length ?? 0);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Removes leaked stylesheet fragments from extracted Markdown while preserving fenced examples.
|
|
210
|
+
*
|
|
211
|
+
* The matcher is deliberately conservative: it removes style elements, recognized block at-rules,
|
|
212
|
+
* and complete standalone selector/declaration lines. Prose that merely discusses CSS and fenced
|
|
213
|
+
* CSS/SCSS/Less examples remain untouched.
|
|
214
|
+
*/
|
|
215
|
+
export function stripExtractedCssCruft(markdown: string): string {
|
|
216
|
+
const output: string[] = [];
|
|
217
|
+
let fence: { character: string; length: number } | undefined;
|
|
218
|
+
let styleElement = false;
|
|
219
|
+
let cssBlockDepth = 0;
|
|
220
|
+
|
|
221
|
+
for (const originalLine of markdown.split("\n")) {
|
|
222
|
+
const fenceMatch = /^[ \t]{0,3}(`{3,}|~{3,})/.exec(originalLine);
|
|
223
|
+
if (!fence && fenceMatch) {
|
|
224
|
+
fence = { character: fenceMatch[1][0], length: fenceMatch[1].length };
|
|
225
|
+
output.push(originalLine);
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
if (fence) {
|
|
229
|
+
output.push(originalLine);
|
|
230
|
+
if (
|
|
231
|
+
fenceMatch &&
|
|
232
|
+
fenceMatch[1][0] === fence.character &&
|
|
233
|
+
fenceMatch[1].length >= fence.length &&
|
|
234
|
+
!originalLine.slice(fenceMatch[0].length).trim()
|
|
235
|
+
) {
|
|
236
|
+
fence = undefined;
|
|
237
|
+
}
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
let line = originalLine;
|
|
242
|
+
if (styleElement) {
|
|
243
|
+
const close = CLOSING_STYLE_ELEMENT.exec(line);
|
|
244
|
+
if (!close) continue;
|
|
245
|
+
line = line.slice(close.index + close[0].length);
|
|
246
|
+
styleElement = false;
|
|
247
|
+
}
|
|
248
|
+
line = stripCompleteStyleElements(line);
|
|
249
|
+
const open = OPENING_STYLE_ELEMENT.exec(line);
|
|
250
|
+
if (open) {
|
|
251
|
+
line = line.slice(0, open.index);
|
|
252
|
+
styleElement = true;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (cssBlockDepth > 0) {
|
|
256
|
+
cssBlockDepth += braceDelta(line);
|
|
257
|
+
if (cssBlockDepth < 0) cssBlockDepth = 0;
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
if (CSS_BLOCK_AT_RULE.test(line)) {
|
|
261
|
+
cssBlockDepth = Math.max(0, braceDelta(line));
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
if (STANDALONE_CSS_RULE.test(line)) continue;
|
|
265
|
+
output.push(line);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return output
|
|
269
|
+
.join("\n")
|
|
270
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
271
|
+
.trim();
|
|
272
|
+
}
|
|
273
|
+
|
|
151
274
|
/**
|
|
152
275
|
* Runs Defuddle over an already-normalized document with Markdown extraction enabled.
|
|
153
276
|
*
|
|
@@ -273,6 +396,10 @@ export async function extractHtmlToMarkdown(
|
|
|
273
396
|
try {
|
|
274
397
|
assertAbsoluteHttpUrl(baseUrl);
|
|
275
398
|
const { document } = parseHTML(html);
|
|
399
|
+
// Defuddle parses URL metadata without using its explicit page URL as the
|
|
400
|
+
// base. Resolve relative values first so it does not log ERR_INVALID_URL
|
|
401
|
+
// through Pi's TUI even though extraction succeeds.
|
|
402
|
+
resolveDocumentRelativeMetadataUrls(document, baseUrl);
|
|
276
403
|
removeMalformedSchemaOrgData(document);
|
|
277
404
|
normalizeSelectorUnsafeIds(document);
|
|
278
405
|
stripChromeWrappers(document, baseUrl);
|
|
@@ -281,7 +408,9 @@ export async function extractHtmlToMarkdown(
|
|
|
281
408
|
// `/owner/repo/releases`) and metadata against the real origin instead of
|
|
282
409
|
// dropping the scheme and host and constructing `new URL(pathname)`.
|
|
283
410
|
const result = await runDefuddle(document, baseUrl.href);
|
|
284
|
-
const markdown =
|
|
411
|
+
const markdown = stripExtractedCssCruft(
|
|
412
|
+
deduplicateAdjacentLinks(result?.content?.trim() ?? ""),
|
|
413
|
+
);
|
|
285
414
|
const trimmedTitle = result?.title?.trim();
|
|
286
415
|
if (markdown) {
|
|
287
416
|
return {
|
|
@@ -294,5 +423,9 @@ export async function extractHtmlToMarkdown(
|
|
|
294
423
|
} catch {
|
|
295
424
|
// Fall through to the basic converter for malformed or unsupported pages.
|
|
296
425
|
}
|
|
297
|
-
return {
|
|
426
|
+
return {
|
|
427
|
+
markdown: stripExtractedCssCruft(htmlToMarkdownFallback(html)),
|
|
428
|
+
extractor: "basic",
|
|
429
|
+
...advertised,
|
|
430
|
+
};
|
|
298
431
|
}
|
package/src/fetch.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import type { IncomingMessage } from "node:http";
|
|
2
2
|
import { awaitWithAbort } from "./abort";
|
|
3
3
|
import { sliceCompleteDocument, type CompleteDocument, type FetchResult } from "./content";
|
|
4
|
+
import { diagnoseExtraction, extractDocumentLinks, hasExtractionWarning } from "./evidence";
|
|
4
5
|
import { extractHtmlToMarkdown } from "./extract";
|
|
5
6
|
import { requestFollowingRedirects, type RedirectDependencies } from "./network-redirects";
|
|
6
7
|
import type { ValidatedTarget } from "./network-policy";
|
|
8
|
+
import { redactUrlForDisplay } from "./redact";
|
|
7
9
|
import {
|
|
8
10
|
decodeResponse,
|
|
9
11
|
FETCH_MAX_BYTES,
|
|
@@ -56,37 +58,9 @@ export function normalizeGitHubRawUrl(rawUrl: string): string {
|
|
|
56
58
|
}
|
|
57
59
|
}
|
|
58
60
|
|
|
59
|
-
|
|
60
|
-
/please\s+enable\s+javascript/i,
|
|
61
|
-
/enable\s+javascript/i,
|
|
62
|
-
// Consent is only treated as an interstitial signal when it appears in a
|
|
63
|
-
// cookie/consent-banner phrase. A bare "consent" matches ordinary prose
|
|
64
|
-
// (e.g. privacy articles) and must not flag readable content as a shell.
|
|
65
|
-
/manage\s+(your\s+)?consent/i,
|
|
66
|
-
/your\s+(privacy\s+)?consent/i,
|
|
67
|
-
/consent\s+to\s+(our\s+use\s+of\s+cookies|cookies)/i,
|
|
68
|
-
/accept\s+(all\s+)?cookies/i,
|
|
69
|
-
/we\s+use\s+cookies/i,
|
|
70
|
-
/are\s+you\s+a\s+robot/i,
|
|
71
|
-
/verify\s+you\s+are\s+human/i,
|
|
72
|
-
/checking\s+your\s+browser/i,
|
|
73
|
-
/<title>\s*just\s+a\s+moment/i,
|
|
74
|
-
];
|
|
75
|
-
|
|
76
|
-
/**
|
|
77
|
-
* Detects pages that are likely app shells, bot walls, or consent interstitials rather than
|
|
78
|
-
* readable content.
|
|
79
|
-
*
|
|
80
|
-
* @param raw - The raw response body
|
|
81
|
-
* @param markdown - The extracted Markdown
|
|
82
|
-
* @returns True when the extracted text is suspiciously sparse relative to the raw page
|
|
83
|
-
*/
|
|
61
|
+
/** @deprecated Prefer diagnoseExtraction for explicit extraction-quality signals. */
|
|
84
62
|
export function detectAppShell(raw: string, markdown: string): boolean {
|
|
85
|
-
|
|
86
|
-
// Require the extracted text to be both absolutely tiny and a very small
|
|
87
|
-
// fraction of the raw payload, so content-rich pages (e.g. React/Next.js SPAs
|
|
88
|
-
// whose raw HTML is dominated by inline scripts) are not mistaken for shells.
|
|
89
|
-
return raw.length > 4000 && markdown.length < 1024 && markdown.length < raw.length * 0.008;
|
|
63
|
+
return hasExtractionWarning(diagnoseExtraction(raw, markdown));
|
|
90
64
|
}
|
|
91
65
|
|
|
92
66
|
const REQUEST_TIMEOUT_MS = 20_000;
|
|
@@ -238,10 +212,13 @@ async function documentFromResponse(
|
|
|
238
212
|
}
|
|
239
213
|
} else markdown = raw.trim();
|
|
240
214
|
|
|
241
|
-
const
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
215
|
+
const isHtml = contentType === "text/html" || contentType === "application/xhtml+xml";
|
|
216
|
+
const extractionDiagnostics = isHtml ? diagnoseExtraction(raw, markdown) : undefined;
|
|
217
|
+
const shellSuspected = extractionDiagnostics
|
|
218
|
+
? hasExtractionWarning(extractionDiagnostics)
|
|
219
|
+
: false;
|
|
220
|
+
const etag = responseHeader(response, "etag");
|
|
221
|
+
const lastModified = responseHeader(response, "last-modified");
|
|
245
222
|
|
|
246
223
|
return {
|
|
247
224
|
url: target.url.toString(),
|
|
@@ -250,6 +227,10 @@ async function documentFromResponse(
|
|
|
250
227
|
title,
|
|
251
228
|
extractor,
|
|
252
229
|
shellSuspected,
|
|
230
|
+
extractionDiagnostics,
|
|
231
|
+
links: isHtml ? extractDocumentLinks(raw, target.url) : undefined,
|
|
232
|
+
validators: etag || lastModified ? { etag, lastModified } : undefined,
|
|
233
|
+
cachedAt: Date.now(),
|
|
253
234
|
llmsTxtDescribedBy: describedBy,
|
|
254
235
|
markdownAlternateUrl,
|
|
255
236
|
};
|
|
@@ -267,10 +248,10 @@ function assertAbsoluteHttpUrlForFetch(value: string): URL {
|
|
|
267
248
|
try {
|
|
268
249
|
url = new URL(value);
|
|
269
250
|
} catch {
|
|
270
|
-
throw new Error(
|
|
251
|
+
throw new Error("web_fetch received an invalid URL.");
|
|
271
252
|
}
|
|
272
253
|
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
273
|
-
throw new Error(`web_fetch only supports http(s) URLs: ${
|
|
254
|
+
throw new Error(`web_fetch only supports http(s) URLs: ${redactUrlForDisplay(url)}`);
|
|
274
255
|
}
|
|
275
256
|
return url;
|
|
276
257
|
}
|
|
@@ -284,11 +265,12 @@ function assertAbsoluteHttpUrlForFetch(value: string): URL {
|
|
|
284
265
|
* @returns The complete document with extracted markdown content
|
|
285
266
|
* @throws If the request times out, is cancelled, or encounters an error
|
|
286
267
|
*/
|
|
287
|
-
|
|
268
|
+
async function fetchDocument(
|
|
288
269
|
rawUrl: string,
|
|
289
270
|
signal: AbortSignal | undefined,
|
|
290
271
|
dependencies: FetchRemoteDependencies,
|
|
291
|
-
|
|
272
|
+
cached?: CompleteDocument,
|
|
273
|
+
): Promise<{ document: CompleteDocument; revalidated: boolean }> {
|
|
292
274
|
assertAbsoluteHttpUrlForFetch(normalizeGitHubRawUrl(rawUrl));
|
|
293
275
|
const controller = new AbortController();
|
|
294
276
|
const timeoutMs = dependencies.timeoutMs ?? REQUEST_TIMEOUT_MS;
|
|
@@ -300,17 +282,36 @@ export async function fetchCompleteDocument(
|
|
|
300
282
|
}, timeoutMs);
|
|
301
283
|
const cancel = () => controller.abort();
|
|
302
284
|
signal?.addEventListener("abort", cancel, { once: true });
|
|
285
|
+
const conditionalHeaders: Record<string, string> = {};
|
|
286
|
+
if (cached?.validators?.etag) conditionalHeaders["If-None-Match"] = cached.validators.etag;
|
|
287
|
+
if (cached?.validators?.lastModified)
|
|
288
|
+
conditionalHeaders["If-Modified-Since"] = cached.validators.lastModified;
|
|
303
289
|
|
|
304
290
|
try {
|
|
305
291
|
const { target, response } = await requestFollowingRedirects(
|
|
306
292
|
normalizeGitHubRawUrl(rawUrl),
|
|
307
293
|
controller.signal,
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
request: dependencies.request,
|
|
311
|
-
},
|
|
294
|
+
dependencies,
|
|
295
|
+
conditionalHeaders,
|
|
312
296
|
);
|
|
313
|
-
|
|
297
|
+
if (response.statusCode === 304 && cached) {
|
|
298
|
+
response.resume();
|
|
299
|
+
const etag = responseHeader(response, "etag") ?? cached.validators?.etag;
|
|
300
|
+
const lastModified =
|
|
301
|
+
responseHeader(response, "last-modified") ?? cached.validators?.lastModified;
|
|
302
|
+
return {
|
|
303
|
+
document: {
|
|
304
|
+
...cached,
|
|
305
|
+
validators: etag || lastModified ? { etag, lastModified } : undefined,
|
|
306
|
+
cachedAt: Date.now(),
|
|
307
|
+
},
|
|
308
|
+
revalidated: true,
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
return {
|
|
312
|
+
document: await documentFromResponse(target, response, controller.signal, extractHtml),
|
|
313
|
+
revalidated: false,
|
|
314
|
+
};
|
|
314
315
|
} catch (error) {
|
|
315
316
|
if (timedOut) throw new Error(`web_fetch timed out after ${timeoutMs / 1000} seconds.`);
|
|
316
317
|
if (signal?.aborted) throw new Error("web_fetch was cancelled.");
|
|
@@ -321,6 +322,24 @@ export async function fetchCompleteDocument(
|
|
|
321
322
|
}
|
|
322
323
|
}
|
|
323
324
|
|
|
325
|
+
export async function fetchCompleteDocument(
|
|
326
|
+
rawUrl: string,
|
|
327
|
+
signal: AbortSignal | undefined,
|
|
328
|
+
dependencies: FetchRemoteDependencies,
|
|
329
|
+
): Promise<CompleteDocument> {
|
|
330
|
+
return (await fetchDocument(rawUrl, signal, dependencies)).document;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** Revalidates a stale representation with its ETag and Last-Modified validators. */
|
|
334
|
+
export async function revalidateCompleteDocument(
|
|
335
|
+
rawUrl: string,
|
|
336
|
+
cached: CompleteDocument,
|
|
337
|
+
signal: AbortSignal | undefined,
|
|
338
|
+
dependencies: FetchRemoteDependencies,
|
|
339
|
+
): Promise<{ document: CompleteDocument; revalidated: boolean }> {
|
|
340
|
+
return fetchDocument(rawUrl, signal, dependencies, cached);
|
|
341
|
+
}
|
|
342
|
+
|
|
324
343
|
/**
|
|
325
344
|
* Fetches a URL and returns a slice of the content starting at the given offset.
|
|
326
345
|
*
|