pi-unsloth-webtools 0.7.6 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,12 +1,12 @@
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`, and `web_render` tools. It began as a port of the Unsloth Studio codebase
3
+ A [pi](https://github.com/earendil-works/pi-coding-agent) extension providing `web_search` and
4
+ `web_fetch` tools. It began as a port of the Unsloth Studio codebase
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, a third-party rendering tool
9
- (`web_render`), and 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,9 +24,15 @@ 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
- 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>`.
35
+ Both tools display their target in the TUI tool row: `web_search "query"` and `web_fetch <url>`.
30
36
 
31
37
  ### web_search
32
38
 
@@ -41,9 +47,6 @@ Mirrors Unsloth Studio's `web_search` tool:
41
47
  re-ranking. Formats results identically: `Title:` / `URL:` /
42
48
  `Snippet:` blocks separated by `---`, ending with the hint to call `web_fetch` to
43
49
  read a full page.
44
- - Accepts an optional `url` parameter; when given, fetches that page's text instead of
45
- searching (optionally truncated with `maxChars`). An HTTP 403 on that fetch falls back
46
- to `web_render` when the tool is enabled.
47
50
  - Rate-limit, timeout, and empty-result messages mirror Studio's `_search_failure_message`.
48
51
  - Transient engine failures (network errors or null responses) are retried once with a short
49
52
  backoff inside the same timeout budget (a retry that cannot fit in the remaining budget is
@@ -83,7 +86,7 @@ Port of Studio's `_fetch_page_text` / `_fetch_url_raw` pipeline:
83
86
  fetch; the deadline abort cuts a retry short when no budget remains.
84
87
  - Proxy environment variables are honored when they name a SOCKS5 proxy: `HTTPS_PROXY` /
85
88
  `HTTP_PROXY` / `ALL_PROXY` (with `NO_PROXY` exclusions) tunnel the pinned connection, so a
86
- Tor-mode agent routes `web_fetch` and `web_search` url mode through its exit. `socks5h` is
89
+ Tor-mode agent routes `web_fetch` through its exit. `socks5h` is
87
90
  treated like `socks5`: the host is resolved locally for the guard and the pinned IP is what the
88
91
  proxy connects to. Other proxy schemes are ignored (direct connection).
89
92
  - GitHub repo root pages are rewritten to the unauthenticated README API
@@ -116,7 +119,7 @@ Port of Studio's `_fetch_page_text` / `_fetch_url_raw` pipeline:
116
119
  with link-density header stripping; boilerplate-line removal.
117
120
  - No page-size budget: fetched pages and PDFs are returned in full (Studio's window-aware
118
121
  cap is deliberately dropped; the optional `maxChars` parameter still truncates when given,
119
- on `web_fetch` and on `web_search`'s url mode).
122
+ on `web_fetch`).
120
123
  The 512 KiB / 10 MiB download caps still bound the raw fetch.
121
124
  - HTML entity decoding replicates CPython's `html.unescape` (full 2,231-entry HTML5 table,
122
125
  longest-prefix rule, Windows-1252 numeric mappings), matching Studio byte-for-byte.
@@ -125,28 +128,67 @@ Port of Studio's `_fetch_page_text` / `_fetch_url_raw` pipeline:
125
128
  `Date:` (`article:published_time` / `dc.date` / `date`) and `Site:` (`og:site_name` /
126
129
  `application-name`) lines are added when declared, so the model can judge recency and
127
130
  provenance.
