@zenrows/cli 1.4.2 → 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.
@@ -0,0 +1,535 @@
1
+ /**
2
+ * `zenrows crawl` — Crawl API (new).
3
+ *
4
+ * Give Crawl one start URL and read back the URLs it finds behind it, optionally
5
+ * with each page's HTML. A crawl is an async job: `create` returns at once
6
+ * (or waits with `--follow`), then `get` / `wait` / `results` / `content` /
7
+ * `download` read it and `stop` ends it. An account without Crawl access gets
8
+ * 403 REQS008 → CRAWL_NOT_ENABLED.
9
+ */
10
+ import { log } from "../../core/logger.js";
11
+ import { requireApiKey } from "../../core/auth.js";
12
+ import { ensureApiKey } from "../../core/ensure-key.js";
13
+ import { assertUsable } from "../../core/capabilities.js";
14
+ import { assertDomainAllowed, assertWithinLimits, loadPolicy } from "../../core/policy.js";
15
+ import { newRunId, writeRun } from "../../core/artifacts.js";
16
+ import { contentIdOf, createCrawl, downloadCrawl, getCrawl, getCrawlContent, isTerminal, listAllResults, listCrawls, stopCrawl, waitForCrawl, } from "../../core/crawl-api.js";
17
+ import { asNumber, asString, parse } from "../command.js";
18
+ import { ToolkitError } from "../../core/errors.js";
19
+ import { printError, writeOut, writeOutStream } from "../output.js";
20
+ /** The API's default for `max_pages` (and `max_items`) when the flag is unset. */
21
+ const DEFAULT_MAX_PAGES = 100;
22
+ /** Largest `depth` / `max_items` / `max_pages` the API takes. */
23
+ const MAX_LIMIT = 100_000;
24
+ export const crawl = {
25
+ name: "crawl",
26
+ summary: "Crawl a site from one start URL and collect the URLs behind it (new).",
27
+ usage: "zenrows crawl <create <url> [--depth N]|get <id>|list|results <id>|content <id> <content_id>|download <id>|stop <id>|wait <id>>",
28
+ help: [
29
+ "Crawl is new. Cloud (needs a key with Crawl access):",
30
+ " create <url> [flags] start a crawl from one URL (returns at once)",
31
+ " --depth <n> link hops to follow from the start URL (1 to 100000);",
32
+ " leave it out for no depth limit (the caps below stop the crawl)",
33
+ " --max-items <n> stop after keeping n URLs (API default 100)",
34
+ " --max-pages <n> stop after fetching n pages (API default 100; bounds cost);",
35
+ " the local policy max_pages_per_run caps it (default 1000)",
36
+ " --include-patterns <p> keep only URLs containing this substring (repeatable)",
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",
40
+ " --follow wait until the crawl ends (Ctrl-C stops the wait, not the crawl)",
41
+ " --timeout <s> with --follow: stop waiting after s seconds (default 600)",
42
+ " --idempotency-key <key> so a retried create starts no second crawl",
43
+ " --no-signup do not auto-create a Free plan account if no key exists",
44
+ " get <id> [--cursor <c>] [--limit <n>]",
45
+ " the crawl and one page of its results (limit up to 10000)",
46
+ " 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",
49
+ " --out <file> write the results to a file instead of printing",
50
+ " content <id> <content_id> print one kept URL's page (content_id or content_url)",
51
+ " --out <file> write it to a file instead",
52
+ " download <id> [--out <file>] save the NDJSON export (with page HTML for html crawls),",
53
+ " default <id>.jsonl; partial while the crawl runs",
54
+ " stop <id> stop a running crawl (an ended crawl answers as it ended)",
55
+ " wait <id> [--timeout <s>] poll until the crawl ends or s seconds (default 600) run out;",
56
+ " a crawl still running then is printed, not stopped",
57
+ " --json print structured output: create/get/wait/stop print",
58
+ " {ok, crawl_id, status, crawl}; get adds results and next_cursor;",
59
+ " a wait that runs out adds note",
60
+ ].join("\n"),
61
+ async run(argv, ctx) {
62
+ const [sub, ...rest] = argv;
63
+ try {
64
+ switch (sub) {
65
+ case "create":
66
+ return await createCmd(rest, ctx);
67
+ case "get":
68
+ return await getCmd(rest, ctx);
69
+ case "list":
70
+ return await listCmd(rest, ctx);
71
+ case "results":
72
+ return await resultsCmd(rest, ctx);
73
+ case "content":
74
+ return await contentCmd(rest, ctx);
75
+ case "download":
76
+ return await downloadCmd(rest, ctx);
77
+ case "stop":
78
+ return await stopCmd(rest, ctx);
79
+ case "wait":
80
+ return await waitCmd(rest, ctx);
81
+ default:
82
+ throw new ToolkitError({
83
+ code: "INVALID_USAGE",
84
+ message: `Unknown crawl subcommand: ${sub ?? "(none)"}`,
85
+ likely_cause: "Subcommand not recognized.",
86
+ next_action: "Use create <url> [--depth N] | get <id> | list | results <id> | content <id> <content_id> | download <id> | stop <id> | wait <id>.",
87
+ suggested_commands: ["zenrows crawl --help"],
88
+ });
89
+ }
90
+ }
91
+ catch (err) {
92
+ printError(err, ctx.json);
93
+ return 1;
94
+ }
95
+ },
96
+ };
97
+ async function createCmd(rest, ctx) {
98
+ const { values, positionals } = parse(rest, {
99
+ depth: { type: "string" },
100
+ "max-items": { type: "string" },
101
+ "max-pages": { type: "string" },
102
+ "include-patterns": { type: "string", multiple: true },
103
+ "exclude-patterns": { type: "string", multiple: true },
104
+ "output-format": { type: "string" },
105
+ follow: { type: "boolean" },
106
+ timeout: { type: "string" },
107
+ "idempotency-key": { type: "string" },
108
+ "no-signup": { type: "boolean" },
109
+ json: { type: "boolean" },
110
+ });
111
+ const json = ctx.json || values.json === true;
112
+ const url = positionals[0];
113
+ if (!url) {
114
+ throw new ToolkitError({
115
+ code: "INVALID_USAGE",
116
+ message: "Provide the start URL.",
117
+ likely_cause: "No <url> positional was given.",
118
+ next_action: "Usage: zenrows crawl create <url> [--depth N]",
119
+ suggested_commands: ["zenrows crawl create https://example.com/ --depth 1"],
120
+ });
121
+ }
122
+ const params = { url };
123
+ const depth = limitFlag("depth", values.depth);
124
+ if (depth !== undefined)
125
+ params.depth = depth;
126
+ const maxItems = limitFlag("max-items", values["max-items"]);
127
+ if (maxItems !== undefined)
128
+ params.max_items = maxItems;
129
+ const maxPages = limitFlag("max-pages", values["max-pages"]);
130
+ if (maxPages !== undefined)
131
+ params.max_pages = maxPages;
132
+ const include = patterns(values["include-patterns"]);
133
+ if (include.length)
134
+ params.include_patterns = include;
135
+ const exclude = patterns(values["exclude-patterns"]);
136
+ if (exclude.length)
137
+ params.exclude_patterns = exclude;
138
+ const outputFormat = asString(values["output-format"]);
139
+ if (outputFormat !== undefined) {
140
+ if (outputFormat !== "html") {
141
+ throw new ToolkitError({
142
+ code: "INVALID_USAGE",
143
+ 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.",
146
+ });
147
+ }
148
+ params.output_format = outputFormat;
149
+ }
150
+ const timeoutMs = timeoutFlag(values.timeout);
151
+ const idempotencyKey = asString(values["idempotency-key"]);
152
+ assertUsable("crawl");
153
+ // Pre-flight governance, all local: the start URL must pass the domain policy,
154
+ // and max_pages (each page is one fetch) must fit the per-run page cap.
155
+ const policy = loadPolicy();
156
+ assertDomainAllowed(url, policy);
157
+ assertWithinLimits({ pages: maxPages ?? DEFAULT_MAX_PAGES }, policy, "crawl");
158
+ const apiKey = await ensureApiKey(values["no-signup"] ? { ...policy, auto_signup: false } : policy, {
159
+ onProvision: (a) => {
160
+ log.info("No API key found — created a Zenrows Free plan account for you.");
161
+ log.dim(`Claim it anytime (keeps your usage): ${a.claimUrl}`);
162
+ },
163
+ });
164
+ const runId = newRunId();
165
+ const startedAt = new Date().toISOString();
166
+ log.step(`Starting crawl of ${url} (${depth === undefined ? "no depth limit" : `depth ${depth}`})…`);
167
+ try {
168
+ const created = await createCrawl(params, { apiKey, idempotencyKey });
169
+ const follow = values.follow === true;
170
+ const finished = follow ? await waitUntilEnd(created.crawl_id, { apiKey, timeoutMs }) : created;
171
+ const failure = crawlFailure(finished);
172
+ const runDir = writeRun({
173
+ runId,
174
+ command: "zenrows crawl create",
175
+ capability: "crawl",
176
+ url,
177
+ startedAt,
178
+ finishedAt: new Date().toISOString(),
179
+ status: failure ? "error" : "ok",
180
+ request: { ...params },
181
+ result: { crawlId: created.crawl_id, status: finished.status, coverage: finished.coverage },
182
+ ...(failure ? { error: failure.toJSON() } : {}),
183
+ });
184
+ const code = follow
185
+ ? printCrawl(finished, json, waitHeadline(finished), { note: waitNote(finished) })
186
+ : printCrawl(finished, json, `Started crawl ${created.crawl_id}`);
187
+ if (!json && runDir)
188
+ log.dim(` artifact: ${runDir}`);
189
+ return code;
190
+ }
191
+ catch (err) {
192
+ writeRun({
193
+ runId,
194
+ command: "zenrows crawl create",
195
+ capability: "crawl",
196
+ url,
197
+ startedAt,
198
+ finishedAt: new Date().toISOString(),
199
+ status: "error",
200
+ request: { ...params },
201
+ error: err instanceof ToolkitError ? err.toJSON() : { message: String(err) },
202
+ });
203
+ throw err;
204
+ }
205
+ }
206
+ async function getCmd(rest, ctx) {
207
+ const { values, positionals } = parse(rest, {
208
+ cursor: { type: "string" },
209
+ limit: { type: "string" },
210
+ json: { type: "boolean" },
211
+ });
212
+ const json = ctx.json || values.json === true;
213
+ const id = requireId(positionals[0], "get");
214
+ const cursor = asString(values.cursor);
215
+ const limit = pageLimit(values.limit, 10_000);
216
+ assertUsable("crawl");
217
+ const apiKey = requireApiKey();
218
+ const page = await getCrawl(id, { apiKey, cursor, limit });
219
+ const results = page.results ?? [];
220
+ const nextCursor = page.next_cursor ?? null;
221
+ const code = printCrawl(page, json, `Crawl ${id}`, { results, next_cursor: nextCursor });
222
+ if (!json) {
223
+ log.info(`${results.length} result(s) on this page:`);
224
+ if (results.length)
225
+ log.out(toJsonl(results).trimEnd());
226
+ log.info(`next_cursor: ${nextCursor ?? "null (last page)"}`);
227
+ }
228
+ return code;
229
+ }
230
+ async function waitCmd(rest, ctx) {
231
+ const { values, positionals } = parse(rest, { timeout: { type: "string" }, json: { type: "boolean" } });
232
+ const json = ctx.json || values.json === true;
233
+ const id = requireId(positionals[0], "wait");
234
+ const timeoutMs = timeoutFlag(values.timeout);
235
+ assertUsable("crawl");
236
+ const apiKey = requireApiKey();
237
+ const c = await waitUntilEnd(id, { apiKey, timeoutMs });
238
+ return printCrawl(c, json, waitHeadline(c), { note: waitNote(c) });
239
+ }
240
+ function waitHeadline(c) {
241
+ return isTerminal(c.status) ? `Crawl ${c.crawl_id} finished` : `Stopped waiting: crawl ${c.crawl_id} is still running`;
242
+ }
243
+ /** Set only when a wait ran out with the crawl still running. */
244
+ function waitNote(c) {
245
+ if (isTerminal(c.status))
246
+ return undefined;
247
+ return `The wait ran out and the crawl is still running. Run zenrows crawl wait ${c.crawl_id}.`;
248
+ }
249
+ async function resultsCmd(rest, ctx) {
250
+ const { values, positionals } = parse(rest, {
251
+ out: { type: "string" },
252
+ limit: { type: "string" },
253
+ json: { type: "boolean" },
254
+ });
255
+ const json = ctx.json || values.json === true;
256
+ const id = requireId(positionals[0], "results");
257
+ const outFile = asString(values.out);
258
+ const pageSize = pageLimit(values.limit, 10_000);
259
+ assertUsable("crawl");
260
+ const apiKey = requireApiKey();
261
+ const { crawl: c, results, partial } = await listAllResults(id, { apiKey, pageSize });
262
+ return printResults(id, c.status, results, { outFile, json, partial });
263
+ }
264
+ async function downloadCmd(rest, ctx) {
265
+ const { values, positionals } = parse(rest, { out: { type: "string" }, json: { type: "boolean" } });
266
+ const json = ctx.json || values.json === true;
267
+ const id = requireId(positionals[0], "download");
268
+ const file = asString(values.out) ?? `${id.replace(/[^A-Za-z0-9._-]+/g, "_")}.jsonl`;
269
+ assertUsable("crawl");
270
+ const apiKey = requireApiKey();
271
+ const { status, count } = await downloadCrawl(id, (ndjson) => writeOutStream(file, ndjson), { apiKey });
272
+ const partial = status === "running";
273
+ if (json) {
274
+ log.out(JSON.stringify({ ok: true, crawl_id: id, status: status ?? null, partial, count, file }, null, 2));
275
+ }
276
+ else {
277
+ 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.");
280
+ }
281
+ return 0;
282
+ }
283
+ /** Write or print results. Under --json, always print the envelope (`file` replaces `results` with --out). */
284
+ function printResults(id, status, results, o) {
285
+ if (o.outFile)
286
+ writeOut(o.outFile, toJsonl(results));
287
+ if (o.json) {
288
+ 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));
290
+ return 0;
291
+ }
292
+ if (o.outFile) {
293
+ log.success(`Wrote ${results.length} result(s) → ${o.outFile}`);
294
+ }
295
+ else {
296
+ log.info(`${results.length} result(s) for crawl ${id} (status: ${status}):`);
297
+ log.out(toJsonl(results).trimEnd());
298
+ }
299
+ if (o.partial)
300
+ log.warn(" The crawl is still running: these results are partial. Read them again once it ends.");
301
+ return 0;
302
+ }
303
+ async function contentCmd(rest, ctx) {
304
+ const { values, positionals } = parse(rest, { out: { type: "string" }, json: { type: "boolean" } });
305
+ const json = ctx.json || values.json === true;
306
+ const id = requireId(positionals[0], "content");
307
+ const ref = positionals[1];
308
+ const contentId = ref ? contentIdOfRef(id, ref) : undefined;
309
+ if (!contentId) {
310
+ throw new ToolkitError({
311
+ code: "INVALID_USAGE",
312
+ message: "Missing content id.",
313
+ likely_cause: "No <content_id> positional was provided.",
314
+ next_action: "Pass the id from a fetched result's content_url (the last path segment), e.g. `zenrows crawl content <id> ct_…`. Only crawls created with --output-format html have content.",
315
+ suggested_commands: [`zenrows crawl results ${id}`],
316
+ });
317
+ }
318
+ assertUsable("crawl");
319
+ const apiKey = requireApiKey();
320
+ const { contentType, body } = await getCrawlContent(id, contentId, { apiKey });
321
+ const outFile = asString(values.out);
322
+ if (outFile) {
323
+ writeOut(outFile, body);
324
+ if (json) {
325
+ log.out(JSON.stringify({ ok: true, crawl_id: id, content_id: contentId, content_type: contentType, bytes: Buffer.byteLength(body), file: outFile }, null, 2));
326
+ }
327
+ else {
328
+ log.success(`Wrote ${Buffer.byteLength(body)} bytes (${contentType || "unknown type"}) → ${outFile}`);
329
+ }
330
+ }
331
+ else if (json) {
332
+ log.out(JSON.stringify({ ok: true, crawl_id: id, content_id: contentId, content_type: contentType, bytes: Buffer.byteLength(body), content: body }, null, 2));
333
+ }
334
+ else {
335
+ log.out(body);
336
+ }
337
+ return 0;
338
+ }
339
+ /** The content id or the result's whole content_url, as the content id. */
340
+ function contentIdOfRef(id, ref) {
341
+ try {
342
+ return contentIdOf({ url: "", content_url: ref }) ?? ref;
343
+ }
344
+ catch (err) {
345
+ if (!(err instanceof URIError))
346
+ throw err;
347
+ throw new ToolkitError({
348
+ code: "INVALID_USAGE",
349
+ message: `Invalid content id '${ref}'.`,
350
+ likely_cause: "It has a % that does not start a valid percent-encoded character.",
351
+ next_action: "Pass the id or content_url exactly as a fetched result shows it.",
352
+ suggested_commands: [`zenrows crawl results ${id}`],
353
+ });
354
+ }
355
+ }
356
+ async function listCmd(rest, ctx) {
357
+ const { values } = parse(rest, { limit: { type: "string" }, cursor: { type: "string" }, json: { type: "boolean" } });
358
+ const json = ctx.json || values.json === true;
359
+ const limit = pageLimit(values.limit, 100);
360
+ const cursor = asString(values.cursor);
361
+ assertUsable("crawl");
362
+ const apiKey = requireApiKey();
363
+ const page = await listCrawls({ apiKey, limit, cursor });
364
+ const crawls = page.crawls ?? [];
365
+ if (json) {
366
+ log.out(JSON.stringify({ ok: true, count: crawls.length, crawls, next_cursor: page.next_cursor ?? null }, null, 2));
367
+ return 0;
368
+ }
369
+ if (!crawls.length)
370
+ log.info("No crawls.");
371
+ for (const c of crawls) {
372
+ const cov = c.coverage;
373
+ log.out(`${c.crawl_id} ${c.status.padEnd(9)} ${c.created_at} ${cov ? `${cov.items_found} items/${cov.pages_fetched} pages` : ""} ${c.url}`);
374
+ }
375
+ if (page.next_cursor)
376
+ log.dim(` more: zenrows crawl list --cursor ${page.next_cursor}`);
377
+ return 0;
378
+ }
379
+ async function stopCmd(rest, ctx) {
380
+ const { values, positionals } = parse(rest, { json: { type: "boolean" } });
381
+ const id = requireId(positionals[0], "stop");
382
+ assertUsable("crawl");
383
+ const apiKey = requireApiKey();
384
+ const s = await stopCrawl(id, { apiKey });
385
+ const headline = s.status === "stopped" ? `Stopped crawl ${id}` : `Nothing to stop: crawl ${id} had already ended`;
386
+ return printCrawl(s, ctx.json || values.json === true, headline);
387
+ }
388
+ /**
389
+ * Wait for a crawl to end. Ctrl-C ends the wait, not the crawl: say so, give
390
+ * the command that resumes the wait, and exit 130 as shells do on SIGINT.
391
+ */
392
+ async function waitUntilEnd(id, opts) {
393
+ const onSigint = () => {
394
+ log.warn(`Stopped waiting. Crawl ${id} is still running; it was not stopped.`);
395
+ log.dim(` resume: zenrows crawl wait ${id}`);
396
+ process.exit(130);
397
+ };
398
+ process.once("SIGINT", onSigint);
399
+ try {
400
+ return await waitForCrawl(id, opts);
401
+ }
402
+ finally {
403
+ process.off("SIGINT", onSigint);
404
+ }
405
+ }
406
+ /**
407
+ * The error for a crawl that ended `failed`, or null for any other state. The
408
+ * crawl's own `error.code` picks the next action.
409
+ */
410
+ export function crawlFailure(c) {
411
+ if (c.status !== "failed")
412
+ return null;
413
+ const code = c.error?.code;
414
+ const why = [code, c.error?.detail].filter(Boolean).join(": ");
415
+ const next = {
416
+ insufficient_credits: "The account ran out of credits. Check `zenrows usage`, add credits, then start a new crawl.",
417
+ seed_unreachable: "The start URL could not be fetched (the site may be blocking it). Check it with `zenrows fetch <url>`.",
418
+ no_items_found: "Nothing matched. Loosen --include-patterns / --exclude-patterns, raise or drop --depth, or check the start page with `zenrows fetch <url>`.",
419
+ };
420
+ return new ToolkitError({
421
+ code: "CRAWL_FAILED",
422
+ message: `Crawl ${c.crawl_id} failed.`,
423
+ likely_cause: why || "The crawl ended in status failed without a reason.",
424
+ next_action: (code && next[code]) || "Read the error, fix the cause, then start a new crawl.",
425
+ suggested_commands: [`zenrows crawl get ${c.crawl_id}`],
426
+ server_code: code,
427
+ crawl_id: c.crawl_id,
428
+ });
429
+ }
430
+ /**
431
+ * Print a crawl (or a stop answer), structured under --json, and return the exit
432
+ * code: 1 when it ended `failed`, else 0. `stopped` is a deliberate outcome, so
433
+ * it exits 0 but prints as a warning. `extra` carries the results a `get` read,
434
+ * or the note of a wait that ran out.
435
+ */
436
+ function printCrawl(c, json, headline, extra = {}) {
437
+ const err = crawlFailure(c);
438
+ const crawlFields = { ...c };
439
+ // A wait polls with one result to stay cheap; that page is not "the results".
440
+ delete crawlFields.results;
441
+ delete crawlFields.next_cursor;
442
+ if (json) {
443
+ log.out(JSON.stringify({
444
+ ok: !err,
445
+ crawl_id: c.crawl_id,
446
+ status: c.status,
447
+ crawl: crawlFields,
448
+ ...extra,
449
+ ...(err ? { error: err.toJSON() } : {}),
450
+ }, null, 2));
451
+ return err ? 1 : 0;
452
+ }
453
+ const line = `${headline} · status: ${c.status ?? "unknown"}${c.stop_reason ? ` (${c.stop_reason})` : ""}`;
454
+ if (err)
455
+ log.error(line);
456
+ else if (c.status === "stopped")
457
+ log.warn(line);
458
+ else
459
+ log.success(line);
460
+ const cov = c.coverage;
461
+ if (cov) {
462
+ log.info(` ${cov.items_found} item(s) found · ${cov.pages_fetched} page(s) fetched · ${cov.pages_failed} failed`);
463
+ }
464
+ if (extra.note)
465
+ log.dim(` next: zenrows crawl wait ${c.crawl_id}`);
466
+ if (err)
467
+ printError(err, false);
468
+ return err ? 1 : 0;
469
+ }
470
+ /**
471
+ * A whole-number flag up to the API's 100,000 ceiling; undefined when unset. The lower bound is
472
+ * the API's to judge: 0 or less comes back as CRAWL_INVALID_REQUEST, naming the parameter.
473
+ */
474
+ function limitFlag(name, v) {
475
+ if (v === undefined)
476
+ return undefined;
477
+ const n = asNumber(v);
478
+ if (n === undefined || !Number.isInteger(n) || n > MAX_LIMIT) {
479
+ throw new ToolkitError({
480
+ code: "INVALID_USAGE",
481
+ message: `Invalid --${name} value '${String(v)}'.`,
482
+ likely_cause: `--${name} takes a whole number up to ${MAX_LIMIT}.`,
483
+ next_action: `Pass e.g. --${name} ${name === "depth" ? 1 : 10}.`,
484
+ });
485
+ }
486
+ return n;
487
+ }
488
+ /** `--timeout` in seconds, returned in milliseconds; undefined when unset. */
489
+ function timeoutFlag(v) {
490
+ if (v === undefined)
491
+ return undefined;
492
+ const seconds = asNumber(v);
493
+ if (seconds === undefined || seconds <= 0) {
494
+ throw new ToolkitError({
495
+ code: "INVALID_USAGE",
496
+ message: `Invalid --timeout value '${String(v)}'.`,
497
+ likely_cause: "--timeout takes a positive number of seconds.",
498
+ next_action: "Pass seconds, e.g. --timeout 600 for ten minutes.",
499
+ });
500
+ }
501
+ return seconds * 1000;
502
+ }
503
+ /** `--limit` for a paged read: a whole number from 1 to `max`. */
504
+ function pageLimit(v, max) {
505
+ if (v === undefined)
506
+ return undefined;
507
+ const n = asNumber(v);
508
+ if (n === undefined || !Number.isInteger(n) || n < 1 || n > max) {
509
+ throw new ToolkitError({
510
+ code: "INVALID_USAGE",
511
+ message: `Invalid --limit value '${String(v)}'.`,
512
+ likely_cause: `--limit takes a whole number from 1 to ${max}.`,
513
+ next_action: `Pass e.g. --limit ${Math.min(max, 100)}.`,
514
+ });
515
+ }
516
+ return n;
517
+ }
518
+ function patterns(v) {
519
+ const list = Array.isArray(v) ? v : typeof v === "string" ? [v] : [];
520
+ return list.filter((p) => typeof p === "string" && p.length > 0);
521
+ }
522
+ function toJsonl(rows) {
523
+ return rows.map((r) => JSON.stringify(r)).join("\n") + (rows.length ? "\n" : "");
524
+ }
525
+ function requireId(id, sub) {
526
+ if (id)
527
+ return id;
528
+ throw new ToolkitError({
529
+ code: "INVALID_USAGE",
530
+ message: "Missing crawl id.",
531
+ likely_cause: "No <id> positional was provided.",
532
+ next_action: `Usage: zenrows crawl ${sub} <id>. List your crawls with \`zenrows crawl list\`.`,
533
+ suggested_commands: ["zenrows crawl list"],
534
+ });
535
+ }
@@ -25,6 +25,7 @@ import { policy } from "./commands/policy.js";
25
25
  import { fetch_ } from "./commands/fetch.js";
