@zenrows/cli 1.7.0 → 1.9.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.
Files changed (33) hide show
  1. package/README.md +83 -12
  2. package/agent-plugin/README.md +1 -1
  3. package/agent-plugin/skills/batch-jobs/SKILL.md +16 -3
  4. package/agent-plugin/skills/cost-control/SKILL.md +2 -2
  5. package/agent-plugin/skills/extract/SKILL.md +49 -9
  6. package/agent-plugin/skills/protected-fetch/SKILL.md +1 -1
  7. package/agent-plugin/skills/zenrows/SKILL.md +3 -2
  8. package/dist/src/adapters/batch.js +237 -59
  9. package/dist/src/adapters/extract.js +18 -19
  10. package/dist/src/cli/commands/batch.js +665 -126
  11. package/dist/src/cli/commands/crawl.js +1 -1
  12. package/dist/src/cli/commands/extract-prepare.js +168 -0
  13. package/dist/src/cli/commands/extract.js +21 -13
  14. package/dist/src/core/batch-api.js +398 -145
  15. package/dist/src/core/config.js +1 -1
  16. package/dist/src/core/errors.js +6 -0
  17. package/dist/src/core/http.js +78 -20
  18. package/dist/src/core/policy.js +13 -6
  19. package/dist/src/core/prepared-domains-api.js +289 -0
  20. package/evals/extract-smoke/README.md +1 -1
  21. package/package.json +1 -1
  22. package/recipes/batch-estimate/RECIPE.md +6 -3
  23. package/registry/capabilities.json +3 -3
  24. package/registry/skills.json +2 -2
  25. package/registry/templates.json +1 -1
  26. package/skills/batch-jobs/SKILL.md +160 -43
  27. package/skills/crawl/SKILL.md +1 -1
  28. package/skills/extract/SKILL.md +50 -6
  29. package/skills/trace-debug/SKILL.md +17 -2
  30. package/skills/zenrows/SKILL.md +9 -5
  31. package/templates/batch-jsonl-pipeline/README.md +10 -5
  32. package/templates/extract-pricing-pages/README.md +3 -5
  33. package/workflows/competitor-intelligence/WORKFLOW.md +2 -2
