Notes for anyone running or reviewing this locally. This file is excluded from
the packaged .difypkg — it is not part of the published plugin.
A Dify Tool plugin wrapping the Zenrows API. Five tools: Fetch, Extract, and Batch create / status / results.
It talks to the REST API directly rather than using the zenrows Python SDK.
That is not a preference — Extract and Batch exist on the SDK's main but were
never published, so PyPI still serves 1.4.0 from Nov 2024. Every HTTP call is
contained in tools/client.py so the swap is one file when the SDK ships.
Tracked in ACT-1598.
manifest.yaml plugin metadata, declared outbound hosts, version
provider/zenrows.yaml credential schema, and the list of tool yamls
provider/zenrows.py credential validation on save
tools/<name>.yaml one per tool: parameters, labels, LLM descriptions
tools/<name>.py one per tool: parameter handling and the response shape
tools/client.py every HTTP call the plugin makes, in one place
utils/errors.py the Zenrows error taxonomy and the exceptions Dify shows
utils/batch.py batch job polling, status vocabulary, run summaries
Two rules worth keeping. All HTTP goes through tools/client.py — that is what
makes the eventual SDK swap a one-file change. And all error interpretation goes
through utils/errors.py, so a tool never decides for itself what an API failure
means.
Each tool is a yaml/py pair and both must be listed in provider/zenrows.yaml
under tools:. Adding a tool without listing it there silently does nothing.
- Python 3.12+ (
dify-plugin>=0.9.0requires it; the scaffold's>=3.11was wrong) uv- The Dify CLI —
brew install langgenius/dify/dify
The plugin runs on your machine and registers itself into Dify over a debug connection. There is no hot reload: every change needs a restart, and YAML changes need the tool list re-checked (see Gotchas).
-
Get a debug key: in Dify, Plugins → Debugging. That panel shows the host, port and key.
-
Copy
.env.exampleto.envand fill in:INSTALL_METHOD=remote REMOTE_INSTALL_URL=debug.dify.ai:5003 REMOTE_INSTALL_KEY=<key from the Debugging panel> -
Install and run:
uv sync --python 3.12 uv run python -m main
Success is one line,
Installed tool: zenrows, with nothing after it. Ahandshake failed, invalid keyloop means the key has been rotated — get a fresh one from the Debugging panel. -
In Dify, Integrations → Tool Plugin. The card should read Zenrows 0.1.0 and be marked
DEBUGGING PLUGIN, with 5 actions listed. Add a tool to any workflow, paste a Zenrows API key when prompted, and run the node.
cd ..
dify plugin package ./zenrows -o zenrows-0.1.0.difypkg
git clone https://github.com/langgenius/dify-marketplace-toolkit
brew install yq
python3 dify-marketplace-toolkit/validator/validate-difypkg.py \
zenrows-0.1.0.difypkg \
--pr-body-file pr-body.md \
--output-dir validation-reportExit 0 and "Blocking failures: 0" is the bar. --pr-body-file is not optional:
without it the sensitive-capability disclosure check is skipped, and that one is
blocking when it runs for real on the submission PR.
Debug mode is the fast loop and is right for almost everything. Install the
packaged plugin when you want to test the artifact as it will actually ship —
Dify runs it in its own runtime rather than as a process on your machine, which
is a different path for anything binary (screenshot, PDF) and for the presigned
storage downloads in batch_results.
Stop the debug process first. Both register as zenrows/zenrows. If the
debug connection is live you will either get a conflict or silently test the
wrong one, and the run panel will not tell you which.
Ctrl-Ctheuv run python -m mainprocess.- In Dify: Integrations → Tool Plugin → Install → Local Package File, and
pick the built
.difypkg. Accept the unverified-plugin warning — that is expected for anything not installed from the marketplace. - Check the badge on the plugin card.
LOCAL PLUGINmeans you are running the package;DEBUGGING PLUGINmeans you are still on the debug connection. That badge is the only reliable way to tell which build a run used.
To go back to debug, uninstall the local plugin first, then start the process again. Same collision, opposite direction.
Note that Dify may refuse to install the same version number over itself. If you
need to iterate on an installed build rather than in debug, bump the version in
manifest.yaml, pyproject.toml, tools/client.py and README.md — or just
use debug mode, which is what it is for.
.github/workflows/plugin-publish.yml came from the scaffold and has never been
run. Do not cut a GitHub release expecting it to publish anything. As generated
it pins the Dify CLI at 0.0.6, runs on depot-ubuntu-24.04, checks out a
zenrows/dify-plugins fork that does not exist, needs a PLUGIN_ACTION PAT
secret we have not created, and pushes a branch named
bump-<name>-plugin-<version> while telling gh pr create the head is
<author>:<name>-<version> — that mismatch alone makes PR creation fail.
It is kept because it is a reasonable starting point if we later want to
automate submission. Until someone fixes it, submission is manual: package,
validate, fork langgenius/dify-plugins, add the plugin under
<Author>/<plugin-name>/, open a PR.
uv run python tests/test_units.pyNo framework, no dependencies, exits non-zero on failure. Excluded from the package.
It deliberately covers a narrow set: every function in it shipped a real bug
during development — string booleans read as truthy, a default-on parameter
that defaulted off, the AUTH010 overloading, and the status filter. It is a
regression net for the things that have actually broken, not a coverage
exercise. Anything you touch in utils/errors.py should be pinned here too.
Tool behaviour beyond that is verified by running the plugin against the live API in a Dify workflow — see Verified below.
A malformed tool YAML takes down the whole plugin, not just that tool. Dify
rejects the entire declaration, nothing registers, and the connection drops with
no useful error. An empty value: on a select option is enough to trigger it.
yaml.safe_load parses it fine and the marketplace validator does not catch it.
After any YAML change, restart and confirm all five actions still list.
Dify sends booleans as strings. An unticked toggle arrives as "0" or
"False", both truthy in Python. Everything goes through as_bool() in
utils/errors.py. Using a raw truthiness check would silently enable premium
proxy on every call.
AUTH010 means two different things — "your plan doesn't include Extract"
and "Extract isn't enabled for this domain yet". Only the second is safe to
recover from by falling back to autoparse; falling back on the first silently
downgrades every call and never tells the user to upgrade. They are only
distinguishable by the detail text. is_domain_scoped_extract_restriction()
mirrors ScraperApiException::isDomainScopedExtractRestriction in the Zenrows
app — if that logic changes upstream, change it here too.
Our caps are deliberately not the SDK's. The SDK defaults to 100,000 files at 50 MiB and waits up to 300s, which suits a CLI writing to disk. Dify kills an invocation at 120s and the result goes into a workflow variable, so this plugin uses 25 results (raisable to 200), 1 MiB per body, and a 100s wait budget.
Against the live API in a Dify workflow:
- Credential validation, including a bad key
- Fetch: Markdown, JavaScript rendering with wait-for-selector, screenshot
- Extract: all four methods; the empty-result case returns
empty: trueand an explanation rather than failing or returning silence - Batch: 3-URL and 30-URL jobs end to end;
max_resultsboundaries, both URL separators, mixed valid and invalid URLs, the 1000 cap - Errors: 401 bad key, 404 unknown job, REQS001 blocked domain
- Adaptive Stealth Mode, both directions, against
scrapingcourse.com/antibot-challengewithjs_renderandpremium_proxyboth off: on, the challenge is bypassed in 25s; off, it fails in 1.1s with the REQS002 the SDK docstring warns about batch_resultsfetches bodies in parallel — 25 bodies, 13.47s to 3.72s- Installed as a packaged
.difypkgon Dify Cloud (not debug mode) andbatch_resultsrun withinclude_content— content came back, so the presigned-storage fetch is not blocked andnetwork.domainsis not enforced as a hard egress allowlist. Note this was a sideloaded local plugin; a marketplace-verified one could in principle run under a different policy.
The AUTH010 fallback has never fired live. The gate matches on target domain
or caller email, and the account used for testing is on the allowlist, so
method: auto returned real data on four unrelated domains. The decision logic
is unit-tested; the HTTP round trip through it is not.