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 +105 -0
- package/dist/index.cjs +299 -27
- package/dist/index.d.cts +251 -17
- package/dist/index.d.ts +251 -17
- package/dist/index.js +296 -27
- package/package.json +7 -2
- package/tsconfig.test.json +9 -0
- package/vitest.config.ts +2 -0
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
|
|
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: [
|
|
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
|
|
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 =
|
|
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 :
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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 &&
|
|
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
|
|
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 >=
|
|
879
|
+
if (now >= deadline2) {
|
|
829
880
|
throw new WaiterTimeoutError(`waiter: timed out after ${timeout}s waiting for target state`);
|
|
830
881
|
}
|
|
831
|
-
const remainingSeconds = (
|
|
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
|
-
|
|
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,
|