@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 +36 -7
- package/agent-plugin/README.md +1 -1
- package/agent-plugin/skills/cost-control/SKILL.md +2 -2
- package/agent-plugin/skills/extract/SKILL.md +49 -9
- package/agent-plugin/skills/interact-browser/SKILL.md +4 -2
- package/agent-plugin/skills/protected-fetch/SKILL.md +1 -1
- package/agent-plugin/skills/zenrows/SKILL.md +7 -3
- package/dist/src/adapters/extract.js +18 -19
- package/dist/src/cli/commands/extract-prepare.js +168 -0
- package/dist/src/cli/commands/extract.js +21 -13
- package/dist/src/cli/commands/signup.js +42 -7
- package/dist/src/core/agent-account.js +46 -6
- package/dist/src/core/config.js +1 -1
- package/dist/src/core/ensure-key.js +11 -5
- package/dist/src/core/errors.js +3 -0
- package/dist/src/core/http.js +78 -20
- package/dist/src/core/prepared-domains-api.js +289 -0
- package/dist/src/core/signup-challenge.js +114 -0
- package/dist/src/core/workspace.js +1 -0
- package/evals/extract-smoke/README.md +1 -1
- package/package.json +1 -1
- package/registry/capabilities.json +3 -3
- package/registry/skills.json +2 -2
- package/registry/templates.json +1 -1
- package/skills/extract/SKILL.md +50 -6
- package/skills/zenrows/SKILL.md +13 -3
- package/templates/extract-pricing-pages/README.md +3 -5
- package/workflows/competitor-intelligence/WORKFLOW.md +2 -2
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 (
|
|
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/ --
|
|
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
|
|
100
|
-
zenrows signup --agent
|
|
101
|
-
zenrows
|
|
102
|
-
zenrows login --
|
|
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
|
package/agent-plugin/README.md
CHANGED
|
@@ -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` +
|
|
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.
|
|
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,
|
|
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
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
|
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
|
|
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`),
|
|
31
|
-
|
|
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
|
-
`
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
4
|
-
|
|
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 (
|
|
34
|
-
|
|
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
|
-
//
|
|
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).
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
|
28
|
-
"
|
|
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
|
-
|
|
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,
|