pi-unsloth-webtools 0.7.6 → 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/README.md +151 -84
- package/entities.ts +2 -2
- package/html-to-md.ts +24 -9
- package/index.ts +96 -166
- package/lightpanda.ts +276 -0
- package/package.json +13 -5
- package/proxy.ts +2 -1
- package/scripts/install-lightpanda.sh +206 -0
- package/settings.ts +57 -11
- package/tls-fetch.ts +205 -0
- package/web-access.ts +4 -0
- package/web-fetch.ts +175 -51
- package/config-ui.ts +0 -105
- package/config.ts +0 -207
- package/web-render.ts +0 -100
package/index.ts
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
import { defineTool, type ExtensionAPI, type ExtensionContext, type Theme } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { Type } from "typebox";
|
|
3
3
|
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
4
|
-
import { collapseWhitespace } from "./html-to-md.ts";
|
|
4
|
+
import { collapseWhitespace, visibleChars } from "./html-to-md.ts";
|
|
5
5
|
import { SEARCH_TIMEOUT_MS, webSearch as defaultWebSearch } from "./web-search.ts";
|
|
6
|
-
import {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
6
|
+
import {
|
|
7
|
+
DEFAULT_FETCH_TIMEOUT_MS,
|
|
8
|
+
fetchPageOutcome as defaultFetchPageOutcome,
|
|
9
|
+
fetchPageText as defaultFetchPageText,
|
|
10
|
+
type FetchPageOutcome,
|
|
11
|
+
} from "./web-fetch.ts";
|
|
12
|
+
import { renderPageWithLightpanda as defaultRenderLocalPageText } from "./lightpanda.ts";
|
|
13
|
+
import { loadDefaultFetchSettings, loadDefaultFetchTimeoutMs, loadLightpandaSettings } from "./settings.ts";
|
|
11
14
|
|
|
12
15
|
function toolCallLine(theme: Theme, name: string, detail: string) {
|
|
13
16
|
const line = theme.fg("toolTitle", theme.bold(name)) + (detail ? ` ${theme.fg("accent", detail)}` : "");
|
|
@@ -23,6 +26,7 @@ function positiveNumber(value: unknown): number | undefined {
|
|
|
23
26
|
const n = Math.floor(value);
|
|
24
27
|
return n > 0 ? n : undefined;
|
|
25
28
|
}
|
|
29
|
+
|
|
26
30
|
async function fetchDefaults(cwd: string | undefined, params: { timeoutMs?: unknown; maxChars?: unknown }) {
|
|
27
31
|
const timeoutParam = positiveNumber(params.timeoutMs);
|
|
28
32
|
const maxCharsParam = positiveNumber(params.maxChars);
|
|
@@ -32,6 +36,7 @@ async function fetchDefaults(cwd: string | undefined, params: { timeoutMs?: unkn
|
|
|
32
36
|
maxChars: maxCharsParam ?? defaults.maxChars,
|
|
33
37
|
allowPrivateAddresses: defaults.allowPrivateAddresses,
|
|
34
38
|
allowLocalFiles: defaults.allowLocalFiles,
|
|
39
|
+
transport: defaults.transport,
|
|
35
40
|
};
|
|
36
41
|
}
|
|
37
42
|
|
|
@@ -39,24 +44,53 @@ function fetchWasForbidden(text: string): boolean {
|
|
|
39
44
|
return /^Failed to fetch URL: HTTP 403\b/m.test(text);
|
|
40
45
|
}
|
|
41
46
|
|
|
42
|
-
|
|
47
|
+
function renderFailed(rendered: string): boolean {
|
|
48
|
+
return (
|
|
49
|
+
rendered.startsWith("Failed to render URL:") ||
|
|
50
|
+
rendered.startsWith("Blocked:") ||
|
|
51
|
+
rendered.startsWith("(page returned no readable text)")
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function renderImprovesPage(rendered: string, fetched: string): boolean {
|
|
56
|
+
return visibleChars(rendered) > visibleChars(fetched) * 1.5;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const LOCAL_FORBIDDEN_NOTE = "Direct fetch failed with HTTP 403; rendered locally with Lightpanda instead.";
|
|
60
|
+
const LOCAL_THIN_NOTE = "Direct fetch returned little content; rendered locally with Lightpanda instead.";
|
|
61
|
+
const INCOMPLETE_NOTE = "\n\n*(JavaScript-rendered page; content may be incomplete)*";
|
|
62
|
+
const RENDER_HEADER_LINE_RE = /^(?:URL: |Rendered via |Title: |Author: |Date: |Site: )/;
|
|
63
|
+
|
|
64
|
+
function renderedProseChars(text: string): number {
|
|
65
|
+
const prose = text
|
|
66
|
+
.split("\n")
|
|
67
|
+
.filter((line) => !RENDER_HEADER_LINE_RE.test(line))
|
|
68
|
+
.join("\n");
|
|
69
|
+
return visibleChars(prose);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const INTERSTITIAL_MAX_PROSE_CHARS = 4000;
|
|
73
|
+
const INTERSTITIAL_MARKERS = [
|
|
74
|
+
"just a moment",
|
|
75
|
+
"attention required",
|
|
76
|
+
"you have been blocked",
|
|
77
|
+
"enable javascript and cookies to continue",
|
|
78
|
+
"checking if the site connection is secure",
|
|
79
|
+
"access denied",
|
|
80
|
+
"please enable cookies",
|
|
81
|
+
"cf-error-details",
|
|
82
|
+
];
|
|
83
|
+
|
|
84
|
+
function looksLikeInterstitial(text: string): boolean {
|
|
85
|
+
if (renderedProseChars(text) > INTERSTITIAL_MAX_PROSE_CHARS) return false;
|
|
86
|
+
const lowered = text.toLowerCase();
|
|
87
|
+
return INTERSTITIAL_MARKERS.some((marker) => lowered.includes(marker));
|
|
88
|
+
}
|
|
43
89
|
|
|
44
90
|
const WebSearchParams = Type.Object({
|
|
45
91
|
query: Type.Optional(
|
|
46
92
|
Type.String({ description: "The search query" }),
|
|
47
93
|
),
|
|
48
|
-
url: Type.Optional(
|
|
49
|
-
Type.String({
|
|
50
|
-
description:
|
|
51
|
-
"A URL to fetch full page content from (instead of searching). Use this to read a page found in search results.",
|
|
52
|
-
}),
|
|
53
|
-
),
|
|
54
|
-
maxChars: Type.Optional(
|
|
55
|
-
Type.Number({
|
|
56
|
-
description:
|
|
57
|
-
"Truncate the fetched page to this many characters (only used with the url parameter)",
|
|
58
|
-
}),
|
|
59
|
-
),
|
|
60
94
|
maxResults: Type.Optional(
|
|
61
95
|
Type.Number({
|
|
62
96
|
minimum: 1,
|
|
@@ -87,55 +121,43 @@ const WebFetchParams = Type.Object({
|
|
|
87
121
|
),
|
|
88
122
|
});
|
|
89
123
|
|
|
90
|
-
const WebRenderParams = Type.Object({
|
|
91
|
-
url: Type.String({ description: "Public URL of the page to render" }),
|
|
92
|
-
maxChars: Type.Optional(
|
|
93
|
-
Type.Number({
|
|
94
|
-
description: "Truncate the returned content to this many characters (default: no limit)",
|
|
95
|
-
}),
|
|
96
|
-
),
|
|
97
|
-
timeoutMs: Type.Optional(
|
|
98
|
-
Type.Number({
|
|
99
|
-
minimum: 1000,
|
|
100
|
-
description: "Overall timeout in milliseconds (default: 60000)",
|
|
101
|
-
}),
|
|
102
|
-
),
|
|
103
|
-
});
|
|
104
|
-
|
|
105
124
|
export interface WebToolsDeps {
|
|
106
125
|
fetchPageText?: typeof defaultFetchPageText;
|
|
126
|
+
fetchPageOutcome?: typeof defaultFetchPageOutcome;
|
|
107
127
|
webSearch?: typeof defaultWebSearch;
|
|
108
|
-
|
|
109
|
-
webRenderEnabled?: () => Promise<boolean>;
|
|
128
|
+
renderLocalPageText?: typeof defaultRenderLocalPageText;
|
|
110
129
|
}
|
|
111
130
|
|
|
112
131
|
export function createWebTools(deps: WebToolsDeps = {}) {
|
|
113
|
-
const fetchPageText = deps.fetchPageText ?? defaultFetchPageText;
|
|
114
132
|
const webSearch = deps.webSearch ?? defaultWebSearch;
|
|
115
|
-
const
|
|
116
|
-
const
|
|
117
|
-
|
|
133
|
+
const renderLocalPageText = deps.renderLocalPageText ?? defaultRenderLocalPageText;
|
|
134
|
+
const legacyFetchPageText = deps.fetchPageText;
|
|
135
|
+
const fetchOutcome: typeof defaultFetchPageOutcome =
|
|
136
|
+
deps.fetchPageOutcome ??
|
|
137
|
+
(legacyFetchPageText
|
|
138
|
+
? async (url, options) => ({ text: await legacyFetchPageText(url, options), hint: null })
|
|
139
|
+
: defaultFetchPageOutcome);
|
|
118
140
|
|
|
119
|
-
const
|
|
141
|
+
const renderFallback = async (
|
|
120
142
|
url: string,
|
|
121
|
-
|
|
143
|
+
outcome: FetchPageOutcome,
|
|
122
144
|
options: { timeoutMs: number; maxChars?: number; signal?: AbortSignal; cwd?: string },
|
|
123
145
|
): Promise<string | null> => {
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
146
|
+
const forbidden = fetchWasForbidden(outcome.text);
|
|
147
|
+
const thin = outcome.hint !== null && !forbidden;
|
|
148
|
+
if (!forbidden && !thin) return null;
|
|
149
|
+
const incomplete = outcome.text + INCOMPLETE_NOTE;
|
|
150
|
+
const settings = await loadLightpandaSettings(options.cwd).catch(() => null);
|
|
151
|
+
const rendered = await renderLocalPageText(url, {
|
|
152
|
+
timeoutMs: options.timeoutMs,
|
|
153
|
+
maxChars: options.maxChars,
|
|
154
|
+
signal: options.signal,
|
|
155
|
+
settings,
|
|
156
|
+
}).catch(() => null);
|
|
157
|
+
if (rendered === null) return thin ? incomplete : null;
|
|
158
|
+
if (renderFailed(rendered) || looksLikeInterstitial(rendered)) return thin ? incomplete : null;
|
|
159
|
+
if (thin && !renderImprovesPage(rendered, outcome.text)) return incomplete;
|
|
160
|
+
return (forbidden ? LOCAL_FORBIDDEN_NOTE : LOCAL_THIN_NOTE) + "\n\n" + rendered;
|
|
139
161
|
};
|
|
140
162
|
|
|
141
163
|
return {
|
|
@@ -143,38 +165,17 @@ export function createWebTools(deps: WebToolsDeps = {}) {
|
|
|
143
165
|
name: "web_search",
|
|
144
166
|
label: "Web Search",
|
|
145
167
|
description:
|
|
146
|
-
"Search the web and return snippets for the top results.
|
|
168
|
+
"Search the web and return snippets for the top results. Use web_fetch to read a page found in the results.",
|
|
147
169
|
promptSnippet: "Search the web and return snippets",
|
|
148
170
|
promptGuidelines: [
|
|
149
|
-
"Web tool order: web_search to discover,
|
|
171
|
+
"Web tool order: web_search to discover, then web_fetch to read a page.",
|
|
150
172
|
],
|
|
151
173
|
parameters: WebSearchParams,
|
|
152
174
|
renderCall(args, theme) {
|
|
153
|
-
const url = collapsedArg(args.url);
|
|
154
175
|
const query = collapsedArg(args.query);
|
|
155
|
-
return toolCallLine(theme, "web_search",
|
|
176
|
+
return toolCallLine(theme, "web_search", query ? `"${query}"` : "");
|
|
156
177
|
},
|
|
157
178
|
async execute(_toolCallId, params, signal, onUpdate, _ctx) {
|
|
158
|
-
if (params.url?.trim()) {
|
|
159
|
-
const url = params.url.trim();
|
|
160
|
-
onUpdate?.({ content: [{ type: "text", text: `Fetching ${url}...` }], details: {} });
|
|
161
|
-
const cwd = (_ctx as ExtensionContext | undefined)?.cwd;
|
|
162
|
-
const { timeoutMs, maxChars, allowPrivateAddresses, allowLocalFiles } = await fetchDefaults(cwd, params);
|
|
163
|
-
const text = await fetchPageText(url, {
|
|
164
|
-
timeoutMs,
|
|
165
|
-
signal: signal ?? undefined,
|
|
166
|
-
maxChars,
|
|
167
|
-
allowPrivateAddresses,
|
|
168
|
-
allowLocalFiles,
|
|
169
|
-
});
|
|
170
|
-
const rendered = await renderOnForbidden(url, text, {
|
|
171
|
-
timeoutMs,
|
|
172
|
-
maxChars,
|
|
173
|
-
signal: signal ?? undefined,
|
|
174
|
-
cwd,
|
|
175
|
-
});
|
|
176
|
-
return { content: [{ type: "text", text: rendered ?? text }], details: {} };
|
|
177
|
-
}
|
|
178
179
|
onUpdate?.({ content: [{ type: "text", text: "Searching the web..." }], details: {} });
|
|
179
180
|
const timeoutParam = positiveNumber(params.timeoutMs);
|
|
180
181
|
const searchCwd = (_ctx as ExtensionContext | undefined)?.cwd;
|
|
@@ -194,13 +195,16 @@ export function createWebTools(deps: WebToolsDeps = {}) {
|
|
|
194
195
|
description:
|
|
195
196
|
"Fetch a URL and return its readable text. HTML pages are converted to Markdown using a " +
|
|
196
197
|
"main-content heuristic: article/main scoping plus hidden-element and boilerplate " +
|
|
197
|
-
"stripping.
|
|
198
|
+
"stripping. HTTP 403 responses are retried over a browser-fingerprint transport, and pages " +
|
|
199
|
+
"that look JavaScript-rendered (or still refused) are rendered locally with Lightpanda when " +
|
|
200
|
+
"the binary is installed; no third-party rendering service is involved. " +
|
|
201
|
+
"Non-HTML text is returned as-is. GitHub repo root pages are rewritten to the " +
|
|
198
202
|
"README API, so the README is returned instead of the repo page's UI chrome. " +
|
|
199
203
|
"Private/loopback/link-local targets and local files (file:// URLs, absolute, ~/ or ./ paths, including " +
|
|
200
204
|
"PDFs) are supported by default; opt out with webFetch.allowPrivateAddresses: false or " +
|
|
201
205
|
"webFetch.allowLocalFiles: false in settings. The download size is capped.",
|
|
202
206
|
promptGuidelines: [
|
|
203
|
-
"web_fetch
|
|
207
|
+
"web_fetch renders JavaScript-rendered pages locally with Lightpanda when its binary is installed; it never calls a third-party rendering service.",
|
|
204
208
|
],
|
|
205
209
|
promptSnippet: "Fetch a web page and return readable text content",
|
|
206
210
|
parameters: WebFetchParams,
|
|
@@ -210,105 +214,31 @@ export function createWebTools(deps: WebToolsDeps = {}) {
|
|
|
210
214
|
async execute(_toolCallId, params, signal, onUpdate, _ctx) {
|
|
211
215
|
onUpdate?.({ content: [{ type: "text", text: `Fetching ${params.url}...` }], details: {} });
|
|
212
216
|
const cwd = (_ctx as ExtensionContext | undefined)?.cwd;
|
|
213
|
-
const { timeoutMs, maxChars, allowPrivateAddresses, allowLocalFiles } = await fetchDefaults(cwd, params);
|
|
214
|
-
const
|
|
217
|
+
const { timeoutMs, maxChars, allowPrivateAddresses, allowLocalFiles, transport } = await fetchDefaults(cwd, params);
|
|
218
|
+
const deadlineMs = Date.now() + timeoutMs;
|
|
219
|
+
const outcome = await fetchOutcome(params.url, {
|
|
215
220
|
timeoutMs,
|
|
221
|
+
deadlineMs,
|
|
216
222
|
signal: signal ?? undefined,
|
|
217
223
|
maxChars,
|
|
218
224
|
allowPrivateAddresses,
|
|
219
225
|
allowLocalFiles,
|
|
226
|
+
transport,
|
|
220
227
|
});
|
|
221
|
-
const rendered = await
|
|
222
|
-
timeoutMs,
|
|
228
|
+
const rendered = await renderFallback(params.url, outcome, {
|
|
229
|
+
timeoutMs: Math.max(1, deadlineMs - Date.now()),
|
|
223
230
|
maxChars,
|
|
224
231
|
signal: signal ?? undefined,
|
|
225
232
|
cwd,
|
|
226
233
|
});
|
|
227
|
-
return { content: [{ type: "text", text: rendered ?? text }], details: {} };
|
|
228
|
-
},
|
|
229
|
-
}),
|
|
230
|
-
webRenderTool: defineTool({
|
|
231
|
-
name: "web_render",
|
|
232
|
-
label: "Web Render",
|
|
233
|
-
description:
|
|
234
|
-
"Render a public web page to Markdown through the third-party Jina Reader (r.jina.ai). " +
|
|
235
|
-
"Use it when web_fetch cannot read the page: JavaScript-rendered pages, or pages where web_fetch returned \"(page returned no readable text)\". " +
|
|
236
|
-
"The target URL is sent to Jina; local files and non-public addresses are refused. " +
|
|
237
|
-
"An optional JINA_API_KEY or unslothWebTools.jinaApiKey raises the Reader's rate limits.",
|
|
238
|
-
promptSnippet: "Render a JavaScript-rendered page to Markdown via the Jina Reader",
|
|
239
|
-
parameters: WebRenderParams,
|
|
240
|
-
renderCall(args, theme) {
|
|
241
|
-
return toolCallLine(theme, "web_render", collapsedArg(args.url));
|
|
242
|
-
},
|
|
243
|
-
async execute(_toolCallId, params, signal, onUpdate, _ctx) {
|
|
244
|
-
onUpdate?.({ content: [{ type: "text", text: `Rendering ${params.url}...` }], details: {} });
|
|
245
|
-
const cwd = (_ctx as ExtensionContext | undefined)?.cwd;
|
|
246
|
-
const { timeoutMs, maxChars } = await fetchDefaults(cwd, params);
|
|
247
|
-
const apiKey = await loadJinaApiKey(cwd);
|
|
248
|
-
const text = await renderPageText(params.url, {
|
|
249
|
-
timeoutMs,
|
|
250
|
-
maxChars,
|
|
251
|
-
signal: signal ?? undefined,
|
|
252
|
-
apiKey,
|
|
253
|
-
});
|
|
254
|
-
return { content: [{ type: "text", text }], details: {} };
|
|
234
|
+
return { content: [{ type: "text", text: rendered ?? outcome.text }], details: {} };
|
|
255
235
|
},
|
|
256
236
|
}),
|
|
257
237
|
};
|
|
258
238
|
}
|
|
259
239
|
|
|
260
240
|
export default function (pi: ExtensionAPI) {
|
|
261
|
-
const { webSearchTool, webFetchTool
|
|
241
|
+
const { webSearchTool, webFetchTool } = createWebTools();
|
|
262
242
|
pi.registerTool(webSearchTool);
|
|
263
243
|
pi.registerTool(webFetchTool);
|
|
264
|
-
pi.registerTool(webRenderTool);
|
|
265
|
-
|
|
266
|
-
pi.on("session_start", async (_event, ctx) => {
|
|
267
|
-
try {
|
|
268
|
-
const { config, corrupted } = await readConfigWithStatus();
|
|
269
|
-
if (corrupted && ctx.hasUI) {
|
|
270
|
-
ctx.ui.notify("Web tools config was corrupt and was reset to defaults", "warning");
|
|
271
|
-
}
|
|
272
|
-
if (!config.webRenderEnabled) {
|
|
273
|
-
pi.setActiveTools(pi.getActiveTools().filter((name) => name !== "web_render"));
|
|
274
|
-
}
|
|
275
|
-
} catch (error) {
|
|
276
|
-
console.error("Failed to load web tools config:", error);
|
|
277
|
-
}
|
|
278
|
-
});
|
|
279
|
-
|
|
280
|
-
pi.registerCommand("webtools-config", {
|
|
281
|
-
description: "Open the web tools settings window (web_render on/off)",
|
|
282
|
-
handler: async (_args, ctx) => {
|
|
283
|
-
if (!ctx.hasUI) {
|
|
284
|
-
ctx.ui.notify("/webtools-config requires interactive mode", "error");
|
|
285
|
-
return;
|
|
286
|
-
}
|
|
287
|
-
await ctx.ui.custom<void>(
|
|
288
|
-
async (tui, theme, _keybindings, done) => {
|
|
289
|
-
const overlay = new WebToolsConfigOverlay({
|
|
290
|
-
tui,
|
|
291
|
-
theme,
|
|
292
|
-
done,
|
|
293
|
-
onToggle: async (key) => {
|
|
294
|
-
if (key !== "webRenderEnabled") return;
|
|
295
|
-
const enabled = await toggleWebRender();
|
|
296
|
-
const active = pi.getActiveTools();
|
|
297
|
-
pi.setActiveTools(
|
|
298
|
-
enabled
|
|
299
|
-
? [...new Set([...active, "web_render"])]
|
|
300
|
-
: active.filter((name) => name !== "web_render"),
|
|
301
|
-
);
|
|
302
|
-
},
|
|
303
|
-
});
|
|
304
|
-
await overlay.load();
|
|
305
|
-
return overlay;
|
|
306
|
-
},
|
|
307
|
-
{
|
|
308
|
-
overlay: true,
|
|
309
|
-
overlayOptions: { anchor: "center", width: "90%", minWidth: 60, maxHeight: "90%" },
|
|
310
|
-
},
|
|
311
|
-
);
|
|
312
|
-
},
|
|
313
|
-
});
|
|
314
244
|
}
|
package/lightpanda.ts
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { spawn as spawnProcess } from "node:child_process";
|
|
2
|
+
import { visibleChars } from "./html-to-md.ts";
|
|
3
|
+
import {
|
|
4
|
+
checkUrlAccess,
|
|
5
|
+
MAX_SIGNAL_TIMEOUT_MS,
|
|
6
|
+
normalizeUrlScheme,
|
|
7
|
+
type WebsitePolicy,
|
|
8
|
+
} from "./web-access.ts";
|
|
9
|
+
import {
|
|
10
|
+
pagePrefixedMarkdown,
|
|
11
|
+
parseLocalPath,
|
|
12
|
+
resolveAndValidateHost,
|
|
13
|
+
TRUNCATED_BODY_NOTICE,
|
|
14
|
+
truncatePageText,
|
|
15
|
+
type ResolvedHost,
|
|
16
|
+
} from "./web-fetch.ts";
|
|
17
|
+
|
|
18
|
+
const DEFAULT_BINARY = "lightpanda";
|
|
19
|
+
const DEFAULT_WAIT_UNTIL = "networkidle";
|
|
20
|
+
const DEFAULT_TIMEOUT_MS = 60_000;
|
|
21
|
+
const MAX_DUMP_BYTES = 4 * 1024 * 1024;
|
|
22
|
+
const PROBE_TIMEOUT_MS = 5_000;
|
|
23
|
+
const METADATA_LINE_RE = /^(?:Title|Author|Date|Site|URL|Rendered via): /;
|
|
24
|
+
const PROVENANCE = "Rendered via Lightpanda (local browser; nothing sent to a third party).";
|
|
25
|
+
const CANCELLED_MESSAGE = "Failed to render URL: cancelled.";
|
|
26
|
+
const TIMED_OUT_MESSAGE = "Failed to render URL: timed out.";
|
|
27
|
+
const LOCAL_FILE_MESSAGE = "Blocked: the local renderer cannot fetch local files.";
|
|
28
|
+
const NO_TEXT_MESSAGE = "(page returned no readable text)";
|
|
29
|
+
|
|
30
|
+
export type LightpandaDump = "html" | "markdown";
|
|
31
|
+
export type LightpandaWaitUntil = "networkidle" | "load" | "domcontentloaded" | "done";
|
|
32
|
+
|
|
33
|
+
export interface LightpandaSettings {
|
|
34
|
+
enabled: boolean;
|
|
35
|
+
binaryPath: string | null;
|
|
36
|
+
command: string[] | null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface LightpandaProcess {
|
|
40
|
+
stdout: AsyncIterable<Buffer> | null;
|
|
41
|
+
stderr: AsyncIterable<Buffer> | null;
|
|
42
|
+
on(event: "error", listener: (error: Error) => void): unknown;
|
|
43
|
+
on(event: "close", listener: (code: number | null) => void): unknown;
|
|
44
|
+
kill(signal?: string): unknown;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export type LightpandaSpawn = (
|
|
48
|
+
binary: string,
|
|
49
|
+
args: string[],
|
|
50
|
+
options: { stdio: ["ignore", "pipe", "pipe"] },
|
|
51
|
+
) => LightpandaProcess;
|
|
52
|
+
|
|
53
|
+
export interface LightpandaRenderOptions {
|
|
54
|
+
timeoutMs?: number;
|
|
55
|
+
maxChars?: number;
|
|
56
|
+
signal?: AbortSignal;
|
|
57
|
+
websitePolicy?: WebsitePolicy | null;
|
|
58
|
+
settings?: LightpandaSettings | null;
|
|
59
|
+
binaryPath?: string | null;
|
|
60
|
+
command?: string[] | null;
|
|
61
|
+
dump?: LightpandaDump;
|
|
62
|
+
waitUntil?: LightpandaWaitUntil;
|
|
63
|
+
spawn?: LightpandaSpawn;
|
|
64
|
+
resolve?: (hostname: string, signal?: AbortSignal, allowPrivateAddresses?: boolean) => Promise<ResolvedHost>;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface RunResult {
|
|
68
|
+
code: number | null;
|
|
69
|
+
stdout: string;
|
|
70
|
+
stderr: string;
|
|
71
|
+
timedOut: boolean;
|
|
72
|
+
cancelled: boolean;
|
|
73
|
+
truncated: boolean;
|
|
74
|
+
missing: boolean;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const availability = new Map<string, Promise<boolean>>();
|
|
78
|
+
|
|
79
|
+
function binaryAvailable(launch: string[], spawn: LightpandaSpawn): Promise<boolean> {
|
|
80
|
+
const key = launch.join(" ");
|
|
81
|
+
const cached = availability.get(key);
|
|
82
|
+
if (cached) return cached;
|
|
83
|
+
const probe = new Promise<boolean>((resolve) => {
|
|
84
|
+
let settled = false;
|
|
85
|
+
const finish = (value: boolean) => {
|
|
86
|
+
if (settled) return;
|
|
87
|
+
settled = true;
|
|
88
|
+
clearTimeout(timer);
|
|
89
|
+
resolve(value);
|
|
90
|
+
};
|
|
91
|
+
const timer = setTimeout(() => {
|
|
92
|
+
try {
|
|
93
|
+
child.kill("SIGKILL");
|
|
94
|
+
} catch {}
|
|
95
|
+
finish(false);
|
|
96
|
+
}, PROBE_TIMEOUT_MS);
|
|
97
|
+
let child: LightpandaProcess;
|
|
98
|
+
try {
|
|
99
|
+
child = spawn(launch[0], [...launch.slice(1), "version"], { stdio: ["ignore", "pipe", "pipe"] });
|
|
100
|
+
} catch {
|
|
101
|
+
finish(false);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
child.on("error", () => finish(false));
|
|
105
|
+
child.on("close", (code) => finish(code === 0));
|
|
106
|
+
});
|
|
107
|
+
availability.set(key, probe);
|
|
108
|
+
return probe;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function runLightpanda(
|
|
112
|
+
launch: string[],
|
|
113
|
+
args: string[],
|
|
114
|
+
options: { timeoutMs: number; signal?: AbortSignal; spawn: LightpandaSpawn },
|
|
115
|
+
): Promise<RunResult> {
|
|
116
|
+
return new Promise<RunResult>((resolve) => {
|
|
117
|
+
const state = {
|
|
118
|
+
code: null as number | null,
|
|
119
|
+
stdout: [] as Buffer[],
|
|
120
|
+
stderr: [] as string[],
|
|
121
|
+
timedOut: false,
|
|
122
|
+
cancelled: false,
|
|
123
|
+
truncated: false,
|
|
124
|
+
missing: false,
|
|
125
|
+
bytes: 0,
|
|
126
|
+
};
|
|
127
|
+
let settled = false;
|
|
128
|
+
const finish = (error: Error | null) => {
|
|
129
|
+
if (settled) return;
|
|
130
|
+
settled = true;
|
|
131
|
+
clearTimeout(timer);
|
|
132
|
+
options.signal?.removeEventListener("abort", onAbort);
|
|
133
|
+
if (error && (error as NodeJS.ErrnoException).code === "ENOENT") state.missing = true;
|
|
134
|
+
const stdout = Buffer.concat(state.stdout).toString("utf-8");
|
|
135
|
+
resolve({ ...state, stdout, stderr: state.stderr.join("") });
|
|
136
|
+
};
|
|
137
|
+
const kill = () => {
|
|
138
|
+
try {
|
|
139
|
+
child.kill("SIGKILL");
|
|
140
|
+
} catch {}
|
|
141
|
+
};
|
|
142
|
+
const onAbort = () => {
|
|
143
|
+
state.cancelled = true;
|
|
144
|
+
kill();
|
|
145
|
+
};
|
|
146
|
+
const timer = setTimeout(() => {
|
|
147
|
+
state.timedOut = true;
|
|
148
|
+
kill();
|
|
149
|
+
}, options.timeoutMs);
|
|
150
|
+
let child: LightpandaProcess;
|
|
151
|
+
try {
|
|
152
|
+
child = options.spawn(launch[0], [...launch.slice(1), ...args], { stdio: ["ignore", "pipe", "pipe"] });
|
|
153
|
+
} catch (error) {
|
|
154
|
+
finish(error instanceof Error ? error : new Error(String(error)));
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
const collect = async (stream: AsyncIterable<Buffer> | null, push: (chunk: Buffer) => void): Promise<void> => {
|
|
158
|
+
if (!stream) return;
|
|
159
|
+
try {
|
|
160
|
+
for await (const chunk of stream) push(chunk);
|
|
161
|
+
} catch {}
|
|
162
|
+
};
|
|
163
|
+
const stdoutDone = collect(child.stdout, (chunk) => {
|
|
164
|
+
if (state.bytes >= MAX_DUMP_BYTES) {
|
|
165
|
+
state.truncated = true;
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
state.bytes += chunk.length;
|
|
169
|
+
if (state.bytes > MAX_DUMP_BYTES) {
|
|
170
|
+
state.truncated = true;
|
|
171
|
+
state.stdout.push(chunk.subarray(0, chunk.length - (state.bytes - MAX_DUMP_BYTES)));
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
state.stdout.push(chunk);
|
|
175
|
+
});
|
|
176
|
+
const stderrDone = collect(child.stderr, (chunk) => state.stderr.push(chunk.toString("utf-8")));
|
|
177
|
+
child.on("error", (error) => finish(error));
|
|
178
|
+
child.on("close", (code) => {
|
|
179
|
+
state.code = code;
|
|
180
|
+
void Promise.all([stdoutDone, stderrDone]).then(() => finish(null));
|
|
181
|
+
});
|
|
182
|
+
options.signal?.addEventListener("abort", onAbort, { once: true });
|
|
183
|
+
if (options.signal?.aborted) onAbort();
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function bodyChars(text: string): number {
|
|
188
|
+
const body = text
|
|
189
|
+
.split("\n")
|
|
190
|
+
.filter((line) => !METADATA_LINE_RE.test(line))
|
|
191
|
+
.join("\n");
|
|
192
|
+
return visibleChars(body);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function withProvenance(prefixed: string, url: string): string {
|
|
196
|
+
const lines = prefixed.split("\n");
|
|
197
|
+
let index = 0;
|
|
198
|
+
while (index < lines.length && METADATA_LINE_RE.test(lines[index])) index++;
|
|
199
|
+
const header = [`URL: ${url}`, PROVENANCE];
|
|
200
|
+
if (index === 0) return `${header.join("\n")}\n\n${prefixed}`;
|
|
201
|
+
return [...lines.slice(0, index), ...header, ...lines.slice(index)].join("\n");
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function lightpandaBinary(options: LightpandaRenderOptions = {}): string {
|
|
205
|
+
return (
|
|
206
|
+
options.binaryPath ??
|
|
207
|
+
options.settings?.binaryPath ??
|
|
208
|
+
process.env.PI_LIGHTPANDA_BIN?.trim() ??
|
|
209
|
+
DEFAULT_BINARY
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function lightpandaLaunch(options: LightpandaRenderOptions = {}): string[] {
|
|
214
|
+
const command = options.command ?? options.settings?.command ?? null;
|
|
215
|
+
if (command && command.length) return command;
|
|
216
|
+
return [lightpandaBinary(options)];
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export async function renderPageWithLightpanda(
|
|
220
|
+
url: string,
|
|
221
|
+
options: LightpandaRenderOptions = {},
|
|
222
|
+
): Promise<string | null> {
|
|
223
|
+
if (options.settings && options.settings.enabled === false) return null;
|
|
224
|
+
const target = typeof url === "string" ? url.trim() : "";
|
|
225
|
+
const policy = options.websitePolicy ?? null;
|
|
226
|
+
if (!target) return checkUrlAccess("", policy)[1];
|
|
227
|
+
if (parseLocalPath(target) !== null) return LOCAL_FILE_MESSAGE;
|
|
228
|
+
const normalized = normalizeUrlScheme(target);
|
|
229
|
+
const [allowed, reason, hostname] = checkUrlAccess(normalized, policy);
|
|
230
|
+
if (!allowed) return reason;
|
|
231
|
+
const launch = lightpandaLaunch(options);
|
|
232
|
+
const spawn = options.spawn ?? (spawnProcess as unknown as LightpandaSpawn);
|
|
233
|
+
if (!(await binaryAvailable(launch, spawn))) return null;
|
|
234
|
+
const timeoutMs = Math.min(
|
|
235
|
+
MAX_SIGNAL_TIMEOUT_MS,
|
|
236
|
+
Math.max(1, Math.floor(options.timeoutMs ?? DEFAULT_TIMEOUT_MS)),
|
|
237
|
+
);
|
|
238
|
+
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
|
239
|
+
const signal = options.signal ? AbortSignal.any([options.signal, timeoutSignal]) : timeoutSignal;
|
|
240
|
+
const resolve = options.resolve ?? resolveAndValidateHost;
|
|
241
|
+
const resolved = await resolve(hostname, signal, false);
|
|
242
|
+
if (options.signal?.aborted) return CANCELLED_MESSAGE;
|
|
243
|
+
if (timeoutSignal.aborted) return TIMED_OUT_MESSAGE;
|
|
244
|
+
if (!resolved.ok) {
|
|
245
|
+
return resolved.reason.startsWith("Blocked:") ? resolved.reason : `Failed to render URL: ${resolved.reason}`;
|
|
246
|
+
}
|
|
247
|
+
const dump = options.dump ?? "html";
|
|
248
|
+
const result = await runLightpanda(
|
|
249
|
+
launch,
|
|
250
|
+
[
|
|
251
|
+
"fetch",
|
|
252
|
+
"--dump",
|
|
253
|
+
dump,
|
|
254
|
+
"--wait-until",
|
|
255
|
+
options.waitUntil ?? DEFAULT_WAIT_UNTIL,
|
|
256
|
+
"--block-private-networks",
|
|
257
|
+
"--http-timeout",
|
|
258
|
+
String(timeoutMs),
|
|
259
|
+
normalized,
|
|
260
|
+
],
|
|
261
|
+
{ timeoutMs, signal: options.signal, spawn },
|
|
262
|
+
);
|
|
263
|
+
if (result.missing) return null;
|
|
264
|
+
if (result.cancelled) return CANCELLED_MESSAGE;
|
|
265
|
+
if (result.timedOut) return TIMED_OUT_MESSAGE;
|
|
266
|
+
if (result.code !== 0) {
|
|
267
|
+
return `Failed to render URL: Lightpanda exited with code ${result.code ?? "unknown"}.`;
|
|
268
|
+
}
|
|
269
|
+
const raw = result.stdout;
|
|
270
|
+
const rendered =
|
|
271
|
+
dump === "html" ? pagePrefixedMarkdown(result.truncated ? raw + TRUNCATED_BODY_NOTICE : raw) : raw.trim();
|
|
272
|
+
if (bodyChars(rendered) === 0) return NO_TEXT_MESSAGE;
|
|
273
|
+
const withHeader =
|
|
274
|
+
dump === "html" || !result.truncated ? withProvenance(rendered, normalized) : withProvenance(rendered + TRUNCATED_BODY_NOTICE, normalized);
|
|
275
|
+
return truncatePageText(withHeader, options.maxChars);
|
|
276
|
+
}
|