@@ -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.7.0"; // x-release-please-version
17
+ export const CLI_VERSION = "1.9.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). */
@@ -13,6 +13,8 @@ export class ToolkitError extends Error {
13
13
  suggested_commands;
14
14
  server_code;
15
15
  crawl_id;
16
+ job_id;
17
+ idempotency_key;
16
18
  retry_after;
17
19
  constructor(shape) {
18
20
  super(shape.message);
@@ -23,6 +25,8 @@ export class ToolkitError extends Error {
23
25
  this.suggested_commands = shape.suggested_commands ?? [];
24
26
  this.server_code = shape.server_code;
25
27
  this.crawl_id = shape.crawl_id;
28
+ this.job_id = shape.job_id;
29
+ this.idempotency_key = shape.idempotency_key;
26
30
  this.retry_after = shape.retry_after;
27
31
  }
28
32
  toJSON() {
@@ -34,6 +38,8 @@ export class ToolkitError extends Error {
34
38
  suggested_commands: this.suggested_commands,
35
39
  ...(this.server_code ? { server_code: this.server_code } : {}),
36
40
  ...(this.crawl_id ? { crawl_id: this.crawl_id } : {}),
41
+ ...(this.job_id ? { job_id: this.job_id } : {}),
42
+ ...(this.idempotency_key ? { idempotency_key: this.idempotency_key } : {}),
37
43
  ...(this.retry_after !== undefined ? { retry_after: this.retry_after } : {}),
38
44
  };
39
45
  }
@@ -12,6 +12,7 @@ import { ENV_KEY, resolveApiKey } from "./auth.js";
12
12
  import { registerSecret } from "./logger.js";
13
13
  import { CLI_USER_AGENT } from "./config.js";
14
14
  import { agentClientHeader } from "./agent-client.js";
15
+ import { normalizeDomain, PAGE_NOT_RECOGNISED_STEPS, prepareCommand } from "./prepared-domains-api.js";
15
16
  /**
16
17
  * Client-side timeout for a Fetch and Extract request, deliberately ABOVE the
17
18
  * gateway's own 90s request budget.
@@ -99,17 +100,10 @@ export async function scrape(apiBase, apiKey, params, opts = {}) {
99
100
  concurrencyRemaining: numOrNull(res.headers.get("concurrency-remaining")),
100
101
  redactedUrl: redacted,
101
102
  };
102
- // REQS007 on extract=auto: the domain hasn't been prepared for Extract yet.
103
- // Same recovery as AUTH010 (the extract adapter falls back to autoparse), so it
104
- // maps to the same code. Checked before the 403 auth branch below.
103
+ // REQS007 on extract=auto: Extract has not prepared the domain yet. Reported,
104
+ // never retried: the fix is to prepare the domain, which the error names.
105
105
  if (res.status === 403 && params.extract !== undefined && zrErrorCode(body) === "REQS007") {
106
- throw new ToolkitError({
107
- code: "EXTRACT_DOMAIN_NOT_ENABLED",
108
- message: "Extract has not been prepared for this domain yet.",
109
- likely_cause: zrErrorDetail(body) ?? "This domain is not prepared for Extract.",
110
- next_action: "Retry with --autoparse for general-purpose extraction on any site, or get the domain prepared for Extract (POST /v1/extract/prepared-domains).",
111
- suggested_commands: [`zenrows extract ${params.url} --autoparse`],
112
- });
106
+ throw extractDomainNotPrepared(params.url, body);
113
107
  }
114
108
  // 422 no_data_extracted on an Extract request: the page was processed and had
115
109
  // nothing to extract. It is an answer, not a failure to reach the target, so
@@ -163,17 +157,10 @@ export async function scrape(apiBase, apiKey, params, opts = {}) {
163
157
  }
164
158
  }
165
159
  if (res.status === 402 && isZenrowsErrorEnvelope(body)) {
166
- // AUTH010 on extract=auto means the target domain is not enabled for
167
- // Extract, distinct from credit exhaustion (AUTH004). Let the extract
168
- // adapter fall back to autoparse.
160
+ // AUTH010 on extract=auto is the legacy code for REQS007: the domain is
161
+ // not prepared for Extract. Distinct from credit exhaustion (AUTH004).
169
162
  if (params.extract !== undefined && zrErrorCode(body) === "AUTH010") {
170
- throw new ToolkitError({
171
- code: "EXTRACT_DOMAIN_NOT_ENABLED",
172
- message: "Extract is not enabled for this domain yet.",
173
- likely_cause: zrErrorDetail(body) ?? "This domain is not enabled for Extract.",
174
- next_action: "Retry with --autoparse for general-purpose extraction on any site, or get the domain prepared for Extract (POST /v1/extract/prepared-domains).",
175
- suggested_commands: [`zenrows extract ${params.url} --autoparse`],
176
- });
163
+ throw extractDomainNotPrepared(params.url, body);
177
164
  }
178
165
  // AUTH014: this key hit one of its credit caps; the account still has credits.
179
166
  if (isKeyCapReached(zrErrorCode(body) ?? undefined)) {
@@ -261,6 +248,77 @@ export async function scrape(apiBase, apiKey, params, opts = {}) {
261
248
  }
262
249
  return result;
263
250
  }
251
+ const REFUSAL_REASONS = ["domain_not_prepared", "page_not_recognised", "domain_not_enabled"];
252
+ /**
253
+ * Which REQS007 this is. Only domain_not_prepared is fixed by preparing the
254
+ * domain: on page_not_recognised the domain is already prepared, and
255
+ * resubmitting it returns the existing ready record, so pointing there would
256
+ * loop.
257
+ *
258
+ * The gateway says it in the problem's `reason` field, read first. The sentence
259
+ * match below is only the fallback for a gateway deployed before that field
260
+ * existed, and reads the titles that gateway sends; remove it once every gateway
261
+ * sends `reason`.
262
+ */
263
+ export function refusalReason(body) {
264
+ let j;
265
+ try {
266
+ j = JSON.parse(body);
267
+ }
268
+ catch {
269
+ return "domain_not_prepared";
270
+ }
271
+ if (typeof j.reason === "string" && REFUSAL_REASONS.includes(j.reason))
272
+ return j.reason;
273
+ const title = typeof j.title === "string" ? j.title : "";
274
+ const detail = typeof j.detail === "string" ? j.detail : "";
275
+ if (/did not recogni[sz]e this page/i.test(title) || /^Extract serves /.test(detail))
276
+ return "page_not_recognised";
277
+ if (/^Extract is not enabled/i.test(title))
278
+ return "domain_not_enabled";
279
+ return "domain_not_prepared";
280
+ }
281
+ /**
282
+ * REQS007 (or the legacy AUTH010) on extract=auto. Each reason names its own
283
+ * next step, because agents copy it: the exact prepare command for a domain not
284
+ * prepared yet, the gateway's advice in the gateway's order for a page it did not
285
+ * recognise. None offers Autoparse.
286
+ */
287
+ export function extractDomainNotPrepared(url, body) {
288
+ const domain = normalizeDomain(url);
289
+ const detail = zrErrorDetail(body);
290
+ const css = `zenrows extract ${url} --css '{"title":"h1"}'`;
291
+ const reason = refusalReason(body);
292
+ if (reason === "page_not_recognised") {
293
+ return new ToolkitError({
294
+ code: "EXTRACT_PAGE_NOT_RECOGNISED",
295
+ message: `Extract serves ${domain} but did not recognise this page as one it can extract (it may be a page with nothing to extract, such as a home page). ${PAGE_NOT_RECOGNISED_STEPS} The request was not charged.`,
296
+ likely_cause: detail ?? `Extract serves ${domain} but did not recognise this page.`,
297
+ next_action: PAGE_NOT_RECOGNISED_STEPS,
298
+ suggested_commands: [css],
299
+ server_code: "REQS007",
300
+ });
301
+ }
302
+ if (reason === "domain_not_enabled") {
303
+ return new ToolkitError({
304
+ code: "EXTRACT_DOMAIN_NOT_ENABLED",
305
+ message: `Extract is not enabled for ${domain}, and preparing it does not change this.`,
306
+ likely_cause: detail ?? `Extract is not enabled for ${domain}.`,
307
+ next_action: "Extract this page with --css and explicit selectors, or contact Zenrows support to have the site enabled.",
308
+ suggested_commands: [css],
309
+ server_code: "REQS007",
310
+ });
311
+ }
312
+ const prepare = prepareCommand(url);
313
+ return new ToolkitError({
314
+ code: "EXTRACT_DOMAIN_NOT_PREPARED",
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
+ 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.`,
318
+ suggested_commands: [`${prepare} --wait`, `zenrows extract prepare status ${domain}`, css],
319
+ server_code: "REQS007",
320
+ });
321
+ }
264
322
  /**
265
323
  * The CLI gave up waiting before the API answered. This is NOT unreachability:
266
324
  * the connection was established and the gateway was still working, so telling
@@ -1,9 +1,16 @@
1
1
  import { ToolkitError } from "./errors.js";
2
2
  import { findWorkspace, readJson, workspacePaths, writeJson } from "./workspace.js";
3
+ /**
4
+ * Safe defaults. The per-run caps let a default run submit up to 10,000 URLs
5
+ * in any mode: an Adaptive Stealth Mode task is estimated at its 25-credit
6
+ * upper bound, so 10,000 of them estimate at 250,000 credits. Larger runs
7
+ * (the Batch API takes up to 100,000 tasks per job) need the caps raised
8
+ * explicitly with `zenrows policy set`.
9
+ */
3
10
  export function defaultPolicy() {
4
11
  return {
5
- max_credits_per_run: 10000,
6
- max_pages_per_run: 1000,
12
+ max_credits_per_run: 250_000,
13
+ max_pages_per_run: 10_000,
7
14
  max_concurrency: 20,
8
15
  allow_browser: true,
9
16
  allow_experimental: false,
@@ -94,8 +101,8 @@ export function assertWithinLimits(requested, policy, context = "run") {
94
101
  code: "POLICY_LIMIT_EXCEEDED",
95
102
  message: `This ${context} submits ${requested.pages} page(s), over the policy cap of ${policy.max_pages_per_run}.`,
96
103
  likely_cause: "policy.max_pages_per_run bounds how many URLs a single run may submit.",
97
- next_action: `Split the job into runs of ≤${policy.max_pages_per_run} page(s), or raise max_pages_per_run in policy.json.`,
98
- suggested_commands: ["zenrows policy show"],
104
+ next_action: `Split the job into runs of at most ${policy.max_pages_per_run} page(s), or raise the cap: \`zenrows policy set max_pages_per_run ${requested.pages}\` (default ${defaultPolicy().max_pages_per_run}).`,
105
+ suggested_commands: [`zenrows policy set max_pages_per_run ${requested.pages}`, "zenrows policy show"],
99
106
  });
100
107
  }
101
108
  if (requested.credits !== undefined && requested.credits > policy.max_credits_per_run) {
@@ -103,8 +110,8 @@ export function assertWithinLimits(requested, policy, context = "run") {
103
110
  code: "POLICY_LIMIT_EXCEEDED",
104
111
  message: `This ${context} is estimated at ${requested.credits} credit(s), over the policy cap of ${policy.max_credits_per_run}.`,
105
112
  likely_cause: "policy.max_credits_per_run bounds the estimated credit cost of a single run.",
106
- next_action: "Reduce the job size or per-task cost (e.g. drop premium_proxy/js_render), or raise max_credits_per_run in policy.json.",
107
- suggested_commands: ["zenrows policy show"],
113
+ next_action: `Reduce the job size or per-task cost (e.g. drop premium_proxy/js_render), or raise the cap: \`zenrows policy set max_credits_per_run ${requested.credits}\` (default ${defaultPolicy().max_credits_per_run}). The estimate is an upper bound: an Adaptive Stealth Mode task counts 25 credits, and is billed 1 to 25.`,
114
+ suggested_commands: [`zenrows policy set max_credits_per_run ${requested.credits}`, "zenrows policy show"],
108
115
  });
109
116
  }
110
117
  }
