Repository navigation
feat(crawl): add the zenrows crawl command and its acceptance test - #31
Merged
Merged
Conversation
Crawl takes one start URL, follows the links on each page up to `depth` on the start URL's domain, and returns the URLs it keeps, optionally with each page's HTML. The new `crawl` command drives https://api.zenrows.com/v1/crawls with the X-API-Key header: - start <url> --depth N [--max-items N] [--max-pages N] [--include P]... [--exclude P]... [--html] [--follow|--wait] [--timeout ms] [--idempotency-key K] - status, wait, results (all pages, one page via --cursor/--limit, or the NDJSON export via --download), content, list, stop src/core/crawl-api.ts is the client (base override ZENROWS_CRAWL_API_BASE, otherwise the configured apiBase). Error responses map to new codes: CRAWL_NOT_ENABLED (403 REQS008), CRAWL_NOT_FOUND (404), CRAWL_QUOTA_EXCEEDED (429 too_many_crawls), CRAWL_INVALID (400/409/422), CRAWL_FAILED (a failed crawl or another error) and CRAWL_TIMEOUT (wait gave up; the crawl keeps running). The guards that asserted `crawl` is an unknown command and that no crawl skill exists are narrowed to `discover` and `monitor`. Adds the `crawl` capability, a crawl skill, README and docs sections, unit tests, and an opt-in e2e test (`npm run test:e2e`) that drives the built CLI against a live API.
Mark the crawl capability and skill as beta. Say Beta in the help, the README and the skills. Map Crawl errors to codes an agent can act on. 429 is now CRAWL_TOO_MANY_CRAWLS with a retry_after field. 400 and 422 are CRAWL_INVALID_REQUEST. 409 is CRAWL_REQUEST_IN_FLIGHT. A missing page is CRAWL_CONTENT_NOT_FOUND. A wait timeout is CRAWL_WAIT_TIMEOUT. Every error carries the API code as server_code, and crawl_id once the crawl exists. Keep --wait and remove --follow. Ctrl-C during a wait now says that the crawl keeps running, gives the resume command and exits 130. results returns what a running crawl kept, with partial: true. results --out --json prints the JSON envelope. stop on an ended crawl prints the status it ended with. The help and docs say that the local policy caps --max-pages and that --html uses up the page budget.
fetch and extract use --wait <ms> for a different meaning, so a boolean --wait on crawl start can confuse people. crawl start now takes --follow only. The crawl wait subcommand stays.
Rename the subcommands to the shared operation names: create, get, list, results, content, download, stop and wait. start and status are gone, and results --download is now download. Rename the flags to the API names: --include-pattern, --exclude-pattern and --output-format html. get and list take --cursor and --limit. results takes --limit as the page size and follows every page. create takes --follow to wait for the end. --timeout is in seconds, default 600. A wait that runs out is not an error. It exits 0, prints the crawl with status running and the hint to wait again. CRAWL_WAIT_TIMEOUT is gone. A 402 is now CRAWL_QUOTA_EXCEEDED, or CRAWL_KEY_CAP_REACHED for AUTH014. The messages keep the claim, top-up and key-cap advice. download reports status and partial. results reports partial when the crawl still runs or a cursor remains.
Every --json key is now snake_case: crawl_id, content_id and
content_type replace crawlId, contentId and contentType. The run
artifact keeps crawlId, as Batch keeps jobId.
create, get, wait and stop print {ok, crawl_id, status, crawl}. get
adds results and next_cursor. stop_reason is no longer at the top
level; read it under crawl.
A wait that runs out with the crawl still running now adds note under
--json, with the command to wait again.
download prints status: null when the API sends no X-Crawl-Status.
The help, the README and the crawl skill describe the JSON shape.
Crawl calls now retry up to 3 times. The wait is the Retry-After seconds, or 250 ms x 2^n with +/-20% jitter, capped at 10 s. - GET requests retry on 429, 502, 503, 504 and network errors, including the per-request timeout. - create retries only with an Idempotency-Key, and only on 502, 503, 504 and network errors. It never retries 429. - stop never retries. Batch and other commands do not change. The README and the crawl skill describe the policy.
Crawl copy now says New, not Beta. The crawl capability and skill have status available. The README Crawl section and the capability notes carry one line that says Crawl is still evolving. Batch and Extract wording does not change.
…port a stopped crawl as complete
- stop retries a 503 crawl_busy (the stop was not saved; the crawl still
runs) up to 3 times, waiting Retry-After, then fails with CRAWL_BUSY and
retry_after. A plain 503 is still not retried. This matches the three
SDKs and the MCP server.
- Every retry waits at most 30 s, whatever Retry-After asks for.
- results on an ended crawl reports partial: false, so a stopped crawl
whose pages are still settling is not reported as incomplete.
- --depth, --max-items and --max-pages no longer reject 0 locally; the
API answers 422 and the CLI reports CRAWL_INVALID_REQUEST.
- Error causes no longer end in a doubled period ("account.. The id").
--include-pattern and --exclude-pattern become --include-patterns and --exclude-patterns, matching include_patterns / exclude_patterns in the Crawl API, the SDKs and the MCP server. Each flag still takes one substring and repeats. The crawl command has not shipped, so no alias keeps the old spelling.
acceptance/crawl/ builds the release artifact from this checkout, installs it in a clean container the way a new user would, and runs every Crawl feature against the live API: README example, create, wait, results, content, download, stop, errors, naming and docs. One JSON result per check and a markdown report land in acceptance/crawl/out/ (git-ignored). It is kept out of the published package.
A 402 for lack of credit is CRAWL_INSUFFICIENT_CREDITS, after the Crawl API's insufficient_credits, instead of CRAWL_QUOTA_EXCEEDED. The MCP server uses the same name. CRAWL_KEY_CAP_REACHED is unchanged and already covers the API's api_key_cap_reached.
The naming check now covers content_id, the argument of the content operation, so a client that names it differently fails check 17. The MCP surface calls crawl_content with content_id.
…al before the API as a pass on check 14 Check 9 read content from the crawl check 20 stops; a stop that lands before any page is fetched left nothing to read. It now falls back to a one-item html crawl that runs to the end. Check 14 passes when the client refuses the gated output format before calling the API, as well as on the API's 422.
StefanoGuerrini
force-pushed
the
feat/crawl-command-v2
branch
from
October 12, 2026 14:09
1e0ff65 to
86d3e57
Compare
zenrows crawl for the Crawl APIzenrows crawl command for the Crawl API
zenrows crawl command for the Crawl APIzenrows crawl command and its acceptance test
The download held the whole NDJSON export, page HTML included, in memory before it wrote the file. It now streams into <file>.part, counts lines as they pass, and renames the file into place, so a failed export keeps any earlier file. A content id with a bad % escape threw a raw URIError; it is now INVALID_USAGE.
…ilient realpath --relative-to is GNU-only, so on macOS the exclude pathspec was empty and every run was tagged +local-changes. The python and go branches had no builder in this repo. Clean-up now records a crawl it cannot reach and goes on, and retries a stop answered 503 crawl_busy. The key scan skipped any path with /build in it; it now skips only out/build.
1 task
…d stop retries on 429, 502, 503 and 504 Folds the two stacked changes into this branch so the crawl command ships as one pull request: - --depth is optional and unbounded; --max-pages and --max-items default to 100 - crawl stop retries 429, 502, 503 and 504, waiting Retry-After up to 30 s, or 1 s when it is missing or unusable
The Crawl API sends 429 only on create, with the code too_many_crawls. Any other 429, on a stop or a read, now maps to CRAWL_RATE_LIMITED with retry_after, instead of naming the active-job limit and "Nothing was created".
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
This PR adds
zenrows crawl, a command group for the new Crawl API (https://api.zenrows.com/v1/crawls). Crawl follows links from one start URL, on the registrable domain of that URL.depthis optional: with it the crawl stops after that many link hops; without it there is no depth limit, andmax_pagesormax_items(both default 100) stops the crawl. It returns the URLs it keeps, with optional page HTML.stopretries 429, 502, 503 and 504, not only a 503crawl_busy: up to 3 retries, each waitingRetry-After(at most 30 s) or 1 s. The CLI uses the same operation and parameter names as the Node, Python and Go SDKs and the MCP server. The PR also adds an acceptance test that installs the release artifact in a clean container and runs every Crawl feature against the live API.This PR also carries #33 and #34, folded into this branch.
Squash commit message
Squash-merge with this message: the branch commits carry trailer lines that should not land.
This PR replaces #30. The review of the first version is on #30. The code changed after that review, and this description matches the current head. This PR also carries #32, merged into this branch.
What changes
Commands
create--depth(1 to 100000) is optional: without it the CLI sends nodepth, the crawl has no depth limit, and the crawl it prints has nodepthfield.--max-itemsand--max-pagesdefault to 100 in the API.--include-patternsand--exclude-patternsare repeatable substring filters that map toinclude_patternsandexclude_patterns.--output-formattakes onlyhtml; each kept page is then one more fetch that--max-pagescounts.getnext_cursor.listresults--limitis the page size. A running crawl gives the URLs kept so far withpartial: true. An ended crawl is complete, also a stopped one withnext_cursorstill set.contentcontent_idor the wholecontent_url. A ref with a bad%escape fails withINVALID_USAGEbefore any request. Under--jsonit printscontent_id,content_type,bytes, andcontentor, with--out,file.download<file>.partand renames it to--out(default<id>.jsonl), so the export never sits whole in memory. A failed export keeps any earlier file. It reportsstatusfromX-Crawl-Status(nullwhen absent) andpartial.stopcreate --follow,waitlimit=1, from 2 s, x1.5, capped at 15 s, until the crawl ends or--timeoutruns out (default 600 s).When the wait runs out, the command exits 0. It prints the crawl with status
runningand the hintzenrows crawl wait <id>; under--jsonthe hint is innote. A crawl that endsfailedexits 1. Ctrl-C stops the wait, not the crawl, prints the resume hint, and exits 130.JSON output
Every subcommand prints JSON with
--json, in the{ok, ..., error}shape of the other commands. All keys are snake_case, as in the MCP server.create,get,waitandstopprint{ok, crawl_id, status, crawl}, and the crawl object holdscoverage,stop_reasonanderror.getaddsresultsandnext_cursor. Withresults --out --json, the envelope carriesfileinstead ofresults. The run artifact keepscrawlId, as Batch keepsjobId.Client and retries (
src/core/crawl-api.ts)Retry-After, else 250 ms x 2^n (+/-20% jitter, max 10 s)createwith--idempotency-keycreatewithout a keystopcrawl_busyincluded), 504Retry-After, else 1 sEach request retries at most 3 times. The client caps
Retry-Afterat 30 s, so a large server value cannot hang a command. A stop is safe to repeat: a stop on an ended crawl answers 200 with its status. Acrawl_busymeans the server did not save the stop, and the crawl still runs. A stop error that remains after the retries maps as the error table says. Adownloadthat breaks mid-stream retries as any GET, and the retry writes the file and the count again from the start. An error from writing the file gets no retry. Batch does not change.Error codes
CRAWL_NOT_ENABLEDREQS008AUTH_INVALIDCRAWL_KEY_CAP_REACHEDAUTH014orapi_key_cap_reachedCRAWL_INSUFFICIENT_CREDITSCRAWL_INVALID_REQUESTidempotency_key_reused, send a new key or no keyCRAWL_REQUEST_IN_FLIGHTCRAWL_NOT_FOUND,CRAWL_CONTENT_NOT_FOUNDCRAWL_TOO_MANY_CRAWLStoo_many_crawls, which the API sends only on create; active-job limit (3 by default, shared with Batch), withretry_afterCRAWL_RATE_LIMITEDretry_afterCRAWL_BUSYcrawl_busyafter the stop retries, withretry_after; running the stop again is safeCRAWL_FAILEDfailed(advice keyed onerror.code, such asseed_unreachableorno_items_found), any other 403, or another error, such as a 502, 504 or other 503 that remains after the retriesBACKEND_UNAVAILABLEEvery error carries the API code as
server_codewhen the API sends one. A call about one crawl also carriescrawl_id.Policy, docs and skills
createchecks the domain policy andmax_pages_per_run(default 1000) before any network call, and writes a run artifact. An unset--max-pagescounts as the API default of 100, so a policy below 100 refuses it withPOLICY_LIMIT_EXCEEDED. The help says that this local policy caps--max-pages.The PR adds the
crawlcapability and acrawlskill, bothavailable, with a registry entry. It adds crawl lines to thezenrowsandtrace-debugskills, a read-me (README) section and table row, and adocs/capabilities.mdrow. The help, README and skills call Crawl "new", with one line that says Crawl is still evolving.Test guards changed on purpose
tests/production-surface.test.tscrawlis an unknown commanddiscoverandmonitor; the registry listscrawltests/registry.test.tscrawlskill existstests/capabilities.test.tscrawlstatus toavailableAcceptance test (
acceptance/crawl/)run.shinstalls the release artifact (npm install -gof thenpm packtarball) in a clean container with digest-pinned images. The container gets no source, no build caches and no tokens, and it gets the key by name only. The run follows the README Crawl quickstart, then runs checks 1 to 21 and the CLI extras. Each check writes one JSON result:PASS,FAILorSKIP.acceptance/crawl/README.mdgives the expected results. The offline half of check 1 also creates without--depthand checks that nodepthis sent. Check 21 expects the documented default of 100 formax_pagesandmax_items; it passes once the API makesdepthoptional and moves those defaults to 100. The report verdict is "ready" when it has no blocker or major finding.The CLI extras check
--jsonon every subcommand, the Ctrl-C exit 130 with the resume hint, and that--waitand--htmlare unknown flags. The harness also runs on macOS. Thefileslist inpackage.jsonis an allow-list withoutacceptance/, so the published package does not contain the harness.depth1,max_pages10 or less, except check 19's crawl, which leavesmax_pagesandmax_itemsat the API default (100) for check 21 and fetches one pageRetry-Afterand tries againcrawl_busy, and records a crawl it cannot reachout/for it before it endsTests
npm run typecheckandnpm testcover the unit tests.tests/crawl-api.test.tsRetry-After, a stop that gives up after 3 retries with the mapped error, a 429 withouttoo_many_crawlson stop that maps toCRAWL_RATE_LIMITED, and a 500 stop with no retry; a create withoutdepththat sends none; aRetry-Afterabove 30 s that waits 30 s;server_codeandcrawl_id; paging on running, ended and stopped crawls; the end and timeout of a wait; the streamed download, its line count, and a restart after a mid-stream failuretests/crawl-command.test.ts--depththat sends onlyurland prints a crawl with nodepth; an unset--max-pagescounted as 100 againstmax_pages_per_run; a create 429too_many_crawlsasCRAWL_TOO_MANY_CRAWLSand a stop 429 without it asCRAWL_RATE_LIMITED; rejection, before any request, of removed flags (--html,--include,--exclude,--wait,results --download,results --cursor) and removed subcommands (start,status); flag validation; JSON keys; the--out --jsonenvelope; a wait that runs out; Ctrl-C during a wait; a failed download that keeps the earlier file;--outinto a missing directory; a content id with a bad%escapetests/e2e/crawl.e2e.test.tsnpm run test:e2e; skips whenZENROWS_E2E=1,ZENROWS_API_KEYorZENROWS_E2E_CRAWL_URLis missing;docs/contributing.mdhas a "Running the Crawl e2e test" sectionThe e2e test passed once against a live API deployment on the first commit. The test now uses the new subcommands and flags. Nobody ran it again after these changes.
How to try it
With a key that has Crawl access,
create --followprints the ended crawl, andurls.jsonlholds the kept URLs. A key without Crawl access getsCRAWL_NOT_ENABLED. The acceptance report isacceptance/crawl/out/crawl-first-install-report.md.