@zenrows/cli 1.7.0 → 1.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +83 -12
- package/agent-plugin/README.md +1 -1
- package/agent-plugin/skills/batch-jobs/SKILL.md +16 -3
- 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/batch.js +237 -59
- package/dist/src/adapters/extract.js +18 -19
- package/dist/src/cli/commands/batch.js +665 -126
- package/dist/src/cli/commands/crawl.js +1 -1
- package/dist/src/cli/commands/extract-prepare.js +168 -0
- package/dist/src/cli/commands/extract.js +21 -13
- package/dist/src/core/batch-api.js +398 -145
- package/dist/src/core/config.js +1 -1
- package/dist/src/core/errors.js +6 -0
- package/dist/src/core/http.js +78 -20
- package/dist/src/core/policy.js +13 -6
- package/dist/src/core/prepared-domains-api.js +289 -0
- package/evals/extract-smoke/README.md +1 -1
- package/package.json +1 -1
- package/recipes/batch-estimate/RECIPE.md +6 -3
- package/registry/capabilities.json +3 -3
- package/registry/skills.json +2 -2
- package/registry/templates.json +1 -1
- package/skills/batch-jobs/SKILL.md +160 -43
- package/skills/crawl/SKILL.md +1 -1
- package/skills/extract/SKILL.md +50 -6
- package/skills/trace-debug/SKILL.md +17 -2
- package/skills/zenrows/SKILL.md +9 -5
- package/templates/batch-jsonl-pipeline/README.md +10 -5
- package/templates/extract-pricing-pages/README.md +3 -5
- package/workflows/competitor-intelligence/WORKFLOW.md +2 -2
|
@@ -34,7 +34,7 @@ export const crawl = {
|
|
|
34
34
|
" leave it out for no depth limit (the caps below stop the crawl)",
|
|
35
35
|
" --max-items <n> stop after keeping n URLs (API default 100)",
|
|
36
36
|
" --max-pages <n> stop after fetching n pages (API default 100; bounds cost);",
|
|
37
|
-
" the local policy max_pages_per_run caps it (default
|
|
37
|
+
" the local policy max_pages_per_run caps it (default 10000)",
|
|
38
38
|
" --include-patterns <p> keep only URLs containing this substring (repeatable)",
|
|
39
39
|
" --exclude-patterns <p> drop URLs containing this substring (repeatable)",
|
|
40
40
|
" --output-format <html|markdown> also fetch each kept URL's page, as HTML or Markdown",
|
|
@@ -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,
|