dsh-scrapling
Verified@jayyuen66/dsh-scrapling · v0.0.1 · MIT · Web UI
Scrapling 网页抓取插件(dsh):静态/浏览器/stealth 抓取 + CSS/XPath 结构化抽取 + 选择器自愈 + capture_xhr + Spider 爬虫,由常驻 Python 进程驱动。
Install
dsh plugin add @jayyuen66/dsh-scrapling Confirm the layer applied with dsh --profile default --dump-config — see the install guide.
Source
Tags
Readme
@jayyuen66/dsh-scrapling
English · 中文
Scrapling web scraping for DeepSeek Harness (dsh), exposed as agent tools backed by a long-lived Python process.
Why this plugin exists
dsh ships web_fetch and web_search. web_fetch is deliberately honest and minimal:
a plain HTTP(S) GET with no JavaScript execution, an honest User-Agent
(deepseek-harness/0.2.0-rc.2, explicitly "never a browser disguise"), and a
turndown-based HTML-to-markdown conversion. That is a good default, but 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 |
DynamicFetcher (Playwright) |
full rendered DOM, Markdown table, structured rows |
This plugin adds exactly three things dsh's built-in tools cannot do:
- Browser rendering - JavaScript execution via Playwright / patchright.
- Structured extraction - CSS/XPath selection returning data, not a wall of Markdown.
- Self-healing selectors - Scrapling's
adaptivemode relocates an element after the site's markup changes. Verified working: a selector that returnsNoneafter a class rename returns the relocated element whenadaptive=True.
Status
All eleven tools (the session family counts as four) are implemented, registered and exercised by the test suite. Tools are registered conditionally: the plugin probes what the environment can actually do at startup and only registers tools that will work, so the model never burns a turn on a tool that was guaranteed to fail.
| Tool | Backend | Needs a browser | Concurrency |
|---|---|---|---|
scrapling_fetch |
Fetcher (curl_cffi) |
no | safe |
scrapling_extract |
Selector + CSS/XPath + adaptive |
no | safe |
scrapling_render |
DynamicFetcher (Playwright) |
yes | serialised |
scrapling_capture_xhr |
capture_xhr |
yes | serialised |
scrapling_stealth_fetch |
StealthyFetcher (patchright) |
yes | serialised |
scrapling_session_open / scrapling_session_fetch / scrapling_session_list / scrapling_session_close |
Scrapling session classes | depends | serialised |
scrapling_crawl |
Scrapling Spider framework | no | serialised, runs as a job |
scrapling_answer |
any of the above, plus ctx.llm |
no (yes with render:true) |
serialised |
scrapling_answer is the only tool that spends another model call: it fetches the page
first, then hands the text plus your question to a model and returns the conclusion instead
of the whole page. By default it follows the model the current session is already using
(read from the session's live request header, so a mid-session model switch is picked up),
which means the plugin does not burn a separate quota; you can pin answerProvider /
answerModel on the settings card to route page-reading to a different model.
Page text is untrusted input — a page can say "ignore your previous instructions". The answer prompt frames the text explicitly as data and forbids acting on it, but that defence is prompt-level: it lowers the risk, it does not remove it.
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.
scrapling_crawl returns a job id immediately rather than blocking: a crawl takes
minutes, a tool call has a timeout. Watch it with job_list, read it with
job_output, stop it with job_kill. A crawl also has a wall-clock ceiling
(crawlMaxRuntimeSeconds) and never leaves the start domain. Politeness is the
deployment's call, stated in the open: Scrapling's spider defaults are kept as-is —
robots.txt is not obeyed, per-domain delay is 0, and up to 4 requests run in
parallel. The crawl is confined to the start domain, so it never fans out across
unrelated sites; a target that needs gentler behaviour should sit behind a rate
limiter on the deployment side.
Requirements
- Python 3.10+ with Scrapling installed, reachable via
PATH(or setpythonBin). - Browser engines are entirely the user's responsibility. The plugin does not download anything. Without a browser it simply does not register the browser-dependent tools, so the model never wastes a turn calling them.
pip install "scrapling[rag]>=0.4.15,<0.5"
python -m playwright install chromium
Why
ragand notfetchers?ragisfetchersplusmarkdownify, and the defaultextractionTypeismarkdown— with plainfetchersthe Markdown conversion raisesModuleNotFoundErroron the first fetch. The Python side then tells you exactly which package is missing.
Version range: this plugin is developed and verified against Scrapling 0.4.15 and depends on its
Convertor._strip_noise_tags/_sanitize_for_ai,Selector,SpiderandSessionManagerinterfaces. None of those are stable API surface, so the upper bound is pinned below0.5; outside the range the guards still run, but the anti-injection cleaning and the crawl path may not behave as documented.
Install
dsh plugin --profile web add @jayyuen66/dsh-scrapling
The plugin is also installable from the Web UI via Sidebar -> Plugins -> Add plugin.
Replacing the built-in web_fetch and web_search
The plugin registers itself as a fetch provider and a search provider on
ctx.web, so the built-in web_fetch / web_search tools actually run on Scrapling
instead of dsh's plain-HTTP implementation. The model still calls web_fetch /
web_search — that is what "replacing the built-in one" means here.
Tool names cannot be swapped; only the backend can.
ToolRuntime.registerends atNamedEntries.insert, which throws on a duplicate name (tool "X" is already registered). Registering a same-named tool is therefore closed off; thectx.webprovider seam is the supported replacement channel.
Both must be done together, or the built-in tools break:
- set
provideWebFetch/provideWebSearchtotruein the plugin config; - set
fetchProvider/searchProviderto"scrapling"in the deployment config.
Why both?
ctx.webauto-selects only when exactly one usable provider is registered. A second one without naming it yieldsWEB_PROVIDER_AMBIGUOUSand the tool becomes unavailable outright. Hence both switches default tofalse.
When searchEndpoint is empty the search provider's available() is false — a second
guard: enabling provideWebSearch without configuring an endpoint cannot displace the
built-in web_search.
Search + render in one call
Scrapling is not a search engine (its google_search option only sets a Google referer
header), so web_search is split in two:
- Discovery → a configurable search backend (deployment-level
searchEndpoint), expected to be SearXNG-compatible JSON: GET<endpoint>?q=<query>&format=jsonreturning{"results": [{"url", "title", "content"}]}. Any backend producing that shape works. - Reading → this plugin's sidecar. The first
searchRenderTopNresults (3 by default) are actually fetched and, when a browser is available, rendered through Playwright. The body lands incontent, the URLs insources.
One web_search call therefore yields both "which pages are relevant" and "what those
pages actually say" — the latter being exactly the SPA content web_fetch structurally
cannot read. Returned URLs are re-checked one by one by this plugin's URL gate; the parsing
layer deliberately does not duplicate that policy. A page that fails to read is skipped
alone — search results routinely contain dead links.
One consequence to know: dsh-web's WebFetchBody is a closed union with only
html | text, no markdown; adding a kind is a cross-package change a plugin cannot make.
So the fetch provider reports Scrapling's Markdown as text, which the built-in renderer
displays normally. Use scrapling_fetch directly when you need html or a CSS selector.
Configuration
Settings live on the scrapling card in the plugin detail page (Settings → Plugins →
expand this bundle). Every field marked user-editable in host.ts appears
there, grouped by what it is for and with a one-line "what changes if you edit this";
deployment-level fields are only settable through the config: block in
cordis.patch.yml. Setting a field back to its default saves a
clear-override, not a write of the same value - so when the default changes later,
that field follows along.
Notable switches:
pythonBin- interpreter name or absolute path; empty meanspython3onPATH.fetchEnabled/extractEnabled/renderEnabled/captureXhrEnabled/sessionEnabled/answerEnabled- on by default.stealthEnabled/crawlEnabled- off by default (extra browser dependency / long-running background job).extractionType-markdown(default),htmlortext. All three paths run the same anti-injection cleaning;htmladditionally strips event-handler attributes (on*,srcdoc,formaction) andmeta refresh, so the result is not byte-for-byte raw HTML.*MaxOutputChars- per-tool output caps.crawlMaxItemsis the deployment-side ceiling over the model-suppliedmaxPages; an out-of-rangemaxPages(0, negative, NaN) is passed through unchanged so the sidecar reports the parameter error rather than this plugin silently clamping it into a legal-looking value.crawlMaxRuntimeSecondsis the crawl's wall-clock ceiling: the page cap says nothing about a site that takes a minute per page, and a crawl is a background thread nobody is watching. On expiry the spider winds down through its own stop channel instead of running until the site runs out.stripInlineImages- on by default: inlinedata:URI images are dropped from the body and only their alt text is kept. Such an image is pure token noise - a base64 SVG costs characters in the context while the model cannot read a shape out of it. Pages with no inline images come back byte-identical; where there are some, the saving depends entirely on how many - a site either inlines none or inlines a whole wall of logos, and little in between. So turning this on is free for pages without inline images, and the whole benefit lands when it matches.data:URIs inside code fences are left untouched - there they are almost always sample code a tutorial is teaching, and stripping would be editing the document. Turn this off only when the raw base64 is the data (colours, fingerprints). Stripping happens before the cap: the other way round is pointless, since those tens of kilobytes have already eaten themaxContentCharsbudget.answerProvider/answerModel/answerMaxTokens- the modelscrapling_answerroutes to and the per-call token ceiling. Provider and model left empty mean "follow the model the current session is already using";answerMaxTokenscaps the reply.mainContentOnly/headless/networkIdle/captureXhrPattern/waitSelectorState/dataDir- extraction scope, browser mode, the XHR capture filter, the selector state a page must reach before it is read, and the sidecar working directory. Each is documented at its declaration inhost.ts.sidecarHandshakeTimeoutMs/fetchTimeoutMs/sidecarGraceMs- the budget for the sidecar handshake, the per-call cooperative timeout (declared as the tools'timeoutMs), and how long the client waits for the process to exit after that timeout before forcing it down. The three are on the settings card.scrapling_crawlis the exception: it runs as a job and passes no timeout, because a crawl is measured in minutes and a fixed budget would tear the process down on every success.failFastOnMissingPython-true(default) refuses to load when the interpreter is unusable, because every tool needs the sidecar and a silent degrade leaves the model discovering "all tools are missing" several turns later.falsedegrades to "all tools unavailable" with a warning.blockAds- on by default. It stacks on top of the SSRF guard rather than competing with it: the guard is registered last (so Playwright runs it first) and yields withroute.fallback(), leaving Scrapling's own interception handler reachable.allowedHosts- extra host names that skip the public-address check. The same setting feeds both layers, so an explicitly allowed intranet target is not rejected by the host-side policy before the Python side ever sees it. Pointing it at an intranet address means giving up SSRF protection for those targets on purpose.syntheticDnsRanges- manual fallback for synthetic DNS: a comma-separated CIDR list of your proxy's fake-IP pool. Detection is the main path and should handle it; this is for the case where the resolver mixes synthetic and real answers. Invalid entries are dropped rather than refusing to load - this is a fallback, and a broken one degrades to "stay strict", not to a crash.proxyUrl- where fetches go out, e.g.http://127.0.0.1:7890orsocks5://127.0.0.1:7890, optionally withuser:pass@. Empty means follow the environment. It is a deployment-level capability and never enters a tool schema. The resolution order is spelled out under Proxies.proxyBypass- host names that should connect directly, mirroringNO_PROXY.
Deployment-grade knobs (not on the settings card; set them in the config: block of
cordis.patch.yml): requestTimeoutSeconds, maxUrlLength, browserExecutablePath,
browserCdpUrl, searchEndpoint, searchRenderTopN, crawlMaxRuntimeSeconds. The first
two of the browser pair are strong capabilities - one hands Chromium a file
to execute, the other hands the browser to a debugger endpoint - so they come only from
deployment config and are unreachable from model input.
Settings changes need a dsh restart. The gate policy and the sidecar environment are snapshotted once when the plugin is applied; edits on the settings card take effect on the next start.
Disabling a tool also removes its system-prompt guidance, so the model is never told about a capability that is not registered.
Proxies
Where a fetch goes out, resolved in this order - first hit wins:
proxyBypassor the environment'sNO_PROXYmatches this host -> direct.proxyUrlon the settings card is non-empty -> use it.http_proxy/https_proxy/all_proxyin the environment (upper case works too) -> use the matching slot.- The operating system's proxy settings (
scutil --proxyon macOS, Internet Settings on Windows) -> use them. - Neither -> direct.
Each rung exists because leaving it out loses a class of users. The settings card is
explicit intent; environment variables cover CI, ssh sessions and docker exec, where a
proxy is only ever exported in the shell; the OS settings are what proxy clients default
to on macOS and Windows - flip "system proxy" on in Clash Verge or Surge and every browser
on the machine is proxied, while curl_cffi has no idea system settings exist.
An explicit proxyUrl makes the last two rungs irrelevant: an explicit setting should
outrank an implicit source, otherwise "I set 7890 and it still goes through 1080" has
nowhere to be debugged from. The bypass list cuts across everything above it - a hit
connects directly no matter where the proxy came from.
What that looks like in practice - with or without a proxy, the tools just work, with no setting to fill in:
| Your setup | Static fetching | Render / stealth / XHR |
|---|---|---|
| No proxy | direct | direct |
| TUN mode (system-wide) | direct, forwarded by the TUN | same |
HTTPS_PROXY exported in the shell |
follows it automatically | follows it automatically |
| macOS / Windows "system proxy" switch | follows it automatically | follows it automatically |
| Intranet targets / internal Git | exclude with proxyBypass |
exclude with proxyBypass |
| PAC (auto-config script) | not followed; stderr says to set proxyUrl |
followed natively by the browser |
PAC is the one shape that still needs a hand-filled setting: evaluating it means downloading a script and running JavaScript, and the dialects differ per platform, so this plugin does not guess. When it sees one it says so on stderr instead of quietly connecting directly.
Details worth knowing:
A proxy and synthetic DNS are two different problems. A fake-IP proxy resolves every domain into
198.18.0.0/15(TUN clients often land in100.64.0.0/10); neither is a public unicast range, so the gate rejects every URL and turning a proxy on makes the plugin stop working. The plugin detects that class of resolver on its own: it resolves three control names unrelated to any target -example.com,www.iana.org,www.wikipedia.org- and if all of them land in one non-public pool while staying distinct, it treats the resolver as fake-IP and relaxes to the whole of the known synthetic pools - 198.18.0.0/15 (the clash / mihomo default) and 100.64.0.0/10 (some TUN clients). Private, loopback and link-local ranges are in neither pool, sohttp://192.168.1.10/,http://127.0.0.1/andhttp://169.254.169.254/stay blocked on a proxied machine.The control names only trigger that verdict; they never size the pool. That was learned the hard way: the three answers are hashed by name, so their spread is chance. Measured on a real machine they were .126/.134/.135 - a spread of 10, which sliced out 198.18.0.112..127.
www.iana.organdwww.wikipedia.orgthen failed their own gate, andgithub.com(.38) andopencode.ai(.61) were refused outright. Three samples carry no size information.syntheticDnsRangesis the manual fallback when detection fails (a self-built pool outside the two ranges above). When detection does not fire, the gate stays strict; an IP literal written into the URL is still judged strictly.Through a proxy, a host name this machine cannot resolve is no longer rejected. The request never leaves this machine's network namespace, the proxy resolves the name, and a local resolver coming up empty says nothing - while on a network with DNS filtered by region, those are exactly the sites that need a proxy to be reachable at all. IP literals, private answers the local resolver can produce, and single-label host names (
intranet,router) stay rejected.WebRTC leaks are closed when proxied. Chromium's WebRTC can send UDP around an HTTP/SOCKS proxy, so one STUN call in a page is enough to learn the visitor's real exit address. patchright gets
block_webrtc; Playwright has no equivalent switch, so it goes in as a Chromium launch flag.A browser session picks its proxy once, at
session_open, and the whole session shares it. The bypass list therefore does not apply per URL inside a browser session: Playwright's proxy lives on the BrowserContext, and switching it means a new context, which throws away the session's cookies and login state - precisely the reason a session exists. Static sessions have no such limit and decide per request.Credentials never reach a log or the capability frame. The capability report echoes
scheme://host:portonly, and itssourcefield names which rung answered (settings/environment/system/none) - that column is the first thing to look at when someone reports that fetching does not work.A malformed proxy address does not stop the plugin from loading. A scheme outside
http/https/socks4/socks5/socks5his logged to stderr and treated as "not configured", rather than thrown out as a load failure.
Render or static: how to choose
scrapling_render is much slower and heavier than scrapling_fetch, so start static.
Two criteria:
If the result is shorter than the static fetch, go back to static. Plenty of sites
ship readable text from the server (for search engines and no-JS users) and then have their
own script empty those containers and swap in canvas / Lottie animation - for those pages
rendering can only lose content, and networkIdle, waitSelector, mainContentOnly and
headless all fail to bring it back: in the rendered DOM those containers are simply empty.
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 that content is
in the result.
One thing that is not a criterion but worth knowing: axis ticks inside SVG cannot be
fetched at all. After Chromium renders, the markdown conversion emits no <svg> or
<text> nodes, so "the numbers on the chart" are out of reach whichever tool you use - the
copy on an example card is server-rendered and was already there statically, it is not a
rendering product. For actual values, go through scrapling_capture_xhr to locate the
JSON endpoint.
Security notes
SSRF. Scrapling itself has no SSRF protection. This plugin adds a URL policy on the host side (scheme allowlist, credential rejection, length cap, rejection of an authority containing a backslash or control characters, DNS resolution with a whole-answer-set public-address check, and IPv4-mapped / NAT64 addresses reclassified by their embedded IPv4) and enforces it again on the Python side. What is judged is what gets sent: the gate decides on the WHATWG-normalised form and the sidecar forwards that same normalised string, otherwise "judged A, connected to B" becomes a bypass in its own right. The browser-side guard is registered by the plugin's own code and covers navigation, subresources, XHR and WebSocket handshakes.
Known gaps, all stated in the open:
- Connection pinning (an undici-level technique) is unavailable for a non-Node
transport, leaving a TOCTOU window between validation and connect. Static fetches are
covered by curl_cffi's
follow_redirects="safe"; browser fetches by a route handler on the Python side. - NAT64 prefixes are not discovered dynamically. dsh's built-in probes the active
prefix via the
ipv4only.arpasentinel; this plugin covers the two standardized prefixes (64:ff9b::/96,64:ff9b:1::/48). Declare any operator-specific prefix via thenat64Prefixessetting, otherwise a private IPv4 hidden inside IPv6 is judged as an ordinary public IPv6. Conversely, a declared prefix applies to every IPv6 address, not only the ones inside the RFC 6052 segment. - No port allowlist (any port allowed by default), matching dsh's own built-in fetch provider.
scrapling_crawlre-checks addresses itself, but the start-URL gate is still the first line. The start URL goes throughguard.url_allowed(the same implementation the browser path uses, so both layers agree), the crawl session setsfollow_redirects="safe"so curl_cffi refuses to follow a jump into a private range, and each page is re-checked twice before it enters the result set: once against the guard (the final URL may still have landed somewhere private) and once against the start domain - Scrapling'sallow_domainsis a string comparison that covers links found in the page and not redirect targets, so an off-site jump on a public host has to be caught here.- WebSocket coverage depends on the Playwright version. The guard intercepts
handshakes via
route_web_socket. Older builds lack that method, WebSocket is then outside the guard, and the plugin says so in the log and in the capability report rather than degrading silently. - Browser binary path and CDP endpoint are deployment-grade capabilities. They come only from deployment config and are unreachable from model input; pointing at them hands over "run this file" and "attach the browser to this debugger".
The host-side policy is enforced by
SidecarClient.call, so it covers every tool and theweb_fetchprovider alike; a rejection throwsURL_BLOCKEDbefore the request is written to the pipe. Theweb_searchprovider runs the same gate over the URLs it is about to hand back: a hit the gate rejects never becomes a citeable source, so the model is never offered an address the next fetch would refuse.- Connection pinning (an undici-level technique) is unavailable for a non-Node
transport, leaving a TOCTOU window between validation and connect. Static fetches are
covered by curl_cffi's
Browser engines ignore proxy environment variables. Playwright does not read
HTTP_PROXY/HTTPS_PROXY, soscrapling_render/scrapling_capture_xhr/scrapling_stealth_fetchconnect directly even when the rest of the system is routed through a proxy — a browser-bound request leaves the tunnel. curl_cffi (behindscrapling_fetchand the crawler) does honour those variables, so the two halves behave differently on a proxy-bound network. This is stated rather than papered over: routing the browser through a proxy means handing the plugin a deployment-grade capability, and shipping that without a way to check it against a live proxy would be worse than saying so.allowSyntheticDnsabove only fixes the gate; it does not change where traffic goes.There are quotas; the resident resources are not unbounded. At most 8 concurrent sessions (of which at most 3 may be
browser/stealth, each a real Chromium process), at most 2 crawls in flight, per-page bodies clipped atmaxContentChars, 8 MB per protocol frame, and 16 frames of pending queue. Hitting a ceiling yields a specific error code (SESSION_LIMIT/CRAWL_BUSY/BAD_FRAME) the model can back off from, rather than exhausting the machine.No code execution from the model.
page_action,page_setup,selector_config,proxy,executablePathandcdpUrlare never exposed in any tool schema. The browser-side network guard is installed by plugin code, never by model input.Prompt-injection scrubbing runs unconditionally on returned content; there is no model-facing switch to turn it off.
Development
pnpm install # install dependencies
pnpm run check # typecheck + lint + build + test + format check, TS *and* Python
pnpm run build # bundles host.ts / src/client-entry.ts into host.js / client.js
pnpm run test:py # end-to-end check of the Python sidecar (needs _env/.venv)
The Python half is gated too. The four .py files ship with this package
(package.json's files lists them one by one) and run on the user's machine, so they
get the same treatment as host.ts: pnpm run check runs ruff check and
ruff format --check over py/, and fails hard when the tools are missing rather than
skipping quietly. Install them into whichever interpreter python3 resolves to:
pip install -r py/requirements-dev.txt
Two more commands measure rather than gate:
pnpm run typecheck:py # mypy over py/ (currently clean; why it stays advisory is below)
pnpm run cov:py # coverage for the four shipped modules + the test script
Run both with an interpreter that has Scrapling installed (the same one test:py uses).
That is not decoration: mypy's findings depend on it. From an interpreter without
Scrapling every Scrapling type degrades to Any and the check reports nothing at all.
mypy stays advisory because this side is duck-typed on purpose — Playwright's page,
route and websocket have no shape until runtime — and a strict profile would buy a
pile of ignore comments that exist only to silence it. cov:py is advisory because its
honest number needs the subprocess wired in: bridge.py and the adapters methods live
in the sidecar child, so test_bridge.py spawns that child under coverage run when
DSH_PY_COVERAGE=1. Without it the report reads adapters.py 20% while none of its fetch
paths have been counted at all — a number that measures nothing. Current shape:
adapters 65%, bridge 37%, crawl 89%, guard 85%.
Sources are all TypeScript. host.js and client.js are build artifacts and are
deliberately .js: Node refuses to strip types for packages inside node_modules,
which is exactly where dsh loads the plugin from.
The artifact is ESM-only: require() will not get you the plugin. That is deliberate
(the host loads it by package name), not a defect.
At build time every package declared in dependencies, peerDependencies or
devDependencies is externalised. The ones the host provides at runtime (cordis,
dsh-tools, dsh-subprocess, dsh-jobs) must not be inlined: the bundle would otherwise
carry its own private copy, splitting module-level state, and for packages that resolve
their own package.json relative to import.meta.url it would fail to load outright with
MODULE_NOT_FOUND. test/publish-manifest.test.ts imports the built artifact and asserts
that no host package body is inlined, so both halves stay pinned.
Publishing runs through .github/workflows/publish.yml
(manual, OIDC trusted publishing plus --provenance).
License
MIT — see LICENSE. Where this package stops and third-party code begins:
THIRD_PARTY_NOTICES.md.