@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 +102 -0
- package/README.md +14 -0
- package/dist/src/adapters/protected-fetch.js +1 -1
- package/dist/src/cli/commands/crawl.js +90 -1
- package/dist/src/cli/commands/extract.js +4 -0
- package/dist/src/cli/commands/fetch.js +17 -0
- package/dist/src/core/batch-api.js +1 -1
- package/dist/src/core/config.js +1 -1
- package/dist/src/core/crawl-api.js +18 -3
- package/dist/src/core/http.js +105 -35
- package/package.json +2 -1
- package/skills/crawl/SKILL.md +31 -1
- package/skills/extract/SKILL.md +1 -0
- package/skills/protected-fetch/SKILL.md +5 -0
- package/skills/trace-debug/SKILL.md +5 -0
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
|
|
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;
|
package/dist/src/core/config.js
CHANGED
|
@@ -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.
|
|
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).
|
|
14
|
-
*
|
|
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
|
-
:
|
|
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,
|
package/dist/src/core/http.js
CHANGED
|
@@ -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
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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.
|
|
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": {
|
package/skills/crawl/SKILL.md
CHANGED
|
@@ -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
|
```
|
package/skills/extract/SKILL.md
CHANGED
|
@@ -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
|