@bacnh85/pi-web 0.6.1 → 0.7.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 +49 -4
- package/README.md +22 -3
- package/extensions/index.ts +122 -26
- package/extensions/lib/chrome.ts +296 -0
- package/extensions/lib/content.ts +10 -0
- package/package.json +1 -1
- package/skills/pi-web/SKILL.md +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,53 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## 0.
|
|
3
|
+
## 0.7.0 (2026-09-11)
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- **Local capture engine** — `web_screenshot` and `web_pdf` now capture
|
|
8
|
+
`localhost`/LAN/`file://` URLs via the locally installed Chrome/Chromium
|
|
9
|
+
(headless CLI, zero dependencies). The Crawl4AI daemon's browser runs on the
|
|
10
|
+
daemon host and SSRF-blocks private addresses, so local dev servers were
|
|
11
|
+
uncapturable before. Routing is automatic (`engine="auto"` default):
|
|
12
|
+
private URLs → local Chrome, public URLs → daemon, and a daemon SSRF-block
|
|
13
|
+
on an otherwise-public URL falls back to local Chrome automatically.
|
|
14
|
+
`engine="local"`/`"daemon"` forces one. New `web_screenshot` params:
|
|
15
|
+
`width` (1280), `height` (800), `full_page` (tall 8000px window — the
|
|
16
|
+
Chrome CLI has no true full-page flag). Binary discovery: `CHROME_PATH` env
|
|
17
|
+
→ standard per-OS paths (Edge as Windows fallback). Captures run in an
|
|
18
|
+
isolated temp profile with a 30s timeout; `wait_for` maps to
|
|
19
|
+
`--virtual-time-budget`. Chrome versions that write the capture but never
|
|
20
|
+
exit (fresh `--user-data-dir` on macOS) are handled by polling for a
|
|
21
|
+
size-stable output file instead of requiring a clean exit. Review-hardened:
|
|
22
|
+
capture URLs are scheme-validated (http/https/file) before spawn so
|
|
23
|
+
switch-like strings can't be injected as Chrome flags; IPv6 loopback/ULA/
|
|
24
|
+
link-local (`[::1]`, `fc00::/7`, `fe80::/10`) route to local Chrome (Node
|
|
25
|
+
`URL.hostname` keeps brackets); daemon `details` payloads are preserved
|
|
26
|
+
(mime/artifact/full result) alongside the new `engine` key; timeout is
|
|
27
|
+
always a failure (a complete capture is caught by the stability poll first).
|
|
28
|
+
`web_status` reports the discovered local Chrome path.
|
|
29
|
+
- New `extensions/lib/chrome.ts` (engine + `isLocalUrl`/`resolveEngine`/
|
|
30
|
+
`isSsrfBlocked` helpers) with unit tests in `test/unit/chrome.test.ts`;
|
|
31
|
+
live-verified against a local `http.server` (PNG magic, PDF magic, inline
|
|
32
|
+
image block, tmp cleanup, no orphan processes).
|
|
33
|
+
|
|
34
|
+
## 0.6.2 (2026-09-06)
|
|
35
|
+
|
|
36
|
+
### Changed
|
|
37
|
+
|
|
38
|
+
- `web_screenshot` now returns the PNG **inline as an image block**
|
|
39
|
+
(`ImageContent`) alongside the text summary, so multimodal models (GLM-5.3,
|
|
40
|
+
Claude, Gemini) actually see the screenshot instead of a base64 char
|
|
41
|
+
count. The "Data: base64 PNG (N chars)" line is gone; artifact/MIME/size
|
|
42
|
+
summary unchanged. Inspired by zcode-plugins video2code's vision-in-the-loop.
|
|
43
|
+
Regression-tested in `test/unit/screenshot.test.ts` (fetch stubbed — no
|
|
44
|
+
daemon needed): image block present + base64-text line absent; text-only
|
|
45
|
+
fallback when the daemon returns no screenshot.
|
|
46
|
+
|
|
47
|
+
Daemon `success:false` responses (HTTP 200) now surface `error_message` as
|
|
48
|
+
a tool error instead of returning a silently empty screenshot result.
|
|
49
|
+
|
|
50
|
+
## 0.6.1 (2026-08-30)
|
|
4
51
|
|
|
5
52
|
### Changed
|
|
6
53
|
|
|
@@ -11,9 +58,7 @@
|
|
|
11
58
|
hook.test.ts assertion updated to the compressed phrasing. No tool,
|
|
12
59
|
parameter, or default changed.
|
|
13
60
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
### Changed
|
|
61
|
+
### Changed (2026-08-19)
|
|
17
62
|
|
|
18
63
|
- `web_extract` agy backend default model updated to `gemini-3.7-flash-medium`
|
|
19
64
|
— the current Flash generation in agy 1.1.x (3.6 is still served, this just
|
package/README.md
CHANGED
|
@@ -142,21 +142,39 @@ web_crawl url="https://example.com" mode=light poll=true # Poll for completio
|
|
|
142
142
|
|
|
143
143
|
### `web_screenshot` — Page screenshot
|
|
144
144
|
|
|
145
|
-
Captures a full-page PNG screenshot using Crawl4AI. Returns
|
|
145
|
+
Captures a full-page PNG screenshot using the Crawl4AI daemon, or **local headless Chrome for localhost/LAN/file URLs** (auto-detected; see [Local capture](#local-capture)). Returns the PNG inline as an image block (multimodal models see it); text summary includes engine/MIME/size.
|
|
146
146
|
|
|
147
147
|
```
|
|
148
148
|
web_screenshot url="https://example.com"
|
|
149
149
|
web_screenshot url="https://example.com" wait_for=5 wait_for_images=true
|
|
150
|
+
web_screenshot url="http://localhost:3000" # local Chrome, auto-detected
|
|
151
|
+
web_screenshot url="http://localhost:3000" full_page=true width=1280
|
|
152
|
+
web_screenshot url="https://example.com" engine="daemon" # force the daemon
|
|
150
153
|
```
|
|
151
154
|
|
|
155
|
+
Local-engine params: `width` (default 1280), `height` (default 800), `full_page` (captures a tall 8000px window — the Chrome CLI has no true full-page flag).
|
|
156
|
+
|
|
152
157
|
### `web_pdf` — Page PDF
|
|
153
158
|
|
|
154
|
-
Generates a PDF document using Crawl4AI. Returns base64-encoded PDF.
|
|
159
|
+
Generates a PDF document using the Crawl4AI daemon, or **local headless Chrome** for localhost/LAN/file URLs (auto-detected). Returns base64-encoded PDF.
|
|
155
160
|
|
|
156
161
|
```
|
|
157
162
|
web_pdf url="https://example.com/article"
|
|
163
|
+
web_pdf url="http://localhost:3000" # local Chrome, auto-detected
|
|
158
164
|
```
|
|
159
165
|
|
|
166
|
+
### Local capture
|
|
167
|
+
|
|
168
|
+
The Crawl4AI daemon's browser runs on the daemon host — it cannot reach (and SSRF-blocks) your `localhost`. pi-web therefore routes private URLs to a **locally installed Chrome/Chromium** in headless mode:
|
|
169
|
+
|
|
170
|
+
| URL | Engine |
|
|
171
|
+
|-----|--------|
|
|
172
|
+
| `localhost`, `127.0.0.1`, LAN IPs (10/8, 172.16/12, 192.168/16, 169.254/16), `file://` | local Chrome |
|
|
173
|
+
| public URLs | Crawl4AI daemon |
|
|
174
|
+
| daemon SSRF-blocks a URL | automatic local-Chrome retry |
|
|
175
|
+
|
|
176
|
+
Override with `engine="local"` / `engine="daemon"`. Binary discovery: `CHROME_PATH` env, then standard Chrome/Chromium paths per OS (Edge as a Windows fallback). Captures use an isolated temp profile, a 30s timeout, and `--virtual-time-budget` for `wait_for`.
|
|
177
|
+
|
|
160
178
|
### `web_status` — Provider status
|
|
161
179
|
|
|
162
180
|
Shows all provider configuration status and Crawl4AI server health.
|
|
@@ -177,7 +195,8 @@ Typical output:
|
|
|
177
195
|
...
|
|
178
196
|
"health": { "status": "healthy", "version": "0.5.0", ... }
|
|
179
197
|
},
|
|
180
|
-
"agy": { "installed": true }
|
|
198
|
+
"agy": { "installed": true },
|
|
199
|
+
"localChrome": { "path": "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" }
|
|
181
200
|
}
|
|
182
201
|
```
|
|
183
202
|
|
package/extensions/index.ts
CHANGED
|
@@ -29,6 +29,13 @@ import {
|
|
|
29
29
|
fetchCrawl4aiPdf,
|
|
30
30
|
fetchCrawl4aiHealth,
|
|
31
31
|
} from "./lib/crawl4ai";
|
|
32
|
+
import {
|
|
33
|
+
capturePdf as captureLocalPdf,
|
|
34
|
+
captureScreenshot as captureLocalScreenshot,
|
|
35
|
+
findChromeBinary,
|
|
36
|
+
isSsrfBlocked,
|
|
37
|
+
resolveEngine,
|
|
38
|
+
} from "./lib/chrome";
|
|
32
39
|
|
|
33
40
|
// ---------------------------------------------------------------------------
|
|
34
41
|
// Shared schema fragment
|
|
@@ -48,6 +55,14 @@ const crawl4aiControlSchema = {
|
|
|
48
55
|
crawl4ai_api_token: Type.Optional(Type.String({ description: "Override $CRAWL4AI_API_TOKEN." })),
|
|
49
56
|
};
|
|
50
57
|
|
|
58
|
+
const engineSchema = {
|
|
59
|
+
engine: Type.Optional(Type.Union([
|
|
60
|
+
Type.Literal("auto"),
|
|
61
|
+
Type.Literal("local"),
|
|
62
|
+
Type.Literal("daemon"),
|
|
63
|
+
], { default: "auto", description: "auto routes localhost/private/file URLs to local Chrome, the rest to the Crawl4AI daemon; local/daemon force one." })),
|
|
64
|
+
};
|
|
65
|
+
|
|
51
66
|
// ---------------------------------------------------------------------------
|
|
52
67
|
// Always-on routing guidance (injected only when a web_* tool is active)
|
|
53
68
|
// ---------------------------------------------------------------------------
|
|
@@ -286,35 +301,89 @@ export default function piWebExtension(pi: ExtensionAPI) {
|
|
|
286
301
|
name: "web_screenshot",
|
|
287
302
|
label: "Web Page Screenshot",
|
|
288
303
|
description:
|
|
289
|
-
"Full-page PNG screenshot via Crawl4AI.",
|
|
304
|
+
"Full-page PNG screenshot via the Crawl4AI daemon, or via local headless Chrome for localhost/private/file URLs (auto-detected, engine overridable). The PNG is returned inline as an image block.",
|
|
290
305
|
promptSnippet: "Screenshot a webpage",
|
|
291
|
-
promptGuidelines: ["
|
|
306
|
+
promptGuidelines: ["PNG returned inline (multimodal models see it); use when web_extract fails on JS-heavy pages, or to visually inspect a built UI. Local dev servers (localhost/LAN/file://) capture automatically via local Chrome."],
|
|
292
307
|
parameters: Type.Object({
|
|
293
308
|
url: Type.String(),
|
|
294
309
|
wait_for: Type.Optional(Type.Number({ default: 2, description: "Seconds to wait before capture." })),
|
|
295
310
|
wait_for_images: Type.Optional(Type.Boolean({ default: false })),
|
|
311
|
+
engine: Type.Optional(engineSchema.engine),
|
|
312
|
+
width: Type.Optional(Type.Number({ default: 1280, description: "Local engine: viewport width." })),
|
|
313
|
+
height: Type.Optional(Type.Number({ default: 800, description: "Local engine: viewport height (full_page uses 8000)." })),
|
|
314
|
+
full_page: Type.Optional(Type.Boolean({ default: false, description: "Local engine: capture a tall 8000px window to approximate full page." })),
|
|
296
315
|
...crawl4aiControlSchema,
|
|
297
316
|
...sharedControlSchema,
|
|
298
317
|
}),
|
|
299
318
|
async execute(_id: string, params: Record<string, unknown>, signal: AbortSignal, _onUpdate: unknown, ctx: any) {
|
|
300
|
-
const
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
319
|
+
const url = params.url as string;
|
|
320
|
+
let engine = resolveEngine(params.engine as string | undefined, url);
|
|
321
|
+
let screenshot: string | undefined;
|
|
322
|
+
let mime: string | undefined;
|
|
323
|
+
let size: number | undefined;
|
|
324
|
+
let artifactUrl: string | undefined;
|
|
325
|
+
let details: Record<string, unknown> = {};
|
|
326
|
+
|
|
327
|
+
if (engine === "local") {
|
|
328
|
+
const cap = await captureLocalScreenshot({
|
|
329
|
+
url,
|
|
330
|
+
width: params.width as number | undefined,
|
|
331
|
+
height: params.height as number | undefined,
|
|
332
|
+
fullPage: params.full_page as boolean | undefined,
|
|
333
|
+
waitForSec: params.wait_for as number | undefined,
|
|
334
|
+
signal,
|
|
335
|
+
});
|
|
336
|
+
screenshot = cap.base64;
|
|
337
|
+
mime = cap.mime;
|
|
338
|
+
size = cap.size;
|
|
339
|
+
details = { mime: cap.mime, size: cap.size };
|
|
340
|
+
} else {
|
|
341
|
+
const config = loadCrawl4aiConfig(params as Record<string, unknown>, cwdFromContext(ctx), includeProjectEnv(ctx));
|
|
342
|
+
try {
|
|
343
|
+
const result = await fetchCrawl4aiScreenshot(
|
|
344
|
+
config,
|
|
345
|
+
url,
|
|
346
|
+
params.wait_for as number | undefined,
|
|
347
|
+
params.wait_for_images as boolean | undefined,
|
|
348
|
+
signal,
|
|
349
|
+
);
|
|
350
|
+
if (result.success === false) {
|
|
351
|
+
throw new Error(String(result.error_message ?? "Crawl4AI screenshot failed"));
|
|
352
|
+
}
|
|
353
|
+
screenshot = result.screenshot as string | undefined;
|
|
354
|
+
artifactUrl = result.url as string | undefined;
|
|
355
|
+
mime = result.mime as string | undefined;
|
|
356
|
+
size = result.size as number | undefined;
|
|
357
|
+
details = { ...result };
|
|
358
|
+
} catch (err) {
|
|
359
|
+
// Daemon can't render this URL (SSRF-blocked); retry via local Chrome.
|
|
360
|
+
if (!isSsrfBlocked(err) || !findChromeBinary()) throw err;
|
|
361
|
+
engine = "local";
|
|
362
|
+
const cap = await captureLocalScreenshot({
|
|
363
|
+
url,
|
|
364
|
+
width: params.width as number | undefined,
|
|
365
|
+
height: params.height as number | undefined,
|
|
366
|
+
fullPage: params.full_page as boolean | undefined,
|
|
367
|
+
waitForSec: params.wait_for as number | undefined,
|
|
368
|
+
signal,
|
|
369
|
+
});
|
|
370
|
+
screenshot = cap.base64;
|
|
371
|
+
mime = cap.mime;
|
|
372
|
+
size = cap.size;
|
|
373
|
+
details = { mime: cap.mime, size: cap.size, fallback: "daemon SSRF-blocked this URL" };
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
let text = `Screenshot: ${url}\nEngine: ${engine === "local" ? "local-chrome" : "crawl4ai"}\n`;
|
|
314
378
|
if (artifactUrl) text += `Artifact: ${artifactUrl}\n`;
|
|
315
379
|
if (mime) text += `MIME: ${mime}\n`;
|
|
316
380
|
if (size) text += `Size: ${size} bytes\n`;
|
|
317
|
-
|
|
381
|
+
// Return the PNG as a real image block so multimodal models see it.
|
|
382
|
+
const content: Array<{ type: "text"; text: string } | { type: "image"; data: string; mimeType: string }> = [
|
|
383
|
+
{ type: "text", text: truncateText(text) },
|
|
384
|
+
];
|
|
385
|
+
if (screenshot) content.push({ type: "image", data: screenshot, mimeType: mime || "image/png" });
|
|
386
|
+
return { content, details: { ...details, url, engine } };
|
|
318
387
|
},
|
|
319
388
|
});
|
|
320
389
|
|
|
@@ -323,25 +392,51 @@ export default function piWebExtension(pi: ExtensionAPI) {
|
|
|
323
392
|
name: "web_pdf",
|
|
324
393
|
label: "Web Page PDF",
|
|
325
394
|
description:
|
|
326
|
-
"PDF document via Crawl4AI.",
|
|
395
|
+
"PDF document via the Crawl4AI daemon, or via local headless Chrome for localhost/private/file URLs (auto-detected, engine overridable).",
|
|
327
396
|
promptSnippet: "PDF a webpage",
|
|
328
|
-
promptGuidelines: ["Printable/archivable page snapshot; returns base64 PDF."],
|
|
397
|
+
promptGuidelines: ["Printable/archivable page snapshot; returns base64 PDF. Local dev servers capture automatically via local Chrome."],
|
|
329
398
|
parameters: Type.Object({
|
|
330
399
|
url: Type.String(),
|
|
400
|
+
engine: Type.Optional(engineSchema.engine),
|
|
331
401
|
...crawl4aiControlSchema,
|
|
332
402
|
...sharedControlSchema,
|
|
333
403
|
}),
|
|
334
404
|
async execute(_id: string, params: Record<string, unknown>, signal: AbortSignal, _onUpdate: unknown, ctx: any) {
|
|
335
|
-
const
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
let
|
|
405
|
+
const url = params.url as string;
|
|
406
|
+
let engine = resolveEngine(params.engine as string | undefined, url);
|
|
407
|
+
let pdf: string | undefined;
|
|
408
|
+
let artifactUrl: string | undefined;
|
|
409
|
+
let size: number | undefined;
|
|
410
|
+
let details: Record<string, unknown> = {};
|
|
411
|
+
|
|
412
|
+
if (engine === "local") {
|
|
413
|
+
const cap = await captureLocalPdf({ url, signal });
|
|
414
|
+
pdf = cap.base64;
|
|
415
|
+
size = cap.size;
|
|
416
|
+
details = { mime: cap.mime, size: cap.size };
|
|
417
|
+
} else {
|
|
418
|
+
const config = loadCrawl4aiConfig(params as Record<string, unknown>, cwdFromContext(ctx), includeProjectEnv(ctx));
|
|
419
|
+
try {
|
|
420
|
+
const result = await fetchCrawl4aiPdf(config, url, signal);
|
|
421
|
+
pdf = result.pdf as string | undefined;
|
|
422
|
+
artifactUrl = result.url as string | undefined;
|
|
423
|
+
size = result.size as number | undefined;
|
|
424
|
+
details = { ...result };
|
|
425
|
+
} catch (err) {
|
|
426
|
+
if (!isSsrfBlocked(err) || !findChromeBinary()) throw err;
|
|
427
|
+
engine = "local";
|
|
428
|
+
const cap = await captureLocalPdf({ url, signal });
|
|
429
|
+
pdf = cap.base64;
|
|
430
|
+
size = cap.size;
|
|
431
|
+
details = { mime: cap.mime, size: cap.size, fallback: "daemon SSRF-blocked this URL" };
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
let text = `PDF: ${url}\nEngine: ${engine === "local" ? "local-chrome" : "crawl4ai"}\n`;
|
|
341
436
|
if (pdf) text += `Data: base64 PDF (${pdf.length} chars)\n`;
|
|
342
437
|
if (artifactUrl) text += `Artifact: ${artifactUrl}\n`;
|
|
343
438
|
if (size) text += `Size: ${size} bytes\n`;
|
|
344
|
-
return { content: [{ type: "text" as const, text: truncateText(text) }], details: { ...
|
|
439
|
+
return { content: [{ type: "text" as const, text: truncateText(text) }], details: { ...details, url, engine } };
|
|
345
440
|
},
|
|
346
441
|
});
|
|
347
442
|
|
|
@@ -389,6 +484,7 @@ export default function piWebExtension(pi: ExtensionAPI) {
|
|
|
389
484
|
apiTokenSource: c4aiToken.value ? c4aiToken.source : "not set",
|
|
390
485
|
},
|
|
391
486
|
agy: { installed: isAgyInstalled() },
|
|
487
|
+
localChrome: { path: findChromeBinary() ?? "not found" },
|
|
392
488
|
};
|
|
393
489
|
|
|
394
490
|
// Crawl4AI health check
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
// Local headless Chrome capture — for localhost/private/file:// URLs the
|
|
2
|
+
// remote Crawl4AI daemon cannot reach (its SSRF protection blocks them).
|
|
3
|
+
// Zero dependencies: drives the locally installed Chrome/Chromium binary.
|
|
4
|
+
|
|
5
|
+
import { spawn } from "node:child_process";
|
|
6
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync, statSync } from "node:fs";
|
|
7
|
+
import { tmpdir } from "node:os";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
|
|
10
|
+
export interface LocalCapture {
|
|
11
|
+
base64: string;
|
|
12
|
+
mime: string;
|
|
13
|
+
size: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const CHROME_TIMEOUT_MS = 30_000;
|
|
17
|
+
// ponytail: tall-window approximates full page (CLI has no fullPage flag) —
|
|
18
|
+
// Playwright tier if this proves insufficient.
|
|
19
|
+
const FULL_PAGE_HEIGHT = 8000;
|
|
20
|
+
|
|
21
|
+
/** Locate a locally installed Chrome/Chromium (or Edge as a Windows fallback). */
|
|
22
|
+
export function findChromeBinary(): string | null {
|
|
23
|
+
const candidates: string[] = [];
|
|
24
|
+
if (process.env.CHROME_PATH) candidates.push(process.env.CHROME_PATH);
|
|
25
|
+
switch (process.platform) {
|
|
26
|
+
case "darwin": {
|
|
27
|
+
const apps = ["/Applications", path.join(process.env.HOME ?? "", "Applications")];
|
|
28
|
+
for (const app of apps) {
|
|
29
|
+
candidates.push(
|
|
30
|
+
path.join(app, "Google Chrome.app/Contents/MacOS/Google Chrome"),
|
|
31
|
+
path.join(app, "Chromium.app/Contents/MacOS/Chromium"),
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
break;
|
|
35
|
+
}
|
|
36
|
+
case "win32": {
|
|
37
|
+
const roots = [
|
|
38
|
+
"C:\\Program Files",
|
|
39
|
+
"C:\\Program Files (x86)",
|
|
40
|
+
process.env.LOCALAPPDATA ?? "",
|
|
41
|
+
].filter(Boolean);
|
|
42
|
+
for (const root of roots) {
|
|
43
|
+
candidates.push(
|
|
44
|
+
path.join(root, "Google\\Chrome\\Application\\chrome.exe"),
|
|
45
|
+
path.join(root, "Microsoft\\Edge\\Application\\msedge.exe"),
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
default: {
|
|
51
|
+
const dirs = [
|
|
52
|
+
...(process.env.PATH ?? "").split(":").filter(Boolean),
|
|
53
|
+
"/usr/bin",
|
|
54
|
+
"/usr/local/bin",
|
|
55
|
+
"/snap/bin",
|
|
56
|
+
];
|
|
57
|
+
for (const dir of dirs) {
|
|
58
|
+
candidates.push(
|
|
59
|
+
path.join(dir, "google-chrome"),
|
|
60
|
+
path.join(dir, "google-chrome-stable"),
|
|
61
|
+
path.join(dir, "chromium"),
|
|
62
|
+
path.join(dir, "chromium-browser"),
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return candidates.find((p) => existsSync(p)) ?? null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** True for URLs a remote daemon provably cannot render: file://, localhost, loopback, private ranges. */
|
|
71
|
+
export function isLocalUrl(raw: string): boolean {
|
|
72
|
+
let u: URL;
|
|
73
|
+
try {
|
|
74
|
+
u = new URL(raw);
|
|
75
|
+
} catch {
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
if (u.protocol === "file:") return true;
|
|
79
|
+
const host = u.hostname.toLowerCase().replace(/^\[|\]$/g, "");
|
|
80
|
+
if (host === "localhost" || host.endsWith(".localhost")) return true;
|
|
81
|
+
if (host === "::1") return true;
|
|
82
|
+
if (host.includes(":")) {
|
|
83
|
+
// IPv6 ULA fc00::/7 and link-local fe80::/10 are private too.
|
|
84
|
+
if (/^f[cd]/.test(host) || /^fe[89ab]/.test(host)) return true;
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
const m = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
|
88
|
+
if (m) {
|
|
89
|
+
const a = Number(m[1]);
|
|
90
|
+
const b = Number(m[2]);
|
|
91
|
+
if (a === 127 || a === 10 || a === 0) return true;
|
|
92
|
+
if (a === 172 && b >= 16 && b <= 31) return true; // 172.16/12
|
|
93
|
+
if (a === 192 && b === 168) return true;
|
|
94
|
+
if (a === 169 && b === 254) return true; // link-local
|
|
95
|
+
}
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Daemon SSRF/URL-blocked failures that a local-Chrome retry can rescue. */
|
|
100
|
+
export function isSsrfBlocked(err: unknown): boolean {
|
|
101
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
102
|
+
return /SSRF|URL blocked/i.test(msg);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Pick the capture engine: local Chrome or the remote Crawl4AI daemon. */
|
|
106
|
+
export function resolveEngine(engine: string | undefined, url: string): "local" | "daemon" {
|
|
107
|
+
if (engine === "local" || engine === "daemon") return engine;
|
|
108
|
+
return isLocalUrl(url) ? "local" : "daemon";
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export interface ScreenshotArgsOpts {
|
|
112
|
+
chromePath: string;
|
|
113
|
+
outPath: string;
|
|
114
|
+
userDataDir: string;
|
|
115
|
+
url: string;
|
|
116
|
+
width: number;
|
|
117
|
+
height: number;
|
|
118
|
+
fullPage?: boolean;
|
|
119
|
+
waitForSec?: number;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function buildScreenshotArgs(opts: ScreenshotArgsOpts): string[] {
|
|
123
|
+
const height = opts.fullPage ? FULL_PAGE_HEIGHT : opts.height;
|
|
124
|
+
return [
|
|
125
|
+
opts.chromePath,
|
|
126
|
+
"--headless",
|
|
127
|
+
"--no-first-run",
|
|
128
|
+
"--disable-gpu",
|
|
129
|
+
`--user-data-dir=${opts.userDataDir}`,
|
|
130
|
+
"--hide-scrollbars",
|
|
131
|
+
`--window-size=${opts.width},${height}`,
|
|
132
|
+
...(opts.waitForSec ? [`--virtual-time-budget=${Math.round(opts.waitForSec * 1000)}`] : []),
|
|
133
|
+
`--screenshot=${opts.outPath}`,
|
|
134
|
+
opts.url,
|
|
135
|
+
];
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function buildPdfArgs(opts: {
|
|
139
|
+
chromePath: string;
|
|
140
|
+
outPath: string;
|
|
141
|
+
userDataDir: string;
|
|
142
|
+
url: string;
|
|
143
|
+
}): string[] {
|
|
144
|
+
return [
|
|
145
|
+
opts.chromePath,
|
|
146
|
+
"--headless",
|
|
147
|
+
"--no-first-run",
|
|
148
|
+
"--disable-gpu",
|
|
149
|
+
`--user-data-dir=${opts.userDataDir}`,
|
|
150
|
+
"--no-pdf-header-footer",
|
|
151
|
+
`--print-to-pdf=${opts.outPath}`,
|
|
152
|
+
opts.url,
|
|
153
|
+
];
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function runChrome(
|
|
157
|
+
args: string[],
|
|
158
|
+
outPath: string,
|
|
159
|
+
signal?: AbortSignal,
|
|
160
|
+
timeoutMs: number = CHROME_TIMEOUT_MS,
|
|
161
|
+
): Promise<void> {
|
|
162
|
+
return new Promise((resolve, reject) => {
|
|
163
|
+
const child = spawn(args[0], args.slice(1), { stdio: ["ignore", "ignore", "pipe"] });
|
|
164
|
+
let stderr = "";
|
|
165
|
+
let settled = false;
|
|
166
|
+
let lastSize = -1;
|
|
167
|
+
let stablePolls = 0;
|
|
168
|
+
let fileTimer: ReturnType<typeof setInterval> | undefined;
|
|
169
|
+
const finish = (err?: Error) => {
|
|
170
|
+
if (settled) return;
|
|
171
|
+
settled = true;
|
|
172
|
+
clearTimeout(killTimer);
|
|
173
|
+
clearInterval(fileTimer);
|
|
174
|
+
signal?.removeEventListener("abort", onAbort);
|
|
175
|
+
if (err) reject(err);
|
|
176
|
+
else resolve();
|
|
177
|
+
};
|
|
178
|
+
const onAbort = () => {
|
|
179
|
+
child.kill("SIGKILL");
|
|
180
|
+
finish(new Error("Local capture aborted"));
|
|
181
|
+
};
|
|
182
|
+
const killTimer = setTimeout(() => {
|
|
183
|
+
child.kill("SIGKILL");
|
|
184
|
+
// Always a failure: a complete capture is caught earlier by the
|
|
185
|
+
// size-stability poll, so surviving to the timeout means the output
|
|
186
|
+
// never settled (or never appeared) — a file here may be mid-write.
|
|
187
|
+
finish(new Error(`Local capture timed out after ${timeoutMs / 1000}s`));
|
|
188
|
+
}, timeoutMs);
|
|
189
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
190
|
+
child.stderr?.on("data", (d: Buffer) => {
|
|
191
|
+
stderr += d.toString();
|
|
192
|
+
});
|
|
193
|
+
child.on("error", (err) => finish(err));
|
|
194
|
+
child.on("close", (code) => {
|
|
195
|
+
if (existsSync(outPath)) finish();
|
|
196
|
+
else finish(new Error(`Chrome exited with code ${code}: ${stderr.slice(-400)}`));
|
|
197
|
+
});
|
|
198
|
+
// Resolve as soon as the capture file is written and stable, then kill —
|
|
199
|
+
// don't require a clean Chrome exit (some versions hang after writing,
|
|
200
|
+
// e.g. fresh --user-data-dir on macOS).
|
|
201
|
+
fileTimer = setInterval(() => {
|
|
202
|
+
if (!existsSync(outPath)) return;
|
|
203
|
+
const size = statSync(outPath).size;
|
|
204
|
+
if (size > 0 && size === lastSize) {
|
|
205
|
+
if (++stablePolls >= 2) {
|
|
206
|
+
child.kill("SIGKILL");
|
|
207
|
+
finish();
|
|
208
|
+
}
|
|
209
|
+
} else {
|
|
210
|
+
stablePolls = 0;
|
|
211
|
+
lastSize = size;
|
|
212
|
+
}
|
|
213
|
+
}, 250);
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async function readCapture(outPath: string, mime: string): Promise<LocalCapture> {
|
|
218
|
+
const buf = readFileSync(outPath);
|
|
219
|
+
return { base64: buf.toString("base64"), mime, size: buf.length };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function assertCaptureUrl(url: string): void {
|
|
223
|
+
// Trust boundary: the URL becomes a spawn argv element — a scheme check
|
|
224
|
+
// keeps strings like "--proxy-server=http://evil" from parsing as switches.
|
|
225
|
+
if (!/^https?:\/\//i.test(url) && !/^file:\/\//i.test(url)) {
|
|
226
|
+
throw new Error(`Invalid capture URL (${url.slice(0, 80)}): must be http://, https://, or file://`);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export async function captureScreenshot(opts: {
|
|
231
|
+
url: string;
|
|
232
|
+
width?: number;
|
|
233
|
+
height?: number;
|
|
234
|
+
fullPage?: boolean;
|
|
235
|
+
waitForSec?: number;
|
|
236
|
+
signal?: AbortSignal;
|
|
237
|
+
timeoutMs?: number;
|
|
238
|
+
}): Promise<LocalCapture> {
|
|
239
|
+
assertCaptureUrl(opts.url);
|
|
240
|
+
const chromePath = findChromeBinary();
|
|
241
|
+
if (!chromePath) {
|
|
242
|
+
throw new Error("No local Chrome/Chromium found — install Chrome or set CHROME_PATH.");
|
|
243
|
+
}
|
|
244
|
+
const dir = mkdtempSync(path.join(tmpdir(), "pi-web-capture-"));
|
|
245
|
+
try {
|
|
246
|
+
const outPath = path.join(dir, "screenshot.png");
|
|
247
|
+
await runChrome(
|
|
248
|
+
buildScreenshotArgs({
|
|
249
|
+
chromePath,
|
|
250
|
+
outPath,
|
|
251
|
+
userDataDir: path.join(dir, "profile"),
|
|
252
|
+
url: opts.url,
|
|
253
|
+
width: opts.width ?? 1280,
|
|
254
|
+
height: opts.height ?? 800,
|
|
255
|
+
fullPage: opts.fullPage,
|
|
256
|
+
waitForSec: opts.waitForSec,
|
|
257
|
+
}),
|
|
258
|
+
outPath,
|
|
259
|
+
opts.signal,
|
|
260
|
+
opts.timeoutMs,
|
|
261
|
+
);
|
|
262
|
+
return await readCapture(outPath, "image/png");
|
|
263
|
+
} finally {
|
|
264
|
+
rmSync(dir, { recursive: true, force: true });
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export async function capturePdf(opts: {
|
|
269
|
+
url: string;
|
|
270
|
+
signal?: AbortSignal;
|
|
271
|
+
timeoutMs?: number;
|
|
272
|
+
}): Promise<LocalCapture> {
|
|
273
|
+
assertCaptureUrl(opts.url);
|
|
274
|
+
const chromePath = findChromeBinary();
|
|
275
|
+
if (!chromePath) {
|
|
276
|
+
throw new Error("No local Chrome/Chromium found — install Chrome or set CHROME_PATH.");
|
|
277
|
+
}
|
|
278
|
+
const dir = mkdtempSync(path.join(tmpdir(), "pi-web-capture-"));
|
|
279
|
+
try {
|
|
280
|
+
const outPath = path.join(dir, "page.pdf");
|
|
281
|
+
await runChrome(
|
|
282
|
+
buildPdfArgs({
|
|
283
|
+
chromePath,
|
|
284
|
+
outPath,
|
|
285
|
+
userDataDir: path.join(dir, "profile"),
|
|
286
|
+
url: opts.url,
|
|
287
|
+
}),
|
|
288
|
+
outPath,
|
|
289
|
+
opts.signal,
|
|
290
|
+
opts.timeoutMs,
|
|
291
|
+
);
|
|
292
|
+
return await readCapture(outPath, "application/pdf");
|
|
293
|
+
} finally {
|
|
294
|
+
rmSync(dir, { recursive: true, force: true });
|
|
295
|
+
}
|
|
296
|
+
}
|
|
@@ -59,6 +59,16 @@ export async function fetchReadableContent(
|
|
|
59
59
|
signal: signalWithTimeout(timeoutMs, signal),
|
|
60
60
|
});
|
|
61
61
|
if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
62
|
+
// Raw text/JSON payloads (raw.githubusercontent.com, JSON APIs) — Readability
|
|
63
|
+
// shreds them to nothing. Pass through verbatim. text/html and text/xml keep
|
|
64
|
+
// the Readability path — they're ordinary web pages. Session mining: 9/40
|
|
65
|
+
// static extract failures were raw-text/JSON shapes.
|
|
66
|
+
const contentType = (response.headers.get("content-type") ?? "").split(";")[0].trim();
|
|
67
|
+
if ((contentType.startsWith("text/") && contentType !== "text/html" && contentType !== "text/xml") || contentType === "application/json") {
|
|
68
|
+
const body = await response.text();
|
|
69
|
+
const markdown = contentType === "application/json" ? "```json\n" + body + "\n```" : body;
|
|
70
|
+
return { title: "", markdown: markdown.slice(0, 20000) };
|
|
71
|
+
}
|
|
62
72
|
const html = await response.text();
|
|
63
73
|
const deps = loadReadableContentDependencies();
|
|
64
74
|
const dom = new deps.JSDOM(html, { url });
|
package/package.json
CHANGED
package/skills/pi-web/SKILL.md
CHANGED
|
@@ -15,8 +15,8 @@ Use the **7 unified tools** from the `pi-web` extension for all web-related task
|
|
|
15
15
|
| `web_extract` | Extract readable content from a URL | Static (JSDOM) → Dynamic (Firecrawl) → Full (Crawl4AI) → agy (model-backed) |
|
|
16
16
|
| `web_map` | Discover URLs from a site | Firecrawl Map (only option) |
|
|
17
17
|
| `web_crawl` | Crawl multiple pages from a site | Light (Firecrawl) or Full (Crawl4AI) |
|
|
18
|
-
| `web_screenshot` | Capture page screenshot as PNG | Crawl4AI (
|
|
19
|
-
| `web_pdf` | Generate page PDF | Crawl4AI (
|
|
18
|
+
| `web_screenshot` | Capture page screenshot as PNG | Crawl4AI daemon (public URLs) or local headless Chrome (localhost/LAN/file URLs — auto-detected) |
|
|
19
|
+
| `web_pdf` | Generate page PDF | Crawl4AI daemon (public URLs) or local headless Chrome (localhost/LAN/file URLs — auto-detected) |
|
|
20
20
|
| `web_status` | Check provider configuration and health | — |
|
|
21
21
|
|
|
22
22
|
## Decision Tree
|