pi-unsloth-webtools 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/README.md +92 -12
- package/config-ui.ts +105 -0
- package/config.ts +207 -0
- package/index.ts +102 -2
- package/package.json +6 -2
- package/settings.ts +31 -0
- package/web-fetch.ts +3 -3
- package/web-render.ts +100 -0
package/README.md
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
# pi-unsloth-webtools
|
|
2
2
|
|
|
3
|
-
A [pi](https://github.com/earendil-works/pi-coding-agent) extension providing `web_search
|
|
4
|
-
`web_fetch` tools
|
|
5
|
-
([`unslothai/unsloth`](https://github.com/unslothai/unsloth), `studio/backend/core/inference/`)
|
|
3
|
+
A [pi](https://github.com/earendil-works/pi-coding-agent) extension providing `web_search`,
|
|
4
|
+
`web_fetch`, and `web_render` tools. It began as a port of the Unsloth Studio codebase
|
|
5
|
+
([`unslothai/unsloth`](https://github.com/unslothai/unsloth), `studio/backend/core/inference/`);
|
|
6
|
+
the engine, extraction, and PDF layers are still derived from it, but the package is no longer
|
|
7
|
+
behavior-identical to Studio — it enables local file and private-address fetching by default
|
|
8
|
+
and adds a fetch cache, Wayback fallbacks, page metadata, a third-party rendering tool
|
|
9
|
+
(`web_render`), and other behavior Studio does not have. See
|
|
10
|
+
[Known differences from Studio](#known-differences-from-studio). The `unsloth` in the name marks
|
|
11
|
+
provenance, not affiliation.
|
|
6
12
|
|
|
7
13
|
## Install
|
|
8
14
|
|
|
@@ -20,7 +26,7 @@ pi install /path/to/pi-unsloth-webtools
|
|
|
20
26
|
|
|
21
27
|
## What it does
|
|
22
28
|
|
|
23
|
-
|
|
29
|
+
All three tools display their target in the TUI tool row: `web_search "query"`, `web_search <url>` in url mode, `web_fetch <url>`, and `web_render <url>`.
|
|
24
30
|
|
|
25
31
|
### web_search
|
|
26
32
|
|
|
@@ -114,8 +120,34 @@ Port of Studio's `_fetch_page_text` / `_fetch_url_raw` pipeline:
|
|
|
114
120
|
`application-name`) lines are added when declared, so the model can judge recency and
|
|
115
121
|
provenance.
|
|
116
122
|
|
|
123
|
+
### web_render
|
|
124
|
+
|
|
125
|
+
Renders a public page to Markdown through the third-party Jina Reader (`r.jina.ai`) for pages
|
|
126
|
+
that need JavaScript to render:
|
|
127
|
+
|
|
128
|
+
- Every target is validated and resolved locally first: http/https only, and any private,
|
|
129
|
+
loopback, link-local, or otherwise non-public address is refused. Local files are refused on
|
|
130
|
+
this path regardless of `webFetch.allowPrivateAddresses` / `webFetch.allowLocalFiles`, because
|
|
131
|
+
the URL is sent to Jina.
|
|
132
|
+
- `unslothWebTools.jinaApiKey` (or `webRender.jinaApiKey`, or the `JINA_API_KEY` environment
|
|
133
|
+
variable) raises the Reader's rate limits; without a key it still works at Jina's free limits.
|
|
134
|
+
- Output is Markdown prefixed with `Title:` / `URL:` lines and a `Rendered via the Jina Reader`
|
|
135
|
+
provenance line. An optional `maxChars` truncates, like `web_fetch`.
|
|
136
|
+
- Keyless Reader requests are rate-limited per outgoing IP; see
|
|
137
|
+
[Companion: rotating exit IPs](#companion-rotating-exit-ips).
|
|
138
|
+
- Enabled by default. Disable it with `/webtools-config` (`webRenderEnabled` in
|
|
139
|
+
`~/.config/pi-unsloth-webtools/config.json`), which deactivates the tool for the session.
|
|
140
|
+
|
|
117
141
|
## Known differences from Studio
|
|
118
142
|
|
|
143
|
+
- Local access: Studio validates every resolved address against non-public ranges and fetches
|
|
144
|
+
public web content only. This port permits private/loopback/link-local targets and local files
|
|
145
|
+
(`file://` URLs and absolute, `~/`, `./` paths) by default; opt out with
|
|
146
|
+
`webFetch.allowPrivateAddresses: false` and `webFetch.allowLocalFiles: false` to restore
|
|
147
|
+
Studio's behavior.
|
|
148
|
+
- Third-party rendering: the extra `web_render` tool asks the Jina Reader (`r.jina.ai`) to fetch
|
|
149
|
+
the page, so the target URL leaves the machine. Studio has no third-party rendering path. This
|
|
150
|
+
path always refuses local files and non-public addresses, regardless of the local-access settings.
|
|
119
151
|
- PDF styling: MuPDF.js exposes one font per line, so mixed-style lines style the
|
|
120
152
|
whole line instead of per-span; superscript, subscript, underline, strikeout, and
|
|
121
153
|
highlight markers are not emitted. Tables use a conservative text-grid detector:
|
|
@@ -135,8 +167,10 @@ Port of Studio's `_fetch_page_text` / `_fetch_url_raw` pipeline:
|
|
|
135
167
|
generic engine failures. The timeout budget bounds the entire sweep: per-engine
|
|
136
168
|
timeouts shrink as the budget is consumed, so the reported timeout matches the
|
|
137
169
|
worst-case wall time.
|
|
138
|
-
- Proxies: Studio routes through environment proxies; this port always connects
|
|
139
|
-
directly with DNS pinning (deliberately out of scope).
|
|
170
|
+
- Proxies: Studio routes through environment proxies; this port's direct fetch always connects
|
|
171
|
+
directly with DNS pinning (deliberately out of scope). The search and `web_render` paths use the
|
|
172
|
+
process-wide `fetch`, so an agent-level proxy dispatcher does apply to them — see
|
|
173
|
+
[Companion: rotating exit IPs](#companion-rotating-exit-ips).
|
|
140
174
|
- Dedup and titles: the aggregator keys on canonicalized hrefs (`utm_*`/tracking parameters
|
|
141
175
|
and fragments stripped, then the URL re-serialized); fetched HTML pages are prefixed with
|
|
142
176
|
the document `<title>`. Studio keys on raw hrefs and returns the converted body alone.
|
|
@@ -147,14 +181,16 @@ Port of Studio's `_fetch_page_text` / `_fetch_url_raw` pipeline:
|
|
|
147
181
|
|
|
148
182
|
## When to use alternatives
|
|
149
183
|
|
|
150
|
-
This package
|
|
151
|
-
|
|
152
|
-
|
|
184
|
+
This package keeps Studio's deterministic, zero-dependency pipeline and test parity with
|
|
185
|
+
`unsloth/studio`, then layers local access, third-party rendering, and other Studio-independent
|
|
186
|
+
behavior on top. The SSRF guard is real and thoroughly tested, but it is opt-in:
|
|
187
|
+
`allowPrivateAddresses` defaults to `true`, and local files are readable unless `allowLocalFiles`
|
|
188
|
+
is `false`. For other tradeoffs, prefer:
|
|
153
189
|
|
|
154
190
|
| Need | Use |
|
|
155
191
|
|---|---|
|
|
156
192
|
| Browser-like TLS/HTTP fingerprinting to unblock bot-defended pages | `pi-smart-fetch` (`wreq-js` `chrome_145`) |
|
|
157
|
-
| Headless Chrome for JS-rendered SPAs/YouTube/Reddit threads | `georgebashi/pi-web-fetch` (puppeteer + trafilatura) |
|
|
193
|
+
| Headless Chrome for JS-rendered SPAs/YouTube/Reddit threads | Built-in `web_render` (Jina Reader) first; `georgebashi/pi-web-fetch` (puppeteer + trafilatura) when the Reader falls short |
|
|
158
194
|
| Hosted search with semantic ranking and no scraping | `Brave Search API` / `Tavily` / `Exa` via `pi-ollama-web-search` |
|
|
159
195
|
| Prompt-focused page distillation to save context | `pi-web-fetch` `prompt` -> sub-agent or Claude Code `WebFetch(url,prompt)` |
|
|
160
196
|
| Batch fetching many URLs concurrently | `pi-smart-fetch` `batch_web_fetch` or call `web_fetch` in parallel |
|
|
@@ -162,6 +198,25 @@ parity with `unsloth/studio`. For other tradeoffs, prefer:
|
|
|
162
198
|
Mixing is supported: `pi install npm:pi-unsloth-webtools npm:pi-smart-fetch` lets the model
|
|
163
199
|
choose the best tool per URL. No need to fork this package to add those features.
|
|
164
200
|
|
|
201
|
+
## Companion: rotating exit IPs
|
|
202
|
+
|
|
203
|
+
[`pi-tor-proxy`](https://github.com/YuGiMob/pi-tor-proxy) routes pi's in-process `fetch` traffic
|
|
204
|
+
through Tor (it downloads and manages its own Tor binary) and gives each pi instance its own
|
|
205
|
+
circuit and exit IP. The search sweep and `web_render` both use `fetch`, so they leave through
|
|
206
|
+
the current Tor exit, and Jina rate-limits keyless Reader requests per outgoing IP —
|
|
207
|
+
`/tor-cycle` swaps the exit those limits are counted against, while `/tor-country` and
|
|
208
|
+
`/tor-exclude` constrain which exits are used.
|
|
209
|
+
|
|
210
|
+
```sh
|
|
211
|
+
pi install npm:pi-unsloth-webtools npm:pi-tor-proxy
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
`web_fetch` is not routed: it connects directly through `node:http`/`node:https` with a pinned,
|
|
215
|
+
validated IP and ignores the proxy variables (see the proxy note under Known differences).
|
|
216
|
+
Caveats: Tor mode supports Linux and macOS only, adds latency, and many search engines and
|
|
217
|
+
Cloudflare-fronted services challenge or block Tor exits, so cycling helps with per-IP limits but
|
|
218
|
+
is not a guarantee.
|
|
219
|
+
|
|
165
220
|
## Configuration
|
|
166
221
|
|
|
167
222
|
Optional settings in `~/.pi/agent/settings.json` or `.pi/settings.json` (project overrides global):
|
|
@@ -189,10 +244,30 @@ Optional settings in `~/.pi/agent/settings.json` or `.pi/settings.json` (project
|
|
|
189
244
|
| `websitePolicy` | none | Not read from settings. Tools run unrestricted by default; `websitePolicy` is a programmatic option the host passes to `webSearch` / `fetchPageText` |
|
|
190
245
|
| `unslothWebTools.allowPrivateAddresses` / `webFetch.allowPrivateAddresses` | `true` | Opt out to restore the resolved-IP SSRF guard: private/loopback/link-local hosts (localhost, LAN IPs) are refused again. Non-canonical numeric IP encodings stay blocked either way |
|
|
191
246
|
| `unslothWebTools.allowLocalFiles` / `webFetch.allowLocalFiles` | `true` | Opt out to refuse local files in `web_fetch` and `web_search` url mode (`file://` URLs, absolute, `~/`, or `./` paths); when enabled, PDFs are extracted and HTML converted |
|
|
247
|
+
| `unslothWebTools.jinaApiKey` / `webRender.jinaApiKey` | none (`JINA_API_KEY` fallback) | API key for `web_render`'s Jina Reader; raises its rate limits. Settings keys win over the environment variable |
|
|
248
|
+
|
|
249
|
+
### Settings window
|
|
250
|
+
|
|
251
|
+
`/webtools-config` opens an interactive settings window (↑↓ navigate, space toggle, q close). Settings
|
|
252
|
+
persist across sessions in `~/.config/pi-unsloth-webtools/config.json`, created when a setting is
|
|
253
|
+
first changed:
|
|
254
|
+
|
|
255
|
+
```json
|
|
256
|
+
{
|
|
257
|
+
"webRenderEnabled": true
|
|
258
|
+
}
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
| Key | Default | Description |
|
|
262
|
+
|---|---|---|
|
|
263
|
+
| `webRenderEnabled` | `true` | When `false`, the `web_render` tool is deactivated for the session; `web_search` and `web_fetch` are unaffected |
|
|
264
|
+
|
|
265
|
+
On non-Windows platforms the directory honors `XDG_CONFIG_HOME` when set (falling back to
|
|
266
|
+
`~/.config`); on Windows it always uses `~/.config`.
|
|
192
267
|
|
|
193
268
|
Tool params always win over file defaults. Search dedup also strips default ports, so `https://example.com:443/a` and `https://example.com/a` collapse.
|
|
194
269
|
|
|
195
|
-
Environment overrides: `PI_UNSLOTH_CACHE_DIR` changes the fetch cache directory, `PI_UNSLOTH_WEBTOOLS_STATS` opts into append-only sweep stats JSONL, `PI_CODING_AGENT_DIR` / `PI_AGENT_DIR` change the global settings directory. Cache entries live 1 hour and stale copies are served only after a network failure.
|
|
270
|
+
Environment overrides: `PI_UNSLOTH_CACHE_DIR` changes the fetch cache directory, `PI_UNSLOTH_WEBTOOLS_STATS` opts into append-only sweep stats JSONL, `PI_CODING_AGENT_DIR` / `PI_AGENT_DIR` change the global settings directory, and `JINA_API_KEY` supplies the `web_render` key when no settings key is set. Cache entries live 1 hour and stale copies are served only after a network failure.
|
|
196
271
|
|
|
197
272
|
## Troubleshooting
|
|
198
273
|
|
|
@@ -214,7 +289,10 @@ Match on the exact prefix. Do not retry blocked hosts with spelling tricks.
|
|
|
214
289
|
| PDF without text | `(PDF contains no extractable text)` / `(PDF content could not be read as text...)` | Scanned or encrypted PDF. |
|
|
215
290
|
| Download cap hit | `... (page truncated at the download limit)` | Raw fetch hit 512 KiB (10 MiB for PDFs). |
|
|
216
291
|
| maxChars cut | `... (truncated, N chars total)` | Raise `maxChars` for the full text. |
|
|
217
|
-
| Empty page | `(page returned no readable text)` | Page had no extractable text;
|
|
292
|
+
| Empty page | `(page returned no readable text)` | Page had no extractable text; try `web_render`, which renders JavaScript pages. |
|
|
293
|
+
| Render blocked (local file) | `Blocked: web_render cannot fetch local files.` | Use `web_fetch`, which reads local files by default. |
|
|
294
|
+
| Render blocked (private host) | `Blocked: refusing to fetch the non-public address ...` | `web_render` only reaches public hosts; use `web_fetch` for localhost/LAN. |
|
|
295
|
+
| Render failure | `Failed to render URL: ...` | Jina rejected or failed the request (rate limit, bad key, unreachable page). Retry, or check `unslothWebTools.jinaApiKey` / `JINA_API_KEY`. |
|
|
218
296
|
| GitHub rewrite | `README of ... (fetched via the GitHub README API):` | Expected repo-root rewrite, not the HTML chrome. |
|
|
219
297
|
| Cache fallback | `Served from cache` / `STALE cache from YYYY-MM-DD` | Network failed; output is the cached copy with its date. |
|
|
220
298
|
| Wayback fallback | `Fetched from Wayback Machine snapshot (YYYY-MM-DD) for ...` | Original 404'd; output is the archived copy with its date. |
|
|
@@ -255,6 +333,8 @@ The suite ports Unsloth Studio's own tests for these tools:
|
|
|
255
333
|
- `test/smoke.test.ts`: live network checks against real hosts, including a per-engine
|
|
256
334
|
result-health sweep (at least two engines must return well-formed results; engines
|
|
257
335
|
that block or reset connections from datacenter IPs count as unhealthy, not failures)
|
|
336
|
+
- `test/web-render.test.ts`: Jina Reader rendering with a stubbed fetch and DNS, the public-only
|
|
337
|
+
guard, policy enforcement, error mapping, truncation, cancellation, and API key precedence
|
|
258
338
|
|
|
259
339
|
The seams (`seams.resolve` / `seams.request` / `rawFetch`) replace the network stack
|
|
260
340
|
with fakes, mirroring how the Studio suite monkeypatches `_validate_and_resolve_host`
|
package/config-ui.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Key, matchesKey, visibleWidth } from "@earendil-works/pi-tui";
|
|
3
|
+
import { readConfig, type Config } from "./config.ts";
|
|
4
|
+
|
|
5
|
+
export type ConfigToggleKey = "webRenderEnabled";
|
|
6
|
+
|
|
7
|
+
export interface ConfigRow {
|
|
8
|
+
key: ConfigToggleKey;
|
|
9
|
+
label: string;
|
|
10
|
+
hint: string;
|
|
11
|
+
enabled: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function configRows(config: Config): ConfigRow[] {
|
|
15
|
+
return [
|
|
16
|
+
{
|
|
17
|
+
key: "webRenderEnabled",
|
|
18
|
+
label: "Web render",
|
|
19
|
+
hint: "web_render tool (Jina Reader)",
|
|
20
|
+
enabled: config.webRenderEnabled !== false,
|
|
21
|
+
},
|
|
22
|
+
];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function padRow(theme: Theme, innerWidth: number, content: string): string {
|
|
26
|
+
const padded = content + " ".repeat(Math.max(0, innerWidth - visibleWidth(content)));
|
|
27
|
+
return theme.fg("border", "│") + padded + theme.fg("border", "│");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class WebToolsConfigOverlay {
|
|
31
|
+
private rows: ConfigRow[] = [];
|
|
32
|
+
private selected = 0;
|
|
33
|
+
|
|
34
|
+
constructor(
|
|
35
|
+
private readonly opts: {
|
|
36
|
+
tui: { requestRender(force?: boolean): void };
|
|
37
|
+
theme: Theme;
|
|
38
|
+
done: () => void;
|
|
39
|
+
onToggle: (key: ConfigToggleKey) => Promise<void>;
|
|
40
|
+
},
|
|
41
|
+
) {}
|
|
42
|
+
|
|
43
|
+
async load(): Promise<void> {
|
|
44
|
+
this.rows = configRows(await readConfig());
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
private runToggle(row: ConfigRow): void {
|
|
48
|
+
this.opts.tui.requestRender(true);
|
|
49
|
+
void this.opts
|
|
50
|
+
.onToggle(row.key)
|
|
51
|
+
.then(async () => {
|
|
52
|
+
this.rows = configRows(await readConfig());
|
|
53
|
+
this.opts.tui.requestRender(true);
|
|
54
|
+
})
|
|
55
|
+
.catch((error: unknown) => {
|
|
56
|
+
console.error("Failed to toggle web tools setting:", error);
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
private toggleSelected(): void {
|
|
61
|
+
const row = this.rows[this.selected];
|
|
62
|
+
if (!row) return;
|
|
63
|
+
row.enabled = !row.enabled;
|
|
64
|
+
this.runToggle(row);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
handleInput(data: string): void {
|
|
68
|
+
if (matchesKey(data, Key.up) || data === "k") {
|
|
69
|
+
this.selected = (this.selected + this.rows.length - 1) % this.rows.length;
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (matchesKey(data, Key.down) || data === "j") {
|
|
73
|
+
this.selected = (this.selected + 1) % this.rows.length;
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (matchesKey(data, Key.space) || matchesKey(data, Key.enter) || data === " " || data === "\r" || data === "\n") {
|
|
77
|
+
this.toggleSelected();
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (matchesKey(data, Key.escape) || data === "q") {
|
|
81
|
+
this.opts.done();
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
invalidate(): void {}
|
|
86
|
+
|
|
87
|
+
render(width: number): string[] {
|
|
88
|
+
const theme = this.opts.theme;
|
|
89
|
+
const innerWidth = width - 2;
|
|
90
|
+
const lines: string[] = [];
|
|
91
|
+
lines.push(theme.fg("border", `╭${"─".repeat(innerWidth)}╮`));
|
|
92
|
+
lines.push(padRow(theme, innerWidth, ` ${theme.fg("accent", theme.bold("Web Tools Config"))}`));
|
|
93
|
+
lines.push(theme.fg("border", `├${"─".repeat(innerWidth)}┤`));
|
|
94
|
+
this.rows.forEach((row, index) => {
|
|
95
|
+
const cursor = index === this.selected ? theme.fg("accent", "> ") : " ";
|
|
96
|
+
const box = row.enabled ? theme.fg("success", "[x]") : theme.fg("dim", "[ ]");
|
|
97
|
+
const label = index === this.selected ? theme.fg("accent", theme.bold(row.label)) : row.label;
|
|
98
|
+
lines.push(padRow(theme, innerWidth, `${cursor}${box} ${label} ${theme.fg("dim", `— ${row.hint}`)}`));
|
|
99
|
+
});
|
|
100
|
+
lines.push(theme.fg("border", `├${"─".repeat(innerWidth)}┤`));
|
|
101
|
+
lines.push(padRow(theme, innerWidth, theme.fg("dim", " ↑↓ navigate · space toggle · q close")));
|
|
102
|
+
lines.push(theme.fg("border", `╰${"─".repeat(innerWidth)}╯`));
|
|
103
|
+
return lines;
|
|
104
|
+
}
|
|
105
|
+
}
|
package/config.ts
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdir, open, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
|
|
6
|
+
const CONFIG_DIR_NAME = "pi-unsloth-webtools";
|
|
7
|
+
const TEMP_PREFIX = ".tmp-";
|
|
8
|
+
const CONFIG_LOCK_DELAY_MS = 25;
|
|
9
|
+
const CONFIG_LOCK_STALE_MS = 5000;
|
|
10
|
+
const CONFIG_LOCK_RETRIES = Math.ceil(CONFIG_LOCK_STALE_MS / CONFIG_LOCK_DELAY_MS) * 2;
|
|
11
|
+
|
|
12
|
+
export interface Config {
|
|
13
|
+
webRenderEnabled: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const DEFAULT_CONFIG: Config = {
|
|
17
|
+
webRenderEnabled: true,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
function homeBase(): string {
|
|
21
|
+
const envHome = process.env.HOME;
|
|
22
|
+
return envHome && envHome.length > 0 ? envHome : homedir();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function configBase(): string {
|
|
26
|
+
if (process.platform !== "win32") {
|
|
27
|
+
const xdg = process.env.XDG_CONFIG_HOME;
|
|
28
|
+
if (xdg && xdg.length > 0) return xdg;
|
|
29
|
+
}
|
|
30
|
+
return join(homeBase(), ".config");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function configDir(): string {
|
|
34
|
+
return join(configBase(), CONFIG_DIR_NAME);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function configPath(): string {
|
|
38
|
+
return join(configDir(), "config.json");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
42
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function errCode(error: unknown): string | undefined {
|
|
46
|
+
if (typeof error !== "object" || error === null) return undefined;
|
|
47
|
+
const code = (error as { code?: unknown }).code;
|
|
48
|
+
return typeof code === "string" ? code : undefined;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function parseConfig(content: string): Config {
|
|
52
|
+
const parsed = JSON.parse(content) as unknown;
|
|
53
|
+
if (!isRecord(parsed) || (parsed.webRenderEnabled !== undefined && typeof parsed.webRenderEnabled !== "boolean")) {
|
|
54
|
+
throw new Error("config.json must be an object with a boolean webRenderEnabled field");
|
|
55
|
+
}
|
|
56
|
+
return {
|
|
57
|
+
webRenderEnabled:
|
|
58
|
+
typeof parsed.webRenderEnabled === "boolean" ? parsed.webRenderEnabled : DEFAULT_CONFIG.webRenderEnabled,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function loadConfigFile(): Promise<{ config: Config; corrupted: boolean }> {
|
|
63
|
+
let content: string;
|
|
64
|
+
try {
|
|
65
|
+
content = await readFile(configPath(), "utf-8");
|
|
66
|
+
} catch (error: unknown) {
|
|
67
|
+
if (errCode(error) === "ENOENT") return { config: { ...DEFAULT_CONFIG }, corrupted: false };
|
|
68
|
+
console.error("Config file unreadable, using defaults:", error);
|
|
69
|
+
return { config: { ...DEFAULT_CONFIG }, corrupted: false };
|
|
70
|
+
}
|
|
71
|
+
try {
|
|
72
|
+
return { config: parseConfig(content), corrupted: false };
|
|
73
|
+
} catch (error: unknown) {
|
|
74
|
+
try {
|
|
75
|
+
const badPath = configPath();
|
|
76
|
+
await rename(badPath, `${badPath}.corrupt-${Date.now()}-${process.pid}-${Math.random().toString(36).slice(2)}`);
|
|
77
|
+
} catch {}
|
|
78
|
+
console.error("Config file corrupted, quarantined, using defaults:", error);
|
|
79
|
+
return { config: { ...DEFAULT_CONFIG }, corrupted: true };
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function readConfig(): Promise<Config> {
|
|
84
|
+
return (await loadConfigFile()).config;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function readConfigWithStatus(): Promise<{ config: Config; corrupted: boolean }> {
|
|
88
|
+
return loadConfigFile();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function syncDir(dir: string): Promise<void> {
|
|
92
|
+
if (process.platform === "win32") return;
|
|
93
|
+
try {
|
|
94
|
+
const handle = await open(dir, "r");
|
|
95
|
+
try {
|
|
96
|
+
await handle.sync();
|
|
97
|
+
} finally {
|
|
98
|
+
await handle.close();
|
|
99
|
+
}
|
|
100
|
+
} catch {}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function writeConfig(config: Config): Promise<void> {
|
|
104
|
+
const path = configPath();
|
|
105
|
+
const dir = dirname(path);
|
|
106
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
107
|
+
const tempPath = join(dir, `${TEMP_PREFIX}${randomUUID()}`);
|
|
108
|
+
const content = JSON.stringify(config, null, 2);
|
|
109
|
+
await writeFile(tempPath, content, { mode: 0o600 });
|
|
110
|
+
try {
|
|
111
|
+
await rename(tempPath, path);
|
|
112
|
+
await syncDir(dir);
|
|
113
|
+
} catch (error: unknown) {
|
|
114
|
+
if (process.platform === "win32" && errCode(error) === "EPERM") {
|
|
115
|
+
try {
|
|
116
|
+
await writeFile(path, content, "utf-8");
|
|
117
|
+
return;
|
|
118
|
+
} finally {
|
|
119
|
+
try {
|
|
120
|
+
await rm(tempPath, { force: true });
|
|
121
|
+
} catch {}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
try {
|
|
125
|
+
await rm(tempPath, { force: true });
|
|
126
|
+
} catch {}
|
|
127
|
+
throw error;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
interface ConfigLock {
|
|
132
|
+
path: string;
|
|
133
|
+
dev: number;
|
|
134
|
+
ino: number;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function acquireConfigLock(lockPath: string): Promise<ConfigLock> {
|
|
138
|
+
try {
|
|
139
|
+
await mkdir(dirname(lockPath), { recursive: true, mode: 0o700 });
|
|
140
|
+
} catch {}
|
|
141
|
+
for (let attempt = 0; attempt < CONFIG_LOCK_RETRIES; attempt++) {
|
|
142
|
+
try {
|
|
143
|
+
await mkdir(lockPath, { mode: 0o700 });
|
|
144
|
+
} catch (error: unknown) {
|
|
145
|
+
if (errCode(error) === "ENOENT") {
|
|
146
|
+
try {
|
|
147
|
+
await mkdir(dirname(lockPath), { recursive: true, mode: 0o700 });
|
|
148
|
+
} catch {}
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
if (errCode(error) !== "EEXIST") throw error;
|
|
152
|
+
try {
|
|
153
|
+
const lockStats = await stat(lockPath);
|
|
154
|
+
if (Date.now() - lockStats.mtimeMs > CONFIG_LOCK_STALE_MS) {
|
|
155
|
+
await rm(lockPath, { recursive: true, force: true });
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
} catch {}
|
|
159
|
+
await new Promise<void>((resolve) => setTimeout(resolve, CONFIG_LOCK_DELAY_MS));
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
try {
|
|
163
|
+
const lockStats = await stat(lockPath);
|
|
164
|
+
return { path: lockPath, dev: lockStats.dev, ino: lockStats.ino };
|
|
165
|
+
} catch (error: unknown) {
|
|
166
|
+
if (errCode(error) !== "ENOENT") {
|
|
167
|
+
try {
|
|
168
|
+
await rm(lockPath, { recursive: true, force: true });
|
|
169
|
+
} catch {}
|
|
170
|
+
}
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
throw new Error(`[E_ACCESS] Could not acquire config lock: ${lockPath}`);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function releaseConfigLock(lock: ConfigLock): Promise<void> {
|
|
178
|
+
try {
|
|
179
|
+
const lockStats = await stat(lock.path);
|
|
180
|
+
if (lockStats.dev !== lock.dev || lockStats.ino !== lock.ino) return;
|
|
181
|
+
} catch {
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
try {
|
|
185
|
+
await rm(lock.path, { recursive: true, force: true });
|
|
186
|
+
} catch {}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export async function updateConfig(mut: (config: Config) => void): Promise<Config> {
|
|
190
|
+
const path = configPath();
|
|
191
|
+
const lock = await acquireConfigLock(`${path}.lock`);
|
|
192
|
+
try {
|
|
193
|
+
const config = await readConfig();
|
|
194
|
+
mut(config);
|
|
195
|
+
await writeConfig(config);
|
|
196
|
+
return config;
|
|
197
|
+
} finally {
|
|
198
|
+
await releaseConfigLock(lock);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export async function toggleWebRender(): Promise<boolean> {
|
|
203
|
+
const config = await updateConfig((current) => {
|
|
204
|
+
current.webRenderEnabled = !(current.webRenderEnabled === true);
|
|
205
|
+
});
|
|
206
|
+
return config.webRenderEnabled === true;
|
|
207
|
+
}
|
package/index.ts
CHANGED
|
@@ -3,7 +3,10 @@ import { Type } from "typebox";
|
|
|
3
3
|
import { collapseWhitespace } from "./html-to-md.ts";
|
|
4
4
|
import { SEARCH_TIMEOUT_MS, webSearch as defaultWebSearch } from "./web-search.ts";
|
|
5
5
|
import { DEFAULT_FETCH_TIMEOUT_MS, fetchPageText as defaultFetchPageText } from "./web-fetch.ts";
|
|
6
|
-
import {
|
|
6
|
+
import { renderPageText as defaultRenderPageText } from "./web-render.ts";
|
|
7
|
+
import { loadDefaultFetchSettings, loadDefaultFetchTimeoutMs, loadJinaApiKey } from "./settings.ts";
|
|
8
|
+
import { readConfigWithStatus, toggleWebRender } from "./config.ts";
|
|
9
|
+
import { WebToolsConfigOverlay } from "./config-ui.ts";
|
|
7
10
|
|
|
8
11
|
function toolCallLine(theme: Theme, name: string, detail: string) {
|
|
9
12
|
const line = theme.fg("toolTitle", theme.bold(name)) + (detail ? ` ${theme.fg("accent", detail)}` : "");
|
|
@@ -77,14 +80,31 @@ const WebFetchParams = Type.Object({
|
|
|
77
80
|
),
|
|
78
81
|
});
|
|
79
82
|
|
|
83
|
+
const WebRenderParams = Type.Object({
|
|
84
|
+
url: Type.String({ description: "Public URL of the page to render" }),
|
|
85
|
+
maxChars: Type.Optional(
|
|
86
|
+
Type.Number({
|
|
87
|
+
description: "Truncate the returned content to this many characters (default: no limit)",
|
|
88
|
+
}),
|
|
89
|
+
),
|
|
90
|
+
timeoutMs: Type.Optional(
|
|
91
|
+
Type.Number({
|
|
92
|
+
minimum: 1000,
|
|
93
|
+
description: "Overall timeout in milliseconds (default: 60000)",
|
|
94
|
+
}),
|
|
95
|
+
),
|
|
96
|
+
});
|
|
97
|
+
|
|
80
98
|
export interface WebToolsDeps {
|
|
81
99
|
fetchPageText?: typeof defaultFetchPageText;
|
|
82
100
|
webSearch?: typeof defaultWebSearch;
|
|
101
|
+
renderPageText?: typeof defaultRenderPageText;
|
|
83
102
|
}
|
|
84
103
|
|
|
85
104
|
export function createWebTools(deps: WebToolsDeps = {}) {
|
|
86
105
|
const fetchPageText = deps.fetchPageText ?? defaultFetchPageText;
|
|
87
106
|
const webSearch = deps.webSearch ?? defaultWebSearch;
|
|
107
|
+
const renderPageText = deps.renderPageText ?? defaultRenderPageText;
|
|
88
108
|
return {
|
|
89
109
|
webSearchTool: defineTool({
|
|
90
110
|
name: "web_search",
|
|
@@ -167,11 +187,91 @@ export function createWebTools(deps: WebToolsDeps = {}) {
|
|
|
167
187
|
return { content: [{ type: "text", text }], details: {} };
|
|
168
188
|
},
|
|
169
189
|
}),
|
|
190
|
+
webRenderTool: defineTool({
|
|
191
|
+
name: "web_render",
|
|
192
|
+
label: "Web Render",
|
|
193
|
+
description:
|
|
194
|
+
"Render a public web page to Markdown through the third-party Jina Reader (r.jina.ai). " +
|
|
195
|
+
"Use it for JavaScript-rendered pages that web_fetch cannot read. " +
|
|
196
|
+
"The target URL is sent to Jina; local files and non-public addresses are refused. " +
|
|
197
|
+
"An optional JINA_API_KEY or unslothWebTools.jinaApiKey raises the Reader's rate limits.",
|
|
198
|
+
promptSnippet: "Render a JavaScript-rendered page to Markdown via the Jina Reader",
|
|
199
|
+
promptGuidelines: [
|
|
200
|
+
'Use web_render when web_fetch reports "(page returned no readable text)" or the page only fills in through JavaScript.',
|
|
201
|
+
],
|
|
202
|
+
parameters: WebRenderParams,
|
|
203
|
+
renderCall(args, theme) {
|
|
204
|
+
return toolCallLine(theme, "web_render", collapsedArg(args.url));
|
|
205
|
+
},
|
|
206
|
+
async execute(_toolCallId, params, signal, onUpdate, _ctx) {
|
|
207
|
+
onUpdate?.({ content: [{ type: "text", text: `Rendering ${params.url}...` }], details: {} });
|
|
208
|
+
const cwd = (_ctx as ExtensionContext | undefined)?.cwd;
|
|
209
|
+
const { timeoutMs, maxChars } = await fetchDefaults(cwd, params);
|
|
210
|
+
const apiKey = await loadJinaApiKey(cwd);
|
|
211
|
+
const text = await renderPageText(params.url, {
|
|
212
|
+
timeoutMs,
|
|
213
|
+
maxChars,
|
|
214
|
+
signal: signal ?? undefined,
|
|
215
|
+
apiKey,
|
|
216
|
+
});
|
|
217
|
+
return { content: [{ type: "text", text }], details: {} };
|
|
218
|
+
},
|
|
219
|
+
}),
|
|
170
220
|
};
|
|
171
221
|
}
|
|
172
222
|
|
|
173
223
|
export default function (pi: ExtensionAPI) {
|
|
174
|
-
const { webSearchTool, webFetchTool } = createWebTools();
|
|
224
|
+
const { webSearchTool, webFetchTool, webRenderTool } = createWebTools();
|
|
175
225
|
pi.registerTool(webSearchTool);
|
|
176
226
|
pi.registerTool(webFetchTool);
|
|
227
|
+
pi.registerTool(webRenderTool);
|
|
228
|
+
|
|
229
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
230
|
+
try {
|
|
231
|
+
const { config, corrupted } = await readConfigWithStatus();
|
|
232
|
+
if (corrupted && ctx.hasUI) {
|
|
233
|
+
ctx.ui.notify("Web tools config was corrupt and was reset to defaults", "warning");
|
|
234
|
+
}
|
|
235
|
+
if (!config.webRenderEnabled) {
|
|
236
|
+
pi.setActiveTools(pi.getActiveTools().filter((name) => name !== "web_render"));
|
|
237
|
+
}
|
|
238
|
+
} catch (error) {
|
|
239
|
+
console.error("Failed to load web tools config:", error);
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
pi.registerCommand("webtools-config", {
|
|
244
|
+
description: "Open the web tools settings window (web_render on/off)",
|
|
245
|
+
handler: async (_args, ctx) => {
|
|
246
|
+
if (!ctx.hasUI) {
|
|
247
|
+
ctx.ui.notify("/webtools-config requires interactive mode", "error");
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
await ctx.ui.custom<void>(
|
|
251
|
+
async (tui, theme, _keybindings, done) => {
|
|
252
|
+
const overlay = new WebToolsConfigOverlay({
|
|
253
|
+
tui,
|
|
254
|
+
theme,
|
|
255
|
+
done,
|
|
256
|
+
onToggle: async (key) => {
|
|
257
|
+
if (key !== "webRenderEnabled") return;
|
|
258
|
+
const enabled = await toggleWebRender();
|
|
259
|
+
const active = pi.getActiveTools();
|
|
260
|
+
pi.setActiveTools(
|
|
261
|
+
enabled
|
|
262
|
+
? [...new Set([...active, "web_render"])]
|
|
263
|
+
: active.filter((name) => name !== "web_render"),
|
|
264
|
+
);
|
|
265
|
+
},
|
|
266
|
+
});
|
|
267
|
+
await overlay.load();
|
|
268
|
+
return overlay;
|
|
269
|
+
},
|
|
270
|
+
{
|
|
271
|
+
overlay: true,
|
|
272
|
+
overlayOptions: { anchor: "center", width: "90%", minWidth: 60, maxHeight: "90%" },
|
|
273
|
+
},
|
|
274
|
+
);
|
|
275
|
+
},
|
|
276
|
+
});
|
|
177
277
|
}
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-unsloth-webtools",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "Pi extension: web_search and web_fetch tools
|
|
5
|
+
"description": "Pi extension: web_search and web_fetch tools that began as a port of the Unsloth Studio codebase and now diverge from it (multi-engine search, opt-in SSRF guard, HTML-to-Markdown extraction)",
|
|
6
6
|
"main": "index.ts",
|
|
7
7
|
"repository": {
|
|
8
8
|
"type": "git",
|
|
@@ -23,8 +23,11 @@
|
|
|
23
23
|
"license": "AGPL-3.0",
|
|
24
24
|
"files": [
|
|
25
25
|
"index.ts",
|
|
26
|
+
"config.ts",
|
|
27
|
+
"config-ui.ts",
|
|
26
28
|
"web-search.ts",
|
|
27
29
|
"web-fetch.ts",
|
|
30
|
+
"web-render.ts",
|
|
28
31
|
"web-access.ts",
|
|
29
32
|
"html-to-md.ts",
|
|
30
33
|
"engines.ts",
|
|
@@ -44,6 +47,7 @@
|
|
|
44
47
|
},
|
|
45
48
|
"peerDependencies": {
|
|
46
49
|
"@earendil-works/pi-coding-agent": "*",
|
|
50
|
+
"@earendil-works/pi-tui": "*",
|
|
47
51
|
"typebox": "*"
|
|
48
52
|
},
|
|
49
53
|
"engines": {
|
package/settings.ts
CHANGED
|
@@ -49,6 +49,21 @@ function pickBoolean(data: Record<string, unknown>, paths: string[][]): boolean
|
|
|
49
49
|
return undefined;
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
function pickString(data: Record<string, unknown>, paths: string[][]): string | undefined {
|
|
53
|
+
for (const path of paths) {
|
|
54
|
+
let cur: unknown = data;
|
|
55
|
+
for (const key of path) {
|
|
56
|
+
if (cur && typeof cur === "object" && !Array.isArray(cur)) cur = (cur as Record<string, unknown>)[key];
|
|
57
|
+
else {
|
|
58
|
+
cur = undefined;
|
|
59
|
+
break;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (typeof cur === "string" && cur.trim()) return cur.trim();
|
|
63
|
+
}
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
|
|
52
67
|
const MAX_RESULTS = 5;
|
|
53
68
|
|
|
54
69
|
const MAX_RESULTS_PATHS: string[][] = [
|
|
@@ -79,6 +94,11 @@ const ALLOW_LOCAL_FILES_PATHS: string[][] = [
|
|
|
79
94
|
["webFetch", "allowLocalFiles"],
|
|
80
95
|
];
|
|
81
96
|
|
|
97
|
+
const JINA_API_KEY_PATHS: string[][] = [
|
|
98
|
+
["unslothWebTools", "jinaApiKey"],
|
|
99
|
+
["webRender", "jinaApiKey"],
|
|
100
|
+
];
|
|
101
|
+
|
|
82
102
|
function clampMaxResults(value: number): number {
|
|
83
103
|
return Math.min(20, Math.max(1, value));
|
|
84
104
|
}
|
|
@@ -148,3 +168,14 @@ export async function loadDefaultFetchSettings(cwd?: string): Promise<{
|
|
|
148
168
|
}
|
|
149
169
|
return { maxChars, timeoutMs, allowPrivateAddresses, allowLocalFiles };
|
|
150
170
|
}
|
|
171
|
+
|
|
172
|
+
export async function loadJinaApiKey(cwd?: string): Promise<string | undefined> {
|
|
173
|
+
let result: string | undefined;
|
|
174
|
+
for (const data of await settingsEntries(cwd)) {
|
|
175
|
+
const candidate = pickString(data, JINA_API_KEY_PATHS);
|
|
176
|
+
if (candidate !== undefined) result = candidate;
|
|
177
|
+
}
|
|
178
|
+
if (result !== undefined) return result;
|
|
179
|
+
const env = process.env.JINA_API_KEY?.trim();
|
|
180
|
+
return env ? env : undefined;
|
|
181
|
+
}
|
package/web-fetch.ts
CHANGED
|
@@ -557,7 +557,7 @@ function sleepAbortable(ms: number, signal?: AbortSignal): Promise<void> {
|
|
|
557
557
|
});
|
|
558
558
|
}
|
|
559
559
|
|
|
560
|
-
async function
|
|
560
|
+
export async function resolveAndValidateHost(
|
|
561
561
|
hostname: string,
|
|
562
562
|
signal?: AbortSignal,
|
|
563
563
|
allowPrivateAddresses = false,
|
|
@@ -824,7 +824,7 @@ export async function fetchUrlRaw(
|
|
|
824
824
|
const maxBytes = options.maxBytes ?? MAX_FETCH_BYTES;
|
|
825
825
|
const maxPdfBytes = options.maxPdfBytes ?? MAX_PDF_FETCH_BYTES;
|
|
826
826
|
const seams = options.seams ?? {};
|
|
827
|
-
const resolveHost = seams.resolve ??
|
|
827
|
+
const resolveHost = seams.resolve ?? resolveAndValidateHost;
|
|
828
828
|
const allowPrivateAddresses = options.allowPrivateAddresses ?? true;
|
|
829
829
|
const performRequest = seams.request ?? requestHop;
|
|
830
830
|
const resolveWithBudget = async (hostname: string): Promise<ResolvedHost> => {
|
|
@@ -1256,7 +1256,7 @@ async function fetchWaybackSnapshot(
|
|
|
1256
1256
|
}
|
|
1257
1257
|
const WINDOWS_PATH_RE = /^[a-zA-Z]:[\\/]/;
|
|
1258
1258
|
|
|
1259
|
-
function parseLocalPath(url: string): string | null {
|
|
1259
|
+
export function parseLocalPath(url: string): string | null {
|
|
1260
1260
|
const trimmed = url.trim();
|
|
1261
1261
|
if (/^file:/i.test(trimmed)) {
|
|
1262
1262
|
try {
|
package/web-render.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { collapseWhitespace } from "./html-to-md.ts";
|
|
2
|
+
import { checkUrlAccess, MAX_SIGNAL_TIMEOUT_MS, normalizeUrlScheme, type WebsitePolicy } from "./web-access.ts";
|
|
3
|
+
import { parseLocalPath, resolveAndValidateHost, truncatePageText } from "./web-fetch.ts";
|
|
4
|
+
|
|
5
|
+
const DEFAULT_RENDER_TIMEOUT_MS = 60_000;
|
|
6
|
+
const JINA_READER_URL = "https://r.jina.ai/";
|
|
7
|
+
const CANCELLED_MESSAGE = "Failed to render URL: cancelled.";
|
|
8
|
+
const TIMED_OUT_MESSAGE = "Failed to render URL: timed out.";
|
|
9
|
+
const LOCAL_FILE_MESSAGE = "Blocked: web_render cannot fetch local files.";
|
|
10
|
+
const EMPTY_READER_MESSAGE = "Failed to render URL: the Jina Reader returned no content.";
|
|
11
|
+
|
|
12
|
+
export interface RenderPageOptions {
|
|
13
|
+
timeoutMs?: number;
|
|
14
|
+
maxChars?: number;
|
|
15
|
+
signal?: AbortSignal;
|
|
16
|
+
websitePolicy?: WebsitePolicy | null;
|
|
17
|
+
apiKey?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface JinaReaderData {
|
|
21
|
+
title?: string;
|
|
22
|
+
url?: string;
|
|
23
|
+
content?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface JinaReaderPayload {
|
|
27
|
+
data?: JinaReaderData;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function clampTimeout(value: number | undefined): number {
|
|
31
|
+
if (value === undefined || !Number.isFinite(value)) return DEFAULT_RENDER_TIMEOUT_MS;
|
|
32
|
+
return Math.min(MAX_SIGNAL_TIMEOUT_MS, Math.max(1, Math.floor(value)));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function budgetMessage(caller: AbortSignal | undefined, signal: AbortSignal): string | null {
|
|
36
|
+
if (caller?.aborted) return CANCELLED_MESSAGE;
|
|
37
|
+
if (signal.aborted) return TIMED_OUT_MESSAGE;
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function readerErrorDetail(response: Response): Promise<string> {
|
|
42
|
+
try {
|
|
43
|
+
const body = (await response.json()) as { readableMessage?: unknown; message?: unknown; detail?: unknown };
|
|
44
|
+
const detail = [body.readableMessage, body.message, body.detail].find(
|
|
45
|
+
(value) => typeof value === "string" && value.trim().length > 0,
|
|
46
|
+
);
|
|
47
|
+
if (detail) return `${response.status} ${detail}`;
|
|
48
|
+
} catch {}
|
|
49
|
+
return `${response.status} ${response.statusText}`.trim();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function formatRenderedPage(data: JinaReaderData, fallbackUrl: string, maxChars: number | undefined): string {
|
|
53
|
+
const title = collapseWhitespace(String(data.title ?? ""));
|
|
54
|
+
const finalUrl = collapseWhitespace(String(data.url ?? "")) || fallbackUrl;
|
|
55
|
+
const content = String(data.content ?? "").trim();
|
|
56
|
+
if (!content) return truncatePageText("", maxChars);
|
|
57
|
+
const header: string[] = [];
|
|
58
|
+
if (title) header.push(`Title: ${title}`);
|
|
59
|
+
header.push(`URL: ${finalUrl}`);
|
|
60
|
+
header.push("Rendered via the Jina Reader (r.jina.ai).");
|
|
61
|
+
return truncatePageText(`${header.join("\n")}\n\n${content}`, maxChars);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function renderPageText(url: string, options: RenderPageOptions = {}): Promise<string> {
|
|
65
|
+
const target = typeof url === "string" ? url.trim() : "";
|
|
66
|
+
const policy = options.websitePolicy ?? null;
|
|
67
|
+
if (!target) return checkUrlAccess("", policy)[1];
|
|
68
|
+
if (parseLocalPath(target) !== null) return LOCAL_FILE_MESSAGE;
|
|
69
|
+
const normalized = normalizeUrlScheme(target);
|
|
70
|
+
const [allowed, reason, hostname] = checkUrlAccess(normalized, policy);
|
|
71
|
+
if (!allowed) return reason;
|
|
72
|
+
const timeoutSignal = AbortSignal.timeout(clampTimeout(options.timeoutMs));
|
|
73
|
+
const signal = options.signal ? AbortSignal.any([options.signal, timeoutSignal]) : timeoutSignal;
|
|
74
|
+
const resolved = await resolveAndValidateHost(hostname, signal, false);
|
|
75
|
+
const resolutionBudget = budgetMessage(options.signal, signal);
|
|
76
|
+
if (resolutionBudget !== null) return resolutionBudget;
|
|
77
|
+
if (!resolved.ok) return resolved.reason;
|
|
78
|
+
const headers: Record<string, string> = { Accept: "application/json" };
|
|
79
|
+
const apiKey = options.apiKey?.trim();
|
|
80
|
+
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
|
|
81
|
+
let response: Response;
|
|
82
|
+
try {
|
|
83
|
+
response = await fetch(JINA_READER_URL + encodeURIComponent(normalized), { headers, signal });
|
|
84
|
+
} catch (err) {
|
|
85
|
+
const budget = budgetMessage(options.signal, signal);
|
|
86
|
+
if (budget !== null) return budget;
|
|
87
|
+
return `Failed to render URL: ${err instanceof Error ? err.message : String(err)}`;
|
|
88
|
+
}
|
|
89
|
+
if (!response.ok) return `Failed to render URL: ${await readerErrorDetail(response)}`;
|
|
90
|
+
let payload: JinaReaderPayload;
|
|
91
|
+
try {
|
|
92
|
+
payload = (await response.json()) as JinaReaderPayload;
|
|
93
|
+
} catch {
|
|
94
|
+
const budget = budgetMessage(options.signal, signal);
|
|
95
|
+
if (budget !== null) return budget;
|
|
96
|
+
return `Failed to render URL: the Jina Reader returned a non-JSON response (HTTP ${response.status}).`;
|
|
97
|
+
}
|
|
98
|
+
if (!payload || !payload.data) return EMPTY_READER_MESSAGE;
|
|
99
|
+
return formatRenderedPage(payload.data, normalized, options.maxChars);
|
|
100
|
+
}
|