Repository navigation
feat(crawl): add the Crawl client, exposed as client.crawl - #34
Closed
StefanoGuerrini wants to merge 1 commit into
Closed
StefanoGuerrini wants to merge 1 commit into
StefanoGuerrini wants to merge 1 commit into
Conversation
ZenRowsCrawlClient covers the Crawl API: create, get, list, iterCrawls, iterResults, stop, getContent, download (one parsed record per line) and wait. Errors throw ZenRowsCrawlError, which carries retryAfter and names REQS008 as "Crawl is not enabled for this account". The shared HTTP transport takes an optional error factory; existing callers keep their default. Adds unit tests with msw, an opt-in e2e test against a live API (`pnpm test:e2e`, skipped unless ZENROWS_API_KEY, ZENROWS_CRAWL_BASE_URL and ZENROWS_E2E_CRAWL_URL are set), and README docs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
StefanoGuerrini
commented
Oct 9, 2026
StefanoGuerrini
left a comment
Contributor
Author
There was a problem hiding this comment.
Crawl Beta public-surface review
This review checks the public API against what production serves to a crawl-beta customer today: Crawl 475016a (docs/openapi/api.yaml) and gateway e382348. crawl-beta is at 100%. crawl-beta-extract (output_format: json, discovery: pagination) is staff-only. Reviewed at 54f85f1: pnpm build is clean, and pnpm test -- --run passes 304 tests with no type errors.
Verdict: changes needed. Items 1 to 5 become breaking changes if they ship as they are.
Fix before merge: breaking if shipped
- No Beta marking. The README labels Extract and Batch "(beta)", but not Crawl. Add "Crawl (beta)" to the README section and
@betato each Crawl export. download()dropsX-Crawl-Status(src/crawl/client.ts:169). It returns a bare async generator, so a caller cannot tell a partial download (crawl stillrunning) from a full one. Return an object that carries the status and the lines. Adding the status later changes the return type.Crawlhas nodiscoveryand noextract_calls(src/crawl/types.ts:26). Production always returnsdiscovery. Adddiscovery: Extensible<"links" | "pagination">[]andextract_calls?: number. Adding a required field later breaks every caller that builds aCrawl, such as test mocks.CrawlExportLine.contentisstring(src/crawl/types.ts:78). Production sends an object forjsoncrawls, andGET /crawlslists staff crawls in the same org. Type itstring | Record<string, unknown>now; widening it later breaks callers.codefalls back to"internal"(src/crawl/errors.ts:38). Production usesinternal_error, so callers can branch on a value the API never sends. Leavecodeundefined when the body has no code.
Fix before merge: behaviour and docs
- A keyed create retries 429
too_many_crawls. The shared transport retries 429 up to 3 times and sleeps for eachRetry-After. The README says the create throws withretryAfter.too_many_crawlsis a capacity limit, so do not retry it on create. Keep the retries on 5xx and network errors. wait()defaults to 300 s (src/crawl/client.ts:200), but MCP and CLI use 600 s. All surfaces will use one value; it is not decided yet.CrawlErrorCodelacksdomain_not_allowedandinternal_error(src/crawl/types.ts:8).- The README's 429 sentence is not correct. The limit is the account's active jobs: 3 by default, shared with its Batch jobs. The error list also leaves out 400
unknown_parameter, 409idempotency_request_in_flightand 422idempotency_key_reused.
Matches production
create()takesurlanddepthas required,outputFormat: "html"only, and nodiscovery. It sends only the fields the caller set.- Response enums are open, unknown fields pass through, and
iterCrawls()ends on an absentnext_cursor. wait()returns afailedcrawl and does not throw. A timeout leaves the crawl running.ErrorFactory,crawlErrorandcontentIdOfstay out of the public types.- The README says a crawl stays on the start URL's domain. Production enforces this by registrable domain, so subdomains count (
urlx.InScope).
Can wait
These are additive and need no change now:
client.crawlonZenRowstakes nobaseURLorretries.getContent()has nodownload=trueoption.- Errors do not expose the
RateLimitheaders.
Release: minor bump (2.2.0) in the release commit.
StefanoGuerrini
marked this pull request as draft
October 9, 2026 10:20
Contributor
Author
|
Replaced by #35: the review fixes, with a history that drops the AI co-author trailer. The review on this PR still applies. |
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.
What this adds
A client for the Crawl API (one start URL in, the URLs behind it out), as
client.crawlonZenRowsand standalone asZenRowsCrawlClient(apiKey, { baseURL?, retries? })(default basehttps://api.zenrows.com/v1). Exported from the package entry.create({ url, depth, maxItems?, maxPages?, includePatterns?, excludePatterns?, outputFormat?: "html" }, { idempotencyKey? })POST /crawls, sends only the fields setget(id, { cursor?, limit? })GET /crawls/{id}list({ cursor?, limit? })/iterCrawls({ limit? })GET /crawls, followingnext_cursoriterResults(id, { limit? })next_cursoruntil null; on a running crawl it yields what is kept so far and returns instead of spinningstop(id)POST /crawls/{id}/stopgetContent(id, contentIdOrResult)→stringGET /crawls/{id}/contents/{content_id}, taking the id from a result'scontent_urldownload(id)→ async iterator of parsed NDJSON linesGET /crawls/{id}/download, streamedwait(id, { timeout?, pollInterval?, maxPollInterval? })get(id, { limit: 1 })until the status is notrunning; starts at 2s, ×1.5, capped at 15s, 300s timeout;WaiterTimeoutErroron timeout, the crawl keeps runningInputs are camelCase and responses keep the API's snake_case. Response enums are
Extensible<...>; unknown fields are tolerated. A failed crawl carrieserror.codeanderror.detail.Errors throw
ZenRowsCrawlError(status,code,problem,extras, plusretryAfterfromRetry-After). A 403REQS008reads "403 Crawl is not enabled for this account (REQS008): …";CRAWL_NOT_ENABLED_CODEis exported to branch on. Requests sendX-API-Key; idempotent requests retry on 429/502/503/504 and network errors. A create without anidempotencyKeyis not retried, so a 429too_many_crawls(the account has too many crawls running) reaches the caller withretryAfter.The shared HTTP transport gains an optional error-factory argument so the Crawl client throws its own error type; existing callers keep their default and their public API is unchanged.
Tests
tests/crawl-client.test.ts(msw): request shape (path, method,X-API-Key,Idempotency-Key, body omits unset fields), parsing, unknown fields and enum values, paging stops on a null cursor, 404 / 422 / 429 / 403REQS008, the waiter ending, resolving onfailed, and timing out.tests/e2e/crawl.e2e.test.ts: a real crawl, skipped unlessZENROWS_API_KEY,ZENROWS_CRAWL_BASE_URLandZENROWS_E2E_CRAWL_URLare set, sopnpm test -- --runstays hermetic. It bypasses the shared msw server. Its target comes fromZENROWS_E2E_CRAWL_URL(and an optionalZENROWS_E2E_CRAWL_INCLUDEpattern). It crawls at depth 1 (3 items, 5 pages, HTML), waits, checks the results match the include pattern, reads one page's HTML, checks the download's line count, finds the crawl in the list, stops the ended crawl, and expectscrawl_not_foundon an unknown id. On 429too_many_crawlsit waitsretryAfterand retries, for up to 5 minutes. The README's Contributing section says how to run it (pnpm test:e2e).pnpm check: clean.pnpm test -- --run: 25 files passed, 1 skipped (the e2e); 304 tests passed, 6 skipped; no type errors.pnpm buildemits the new exports.E2E evidence
Ran
pnpm test:e2eagainst a live ZenRows API deployment, with the target set throughZENROWS_E2E_CRAWL_URLandZENROWS_E2E_CRAWL_INCLUDE: 6 of 6 tests passed, no type errors.🤖 Generated with Claude Code