@zenrows/cli 1.5.3 → 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,7 +35,7 @@ Or point any Agent Plugins–compatible client at that `agent-plugin` folder
35
35
 
36
36
  ### From git
37
37
 
38
- Clone [`zenrows/cli`](https://github.com/zenrows/cli) and use the repo-root `agent-plugin/` folder the same way.
38
+ Clone [`ZenRows/cli`](https://github.com/ZenRows/cli) and use the repo-root `agent-plugin/` folder the same way.
39
39
 
40
40
  Clients discover:
41
41
 
@@ -8,7 +8,7 @@
8
8
  "url": "https://www.zenrows.com"
9
9
  },
10
10
  "homepage": "https://www.zenrows.com",
11
- "repository": "https://github.com/zenrows/cli",
11
+ "repository": "https://github.com/ZenRows/cli",
12
12
  "license": "MIT",
13
13
  "keywords": [
14
14
  "zenrows",
@@ -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 also fetch each kept URL's page HTML (read with content/download);",
39
- " each kept page is one more fetch counted by --max-pages",
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; on a running crawl, those kept so far",
48
- " (partial). --limit is the page size per request",
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 the crawl runs",
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 only. Without it, a crawl returns URLs only.",
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
- const partial = status === "running";
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 (partial)
279
- log.warn(" The crawl is still running: this file is partial. Download again once it ends.");
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 (o.partial)
300
- log.warn(" The crawl is still running: these results are partial. Read them again once it ends.");
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",
@@ -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.5.3";
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` only (absent = URLs only). Response fields we do
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 (HTML for `output_format: html`). */
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. 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) };
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.5.3",
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",
@@ -8,10 +8,10 @@
8
8
  "homepage": "https://www.zenrows.com",
9
9
  "repository": {
10
10
  "type": "git",
11
- "url": "git+https://github.com/zenrows/cli.git"
11
+ "url": "git+https://github.com/ZenRows/cli.git"
12
12
  },
13
13
  "bugs": {
14
- "url": "https://github.com/zenrows/cli/issues"
14
+ "url": "https://github.com/ZenRows/cli/issues"
15
15
  },
16
16
  "keywords": [
17
17
  "zenrows",
@@ -51,8 +51,7 @@
51
51
  "test:e2e": "npm run build && node --test dist/tests/e2e/*.test.js",
52
52
  "typecheck": "tsc --noEmit",
53
53
  "build": "rm -rf dist && tsc && chmod +x dist/bin/zenrows.js",
54
- "prepack": "npm run build",
55
- "version": "node scripts/sync-version.mjs && git add src/core/config.ts"
54
+ "prepack": "npm run build"
56
55
  },
57
56
  "devDependencies": {
58
57
  "@types/node": "^22.10.0",
@@ -1,6 +1,6 @@
1
1
  ---
2
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.
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
4
  version: 0.1.0
5
5
  requires_backend_capabilities: [crawl]
6
6
  ---
@@ -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's HTML
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 HTML
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's HTML (or pass the content_url)
59
- zenrows crawl download <id> --out pages.jsonl # every URL + HTML, NDJSON
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` uses up the page budget: each kept page is one more
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`