@zenrows/cli 1.10.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.
package/README.md CHANGED
@@ -147,10 +147,18 @@ few minutes if it should work, or use `--css`; preparing again does not change
147
147
  it. `zenrows extract` never falls back to Autoparse. The `fallbackAutoparse`
148
148
  adapter option is deprecated and has no effect.
149
149
 
150
+ Pass 1 to 5 example URLs. One works; we recommend 3 or more of the same page
151
+ type (e.g. 3 product pages), because Extract compares pages of one type to check
152
+ the fields it found and fill in fields one page is missing. Several page types
153
+ can share the 5 (e.g. 3 product pages + 2 search-result pages).
154
+
150
155
  ```bash
151
- zenrows extract prepare example.com --url https://example.com/p/1 --url https://example.com/list --wait
156
+ zenrows extract prepare example.com \
157
+ --url https://www.example.com/products/a \
158
+ --url https://www.example.com/products/b \
159
+ --url https://www.example.com/products/c --wait
152
160
  zenrows extract prepare status example.com # progress + monthly allowance
153
- zenrows extract https://example.com/p/1 # then extract again
161
+ zenrows extract https://www.example.com/products/a # then extract again
154
162
  ```
155
163
 
156
164
  ## 9. Batch
@@ -62,7 +62,7 @@ Use MCP tool names — not CLI `zenrows fetch`:
62
62
  | --- | --- |
63
63
  | `zenrows` | Decision tree across all primitives |
64
64
  | `protected-fetch` | `scrape` |
65
- | `extract` | `extract` (fallback: `scrape` + css_extractor); on REQS007 with reason `domain_not_prepared`, `prepare_domain` then `prepared_domain_status` |
65
+ | `extract` | `extract` (fallback: `scrape` + css_extractor); on REQS007 with reason `domain_not_prepared`, `prepare_domain` (1 to 5 example URLs; we recommend 3 or more of the same page type) then `prepared_domain_status` |
66
66
  | `batch-jobs` | `batch_create`, `batch_status`, `batch_results`, `batch_cancel` |
67
67
  | `interact-browser` | `browser_*` (escalation only) |
68
68
  | `cost-control` | Cost multipliers + escalate-with-evidence |
@@ -48,15 +48,20 @@ Preparing a domain:
48
48
 
49
49
  ```
50
50
  extract({ url }) # 1. REQS007, reason domain_not_prepared
51
- prepare_domain({ domain: "example.com",
52
- example_urls: ["https://example.com/p/1", "https://example.com/list"] })
51
+ prepare_domain({ domain: "example.com", example_urls: [
52
+ "https://www.example.com/products/a",
53
+ "https://www.example.com/products/b",
54
+ "https://www.example.com/products/c"] })
53
55
  # 2. returns at once, status "preparing"
54
56
  prepared_domain_status({ domain: "example.com" }) # 3. about every 30 seconds until "ready"
55
57
  extract({ url }) # 4. extract again
56
58
  ```
57
59
 
58
- - `example_urls`: 1 to 5 real pages you want to extract, one per page kind
59
- (for example a product page and a listing page).
60
+ - `example_urls`: 1 to 5 real pages you want to extract. One works; we
61
+ recommend 3 or more of the same page type (e.g. 3 product pages), because
62
+ Extract compares pages of one type to check the fields it found and fill in
63
+ fields one page is missing. Several page types can share the 5 (e.g. 3
64
+ product pages + 2 search-result pages).
60
65
  - The domain is the host: `shop.example.com` and `example.com` are prepared
61
66
  separately, and a leading `www.` is dropped.
62
67
  - Preparing takes a few minutes and costs no credits. Each new domain uses one
@@ -71,7 +71,7 @@ If the user needs login, clicks, forms, sessions, or persistent state:
71
71
  | --- | --- | --- |
72
72
  | Protected Fetch | `scrape` | Default markdown; HTML / plaintext / PDF / screenshot options |
