Scrapling web scraping for dsh: JavaScript rendering, structured extraction and a background crawler, behind one resident Python sidecar.
**A web-scraping extension for [dsh](https://github.com/deepseek-ai/deepseek-harness): [Scrapling](https://github.com/D4Vinci/Scrapling) as agent tools — JavaScript rendering, CSS/XPath extraction with self-healing selectors, sessions with login state, a background crawler and page Q&A. You supply the interpreter and the browser engine; everything runs in one resident Python process.**
## Contents
- [What it does](#what-it-does)
- [Install](#install)
- [Runtime packs (macOS arm64)](#runtime-packs-macos-arm64)
- [Verify it is live](#verify-it-is-live)
- [What it does not provide](#what-it-does-not-provide)
- [Enabling it in dsh](#enabling-it-in-dsh)
- [The tools](#the-tools)
- [Replacing the built-in fetch and search](#replacing-the-built-in-fetch-and-search)
- [MCP mode](#mcp-mode)
- [Settings](#settings)
- [Proxies](#proxies)
- [Security](#security)
- [Data and privacy](#data-and-privacy)
- [Known boundaries](#known-boundaries)
- [FAQ](#faq)
- [Development](#development)
- [License](#license)
## What it does
dsh ships `web_fetch` and `web_search`. The built-in `web_fetch` is deliberately honest and minimal: a plain HTTP(S) GET with no JavaScript execution and an honest User-Agent. That is a good default — and it means a single-page application yields only its loading shell, and there is no way to ask for "just the product table". Measured on a page whose content exists only after JavaScript runs:
| Pipeline | Text obtained |
|---|---|
| raw HTML to text (no JS) | `SPA Shell / LOADING_PLACEHOLDER` |
| `Fetcher` (curl_cffi, still no JS) | `SPA Shell / LOADING_PLACEHOLDER` |
| browser render (patchright) | full rendered DOM, Markdown table, structured rows |
This package adds what the built-ins structurally cannot do:
- **Browser rendering** — JavaScript execution via patchright.
- **Structured extraction** — CSS/XPath selection returning data, not a wall of Markdown.
- **Self-healing selectors** — Scrapling's `adaptive` mode relocates an element after the site's markup changes; a selector that returns `None` after a class rename returns the relocated element with `adaptive` on.
Everything runs against **one long-lived Python process**. That is not an optimisation: Scrapling's session classes keep cookies, the browser engines keep a page pool, and `adaptive` keeps a SQLite fingerprint database — a process per call would throw all of that away. Tools are registered **conditionally**: the plugin probes what the environment can actually do at startup and registers only what will work, so the model never burns a turn on a tool that was guaranteed to fail. Every tool renders its result row through the official tool-call view slot. `scrapling_answer` is the only tool that spends **another model call** — see [The tools](#the-tools).
## Install
```sh
dsh plugin --profile web add @jayyuen66/dsh-scrapling
```
Remove with `dsh plugin --profile web remove @jayyuen66/dsh-scrapling`. The package is on the public npm registry, so installation needs no credentials and no scoped-registry line.
- **Requires Node.js `>= 22.18`.** This is an ESM-only package, and the build entry points (`build-client.ts`, `build-host.ts`, `build-cli.ts`) are executed by `node` directly; TypeScript type stripping is on by default from Node 22.18, so that is the floor that keeps `pnpm build` and `prepack` working on a declared-supported version.
- **Requires host `dsh` at the `0.2.1-rc.5` or `0.2.2-alpha.3` line.** The requirement the host actually reads is `engines.dsh` (`^0.2.1-rc.5 || ^0.2.2-alpha.3`), and it decides alone; a host on a later line is refused and needs the explicit risk grant `dsh plugin --profile
allow-version @ --dsh-version --accept-risk`. The support window rolls: this package is verified against every released host version inside the window at publish time and re-verified once the window moves, so the version number in this bullet is not a standing promise.
- **Requires Python 3.14+ with Scrapling installed** — named by `pythonBin`, or found by the fixed chain in `lib/runtime.ts` (managed runtime under `dataDir/runtime`, then the repository's `_env/.venv`, then `PATH`). In an installed package the `_env/.venv` leg does not exist, so after `setup --feature core --apply` the interpreter is that managed Python and otherwise the `python3` on your `PATH` (`python` on Windows); whichever one is chosen is logged at load time, and a missing Scrapling reports an error carrying the install line to copy.
```sh
pip install "scrapling[rag]>=0.5,<0.6"
python -m patchright install chromium
```
- **Why `rag` and not `fetchers`:** `rag` is `fetchers` plus `markdownify`, and the default `extractionType` is `markdown` — with plain `fetchers` the first fetch raises `ModuleNotFoundError` at the Markdown conversion.
- **Version range:** developed and verified against Scrapling **0.5**, through interfaces that are not stable API surface (`Selector`, `Spider`, `SessionManager`, and the converter's noise-stripping helpers). The upper bound is pinned below `0.6`; outside the range the guards still run, but the anti-injection cleaning and the crawl path may not behave as documented. In 0.5 the browser backend is patchright only (the old standalone stealth fetch is gone; its anti-detection arguments live on `scrapling_render`), `capture_xhr` became session-level recording (hence `scrapling_session_network`), and `extra_flags` became additive — this package sends none, and WebRTC leak protection is carried by the `blockWebrtc` switch.
- **Without the installer, the browser engine is yours.** The optional CLI can install the verified macOS arm64 set; the plugin itself downloads nothing at load time. Without a browser it simply does not register the browser-dependent tools, so the model never wastes a turn calling them.
- **No `@deepseek-ai/*` under `dependencies`, by contract** (`test/publish-manifest.test.ts` enforces it). Consumer profiles install with `nodeLinker: hoisted` and `autoInstallPeers: false`, so a host package there becomes a real second copy whose module-level state splits from the running host's — every native tool call then throws while build and tests stay green. The host packages the artifact imports for value are required peers and stay externalized; the gate imports the built artifact and asserts that no host package body is inlined.
- **The artifact is ESM-only and never committed** — `prepack` rebuilds it, so what ships is always what the current source produces.
### Shared version floor
If your profile pins `@jayyuen66/dsh-plugin-shared` with a pnpm `overrides` entry, that pin must be at least the version this package declares. A lower pin installs a copy without the names the sources use, and the host then reports `N entries did not activate` together with `does not provide an export named ...` at boot - the package installs fine and never loads, and no per-repository CI run can see it.
## Runtime packs (macOS arm64)
The package ships `bin/dsh-scrapling.mjs`. It installs the ability packs into `dataDir/runtime` (default `~/.dsh-scrapling`) — never into system Python and never into the user's Playwright cache. There is **no `postinstall`**; nothing is downloaded when the plugin loads.
```sh
dsh-scrapling setup --feature core --apply
dsh-scrapling setup --feature browser --apply
dsh-scrapling setup --status
```
- `core` installs managed Python 3.14.9, Scrapling 0.5 and its pinned wheels; `browser` adds the full Chromium build (not the headless shell).
- `search`, `media`, `mcp` and `adaptive-redis` all ride on `core`'s venv: `mcp` brings the official `scrapling-mcp` server (the pinned `mcp` package; its tool set is the upstream's, not this plugin's) that `official-mcp` mode needs, and `adaptive-redis` brings the `redis` client behind `adaptiveStorage: redis`. Every pack is installable; the settings card reports each as `ready`, `missing`, `corrupt` or `installing`.
- `--feature` is a dry run by default; `--apply` writes. `--repair` re-installs without touching user data, and `--uninstall [--purge]` removes runtime files; only `--purge` removes `elements_storage.db` and `downloads/`.
- `--offline ` installs with no network at all: `` must hold the Python archive named exactly as the manifest URL's last segment (browser and `curl -O` both keep `cpython-3.14.9+…-install_only.tar.gz`, and the percent-encoded spelling is accepted too) plus a `wheels/` directory with one wheel per pinned requirement - `pip download --only-binary=:all: --require-hashes -r py/runtime-requirements.txt -d /wheels` produces it. The hash check on the Python archive is the same one the networked path runs, and pip is invoked with `--no-index`; a missing archive or wheelhouse fails as `INSTALL_OFFLINE_ASSETS` instead of silently going online.
- `setup --status` prints one JSON document: `dataDir`, `profile`, `runtime`, `packs` and `state`. With `--profile ` it reads that profile's `cordis.patch.yml` and resolves the interpreter from this package's `pythonBin` (the setting the host actually uses); without it, `profile.path` is `null`, a note says the profile was not read, and `runtime` falls back to the CLI's own chain (managed runtime → repo `_env/.venv` → PATH `python3`). Pass `--profile` whenever you compare the card's interpreter with what `--status` reports.
- The settings card can start the same install/repair/cancel actions through the `scraplingRuntime` remote when the profile has both a remote surface and a jobs backend; otherwise it prints the CLI command.
- `integrationMode` is deployment-only; `setup --mode` prints or writes the client entry for the two MCP shapes and `doctor` checks a profile against it — see [MCP mode](#mcp-mode). `official-mcp` starts `scrapling-mcp` from the managed venv, which the `mcp` pack provisions (`setup --feature mcp --apply`).
- Only **macOS arm64** is verified. Windows, Linux and macOS x64 are blocked (B1/B2): every pack reports `blocked` and the installer refuses rather than guessing an asset path.
## Verify it is live
1. **The layer is mounted.** `dsh --profile web --dump-config` shows this package's layer line.
- That command is the only accepted "it is in the layer stack" signal, and it is **not read-only** — it rewrites the profile's root assembly file. Read layer lines from stdout **and** check stderr separately: an unmatched patch target appears only on stderr, so the exit code alone proves nothing. Accept both `# == ` and `# == , patched by `.
2. **The startup log names the interpreter and the plan.** One line prints the probed capabilities and how many of the tools registered; the chosen interpreter path is logged at load. An interpreter that starts but lacks Scrapling is named in the log together with the pip line to copy.
3. **The settings card renders.** The plugin detail page carries the **scrapling** card with every user-editable field grouped by purpose; toggling a value enables Save, and the write returns 200.
4. **A tool round-trip.** Ask the model to fetch a JavaScript-heavy page: the result row renders in the chat through the official tool-call view. On a machine without a browser the render / capture_xhr tools are absent from the model's tool list and their prompt guidance is gone too — that absence is the conditional registration working, not a failure.
## What it does not provide
- **It is not a search engine.** Discovery runs in the Python sidecar against one of six backends (`searchBackend`: `auto` / `tavily` / `bing` / `brave` / `serper` / `duckduckgo` / `searxng`); Scrapling's own `google_search` option only sets a referer header. Each backend needs its own setting (`tavilyApiKey`, `braveApiKey`, `serperApiKey`, `searxngEndpoint`) — a backend that is selected but not configured reports `SEARCH_AUTH` instead of silently switching to another one.
- **The npm tarball bundles no interpreter or browser.** They come from your machine or from the optional CLI installer, which downloads the verified macOS arm64 set into `dataDir/runtime`; missing pieces remove the matching tools instead of being fetched at load time.
- **It adds no HTTP route on the host side.** The settings card is a host config form and this package exposes no `/api` surface. The one socket that really listens is the MCP server under `integrationMode: mcp`, bound to `127.0.0.1` (`http://127.0.0.1:/mcp`): any other path gets 404, a declared body over 1 MiB gets 413, a request without `Content-Length` gets 411, and the bearer token is read only from `DSH_SCRAPLING_MCP_TOKEN` — with no token set, startup prints a stderr notice that the endpoint is unauthenticated and reachable from any local process.
- **It adds no dsh subcommand.** The package's own `dsh-scrapling` CLI manages runtime packs, plus the two halves of the MCP hookup: `setup --mode native|mcp|official-mcp` generates the client entry and, with `--apply`, writes it to `/mcp/.patch.yml` (the profile itself is never touched), while `doctor --mode` compares that entry against the profile's `cordis.patch.yml` item by item. Given `--profile`, `setup --status` reads `pythonBin` from that same file.
## Enabling it in dsh
- `dsh.bundle.patch` points at `cordis.patch.yml`, whose single registration line is `- id: scrapling` with the **bare package name** — the host scans `dsh.client` on bare package-name entries only.
- The client half registers two surfaces: the settings card (on the `plugins.bundle.config` slot, keyed by the bundle package name — the host dispatches `entryKey: pkg.name`, so the card renders on this package's detail page) and one result row per tool on `tool.call.toolview`, drawn with the official tool-call view props.
- The settings namespace is the entry id `scrapling`; precedence is settings-card value > registration-line `config:` > schema default. Setting a field back to its default saves a **clear-override**, not a write of the same value, so a later default change still reaches you.
- Gate policy and the sidecar environment are snapshotted once when the plugin is applied — **settings changes take effect on the next start**.
- Disabling a tool also removes its system-prompt guidance: the guidance is re-read at each prompt assembly against the tools that scope can actually see, so a machine without a browser loses the render / capture_xhr lines and a sidecar that never came up loses all of them. The section is placed next to the built-in `web_fetch` guidance.
- `scrapling_crawl` registers its own job kind, so `job_list` labels its jobs as crawls rather than shell jobs.
## The tools
Nineteen tools, behind fifteen settings switches (the seven session/state tools share switches), all exercised by the test suite:
| Tool | Backend | Needs a browser | Concurrency |
|---|---|---|---|
| `scrapling_fetch` | `Fetcher` (curl_cffi) | no | concurrent; write verbs are serialised once `allowWriteMethods` is on |
| `scrapling_extract` | `Selector` + CSS/XPath + `adaptive` | no | concurrent |
| `scrapling_locate` | text / regex / similar-element lookup; returns generated selectors | no | concurrent |
| `scrapling_links` | `LinkExtractor` plus next-page candidates | no | concurrent |
| `scrapling_render` | `StealthySession` (patchright) | yes | serialised; the anti-detection switches (`solveCloudflare` / `hideCanvas` / `blockWebrtc`) live here |
| `scrapling_capture_xhr` | one throwaway session, page-level recording | yes | serialised |
| `scrapling_session_open` / `scrapling_session_fetch` / `scrapling_session_network` / `scrapling_session_state_save` / `scrapling_session_state_load` / `scrapling_session_list` / `scrapling_session_close` | Scrapling session classes | browser sessions only | serialised |
| `scrapling_session_read` | current DOM of a browser session page | yes | serialised |
| `scrapling_session_act` | structured actions on a session page (deployment gate) | yes | serialised |
| `scrapling_download` | byte download into `dataDir/downloads` (deployment gate) | no | concurrent |
| `scrapling_screenshot` | PNG/JPEG screenshot handed to the attachment service | yes | serialised |
| `scrapling_crawl` | Scrapling Spider framework (`content` / `sitemap` / `feed` / `shopify`) as a job | no | serialised |
| `scrapling_answer` | any fetch path plus `ctx.llm` | with `render: true` | serialised |
- **Quotas.** At most 8 open sessions (3 of them browser sessions, each a real Chromium process), 2 crawls in flight, per-page bodies clipped by `*MaxOutputChars`, 8 MB per protocol frame, 16 queued frames. A ceiling answers with a specific code (`SESSION_LIMIT` / `CRAWL_BUSY` / `BAD_FRAME`) the model can back off from.
- **`scrapling_crawl` returns a job id immediately** — a crawl takes minutes and a tool call has a timeout. Watch it with `job_list`, read it with `job_output`, stop it with `job_kill`; the tool needs a jobs registry that can attach a controller, and a profile without one simply does not register it (single-page fetching is unaffected). The crawl has a wall-clock ceiling (`crawlMaxRuntimeSeconds`) and never leaves the start domain. Politeness defaults follow Scrapling's spider defaults — robots.txt is **not** obeyed, per-domain delay is 0, up to 4 requests run in parallel — and is a deployment decision, not a per-call one (see [Settings](#settings)).
- **`scrapling_answer` fetches the page first, then hands the text plus your question to a model** and returns the conclusion instead of the whole page. It follows the model the current session is already using by default (read from the session's live request header, so a mid-session model switch is picked up), which burns no separate quota; pin `answerProvider` / `answerModel` to route page-reading elsewhere. Page text is **untrusted input** — the prompt frames it explicitly as data, but that defence is prompt-level: it lowers the risk, it does not remove it.
### Render or static
`scrapling_render` is much slower and heavier than `scrapling_fetch`, so start static. Two criteria:
- **If the rendered result is shorter than the static fetch, go back to static.** Plenty of sites ship readable text from the server and then have their own script empty those containers and swap in canvas / Lottie animation — for those pages rendering can only lose content, and no wait setting brings it back. That is site behaviour, not a plugin defect.
- **`waitSelector` waits for an element to appear, not for content to settle.** The element can be removed after it matched, so "the selector matched" does not mean the content is in the result.
One thing that is not a criterion but worth knowing: axis ticks inside SVG **cannot be fetched at all** — the Markdown conversion emits no `