@zenrows/cli 1.11.0 → 1.13.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.
package/CHANGELOG.md ADDED
@@ -0,0 +1,102 @@
1
+ # Changelog
2
+
3
+ ## [1.13.0](https://github.com/ZenRows/cli/compare/v1.12.0...v1.13.0) (2026-11-06)
4
+
5
+
6
+ ### Features
7
+
8
+ * **fetch:** add --retries to fetch and extract, off by default, following the shared retry policy ([ea4d557](https://github.com/ZenRows/cli/commit/ea4d5578fef496d0a01123259c139dd386ee55a1))
9
+
10
+ ## [1.12.0](https://github.com/ZenRows/cli/compare/v1.11.0...v1.12.0) (2026-10-30)
11
+
12
+
13
+ ### Features
14
+
15
+ * **crawl:** get notified by webhook when a crawl starts, completes, stops or fails ([#75](https://github.com/ZenRows/cli/issues/75)) ([d71df97](https://github.com/ZenRows/cli/commit/d71df972a6a9e29d619d5c8a829022c6156530ea))
16
+
17
+
18
+ ### Bug Fixes
19
+
20
+ * **batch:** stop a wait before a back-off that ends on its --timeout ([#76](https://github.com/ZenRows/cli/issues/76)) ([090c75f](https://github.com/ZenRows/cli/commit/090c75f4fabbfc19f86c2c1c6c79354175677636))
21
+
22
+ ## [1.11.0](https://github.com/ZenRows/cli/compare/v1.10.0...v1.11.0) (2026-10-27)
23
+
24
+
25
+ ### Features
26
+
27
+ * **extract:** steer prepare callers to send several example URLs of one page type ([#72](https://github.com/ZenRows/cli/issues/72)) ([c8dc644](https://github.com/ZenRows/cli/commit/c8dc644d7b04d6a59ae8a72900961438e3b76b6f))
28
+
29
+ ## [1.10.0](https://github.com/ZenRows/cli/compare/v1.9.0...v1.10.0) (2026-10-23)
30
+
31
+
32
+ ### Features
33
+
34
+ * **signup:** agents could not get past the signup question, which only surfaced as a raw HTTP 428 body ([#73](https://github.com/ZenRows/cli/issues/73)) ([eac6f7a](https://github.com/ZenRows/cli/commit/eac6f7a625fb51ab081f282a428966f187493cb6))
35
+
36
+
37
+ ### Bug Fixes
38
+
39
+ * **skills:** name the local storage tools the MCP server registers since 2.15.0 ([#70](https://github.com/ZenRows/cli/issues/70)) ([eb61828](https://github.com/ZenRows/cli/commit/eb61828df42df32bf1b6c497caf22185ed6ec545))
40
+
41
+ ## [1.9.0](https://github.com/ZenRows/cli/compare/v1.8.0...v1.9.0) (2026-10-20)
42
+
43
+
44
+ ### Features
45
+
46
+ * **extract:** remove the autoparse fallback and add zenrows extract prepare ([#47](https://github.com/ZenRows/cli/issues/47)) ([88a33d6](https://github.com/ZenRows/cli/commit/88a33d67f0c6c94accde1061f9ea10e7911f047b))
47
+
48
+ ## [1.8.0](https://github.com/ZenRows/cli/compare/v1.7.0...v1.8.0) (2026-10-20)
49
+
50
+
51
+ ### Features
52
+
53
+ * **batch:** agents could not recover, inspect or manage batch jobs from the CLI ([#67](https://github.com/ZenRows/cli/issues/67)) ([a850ea4](https://github.com/ZenRows/cli/commit/a850ea437b013cf10e584e6b87366b94037ae81b))
54
+
55
+ ## [1.7.0](https://github.com/ZenRows/cli/compare/v1.6.2...v1.7.0) (2026-10-18)
56
+
57
+
58
+ ### Features
59
+
60
+ * **account:** claim links expire after 30 days and accounts get claimed in the browser, so refresh the stored link and stop offering it once claimed ([#65](https://github.com/ZenRows/cli/issues/65)) ([ded5602](https://github.com/ZenRows/cli/commit/ded5602caa300aa288f6532a27abd38de83362a1))
61
+
62
+ ## [1.6.2](https://github.com/ZenRows/cli/compare/v1.6.1...v1.6.2) (2026-10-16)
63
+
64
+
65
+ ### Bug Fixes
66
+
67
+ * **crawl:** say a crawl with nothing new to follow completes, so users stop chasing their patterns ([#50](https://github.com/ZenRows/cli/issues/50)) ([0301d47](https://github.com/ZenRows/cli/commit/0301d47036a8a8dacd7549c1aacf0f3c29be5afe))
68
+
69
+ ## [1.6.1](https://github.com/ZenRows/cli/compare/v1.6.0...v1.6.1) (2026-10-16)
70
+
71
+
72
+ ### Bug Fixes
73
+
74
+ * **crawl:** exit 1 when a crawl stops because credits ran out ([#49](https://github.com/ZenRows/cli/issues/49)) ([8661688](https://github.com/ZenRows/cli/commit/8661688d97029f0ab8ff18fe1a0ce5cb54f6b416))
75
+
76
+ ## [1.6.0](https://github.com/ZenRows/cli/compare/v1.5.5...v1.6.0) (2026-10-16)
77
+
78
+
79
+ ### Features
80
+
81
+ * **crawl:** return each page as Markdown, and choose how a crawl finds URLs ([#44](https://github.com/ZenRows/cli/issues/44)) ([6cd34a2](https://github.com/ZenRows/cli/commit/6cd34a2de8ed2c7a5fcfedc4ecddc8f1b8480a81))
82
+
83
+
84
+ ### Bug Fixes
85
+
86
+ * **batch:** point an inactive subscription to Billing, not to re-login ([#45](https://github.com/ZenRows/cli/issues/45)) ([58a1229](https://github.com/ZenRows/cli/commit/58a12297728690af3d45315fe74a4b6438e1935f))
87
+
88
+ ## [1.5.5](https://github.com/ZenRows/cli/compare/v1.5.4...v1.5.5) (2026-10-15)
89
+
90
+
91
+ ### Bug Fixes
92
+
93
+ * **release:** npm rejected the 1.5.4 publish because the repository URL casing did not match the provenance ([#54](https://github.com/ZenRows/cli/issues/54)) ([f778864](https://github.com/ZenRows/cli/commit/f7788649d7f834ba01b88e1bfe45dfd0cb67518d))
94
+
95
+ ## [1.5.4](https://github.com/ZenRows/cli/compare/v1.5.3...v1.5.4) (2026-10-15)
96
+
97
+
98
+ ### Bug Fixes
99
+
100
+ * **skills:** strict YAML parsers skipped the crawl skill over an unquoted colon ([#51](https://github.com/ZenRows/cli/issues/51)) ([fdd9eaa](https://github.com/ZenRows/cli/commit/fdd9eaa839f041c27b6f7f8d1ef97f183983efb9))
101
+
102
+ Releases before 1.5.4 are on [GitHub Releases](https://github.com/ZenRows/cli/releases).
package/README.md CHANGED
@@ -125,8 +125,22 @@ zenrows fetch <url> # Adaptive Stealth Mode (mode=auto)
125
125
  zenrows fetch <url> --output markdown
126
126
  zenrows fetch <url> --manual --js-render --premium-proxy
127
127
  zenrows fetch <url> --proxy-country us --wait-for ".price"
128
+ zenrows fetch <url> --retries 2 # retry a busy answer up to 2 times; off by default
128
129
  ```
129
130
 
131
+ `fetch` and `extract` send each request once. With `--retries <n>`, they retry
132
+ up to `n` times on a 429, 502, 503 or 504 from the API, and on a network error
133
+ before the request reached the API (refused connection, DNS failure, connect
134
+ timeout, unreachable network). Each wait is the `Retry-After` header in whole
135
+ seconds (0.25 s to 30 s) or a random delay that grows with each retry, and no
136
+ retry starts more than 90 s after the first request. They never retry a client
137
+ timeout (`--timeout`) or a connection that closed after the request was sent,
138
+ because the API may still finish and charge it; a status the target site sent
139
+ (`--original-status`); or any request of `zenrows extract prepare`. If a 502,
140
+ 503 or 504 is still the answer after one or more retries, the error says how
141
+ many retries ran and to wait a minute and run the same command again, without
142
+ suggesting costlier options. A 429 keeps the error it has without `--retries`.
143
+
130
144
  ## 8. Extract
131
145
 
132
146
  ```bash
@@ -100,6 +100,6 @@ projectRoot) {
100
100
  validateAutoManual(opts, config);
101
101
  const params = buildParams(opts, config);
102
102
  const mode = params.mode === "auto" ? "auto" : "manual";
103
- const result = await scrape(config.apiBase, apiKey, params, { timeoutMs: opts.timeoutMs, projectRoot });
103
+ const result = await scrape(config.apiBase, apiKey, params, { timeoutMs: opts.timeoutMs, retries: opts.retries, projectRoot });
104
104
  return { result, params, mode };
105
105
  }
@@ -13,7 +13,7 @@ import { ensureApiKey } from "../../core/ensure-key.js";
13
13
  import { assertUsable } from "../../core/capabilities.js";
14
14
  import { assertDomainAllowed, assertWithinLimits, loadPolicy } from "../../core/policy.js";
15
15
  import { newRunId, writeRun } from "../../core/artifacts.js";
16
- import { contentIdOf, createCrawl, downloadCrawl, getCrawl, getCrawlContent, isTerminal, listAllResults, listCrawls, stopCrawl, waitForCrawl, } from "../../core/crawl-api.js";
16
+ import { contentIdOf, createCrawl, downloadCrawl, getCrawl, getCrawlContent, isTerminal, listAllResults, listCrawls, stopCrawl, waitForCrawl, CRAWL_WEBHOOK_EVENTS, WEBHOOK_METADATA_LIMITS, } from "../../core/crawl-api.js";
17
17
  import { asNumber, asString, parse } from "../command.js";
18
18
  import { ToolkitError, isKeyCapReached, keyCapReached, quotaExhausted } from "../../core/errors.js";
19
19
  import { readAccount } from "../../core/agent-account.js";
@@ -42,6 +42,13 @@ export const crawl = {
42
42
  " fetch counted by --max-pages",
43
43
  " --discovery links how the crawl finds the URLs it follows; links (the",
44
44
  " default) follows the links in each page's HTML",
45
+ " --webhook <url> post the crawl's events to this https URL, signed with the",
46
+ " account's webhook signing key (Settings → API Keys)",
47
+ " --webhook-event <event> with --webhook: send only this event (repeatable); one of",
48
+ " crawl.started, crawl.completed, crawl.stopped, crawl.failed;",
49
+ " leave it out for all four",
50
+ " --webhook-metadata <key=value> with --webhook: sent back with every event (repeatable,",
51
+ " up to 20)",
45
52
  " --follow wait until the crawl ends (Ctrl-C stops the wait, not the crawl)",
46
53
  " --timeout <s> with --follow: stop waiting after s seconds (default 600)",
47
54
  " --idempotency-key <key> so a retried create starts no second crawl",
@@ -113,6 +120,9 @@ async function createCmd(rest, ctx) {
113
120
  "exclude-patterns": { type: "string", multiple: true },
114
121
  "output-format": { type: "string" },
115
122
  discovery: { type: "string", multiple: true },
123
+ webhook: { type: "string" },
124
+ "webhook-event": { type: "string", multiple: true },
125
+ "webhook-metadata": { type: "string", multiple: true },
116
126
  follow: { type: "boolean" },
117
127
  timeout: { type: "string" },
118
128
  "idempotency-key": { type: "string" },
@@ -171,6 +181,9 @@ async function createCmd(rest, ctx) {
171
181
  }
172
182
  params.discovery = ["links"];
173
183
  }
184
+ const webhook = webhookOf(values);
185
+ if (webhook)
186
+ params.webhook = webhook;
174
187
  const timeoutMs = timeoutFlag(values.timeout);
175
188
  const idempotencyKey = asString(values["idempotency-key"]);
176
189
  assertUsable("crawl");
@@ -531,6 +544,11 @@ function printCrawl(c, json, headline, extra = {}) {
531
544
  if (cov) {
532
545
  log.info(` ${cov.items_found} item(s) found · ${cov.pages_fetched} page(s) fetched · ${cov.pages_failed} failed`);
533
546
  }
547
+ const hook = c.webhook;
548
+ if (hook?.url) {
549
+ const meta = hook.metadata && Object.keys(hook.metadata).length ? ` · metadata: ${JSON.stringify(hook.metadata)}` : "";
550
+ log.info(` webhook: ${hook.url} · events: ${(hook.events ?? []).join(", ") || "all"}${meta}`);
551
+ }
534
552
  if (extra.note)
535
553
  log.dim(` next: zenrows crawl wait ${c.crawl_id}`);
536
554
  else if (note)
@@ -587,6 +605,77 @@ function pageLimit(v, max) {
587
605
  }
588
606
  return n;
589
607
  }
608
+ /**
609
+ * `--webhook`, `--webhook-event` and `--webhook-metadata key=value` as the create's
610
+ * `webhook`, or undefined without `--webhook`. Checks the shapes and the API's limits
611
+ * before any request; whether the URL's host resolves is the API's to judge.
612
+ */
613
+ function webhookOf(values) {
614
+ const url = asString(values.webhook);
615
+ const events = patterns(values["webhook-event"]);
616
+ const pairs = patterns(values["webhook-metadata"]);
617
+ if (url === undefined) {
618
+ if (events.length || pairs.length) {
619
+ throw webhookUsage(`${events.length ? "--webhook-event" : "--webhook-metadata"} needs --webhook.`, "Pass --webhook <https-url> with it, or leave it out.");
620
+ }
621
+ return undefined;
622
+ }
623
+ let parsed;
624
+ try {
625
+ parsed = new URL(url);
626
+ }
627
+ catch {
628
+ parsed = undefined;
629
+ }
630
+ if (parsed?.protocol !== "https:") {
631
+ throw webhookUsage(`Invalid --webhook value '${url}': it must be an absolute https URL.`, "Pass e.g. --webhook https://example.com/hooks/zenrows.");
632
+ }
633
+ const webhook = { url };
634
+ if (events.length) {
635
+ const known = new Set(CRAWL_WEBHOOK_EVENTS);
636
+ const unknown = events.find((e) => !known.has(e));
637
+ if (unknown !== undefined) {
638
+ throw webhookUsage(`Invalid --webhook-event value '${unknown}'.`, `Pass one of ${CRAWL_WEBHOOK_EVENTS.join(", ")}, or leave it out for all four.`);
639
+ }
640
+ const repeated = events.find((e, i) => events.indexOf(e) !== i);
641
+ if (repeated !== undefined) {
642
+ throw webhookUsage(`--webhook-event '${repeated}' is given twice.`, "Name each event once.");
643
+ }
644
+ webhook.events = events;
645
+ }
646
+ if (pairs.length) {
647
+ const { keys, keyLength, valueBytes } = WEBHOOK_METADATA_LIMITS;
648
+ const metadata = {};
649
+ for (const p of pairs) {
650
+ const eq = p.indexOf("=");
651
+ if (eq <= 0)
652
+ throw webhookUsage(`Invalid --webhook-metadata '${p}': use key=value.`, "Pass e.g. --webhook-metadata site=books.");
653
+ const key = p.slice(0, eq);
654
+ const value = p.slice(eq + 1);
655
+ if ([...key].length > keyLength)
656
+ throw webhookUsage(`--webhook-metadata key '${key}' is longer than ${keyLength} characters.`, "Use a shorter key.");
657
+ if (key in metadata)
658
+ throw webhookUsage(`--webhook-metadata key '${key}' is given twice.`, "Name each key once.");
659
+ if (Buffer.byteLength(JSON.stringify(value)) > valueBytes) {
660
+ throw webhookUsage(`--webhook-metadata '${key}' is longer than ${valueBytes} bytes as JSON.`, "Use a shorter value.");
661
+ }
662
+ metadata[key] = value;
663
+ }
664
+ if (Object.keys(metadata).length > keys)
665
+ throw webhookUsage(`--webhook-metadata has more than ${keys} keys.`, `Pass at most ${keys}.`);
666
+ webhook.metadata = metadata;
667
+ }
668
+ return webhook;
669
+ }
670
+ function webhookUsage(message, next_action) {
671
+ return new ToolkitError({
672
+ code: "INVALID_USAGE",
673
+ message,
674
+ likely_cause: "The webhook flags do not match what Crawl accepts.",
675
+ next_action,
676
+ suggested_commands: ["zenrows crawl --help"],
677
+ });
678
+ }
590
679
  function patterns(v) {
591
680
  const list = Array.isArray(v) ? v : typeof v === "string" ? [v] : [];
592
681
  return list.filter((p) => typeof p === "string" && p.length > 0);
@@ -21,6 +21,7 @@ import { formatRequestCost } from "../../core/http.js";
21
21
  import { parse, asString, asNumber } from "../command.js";
22
22
  import { printError, writeOut } from "../output.js";
23
23
  import { refreshClaimAdvice } from "../../core/claim-link.js";
24
+ import { normalizeRetries } from "./fetch.js";
24
25
  import { PREPARE_HELP, PREPARE_USAGE, runPrepare } from "./extract-prepare.js";
25
26
  export const extract = {
26
27
  name: "extract",
@@ -40,6 +41,7 @@ export const extract = {
40
41
  "Shared:",
41
42
  " --manual --js-render --premium-proxy manual fetch controls",
42
43
  " --validate fail if the result is not valid JSON",
44
+ " --retries <n> retry 429/502/503/504 and failed connects up to n times (default 0)",
43
45
  " --out <file> write output to a file",
44
46
  " --no-signup do not auto-create a Free plan account if no key exists",
45
47
  " --json structured result",
@@ -65,6 +67,7 @@ export const extract = {
65
67
  wait: { type: "string" },
66
68
  "wait-for": { type: "string" },
67
69
  validate: { type: "boolean" },
70
+ retries: { type: "string" },
68
71
  out: { type: "string" },
69
72
  "no-signup": { type: "boolean" },
70
73
  json: { type: "boolean" },
@@ -112,6 +115,7 @@ export const extract = {
112
115
  wait: asNumber(values.wait),
113
116
  waitFor: asString(values["wait-for"]),
114
117
  validate: values.validate === true,
118
+ retries: normalizeRetries(values.retries),
115
119
  };
116
120
  assertUsable("extract");
117
121
  const config = loadConfig();
@@ -36,6 +36,7 @@ export const fetch_ = {
36
36
  " --screenshot capture an above-the-fold screenshot",
37
37
  " --out <file> write the response body to a file",
38
38
  " --timeout <ms> client-side timeout (default 120000; above the API's own 90s budget)",
39
+ " --retries <n> retry 429/502/503/504 and failed connects up to n times (default 0)",
39
40
  " --no-signup do not auto-create a Free plan account if no key exists",
40
41
  " --json print a structured result",
41
42
  "",
@@ -58,6 +59,7 @@ export const fetch_ = {
58
59
  screenshot: { type: "boolean" },
59
60
  out: { type: "string" },
60
61
  timeout: { type: "string" },
62
+ retries: { type: "string" },
61
63
  "no-signup": { type: "boolean" },
62
64
  json: { type: "boolean" },
63
65
  });
@@ -94,6 +96,7 @@ export const fetch_ = {
94
96
  output: normalizeOutput(asString(values.output)),
95
97
  screenshot: values.screenshot === true,
96
98
  timeoutMs: normalizeTimeout(values.timeout),
99
+ retries: normalizeRetries(values.retries),
97
100
  };
98
101
  const runId = newRunId();
99
102
  const startedAt = new Date().toISOString();
@@ -194,6 +197,20 @@ export function normalizeTimeout(v) {
194
197
  }
195
198
  return ms;
196
199
  }
200
+ /** `--retries <n>`: a whole number, 0 or more. Like `--timeout`, a bad value is an error. */
201
+ export function normalizeRetries(v) {
202
+ if (v === undefined)
203
+ return undefined;
204
+ if (typeof v !== "string" || !/^\d+$/.test(v)) {
205
+ throw new ToolkitError({
206
+ code: "INVALID_USAGE",
207
+ message: `Invalid --retries value '${String(v)}'.`,
208
+ likely_cause: "--retries takes a whole number, 0 or more.",
209
+ next_action: "Pass a count, e.g. --retries 2. 0, the default, sends the request once.",
210
+ });
211
+ }
212
+ return Number(v);
213
+ }
197
214
  export function normalizeOutput(v) {
198
215
  if (!v)
199
216
  return undefined;
@@ -581,7 +581,7 @@ export async function waitForJob(id, opts) {
581
581
  throw err;
582
582
  lastErr = err;
583
583
  }
584
- if (Date.now() + delay > deadline) {
584
+ if (Date.now() + delay >= deadline) {
585
585
  // Out of time: report the last state read, or the error if none was.
586
586
  if (!last)
587
587
  throw lastErr;
@@ -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.11.0"; // x-release-please-version
17
+ export const CLI_VERSION = "1.13.0"; // 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,8 +10,9 @@
10
10
  * Crawl SDKs do (see `retryStatuses`). `fetchImpl` / `sleepImpl` are injectable
11
11
  * for tests.
12
12
  *
13
- * `output_format` takes `html` or `markdown` (absent = URLs only). Response fields we do
14
- * not model pass through untouched.
13
+ * `output_format` takes `html` or `markdown` (absent = URLs only). A `webhook` gets the
14
+ * crawl's events (`crawl.started` and how it ended) posted to a URL, signed with the
15
+ * account's webhook signing key. Response fields we do not model pass through untouched.
15
16
  */
16
17
  import { Readable } from "node:stream";
17
18
  import { ToolkitError, isKeyCapReached, keyCapReached, quotaExhausted } from "./errors.js";
@@ -36,6 +37,10 @@ export function crawlBase() {
36
37
  }
37
38
  /** The API's "Crawl is not enabled for this account" code (403). */
38
39
  export const CRAWL_NOT_ENABLED_CODE = "REQS008";
40
+ /** The events a crawl's webhook can send. */
41
+ export const CRAWL_WEBHOOK_EVENTS = ["crawl.started", "crawl.completed", "crawl.stopped", "crawl.failed"];
42
+ /** The API's limits on a webhook's `metadata`. */
43
+ export const WEBHOOK_METADATA_LIMITS = { keys: 20, keyLength: 64, valueBytes: 1024 };
39
44
  /** A crawl stops progressing once its status is anything but `running`. */
40
45
  export function isTerminal(status) {
41
46
  return !!status && status !== "running";
@@ -273,7 +278,9 @@ function classifyProblem(status, problem, body, request, serverCode, retryAfter)
273
278
  likely_cause: cause,
274
279
  next_action: serverCode === "idempotency_key_reused"
275
280
  ? "This Idempotency-Key was already used for a different request. Send a new key, or no key. Do not retry as is."
276
- : "Fix the reported parameter and retry. See `zenrows crawl --help` for the accepted flags and ranges.",
281
+ : serverCode === "webhook_signing_key_required"
282
+ ? "Nothing was created. Create a webhook signing key in the dashboard (Settings → API Keys), then retry."
283
+ : "Fix the reported parameter and retry. See `zenrows crawl --help` for the accepted flags and ranges.",
277
284
  });
278
285
  }
279
286
  return new ToolkitError({
@@ -300,6 +307,14 @@ export function createCrawl(params, opts) {
300
307
  body.output_format = params.output_format;
301
308
  if (params.discovery?.length)
302
309
  body.discovery = params.discovery;
310
+ if (params.webhook !== undefined) {
311
+ const webhook = { url: params.webhook.url };
312
+ if (params.webhook.events?.length)
313
+ webhook.events = params.webhook.events;
314
+ if (params.webhook.metadata !== undefined)
315
+ webhook.metadata = params.webhook.metadata;
316
+ body.webhook = webhook;
317
+ }
303
318
  return crawlJson("POST", "/crawls", {
304
319
  ...opts,
305
320
  body,
@@ -9,7 +9,7 @@ import { ToolkitError, isKeyCapReached, keyCapReached, quotaExhausted } from "./
9
9
  import { storedClaimHint, usableClaimUrl } from "./claim-link.js";
10
10
  import { readAccount } from "./agent-account.js";
11
11
  import { ENV_KEY, resolveApiKey } from "./auth.js";
12
- import { registerSecret } from "./logger.js";
12
+ import { log, registerSecret } from "./logger.js";
13
13
  import { CLI_USER_AGENT } from "./config.js";
14
14
  import { agentClientHeader } from "./agent-client.js";
15
15
  import { normalizeDomain, PAGE_NOT_RECOGNISED_STEPS, prepareCommand } from "./prepared-domains-api.js";
@@ -27,6 +27,44 @@ import { normalizeDomain, PAGE_NOT_RECOGNISED_STEPS, prepareCommand } from "./pr
27
27
  export const DEFAULT_TIMEOUT_MS = 120_000;
28
28
  /** The gateway's own request budget. Kept here only to justify the default above. */
29
29
  export const SERVER_BUDGET_MS = 90_000;
30
+ /** No retry starts later than this after the first request. */
31
+ const RETRY_BUDGET_MS = 90_000;
32
+ /** Busy answers from the API, retried with `--retries`. */
33
+ const RETRY_STATUSES = new Set([429, 502, 503, 504]);
34
+ /** Network error codes that mean the request never reached the API. */
35
+ const NEVER_SENT_CODES = new Set([
36
+ "ECONNREFUSED",
37
+ "ENOTFOUND",
38
+ "EAI_AGAIN",
39
+ "ENETUNREACH",
40
+ "EHOSTUNREACH",
41
+ "UND_ERR_CONNECT_TIMEOUT",
42
+ ]);
43
+ /** `Retry-After` in whole seconds, clamped to 0.25-30 s; undefined for any other value. */
44
+ export function retryAfterMs(value) {
45
+ if (!value || !/^\d+$/.test(value.trim()))
46
+ return undefined;
47
+ return Math.min(Math.max(Number(value) * 1000, 250), 30_000);
48
+ }
49
+ /** Full jitter before retry `n` (0-based): uniform(0.25 s, min(30 s, 2^n s)). */
50
+ export function jitterMs(n, random = Math.random) {
51
+ return 250 + random() * (Math.min(30_000, 1000 * 2 ** n) - 250);
52
+ }
53
+ /**
54
+ * True when a fetch error's `cause` means the request never reached the API.
55
+ * ETIMEDOUT counts only on connect. A happy-eyeballs AggregateError counts
56
+ * only when every address failed to connect.
57
+ */
58
+ export function neverSent(cause) {
59
+ if (cause instanceof AggregateError)
60
+ return cause.errors.length > 0 && cause.errors.every(neverSent);
61
+ const { code, syscall } = (cause ?? {});
62
+ return code === "ETIMEDOUT" ? syscall === "connect" : NEVER_SENT_CODES.has(String(code));
63
+ }
64
+ /** A busy answer from the API. A status the target site sent is never retried. */
65
+ function isRetryable(status, params) {
66
+ return RETRY_STATUSES.has(status) && !params.original_status && params.allowed_status_codes === undefined;
67
+ }
30
68
  function buildUrl(apiBase, apiKey, params) {
31
69
  const u = new URL(apiBase);
32
70
  u.searchParams.set("apikey", apiKey);
@@ -45,43 +83,66 @@ export async function scrape(apiBase, apiKey, params, opts = {}) {
45
83
  registerSecret(apiKey);
46
84
  const { full, redacted } = buildUrl(apiBase, apiKey, params);
47
85
  const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
48
- const controller = new AbortController();
49
- // Our own timer is the only thing that aborts this controller, so this flag —
50
- // not `err.name === "AbortError"` — is what tells a client-side give-up apart
51
- // from a genuine transport failure. The two must never share an error code.
52
- let timedOut = false;
53
- const timeout = setTimeout(() => {
54
- timedOut = true;
55
- controller.abort();
56
- }, timeoutMs);
57
- const startedAt = Date.now();
86
+ const retries = opts.retries ?? 0;
87
+ const now = opts.now ?? Date.now;
88
+ const firstSentAt = now();
89
+ /** Waits before retry `n` and returns true, unless no retry is left or it would start after the budget. */
90
+ const retry = async (n, delayMs, reason) => {
91
+ if (n >= retries || now() - firstSentAt + delayMs > RETRY_BUDGET_MS)
92
+ return false;
93
+ log.dim(`${reason}; retry ${n + 1}/${retries} in ${formatMs(delayMs)}`);
94
+ await (opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms))))(delayMs);
95
+ return true;
96
+ };
58
97
  let res;
59
- try {
60
- res = await fetch(full, {
61
- method: "GET",
62
- headers: { "User-Agent": CLI_USER_AGENT, "Accept-Encoding": "gzip, deflate", ...agentClientHeader({ projectRoot: opts.projectRoot }) },
63
- signal: controller.signal,
64
- });
65
- }
66
- catch (err) {
98
+ let raw;
99
+ let retriesUsed = 0;
100
+ for (;; retriesUsed++) {
101
+ const controller = new AbortController();
102
+ // Our own timer is the only thing that aborts this controller, so this flag —
103
+ // not `err.name === "AbortError"` — is what tells a client-side give-up apart
104
+ // from a genuine transport failure. The two must never share an error code.
105
+ let timedOut = false;
106
+ const timeout = setTimeout(() => {
107
+ timedOut = true;
108
+ controller.abort();
109
+ }, timeoutMs);
110
+ const startedAt = Date.now();
111
+ try {
112
+ res = await fetch(full, {
113
+ method: "GET",
114
+ headers: { "User-Agent": CLI_USER_AGENT, "Accept-Encoding": "gzip, deflate", ...agentClientHeader({ projectRoot: opts.projectRoot }) },
115
+ signal: controller.signal,
116
+ });
117
+ }
118
+ catch (err) {
119
+ clearTimeout(timeout);
120
+ const elapsedMs = Date.now() - startedAt;
121
+ // A timeout, or an error after the request was sent, is never retried:
122
+ // the API may still finish and charge that request.
123
+ if (timedOut)
124
+ throw requestTimeout(params.url, timeoutMs, elapsedMs);
125
+ const retryable = err instanceof Error && neverSent(err.cause);
126
+ if (retryable && (await retry(retriesUsed, jitterMs(retriesUsed), "Network error before the request reached the API")))
127
+ continue;
128
+ const cause = err instanceof Error ? err.message : String(err);
129
+ throw new ToolkitError({
130
+ code: "BACKEND_UNAVAILABLE",
131
+ message: `Could not reach the Zenrows API.`,
132
+ likely_cause: `Network error after ${formatMs(elapsedMs)}: ${cause}`,
133
+ next_action: "Check connectivity and retry. Verify api base in `zenrows config show`.",
134
+ suggested_commands: ["zenrows status", "zenrows config show"],
135
+ });
136
+ }
67
137
  clearTimeout(timeout);
68
- const elapsedMs = Date.now() - startedAt;
69
- if (timedOut)
70
- throw requestTimeout(params.url, timeoutMs, elapsedMs);
71
- const cause = err instanceof Error ? err.message : String(err);
72
- throw new ToolkitError({
73
- code: "BACKEND_UNAVAILABLE",
74
- message: `Could not reach the Zenrows API.`,
75
- likely_cause: `Network error after ${formatMs(elapsedMs)}: ${cause}`,
76
- next_action: "Check connectivity and retry. Verify api base in `zenrows config show`.",
77
- suggested_commands: ["zenrows status", "zenrows config show"],
78
- });
138
+ // Read the raw bytes once. `body` (UTF-8) drives all error detection and text
139
+ // output; `raw` preserves the exact bytes so binary responses (screenshot /
140
+ // PDF) can be written without the UTF-8 round-trip that corrupts them.
141
+ raw = Buffer.from(await res.arrayBuffer());
142
+ const delayMs = retryAfterMs(res.headers.get("retry-after")) ?? jitterMs(retriesUsed);
143
+ if (!isRetryable(res.status, params) || !(await retry(retriesUsed, delayMs, `HTTP ${res.status}`)))
144
+ break;
79
145
  }
80
- clearTimeout(timeout);
81
- // Read the raw bytes once. `body` (UTF-8) drives all error detection and text
82
- // output; `raw` preserves the exact bytes so binary responses (screenshot /
83
- // PDF) can be written without the UTF-8 round-trip that corrupts them.
84
- const raw = Buffer.from(await res.arrayBuffer());
85
146
  const body = raw.toString("utf8");
86
147
  // A binary body is only ever produced by a successful screenshot/PDF request.
87
148
  // Zenrows error responses are always a JSON envelope (text), so exclude those.
@@ -228,6 +289,15 @@ export async function scrape(apiBase, apiKey, params, opts = {}) {
228
289
  : "Verify the URL. Rendering or premium proxies will not turn a 404 into content — do not retry; it will fail identically and still cost credits.",
229
290
  });
230
291
  }
292
+ if (retriesUsed > 0 && isRetryable(res.status, params)) {
293
+ // A busy answer that outlasted the retries: a costlier configuration does not fix it.
294
+ throw new ToolkitError({
295
+ code: "FETCH_FAILED",
296
+ message: `The Zenrows API answered HTTP ${res.status} after ${retriesUsed} ${retriesUsed === 1 ? "retry" : "retries"}.`,
297
+ likely_cause: snippet(body) || "The Zenrows API is busy.",
298
+ next_action: "Wait a minute, then run the same command again. Do not add --js-render or --premium-proxy: they cost more and do not help here.",
299
+ });
300
+ }
231
301
  if (res.status === 422 || res.status >= 500 || (res.status >= 400 && !looksLikeContent(result))) {
232
302
  throw new ToolkitError({
233
303
  code: "FETCH_FAILED",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zenrows/cli",
3
- "version": "1.11.0",
3
+ "version": "1.13.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",
@@ -42,6 +42,7 @@
42
42
  "recipes",
43
43
  "evals",
44
44
  "README.md",
45
+ "CHANGELOG.md",
45
46
  "LICENSE"
46
47
  ],
47
48
  "scripts": {
@@ -1,6 +1,6 @@
1
1
  ---
2
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."
3
+ description: "Collect the URLs behind one start page with Crawl (new): create a crawl, wait for it or get notified by webhook, 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
4
  version: 0.1.0
5
5
  requires_backend_capabilities: [crawl]
6
6
  ---
@@ -71,6 +71,36 @@ of URLs. A result's `content_status` is `pending`, `fetched` or `failed`; only
71
71
  `fetched` results have content (else `CRAWL_CONTENT_NOT_FOUND`). `download`
72
72
  reports the crawl's `status`, and `partial: true` while it runs.
73
73
 
74
+ ## Webhooks
75
+
76
+ Instead of polling, have Crawl post its events to a URL you run:
77
+
78
+ ```
79
+ zenrows crawl create <url> --depth 1 --webhook https://example.com/hooks/zenrows \
80
+ --webhook-event crawl.completed --webhook-event crawl.failed \
81
+ --webhook-metadata site=books --json
82
+ ```
83
+
84
+ - `--webhook <url>` takes an absolute `https` URL. It is fixed at create.
85
+ - `--webhook-event` repeats; each of `crawl.started`, `crawl.completed`,
86
+ `crawl.stopped`, `crawl.failed` at most once. Leave it out for all four.
87
+ Exactly one of the last three is sent per crawl.
88
+ - `--webhook-metadata key=value` repeats (up to 20 keys of 1 to 64
89
+ characters, each value at most 1,024 bytes as JSON); every event carries it
90
+ back, to match the crawl to your own records.
91
+ - The crawl shows `webhook` as `{url, events, metadata}`, `events` being the
92
+ effective list; a crawl without one has none.
93
+ - Events are signed JSON (`schema: zenrows.webhook/v1`, `event_id`,
94
+ `event_type`, `occurred_at`, `resource {type: crawl, id, url}`, `metadata`).
95
+ Verify the `X-Signature: t=…,v1=…,kid=…` header: `v1` is the hex
96
+ HMAC-SHA256 of `"<t>." + body` with the account's webhook signing key
97
+ (Settings → API Keys). Events can repeat (drop one whose `event_id` you
98
+ handled) and arrive out of order (sort by `occurred_at`).
99
+ - The account needs a webhook signing key: without one the create gets 422
100
+ `webhook_signing_key_required` (create the key, then retry). A bad URL,
101
+ event or metadata gets 422 `invalid_webhook`. Both are
102
+ `CRAWL_INVALID_REQUEST`, and nothing was created.
103
+
74
104
  ## While it runs, and after
75
105
 
76
106
  ```
@@ -68,6 +68,7 @@ zenrows extract https://www.example.com/products/a # 3. extract again
68
68
  `limit` / `remaining`.
69
69
  - `EXTRACT_PREPARE_OUT_OF_ALLOWANCE`: no slots left this month; use `--css`.
70
70
  `EXTRACT_PREPARE_UNAVAILABLE`: retry in a few minutes; do not loop.
71
+ `prepare` sends each request once: `--retries` does not apply to it.
71
72
  A `failed` status prints the next step for its failure code and says whether
72
73
  the attempt used an allowance slot.
73
74
  - A domain that is already prepared answers `ready` at once
@@ -47,6 +47,11 @@ zenrows fetch <url> --manual --js-render --premium-proxy # the most expensive
47
47
  needs `--premium-proxy` (else `PARAM_PROXY_COUNTRY_REQUIRES_PREMIUM`).
48
48
  - You are billed only for the configuration that succeeds.
49
49
  - On failure, read `zenrows trace explain <run-id>` before escalating.
50
+ - The CLI sends each request once. `--retries <n>` retries a 429, 502, 503 or
51
+ 504 and a network error before the request reached the API, up to `n` times,
52
+ within 90 s; prefer it to your own retry loop. It never retries a timeout, a
53
+ connection closed after the request was sent, or a target status under
54
+ `--original-status`.
50
55
 
51
56
  Every run is saved under `.zenrows/runs/<run-id>/` (no secrets). See [[extract]]
52
57
  for structured output and [[cost-control]] for the cost model.
@@ -26,6 +26,11 @@ zenrows trace export <run-id> # JSON for sharing
26
26
  `--manual --js-render` run; auto mode may ignore it. Each step
27
27
  multiplies the cost of the request, and both together are the most expensive
28
28
  configuration available ([[cost-control]]).
29
+ - `FETCH_FAILED` "The Zenrows API answered HTTP 502/503/504 after N retries" →
30
+ the CLI already retried a busy answer (`--retries`). Wait a minute and run the
31
+ same command again; do not escalate, a costlier configuration does not fix
32
+ it. A 429 ("Rate limited") keeps its advice with or without `--retries`: wait
33
+ a few seconds and retry; if it persists, lower concurrency.
29
34
  - `REQUEST_TIMEOUT` → the CLI stopped waiting; the API was reached. Raise
30
35
  `--timeout` (default 120000ms, above the API's own 90s budget), or drop
31
36
  `--wait-for` so the request finishes inside that budget and the API returns