@zenrows/cli 1.8.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.
package/README.md CHANGED
@@ -37,7 +37,7 @@ that endpoint with extraction parameters, not a separate product.
37
37
  | Command | What it does | Status (this build) |
38
38
  | --- | --- | --- |
39
39
  | `zenrows fetch` | Fetch: retrieve a protected page | **available**: `GET https://api.zenrows.com/v1/` |
40
- | `zenrows extract` | Extract: turn a page into structured data with `extract=auto` on any prepared domain (falls back to Autoparse on one not prepared yet), CSS selectors, or Markdown | **available**: `GET https://api.zenrows.com/v1/?extract=auto` |
40
+ | `zenrows extract` | Extract: turn a page into structured data with `extract=auto` on any prepared domain (prepare one yourself with `zenrows extract prepare`), CSS selectors, or Markdown | **available**: `GET https://api.zenrows.com/v1/?extract=auto` |
41
41
  | `zenrows batch` | Batch: run Fetch and Extract over many URLs in one job; specs validate and estimate locally with no key | **available**: `POST https://async.api.zenrows.com/v1/jobs` |
42
42
  | `zenrows crawl` | Crawl (new): collect the URLs behind one start page, optionally with each page's HTML | **available**: `POST https://api.zenrows.com/v1/crawls` |
43
43
  | `zenrows browser` | Browser Sessions: a real cloud browser over REST (same backend as MCP `browser_*`); escalation-only, billed by bandwidth + time | **available**: `POST https://mcp.zenrows.com/browser/sessions` |
@@ -56,7 +56,7 @@ npm install -g @zenrows/cli # puts `zenrows` on PATH (466 ms, zero deps)
56
56
  zenrows init
57
57
  zenrows fetch https://httpbin.io/html # auto-provisions a Free plan account on first use
58
58
  zenrows extract https://www.owler.com/company/meltwater # extract=auto on a prepared domain
59
- zenrows extract https://www.scrapingcourse.com/ecommerce/ --autoparse # Autoparse (any domain)
59
+ zenrows extract https://www.scrapingcourse.com/ecommerce/ --css '{"title":"h1"}' # CSS selectors (any domain)
60
60
  ```
61
61
 
62
62
  > Prefer no global install? Prefix each command with `npx -y @zenrows/cli`
@@ -118,12 +118,28 @@ zenrows fetch <url> --proxy-country us --wait-for ".price"
118
118
 
119
119
  ```bash
120
120
  zenrows extract https://www.owler.com/company/meltwater # extract=auto (prepared domain)
121
- zenrows extract https://www.scrapingcourse.com/ecommerce/ --autoparse # Autoparse (any domain)
121
+ zenrows extract https://www.scrapingcourse.com/ecommerce/ --autoparse # deprecated Autoparse (any domain)
122
122
  zenrows extract <url> --css '{"title":"h1","price":".price"}' --validate
123
123
  zenrows extract <url> --outputs emails,links # built-in output filters → JSON
124
124
  zenrows extract <url> --output markdown
125
125
  ```
126
126
 
127
+ On a domain Extract hasn't prepared yet, `zenrows extract` fails with
128
+ `EXTRACT_DOMAIN_NOT_PREPARED` (API code REQS007) and prints the command to
129
+ prepare it. Preparing takes a few minutes and costs no credits; each new domain
130
+ uses one slot of the monthly allowance. `EXTRACT_PAGE_NOT_RECOGNISED` (also
131
+ REQS007) is different: the domain is prepared, but this page is not one Extract
132
+ extracts. Try a product, article or listing page instead, retry it once in a
133
+ few minutes if it should work, or use `--css`; preparing again does not change
134
+ it. `zenrows extract` never falls back to Autoparse. The `fallbackAutoparse`
135
+ adapter option is deprecated and has no effect.
136
+
137
+ ```bash
138
+ zenrows extract prepare example.com --url https://example.com/p/1 --url https://example.com/list --wait
139
+ zenrows extract prepare status example.com # progress + monthly allowance
140
+ zenrows extract https://example.com/p/1 # then extract again
141
+ ```
142
+
127
143
  ## 9. Batch
128
144
 
129
145
  Zenrows **Batch** (`https://async.api.zenrows.com/v1`) fans a
@@ -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` + autoparse / css_extractor) |
65
+ | `extract` | `extract` (fallback: `scrape` + css_extractor); on REQS007 with reason `domain_not_prepared`, `prepare_domain` 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 |
@@ -19,8 +19,8 @@ These multipliers apply when you enable the flags (on `scrape` or `extract`):
19
19
  - Both: **25×**
20
20
 
21
21
  `extract` `mode=auto` is currently free; billing may apply
22
- later. Do not promise permanent free extract pricing. Autoparse / CSS still
23
- follow the multipliers above when stealth flags are set.
22
+ later. Do not promise permanent free extract pricing. CSS (and the deprecated
23
+ Autoparse) still follow the multipliers above when stealth flags are set.
24
24
 
25
25
  ## Browser sessions (`browser_*`)
26
26
 
@@ -14,28 +14,67 @@ Convert protected pages into structured data. Value is **protected page access
14
14
  Use **`extract`** when you need structured JSON fields rather than a full page:
15
15
 
16
16
  ```