@@ -0,0 +1,289 @@
1
+ /**
2
+ * Client for Extract's prepared-domains API.
3
+ *
4
+ * `extract=auto` answers REQS007 on a domain Extract has not prepared yet. Preparing
5
+ * it is a call any key can make: `POST {apiBase}/extract/prepared-domains` with the
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
8
+ * `GET .../allowance` the account's monthly allowance of new domains. Auth is the
9
+ * `X-API-Key` header; errors are problem+json and we branch on `status` + `code`.
10
+ * `fetchImpl` / `sleepImpl` / `now` are injectable for tests.
11
+ */
12
+ import { ToolkitError } from "./errors.js";
13
+ import { registerSecret } from "./logger.js";
14
+ import { CLI_USER_AGENT, loadConfig } from "./config.js";
15
+ import { agentClientHeader } from "./agent-client.js";
16
+ export const MAX_EXAMPLE_URLS = 5;
17
+ /** How often `--wait` polls, and for how long by default. */
18
+ export const DEFAULT_POLL_INTERVAL_MS = 30_000;
19
+ export const DEFAULT_WAIT_TIMEOUT_MS = 15 * 60_000;
20
+ /**
21
+ * The domain as the API keys it: host only, no scheme, path or leading `www.`.
22
+ * Accepts a URL.
23
+ *
24
+ * Deliberately not the registrable domain: Extract prepares and serves each host
25
+ * on its own. A prepared `es.wikipedia.org` does not cover `pt.wikipedia.org`
26
+ * (REQS007 names `pt.wikipedia.org`), and `wikipedia.org` has no record of its
27
+ * own. The API itself folds `www.` into the bare host, so dropping it here
28
+ * matches what it stores.
29
+ */
30
+ export function normalizeDomain(input) {
31
+ const trimmed = input.trim();
32
+ let host = trimmed;
33
+ try {
34
+ host = new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`).hostname;
35
+ }
36
+ catch {
37
+ // Left as given: the API answers invalid_argument with the reason.
38
+ }
39
+ return host.toLowerCase().replace(/^www\./, "");
40
+ }
41
+ /** The exact command an agent runs to prepare the domain of `url`. */
42
+ export function prepareCommand(url) {
43
+ return `zenrows extract prepare ${normalizeDomain(url)} --url ${url}`;
44
+ }
45
+ function base(opts) {
46
+ return (opts.apiBase ?? loadConfig().apiBase).replace(/\/+$/, "");
47
+ }
48
+ async function request(method, path, domain, opts) {
49
+ registerSecret(opts.apiKey);
50
+ const headers = {
51
+ "X-API-Key": opts.apiKey,
52
+ Accept: "application/json",
53
+ "User-Agent": CLI_USER_AGENT,
54
+ ...agentClientHeader(),
55
+ };
56
+ if (opts.body !== undefined)
57
+ headers["Content-Type"] = "application/json";
58
+ let res;
59
+ try {
60
+ res = await (opts.fetchImpl ?? fetch)(`${base(opts)}/extract/prepared-domains${path}`, {
61
+ method,
62
+ headers,
63
+ body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
64
+ });
65
+ }
66
+ catch (err) {
67
+ throw new ToolkitError({
68
+ code: "BACKEND_UNAVAILABLE",
69
+ message: "Could not reach the Zenrows API.",
70
+ likely_cause: err instanceof Error ? err.message : String(err),
71
+ next_action: "Check connectivity and retry. Verify the api base in `zenrows config show`.",
72
+ suggested_commands: ["zenrows status", "zenrows config show"],
73
+ });
74
+ }
75
+ const text = await res.text();
76
+ if (res.status < 200 || res.status >= 300)
77
+ throw prepareError(res.status, text, domain);
78
+ try {
79
+ return JSON.parse(text);
80
+ }
81
+ catch {
82
+ throw new ToolkitError({
83
+ code: "EXTRACT_PREPARE_FAILED",
84
+ message: "The prepared-domains API returned a non-JSON answer.",
85
+ likely_cause: text.slice(0, 200),
86
+ next_action: "Retry in a minute.",
87
+ });
88
+ }
89
+ }
90
+ /** Map a non-2xx prepared-domains answer to an error that names the next step. */
91
+ export function prepareError(status, body, domain) {
92
+ let p = {};
93
+ try {
94
+ p = JSON.parse(body) ?? {};
95
+ }
96
+ catch {
97
+ // not problem+json
98
+ }
99
+ const code = typeof p.code === "string" ? p.code.toLowerCase() : undefined;
100
+ const detail = typeof p.detail === "string" ? p.detail : body.slice(0, 200) || `HTTP ${status}`;
101
+ const cause = code ? `(${code}) ${detail}` : detail;
102
+ if (code === "invalid_argument" || status === 400) {
103
+ return new ToolkitError({
104
+ code: "EXTRACT_PREPARE_INVALID",
105
+ message: `The request to prepare ${domain} was rejected.`,
106
+ likely_cause: p.reason ? `${cause} [reason: ${String(p.reason)}]` : cause,
107
+ next_action: `Pass the domain without scheme or path, and 1 to ${MAX_EXAMPLE_URLS} full page URLs from that site with --url.`,
108
+ suggested_commands: [`zenrows extract prepare ${domain} --url https://${domain}/<page>`],
109
+ server_code: code,
110
+ });
111
+ }
112
+ if (code === "not_found" || status === 404) {
113
+ return new ToolkitError({
114
+ code: "EXTRACT_PREPARE_NOT_FOUND",
115
+ message: `This account has not submitted ${domain} for preparation.`,
116
+ likely_cause: cause,
117
+ next_action: `Prepare it first: zenrows extract prepare ${domain} --url <page url> (1 to ${MAX_EXAMPLE_URLS} --url).`,
118
+ suggested_commands: [`zenrows extract prepare ${domain} --url https://${domain}/<page>`],
119
+ server_code: code,
120
+ });
121
+ }
122
+ if (code === "in_progress") {
123
+ return new ToolkitError({
124
+ code: "EXTRACT_PREPARE_IN_PROGRESS",
125
+ message: `${domain} is already being prepared.`,
126
+ likely_cause: cause,
127
+ next_action: "Do not resubmit. Retry the extract in a few minutes.",
128
+ suggested_commands: [`zenrows extract prepare status ${domain}`],
129
+ server_code: code,
130
+ });
131
+ }
132
+ if (code === "idempotency_conflict") {
133
+ return new ToolkitError({
134
+ code: "EXTRACT_PREPARE_INVALID",
135
+ message: "This idempotency key was already used with a different request.",
136
+ likely_cause: cause,
137
+ next_action: "Retry without the key, or with a new one.",
138
+ server_code: code,
139
+ });
140
+ }
141
+ if (code === "out_of_allowance" || status === 429) {
142
+ const used = p.used !== undefined && p.limit !== undefined ? ` (${String(p.used)} of ${String(p.limit)} used)` : "";
143
+ return new ToolkitError({
144
+ code: "EXTRACT_PREPARE_OUT_OF_ALLOWANCE",
145
+ message: `This account has used its monthly allowance of new domains to prepare${used}.`,
146
+ likely_cause: cause,
147
+ next_action: "Do not retry this month. Meanwhile extract this domain with --css and explicit selectors.",
148
+ suggested_commands: [`zenrows extract <url> --css '{"title":"h1"}'`],
149
+ server_code: code,
150
+ });
151
+ }
152
+ if (code === "overloaded" || code === "disabled" || status === 503) {
153
+ return new ToolkitError({
154
+ code: "EXTRACT_PREPARE_UNAVAILABLE",
155
+ message: "Domain preparation is unavailable right now.",
156
+ likely_cause: cause,
157
+ next_action: "Retry in a few minutes; do not loop. Meanwhile extract with --css and explicit selectors.",
158
+ server_code: code,
159
+ });
160
+ }
161
+ if (status === 401 || status === 403) {
162
+ return new ToolkitError({
163
+ code: "AUTH_INVALID",
164
+ message: "Zenrows rejected the API key.",
165
+ likely_cause: cause,
166
+ next_action: "Re-check your key and log in again.",
167
+ suggested_commands: ["zenrows login --api-key <your-key>"],
168
+ server_code: code,
169
+ });
170
+ }
171
+ return new ToolkitError({
172
+ code: "EXTRACT_PREPARE_FAILED",
173
+ message: `Preparing ${domain} failed with HTTP ${status}.`,
174
+ likely_cause: cause,
175
+ next_action: "Retry in a few minutes.",
176
+ server_code: code,
177
+ });
178
+ }
179
+ export function submitPreparedDomain(domain, exampleUrls, opts) {
180
+ return request("POST", "", domain, { ...opts, body: { domain, example_urls: exampleUrls } });
181
+ }
182
+ export function getPreparedDomain(domain, opts) {
183
+ return request("GET", `/${encodeURIComponent(domain)}`, domain, opts);
184
+ }
185
+ export function getAllowance(opts) {
186
+ return request("GET", "/allowance", "", opts);
187
+ }
188
+ /**
189
+ * Submit a domain. A 409 in_progress (already being prepared) answers with the
190
+ * current status when this account can read it, so the caller just follows it.
191
+ */
192
+ export async function prepareDomain(domain, exampleUrls, opts) {
193
+ try {
194
+ return await submitPreparedDomain(domain, exampleUrls, opts);
195
+ }
196
+ catch (err) {
197
+ if (!(err instanceof ToolkitError) || err.code !== "EXTRACT_PREPARE_IN_PROGRESS")
198
+ throw err;
199
+ try {
200
+ return { ...(await getPreparedDomain(domain, opts)), already_in_progress: true };
201
+ }
202
+ catch {
203
+ throw err;
204
+ }
205
+ }
206
+ }
207
+ export function isTerminal(record) {
208
+ return record.status === "ready" || record.status === "failed";
209
+ }
210
+ /** Poll until ready or failed. Throws EXTRACT_PREPARE_TIMEOUT when the wait runs out. */
211
+ export async function waitForPreparedDomain(domain, opts) {
212
+ const sleep = opts.sleepImpl ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
213
+ const now = opts.now ?? Date.now;
214
+ const interval = opts.intervalMs ?? DEFAULT_POLL_INTERVAL_MS;
215
+ const deadline = now() + (opts.timeoutMs ?? DEFAULT_WAIT_TIMEOUT_MS);
216
+ for (;;) {
217
+ const record = await getPreparedDomain(domain, opts);
218
+ opts.onPoll?.(record);
219
+ if (isTerminal(record))
220
+ return record;
221
+ if (now() + interval > deadline) {
222
+ throw new ToolkitError({
223
+ code: "EXTRACT_PREPARE_TIMEOUT",
224
+ message: `${domain} is still being prepared (state: ${record.state ?? record.status}).`,
225
+ likely_cause: "Preparation usually takes a few minutes; this one is taking longer than the wait.",
226
+ next_action: "Check again later; it keeps running on the server.",
227
+ suggested_commands: [`zenrows extract prepare status ${domain}`],
228
+ });
229
+ }
230
+ await sleep(interval);
231
+ }
232
+ }
233
+ /** Whether a failed preparation used an allowance slot, from the record's failure.slot_consumed. */
234
+ function slotSentence(slotConsumed) {
235
+ if (slotConsumed === true)
236
+ return " This attempt used an allowance slot.";
237
+ if (slotConsumed === false)
238
+ return " This attempt did not use an allowance slot.";
239
+ return "";
240
+ }
241
+ /** What to do after a failed preparation, by failure.code (docs.zenrows.com extract/endpoints). */
242
+ export function failureNextStep(domain, failure) {
243
+ let step;
244
+ switch (failure?.code) {
245
+ case "interrupted":
246
+ case "preparation_exhausted":
247
+ step = `Run the same zenrows extract prepare ${domain} command again.`;
248
+ break;
249
+ case "discovery_failed":
250
+ step = `Run zenrows extract prepare ${domain} again with 1 to ${MAX_EXAMPLE_URLS} --url page URLs.`;
251
+ break;
252
+ case "unpreparable":
253
+ step = `Run zenrows extract prepare ${domain} again with different --url pages from the same site; the same URLs fail again.`;
254
+ break;
255
+ case "preparation_failed":
256
+ step = "Try other --url pages, or contact Zenrows support.";
257
+ break;
258
+ default:
259
+ step = "Contact Zenrows support before preparing this domain again. Meanwhile extract with --css.";
260
+ }
261
+ return step + slotSentence(failure?.slot_consumed);
262
+ }
263
+ /**
264
+ * What to do when a page on a prepared domain answers REQS007 with reason
265
+ * page_not_recognised, in the gateway's own order. On a prepared domain Extract
266
+ * learns a new kind of page on its first request, so that answer means this page
267
+ * is not one Extract extracts (a page with nothing to extract, such as a home page) or a
268
+ * transient failure kept it from trying, never that the domain needs preparing
269
+ * again: resubmitting returns the existing ready record, so pointing there would
270
+ * loop.
271
+ */
272
+ export const PAGE_NOT_RECOGNISED_STEPS = "Try a product, article or listing page on the same site instead. If this page should work, retry it once in a few minutes; do not retry in a loop. Otherwise use --css with explicit selectors. Preparing the domain again does not change this.";
273
+ /** Said once a domain is ready, so a user who then meets EXTRACT_PAGE_NOT_RECOGNISED does not resubmit. */
274
+ const IF_PAGE_NOT_RECOGNISED = `If the extract then fails with EXTRACT_PAGE_NOT_RECOGNISED, Extract did not recognise that page. ${PAGE_NOT_RECOGNISED_STEPS}`;
275
+ /**
276
+ * The next step for a status record. `alreadyReady` is set when a submit answered
277
+ * ready at once: the domain was prepared before this call.
278
+ */
279
+ export function statusNextStep(record, opts = {}) {
280
+ if (record.status === "ready") {
281
+ const extract = opts.exampleUrl ? ` (\`zenrows extract ${opts.exampleUrl}\`)` : "";
282
+ if (opts.alreadyReady)
283
+ return `${record.domain} is already prepared, so nothing new was submitted: run the extract${extract}. ${IF_PAGE_NOT_RECOGNISED}`;
284
+ return `${record.domain} is ready: run the extract again${extract}. ${IF_PAGE_NOT_RECOGNISED}`;
285
+ }
286
+ if (record.status === "failed")
287
+ return failureNextStep(record.domain, record.failure);
288
+ return `Preparing; it takes a few minutes. Check about every 30 seconds with: zenrows extract prepare status ${record.domain}`;
289
+ }
@@ -2,7 +2,7 @@
2
2
 
