zenrows 2.0.3 → 2.2.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 CHANGED
@@ -17,11 +17,14 @@ SDK to access [Zenrows](https://www.zenrows.com/) API directly from Node.js. Zen
17
17
  - [POST Requests](#post-requests)
18
18
  - [Extract](#extract)
19
19
  - [Batch](#batch)
20
+ - [Failed runs and extensible values](#failed-runs-and-extensible-values)
20
21
  - [Extract in a batch](#extract-in-a-batch)
22
+ - [Crawl](#crawl)
21
23
  - [Concurrency](#concurrency)
22
24
  - [An important note about Promise.allSettled() on TypeScript](#an-important-note-about-promiseallsettled-on-typescript)
23
25
  - [Examples](#examples)
24
26
  - [Contributing](#contributing)
27
+ - [Running the Crawl e2e test](#running-the-crawl-e2e-test)
25
28
  - [License](#license)
26
29
 
27
30
  ## Installation
@@ -226,6 +229,17 @@ const apiKey = "YOUR-API-KEY";
226
229
 
227
230
  `client.batch` also exposes `listJobs()`, `deleteJob()`, `stopRun()`, `rerun()`, `listRuns()`, `getRun()`, `deleteRun()`, and `getTaskContent()` (returns the scraped page's raw content as a string, not JSON — the endpoint can return HTML or plain text depending on what the target page served). Scheduling, webhook config, HMAC key rotation, CSV task uploads, and results exports aren't wrapped yet — call the [Batch API](https://docs.zenrows.com) directly for those.
228
231
 
232
+ #### Failed runs and extensible values
233
+
234
+ A run the server auto-fails carries `failure_reason` and a human-readable `failure_detail`. Starting, rerunning or resuming a job whose API key hit one of its credit caps throws a `ZenRowsBatchError` with `status` 402 and `code` `"api_key_cap_reached"`; `error.problem.detail` names the cap and when it resets (UTC).
235
+
236
+ ```js
237
+ const run = await client.batch.getRun(jobId, runId);
238
+ if (run.data.failure_reason === "api_key_cap_reached") console.log(run.data.failure_detail);
239
+ ```
240
+
241
+ Enum-valued fields on responses (run, job, task and export `status`, `ingest_status`, `failure_reason`) are typed `Extensible<...>`: the known values plus any `string`, because the server may add values. The exported named types (`RunStatus`, `JobStatus`, `TaskStatus`, `ExportStatus`, `IngestStatus`, `RunFailureReason`) stay closed, so they still work as request input. Give switches on these fields a default branch; an exhaustive `switch` that assigns the leftover to `never` no longer compiles.
242
+
229
243
  #### Extract in a batch
230
244
 
231
245
  Set `extract` in the batch params to run tasks through Extract — structured data instead of raw HTML. It works job-wide or per task, and per-task values win on collision.
@@ -253,6 +267,77 @@ const { ZenRowsBatchClient } = require("zenrows");
253
267
  const batch = new ZenRowsBatchClient(apiKey, { baseURL: "https://async.api.zenrows.com/v1" }); // baseURL is optional
254
268
  ```
255
269
 
270
+ ### Crawl
271
+
272
+ [Crawl](https://docs.zenrows.com/crawl/introduction) (new) takes one start URL and returns the URLs behind it, and is reachable via `client.crawl`. It follows the links on each page up to `depth` hops; without `depth` it has no depth limit and follows links until `maxPages` or `maxItems` stops it (each defaults to 100), or no link is left to open. A crawl stays on the start URL's registrable domain; subdomains count. A crawl runs asynchronously: `create()` returns at once, `wait()` polls until it ends, and `results()` reads the URLs it kept. With `outputFormat: "html"` it also fetches each kept page, which you read with `content()` or all at once with `download()`.
273
+
274
+ Crawl is still evolving: new features are coming, limits may be tuned, and the changelog announces each change.
275
+
276
+ ```javascript
277
+ const { ZenRows } = require("zenrows");
278
+
279
+ const apiKey = "YOUR-API-KEY";
280
+
281
+ (async () => {
282
+ const client = new ZenRows(apiKey);
283
+
284
+ const crawl = await client.crawl.create({
285
+ url: "https://www.scrapingcourse.com/ecommerce/",
286
+ depth: 1, // link hops from the start URL; omit for no depth limit
287
+ maxItems: 10, // stop once this many URLs are kept
288
+ maxPages: 20, // stop once this many pages are fetched; bounds the cost
289
+ includePatterns: ["/product/"], // keep only URLs containing one of these
290
+ outputFormat: "html", // omit for URLs only
291
+ });
292
+
293
+ const ended = await client.crawl.wait(crawl.crawl_id); // completed, stopped, failed, or running on timeout
294
+ if (ended.status === "failed") throw new Error(ended.error?.detail);
295
+
296
+ for await (const result of client.crawl.results(crawl.crawl_id)) {
297
+ console.log(result.url);
298
+ if (result.content_status === "fetched") {
299
+ const html = await client.crawl.content(crawl.crawl_id, result);
300
+ }
301
+ }
302
+
303
+ // Or every result, with its page, as parsed NDJSON lines.
304
+ const download = await client.crawl.download(crawl.crawl_id);
305
+ console.log(download.status); // from X-Crawl-Status; "running" means more lines may come later
306
+ for await (const line of download.lines) {
307
+ console.log(line.url, line.content);
308
+ }
309
+ })();
310
+ ```
311
+
312
+ `client.crawl` also exposes `get()` (status, coverage and one page of results), `list()` (one page of the account's crawls, newest first, with `next_cursor` until the last page), and `stop()` (idempotent; a crawl that already ended answers as it ended). `create()` takes an `idempotencyKey` option, so a retried create does not start a second crawl.
313
+
314
+ `results()` does not poll. On a running crawl it yields only what the crawl has kept so far, then returns. Call `wait()` first to read every result. `wait()` polls for up to `timeout` seconds (default 600). When the time runs out, it returns the crawl with status `running` and does not throw; the crawl keeps running.
315
+
316
+ Errors throw a `ZenRowsCrawlError` with `status`, `code`, `detail` and `retryAfter`. `code` is undefined when the error body has no code. Branch on `status` and `code`:
317
+
318
+ - `400` `invalid_request` / `unknown_parameter` / `invalid_cursor`.
319
+ - `403` `REQS008`: Crawl is not enabled for this account.
320
+ - `404` `crawl_not_found` / `content_not_found`.
321
+ - `409` `idempotency_request_in_flight`: a request with the same `idempotencyKey` is still running. Retry after it finishes.
322
+ - `422` `invalid_parameter` / `invalid_start_url` / `domain_not_allowed`: `detail` names the problem. `422` `idempotency_key_reused`: the key was used with a different body. Use a new key or no key; do not retry as is.
323
+ - `429` `too_many_crawls`: the account has reached its limit of active jobs (3 by default), shared with its Batch jobs. Nothing was created. `create()` never retries it; retry after `error.retryAfter` seconds.
324
+ - `503` `crawl_busy`: the stop could not be saved yet; the crawl is still running. Repeating the stop is safe, so `stop()` retries it, and any other 429, 502, 503 or 504, up to `retries` times, waiting `error.retryAfter` seconds (at most 30, or 1 when the header is missing), then throws the last error.
325
+
326
+ `content()` takes a content id, a `content_url` or a result, and throws a `TypeError` when it cannot read a content id from it. Requests retry network errors and 502/503/504 (and 429 on reads) when it is safe; a `create()` is retried only with an `idempotencyKey`, and a `stop()` as described above.
327
+
328
+ `outputFormat` takes `"html"`; without it a crawl returns URLs only. `maxPages` and `maxItems` default to 100 each. A crawl created without `depth` has no `depth` field; one created with it echoes it. Response enum fields (`status`, `stop_reason`, `error.code`, `content_status`) are `Extensible<...>`: the known values plus any `string`. A failed crawl carries `error.code` and `error.detail`. A download line's `content` is a string, or an object for a crawl that returns JSON.
329
+
330
+ The crawl client (`ZenRowsCrawlClient`) also works standalone. Both forms take the same options:
331
+
332
+ ```javascript
333
+ const { ZenRows, ZenRowsCrawlClient } = require("zenrows");
334
+
335
+ // All options are optional. timeout is in seconds and applies to each HTTP request, not to reading a download.
336
+ const options = { baseURL: "https://api.zenrows.com/v1", retries: 3, timeout: 30 };
337
+ const crawl = new ZenRowsCrawlClient(apiKey, options);
338
+ const client = new ZenRows(apiKey, { crawl: options }); // client.crawl uses them
339
+ ```
340
+
256
341
  ### Concurrency
257
342
 
258
343
  To limit the concurrency, it uses [fastq](https://github.com/mcollina/fastq), which will simultaneously send a maximum of requests. The concurrency is determined by the plan you are in, so take a look at the [pricing](https://www.zenrows.com/pricing) and set it accordingly. Take into account that each client instance will have its own limit, meaning that two different scripts will not share it, and 429 (Too Many Requests) errors might arise.
@@ -328,6 +413,26 @@ npx tsx index.ts # TS example
328
413
 
329
414
  Pull requests are welcome. For significant changes, please open an issue first to discuss what you would like to change.
330
415
 
416
+ Before opening a pull request, run `pnpm check` and `pnpm test -- --run`. Neither touches the network.
417
+
418
+ ### Running the Crawl e2e test
419
+
420
+ `tests/e2e/crawl.e2e.test.ts` runs a small real crawl (depth 1, up to 3 items and 5 pages, HTML output) and checks every Crawl method against the live API. It is skipped unless `ZENROWS_API_KEY` and `ZENROWS_E2E_CRAWL_URL` are set:
421
+
422
+ - `ZENROWS_API_KEY`: a key with Crawl access. Keep it out of your shell history, e.g. read it from a file.
423
+ - `ZENROWS_CRAWL_BASE_URL` (optional): the API base. Default `https://api.zenrows.com/v1`. Point it at a local or staging deployment to test that instead.
424
+ - `ZENROWS_E2E_CRAWL_URL`: the start URL to crawl, a site you may crawl whose start page links to at least one page.
425
+ - `ZENROWS_E2E_CRAWL_INCLUDE` (optional): an include pattern, e.g. `/product/`. When set, the crawl keeps only matching URLs and the test asserts every result contains it.
426
+
427
+ ```bash
428
+ export ZENROWS_API_KEY="$(cat path/to/key-file)"
429
+ export ZENROWS_E2E_CRAWL_URL=https://www.scrapingcourse.com/ecommerce/
430
+ export ZENROWS_E2E_CRAWL_INCLUDE=/product/
431
+ pnpm test:e2e
432
+ ```
433
+
434
+ The run creates one crawl on the account and uses up to a few pages of credits. If the account has reached its limit of active jobs, the test waits and retries (up to 5 minutes).
435
+
331
436
  ## License
332
437
 
333
438
  [MIT](./LICENSE)
package/dist/index.cjs CHANGED
@@ -34,6 +34,7 @@ __export(index_exports, {
34
34
  AUTO_MIN_CREDITS: () => AUTO_MIN_CREDITS,
35
35
  At: () => At,
36
36
  BASE_CREDITS: () => BASE_CREDITS,
37
+ CRAWL_NOT_ENABLED_CODE: () => CRAWL_NOT_ENABLED_CODE,
37
38
  Calendar: () => Calendar,
38
39
  CurrentRun: () => CurrentRun,
39
40
  DEFAULT_MAX_BYTES_PER_FILE: () => DEFAULT_MAX_BYTES_PER_FILE,
@@ -58,6 +59,8 @@ __export(index_exports, {
58
59
  ZenRows: () => ZenRows,
59
60
  ZenRowsBatchClient: () => ZenRowsBatchClient,
60
61
  ZenRowsBatchError: () => ZenRowsBatchError,
62
+ ZenRowsCrawlClient: () => ZenRowsCrawlClient,
63
+ ZenRowsCrawlError: () => ZenRowsCrawlError,
61
64
  downloadTaskToFile: () => downloadTaskToFile,
62
65
  downloadTaskToMemory: () => downloadTaskToMemory,
63
66
  downloadToDir: () => downloadToDir,
@@ -73,7 +76,7 @@ var import_fetch_retry = __toESM(require("fetch-retry"), 1);
73
76
  // package.json
74
77
  var package_default = {
75
78
  name: "zenrows",
76
- version: "2.0.2",
79
+ version: "2.2.0",
77
80
  description: "ZenRows Node SDK",
78
81
  main: "./dist/index.cjs",
79
82
  module: "./dist/index.js",
@@ -92,10 +95,19 @@ var package_default = {
92
95
  scripts: {
93
96
  build: "tsup src/index.ts --dts --format cjs,esm --clean",
94
97
  test: "vitest",
98
+ "test:e2e": "vitest run tests/e2e",
95
99
  format: "biome format --write .",
96
100
  check: "biome check ."
97
101
  },
98
- keywords: ["sdk", "zenrows", "scraping"],
102
+ keywords: [
103
+ "sdk",
104
+ "zenrows",
105
+ "scraping",
106
+ "proxy",
107
+ "data extraction",
108
+ "web scraping",
109
+ "web scraping api"
110
+ ],
99
111
  author: "ZenRows",
100
112
  repository: {
101
113
  type: "git",
@@ -235,12 +247,13 @@ var ZenRowsBatchError = class extends Error {
235
247
  code;
236
248
  extras;
237
249
  constructor(status, problem, extras) {
238
- const message = problem ? `${status} ${problem.title ?? "Error"}: ${problem.detail ?? problem.code ?? "unknown"}` : `${status} (no problem body)`;
250
+ const code = problem?.code ?? "internal";
251
+ const message = problem ? `${status} ${problem.title ?? "Error"}: ${problem.detail ?? code}` : `${status} (no problem body)`;
239
252
  super(message);
240
253
  this.name = "ZenRowsBatchError";
241
254
  this.status = status;
242
- this.problem = problem;
243
- this.code = problem?.code ?? "internal";
255
+ this.problem = problem && { ...problem, code };
256
+ this.code = code;
244
257
  this.extras = extras;
245
258
  }
246
259
  };
@@ -266,7 +279,7 @@ async function parseProblem(response) {
266
279
  type: typeof record.type === "string" ? record.type : "about:blank",
267
280
  title: typeof record.title === "string" ? record.title : "Error",
268
281
  status: typeof record.status === "number" ? record.status : response.status,
269
- code: typeof record.code === "string" ? record.code : "internal",
282
+ code: typeof record.code === "string" ? record.code : void 0,
270
283
  detail: typeof record.detail === "string" ? record.detail : void 0,
271
284
  instance: typeof record.instance === "string" ? record.instance : void 0
272
285
  };
@@ -689,6 +702,7 @@ var IDEMPOTENT_METHODS = /* @__PURE__ */ new Set(["GET", "PUT", "DELETE", "HEAD"
689
702
  var BACKOFF_BASE_MS = 250;
690
703
  var BACKOFF_CAP_MS = 1e4;
691
704
  var DEFAULT_RETRIES = 3;
705
+ var RETRY_AFTER_CAP_MS = 3e4;
692
706
  function isIdempotent(method, hasIdempotencyKey) {
693
707
  return IDEMPOTENT_METHODS.has(method.toUpperCase()) || method.toUpperCase() === "POST" && hasIdempotencyKey;
694
708
  }
@@ -701,41 +715,71 @@ function retryAfterMs(response) {
701
715
  if (!raw) return void 0;
702
716
  const secs = Number(raw);
703
717
  if (Number.isNaN(secs) || secs < 0) return void 0;
704
- return secs * 1e3;
718
+ return Math.min(secs * 1e3, RETRY_AFTER_CAP_MS);
705
719
  }
706
720
  function sleep(ms) {
707
721
  return new Promise((resolve) => setTimeout(resolve, ms));
708
722
  }
723
+ var batchError = (response, problem, extras) => new ZenRowsBatchError(response.status, problem, extras);
724
+ function deadline(ms) {
725
+ if (ms === void 0) return { clear() {
726
+ } };
727
+ const controller = new AbortController();
728
+ const timer = setTimeout(
729
+ () => controller.abort(new DOMException(`request timed out after ${ms / 1e3}s`, "TimeoutError")),
730
+ ms
731
+ );
732
+ return { signal: controller.signal, clear: () => clearTimeout(timer) };
733
+ }
709
734
  var BatchTransport = class {
710
- constructor(baseURL, apiKey, retries = DEFAULT_RETRIES) {
735
+ constructor(baseURL, apiKey, retries = DEFAULT_RETRIES, errorFactory = batchError, timeoutMs) {
711
736
  this.baseURL = baseURL;
712
737
  this.apiKey = apiKey;
713
738
  this.retries = retries;
739
+ this.errorFactory = errorFactory;
740
+ this.timeoutMs = timeoutMs;
714
741
  }
715
742
  baseURL;
716
743
  apiKey;
717
744
  retries;
718
- /** Send a request, parse the response, throw `ZenRowsBatchError` on non-2xx. */
745
+ errorFactory;
746
+ timeoutMs;
747
+ /** Send a request, parse the response, throw the factory's error on non-2xx. */
719
748
  async requestJson(method, path, options = {}) {
720
- const response = await this.send(method, path, options);
721
- if (!response.ok) {
722
- const { problem, extras } = await parseProblem(response);
723
- throw new ZenRowsBatchError(response.status, problem, extras);
724
- }
725
- const text = await response.text();
749
+ const text = await this.requestText(method, path, options);
726
750
  if (!text) {
727
751
  return void 0;
728
752
  }
729
753
  return JSON.parse(text);
730
754
  }
731
- /** Send a request and return the raw `Response` — used for endpoints that don't return JSON. */
755
+ /** Send a request and return its body as text; throw the factory's error on non-2xx. */
756
+ async requestText(method, path, options = {}) {
757
+ const { response, clear } = await this.send(method, path, options);
758
+ try {
759
+ await this.throwUnlessOk(response);
760
+ return await response.text();
761
+ } finally {
762
+ clear();
763
+ }
764
+ }
765
+ /**
766
+ * Send a request and return the raw `Response`, for bodies read as a stream. The timeout
767
+ * stops once the headers of a 2xx arrive; an error body is still read within it.
768
+ */
732
769
  async requestRaw(method, path, options = {}) {
733
- const response = await this.send(method, path, options);
770
+ const { response, clear } = await this.send(method, path, options);
771
+ try {
772
+ await this.throwUnlessOk(response);
773
+ } finally {
774
+ clear();
775
+ }
776
+ return response;
777
+ }
778
+ async throwUnlessOk(response) {
734
779
  if (!response.ok) {
735
780
  const { problem, extras } = await parseProblem(response);
736
- throw new ZenRowsBatchError(response.status, problem, extras);
781
+ throw this.errorFactory(response, problem, extras);
737
782
  }
738
- return response;
739
783
  }
740
784
  buildUrl(path, query) {
741
785
  const url = new URL(`${this.baseURL}${path}`);
@@ -762,16 +806,20 @@ var BatchTransport = class {
762
806
  headers["Idempotency-Key"] = options.idempotencyKey;
763
807
  }
764
808
  const idempotent = isIdempotent(method, Boolean(options.idempotencyKey));
809
+ const retryStatuses = options.retryStatuses ?? RETRYABLE_STATUSES;
765
810
  let attempt = 0;
766
811
  while (true) {
812
+ const { signal, clear } = deadline(this.timeoutMs);
767
813
  let response;
768
814
  try {
769
815
  response = await fetch(url.toString(), {
770
816
  method,
771
817
  headers,
772
- body: options.body !== void 0 ? JSON.stringify(options.body) : void 0
818
+ body: options.body !== void 0 ? JSON.stringify(options.body) : void 0,
819
+ signal
773
820
  });
774
821
  } catch (error) {
822
+ clear();
775
823
  if (idempotent && attempt < this.retries) {
776
824
  await sleep(backoffMs(attempt));
777
825
  attempt += 1;
@@ -779,13 +827,16 @@ var BatchTransport = class {
779
827
  }
780
828
  throw error;
781
829
  }
782
- if (idempotent && attempt < this.retries && RETRYABLE_STATUSES.has(response.status)) {
830
+ if (idempotent && attempt < this.retries && retryStatuses.has(response.status)) {
831
+ response.body?.cancel().catch(() => {
832
+ });
833
+ clear();
783
834
  const wait = retryAfterMs(response) ?? backoffMs(attempt);
784
835
  await sleep(wait);
785
836
  attempt += 1;
786
837
  continue;
787
838
  }
788
- return response;
839
+ return { response, clear };
789
840
  }
790
841
  }
791
842
  };
@@ -812,7 +863,7 @@ async function pollUntil(fetch2, {
812
863
  backoff = 1.5,
813
864
  jitter = 0.2
814
865
  }) {
815
- const deadline = Date.now() + timeout * 1e3;
866
+ const deadline2 = Date.now() + timeout * 1e3;
816
867
  let interval = initialInterval;
817
868
  while (true) {
818
869
  const value = await fetch2();
@@ -825,10 +876,10 @@ async function pollUntil(fetch2, {
825
876
  );
826
877
  }
827
878
  const now = Date.now();
828
- if (now >= deadline) {
879
+ if (now >= deadline2) {
829
880
  throw new WaiterTimeoutError(`waiter: timed out after ${timeout}s waiting for target state`);
830
881
  }
831
- const remainingSeconds = (deadline - now) / 1e3;
882
+ const remainingSeconds = (deadline2 - now) / 1e3;
832
883
  const sleepSeconds = Math.min(interval, remainingSeconds) * (1 + jitter * (Math.random() * 2 - 1));
833
884
  if (sleepSeconds > 0) {
834
885
  await new Promise((resolve) => setTimeout(resolve, sleepSeconds * 1e3));
@@ -839,7 +890,7 @@ async function pollUntil(fetch2, {
839
890
 
840
891
  // src/batch/client.ts
841
892
  var DEFAULT_BATCH_API_URL = "https://async.api.zenrows.com/v1";
842
- var TERMINAL_RUN_STATUSES = /* @__PURE__ */ new Set(["completed", "stopped", "deleted"]);
893
+ var TERMINAL_RUN_STATUSES = /* @__PURE__ */ new Set(["completed", "stopped", "failed", "deleted"]);
843
894
  var TERMINAL_EXPORT_STATUSES = /* @__PURE__ */ new Set(["completed", "failed"]);
844
895
  function coerceUrl(item) {
845
896
  return typeof item === "string" ? { url: item } : item;
@@ -1202,7 +1253,8 @@ var ZenRowsBatchClient = class {
1202
1253
  return this._getRunData(jobId, options.runId);
1203
1254
  };
1204
1255
  return pollUntil(fetchRun, {
1205
- isDone: (run) => run !== void 0 && target.has(run.status),
1256
+ // A status the caller named as a failure throws, even when it is also a target.
1257
+ isDone: (run) => run !== void 0 && target.has(run.status) && !options.failureStatuses?.has(run.status),
1206
1258
  isFailure: (run) => run !== void 0 && Boolean(options.failureStatuses?.has(run.status)),
1207
1259
  timeout: options.timeout ?? 300,
1208
1260
  initialInterval: options.pollInterval ?? 2,
@@ -1225,6 +1277,220 @@ var ZenRowsBatchClient = class {
1225
1277
  }
1226
1278
  };
1227
1279
 
1280
+ // src/crawl/errors.ts
1281
+ var CRAWL_NOT_ENABLED_CODE = "REQS008";
1282
+ var ZenRowsCrawlError = class extends Error {
1283
+ status;
1284
+ problem;
1285
+ code;
1286
+ detail;
1287
+ extras;
1288
+ /** Seconds from the `Retry-After` header, e.g. 30 on a 429 `too_many_crawls`. */
1289
+ retryAfter;
1290
+ constructor(status, problem, extras, retryAfter) {
1291
+ let message;
1292
+ if (status === 403 && problem?.code === CRAWL_NOT_ENABLED_CODE) {
1293
+ message = `403 Crawl is not enabled for this account (${CRAWL_NOT_ENABLED_CODE})${problem.detail ? `: ${problem.detail}` : ""}`;
1294
+ } else if (problem) {
1295
+ message = `${status} ${problem.title ?? "Error"}: ${problem.detail ?? problem.code ?? "unknown"}`;
1296
+ } else {
1297
+ message = `${status} (no problem body)`;
1298
+ }
1299
+ super(message);
1300
+ this.name = "ZenRowsCrawlError";
1301
+ this.status = status;
1302
+ this.problem = problem;
1303
+ this.code = problem?.code;
1304
+ this.detail = problem?.detail;
1305
+ this.extras = extras;
1306
+ this.retryAfter = retryAfter;
1307
+ }
1308
+ };
1309
+ function crawlError(response, problem, extras) {
1310
+ const raw = response.headers.get("Retry-After");
1311
+ const secs = raw === null || raw.trim() === "" ? Number.NaN : Number(raw);
1312
+ return new ZenRowsCrawlError(
1313
+ response.status,
1314
+ problem,
1315
+ extras,
1316
+ Number.isNaN(secs) || secs < 0 ? void 0 : secs
1317
+ );
1318
+ }
1319
+
1320
+ // src/crawl/client.ts
1321
+ var DEFAULT_CRAWL_API_URL = "https://api.zenrows.com/v1";
1322
+ var DEFAULT_TIMEOUT_SECONDS = 30;
1323
+ var DEFAULT_WAIT_TIMEOUT_SECONDS = 600;
1324
+ var POLL_INTERVAL_SECONDS = 2;
1325
+ var MAX_POLL_INTERVAL_SECONDS = 15;
1326
+ var POLL_BACKOFF = 1.5;
1327
+ var CREATE_RETRY_STATUSES = /* @__PURE__ */ new Set([502, 503, 504]);
1328
+ var STOP_RETRY_STATUSES = /* @__PURE__ */ new Set([429, 502, 503, 504]);
1329
+ var MAX_STOP_RETRY_AFTER_SECONDS = 30;
1330
+ function contentIdOf(content) {
1331
+ let ref;
1332
+ if (typeof content === "string") ref = content;
1333
+ else if (content.content_url) ref = content.content_url;
1334
+ else {
1335
+ throw new TypeError(
1336
+ `content: result for ${content.url} has no content_url (content_status: ${content.content_status ?? "absent"})`
1337
+ );
1338
+ }
1339
+ const id = ref.split("/").pop();
1340
+ if (!id) throw new TypeError(`content: cannot read a content id from ${ref}`);
1341
+ return id;
1342
+ }
1343
+ async function* ndjsonLines(body) {
1344
+ if (!body) return;
1345
+ const decoder = new TextDecoder();
1346
+ let buffered = "";
1347
+ for await (const chunk of body) {
1348
+ buffered += decoder.decode(chunk, { stream: true });
1349
+ let newline = buffered.indexOf("\n");
1350
+ while (newline !== -1) {
1351
+ const line = buffered.slice(0, newline).trim();
1352
+ buffered = buffered.slice(newline + 1);
1353
+ if (line) yield JSON.parse(line);
1354
+ newline = buffered.indexOf("\n");
1355
+ }
1356
+ }
1357
+ const last = (buffered + decoder.decode()).trim();
1358
+ if (last) yield JSON.parse(last);
1359
+ }
1360
+ var ZenRowsCrawlClient = class {
1361
+ apiKey;
1362
+ transport;
1363
+ retries;
1364
+ constructor(apiKey, config = {}) {
1365
+ this.apiKey = apiKey;
1366
+ this.retries = config.retries ?? DEFAULT_RETRIES;
1367
+ this.transport = new BatchTransport(
1368
+ (config.baseURL ?? DEFAULT_CRAWL_API_URL).replace(/\/+$/, ""),
1369
+ apiKey,
1370
+ config.retries,
1371
+ crawlError,
1372
+ (config.timeout ?? DEFAULT_TIMEOUT_SECONDS) * 1e3
1373
+ );
1374
+ }
1375
+ /**
1376
+ * Start a crawl. Sends only the fields you set. With an `idempotencyKey`, a retry returns the
1377
+ * crawl the first request created instead of starting another, and 5xx and network failures
1378
+ * are retried. A 429 `too_many_crawls` is never retried: it throws `ZenRowsCrawlError` with
1379
+ * `retryAfter` set.
1380
+ */
1381
+ create(params, opts = {}) {
1382
+ const body = {
1383
+ url: params.url,
1384
+ depth: params.depth,
1385
+ max_items: params.maxItems,
1386
+ max_pages: params.maxPages,
1387
+ include_patterns: params.includePatterns,
1388
+ exclude_patterns: params.excludePatterns,
1389
+ output_format: params.outputFormat
1390
+ };
1391
+ return this.transport.requestJson("POST", "/crawls", {
1392
+ body,
1393
+ idempotencyKey: opts.idempotencyKey,
1394
+ retryStatuses: CREATE_RETRY_STATUSES
1395
+ });
1396
+ }
1397
+ /** Read a crawl's status, coverage and one page of results (`limit` 1–10000, default 1000). */
1398
+ get(crawlId, options = {}) {
1399
+ return this.transport.requestJson("GET", `/crawls/${encodeURIComponent(crawlId)}`, {
1400
+ query: options
1401
+ });
1402
+ }
1403
+ /** One page of the account's crawls, newest first (`limit` 1–100, default 20). */
1404
+ list(options = {}) {
1405
+ return this.transport.requestJson("GET", "/crawls", { query: options });
1406
+ }
1407
+ /**
1408
+ * Every URL the crawl kept, following `next_cursor` (`limit` is the page size). On a running
1409
+ * crawl it yields the URLs kept so far and returns at the first empty page; it does not poll.
1410
+ * Call `wait()` first to read every result.
1411
+ */
1412
+ async *results(crawlId, options = {}) {
1413
+ let cursor;
1414
+ while (true) {
1415
+ const page = await this.get(crawlId, { ...options, cursor });
1416
+ yield* page.results;
1417
+ if (page.next_cursor === null || page.results.length === 0) return;
1418
+ cursor = page.next_cursor;
1419
+ }
1420
+ }
1421
+ /**
1422
+ * Stop a running crawl. Idempotent: a crawl that already ended answers as it ended.
1423
+ * Because a stop is safe to repeat, `stop()` retries a 429, 502, 503 (including `crawl_busy`,
1424
+ * which means the stop was not saved and the crawl is still running) or 504 up to `retries`
1425
+ * times, waiting `Retry-After` seconds (at most 30; 1 when the header is missing or unusable)
1426
+ * each time, then throws the last error.
1427
+ */
1428
+ async stop(crawlId) {
1429
+ for (let attempt = 0; ; attempt += 1) {
1430
+ try {
1431
+ return await this.transport.requestJson(
1432
+ "POST",
1433
+ `/crawls/${encodeURIComponent(crawlId)}/stop`
1434
+ );
1435
+ } catch (error) {
1436
+ if (!(error instanceof ZenRowsCrawlError) || !STOP_RETRY_STATUSES.has(error.status) || attempt >= this.retries) {
1437
+ throw error;
1438
+ }
1439
+ const waitSeconds = Math.min(error.retryAfter ?? 1, MAX_STOP_RETRY_AFTER_SECONDS);
1440
+ await new Promise((resolve) => setTimeout(resolve, waitSeconds * 1e3));
1441
+ }
1442
+ }
1443
+ }
1444
+ /**
1445
+ * One kept URL's page as fetched (HTML). Pass the content id, a `content_url`, or the
1446
+ * `CrawlResult` itself (its `content_url` is present once `content_status` is `fetched`).
1447
+ * Throws `TypeError` when no content id can be read.
1448
+ */
1449
+ async content(crawlId, contentId) {
1450
+ return this.transport.requestText(
1451
+ "GET",
1452
+ `/crawls/${encodeURIComponent(crawlId)}/contents/${encodeURIComponent(contentIdOf(contentId))}`
1453
+ );
1454
+ }
1455
+ /**
1456
+ * Every result of the crawl in one download: its `status` and the parsed lines (with the
1457
+ * page when the crawl has an `output_format`). On a running crawl, `status` is `running` and
1458
+ * the lines hold only what was kept so far.
1459
+ */
1460
+ async download(crawlId) {
1461
+ const response = await this.transport.requestRaw(
1462
+ "GET",
1463
+ `/crawls/${encodeURIComponent(crawlId)}/download`
1464
+ );
1465
+ return {
1466
+ status: response.headers.get("X-Crawl-Status") ?? void 0,
1467
+ lines: ndjsonLines(response.body)
1468
+ };
1469
+ }
1470
+ /**
1471
+ * Poll until the crawl ends (any status but `running`) or `timeout` seconds pass, and return
1472
+ * it, without results. A `failed` crawl resolves too: read `error`. On timeout the crawl is
1473
+ * returned with status `running` and keeps running.
1474
+ */
1475
+ async wait(crawlId, options = {}) {
1476
+ const deadline2 = Date.now() + (options.timeout ?? DEFAULT_WAIT_TIMEOUT_SECONDS) * 1e3;
1477
+ let interval = POLL_INTERVAL_SECONDS;
1478
+ let latest;
1479
+ while (true) {
1480
+ latest = await this.get(crawlId, { limit: 1 });
1481
+ const remaining = deadline2 - Date.now();
1482
+ if (latest.status !== "running" || remaining <= 0) break;
1483
+ const jitter = 0.8 + Math.random() * 0.4;
1484
+ await new Promise(
1485
+ (resolve) => setTimeout(resolve, Math.min(interval * 1e3 * jitter, remaining))
1486
+ );
1487
+ interval = Math.min(interval * POLL_BACKOFF, MAX_POLL_INTERVAL_SECONDS);
1488
+ }
1489
+ const { results: _results, next_cursor: _cursor, ...crawl } = latest;
1490
+ return crawl;
1491
+ }
1492
+ };
1493
+
1228
1494
  // src/batch/schedule.ts
1229
1495
  var DAYS_OF_WEEK = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"];
1230
1496
  function validateTimezone(tz, field) {
@@ -1394,10 +1660,13 @@ var ZenRows = class {
1394
1660
  fetchWithRetry;
1395
1661
  /** Client for the Batch API (async job/run/task model) — see `./batch.ts`. */
1396
1662
  batch;
1663
+ /** Client for the Crawl API (new; one start URL in, the URLs behind it out). See `./crawl/client.ts`. */
1664
+ crawl;
1397
1665
  constructor(apiKey, clientConfig = {}) {
1398
1666
  this.apiKey = apiKey;
1399
1667
  this.clientConfig = clientConfig;
1400
1668
  this.batch = new ZenRowsBatchClient(apiKey);
1669
+ this.crawl = new ZenRowsCrawlClient(apiKey, clientConfig.crawl);
1401
1670
  const retries = this.clientConfig.retries ?? 0;
1402
1671
  this.queue = import_fastq.default.promise(this, this.worker, this.clientConfig.concurrency ?? 5);
1403
1672
  this.fetchWithRetry = (0, import_fetch_retry.default)(fetch, {
@@ -1529,6 +1798,7 @@ var ZenRows = class {
1529
1798
  AUTO_MIN_CREDITS,
1530
1799
  At,
1531
1800
  BASE_CREDITS,
1801
+ CRAWL_NOT_ENABLED_CODE,
1532
1802
  Calendar,
1533
1803
  CurrentRun,
1534
1804
  DEFAULT_MAX_BYTES_PER_FILE,
@@ -1553,6 +1823,8 @@ var ZenRows = class {
1553
1823
  ZenRows,
1554
1824
  ZenRowsBatchClient,
1555
1825
  ZenRowsBatchError,
1826
+ ZenRowsCrawlClient,
1827
+ ZenRowsCrawlError,
1556
1828
  downloadTaskToFile,
1557
1829
  downloadTaskToMemory,
1558
1830
  downloadToDir,