@zenrows/cli 1.2.2 → 1.3.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 +16 -3
- package/agent-plugin/skills/batch-jobs/SKILL.md +3 -1
- package/dist/src/adapters/protected-fetch.js +1 -1
- package/dist/src/cli/commands/batch.js +67 -17
- package/dist/src/cli/commands/fetch.js +25 -0
- package/dist/src/cli/commands/usage.js +33 -0
- package/dist/src/core/batch-api.js +4 -1
- package/dist/src/core/browser-api.js +8 -2
- package/dist/src/core/config.js +1 -1
- package/dist/src/core/errors.js +38 -4
- package/dist/src/core/http.js +71 -3
- package/dist/src/core/open-url.js +3 -0
- package/package.json +1 -1
- package/skills/batch-jobs/SKILL.md +23 -1
- package/skills/extract/SKILL.md +5 -2
- package/skills/protected-fetch/SKILL.md +22 -7
- package/skills/trace-debug/SKILL.md +24 -2
- package/templates/batch-jsonl-pipeline/README.md +6 -1
- package/templates/batch-jsonl-pipeline/jobs.example.jsonl +1 -0
package/README.md
CHANGED
|
@@ -139,9 +139,22 @@ zenrows batch cancel <job-id> # stop an in-flight run
|
|
|
139
139
|
```
|
|
140
140
|
|
|
141
141
|
`jobs.jsonl` is one JSON object per line, each with a `url` (plus optional
|
|
142
|
-
per-line overrides like `js_render`, `premium_proxy`, `proxy_country
|
|
143
|
-
beta access from Zenrows to run in the cloud; until then,
|
|
144
|
-
locally or fan out with `zenrows fetch` per URL.
|
|
142
|
+
per-line overrides like `js_render`, `premium_proxy`, `proxy_country`,
|
|
143
|
+
`extract`). Request beta access from Zenrows to run in the cloud; until then,
|
|
144
|
+
validate/estimate locally or fan out with `zenrows fetch` per URL.
|
|
145
|
+
|
|
146
|
+
**Extract in Batch.** Set `"extract": "auto"` on a line to run that URL through
|
|
147
|
+
Extract — structured data instead of raw HTML:
|
|
148
|
+
|
|
149
|
+
```jsonl
|
|
150
|
+
{"url": "https://example.com/products", "extract": "auto", "external_id": "p1"}
|
|
151
|
+
{"url": "https://example.com/reviews", "extract": "auto", "external_id": "r1"}
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
An Extract task costs the same as a regular one (1 credit at base tier), so
|
|
155
|
+
`batch estimate` prices it correctly. Results for an Extract task carry two
|
|
156
|
+
keys: `html` (the raw page) and `parsed` (the structured data) — validate a
|
|
157
|
+
sample with `zenrows extract <url>` before running the full batch.
|
|
145
158
|
|
|
146
159
|
## 10. Browser Sessions
|
|
147
160
|
|
|
@@ -30,7 +30,9 @@ Optional if exposed by the server: `batch_wait` to block until terminal status.
|
|
|
30
30
|
1. Validate the workflow on **one** URL with `scrape` or `extract`.
|
|
31
31
|
2. `batch_create` with the full task list (each task: `url` + optional
|
|
32
32
|
overrides like `js_render`, `premium_proxy`, `proxy_country`, `mode`,
|
|
33
|
-
`autoparse`, `external_id`, `metadata`).
|
|
33
|
+
`autoparse`, `extract`, `external_id`, `metadata`). `extract: "auto"`
|
|
34
|
+
returns structured data instead of raw HTML, at the same credit cost as a
|
|
35
|
+
regular task; the result carries `html` and `parsed` keys.
|
|
34
36
|
3. Poll `batch_status` until terminal (`completed` / `stopped` / …).
|
|
35
37
|
4. Collect with `batch_results`.
|
|
36
38
|
5. `batch_cancel` if the user aborts or the run should stop early.
|
|
@@ -98,6 +98,6 @@ export async function runFetch(opts, config, policy, apiKey) {
|
|
|
98
98
|
validateAutoManual(opts, config);
|
|
99
99
|
const params = buildParams(opts, config);
|
|
100
100
|
const mode = params.mode === "auto" ? "auto" : "manual";
|
|
101
|
-
const result = await scrape(config.apiBase, apiKey, params);
|
|
101
|
+
const result = await scrape(config.apiBase, apiKey, params, { timeoutMs: opts.timeoutMs });
|
|
102
102
|
return { result, params, mode };
|
|
103
103
|
}
|
|
@@ -17,7 +17,7 @@ import { assertDomainAllowed, assertWithinLimits, loadPolicy } from "../../core/
|
|
|
17
17
|
import { newRunId, writeRun } from "../../core/artifacts.js";
|
|
18
18
|
import { createJob, downloadResults, getJob, listResults, rerunJob, stopJob, waitForJob } from "../../core/batch-api.js";
|
|
19
19
|
import { asNumber, asString, parse } from "../command.js";
|
|
20
|
-
import { ToolkitError } from "../../core/errors.js";
|
|
20
|
+
import { ToolkitError, isKeyCapReached, keyCapReached } from "../../core/errors.js";
|
|
21
21
|
import { printError, writeOut } from "../output.js";
|
|
22
22
|
export const batch = {
|
|
23
23
|
name: "batch",
|
|
@@ -166,20 +166,22 @@ async function createCmd(rest, ctx) {
|
|
|
166
166
|
try {
|
|
167
167
|
const job = await createJob(body, { apiKey });
|
|
168
168
|
const finished = follow ? await waitForJob(job.job_id, { apiKey }) : job;
|
|
169
|
+
const runError = runFailure(finished);
|
|
169
170
|
const runDir = writeRun({
|
|
170
171
|
runId,
|
|
171
172
|
command: "zenrows batch create",
|
|
172
173
|
capability: "batch",
|
|
173
174
|
startedAt,
|
|
174
175
|
finishedAt: new Date().toISOString(),
|
|
175
|
-
status: "ok",
|
|
176
|
+
status: runError ? "error" : "ok",
|
|
176
177
|
request: { file, tasks: body.tasks.length, estimatedCredits: est.credits, jobParams },
|
|
177
|
-
result: { jobId: job.job_id, status: finished.latest_run?.status ?? "unknown" },
|
|
178
|
+
result: { jobId: job.job_id, status: finished.latest_run?.status ?? "unknown", ...failureFields(finished) },
|
|
179
|
+
...(runError ? { error: runError.toJSON() } : {}),
|
|
178
180
|
});
|
|
179
|
-
printJob(finished, json, `Submitted job ${job.job_id}`);
|
|
181
|
+
const code = printJob(finished, json, `Submitted job ${job.job_id}`);
|
|
180
182
|
if (runDir && !json)
|
|
181
183
|
log.dim(` artifact: ${runDir}`);
|
|
182
|
-
return
|
|
184
|
+
return code;
|
|
183
185
|
}
|
|
184
186
|
catch (err) {
|
|
185
187
|
writeRun({
|
|
@@ -201,8 +203,7 @@ async function statusCmd(rest, ctx) {
|
|
|
201
203
|
assertUsable("batch");
|
|
202
204
|
const apiKey = requireApiKey();
|
|
203
205
|
const job = await getJob(id, { apiKey });
|
|
204
|
-
printJob(job, ctx.json, `Job ${id}`);
|
|
205
|
-
return 0;
|
|
206
|
+
return printJob(job, ctx.json, `Job ${id}`);
|
|
206
207
|
}
|
|
207
208
|
async function resultsCmd(rest, ctx) {
|
|
208
209
|
const { values, positionals } = parse(rest, {
|
|
@@ -273,8 +274,7 @@ async function cancelCmd(rest, ctx) {
|
|
|
273
274
|
assertUsable("batch");
|
|
274
275
|
const apiKey = requireApiKey();
|
|
275
276
|
const job = await stopJob(id, { apiKey });
|
|
276
|
-
printJob(job, ctx.json, `Stopped job ${id}`);
|
|
277
|
-
return 0;
|
|
277
|
+
return printJob(job, ctx.json, `Stopped job ${id}`);
|
|
278
278
|
}
|
|
279
279
|
async function waitCmd(rest, ctx) {
|
|
280
280
|
const { values, positionals } = parse(rest, { timeout: { type: "string" }, json: { type: "boolean" } });
|
|
@@ -283,8 +283,7 @@ async function waitCmd(rest, ctx) {
|
|
|
283
283
|
assertUsable("batch");
|
|
284
284
|
const apiKey = requireApiKey();
|
|
285
285
|
const job = await waitForJob(id, { apiKey, timeoutMs: asNumber(values.timeout) });
|
|
286
|
-
printJob(job, json, `Job ${id} finished`);
|
|
287
|
-
return 0;
|
|
286
|
+
return printJob(job, json, `Job ${id} finished`);
|
|
288
287
|
}
|
|
289
288
|
async function retryCmd(rest, ctx) {
|
|
290
289
|
const { positionals } = parse(rest, {});
|
|
@@ -294,21 +293,72 @@ async function retryCmd(rest, ctx) {
|
|
|
294
293
|
// "Reruns and retrying failures": POST /jobs/{id}/rerun?status=failed replays
|
|
295
294
|
// only the failures; already-successful tasks carry over.
|
|
296
295
|
const job = await rerunJob(id, { apiKey, status: "failed" });
|
|
297
|
-
printJob(job, ctx.json, `Reran failed tasks for job ${id}`);
|
|
298
|
-
|
|
296
|
+
return printJob(job, ctx.json, `Reran failed tasks for job ${id}`);
|
|
297
|
+
}
|
|
298
|
+
/** The run's failure_reason / failure_detail, when the API reports them. */
|
|
299
|
+
function failureFields(job) {
|
|
300
|
+
const run = job.latest_run ?? {};
|
|
301
|
+
const out = {};
|
|
302
|
+
if (typeof run.failure_reason === "string" && run.failure_reason)
|
|
303
|
+
out.failure_reason = run.failure_reason;
|
|
304
|
+
if (typeof run.failure_detail === "string" && run.failure_detail)
|
|
305
|
+
out.failure_detail = run.failure_detail;
|
|
306
|
+
return out;
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* The error for a run that ended `failed`, or null for any other state. A run the
|
|
310
|
+
* key's credit cap stopped (`api_key_cap_reached`) gets the cap guidance: the
|
|
311
|
+
* account still has credits and its other keys keep working.
|
|
312
|
+
*/
|
|
313
|
+
export function runFailure(job) {
|
|
314
|
+
if (job.latest_run?.status !== "failed")
|
|
315
|
+
return null;
|
|
316
|
+
const { failure_reason, failure_detail } = failureFields(job);
|
|
317
|
+
const where = `batch job ${job.job_id}`;
|
|
318
|
+
if (isKeyCapReached(failure_reason)) {
|
|
319
|
+
return keyCapReached(where, { status: null, detail: failure_detail });
|
|
320
|
+
}
|
|
321
|
+
const why = [failure_reason, failure_detail].filter(Boolean).join(": ");
|
|
322
|
+
return new ToolkitError({
|
|
323
|
+
code: "BATCH_FAILED",
|
|
324
|
+
message: `Batch job ${job.job_id} failed.`,
|
|
325
|
+
likely_cause: why || "The run ended in status failed without a reason.",
|
|
326
|
+
next_action: "Inspect the per-task results, fix the cause, then rerun the failed tasks.",
|
|
327
|
+
suggested_commands: [`zenrows batch results ${job.job_id} --status failed`, `zenrows batch retry-failed ${job.job_id}`],
|
|
328
|
+
});
|
|
299
329
|
}
|
|
300
|
-
/**
|
|
330
|
+
/**
|
|
331
|
+
* Print a job's status + stats, structured under --json, and return the exit
|
|
332
|
+
* code: 1 when the run ended `failed`, else 0. `stopped` and `deleted` are
|
|
333
|
+
* deliberate outcomes (someone cancelled or removed the run), so they exit 0
|
|
334
|
+
* but print as a warning, not a success.
|
|
335
|
+
*/
|
|
301
336
|
function printJob(job, json, headline) {
|
|
302
337
|
const run = job.latest_run ?? {};
|
|
303
338
|
const stats = run.stats;
|
|
339
|
+
const failure = failureFields(job);
|
|
340
|
+
const err = runFailure(job);
|
|
304
341
|
if (json) {
|
|
305
|
-
log.out(JSON.stringify({ ok:
|
|
306
|
-
return;
|
|
342
|
+
log.out(JSON.stringify({ ok: !err, jobId: job.job_id, status: run.status, stats, ...failure, ...(err ? { error: err.toJSON() } : {}) }, null, 2));
|
|
343
|
+
return err ? 1 : 0;
|
|
307
344
|
}
|
|
308
|
-
|
|
345
|
+
const line = `${headline} · status: ${run.status ?? "unknown"}`;
|
|
346
|
+
if (err)
|
|
347
|
+
log.error(line);
|
|
348
|
+
else if (run.status === "stopped" || run.status === "deleted")
|
|
349
|
+
log.warn(line);
|
|
350
|
+
else
|
|
351
|
+
log.success(line);
|
|
309
352
|
if (stats) {
|
|
310
353
|
log.info(` ${stats.completed}/${stats.total} completed · ${stats.successful} successful · ${stats.failed} failed`);
|
|
311
354
|
}
|
|
355
|
+
if (failure.failure_reason)
|
|
356
|
+
log.info(` failure_reason: ${failure.failure_reason}`);
|
|
357
|
+
if (failure.failure_detail)
|
|
358
|
+
log.info(` failure_detail: ${failure.failure_detail}`);
|
|
359
|
+
if (err)
|
|
360
|
+
printError(err, false);
|
|
361
|
+
return err ? 1 : 0;
|
|
312
362
|
}
|
|
313
363
|
function normalizeResultStatus(v) {
|
|
314
364
|
if (!v)
|
|
@@ -34,6 +34,7 @@ export const fetch_ = {
|
|
|
34
34
|
" --output <fmt> html (default) | markdown | text | pdf",
|
|
35
35
|
" --screenshot capture an above-the-fold screenshot",
|
|
36
36
|
" --out <file> write the response body to a file",
|
|
37
|
+
" --timeout <ms> client-side timeout (default 120000; above the API's own 90s budget)",
|
|
37
38
|
" --no-signup do not auto-create a Free plan account if no key exists",
|
|
38
39
|
" --json print a structured result",
|
|
39
40
|
"",
|
|
@@ -55,6 +56,7 @@ export const fetch_ = {
|
|
|
55
56
|
output: { type: "string" },
|
|
56
57
|
screenshot: { type: "boolean" },
|
|
57
58
|
out: { type: "string" },
|
|
59
|
+
timeout: { type: "string" },
|
|
58
60
|
"no-signup": { type: "boolean" },
|
|
59
61
|
json: { type: "boolean" },
|
|
60
62
|
});
|
|
@@ -90,6 +92,7 @@ export const fetch_ = {
|
|
|
90
92
|
originalStatus: values["original-status"] === true,
|
|
91
93
|
output: normalizeOutput(asString(values.output)),
|
|
92
94
|
screenshot: values.screenshot === true,
|
|
95
|
+
timeoutMs: normalizeTimeout(values.timeout),
|
|
93
96
|
};
|
|
94
97
|
const runId = newRunId();
|
|
95
98
|
const startedAt = new Date().toISOString();
|
|
@@ -166,6 +169,28 @@ export const fetch_ = {
|
|
|
166
169
|
}
|
|
167
170
|
},
|
|
168
171
|
};
|
|
172
|
+
/**
|
|
173
|
+
* `--timeout <ms>`, in milliseconds, matching `batch wait --timeout`.
|
|
174
|
+
*
|
|
175
|
+
* Rejects a non-numeric or non-positive value rather than silently falling back
|
|
176
|
+
* to the default: an agent that passes `--timeout fast` must not get a green
|
|
177
|
+
* result on a timeout the CLI never honored.
|
|
178
|
+
*/
|
|
179
|
+
export function normalizeTimeout(v) {
|
|
180
|
+
if (v === undefined)
|
|
181
|
+
return undefined;
|
|
182
|
+
const ms = asNumber(v);
|
|
183
|
+
if (ms === undefined || ms <= 0) {
|
|
184
|
+
throw new ToolkitError({
|
|
185
|
+
code: "INVALID_USAGE",
|
|
186
|
+
message: `Invalid --timeout value '${String(v)}'.`,
|
|
187
|
+
likely_cause: "--timeout takes a positive number of milliseconds.",
|
|
188
|
+
next_action: "Pass milliseconds, e.g. --timeout 180000 for three minutes.",
|
|
189
|
+
suggested_commands: ["zenrows fetch <url> --timeout 180000"],
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
return ms;
|
|
193
|
+
}
|
|
169
194
|
export function normalizeOutput(v) {
|
|
170
195
|
if (!v)
|
|
171
196
|
return undefined;
|
|
@@ -7,6 +7,10 @@ import { loadConfig } from "../../core/config.js";
|
|
|
7
7
|
import { log } from "../../core/logger.js";
|
|
8
8
|
import { fetchUsage } from "../../core/usage.js";
|
|
9
9
|
import { parse } from "../command.js";
|
|
10
|
+
/** Thousands separators, so a seven-digit credit limit stays readable. */
|
|
11
|
+
export function fmt(n) {
|
|
12
|
+
return n.toLocaleString("en-US");
|
|
13
|
+
}
|
|
10
14
|
/** Free plan still ships as plan_code=trial in R1; display the product name. */
|
|
11
15
|
export function formatPlanName(name) {
|
|
12
16
|
if (!name)
|
|
@@ -23,6 +27,22 @@ export function formatPlanStatus(status) {
|
|
|
23
27
|
return "active";
|
|
24
28
|
return status;
|
|
25
29
|
}
|
|
30
|
+
const CAP_WINDOW_LABEL = {
|
|
31
|
+
day: "daily",
|
|
32
|
+
week: "weekly",
|
|
33
|
+
month: "monthly",
|
|
34
|
+
billing_period: "billing-period",
|
|
35
|
+
};
|
|
36
|
+
/** One line per credit cap on the calling key; none when the key is uncapped. */
|
|
37
|
+
export function formatKeyCaps(caps) {
|
|
38
|
+
return (caps ?? []).map((c) => {
|
|
39
|
+
const label = `This key: ${CAP_WINDOW_LABEL[c.window] ?? c.window} cap ${fmt(c.credits)}`;
|
|
40
|
+
const reset = c.resets_at ? `, resets ${c.resets_at}` : "";
|
|
41
|
+
if (c.unavailable || c.used_credits === undefined)
|
|
42
|
+
return `${label}, usage unavailable right now${reset}`;
|
|
43
|
+
return `${label}, ${fmt(c.used_credits)} used, ${fmt(c.remaining_credits ?? Math.max(0, c.credits - c.used_credits))} left${reset}`;
|
|
44
|
+
});
|
|
45
|
+
}
|
|
26
46
|
export const usage = {
|
|
27
47
|
name: "usage",
|
|
28
48
|
summary: "Show plan usage, credits, and concurrency for the current API key.",
|
|
@@ -40,6 +60,17 @@ export const usage = {
|
|
|
40
60
|
const api = u.plan?.products?.api;
|
|
41
61
|
log.info(`Plan: ${formatPlanName(u.plan?.name)}${u.plan?.recurrence ? ` (${u.plan.recurrence})` : ""}`);
|
|
42
62
|
log.info(`Status: ${formatPlanStatus(u.status)}`);
|
|
63
|
+
// Credits are the unit the docs, the dashboard and the error messages all speak in,
|
|
64
|
+
// and the endpoint has been returning them all along. Showing only the dollar figure
|
|
65
|
+
// left the reader to convert — and the rate is per plan, so they could not.
|
|
66
|
+
if (u.usage_credits !== undefined && u.credit_limit !== undefined) {
|
|
67
|
+
const remaining = Math.max(0, u.credit_limit - u.usage_credits);
|
|
68
|
+
log.info(`Credits: ${fmt(u.usage_credits)} of ${fmt(u.credit_limit)} used` +
|
|
69
|
+
`${u.usage_percent !== undefined ? ` (${u.usage_percent}%)` : ""} — ${fmt(remaining)} left`);
|
|
70
|
+
}
|
|
71
|
+
else if (u.usage_credits !== undefined) {
|
|
72
|
+
log.info(`Credits: ${fmt(u.usage_credits)} used`);
|
|
73
|
+
}
|
|
43
74
|
if (u.usage !== undefined) {
|
|
44
75
|
log.info(`Usage: ${u.usage}${u.usage_percent !== undefined ? ` (${u.usage_percent}% of plan)` : ""}`);
|
|
45
76
|
}
|
|
@@ -48,6 +79,8 @@ export const usage = {
|
|
|
48
79
|
}
|
|
49
80
|
if (u.period_ends_at)
|
|
50
81
|
log.info(`Billing period ends: ${u.period_ends_at}`);
|
|
82
|
+
for (const line of formatKeyCaps(u.api_key?.caps))
|
|
83
|
+
log.info(line);
|
|
51
84
|
if (Array.isArray(u.top_ups) && u.top_ups.length)
|
|
52
85
|
log.info(`Top-ups: ${u.top_ups.length}`);
|
|
53
86
|
return 0;
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import { mkdirSync, writeFileSync } from "node:fs";
|
|
18
18
|
import { join } from "node:path";
|
|
19
|
-
import { ToolkitError, quotaExhausted } from "./errors.js";
|
|
19
|
+
import { ToolkitError, isKeyCapReached, keyCapReached, quotaExhausted } from "./errors.js";
|
|
20
20
|
import { readAccount } from "./agent-account.js";
|
|
21
21
|
import { registerSecret } from "./logger.js";
|
|
22
22
|
/** Confirmed Batch API base (no trailing slash). */
|
|
@@ -139,6 +139,9 @@ function problemToError(status, body, method, path) {
|
|
|
139
139
|
suggested_commands: ["zenrows batch status <id>"],
|
|
140
140
|
});
|
|
141
141
|
}
|
|
142
|
+
if (status === 402 && isKeyCapReached(serverCode)) {
|
|
143
|
+
return keyCapReached(`${method} ${path}`, { status: 402, detail: problem.detail || problem.title || undefined });
|
|
144
|
+
}
|
|
142
145
|
if (status === 402) {
|
|
143
146
|
// Out of credits ("Subscription has no credit available") — the same
|
|
144
147
|
// exhausted state as the scraper/usage 402. Surface the credits error with
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* A managed REST session API (used in production by the official `@zenrows/mcp`
|
|
5
5
|
* server) hosted at `https://mcp.zenrows.com/browser/sessions/*`. Auth is the
|
|
6
|
-
* `Authorization: Bearer <key>` header (same key as the
|
|
6
|
+
* `Authorization: Bearer <key>` header (same key as the Fetch API). Bodies are
|
|
7
7
|
* JSON; the CLI drives it directly — no CDP/WebSocket, no browser dependency.
|
|
8
8
|
* Structurally the same shape as `batch-api.ts` (separate host, header auth,
|
|
9
9
|
* injectable `fetchImpl` for tests).
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* Sessions are billed by bandwidth + session time, so callers must always close
|
|
12
12
|
* them (`closeSession`) — the `browser run` command does so in a `finally`.
|
|
13
13
|
*/
|
|
14
|
-
import { ToolkitError, quotaExhausted } from "./errors.js";
|
|
14
|
+
import { ToolkitError, isKeyCapReached, keyCapReached, quotaExhausted } from "./errors.js";
|
|
15
15
|
import { readAccount } from "./agent-account.js";
|
|
16
16
|
import { registerSecret } from "./logger.js";
|
|
17
17
|
import { CLI_VERSION } from "./config.js";
|
|
@@ -105,6 +105,12 @@ export function browserProblemToError(status, body, method, path) {
|
|
|
105
105
|
suggested_commands: ["zenrows login --api-key <your-key>"],
|
|
106
106
|
});
|
|
107
107
|
}
|
|
108
|
+
if (status === 402 && isKeyCapReached(serverCode, body)) {
|
|
109
|
+
return keyCapReached(`${method} ${path}`, {
|
|
110
|
+
status: 402,
|
|
111
|
+
detail: parsed.error || parsed.detail || parsed.title || undefined,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
108
114
|
if (status === 402) {
|
|
109
115
|
// Out of credits — same exhausted state as the scraper/batch 402.
|
|
110
116
|
const acct = readAccount();
|
package/dist/src/core/config.js
CHANGED
|
@@ -11,7 +11,7 @@ export const CONFIG_VERSION = "0.1.0";
|
|
|
11
11
|
* entrypoint) so core modules — telemetry, signup provenance — can read it
|
|
12
12
|
* without an import cycle. `VERSION` in `cli/index.ts` re-exports this.
|
|
13
13
|
*/
|
|
14
|
-
export const CLI_VERSION = "1.
|
|
14
|
+
export const CLI_VERSION = "1.3.0";
|
|
15
15
|
/** Env var to override the Fetch and Extract API base (local/staging testing). */
|
|
16
16
|
export const API_BASE_ENV = "ZENROWS_API_BASE";
|
|
17
17
|
/**
|
package/dist/src/core/errors.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* a `next_action`, and optional `suggested_commands`. Agents (and humans) can
|
|
6
6
|
* react to the code and follow the suggested command without guessing.
|
|
7
7
|
*/
|
|
8
|
-
import {
|
|
8
|
+
import { BILLING_TOPUP_URL, PLANS_URL } from "./open-url.js";
|
|
9
9
|
export class ToolkitError extends Error {
|
|
10
10
|
code;
|
|
11
11
|
likely_cause;
|
|
@@ -39,15 +39,49 @@ export class ToolkitError extends Error {
|
|
|
39
39
|
* quota, …) instead of a hardcoded status.
|
|
40
40
|
*/
|
|
41
41
|
export function quotaExhausted(url, claimUrl, opts = {}) {
|
|
42
|
+
// Say that the allowance comes back. Without it this reads as a permanent paywall,
|
|
43
|
+
// which is how the API's own AUTH004 text reads ("Purchase a new subscription to
|
|
44
|
+
// continue") and why exhausted clients retry-loop instead of waiting or upgrading —
|
|
45
|
+
// one account spent seven days at ~3 req/s against this wall. `zenrows usage` prints
|
|
46
|
+
// the exact `period_ends_at`, so the date is one command away rather than guessed here.
|
|
47
|
+
const renewLine = "Credits renew at the end of the billing period — run `zenrows usage` for the date.";
|
|
42
48
|
const claimLine = claimUrl
|
|
43
|
-
? `You are on the Zenrows Free plan. Claim your account to keep your usage and add credits: ${claimUrl}`
|
|
44
|
-
: `You are out of Zenrows credits.
|
|
49
|
+
? `You are on the Zenrows Free plan. Claim your account to keep your usage and add credits: ${claimUrl}. ${renewLine}`
|
|
50
|
+
: `You are out of Zenrows credits. ${renewLine} To carry on now, add a credit pack (${BILLING_TOPUP_URL} opens the purchase directly) or upgrade your plan: ${PLANS_URL}`;
|
|
45
51
|
const detail = opts.detail ? `${opts.detail.replace(/\.\s*$/, "")}. ` : "";
|
|
46
52
|
return new ToolkitError({
|
|
47
53
|
code: "POLICY_MAX_CREDITS_EXCEEDED",
|
|
48
54
|
message: "Zenrows request quota exhausted.",
|
|
49
55
|
likely_cause: `${detail}HTTP ${opts.status ?? 429} for ${url}`,
|
|
50
56
|
next_action: claimLine,
|
|
51
|
-
suggested_commands:
|
|
57
|
+
suggested_commands: ["zenrows usage"],
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
/** Where an account manages its API keys and their credit caps. */
|
|
61
|
+
export const API_KEYS_SETTINGS_URL = "https://app.zenrows.com/settings/api-keys";
|
|
62
|
+
/**
|
|
63
|
+
* True when a 402 is a per-key credit cap (gateway AUTH014, Batch
|
|
64
|
+
* `api_key_cap_reached`), not an account out of credits. The account still has
|
|
65
|
+
* credits, so the out-of-credits advice (top up, upgrade, claim) is wrong here.
|
|
66
|
+
*/
|
|
67
|
+
export function isKeyCapReached(code, body) {
|
|
68
|
+
if (code === "AUTH014" || code === "api_key_cap_reached")
|
|
69
|
+
return true;
|
|
70
|
+
return !!body && /\b(AUTH014|api_key_cap_reached)\b/.test(body);
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* The error for a request refused because this API key reached one of its credit
|
|
74
|
+
* caps. `opts.status: null` is for a Batch run the cap stopped: the run's status
|
|
75
|
+
* call succeeded, so there is no HTTP 402 to cite, only `url` (the job).
|
|
76
|
+
*/
|
|
77
|
+
export function keyCapReached(url, opts = {}) {
|
|
78
|
+
const detail = opts.detail ? `${opts.detail.replace(/\.\s*$/, "")}. ` : "";
|
|
79
|
+
const where = opts.status === null ? `Stopped ${url}` : `HTTP ${opts.status ?? 402} for ${url}`;
|
|
80
|
+
return new ToolkitError({
|
|
81
|
+
code: "KEY_CREDIT_CAP_REACHED",
|
|
82
|
+
message: "This API key reached one of its credit caps.",
|
|
83
|
+
likely_cause: `${detail}${where}`,
|
|
84
|
+
next_action: `The account still has credits and its other API keys keep working. Wait for the cap to reset (\`zenrows usage\` shows when), or raise or remove this key's cap at ${API_KEYS_SETTINGS_URL}.`,
|
|
85
|
+
suggested_commands: ["zenrows usage"],
|
|
52
86
|
});
|
|
53
87
|
}
|
package/dist/src/core/http.js
CHANGED
|
@@ -5,11 +5,25 @@
|
|
|
5
5
|
* query parameter (per docs) and is registered as a secret so it is redacted
|
|
6
6
|
* from any logged URL.
|
|
7
7
|
*/
|
|
8
|
-
import { ToolkitError, quotaExhausted } from "./errors.js";
|
|
8
|
+
import { ToolkitError, isKeyCapReached, keyCapReached, quotaExhausted } from "./errors.js";
|
|
9
9
|
import { readAccount } from "./agent-account.js";
|
|
10
10
|
import { ENV_KEY, resolveApiKey } from "./auth.js";
|
|
11
11
|
import { registerSecret } from "./logger.js";
|
|
12
12
|
import { CLI_VERSION } from "./config.js";
|
|
13
|
+
/**
|
|
14
|
+
* Client-side timeout for a Fetch and Extract request, deliberately ABOVE the
|
|
15
|
+
* gateway's own 90s request budget.
|
|
16
|
+
*
|
|
17
|
+
* The two must not be equal. When they were both 90s, any request that used
|
|
18
|
+
* the full server budget became a race between our own abort and the API's
|
|
19
|
+
* real error envelope — and the abort usually won, so a specific, actionable
|
|
20
|
+
* server error (a 422, a 499) was reported as "could not reach the API". The
|
|
21
|
+
* client must outlive the server budget so the API always gets the chance to
|
|
22
|
+
* answer for itself.
|
|
23
|
+
*/
|
|
24
|
+
export const DEFAULT_TIMEOUT_MS = 120_000;
|
|
25
|
+
/** The gateway's own request budget. Kept here only to justify the default above. */
|
|
26
|
+
export const SERVER_BUDGET_MS = 90_000;
|
|
13
27
|
function buildUrl(apiBase, apiKey, params) {
|
|
14
28
|
const u = new URL(apiBase);
|
|
15
29
|
u.searchParams.set("apikey", apiKey);
|
|
@@ -27,8 +41,17 @@ function buildUrl(apiBase, apiKey, params) {
|
|
|
27
41
|
export async function scrape(apiBase, apiKey, params, opts = {}) {
|
|
28
42
|
registerSecret(apiKey);
|
|
29
43
|
const { full, redacted } = buildUrl(apiBase, apiKey, params);
|
|
44
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
30
45
|
const controller = new AbortController();
|
|
31
|
-
|
|
46
|
+
// Our own timer is the only thing that aborts this controller, so this flag —
|
|
47
|
+
// not `err.name === "AbortError"` — is what tells a client-side give-up apart
|
|
48
|
+
// from a genuine transport failure. The two must never share an error code.
|
|
49
|
+
let timedOut = false;
|
|
50
|
+
const timeout = setTimeout(() => {
|
|
51
|
+
timedOut = true;
|
|
52
|
+
controller.abort();
|
|
53
|
+
}, timeoutMs);
|
|
54
|
+
const startedAt = Date.now();
|
|
32
55
|
let res;
|
|
33
56
|
try {
|
|
34
57
|
res = await fetch(full, {
|
|
@@ -39,11 +62,14 @@ export async function scrape(apiBase, apiKey, params, opts = {}) {
|
|
|
39
62
|
}
|
|
40
63
|
catch (err) {
|
|
41
64
|
clearTimeout(timeout);
|
|
65
|
+
const elapsedMs = Date.now() - startedAt;
|
|
66
|
+
if (timedOut)
|
|
67
|
+
throw requestTimeout(params.url, timeoutMs, elapsedMs);
|
|
42
68
|
const cause = err instanceof Error ? err.message : String(err);
|
|
43
69
|
throw new ToolkitError({
|
|
44
70
|
code: "BACKEND_UNAVAILABLE",
|
|
45
71
|
message: `Could not reach the Zenrows API.`,
|
|
46
|
-
likely_cause: `Network error
|
|
72
|
+
likely_cause: `Network error after ${formatMs(elapsedMs)}: ${cause}`,
|
|
47
73
|
next_action: "Check connectivity and retry. Verify api base in `zenrows config show`.",
|
|
48
74
|
suggested_commands: ["zenrows status", "zenrows config show"],
|
|
49
75
|
});
|
|
@@ -116,6 +142,11 @@ export async function scrape(apiBase, apiKey, params, opts = {}) {
|
|
|
116
142
|
suggested_commands: [`zenrows extract ${params.url} --autoparse`],
|
|
117
143
|
});
|
|
118
144
|
}
|
|
145
|
+
// AUTH014: this key hit one of its credit caps; the account still has credits.
|
|
146
|
+
if (isKeyCapReached(zrErrorCode(body) ?? undefined)) {
|
|
147
|
+
// `detail` names the cap and its reset date; the title alone says neither.
|
|
148
|
+
throw keyCapReached(redacted, { status: 402, detail: zrErrorProblemDetail(body) ?? zrErrorDetail(body) ?? undefined });
|
|
149
|
+
}
|
|
119
150
|
// Zenrows returns 402 with a JSON error envelope (e.g. AUTH004 "reached its
|
|
120
151
|
// usage limit" / "Subscription has no credit available") when the account is
|
|
121
152
|
// out of credits. This is NOT scraped content — surface it as a credits
|
|
@@ -197,6 +228,31 @@ export async function scrape(apiBase, apiKey, params, opts = {}) {
|
|
|
197
228
|
}
|
|
198
229
|
return result;
|
|
199
230
|
}
|
|
231
|
+
/**
|
|
232
|
+
* The CLI gave up waiting before the API answered. This is NOT unreachability:
|
|
233
|
+
* the connection was established and the gateway was still working, so telling
|
|
234
|
+
* the operator to check connectivity would send them at the wrong problem. The
|
|
235
|
+
* elapsed time is included so the 90s server budget is visible in the output.
|
|
236
|
+
*/
|
|
237
|
+
export function requestTimeout(url, timeoutMs, elapsedMs) {
|
|
238
|
+
const suggestedMs = Math.max(timeoutMs + 60_000, SERVER_BUDGET_MS + 60_000);
|
|
239
|
+
const atBudget = elapsedMs >= SERVER_BUDGET_MS;
|
|
240
|
+
return new ToolkitError({
|
|
241
|
+
code: "REQUEST_TIMEOUT",
|
|
242
|
+
message: `The CLI stopped waiting after ${formatMs(timeoutMs)}. The Zenrows API did not respond in time.`,
|
|
243
|
+
likely_cause: `The request was aborted client-side after ${formatMs(elapsedMs)}. The API was reached — this is not a ` +
|
|
244
|
+
(atBudget
|
|
245
|
+
? `connectivity problem. The request also passed the API's own ${formatMs(SERVER_BUDGET_MS)} budget, so the target is very likely rendering slowly or a wait condition never matched.`
|
|
246
|
+
: `connectivity problem, and the API's own ${formatMs(SERVER_BUDGET_MS)} budget had not run out yet.`),
|
|
247
|
+
next_action: `Retry with a longer client timeout (\`--timeout ${suggestedMs}\`). If the target needs a long render, drop ` +
|
|
248
|
+
"`--wait-for` so the request finishes inside the API's budget and the API can return its own error instead.",
|
|
249
|
+
suggested_commands: [`zenrows fetch ${url} --timeout ${suggestedMs}`],
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
/** `89677` → `89.7s`; `120004` → `120s`. One decimal, and never a bare `.0`. */
|
|
253
|
+
function formatMs(ms) {
|
|
254
|
+
return `${(ms / 1000).toFixed(1).replace(/\.0$/, "")}s`;
|
|
255
|
+
}
|
|
200
256
|
function looksLikeContent(r) {
|
|
201
257
|
// allowed_status_codes / original_status can legitimately return 4xx bodies
|
|
202
258
|
// (the target's real page content). But Zenrows' OWN error responses also
|
|
@@ -307,6 +363,18 @@ export function zrErrorCode(body) {
|
|
|
307
363
|
return null;
|
|
308
364
|
}
|
|
309
365
|
}
|
|
366
|
+
/** The problem body's `detail` field alone, prefixed with its code. */
|
|
367
|
+
function zrErrorProblemDetail(body) {
|
|
368
|
+
try {
|
|
369
|
+
const j = JSON.parse(body);
|
|
370
|
+
if (!j.detail)
|
|
371
|
+
return null;
|
|
372
|
+
return j.code ? `(${j.code}) ${j.detail}` : j.detail;
|
|
373
|
+
}
|
|
374
|
+
catch {
|
|
375
|
+
return null;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
310
378
|
/**
|
|
311
379
|
* Clean one-line detail from a Zenrows JSON error body
|
|
312
380
|
* (e.g. `(AUTH003) Invalid apikey provided`).
|
|
@@ -9,6 +9,9 @@ export const AGENT_SIGNUP_API_URL = "https://app.zenrows.com/api/agent/signup";
|
|
|
9
9
|
/** Well-known path advertising the agent-auth endpoints (signup discovery). */
|
|
10
10
|
export const WELL_KNOWN_PROTECTED_RESOURCE = "/.well-known/oauth-protected-resource";
|
|
11
11
|
export const DASHBOARD_URL = "https://app.zenrows.com/dashboard";
|
|
12
|
+
/** Lands on Billing with the credit-pack purchase modal already open (app PR #1972). */
|
|
13
|
+
export const BILLING_TOPUP_URL = "https://app.zenrows.com/billing?topup=open";
|
|
14
|
+
export const PLANS_URL = "https://app.zenrows.com/plans";
|
|
12
15
|
export const DOCS_URL = "https://docs.zenrows.com";
|
|
13
16
|
/** Append query params to a URL (used to attach anonymous `utm_*` attribution). */
|
|
14
17
|
export function withParams(url, params) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zenrows/cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.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",
|
|
@@ -24,7 +24,21 @@ zenrows batch estimate jobs.jsonl # validate the spec + estimate credits
|
|
|
24
24
|
|
|
25
25
|
`jobs.jsonl` is one JSON object per line, each with a `url` (plus optional
|
|
26
26
|
per-task keys: `external_id`, `metadata`, and scrape params like `js_render`,
|
|
27
|
-
`premium_proxy`, `proxy_country`, `mode`, `autoparse`).
|
|
27
|
+
`premium_proxy`, `proxy_country`, `mode`, `autoparse`, `extract`).
|
|
28
|
+
|
|
29
|
+
## Extract in Batch
|
|
30
|
+
|
|
31
|
+
`"extract": "auto"` on a line runs that URL through Extract, returning
|
|
32
|
+
structured data instead of raw HTML.
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
{"url": "https://example.com/products", "extract": "auto", "external_id": "p1"}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
An Extract task costs the same as a regular one (1 credit at base tier), so
|
|
39
|
+
`batch estimate` prices it correctly. The result carries two keys: `html` (the
|
|
40
|
+
raw page) and `parsed` (the structured data). Validate on a single URL with
|
|
41
|
+
`zenrows extract <url>` before running the full batch.
|
|
28
42
|
|
|
29
43
|
## Cloud (needs a key + beta access)
|
|
30
44
|
```
|
|
@@ -40,6 +54,14 @@ zenrows batch retry-failed <id> # rerun only the failed tasks (new run)
|
|
|
40
54
|
`--premium-proxy` (or `mode=auto`) and is rejected before any request. Job-level
|
|
41
55
|
flags apply to every task; per-task keys in the JSONL override them.
|
|
42
56
|
|
|
57
|
+
A run ends in one of `completed`, `failed`, `stopped`, or `deleted`. `status`,
|
|
58
|
+
`wait`, and `create --wait` exit non-zero (`ok: false` under `--json`) only for
|
|
59
|
+
`failed`, and print the run's `failure_reason` and `failure_detail`. When
|
|
60
|
+
`failure_reason` is `api_key_cap_reached`, the key hit its own credit cap
|
|
61
|
+
(`KEY_CREDIT_CAP_REACHED`): the account still has credits, other keys keep
|
|
62
|
+
working, and the tasks not yet run stay pending. Wait for the cap to reset
|
|
63
|
+
(`zenrows usage`) or raise the cap; do not resubmit the job with the same key.
|
|
64
|
+
|
|
43
65
|
Deferred (documented, not yet in the CLI): CSV upload, open/queue jobs,
|
|
44
66
|
scheduled jobs, webhooks/HMAC, and ZIP export.
|
|
45
67
|
|
package/skills/extract/SKILL.md
CHANGED
|
@@ -28,7 +28,10 @@ zenrows extract <url> --validate # fail if not valid JSON
|
|
|
28
28
|
|
|
29
29
|
## Rules
|
|
30
30
|
- Validate on a single page before scaling across many URLs.
|
|
31
|
-
- If Autoparse misses fields, switch to `--css` with explicit selectors
|
|
32
|
-
|
|
31
|
+
- If Autoparse misses fields, switch to `--css` with explicit selectors. Auto
|
|
32
|
+
mode already renders JS when the target needs it, so reach for
|
|
33
|
+
`--manual --js-render` only after auto mode has returned an incomplete page:
|
|
34
|
+
it costs several times a basic call ([[cost-control]]) and it makes the
|
|
35
|
+
escalation yours.
|
|
33
36
|
|
|
34
37
|
See [[protected-fetch]] for retrieval semantics.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: protected-fetch
|
|
3
|
-
description: Use Zenrows Protected Fetch for anti-bot-protected page retrieval.
|
|
3
|
+
description: Use Zenrows Protected Fetch in auto mode for anti-bot-protected page retrieval. Auto mode handles the anti-bot escalation, so never enable JS rendering or premium proxies yourself on a first attempt.
|
|
4
4
|
version: 0.1.0
|
|
5
5
|
requires_backend_capabilities: [protected_fetch]
|
|
6
6
|
---
|
|
@@ -16,18 +16,33 @@ cannot. This is the **core primitive** — backed by Zenrows **Fetch**
|
|
|
16
16
|
- The target has anti-bot protection, needs JS rendering, or geo-specific access.
|
|
17
17
|
|
|
18
18
|
## How to call
|
|
19
|
+
|
|
20
|
+
Auto mode is the answer for anti-bot targets, Cloudflare included. It escalates
|
|
21
|
+
for you and bills only for the configuration that succeeds.
|
|
22
|
+
|
|
19
23
|
```
|
|
20
|
-
zenrows fetch <url> # Adaptive Stealth Mode
|
|
24
|
+
zenrows fetch <url> # Adaptive Stealth Mode. Start here, always
|
|
21
25
|
zenrows fetch <url> --output markdown # convert to Markdown
|
|
22
|
-
zenrows fetch <url> --
|
|
23
|
-
zenrows fetch <url> --proxy-country us # geo-target (auto mode; in manual mode needs --premium-proxy)
|
|
26
|
+
zenrows fetch <url> --proxy-country us # geo-target, works in auto mode
|
|
24
27
|
zenrows fetch <url> --wait-for ".price" # wait for a selector
|
|
25
28
|
```
|
|
26
29
|
|
|
30
|
+
Manual mode exists for the rare case where auto mode has already failed and a
|
|
31
|
+
trace shows why. It costs more and it makes the escalation your problem:
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
zenrows fetch <url> --manual --js-render --premium-proxy # the most expensive path, see [[cost-control]]
|
|
35
|
+
```
|
|
36
|
+
|
|
27
37
|
## Rules
|
|
28
|
-
- Start with **auto mode**.
|
|
29
|
-
|
|
30
|
-
|
|
38
|
+
- Start with **auto mode**. Enabling `--js-render` and `--premium-proxy` yourself
|
|
39
|
+
is the most expensive configuration this API offers, by a wide margin, and auto
|
|
40
|
+
mode reaches the same place only when the target actually needs it. The
|
|
41
|
+
multipliers are in [[cost-control]].
|
|
42
|
+
- A hard target is not a reason to skip auto mode. It is the reason auto mode
|
|
43
|
+
exists.
|
|
44
|
+
- In auto mode `js_render` and `premium_proxy` are managed for you. Passing them
|
|
45
|
+
manually requires `--manual`, otherwise you get `PARAM_CONFLICT_AUTO_MANUAL`.
|
|
31
46
|
- `--proxy-country` works in auto mode on its own; in `--manual` mode it also
|
|
32
47
|
needs `--premium-proxy` (else `PARAM_PROXY_COUNTRY_REQUIRES_PREMIUM`).
|
|
33
48
|
- You are billed only for the configuration that succeeds.
|
|
@@ -19,9 +19,31 @@ zenrows trace export <run-id> # JSON for sharing
|
|
|
19
19
|
```
|
|
20
20
|
|
|
21
21
|
## Failure → action map
|
|
22
|
-
- `FETCH_FAILED` / empty content → retry
|
|
23
|
-
|
|
22
|
+
- `FETCH_FAILED` / empty content → first retry in auto mode (`mode=auto`), which
|
|
23
|
+
escalates for you and bills only for what succeeds. For slow pages add
|
|
24
|
+
`--wait-for <selector>`. Only when auto mode has failed on its own, take
|
|
25
|
+
manual control with `--manual --js-render`, then `--premium-proxy`. Each step
|
|
26
|
+
multiplies the cost of the request, and both together are the most expensive
|
|
27
|
+
configuration available ([[cost-control]]).
|
|
28
|
+
- `REQUEST_TIMEOUT` → the CLI stopped waiting; the API was reached. Raise
|
|
29
|
+
`--timeout` (default 120000ms, above the API's own 90s budget), or drop
|
|
30
|
+
`--wait-for` so the request finishes inside that budget and the API returns
|
|
31
|
+
its own error. Not a connectivity problem — do not chase the network.
|
|
32
|
+
- `BACKEND_UNAVAILABLE` → a genuine transport failure (DNS/TCP/TLS). Check
|
|
33
|
+
connectivity and `zenrows config show`.
|
|
24
34
|
- `AUTH_INVALID` → re-check the key, `zenrows login --api-key …`.
|
|
35
|
+
- `KEY_CREDIT_CAP_REACHED` → this API key hit one of its own credit caps (HTTP
|
|
36
|
+
402, gateway code `AUTH014`, Batch `api_key_cap_reached`). The account still
|
|
37
|
+
has credits and its other keys keep working. Do not retry and do not escalate:
|
|
38
|
+
`zenrows usage` shows the cap and when it resets. Wait for the reset, or have
|
|
39
|
+
the account owner raise or remove the cap at
|
|
40
|
+
https://app.zenrows.com/settings/api-keys.
|
|
41
|
+
- `POLICY_MAX_CREDITS_EXCEEDED` → the account itself is out of credits (or a
|
|
42
|
+
local policy credit limit was hit). Do not retry-loop; `zenrows usage` shows
|
|
43
|
+
when credits renew. Top up or upgrade, or claim the account if it is an
|
|
44
|
+
auto-created Free plan.
|
|
45
|
+
- `BATCH_FAILED` → the batch run ended `failed`; `failure_reason` and
|
|
46
|
+
`failure_detail` in `zenrows batch status <id> --json` say why.
|
|
25
47
|
- `PARAM_CONFLICT_AUTO_MANUAL` → drop the managed flags or add `--manual`.
|
|
26
48
|
- `CAPABILITY_UNAVAILABLE` → the primitive is not available on this account (e.g. beta/invite-only); use the local-spec path where offered.
|
|
27
49
|
|
|
@@ -3,7 +3,12 @@
|
|
|
3
3
|
Scaffold for a high-scale workload expressed as a JSONL job spec, run on the
|
|
4
4
|
Zenrows **Batch**. One JSON object per line, each with a `url` (plus
|
|
5
5
|
optional per-line overrides like `js_render`, `premium_proxy`, `proxy_country`,
|
|
6
|
-
`mode`, `autoparse`, and an `external_id` echoed back on each
|
|
6
|
+
`mode`, `autoparse`, `extract`, and an `external_id` echoed back on each
|
|
7
|
+
result).
|
|
8
|
+
|
|
9
|
+
Set `"extract": "auto"` on a line to run that URL through Extract — structured
|
|
10
|
+
data instead of raw HTML, at the same credit cost as a regular task. Extract
|
|
11
|
+
results carry `html` and `parsed` keys.
|
|
7
12
|
|
|
8
13
|
## Local (no key)
|
|
9
14
|
|
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
{"url": "https://www.scrapingcourse.com/ecommerce/", "mode": "auto", "autoparse": true}
|
|
2
2
|
{"url": "https://httpbin.io/html"}
|
|
3
3
|
{"url": "https://example.com", "js_render": true, "premium_proxy": true}
|
|
4
|
+
{"url": "https://www.scrapingcourse.com/ecommerce/", "extract": "auto", "external_id": "extract-demo"}
|