@zenrows/cli 1.5.5 → 1.6.1

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.
package/README.md CHANGED
@@ -203,7 +203,12 @@ run out (default 600). When the time runs out, the command exits 0 and prints
203
203
  the crawl with status `running` and the hint `zenrows crawl wait <crawl-id>`
204
204
  (under `--json`, in `note`); the crawl keeps running. Ctrl-C stops the wait, not the crawl, and exits 130;
205
205
  resume with `zenrows crawl wait <crawl-id>`. A crawl that ends `failed` exits 1
206
- with `CRAWL_FAILED`.
206
+ with `CRAWL_FAILED`. A crawl that ends `stopped` because the account ran out of
207
+ credits or the API key hit a credit cap also exits 1, with the code a refused
208
+ create gets: `CRAWL_INSUFFICIENT_CREDITS` or `CRAWL_KEY_CAP_REACHED`, and the
209
+ API's `insufficient_credits` or `api_key_cap_reached` as `server_code`. Older
210
+ deployments end such a crawl `failed`; it gets the same codes. Its fetched pages
211
+ stay readable.
207
212
 
208
213
  Errors carry the API's code as `server_code`, `crawl_id` on any call about one
209
214
  crawl, and `retry_after` on a 429. When the account has reached its limit of
@@ -15,7 +15,8 @@ import { assertDomainAllowed, assertWithinLimits, loadPolicy } from "../../core/
15
15
  import { newRunId, writeRun } from "../../core/artifacts.js";
16
16
  import { contentIdOf, createCrawl, downloadCrawl, getCrawl, getCrawlContent, isTerminal, listAllResults, listCrawls, stopCrawl, waitForCrawl, } from "../../core/crawl-api.js";
17
17
  import { asNumber, asString, parse } from "../command.js";
18
- import { ToolkitError } from "../../core/errors.js";
18
+ import { ToolkitError, isKeyCapReached, keyCapReached, quotaExhausted } from "../../core/errors.js";
19
+ import { readAccount } from "../../core/agent-account.js";
19
20
  import { printError, writeOut, writeOutStream } from "../output.js";
20
21
  /** The API's default for `max_pages` (and `max_items`) when the flag is unset. */
21
22
  const DEFAULT_MAX_PAGES = 100;
