@zenrows/cli 1.4.1 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,418 @@
1
+ /**
2
+ * Client for the Zenrows Crawl API.
3
+ *
4
+ * Crawl lives on the same host as Fetch and Extract (`https://api.zenrows.com/v1`)
5
+ * under `/crawls`. Auth is the `X-API-Key` header, never the `apikey` query
6
+ * param: Crawl answers any query parameter it does not know with 400. Bodies are
7
+ * JSON; errors come back as `application/problem+json` (RFC 9457) and we branch
8
+ * on `status` + `code` only. A crawl is an async job: create it, then poll it
9
+ * until its status leaves `running`. Calls retry transient failures the way the
10
+ * Crawl SDKs do (see `retryStatuses`). `fetchImpl` / `sleepImpl` are injectable
11
+ * for tests.
12
+ *
13
+ * `output_format` takes `html` only (absent = URLs only). Response fields we do
14
+ * not model pass through untouched.
15
+ */
16
+ import { Readable } from "node:stream";
17
+ import { ToolkitError, isKeyCapReached, keyCapReached, quotaExhausted } from "./errors.js";
18
+ import { readAccount } from "./agent-account.js";
19
+ import { registerSecret } from "./logger.js";
20
+ import { CLI_USER_AGENT, loadConfig } from "./config.js";
21
+ import { agentClientHeader } from "./agent-client.js";
22
+ /** Production Crawl API base (no trailing slash): the Fetch/Extract host. */
23
+ export const DEFAULT_CRAWL_API_BASE = "https://api.zenrows.com/v1";
24
+ /** Env var to override the Crawl API base (local/staging testing). */
25
+ export const CRAWL_API_BASE_ENV = "ZENROWS_CRAWL_API_BASE";
26
+ /**
27
+ * Resolve the Crawl API base, trimmed of trailing `/`. `ZENROWS_CRAWL_API_BASE`
28
+ * wins; otherwise Crawl follows the Fetch/Extract `apiBase` (config or
29
+ * `ZENROWS_API_BASE`), since both live on the same API host.
30
+ */
31
+ export function crawlBase() {
32
+ const env = process.env[CRAWL_API_BASE_ENV];
33
+ const base = env && env.trim() ? env.trim() : loadConfig().apiBase || DEFAULT_CRAWL_API_BASE;
34
+ return base.replace(/\/+$/, "");
35
+ }
36
+ /** The API's "Crawl is not enabled for this account" code (403). */
37
+ export const CRAWL_NOT_ENABLED_CODE = "REQS008";
38
+ /** A crawl stops progressing once its status is anything but `running`. */
39
+ export function isTerminal(status) {
40
+ return !!status && status !== "running";
41
+ }
42
+ const MAX_RETRIES = 3;
43
+ /** The longest a Retry-After may make one retry wait, so a large server value cannot hang a command. */
44
+ const MAX_RETRY_AFTER_SECONDS = 30;
45
+ const READ_RETRY_STATUSES = new Set([429, 502, 503, 504]);
46
+ // 429 on create is the active-job limit: a retry only waits for a slot that may never free.
47
+ const CREATE_RETRY_STATUSES = new Set([502, 503, 504]);
48
+ // Any 503 on stop, crawl_busy included: crawl_busy means the stop was not saved and the crawl still runs.
49
+ const STOP_RETRY_STATUSES = new Set([429, 502, 503, 504]);
50
+ /**
51
+ * The statuses to retry, or undefined when the request must run once: a GET is
52
+ * safe to repeat, a create only with an Idempotency-Key. Stop retries on its own
53
+ * statuses and wait rule (see `retryStop`).
54
+ */
55
+ function retryStatuses(method, headers) {
56
+ if (method === "GET")
57
+ return READ_RETRY_STATUSES;
58
+ return headers["Idempotency-Key"] ? CREATE_RETRY_STATUSES : undefined;
59
+ }
60
+ /** 250 ms x 2^attempt, capped at 10 s, +/-20% jitter. */
61
+ function backoffMs(attempt) {
62
+ return Math.min(250 * 2 ** attempt, 10_000) * (0.8 + Math.random() * 0.4);
63
+ }
64
+ /** `Retry-After` in whole seconds, as milliseconds, at most `MAX_RETRY_AFTER_SECONDS`. */
65
+ function retryAfterMs(value) {
66
+ return value && /^\d+$/.test(value.trim()) ? Math.min(Number(value), MAX_RETRY_AFTER_SECONDS) * 1000 : undefined;
67
+ }
68
+ const defaultSleep = (ms) => new Promise((r) => setTimeout(r, ms));
69
+ /** One HTTP attempt with its own timeout; the body is read inside it. */
70
+ async function attempt(url, init, opts) {
71
+ const controller = new AbortController();
72
+ const timeout = setTimeout(() => controller.abort(), opts.timeoutMs ?? 60_000);
73
+ try {
74
+ const res = await (opts.fetchImpl ?? fetch)(url, { ...init, signal: controller.signal });
75
+ if (opts.sink && res.ok) {
76
+ await opts.sink(res.body ?? Readable.from([]));
77
+ return { status: res.status, headers: res.headers, text: "" };
78
+ }
79
+ return { status: res.status, headers: res.headers, text: await res.text() };
80
+ }
81
+ finally {
82
+ clearTimeout(timeout);
83
+ }
84
+ }
85
+ /**
86
+ * Perform a Crawl API request and return the raw body. Sets `X-API-Key`, adds a
87
+ * JSON content-type when a body is present, retries transient failures as
88
+ * `retryStatuses` allows, and on a non-2xx parses the problem+json body into a
89
+ * normalized ToolkitError.
90
+ */
91
+ async function crawlFetch(method, path, opts) {
92
+ registerSecret(opts.apiKey);
93
+ const url = new URL(crawlBase() + path);
94
+ for (const [k, v] of Object.entries(opts.query ?? {})) {
95
+ if (v !== undefined && v !== null)
96
+ url.searchParams.set(k, String(v));
97
+ }
98
+ const headers = {
99
+ "X-API-Key": opts.apiKey,
100
+ Accept: opts.accept ?? "application/json",
101
+ "User-Agent": CLI_USER_AGENT,
102
+ ...agentClientHeader(),
103
+ ...(opts.headers ?? {}),
104
+ };
105
+ if (opts.body !== undefined)
106
+ headers["Content-Type"] = "application/json";
107
+ const init = { method, headers, body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined };
108
+ const retryOn = retryStatuses(method, headers);
109
+ const sleep = opts.sleepImpl ?? defaultSleep;
110
+ for (let n = 0;; n++) {
111
+ const canRetry = retryOn !== undefined && n < MAX_RETRIES;
112
+ let res;
113
+ try {
114
+ res = await attempt(url.toString(), init, opts);
115
+ }
116
+ catch (err) {
117
+ if (err instanceof ToolkitError)
118
+ throw err;
119
+ if (canRetry) {
120
+ await sleep(backoffMs(n));
121
+ continue;
122
+ }
123
+ throw new ToolkitError({
124
+ code: "BACKEND_UNAVAILABLE",
125
+ message: "Could not reach the Zenrows Crawl API.",
126
+ likely_cause: err instanceof Error ? err.message : String(err),
127
+ next_action: "Check connectivity and retry. Override the host with ZENROWS_CRAWL_API_BASE if you are testing against staging.",
128
+ suggested_commands: ["zenrows status"],
129
+ crawl_id: opts.crawlId,
130
+ });
131
+ }
132
+ const retryAfter = res.headers.get("retry-after");
133
+ if (canRetry && retryOn.has(res.status)) {
134
+ await sleep(retryAfterMs(retryAfter) ?? backoffMs(n));
135
+ continue;
136
+ }
137
+ if (opts.retryStop && n < MAX_RETRIES && STOP_RETRY_STATUSES.has(res.status)) {
138
+ await sleep(retryAfterMs(retryAfter) ?? 1000);
139
+ continue;
140
+ }
141
+ if (res.status < 200 || res.status >= 300) {
142
+ throw problemToError(res.status, res.text, `${method} ${path}`, retryAfter, opts.crawlId);
143
+ }
144
+ return res;
145
+ }
146
+ }
147
+ /** `crawlFetch` + JSON parse. */
148
+ async function crawlJson(method, path, opts) {
149
+ const { text } = await crawlFetch(method, path, opts);
150
+ try {
151
+ return JSON.parse(text);
152
+ }
153
+ catch {
154
+ throw new ToolkitError({
155
+ code: "CRAWL_FAILED",
156
+ message: "The Crawl API response was not valid JSON.",
157
+ likely_cause: text.slice(0, 240) || "Empty response body.",
158
+ next_action: "Retry, or check the crawl with `zenrows crawl get <id>`.",
159
+ crawl_id: opts.crawlId,
160
+ });
161
+ }
162
+ }
163
+ /**
164
+ * Map a problem+json body + HTTP status to a normalized ToolkitError that
165
+ * carries the API's `code` as `server_code`, and `crawl_id` when known.
166
+ */
167
+ function problemToError(status, body, request, retryAfter, crawlId) {
168
+ let problem = {};
169
+ try {
170
+ problem = JSON.parse(body);
171
+ }
172
+ catch {
173
+ // non-JSON error body — fall through with an empty problem
174
+ }
175
+ const serverCode = problem.code || undefined;
176
+ const throttled = status === 429 || (status === 503 && serverCode === "crawl_busy");
177
+ const seconds = throttled && retryAfter && /^\d+$/.test(retryAfter.trim()) ? Number(retryAfter) : undefined;
178
+ const err = classifyProblem(status, problem, body, request, serverCode, seconds);
179
+ return new ToolkitError({ ...err.toJSON(), server_code: serverCode, crawl_id: crawlId, retry_after: seconds });
180
+ }
181
+ function classifyProblem(status, problem, body, request, serverCode, retryAfter) {
182
+ const detail = problem.detail || problem.title || body.slice(0, 240) || `HTTP ${status}`;
183
+ // The API's detail often ends in a period already; add one only when it does not.
184
+ const raw = `HTTP ${status}${serverCode ? ` (${serverCode})` : ""} for ${request}: ${detail}`;
185
+ const cause = /[.!?]$/.test(raw) ? raw : `${raw}.`;
186
+ if (status === 403 && serverCode === CRAWL_NOT_ENABLED_CODE) {
187
+ return new ToolkitError({
188
+ code: "CRAWL_NOT_ENABLED",
189
+ message: "Crawl is not enabled for this account.",
190
+ likely_cause: `${cause} Crawl is not enabled for this account yet.`,
191
+ next_action: "Ask Zenrows support to enable Crawl for this account. Meanwhile fetch known URLs with `zenrows fetch`.",
192
+ suggested_commands: ["zenrows fetch <url>"],
193
+ });
194
+ }
195
+ if (status === 401) {
196
+ return new ToolkitError({
197
+ code: "AUTH_INVALID",
198
+ message: "Zenrows rejected the API key for the Crawl API.",
199
+ likely_cause: cause,
200
+ next_action: "Re-check your key and log in again.",
201
+ suggested_commands: ["zenrows login --api-key <your-key>"],
202
+ });
203
+ }
204
+ if (status === 404 && serverCode === "content_not_found") {
205
+ return new ToolkitError({
206
+ code: "CRAWL_CONTENT_NOT_FOUND",
207
+ message: "Crawl content not found.",
208
+ likely_cause: `${cause} The page was not fetched (yet), its fetch failed, or the crawl ran without --output-format html.`,
209
+ next_action: "Read the result's content_status with `zenrows crawl results <id>`; only `fetched` results have content.",
210
+ suggested_commands: ["zenrows crawl list"],
211
+ });
212
+ }
213
+ if (status === 404) {
214
+ return new ToolkitError({
215
+ code: "CRAWL_NOT_FOUND",
216
+ message: "Crawl not found.",
217
+ likely_cause: `${cause} The id may be wrong or the crawl is not owned by this account.`,
218
+ next_action: "Check the crawl id, or list your crawls with `zenrows crawl list`.",
219
+ suggested_commands: ["zenrows crawl list"],
220
+ });
221
+ }
222
+ if (status === 429 && serverCode === "too_many_crawls") {
223
+ return new ToolkitError({
224
+ code: "CRAWL_TOO_MANY_CRAWLS",
225
+ message: "The account has reached its limit of active jobs.",
226
+ likely_cause: `${cause} The account has reached its limit of active jobs (3 by default), shared with its Batch jobs.`,
227
+ next_action: `Nothing was created. ${retryAfter !== undefined ? `Retry after ${retryAfter} seconds` : "Retry later"}, or stop one of your crawls (\`zenrows crawl stop <id>\`) or Batch jobs first.`,
228
+ suggested_commands: ["zenrows crawl list"],
229
+ });
230
+ }
231
+ if (status === 429) {
232
+ // The Crawl API sends 429 only on create, as too_many_crawls. Any other 429
233
+ // comes from something in front of it and says nothing about active jobs.
234
+ return new ToolkitError({
235
+ code: "CRAWL_RATE_LIMITED",
236
+ message: "The Crawl API is rate limiting requests.",
237
+ likely_cause: cause,
238
+ next_action: retryAfter !== undefined ? `Retry after ${retryAfter} seconds.` : "Retry later.",
239
+ });
240
+ }
241
+ if (status === 402) {
242
+ // Same advice as the shared credit errors, under Crawl's own codes.
243
+ const detail = problem.detail || problem.title || undefined;
244
+ if (isKeyCapReached(serverCode)) {
245
+ return new ToolkitError({ ...keyCapReached(request, { status, detail }).toJSON(), code: "CRAWL_KEY_CAP_REACHED" });
246
+ }
247
+ const acct = readAccount();
248
+ const quota = quotaExhausted(request, acct?.unclaimed ? acct.claimUrl : undefined, { status, detail });
249
+ return new ToolkitError({ ...quota.toJSON(), code: "CRAWL_INSUFFICIENT_CREDITS" });
250
+ }
251
+ if (status === 409) {
252
+ return new ToolkitError({
253
+ code: "CRAWL_REQUEST_IN_FLIGHT",
254
+ message: "A request with the same Idempotency-Key is still in flight.",
255
+ likely_cause: cause,
256
+ next_action: "Retry the same request once the first one has finished.",
257
+ });
258
+ }
259
+ if (status === 503 && serverCode === "crawl_busy") {
260
+ return new ToolkitError({
261
+ code: "CRAWL_BUSY",
262
+ message: "The stop was not saved yet; the crawl is still running.",
263
+ likely_cause: cause,
264
+ next_action: `Run the stop again${retryAfter !== undefined ? ` after ${retryAfter} seconds` : ""}. Repeating a stop is safe.`,
265
+ suggested_commands: ["zenrows crawl stop <id>"],
266
+ });
267
+ }
268
+ if (status === 400 || status === 422) {
269
+ return new ToolkitError({
270
+ code: "CRAWL_INVALID_REQUEST",
271
+ message: `Crawl rejected the request (HTTP ${status}).`,
272
+ likely_cause: cause,
273
+ next_action: serverCode === "idempotency_key_reused"
274
+ ? "This Idempotency-Key was already used for a different request. Send a new key, or no key. Do not retry as is."
275
+ : "Fix the reported parameter and retry. See `zenrows crawl --help` for the accepted flags and ranges.",
276
+ });
277
+ }
278
+ return new ToolkitError({
279
+ code: "CRAWL_FAILED",
280
+ message: `Crawl request failed (HTTP ${status}).`,
281
+ likely_cause: cause,
282
+ next_action: status >= 500 ? "Retry with a short backoff." : "Fix the reported problem and retry.",
283
+ });
284
+ }
285
+ /** Start a crawl (202). Sends only the fields set in `params`. */
286
+ export function createCrawl(params, opts) {
287
+ const body = { url: params.url };
288
+ if (params.depth !== undefined)
289
+ body.depth = params.depth;
290
+ if (params.max_items !== undefined)
291
+ body.max_items = params.max_items;
292
+ if (params.max_pages !== undefined)
293
+ body.max_pages = params.max_pages;
294
+ if (params.include_patterns?.length)
295
+ body.include_patterns = params.include_patterns;
296
+ if (params.exclude_patterns?.length)
297
+ body.exclude_patterns = params.exclude_patterns;
298
+ if (params.output_format !== undefined)
299
+ body.output_format = params.output_format;
300
+ return crawlJson("POST", "/crawls", {
301
+ ...opts,
302
+ body,
303
+ headers: opts.idempotencyKey ? { "Idempotency-Key": opts.idempotencyKey } : undefined,
304
+ });
305
+ }
306
+ /** Read a crawl and one page of its results. */
307
+ export function getCrawl(id, opts) {
308
+ return crawlJson("GET", `/crawls/${encodeURIComponent(id)}`, {
309
+ ...opts,
310
+ crawlId: id,
311
+ query: { cursor: opts.cursor, limit: opts.limit },
312
+ });
313
+ }
314
+ /** One page of the account's crawls, newest first. */
315
+ export function listCrawls(opts) {
316
+ return crawlJson("GET", "/crawls", { ...opts, query: { cursor: opts.cursor, limit: opts.limit } });
317
+ }
318
+ /** Stop a running crawl. Idempotent: an ended crawl answers as it ended. */
319
+ export function stopCrawl(id, opts) {
320
+ return crawlJson("POST", `/crawls/${encodeURIComponent(id)}/stop`, { ...opts, crawlId: id, retryStop: true });
321
+ }
322
+ /** The page of one kept URL (HTML for `output_format: html`). */
323
+ export async function getCrawlContent(id, contentId, opts) {
324
+ const res = await crawlFetch("GET", `/crawls/${encodeURIComponent(id)}/contents/${encodeURIComponent(contentId)}`, {
325
+ ...opts,
326
+ crawlId: id,
327
+ accept: "text/html, application/json;q=0.9, application/problem+json;q=0.8",
328
+ });
329
+ return { contentType: res.headers.get("content-type") ?? "", body: res.text };
330
+ }
331
+ /**
332
+ * Every result in one NDJSON file (`{url, content_status?, content?}` per line),
333
+ * streamed into `write` because page HTML makes it large. `count` is its
334
+ * non-blank lines. `status` is the `X-Crawl-Status` header: `running` means the
335
+ * file is partial.
336
+ */
337
+ export async function downloadCrawl(id, write, opts) {
338
+ let count = 0;
339
+ const res = await crawlFetch("GET", `/crawls/${encodeURIComponent(id)}/download`, {
340
+ ...opts,
341
+ crawlId: id,
342
+ accept: "application/x-ndjson, application/problem+json;q=0.8",
343
+ timeoutMs: opts.timeoutMs ?? 300_000,
344
+ sink: (body) => {
345
+ count = 0;
346
+ return write(countLines(body, () => count++));
347
+ },
348
+ });
349
+ return { status: res.headers.get("x-crawl-status") ?? undefined, count };
350
+ }
351
+ /** Pass `chunks` through, calling `onLine` for each line that is not blank. */
352
+ async function* countLines(chunks, onLine) {
353
+ let blank = true;
354
+ for await (const chunk of chunks) {
355
+ for (const b of chunk) {
356
+ if (b === 0x0a) {
357
+ if (!blank)
358
+ onLine();
359
+ blank = true;
360
+ }
361
+ else if (b !== 0x20 && b !== 0x09 && b !== 0x0d) {
362
+ blank = false;
363
+ }
364
+ }
365
+ yield chunk;
366
+ }
367
+ if (!blank)
368
+ onLine();
369
+ }
370
+ /**
371
+ * The content id of a fetched result: the last segment of its `content_url`.
372
+ * Undefined when the result has no content yet.
373
+ */
374
+ export function contentIdOf(result) {
375
+ if (!result.content_url)
376
+ return undefined;
377
+ const last = result.content_url.split("?")[0].replace(/\/+$/, "").split("/").pop();
378
+ return last ? decodeURIComponent(last) : undefined;
379
+ }
380
+ /**
381
+ * Read every kept result, following `next_cursor`. While a crawl runs its
382
+ * `next_cursor` is never null, so the read also ends at the first empty page;
383
+ * `partial` is true when the crawl was still running or a cursor remains.
384
+ */
385
+ export async function listAllResults(id, opts) {
386
+ const results = [];
387
+ let cursor;
388
+ for (;;) {
389
+ const page = await getCrawl(id, { ...opts, cursor, limit: opts.pageSize });
390
+ const rows = page.results ?? [];
391
+ results.push(...rows);
392
+ if (!rows.length || !page.next_cursor || page.next_cursor === cursor) {
393
+ // Every page was read. An ended crawl keeps no new URLs, even while pages in flight after
394
+ // a stop keep next_cursor set, so only a running crawl's results are partial.
395
+ return { crawl: page, results, partial: !isTerminal(page.status) };
396
+ }
397
+ cursor = page.next_cursor;
398
+ }
399
+ }
400
+ /**
401
+ * Poll `GET /crawls/{id}?limit=1` until the status leaves `running` or
402
+ * `timeoutMs` (default 600 s) runs out, backing off 2s → ×1.5 → capped at 15s.
403
+ * A timeout is not an error: it returns the crawl, still `running`.
404
+ */
405
+ export async function waitForCrawl(id, opts) {
406
+ const sleep = opts.sleepImpl ?? defaultSleep;
407
+ const deadline = Date.now() + (opts.timeoutMs ?? 600_000);
408
+ let delay = 2000;
409
+ for (;;) {
410
+ const crawl = await getCrawl(id, { apiKey: opts.apiKey, fetchImpl: opts.fetchImpl, sleepImpl: opts.sleepImpl, limit: 1 });
411
+ if (isTerminal(crawl.status))
412
+ return crawl;
413
+ if (Date.now() + delay > deadline)
414
+ return crawl;
415
+ await sleep(delay);
416
+ delay = Math.min(delay * 1.5, 15_000);
417
+ }
418
+ }
@@ -11,6 +11,9 @@ export class ToolkitError extends Error {
11
11
  likely_cause;
12
12
  next_action;
13
13
  suggested_commands;
14
+ server_code;
15
+ crawl_id;
16
+ retry_after;
14
17
  constructor(shape) {
15
18
  super(shape.message);
16
19
  this.name = "ToolkitError";
@@ -18,6 +21,9 @@ export class ToolkitError extends Error {
18
21
  this.likely_cause = shape.likely_cause;
19
22
  this.next_action = shape.next_action;
20
23
  this.suggested_commands = shape.suggested_commands ?? [];
24
+ this.server_code = shape.server_code;
25
+ this.crawl_id = shape.crawl_id;
26
+ this.retry_after = shape.retry_after;
21
27
  }
22
28
  toJSON() {
23
29
  return {
@@ -26,6 +32,9 @@ export class ToolkitError extends Error {
26
32
  likely_cause: this.likely_cause,
27
33
  next_action: this.next_action,
28
34
  suggested_commands: this.suggested_commands,
35
+ ...(this.server_code ? { server_code: this.server_code } : {}),
36
+ ...(this.crawl_id ? { crawl_id: this.crawl_id } : {}),
37
+ ...(this.retry_after !== undefined ? { retry_after: this.retry_after } : {}),
29
38
  };
30
39
  }
31
40
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zenrows/cli",
3
- "version": "1.4.1",
3
+ "version": "1.5.0",
4
4
  "description": "Open-source CLI, MCP, skills, templates, workflows, recipes, and evals layer for giving AI agents reliable access to protected web data through Zenrows cloud infrastructure.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -48,6 +48,7 @@
48
48
  "zenrows": "node bin/zenrows.ts",
49
49
  "dev": "node bin/zenrows.ts",
50
50
  "test": "npm run build && node --test --import ./dist/tests/setup.js dist/tests/*.test.js",
51
+ "test:e2e": "npm run build && node --test dist/tests/e2e/*.test.js",
51
52
  "typecheck": "tsc --noEmit",
52
53
  "build": "rm -rf dist && tsc && chmod +x dist/bin/zenrows.js",
53
54
  "prepack": "npm run build",
@@ -28,6 +28,15 @@
28
28
  "requiresAuth": true,
29
29
  "notes": "Open beta. Cloud subcommands (create/status/results/cancel/wait/retry-failed) call the Batch API; `zenrows batch estimate` works locally with no API key."
30
30
  },
31
+ "crawl": {
32
+ "key": "crawl",
33
+ "label": "Crawl",
34
+ "status": "available",
35
+ "command": "zenrows crawl",
36
+ "backend": "Crawl — https://api.zenrows.com/v1/crawls (X-API-Key header; same host as Fetch/Extract)",
37
+ "requiresAuth": true,
38
+ "notes": "Crawl is still evolving: new features are coming, limits may be tuned, and the changelog announces each change. Follows the links on each page from one start URL, up to depth when set (optional; without it max_pages / max_items stop the crawl, both default 100), on the start URL's registrable domain (subdomains count), and returns the URLs it keeps, optionally with each page's HTML (create/get/list/results/content/download/stop/wait). An account without Crawl access gets 403 REQS008 (CRAWL_NOT_ENABLED). When the account has reached its limit of active jobs (3 by default), shared with its Batch jobs, a create gets 429 too_many_crawls (CRAWL_TOO_MANY_CRAWLS); retry after retry_after seconds. Any other 429 is CRAWL_RATE_LIMITED (requests are being throttled); retry after retry_after seconds."
39
+ },
31
40
  "browser": {
32
41
  "key": "browser",
33
42
  "label": "Browser Sessions",
@@ -44,6 +44,17 @@
44
44
  "path": "skills/batch-jobs",
45
45
  "tags": ["batch", "scale", "jsonl"]
46
46
  },
47
+ {
48
+ "name": "crawl",
49
+ "type": "skill",
50
+ "description": "Collect the URLs behind one start page with Crawl (new): create, wait, read results and page HTML, stop.",
51
+ "status": "available",
52
+ "requires_backend_capabilities": ["crawl"],
53
+ "requires_auth": true,
54
+ "version": "0.1.0",
55
+ "path": "skills/crawl",
56
+ "tags": ["crawl", "links", "urls"]
57
+ },
47
58
  {
48
59
  "name": "interact-browser",
49
60
  "type": "skill",
@@ -0,0 +1,126 @@
1
+ ---
2
+ name: crawl
3
+ description: Collect the URLs behind one start page with Crawl (new): create a crawl, wait for it, read the kept URLs and optionally each page's HTML, stop it. Use when the user has a listing or section URL but not the item URLs.
4
+ version: 0.1.0
5
+ requires_backend_capabilities: [crawl]
6
+ ---
7
+
8
+ # Crawl (new)
9
+
10
+ Give Crawl one start URL; it follows the links on that page (and on the
11
+ pages behind it, up to the depth you set) and returns the URLs it keeps. Use it
12
+ when you know where the items live (a category page, a blog index, a docs
13
+ section) but not the item URLs themselves. Once you have the URLs, read pages
14
+ with `zenrows fetch` / `zenrows extract`, or ask Crawl for each page's HTML
15
+ with `--output-format html`.
16
+
17
+ Crawl stays on the start URL's registrable domain; subdomains count. An account without Crawl access gets
18
+ 403 `REQS008` → `CRAWL_NOT_ENABLED` ("Crawl is not enabled for this account");
19
+ do not retry it.
20
+
21
+ ## Create, wait, read
22
+
23
+ ```
24
+ zenrows crawl create https://example.com/products/ --depth 1 \
25
+ --max-items 20 --max-pages 25 --include-patterns /product/ --follow --json
26
+ zenrows crawl results <id> --json # every kept URL
27
+ zenrows crawl results <id> --out urls.jsonl # same, as JSONL
28
+ ```
29
+
30
+ - `--depth N` (1 to 100000) is optional: link hops from the start URL. `1` =
31
+ the start page's links; `2` = also the links on each of those pages. Leave
32
+ it out for no depth limit: the crawl follows links until `--max-items` or
33
+ `--max-pages` stops it, or nothing is left to open. A crawl created without
34
+ a depth has no `depth` field in its output.
35
+ - `--max-items` (kept URLs) and `--max-pages` (fetched pages) cap the crawl;
36
+ both default to 100. `--max-pages` bounds the cost: each page is one fetch.
37
+ The local policy `max_pages_per_run` caps `--max-pages` (default 1000);
38
+ above it the CLI refuses with `POLICY_LIMIT_EXCEEDED` before any request.
39
+ - `--include-patterns` / `--exclude-patterns` are substring matches on the
40
+ normalized URL and repeat:
41
+ `--include-patterns /product/ --exclude-patterns ?add-to-cart`.
42
+ - `create` returns at once. `--follow` (or `zenrows crawl wait <id>`) polls
43
+ until the crawl ends or `--timeout` seconds run out (default 600). When the
44
+ time runs out, the command exits 0 and prints the crawl with status
45
+ `running` and the hint `zenrows crawl wait <id>` (under `--json`, in
46
+ `note`). The crawl keeps running;
47
+ it is not stopped. Ctrl-C also stops the wait, not the crawl, and exits 130;
48
+ resume with `zenrows crawl wait <id>`.
49
+ - `results` on a running crawl returns the URLs kept so far, with
50
+ `partial: true`. Read again once the crawl ends. `--limit` sets the page size
51
+ of each request.
52
+
53
+ ## Page HTML
54
+
55
+ ```
56
+ zenrows crawl create <url> --depth 1 --output-format html --max-pages 20 --follow
57
+ zenrows crawl results <id> --json # fetched results carry content_url
58
+ zenrows crawl content <id> <content_id> # one page's HTML (or pass the content_url)
59
+ zenrows crawl download <id> --out pages.jsonl # every URL + HTML, NDJSON
60
+ ```
61
+
62
+ `--output-format html` uses up the page budget: each kept page is one more
63
+ fetch, and `--max-pages` counts it. Raise `--max-pages` to keep the same number
64
+ of URLs. A result's `content_status` is `pending`, `fetched` or `failed`; only
65
+ `fetched` results have content (else `CRAWL_CONTENT_NOT_FOUND`). `download`
66
+ reports the crawl's `status`, and `partial: true` while it runs.
67
+
68
+ ## While it runs, and after
69
+
70
+ ```
71
+ zenrows crawl get <id> # status, coverage, stop_reason / error, one page of results
72
+ zenrows crawl get <id> --cursor <c> --json # the next page; keep polling with next_cursor
73
+ zenrows crawl list # your crawls, newest first (--cursor, --limit)
74
+ zenrows crawl stop <id> # stop it; kept URLs stay readable
75
+ ```
76
+
77
+ A crawl ends `completed` (nothing left, or a cap hit: `stop_reason`
78
+ `max_items` / `max_pages`), `stopped` (you stopped it) or `failed` (`error.code`
79
+ and `error.detail` say why). Only `failed` exits non-zero (`CRAWL_FAILED`).
80
+ `stop` on a crawl that already ended prints the status it ended with.
81
+
82
+ With `--json`, `create`, `get`, `wait` and `stop` print
83
+ `{ok, crawl_id, status, crawl}`; read `coverage`, `stop_reason` and `error`
84
+ under `crawl`. `get` adds `results` and `next_cursor`. `download` prints
85
+ `status: null` when the API sends no status.
86
+
87
+ ## Limits
88
+
89
+ When the account has reached its limit of active jobs (3 by default), shared
90
+ with its Batch jobs, a create gets 429 `too_many_crawls` →
91
+ `CRAWL_TOO_MANY_CRAWLS`; nothing was created. Retry after `retry_after`
92
+ seconds (about 30), or stop one of your crawls or Batch jobs first. Any other
93
+ 429 is `CRAWL_RATE_LIMITED`: requests are being throttled; retry after
94
+ `retry_after` seconds.
95
+
96
+ ## Errors
97
+
98
+ Every Crawl error carries the API's code as `server_code` (when the API sent
99
+ one), and `crawl_id` on any call about one crawl.
100
+
101
+ The CLI retries transient failures, as the Crawl SDKs do: up to 3 retries,
102
+ waiting the `Retry-After` seconds (at most 30) or 250 ms x 2^n (+/-20% jitter,
103
+ at most 10 s). Reads retry on 429, 502, 503, 504 and network errors (a timeout
104
+ counts). `create` retries only with `--idempotency-key`, and only on 502, 503,
105
+ 504 and network errors, never on 429. `stop` retries on 429, 502, 503 (including
106
+ `crawl_busy`) and 504, waiting the `Retry-After` seconds (at most 30) or 1 s.
107
+ The "Retry?" column is for errors that remain after these retries.
108
+
109
+ | Code | Meaning | Retry? |
110
+ | --- | --- | --- |
111
+ | `CRAWL_NOT_ENABLED` | 403 `REQS008`: Crawl is not enabled for this account | no |
112
+ | `AUTH_INVALID` | 401: the API key was rejected | no, log in again |
113
+ | `CRAWL_INSUFFICIENT_CREDITS` | 402: the account is out of credits (claim the account or add credits) | after credits renew or are added |
114
+ | `CRAWL_KEY_CAP_REACHED` | 402 `AUTH014` or `api_key_cap_reached`: this API key reached its credit cap | after the cap resets or is raised |
115
+ | `CRAWL_INVALID_REQUEST` | 400 or 422: fix the request. For `idempotency_key_reused`, send a new key or no key | no, not as is |
116
+ | `CRAWL_REQUEST_IN_FLIGHT` | 409: the same Idempotency-Key is still in flight | yes, once the first request ends |
117
+ | `CRAWL_NOT_FOUND` / `CRAWL_CONTENT_NOT_FOUND` | 404: wrong id, or the page has no content | no |
118
+ | `CRAWL_TOO_MANY_CRAWLS` | 429 `too_many_crawls` on `create`: the account's active-job limit | yes, after `retry_after` |
119
+ | `CRAWL_RATE_LIMITED` | any other 429: requests are being throttled | yes, after `retry_after` |
120
+ | `CRAWL_BUSY` | 503 `crawl_busy` on `stop`: the stop was not saved yet and the crawl still runs | yes, run the stop again after `retry_after` |
121
+ | `CRAWL_FAILED` | the crawl ended `failed`, or another error | 5xx: yes, with backoff |
122
+ | `BACKEND_UNAVAILABLE` | the API could not be reached | yes |
123
+
124
+ Rules: start small (`--depth 1`, low caps) and check the results before a
125
+ deep crawl. Mind the cost ([[cost-control]]). Read errors with
126
+ [[trace-debug]].
@@ -20,9 +20,10 @@ zenrows trace export <run-id> # JSON for sharing
20
20
 
21
21
  ## Failure → action map
22
22
  - `FETCH_FAILED` / empty content → first retry in auto mode (`mode=auto`), which
23
- escalates for you and bills only for what succeeds. For slow pages add
24
- `--wait-for <selector>`. Only when auto mode has failed on its own, take
25
- manual control with `--manual --js-render`, then `--premium-proxy`. Each step
23
+ escalates for you and bills only for what succeeds. Only when auto mode has
24
+ failed on its own, take manual control with `--manual --js-render`, then
25
+ `--premium-proxy`. For slow pages, add `--wait-for <selector>` to a
26
+ `--manual --js-render` run; auto mode may ignore it. Each step
26
27
  multiplies the cost of the request, and both together are the most expensive
27
28
  configuration available ([[cost-control]]).
28
29
  - `REQUEST_TIMEOUT` → the CLI stopped waiting; the API was reached. Raise
@@ -44,6 +45,18 @@ zenrows trace export <run-id> # JSON for sharing
44
45
  auto-created Free plan.
45
46
  - `BATCH_FAILED` → the batch run ended `failed`; `failure_reason` and
46
47
  `failure_detail` in `zenrows batch status <id> --json` say why.
48
+ - `CRAWL_NOT_ENABLED` → the API answered 403 `REQS008`: Crawl is not
49
+ enabled for this account. Do not retry; ask Zenrows support for access.
50
+ - `CRAWL_INSUFFICIENT_CREDITS` / `CRAWL_KEY_CAP_REACHED` → Crawl's 402: the
51
+ account is out of credits, or this key reached its credit cap (`AUTH014`).
52
+ Same advice as `POLICY_MAX_CREDITS_EXCEEDED` / `KEY_CREDIT_CAP_REACHED`.
53
+ - `CRAWL_TOO_MANY_CRAWLS` → 429 `too_many_crawls`: the account has reached
54
+ its limit of active jobs (3 by default), shared with its Batch jobs.
55
+ Nothing was created; retry after `retry_after` seconds (see
56
+ `zenrows crawl list`).
57
+ - `CRAWL_FAILED` → the crawl ended `failed`; `error.code` in
58
+ `zenrows crawl get <id> --json` says why, with `error.detail`. See
59
+ [[crawl]].
47
60
  - `PARAM_CONFLICT_AUTO_MANUAL` → drop the managed flags or add `--manual`.
48
61
  - `CAPABILITY_UNAVAILABLE` → the primitive is not available on this account (e.g. beta/invite-only); use the local-spec path where offered.
49
62