@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 +19 -3
- 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/protected-fetch/SKILL.md +1 -1
- package/agent-plugin/skills/zenrows/SKILL.md +3 -2
- 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/core/config.js +1 -1
- package/dist/src/core/http.js +78 -20
- package/dist/src/core/prepared-domains-api.js +289 -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 +6 -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`
|
|
@@ -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
|
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.
|
|
@@ -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
|
|
|
@@ -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
|
|
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
|
|
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
|
-
/**
|
|
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,
|
package/dist/src/core/config.js
CHANGED
|
@@ -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.
|
|
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). */
|
package/dist/src/core/http.js
CHANGED
|
@@ -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:
|
|
103
|
-
//
|
|
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
|
|
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
|
|
167
|
-
// 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
|
|
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` (
|
|
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.
|
|
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 /
|
|
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,
|
|
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
|
|
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",
|
package/registry/skills.json
CHANGED
|
@@ -25,13 +25,13 @@
|
|
|
25
25
|
{
|
|
26
26
|
"name": "extract",
|
|
27
27
|
"type": "skill",
|
|
28
|
-
"description": "Turn protected pages into structured data with
|
|
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", "
|
|
34
|
+
"tags": ["extract", "structured-data"]
|
|
35
35
|
},
|
|
36
36
|
{
|
|
37
37
|
"name": "batch-jobs",
|
package/registry/templates.json
CHANGED
|
@@ -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 (
|
|
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,
|
package/skills/extract/SKILL.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: extract
|
|
3
|
-
description: Turn protected pages into structured data with Extract /
|
|
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)
|
|
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
|
|
25
|
-
|
|
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
|
-
-
|
|
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
|
package/skills/zenrows/SKILL.md
CHANGED
|
@@ -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> | --
|
|
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`,
|
|
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`
|
|
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
|
|
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
|
-
#
|
|
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
|
|
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 (
|
|
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
|
```
|