@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
@@ -3,14 +3,28 @@
3
3
  *
4
4
  * Status: `available`. The cloud subcommands call the Batch API with an API key
5
5
  * (see `core/batch-api.ts`); a 403 maps to BATCH_ACCESS_DENIED. This adapter owns
6
- * the local, no-network pieces: validating a JSONL job spec, estimating credit
7
- * cost, and mapping the validated spec to the Batch API request body.
6
+ * the local, no-network pieces: validating a JSONL job spec against the API's
7
+ * limits, estimating credit cost, and mapping the validated spec to the Batch
8
+ * API request body.
8
9
  */
9
10
  import { existsSync, readFileSync } from "node:fs";
10
11
  import { ToolkitError } from "../core/errors.js";
11
- /** Per-task keys handled explicitly; everything else on a line is a scrape param. */
12
- const TASK_RESERVED = new Set(["url", "external_id", "metadata", "zenrows_params"]);
13
- /** Parse + validate a JSONL job spec locally. */
12
+ /** Per-task keys sent at task level; everything else on a line is a scrape param. */
13
+ const TASK_RESERVED = new Set(["url", "external_id", "metadata", "zenrows_params", "method", "body"]);
14
+ /** The API's limits on a task and on job-level fields. */
15
+ export const LIMITS = {
16
+ urlLength: 2048,
17
+ externalIdLength: 128,
18
+ metadataEntries: 20,
19
+ metadataKeyLength: 64,
20
+ metadataValueLength: 1024,
21
+ metadataTotalBytes: 4 * 1024,
22
+ bodyBytes: 16 * 1024,
23
+ nameLength: 100,
24
+ };
25
+ /** `external_id` and metadata keys: letters, digits, `.`, `_` and `-`. */
26
+ const SAFE_ID = /^[A-Za-z0-9._-]+$/;
27
+ /** Parse + validate a JSONL job spec locally, against the API's per-task limits. */
14
28
  export function validateJsonl(file) {
15
29
  if (!existsSync(file)) {
16
30
  throw new ToolkitError({
@@ -28,45 +42,171 @@ export function validateJsonl(file) {
28
42
  if (!trimmed)
29
43
  return;
30
44
  result.totalLines++;
45
+ let obj;
31
46
  try {
32
- const obj = JSON.parse(trimmed);
33
- if (!obj.url || typeof obj.url !== "string") {
34
- result.errors.push({ line: idx + 1, reason: "missing string `url`" });
35
- return;
36
- }
37
- try {
38
- new URL(obj.url);
39
- }
40
- catch {
41
- result.errors.push({ line: idx + 1, reason: `invalid url: ${obj.url}` });
42
- return;
43
- }
44
- result.validJobs++;
45
- result.jobs.push(obj);
47
+ obj = JSON.parse(trimmed);
46
48
  }
47
49
  catch {
48
50
  result.errors.push({ line: idx + 1, reason: "invalid JSON" });
51
+ return;
52
+ }
53
+ const reason = taskError(obj);
54
+ if (reason) {
55
+ result.errors.push({ line: idx + 1, reason });
56
+ return;
49
57
  }
58
+ result.validJobs++;
59
+ result.jobs.push(obj);
50
60
  });
51
61
  return result;
52
62
  }
63
+ /** Why one JSONL line would be rejected by the API, or null when it is valid. */
64
+ function taskError(obj) {
65
+ if (typeof obj !== "object" || obj === null || Array.isArray(obj))
66
+ return "a line must be a JSON object";
67
+ const job = obj;
68
+ if (!job.url || typeof job.url !== "string")
69
+ return "missing string `url`";
70
+ let u;
71
+ try {
72
+ u = new URL(job.url);
73
+ }
74
+ catch {
75
+ return `invalid url: ${job.url}`;
76
+ }
77
+ if (u.protocol !== "http:" && u.protocol !== "https:")
78
+ return `url must be http or https: ${job.url}`;
79
+ // The API counts these limits in UTF-8 bytes, not characters.
80
+ if (Buffer.byteLength(job.url) > LIMITS.urlLength)
81
+ return `url is longer than ${LIMITS.urlLength} bytes`;
82
+ if (job.external_id !== undefined && job.external_id !== null) {
83
+ const e = externalIdError(job.external_id);
84
+ if (e)
85
+ return e;
86
+ }
87
+ const meta = metadataError(job.metadata);
88
+ if (meta)
89
+ return meta;
90
+ if (job.zenrows_params !== undefined && (typeof job.zenrows_params !== "object" || job.zenrows_params === null || Array.isArray(job.zenrows_params))) {
91
+ return "`zenrows_params` must be an object";
92
+ }
93
+ return methodBodyError(job.method, job.body);
94
+ }
95
+ /** Why an `external_id` is invalid, or null. Numbers are sent as their string form. */
96
+ export function externalIdError(v) {
97
+ if (typeof v !== "string" && typeof v !== "number")
98
+ return "`external_id` must be a string";
99
+ const s = String(v);
100
+ if (s.length === 0 || s.length > LIMITS.externalIdLength)
101
+ return `\`external_id\` must be 1 to ${LIMITS.externalIdLength} characters`;
102
+ if (!SAFE_ID.test(s))
103
+ return "`external_id` may only use letters, digits, `.`, `_` and `-`";
104
+ return null;
105
+ }
53
106
  /**
54
- * Rough local credit estimate using documented multipliers
55
- * (basic 1x, js 5x, proxies 10x, both 25x). Counts in abstract "credits".
107
+ * Why `metadata` is invalid, or null: the API takes at most 20 string-to-string
108
+ * entries, keys of 1 to 64 safe characters, values up to 1024 bytes, 4 KiB
109
+ * in total. Any other shape fails the whole submit.
56
110
  */
57
- export function estimateCredits(jobs) {
111
+ export function metadataError(metadata) {
112
+ if (metadata === undefined || metadata === null)
113
+ return null;
114
+ if (typeof metadata !== "object" || Array.isArray(metadata))
115
+ return "`metadata` must be an object of string values";
116
+ const entries = Object.entries(metadata);
117
+ if (entries.length > LIMITS.metadataEntries)
118
+ return `\`metadata\` has more than ${LIMITS.metadataEntries} entries`;
119
+ let total = 0;
120
+ for (const [k, v] of entries) {
121
+ if (k.length === 0 || k.length > LIMITS.metadataKeyLength || !SAFE_ID.test(k)) {
122
+ return `\`metadata\` key "${k}" must be 1 to ${LIMITS.metadataKeyLength} letters, digits, \`.\`, \`_\` or \`-\``;
123
+ }
124
+ if (typeof v !== "string")
125
+ return `\`metadata.${k}\` must be a string`;
126
+ if (Buffer.byteLength(v) > LIMITS.metadataValueLength)
127
+ return `\`metadata.${k}\` is longer than ${LIMITS.metadataValueLength} bytes`;
128
+ total += Buffer.byteLength(k) + Buffer.byteLength(v);
129
+ }
130
+ if (total > LIMITS.metadataTotalBytes)
131
+ return "`metadata` is larger than 4 KiB";
132
+ return null;
133
+ }
134
+ /** Why `method` / `body` are invalid, or null. Only GET and POST exist; a body needs POST. */
135
+ function methodBodyError(method, body) {
136
+ if (method !== undefined && method !== null) {
137
+ if (typeof method !== "string" || !/^(get|post)$/i.test(method))
138
+ return "`method` must be GET or POST";
139
+ }
140
+ if (body === undefined || body === null)
141
+ return null;
142
+ if (!isPost(method))
143
+ return "`body` needs `\"method\": \"POST\"`";
144
+ const bytes = typeof body === "string" ? Buffer.byteLength(body) : Buffer.byteLength(JSON.stringify(body));
145
+ if (bytes > LIMITS.bodyBytes)
146
+ return `\`body\` is larger than ${LIMITS.bodyBytes / 1024} KiB`;
147
+ return null;
148
+ }
149
+ function isPost(method) {
150
+ return typeof method === "string" && method.toUpperCase() === "POST";
151
+ }
152
+ /**
153
+ * Scrape params the API reads as booleans. It accepts JSON `true`/`false` or the
154
+ * strings "true"/"false" in any case, and rejects anything else with a 400.
155
+ */
156
+ const BOOLEAN_PARAMS = new Set(["js_render", "premium_proxy", "original_status", "json_response", "autoparse"]);
157
+ /** JSON `true` and "true" in any case become "true"; likewise "false". Any other value is unchanged. */
158
+ function canonicalBool(v) {
159
+ if (v === true)
160
+ return "true";
161
+ if (v === false)
162
+ return "false";
163
+ if (typeof v === "string") {
164
+ const s = v.trim().toLowerCase();
165
+ if (s === "true" || s === "false")
166
+ return s;
167
+ }
168
+ return v;
169
+ }
170
+ /** A task's own params: its nested `zenrows_params`, then its flat keys (later wins). */
171
+ function taskParamsOf(job) {
172
+ const flat = {};
173
+ for (const [k, v] of Object.entries(job)) {
174
+ if (!TASK_RESERVED.has(k))
175
+ flat[k] = v;
176
+ }
177
+ return { ...(job.zenrows_params ?? {}), ...flat };
178
+ }
179
+ /**
180
+ * The params a task actually runs with: the job-level params and the task's
181
+ * own, each canonicalized exactly as submitted, merged the way the API merges
182
+ * them (the task wins on a shared key). A task key set to null is not
183
+ * submitted, so the job's value applies.
184
+ */
185
+ export function effectiveParams(job, jobParams = {}) {
186
+ return { ...normalizeParams(jobParams), ...normalizeParams(taskParamsOf(job)) };
187
+ }
188
+ /**
189
+ * Local credit estimate from the rate card: basic 1, js_render 5,
190
+ * premium_proxy 10, both 25. An Adaptive Stealth Mode task (`mode=auto`)
191
+ * costs 1 to 25 depending on what each page needs, so it counts 1 in `min`
192
+ * and 25 in `credits` (the upper bound). Each task is priced on the params
193
+ * it is submitted with, job-level flags included.
194
+ */
195
+ export function estimateCredits(jobs, jobParams = {}) {
196
+ let min = 0;
58
197
  const perJob = jobs.map((j) => {
59
- const js = j.js_render || j.mode === "auto";
60
- const proxy = j.premium_proxy || j.mode === "auto";
61
- if (js && proxy)
198
+ const p = effectiveParams(j, jobParams);
199
+ if (p.mode === "auto") {
200
+ min += 1;
62
201
  return 25;
63
- if (proxy)
64
- return 10;
65
- if (js)
66
- return 5;
67
- return 1;
202
+ }
203
+ const js = p.js_render === "true";
204
+ const proxy = p.premium_proxy === "true";
205
+ const cost = js && proxy ? 25 : proxy ? 10 : js ? 5 : 1;
206
+ min += cost;
207
+ return cost;
68
208
  });
69
- return { credits: perJob.reduce((a, b) => a + b, 0), perJob };
209
+ return { credits: perJob.reduce((a, b) => a + b, 0), min, perJob };
70
210
  }
71
211
  /**
72
212
  * Adaptive Stealth Mode (mode=auto) by default, like `fetch` and `extract`: returns
@@ -74,14 +214,17 @@ export function estimateCredits(jobs) {
74
214
  * `premium_proxy` or set `mode` itself, and none at all when the job-level params
75
215
  * force either flag. Applied per task rather than job-level because the API rejects
76
216
  * a task that combines a job-level mode=auto with its own js_render or
77
- * premium_proxy (REQS004).
217
+ * premium_proxy (REQS004). A POST task is left alone: Adaptive Stealth Mode may
218
+ * turn on JavaScript rendering, which a POST cannot use.
78
219
  */
79
220
  export function withAdaptiveStealth(jobs, jobParams = {}) {
80
221
  const forced = (p) => isTruthyFlag(p.js_render) || isTruthyFlag(p.premium_proxy);
81
222
  if (forced(jobParams) || jobParams.mode != null)
82
223
  return jobs;
83
224
  return jobs.map((job) => {
84
- const own = { ...(job.zenrows_params ?? {}), ...job };
225
+ if (isPost(job.method))
226
+ return job;
227
+ const own = taskParamsOf(job);
85
228
  if (forced(own) || own.mode != null)
86
229
  return job;
87
230
  return { ...job, mode: "auto" };
@@ -92,38 +235,45 @@ export function withAdaptiveStealth(jobs, jobParams = {}) {
92
235
  * The flat per-line options (`js_render`, `premium_proxy`, `proxy_country`,
93
236
  * `autoparse`, `mode`, …) plus any nested `zenrows_params` become that task's
94
237
  * `zenrows_params`; `jobParams` (from CLI flags) becomes the job-level
95
- * `zenrows_params`. `url`/`external_id`/`metadata` map straight through.
238
+ * `zenrows_params`. `url`, `external_id`, `metadata`, `method` and `body` are
239
+ * task fields and map straight through.
96
240
  *
97
- * The API rejects `proxy_country` without `premium_proxy` (unless mode=auto),
98
- * so we surface PARAM_PROXY_COUNTRY_REQUIRES_PREMIUM here — before submitting —
99
- * for the effective (job + task) params, mirroring the manual-mode guard in
100
- * `protected-fetch.ts::validateAutoManual`.
241
+ * Checked here, before submitting, on the effective (job + task) params:
242
+ * boolean params hold true or false; `proxy_country` needs `premium_proxy`
243
+ * unless mode=auto (PARAM_PROXY_COUNTRY_REQUIRES_PREMIUM, mirroring
244
+ * `protected-fetch.ts::validateAutoManual`); a POST task does not turn on
245
+ * `js_render`, `js_instructions` or `json_response`, which the API rejects.
101
246
  */
102
- export function toJobBody(jobs, jobParams = {}) {
103
- // No separate job-level check: a job-level proxy_country is valid for each task
104
- // in Adaptive Stealth Mode, and the per-task check below sees job ⊕ task params.
247
+ export function toJobBody(jobs, jobParams = {}, fields = {}) {
105
248
  const tasks = jobs.map((job, idx) => {
106
- const { url, external_id, metadata, zenrows_params } = job;
107
- const flat = {};
108
- for (const [k, v] of Object.entries(job)) {
109
- if (!TASK_RESERVED.has(k))
110
- flat[k] = v;
111
- }
112
- const taskParams = { ...(zenrows_params ?? {}), ...flat };
113
- // Validate on the effective (job ⊕ task) params so a job-level premium_proxy
114
- // covers a per-task proxy_country, matching how the API merges them.
115
- assertProxyCountryPremium({ ...jobParams, ...taskParams }, `line ${idx + 1} (${url})`);
249
+ const { url, external_id, metadata } = job;
250
+ const where = `line ${idx + 1} (${url})`;
251
+ const effective = effectiveParams(job, jobParams);
252
+ assertBooleanParams(effective, where);
253
+ assertProxyCountryPremium(effective, where);
116
254
  const task = { url };
117
- if (external_id !== undefined)
255
+ if (external_id !== undefined && external_id !== null)
118
256
  task.external_id = String(external_id);
119
- if (metadata !== undefined)
257
+ if (metadata !== undefined && metadata !== null)
120
258
  task.metadata = metadata;
121
- const zp = normalizeParams(taskParams);
259
+ if (isPost(job.method)) {
260
+ assertPostParams(effective, where);
261
+ task.method = "POST";
262
+ if (job.body !== undefined && job.body !== null)
263
+ task.body = job.body;
264
+ }
265
+ const zp = normalizeParams(taskParamsOf(job));
122
266
  if (Object.keys(zp).length)
123
267
  task.zenrows_params = zp;
124
268
  return task;
125
269
  });
126
270
  const body = { type: "regular", status: "closed", tasks };
271
+ if (fields.name)
272
+ body.name = fields.name;
273
+ if (fields.external_id)
274
+ body.external_id = fields.external_id;
275
+ if (fields.metadata && Object.keys(fields.metadata).length)
276
+ body.metadata = fields.metadata;
127
277
  const jobZp = normalizeParams(jobParams);
128
278
  if (Object.keys(jobZp).length)
129
279
  body.zenrows_params = jobZp;
@@ -131,14 +281,16 @@ export function toJobBody(jobs, jobParams = {}) {
131
281
  }
132
282
  /**
133
283
  * Coerce a flat options object into the string-valued map the Batch API expects
134
- * (its docs pass `zenrows_params` values as strings, e.g. `"true"`, `"us"`).
135
- * Booleans/numbers are stringified; objects are JSON-encoded; nullish is dropped.
284
+ * (`zenrows_params` values are strings, e.g. `"true"`, `"us"`). Boolean params
285
+ * are canonicalized to "true"/"false"; other booleans/numbers are stringified;
286
+ * objects are JSON-encoded; nullish is dropped.
136
287
  */
137
288
  function normalizeParams(obj) {
138
289
  const out = {};
139
- for (const [k, v] of Object.entries(obj)) {
140
- if (v === undefined || v === null)
290
+ for (const [k, raw] of Object.entries(obj)) {
291
+ if (raw === undefined || raw === null)
141
292
  continue;
293
+ const v = BOOLEAN_PARAMS.has(k) ? canonicalBool(raw) : raw;
142
294
  if (typeof v === "string")
143
295
  out[k] = v;
144
296
  else if (typeof v === "boolean" || typeof v === "number")
@@ -153,7 +305,33 @@ function isAutoMode(v) {
153
305
  return v === "auto" || v === true;
154
306
  }
155
307
  function isTruthyFlag(v) {
156
- return v === true || v === "true";
308
+ return canonicalBool(v) === "true";
309
+ }
310
+ /** Throw before submitting if a boolean param holds a value the API rejects. */
311
+ function assertBooleanParams(params, where) {
312
+ for (const k of BOOLEAN_PARAMS) {
313
+ const v = params[k];
314
+ if (v !== undefined && v !== "true" && v !== "false") {
315
+ throw new ToolkitError({
316
+ code: "INVALID_USAGE",
317
+ message: `${k} must be true or false (got ${JSON.stringify(v)}) in ${where}.`,
318
+ likely_cause: `The Batch API accepts only true or false for ${k}; any other value fails the whole job.`,
319
+ next_action: `Set ${k} to true or false on that line, then retry.`,
320
+ });
321
+ }
322
+ }
323
+ }
324
+ /** Throw if a POST task would turn on a param the API refuses with POST (method_param_conflict). */
325
+ function assertPostParams(params, where) {
326
+ const conflict = params.js_render === "true" ? "js_render" : params.json_response === "true" ? "json_response" : params.js_instructions ? "js_instructions" : undefined;
327
+ if (!conflict)
328
+ return;
329
+ throw new ToolkitError({
330
+ code: "INVALID_USAGE",
331
+ message: `A POST task cannot use ${conflict}: ${where}.`,
332
+ likely_cause: "POST tasks run without a browser, so the Batch API rejects js_render, js_instructions and json_response on them (method_param_conflict).",
333
+ next_action: "Drop that param from the line (or the job-level --js-render flag), or send the task as GET.",
334
+ });
157
335
  }
158
336
  /** Throw if proxy_country is set without premium_proxy and outside auto mode. */
159
337
  function assertProxyCountryPremium(params, where) {
@@ -1,17 +1,26 @@
1
1
  import { ToolkitError } from "../core/errors.js";
2
+ import { log } from "../core/logger.js";
2
3
  import { runFetch } from "./protected-fetch.js";
3
- /** True when a parsed extraction result carries no usable structured data. */
4
- function isEmptyData(data) {
4
+ /**
5
+ * True when a parsed extraction result carries no usable structured data: null,
6
+ * a blank string, or an array / object whose every value is itself empty. So
7
+ * `{"title": null, "items": [], "author": {"name": ""}}` is empty. Numbers and
8
+ * booleans are values, so `0` and `false` count as data.
9
+ */
10
+ export function isEmptyData(data) {
5
11
  if (data === null || data === undefined)
6
12
  return true;
7
- if (Array.isArray(data))
8
- return data.length === 0;
9
- if (typeof data === "object")
10
- return Object.keys(data).length === 0;
11
13
  if (typeof data === "string")
12
14
  return data.trim() === "";
15
+ if (Array.isArray(data))
16
+ return data.every(isEmptyData);
17
+ if (typeof data === "object")
18
+ return Object.values(data).every(isEmptyData);
13
19
  return false;
14
20
  }
21
+ /** The warning a caller that still passes `fallbackAutoparse: true` gets. */
22
+ export const FALLBACK_AUTOPARSE_DEPRECATION = "fallbackAutoparse is deprecated and has no effect: Extract no longer falls back to Autoparse. " +
23
+ "On REQS007, prepare the domain with `zenrows extract prepare <domain> --url <page>` instead.";
15
24
  export async function runExtract(opts, config, policy, apiKey) {
16
25
  const method = opts.method ?? (opts.outputs ? "outputs" : opts.cssExtractor ? "css" : "extract");
17
26
  if (method === "css" && !opts.cssExtractor) {
@@ -30,18 +39,8 @@ export async function runExtract(opts, config, policy, apiKey) {
30
39
  next_action: `Pass one or more filters, e.g. --outputs emails,links (or --outputs '*').`,
31
40
  });
32
41
  }
33
- if (method === "extract" && opts.fallbackAutoparse !== false) {
34
- try {
35
- return await runExtractOnce({ ...opts, method: "extract" }, config, policy, apiKey);
36
- }
37
- catch (err) {
38
- if (err instanceof ToolkitError && err.code === "EXTRACT_DOMAIN_NOT_ENABLED") {
39
- const outcome = await runExtractOnce({ ...opts, method: "autoparse" }, config, policy, apiKey);
40
- return { ...outcome, fellBackToAutoparse: true };
41
- }
42
- throw err;
43
- }
44
- }
42
+ if (opts.fallbackAutoparse === true)
43
+ log.warn(FALLBACK_AUTOPARSE_DEPRECATION);
45
44
  return runExtractOnce({ ...opts, method }, config, policy, apiKey);
46
45
  }
47
46
  async function runExtractOnce(opts, config, policy, apiKey) {
@@ -101,7 +100,7 @@ function parseExtractBody(method, body, opts) {
101
100
  }
102
101
  }
103
102
  const empty = isEmptyData(data);
104
- // ACT-1514: an empty result must not read as a silent success. Under
103
+ // An empty result must not read as a silent success. Under
105
104
  // --validate it's an error; otherwise the command surfaces a warning.
106
105
  if (opts.validate && empty) {
107
106
  throw new ToolkitError({