@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.
Files changed (33) hide show
  1. package/README.md +83 -12
  2. package/agent-plugin/README.md +1 -1
  3. package/agent-plugin/skills/batch-jobs/SKILL.md +16 -3
  4. package/agent-plugin/skills/cost-control/SKILL.md +2 -2
  5. package/agent-plugin/skills/extract/SKILL.md +49 -9
  6. package/agent-plugin/skills/protected-fetch/SKILL.md +1 -1
  7. package/agent-plugin/skills/zenrows/SKILL.md +3 -2
  8. package/dist/src/adapters/batch.js +237 -59
  9. package/dist/src/adapters/extract.js +18 -19
  10. package/dist/src/cli/commands/batch.js +665 -126
  11. package/dist/src/cli/commands/crawl.js +1 -1
  12. package/dist/src/cli/commands/extract-prepare.js +168 -0
  13. package/dist/src/cli/commands/extract.js +21 -13
  14. package/dist/src/core/batch-api.js +398 -145
  15. package/dist/src/core/config.js +1 -1
  16. package/dist/src/core/errors.js +6 -0
  17. package/dist/src/core/http.js +78 -20
  18. package/dist/src/core/policy.js +13 -6
  19. package/dist/src/core/prepared-domains-api.js +289 -0
  20. package/evals/extract-smoke/README.md +1 -1
  21. package/package.json +1 -1
  22. package/recipes/batch-estimate/RECIPE.md +6 -3
  23. package/registry/capabilities.json +3 -3
  24. package/registry/skills.json +2 -2
  25. package/registry/templates.json +1 -1
  26. package/skills/batch-jobs/SKILL.md +160 -43
  27. package/skills/crawl/SKILL.md +1 -1
  28. package/skills/extract/SKILL.md +50 -6
  29. package/skills/trace-debug/SKILL.md +17 -2
  30. package/skills/zenrows/SKILL.md +9 -5
  31. package/templates/batch-jsonl-pipeline/README.md +10 -5
  32. package/templates/extract-pricing-pages/README.md +3 -5
  33. 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 1000)",
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). If the domain is
5
- * not enabled or prepared (AUTH010 / REQS007), falls back once to Autoparse. Opt into Autoparse
6
- * directly with `--autoparse`. Other methods: `--css`, `--outputs`, `--output`.
4
+ * Default: `extract=auto` (site-tailored Extract). On a REQS007 / AUTH010 the
5
+ * error names the next step for its reason (prepare the domain, or try another
6
+ * page); nothing is retried. `--autoparse` runs the deprecated Autoparse
7
+ * explicitly. Other
8
+ * methods: `--css`, `--outputs`, `--output`. `zenrows extract prepare` prepares a
9
+ * domain (see extract-prepare.ts).
7
10
  */
8
11
  import { ensureApiKey } from "../../core/ensure-key.js";
9
12
  import { maybeNudgeClaim } from "../../core/nudge.js";
@@ -18,14 +21,17 @@ import { formatRequestCost } from "../../core/http.js";
18
21
  import { parse, asString, asNumber } from "../command.js";
19
22
  import { printError, writeOut } from "../output.js";
20
23
  import { refreshClaimAdvice } from "../../core/claim-link.js";