26
26
  import { extract } from "./commands/extract.js";
27
27
  import { batch } from "./commands/batch.js";
28
+ import { crawl } from "./commands/crawl.js";
28
29
  import { browser } from "./commands/browser.js";
29
30
  import { mcp } from "./commands/mcp.js";
30
31
  import { plugin } from "./commands/plugin.js";
@@ -38,7 +39,7 @@ const evalCmd = makeAssetCommand("eval", "Reproducible benchmark/test suites.");
38
39
  /** Ordered groups for help output. */
39
40
  const GROUPS = [
40
41
  { title: "Setup", commands: [init, signup, account, login, logout, status, usage, update, uninstall, config, policy] },
41
- { title: "Primitives", commands: [fetch_, extract, batch, browser] },
42
+ { title: "Primitives", commands: [fetch_, extract, batch, crawl, browser] },
42
43
  { title: "Distribution", commands: [mcp, plugin, skill, template, workflow, recipe, evalCmd, trace] },
43
44
  ];
44
45
  const COMMANDS = {};
@@ -2,7 +2,8 @@
2
2
  * Output helpers: render normalized errors for humans and machines, and a tiny
3
3
  * key/value printer used by `status`, `config`, `policy`.
4
4
  */
5
- import { statSync, writeFileSync } from "node:fs";
5
+ import { createWriteStream, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
6
+ import { pipeline } from "node:stream/promises";
6
7
  import { ToolkitError } from "../core/errors.js";
7
8
  import { ANSI, c, log } from "../core/logger.js";
8
9
  /**
@@ -11,6 +12,32 @@ import { ANSI, c, log } from "../core/logger.js";
11
12
  * parent directory doesn't exist (ENOENT). Everything else propagates.
12
13
  */
13
14
  export function writeOut(filePath, data) {
15
+ assertNotDirectory(filePath);
16
+ try {
17
+ writeFileSync(filePath, data);
18
+ }
19
+ catch (err) {
20
+ throw outError(filePath, err);
21
+ }
22
+ }
23
+ /**
24
+ * `writeOut` for a body too large to hold in memory: streams into
25
+ * `<file>.part`, then renames it into place, so a failed stream leaves any
26
+ * earlier file intact.
27
+ */
28
+ export async function writeOutStream(filePath, chunks) {
29
+ assertNotDirectory(filePath);
30
+ const part = `${filePath}.part`;
31
+ try {
32
+ await pipeline(chunks, createWriteStream(part));
33
+ renameSync(part, filePath);
34
+ }
35
+ catch (err) {
36
+ rmSync(part, { force: true });
37
+ throw outError(filePath, err);
38
+ }
39
+ }
40
+ function assertNotDirectory(filePath) {
14
41
  if (statSync(filePath, { throwIfNoEntry: false })?.isDirectory()) {
15
42
  throw new ToolkitError({
16
43
  code: "INVALID_USAGE",
@@ -19,20 +46,16 @@ export function writeOut(filePath, data) {
19
46
  next_action: "Pass a file path, e.g. --out results.jsonl.",
20
47
  });
21
48
  }
22
- try {
23
- writeFileSync(filePath, data);
24
- }
25
- catch (err) {
26
- if (err?.code === "ENOENT") {
27
- throw new ToolkitError({
28
- code: "INVALID_USAGE",
29
- message: `Cannot write --out '${filePath}': the parent directory does not exist.`,
30
- likely_cause: "A directory in the output path is missing.",
31
- next_action: "Create the directory first, or choose an existing one.",
32
- });
33
- }
34
- throw err;
35
- }
49
+ }
50
+ function outError(filePath, err) {
51
+ if (err?.code !== "ENOENT")
52
+ return err;
53
+ return new ToolkitError({
54
+ code: "INVALID_USAGE",
55
+ message: `Cannot write --out '${filePath}': the parent directory does not exist.`,
56
+ likely_cause: "A directory in the output path is missing.",
57
+ next_action: "Create the directory first, or choose an existing one.",
58
+ });
36
59
  }
37
60
  /** Print a ToolkitError. In --json mode emits the structured shape to stdout. */
38
61
  export function printError(err, json = false) {
@@ -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.4.2";
17
+ export const CLI_VERSION = "1.5.0";
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). */