zenrows 2.1.1 → 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/dist/index.js CHANGED
@@ -5,7 +5,7 @@ import fetchRetry from "fetch-retry";
5
5
  // package.json
6
6
  var package_default = {
7
7
  name: "zenrows",
8
- version: "2.1.1",
8
+ version: "2.2.0",
9
9
  description: "ZenRows Node SDK",
10
10
  main: "./dist/index.cjs",
11
11
  module: "./dist/index.js",
@@ -24,6 +24,7 @@ var package_default = {
24
24
  scripts: {
25
25
  build: "tsup src/index.ts --dts --format cjs,esm --clean",
26
26
  test: "vitest",
27
+ "test:e2e": "vitest run tests/e2e",
27
28
  format: "biome format --write .",
28
29
  check: "biome check ."
29
30
  },
@@ -175,12 +176,13 @@ var ZenRowsBatchError = class extends Error {
175
176
  code;
176
177
  extras;
177
178
  constructor(status, problem, extras) {
178
- const message = problem ? `${status} ${problem.title ?? "Error"}: ${problem.detail ?? problem.code ?? "unknown"}` : `${status} (no problem body)`;
179
+ const code = problem?.code ?? "internal";
180
+ const message = problem ? `${status} ${problem.title ?? "Error"}: ${problem.detail ?? code}` : `${status} (no problem body)`;
179
181
  super(message);
180
182
  this.name = "ZenRowsBatchError";
181
183
  this.status = status;
182
- this.problem = problem;
183
- this.code = problem?.code ?? "internal";
184
+ this.problem = problem && { ...problem, code };
185
+ this.code = code;
184
186
  this.extras = extras;
185
187
  }
186
188
  };
@@ -206,7 +208,7 @@ async function parseProblem(response) {
206
208
  type: typeof record.type === "string" ? record.type : "about:blank",
207
209
  title: typeof record.title === "string" ? record.title : "Error",
208
210
  status: typeof record.status === "number" ? record.status : response.status,
209
- code: typeof record.code === "string" ? record.code : "internal",
211
+ code: typeof record.code === "string" ? record.code : void 0,
210
212
  detail: typeof record.detail === "string" ? record.detail : void 0,
211
213
  instance: typeof record.instance === "string" ? record.instance : void 0
212
214
  };
@@ -629,6 +631,7 @@ var IDEMPOTENT_METHODS = /* @__PURE__ */ new Set(["GET", "PUT", "DELETE", "HEAD"
629
631
  var BACKOFF_BASE_MS = 250;
630
632
  var BACKOFF_CAP_MS = 1e4;
631
633
  var DEFAULT_RETRIES = 3;
634
+ var RETRY_AFTER_CAP_MS = 3e4;
632
635
  function isIdempotent(method, hasIdempotencyKey) {
633
636
  return IDEMPOTENT_METHODS.has(method.toUpperCase()) || method.toUpperCase() === "POST" && hasIdempotencyKey;
634
637
  }
@@ -641,41 +644,71 @@ function retryAfterMs(response) {
641
644
  if (!raw) return void 0;
642
645
  const secs = Number(raw);
643
646
  if (Number.isNaN(secs) || secs < 0) return void 0;
644
- return secs * 1e3;
647
+ return Math.min(secs * 1e3, RETRY_AFTER_CAP_MS);
645
648
  }
646
649
  function sleep(ms) {
647
650
  return new Promise((resolve) => setTimeout(resolve, ms));
648
651
  }
652
+ var batchError = (response, problem, extras) => new ZenRowsBatchError(response.status, problem, extras);
653
+ function deadline(ms) {
654
+ if (ms === void 0) return { clear() {
655
+ } };
656
+ const controller = new AbortController();
657
+ const timer = setTimeout(
658
+ () => controller.abort(new DOMException(`request timed out after ${ms / 1e3}s`, "TimeoutError")),
659
+ ms
660
+ );
661
+ return { signal: controller.signal, clear: () => clearTimeout(timer) };
662
+ }
649
663
  var BatchTransport = class {
650
- constructor(baseURL, apiKey, retries = DEFAULT_RETRIES) {
664
+ constructor(baseURL, apiKey, retries = DEFAULT_RETRIES, errorFactory = batchError, timeoutMs) {
651
665
  this.baseURL = baseURL;
652
666
  this.apiKey = apiKey;
653
667
  this.retries = retries;
668
+ this.errorFactory = errorFactory;
669
+ this.timeoutMs = timeoutMs;
654
670
  }
655
671
  baseURL;
656
672
  apiKey;
657
673
  retries;
658
- /** Send a request, parse the response, throw `ZenRowsBatchError` on non-2xx. */
674
+ errorFactory;
675
+ timeoutMs;
676
+ /** Send a request, parse the response, throw the factory's error on non-2xx. */
659
677
  async requestJson(method, path, options = {}) {
660
- const response = await this.send(method, path, options);
661
- if (!response.ok) {
662
- const { problem, extras } = await parseProblem(response);
663
- throw new ZenRowsBatchError(response.status, problem, extras);
664
- }
665
- const text = await response.text();
678
+ const text = await this.requestText(method, path, options);
666
679
  if (!text) {
667
680
  return void 0;
668
681
  }
669
682
  return JSON.parse(text);
670
683
  }
671
- /** Send a request and return the raw `Response` — used for endpoints that don't return JSON. */
684
+ /** Send a request and return its body as text; throw the factory's error on non-2xx. */
685
+ async requestText(method, path, options = {}) {
686
+ const { response, clear } = await this.send(method, path, options);
687
+ try {
688
+ await this.throwUnlessOk(response);
689
+ return await response.text();
690
+ } finally {
691
+ clear();
692
+ }
693
+ }
694
+ /**
695
+ * Send a request and return the raw `Response`, for bodies read as a stream. The timeout
696
+ * stops once the headers of a 2xx arrive; an error body is still read within it.
697
+ */
672
698
  async requestRaw(method, path, options = {}) {
673
- const response = await this.send(method, path, options);
699
+ const { response, clear } = await this.send(method, path, options);
700
+ try {
701
+ await this.throwUnlessOk(response);
702
+ } finally {
703
+ clear();
704
+ }
705
+ return response;
706
+ }
707
+ async throwUnlessOk(response) {
674
708
  if (!response.ok) {
675
709
  const { problem, extras } = await parseProblem(response);
676
- throw new ZenRowsBatchError(response.status, problem, extras);
710
+ throw this.errorFactory(response, problem, extras);
677
711
  }
678
- return response;
679
712
  }
680
713
  buildUrl(path, query) {
681
714
  const url = new URL(`${this.baseURL}${path}`);
@@ -702,16 +735,20 @@ var BatchTransport = class {
702
735
  headers["Idempotency-Key"] = options.idempotencyKey;
703
736
  }
704
737
  const idempotent = isIdempotent(method, Boolean(options.idempotencyKey));
738
+ const retryStatuses = options.retryStatuses ?? RETRYABLE_STATUSES;
705
739
  let attempt = 0;
706
740
  while (true) {
741
+ const { signal, clear } = deadline(this.timeoutMs);
707
742
  let response;
708
743
  try {
709
744
  response = await fetch(url.toString(), {
710
745
  method,
711
746
  headers,
712
- body: options.body !== void 0 ? JSON.stringify(options.body) : void 0
747
+ body: options.body !== void 0 ? JSON.stringify(options.body) : void 0,
748
+ signal
713
749
  });
714
750
  } catch (error) {
751
+ clear();
715
752
  if (idempotent && attempt < this.retries) {
716
753
  await sleep(backoffMs(attempt));
717
754
  attempt += 1;
@@ -719,13 +756,16 @@ var BatchTransport = class {
719
756
  }
720
757
  throw error;
721
758
  }
722
- if (idempotent && attempt < this.retries && RETRYABLE_STATUSES.has(response.status)) {
759
+ if (idempotent && attempt < this.retries && retryStatuses.has(response.status)) {
760
+ response.body?.cancel().catch(() => {
761
+ });
762
+ clear();
723
763
  const wait = retryAfterMs(response) ?? backoffMs(attempt);
724
764
  await sleep(wait);
725
765
  attempt += 1;
726
766
  continue;
727
767
  }
728
- return response;
768
+ return { response, clear };
729
769
  }
730
770
  }
731
771
  };
@@ -752,7 +792,7 @@ async function pollUntil(fetch2, {
752
792
  backoff = 1.5,
753
793
  jitter = 0.2
754
794
  }) {
755
- const deadline = Date.now() + timeout * 1e3;
795
+ const deadline2 = Date.now() + timeout * 1e3;
756
796
  let interval = initialInterval;
757
797
  while (true) {
758
798
  const value = await fetch2();
@@ -765,10 +805,10 @@ async function pollUntil(fetch2, {
765
805
  );
766
806
  }
767
807
  const now = Date.now();
768
- if (now >= deadline) {
808
+ if (now >= deadline2) {
769
809
  throw new WaiterTimeoutError(`waiter: timed out after ${timeout}s waiting for target state`);
770
810
  }
771
- const remainingSeconds = (deadline - now) / 1e3;
811
+ const remainingSeconds = (deadline2 - now) / 1e3;
772
812
  const sleepSeconds = Math.min(interval, remainingSeconds) * (1 + jitter * (Math.random() * 2 - 1));
773
813
  if (sleepSeconds > 0) {
774
814
  await new Promise((resolve) => setTimeout(resolve, sleepSeconds * 1e3));
@@ -1166,6 +1206,220 @@ var ZenRowsBatchClient = class {
1166
1206
  }
1167
1207
  };
1168
1208
 
1209
+ // src/crawl/errors.ts
1210
+ var CRAWL_NOT_ENABLED_CODE = "REQS008";
1211
+ var ZenRowsCrawlError = class extends Error {
1212
+ status;
1213
+ problem;
1214
+ code;
1215
+ detail;
1216
+ extras;
1217
+ /** Seconds from the `Retry-After` header, e.g. 30 on a 429 `too_many_crawls`. */
1218
+ retryAfter;
1219
+ constructor(status, problem, extras, retryAfter) {
1220
+ let message;
1221
+ if (status === 403 && problem?.code === CRAWL_NOT_ENABLED_CODE) {
1222
+ message = `403 Crawl is not enabled for this account (${CRAWL_NOT_ENABLED_CODE})${problem.detail ? `: ${problem.detail}` : ""}`;
1223
+ } else if (problem) {
1224
+ message = `${status} ${problem.title ?? "Error"}: ${problem.detail ?? problem.code ?? "unknown"}`;
1225
+ } else {
1226
+ message = `${status} (no problem body)`;
1227
+ }
1228
+ super(message);
1229
+ this.name = "ZenRowsCrawlError";
1230
+ this.status = status;
1231
+ this.problem = problem;
1232
+ this.code = problem?.code;
1233
+ this.detail = problem?.detail;
1234
+ this.extras = extras;
1235
+ this.retryAfter = retryAfter;
1236
+ }
1237
+ };
1238
+ function crawlError(response, problem, extras) {
1239
+ const raw = response.headers.get("Retry-After");
1240
+ const secs = raw === null || raw.trim() === "" ? Number.NaN : Number(raw);
1241
+ return new ZenRowsCrawlError(
1242
+ response.status,
1243
+ problem,
1244
+ extras,
1245
+ Number.isNaN(secs) || secs < 0 ? void 0 : secs
1246
+ );
1247
+ }
1248
+
1249
+ // src/crawl/client.ts
1250
+ var DEFAULT_CRAWL_API_URL = "https://api.zenrows.com/v1";
1251
+ var DEFAULT_TIMEOUT_SECONDS = 30;
1252
+ var DEFAULT_WAIT_TIMEOUT_SECONDS = 600;
1253
+ var POLL_INTERVAL_SECONDS = 2;
1254
+ var MAX_POLL_INTERVAL_SECONDS = 15;
1255
+ var POLL_BACKOFF = 1.5;
1256
+ var CREATE_RETRY_STATUSES = /* @__PURE__ */ new Set([502, 503, 504]);
1257
+ var STOP_RETRY_STATUSES = /* @__PURE__ */ new Set([429, 502, 503, 504]);
1258
+ var MAX_STOP_RETRY_AFTER_SECONDS = 30;
1259
+ function contentIdOf(content) {
1260
+ let ref;
1261
+ if (typeof content === "string") ref = content;
1262
+ else if (content.content_url) ref = content.content_url;
1263
+ else {
1264
+ throw new TypeError(
1265
+ `content: result for ${content.url} has no content_url (content_status: ${content.content_status ?? "absent"})`
1266
+ );
1267
+ }
1268
+ const id = ref.split("/").pop();
1269
+ if (!id) throw new TypeError(`content: cannot read a content id from ${ref}`);
1270
+ return id;
1271
+ }
1272
+ async function* ndjsonLines(body) {
1273
+ if (!body) return;
1274
+ const decoder = new TextDecoder();
1275
+ let buffered = "";
1276
+ for await (const chunk of body) {
1277
+ buffered += decoder.decode(chunk, { stream: true });
1278
+ let newline = buffered.indexOf("\n");
1279
+ while (newline !== -1) {
1280
+ const line = buffered.slice(0, newline).trim();
1281
+ buffered = buffered.slice(newline + 1);
1282
+ if (line) yield JSON.parse(line);
1283
+ newline = buffered.indexOf("\n");
1284
+ }
1285
+ }
1286
+ const last = (buffered + decoder.decode()).trim();
1287
+ if (last) yield JSON.parse(last);
1288
+ }
1289
+ var ZenRowsCrawlClient = class {
1290
+ apiKey;
1291
+ transport;
1292
+ retries;
1293
+ constructor(apiKey, config = {}) {
1294
+ this.apiKey = apiKey;
1295
+ this.retries = config.retries ?? DEFAULT_RETRIES;
1296
+ this.transport = new BatchTransport(
1297
+ (config.baseURL ?? DEFAULT_CRAWL_API_URL).replace(/\/+$/, ""),
1298
+ apiKey,
1299
+ config.retries,
1300
+ crawlError,
1301
+ (config.timeout ?? DEFAULT_TIMEOUT_SECONDS) * 1e3
1302
+ );
1303
+ }
1304
+ /**
1305
+ * Start a crawl. Sends only the fields you set. With an `idempotencyKey`, a retry returns the
1306
+ * crawl the first request created instead of starting another, and 5xx and network failures
1307
+ * are retried. A 429 `too_many_crawls` is never retried: it throws `ZenRowsCrawlError` with
1308
+ * `retryAfter` set.
1309
+ */
1310
+ create(params, opts = {}) {
1311
+ const body = {
1312
+ url: params.url,
1313
+ depth: params.depth,
1314
+ max_items: params.maxItems,
1315
+ max_pages: params.maxPages,
1316
+ include_patterns: params.includePatterns,
1317
+ exclude_patterns: params.excludePatterns,
1318
+ output_format: params.outputFormat
1319
+ };
1320
+ return this.transport.requestJson("POST", "/crawls", {
1321
+ body,
1322
+ idempotencyKey: opts.idempotencyKey,
1323
+ retryStatuses: CREATE_RETRY_STATUSES
1324
+ });
1325
+ }
1326
+ /** Read a crawl's status, coverage and one page of results (`limit` 1–10000, default 1000). */
1327
+ get(crawlId, options = {}) {
1328
+ return this.transport.requestJson("GET", `/crawls/${encodeURIComponent(crawlId)}`, {
1329
+ query: options
1330
+ });
1331
+ }
1332
+ /** One page of the account's crawls, newest first (`limit` 1–100, default 20). */
1333
+ list(options = {}) {
1334
+ return this.transport.requestJson("GET", "/crawls", { query: options });
1335
+ }
1336
+ /**
1337
+ * Every URL the crawl kept, following `next_cursor` (`limit` is the page size). On a running
1338
+ * crawl it yields the URLs kept so far and returns at the first empty page; it does not poll.
1339
+ * Call `wait()` first to read every result.
1340
+ */
1341
+ async *results(crawlId, options = {}) {
1342
+ let cursor;
1343
+ while (true) {
1344
+ const page = await this.get(crawlId, { ...options, cursor });
1345
+ yield* page.results;
1346
+ if (page.next_cursor === null || page.results.length === 0) return;
1347
+ cursor = page.next_cursor;
1348
+ }
1349
+ }
1350
+ /**
1351
+ * Stop a running crawl. Idempotent: a crawl that already ended answers as it ended.
1352
+ * Because a stop is safe to repeat, `stop()` retries a 429, 502, 503 (including `crawl_busy`,
1353
+ * which means the stop was not saved and the crawl is still running) or 504 up to `retries`
1354
+ * times, waiting `Retry-After` seconds (at most 30; 1 when the header is missing or unusable)
1355
+ * each time, then throws the last error.
1356
+ */
1357
+ async stop(crawlId) {
1358
+ for (let attempt = 0; ; attempt += 1) {
1359
+ try {
1360
+ return await this.transport.requestJson(
1361
+ "POST",
1362
+ `/crawls/${encodeURIComponent(crawlId)}/stop`
1363
+ );
1364
+ } catch (error) {
1365
+ if (!(error instanceof ZenRowsCrawlError) || !STOP_RETRY_STATUSES.has(error.status) || attempt >= this.retries) {
1366
+ throw error;
1367
+ }
1368
+ const waitSeconds = Math.min(error.retryAfter ?? 1, MAX_STOP_RETRY_AFTER_SECONDS);
1369
+ await new Promise((resolve) => setTimeout(resolve, waitSeconds * 1e3));
1370
+ }
1371
+ }
1372
+ }
1373
+ /**
1374
+ * One kept URL's page as fetched (HTML). Pass the content id, a `content_url`, or the
1375
+ * `CrawlResult` itself (its `content_url` is present once `content_status` is `fetched`).
1376
+ * Throws `TypeError` when no content id can be read.
1377
+ */
1378
+ async content(crawlId, contentId) {
1379
+ return this.transport.requestText(
1380
+ "GET",
1381
+ `/crawls/${encodeURIComponent(crawlId)}/contents/${encodeURIComponent(contentIdOf(contentId))}`
1382
+ );
1383
+ }
1384
+ /**
1385
+ * Every result of the crawl in one download: its `status` and the parsed lines (with the
1386
+ * page when the crawl has an `output_format`). On a running crawl, `status` is `running` and
1387
+ * the lines hold only what was kept so far.
1388
+ */
1389
+ async download(crawlId) {
1390
+ const response = await this.transport.requestRaw(
1391
+ "GET",
1392
+ `/crawls/${encodeURIComponent(crawlId)}/download`
1393
+ );
1394
+ return {
1395
+ status: response.headers.get("X-Crawl-Status") ?? void 0,
1396
+ lines: ndjsonLines(response.body)
1397
+ };
1398
+ }
1399
+ /**
1400
+ * Poll until the crawl ends (any status but `running`) or `timeout` seconds pass, and return
1401
+ * it, without results. A `failed` crawl resolves too: read `error`. On timeout the crawl is
1402
+ * returned with status `running` and keeps running.
1403
+ */
1404
+ async wait(crawlId, options = {}) {
1405
+ const deadline2 = Date.now() + (options.timeout ?? DEFAULT_WAIT_TIMEOUT_SECONDS) * 1e3;
1406
+ let interval = POLL_INTERVAL_SECONDS;
1407
+ let latest;
1408
+ while (true) {
1409
+ latest = await this.get(crawlId, { limit: 1 });
1410
+ const remaining = deadline2 - Date.now();
1411
+ if (latest.status !== "running" || remaining <= 0) break;
1412
+ const jitter = 0.8 + Math.random() * 0.4;
1413
+ await new Promise(
1414
+ (resolve) => setTimeout(resolve, Math.min(interval * 1e3 * jitter, remaining))
1415
+ );
1416
+ interval = Math.min(interval * POLL_BACKOFF, MAX_POLL_INTERVAL_SECONDS);
1417
+ }
1418
+ const { results: _results, next_cursor: _cursor, ...crawl } = latest;
1419
+ return crawl;
1420
+ }
1421
+ };
1422
+
1169
1423
  // src/batch/schedule.ts
1170
1424
  var DAYS_OF_WEEK = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"];
1171
1425
  function validateTimezone(tz, field) {
@@ -1335,10 +1589,13 @@ var ZenRows = class {
1335
1589
  fetchWithRetry;
1336
1590
  /** Client for the Batch API (async job/run/task model) — see `./batch.ts`. */
1337
1591
  batch;
1592
+ /** Client for the Crawl API (new; one start URL in, the URLs behind it out). See `./crawl/client.ts`. */
1593
+ crawl;
1338
1594
  constructor(apiKey, clientConfig = {}) {
1339
1595
  this.apiKey = apiKey;
1340
1596
  this.clientConfig = clientConfig;
1341
1597
  this.batch = new ZenRowsBatchClient(apiKey);
1598
+ this.crawl = new ZenRowsCrawlClient(apiKey, clientConfig.crawl);
1342
1599
  const retries = this.clientConfig.retries ?? 0;
1343
1600
  this.queue = fastq.promise(this, this.worker, this.clientConfig.concurrency ?? 5);
1344
1601
  this.fetchWithRetry = fetchRetry(fetch, {
@@ -1469,6 +1726,7 @@ export {
1469
1726
  AUTO_MIN_CREDITS,
1470
1727
  At,
1471
1728
  BASE_CREDITS,
1729
+ CRAWL_NOT_ENABLED_CODE,
1472
1730
  Calendar,
1473
1731
  CurrentRun,
1474
1732
  DEFAULT_MAX_BYTES_PER_FILE,
@@ -1493,6 +1751,8 @@ export {
1493
1751
  ZenRows,
1494
1752
  ZenRowsBatchClient,
1495
1753
  ZenRowsBatchError,
1754
+ ZenRowsCrawlClient,
1755
+ ZenRowsCrawlError,
1496
1756
  downloadTaskToFile,
1497
1757
  downloadTaskToMemory,
1498
1758
  downloadToDir,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zenrows",
3
- "version": "2.1.1",
3
+ "version": "2.2.0",
4
4
  "description": "ZenRows Node SDK",
5
5
  "main": "./dist/index.cjs",
6
6
  "module": "./dist/index.js",
@@ -19,6 +19,7 @@
19
19
  "scripts": {
20
20
  "build": "tsup src/index.ts --dts --format cjs,esm --clean",
21
21
  "test": "vitest",
22
+ "test:e2e": "vitest run tests/e2e",
22
23
  "format": "biome format --write .",
23
24
  "check": "biome check ."
24
25
  },