@@ -35,8 +36,11 @@ export const crawl = {
35
36
  " the local policy max_pages_per_run caps it (default 1000)",
36
37
  " --include-patterns <p> keep only URLs containing this substring (repeatable)",
37
38
  " --exclude-patterns <p> drop URLs containing this substring (repeatable)",
38
- " --output-format html also fetch each kept URL's page HTML (read with content/download);",
39
- " each kept page is one more fetch counted by --max-pages",
39
+ " --output-format <html|markdown> also fetch each kept URL's page, as HTML or Markdown",
40
+ " (read with content/download); each kept page is one more",
41
+ " fetch counted by --max-pages",
42
+ " --discovery links how the crawl finds the URLs it follows; links (the",
43
+ " default) follows the links in each page's HTML",
40
44
  " --follow wait until the crawl ends (Ctrl-C stops the wait, not the crawl)",
41
45
  " --timeout <s> with --follow: stop waiting after s seconds (default 600)",
42
46
  " --idempotency-key <key> so a retried create starts no second crawl",
@@ -44,13 +48,15 @@ export const crawl = {
44
48
  " get <id> [--cursor <c>] [--limit <n>]",
45
49
  " the crawl and one page of its results (limit up to 10000)",
46
50
  " list [--cursor <c>] [--limit <n>] your crawls, newest first (limit up to 100)",
47
- " results <id> [--limit <n>] every kept URL as JSONL; on a running crawl, those kept so far",
48
- " (partial). --limit is the page size per request",
51
+ " results <id> [--limit <n>] every kept URL as JSONL; partial while more can come (a running",
52
+ " crawl, or pages still in flight after a stop). --limit is the page size",
53
+ " per request",
49
54
  " --out <file> write the results to a file instead of printing",
50
55
  " content <id> <content_id> print one kept URL's page (content_id or content_url)",
51
56
  " --out <file> write it to a file instead",
52
57
  " download <id> [--out <file>] save the NDJSON export (with page HTML for html crawls),",
53
- " default <id>.jsonl; partial while the crawl runs",
58
+ " default <id>.jsonl; partial while more can come (a running",
59
+ " crawl, or pages still in flight after a stop)",
54
60
  " stop <id> stop a running crawl (an ended crawl answers as it ended)",
55
61
  " wait <id> [--timeout <s>] poll until the crawl ends or s seconds (default 600) run out;",
56
62
  " a crawl still running then is printed, not stopped",
@@ -102,6 +108,7 @@ async function createCmd(rest, ctx) {
102
108
  "include-patterns": { type: "string", multiple: true },
103
109
  "exclude-patterns": { type: "string", multiple: true },
104
110
  "output-format": { type: "string" },
111
+ discovery: { type: "string", multiple: true },
105
112
  follow: { type: "boolean" },
106
113
  timeout: { type: "string" },
107
114
  "idempotency-key": { type: "string" },
@@ -137,16 +144,29 @@ async function createCmd(rest, ctx) {
137
144
  params.exclude_patterns = exclude;
138
145
  const outputFormat = asString(values["output-format"]);
139
146
  if (outputFormat !== undefined) {
140
- if (outputFormat !== "html") {
147
+ if (outputFormat !== "html" && outputFormat !== "markdown") {
141
148
  throw new ToolkitError({
142
149
  code: "INVALID_USAGE",
143
150
  message: `Invalid --output-format value '${outputFormat}'.`,
144
- likely_cause: "--output-format takes html only. Without it, a crawl returns URLs only.",
145
- next_action: "Pass --output-format html, or leave it out.",
151
+ likely_cause: "--output-format takes html or markdown. Without it, a crawl returns URLs only.",
152
+ next_action: "Pass --output-format html or --output-format markdown, or leave it out.",
146
153
  });
147
154
  }
148
155
  params.output_format = outputFormat;
149
156
  }
157
+ const discovery = patterns(values.discovery);
158
+ if (discovery.length) {
159
+ const other = discovery.find((d) => d !== "links");
160
+ if (other !== undefined) {
161
+ throw new ToolkitError({
162
+ code: "INVALID_USAGE",
163
+ message: `Invalid --discovery value '${other}'.`,
164
+ likely_cause: "--discovery takes links. Without it, the crawl follows the links in each page's HTML.",
165
+ next_action: "Pass --discovery links, or leave it out.",
166
+ });
167
+ }
168
+ params.discovery = ["links"];
169
+ }
150
170
  const timeoutMs = timeoutFlag(values.timeout);
151
171
  const idempotencyKey = asString(values["idempotency-key"]);
152
172
  assertUsable("crawl");
@@ -269,14 +289,17 @@ async function downloadCmd(rest, ctx) {
269
289
  assertUsable("crawl");
270
290
  const apiKey = requireApiKey();
271
291
  const { status, count } = await downloadCrawl(id, (ndjson) => writeOutStream(file, ndjson), { apiKey });
272
- const partial = status === "running";
292
+ // A stop reads `stopped` at once, but the API still reads the pages in flight for up to 10
293
+ // minutes and builds the file from what it has kept until then. Its next_cursor says when it is done.
294
+ const partial = status === "running" || (status === "stopped" && (await listAllResults(id, { apiKey, pageSize: 10_000 })).partial);
295
+ const note = partial ? downloadNote(status) : undefined;
273
296
  if (json) {
274
- log.out(JSON.stringify({ ok: true, crawl_id: id, status: status ?? null, partial, count, file }, null, 2));
297
+ log.out(JSON.stringify({ ok: true, crawl_id: id, status: status ?? null, partial, ...(note ? { note } : {}), count, file }, null, 2));
275
298
  }
276
299
  else {
277
300
  log.success(`Downloaded ${count} result(s) → ${file}`);
278
- if (partial)
279
- log.warn(" The crawl is still running: this file is partial. Download again once it ends.");
301
+ if (note)
302
+ log.warn(` ${note}`);
280
303
  }
281
304
  return 0;
282
305
  }
@@ -284,9 +307,10 @@ async function downloadCmd(rest, ctx) {
284
307
  function printResults(id, status, results, o) {
285
308
  if (o.outFile)
286
309
  writeOut(o.outFile, toJsonl(results));
310
+ const note = o.partial ? partialNote(status) : undefined;
287
311
  if (o.json) {
288
312
  const rows = o.outFile ? { file: o.outFile } : { results };
289
- log.out(JSON.stringify({ ok: true, crawl_id: id, status, count: results.length, ...rows, partial: o.partial }, null, 2));
313
+ log.out(JSON.stringify({ ok: true, crawl_id: id, status, count: results.length, ...rows, partial: o.partial, ...(note ? { note } : {}) }, null, 2));
290
314
  return 0;
291
315
  }
292
316
  if (o.outFile) {
@@ -296,10 +320,20 @@ function printResults(id, status, results, o) {
296
320
  log.info(`${results.length} result(s) for crawl ${id} (status: ${status}):`);
297
321
  log.out(toJsonl(results).trimEnd());
298
322
  }
299
- if (o.partial)
300
- log.warn(" The crawl is still running: these results are partial. Read them again once it ends.");
323
+ if (note)
324
+ log.warn(` ${note}`);
301
325
  return 0;
302
326
  }
327
+ function downloadNote(status) {
328
+ return status === "running"
329
+ ? "The crawl is still running: this file is partial. Download again once it ends."
330
+ : "The crawl was stopped, but pages fetched before the stop can still add lines for up to 10 minutes: download again later.";
331
+ }
332
+ function partialNote(status) {
333
+ return isTerminal(status)
334
+ ? "The crawl ended, but pages fetched before the end can still add URLs for up to 10 minutes: read the results again later."
335
+ : "The crawl is still running: these results are partial. Read them again once it ends.";
336
+ }
303
337
  async function contentCmd(rest, ctx) {
304
338
  const { values, positionals } = parse(rest, { out: { type: "string" }, json: { type: "boolean" } });
305
339
  const json = ctx.json || values.json === true;
@@ -382,7 +416,8 @@ async function stopCmd(rest, ctx) {
382
416
  assertUsable("crawl");
383
417
  const apiKey = requireApiKey();
384
418
  const s = await stopCrawl(id, { apiKey });
385
- const headline = s.status === "stopped" ? `Stopped crawl ${id}` : `Nothing to stop: crawl ${id} had already ended`;
419
+ // A crawl stopped on credits carries `error`: it had already ended, this call did not stop it.
420
+ const headline = s.status === "stopped" && !s.error ? `Stopped crawl ${id}` : `Nothing to stop: crawl ${id} had already ended`;
386
421
  return printCrawl(s, ctx.json || values.json === true, headline);
387
422
  }
388
423
  /**
@@ -404,33 +439,50 @@ async function waitUntilEnd(id, opts) {
404
439
  }
405
440
  }
406
441
  /**
407
- * The error for a crawl that ended `failed`, or null for any other state. The
408
- * crawl's own `error.code` picks the next action.
442
+ * The error for a crawl that ended `failed`, or `stopped` with an `error` (the
443
+ * account ran out of credits or the API key hit a credit cap), or null for any
444
+ * other state. A crawl the credits ended gets the code and advice a create
445
+ * refused with 402 gets (`CRAWL_INSUFFICIENT_CREDITS`, `CRAWL_KEY_CAP_REACHED`),
446
+ * whether the API reports it `stopped` or, as older deployments do, `failed`.
447
+ * Any other `error.code` picks the next action of a `CRAWL_FAILED`.
409
448
  */
410
449
  export function crawlFailure(c) {
411
- if (c.status !== "failed")
450
+ const creditStop = c.status === "stopped" && !!c.error;
451
+ if (c.status !== "failed" && !creditStop)
412
452
  return null;
413
453
  const code = c.error?.code;
454
+ const ids = { server_code: code, crawl_id: c.crawl_id };
455
+ const where = `crawl ${c.crawl_id}`;
456
+ const detail = c.error?.detail || undefined;
457
+ const getCmd = `zenrows crawl get ${c.crawl_id}`;
458
+ if (code && isKeyCapReached(code)) {
459
+ const cap = keyCapReached(where, { status: null, detail }).toJSON();
460
+ return new ToolkitError({ ...cap, ...ids, code: "CRAWL_KEY_CAP_REACHED", suggested_commands: [...(cap.suggested_commands ?? []), getCmd] });
461
+ }
462
+ if (code === "insufficient_credits") {
463
+ const acct = readAccount();
464
+ const quota = quotaExhausted(where, acct?.unclaimed ? acct.claimUrl : undefined, { status: null, detail }).toJSON();
465
+ return new ToolkitError({ ...quota, ...ids, code: "CRAWL_INSUFFICIENT_CREDITS", suggested_commands: [...(quota.suggested_commands ?? []), getCmd] });
466
+ }
414
467
  const why = [code, c.error?.detail].filter(Boolean).join(": ");
415
468
  const next = {
416
- insufficient_credits: "The account ran out of credits. Check `zenrows usage`, add credits, then start a new crawl.",
417
469
  seed_unreachable: "The start URL could not be fetched (the site may be blocking it). Check it with `zenrows fetch <url>`.",
418
470
  no_items_found: "Nothing matched. Loosen --include-patterns / --exclude-patterns, raise or drop --depth, or check the start page with `zenrows fetch <url>`.",
419
471
  };
420
472
  return new ToolkitError({
421
473
  code: "CRAWL_FAILED",
422
- message: `Crawl ${c.crawl_id} failed.`,
474
+ message: creditStop ? `Crawl ${c.crawl_id} stopped before it finished.` : `Crawl ${c.crawl_id} failed.`,
423
475
  likely_cause: why || "The crawl ended in status failed without a reason.",
424
476
  next_action: (code && next[code]) || "Read the error, fix the cause, then start a new crawl.",
425
- suggested_commands: [`zenrows crawl get ${c.crawl_id}`],
426
- server_code: code,
427
- crawl_id: c.crawl_id,
477
+ suggested_commands: [getCmd],
478
+ ...ids,
428
479
  });
429
480
  }
430
481
  /**
431
482
  * Print a crawl (or a stop answer), structured under --json, and return the exit
432
- * code: 1 when it ended `failed`, else 0. `stopped` is a deliberate outcome, so
433
- * it exits 0 but prints as a warning. `extra` carries the results a `get` read,
483
+ * code: 1 when it ended `failed`, or `stopped` because credits ran out or the
484
+ * API key hit a credit cap, else 0. A `stopped` crawl the caller stopped is a
485
+ * deliberate outcome, so it exits 0 but prints as a warning. `extra` carries the results a `get` read,
434
486
  * or the note of a wait that ran out.
435
487
  */
436
488
  function printCrawl(c, json, headline, extra = {}) {
@@ -21,6 +21,7 @@ import { readAccount } from "./agent-account.js";
21
21
  import { registerSecret } from "./logger.js";
22
22
  import { CLI_USER_AGENT } from "./config.js";
23
23
  import { agentClientHeader } from "./agent-client.js";
24
+ import { BILLING_URL } from "./open-url.js";
24
25
  /** Confirmed Batch API base (no trailing slash). */
25
26
  export const DEFAULT_BATCH_API_BASE = "https://async.api.zenrows.com/v1";
26
27
  /** Env var to override the Batch API base (local/staging testing). */
@@ -116,6 +117,17 @@ function problemToError(status, body, method, path) {
116
117
  suggested_commands: ["zenrows batch estimate jobs.jsonl"],
117
118
  });
118
119
  }
120
+ if (status === 401 && serverCode === "subscription_inactive") {
121
+ // The key is fine; the subscription needs a billing action. Same
122
+ // AUTH_INVALID code as any 401 so callers that branch on it keep
123
+ // working; only the guidance changes, since re-entering the key won't help.
124
+ return new ToolkitError({
125
+ code: "AUTH_INVALID",
126
+ message: "Zenrows refused the Batch API request because the subscription is not active.",
127
+ likely_cause: `${cause.replace(/\.?$/, ".")} The API key is valid, but the subscription is past due, unpaid, incomplete or paused.`,
128
+ next_action: `Fix the subscription in Billing (${BILLING_URL}), or in Azure Marketplace if you subscribed there, then retry.`,
129
+ });
130
+ }
119
131
  if (status === 401) {
120
132
  return new ToolkitError({
121
133
  code: "AUTH_INVALID",
@@ -14,7 +14,7 @@ export const CONFIG_VERSION = "0.1.0";
14
14
  * entrypoint) so core modules — telemetry, signup provenance — can read it
15
15
  * without an import cycle. `VERSION` in `cli/index.ts` re-exports this.
16
16
  */
17
- export const CLI_VERSION = "1.5.5"; // x-release-please-version
17
+ export const CLI_VERSION = "1.6.1"; // x-release-please-version
18
18
  /** The User-Agent on every request to a Zenrows API. The gateway reads the version from it. */
19
19
  export const CLI_USER_AGENT = `zenrows-cli/${CLI_VERSION}`;
20
20
  /** Env var to override the Fetch and Extract API base (local/staging testing). */
@@ -10,7 +10,7 @@
10
10
  * Crawl SDKs do (see `retryStatuses`). `fetchImpl` / `sleepImpl` are injectable
11
11
  * for tests.
12
12
  *
13
- * `output_format` takes `html` only (absent = URLs only). Response fields we do
13
+ * `output_format` takes `html` or `markdown` (absent = URLs only). Response fields we do
14
14
  * not model pass through untouched.
15
15
  */
16
16
  import { Readable } from "node:stream";
@@ -297,6 +297,8 @@ export function createCrawl(params, opts) {
297
297
  body.exclude_patterns = params.exclude_patterns;
298
298
  if (params.output_format !== undefined)
299
299
  body.output_format = params.output_format;
300
+ if (params.discovery?.length)
301
+ body.discovery = params.discovery;
300
302
  return crawlJson("POST", "/crawls", {
301
303
  ...opts,
302
304
  body,
@@ -319,7 +321,7 @@ export function listCrawls(opts) {
319
321
  export function stopCrawl(id, opts) {
320
322
  return crawlJson("POST", `/crawls/${encodeURIComponent(id)}/stop`, { ...opts, crawlId: id, retryStop: true });
321
323
  }
322
- /** The page of one kept URL (HTML for `output_format: html`). */
324
+ /** The page of one kept URL, in the crawl's `output_format`. */
323
325
  export async function getCrawlContent(id, contentId, opts) {
324
326
  const res = await crawlFetch("GET", `/crawls/${encodeURIComponent(id)}/contents/${encodeURIComponent(contentId)}`, {
325
327
  ...opts,
@@ -390,9 +392,9 @@ export async function listAllResults(id, opts) {
390
392
  const rows = page.results ?? [];
391
393
  results.push(...rows);
392
394
  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) };
395
+ // Every page was read. The API keeps next_cursor set while URLs can still come: on a
396
+ // running crawl, and for up to 10 minutes after a stop, from the pages still in flight.
397
+ return { crawl: page, results, partial: page.next_cursor != null };
396
398
  }
397
399
  cursor = page.next_cursor;
398
400
  }
@@ -45,7 +45,9 @@ export class ToolkitError extends Error {
45
45
  * (so `claimUrl` is surfaced); for a real logged-in account there is nothing to
46
46
  * claim — the step is to add credits / upgrade in the dashboard. `status`/`detail`
47
47
  * let the caller record the actual upstream response (402 usage-limit, 429
48
- * quota, …) instead of a hardcoded status.
48
+ * quota, …) instead of a hardcoded status. `status: null` is for a crawl the
49
+ * credits stopped: its status call succeeded, so there is no HTTP error to cite,
50
+ * only `url` (the crawl).
49
51
  */
50
52
  export function quotaExhausted(url, claimUrl, opts = {}) {
51
53
  // Say that the allowance comes back. Without it this reads as a permanent paywall,
@@ -58,10 +60,11 @@ export function quotaExhausted(url, claimUrl, opts = {}) {
58
60
  ? `You are on the Zenrows Free plan. Claim your account to keep your usage and add credits: ${claimUrl}. ${renewLine}`
59
61
  : `You are out of Zenrows credits. ${renewLine} To carry on now, add a credit pack (${BILLING_TOPUP_URL} opens the purchase directly) or upgrade your plan: ${PLANS_URL}`;
60
62
  const detail = opts.detail ? `${opts.detail.replace(/\.\s*$/, "")}. ` : "";
63
+ const where = opts.status === null ? `Stopped ${url}` : `HTTP ${opts.status ?? 429} for ${url}`;
61
64
  return new ToolkitError({
62
65
  code: "POLICY_MAX_CREDITS_EXCEEDED",
63
66
  message: "Zenrows request quota exhausted.",
64
- likely_cause: `${detail}HTTP ${opts.status ?? 429} for ${url}`,
67
+ likely_cause: `${detail}${where}`,
65
68
  next_action: claimLine,
66
69
  suggested_commands: ["zenrows usage"],
67
70
  });
@@ -12,6 +12,7 @@ export const DASHBOARD_URL = "https://app.zenrows.com/dashboard";
12
12
  /** Lands on Billing with the credit-pack purchase modal already open (app PR #1972). */
13
13
  export const BILLING_TOPUP_URL = "https://app.zenrows.com/billing?topup=open";
14
14
  export const PLANS_URL = "https://app.zenrows.com/plans";
15
+ export const BILLING_URL = "https://app.zenrows.com/billing";
15
16
  export const DOCS_URL = "https://docs.zenrows.com";
16
17
  /** Append query params to a URL (used to attach anonymous `utm_*` attribution). */
17
18
  export function withParams(url, params) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zenrows/cli",
3
- "version": "1.5.5",
3
+ "version": "1.6.1",
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",
@@ -11,8 +11,8 @@ Give Crawl one start URL; it follows the links on that page (and on the
11
11
  pages behind it, up to the depth you set) and returns the URLs it keeps. Use it
12
12
  when you know where the items live (a category page, a blog index, a docs
13
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`.
14
+ with `zenrows fetch` / `zenrows extract`, or ask Crawl for each page itself
15
+ with `--output-format html` or `--output-format markdown`.
16
16
 
17
17
  Crawl stays on the start URL's registrable domain; subdomains count. An account without Crawl access gets
18
18
  403 `REQS008` → `CRAWL_NOT_ENABLED` ("Crawl is not enabled for this account");
@@ -50,16 +50,18 @@ zenrows crawl results <id> --out urls.jsonl # same, as JSONL
50
50
  `partial: true`. Read again once the crawl ends. `--limit` sets the page size
51
51
  of each request.
52
52
 
53
- ## Page HTML
53
+ ## Page content
54
54
 
55
55
  ```
56
56
  zenrows crawl create <url> --depth 1 --output-format html --max-pages 20 --follow
57
+ zenrows crawl create <url> --depth 1 --output-format markdown --max-pages 20 --follow
57
58
  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
59
+ zenrows crawl content <id> <content_id> # one page (or pass the content_url)
60
+ zenrows crawl download <id> --out pages.jsonl # every URL + page, NDJSON
60
61
  ```
61
62
 
62
- `--output-format html` uses up the page budget: each kept page is one more
63
+ `--output-format html` stores each page as fetched; `--output-format markdown`
64
+ stores it converted to Markdown. Either uses up the page budget: each kept page is one more
63
65
  fetch, and `--max-pages` counts it. Raise `--max-pages` to keep the same number
64
66
  of URLs. A result's `content_status` is `pending`, `fetched` or `failed`; only
65
67
  `fetched` results have content (else `CRAWL_CONTENT_NOT_FOUND`). `download`
@@ -75,8 +77,15 @@ zenrows crawl stop <id> # stop it; kept URLs stay readable
75
77
  ```
76
78
 
77
79
  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
+ `max_items` / `max_pages`), `stopped` or `failed` (`error.code` and
81
+ `error.detail` say why). `stopped` with `stop_reason` `user` means you stopped
82
+ it. `stopped` with no `stop_reason` and `error.code` `insufficient_credits` or
83
+ `api_key_cap_reached` means the account ran out of credits or the API key hit a
84
+ credit cap; the pages fetched before that stay readable. `failed`, and a
85
+ `stopped` crawl with an `error`, exit non-zero: `CRAWL_INSUFFICIENT_CREDITS` or
86
+ `CRAWL_KEY_CAP_REACHED` when credits ended it (the same codes a refused
87
+ `create` gets, also when an older deployment reports it `failed`), else
88
+ `CRAWL_FAILED`. `server_code` carries the API's `error.code`.
80
89
  `stop` on a crawl that already ended prints the status it ended with.
81
90
 
82
91
  With `--json`, `create`, `get`, `wait` and `stop` print
@@ -110,15 +119,15 @@ The "Retry?" column is for errors that remain after these retries.
110
119
  | --- | --- | --- |
111
120
  | `CRAWL_NOT_ENABLED` | 403 `REQS008`: Crawl is not enabled for this account | no |
112
121
  | `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 |
122
+ | `CRAWL_INSUFFICIENT_CREDITS` | 402 on `create`, or a crawl that ended on `insufficient_credits`: the account is out of credits (claim the account or add credits) | after credits renew or are added |
123
+ | `CRAWL_KEY_CAP_REACHED` | 402 `AUTH014` or `api_key_cap_reached` on `create`, or a crawl that ended on `api_key_cap_reached`: this API key reached its credit cap | after the cap resets or is raised |
115
124
  | `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
125
  | `CRAWL_REQUEST_IN_FLIGHT` | 409: the same Idempotency-Key is still in flight | yes, once the first request ends |
117
126
  | `CRAWL_NOT_FOUND` / `CRAWL_CONTENT_NOT_FOUND` | 404: wrong id, or the page has no content | no |
118
127
  | `CRAWL_TOO_MANY_CRAWLS` | 429 `too_many_crawls` on `create`: the account's active-job limit | yes, after `retry_after` |
119
128
  | `CRAWL_RATE_LIMITED` | any other 429: requests are being throttled | yes, after `retry_after` |
120
129
  | `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 |
130
+ | `CRAWL_FAILED` | the crawl ended `failed`, or `stopped` with an error other than credits, or another error | 5xx: yes, with backoff |
122
131
  | `BACKEND_UNAVAILABLE` | the API could not be reached | yes |
123
132
 
124
133
  Rules: start small (`--depth 1`, low caps) and check the results before a
@@ -49,12 +49,16 @@ zenrows trace export <run-id> # JSON for sharing
49
49
  enabled for this account. Do not retry; ask Zenrows support for access.
50
50
  - `CRAWL_INSUFFICIENT_CREDITS` / `CRAWL_KEY_CAP_REACHED` → Crawl's 402: the
51
51
  account is out of credits, or this key reached its credit cap (`AUTH014`).
52
+ The same codes come back for a crawl that credits ended (`stopped`, or
53
+ `failed` on older deployments; `server_code` `insufficient_credits` /
54
+ `api_key_cap_reached`); its fetched pages stay readable.
52
55
  Same advice as `POLICY_MAX_CREDITS_EXCEEDED` / `KEY_CREDIT_CAP_REACHED`.
53
56
  - `CRAWL_TOO_MANY_CRAWLS` → 429 `too_many_crawls`: the account has reached
54
57
  its limit of active jobs (3 by default), shared with its Batch jobs.
55
58
  Nothing was created; retry after `retry_after` seconds (see
56
59
  `zenrows crawl list`).
57
- - `CRAWL_FAILED` → the crawl ended `failed`; `error.code` in
60
+ - `CRAWL_FAILED` → the crawl ended `failed`, or `stopped` with an error
61
+ other than credits; `error.code` in
58
62
  `zenrows crawl get <id> --json` says why, with `error.detail`. See
59
63
  [[crawl]].
60
64
  - `PARAM_CONFLICT_AUTO_MANUAL` → drop the managed flags or add `--manual`.