73
73
  | Extract | `extract` | Structured JSON (`extract=auto` on any prepared domain, css_extractor; autoparse deprecated). `extract=auto` is currently free; billing may apply later |
74
- | Prepare a domain | `prepare_domain`, `prepared_domain_status` | After an `extract` REQS007 with reason `domain_not_prepared`: prepare the domain with 1 to 5 example URLs, poll about every 30 seconds until ready, extract again. No credits; uses one monthly allowance slot. Not for `page_not_recognised`: that domain is already prepared |
74
+ | Prepare a domain | `prepare_domain`, `prepared_domain_status` | After an `extract` REQS007 with reason `domain_not_prepared`: prepare the domain with 1 to 5 example URLs (one works; we recommend 3 or more of the same page type, because Extract compares pages of one type to check the fields it found and fill in fields one page is missing), poll about every 30 seconds until ready, extract again. No credits; uses one monthly allowance slot. Not for `page_not_recognised`: that domain is already prepared |
75
75
  | Batch | `batch_create`, `batch_status`, `batch_results`, `batch_cancel` | Same credits as Fetch; a 403 maps to `BATCH_ACCESS_DENIED` |
76
76
  | Browser | `browser_*` (start with `browser_navigate`) | Escalation-only; same backend as Browser Sessions |
77
77
 
@@ -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);
@@ -5,14 +5,15 @@
5
5
  * The way out of REQS007: prepare the domain so `extract=auto` works on it.
6
6
  * Submitting returns at once; `--wait` polls until the preparation is ready or
7
7
  * failed (a few minutes). Costs no credits; each new domain uses one slot of the
8
- * account's monthly allowance.
8
+ * account's monthly allowance. One --url works; with fewer than 3 the command prints a
9
+ * tip to stderr (never an error): we recommend 3 or more of the same page type, up to 5.
9
10
  */
10
11
  import { ensureApiKey } from "../../core/ensure-key.js";
11
12
  import { loadConfig } from "../../core/config.js";
12
13
  import { loadPolicy } from "../../core/policy.js";
13
14
  import { log } from "../../core/logger.js";
14
15
  import { ToolkitError } from "../../core/errors.js";
15
- import { DEFAULT_WAIT_TIMEOUT_MS, MAX_EXAMPLE_URLS, failureNextStep, getAllowance, getPreparedDomain, normalizeDomain, prepareDomain, statusNextStep, waitForPreparedDomain, } from "../../core/prepared-domains-api.js";
16
+ import { DEFAULT_WAIT_TIMEOUT_MS, MAX_EXAMPLE_URLS, RECOMMENDED_EXAMPLE_URLS, exampleUrlsHint, failureNextStep, getAllowance, getPreparedDomain, normalizeDomain, prepareDomain, statusNextStep, waitForPreparedDomain, } from "../../core/prepared-domains-api.js";
16
17
  import { parse, asString } from "../command.js";
17
18
  import { printError } from "../output.js";
18
19
  export const PREPARE_USAGE = "zenrows extract prepare <domain> --url <page-url> [--url <page-url>...] [--wait [--timeout <seconds>]]\n" +
