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 +151 -84
- package/entities.ts +2 -2
- package/html-to-md.ts +24 -9
- package/index.ts +96 -166
- package/lightpanda.ts +276 -0
- package/package.json +13 -5
- package/proxy.ts +2 -1
- package/scripts/install-lightpanda.sh +206 -0
- package/settings.ts +57 -11
- package/tls-fetch.ts +205 -0
- package/web-access.ts +4 -0
- package/web-fetch.ts +175 -51
- package/config-ui.ts +0 -105
- package/config.ts +0 -207
- package/web-render.ts +0 -100
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
|
|
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
|
|
9
|
-
|
|
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
|
-
|
|
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`
|
|
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`
|
|
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
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
|
186
|
-
|
|
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
|
|
199
|
-
`unsloth/studio
|
|
200
|
-
behavior on top.
|
|
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
|
|
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
|
|
220
|
-
|
|
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`
|
|
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`
|
|
258
|
-
| `unslothWebTools.timeoutMs` / `webFetch.timeoutMs` / `smartFetchDefaultTimeoutMs` | `60000` fetch, `300000` search | Default `timeoutMs` when the tool param is absent (>=1000). Fetch
|
|
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`
|
|
263
|
-
| `
|
|
264
|
-
|
|
265
|
-
|
|
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
|
-
|
|
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
|
|
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;
|
|
310
|
-
|
|
|
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
|
-
|
|
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
|
-
|
|
357
|
-
|
|
358
|
-
and
|
|
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
|
-
|
|
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
|
}
|