128
- - A direct fetch refused with HTTP 403 is retried through `web_render` automatically when that
129
- tool is enabled; the rendered page is prefixed with a note saying so. When `web_render` is
130
- disabled or the render also fails, the original `Failed to fetch URL: HTTP 403 ...` is returned.
131
-
132
- ### web_render
133
-
134
- Renders a public page to Markdown through the third-party Jina Reader (`r.jina.ai`) for pages
135
- that need JavaScript to render:
136
-
137
- - Every target is validated and resolved locally first: http/https only, and any private,
138
- loopback, link-local, or otherwise non-public address is refused. Local files are refused on
139
- this path regardless of `webFetch.allowPrivateAddresses` / `webFetch.allowLocalFiles`, because
140
- the URL is sent to Jina.
141
- - `unslothWebTools.jinaApiKey` (or `webRender.jinaApiKey`, or the `JINA_API_KEY` environment
142
- variable) raises the Reader's rate limits; without a key it still works at Jina's free limits.
143
- - Output is Markdown prefixed with `Title:` / `URL:` lines and a `Rendered via the Jina Reader`
144
- provenance line. An optional `maxChars` truncates, like `web_fetch`.
145
- - Keyless Reader requests are rate-limited per outgoing IP; see
146
- [Companion: rotating exit IPs](#companion-rotating-exit-ips).
147
- - Enabled by default. Disable it with `/webtools-config` (`webRenderEnabled` in
148
- `~/.config/pi-unsloth-webtools/config.json`), which deactivates the tool for the session.
149
- - Also used automatically when a `web_fetch` or `web_search` url-mode fetch is refused with HTTP 403.
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: `lightpanda` on `PATH`, then `PI_LIGHTPANDA_BIN`, then
176
+ `webRender.lightpandaPath`. Prebuilt binaries exist for Linux (glibc; musl needs a source
177
+ build) and macOS, plus Docker images; Windows needs WSL2.
178
+ - Version matters: 1.0.0 renders JavaScript-heavy pages that the 0.2.x line cannot — measured on
179
+ the same machine, IMDb went from a 76-byte empty document to 21k characters, dribbble from 32
180
+ characters to 18k, and a Medium article from a challenge page to real text. Linux builds from
181
+ 0.3 on require glibc 2.38, so older distributions are stuck on 0.2.x without the next bullet.
182
+ - Windows has no native Lightpanda build: install it inside WSL2 and point `webRender.lightpandaCommand`
183
+ at `["wsl.exe", "-e", "<path inside WSL>"]`, which the renderer then drives like any other binary.
184
+ The same setting works for a container (`["docker", "run", "--rm", "lightpanda/browser:nightly"]`)
185
+ or any wrapper. `scripts/install-lightpanda.sh` prints that recipe when run outside WSL.
186
+ - `bash scripts/install-lightpanda.sh` installs the newest stable release. When the system glibc
187
+ predates what the binary needs, it downloads Debian's `libc6` for the current stable suite,
188
+ extracts it into the install directory, and writes a launcher shim — so 1.0.0 runs on a
189
+ glibc 2.36 host without touching the system libraries. The script prints the path to use.
190
+ - Disable the tier with `webRender.lightpandaEnabled: false`; `web_fetch` then stops after the
191
+ network attempts and reports the original error.
150
192
 
151
193
  ## Known differences from Studio
152
194
 
@@ -155,11 +197,15 @@ that need JavaScript to render:
155
197
  (`file://` URLs and absolute, `~/`, `./` paths) by default; opt out with
156
198
  `webFetch.allowPrivateAddresses: false` and `webFetch.allowLocalFiles: false` to restore
157
199
  Studio's behavior.
158
- - Third-party rendering: the extra `web_render` tool asks the Jina Reader (`r.jina.ai`) to fetch
159
- the page, so the target URL leaves the machine. Studio has no third-party rendering path. This
160
- path always refuses local files and non-public addresses, regardless of the local-access settings.
161
- A direct fetch refused with HTTP 403 is retried through it automatically when enabled, so those
162
- targets also leave the machine in that case.
200
+ - Browser-fingerprint retry: a 403 is retried through `wreq-js` (Chrome TLS/HTTP2 shape) before any
201
+ rendering, keeping the pinned IP, the website policy, and the local-file refusal rules. Studio has
202
+ no such path.
203
+ - Local rendering: Lightpanda renders the page in a local browser and the dump runs through the same
204
+ extraction pipeline; nothing leaves the machine, and `--block-private-networks` is always passed.
205
+ Studio has no local rendering path.
206
+ - No third-party rendering: Studio has no rendering path at all; this port renders refused or
207
+ JavaScript-heavy pages locally with Lightpanda and never sends the URL to a rendering service.
208
+ Local files and non-public addresses stay refused for the browser tier.
163
209
  - PDF styling: MuPDF.js exposes one font per line, so mixed-style lines style the
164
210
  whole line instead of per-span; superscript, subscript, underline, strikeout, and
165
211
  highlight markers are not emitted. Tables use a conservative text-grid detector:
@@ -182,8 +228,8 @@ that need JavaScript to render:
182
228
  - Proxies: Studio routes through environment proxies; this port resolves and pins the target IP and
183
229
  tunnels that connection through `HTTPS_PROXY` / `HTTP_PROXY` / `ALL_PROXY` when the proxy is a
184
230
  SOCKS5 proxy (`NO_PROXY` exclusions respected; DNS stays local for the guard). Other proxy
185
- schemes fall back to a direct connection. The search and `web_render` paths use the process-wide
186
- `fetch`, so an agent-level proxy dispatcher applies there too — see
231
+ schemes fall back to a direct connection. The search path uses the process-wide `fetch`, so an
232
+ agent-level proxy dispatcher applies there too — see
187
233
  [Companion: rotating exit IPs](#companion-rotating-exit-ips).
188
234
  - Dedup and titles: the aggregator keys on canonicalized hrefs (`utm_*`/tracking parameters
189
235
  and fragments stripped, then the URL re-serialized); fetched HTML pages are prefixed with
@@ -195,16 +241,17 @@ that need JavaScript to render:
195
241
 
196
242
  ## When to use alternatives
197
243
 
198
- This package keeps Studio's deterministic, zero-dependency pipeline and test parity with
199
- `unsloth/studio`, then layers local access, third-party rendering, and other Studio-independent
200
- behavior on top. The SSRF guard is real and thoroughly tested, but it is opt-in:
244
+ This package keeps Studio's deterministic extraction and search pipeline — and its test parity with
245
+ `unsloth/studio` — then layers browser-fingerprint fetching, local rendering, local access, and other
246
+ Studio-independent behavior on top. Its only runtime dependency is `wreq-js` (the default transport);
247
+ `mupdf` stays optional. The SSRF guard is real and thoroughly tested, but it is opt-in:
201
248
  `allowPrivateAddresses` defaults to `true`, and local files are readable unless `allowLocalFiles`
202
249
  is `false`. For other tradeoffs, prefer:
203
250
 
204
251
  | Need | Use |
205
252
  |---|---|
206
- | Browser-like TLS/HTTP fingerprinting to unblock bot-defended pages | `pi-smart-fetch` (`wreq-js` `chrome_145`) |
207
- | 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 |
253
+ | 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 |
254
+ | 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 |
208
255
  | Hosted search with semantic ranking and no scraping | `Brave Search API` / `Tavily` / `Exa` via `pi-ollama-web-search` |
209
256
  | Prompt-focused page distillation to save context | `pi-web-fetch` `prompt` -> sub-agent or Claude Code `WebFetch(url,prompt)` |
210
257
  | Batch fetching many URLs concurrently | `pi-smart-fetch` `batch_web_fetch` or call `web_fetch` in parallel |
@@ -216,8 +263,8 @@ choose the best tool per URL. No need to fork this package to add those features
216
263
 
217
264
  [`pi-tor-proxy`](https://github.com/YuGiMob/pi-tor-proxy) routes pi's in-process `fetch` traffic
218
265
  through Tor (it downloads and manages its own Tor binary) and gives each pi instance its own
219
- circuit and exit IP. The search sweep and `web_render` both use `fetch`, so they leave through
220
- the current Tor exit, and Jina rate-limits keyless Reader requests per outgoing IP —
266
+ circuit and exit IP. The search sweep uses the process-wide `fetch`, so it leaves through the
267
+ current Tor exit, and many search engines rate-limit or challenge per outgoing IP —
221
268
  `/tor-cycle` swaps the exit those limits are counted against, while `/tor-country` and
222
269
  `/tor-exclude` constrain which exits are used.
223
270
 
@@ -225,7 +272,7 @@ the current Tor exit, and Jina rate-limits keyless Reader requests per outgoing
225
272
  pi install npm:pi-unsloth-webtools npm:pi-tor-proxy
226
273
  ```
227
274
 
228
- `web_fetch` and `web_search` url mode also route: they resolve and pin the target IP, then tunnel
275
+ `web_fetch` also routes: it resolves and pins the target IP, then tunnels
229
276
  the connection through the SOCKS5 proxy named by `HTTPS_PROXY` / `HTTP_PROXY` / `ALL_PROXY` (with
230
277
  `NO_PROXY` exclusions, so localhost and local files stay direct). DNS is still resolved locally for
231
278
  the SSRF guard, and the proxy connects to that pinned IP.
@@ -254,36 +301,18 @@ Optional settings in `~/.pi/agent/settings.json` or `.pi/settings.json` (project
254
301
  | Key | Default | Description |
255
302
  |---|---|---|
256
303
  | `unslothWebTools.maxResults` | `5` | Default `maxResults` for `web_search` (clamped 1-20) |
257
- | `unslothWebTools.maxChars` / `webFetch.maxChars` / `smartFetchDefaultMaxChars` | tool param | Default `maxChars` for `web_fetch` and `web_search` url mode |
258
- | `unslothWebTools.timeoutMs` / `webFetch.timeoutMs` / `smartFetchDefaultTimeoutMs` | `60000` fetch, `300000` search | Default `timeoutMs` when the tool param is absent (>=1000). Fetch and `web_search` url mode fall back to 60000; `web_search` query mode falls back to 300000 |
304
+ | `unslothWebTools.maxChars` / `webFetch.maxChars` / `smartFetchDefaultMaxChars` | tool param | Default `maxChars` for `web_fetch` |
305
+ | `unslothWebTools.timeoutMs` / `webFetch.timeoutMs` / `smartFetchDefaultTimeoutMs` | `60000` fetch, `300000` search | Default `timeoutMs` when the tool param is absent (>=1000). Fetch falls back to 60000; `web_search` query mode falls back to 300000 |
259
306
  | `webSearch.maxResults` / `smartWebSearch.resultsPerQuery` | same as above | Legacy aliases for `maxResults` |
260
307
  | `websitePolicy` | none | Not read from settings. Tools run unrestricted by default; `websitePolicy` is a programmatic option the host passes to `webSearch` / `fetchPageText` |
261
308
  | `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 |
262
- | `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 |
263
- | `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 |
264
-
265
- ### Settings window
266
-
267
- `/webtools-config` opens an interactive settings window (↑↓ navigate, space toggle, q close). Settings
268
- persist across sessions in `~/.config/pi-unsloth-webtools/config.json`, created when a setting is
269
- first changed:
309
+ | `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 |
310
+ | `webFetch.transport` / `unslothWebTools.transport` | `tls-first` | Fetch transport order: `tls-first` (default), `direct-first`, or `off` to disable the browser-fingerprint transport entirely |
311
+ | `webRender.lightpandaEnabled` / `unslothWebTools.lightpandaEnabled` | `true` | Opt out to disable local Lightpanda rendering |
312
+ | `webRender.lightpandaPath` / `unslothWebTools.lightpandaPath` | `lightpanda` on `PATH` (`PI_LIGHTPANDA_BIN` fallback) | Path to the Lightpanda binary used for local rendering |
313
+ | `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 |
270
314
 
271
- ```json
272
- {
273
- "webRenderEnabled": true
274
- }
275
- ```
276
-
277
- | Key | Default | Description |
278
- |---|---|---|
279
- | `webRenderEnabled` | `true` | When `false`, the `web_render` tool is deactivated for the session and the automatic HTTP 403 fallback in `web_fetch` / `web_search` url mode is disabled |
280
-
281
- On non-Windows platforms the directory honors `XDG_CONFIG_HOME` when set (falling back to
282
- `~/.config`); on Windows it always uses `~/.config`.
283
-
284
- 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.
285
-
286
- 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. SOCKS5 proxies named by `HTTPS_PROXY`, `HTTP_PROXY`, or `ALL_PROXY` are honored on every fetch (`NO_PROXY` exclusions apply).
315
+ 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).
287
316
 
288
317
  ## Troubleshooting
289
318
 
@@ -300,20 +329,22 @@ Match on the exact prefix. Do not retry blocked hosts with spelling tricks.
300
329
  | 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`). |
301
330
  | 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. |
302
331
  | File read failed | `Failed to read file: ...` | Check the path exists and is a regular file. |
303
- | HTTP failure | `Failed to fetch URL: HTTP ...` | Fix the URL. A 404 automatically tries a Wayback snapshot; a 403 retries through `web_render` when enabled. |
332
+ | 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. |
304
333
  | 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. |
305
334
  | Non-text / binary | `(non-text content:` / `(binary content,` | Not readable as text by design. |
306
335
  | PDF without text | `(PDF contains no extractable text)` / `(PDF content could not be read as text...)` | Scanned or encrypted PDF. |
307
336
  | Download cap hit | `... (page truncated at the download limit)` | Raw fetch hit 512 KiB (10 MiB for PDFs). |
308
337
  | maxChars cut | `... (truncated, N chars total)` | Raise `maxChars` for the full text. |
309
- | Empty page | `(page returned no readable text)` | Page had no extractable text; try `web_render`, which renders JavaScript pages. |
310
- | Render blocked (local file) | `Blocked: web_render cannot fetch local files.` | Use `web_fetch`, which reads local files by default. |
311
- | Render blocked (private host) | `Blocked: refusing to fetch the non-public address ...` | `web_render` only reaches public hosts; use `web_fetch` for localhost/LAN. |
312
- | 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`. |
338
+ | Empty page | `(page returned no readable text)` | Page had no extractable text; `web_fetch` then tries local Lightpanda if it is installed. |
339
+ | JavaScript-rendered page | `*(JavaScript-rendered page; content may be incomplete)*` | Rendering was disabled or produced no more text; the page likely needs a browser. |
313
340
  | GitHub rewrite | `README of ... (fetched via the GitHub README API):` | Expected repo-root rewrite, not the HTML chrome. |
314
341
  | Cache fallback | `Served from cache` / `STALE cache from YYYY-MM-DD` | Network failed; output is the cached copy with its date. |
315
342
  | Wayback fallback | `Fetched from Wayback Machine snapshot (YYYY-MM-DD) for ...` | Original 404'd; output is the archived copy with its date. |
316
343
 
344
+ | 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 |
345
+ | 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 |
346
+ | 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>` |
347
+ | 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 |
317
348
  ## Development
318
349
 
319
350
  ```sh
@@ -322,12 +353,43 @@ npm run typecheck
322
353
  npm test
323
354
  npm run test:unit
324
355
  npm run test:smoke
356
+ bash scripts/install-lightpanda.sh
357
+ npm run compare:fetch
358
+ npm run compare:browsers
359
+ npm run camoufox:warmup
360
+ npm run stealth:matrix
361
+ /tmp/pyenv/bin/python scripts/stealth-python.py
325
362
  ```
326
363
 
364
+ `npm run compare:fetch` runs a live head-to-head of the direct fetch, the TLS-impersonation retry,
365
+ local Lightpanda rendering over a target list. It accepts URLs as arguments and `--no-lightpanda`
366
+ to drop the render tier. `npm run compare:browsers` adds a Camoufox column;
367
+ install it separately (`npm i camoufox-js playwright-core && npx camoufox-js fetch`, plus GTK3
368
+ libraries on Linux) and use `--seconds=N` to bound how long it waits out a JS challenge.
369
+ `npm run stealth:matrix` compares stealth-browser options against one walled page (plus
370
+ `bot.sannysoft.com` detection rows and a plain-page sanity check): raw CDP to a system Chromium
371
+ (`PI_CHROMIUM_BIN` to point at it), Playwright with its bundled Chromium, Patchright, and Camoufox.
372
+ Every browser dependency is loaded through a guarded dynamic import, so nothing is added to
373
+ `package.json`; install whichever rows you want to measure. `--attempts=N` and `--seconds=N` bound
374
+ the walled-page attempts, and passing row names runs a subset (`raw-cdp`, `playwright`, `patchright`,
375
+ `camoufox`).
376
+
377
+ `scripts/stealth-python.py` is the same idea for the Python-side options (nodriver, CloakBrowser,
378
+ DrissionPage, cloudscraper, curl_cffi) against the same walled page; it needs a venv with those
379
+ packages installed and is not wired into any npm script. Measured findings are in its module docstring.
380
+
381
+ `npm run camoufox:warmup` measures what a warm Camoufox costs and buys: launch time, idle CPU and
382
+ RSS, per-fetch latency with the browser already running, and whether a persistent profile
383
+ (`user_data_dir`, pinned fingerprint) lets a Cloudflare clearance survive a restart. Flags:
384
+ `--virtual` for a virtual display, `--pin=0` to let Camoufox rotate fingerprints, `--seconds=N`,
385
+ `--idle=N`.
386
+
327
387
  ## Tests
328
388
 
329
389
  `npm test` runs the full suite. `npm run test:unit` skips the live-network smoke tests,
330
- and `npm run test:smoke` runs only those.
390
+ `npm run test:smoke` runs only those, and `npm run test:lightpanda` drives a real Lightpanda binary
391
+ against live pages — it is skipped unless `PI_LIGHTPANDA_E2E=1` is set (`PI_LIGHTPANDA_E2E_BIN` points
392
+ at the binary), and CI runs it in a dedicated job after `scripts/install-lightpanda.sh`.
331
393
 
332
394
  The suite ports Unsloth Studio's own tests for these tools:
333
395
 
@@ -350,12 +412,17 @@ The suite ports Unsloth Studio's own tests for these tools:
350
412
  - `test/smoke.test.ts`: live network checks against real hosts, including a per-engine
351
413
  result-health sweep (at least two engines must return well-formed results; engines
352
414
  that block or reset connections from datacenter IPs count as unhealthy, not failures)
353
- - `test/web-render.test.ts`: Jina Reader rendering with a stubbed fetch and DNS, the public-only
354
- guard, policy enforcement, error mapping, truncation, cancellation, and API key precedence
355
415
 
356
- The seams (`seams.resolve` / `seams.request` / `rawFetch`) replace the network stack
357
- with fakes, mirroring how the Studio suite monkeypatches `_validate_and_resolve_host`
358
- and `build_opener`.
416
+ - `test/tls-fetch.test.ts`: the browser-fingerprint transport with a stubbed native module, including
417
+ transport reuse, body caps, redirect passthrough, and abort/timeout mapping
418
+ - `test/lightpanda.test.ts`: the local renderer's command line, guards, failure modes, and a real
419
+ child-process run against a scripted binary
420
+ - `test/impersonation.test.ts`: the HTTP 403 retry inside the fetch pipeline, including redirect
421
+ re-validation, the disabled path, and the stubbed-network contract
422
+ The seams (`seams.resolve` / `seams.request` / `seams.impersonate` / `rawFetch`) replace the network
423
+ stack with fakes, mirroring how the Studio suite monkeypatches `_validate_and_resolve_host` and
424
+ `build_opener`. Supplying a `request` seam also disables the browser-fingerprint retry, so a stubbed
425
+ transport never escapes to the real network.
359
426
 
360
427
  ## License
361
428
 
package/entities.ts CHANGED
@@ -1,4 +1,4 @@
1
- export const NAMED_ENTITIES: Record<string, string> = {
1
+ export const NAMED_ENTITIES: Record<string, string> = Object.assign(Object.create(null), {
2
2
  "AElig": "\u00c6",
3
3
  "AElig;": "\u00c6",
4
4
  "AMP": "&",
@@ -2230,7 +2230,7 @@ export const NAMED_ENTITIES: Record<string, string> = {
2230
2230
  "zscr;": "\ud835\udccf",
2231
2231
  "zwj;": "\u200d",
2232
2232
  "zwnj;": "\u200c",
2233
- };
2233
+ });
2234
2234
 
2235
2235
  export const INVALID_CHARREFS: Record<number, string> = {
2236
2236
  0: "\ufffd",
package/html-to-md.ts CHANGED
@@ -66,7 +66,7 @@ const P_CLOSING_TAGS = new Set([
66
66
  "ul",
67
67
  ]);
68
68
 
69
- const IMPLICIT_CLOSERS: Record<string, Set<string>> = {
69
+ const IMPLICIT_CLOSERS: Record<string, Set<string>> = Object.assign(Object.create(null), {
70
70
  p: P_CLOSING_TAGS,
71
71
  li: new Set(["li"]),
72
72
  dt: new Set(["dt", "dd"]),
@@ -76,9 +76,9 @@ const IMPLICIT_CLOSERS: Record<string, Set<string>> = {
76
76
  th: new Set(["td", "th", "tr"]),
77
77
  option: new Set(["option", "optgroup"]),
78
78
  optgroup: new Set(["optgroup"]),
79
- };
79
+ });
80
80
 
81
- const CLOSE_BARRIERS: Record<string, Set<string>> = {
81
+ const CLOSE_BARRIERS: Record<string, Set<string>> = Object.assign(Object.create(null), {
82
82
  li: new Set(["ul", "ol", "menu"]),
83
83
  dt: new Set(["dl"]),
84
84
  dd: new Set(["dl"]),
@@ -87,7 +87,7 @@ const CLOSE_BARRIERS: Record<string, Set<string>> = {
87
87
  th: new Set(["table"]),
88
88
  option: new Set(["select", "datalist"]),
89
89
  optgroup: new Set(["select", "datalist"]),
90
- };
90
+ });
91
91
 
92
92
  const BLOCK_TAGS = new Set([
93
93
  "p",
@@ -106,7 +106,7 @@ const BLOCK_TAGS = new Set([
106
106
  ]);
107
107
 
108
108
  const HEADING_TAGS = new Set(["h1", "h2", "h3", "h4", "h5", "h6"]);
109
- const INLINE_EMPHASIS: Record<string, string> = { strong: "**", b: "**", em: "*", i: "*" };
109
+ const INLINE_EMPHASIS: Record<string, string> = Object.assign(Object.create(null), { strong: "**", b: "**", em: "*", i: "*" });
110
110
 
111
111
  const HEADER_LINK_DENSITY = 0.93;
112
112
  const HEADER_MIN_CHARS = 150;
@@ -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
  }