@zenrows/cli 1.5.5 → 1.6.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 +6 -1
- package/dist/src/cli/commands/crawl.js +79 -27
- package/dist/src/core/batch-api.js +12 -0
- package/dist/src/core/config.js +1 -1
- package/dist/src/core/crawl-api.js +7 -5
- package/dist/src/core/errors.js +5 -2
- package/dist/src/core/open-url.js +1 -0
- package/package.json +1 -1
- package/skills/crawl/SKILL.md +20 -11
- package/skills/trace-debug/SKILL.md +5 -1
package/README.md
CHANGED
|
@@ -203,7 +203,12 @@ run out (default 600). When the time runs out, the command exits 0 and prints
|
|
|
203
203
|
the crawl with status `running` and the hint `zenrows crawl wait <crawl-id>`
|
|
204
204
|
(under `--json`, in `note`); the crawl keeps running. Ctrl-C stops the wait, not the crawl, and exits 130;
|
|
205
205
|
resume with `zenrows crawl wait <crawl-id>`. A crawl that ends `failed` exits 1
|
|
206
|
-
with `CRAWL_FAILED`.
|
|
206
|
+
with `CRAWL_FAILED`. A crawl that ends `stopped` because the account ran out of
|
|
207
|
+
credits or the API key hit a credit cap also exits 1, with the code a refused
|
|
208
|
+
create gets: `CRAWL_INSUFFICIENT_CREDITS` or `CRAWL_KEY_CAP_REACHED`, and the
|
|
209
|
+
API's `insufficient_credits` or `api_key_cap_reached` as `server_code`. Older
|
|
210
|
+
deployments end such a crawl `failed`; it gets the same codes. Its fetched pages
|
|
211
|
+
stay readable.
|
|
207
212
|
|
|
208
213
|
Errors carry the API's code as `server_code`, `crawl_id` on any call about one
|
|
209
214
|
crawl, and `retry_after` on a 429. When the account has reached its limit of
|
|
@@ -15,7 +15,8 @@ import { assertDomainAllowed, assertWithinLimits, loadPolicy } from "../../core/
|
|
|
15
15
|
import { newRunId, writeRun } from "../../core/artifacts.js";
|
|
16
16
|
import { contentIdOf, createCrawl, downloadCrawl, getCrawl, getCrawlContent, isTerminal, listAllResults, listCrawls, stopCrawl, waitForCrawl, } from "../../core/crawl-api.js";
|
|
17
17
|
import { asNumber, asString, parse } from "../command.js";
|
|
18
|
-
import { ToolkitError } from "../../core/errors.js";
|
|
18
|
+
import { ToolkitError, isKeyCapReached, keyCapReached, quotaExhausted } from "../../core/errors.js";
|
|
19
|
+
import { readAccount } from "../../core/agent-account.js";
|
|
19
20
|
import { printError, writeOut, writeOutStream } from "../output.js";
|
|
20
21
|
/** The API's default for `max_pages` (and `max_items`) when the flag is unset. */
|
|
21
22
|
const DEFAULT_MAX_PAGES = 100;
|
|
@@ -35,8 +36,11 @@ export const crawl = {
|
|
|
35
36
|
" the local policy max_pages_per_run caps it (default 1000)",
|
|
36
37
|
" --include-patterns <p> keep only URLs containing this substring (repeatable)",
|
|
37
38
|
" --exclude-patterns <p> drop URLs containing this substring (repeatable)",
|
|
38
|
-
" --output-format html
|
|
39
|
-
" each kept page is one more
|
|
39
|
+
" --output-format <html|markdown> also fetch each kept URL's page, as HTML or Markdown",
|
|
40
|
+
" (read with content/download); each kept page is one more",
|
|
41
|
+
" fetch counted by --max-pages",
|
|
42
|
+
" --discovery links how the crawl finds the URLs it follows; links (the",
|
|
43
|
+
" default) follows the links in each page's HTML",
|
|
40
44
|
" --follow wait until the crawl ends (Ctrl-C stops the wait, not the crawl)",
|
|
41
45
|
" --timeout <s> with --follow: stop waiting after s seconds (default 600)",
|
|
42
46
|
" --idempotency-key <key> so a retried create starts no second crawl",
|
|
@@ -44,13 +48,15 @@ export const crawl = {
|
|
|
44
48
|
" get <id> [--cursor <c>] [--limit <n>]",
|
|
45
49
|
" the crawl and one page of its results (limit up to 10000)",
|
|
46
50
|
" list [--cursor <c>] [--limit <n>] your crawls, newest first (limit up to 100)",
|
|
47
|
-
" results <id> [--limit <n>] every kept URL as JSONL;
|
|
48
|
-
"
|
|
51
|
+
" results <id> [--limit <n>] every kept URL as JSONL; partial while more can come (a running",
|
|
52
|
+
" crawl, or pages still in flight after a stop). --limit is the page size",
|
|
53
|
+
" per request",
|
|
49
54
|
" --out <file> write the results to a file instead of printing",
|
|
50
55
|
" content <id> <content_id> print one kept URL's page (content_id or content_url)",
|
|
51
56
|
" --out <file> write it to a file instead",
|
|
52
57
|
" download <id> [--out <file>] save the NDJSON export (with page HTML for html crawls),",
|
|
53
|
-
" default <id>.jsonl; partial while
|
|
58
|
+
" default <id>.jsonl; partial while more can come (a running",
|
|
59
|
+
" crawl, or pages still in flight after a stop)",
|
|
54
60
|
" stop <id> stop a running crawl (an ended crawl answers as it ended)",
|
|
55
61
|
" wait <id> [--timeout <s>] poll until the crawl ends or s seconds (default 600) run out;",
|
|
56
62
|
" a crawl still running then is printed, not stopped",
|
|
@@ -102,6 +108,7 @@ async function createCmd(rest, ctx) {
|
|
|
102
108
|
"include-patterns": { type: "string", multiple: true },
|
|
103
109
|
"exclude-patterns": { type: "string", multiple: true },
|
|
104
110
|
"output-format": { type: "string" },
|
|
111
|
+
discovery: { type: "string", multiple: true },
|
|
105
112
|
follow: { type: "boolean" },
|
|
106
113
|
timeout: { type: "string" },
|
|
107
114
|
"idempotency-key": { type: "string" },
|
|
@@ -137,16 +144,29 @@ async function createCmd(rest, ctx) {
|
|
|
137
144
|
params.exclude_patterns = exclude;
|
|
138
145
|
const outputFormat = asString(values["output-format"]);
|
|
139
146
|
if (outputFormat !== undefined) {
|
|
140
|
-
if (outputFormat !== "html") {
|
|
147
|
+
if (outputFormat !== "html" && outputFormat !== "markdown") {
|
|
141
148
|
throw new ToolkitError({
|
|
142
149
|
code: "INVALID_USAGE",
|
|
143
150
|
message: `Invalid --output-format value '${outputFormat}'.`,
|
|
144
|
-
likely_cause: "--output-format takes html
|
|
145
|
-
next_action: "Pass --output-format html, or leave it out.",
|
|
151
|
+
likely_cause: "--output-format takes html or markdown. Without it, a crawl returns URLs only.",
|
|
152
|
+
next_action: "Pass --output-format html or --output-format markdown, or leave it out.",
|
|
146
153
|
});
|
|
147
154
|
}
|
|
148
155
|
params.output_format = outputFormat;
|
|
149
156
|
}
|
|
157
|
+
const discovery = patterns(values.discovery);
|
|
158
|
+
if (discovery.length) {
|
|
159
|
+
const other = discovery.find((d) => d !== "links");
|
|
160
|
+
if (other !== undefined) {
|
|
161
|
+
throw new ToolkitError({
|
|
162
|
+
code: "INVALID_USAGE",
|
|
163
|
+
message: `Invalid --discovery value '${other}'.`,
|
|
164
|
+
likely_cause: "--discovery takes links. Without it, the crawl follows the links in each page's HTML.",
|
|
165
|
+
next_action: "Pass --discovery links, or leave it out.",
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
params.discovery = ["links"];
|
|
169
|
+
}
|
|
150
170
|
const timeoutMs = timeoutFlag(values.timeout);
|
|
151
171
|
const idempotencyKey = asString(values["idempotency-key"]);
|
|
152
172
|
assertUsable("crawl");
|
|
@@ -269,14 +289,17 @@ async function downloadCmd(rest, ctx) {
|
|
|
269
289
|
assertUsable("crawl");
|
|
270
290
|
const apiKey = requireApiKey();
|
|
271
291
|
const { status, count } = await downloadCrawl(id, (ndjson) => writeOutStream(file, ndjson), { apiKey });
|
|
272
|
-
|
|
292
|
+
// A stop reads `stopped` at once, but the API still reads the pages in flight for up to 10
|
|
293
|
+
// minutes and builds the file from what it has kept until then. Its next_cursor says when it is done.
|
|
294
|
+
const partial = status === "running" || (status === "stopped" && (await listAllResults(id, { apiKey, pageSize: 10_000 })).partial);
|
|
295
|
+
const note = partial ? downloadNote(status) : undefined;
|
|
273
296
|
if (json) {
|
|
274
|
-
log.out(JSON.stringify({ ok: true, crawl_id: id, status: status ?? null, partial, count, file }, null, 2));
|
|
297
|
+
log.out(JSON.stringify({ ok: true, crawl_id: id, status: status ?? null, partial, ...(note ? { note } : {}), count, file }, null, 2));
|
|
275
298
|
}
|
|
276
299
|
else {
|
|
277
300
|
log.success(`Downloaded ${count} result(s) → ${file}`);
|
|
278
|
-
if (
|
|
279
|
-
log.warn(
|
|
301
|
+
if (note)
|
|
302
|
+
log.warn(` ${note}`);
|
|
280
303
|
}
|
|
281
304
|
return 0;
|
|
282
305
|
}
|
|
@@ -284,9 +307,10 @@ async function downloadCmd(rest, ctx) {
|
|
|
284
307
|
function printResults(id, status, results, o) {
|
|
285
308
|
if (o.outFile)
|
|
286
309
|
writeOut(o.outFile, toJsonl(results));
|
|
310
|
+
const note = o.partial ? partialNote(status) : undefined;
|
|
287
311
|
if (o.json) {
|
|
288
312
|
const rows = o.outFile ? { file: o.outFile } : { results };
|
|
289
|
-
log.out(JSON.stringify({ ok: true, crawl_id: id, status, count: results.length, ...rows, partial: o.partial }, null, 2));
|
|
313
|
+
log.out(JSON.stringify({ ok: true, crawl_id: id, status, count: results.length, ...rows, partial: o.partial, ...(note ? { note } : {}) }, null, 2));
|
|
290
314
|
return 0;
|
|
291
315
|
}
|
|
292
316
|
if (o.outFile) {
|
|
@@ -296,10 +320,20 @@ function printResults(id, status, results, o) {
|
|
|
296
320
|
log.info(`${results.length} result(s) for crawl ${id} (status: ${status}):`);
|
|
297
321
|
log.out(toJsonl(results).trimEnd());
|
|
298
322
|
}
|
|
299
|
-
if (
|
|
300
|
-
log.warn(
|
|
323
|
+
if (note)
|
|
324
|
+
log.warn(` ${note}`);
|
|
301
325
|
return 0;
|
|
302
326
|
}
|
|
327
|
+
function downloadNote(status) {
|
|
328
|
+
return status === "running"
|
|
329
|
+
? "The crawl is still running: this file is partial. Download again once it ends."
|
|
330
|
+
: "The crawl was stopped, but pages fetched before the stop can still add lines for up to 10 minutes: download again later.";
|
|
331
|
+
}
|
|
332
|
+
function partialNote(status) {
|
|
333
|
+
return isTerminal(status)
|
|
334
|
+
? "The crawl ended, but pages fetched before the end can still add URLs for up to 10 minutes: read the results again later."
|
|
335
|
+
: "The crawl is still running: these results are partial. Read them again once it ends.";
|
|
336
|
+
}
|
|
303
337
|
async function contentCmd(rest, ctx) {
|
|
304
338
|
const { values, positionals } = parse(rest, { out: { type: "string" }, json: { type: "boolean" } });
|
|
305
339
|
const json = ctx.json || values.json === true;
|
|
@@ -382,7 +416,8 @@ async function stopCmd(rest, ctx) {
|
|
|
382
416
|
assertUsable("crawl");
|
|
383
417
|
const apiKey = requireApiKey();
|
|
384
418
|
const s = await stopCrawl(id, { apiKey });
|
|
385
|
-
|
|
419
|
+
// A crawl stopped on credits carries `error`: it had already ended, this call did not stop it.
|
|
420
|
+
const headline = s.status === "stopped" && !s.error ? `Stopped crawl ${id}` : `Nothing to stop: crawl ${id} had already ended`;
|
|
386
421
|
return printCrawl(s, ctx.json || values.json === true, headline);
|
|
387
422
|
}
|
|
388
423
|
/**
|
|
@@ -404,33 +439,50 @@ async function waitUntilEnd(id, opts) {
|
|
|
404
439
|
}
|
|
405
440
|
}
|
|
406
441
|
/**
|
|
407
|
-
* The error for a crawl that ended `failed`, or
|
|
408
|
-
*
|
|
442
|
+
* The error for a crawl that ended `failed`, or `stopped` with an `error` (the
|
|
443
|
+
* account ran out of credits or the API key hit a credit cap), or null for any
|
|
444
|
+
* other state. A crawl the credits ended gets the code and advice a create
|
|
445
|
+
* refused with 402 gets (`CRAWL_INSUFFICIENT_CREDITS`, `CRAWL_KEY_CAP_REACHED`),
|
|
446
|
+
* whether the API reports it `stopped` or, as older deployments do, `failed`.
|
|
447
|
+
* Any other `error.code` picks the next action of a `CRAWL_FAILED`.
|
|
409
448
|
*/
|
|
410
449
|
export function crawlFailure(c) {
|
|
411
|
-
|
|
450
|
+
const creditStop = c.status === "stopped" && !!c.error;
|
|
451
|
+
if (c.status !== "failed" && !creditStop)
|
|
412
452
|
return null;
|
|
413
453
|
const code = c.error?.code;
|
|
454
|
+
const ids = { server_code: code, crawl_id: c.crawl_id };
|
|
455
|
+
const where = `crawl ${c.crawl_id}`;
|
|
456
|
+
const detail = c.error?.detail || undefined;
|
|
457
|
+
const getCmd = `zenrows crawl get ${c.crawl_id}`;
|
|
458
|
+
if (code && isKeyCapReached(code)) {
|
|
459
|
+
const cap = keyCapReached(where, { status: null, detail }).toJSON();
|
|
460
|
+
return new ToolkitError({ ...cap, ...ids, code: "CRAWL_KEY_CAP_REACHED", suggested_commands: [...(cap.suggested_commands ?? []), getCmd] });
|
|
461
|
+
}
|
|
462
|
+
if (code === "insufficient_credits") {
|
|
463
|
+
const acct = readAccount();
|
|
464
|
+
const quota = quotaExhausted(where, acct?.unclaimed ? acct.claimUrl : undefined, { status: null, detail }).toJSON();
|
|
465
|
+
return new ToolkitError({ ...quota, ...ids, code: "CRAWL_INSUFFICIENT_CREDITS", suggested_commands: [...(quota.suggested_commands ?? []), getCmd] });
|
|
466
|
+
}
|
|
414
467
|
const why = [code, c.error?.detail].filter(Boolean).join(": ");
|
|
415
468
|
const next = {
|
|
416
|
-
insufficient_credits: "The account ran out of credits. Check `zenrows usage`, add credits, then start a new crawl.",
|
|
417
469
|
seed_unreachable: "The start URL could not be fetched (the site may be blocking it). Check it with `zenrows fetch <url>`.",
|
|
418
470
|
no_items_found: "Nothing matched. Loosen --include-patterns / --exclude-patterns, raise or drop --depth, or check the start page with `zenrows fetch <url>`.",
|
|
419
471
|
};
|
|
420
472
|
return new ToolkitError({
|
|
421
473
|
code: "CRAWL_FAILED",
|
|
422
|
-
message: `Crawl ${c.crawl_id} failed.`,
|
|
474
|
+
message: creditStop ? `Crawl ${c.crawl_id} stopped before it finished.` : `Crawl ${c.crawl_id} failed.`,
|
|
423
475
|
likely_cause: why || "The crawl ended in status failed without a reason.",
|
|
424
476
|
next_action: (code && next[code]) || "Read the error, fix the cause, then start a new crawl.",
|
|
425
|
-
suggested_commands: [
|
|
426
|
-
|
|
427
|
-
crawl_id: c.crawl_id,
|
|
477
|
+
suggested_commands: [getCmd],
|
|
478
|
+
...ids,
|
|
428
479
|
});
|
|
429
480
|
}
|
|
430
481
|
/**
|
|
431
482
|
* Print a crawl (or a stop answer), structured under --json, and return the exit
|
|
432
|
-
* code: 1 when it ended `failed`,
|
|
433
|
-
*
|
|
483
|
+
* code: 1 when it ended `failed`, or `stopped` because credits ran out or the
|
|
484
|
+
* API key hit a credit cap, else 0. A `stopped` crawl the caller stopped is a
|
|
485
|
+
* deliberate outcome, so it exits 0 but prints as a warning. `extra` carries the results a `get` read,
|
|
434
486
|
* or the note of a wait that ran out.
|
|
435
487
|
*/
|
|
436
488
|
function printCrawl(c, json, headline, extra = {}) {
|
|
@@ -21,6 +21,7 @@ import { readAccount } from "./agent-account.js";
|
|
|
21
21
|
import { registerSecret } from "./logger.js";
|
|
22
22
|
import { CLI_USER_AGENT } from "./config.js";
|
|
23
23
|
import { agentClientHeader } from "./agent-client.js";
|
|
24
|
+
import { BILLING_URL } from "./open-url.js";
|
|
24
25
|
/** Confirmed Batch API base (no trailing slash). */
|
|
25
26
|
export const DEFAULT_BATCH_API_BASE = "https://async.api.zenrows.com/v1";
|
|
26
27
|
/** Env var to override the Batch API base (local/staging testing). */
|
|
@@ -116,6 +117,17 @@ function problemToError(status, body, method, path) {
|
|
|
116
117
|
suggested_commands: ["zenrows batch estimate jobs.jsonl"],
|
|
117
118
|
});
|
|
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
|
+
});
|
|
130
|
+
}
|
|
119
131
|
if (status === 401) {
|
|
120
132
|
return new ToolkitError({
|
|
121
133
|
code: "AUTH_INVALID",
|
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.6.1"; // x-release-please-version
|
|
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). */
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* Crawl SDKs do (see `retryStatuses`). `fetchImpl` / `sleepImpl` are injectable
|
|
11
11
|
* for tests.
|
|
12
12
|
*
|
|
13
|
-
* `output_format` takes `html`
|
|
13
|
+
* `output_format` takes `html` or `markdown` (absent = URLs only). Response fields we do
|
|
14
14
|
* not model pass through untouched.
|
|
15
15
|
*/
|
|
16
16
|
import { Readable } from "node:stream";
|
|
@@ -297,6 +297,8 @@ export function createCrawl(params, opts) {
|
|
|
297
297
|
body.exclude_patterns = params.exclude_patterns;
|
|
298
298
|
if (params.output_format !== undefined)
|
|
299
299
|
body.output_format = params.output_format;
|
|
300
|
+
if (params.discovery?.length)
|
|
301
|
+
body.discovery = params.discovery;
|
|
300
302
|
return crawlJson("POST", "/crawls", {
|
|
301
303
|
...opts,
|
|
302
304
|
body,
|
|
@@ -319,7 +321,7 @@ export function listCrawls(opts) {
|
|
|
319
321
|
export function stopCrawl(id, opts) {
|
|
320
322
|
return crawlJson("POST", `/crawls/${encodeURIComponent(id)}/stop`, { ...opts, crawlId: id, retryStop: true });
|
|
321
323
|
}
|
|
322
|
-
/** The page of one kept URL
|
|
324
|
+
/** The page of one kept URL, in the crawl's `output_format`. */
|
|
323
325
|
export async function getCrawlContent(id, contentId, opts) {
|
|
324
326
|
const res = await crawlFetch("GET", `/crawls/${encodeURIComponent(id)}/contents/${encodeURIComponent(contentId)}`, {
|
|
325
327
|
...opts,
|
|
@@ -390,9 +392,9 @@ export async function listAllResults(id, opts) {
|
|
|
390
392
|
const rows = page.results ?? [];
|
|
391
393
|
results.push(...rows);
|
|
392
394
|
if (!rows.length || !page.next_cursor || page.next_cursor === cursor) {
|
|
393
|
-
// Every page was read.
|
|
394
|
-
//
|
|
395
|
-
return { crawl: page, results, partial:
|
|
395
|
+
// Every page was read. The API keeps next_cursor set while URLs can still come: on a
|
|
396
|
+
// running crawl, and for up to 10 minutes after a stop, from the pages still in flight.
|
|
397
|
+
return { crawl: page, results, partial: page.next_cursor != null };
|
|
396
398
|
}
|
|
397
399
|
cursor = page.next_cursor;
|
|
398
400
|
}
|
package/dist/src/core/errors.js
CHANGED
|
@@ -45,7 +45,9 @@ export class ToolkitError extends Error {
|
|
|
45
45
|
* (so `claimUrl` is surfaced); for a real logged-in account there is nothing to
|
|
46
46
|
* claim — the step is to add credits / upgrade in the dashboard. `status`/`detail`
|
|
47
47
|
* let the caller record the actual upstream response (402 usage-limit, 429
|
|
48
|
-
* quota, …) instead of a hardcoded status.
|
|
48
|
+
* quota, …) instead of a hardcoded status. `status: null` is for a crawl the
|
|
49
|
+
* credits stopped: its status call succeeded, so there is no HTTP error to cite,
|
|
50
|
+
* only `url` (the crawl).
|
|
49
51
|
*/
|
|
50
52
|
export function quotaExhausted(url, claimUrl, opts = {}) {
|
|
51
53
|
// Say that the allowance comes back. Without it this reads as a permanent paywall,
|
|
@@ -58,10 +60,11 @@ export function quotaExhausted(url, claimUrl, opts = {}) {
|
|
|
58
60
|
? `You are on the Zenrows Free plan. Claim your account to keep your usage and add credits: ${claimUrl}. ${renewLine}`
|
|
59
61
|
: `You are out of Zenrows credits. ${renewLine} To carry on now, add a credit pack (${BILLING_TOPUP_URL} opens the purchase directly) or upgrade your plan: ${PLANS_URL}`;
|
|
60
62
|
const detail = opts.detail ? `${opts.detail.replace(/\.\s*$/, "")}. ` : "";
|
|
63
|
+
const where = opts.status === null ? `Stopped ${url}` : `HTTP ${opts.status ?? 429} for ${url}`;
|
|
61
64
|
return new ToolkitError({
|
|
62
65
|
code: "POLICY_MAX_CREDITS_EXCEEDED",
|
|
63
66
|
message: "Zenrows request quota exhausted.",
|
|
64
|
-
likely_cause: `${detail}
|
|
67
|
+
likely_cause: `${detail}${where}`,
|
|
65
68
|
next_action: claimLine,
|
|
66
69
|
suggested_commands: ["zenrows usage"],
|
|
67
70
|
});
|
|
@@ -12,6 +12,7 @@ export const DASHBOARD_URL = "https://app.zenrows.com/dashboard";
|
|
|
12
12
|
/** Lands on Billing with the credit-pack purchase modal already open (app PR #1972). */
|
|
13
13
|
export const BILLING_TOPUP_URL = "https://app.zenrows.com/billing?topup=open";
|
|
14
14
|
export const PLANS_URL = "https://app.zenrows.com/plans";
|
|
15
|
+
export const BILLING_URL = "https://app.zenrows.com/billing";
|
|
15
16
|
export const DOCS_URL = "https://docs.zenrows.com";
|
|
16
17
|
/** Append query params to a URL (used to attach anonymous `utm_*` attribution). */
|
|
17
18
|
export function withParams(url, params) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zenrows/cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.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",
|
package/skills/crawl/SKILL.md
CHANGED
|
@@ -11,8 +11,8 @@ Give Crawl one start URL; it follows the links on that page (and on the
|
|
|
11
11
|
pages behind it, up to the depth you set) and returns the URLs it keeps. Use it
|
|
12
12
|
when you know where the items live (a category page, a blog index, a docs
|
|
13
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
|
|
15
|
-
with `--output-format html`.
|
|
14
|
+
with `zenrows fetch` / `zenrows extract`, or ask Crawl for each page itself
|
|
15
|
+
with `--output-format html` or `--output-format markdown`.
|
|
16
16
|
|
|
17
17
|
Crawl stays on the start URL's registrable domain; subdomains count. An account without Crawl access gets
|
|
18
18
|
403 `REQS008` → `CRAWL_NOT_ENABLED` ("Crawl is not enabled for this account");
|
|
@@ -50,16 +50,18 @@ zenrows crawl results <id> --out urls.jsonl # same, as JSONL
|
|
|
50
50
|
`partial: true`. Read again once the crawl ends. `--limit` sets the page size
|
|
51
51
|
of each request.
|
|
52
52
|
|
|
53
|
-
## Page
|
|
53
|
+
## Page content
|
|
54
54
|
|
|
55
55
|
```
|
|
56
56
|
zenrows crawl create <url> --depth 1 --output-format html --max-pages 20 --follow
|
|
57
|
+
zenrows crawl create <url> --depth 1 --output-format markdown --max-pages 20 --follow
|
|
57
58
|
zenrows crawl results <id> --json # fetched results carry content_url
|
|
58
|
-
zenrows crawl content <id> <content_id> # one page
|
|
59
|
-
zenrows crawl download <id> --out pages.jsonl # every URL +
|
|
59
|
+
zenrows crawl content <id> <content_id> # one page (or pass the content_url)
|
|
60
|
+
zenrows crawl download <id> --out pages.jsonl # every URL + page, NDJSON
|
|
60
61
|
```
|
|
61
62
|
|
|
62
|
-
`--output-format html`
|
|
63
|
+
`--output-format html` stores each page as fetched; `--output-format markdown`
|
|
64
|
+
stores it converted to Markdown. Either uses up the page budget: each kept page is one more
|
|
63
65
|
fetch, and `--max-pages` counts it. Raise `--max-pages` to keep the same number
|
|
64
66
|
of URLs. A result's `content_status` is `pending`, `fetched` or `failed`; only
|
|
65
67
|
`fetched` results have content (else `CRAWL_CONTENT_NOT_FOUND`). `download`
|
|
@@ -75,8 +77,15 @@ zenrows crawl stop <id> # stop it; kept URLs stay readable
|
|
|
75
77
|
```
|
|
76
78
|
|
|
77
79
|
A crawl ends `completed` (nothing left, or a cap hit: `stop_reason`
|
|
78
|
-
`max_items` / `max_pages`), `stopped`
|
|
79
|
-
|
|
80
|
+
`max_items` / `max_pages`), `stopped` or `failed` (`error.code` and
|
|
81
|
+
`error.detail` say why). `stopped` with `stop_reason` `user` means you stopped
|
|
82
|
+
it. `stopped` with no `stop_reason` and `error.code` `insufficient_credits` or
|
|
83
|
+
`api_key_cap_reached` means the account ran out of credits or the API key hit a
|
|
84
|
+
credit cap; the pages fetched before that stay readable. `failed`, and a
|
|
85
|
+
`stopped` crawl with an `error`, exit non-zero: `CRAWL_INSUFFICIENT_CREDITS` or
|
|
86
|
+
`CRAWL_KEY_CAP_REACHED` when credits ended it (the same codes a refused
|
|
87
|
+
`create` gets, also when an older deployment reports it `failed`), else
|
|
88
|
+
`CRAWL_FAILED`. `server_code` carries the API's `error.code`.
|
|
80
89
|
`stop` on a crawl that already ended prints the status it ended with.
|
|
81
90
|
|
|
82
91
|
With `--json`, `create`, `get`, `wait` and `stop` print
|
|
@@ -110,15 +119,15 @@ The "Retry?" column is for errors that remain after these retries.
|
|
|
110
119
|
| --- | --- | --- |
|
|
111
120
|
| `CRAWL_NOT_ENABLED` | 403 `REQS008`: Crawl is not enabled for this account | no |
|
|
112
121
|
| `AUTH_INVALID` | 401: the API key was rejected | no, log in again |
|
|
113
|
-
| `CRAWL_INSUFFICIENT_CREDITS` | 402
|
|
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 |
|
|
122
|
+
| `CRAWL_INSUFFICIENT_CREDITS` | 402 on `create`, or a crawl that ended on `insufficient_credits`: the account is out of credits (claim the account or add credits) | after credits renew or are added |
|
|
123
|
+
| `CRAWL_KEY_CAP_REACHED` | 402 `AUTH014` or `api_key_cap_reached` on `create`, or a crawl that ended on `api_key_cap_reached`: this API key reached its credit cap | after the cap resets or is raised |
|
|
115
124
|
| `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
125
|
| `CRAWL_REQUEST_IN_FLIGHT` | 409: the same Idempotency-Key is still in flight | yes, once the first request ends |
|
|
117
126
|
| `CRAWL_NOT_FOUND` / `CRAWL_CONTENT_NOT_FOUND` | 404: wrong id, or the page has no content | no |
|
|
118
127
|
| `CRAWL_TOO_MANY_CRAWLS` | 429 `too_many_crawls` on `create`: the account's active-job limit | yes, after `retry_after` |
|
|
119
128
|
| `CRAWL_RATE_LIMITED` | any other 429: requests are being throttled | yes, after `retry_after` |
|
|
120
129
|
| `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 |
|
|
130
|
+
| `CRAWL_FAILED` | the crawl ended `failed`, or `stopped` with an error other than credits, or another error | 5xx: yes, with backoff |
|
|
122
131
|
| `BACKEND_UNAVAILABLE` | the API could not be reached | yes |
|
|
123
132
|
|
|
124
133
|
Rules: start small (`--depth 1`, low caps) and check the results before a
|
|
@@ -49,12 +49,16 @@ zenrows trace export <run-id> # JSON for sharing
|
|
|
49
49
|
enabled for this account. Do not retry; ask Zenrows support for access.
|
|
50
50
|
- `CRAWL_INSUFFICIENT_CREDITS` / `CRAWL_KEY_CAP_REACHED` → Crawl's 402: the
|
|
51
51
|
account is out of credits, or this key reached its credit cap (`AUTH014`).
|
|
52
|
+
The same codes come back for a crawl that credits ended (`stopped`, or
|
|
53
|
+
`failed` on older deployments; `server_code` `insufficient_credits` /
|
|
54
|
+
`api_key_cap_reached`); its fetched pages stay readable.
|
|
52
55
|
Same advice as `POLICY_MAX_CREDITS_EXCEEDED` / `KEY_CREDIT_CAP_REACHED`.
|
|
53
56
|
- `CRAWL_TOO_MANY_CRAWLS` → 429 `too_many_crawls`: the account has reached
|
|
54
57
|
its limit of active jobs (3 by default), shared with its Batch jobs.
|
|
55
58
|
Nothing was created; retry after `retry_after` seconds (see
|
|
56
59
|
`zenrows crawl list`).
|
|
57
|
-
- `CRAWL_FAILED` → the crawl ended `failed
|
|
60
|
+
- `CRAWL_FAILED` → the crawl ended `failed`, or `stopped` with an error
|
|
61
|
+
other than credits; `error.code` in
|
|
58
62
|
`zenrows crawl get <id> --json` says why, with `error.detail`. See
|
|
59
63
|
[[crawl]].
|
|
60
64
|
- `PARAM_CONFLICT_AUTO_MANUAL` → drop the managed flags or add `--manual`.
|