@zeldrisho/pi-web-fetch 0.6.0 → 0.6.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 +8 -1
- package/package.json +5 -5
- package/src/extract.ts +70 -8
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.6.1] - 2026-08-23
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- Preserve the absolute URL when extracting HTML so Defuddle never constructs `new URL(pathname)` for GitHub-style host + path pages, and guard Defuddle failures (including detached rejections) so extraction always falls back to the basic converter instead of surfacing an `ERR_INVALID_URL` to the caller
|
|
15
|
+
|
|
10
16
|
## [0.6.0] - 2026-08-20
|
|
11
17
|
|
|
12
18
|
### Added
|
|
@@ -87,7 +93,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
87
93
|
|
|
88
94
|
- Add bounded public page fetching ([b979496](https://github.com/zeldrisho/pi-packages/commit/b979496b32de1cead172ba570307e4a4a7b3421d))
|
|
89
95
|
|
|
90
|
-
[Unreleased]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.6.
|
|
96
|
+
[Unreleased]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.6.1...HEAD
|
|
97
|
+
[0.6.1]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.6.0...pi-web-fetch-v0.6.1
|
|
91
98
|
[0.6.0]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.5.4...pi-web-fetch-v0.6.0
|
|
92
99
|
[0.5.4]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.5.3...pi-web-fetch-v0.5.4
|
|
93
100
|
[0.5.3]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.5.2...pi-web-fetch-v0.5.3
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zeldrisho/pi-web-fetch",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.1",
|
|
4
4
|
"description": "Pi extension for secure, bounded public web page fetching and Markdown extraction",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi",
|
|
@@ -35,10 +35,10 @@
|
|
|
35
35
|
"linkedom": "^0.18.13"
|
|
36
36
|
},
|
|
37
37
|
"devDependencies": {
|
|
38
|
-
"@earendil-works/pi-coding-agent": "^0.84.
|
|
39
|
-
"@earendil-works/pi-tui": "^0.84.
|
|
40
|
-
"typebox": "^1.
|
|
41
|
-
"typescript": "^7.0.
|
|
38
|
+
"@earendil-works/pi-coding-agent": "^0.84.2",
|
|
39
|
+
"@earendil-works/pi-tui": "^0.84.2",
|
|
40
|
+
"typebox": "^1.3.16",
|
|
41
|
+
"typescript": "^7.0.2",
|
|
42
42
|
"vite": "npm:@voidzero-dev/vite-plus-core@0.2.9",
|
|
43
43
|
"vite-plus": "0.2.9"
|
|
44
44
|
},
|
package/src/extract.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { parseHTML } from "linkedom";
|
|
2
|
+
import type { DefuddleResponse } from "defuddle/node";
|
|
2
3
|
|
|
3
4
|
const RAW_ID_SELECTOR_SAFE = /^-?[_a-zA-Z][-_a-zA-Z0-9]*$/;
|
|
4
5
|
|
|
@@ -75,11 +76,73 @@ export function htmlToMarkdownFallback(html: string): string {
|
|
|
75
76
|
.trim();
|
|
76
77
|
}
|
|
77
78
|
|
|
79
|
+
/**
|
|
80
|
+
* Runs Defuddle over an already-normalized document with Markdown extraction enabled.
|
|
81
|
+
*
|
|
82
|
+
* Defuddle can fail in two distinct ways. The common case rejects the promise
|
|
83
|
+
* we `await` below, which the caller's `try/catch` catches and turns into a
|
|
84
|
+
* fallback. The dangerous case is when Defuddle schedules a throw on a
|
|
85
|
+
* *detached* microtask or timer — for example, when it resolves a
|
|
86
|
+
* document-relative link such as `/owner/repo/releases` into
|
|
87
|
+
* `new URL(relative, undefined)` *after* its own promise has already resolved.
|
|
88
|
+
* That rejection never reaches the `await` and instead escapes as an unhandled
|
|
89
|
+
* rejection that bypasses the surrounding `try/catch` and crashes the calling
|
|
90
|
+
* harness UI. Passing the absolute `pageUrl` prevents the URL-resolution form
|
|
91
|
+
* of this failure, but the guard below still covers any residual detached
|
|
92
|
+
* rejection.
|
|
93
|
+
*
|
|
94
|
+
* To keep `extractHtmlToMarkdown` from ever propagating such a failure, this
|
|
95
|
+
* helper arms a scoped `unhandledRejection` listener for the lifetime of the
|
|
96
|
+
* call. The listener is scoped, not process-wide in effect: it only treats a
|
|
97
|
+
* rejection as a Defuddle failure when its message or stack mentions Defuddle,
|
|
98
|
+
* so unrelated rejections from other concurrent work are ignored and do not
|
|
99
|
+
* force a spurious fallback to the basic extractor. After Defuddle resolves we
|
|
100
|
+
* flush a microtask and a macrotask so any rejection Defuddle scheduled settles
|
|
101
|
+
* inside the armed window; a rejection observed there is re-thrown so the
|
|
102
|
+
* caller falls back. Deeply-nested timers in Defuddle are out of scope and would
|
|
103
|
+
* still surface as a logged (non-crashing) unhandled rejection.
|
|
104
|
+
*
|
|
105
|
+
* @param document - The normalized document to parse
|
|
106
|
+
* @param pageUrl - The absolute URL of the page, used to resolve relative links
|
|
107
|
+
* @returns The Defuddle result, or `undefined` when extraction must fall back
|
|
108
|
+
*/
|
|
109
|
+
async function runDefuddle(
|
|
110
|
+
document: Document,
|
|
111
|
+
pageUrl: string,
|
|
112
|
+
): Promise<DefuddleResponse | undefined> {
|
|
113
|
+
let escapedRejection: unknown = undefined;
|
|
114
|
+
let armed = true;
|
|
115
|
+
// Only attribute a rejection to Defuddle when it mentions Defuddle. This keeps
|
|
116
|
+
// the guard scoped so unrelated concurrent rejections are ignored.
|
|
117
|
+
const captureUnhandled = (cause: unknown): void => {
|
|
118
|
+
if (!armed || escapedRejection !== undefined) return;
|
|
119
|
+
const detail =
|
|
120
|
+
cause instanceof Error ? `${cause.message}\n${cause.stack ?? ""}` : String(cause);
|
|
121
|
+
if (/defuddle/i.test(detail)) escapedRejection = cause;
|
|
122
|
+
};
|
|
123
|
+
process.on("unhandledRejection", captureUnhandled);
|
|
124
|
+
try {
|
|
125
|
+
const { Defuddle } = await import("defuddle/node");
|
|
126
|
+
const result = await Defuddle(document, pageUrl, { markdown: true, useAsync: false });
|
|
127
|
+
// Let Defuddle's scheduled microtask/macrotask work settle so a detached
|
|
128
|
+
// rejection is observed by the guard instead of reaching the harness.
|
|
129
|
+
await Promise.resolve();
|
|
130
|
+
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
131
|
+
if (escapedRejection !== undefined) throw escapedRejection;
|
|
132
|
+
return result;
|
|
133
|
+
} catch {
|
|
134
|
+
return undefined;
|
|
135
|
+
} finally {
|
|
136
|
+
armed = false;
|
|
137
|
+
process.off("unhandledRejection", captureUnhandled);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
78
141
|
/**
|
|
79
142
|
* Extracts readable Markdown and an optional title from HTML.
|
|
80
143
|
*
|
|
81
144
|
* @param html - The HTML document to convert
|
|
82
|
-
* @param baseUrl - The base URL used to resolve document-relative links
|
|
145
|
+
* @param baseUrl - The absolute base URL used to resolve document-relative links
|
|
83
146
|
* @returns The extracted Markdown, optional title, and extractor used
|
|
84
147
|
*/
|
|
85
148
|
export async function extractHtmlToMarkdown(
|
|
@@ -87,16 +150,15 @@ export async function extractHtmlToMarkdown(
|
|
|
87
150
|
baseUrl: URL,
|
|
88
151
|
): Promise<{ markdown: string; title?: string; extractor: "defuddle" | "basic" }> {
|
|
89
152
|
try {
|
|
90
|
-
const { Defuddle } = await import("defuddle/node");
|
|
91
153
|
const { document } = parseHTML(html);
|
|
92
154
|
removeMalformedSchemaOrgData(document);
|
|
93
155
|
normalizeSelectorUnsafeIds(document);
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
const markdown = result
|
|
99
|
-
const trimmedTitle = result
|
|
156
|
+
// Pass the full absolute URL so Defuddle resolves relative links (e.g.
|
|
157
|
+
// `/owner/repo/releases`) and metadata against the real origin instead of
|
|
158
|
+
// dropping the scheme and host and constructing `new URL(pathname)`.
|
|
159
|
+
const result = await runDefuddle(document, baseUrl.href);
|
|
160
|
+
const markdown = result?.content?.trim() ?? "";
|
|
161
|
+
const trimmedTitle = result?.title?.trim();
|
|
100
162
|
if (markdown) {
|
|
101
163
|
return {
|
|
102
164
|
markdown,
|