pi-unsloth-webtools 0.8.0 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +123 -75
- package/html-to-md.ts +19 -4
- package/index.ts +52 -70
- package/lightpanda.ts +299 -0
- package/package.json +10 -5
- package/scripts/install-lightpanda.sh +294 -0
- package/settings.ts +57 -11
- package/tls-fetch.ts +205 -0
- package/web-fetch.ts +72 -26
- package/config-ui.ts +0 -105
- package/config.ts +0 -207
- package/web-render.ts +0 -100
package/README.md
CHANGED
|
@@ -5,8 +5,8 @@ A [pi](https://github.com/earendil-works/pi-coding-agent) extension providing `w
|
|
|
5
5
|
([`unslothai/unsloth`](https://github.com/unslothai/unsloth), `studio/backend/core/inference/`);
|
|
6
6
|
the engine, extraction, and PDF layers are still derived from it, but the package is no longer
|
|
7
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,
|
|
9
|
-
other behavior Studio does not have.
|
|
8
|
+
and adds a fetch cache, Wayback fallbacks, page metadata, a browser-fingerprint retry, local
|
|
9
|
+
local Lightpanda rendering, and other behavior Studio does not have.
|
|
10
10
|
[Known differences from Studio](#known-differences-from-studio). The `unsloth` in the name marks
|
|
11
11
|
provenance, not affiliation.
|
|
12
12
|
|
|
@@ -24,6 +24,12 @@ To install from source instead:
|
|
|
24
24
|
pi install /path/to/pi-unsloth-webtools
|
|
25
25
|
```
|
|
26
26
|
|
|
27
|
+
Installing pulls one runtime dependency, `wreq-js`, whose prebuilt native binding gives `web_fetch`
|
|
28
|
+
its browser-shaped TLS fingerprint. Bindings exist for Linux (x64/arm64), macOS and Windows; because
|
|
29
|
+
the binding is what `--omit=optional` skips, that install gets the shim without the engine and
|
|
30
|
+
`web_fetch` quietly uses the plain Node transport instead. `mupdf` (PDF text extraction) stays an
|
|
31
|
+
optional dependency, and the package works without it.
|
|
32
|
+
|
|
27
33
|
## What it does
|
|
28
34
|
|
|
29
35
|
Both tools display their target in the TUI tool row: `web_search "query"` and `web_fetch <url>`.
|
|
@@ -122,35 +128,70 @@ Port of Studio's `_fetch_page_text` / `_fetch_url_raw` pipeline:
|
|
|
122
128
|
`Date:` (`article:published_time` / `dc.date` / `date`) and `Site:` (`og:site_name` /
|
|
123
129
|
`application-name`) lines are added when declared, so the model can judge recency and
|
|
124
130
|
provenance.
|
|
125
|
-
- A direct fetch refused with HTTP 403 is retried through
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
the
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
131
|
+
- A direct fetch refused with HTTP 403 is retried through a browser-fingerprint transport
|
|
132
|
+
(`wreq-js`, Chrome TLS/HTTP2 shape) that keeps the pinned DNS and the website policy checks.
|
|
133
|
+
This is the default transport and clears many bot walls without a browser and without a third
|
|
134
|
+
party; opt out with `webFetch.transport: "direct-first"` or `"off"`.
|
|
135
|
+
- A page that is still refused, or that looks JavaScript-rendered, is rendered locally with
|
|
136
|
+
Lightpanda when the binary is installed. There is no third-party rendering service: nothing is
|
|
137
|
+
sent anywhere except to the target itself. Bot-challenge pages are rejected, so an interstitial
|
|
138
|
+
never replaces a real render. When nothing renders, the original error or a
|
|
139
|
+
`*(JavaScript-rendered page; content may be incomplete)*` note is returned.
|
|
140
|
+
|
|
141
|
+
### Rendering and anti-bot tiers
|
|
142
|
+
|
|
143
|
+
`web_fetch` escalates through two tiers, cheapest first:
|
|
144
|
+
|
|
145
|
+
1. **Browser-fingerprint transport** (`wreq-js`, Chrome TLS/HTTP2 shape) is the default, and it is
|
|
146
|
+
a required dependency: it is a small native Rust module with prebuilt bindings for Linux
|
|
147
|
+
(x64/arm64, glibc and musl), macOS (x64/arm64) and Windows (x64/arm64). Requests are pinned to
|
|
148
|
+
the resolved, validated IP, browser-emulation headers are left intact so the fingerprint stays
|
|
149
|
+
coherent, and redirects are handed back to the main hop loop, so every hop is re-validated
|
|
150
|
+
against the website policy. The plain Node transport takes over when no prebuilt binding exists
|
|
151
|
+
for the platform, when a SOCKS5 proxy is configured (traffic must keep using the tunnelling
|
|
152
|
+
path), or when the request fails at the connection level; a 403 from either transport triggers
|
|
153
|
+
one retry through the other. `webFetch.transport` selects `tls-first` (default), `direct-first`,
|
|
154
|
+
or `off`.
|
|
155
|
+
2. **Local rendering** with a locally installed
|
|
156
|
+
[Lightpanda](https://github.com/lightpanda-io/browser) binary when a page is still refused or
|
|
157
|
+
looks JavaScript-rendered.
|
|
158
|
+
|
|
159
|
+
There is no third tier. If a page needs a browser that the local renderer cannot provide, the
|
|
160
|
+
original error (or the incomplete-content note) is returned rather than shipping the URL to a
|
|
161
|
+
third-party rendering service.
|
|
162
|
+
|
|
163
|
+
#### Local rendering (Lightpanda)
|
|
164
|
+
|
|
165
|
+
- Runs `lightpanda fetch --dump html --wait-until networkidle --block-private-networks` and puts
|
|
166
|
+
the dump through the same Markdown pipeline as any other fetch, so titles, metadata,
|
|
167
|
+
main-content scoping and boilerplate removal match the direct path.
|
|
168
|
+
- `--block-private-networks` is always passed in addition to the pre-flight resolve check, so a
|
|
169
|
+
redirect or subresource inside the browser cannot reach a private address.
|
|
170
|
+
- A render is only accepted when it contains real prose, or beats the fetched text by 1.5x. A
|
|
171
|
+
non-zero exit, a timeout, or a bot-challenge page counts as a failed tier, so the next tier
|
|
172
|
+
still gets its chance.
|
|
173
|
+
- Lightpanda identifies itself honestly and refuses to impersonate a browser user agent, so hard
|
|
174
|
+
anti-bot walls are returned as failures (the direct error, or the incomplete-content note).
|
|
175
|
+
- Binary resolution: `webRender.lightpandaPath`, then `PI_LIGHTPANDA_BIN`, then the launcher
|
|
176
|
+
installed by `scripts/install-lightpanda.sh`, then `lightpanda` on `PATH`. Prebuilt binaries
|
|
177
|
+
exist for Linux (glibc; musl needs a source build) and macOS, plus Docker images; Windows
|
|
178
|
+
needs WSL2.
|
|
179
|
+
- Version matters: 1.0.0 renders JavaScript-heavy pages that the 0.2.x line cannot — measured on
|
|
180
|
+
the same machine, IMDb went from a 76-byte empty document to 21k characters, dribbble from 32
|
|
181
|
+
characters to 18k, and a Medium article from a challenge page to real text. Linux builds from
|
|
182
|
+
0.3 on require glibc 2.38, so older distributions are stuck on 0.2.x without the next bullet.
|
|
183
|
+
- Windows has no native Lightpanda build: install it inside WSL2 and point `webRender.lightpandaCommand`
|
|
184
|
+
at `["wsl.exe", "-e", "<path inside WSL>"]`, which the renderer then drives like any other binary.
|
|
185
|
+
The same setting works for a container (`["docker", "run", "--rm", "lightpanda/browser:nightly"]`)
|
|
186
|
+
or any wrapper. `scripts/install-lightpanda.sh` prints that recipe when run outside WSL.
|
|
187
|
+
- `bash scripts/install-lightpanda.sh` installs the newest stable release. When the system glibc
|
|
188
|
+
predates what the binary needs, it downloads Debian's `libc6` for the current stable suite,
|
|
189
|
+
extracts it into the install directory, and writes a launcher shim — so 1.0.0 runs on a
|
|
190
|
+
glibc 2.36 host without touching the system libraries. It also writes
|
|
191
|
+
`webRender.lightpandaPath` into the global settings, so the extension picks the binary up with
|
|
192
|
+
no further setup; `--no-configure` skips that and `--print-path` prints the launcher path for CI.
|
|
193
|
+
- Disable the tier with `webRender.lightpandaEnabled: false`; `web_fetch` then stops after the
|
|
194
|
+
network attempts and reports the original error.
|
|
154
195
|
|
|
155
196
|
## Known differences from Studio
|
|
156
197
|
|
|
@@ -159,10 +200,15 @@ converted text plus SPA markers, script-heavy markup, a noscript body, or a desc
|
|
|
159
200
|
(`file://` URLs and absolute, `~/`, `./` paths) by default; opt out with
|
|
160
201
|
`webFetch.allowPrivateAddresses: false` and `webFetch.allowLocalFiles: false` to restore
|
|
161
202
|
Studio's behavior.
|
|
162
|
-
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
203
|
+
- Browser-fingerprint retry: a 403 is retried through `wreq-js` (Chrome TLS/HTTP2 shape) before any
|
|
204
|
+
rendering, keeping the pinned IP, the website policy, and the local-file refusal rules. Studio has
|
|
205
|
+
no such path.
|
|
206
|
+
- Local rendering: Lightpanda renders the page in a local browser and the dump runs through the same
|
|
207
|
+
extraction pipeline; nothing leaves the machine, and `--block-private-networks` is always passed.
|
|
208
|
+
Studio has no local rendering path.
|
|
209
|
+
- No third-party rendering: Studio has no rendering path at all; this port renders refused or
|
|
210
|
+
JavaScript-heavy pages locally with Lightpanda and never sends the URL to a rendering service.
|
|
211
|
+
Local files and non-public addresses stay refused for the browser tier.
|
|
166
212
|
- PDF styling: MuPDF.js exposes one font per line, so mixed-style lines style the
|
|
167
213
|
whole line instead of per-span; superscript, subscript, underline, strikeout, and
|
|
168
214
|
highlight markers are not emitted. Tables use a conservative text-grid detector:
|
|
@@ -185,8 +231,8 @@ converted text plus SPA markers, script-heavy markup, a noscript body, or a desc
|
|
|
185
231
|
- Proxies: Studio routes through environment proxies; this port resolves and pins the target IP and
|
|
186
232
|
tunnels that connection through `HTTPS_PROXY` / `HTTP_PROXY` / `ALL_PROXY` when the proxy is a
|
|
187
233
|
SOCKS5 proxy (`NO_PROXY` exclusions respected; DNS stays local for the guard). Other proxy
|
|
188
|
-
schemes fall back to a direct connection. The search
|
|
189
|
-
|
|
234
|
+
schemes fall back to a direct connection. The search path uses the process-wide `fetch`, so an
|
|
235
|
+
agent-level proxy dispatcher applies there too — see
|
|
190
236
|
[Companion: rotating exit IPs](#companion-rotating-exit-ips).
|
|
191
237
|
- Dedup and titles: the aggregator keys on canonicalized hrefs (`utm_*`/tracking parameters
|
|
192
238
|
and fragments stripped, then the URL re-serialized); fetched HTML pages are prefixed with
|
|
@@ -198,16 +244,17 @@ converted text plus SPA markers, script-heavy markup, a noscript body, or a desc
|
|
|
198
244
|
|
|
199
245
|
## When to use alternatives
|
|
200
246
|
|
|
201
|
-
This package keeps Studio's deterministic
|
|
202
|
-
`unsloth/studio
|
|
203
|
-
behavior on top.
|
|
247
|
+
This package keeps Studio's deterministic extraction and search pipeline — and its test parity with
|
|
248
|
+
`unsloth/studio` — then layers browser-fingerprint fetching, local rendering, local access, and other
|
|
249
|
+
Studio-independent behavior on top. Its only runtime dependency is `wreq-js` (the default transport);
|
|
250
|
+
`mupdf` stays optional. The SSRF guard is real and thoroughly tested, but it is opt-in:
|
|
204
251
|
`allowPrivateAddresses` defaults to `true`, and local files are readable unless `allowLocalFiles`
|
|
205
252
|
is `false`. For other tradeoffs, prefer:
|
|
206
253
|
|
|
207
254
|
| Need | Use |
|
|
208
255
|
|---|---|
|
|
209
|
-
| Browser-like TLS/HTTP fingerprinting to unblock bot-defended pages | `pi-smart-fetch` (`wreq-js` `chrome_145`) |
|
|
210
|
-
| Headless Chrome for JS-rendered SPAs/YouTube/Reddit threads | Built-in
|
|
256
|
+
| Browser-like TLS/HTTP fingerprinting to unblock bot-defended pages | Built in: `webFetch.transport` defaults to `tls-first`, which speaks Chrome's TLS/HTTP2 shape through `wreq-js`; `pi-smart-fetch` (`wreq-js` `chrome_145`) if you want a separate tool |
|
|
257
|
+
| Headless Chrome for JS-rendered SPAs/YouTube/Reddit threads | Built-in local Lightpanda rendering; `georgebashi/pi-web-fetch` (puppeteer + trafilatura) or a Patchright-based tier when it falls short |
|
|
211
258
|
| Hosted search with semantic ranking and no scraping | `Brave Search API` / `Tavily` / `Exa` via `pi-ollama-web-search` |
|
|
212
259
|
| Prompt-focused page distillation to save context | `pi-web-fetch` `prompt` -> sub-agent or Claude Code `WebFetch(url,prompt)` |
|
|
213
260
|
| Batch fetching many URLs concurrently | `pi-smart-fetch` `batch_web_fetch` or call `web_fetch` in parallel |
|
|
@@ -219,8 +266,8 @@ choose the best tool per URL. No need to fork this package to add those features
|
|
|
219
266
|
|
|
220
267
|
[`pi-tor-proxy`](https://github.com/YuGiMob/pi-tor-proxy) routes pi's in-process `fetch` traffic
|
|
221
268
|
through Tor (it downloads and manages its own Tor binary) and gives each pi instance its own
|
|
222
|
-
circuit and exit IP. The search sweep
|
|
223
|
-
|
|
269
|
+
circuit and exit IP. The search sweep uses the process-wide `fetch`, so it leaves through the
|
|
270
|
+
current Tor exit, and many search engines rate-limit or challenge per outgoing IP —
|
|
224
271
|
`/tor-cycle` swaps the exit those limits are counted against, while `/tor-country` and
|
|
225
272
|
`/tor-exclude` constrain which exits are used.
|
|
226
273
|
|
|
@@ -263,30 +310,12 @@ Optional settings in `~/.pi/agent/settings.json` or `.pi/settings.json` (project
|
|
|
263
310
|
| `websitePolicy` | none | Not read from settings. Tools run unrestricted by default; `websitePolicy` is a programmatic option the host passes to `webSearch` / `fetchPageText` |
|
|
264
311
|
| `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 |
|
|
265
312
|
| `unslothWebTools.allowLocalFiles` / `webFetch.allowLocalFiles` | `true` | Opt out to refuse local files in `web_fetch` (`file://` URLs, absolute, `~/`, or `./` paths); when enabled, PDFs are extracted and HTML converted |
|
|
266
|
-
| `
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
`/webtools-config` opens an interactive settings window (↑↓ navigate, space toggle, q close). Settings
|
|
271
|
-
persist across sessions in `~/.config/pi-unsloth-webtools/config.json`, created when a setting is
|
|
272
|
-
first changed:
|
|
313
|
+
| `webFetch.transport` / `unslothWebTools.transport` | `tls-first` | Fetch transport order: `tls-first` (default), `direct-first`, or `off` to disable the browser-fingerprint transport entirely |
|
|
314
|
+
| `webRender.lightpandaEnabled` / `unslothWebTools.lightpandaEnabled` | `true` | Opt out to disable local Lightpanda rendering |
|
|
315
|
+
| `webRender.lightpandaPath` / `unslothWebTools.lightpandaPath` | launcher installed by `scripts/install-lightpanda.sh`, else `lightpanda` on `PATH` | Path to the Lightpanda binary used for local rendering |
|
|
316
|
+
| `webRender.lightpandaCommand` / `unslothWebTools.lightpandaCommand` | none | Command prefix that launches the renderer, for WSL (`["wsl.exe","-e","<path>"]`) or containers; overrides `lightpandaPath`. The fetch flags are appended to it |
|
|
273
317
|
|
|
274
|
-
|
|
275
|
-
{
|
|
276
|
-
"webRenderEnabled": true
|
|
277
|
-
}
|
|
278
|
-
```
|
|
279
|
-
|
|
280
|
-
| Key | Default | Description |
|
|
281
|
-
|---|---|---|
|
|
282
|
-
| `webRenderEnabled` | `true` | When `false`, automatic Jina Reader rendering is disabled: HTTP 403 and JavaScript-page escalation in `web_fetch` no longer run |
|
|
283
|
-
|
|
284
|
-
On non-Windows platforms the directory honors `XDG_CONFIG_HOME` when set (falling back to
|
|
285
|
-
`~/.config`); on Windows it always uses `~/.config`.
|
|
286
|
-
|
|
287
|
-
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.
|
|
288
|
-
|
|
289
|
-
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 Jina Reader key when no settings key is set. Cache entries live 1 hour and stale copies are served only after a network failure. SOCKS5 proxies named by `HTTPS_PROXY`, `HTTP_PROXY`, or `ALL_PROXY` are honored on every fetch (`NO_PROXY` exclusions apply).
|
|
318
|
+
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 `PI_LIGHTPANDA_BIN` points at the local renderer binary. Cache entries live 1 hour and stale copies are served only after a network failure. SOCKS5 proxies named by `HTTPS_PROXY`, `HTTP_PROXY`, or `ALL_PROXY` are honored on every fetch (`NO_PROXY` exclusions apply).
|
|
290
319
|
|
|
291
320
|
## Troubleshooting
|
|
292
321
|
|
|
@@ -303,18 +332,22 @@ Match on the exact prefix. Do not retry blocked hosts with spelling tricks.
|
|
|
303
332
|
| Private address blocked | `Blocked: refusing to fetch the non-public address ...` | The SSRF guard is active (`allowPrivateAddresses: false`); remove it or set `true` to reach localhost/LAN, and write the scheme explicitly (`http://localhost:3000`). |
|
|
304
333
|
| Local file blocked | `Blocked: the URL has an invalid hostname or port.` for paths | Local files are disabled: remove `allowLocalFiles: false` to read `file://`, absolute, `~/`, or `./` paths. |
|
|
305
334
|
| File read failed | `Failed to read file: ...` | Check the path exists and is a regular file. |
|
|
306
|
-
| HTTP failure | `Failed to fetch URL: HTTP ...` | Fix the URL. A 404 automatically tries a Wayback snapshot; a 403 retries through the
|
|
335
|
+
| HTTP failure | `Failed to fetch URL: HTTP ...` | Fix the URL. A 404 automatically tries a Wayback snapshot; a 403 retries through the browser-fingerprint transport first. |
|
|
307
336
|
| Proxy failure | `Failed to fetch URL: SOCKS5 proxy ...` | The SOCKS5 proxy refused or failed (for example Tor is stopping). Check the proxy, or unset the proxy variables for a direct fetch. |
|
|
308
337
|
| Non-text / binary | `(non-text content:` / `(binary content,` | Not readable as text by design. |
|
|
309
338
|
| PDF without text | `(PDF contains no extractable text)` / `(PDF content could not be read as text...)` | Scanned or encrypted PDF. |
|
|
310
339
|
| Download cap hit | `... (page truncated at the download limit)` | Raw fetch hit 512 KiB (10 MiB for PDFs). |
|
|
311
340
|
| maxChars cut | `... (truncated, N chars total)` | Raise `maxChars` for the full text. |
|
|
312
|
-
| Empty page | `(page returned no readable text)` | Page had no extractable text; `web_fetch`
|
|
341
|
+
| Empty page | `(page returned no readable text)` | Page had no extractable text; `web_fetch` then tries local Lightpanda if it is installed. |
|
|
313
342
|
| JavaScript-rendered page | `*(JavaScript-rendered page; content may be incomplete)*` | Rendering was disabled or produced no more text; the page likely needs a browser. |
|
|
314
343
|
| GitHub rewrite | `README of ... (fetched via the GitHub README API):` | Expected repo-root rewrite, not the HTML chrome. |
|
|
315
344
|
| Cache fallback | `Served from cache` / `STALE cache from YYYY-MM-DD` | Network failed; output is the cached copy with its date. |
|
|
316
345
|
| Wayback fallback | `Fetched from Wayback Machine snapshot (YYYY-MM-DD) for ...` | Original 404'd; output is the archived copy with its date. |
|
|
317
346
|
|
|
347
|
+
| No browser-fingerprint binding | fetch behaves as if `webFetch.transport` were `direct-first` | `wreq-js` ships prebuilt bindings for Linux, macOS and Windows only; on other platforms the tier is skipped automatically |
|
|
348
|
+
| No renderer available | `*(JavaScript-rendered page; content may be incomplete)*` with no local note | Install Lightpanda, or set `webRender.lightpandaPath` / `PI_LIGHTPANDA_BIN`, to render JavaScript-heavy pages locally |
|
|
349
|
+
| Local renderer failed | `Failed to render URL: Lightpanda exited with code N.` | The failed tier falls through to the next one; check the binary by hand with `lightpanda fetch --dump markdown <url>` |
|
|
350
|
+
| Local renderer blocked a target | `Blocked: the local renderer cannot fetch local files.` | The local browser refuses local paths by design; fetch them with `web_fetch` directly instead |
|
|
318
351
|
## Development
|
|
319
352
|
|
|
320
353
|
```sh
|
|
@@ -323,12 +356,22 @@ npm run typecheck
|
|
|
323
356
|
npm test
|
|
324
357
|
npm run test:unit
|
|
325
358
|
npm run test:smoke
|
|
359
|
+
bash scripts/install-lightpanda.sh
|
|
360
|
+
npm run camoufox:warmup
|
|
326
361
|
```
|
|
327
362
|
|
|
363
|
+
`npm run camoufox:warmup` measures what a warm Camoufox costs and buys: launch time, idle CPU and
|
|
364
|
+
RSS, per-fetch latency with the browser already running, and whether a persistent profile
|
|
365
|
+
(`user_data_dir`, pinned fingerprint) lets a Cloudflare clearance survive a restart. Flags:
|
|
366
|
+
`--virtual` for a virtual display, `--pin=0` to let Camoufox rotate fingerprints, `--seconds=N`,
|
|
367
|
+
`--idle=N`.
|
|
368
|
+
|
|
328
369
|
## Tests
|
|
329
370
|
|
|
330
371
|
`npm test` runs the full suite. `npm run test:unit` skips the live-network smoke tests,
|
|
331
|
-
|
|
372
|
+
`npm run test:smoke` runs only those, and `npm run test:lightpanda` drives a real Lightpanda binary
|
|
373
|
+
against live pages — it is skipped unless `PI_LIGHTPANDA_E2E=1` is set (`PI_LIGHTPANDA_E2E_BIN` points
|
|
374
|
+
at the binary), and CI runs it in a dedicated job after `scripts/install-lightpanda.sh`.
|
|
332
375
|
|
|
333
376
|
The suite ports Unsloth Studio's own tests for these tools:
|
|
334
377
|
|
|
@@ -351,12 +394,17 @@ The suite ports Unsloth Studio's own tests for these tools:
|
|
|
351
394
|
- `test/smoke.test.ts`: live network checks against real hosts, including a per-engine
|
|
352
395
|
result-health sweep (at least two engines must return well-formed results; engines
|
|
353
396
|
that block or reset connections from datacenter IPs count as unhealthy, not failures)
|
|
354
|
-
- `test/web-render.test.ts`: Jina Reader rendering with a stubbed fetch and DNS, the public-only
|
|
355
|
-
guard, policy enforcement, error mapping, truncation, cancellation, and API key precedence
|
|
356
397
|
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
and
|
|
398
|
+
- `test/tls-fetch.test.ts`: the browser-fingerprint transport with a stubbed native module, including
|
|
399
|
+
transport reuse, body caps, redirect passthrough, and abort/timeout mapping
|
|
400
|
+
- `test/lightpanda.test.ts`: the local renderer's command line, guards, failure modes, and a real
|
|
401
|
+
child-process run against a scripted binary
|
|
402
|
+
- `test/impersonation.test.ts`: the HTTP 403 retry inside the fetch pipeline, including redirect
|
|
403
|
+
re-validation, the disabled path, and the stubbed-network contract
|
|
404
|
+
The seams (`seams.resolve` / `seams.request` / `seams.impersonate` / `rawFetch`) replace the network
|
|
405
|
+
stack with fakes, mirroring how the Studio suite monkeypatches `_validate_and_resolve_host` and
|
|
406
|
+
`build_opener`. Supplying a `request` seam also disables the browser-fingerprint retry, so a stubbed
|
|
407
|
+
transport never escapes to the real network.
|
|
360
408
|
|
|
361
409
|
## License
|
|
362
410
|
|
package/html-to-md.ts
CHANGED
|
@@ -114,6 +114,8 @@ const HEADER_MAX_RENDERED_CHARS = 800;
|
|
|
114
114
|
const MAX_HEADER_NESTING = 8;
|
|
115
115
|
|
|
116
116
|
const MIN_MAIN_CONTENT_CHARS = 200;
|
|
117
|
+
const LISTING_MIN_SEGMENTS = 3;
|
|
118
|
+
const LISTING_DOMINANCE_RATIO = 3;
|
|
117
119
|
|
|
118
120
|
export type AttrDict = Record<string, string | null>;
|
|
119
121
|
|
|
@@ -1052,23 +1054,36 @@ function render(sourceHtml: string, scopeTags: Set<string> | null, stripHeader =
|
|
|
1052
1054
|
return cleanup(newRenderer(sourceHtml, scopeTags, stripHeader).out.join(""));
|
|
1053
1055
|
}
|
|
1054
1056
|
|
|
1055
|
-
function selectMainScopeRender(sourceHtml: string, tag: string): [number, string] {
|
|
1057
|
+
function selectMainScopeRender(sourceHtml: string, tag: string): [number, string, boolean] {
|
|
1056
1058
|
const renderer = newRenderer(sourceHtml, new Set([tag]), true);
|
|
1057
1059
|
const dropped = renderer.scopeDropped;
|
|
1058
1060
|
const headingProse = renderer.scopeHeadingProse;
|
|
1059
1061
|
let bestLen = 0;
|
|
1060
1062
|
let bestRender = "";
|
|
1063
|
+
let bestProse = 0;
|
|
1064
|
+
let secondProse = 0;
|
|
1065
|
+
let qualifiers = 0;
|
|
1061
1066
|
for (let i = 0; i < renderer.scopeSegments.length; i++) {
|
|
1062
1067
|
const rendered = stripBoilerplateLines(cleanup(renderer.scopeSegments[i]));
|
|
1063
1068
|
const prose = visibleChars(rendered) - headingProse[i];
|
|
1064
1069
|
if (prose < MIN_MAIN_CONTENT_CHARS) continue;
|
|
1070
|
+
qualifiers++;
|
|
1071
|
+
if (prose > bestProse) {
|
|
1072
|
+
secondProse = bestProse;
|
|
1073
|
+
bestProse = prose;
|
|
1074
|
+
} else if (prose > secondProse) {
|
|
1075
|
+
secondProse = prose;
|
|
1076
|
+
}
|
|
1065
1077
|
const size = rendered.length + Math.min(dropped[i], rendered.length);
|
|
1066
1078
|
if (size > bestLen) {
|
|
1067
1079
|
bestLen = size;
|
|
1068
1080
|
bestRender = rendered;
|
|
1069
1081
|
}
|
|
1070
1082
|
}
|
|
1071
|
-
|
|
1083
|
+
const listing =
|
|
1084
|
+
qualifiers >= LISTING_MIN_SEGMENTS &&
|
|
1085
|
+
bestProse < LISTING_DOMINANCE_RATIO * Math.max(1, secondProse);
|
|
1086
|
+
return [bestLen, bestRender, listing];
|
|
1072
1087
|
}
|
|
1073
1088
|
|
|
1074
1089
|
export function visibleChars(text: string): number {
|
|
@@ -1141,8 +1156,8 @@ export function htmlToMarkdown(sourceHtml: string, mainContent = false): string
|
|
|
1141
1156
|
sourceHtml = sourceHtml.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
1142
1157
|
if (mainContent) {
|
|
1143
1158
|
for (const scopeTag of ["article", "main"]) {
|
|
1144
|
-
const [length, rendered] = selectMainScopeRender(sourceHtml, scopeTag);
|
|
1145
|
-
if (length >= MIN_MAIN_CONTENT_CHARS) return rendered;
|
|
1159
|
+
const [length, rendered, listing] = selectMainScopeRender(sourceHtml, scopeTag);
|
|
1160
|
+
if (length >= MIN_MAIN_CONTENT_CHARS && !listing) return rendered;
|
|
1146
1161
|
}
|
|
1147
1162
|
return stripBoilerplateLines(render(sourceHtml, null, true));
|
|
1148
1163
|
}
|
package/index.ts
CHANGED
|
@@ -9,10 +9,8 @@ import {
|
|
|
9
9
|
fetchPageText as defaultFetchPageText,
|
|
10
10
|
type FetchPageOutcome,
|
|
11
11
|
} from "./web-fetch.ts";
|
|
12
|
-
import {
|
|
13
|
-
import { loadDefaultFetchSettings, loadDefaultFetchTimeoutMs,
|
|
14
|
-
import { readConfig, readConfigWithStatus, toggleWebRender } from "./config.ts";
|
|
15
|
-
import { WebToolsConfigOverlay } from "./config-ui.ts";
|
|
12
|
+
import { renderPageWithLightpanda as defaultRenderLocalPageText } from "./lightpanda.ts";
|
|
13
|
+
import { loadDefaultFetchSettings, loadDefaultFetchTimeoutMs, loadLightpandaSettings } from "./settings.ts";
|
|
16
14
|
|
|
17
15
|
function toolCallLine(theme: Theme, name: string, detail: string) {
|
|
18
16
|
const line = theme.fg("toolTitle", theme.bold(name)) + (detail ? ` ${theme.fg("accent", detail)}` : "");
|
|
@@ -28,6 +26,7 @@ function positiveNumber(value: unknown): number | undefined {
|
|
|
28
26
|
const n = Math.floor(value);
|
|
29
27
|
return n > 0 ? n : undefined;
|
|
30
28
|
}
|
|
29
|
+
|
|
31
30
|
async function fetchDefaults(cwd: string | undefined, params: { timeoutMs?: unknown; maxChars?: unknown }) {
|
|
32
31
|
const timeoutParam = positiveNumber(params.timeoutMs);
|
|
33
32
|
const maxCharsParam = positiveNumber(params.maxChars);
|
|
@@ -37,6 +36,7 @@ async function fetchDefaults(cwd: string | undefined, params: { timeoutMs?: unkn
|
|
|
37
36
|
maxChars: maxCharsParam ?? defaults.maxChars,
|
|
38
37
|
allowPrivateAddresses: defaults.allowPrivateAddresses,
|
|
39
38
|
allowLocalFiles: defaults.allowLocalFiles,
|
|
39
|
+
transport: defaults.transport,
|
|
40
40
|
};
|
|
41
41
|
}
|
|
42
42
|
|
|
@@ -56,9 +56,36 @@ function renderImprovesPage(rendered: string, fetched: string): boolean {
|
|
|
56
56
|
return visibleChars(rendered) > visibleChars(fetched) * 1.5;
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
-
const
|
|
60
|
-
const
|
|
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
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
|
+
}
|
|
62
89
|
|
|
63
90
|
const WebSearchParams = Type.Object({
|
|
64
91
|
query: Type.Optional(
|
|
@@ -98,21 +125,18 @@ export interface WebToolsDeps {
|
|
|
98
125
|
fetchPageText?: typeof defaultFetchPageText;
|
|
99
126
|
fetchPageOutcome?: typeof defaultFetchPageOutcome;
|
|
100
127
|
webSearch?: typeof defaultWebSearch;
|
|
101
|
-
|
|
102
|
-
webRenderEnabled?: () => Promise<boolean>;
|
|
128
|
+
renderLocalPageText?: typeof defaultRenderLocalPageText;
|
|
103
129
|
}
|
|
104
130
|
|
|
105
131
|
export function createWebTools(deps: WebToolsDeps = {}) {
|
|
106
132
|
const webSearch = deps.webSearch ?? defaultWebSearch;
|
|
107
|
-
const
|
|
133
|
+
const renderLocalPageText = deps.renderLocalPageText ?? defaultRenderLocalPageText;
|
|
108
134
|
const legacyFetchPageText = deps.fetchPageText;
|
|
109
135
|
const fetchOutcome: typeof defaultFetchPageOutcome =
|
|
110
136
|
deps.fetchPageOutcome ??
|
|
111
137
|
(legacyFetchPageText
|
|
112
138
|
? async (url, options) => ({ text: await legacyFetchPageText(url, options), hint: null })
|
|
113
139
|
: defaultFetchPageOutcome);
|
|
114
|
-
const webRenderEnabled =
|
|
115
|
-
deps.webRenderEnabled ?? (async () => (await readConfig()).webRenderEnabled !== false);
|
|
116
140
|
|
|
117
141
|
const renderFallback = async (
|
|
118
142
|
url: string,
|
|
@@ -123,21 +147,17 @@ export function createWebTools(deps: WebToolsDeps = {}) {
|
|
|
123
147
|
const thin = outcome.hint !== null && !forbidden;
|
|
124
148
|
if (!forbidden && !thin) return null;
|
|
125
149
|
const incomplete = outcome.text + INCOMPLETE_NOTE;
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
return (forbidden ? FORBIDDEN_FALLBACK_NOTE : THIN_FALLBACK_NOTE) + "\n\n" + rendered;
|
|
138
|
-
} catch {
|
|
139
|
-
return thin ? incomplete : null;
|
|
140
|
-
}
|
|
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;
|
|
141
161
|
};
|
|
142
162
|
|
|
143
163
|
return {
|
|
@@ -175,15 +195,16 @@ export function createWebTools(deps: WebToolsDeps = {}) {
|
|
|
175
195
|
description:
|
|
176
196
|
"Fetch a URL and return its readable text. HTML pages are converted to Markdown using a " +
|
|
177
197
|
"main-content heuristic: article/main scoping plus hidden-element and boilerplate " +
|
|
178
|
-
"stripping.
|
|
179
|
-
"
|
|
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. " +
|
|
180
201
|
"Non-HTML text is returned as-is. GitHub repo root pages are rewritten to the " +
|
|
181
202
|
"README API, so the README is returned instead of the repo page's UI chrome. " +
|
|
182
203
|
"Private/loopback/link-local targets and local files (file:// URLs, absolute, ~/ or ./ paths, including " +
|
|
183
204
|
"PDFs) are supported by default; opt out with webFetch.allowPrivateAddresses: false or " +
|
|
184
205
|
"webFetch.allowLocalFiles: false in settings. The download size is capped.",
|
|
185
206
|
promptGuidelines: [
|
|
186
|
-
"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.",
|
|
187
208
|
],
|
|
188
209
|
promptSnippet: "Fetch a web page and return readable text content",
|
|
189
210
|
parameters: WebFetchParams,
|
|
@@ -193,7 +214,7 @@ export function createWebTools(deps: WebToolsDeps = {}) {
|
|
|
193
214
|
async execute(_toolCallId, params, signal, onUpdate, _ctx) {
|
|
194
215
|
onUpdate?.({ content: [{ type: "text", text: `Fetching ${params.url}...` }], details: {} });
|
|
195
216
|
const cwd = (_ctx as ExtensionContext | undefined)?.cwd;
|
|
196
|
-
const { timeoutMs, maxChars, allowPrivateAddresses, allowLocalFiles } = await fetchDefaults(cwd, params);
|
|
217
|
+
const { timeoutMs, maxChars, allowPrivateAddresses, allowLocalFiles, transport } = await fetchDefaults(cwd, params);
|
|
197
218
|
const deadlineMs = Date.now() + timeoutMs;
|
|
198
219
|
const outcome = await fetchOutcome(params.url, {
|
|
199
220
|
timeoutMs,
|
|
@@ -202,6 +223,7 @@ export function createWebTools(deps: WebToolsDeps = {}) {
|
|
|
202
223
|
maxChars,
|
|
203
224
|
allowPrivateAddresses,
|
|
204
225
|
allowLocalFiles,
|
|
226
|
+
transport,
|
|
205
227
|
});
|
|
206
228
|
const rendered = await renderFallback(params.url, outcome, {
|
|
207
229
|
timeoutMs: Math.max(1, deadlineMs - Date.now()),
|
|
@@ -219,44 +241,4 @@ export default function (pi: ExtensionAPI) {
|
|
|
219
241
|
const { webSearchTool, webFetchTool } = createWebTools();
|
|
220
242
|
pi.registerTool(webSearchTool);
|
|
221
243
|
pi.registerTool(webFetchTool);
|
|
222
|
-
|
|
223
|
-
pi.on("session_start", async (_event, ctx) => {
|
|
224
|
-
try {
|
|
225
|
-
const { corrupted } = await readConfigWithStatus();
|
|
226
|
-
if (corrupted && ctx.hasUI) {
|
|
227
|
-
ctx.ui.notify("Web tools config was corrupt and was reset to defaults", "warning");
|
|
228
|
-
}
|
|
229
|
-
} catch (error) {
|
|
230
|
-
console.error("Failed to load web tools config:", error);
|
|
231
|
-
}
|
|
232
|
-
});
|
|
233
|
-
|
|
234
|
-
pi.registerCommand("webtools-config", {
|
|
235
|
-
description: "Open the web tools settings window (JavaScript rendering on/off)",
|
|
236
|
-
handler: async (_args, ctx) => {
|
|
237
|
-
if (!ctx.hasUI) {
|
|
238
|
-
ctx.ui.notify("/webtools-config requires interactive mode", "error");
|
|
239
|
-
return;
|
|
240
|
-
}
|
|
241
|
-
await ctx.ui.custom<void>(
|
|
242
|
-
async (tui, theme, _keybindings, done) => {
|
|
243
|
-
const overlay = new WebToolsConfigOverlay({
|
|
244
|
-
tui,
|
|
245
|
-
theme,
|
|
246
|
-
done,
|
|
247
|
-
onToggle: async (key) => {
|
|
248
|
-
if (key !== "webRenderEnabled") return;
|
|
249
|
-
await toggleWebRender();
|
|
250
|
-
},
|
|
251
|
-
});
|
|
252
|
-
await overlay.load();
|
|
253
|
-
return overlay;
|
|
254
|
-
},
|
|
255
|
-
{
|
|
256
|
-
overlay: true,
|
|
257
|
-
overlayOptions: { anchor: "center", width: "90%", minWidth: 60, maxHeight: "90%" },
|
|
258
|
-
},
|
|
259
|
-
);
|
|
260
|
-
},
|
|
261
|
-
});
|
|
262
244
|
}
|