@@ -23,7 +24,8 @@ export const PREPARE_HELP = [
23
24
  "Not for EXTRACT_PAGE_NOT_RECOGNISED: that domain is already prepared, and preparing it",
24
25
  "again does not change that answer.",
25
26
  "",
26
- ` --url <page-url> 1 to ${MAX_EXAMPLE_URLS} real pages you want to extract, one per page kind (repeat --url)`,
27
+ ` --url <page-url> a real page you want to extract (repeat --url, 1 to ${MAX_EXAMPLE_URLS}). One works;`,
28
+ ` we recommend ${RECOMMENDED_EXAMPLE_URLS} or more of the same page type (e.g. 3 product pages)`,
27
29
  " --wait poll every 30 seconds until the domain is ready or failed; off by default",
28
30
  ` --timeout <seconds> give up waiting after this long (default ${DEFAULT_WAIT_TIMEOUT_MS / 1000})`,
29
31
  " status <domain> show progress and the account's monthly allowance",
@@ -33,6 +35,14 @@ export const PREPARE_HELP = [
33
35
  "account's monthly allowance; a domain that is already prepared answers ready at once and",
34
36
  "uses none. The domain is the host (shop.example.com and example.com are separate); a",
35
37
  "leading www. is dropped.",
38
+ "",
39
+ `1 to ${MAX_EXAMPLE_URLS} example URLs. One works; we recommend ${RECOMMENDED_EXAMPLE_URLS} or more of the same page type`,
40
+ "(e.g. 3 product pages), because Extract compares pages of one type to check the fields",
41
+ `it found and fill in fields one page is missing. Several page types can share the ${MAX_EXAMPLE_URLS}`,
42
+ "(e.g. 3 product pages + 2 search-result pages). Three product pages:",
43
+ "",
44
+ " zenrows extract prepare example.com --url https://www.example.com/products/a \\",
45
+ " --url https://www.example.com/products/b --url https://www.example.com/products/c --wait",
36
46
  ].join("\n");
37
47
  export async function runPrepare(argv, ctx, deps = {}) {
38
48
  const isStatus = argv[0] === "status";
@@ -69,6 +79,10 @@ export async function runPrepare(argv, ctx, deps = {}) {
69
79
  return 0;
70
80
  }
71
81
  log.step(`Prepare ${domain} for Extract (${urls.length} example URL${urls.length === 1 ? "" : "s"})…`);
82
+ // Advice on stderr only: the --json payload on stdout and the exit code are unchanged.
83
+ const hint = exampleUrlsHint(urls.length);
84
+ if (hint)
85
+ log.dim(hint);
72
86
  let record = await prepareDomain(domain, urls, opts);
73
87
  if (record.already_in_progress)
74
88
  log.info(`${domain} was already being prepared; following that preparation.`);
@@ -108,7 +122,7 @@ function validateUrls(raw, domain) {
108
122
  code: "INVALID_USAGE",
109
123
  message: `Pass 1 to ${MAX_EXAMPLE_URLS} example page URLs with --url (got ${urls.length}).`,
110
124
  likely_cause: "Preparation learns the domain from real pages you want to extract.",
111
- next_action: "Add one --url per page kind, e.g. a product page and a listing page.",
125
+ next_action: `Add 1 to ${MAX_EXAMPLE_URLS} --url pages. One works; ${RECOMMENDED_EXAMPLE_URLS} or more of the same page type work better.`,
112
126
  suggested_commands: [`zenrows extract prepare ${domain} --url https://${domain}/<page>`],
113
127
  });
114
128
  }
@@ -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.10.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,
@@ -314,7 +314,7 @@ export function extractDomainNotPrepared(url, body) {
314
314
  code: "EXTRACT_DOMAIN_NOT_PREPARED",
315
315
  message: `Extract hasn't prepared ${domain} yet. Prepare it with \`${prepare}\` (a few example URLs, a few minutes, no credits), then retry; meanwhile use --css.`,
316
316
  likely_cause: detail ?? `Extract has not prepared ${domain} yet.`,
317
- next_action: `Run \`${prepare} --wait\` (up to 5 --url pages, one per page kind you want), then retry \`zenrows extract ${url}\`. Without --wait, check progress about every 30 seconds with \`zenrows extract prepare status ${domain}\`. Meanwhile extract with --css and explicit selectors.`,
317
+ next_action: `Run \`${prepare} --wait\` (1 to 5 --url pages: one works; we recommend 3 or more of the same page type), then retry \`zenrows extract ${url}\`. Without --wait, check progress about every 30 seconds with \`zenrows extract prepare status ${domain}\`. Meanwhile extract with --css and explicit selectors.`,
318
318
  suggested_commands: [`${prepare} --wait`, `zenrows extract prepare status ${domain}`, css],
319
319
  server_code: "REQS007",
320
320
  });
@@ -4,7 +4,8 @@
4
4
  * `extract=auto` answers REQS007 on a domain Extract has not prepared yet. Preparing