3
3
  Reproducible smoke test for Extract (`extract=auto`).
4
4
 
5
- - **Target:** `https://www.owler.com/company/meltwater` (enabled domain, real company page)
5
+ - **Target:** `https://www.owler.com/company/meltwater` (prepared domain, real company page)
6
6
  - **Config:** `extract=auto`
7
7
  - **Success criteria:** HTTP 2xx and a non-empty body
8
8
  - **Cost:** ~1× basic request
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zenrows/cli",
3
- "version": "1.7.0",
3
+ "version": "1.9.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",
@@ -23,11 +23,14 @@ zenrows batch estimate jobs.jsonl
23
23
  ```
24
24
 
25
25
  Swap `jobs.jsonl` for your own spec. Per-line keys (`js_render`,
26
- `premium_proxy`, `proxy_country`, `mode`, `autoparse`, `external_id`) tune each
27
- URL; cost multipliers are basic 1x / JS 5x / proxy 10x / both 25x.
26
+ `premium_proxy`, `proxy_country`, `mode`, `autoparse`, `external_id`,
27
+ `metadata`, `method`, `body`) tune each URL; cost multipliers are basic 1x / JS
28
+ 5x / proxy 10x / both 25x, and Adaptive Stealth Mode costs 1x to 25x. Pass
29
+ `create`'s job-level flags (`--premium-proxy`, `--js-render`, `--manual`) to
30
+ `estimate` to price the same request.
28
31
 
29
32
  Once the spec is clean, submit it:
30
33
  ```
