@zenrows/cli 1.11.0 → 1.12.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.
@@ -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);
@@ -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.12.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,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zenrows/cli",
3
- "version": "1.11.0",
3
+ "version": "1.12.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",
@@ -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
  ```