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 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, automatic Jina Reader rendering, and
9
- other behavior Studio does not have. See
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 the Jina Reader automatically when
126
- rendering is enabled; the rendered page is prefixed with a note saying so. When rendering is
127
- disabled or the render also fails, the original `Failed to fetch URL: HTTP 403 ...` is returned.
128
- - Pages that look JavaScript-rendered are re-fetched through the Jina Reader the same way. When
129
- rendering is disabled or does not produce more text, the page gets a
130
- `*(JavaScript-rendered page; content may be incomplete)*` note, so a shell is not mistaken for
131
- the whole page.
132
-
133
- ### JavaScript rendering
134
-
135
- `web_fetch` retries through the third-party Jina Reader (`r.jina.ai`) when
136
- a direct fetch is refused with HTTP 403 or returns a page that looks JavaScript-rendered (thin
137
- converted text plus SPA markers, script-heavy markup, a noscript body, or a description meta tag):
138
-
139
- - Every target is validated and resolved locally first: http/https only, and any private,
140
- loopback, link-local, or otherwise non-public address is refused. Local files are never sent,
141
- regardless of `webFetch.allowPrivateAddresses` / `webFetch.allowLocalFiles`.
142
- - `unslothWebTools.jinaApiKey` (or `webRender.jinaApiKey`, or the `JINA_API_KEY` environment
143
- variable) raises the Reader's rate limits; without a key it still works at Jina's free limits.
144
- - Rendered output is Markdown prefixed with `Title:` / `URL:` lines and a `Rendered via the Jina
145
- Reader` provenance line; the 403 fallback prefixes a note instead. An optional `maxChars`
146
- truncates, like any fetch.
147
- - When rendering is disabled or does not produce more text, a page that looks JavaScript-rendered
148
- is returned with a `*(JavaScript-rendered page; content may be incomplete)*` note, so a shell is
149
- not mistaken for the whole page.
150
- - Enabled by default. Disable it with `/webtools-config` (`webRenderEnabled` in
151
- `~/.config/pi-unsloth-webtools/config.json`), which turns off both escalation paths.
152
- - Keyless Reader requests are rate-limited per outgoing IP; see
153
- [Companion: rotating exit IPs](#companion-rotating-exit-ips).
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
- - Third-party rendering: when rendering is enabled, a fetch refused with HTTP 403 or returning a
163
- page that looks JavaScript-rendered is retried through the Jina Reader (`r.jina.ai`), so the
164
- target URL leaves the machine. Studio has no third-party rendering path. This path always refuses
165
- local files and non-public addresses, regardless of the local-access settings.
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 and Jina rendering paths use the process-wide
189
- `fetch`, so an agent-level proxy dispatcher applies there too — see
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, zero-dependency pipeline and test parity with
202
- `unsloth/studio`, then layers local access, third-party rendering, and other Studio-independent
203
- behavior on top. The SSRF guard is real and thoroughly tested, but it is opt-in:
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 automatic Jina Reader rendering in `web_fetch` first; `georgebashi/pi-web-fetch` (puppeteer + trafilatura) when the Reader falls short |
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 and Jina rendering both use `fetch`, so they leave through
223
- the current Tor exit, and Jina rate-limits keyless Reader requests per outgoing IP —
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
- | `unslothWebTools.jinaApiKey` / `webRender.jinaApiKey` | none (`JINA_API_KEY` fallback) | API key for automatic Jina Reader rendering; raises its rate limits. Settings keys win over the environment variable |
267
-
268
- ### Settings window
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
- ```json
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 Jina Reader when rendering is enabled. |
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` retries through the Jina Reader automatically when rendering is enabled. |
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
- and `npm run test:smoke` runs only those.
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
- The seams (`seams.resolve` / `seams.request` / `rawFetch`) replace the network stack
358
- with fakes, mirroring how the Studio suite monkeypatches `_validate_and_resolve_host`
359
- and `build_opener`.
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
- return [bestLen, bestRender];
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 { renderPageText as defaultRenderPageText } from "./web-render.ts";
13
- import { loadDefaultFetchSettings, loadDefaultFetchTimeoutMs, loadJinaApiKey } from "./settings.ts";
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 FORBIDDEN_FALLBACK_NOTE = "Direct fetch failed with HTTP 403; rendered via the Jina Reader instead.";
60
- const THIN_FALLBACK_NOTE = "Direct fetch returned little content; rendered via the Jina Reader instead.";
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
- renderPageText?: typeof defaultRenderPageText;
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 renderPageText = deps.renderPageText ?? defaultRenderPageText;
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
- try {
127
- if (!(await webRenderEnabled())) return thin ? incomplete : null;
128
- const apiKey = await loadJinaApiKey(options.cwd);
129
- const rendered = await renderPageText(url, {
130
- timeoutMs: options.timeoutMs,
131
- maxChars: options.maxChars,
132
- signal: options.signal,
133
- apiKey,
134
- });
135
- if (renderFailed(rendered)) return thin ? incomplete : null;
136
- if (thin && !renderImprovesPage(rendered, outcome.text)) return incomplete;
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. Pages that look JavaScript-rendered are retried through the Jina Reader " +
179
- "automatically when rendering is enabled, as are HTTP 403 responses. " +
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 escalates JavaScript-rendered pages and HTTP 403 responses to the Jina Reader automatically when rendering is enabled.",
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
  }