31
- zenrows batch create jobs.jsonl --wait
34
+ zenrows batch create jobs.jsonl --follow
32
35
  ```
33
36
  See [[batch-jobs]] for the full cloud flow.
@@ -12,12 +12,12 @@
12
12
  },
13
13
  "extract": {
14
14
  "key": "extract",
15
- "label": "Extract (extract=auto / Autoparse / CSS / Markdown)",
15
+ "label": "Extract (extract=auto / CSS / Markdown)",
16
16
  "status": "available",
17
17
  "command": "zenrows extract",
18
- "backend": "Extract — GET https://api.zenrows.com/v1/ (extract=auto, autoparse, css_extractor, outputs, response_type)",
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; CLI falls back to autoparse on REQS007 (domain not prepared yet) or AUTH010. Autoparse / CSS / outputs / markdown work on any domain."
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."
21
21
  },
22
22
  "batch": {
23
23
  "key": "batch",
@@ -25,13 +25,13 @@
25
25
  {
26
26
  "name": "extract",
27
27
  "type": "skill",
28
- "description": "Turn protected pages into structured data with Autoparse / CSS / Markdown.",
28
+ "description": "Turn protected pages into structured data with Extract / CSS / Markdown (Autoparse is deprecated).",
29
29
  "status": "available",
30
30
  "requires_backend_capabilities": ["extract"],
31
31
  "requires_auth": true,
32
32
  "version": "0.1.0",
33
33
  "path": "skills/extract",
34
- "tags": ["extract", "autoparse", "structured-data"]
34
+ "tags": ["extract", "structured-data"]
35
35
  },
36
36
  {
37
37
  "name": "batch-jobs",
@@ -14,7 +14,7 @@
14
14
  {
15
15
  "name": "extract-pricing-pages",
16
16
  "type": "template",
17
- "description": "Extract structured pricing fields from product pages (Autoparse / CSS).",
17
+ "description": "Extract structured pricing fields from product pages (extract=auto / CSS).",
18
18
  "status": "available",
19
19
  "requires_backend_capabilities": ["extract"],
20
20
  "requires_auth": true,