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