5
5
  * it is a call any key can make: `POST {apiBase}/extract/prepared-domains` with the
6
6
  * domain and 1 to 5 example URLs answers 202 at once; the preparation itself takes a
7
- * few minutes and costs no credits. `GET .../{domain}` reports progress and
7
+ * few minutes and costs no credits. One example URL works; we recommend 3 or more of
8
+ * the same page type (see `RECOMMENDED_EXAMPLE_URLS`). `GET .../{domain}` reports progress and
8
9
  * `GET .../allowance` the account's monthly allowance of new domains. Auth is the
9
10
  * `X-API-Key` header; errors are problem+json and we branch on `status` + `code`.
10
11
  * `fetchImpl` / `sleepImpl` / `now` are injectable for tests.
@@ -14,6 +15,23 @@ import { registerSecret } from "./logger.js";
14
15
  import { CLI_USER_AGENT, loadConfig } from "./config.js";
15
16
  import { agentClientHeader } from "./agent-client.js";
16
17
  export const MAX_EXAMPLE_URLS = 5;
18
+ /**
19
+ * How many example URLs to recommend: 3 or more of the same page type, because
20
+ * Extract compares pages of one type to check the fields it found and fill in fields
21
+ * one page is missing. Advice only: 1 and 2 URLs are still valid.
22
+ */
23
+ export const RECOMMENDED_EXAMPLE_URLS = 3;
24
+ /**
25
+ * A short, non-blocking hint when fewer than `RECOMMENDED_EXAMPLE_URLS` example
26
+ * URLs were given; undefined otherwise. Never an error.
27
+ */
28
+ export function exampleUrlsHint(count) {
29
+ if (count >= RECOMMENDED_EXAMPLE_URLS)
30
+ return undefined;
31
+ return (`Tip: ${count === 1 ? "one example URL works" : `${count} example URLs work`}; we recommend ` +
32
+ `${RECOMMENDED_EXAMPLE_URLS} or more of the same page type (e.g. 3 product pages), up to ${MAX_EXAMPLE_URLS} in total, ` +
33
+ "because Extract compares pages of one type to check the fields it found and fill in fields one page is missing.");
34
+ }
17
35
  /** How often `--wait` polls, and for how long by default. */
18
36
  export const DEFAULT_POLL_INTERVAL_MS = 30_000;
19
37
  export const DEFAULT_WAIT_TIMEOUT_MS = 15 * 60_000;
@@ -247,7 +265,7 @@ export function failureNextStep(domain, failure) {
247
265
  step = `Run the same zenrows extract prepare ${domain} command again.`;
248
266
  break;
249
267
  case "discovery_failed":
250
- step = `Run zenrows extract prepare ${domain} again with 1 to ${MAX_EXAMPLE_URLS} --url page URLs.`;
268
+ step = `Run zenrows extract prepare ${domain} again with 1 to ${MAX_EXAMPLE_URLS} --url page URLs; we recommend ${RECOMMENDED_EXAMPLE_URLS} or more of the same page type (e.g. 3 product pages).`;
251
269
  break;
252
270
  case "unpreparable":
253
271
  step = `Run zenrows extract prepare ${domain} again with different --url pages from the same site; the same URLs fail again.`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zenrows/cli",
3
- "version": "1.10.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",
@@ -17,7 +17,7 @@
17
17
  "command": "zenrows extract",
18
18
  "backend": "Extract — GET https://api.zenrows.com/v1/ (extract=auto, css_extractor, outputs, response_type; autoparse deprecated)",
19
19
  "requiresAuth": true,
20
- "notes": "Default is extract=auto, which works on any prepared domain with any key. A REQS007 (legacy AUTH010) is reported, never retried, with the next step for its reason: on a domain not prepared yet (EXTRACT_DOMAIN_NOT_PREPARED), prepare it with `zenrows extract prepare <domain> --url <page> [--wait]` (POST /v1/extract/prepared-domains: 1 to 5 example URLs, a few minutes, no credits) and check it with `zenrows extract prepare status <domain>`; on a page Extract did not recognise on a prepared domain (EXTRACT_PAGE_NOT_RECOGNISED), try a product, article or listing page, retry it once in a few minutes, or use --css, because preparing again does not change it. CSS / outputs / markdown work on any domain; autoparse is deprecated and runs only with --autoparse."
20
+ "notes": "Default is extract=auto, which works on any prepared domain with any key. A REQS007 (legacy AUTH010) is reported, never retried, with the next step for its reason: on a domain not prepared yet (EXTRACT_DOMAIN_NOT_PREPARED), prepare it with `zenrows extract prepare <domain> --url <page> [--wait]` (POST /v1/extract/prepared-domains: 1 to 5 example URLs; one works, we recommend 3 or more of the same page type because Extract compares pages of one type to check the fields it found and fill in fields one page is missing; a few minutes, no credits) and check it with `zenrows extract prepare status <domain>`; on a page Extract did not recognise on a prepared domain (EXTRACT_PAGE_NOT_RECOGNISED), try a product, article or listing page, retry it once in a few minutes, or use --css, because preparing again does not change it. CSS / outputs / markdown work on any domain; autoparse is deprecated and runs only with --autoparse."
21
21
  },