17
- extract({ url }) # mode=auto (default)
18
- extract({ url, mode: "autoparse" }) # general Autoparse (any domain)
17
+ extract({ url }) # mode=auto (default), returns `parsed` only
18
+ extract({ url, include_html: true }) # also return the full page HTML
19
+ extract({ url, mode: "autoparse" }) # deprecated Autoparse (any domain)
19
20
  extract({ url, mode: "css", css_extractor: '{"title":"h1","price":".price"}' })
20
21
  extract({ url, proxy_country: "US" }) # geo-targeted
21
22
  ```
22
23
 
23
- `mode=auto` (`extract=auto`) works on any domain Zenrows has prepared, with any
24
- key, and is currently free; billing may apply later. If the
25
- domain is not enabled (AUTH010) or not prepared yet (REQS007), the tool retries
26
- once with Autoparse by default (`fallback_autoparse`).
24
+ `mode=auto` (`extract=auto`) works on any prepared domain, with any key, and
25
+ is currently free; billing may apply later. The result carries `parsed` only;
26
+ the page HTML is most of the response size, so ask for it with
27
+ `include_html: true` only when you need it.
27
28
 
28
29
  Every mode uses Adaptive Stealth Mode by default: pass just the URL, even for
29
30
  protected or dynamic sites. Set `js_render` or `premium_proxy` only to force a
30
31
  fixed configuration; that turns Adaptive Stealth Mode off. Check `empty` in the
31
32
  result: true means no field came back with a value.
32
33
 
34
+ ## REQS007: prepare the domain, then extract again
35
+
36
+ `mode=auto` works on any prepared domain, and you can prepare one yourself.
37
+ When Extract refuses a page, `extract` returns a tool error with code
38
+ `REQS007` (legacy `AUTH010`) and a `reason`. It never retries with Autoparse.
39
+ `fallback_autoparse` is deprecated and has no effect; don't send it.
40
+
41
+ | `reason` | Meaning | Next step |
42
+ | --- | --- | --- |
43
+ | `domain_not_prepared` | The domain is not prepared yet. | `prepare_domain` (the error's `next_tool` holds its arguments), then poll, then `extract` again. |
44
+ | `page_not_recognised` | The domain is prepared, but Extract did not recognise this page as one it can extract (a page with nothing to extract, such as a home page, or a transient failure). | 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 `mode=css`. Do not call `prepare_domain`: preparing again does not change this. |
45
+ | `domain_not_enabled` | Extract is not enabled for the site. | Use `mode=css`, or contact Zenrows support to have the site enabled. Preparing does not change this. |
46
+
47
+ Preparing a domain:
48
+
49
+ ```
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"] })
53
+ # 2. returns at once, status "preparing"
54
+ prepared_domain_status({ domain: "example.com" }) # 3. about every 30 seconds until "ready"
55
+ extract({ url }) # 4. extract again
56
+ ```
57
+
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
+ - The domain is the host: `shop.example.com` and `example.com` are prepared
61
+ separately, and a leading `www.` is dropped.
62
+ - Preparing takes a few minutes and costs no credits. Each new domain uses one
63
+ slot of the account's monthly allowance; `prepared_domain_status` returns it
64
+ (`used` / `limit` / `remaining`) and a `next` field with what to do now.
65
+ - `out_of_allowance`: no slots left this month; use `mode=css`.
66
+ `overloaded` / `disabled`: retry in a few minutes; do not loop.
67
+ A `failed` status carries `failure.code` and `failure.slot_consumed`; follow `next`.
68
+ - A domain that is already prepared answers `ready` at once
69
+ (`already_prepared: true`) and uses no slot: call `extract`. If it then
70
+ returns `page_not_recognised`, follow that row of the table, not `prepare_domain`.
71
+
33
72
  ## Fallback: `scrape` extraction params
34
73
 
35
74
  If `extract` is unavailable, similar outcomes are available on **`scrape`**:
36
75
 
37
76
  ```
38
- scrape({ url, autoparse: true })
77
+ scrape({ url, autoparse: true }) # deprecated
39
78
  scrape({ url, css_extractor: '{"title":"h1","price":".price"}' })
40
79
  scrape({ url, outputs: "emails,links" }) # or "*" for all built-ins
