@zenrows/cli 1.4.1 → 1.5.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 +84 -11
- package/dist/src/cli/commands/crawl.js +535 -0
- package/dist/src/cli/commands/fetch.js +2 -2
- package/dist/src/cli/index.js +2 -1
- package/dist/src/cli/output.js +38 -15
- 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/package.json +2 -1
- package/registry/capabilities.json +9 -0
- package/registry/skills.json +11 -0
- package/skills/crawl/SKILL.md +126 -0
- package/skills/trace-debug/SKILL.md +16 -3
- package/skills/zenrows/SKILL.md +5 -0
|
@@ -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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zenrows/cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.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",
|
|
@@ -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",
|
|
@@ -28,6 +28,15 @@
|
|
|
28
28
|
"requiresAuth": true,
|
|
29
29
|
"notes": "Open beta. Cloud subcommands (create/status/results/cancel/wait/retry-failed) call the Batch API; `zenrows batch estimate` works locally with no API key."
|
|
30
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."
|
|
39
|
+
},
|
|
31
40
|
"browser": {
|
|
32
41
|
"key": "browser",
|
|
33
42
|
"label": "Browser Sessions",
|
package/registry/skills.json
CHANGED
|
@@ -44,6 +44,17 @@
|
|
|
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",
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: crawl
|
|
3
|
+
description: Collect the URLs behind one start page with Crawl (new): create a crawl, wait for it, read the kept URLs and optionally each page's HTML, stop it. Use when the user has a listing or section URL but not the item URLs.
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
requires_backend_capabilities: [crawl]
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Crawl (new)
|
|
9
|
+
|
|
10
|
+
Give Crawl one start URL; it follows the links on that page (and on the
|
|
11
|
+
pages behind it, up to the depth you set) and returns the URLs it keeps. Use it
|
|
12
|
+
when you know where the items live (a category page, a blog index, a docs
|
|
13
|
+
section) but not the item URLs themselves. Once you have the URLs, read pages
|
|
14
|
+
with `zenrows fetch` / `zenrows extract`, or ask Crawl for each page's HTML
|
|
15
|
+
with `--output-format html`.
|
|
16
|
+
|
|
17
|
+
Crawl stays on the start URL's registrable domain; subdomains count. An account without Crawl access gets
|
|
18
|
+
403 `REQS008` → `CRAWL_NOT_ENABLED` ("Crawl is not enabled for this account");
|
|
19
|
+
do not retry it.
|
|
20
|
+
|
|
21
|
+
## Create, wait, read
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
zenrows crawl create https://example.com/products/ --depth 1 \
|
|
25
|
+
--max-items 20 --max-pages 25 --include-patterns /product/ --follow --json
|
|
26
|
+
zenrows crawl results <id> --json # every kept URL
|
|
27
|
+
zenrows crawl results <id> --out urls.jsonl # same, as JSONL
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
- `--depth N` (1 to 100000) is optional: link hops from the start URL. `1` =
|
|
31
|
+
the start page's links; `2` = also the links on each of those pages. Leave
|
|
32
|
+
it out for no depth limit: the crawl follows links until `--max-items` or
|
|
33
|
+
`--max-pages` stops it, or nothing is left to open. A crawl created without
|
|
34
|
+
a depth has no `depth` field in its output.
|
|
35
|
+
- `--max-items` (kept URLs) and `--max-pages` (fetched pages) cap the crawl;
|
|
36
|
+
both default to 100. `--max-pages` bounds the cost: each page is one fetch.
|
|
37
|
+
The local policy `max_pages_per_run` caps `--max-pages` (default 1000);
|
|
38
|
+
above it the CLI refuses with `POLICY_LIMIT_EXCEEDED` before any request.
|
|
39
|
+
- `--include-patterns` / `--exclude-patterns` are substring matches on the
|
|
40
|
+
normalized URL and repeat:
|
|
41
|
+
`--include-patterns /product/ --exclude-patterns ?add-to-cart`.
|
|
42
|
+
- `create` returns at once. `--follow` (or `zenrows crawl wait <id>`) polls
|
|
43
|
+
until the crawl ends or `--timeout` seconds run out (default 600). When the
|
|
44
|
+
time runs out, the command exits 0 and prints the crawl with status
|
|
45
|
+
`running` and the hint `zenrows crawl wait <id>` (under `--json`, in
|
|
46
|
+
`note`). The crawl keeps running;
|
|
47
|
+
it is not stopped. Ctrl-C also stops the wait, not the crawl, and exits 130;
|
|
48
|
+
resume with `zenrows crawl wait <id>`.
|
|
49
|
+
- `results` on a running crawl returns the URLs kept so far, with
|
|
50
|
+
`partial: true`. Read again once the crawl ends. `--limit` sets the page size
|
|
51
|
+
of each request.
|
|
52
|
+
|
|
53
|
+
## Page HTML
|
|
54
|
+
|
|
55
|
+
```
|
|
56
|
+
zenrows crawl create <url> --depth 1 --output-format html --max-pages 20 --follow
|
|
57
|
+
zenrows crawl results <id> --json # fetched results carry content_url
|
|
58
|
+
zenrows crawl content <id> <content_id> # one page's HTML (or pass the content_url)
|
|
59
|
+
zenrows crawl download <id> --out pages.jsonl # every URL + HTML, NDJSON
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
`--output-format html` uses up the page budget: each kept page is one more
|
|
63
|
+
fetch, and `--max-pages` counts it. Raise `--max-pages` to keep the same number
|
|
64
|
+
of URLs. A result's `content_status` is `pending`, `fetched` or `failed`; only
|
|
65
|
+
`fetched` results have content (else `CRAWL_CONTENT_NOT_FOUND`). `download`
|
|
66
|
+
reports the crawl's `status`, and `partial: true` while it runs.
|
|
67
|
+
|
|
68
|
+
## While it runs, and after
|
|
69
|
+
|
|
70
|
+
```
|
|
71
|
+
zenrows crawl get <id> # status, coverage, stop_reason / error, one page of results
|
|
72
|
+
zenrows crawl get <id> --cursor <c> --json # the next page; keep polling with next_cursor
|
|
73
|
+
zenrows crawl list # your crawls, newest first (--cursor, --limit)
|
|
74
|
+
zenrows crawl stop <id> # stop it; kept URLs stay readable
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
A crawl ends `completed` (nothing left, or a cap hit: `stop_reason`
|
|
78
|
+
`max_items` / `max_pages`), `stopped` (you stopped it) or `failed` (`error.code`
|
|
79
|
+
and `error.detail` say why). Only `failed` exits non-zero (`CRAWL_FAILED`).
|
|
80
|
+
`stop` on a crawl that already ended prints the status it ended with.
|
|
81
|
+
|
|
82
|
+
With `--json`, `create`, `get`, `wait` and `stop` print
|
|
83
|
+
`{ok, crawl_id, status, crawl}`; read `coverage`, `stop_reason` and `error`
|
|
84
|
+
under `crawl`. `get` adds `results` and `next_cursor`. `download` prints
|
|
85
|
+
`status: null` when the API sends no status.
|
|
86
|
+
|
|
87
|
+
## Limits
|
|
88
|
+
|
|
89
|
+
When the account has reached its limit of active jobs (3 by default), shared
|
|
90
|
+
with its Batch jobs, a create gets 429 `too_many_crawls` →
|
|
91
|
+
`CRAWL_TOO_MANY_CRAWLS`; nothing was created. Retry after `retry_after`
|
|
92
|
+
seconds (about 30), or stop one of your crawls or Batch jobs first. Any other
|
|
93
|
+
429 is `CRAWL_RATE_LIMITED`: requests are being throttled; retry after
|
|
94
|
+
`retry_after` seconds.
|
|
95
|
+
|
|
96
|
+
## Errors
|
|
97
|
+
|
|
98
|
+
Every Crawl error carries the API's code as `server_code` (when the API sent
|
|
99
|
+
one), and `crawl_id` on any call about one crawl.
|
|
100
|
+
|
|
101
|
+
The CLI retries transient failures, as the Crawl SDKs do: up to 3 retries,
|
|
102
|
+
waiting the `Retry-After` seconds (at most 30) or 250 ms x 2^n (+/-20% jitter,
|
|
103
|
+
at most 10 s). Reads retry on 429, 502, 503, 504 and network errors (a timeout
|
|
104
|
+
counts). `create` retries only with `--idempotency-key`, and only on 502, 503,
|
|
105
|
+
504 and network errors, never on 429. `stop` retries on 429, 502, 503 (including
|
|
106
|
+
`crawl_busy`) and 504, waiting the `Retry-After` seconds (at most 30) or 1 s.
|
|
107
|
+
The "Retry?" column is for errors that remain after these retries.
|
|
108
|
+
|
|
109
|
+
| Code | Meaning | Retry? |
|
|
110
|
+
| --- | --- | --- |
|
|
111
|
+
| `CRAWL_NOT_ENABLED` | 403 `REQS008`: Crawl is not enabled for this account | no |
|
|
112
|
+
| `AUTH_INVALID` | 401: the API key was rejected | no, log in again |
|
|
113
|
+
| `CRAWL_INSUFFICIENT_CREDITS` | 402: the account is out of credits (claim the account or add credits) | after credits renew or are added |
|
|
114
|
+
| `CRAWL_KEY_CAP_REACHED` | 402 `AUTH014` or `api_key_cap_reached`: this API key reached its credit cap | after the cap resets or is raised |
|
|
115
|
+
| `CRAWL_INVALID_REQUEST` | 400 or 422: fix the request. For `idempotency_key_reused`, send a new key or no key | no, not as is |
|
|
116
|
+
| `CRAWL_REQUEST_IN_FLIGHT` | 409: the same Idempotency-Key is still in flight | yes, once the first request ends |
|
|
117
|
+
| `CRAWL_NOT_FOUND` / `CRAWL_CONTENT_NOT_FOUND` | 404: wrong id, or the page has no content | no |
|
|
118
|
+
| `CRAWL_TOO_MANY_CRAWLS` | 429 `too_many_crawls` on `create`: the account's active-job limit | yes, after `retry_after` |
|
|
119
|
+
| `CRAWL_RATE_LIMITED` | any other 429: requests are being throttled | yes, after `retry_after` |
|
|
120
|
+
| `CRAWL_BUSY` | 503 `crawl_busy` on `stop`: the stop was not saved yet and the crawl still runs | yes, run the stop again after `retry_after` |
|
|
121
|
+
| `CRAWL_FAILED` | the crawl ended `failed`, or another error | 5xx: yes, with backoff |
|
|
122
|
+
| `BACKEND_UNAVAILABLE` | the API could not be reached | yes |
|
|
123
|
+
|
|
124
|
+
Rules: start small (`--depth 1`, low caps) and check the results before a
|
|
125
|
+
deep crawl. Mind the cost ([[cost-control]]). Read errors with
|
|
126
|
+
[[trace-debug]].
|
|
@@ -20,9 +20,10 @@ zenrows trace export <run-id> # JSON for sharing
|
|
|
20
20
|
|
|
21
21
|
## Failure → action map
|
|
22
22
|
- `FETCH_FAILED` / empty content → first retry in auto mode (`mode=auto`), which
|
|
23
|
-
escalates for you and bills only for what succeeds.
|
|
24
|
-
|
|
25
|
-
|
|
23
|
+
escalates for you and bills only for what succeeds. Only when auto mode has
|
|
24
|
+
failed on its own, take manual control with `--manual --js-render`, then
|
|
25
|
+
`--premium-proxy`. For slow pages, add `--wait-for <selector>` to a
|
|
26
|
+
`--manual --js-render` run; auto mode may ignore it. Each step
|
|
26
27
|
multiplies the cost of the request, and both together are the most expensive
|
|
27
28
|
configuration available ([[cost-control]]).
|
|
28
29
|
- `REQUEST_TIMEOUT` → the CLI stopped waiting; the API was reached. Raise
|
|
@@ -44,6 +45,18 @@ zenrows trace export <run-id> # JSON for sharing
|
|
|
44
45
|
auto-created Free plan.
|
|
45
46
|
- `BATCH_FAILED` → the batch run ended `failed`; `failure_reason` and
|
|
46
47
|
`failure_detail` in `zenrows batch status <id> --json` say why.
|
|
48
|
+
- `CRAWL_NOT_ENABLED` → the API answered 403 `REQS008`: Crawl is not
|
|
49
|
+
enabled for this account. Do not retry; ask Zenrows support for access.
|
|
50
|
+
- `CRAWL_INSUFFICIENT_CREDITS` / `CRAWL_KEY_CAP_REACHED` → Crawl's 402: the
|
|
51
|
+
account is out of credits, or this key reached its credit cap (`AUTH014`).
|
|
52
|
+
Same advice as `POLICY_MAX_CREDITS_EXCEEDED` / `KEY_CREDIT_CAP_REACHED`.
|
|
53
|
+
- `CRAWL_TOO_MANY_CRAWLS` → 429 `too_many_crawls`: the account has reached
|
|
54
|
+
its limit of active jobs (3 by default), shared with its Batch jobs.
|
|
55
|
+
Nothing was created; retry after `retry_after` seconds (see
|
|
56
|
+
`zenrows crawl list`).
|
|
57
|
+
- `CRAWL_FAILED` → the crawl ended `failed`; `error.code` in
|
|
58
|
+
`zenrows crawl get <id> --json` says why, with `error.detail`. See
|
|
59
|
+
[[crawl]].
|
|
47
60
|
- `PARAM_CONFLICT_AUTO_MANUAL` → drop the managed flags or add `--manual`.
|
|
48
61
|
- `CAPABILITY_UNAVAILABLE` → the primitive is not available on this account (e.g. beta/invite-only); use the local-spec path where offered.
|
|
49
62
|
|