22
22
  "batch": {
23
23
  "key": "batch",
@@ -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
  ```
@@ -43,15 +43,21 @@ of three cases it is and prints the next step. It never retries with Autoparse.
43
43
  Preparing a domain:
44
44
 
45
45
  ```
46
- zenrows extract https://example.com/p/1 # 1. fails: EXTRACT_DOMAIN_NOT_PREPARED
46
+ zenrows extract https://www.example.com/products/a # 1. fails: EXTRACT_DOMAIN_NOT_PREPARED
47
47
  zenrows extract prepare example.com \
48
- --url https://example.com/p/1 --url https://example.com/list --wait
48
+ --url https://www.example.com/products/a \
49
+ --url https://www.example.com/products/b \
50
+ --url https://www.example.com/products/c --wait
49
51
  # 2. prepare it and wait until ready (a few minutes)
50
- zenrows extract https://example.com/p/1 # 3. extract again
52
+ zenrows extract https://www.example.com/products/a # 3. extract again
51
53
  ```
52
54
 
53
- - `--url`: 1 to 5 real pages you want to extract, one per page kind (for
54
- example a product page and a listing page).
55
+ - `--url`: 1 to 5 real pages you want to extract. One works; we recommend 3 or
56
+ more of the same page type (e.g. 3 product pages), because Extract compares
57
+ pages of one type to check the fields it found and fill in fields one page is
58
+ missing. Several page types can share the 5 (e.g. 3 product pages + 2
59
+ search-result pages). With fewer than 3 the command prints a tip on stderr;
60
+ it is never an error.
55
61
  - Without `--wait` the command returns at once; then check about every 30
56
62
  seconds with `zenrows extract prepare status example.com` until it says
57
63
  `ready`. `--wait` polls at the same pace.
@@ -90,7 +90,8 @@ Run `zenrows status` for the live capability matrix. As of this toolkit:
90
90
 
91
91
  - Extract: `extract=auto` works on any prepared domain. On one not prepared yet
92
92
  (`EXTRACT_DOMAIN_NOT_PREPARED`), run `zenrows extract prepare <domain> --url
93
- <page> --wait`, then extract again. `EXTRACT_PAGE_NOT_RECOGNISED` means the
93
+ <page> --wait` (repeat `--url`, 1 to 5: one works; we recommend 3 or more of
94
+ the same page type), then extract again. `EXTRACT_PAGE_NOT_RECOGNISED` means the
94
95
  domain is prepared but not this page: do not prepare again; see [[extract]].
95
96
  - Batch: the reliable path at scale, at Fetch's prices (create, list, status, wait, results, content, pause, resume, rerun, delete). Estimate specs locally with no key.
96
97
  - Crawl: collects the URLs behind one start page, optionally with each page's HTML.