@zenrows/cli 1.8.0 → 1.10.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`
@@ -83,6 +83,18 @@ unclaimed Zenrows Free plan account**, stores the key in `.zenrows/secrets.json`
83
83
  (0600, gitignored), and records the account in `.zenrows/account.json`
84
84
  (no secret — just the accountId, Free-period info, and a claim link).
85
85
 
86
+ Zenrows may first ask the agent one short question before it creates the
87
+ account. The command then fails with `SIGNUP_CHALLENGE` and prints the
88
+ question; answer it and re-run the original command:
89
+
90
+ ```bash
91
+ zenrows signup --answer "<your answer>"
92
+ ```
93
+
94
+ A wrong or late answer gets a new question. Too many questions or wrong
95
+ answers from one network pause signups there for an hour
96
+ (`SIGNUP_CHALLENGE_LOCKED`).
97
+
86
98
  When you (or your agent) want to keep the usage and history, **claim** the
87
99
  account — this confirms an email (Google, GitHub or a 6-digit code); the same
88
100
  key keeps working:
@@ -96,10 +108,11 @@ zenrows usage # plan usage, credits & concurrency (does not count again
96
108
  Opt out of auto-provisioning at any time:
97
109
 
98
110
  ```bash
99
- zenrows fetch <url> --no-signup # do not auto-create an account for this call
100
- zenrows signup --agent # explicitly provision a Free plan account, headless
101
- zenrows login --api-key <key> # use an existing key (stored 0600, gitignored)
102
- zenrows login --env # use the ZENROWS_API_KEY environment variable
111
+ zenrows fetch <url> --no-signup # do not auto-create an account for this call
112
+ zenrows signup --agent # explicitly provision a Free plan account, headless
113
+ zenrows signup --answer "<text>" # answer the signup question (SIGNUP_CHALLENGE)
114
+ zenrows login --api-key <key> # use an existing key (stored 0600, gitignored)
115
+ zenrows login --env # use the ZENROWS_API_KEY environment variable
103
116
  ```
104
117
 
105
118
  Set `auto_signup: false` in `.zenrows/policy.json` to disable auto-provisioning
@@ -118,12 +131,28 @@ zenrows fetch <url> --proxy-country us --wait-for ".price"
118
131
 
119
132
  ```bash
120
133
  zenrows extract https://www.owler.com/company/meltwater # extract=auto (prepared domain)
121
- zenrows extract https://www.scrapingcourse.com/ecommerce/ --autoparse # Autoparse (any domain)
134
+ zenrows extract https://www.scrapingcourse.com/ecommerce/ --autoparse # deprecated Autoparse (any domain)
122
135
  zenrows extract <url> --css '{"title":"h1","price":".price"}' --validate
123
136
  zenrows extract <url> --outputs emails,links # built-in output filters → JSON
124
137
  zenrows extract <url> --output markdown
125
138
  ```
126
139
 
140
+ On a domain Extract hasn't prepared yet, `zenrows extract` fails with
141
+ `EXTRACT_DOMAIN_NOT_PREPARED` (API code REQS007) and prints the command to
142
+ prepare it. Preparing takes a few minutes and costs no credits; each new domain
143
+ uses one slot of the monthly allowance. `EXTRACT_PAGE_NOT_RECOGNISED` (also
144
+ REQS007) is different: the domain is prepared, but this page is not one Extract
145
+ extracts. Try a product, article or listing page instead, retry it once in a
146
+ few minutes if it should work, or use `--css`; preparing again does not change
147
+ it. `zenrows extract` never falls back to Autoparse. The `fallbackAutoparse`
148
+ adapter option is deprecated and has no effect.
149
+
150
+ ```bash
151
+ zenrows extract prepare example.com --url https://example.com/p/1 --url https://example.com/list --wait
152
+ zenrows extract prepare status example.com # progress + monthly allowance
153
+ zenrows extract https://example.com/p/1 # then extract again
154
+ ```
155
+
127
156
  ## 9. Batch
128
157
 
129
158
  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.
@@ -27,8 +27,10 @@ cannot handle. **Escalation only.**
27
27
  6. Always `browser_close` when done
28
28
 
29
29
  Other useful tools: `browser_evaluate`, cookies (`browser_get_cookies` /
30
- `browser_set_cookies` / `browser_clear_cookies`), `browser_local_storage`,
31
- tabs (`browser_new_tab` / `browser_switch_tab`), `browser_generate_pdf`.
30
+ `browser_set_cookies` / `browser_clear_cookies`), local storage
31
+ (`browser_get_local_storage` / `browser_set_local_storage` /
32
+ `browser_clear_local_storage`), tabs (`browser_new_tab` / `browser_switch_tab`),
33
+ `browser_generate_pdf`.
32
34
 
33
35
  ## Rules
34
36
 
@@ -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
 
@@ -20,7 +20,10 @@ You do **not** need an API key in the plugin package.
20
20
 
21
21
  - **stdio** (`npx -y @zenrows/mcp`): on first cloud call the server can
22
22
  auto-create a **free, unclaimed** Zenrows Free plan account and persist the
23
- key locally. When the Free plan nears its limit (or the user asks), surface
23
+ key locally. Zenrows may first ask one short question (which word is a
24
+ bird, which word does not belong): the tool call
25
+ returns it; answer it yourself with the `account_signup_answer` tool, then
26
+ retry the call. When the Free plan nears its limit (or the user asks), surface
24
27
  the **claim URL** so a human can claim the account (confirming an email
25
28
  with Google, GitHub or a 6-digit code). Claim links expire after 30 days. The
26
29
  same key keeps working after claim. Never handle payment or upgrades —
@@ -37,7 +40,7 @@ If the user has a known URL and wants page content:
37
40
 
38
41
  If the user has a known URL and wants structured data:
39
42
  → Use Extract. (MCP tool: extract)
40
- Fallback: scrape with autoparse / css_extractor / outputs
43
+ Fallback: scrape with css_extractor / outputs
41
44
 
42
45
  If the user has many URLs (bulk):
43
46
  → Use Batch. (validate one page with scrape/extract first,
@@ -67,7 +70,8 @@ If the user needs login, clicks, forms, sessions, or persistent state:
67
70
  | Primitive | MCP tools | Notes |
68
71
  | --- | --- | --- |
69
72
  | 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 |
73
+ | Extract | `extract` | Structured JSON (`extract=auto` on any prepared domain, css_extractor; autoparse deprecated). `extract=auto` is currently free; billing may apply later |
74
+ | Prepare a domain | `prepare_domain`, `prepared_domain_status` | After an `extract` REQS007 with reason `domain_not_prepared`: prepare the domain with 1 to 5 example URLs, poll about every 30 seconds until ready, extract again. No credits; uses one monthly allowance slot. Not for `page_not_recognised`: that domain is already prepared |
71
75
  | Batch | `batch_create`, `batch_status`, `batch_results`, `batch_cancel` | Same credits as Fetch; a 403 maps to `BATCH_ACCESS_DENIED` |
72
76
  | Browser | `browser_*` (start with `browser_navigate`) | Escalation-only; same backend as Browser Sessions |
73
77
 
@@ -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,