@zenrows/cli 1.5.5 → 1.6.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.
|
@@ -35,8 +35,11 @@ export const crawl = {
|
|
|
35
35
|
" the local policy max_pages_per_run caps it (default 1000)",
|
|
36
36
|
" --include-patterns <p> keep only URLs containing this substring (repeatable)",
|
|
37
37
|
" --exclude-patterns <p> drop URLs containing this substring (repeatable)",
|
|
38
|
-
" --output-format html
|
|
39
|
-
" each kept page is one more
|
|
38
|
+
" --output-format <html|markdown> also fetch each kept URL's page, as HTML or Markdown",
|
|
39
|
+
" (read with content/download); each kept page is one more",
|
|
40
|
+
" fetch counted by --max-pages",
|
|
41
|
+
" --discovery links how the crawl finds the URLs it follows; links (the",
|
|
42
|
+
" default) follows the links in each page's HTML",
|
|
40
43
|
" --follow wait until the crawl ends (Ctrl-C stops the wait, not the crawl)",
|
|
41
44
|
" --timeout <s> with --follow: stop waiting after s seconds (default 600)",
|
|
42
45
|
" --idempotency-key <key> so a retried create starts no second crawl",
|
|
@@ -44,13 +47,15 @@ export const crawl = {
|
|
|
44
47
|
" get <id> [--cursor <c>] [--limit <n>]",
|
|
45
48
|
" the crawl and one page of its results (limit up to 10000)",
|
|
46
49
|
" list [--cursor <c>] [--limit <n>] your crawls, newest first (limit up to 100)",
|
|
47
|
-
" results <id> [--limit <n>] every kept URL as JSONL;
|
|
48
|
-
"
|
|
50
|
+
" results <id> [--limit <n>] every kept URL as JSONL; partial while more can come (a running",
|
|
51
|
+
" crawl, or pages still in flight after a stop). --limit is the page size",
|
|
52
|
+
" per request",
|
|
49
53
|
" --out <file> write the results to a file instead of printing",
|
|
50
54
|
" content <id> <content_id> print one kept URL's page (content_id or content_url)",
|
|
51
55
|
" --out <file> write it to a file instead",
|
|
52
56
|
" download <id> [--out <file>] save the NDJSON export (with page HTML for html crawls),",
|
|
53
|
-
" default <id>.jsonl; partial while
|
|
57
|
+
" default <id>.jsonl; partial while more can come (a running",
|
|
58
|
+
" crawl, or pages still in flight after a stop)",
|
|
54
59
|
" stop <id> stop a running crawl (an ended crawl answers as it ended)",
|
|
55
60
|
" wait <id> [--timeout <s>] poll until the crawl ends or s seconds (default 600) run out;",
|
|
56
61
|
" a crawl still running then is printed, not stopped",
|
|
@@ -102,6 +107,7 @@ async function createCmd(rest, ctx) {
|
|
|
102
107
|
"include-patterns": { type: "string", multiple: true },
|
|
103
108
|
"exclude-patterns": { type: "string", multiple: true },
|
|
104
109
|
"output-format": { type: "string" },
|
|
110
|
+
discovery: { type: "string", multiple: true },
|
|
105
111
|
follow: { type: "boolean" },
|
|
106
112
|
timeout: { type: "string" },
|
|
107
113
|
"idempotency-key": { type: "string" },
|
|
@@ -137,16 +143,29 @@ async function createCmd(rest, ctx) {
|
|
|
137
143
|
params.exclude_patterns = exclude;
|
|
138
144
|
const outputFormat = asString(values["output-format"]);
|
|
139
145
|
if (outputFormat !== undefined) {
|
|
140
|
-
if (outputFormat !== "html") {
|
|
146
|
+
if (outputFormat !== "html" && outputFormat !== "markdown") {
|
|
141
147
|
throw new ToolkitError({
|
|
142
148
|
code: "INVALID_USAGE",
|
|
143
149
|
message: `Invalid --output-format value '${outputFormat}'.`,
|
|
144
|
-
likely_cause: "--output-format takes html
|
|
145
|
-
next_action: "Pass --output-format html, or leave it out.",
|
|
150
|
+
likely_cause: "--output-format takes html or markdown. Without it, a crawl returns URLs only.",
|
|
151
|
+
next_action: "Pass --output-format html or --output-format markdown, or leave it out.",
|
|
146
152
|
});
|
|
147
153
|
}
|
|
148
154
|
params.output_format = outputFormat;
|
|
149
155
|
}
|
|
156
|
+
const discovery = patterns(values.discovery);
|
|
157
|
+
if (discovery.length) {
|
|
158
|
+
const other = discovery.find((d) => d !== "links");
|
|
159
|
+
if (other !== undefined) {
|
|
160
|
+
throw new ToolkitError({
|
|
161
|
+
code: "INVALID_USAGE",
|
|
162
|
+
message: `Invalid --discovery value '${other}'.`,
|
|
163
|
+
likely_cause: "--discovery takes links. Without it, the crawl follows the links in each page's HTML.",
|
|
164
|
+
next_action: "Pass --discovery links, or leave it out.",
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
params.discovery = ["links"];
|
|
168
|
+
}
|
|
150
169
|
const timeoutMs = timeoutFlag(values.timeout);
|
|
151
170
|
const idempotencyKey = asString(values["idempotency-key"]);
|
|
152
171
|
assertUsable("crawl");
|
|
@@ -269,14 +288,17 @@ async function downloadCmd(rest, ctx) {
|
|
|
269
288
|
assertUsable("crawl");
|
|
270
289
|
const apiKey = requireApiKey();
|
|
271
290
|
const { status, count } = await downloadCrawl(id, (ndjson) => writeOutStream(file, ndjson), { apiKey });
|
|
272
|
-
|
|
291
|
+
// A stop reads `stopped` at once, but the API still reads the pages in flight for up to 10
|
|
292
|
+
// minutes and builds the file from what it has kept until then. Its next_cursor says when it is done.
|
|
293
|
+
const partial = status === "running" || (status === "stopped" && (await listAllResults(id, { apiKey, pageSize: 10_000 })).partial);
|
|
294
|
+
const note = partial ? downloadNote(status) : undefined;
|
|
273
295
|
if (json) {
|
|
274
|
-
log.out(JSON.stringify({ ok: true, crawl_id: id, status: status ?? null, partial, count, file }, null, 2));
|
|
296
|
+
log.out(JSON.stringify({ ok: true, crawl_id: id, status: status ?? null, partial, ...(note ? { note } : {}), count, file }, null, 2));
|
|
275
297
|
}
|
|
276
298
|
else {
|
|
277
299
|
log.success(`Downloaded ${count} result(s) → ${file}`);
|
|
278
|
-
if (
|
|
279
|
-
log.warn(
|
|
300
|
+
if (note)
|
|
301
|
+
log.warn(` ${note}`);
|
|
280
302
|
}
|
|
281
303
|
return 0;
|
|
282
304
|
}
|
|
@@ -284,9 +306,10 @@ async function downloadCmd(rest, ctx) {
|
|
|
284
306
|
function printResults(id, status, results, o) {
|
|
285
307
|
if (o.outFile)
|
|
286
308
|
writeOut(o.outFile, toJsonl(results));
|
|
309
|
+
const note = o.partial ? partialNote(status) : undefined;
|
|
287
310
|
if (o.json) {
|
|
288
311
|
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));
|
|
312
|
+
log.out(JSON.stringify({ ok: true, crawl_id: id, status, count: results.length, ...rows, partial: o.partial, ...(note ? { note } : {}) }, null, 2));
|
|
290
313
|
return 0;
|
|
291
314
|
}
|
|
292
315
|
if (o.outFile) {
|
|
@@ -296,10 +319,20 @@ function printResults(id, status, results, o) {
|
|
|
296
319
|
log.info(`${results.length} result(s) for crawl ${id} (status: ${status}):`);
|
|
297
320
|
log.out(toJsonl(results).trimEnd());
|
|
298
321
|
}
|
|
299
|
-
if (
|
|
300
|
-
log.warn(
|
|
322
|
+
if (note)
|
|
323
|
+
log.warn(` ${note}`);
|
|
301
324
|
return 0;
|
|
302
325
|
}
|
|
326
|
+
function downloadNote(status) {
|
|
327
|
+
return status === "running"
|
|
328
|
+
? "The crawl is still running: this file is partial. Download again once it ends."
|
|
329
|
+
: "The crawl was stopped, but pages fetched before the stop can still add lines for up to 10 minutes: download again later.";
|
|
330
|
+
}
|
|
331
|
+
function partialNote(status) {
|
|
332
|
+
return isTerminal(status)
|
|
333
|
+
? "The crawl ended, but pages fetched before the end can still add URLs for up to 10 minutes: read the results again later."
|
|
334
|
+
: "The crawl is still running: these results are partial. Read them again once it ends.";
|
|
335
|
+
}
|
|
303
336
|
async function contentCmd(rest, ctx) {
|
|
304
337
|
const { values, positionals } = parse(rest, { out: { type: "string" }, json: { type: "boolean" } });
|
|
305
338
|
const json = ctx.json || values.json === true;
|
|
@@ -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.0"; // 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
|
}
|
|
@@ -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.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",
|
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`
|