24
+ import { PREPARE_HELP, PREPARE_USAGE, runPrepare } from "./extract-prepare.js";
21
25
  export const extract = {
22
26
  name: "extract",
23
27
  summary: "Turn a protected page into structured data (Extract / Autoparse / CSS / Markdown).",
24
- usage: "zenrows extract <url> [--autoparse | --css <json> | --outputs <filters> | --output md|text] [flags]",
28
+ usage: "zenrows extract <url> [--autoparse | --css <json> | --outputs <filters> | --output md|text] [flags]\n " +
29
+ PREPARE_USAGE,
25
30
  help: [
26
31
  "Methods:",
27
- " (default) extract=auto: site-tailored Extract; falls back to Autoparse if the domain is not enabled or prepared",
28
- " --autoparse general-purpose Autoparse (any domain, no Extract fallback)",
32
+ " (default) extract=auto: site-tailored Extract on any prepared domain",
33
+ " (EXTRACT_DOMAIN_NOT_PREPARED = not prepared yet: run zenrows extract prepare, below)",
34
+ " --autoparse deprecated general-purpose Autoparse (any domain)",
29
35
  " --css <json> CSS selector map, e.g. '{\"title\":\"h1\",\"price\":\".price\"}'",
30
36
  " --outputs <list> built-in output filters → JSON: emails, phone_numbers, headings,",
31
37
  " images, audios, videos, links, menus, hashtags, metadata, tables,",
@@ -40,8 +46,13 @@ export const extract = {
40
46
  "",
41
47
  "Cost (credits per request): 1x normal · 5x --js-render · 10x --premium-proxy · 25x both.",
42
48
  "The exact charge is reported after every request (costCredits / X-Request-Credits).",
49
+ "",
50
+ "Prepare a domain:",
51
+ PREPARE_HELP.split("\n").map((l) => (l ? " " + l : l)).join("\n"),
43
52
  ].join("\n"),
44
53
  async run(argv, ctx) {
54
+ if (argv[0] === "prepare")
55
+ return runPrepare(argv.slice(1), ctx);
45
56
  const { values, positionals } = parse(argv, {
46
57
  autoparse: { type: "boolean" },
47
58
  css: { type: "string" },
@@ -114,14 +125,14 @@ export const extract = {
114
125
  const runId = newRunId();
115
126
  const startedAt = new Date().toISOString();
116
127
  log.step(`Extract ${url} (method=${opts.method ?? "extract"})…`);
128
+ if (opts.method === "autoparse") {
129
+ log.warn("Autoparse is deprecated. Prefer the default extract=auto on a prepared domain, or --css.");
130
+ }
117
131
  try {
118
132
  const outcome = await runExtract(opts, config, policy, apiKey);
119
133
  const finishedAt = new Date().toISOString();
120
134
  const safeParams = { ...outcome.params };
121
135
  delete safeParams.apikey;
122
- if (outcome.fellBackToAutoparse) {
123
- log.info("Extract not available for this domain yet — fell back to Autoparse.");
124
- }
125
136
  const runDir = writeRun({
126
137
  runId,
127
138
  command: "zenrows extract",
@@ -133,7 +144,6 @@ export const extract = {
133
144
  request: {
134
145
  ...safeParams,
135
146
  method: outcome.method,
136
- fellBackToAutoparse: outcome.fellBackToAutoparse ?? false,
137
147
  },
138
148
  result: { httpStatus: outcome.result.status, bytes: outcome.result.body.length, parsed: outcome.data !== undefined },
139
149
  costUsd: outcome.result.costUsd,
@@ -145,8 +155,7 @@ export const extract = {
145
155
  writeOut(values.out, payload);
146
156
  log.success(`Wrote output → ${values.out}`);
147
157
  }
148
- const via = outcome.fellBackToAutoparse ? `${outcome.method} (fallback)` : outcome.method;
149
- log.success(`Extracted via ${via} · ${outcome.result.body.length} bytes · ${formatRequestCost(outcome.result.costUsd, outcome.result.costCredits)} · run ${runId}`);
158
+ log.success(`Extracted via ${outcome.method} · ${outcome.result.body.length} bytes · ${formatRequestCost(outcome.result.costUsd, outcome.result.costCredits)} · run ${runId}`);
150
159
  if (runDir)
151
160
  log.dim(` artifact: ${runDir}`);
152
161
  if (outcome.noDataExtracted) {
@@ -160,7 +169,6 @@ export const extract = {
160
169
  ok: true,
161
170
  runId,
162
171
  method: outcome.method,
163
- fellBackToAutoparse: outcome.fellBackToAutoparse ?? false,
164
172
  empty: outcome.empty ?? false,
165
173
  noDataExtracted: outcome.noDataExtracted ?? false,
166
174
  httpStatus: outcome.result.status,