Repository navigation
feat(crawl): add ZenRowsCrawlClient for the Crawl API - #31
Open
StefanoGuerrini wants to merge 21 commits into
Open
StefanoGuerrini wants to merge 21 commits into
StefanoGuerrini wants to merge 21 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.
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.
Rename the methods to the names that all Crawl clients share: list_crawls is now list, iter_results is now results (with limit for the page size), get_content is now content, and iter_download is now download. download returns a CrawlDownload with the status and the parsed lines. Remove iter_crawls, the download-to-path helper with CrawlDownloadFile, and the wait poll interval options. The 2 s, x1.5, 15 s backoff stays internal. wait now returns the crawl when the timeout (600 s by default) runs out. The crawl is still running. It no longer raises WaiterTimeout, so the crawl package no longer exports it.
- content() accepts a result, its content_url, or a content id. - CrawlDownload exposes its parsed lines as the lines property. - Rename CRAWL_NOT_ENABLED to CRAWL_NOT_ENABLED_CODE and remove CrawlAPIError.not_enabled. - Remove CrawlResult.content_id. - Remove the runtime check on output_format; the type hint stays. - Document the client options base_url, retries and timeout.
Wait checks the status against running directly. The default base URL stays internal to the client.
Customer-facing Crawl text now says New. The README Crawl section and the package doc carry one still-evolving line.
A stop that collides with the crawl saving its own progress answers 503 crawl_busy with Retry-After. Nothing was saved and the crawl keeps running, and the stop is safe to repeat, but stop raised at once. stop now retries that answer up to `retries` times, waiting Retry-After seconds between tries, then raises it. Other errors, and other 503s, are raised as before.
gonzalo-zenrows
added this pull request to stack #33
October 9, 2026 19:51
- A retry honored any Retry-After the server sent, so a value of 3600 made a call wait an hour. The shared transport now waits at most 30 s. - CrawlAPIError.retry_after is None for a negative, NaN or infinite Retry-After, so code that sleeps for it cannot crash.
`from zenrows import CrawlAPIError` now works, next to ZenRowsCrawlClient. The README example now crawls the scrapingcourse.com demo shop, whose product URLs match /product/.
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.
content(crawl_id, content_id) matches the API's content_id and the other clients. It still accepts a content_url or a CrawlResult. A caller that passed content= by keyword now passes content_id=.
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.
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.
…-stop-retry-busy # Conflicts: # tests/test_crawl_client.py
StefanoGuerrini
force-pushed
the
feat/crawl-client-v2
branch
from
October 12, 2026 14:09
5b18043 to
3522a63
Compare
The crawl client needs to know when an error body has no code. That change made Batch's public ProblemDetail.code optional. Restore the Batch contract and read the missing code from the crawl error body.
realpath --relative-to is GNU-only, so on macOS every run was tagged +local-changes. The clean-up now continues past a network error and retries a stop answered 503 crawl_busy. The key scan skips only the source copy, and run.sh drops the builders this repo does not have.
make check covered only src/ and tests/, so the harness had about 96 ruff errors. Bring acceptance/ into the lint and format targets and fix the errors.
1 task
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
ZenRowsCrawlClient, a typed client for the Crawl API, in a newzenrows.crawlpackage. The shared HTTP transport now serves both the Batch and the Crawl clients. It caps aRetry-Afterwait at 30 s for both clients.stopretries a503 crawl_busyanswer. The PR also adds a first-install acceptance test for the Crawl client underacceptance/crawl/.This PR also carries #32 and #35, merged into this branch.
Squash commit message
Squash-merge with this message: the branch commits carry trailer lines that should not land.
Changes since #30
This PR replaces #30. The review of the first version is here. Since that review, these things changed:
content()names its argumentcontent_id.zenrowspackage exportsCrawlAPIError.Retry-After.stopretries a503 crawl_busy.What this adds
Import the client from the package or from the top level:
from zenrows import ZenRowsCrawlClient, CrawlAPIError.README.mdand the docstrings call Crawl "(new)".README.mdand the package docstring add one line that Crawl is still evolving. The client gives no runtime warning.ZenRowsCrawlClient(api_key, *, base_url=None, timeout=30.0, retries=3, verify_ssl=True, user_agent=DEFAULT_USER_AGENT, httpx_args=None)zenrows-crawl-python/<version>. Default base URLhttps://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) -> Crawloutput_format; the API decides. 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(*, cursor=None, limit=None) -> CrawlListnext_cursoris None on the last page.results(crawl_id, *, limit=None)next_cursor,limitURLs per request, and stops at the first empty page or a null cursor. It does not poll. On a running crawl it yields only what the crawl kept so far; callwaitfirst.stop(crawl_id) -> CrawlStop503 crawl_busy; see the next section.content(crawl_id, content_id) -> strcontent_idis aCrawlResult, itscontent_url, or a content id.download(crawl_id) -> CrawlDownloadlinesfor parsedDownloadLines; read.statusfromX-Crawl-Status. It is also a context manager. On a running crawl,statusisrunningand the file holds what the crawl kept so far.DownloadLine.contentisstr | dict | None.wait(crawl_id, *, timeout=600.0) -> Crawltimeoutpasses. It never raises for the crawl's state. On timeout it returns the crawl, stillrunning. It returns afailedcrawl; readcrawl.error.codeandcrawl.error.detail.Retries: the client retries up to
retriestimes on 429, 502, 503, 504 and network errors. It retries GETs, and acreatethat carries anidempotency_key.createnever retries a 429. The client uses jittered exponential backoff and honors aRetry-Afterup to 30 s.Models: responses parse into pydantic v2 models. The models ignore unknown fields. Every response enum is open: a value added later parses as
UNKNOWNand keeps its raw value.Errors: the client raises
CrawlAPIError, withstatus_code,code,detail,problemandretry_after.codeis None when the body has no code.retry_afteris None whenRetry-Afteris missing, negative, NaN or infinite.README.mdlists every code:invalid_request,unknown_parameter,invalid_cursorREQS008exc.codewith the exportedCRAWL_NOT_ENABLED_CODE.crawl_not_found,content_not_foundidempotency_request_in_flightinvalid_parameter,invalid_start_url,domain_not_allowedidempotency_key_reusedtoo_many_crawlscreatenever retries it.crawl_busystopretries it, then raises it. A second stop is safe.Packaging: the package now ships a
py.typedmarker, so type checkers use its types.Shared transport: it now takes the error mapping and the logger as constructor arguments. It gains
request()andstream()helpers and takes the retryable statuses per request. These changes apply toZenRowsBatchClienttoo. Batch still reports a missing code as"internal", in bothProblemDetail.codeandBatchAPIError.code. Otherwise the existing clients behave as before.Stop on
503 crawl_busyA stop can collide with the crawl while the crawl saves its own progress. The API retries that collision for under 2 s. If the collision stays, the API answers
503withcode: crawl_busyandRetry-After: 1. Stop is a POST without anIdempotency-Key, so the shared transport never retries it. Without this change, the caller saw an error while the crawl kept running.stopbehavior503with codecrawl_busyretriesmore times (default 3), then raises theCrawlAPIError.retries=0turns the retry off.Retry-Aftervalue, at most 30 s. A missing, negative, NaN or infinite value gives 1 s.503with another code, any other errorAcceptance test
acceptance/crawl/run.shtests the release artifact the way a new user meets it. It builds the wheel withpython -m buildin its own container. Then it installs the wheel in a clean, digest-pinned container with no source, caches or tokens. It runs theREADME.mdCrawl quickstart as written, then checks 1 to 21 and amypy --strictcheck. It stops every crawl it started, also on a failure or Ctrl-C. It writesacceptance/crawl/out/crawl-first-install-report.mdand makes sure that no file underout/holds the key.acceptance/crawl/README.mdlists each check with its expected result. The harness runs on macOS and Linux, andmake checklints it.CRAWL_URLhttps://www.scrapingcourse.com/ecommerce/CRAWL_INCLUDE/product/CRAWL_API_BASEhttps://api.zenrows.com/v1REFOUT_DIRCost: a run starts five crawls, each with
depth1 andmax_pages10 or less. On the defaultCRAWL_URL, a run fetches about 10 pages, and at most 50. Check 9 can start a sixth crawl, which adds at most 10 pages. At most 2 crawls run at the same time, under the account limit of 3 active jobs. Do not run two copies against one account at the same time.Packaging:
pyproject.tomlexcludes/acceptancefrom the sdist, so the harness never ships in the package.Tests
make check(ty, ruff check, ruff format --check)make testwaitlist,resultslistpages with a cursor, andresultssendslimit.Retry-After3600waits 30 s; an unusable value givesretry_after=None.contentcontent_id=by keyword.stopon503 crawl_busycrawl_busythen200returns after one 1.0 s wait. Withretries=2andcrawl_busyon every call, it raises after 3 calls, waits[1.0, 1.0]. A503with another code does not retry.stopwait perRetry-After3600waits 30.0 s,-1andnanwait 1.0 s,2waits 2.0 s.tests/e2e/test_crawl_e2e.py, markede2eZENROWS_API_KEY,ZENROWS_CRAWL_BASE_URLandZENROWS_E2E_CRAWL_URLare set;ZENROWS_E2E_CRAWL_INCLUDEis optional. PassesZENROWS_CRAWL_BASE_URLasbase_url=. DEVELOPMENT.md has a "Running the Crawl e2e test" section.The e2e test passed against a live deployment before the method renames. It has not run since then, nor since the
content_idrename.How to try it
examples/11_crawl.pyruns a crawl end to end.README.mdhas a Crawl API (new) quickstart, with the client optionsbase_url,retriesandtimeout.The acceptance test needs bash, git and Docker. Run it from the repository root:
Without
ZENROWS_API_KEY,run.shstops and asks for it.Release
This PR does not change the version. Do a minor bump (1.6.0) in the release commit.