Repository navigation
feat(crawl): add ZenRowsCrawlClient for the Crawl API - #30
StefanoGuerrini wants to merge 2 commits into
Conversation
Add a `zenrows.crawl` package with a typed client for the Crawl API: create, get, list_crawls / iter_crawls, iter_results, stop, get_content, iter_download / download, and a wait helper. Responses parse into pydantic v2 models with open enums; errors raise CrawlAPIError (403 REQS008 reads "Crawl is not enabled for this account"). The shared HTTP transport now takes the error mapping and logger as arguments and gains raw and streaming request helpers. Adds unit tests, an opt-in e2e test (`make test-e2e`, deselected from `make test`), a README section, examples/11_crawl.py and DEVELOPMENT.md notes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
StefanoGuerrini
left a comment
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 9b3a2c6: make check (ty, ruff, ruff format) is clean, and make test passes 200 tests (1 e2e test deselected; I did not run it).
Verdict: changes needed. Items 1 to 7 become breaking changes if they ship as they are.
Fix before merge: breaking if shipped
- No Beta marking. The README marks Batch "Private beta" and Extract "(beta)", but not Crawl. Add "(beta)" to the Crawl README section and say Beta in the
zenrows.crawlmodule docstring and theZenRowsCrawlClientdocstring (proposed decision 1). Do not add a runtime warning. iter_resultsfollows a running crawl until it ends (src/zenrows/crawl/client.py:217). It sleeps and polls with no timeout, and the README documents this (README.md:526). Proposed decision 2: return at the first empty page. Callers who depend on the live follow break when this changes.downloadanditer_downloaddropX-Crawl-Status(src/zenrows/crawl/client.py:258,:271). The status only goes to a log warning (:326), so a caller cannot tell a partial file from a full one. Return the status with the lines or the path (proposed decision 3). Adding it later changes the return types.Crawlhas nodiscoveryand noextract_calls(src/zenrows/crawl/models.py:93). Production always returnsdiscovery. Adddiscovery: list[Discovery](open enum:links,pagination) andextract_calls: int | None = None(proposed decision 6). Adding a required field later breaks callers that build aCrawl, such as test fixtures.DownloadLine.contentisstr | None(src/zenrows/crawl/models.py:175). A line with object content raisesValidationError(I checked this), soiter_downloadfails on a staffjsoncrawl in the same org. Type itstr | dict[str, Any] | None. Widening it later breaks callers that treat it asstr.codefalls back to"internal"(src/zenrows/crawl/errors.py:41). A 502 with a non-JSON body givescode == "internal", a value the API never sends (production usesinternal_error). UseNonewhen the body has no code (proposed decision 5), and typecodeasstr | None.- The client reads
ZENROWS_CRAWL_BASE_URL(src/zenrows/crawl/client.py:81, comment at:43). Proposed decision 9: the SDKs take the base URL as a constructor option only. Remove the env read now; removing it after release breaks callers who set it. The e2e test,Makefiletest-e2eandDEVELOPMENT.mdcan passbase_url=from their own variable.
Fix before merge: behaviour and docs
- A keyed
createretries 429too_many_crawls. The shared transport retries 429 on a POST with anIdempotency-Keyup toretriestimes and sleeps eachRetry-After(src/zenrows/batch/_transport.py:33,:132,:152). Thecreatedocstring and the README say the caller gets the 429 withretry_after.too_many_crawlsis a capacity limit, so do not retry it on create (proposed decision 7). Keep the 5xx and network retries. wait()defaults to 300 s (src/zenrows/crawl/client.py:299). MCP and CLI use 600 s, and the README and the example pass 600. All surfaces will use one value; we have not picked the value yet (proposed decision 8).- The README error section is incomplete (
README.md:558). The 429 limit is the account's active jobs: 3 by default, shared with its Batch jobs. The section leaves out 400invalid_request/unknown_parameter/invalid_cursor, 409idempotency_request_in_flight(retry later), and 422domain_not_allowed/idempotency_key_reused(never retry). - The example always keeps
/product/(examples/11_crawl.py:29).action="append"withdefault=["/product/"]appends each--includeto the default, so a user cannot remove it. Usedefault=Noneand fall back to["/product/"]when it is empty.
Matches production
create()takesurlanddepthas required,output_format="html"only, and nodiscovery. It sends only the arguments the caller set. Documented ranges and defaults match the spec.- Response enums are open: a staff
output_format: "json"parses asOutputFormat.UNKNOWNwith the raw value. The models ignore unknown fields.CrawlErrorCodehas all five run error codes. iter_crawls()ends on an absentnext_cursor.list_crawls/iter_crawls(page_size=)follow the Batch client's names.wait()returns afailedcrawl and does not raise. On timeout it raisesWaiterTimeoutand leaves the crawl running.- 403
REQS008maps toCrawlAPIError.not_enabled, andretry_afterreadsRetry-After. - The
_transport.pychange is private: the newrequest()/stream()and the two new constructor arguments have defaults, and Batch behaviour is the same (its tests pass).
Can wait
These are additive and need no change now:
OutputFormathas noJSONmember.get_content()has nodownload=Trueoption, and its docstring says "HTML text" only.CrawlAPIErrordoes not expose theRateLimitheaders.
Release: the PR leaves the version at 1.5.0, and the repo already has the v1.5.0 tag. Do a minor bump (1.6.0) in the release commit.
Mark Crawl as Beta in the README and the module and client docstrings. iter_results no longer polls. It returns at the first empty page. iter_download and download return the X-Crawl-Status status with the lines or the saved path. Download lines accept object content. CrawlAPIError.code is None when the body has no code. The client takes the base URL only from the constructor. create never retries 429 too_many_crawls. Batch retries do not change. wait defaults to 600 s. The README lists every Crawl error code. The example lets --include replace the default pattern. Add a py.typed marker so type checkers use the package types.
|
Replaced by #31: the same code, with a history that drops the AI co-author trailer. The review on this PR still applies. |
What this adds
A
zenrows.crawlpackage withZenRowsCrawlClient, also exported from the top level (from zenrows import ZenRowsCrawlClient). Crawl is in Beta: the README, the module docstring and the client docstring say so. There is no runtime warning.ZenRowsCrawlClient(api_key, *, base_url=None, timeout=30.0, retries=3, verify_ssl=True, user_agent=..., httpx_args=None). The default base URL ishttps://api.zenrows.com/v1. Override it withbase_url=only; the client reads no environment variable.create(url, *, depth, max_items=None, max_pages=None, include_patterns=None, exclude_patterns=None, output_format: Literal["html"] | None = None, idempotency_key=None) -> Crawlsends only the fields you set. Withoutoutput_format, a crawl returns URLs only. A crawl stays on the start URL's registrable domain; subdomains count.get(crawl_id, *, cursor=None, limit=None) -> CrawlWithResultslist_crawls(*, cursor=None, limit=None) -> CrawlListanditer_crawls(*, page_size=None)iter_results(crawl_id, *, page_size=None)followsnext_cursorand returns at the first empty page or a null cursor. It does not poll: on a running crawl it yields only what the crawl has kept so far. Callwaitfirst to read every result.stop(crawl_id) -> CrawlStopis idempotent.get_content(crawl_id, result_or_content_id) -> strreturns one kept URL's HTML.iter_download(crawl_id) -> CrawlDownloadopens the NDJSON file. Iterate it for parsedDownloadLines, and read.status(fromX-Crawl-Status). It is also a context manager.download(crawl_id, path) -> CrawlDownloadFilestreams the file to disk and returns.pathand.status. On a running crawl,statusisrunningand the file holds only what the crawl has kept so far.DownloadLine.contentisstr | dict | None, so a line with object content parses.wait(crawl_id, *, timeout=600.0, poll_interval=2.0, max_poll_interval=15.0) -> Crawlpolls with backoff. Afailedcrawl is returned, not raised; readcrawl.error.codeandcrawl.error.detail. A timeout raisesWaiterTimeoutand leaves the crawl running.UNKNOWNand keeps its raw value.CrawlAPIError, withstatus_code,code,detail,problemandretry_after.codeis None when the body has no code. The README lists every code:invalid_request/unknown_parameter/invalid_cursor.REQS008reads "Crawl is not enabled for this account", andexc.not_enabledis true for it.crawl_not_found/content_not_found.idempotency_request_in_flight: retry after the first request finishes.invalid_parameter/invalid_start_url/domain_not_allowed, andidempotency_key_reused(use a new key or no key).too_many_crawls, withretry_after: the account has reached its limit of active jobs (3 by default), shared with its Batch jobs.createnever retries it, with or without an idempotency key.py.typedmarker (in the wheel and the sdist), so type checkers use its types.The SDK's shared HTTP transport now takes the error mapping and logger as constructor arguments, gains
request()andstream()helpers, and takes the retryable statuses per request.ProblemDetail.codeis None when the body has no code;BatchAPIErrorkeeps its"internal"fallback. Existing clients behave as before.Tests
make checkpasses (ty, ruff check, ruff format --check).make test: 206 passed, 1 deselected (the e2e test).tests/e2e/test_crawl_e2e.py:e2eand deselected frommake test.ZENROWS_API_KEY,ZENROWS_CRAWL_BASE_URLandZENROWS_E2E_CRAWL_URLare set. It passesZENROWS_CRAWL_BASE_URLasbase_url=.ZENROWS_E2E_CRAWL_INCLUDEis optional.make test-e2e. DEVELOPMENT.md has a "Running the Crawl e2e test" section.make test-e2eagainst a live deployment before the latest changes: 1 passed. I did not run it again after them.Docs: a README section ("Quickstart — Crawl API (beta)"),
examples/11_crawl.py, and DEVELOPMENT.md notes.Release: this PR does not change the version. Do a minor bump (1.6.0) in the release commit.