41
80
  ```
@@ -46,7 +85,8 @@ scrape({ url, outputs: "emails,links" }) # or "*" for all built-ins
46
85
  ## When to use which
47
86
 
48
87
  - **`mode=auto`**: site-tailored fields on any prepared domain.
49
- - **`mode=autoparse`**: any website; also the automatic AUTH010 / REQS007 fallback.
88
+ - **`mode=autoparse`** (deprecated): any website, only when asked for. Do not
89
+ build new work on it.
50
90
  - **`mode=css`**: known fields/selectors; deterministic.
51
91
  - **Markdown via `scrape`**: feed clean content to an LLM yourself.
52
92
 
@@ -57,7 +97,7 @@ Stealth flags (`js_render`, `premium_proxy`) still apply the usual multipliers
57
97
  ## Rules
58
98
 
59
99
  - Validate on a single page before `batch_create` ([[batch-jobs]]).
60
- - If Autoparse misses fields, switch to `css_extractor`, or add `js_render`
100
+ - If a field is missing, switch to `css_extractor`, or add `js_render`
61
101
  for JS-heavy pages.
62
102
  - Prefer MCP `extract` / `scrape` over CLI `zenrows extract` when the MCP
63
103
  server is connected.
@@ -34,7 +34,7 @@ scrape({ url, js_render: true, wait_for: ".price" })
34
34
  - Prefer `response_type: "markdown"` for LLM consumption; use `html` only when
35
35
  you need raw markup.
36
36
  - For **structured** fields, prefer the `extract` tool (or `scrape` with
37
- `autoparse` / `css_extractor` / `outputs`) — see [[extract]].
37
+ `css_extractor` / `outputs`) — see [[extract]].
38
38
  - On failure, escalate with evidence before `browser_*` ([[interact-browser]]).
39
39
  - Cost model: [[cost-control]].
40
40
 
@@ -37,7 +37,7 @@ If the user has a known URL and wants page content:
37
37
 
38
38
  If the user has a known URL and wants structured data:
39
39
  → Use Extract. (MCP tool: extract)
40
- Fallback: scrape with autoparse / css_extractor / outputs
40
+ Fallback: scrape with css_extractor / outputs
41
41
 
42
42
  If the user has many URLs (bulk):
43
43
  → Use Batch. (validate one page with scrape/extract first,
@@ -67,7 +67,8 @@ If the user needs login, clicks, forms, sessions, or persistent state:
67
67
  | Primitive | MCP tools | Notes |
68
68
  | --- | --- | --- |
69
69
  | Protected Fetch | `scrape` | Default markdown; HTML / plaintext / PDF / screenshot options |
70
- | Extract | `extract` | Structured JSON (`extract=auto`, autoparse, css_extractor). `extract=auto` is currently free; billing may apply later |
70
+ | Extract | `extract` | Structured JSON (`extract=auto` on any prepared domain, css_extractor; autoparse deprecated). `extract=auto` is currently free; billing may apply later |
71
+ | 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 |
71
72
  | Batch | `batch_create`, `batch_status`, `batch_results`, `batch_cancel` | Same credits as Fetch; a 403 maps to `BATCH_ACCESS_DENIED` |
72
73
  | Browser | `browser_*` (start with `browser_navigate`) | Escalation-only; same backend as Browser Sessions |
73
74
 
@@ -1,17 +1,26 @@
1
1
  import { ToolkitError } from "../core/errors.js";
2
+ import { log } from "../core/logger.js";
2
3
  import { runFetch } from "./protected-fetch.js";
3
- /** True when a parsed extraction result carries no usable structured data. */
4
- function isEmptyData(data) {
4
+ /**
5
+ * True when a parsed extraction result carries no usable structured data: null,
6
+ * a blank string, or an array / object whose every value is itself empty. So
7
+ * `{"title": null, "items": [], "author": {"name": ""}}` is empty. Numbers and
8
+ * booleans are values, so `0` and `false` count as data.
9
+ */
10
+ export function isEmptyData(data) {
5
11
  if (data === null || data === undefined)
6
12
  return true;
7
- if (Array.isArray(data))
8
- return data.length === 0;
9
- if (typeof data === "object")
10
- return Object.keys(data).length === 0;
11
13
  if (typeof data === "string")
12
14
  return data.trim() === "";
15
+ if (Array.isArray(data))
16
+ return data.every(isEmptyData);
17
+ if (typeof data === "object")
18
+ return Object.values(data).every(isEmptyData);
13
19
  return false;
14
20
  }
21
+ /** The warning a caller that still passes `fallbackAutoparse: true` gets. */
22
+ export const FALLBACK_AUTOPARSE_DEPRECATION = "fallbackAutoparse is deprecated and has no effect: Extract no longer falls back to Autoparse. " +
23
+ "On REQS007, prepare the domain with `zenrows extract prepare <domain> --url <page>` instead.";
15
24
  export async function runExtract(opts, config, policy, apiKey) {
16
25
  const method = opts.method ?? (opts.outputs ? "outputs" : opts.cssExtractor ? "css" : "extract");
17
26
  if (method === "css" && !opts.cssExtractor) {
@@ -30,18 +39,8 @@ export async function runExtract(opts, config, policy, apiKey) {
30
39
  next_action: `Pass one or more filters, e.g. --outputs emails,links (or --outputs '*').`,
31
40
  });
32
41
  }
33
- if (method === "extract" && opts.fallbackAutoparse !== false) {
34
- try {
35
- return await runExtractOnce({ ...opts, method: "extract" }, config, policy, apiKey);
36
- }
37
- catch (err) {
38
- if (err instanceof ToolkitError && err.code === "EXTRACT_DOMAIN_NOT_ENABLED") {
39
- const outcome = await runExtractOnce({ ...opts, method: "autoparse" }, config, policy, apiKey);
40
- return { ...outcome, fellBackToAutoparse: true };
41
- }
42
- throw err;
43
- }
44
- }
42
+ if (opts.fallbackAutoparse === true)
43
+ log.warn(FALLBACK_AUTOPARSE_DEPRECATION);
45
44
  return runExtractOnce({ ...opts, method }, config, policy, apiKey);
46
45
  }
47
46
  async function runExtractOnce(opts, config, policy, apiKey) {
@@ -101,7 +100,7 @@ function parseExtractBody(method, body, opts) {
101
100
  }
102
101
  }
103
102
  const empty = isEmptyData(data);
104
- // ACT-1514: an empty result must not read as a silent success. Under
103
+ // An empty result must not read as a silent success. Under
105
104
  // --validate it's an error; otherwise the command surfaces a warning.
106
105
  if (opts.validate && empty) {
107
106
  throw new ToolkitError({
@@ -0,0 +1,168 @@
1
+ /**
2
+ * `zenrows extract prepare <domain> --url <u> [--url <u>...] [--wait]` and
3
+ * `zenrows extract prepare status <domain>`.
4
+ *
5
+ * The way out of REQS007: prepare the domain so `extract=auto` works on it.
6
+ * Submitting returns at once; `--wait` polls until the preparation is ready or
7
+ * failed (a few minutes). Costs no credits; each new domain uses one slot of the
8
+ * account's monthly allowance.
9
+ */
10
+ import { ensureApiKey } from "../../core/ensure-key.js";
11
+ import { loadConfig } from "../../core/config.js";
12
+ import { loadPolicy } from "../../core/policy.js";
13
+ import { log } from "../../core/logger.js";
14
+ 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 { parse, asString } from "../command.js";
17
+ import { printError } from "../output.js";
18
+ export const PREPARE_USAGE = "zenrows extract prepare <domain> --url <page-url> [--url <page-url>...] [--wait [--timeout <seconds>]]\n" +
19
+ " zenrows extract prepare status <domain>";
20
+ export const PREPARE_HELP = [
21
+ "Prepare a domain so `zenrows extract <url>` (extract=auto) works on it. Use it when an",
22
+ "extract fails with EXTRACT_DOMAIN_NOT_PREPARED (REQS007: the domain is not prepared yet).",
23
+ "Not for EXTRACT_PAGE_NOT_RECOGNISED: that domain is already prepared, and preparing it",
24
+ "again does not change that answer.",
25
+ "",
26
+ ` --url <page-url> 1 to ${MAX_EXAMPLE_URLS} real pages you want to extract, one per page kind (repeat --url)`,
27
+ " --wait poll every 30 seconds until the domain is ready or failed; off by default",
28
+ ` --timeout <seconds> give up waiting after this long (default ${DEFAULT_WAIT_TIMEOUT_MS / 1000})`,
29
+ " status <domain> show progress and the account's monthly allowance",
30
+ " --json structured result",
31
+ "",
32
+ "Preparing takes a few minutes and costs no credits. Each new domain uses one slot of the",
33
+ "account's monthly allowance; a domain that is already prepared answers ready at once and",
34
+ "uses none. The domain is the host (shop.example.com and example.com are separate); a",
35
+ "leading www. is dropped.",
36
+ ].join("\n");
37
+ export async function runPrepare(argv, ctx, deps = {}) {
38
+ const isStatus = argv[0] === "status";
39
+ const { values, positionals } = parse(isStatus ? argv.slice(1) : argv, {
40
+ url: { type: "string", multiple: true },
41
+ wait: { type: "boolean" },
42
+ timeout: { type: "string" },
43
+ json: { type: "boolean" },
44
+ help: { type: "boolean" },
45
+ });
46
+ const json = ctx.json || values.json === true;
47
+ try {
48
+ const raw = positionals[0];
49
+ if (!raw) {
50
+ throw new ToolkitError({
51
+ code: "INVALID_USAGE",
52
+ message: "Missing domain.",
53
+ likely_cause: "No <domain> was given.",
54
+ next_action: `Usage: ${PREPARE_USAGE}`,
55
+ suggested_commands: ["zenrows extract prepare example.com --url https://example.com/products/1"],
56
+ });
57
+ }
58
+ const domain = normalizeDomain(raw);
59
+ const urls = isStatus ? [] : validateUrls(values.url, domain);
60
+ const timeoutMs = parseTimeout(asString(values.timeout));
61
+ const policy = loadPolicy();
62
+ const apiKey = deps.apiKey ??
63
+ // Preparing never creates a Free plan account: it needs a key the user already has.
64
+ (await ensureApiKey({ ...policy, auto_signup: false }));
65
+ const opts = { apiKey, apiBase: loadConfig().apiBase, fetchImpl: deps.fetchImpl };
66
+ if (isStatus) {
67
+ const [record, allowance] = await Promise.all([getPreparedDomain(domain, opts), getAllowance(opts).catch(() => undefined)]);
68
+ report(record, allowance, json);
69
+ return 0;
70
+ }
71
+ log.step(`Prepare ${domain} for Extract (${urls.length} example URL${urls.length === 1 ? "" : "s"})…`);
72
+ let record = await prepareDomain(domain, urls, opts);
73
+ if (record.already_in_progress)
74
+ log.info(`${domain} was already being prepared; following that preparation.`);
75
+ // A submit that answers ready at once found the domain already prepared.
76
+ const alreadyReady = record.status === "ready" && !record.already_in_progress;
77
+ if (values.wait === true && record.status !== "ready" && record.status !== "failed") {
78
+ record = await waitForPreparedDomain(domain, {
79
+ ...opts,
80
+ timeoutMs,
81
+ intervalMs: deps.intervalMs,
82
+ sleepImpl: deps.sleepImpl,
83
+ now: deps.now,
84
+ onPoll: (r) => log.dim(` ${r.domain}: ${r.status} (${r.state ?? r.status})`),
85
+ });
86
+ }
87
+ if (record.status === "failed") {
88
+ throw new ToolkitError({
89
+ code: "EXTRACT_PREPARE_FAILED",
90
+ message: `Preparing ${domain} failed${record.failure?.code ? ` (${record.failure.code})` : ""}.`,
91
+ likely_cause: record.failure?.detail ?? "The preparation did not finish.",
92
+ next_action: failureNextStep(domain, record.failure),
93
+ server_code: record.failure?.code,
94
+ });
95
+ }
96
+ report(record, undefined, json, { exampleUrl: urls[0], alreadyReady });
97
+ return 0;
98
+ }
99
+ catch (err) {
100
+ printError(err, json);
101
+ return 1;
102
+ }
103
+ }
104
+ function validateUrls(raw, domain) {
105
+ const urls = (Array.isArray(raw) ? raw : raw === undefined ? [] : [raw]).map(String);
106
+ if (urls.length < 1 || urls.length > MAX_EXAMPLE_URLS) {
107
+ throw new ToolkitError({
108
+ code: "INVALID_USAGE",
109
+ message: `Pass 1 to ${MAX_EXAMPLE_URLS} example page URLs with --url (got ${urls.length}).`,
110
+ 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.",
112
+ suggested_commands: [`zenrows extract prepare ${domain} --url https://${domain}/<page>`],
113
+ });
114
+ }
115
+ for (const u of urls) {
116
+ let parsed;
117
+ try {
118
+ parsed = new URL(u);
119
+ }
120
+ catch {
121
+ // reported below
122
+ }
123
+ if (!parsed || (parsed.protocol !== "http:" && parsed.protocol !== "https:")) {
124
+ throw new ToolkitError({
125
+ code: "INVALID_USAGE",
126
+ message: `--url ${u} is not a full http(s) URL.`,
127
+ likely_cause: "Each --url must be a page URL including its scheme.",
128
+ next_action: `Use full URLs, e.g. --url https://${domain}/<page>.`,
129
+ });
130
+ }
131
+ }
132
+ return urls;
133
+ }
134
+ function parseTimeout(v) {
135
+ if (v === undefined)
136
+ return DEFAULT_WAIT_TIMEOUT_MS;
137
+ const n = Number(v);
138
+ if (!Number.isFinite(n) || n <= 0) {
139
+ throw new ToolkitError({
140
+ code: "INVALID_USAGE",
141
+ message: `--timeout must be a positive number of seconds (got ${v}).`,
142
+ likely_cause: "The value is not a number.",
143
+ next_action: "Pass seconds, e.g. --timeout 600.",
144
+ });
145
+ }
146
+ return n * 1000;
147
+ }
148
+ function report(record, allowance, json, opts = {}) {
149
+ const next = statusNextStep(record, opts);
150
+ if (json) {
151
+ log.out(JSON.stringify({ ok: true, ...record, ...(opts.alreadyReady ? { already_prepared: true } : {}), ...(allowance ? { allowance } : {}), next }, null, 2));
152
+ return;
153
+ }
154
+ const line = `${record.domain}: ${record.status}${record.state && record.state !== record.status ? ` (${record.state})` : ""}`;
155
+ if (record.status === "ready")
156
+ log.success(line);
157
+ else if (record.status === "failed")
158
+ log.warn(line);
159
+ else
160
+ log.info(line);
161
+ if (record.failure)
162
+ log.dim(` failure: ${record.failure.code}${record.failure.detail ? ` — ${record.failure.detail}` : ""}`);
163
+ for (const role of record.roles ?? [])
164
+ log.dim(` ${role.role}: ${role.status}${role.url ? ` ${role.url}` : ""}`);
165
+ if (allowance)
166
+ log.dim(` allowance: ${allowance.used} of ${allowance.limit} used, ${allowance.remaining} left${allowance.month ? ` (${allowance.month})` : ""}`);
167
+ log.out(next);
168
+ }
@@ -1,9 +1,12 @@
1
1
  /**
2
2
  * `zenrows extract <url> [flags]` → Extract (structured extraction on `/v1/`).
3
3
  *
4
- * Default: `extract=auto` (site-tailored Extract). If the domain is
5
- * not enabled or prepared (AUTH010 / REQS007), falls back once to Autoparse. Opt into Autoparse
6
- * directly with `--autoparse`. Other methods: `--css`, `--outputs`, `--output`.
4
+ * Default: `extract=auto` (site-tailored Extract). On a REQS007 / AUTH010 the
5
+ * error names the next step for its reason (prepare the domain, or try another
6
+ * page); nothing is retried. `--autoparse` runs the deprecated Autoparse
7
+ * explicitly. Other
8
+ * methods: `--css`, `--outputs`, `--output`. `zenrows extract prepare` prepares a
9
+ * domain (see extract-prepare.ts).
7
10
  */
