@zenrows/cli 1.6.2 → 1.8.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 +67 -11
- package/agent-plugin/skills/batch-jobs/SKILL.md +16 -3
- package/agent-plugin/skills/zenrows/SKILL.md +2 -1
- package/dist/src/adapters/batch.js +237 -59
- package/dist/src/cli/commands/account.js +37 -27
- package/dist/src/cli/commands/batch.js +667 -125
- package/dist/src/cli/commands/browser.js +4 -1
- package/dist/src/cli/commands/crawl.js +6 -3
- package/dist/src/cli/commands/extract.js +4 -1
- package/dist/src/cli/commands/fetch.js +4 -1
- package/dist/src/cli/commands/signup.js +3 -2
- package/dist/src/cli/index.js +2 -1
- package/dist/src/core/agent-account.js +111 -14
- package/dist/src/core/auth.js +4 -10
- package/dist/src/core/batch-api.js +400 -146
- package/dist/src/core/browser-api.js +2 -1
- package/dist/src/core/claim-link.js +237 -0
- package/dist/src/core/config.js +1 -1
- package/dist/src/core/crawl-api.js +2 -1
- package/dist/src/core/ensure-key.js +1 -0
- package/dist/src/core/errors.js +27 -11
- package/dist/src/core/http.js +4 -3
- package/dist/src/core/nudge.js +3 -1
- package/dist/src/core/policy.js +13 -6
- package/dist/src/core/provenance.js +10 -2
- package/dist/src/core/usage.js +2 -1
- package/dist/src/core/workspace.js +29 -1
- package/package.json +1 -1
- package/recipes/batch-estimate/RECIPE.md +6 -3
- package/skills/batch-jobs/SKILL.md +160 -43
- package/skills/crawl/SKILL.md +1 -1
- package/skills/trace-debug/SKILL.md +17 -2
- package/skills/zenrows/SKILL.md +6 -3
- package/templates/batch-jsonl-pipeline/README.md +10 -5
|
@@ -4,19 +4,29 @@
|
|
|
4
4
|
* A SEPARATE host from Fetch and Extract (`api.zenrows.com/v1`): batch
|
|
5
5
|
* jobs live at `https://async.api.zenrows.com/v1`. Auth is the `X-API-Key`
|
|
6
6
|
* header (same key as Fetch and Extract), never the `apikey` query param. Bodies
|
|
7
|
-
* are JSON; errors come back as `application/problem+json` (RFC
|
|
8
|
-
* stable `code` we branch on
|
|
9
|
-
* redacted from any logged output. `fetchImpl` is
|
|
10
|
-
* `usage.ts` / `agent-account.ts`.
|
|
7
|
+
* are JSON; errors come back as `application/problem+json` (RFC 9457) with a
|
|
8
|
+
* stable `code` we branch on and carry as `server_code`. The key is registered
|
|
9
|
+
* as a secret so it is redacted from any logged output. `fetchImpl` is
|
|
10
|
+
* injectable for tests, mirroring `usage.ts` / `agent-account.ts`.
|
|
11
11
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
12
|
+
* Retries: reads (GET, DELETE) retry 429/502/503/504 and connection errors;
|
|
13
|
+
* stop, pause and resume retry 502/503/504 and connection errors, since
|
|
14
|
+
* repeating them is harmless; a submit or rerun retries the same way only
|
|
15
|
+
* under its `Idempotency-Key`, which `createJob` and `rerunJob` always send and
|
|
16
|
+
* reuse across the retries of one call. The API keeps a key for 24 hours and
|
|
17
|
+
* replays the original answer to a repeat with the same body, so a retry never
|
|
18
|
+
* starts a second job or run. A keyed write that still has no answer after its
|
|
19
|
+
* retries fails with `idempotency_key` set: repeating it with that key is safe.
|
|
20
|
+
*
|
|
21
|
+
* Not in the CLI (the API and the SDKs have them): CSV inputs, open jobs and
|
|
22
|
+
* add-tasks, scheduled jobs, webhooks and HMAC keys, ZIP exports, the runs
|
|
23
|
+
* list, and task history.
|
|
16
24
|
*/
|
|
17
25
|
import { mkdirSync, writeFileSync } from "node:fs";
|
|
18
26
|
import { join } from "node:path";
|
|
27
|
+
import { randomUUID } from "node:crypto";
|
|
19
28
|
import { ToolkitError, isKeyCapReached, keyCapReached, quotaExhausted } from "./errors.js";
|
|
29
|
+
import { storedClaimHint } from "./claim-link.js";
|
|
20
30
|
import { readAccount } from "./agent-account.js";
|
|
21
31
|
import { registerSecret } from "./logger.js";
|
|
22
32
|
import { CLI_USER_AGENT } from "./config.js";
|
|
@@ -32,193 +42,413 @@ export function batchBase() {
|
|
|
32
42
|
const base = env && env.trim() ? env.trim() : DEFAULT_BATCH_API_BASE;
|
|
33
43
|
return base.replace(/\/+$/, "");
|
|
34
44
|
}
|
|
35
|
-
/** Terminal run states
|
|
45
|
+
/** Terminal run states: a run stops progressing once it reaches one of these. */
|
|
36
46
|
export const TERMINAL_STATUSES = new Set(["completed", "stopped", "failed", "deleted"]);
|
|
47
|
+
/** Task statuses a rerun can select (`?status=` on `/rerun`). */
|
|
48
|
+
export const RERUN_STATUSES = ["pending", "processing", "failed", "successful"];
|
|
49
|
+
/** True when the run reached a terminal state. */
|
|
50
|
+
export function isTerminal(run) {
|
|
51
|
+
return !!run && TERMINAL_STATUSES.has(run.status);
|
|
52
|
+
}
|
|
53
|
+
/** True when a live run is paused: it picks up no new task until resumed. */
|
|
54
|
+
export function isPaused(run) {
|
|
55
|
+
return run?.pause_state === "paused" && !isTerminal(run);
|
|
56
|
+
}
|
|
57
|
+
const MAX_RETRIES = 3;
|
|
58
|
+
/** The longest a Retry-After may make one retry wait, so a large value cannot hang a command. */
|
|
59
|
+
const MAX_RETRY_AFTER_SECONDS = 30;
|
|
60
|
+
const READ_RETRY_STATUSES = new Set([429, 502, 503, 504]);
|
|
61
|
+
// 429 on a write is an account limit (active jobs): a retry only waits for a slot that may never free.
|
|
62
|
+
const WRITE_RETRY_STATUSES = new Set([502, 503, 504]);
|
|
63
|
+
/** 250 ms x 2^attempt, capped at 10 s, +/-20% jitter. */
|
|
64
|
+
function backoffMs(attempt) {
|
|
65
|
+
return Math.min(250 * 2 ** attempt, 10_000) * (0.8 + Math.random() * 0.4);
|
|
66
|
+
}
|
|
67
|
+
/** `Retry-After` in whole seconds, as milliseconds, at most `MAX_RETRY_AFTER_SECONDS`. */
|
|
68
|
+
function retryAfterMs(value) {
|
|
69
|
+
return value && /^\d+$/.test(value.trim()) ? Math.min(Number(value), MAX_RETRY_AFTER_SECONDS) * 1000 : undefined;
|
|
70
|
+
}
|
|
71
|
+
const defaultSleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
72
|
+
/** A fresh Idempotency-Key for one logical submit or rerun. */
|
|
73
|
+
export function newIdempotencyKey() {
|
|
74
|
+
return randomUUID();
|
|
75
|
+
}
|
|
76
|
+
/** The longest Idempotency-Key the Batch API accepts. */
|
|
77
|
+
export const MAX_IDEMPOTENCY_KEY_LENGTH = 128;
|
|
37
78
|
/**
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
* (204 / no-body) responses.
|
|
79
|
+
* Throw before any request if `key` cannot work as an Idempotency-Key: HTTP
|
|
80
|
+
* strips a header's surrounding whitespace, so a blank or padded key would
|
|
81
|
+
* reach the API as a different key, and control characters cannot be sent.
|
|
42
82
|
*/
|
|
43
|
-
export
|
|
83
|
+
export function assertIdempotencyKey(key) {
|
|
84
|
+
let reason;
|
|
85
|
+
if (key.trim() === "")
|
|
86
|
+
reason = "it is empty";
|
|
87
|
+
else if (key !== key.trim())
|
|
88
|
+
reason = "it starts or ends with whitespace";
|
|
89
|
+
else if (/[\u0000-\u001f\u007f]/.test(key))
|
|
90
|
+
reason = "it contains a control character";
|
|
91
|
+
else if (key.length > MAX_IDEMPOTENCY_KEY_LENGTH)
|
|
92
|
+
reason = `it is longer than ${MAX_IDEMPOTENCY_KEY_LENGTH} characters`;
|
|
93
|
+
if (!reason)
|
|
94
|
+
return;
|
|
95
|
+
throw new ToolkitError({
|
|
96
|
+
code: "INVALID_USAGE",
|
|
97
|
+
message: `Invalid --idempotency-key: ${reason}.`,
|
|
98
|
+
likely_cause: `An Idempotency-Key is 1 to ${MAX_IDEMPOTENCY_KEY_LENGTH} characters, with no surrounding whitespace and no control characters.`,
|
|
99
|
+
next_action: "Pass a key such as a UUID, or leave --idempotency-key out to have one generated.",
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
/** Errors a poll may ride out: the API was unreachable or answered 429/5xx. */
|
|
103
|
+
const transientErrors = new WeakSet();
|
|
104
|
+
/** True for an error worth polling past (a connection failure, a 429 or a 5xx). */
|
|
105
|
+
export function isTransient(err) {
|
|
106
|
+
return err instanceof ToolkitError && transientErrors.has(err);
|
|
107
|
+
}
|
|
108
|
+
function transient(err) {
|
|
109
|
+
transientErrors.add(err);
|
|
110
|
+
return err;
|
|
111
|
+
}
|
|
112
|
+
/** One HTTP attempt with its own timeout; the body is read inside it. */
|
|
113
|
+
async function attempt(url, init, opts) {
|
|
114
|
+
const controller = new AbortController();
|
|
115
|
+
const perAttempt = opts.timeoutMs ?? 60_000;
|
|
116
|
+
const ms = opts.deadline !== undefined ? Math.max(1, Math.min(perAttempt, opts.deadline - Date.now())) : perAttempt;
|
|
117
|
+
const timeout = setTimeout(() => controller.abort(), ms);
|
|
118
|
+
try {
|
|
119
|
+
const res = await (opts.fetchImpl ?? fetch)(url, { ...init, signal: controller.signal });
|
|
120
|
+
return { status: res.status, headers: res.headers, body: Buffer.from(await res.arrayBuffer()) };
|
|
121
|
+
}
|
|
122
|
+
finally {
|
|
123
|
+
clearTimeout(timeout);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Perform a Batch API request and return the raw response. Sets `X-API-Key`,
|
|
128
|
+
* adds a JSON content-type when a body is present, retries transient failures
|
|
129
|
+
* as `opts.retry` allows, and on a non-2xx parses the problem+json body into a
|
|
130
|
+
* normalized ToolkitError.
|
|
131
|
+
*/
|
|
132
|
+
async function batchFetch(method, path, opts) {
|
|
44
133
|
registerSecret(opts.apiKey);
|
|
45
134
|
const url = new URL(batchBase() + path);
|
|
46
135
|
for (const [k, v] of Object.entries(opts.query ?? {})) {
|
|
47
136
|
if (v !== undefined && v !== null)
|
|
48
|
-
url.searchParams.set(k, v);
|
|
137
|
+
url.searchParams.set(k, String(v));
|
|
49
138
|
}
|
|
50
|
-
const doFetch = opts.fetchImpl ?? fetch;
|
|
51
|
-
const controller = new AbortController();
|
|
52
|
-
const timeout = setTimeout(() => controller.abort(), opts.timeoutMs ?? 60_000);
|
|
53
139
|
const headers = {
|
|
54
140
|
"X-API-Key": opts.apiKey,
|
|
55
141
|
Accept: "application/json",
|
|
56
142
|
"User-Agent": CLI_USER_AGENT,
|
|
57
143
|
...agentClientHeader(),
|
|
58
144
|
};
|
|
145
|
+
if (opts.idempotencyKey)
|
|
146
|
+
headers["Idempotency-Key"] = opts.idempotencyKey;
|
|
59
147
|
if (opts.body !== undefined)
|
|
60
148
|
headers["Content-Type"] = "application/json";
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
149
|
+
const init = { method, headers, body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined };
|
|
150
|
+
const retryOn = opts.retry === "read" ? READ_RETRY_STATUSES : opts.retry === "write" ? WRITE_RETRY_STATUSES : undefined;
|
|
151
|
+
const sleep = opts.sleepImpl ?? defaultSleep;
|
|
152
|
+
const key = opts.idempotencyKey;
|
|
153
|
+
// Set once an attempt of a keyed write got no answer: the API may have done the work.
|
|
154
|
+
let mayHaveLanded = false;
|
|
155
|
+
// A retry that would sleep past the call's deadline is not started.
|
|
156
|
+
const fits = (waitMs) => opts.deadline === undefined || Date.now() + waitMs < opts.deadline;
|
|
157
|
+
for (let n = 0;; n++) {
|
|
158
|
+
const canRetry = retryOn !== undefined && n < MAX_RETRIES;
|
|
159
|
+
let res;
|
|
160
|
+
try {
|
|
161
|
+
res = await attempt(url.toString(), init, opts);
|
|
162
|
+
}
|
|
163
|
+
catch (err) {
|
|
164
|
+
const wait = backoffMs(n);
|
|
165
|
+
if (canRetry && fits(wait)) {
|
|
166
|
+
mayHaveLanded = true;
|
|
167
|
+
await sleep(wait);
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
if (key)
|
|
171
|
+
mayHaveLanded = true;
|
|
172
|
+
const failure = transient(new ToolkitError({
|
|
173
|
+
code: "BACKEND_UNAVAILABLE",
|
|
174
|
+
message: "Could not reach the Zenrows Batch API.",
|
|
175
|
+
likely_cause: err instanceof Error ? err.message : String(err),
|
|
176
|
+
next_action: "Check connectivity and retry. Override the host with ZENROWS_BATCH_API_BASE if you are testing against staging.",
|
|
177
|
+
suggested_commands: ["zenrows status"],
|
|
178
|
+
...(opts.jobId ? { job_id: opts.jobId } : {}),
|
|
179
|
+
}));
|
|
180
|
+
throw key ? outcomeUnknown(failure, key) : failure;
|
|
181
|
+
}
|
|
182
|
+
if (canRetry && retryOn.has(res.status)) {
|
|
183
|
+
const wait = retryAfterMs(res.headers.get("retry-after")) ?? backoffMs(n);
|
|
184
|
+
if (fits(wait)) {
|
|
185
|
+
if (res.status >= 500)
|
|
186
|
+
mayHaveLanded = true;
|
|
187
|
+
await sleep(wait);
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
if (res.status < 200 || res.status >= 300) {
|
|
192
|
+
let failure = problemToError(res.status, res.body.toString("utf8"), method, path, opts.jobId);
|
|
193
|
+
if (res.status === 429 || res.status >= 500)
|
|
194
|
+
failure = transient(failure);
|
|
195
|
+
if (key && res.status >= 500)
|
|
196
|
+
throw outcomeUnknown(failure, key);
|
|
197
|
+
// An earlier attempt may have been accepted, and the API checks credit
|
|
198
|
+
// (402) and other gates before it looks at the key: whatever this attempt
|
|
199
|
+
// answers, the safe next step is still to repeat with the same key.
|
|
200
|
+
if (key && mayHaveLanded)
|
|
201
|
+
throw outcomeUnknown(failure, key, { keepAdvice: true });
|
|
202
|
+
throw failure;
|
|
203
|
+
}
|
|
204
|
+
return res;
|
|
84
205
|
}
|
|
206
|
+
}
|
|
207
|
+
/** `batchFetch` + JSON parse. Returns `null` for an empty body. */
|
|
208
|
+
export async function batchRequest(method, path, opts) {
|
|
209
|
+
return batchJson(method, path, opts);
|
|
210
|
+
}
|
|
211
|
+
async function batchJson(method, path, opts) {
|
|
212
|
+
const { status, body } = await batchFetch(method, path, opts);
|
|
213
|
+
const text = body.toString("utf8");
|
|
85
214
|
if (!text)
|
|
86
215
|
return null;
|
|
87
216
|
try {
|
|
88
217
|
return JSON.parse(text);
|
|
89
218
|
}
|
|
90
219
|
catch {
|
|
91
|
-
|
|
220
|
+
const failure = new ToolkitError({
|
|
92
221
|
code: "BATCH_FAILED",
|
|
93
|
-
message:
|
|
222
|
+
message: `The Batch API answered ${method} ${path} (HTTP ${status}) with a body that is not JSON.`,
|
|
94
223
|
likely_cause: text.slice(0, 240),
|
|
95
|
-
next_action: "Retry, or
|
|
224
|
+
next_action: "Retry, or check the job with `zenrows batch list`.",
|
|
225
|
+
...(opts.jobId ? { job_id: opts.jobId } : {}),
|
|
96
226
|
});
|
|
227
|
+
throw opts.idempotencyKey ? outcomeUnknown(failure, opts.idempotencyKey) : failure;
|
|
97
228
|
}
|
|
98
229
|
}
|
|
99
|
-
/**
|
|
100
|
-
|
|
230
|
+
/**
|
|
231
|
+
* A keyed write (submit or rerun) whose outcome is unknown: the API may have
|
|
232
|
+
* done the work although no usable answer arrived. Carry the key and say what
|
|
233
|
+
* is safe; the command layer adds the exact command to repeat.
|
|
234
|
+
*/
|
|
235
|
+
function outcomeUnknown(err, key, opts = {}) {
|
|
236
|
+
const lead = opts.keepAdvice
|
|
237
|
+
? `${err.next_action.replace(/[.\s]*$/, ".")} An earlier attempt of this request got no answer and may have been accepted. Once that is resolved, repeat it with the same Idempotency-Key (${key}) `
|
|
238
|
+
: `The request may have been accepted even though no usable answer arrived. Repeat it with the same Idempotency-Key (${key}) `;
|
|
239
|
+
const out = new ToolkitError({
|
|
240
|
+
...err.toJSON(),
|
|
241
|
+
idempotency_key: key,
|
|
242
|
+
next_action: lead +
|
|
243
|
+
"within 24 hours: the API then answers with the original job or run instead of creating a second one. " +
|
|
244
|
+
"Do not repeat it with a new key, which can create a duplicate. Check `zenrows batch list` first.",
|
|
245
|
+
});
|
|
246
|
+
return isTransient(err) ? transient(out) : out;
|
|
247
|
+
}
|
|
248
|
+
/** `s` without a trailing period, so sentences can be joined. */
|
|
249
|
+
function trimDot(s) {
|
|
250
|
+
return s.replace(/[.\s]+$/, "");
|
|
251
|
+
}
|
|
252
|
+
/** The error for an account whose subscription is not active (401 subscription_inactive, 402 expired). */
|
|
253
|
+
export function subscriptionInactive(cause) {
|
|
254
|
+
// Same AUTH_INVALID code as any 401 so callers that branch on it keep
|
|
255
|
+
// working; only the guidance changes, since re-entering the key won't help.
|
|
256
|
+
return new ToolkitError({
|
|
257
|
+
code: "AUTH_INVALID",
|
|
258
|
+
message: "Zenrows refused the Batch API request because the subscription is not active.",
|
|
259
|
+
likely_cause: `${trimDot(cause)}. The API key is valid, but the subscription is expired, past due, unpaid, incomplete or paused.`,
|
|
260
|
+
next_action: `Fix the subscription in Billing (${BILLING_URL}), or in Azure Marketplace if you subscribed there, then retry.`,
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
/** Map a problem+json body + HTTP status to a normalized ToolkitError carrying `server_code` and `job_id`. */
|
|
264
|
+
function problemToError(status, text, method, path, jobId) {
|
|
101
265
|
let problem = {};
|
|
102
266
|
try {
|
|
103
|
-
problem = JSON.parse(
|
|
267
|
+
problem = JSON.parse(text);
|
|
104
268
|
}
|
|
105
269
|
catch {
|
|
106
|
-
// non-JSON error body
|
|
270
|
+
// non-JSON error body: fall through with an empty problem
|
|
107
271
|
}
|
|
108
|
-
const serverCode = problem.code
|
|
109
|
-
const detail = problem.detail || problem.title ||
|
|
110
|
-
const cause = `HTTP ${status}${serverCode ? ` (${serverCode})` : ""} for ${method} ${path}: ${detail}
|
|
272
|
+
const serverCode = problem.code || undefined;
|
|
273
|
+
const detail = trimDot(problem.detail || problem.title || text.slice(0, 240) || `HTTP ${status}`);
|
|
274
|
+
const cause = `HTTP ${status}${serverCode ? ` (${serverCode})` : ""} for ${method} ${path}: ${detail}.`;
|
|
275
|
+
const err = classifyProblem(status, problem, detail, cause, serverCode, method, path, jobId ?? "<id>");
|
|
276
|
+
return new ToolkitError({
|
|
277
|
+
...err.toJSON(),
|
|
278
|
+
...(serverCode ? { server_code: serverCode } : {}),
|
|
279
|
+
...(jobId ? { job_id: jobId } : {}),
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
function classifyProblem(status, problem, detail, cause, serverCode, method, path, id) {
|
|
283
|
+
const err = (code, message, next_action, suggested_commands, likely = cause) => new ToolkitError({ code, message, likely_cause: likely, next_action, suggested_commands });
|
|
111
284
|
if (status === 403) {
|
|
112
|
-
return
|
|
113
|
-
code: "BATCH_ACCESS_DENIED",
|
|
114
|
-
message: "The Batch API rejected this request (access denied).",
|
|
115
|
-
likely_cause: `${cause}. Batch is not enabled for this account or API key.`,
|
|
116
|
-
next_action: "Check that your Zenrows API key is active, and contact Zenrows support if the 403 persists. Meanwhile validate/estimate specs locally and fan out with `zenrows fetch` per URL.",
|
|
117
|
-
suggested_commands: ["zenrows batch estimate jobs.jsonl"],
|
|
118
|
-
});
|
|
119
|
-
}
|
|
120
|
-
if (status === 401 && serverCode === "subscription_inactive") {
|
|
121
|
-
// The key is fine; the subscription needs a billing action. Same
|
|
122
|
-
// AUTH_INVALID code as any 401 so callers that branch on it keep
|
|
123
|
-
// working; only the guidance changes, since re-entering the key won't help.
|
|
124
|
-
return new ToolkitError({
|
|
125
|
-
code: "AUTH_INVALID",
|
|
126
|
-
message: "Zenrows refused the Batch API request because the subscription is not active.",
|
|
127
|
-
likely_cause: `${cause.replace(/\.?$/, ".")} The API key is valid, but the subscription is past due, unpaid, incomplete or paused.`,
|
|
128
|
-
next_action: `Fix the subscription in Billing (${BILLING_URL}), or in Azure Marketplace if you subscribed there, then retry.`,
|
|
129
|
-
});
|
|
285
|
+
return err("BATCH_ACCESS_DENIED", "The Batch API rejected this request (access denied).", "Check that your Zenrows API key is active, and contact Zenrows support if the 403 persists. Meanwhile validate/estimate specs locally and fan out with `zenrows fetch` per URL.", ["zenrows batch estimate jobs.jsonl"], `${cause} Batch is not enabled for this account or API key.`);
|
|
130
286
|
}
|
|
287
|
+
if (status === 401 && serverCode === "subscription_inactive")
|
|
288
|
+
return subscriptionInactive(cause);
|
|
131
289
|
if (status === 401) {
|
|
132
|
-
return
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
likely_cause: cause,
|
|
136
|
-
next_action: "Re-check your key and log in again.",
|
|
137
|
-
suggested_commands: ["zenrows login --api-key <your-key>"],
|
|
138
|
-
});
|
|
290
|
+
return err("AUTH_INVALID", "Zenrows rejected the API key for the Batch API.", "Re-check your key and log in again.", [
|
|
291
|
+
"zenrows login --api-key <your-key>",
|
|
292
|
+
]);
|
|
139
293
|
}
|
|
140
294
|
if (status === 404) {
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
next_action: "Check the job id, or list your recent jobs in the Zenrows dashboard.",
|
|
146
|
-
});
|
|
295
|
+
const task = /\/tasks\//.test(path);
|
|
296
|
+
return err("BATCH_NOT_FOUND", task ? "Batch job, run or task not found." : "Batch job or run not found.", task
|
|
297
|
+
? `Check the ids. List the job's task ids with \`zenrows batch results ${id}\`.`
|
|
298
|
+
: "Check the job id, or list your jobs with `zenrows batch list`.", [task ? `zenrows batch results ${id}` : "zenrows batch list"], `${cause} The id may be wrong, the job deleted, or not owned by this account.`);
|
|
147
299
|
}
|
|
148
300
|
if (status === 429) {
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
});
|
|
301
|
+
if (/scheduled job/i.test(detail)) {
|
|
302
|
+
return err("BATCH_QUOTA_EXCEEDED", "The account has reached its limit of scheduled jobs.", "Nothing was created. A scheduled job holds its slot until it is deleted, whatever its runs do: delete one you no longer need, then retry.", ["zenrows batch list --type scheduled", "zenrows batch delete <id> --yes"], `${cause} The account holds 5 scheduled jobs at most by default.`);
|
|
303
|
+
}
|
|
304
|
+
if (/task cap/i.test(detail)) {
|
|
305
|
+
return err("BATCH_QUOTA_EXCEEDED", "The open job has reached its task cap.", "Nothing was added. An open job holds 100,000 tasks at most: close it and start a new job for the rest.");
|
|
306
|
+
}
|
|
307
|
+
return err("BATCH_QUOTA_EXCEEDED", "The account has reached its limit of active jobs.", "Nothing was created. Active jobs are shared with Crawl. A closed job frees its slot when its run ends: wait for one (`zenrows batch wait <id>`) or stop it (`zenrows batch cancel <id>`). An open or scheduled job frees it only when deleted (`zenrows batch delete <id> --yes`). Then retry.", ["zenrows batch list", "zenrows crawl list"], `${cause} The account holds 3 active jobs at most by default, Batch and Crawl together.`);
|
|
156
308
|
}
|
|
157
309
|
if (status === 402 && isKeyCapReached(serverCode)) {
|
|
158
310
|
return keyCapReached(`${method} ${path}`, { status: 402, detail: problem.detail || problem.title || undefined });
|
|
159
311
|
}
|
|
312
|
+
if (status === 402 && /subscription has expired/i.test(detail))
|
|
313
|
+
return subscriptionInactive(cause);
|
|
160
314
|
if (status === 402) {
|
|
161
|
-
// Out of credits ("Subscription has no credit available")
|
|
162
|
-
// exhausted state as the scraper/usage 402
|
|
163
|
-
//
|
|
164
|
-
// generic "fix the problem and retry".
|
|
315
|
+
// Out of credits ("Subscription has no credit available"): the same
|
|
316
|
+
// exhausted state as the scraper/usage 402, with a claim link (unclaimed
|
|
317
|
+
// Free plan) or dashboard/upgrade pointer.
|
|
165
318
|
const acct = readAccount();
|
|
166
|
-
return quotaExhausted(`${method} ${path}`, acct
|
|
319
|
+
return quotaExhausted(`${method} ${path}`, storedClaimHint(acct), {
|
|
167
320
|
status: 402,
|
|
168
321
|
detail: problem.detail || problem.title || undefined,
|
|
169
322
|
});
|
|
170
323
|
}
|
|
171
|
-
|
|
324
|
+
if (status === 409)
|
|
325
|
+
return conflictError(serverCode, detail, cause, id);
|
|
326
|
+
if (status === 410) {
|
|
327
|
+
return err("BATCH_RESULT_EXPIRED", "This result has expired.", `Results are kept 14 days by default; after that a task reads as expired and its body is gone. Scrape it again with a full rerun: \`zenrows batch rerun ${id} --all\`.`, [`zenrows batch rerun ${id} --all`]);
|
|
328
|
+
}
|
|
329
|
+
if (status === 422 && /\/content$/.test(path)) {
|
|
330
|
+
return err("BATCH_TASK_FAILED", "This task failed, so it has no content.", `Read why in its result row (\`zenrows batch results ${id} --status failed\`), then rerun the failures with \`zenrows batch retry-failed ${id}\`.`, [`zenrows batch results ${id} --status failed`]);
|
|
331
|
+
}
|
|
332
|
+
// 400 invalid_argument, 501, 5xx, etc.
|
|
172
333
|
const invalid = problem.invalid_tasks?.length
|
|
173
334
|
? ` invalid_tasks: ${problem.invalid_tasks
|
|
174
335
|
.slice(0, 10)
|
|
175
|
-
.map((t) => `#${t.index}: ${t.reason}`)
|
|
336
|
+
.map((t) => `#${t.index}: ${t.reason}${t.value ? ` (${t.value})` : ""}`)
|
|
176
337
|
.join("; ")}`
|
|
177
338
|
: "";
|
|
178
|
-
return
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
? "This is transient — retry with a short backoff."
|
|
184
|
-
: "Fix the reported problem and retry. For validation errors, correct the flagged tasks.",
|
|
185
|
-
});
|
|
339
|
+
return err("BATCH_FAILED", `Batch request failed (HTTP ${status}).`, status === 501
|
|
340
|
+
? "This operation is not available for this job."
|
|
341
|
+
: status >= 500
|
|
342
|
+
? "This is transient: retry with a short backoff."
|
|
343
|
+
: "Fix the reported problem and retry. For validation errors, correct the flagged tasks (#n is the task's 0-based position in the submit).", undefined, `${cause}${invalid}`);
|
|
186
344
|
}
|
|
187
|
-
/**
|
|
345
|
+
/** One code and next action per 409 the Batch API answers. */
|
|
346
|
+
function conflictError(serverCode, detail, cause, id) {
|
|
347
|
+
const e = (code, message, next_action, suggested_commands = [`zenrows batch status ${id}`]) => new ToolkitError({ code, message, likely_cause: cause, next_action, suggested_commands });
|
|
348
|
+
switch (serverCode) {
|
|
349
|
+
case "run_not_terminal":
|
|
350
|
+
if (/ingestion did not complete/i.test(detail)) {
|
|
351
|
+
return e("BATCH_RUN_IN_PROGRESS", "The job's last run never finished receiving its tasks.", `It cannot be rerun. Delete the job (\`zenrows batch delete ${id} --yes\`) and submit the URLs again as a new job.`);
|
|
352
|
+
}
|
|
353
|
+
return e("BATCH_RUN_IN_PROGRESS", "The job's current run is still in progress.", `Wait for it with \`zenrows batch wait ${id}\`, or stop it with \`zenrows batch cancel ${id}\`, then retry. A large submit or a rerun also reads as in progress while it is still receiving its tasks.`, [`zenrows batch wait ${id}`]);
|
|
354
|
+
case "run_not_stoppable":
|
|
355
|
+
return e("BATCH_RUN_ENDED", "The job's run already ended.", `Only a pending or running run can be stopped, paused or resumed. Start a new run with \`zenrows batch rerun ${id}\`.`);
|
|
356
|
+
case "no_matching_tasks":
|
|
357
|
+
return e("BATCH_NO_MATCHING_TASKS", "No task of the last run has the statuses you asked to rerun.", `Tasks a stop or a credit failure left unfinished are pending or processing: rerun them with \`zenrows batch rerun ${id} --status failed,pending,processing\`, or rerun every task with \`zenrows batch rerun ${id} --all\`.`, [`zenrows batch rerun ${id} --status failed,pending,processing`]);
|
|
358
|
+
case "partial_rerun_window_expired":
|
|
359
|
+
return e("BATCH_RERUN_WINDOW_EXPIRED", "Rerunning only some tasks is no longer allowed for this run.", `A partial rerun (the default, or --status) works for 10 days after the run started. Rerun every task with \`zenrows batch rerun ${id} --all\` instead.`, [`zenrows batch rerun ${id} --all`]);
|
|
360
|
+
case "idempotency_key_conflict":
|
|
361
|
+
return e("BATCH_REQUEST_IN_FLIGHT", "This Idempotency-Key is already in use.", "Either the first request with this key is still being processed, or the key was used for a different request body. Wait a moment and repeat the same command with the same key; use a new key only for a deliberately different request.", ["zenrows batch list"]);
|
|
362
|
+
case "task_not_ready":
|
|
363
|
+
return e("BATCH_TASK_NOT_READY", "This task has not finished yet.", `Wait for the run with \`zenrows batch wait ${id}\`, then read the task again.`, [`zenrows batch wait ${id}`]);
|
|
364
|
+
case "conflict":
|
|
365
|
+
if (/no longer valid/i.test(detail)) {
|
|
366
|
+
return e("BATCH_CONFLICT", "The API key that created this job no longer works.", "The job cannot run again. Submit its URLs as a new job.", []);
|
|
367
|
+
}
|
|
368
|
+
return e("BATCH_CONFLICT", "The job changed while the request ran.", "Retry the same command.");
|
|
369
|
+
default:
|
|
370
|
+
return e("BATCH_CONFLICT", "The job's state does not allow this request.", `Check the job with \`zenrows batch status ${id}\`; the cause above says what blocked it.`);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
const jobPath = (id, rest = "") => `/jobs/${encodeURIComponent(id)}${rest}`;
|
|
374
|
+
/**
|
|
375
|
+
* Create a job. Both 201 (tasks stored) and 202 (a large submit, still
|
|
376
|
+
* receiving its tasks: `latest_run.ingest_status` is `pending`) are success.
|
|
377
|
+
*/
|
|
188
378
|
export function createJob(body, opts) {
|
|
189
|
-
|
|
379
|
+
const idempotencyKey = opts.idempotencyKey ?? newIdempotencyKey();
|
|
380
|
+
assertIdempotencyKey(idempotencyKey);
|
|
381
|
+
return batchJson("POST", "/jobs", { ...opts, body, idempotencyKey, retry: "write" });
|
|
190
382
|
}
|
|
191
383
|
/** Read a job (`latest_run.status` + `latest_run.stats`). */
|
|
192
384
|
export function getJob(id, opts) {
|
|
193
|
-
return
|
|
385
|
+
return batchJson("GET", jobPath(id), { ...opts, retry: "read", jobId: id });
|
|
386
|
+
}
|
|
387
|
+
/** Read one run of a job. */
|
|
388
|
+
export function getRun(id, runId, opts) {
|
|
389
|
+
return batchJson("GET", jobPath(id, `/runs/${encodeURIComponent(runId)}`), { ...opts, retry: "read", jobId: id });
|
|
194
390
|
}
|
|
195
|
-
/**
|
|
391
|
+
/** One page of the account's jobs, newest first. */
|
|
392
|
+
export async function listJobs(opts) {
|
|
393
|
+
const page = await batchJson("GET", "/jobs", {
|
|
394
|
+
...opts,
|
|
395
|
+
retry: "read",
|
|
396
|
+
query: { limit: opts.limit, cursor: opts.cursor, status: opts.status, type: opts.type },
|
|
397
|
+
});
|
|
398
|
+
return { jobs: page?.jobs ?? [], next_cursor: page?.next_cursor || null };
|
|
399
|
+
}
|
|
400
|
+
/** Stop the job's in-flight run. The API answers with the refreshed run, not the job. */
|
|
196
401
|
export function stopJob(id, opts) {
|
|
197
|
-
return
|
|
402
|
+
return batchJson("POST", jobPath(id, "/stop"), { ...opts, retry: "write", jobId: id });
|
|
403
|
+
}
|
|
404
|
+
/** Pause the job's in-flight run: no new task is picked up until it is resumed. Answers with the run. */
|
|
405
|
+
export function pauseJob(id, opts) {
|
|
406
|
+
return batchJson("POST", jobPath(id, "/pause"), { ...opts, retry: "write", jobId: id });
|
|
407
|
+
}
|
|
408
|
+
/** Resume a paused run. Needs credit on the job's API key. Answers with the run. */
|
|
409
|
+
export function resumeJob(id, opts) {
|
|
410
|
+
return batchJson("POST", jobPath(id, "/resume"), { ...opts, retry: "write", jobId: id });
|
|
198
411
|
}
|
|
199
412
|
/**
|
|
200
|
-
*
|
|
201
|
-
*
|
|
202
|
-
|
|
413
|
+
* Delete a job, its runs and its stored results (202, asynchronous). Stops an
|
|
414
|
+
* in-flight run and frees the job's slot. Repeating it is harmless.
|
|
415
|
+
*/
|
|
416
|
+
export async function deleteJob(id, opts) {
|
|
417
|
+
await batchFetch("DELETE", jobPath(id), { ...opts, retry: "read", jobId: id });
|
|
418
|
+
}
|
|
419
|
+
/**
|
|
420
|
+
* Start a new run of a job. With no `status`, every task runs again (a full
|
|
421
|
+
* rerun). `status` is a comma-separated list of task statuses to rerun
|
|
422
|
+
* (`pending,processing,failed,successful`); the rest carry over from the
|
|
423
|
+
* previous run. After a stop or a credit failure, unstarted tasks stay
|
|
424
|
+
* `pending` and tasks cut off mid-flight can stay `processing`, so
|
|
425
|
+
* `failed,pending,processing` recovers everything that did not succeed.
|
|
203
426
|
*/
|
|
204
427
|
export function rerunJob(id, opts) {
|
|
205
|
-
|
|
206
|
-
|
|
428
|
+
const idempotencyKey = opts.idempotencyKey ?? newIdempotencyKey();
|
|
429
|
+
assertIdempotencyKey(idempotencyKey);
|
|
430
|
+
return batchJson("POST", jobPath(id, "/rerun"), {
|
|
431
|
+
...opts,
|
|
207
432
|
query: opts.status ? { status: opts.status } : undefined,
|
|
208
|
-
|
|
209
|
-
|
|
433
|
+
idempotencyKey,
|
|
434
|
+
retry: "write",
|
|
435
|
+
jobId: id,
|
|
210
436
|
});
|
|
211
437
|
}
|
|
212
|
-
/**
|
|
438
|
+
/**
|
|
439
|
+
* List all result rows of the latest run (or `runId`), following `next_cursor`
|
|
440
|
+
* until it is null.
|
|
441
|
+
*/
|
|
213
442
|
export async function listResults(id, opts) {
|
|
443
|
+
const path = opts.runId ? jobPath(id, `/runs/${encodeURIComponent(opts.runId)}/results`) : jobPath(id, "/results");
|
|
214
444
|
const all = [];
|
|
215
445
|
let cursor;
|
|
216
446
|
do {
|
|
217
|
-
const page = await
|
|
218
|
-
|
|
447
|
+
const page = await batchJson("GET", path, {
|
|
448
|
+
...opts,
|
|
449
|
+
retry: "read",
|
|
450
|
+
jobId: id,
|
|
219
451
|
query: { status: opts.status, cursor },
|
|
220
|
-
fetchImpl: opts.fetchImpl,
|
|
221
|
-
timeoutMs: opts.timeoutMs,
|
|
222
452
|
});
|
|
223
453
|
if (page?.results)
|
|
224
454
|
all.push(...page.results);
|
|
@@ -226,6 +456,13 @@ export async function listResults(id, opts) {
|
|
|
226
456
|
} while (cursor);
|
|
227
457
|
return all;
|
|
228
458
|
}
|
|
459
|
+
/** One task's result body (latest run, or `runId`), as returned by the content endpoint. */
|
|
460
|
+
export async function getTaskContent(id, taskId, opts) {
|
|
461
|
+
const task = `/tasks/${encodeURIComponent(taskId)}/content`;
|
|
462
|
+
const path = opts.runId ? jobPath(id, `/runs/${encodeURIComponent(opts.runId)}${task}`) : jobPath(id, task);
|
|
463
|
+
const res = await batchFetch("GET", path, { ...opts, retry: "read", jobId: id });
|
|
464
|
+
return { contentType: res.headers.get("content-type") ?? "", body: res.body };
|
|
465
|
+
}
|
|
229
466
|
/** Map a result `type` to a file extension for downloaded bodies. */
|
|
230
467
|
const EXT_BY_TYPE = {
|
|
231
468
|
json: "json",
|
|
@@ -252,15 +489,25 @@ function uniqueName(used, base, ext) {
|
|
|
252
489
|
used.add(name);
|
|
253
490
|
return name;
|
|
254
491
|
}
|
|
492
|
+
/** Why a row has no body to download. */
|
|
493
|
+
function skipReason(row) {
|
|
494
|
+
if (row.status === "failed")
|
|
495
|
+
return "task failed: no result body";
|
|
496
|
+
if (row.status === "expired")
|
|
497
|
+
return "result expired (kept 14 days by default): a full rerun (--all) scrapes it again";
|
|
498
|
+
if (row.status === "pending" || row.status === "processing")
|
|
499
|
+
return "task not finished yet";
|
|
500
|
+
return "no result_url";
|
|
501
|
+
}
|
|
255
502
|
/**
|
|
256
503
|
* Stream each result body into its own file under `dir` (created if needed),
|
|
257
|
-
* plus a self-describing `_manifest.jsonl` index (result_url dropped
|
|
504
|
+
* plus a self-describing `_manifest.jsonl` index (result_url dropped: it is a
|
|
258
505
|
* short-lived presigned link). Fetches run `concurrency` at a time and a single
|
|
259
506
|
* failure never aborts the rest: per-task outcomes come back in the result.
|
|
260
507
|
*
|
|
261
|
-
* Only tasks with a `result_url` have a body to fetch; failed
|
|
262
|
-
*
|
|
263
|
-
*
|
|
508
|
+
* Only tasks with a `result_url` have a body to fetch; failed, expired and
|
|
509
|
+
* unfinished tasks are reported as `skipped`. The presigned links are valid for
|
|
510
|
+
* 2 hours, so download soon after `listResults`. Deliberately opt-in (not the
|
|
264
511
|
* `results` default): a large job's bodies can be many GB, and the URL manifest
|
|
265
512
|
* is the right primitive to pull selectively.
|
|
266
513
|
*/
|
|
@@ -281,10 +528,7 @@ export async function downloadResults(rows, dir, opts = {}) {
|
|
|
281
528
|
const { row, file } = planned[next++];
|
|
282
529
|
const ref = { task_id: row.task_id, external_id: row.external_id };
|
|
283
530
|
if (!file) {
|
|
284
|
-
outcome.skipped.push({
|
|
285
|
-
...ref,
|
|
286
|
-
reason: row.status === "failed" ? "task failed — no result body" : "no result_url",
|
|
287
|
-
});
|
|
531
|
+
outcome.skipped.push({ ...ref, reason: skipReason(row) });
|
|
288
532
|
continue;
|
|
289
533
|
}
|
|
290
534
|
try {
|
|
@@ -305,33 +549,43 @@ export async function downloadResults(rows, dir, opts = {}) {
|
|
|
305
549
|
const fileByTask = new Map(outcome.downloaded.map((d) => [d.task_id, d.file]));
|
|
306
550
|
const manifest = rows.map((r) => {
|
|
307
551
|
const rest = { ...r };
|
|
308
|
-
delete rest.result_url; // expires
|
|
552
|
+
delete rest.result_url; // expires: the local `file` is the durable pointer
|
|
309
553
|
return { ...rest, file: fileByTask.get(r.task_id) ?? null };
|
|
310
554
|
});
|
|
311
555
|
writeFileSync(join(dir, "_manifest.jsonl"), manifest.map((m) => JSON.stringify(m)).join("\n") + (manifest.length ? "\n" : ""));
|
|
312
556
|
return outcome;
|
|
313
557
|
}
|
|
314
558
|
/**
|
|
315
|
-
* Poll `GET /jobs/{id}` until
|
|
316
|
-
*
|
|
317
|
-
*
|
|
559
|
+
* Poll `GET /jobs/{id}` until the latest run is terminal, the run is paused (a
|
|
560
|
+
* paused run never progresses, so the wait returns at once), or `waitMs`
|
|
561
|
+
* (default 10 minutes) runs out, backing off 2s, x1.5, capped at 15s. Returns
|
|
562
|
+
* the last job read in every case; the caller tells them apart with
|
|
563
|
+
* `isTerminal` / `isPaused`. A transient error (connection, 429, 5xx) is
|
|
564
|
+
* ridden out until the deadline. `sleepImpl` is injectable for tests.
|
|
318
565
|
*/
|
|
319
566
|
export async function waitForJob(id, opts) {
|
|
320
|
-
const sleep = opts.sleepImpl ??
|
|
321
|
-
const deadline = Date.now() + (opts.
|
|
567
|
+
const sleep = opts.sleepImpl ?? defaultSleep;
|
|
568
|
+
const deadline = Date.now() + (opts.waitMs ?? 600_000);
|
|
322
569
|
let delay = 2000;
|
|
570
|
+
const read = { ...opts, deadline };
|
|
571
|
+
let last;
|
|
572
|
+
let lastErr;
|
|
323
573
|
for (;;) {
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
574
|
+
try {
|
|
575
|
+
last = await getJob(id, read);
|
|
576
|
+
if (isTerminal(last.latest_run) || isPaused(last.latest_run))
|
|
577
|
+
return last;
|
|
578
|
+
}
|
|
579
|
+
catch (err) {
|
|
580
|
+
if (!isTransient(err))
|
|
581
|
+
throw err;
|
|
582
|
+
lastErr = err;
|
|
583
|
+
}
|
|
584
|
+
if (Date.now() + delay > deadline) {
|
|
585
|
+
// Out of time: report the last state read, or the error if none was.
|
|
586
|
+
if (!last)
|
|
587
|
+
throw lastErr;
|
|
588
|
+
return last;
|
|
335
589
|
}
|
|
336
590
|
await sleep(delay);
|
|
337
591
|
delay = Math.min(delay * 1.5, 15_000);
|