@zenrows/cli 1.4.2 → 1.5.1
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 +90 -19
- package/agent-plugin/skills/batch-jobs/SKILL.md +3 -3
- package/agent-plugin/skills/cost-control/SKILL.md +1 -2
- package/agent-plugin/skills/extract/SKILL.md +5 -5
- package/agent-plugin/skills/zenrows/SKILL.md +3 -3
- package/dist/src/adapters/batch.js +2 -3
- package/dist/src/adapters/extract.js +13 -1
- package/dist/src/cli/commands/batch.js +6 -6
- package/dist/src/cli/commands/crawl.js +535 -0
- package/dist/src/cli/commands/extract.js +7 -3
- package/dist/src/cli/index.js +2 -1
- package/dist/src/cli/output.js +38 -15
- package/dist/src/core/batch-api.js +2 -2
- package/dist/src/core/capabilities.js +2 -2
- package/dist/src/core/config.js +1 -1
- package/dist/src/core/crawl-api.js +418 -0
- package/dist/src/core/errors.js +9 -0
- package/dist/src/core/http.js +24 -5
- package/package.json +2 -1
- package/recipes/batch-estimate/RECIPE.md +2 -2
- package/registry/capabilities.json +14 -5
- package/registry/skills.json +14 -3
- package/registry/templates.json +2 -2
- package/skills/batch-jobs/SKILL.md +6 -7
- package/skills/crawl/SKILL.md +126 -0
- package/skills/extract/SKILL.md +3 -3
- package/skills/trace-debug/SKILL.md +12 -0
- package/skills/zenrows/SKILL.md +7 -2
- package/templates/batch-jsonl-pipeline/README.md +5 -5
- package/templates/extract-pricing-pages/README.md +1 -1
- package/workflows/competitor-intelligence/WORKFLOW.md +1 -1
|
@@ -24,8 +24,8 @@ export function getCapability(key) {
|
|
|
24
24
|
const USABLE = new Set([
|
|
25
25
|
"available",
|
|
26
26
|
"available-but-needs-confirmation",
|
|
27
|
-
//
|
|
28
|
-
//
|
|
27
|
+
// Beta: any key can call; product-specific limits are handled by the
|
|
28
|
+
// adapters / API errors, not here.
|
|
29
29
|
"beta",
|
|
30
30
|
]);
|
|
31
31
|
export function isUsable(key) {
|
package/dist/src/core/config.js
CHANGED
|
@@ -14,7 +14,7 @@ export const CONFIG_VERSION = "0.1.0";
|
|
|
14
14
|
* entrypoint) so core modules — telemetry, signup provenance — can read it
|
|
15
15
|
* without an import cycle. `VERSION` in `cli/index.ts` re-exports this.
|
|
16
16
|
*/
|
|
17
|
-
export const CLI_VERSION = "1.
|
|
17
|
+
export const CLI_VERSION = "1.5.1";
|
|
18
18
|
/** The User-Agent on every request to a Zenrows API. The gateway reads the version from it. */
|
|
19
19
|
export const CLI_USER_AGENT = `zenrows-cli/${CLI_VERSION}`;
|
|
20
20
|
/** Env var to override the Fetch and Extract API base (local/staging testing). */
|
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client for the Zenrows Crawl API.
|
|
3
|
+
*
|
|
4
|
+
* Crawl lives on the same host as Fetch and Extract (`https://api.zenrows.com/v1`)
|
|
5
|
+
* under `/crawls`. Auth is the `X-API-Key` header, never the `apikey` query
|
|
6
|
+
* param: Crawl answers any query parameter it does not know with 400. Bodies are
|
|
7
|
+
* JSON; errors come back as `application/problem+json` (RFC 9457) and we branch
|
|
8
|
+
* on `status` + `code` only. A crawl is an async job: create it, then poll it
|
|
9
|
+
* until its status leaves `running`. Calls retry transient failures the way the
|
|
10
|
+
* Crawl SDKs do (see `retryStatuses`). `fetchImpl` / `sleepImpl` are injectable
|
|
11
|
+
* for tests.
|
|
12
|
+
*
|
|
13
|
+
* `output_format` takes `html` only (absent = URLs only). Response fields we do
|
|
14
|
+
* not model pass through untouched.
|
|
15
|
+
*/
|
|
16
|
+
import { Readable } from "node:stream";
|
|
17
|
+
import { ToolkitError, isKeyCapReached, keyCapReached, quotaExhausted } from "./errors.js";
|
|
18
|
+
import { readAccount } from "./agent-account.js";
|
|
19
|
+
import { registerSecret } from "./logger.js";
|
|
20
|
+
import { CLI_USER_AGENT, loadConfig } from "./config.js";
|
|
21
|
+
import { agentClientHeader } from "./agent-client.js";
|
|
22
|
+
/** Production Crawl API base (no trailing slash): the Fetch/Extract host. */
|
|
23
|
+
export const DEFAULT_CRAWL_API_BASE = "https://api.zenrows.com/v1";
|
|
24
|
+
/** Env var to override the Crawl API base (local/staging testing). */
|
|
25
|
+
export const CRAWL_API_BASE_ENV = "ZENROWS_CRAWL_API_BASE";
|
|
26
|
+
/**
|
|
27
|
+
* Resolve the Crawl API base, trimmed of trailing `/`. `ZENROWS_CRAWL_API_BASE`
|
|
28
|
+
* wins; otherwise Crawl follows the Fetch/Extract `apiBase` (config or
|
|
29
|
+
* `ZENROWS_API_BASE`), since both live on the same API host.
|
|
30
|
+
*/
|
|
31
|
+
export function crawlBase() {
|
|
32
|
+
const env = process.env[CRAWL_API_BASE_ENV];
|
|
33
|
+
const base = env && env.trim() ? env.trim() : loadConfig().apiBase || DEFAULT_CRAWL_API_BASE;
|
|
34
|
+
return base.replace(/\/+$/, "");
|
|
35
|
+
}
|
|
36
|
+
/** The API's "Crawl is not enabled for this account" code (403). */
|
|
37
|
+
export const CRAWL_NOT_ENABLED_CODE = "REQS008";
|
|
38
|
+
/** A crawl stops progressing once its status is anything but `running`. */
|
|
39
|
+
export function isTerminal(status) {
|
|
40
|
+
return !!status && status !== "running";
|
|
41
|
+
}
|
|
42
|
+
const MAX_RETRIES = 3;
|
|
43
|
+
/** The longest a Retry-After may make one retry wait, so a large server value cannot hang a command. */
|
|
44
|
+
const MAX_RETRY_AFTER_SECONDS = 30;
|
|
45
|
+
const READ_RETRY_STATUSES = new Set([429, 502, 503, 504]);
|
|
46
|
+
// 429 on create is the active-job limit: a retry only waits for a slot that may never free.
|
|
47
|
+
const CREATE_RETRY_STATUSES = new Set([502, 503, 504]);
|
|
48
|
+
// Any 503 on stop, crawl_busy included: crawl_busy means the stop was not saved and the crawl still runs.
|
|
49
|
+
const STOP_RETRY_STATUSES = new Set([429, 502, 503, 504]);
|
|
50
|
+
/**
|
|
51
|
+
* The statuses to retry, or undefined when the request must run once: a GET is
|
|
52
|
+
* safe to repeat, a create only with an Idempotency-Key. Stop retries on its own
|
|
53
|
+
* statuses and wait rule (see `retryStop`).
|
|
54
|
+
*/
|
|
55
|
+
function retryStatuses(method, headers) {
|
|
56
|
+
if (method === "GET")
|
|
57
|
+
return READ_RETRY_STATUSES;
|
|
58
|
+
return headers["Idempotency-Key"] ? CREATE_RETRY_STATUSES : undefined;
|
|
59
|
+
}
|
|
60
|
+
/** 250 ms x 2^attempt, capped at 10 s, +/-20% jitter. */
|
|
61
|
+
function backoffMs(attempt) {
|
|
62
|
+
return Math.min(250 * 2 ** attempt, 10_000) * (0.8 + Math.random() * 0.4);
|
|
63
|
+
}
|
|
64
|
+
/** `Retry-After` in whole seconds, as milliseconds, at most `MAX_RETRY_AFTER_SECONDS`. */
|
|
65
|
+
function retryAfterMs(value) {
|
|
66
|
+
return value && /^\d+$/.test(value.trim()) ? Math.min(Number(value), MAX_RETRY_AFTER_SECONDS) * 1000 : undefined;
|
|
67
|
+
}
|
|
68
|
+
const defaultSleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
69
|
+
/** One HTTP attempt with its own timeout; the body is read inside it. */
|
|
70
|
+
async function attempt(url, init, opts) {
|
|
71
|
+
const controller = new AbortController();
|
|
72
|
+
const timeout = setTimeout(() => controller.abort(), opts.timeoutMs ?? 60_000);
|
|
73
|
+
try {
|
|
74
|
+
const res = await (opts.fetchImpl ?? fetch)(url, { ...init, signal: controller.signal });
|
|
75
|
+
if (opts.sink && res.ok) {
|
|
76
|
+
await opts.sink(res.body ?? Readable.from([]));
|
|
77
|
+
return { status: res.status, headers: res.headers, text: "" };
|
|
78
|
+
}
|
|
79
|
+
return { status: res.status, headers: res.headers, text: await res.text() };
|
|
80
|
+
}
|
|
81
|
+
finally {
|
|
82
|
+
clearTimeout(timeout);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Perform a Crawl API request and return the raw body. Sets `X-API-Key`, adds a
|
|
87
|
+
* JSON content-type when a body is present, retries transient failures as
|
|
88
|
+
* `retryStatuses` allows, and on a non-2xx parses the problem+json body into a
|
|
89
|
+
* normalized ToolkitError.
|
|
90
|
+
*/
|
|
91
|
+
async function crawlFetch(method, path, opts) {
|
|
92
|
+
registerSecret(opts.apiKey);
|
|
93
|
+
const url = new URL(crawlBase() + path);
|
|
94
|
+
for (const [k, v] of Object.entries(opts.query ?? {})) {
|
|
95
|
+
if (v !== undefined && v !== null)
|
|
96
|
+
url.searchParams.set(k, String(v));
|
|
97
|
+
}
|
|
98
|
+
const headers = {
|
|
99
|
+
"X-API-Key": opts.apiKey,
|
|
100
|
+
Accept: opts.accept ?? "application/json",
|
|
101
|
+
"User-Agent": CLI_USER_AGENT,
|
|
102
|
+
...agentClientHeader(),
|
|
103
|
+
...(opts.headers ?? {}),
|
|
104
|
+
};
|
|
105
|
+
if (opts.body !== undefined)
|
|
106
|
+
headers["Content-Type"] = "application/json";
|
|
107
|
+
const init = { method, headers, body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined };
|
|
108
|
+
const retryOn = retryStatuses(method, headers);
|
|
109
|
+
const sleep = opts.sleepImpl ?? defaultSleep;
|
|
110
|
+
for (let n = 0;; n++) {
|
|
111
|
+
const canRetry = retryOn !== undefined && n < MAX_RETRIES;
|
|
112
|
+
let res;
|
|
113
|
+
try {
|
|
114
|
+
res = await attempt(url.toString(), init, opts);
|
|
115
|
+
}
|
|
116
|
+
catch (err) {
|
|
117
|
+
if (err instanceof ToolkitError)
|
|
118
|
+
throw err;
|
|
119
|
+
if (canRetry) {
|
|
120
|
+
await sleep(backoffMs(n));
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
throw new ToolkitError({
|
|
124
|
+
code: "BACKEND_UNAVAILABLE",
|
|
125
|
+
message: "Could not reach the Zenrows Crawl API.",
|
|
126
|
+
likely_cause: err instanceof Error ? err.message : String(err),
|
|
127
|
+
next_action: "Check connectivity and retry. Override the host with ZENROWS_CRAWL_API_BASE if you are testing against staging.",
|
|
128
|
+
suggested_commands: ["zenrows status"],
|
|
129
|
+
crawl_id: opts.crawlId,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
const retryAfter = res.headers.get("retry-after");
|
|
133
|
+
if (canRetry && retryOn.has(res.status)) {
|
|
134
|
+
await sleep(retryAfterMs(retryAfter) ?? backoffMs(n));
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
if (opts.retryStop && n < MAX_RETRIES && STOP_RETRY_STATUSES.has(res.status)) {
|
|
138
|
+
await sleep(retryAfterMs(retryAfter) ?? 1000);
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
if (res.status < 200 || res.status >= 300) {
|
|
142
|
+
throw problemToError(res.status, res.text, `${method} ${path}`, retryAfter, opts.crawlId);
|
|
143
|
+
}
|
|
144
|
+
return res;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
/** `crawlFetch` + JSON parse. */
|
|
148
|
+
async function crawlJson(method, path, opts) {
|
|
149
|
+
const { text } = await crawlFetch(method, path, opts);
|
|
150
|
+
try {
|
|
151
|
+
return JSON.parse(text);
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
throw new ToolkitError({
|
|
155
|
+
code: "CRAWL_FAILED",
|
|
156
|
+
message: "The Crawl API response was not valid JSON.",
|
|
157
|
+
likely_cause: text.slice(0, 240) || "Empty response body.",
|
|
158
|
+
next_action: "Retry, or check the crawl with `zenrows crawl get <id>`.",
|
|
159
|
+
crawl_id: opts.crawlId,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Map a problem+json body + HTTP status to a normalized ToolkitError that
|
|
165
|
+
* carries the API's `code` as `server_code`, and `crawl_id` when known.
|
|
166
|
+
*/
|
|
167
|
+
function problemToError(status, body, request, retryAfter, crawlId) {
|
|
168
|
+
let problem = {};
|
|
169
|
+
try {
|
|
170
|
+
problem = JSON.parse(body);
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
// non-JSON error body — fall through with an empty problem
|
|
174
|
+
}
|
|
175
|
+
const serverCode = problem.code || undefined;
|
|
176
|
+
const throttled = status === 429 || (status === 503 && serverCode === "crawl_busy");
|
|
177
|
+
const seconds = throttled && retryAfter && /^\d+$/.test(retryAfter.trim()) ? Number(retryAfter) : undefined;
|
|
178
|
+
const err = classifyProblem(status, problem, body, request, serverCode, seconds);
|
|
179
|
+
return new ToolkitError({ ...err.toJSON(), server_code: serverCode, crawl_id: crawlId, retry_after: seconds });
|
|
180
|
+
}
|
|
181
|
+
function classifyProblem(status, problem, body, request, serverCode, retryAfter) {
|
|
182
|
+
const detail = problem.detail || problem.title || body.slice(0, 240) || `HTTP ${status}`;
|
|
183
|
+
// The API's detail often ends in a period already; add one only when it does not.
|
|
184
|
+
const raw = `HTTP ${status}${serverCode ? ` (${serverCode})` : ""} for ${request}: ${detail}`;
|
|
185
|
+
const cause = /[.!?]$/.test(raw) ? raw : `${raw}.`;
|
|
186
|
+
if (status === 403 && serverCode === CRAWL_NOT_ENABLED_CODE) {
|
|
187
|
+
return new ToolkitError({
|
|
188
|
+
code: "CRAWL_NOT_ENABLED",
|
|
189
|
+
message: "Crawl is not enabled for this account.",
|
|
190
|
+
likely_cause: `${cause} Crawl is not enabled for this account yet.`,
|
|
191
|
+
next_action: "Ask Zenrows support to enable Crawl for this account. Meanwhile fetch known URLs with `zenrows fetch`.",
|
|
192
|
+
suggested_commands: ["zenrows fetch <url>"],
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
if (status === 401) {
|
|
196
|
+
return new ToolkitError({
|
|
197
|
+
code: "AUTH_INVALID",
|
|
198
|
+
message: "Zenrows rejected the API key for the Crawl API.",
|
|
199
|
+
likely_cause: cause,
|
|
200
|
+
next_action: "Re-check your key and log in again.",
|
|
201
|
+
suggested_commands: ["zenrows login --api-key <your-key>"],
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
if (status === 404 && serverCode === "content_not_found") {
|
|
205
|
+
return new ToolkitError({
|
|
206
|
+
code: "CRAWL_CONTENT_NOT_FOUND",
|
|
207
|
+
message: "Crawl content not found.",
|
|
208
|
+
likely_cause: `${cause} The page was not fetched (yet), its fetch failed, or the crawl ran without --output-format html.`,
|
|
209
|
+
next_action: "Read the result's content_status with `zenrows crawl results <id>`; only `fetched` results have content.",
|
|
210
|
+
suggested_commands: ["zenrows crawl list"],
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
if (status === 404) {
|
|
214
|
+
return new ToolkitError({
|
|
215
|
+
code: "CRAWL_NOT_FOUND",
|
|
216
|
+
message: "Crawl not found.",
|
|
217
|
+
likely_cause: `${cause} The id may be wrong or the crawl is not owned by this account.`,
|
|
218
|
+
next_action: "Check the crawl id, or list your crawls with `zenrows crawl list`.",
|
|
219
|
+
suggested_commands: ["zenrows crawl list"],
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
if (status === 429 && serverCode === "too_many_crawls") {
|
|
223
|
+
return new ToolkitError({
|
|
224
|
+
code: "CRAWL_TOO_MANY_CRAWLS",
|
|
225
|
+
message: "The account has reached its limit of active jobs.",
|
|
226
|
+
likely_cause: `${cause} The account has reached its limit of active jobs (3 by default), shared with its Batch jobs.`,
|
|
227
|
+
next_action: `Nothing was created. ${retryAfter !== undefined ? `Retry after ${retryAfter} seconds` : "Retry later"}, or stop one of your crawls (\`zenrows crawl stop <id>\`) or Batch jobs first.`,
|
|
228
|
+
suggested_commands: ["zenrows crawl list"],
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
if (status === 429) {
|
|
232
|
+
// The Crawl API sends 429 only on create, as too_many_crawls. Any other 429
|
|
233
|
+
// comes from something in front of it and says nothing about active jobs.
|
|
234
|
+
return new ToolkitError({
|
|
235
|
+
code: "CRAWL_RATE_LIMITED",
|
|
236
|
+
message: "The Crawl API is rate limiting requests.",
|
|
237
|
+
likely_cause: cause,
|
|
238
|
+
next_action: retryAfter !== undefined ? `Retry after ${retryAfter} seconds.` : "Retry later.",
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
if (status === 402) {
|
|
242
|
+
// Same advice as the shared credit errors, under Crawl's own codes.
|
|
243
|
+
const detail = problem.detail || problem.title || undefined;
|
|
244
|
+
if (isKeyCapReached(serverCode)) {
|
|
245
|
+
return new ToolkitError({ ...keyCapReached(request, { status, detail }).toJSON(), code: "CRAWL_KEY_CAP_REACHED" });
|
|
246
|
+
}
|
|
247
|
+
const acct = readAccount();
|
|
248
|
+
const quota = quotaExhausted(request, acct?.unclaimed ? acct.claimUrl : undefined, { status, detail });
|
|
249
|
+
return new ToolkitError({ ...quota.toJSON(), code: "CRAWL_INSUFFICIENT_CREDITS" });
|
|
250
|
+
}
|
|
251
|
+
if (status === 409) {
|
|
252
|
+
return new ToolkitError({
|
|
253
|
+
code: "CRAWL_REQUEST_IN_FLIGHT",
|
|
254
|
+
message: "A request with the same Idempotency-Key is still in flight.",
|
|
255
|
+
likely_cause: cause,
|
|
256
|
+
next_action: "Retry the same request once the first one has finished.",
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
if (status === 503 && serverCode === "crawl_busy") {
|
|
260
|
+
return new ToolkitError({
|
|
261
|
+
code: "CRAWL_BUSY",
|
|
262
|
+
message: "The stop was not saved yet; the crawl is still running.",
|
|
263
|
+
likely_cause: cause,
|
|
264
|
+
next_action: `Run the stop again${retryAfter !== undefined ? ` after ${retryAfter} seconds` : ""}. Repeating a stop is safe.`,
|
|
265
|
+
suggested_commands: ["zenrows crawl stop <id>"],
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
if (status === 400 || status === 422) {
|
|
269
|
+
return new ToolkitError({
|
|
270
|
+
code: "CRAWL_INVALID_REQUEST",
|
|
271
|
+
message: `Crawl rejected the request (HTTP ${status}).`,
|
|
272
|
+
likely_cause: cause,
|
|
273
|
+
next_action: serverCode === "idempotency_key_reused"
|
|
274
|
+
? "This Idempotency-Key was already used for a different request. Send a new key, or no key. Do not retry as is."
|
|
275
|
+
: "Fix the reported parameter and retry. See `zenrows crawl --help` for the accepted flags and ranges.",
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
return new ToolkitError({
|
|
279
|
+
code: "CRAWL_FAILED",
|
|
280
|
+
message: `Crawl request failed (HTTP ${status}).`,
|
|
281
|
+
likely_cause: cause,
|
|
282
|
+
next_action: status >= 500 ? "Retry with a short backoff." : "Fix the reported problem and retry.",
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
/** Start a crawl (202). Sends only the fields set in `params`. */
|
|
286
|
+
export function createCrawl(params, opts) {
|
|
287
|
+
const body = { url: params.url };
|
|
288
|
+
if (params.depth !== undefined)
|
|
289
|
+
body.depth = params.depth;
|
|
290
|
+
if (params.max_items !== undefined)
|
|
291
|
+
body.max_items = params.max_items;
|
|
292
|
+
if (params.max_pages !== undefined)
|
|
293
|
+
body.max_pages = params.max_pages;
|
|
294
|
+
if (params.include_patterns?.length)
|
|
295
|
+
body.include_patterns = params.include_patterns;
|
|
296
|
+
if (params.exclude_patterns?.length)
|
|
297
|
+
body.exclude_patterns = params.exclude_patterns;
|
|
298
|
+
if (params.output_format !== undefined)
|
|
299
|
+
body.output_format = params.output_format;
|
|
300
|
+
return crawlJson("POST", "/crawls", {
|
|
301
|
+
...opts,
|
|
302
|
+
body,
|
|
303
|
+
headers: opts.idempotencyKey ? { "Idempotency-Key": opts.idempotencyKey } : undefined,
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
/** Read a crawl and one page of its results. */
|
|
307
|
+
export function getCrawl(id, opts) {
|
|
308
|
+
return crawlJson("GET", `/crawls/${encodeURIComponent(id)}`, {
|
|
309
|
+
...opts,
|
|
310
|
+
crawlId: id,
|
|
311
|
+
query: { cursor: opts.cursor, limit: opts.limit },
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
/** One page of the account's crawls, newest first. */
|
|
315
|
+
export function listCrawls(opts) {
|
|
316
|
+
return crawlJson("GET", "/crawls", { ...opts, query: { cursor: opts.cursor, limit: opts.limit } });
|
|
317
|
+
}
|
|
318
|
+
/** Stop a running crawl. Idempotent: an ended crawl answers as it ended. */
|
|
319
|
+
export function stopCrawl(id, opts) {
|
|
320
|
+
return crawlJson("POST", `/crawls/${encodeURIComponent(id)}/stop`, { ...opts, crawlId: id, retryStop: true });
|
|
321
|
+
}
|
|
322
|
+
/** The page of one kept URL (HTML for `output_format: html`). */
|
|
323
|
+
export async function getCrawlContent(id, contentId, opts) {
|
|
324
|
+
const res = await crawlFetch("GET", `/crawls/${encodeURIComponent(id)}/contents/${encodeURIComponent(contentId)}`, {
|
|
325
|
+
...opts,
|
|
326
|
+
crawlId: id,
|
|
327
|
+
accept: "text/html, application/json;q=0.9, application/problem+json;q=0.8",
|
|
328
|
+
});
|
|
329
|
+
return { contentType: res.headers.get("content-type") ?? "", body: res.text };
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* Every result in one NDJSON file (`{url, content_status?, content?}` per line),
|
|
333
|
+
* streamed into `write` because page HTML makes it large. `count` is its
|
|
334
|
+
* non-blank lines. `status` is the `X-Crawl-Status` header: `running` means the
|
|
335
|
+
* file is partial.
|
|
336
|
+
*/
|
|
337
|
+
export async function downloadCrawl(id, write, opts) {
|
|
338
|
+
let count = 0;
|
|
339
|
+
const res = await crawlFetch("GET", `/crawls/${encodeURIComponent(id)}/download`, {
|
|
340
|
+
...opts,
|
|
341
|
+
crawlId: id,
|
|
342
|
+
accept: "application/x-ndjson, application/problem+json;q=0.8",
|
|
343
|
+
timeoutMs: opts.timeoutMs ?? 300_000,
|
|
344
|
+
sink: (body) => {
|
|
345
|
+
count = 0;
|
|
346
|
+
return write(countLines(body, () => count++));
|
|
347
|
+
},
|
|
348
|
+
});
|
|
349
|
+
return { status: res.headers.get("x-crawl-status") ?? undefined, count };
|
|
350
|
+
}
|
|
351
|
+
/** Pass `chunks` through, calling `onLine` for each line that is not blank. */
|
|
352
|
+
async function* countLines(chunks, onLine) {
|
|
353
|
+
let blank = true;
|
|
354
|
+
for await (const chunk of chunks) {
|
|
355
|
+
for (const b of chunk) {
|
|
356
|
+
if (b === 0x0a) {
|
|
357
|
+
if (!blank)
|
|
358
|
+
onLine();
|
|
359
|
+
blank = true;
|
|
360
|
+
}
|
|
361
|
+
else if (b !== 0x20 && b !== 0x09 && b !== 0x0d) {
|
|
362
|
+
blank = false;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
yield chunk;
|
|
366
|
+
}
|
|
367
|
+
if (!blank)
|
|
368
|
+
onLine();
|
|
369
|
+
}
|
|
370
|
+
/**
|
|
371
|
+
* The content id of a fetched result: the last segment of its `content_url`.
|
|
372
|
+
* Undefined when the result has no content yet.
|
|
373
|
+
*/
|
|
374
|
+
export function contentIdOf(result) {
|
|
375
|
+
if (!result.content_url)
|
|
376
|
+
return undefined;
|
|
377
|
+
const last = result.content_url.split("?")[0].replace(/\/+$/, "").split("/").pop();
|
|
378
|
+
return last ? decodeURIComponent(last) : undefined;
|
|
379
|
+
}
|
|
380
|
+
/**
|
|
381
|
+
* Read every kept result, following `next_cursor`. While a crawl runs its
|
|
382
|
+
* `next_cursor` is never null, so the read also ends at the first empty page;
|
|
383
|
+
* `partial` is true when the crawl was still running or a cursor remains.
|
|
384
|
+
*/
|
|
385
|
+
export async function listAllResults(id, opts) {
|
|
386
|
+
const results = [];
|
|
387
|
+
let cursor;
|
|
388
|
+
for (;;) {
|
|
389
|
+
const page = await getCrawl(id, { ...opts, cursor, limit: opts.pageSize });
|
|
390
|
+
const rows = page.results ?? [];
|
|
391
|
+
results.push(...rows);
|
|
392
|
+
if (!rows.length || !page.next_cursor || page.next_cursor === cursor) {
|
|
393
|
+
// Every page was read. An ended crawl keeps no new URLs, even while pages in flight after
|
|
394
|
+
// a stop keep next_cursor set, so only a running crawl's results are partial.
|
|
395
|
+
return { crawl: page, results, partial: !isTerminal(page.status) };
|
|
396
|
+
}
|
|
397
|
+
cursor = page.next_cursor;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
/**
|
|
401
|
+
* Poll `GET /crawls/{id}?limit=1` until the status leaves `running` or
|
|
402
|
+
* `timeoutMs` (default 600 s) runs out, backing off 2s → ×1.5 → capped at 15s.
|
|
403
|
+
* A timeout is not an error: it returns the crawl, still `running`.
|
|
404
|
+
*/
|
|
405
|
+
export async function waitForCrawl(id, opts) {
|
|
406
|
+
const sleep = opts.sleepImpl ?? defaultSleep;
|
|
407
|
+
const deadline = Date.now() + (opts.timeoutMs ?? 600_000);
|
|
408
|
+
let delay = 2000;
|
|
409
|
+
for (;;) {
|
|
410
|
+
const crawl = await getCrawl(id, { apiKey: opts.apiKey, fetchImpl: opts.fetchImpl, sleepImpl: opts.sleepImpl, limit: 1 });
|
|
411
|
+
if (isTerminal(crawl.status))
|
|
412
|
+
return crawl;
|
|
413
|
+
if (Date.now() + delay > deadline)
|
|
414
|
+
return crawl;
|
|
415
|
+
await sleep(delay);
|
|
416
|
+
delay = Math.min(delay * 1.5, 15_000);
|
|
417
|
+
}
|
|
418
|
+
}
|
package/dist/src/core/errors.js
CHANGED
|
@@ -11,6 +11,9 @@ export class ToolkitError extends Error {
|
|
|
11
11
|
likely_cause;
|
|
12
12
|
next_action;
|
|
13
13
|
suggested_commands;
|
|
14
|
+
server_code;
|
|
15
|
+
crawl_id;
|
|
16
|
+
retry_after;
|
|
14
17
|
constructor(shape) {
|
|
15
18
|
super(shape.message);
|
|
16
19
|
this.name = "ToolkitError";
|
|
@@ -18,6 +21,9 @@ export class ToolkitError extends Error {
|
|
|
18
21
|
this.likely_cause = shape.likely_cause;
|
|
19
22
|
this.next_action = shape.next_action;
|
|
20
23
|
this.suggested_commands = shape.suggested_commands ?? [];
|
|
24
|
+
this.server_code = shape.server_code;
|
|
25
|
+
this.crawl_id = shape.crawl_id;
|
|
26
|
+
this.retry_after = shape.retry_after;
|
|
21
27
|
}
|
|
22
28
|
toJSON() {
|
|
23
29
|
return {
|
|
@@ -26,6 +32,9 @@ export class ToolkitError extends Error {
|
|
|
26
32
|
likely_cause: this.likely_cause,
|
|
27
33
|
next_action: this.next_action,
|
|
28
34
|
suggested_commands: this.suggested_commands,
|
|
35
|
+
...(this.server_code ? { server_code: this.server_code } : {}),
|
|
36
|
+
...(this.crawl_id ? { crawl_id: this.crawl_id } : {}),
|
|
37
|
+
...(this.retry_after !== undefined ? { retry_after: this.retry_after } : {}),
|
|
29
38
|
};
|
|
30
39
|
}
|
|
31
40
|
}
|
package/dist/src/core/http.js
CHANGED
|
@@ -106,10 +106,29 @@ export async function scrape(apiBase, apiKey, params, opts = {}) {
|
|
|
106
106
|
code: "EXTRACT_DOMAIN_NOT_ENABLED",
|
|
107
107
|
message: "Extract has not been prepared for this domain yet.",
|
|
108
108
|
likely_cause: zrErrorDetail(body) ?? "This domain is not prepared for Extract.",
|
|
109
|
-
next_action: "Retry with --autoparse for general-purpose extraction on any site.",
|
|
109
|
+
next_action: "Retry with --autoparse for general-purpose extraction on any site, or get the domain prepared for Extract (POST /v1/extract/prepared-domains).",
|
|
110
110
|
suggested_commands: [`zenrows extract ${params.url} --autoparse`],
|
|
111
111
|
});
|
|
112
112
|
}
|
|
113
|
+
// 422 no_data_extracted on an Extract request: the page was processed and had
|
|
114
|
+
// nothing to extract. It is an answer, not a failure to reach the target, so
|
|
115
|
+
// return it for the extract adapter to report as an empty result instead of
|
|
116
|
+
// advising escalation (which would repeat a slow, deterministic request).
|
|
117
|
+
if (res.status === 422 && params.extract !== undefined && zrErrorCode(body) === "NO_DATA_EXTRACTED") {
|
|
118
|
+
return { ...result, noDataExtracted: true };
|
|
119
|
+
}
|
|
120
|
+
// 422 blocked_document on an Extract request: the fetched page was a bot wall,
|
|
121
|
+
// an error page or an empty shell, so Extract had nothing real to read. Not
|
|
122
|
+
// charged. The fix is a stronger fetch of the same URL, not another extractor.
|
|
123
|
+
if (res.status === 422 && params.extract !== undefined && zrErrorCode(body) === "BLOCKED_DOCUMENT") {
|
|
124
|
+
throw new ToolkitError({
|
|
125
|
+
code: "EXTRACT_FAILED",
|
|
126
|
+
message: "Extract could not read this page: the site answered with a block or error page (HTTP 422 blocked_document, not charged).",
|
|
127
|
+
likely_cause: zrErrorDetail(body) ?? "The fetched page was a bot challenge, an error page or a page that had not finished loading.",
|
|
128
|
+
next_action: "Retry with a stronger fetch: --manual --js-render --premium-proxy. Check the URL if the site may be returning an error page.",
|
|
129
|
+
suggested_commands: [`zenrows extract ${params.url} --manual --js-render --premium-proxy`],
|
|
130
|
+
});
|
|
131
|
+
}
|
|
113
132
|
if (res.status === 401 || res.status === 403) {
|
|
114
133
|
// Distinguish Zenrows auth errors from target-site 403s. Zenrows auth
|
|
115
134
|
// failures surface AUTH00x codes in the body.
|
|
@@ -143,15 +162,15 @@ export async function scrape(apiBase, apiKey, params, opts = {}) {
|
|
|
143
162
|
}
|
|
144
163
|
}
|
|
145
164
|
if (res.status === 402 && isZenrowsErrorEnvelope(body)) {
|
|
146
|
-
// AUTH010 on extract=auto means the target domain is not
|
|
147
|
-
//
|
|
165
|
+
// AUTH010 on extract=auto means the target domain is not enabled for
|
|
166
|
+
// Extract, distinct from credit exhaustion (AUTH004). Let the extract
|
|
148
167
|
// adapter fall back to autoparse.
|
|
149
168
|
if (params.extract !== undefined && zrErrorCode(body) === "AUTH010") {
|
|
150
169
|
throw new ToolkitError({
|
|
151
170
|
code: "EXTRACT_DOMAIN_NOT_ENABLED",
|
|
152
171
|
message: "Extract is not enabled for this domain yet.",
|
|
153
|
-
likely_cause: zrErrorDetail(body) ?? "This domain is not
|
|
154
|
-
next_action: "Retry with --autoparse for general-purpose extraction on any site, or
|
|
172
|
+
likely_cause: zrErrorDetail(body) ?? "This domain is not enabled for Extract.",
|
|
173
|
+
next_action: "Retry with --autoparse for general-purpose extraction on any site, or get the domain prepared for Extract (POST /v1/extract/prepared-domains).",
|
|
155
174
|
suggested_commands: [`zenrows extract ${params.url} --autoparse`],
|
|
156
175
|
});
|
|
157
176
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zenrows/cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.1",
|
|
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",
|
|
@@ -48,6 +48,7 @@
|
|
|
48
48
|
"zenrows": "node bin/zenrows.ts",
|
|
49
49
|
"dev": "node bin/zenrows.ts",
|
|
50
50
|
"test": "npm run build && node --test --import ./dist/tests/setup.js dist/tests/*.test.js",
|
|
51
|
+
"test:e2e": "npm run build && node --test dist/tests/e2e/*.test.js",
|
|
51
52
|
"typecheck": "tsc --noEmit",
|
|
52
53
|
"build": "rm -rf dist && tsc && chmod +x dist/bin/zenrows.js",
|
|
53
54
|
"prepack": "npm run build",
|
|
@@ -26,8 +26,8 @@ Swap `jobs.jsonl` for your own spec. Per-line keys (`js_render`,
|
|
|
26
26
|
`premium_proxy`, `proxy_country`, `mode`, `autoparse`, `external_id`) tune each
|
|
27
27
|
URL; cost multipliers are basic 1x / JS 5x / proxy 10x / both 25x.
|
|
28
28
|
|
|
29
|
-
Once the spec is clean, submit it
|
|
29
|
+
Once the spec is clean, submit it:
|
|
30
30
|
```
|
|
31
31
|
zenrows batch create jobs.jsonl --wait
|
|
32
32
|
```
|
|
33
|
-
See [[batch-jobs]] for the full cloud flow
|
|
33
|
+
See [[batch-jobs]] for the full cloud flow.
|
|
@@ -13,20 +13,29 @@
|
|
|
13
13
|
"extract": {
|
|
14
14
|
"key": "extract",
|
|
15
15
|
"label": "Extract (extract=auto / Autoparse / CSS / Markdown)",
|
|
16
|
-
"status": "
|
|
16
|
+
"status": "available",
|
|
17
17
|
"command": "zenrows extract",
|
|
18
18
|
"backend": "Extract — GET https://api.zenrows.com/v1/ (extract=auto, autoparse, css_extractor, outputs, response_type)",
|
|
19
19
|
"requiresAuth": true,
|
|
20
|
-
"notes": "
|
|
20
|
+
"notes": "Default is extract=auto, which works on any prepared domain with any key; CLI falls back to autoparse on REQS007 (domain not prepared yet) or AUTH010. Autoparse / CSS / outputs / markdown work on any domain."
|
|
21
21
|
},
|
|
22
22
|
"batch": {
|
|
23
23
|
"key": "batch",
|
|
24
|
-
"label": "Batch
|
|
25
|
-
"status": "
|
|
24
|
+
"label": "Batch",
|
|
25
|
+
"status": "available",
|
|
26
26
|
"command": "zenrows batch",
|
|
27
27
|
"backend": "Batch — https://async.api.zenrows.com/v1 (X-API-Key header; separate host from the Fetch/Extract /v1/)",
|
|
28
28
|
"requiresAuth": true,
|
|
29
|
-
"notes": "
|
|
29
|
+
"notes": "Cloud subcommands (create/status/results/cancel/wait/retry-failed) call the Batch API with your API key; `zenrows batch estimate` works locally with no API key."
|
|
30
|
+
},
|
|
31
|
+
"crawl": {
|
|
32
|
+
"key": "crawl",
|
|
33
|
+
"label": "Crawl",
|
|
34
|
+
"status": "available",
|
|
35
|
+
"command": "zenrows crawl",
|
|
36
|
+
"backend": "Crawl — https://api.zenrows.com/v1/crawls (X-API-Key header; same host as Fetch/Extract)",
|
|
37
|
+
"requiresAuth": true,
|
|
38
|
+
"notes": "Crawl is still evolving: new features are coming, limits may be tuned, and the changelog announces each change. Follows the links on each page from one start URL, up to depth when set (optional; without it max_pages / max_items stop the crawl, both default 100), on the start URL's registrable domain (subdomains count), and returns the URLs it keeps, optionally with each page's HTML (create/get/list/results/content/download/stop/wait). An account without Crawl access gets 403 REQS008 (CRAWL_NOT_ENABLED). When the account has reached its limit of active jobs (3 by default), shared with its Batch jobs, a create gets 429 too_many_crawls (CRAWL_TOO_MANY_CRAWLS); retry after retry_after seconds. Any other 429 is CRAWL_RATE_LIMITED (requests are being throttled); retry after retry_after seconds."
|
|
30
39
|
},
|
|
31
40
|
"browser": {
|
|
32
41
|
"key": "browser",
|
package/registry/skills.json
CHANGED
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
"name": "extract",
|
|
27
27
|
"type": "skill",
|
|
28
28
|
"description": "Turn protected pages into structured data with Autoparse / CSS / Markdown.",
|
|
29
|
-
"status": "
|
|
29
|
+
"status": "available",
|
|
30
30
|
"requires_backend_capabilities": ["extract"],
|
|
31
31
|
"requires_auth": true,
|
|
32
32
|
"version": "0.1.0",
|
|
@@ -36,14 +36,25 @@
|
|
|
36
36
|
{
|
|
37
37
|
"name": "batch-jobs",
|
|
38
38
|
"type": "skill",
|
|
39
|
-
"description": "Scale protected fetch/extract over many URLs with Batch
|
|
40
|
-
"status": "
|
|
39
|
+
"description": "Scale protected fetch/extract over many URLs with Batch: submit/track/collect jobs with an API key; validate + estimate specs locally with no key.",
|
|
40
|
+
"status": "available",
|
|
41
41
|
"requires_backend_capabilities": ["batch"],
|
|
42
42
|
"requires_auth": true,
|
|
43
43
|
"version": "0.1.0",
|
|
44
44
|
"path": "skills/batch-jobs",
|
|
45
45
|
"tags": ["batch", "scale", "jsonl"]
|
|
46
46
|
},
|
|
47
|
+
{
|
|
48
|
+
"name": "crawl",
|
|
49
|
+
"type": "skill",
|
|
50
|
+
"description": "Collect the URLs behind one start page with Crawl (new): create, wait, read results and page HTML, stop.",
|
|
51
|
+
"status": "available",
|
|
52
|
+
"requires_backend_capabilities": ["crawl"],
|
|
53
|
+
"requires_auth": true,
|
|
54
|
+
"version": "0.1.0",
|
|
55
|
+
"path": "skills/crawl",
|
|
56
|
+
"tags": ["crawl", "links", "urls"]
|
|
57
|
+
},
|
|
47
58
|
{
|
|
48
59
|
"name": "interact-browser",
|
|
49
60
|
"type": "skill",
|
package/registry/templates.json
CHANGED
|
@@ -25,8 +25,8 @@
|
|
|
25
25
|
{
|
|
26
26
|
"name": "batch-jsonl-pipeline",
|
|
27
27
|
"type": "template",
|
|
28
|
-
"description": "JSONL job-spec scaffold for high-scale workloads on Batch: submit/track/collect with
|
|
29
|
-
"status": "
|
|
28
|
+
"description": "JSONL job-spec scaffold for high-scale workloads on Batch: submit/track/collect with an API key, validate + estimate locally with no key.",
|
|
29
|
+
"status": "available",
|
|
30
30
|
"requires_backend_capabilities": [],
|
|
31
31
|
"requires_auth": false,
|
|
32
32
|
"version": "0.1.0",
|