8
11
  import { ensureApiKey } from "../../core/ensure-key.js";
9
12
  import { maybeNudgeClaim } from "../../core/nudge.js";
@@ -18,14 +21,17 @@ import { formatRequestCost } from "../../core/http.js";
18
21
  import { parse, asString, asNumber } from "../command.js";
19
22
  import { printError, writeOut } from "../output.js";
20
23
  import { refreshClaimAdvice } from "../../core/claim-link.js";
24
+ import { PREPARE_HELP, PREPARE_USAGE, runPrepare } from "./extract-prepare.js";
21
25
  export const extract = {
22
26
  name: "extract",
23
27
  summary: "Turn a protected page into structured data (Extract / Autoparse / CSS / Markdown).",
24
- usage: "zenrows extract <url> [--autoparse | --css <json> | --outputs <filters> | --output md|text] [flags]",
28
+ usage: "zenrows extract <url> [--autoparse | --css <json> | --outputs <filters> | --output md|text] [flags]\n " +
29
+ PREPARE_USAGE,
25
30
  help: [
26
31
  "Methods:",
27
- " (default) extract=auto: site-tailored Extract; falls back to Autoparse if the domain is not enabled or prepared",
28
- " --autoparse general-purpose Autoparse (any domain, no Extract fallback)",
32
+ " (default) extract=auto: site-tailored Extract on any prepared domain",
33
+ " (EXTRACT_DOMAIN_NOT_PREPARED = not prepared yet: run zenrows extract prepare, below)",
34
+ " --autoparse deprecated general-purpose Autoparse (any domain)",
29
35
  " --css <json> CSS selector map, e.g. '{\"title\":\"h1\",\"price\":\".price\"}'",
30
36
  " --outputs <list> built-in output filters → JSON: emails, phone_numbers, headings,",
31
37
  " images, audios, videos, links, menus, hashtags, metadata, tables,",
@@ -40,8 +46,13 @@ export const extract = {
40
46
  "",
41
47
  "Cost (credits per request): 1x normal · 5x --js-render · 10x --premium-proxy · 25x both.",
42
48
  "The exact charge is reported after every request (costCredits / X-Request-Credits).",
49
+ "",
50
+ "Prepare a domain:",
51
+ PREPARE_HELP.split("\n").map((l) => (l ? " " + l : l)).join("\n"),
43
52
  ].join("\n"),
44
53
  async run(argv, ctx) {
54
+ if (argv[0] === "prepare")
55
+ return runPrepare(argv.slice(1), ctx);
45
56
  const { values, positionals } = parse(argv, {
46
57
  autoparse: { type: "boolean" },
47
58
  css: { type: "string" },
@@ -114,14 +125,14 @@ export const extract = {
114
125
  const runId = newRunId();
115
126
  const startedAt = new Date().toISOString();
116
127
  log.step(`Extract ${url} (method=${opts.method ?? "extract"})…`);
128
+ if (opts.method === "autoparse") {
129
+ log.warn("Autoparse is deprecated. Prefer the default extract=auto on a prepared domain, or --css.");
130
+ }
117
131
  try {
118
132
  const outcome = await runExtract(opts, config, policy, apiKey);
119
133
  const finishedAt = new Date().toISOString();
120
134
  const safeParams = { ...outcome.params };
121
135
  delete safeParams.apikey;
122
- if (outcome.fellBackToAutoparse) {
123
- log.info("Extract not available for this domain yet — fell back to Autoparse.");
124
- }
125
136
  const runDir = writeRun({
126
137
  runId,
127
138
  command: "zenrows extract",
@@ -133,7 +144,6 @@ export const extract = {
133
144
  request: {
134
145
  ...safeParams,
135
146
  method: outcome.method,
136
- fellBackToAutoparse: outcome.fellBackToAutoparse ?? false,
137
147
  },
138
148
  result: { httpStatus: outcome.result.status, bytes: outcome.result.body.length, parsed: outcome.data !== undefined },
139
149
  costUsd: outcome.result.costUsd,
@@ -145,8 +155,7 @@ export const extract = {
145
155
  writeOut(values.out, payload);
146
156
  log.success(`Wrote output → ${values.out}`);
147
157
  }
148
- const via = outcome.fellBackToAutoparse ? `${outcome.method} (fallback)` : outcome.method;
149
- log.success(`Extracted via ${via} · ${outcome.result.body.length} bytes · ${formatRequestCost(outcome.result.costUsd, outcome.result.costCredits)} · run ${runId}`);
158
+ log.success(`Extracted via ${outcome.method} · ${outcome.result.body.length} bytes · ${formatRequestCost(outcome.result.costUsd, outcome.result.costCredits)} · run ${runId}`);
150
159
  if (runDir)
151
160
  log.dim(` artifact: ${runDir}`);
152
161
  if (outcome.noDataExtracted) {
@@ -160,7 +169,6 @@ export const extract = {
160
169
  ok: true,
161
170
  runId,
162
171
  method: outcome.method,
163
- fellBackToAutoparse: outcome.fellBackToAutoparse ?? false,
164
172
  empty: outcome.empty ?? false,
165
173
  noDataExtracted: outcome.noDataExtracted ?? false,
166
174
  httpStatus: outcome.result.status,
@@ -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.8.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). */
@@ -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
@@ -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.8.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",
@@ -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,
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: extract
3
- description: Turn protected pages into structured data with Extract / Autoparse / CSS / Markdown.
3
+ description: Turn protected pages into structured data with Extract / CSS / Markdown (Autoparse is deprecated).
4
4
  version: 0.1.0
5
5
  requires_backend_capabilities: [extract]
6
6
  ---
@@ -12,8 +12,8 @@ access + extraction**, not generic LLM parsing.
12
12
 
13
13
  ## Methods
14
14
  ```
15
- zenrows extract <url> # extract=auto (default); falls back to Autoparse if the domain is not prepared yet
16
- zenrows extract <url> --autoparse # general-purpose Autoparse (any domain)
15
+ zenrows extract <url> # extract=auto (default), any prepared domain
16
+ zenrows extract <url> --autoparse # deprecated general-purpose Autoparse (any domain)
17
17
  zenrows extract <url> --css '{"title":"h1","price":".price"}' # selector map
18
18
  zenrows extract <url> --outputs emails,links # built-in output filters → JSON
19
19
  zenrows extract <url> --output markdown # Markdown conversion
@@ -21,14 +21,58 @@ zenrows extract <url> --validate # fail if not valid JSON
21
21
  ```
22
22
 
23
23
  ## When to use which
24
- - **Extract** (`extract=auto`, default): richest fields; works on any domain Zenrows has prepared.
25
- - **Autoparse**: any website; also the automatic fallback when the domain is not prepared for Extract yet.
24
+ - **Extract** (`extract=auto`, default): richest fields; works on any prepared
25
+ domain (you can prepare one yourself, see below).
26
+ - **Autoparse** (deprecated): only with `--autoparse`. Never automatic; do not
27
+ build new work on it.
26
28
  - **CSS**: you know the exact fields/selectors and want determinism.
27
29
  - **Markdown/plaintext**: feed clean content to an LLM yourself.
28
30
 
31
+ ## REQS007: prepare the domain, then extract again
32
+
33
+ `extract=auto` works on any prepared domain, and you can prepare one yourself.
34
+ When Extract refuses a page with `403 REQS007`, `zenrows extract` says which
35
+ of three cases it is and prints the next step. It never retries with Autoparse.
36
+
37
+ | Error code | Meaning | Next step |
38
+ | --- | --- | --- |
39
+ | `EXTRACT_DOMAIN_NOT_PREPARED` | The domain is not prepared yet (also the legacy `402 AUTH010`). | Prepare it (below), then extract again. |
40
+ | `EXTRACT_PAGE_NOT_RECOGNISED` | The domain is prepared, but Extract did not recognise this page as one it can extract (a page with nothing to extract, such as a home page, or a transient failure). | 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`. Preparing the domain again does not change this. |
41
+ | `EXTRACT_DOMAIN_NOT_ENABLED` | Extract is not enabled for the site. | Use `--css`, or contact Zenrows support to have the site enabled. Preparing does not change this. |
42
+
43
+ Preparing a domain:
44
+
45
+ ```
46
+ zenrows extract https://example.com/p/1 # 1. fails: EXTRACT_DOMAIN_NOT_PREPARED
47
+ zenrows extract prepare example.com \
48
+ --url https://example.com/p/1 --url https://example.com/list --wait
49
+ # 2. prepare it and wait until ready (a few minutes)
50
+ zenrows extract https://example.com/p/1 # 3. extract again
51
+ ```
52
+
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
+ - Without `--wait` the command returns at once; then check about every 30
56
+ seconds with `zenrows extract prepare status example.com` until it says
57
+ `ready`. `--wait` polls at the same pace.
58
+ - The domain is the host: `shop.example.com` and `example.com` are prepared
59
+ separately, and a leading `www.` is dropped.
60
+ - Preparing takes a few minutes and costs no credits. Each new domain uses one
61
+ slot of the account's monthly allowance; `prepare status` shows `used` /
62
+ `limit` / `remaining`.
63
+ - `EXTRACT_PREPARE_OUT_OF_ALLOWANCE`: no slots left this month; use `--css`.
64
+ `EXTRACT_PREPARE_UNAVAILABLE`: retry in a few minutes; do not loop.
65
+ A `failed` status prints the next step for its failure code and says whether
66
+ the attempt used an allowance slot.
67
+ - A domain that is already prepared answers `ready` at once
68
+ (`already_prepared`) and uses no slot: run the extract. If it then fails with
69
+ `EXTRACT_PAGE_NOT_RECOGNISED`, follow that row of the table, not `prepare`.
70
+
29
71
  ## Rules
30
72
  - Validate on a single page before scaling across many URLs.
31
- - If Autoparse misses fields, switch to `--css` with explicit selectors. Auto
73
+ - Check `empty` in `--json` output: true means every field came back null or
74
+ empty, so the result is not data.
75
+ - If a field is missing, switch to `--css` with explicit selectors. Auto
32
76
  mode already renders JS when the target needs it, so reach for
33
77
  `--manual --js-render` only after auto mode has returned an incomplete page:
34
78
  it costs several times a basic call ([[cost-control]]) and it makes the
@@ -34,7 +34,7 @@ If the user has a known URL and wants page content:
34
34
  → Use Protected Fetch. (zenrows fetch <url>)
35
35
 
36
36
  If the user has a known URL and wants structured data:
37
- → Use Extract. (zenrows extract <url> | --autoparse | --css)
37
+ → Use Extract. (zenrows extract <url> | --css)
38
38
 
39
39
  If the user has a start page (listing, index, section) but not the item URLs:
40
40
  → Use Crawl. (zenrows crawl create <url> --depth 1 --follow,
@@ -75,13 +75,16 @@ Run `zenrows status` for the live capability matrix. As of this toolkit:
75
75
  | Primitive | Command | Status |
76
76
  | --- | --- | --- |
77
77
  | Protected Fetch | `zenrows fetch` | **available**: `GET https://api.zenrows.com/v1/` |
78
- | Extract (`extract=auto`, Autoparse, CSS, Markdown) | `zenrows extract` | **available**: `GET https://api.zenrows.com/v1/?extract=auto` |
78
+ | Extract (`extract=auto`, CSS, Markdown; Autoparse deprecated) | `zenrows extract` | **available**: `GET https://api.zenrows.com/v1/?extract=auto` |
79
79
  | Batch | `zenrows batch` | **available**: `POST https://async.api.zenrows.com/v1/jobs` |
80
80
  | Crawl (new) | `zenrows crawl` | **available**: `POST https://api.zenrows.com/v1/crawls` |
81
81
  | Browser | `zenrows browser` | **available**: `POST https://mcp.zenrows.com/browser/sessions` |
82
82
  | MCP | `zenrows mcp` | **available**: `https://mcp.zenrows.com/mcp` |
83
83
 
84
- - Extract: `extract=auto` falls back to Autoparse on a domain not prepared yet.
84
+ - Extract: `extract=auto` works on any prepared domain. On one not prepared yet
85
+ (`EXTRACT_DOMAIN_NOT_PREPARED`), run `zenrows extract prepare <domain> --url
86
+ <page> --wait`, then extract again. `EXTRACT_PAGE_NOT_RECOGNISED` means the
87
+ domain is prepared but not this page: do not prepare again; see [[extract]].
85
88
  - 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.
86
89
  - Crawl: collects the URLs behind one start page, optionally with each page's HTML.
87
90
  - Browser: escalation-only, for pages Fetch can't handle; same backend as the MCP `browser_*` tools.
@@ -3,13 +3,11 @@
3
3
  Extract structured pricing fields from product pages with Zenrows Extract.
4
4
 
5
5
  ```bash
6
- # Default: extract=auto on an enabled domain (site-tailored JSON)
6
+ # Default: extract=auto on a prepared domain (site-tailored JSON).
7
+ # Not prepared yet (EXTRACT_DOMAIN_NOT_PREPARED)? zenrows extract prepare <domain> --url <product-url> --wait
7
8
  zenrows extract https://www.owler.com/company/meltwater
8
9
 
9
- # Autoparse — general-purpose JSON on any domain
10
- zenrows extract https://www.scrapingcourse.com/ecommerce/ --autoparse
11
-
12
- # Or a precise CSS selector map
10
+ # A precise CSS selector map, on any domain
13
11
  zenrows extract <product-url> --css '{"title":"h1","price":".price"}' --validate
14
12
  ```
15
13
 
@@ -9,7 +9,7 @@ status: available
9
9
 
10
10
  A higher-level process built on Zenrows **Fetch and Extract**: retrieve
11
11
  protected pages with `zenrows fetch`, then turn them into structured data with
12
- `zenrows extract` (the same API's Autoparse / CSS / Markdown — not a separate
12
+ `zenrows extract` (the same API's `extract=auto` / CSS / Markdown — not a separate
13
13
  product). Scheduling and scaling use the tools you already have (cron/CI, a
14
14
  simple per-URL loop).
15
15
 
@@ -20,7 +20,7 @@ simple per-URL loop).
20
20
  ```
21
21
  zenrows fetch https://competitor.example/products
22
22
  ```
23
- 2. **Validate extraction** on one page (extract=auto falls back to autoparse on a domain not prepared yet).
23
+ 2. **Validate extraction** on one page (on a domain not prepared yet, `EXTRACT_DOMAIN_NOT_PREPARED`, run `zenrows extract prepare <domain> --url <page> --wait` first).
24
24
  ```
25
25
  zenrows extract https://competitor.example/p/123 --validate
26
26
  ```