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/README.md +93 -0
- package/dist/index.cjs +287 -24
- package/dist/index.d.cts +207 -1
- package/dist/index.d.ts +207 -1
- package/dist/index.js +284 -24
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -19,10 +19,12 @@ SDK to access [Zenrows](https://www.zenrows.com/) API directly from Node.js. Zen
|
|
|
19
19
|
- [Batch](#batch)
|
|
20
20
|
- [Failed runs and extensible values](#failed-runs-and-extensible-values)
|
|
21
21
|
- [Extract in a batch](#extract-in-a-batch)
|
|
22
|
+
- [Crawl](#crawl)
|
|
22
23
|
- [Concurrency](#concurrency)
|
|
23
24
|
- [An important note about Promise.allSettled() on TypeScript](#an-important-note-about-promiseallsettled-on-typescript)
|
|
24
25
|
- [Examples](#examples)
|
|
25
26
|
- [Contributing](#contributing)
|
|
27
|
+
- [Running the Crawl e2e test](#running-the-crawl-e2e-test)
|
|
26
28
|
- [License](#license)
|
|
27
29
|
|
|
28
30
|
## Installation
|
|
@@ -265,6 +267,77 @@ const { ZenRowsBatchClient } = require("zenrows");
|
|
|
265
267
|
const batch = new ZenRowsBatchClient(apiKey, { baseURL: "https://async.api.zenrows.com/v1" }); // baseURL is optional
|
|
266
268
|
```
|
|
267
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
|
+
|
|
268
341
|
### Concurrency
|
|
269
342
|
|
|
270
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.
|
|
@@ -340,6 +413,26 @@ npx tsx index.ts # TS example
|
|
|
340
413
|
|
|
341
414
|
Pull requests are welcome. For significant changes, please open an issue first to discuss what you would like to change.
|
|
342
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
|
+
|
|
343
436
|
## License
|
|
344
437
|
|
|
345
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.
|
|
79
|
+
version: "2.2.0",
|
|
77
80
|
description: "ZenRows Node SDK",
|
|
78
81
|
main: "./dist/index.cjs",
|
|
79
82
|
module: "./dist/index.js",
|
|
@@ -92,6 +95,7 @@ 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
|
},
|
|
@@ -243,12 +247,13 @@ var ZenRowsBatchError = class extends Error {
|
|
|
243
247
|
code;
|
|
244
248
|
extras;
|
|
245
249
|
constructor(status, problem, extras) {
|
|
246
|
-
const
|
|
250
|
+
const code = problem?.code ?? "internal";
|
|
251
|
+
const message = problem ? `${status} ${problem.title ?? "Error"}: ${problem.detail ?? code}` : `${status} (no problem body)`;
|
|
247
252
|
super(message);
|
|
248
253
|
this.name = "ZenRowsBatchError";
|
|
249
254
|
this.status = status;
|
|
250
|
-
this.problem = problem;
|
|
251
|
-
this.code =
|
|
255
|
+
this.problem = problem && { ...problem, code };
|
|
256
|
+
this.code = code;
|
|
252
257
|
this.extras = extras;
|
|
253
258
|
}
|
|
254
259
|
};
|
|
@@ -274,7 +279,7 @@ async function parseProblem(response) {
|
|
|
274
279
|
type: typeof record.type === "string" ? record.type : "about:blank",
|
|
275
280
|
title: typeof record.title === "string" ? record.title : "Error",
|
|
276
281
|
status: typeof record.status === "number" ? record.status : response.status,
|
|
277
|
-
code: typeof record.code === "string" ? record.code :
|
|
282
|
+
code: typeof record.code === "string" ? record.code : void 0,
|
|
278
283
|
detail: typeof record.detail === "string" ? record.detail : void 0,
|
|
279
284
|
instance: typeof record.instance === "string" ? record.instance : void 0
|
|
280
285
|
};
|
|
@@ -697,6 +702,7 @@ var IDEMPOTENT_METHODS = /* @__PURE__ */ new Set(["GET", "PUT", "DELETE", "HEAD"
|
|
|
697
702
|
var BACKOFF_BASE_MS = 250;
|
|
698
703
|
var BACKOFF_CAP_MS = 1e4;
|
|
699
704
|
var DEFAULT_RETRIES = 3;
|
|
705
|
+
var RETRY_AFTER_CAP_MS = 3e4;
|
|
700
706
|
function isIdempotent(method, hasIdempotencyKey) {
|
|
701
707
|
return IDEMPOTENT_METHODS.has(method.toUpperCase()) || method.toUpperCase() === "POST" && hasIdempotencyKey;
|
|
702
708
|
}
|
|
@@ -709,41 +715,71 @@ function retryAfterMs(response) {
|
|
|
709
715
|
if (!raw) return void 0;
|
|
710
716
|
const secs = Number(raw);
|
|
711
717
|
if (Number.isNaN(secs) || secs < 0) return void 0;
|
|
712
|
-
return secs * 1e3;
|
|
718
|
+
return Math.min(secs * 1e3, RETRY_AFTER_CAP_MS);
|
|
713
719
|
}
|
|
714
720
|
function sleep(ms) {
|
|
715
721
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
716
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
|
+
}
|
|
717
734
|
var BatchTransport = class {
|
|
718
|
-
constructor(baseURL, apiKey, retries = DEFAULT_RETRIES) {
|
|
735
|
+
constructor(baseURL, apiKey, retries = DEFAULT_RETRIES, errorFactory = batchError, timeoutMs) {
|
|
719
736
|
this.baseURL = baseURL;
|
|
720
737
|
this.apiKey = apiKey;
|
|
721
738
|
this.retries = retries;
|
|
739
|
+
this.errorFactory = errorFactory;
|
|
740
|
+
this.timeoutMs = timeoutMs;
|
|
722
741
|
}
|
|
723
742
|
baseURL;
|
|
724
743
|
apiKey;
|
|
725
744
|
retries;
|
|
726
|
-
|
|
745
|
+
errorFactory;
|
|
746
|
+
timeoutMs;
|
|
747
|
+
/** Send a request, parse the response, throw the factory's error on non-2xx. */
|
|
727
748
|
async requestJson(method, path, options = {}) {
|
|
728
|
-
const
|
|
729
|
-
if (!response.ok) {
|
|
730
|
-
const { problem, extras } = await parseProblem(response);
|
|
731
|
-
throw new ZenRowsBatchError(response.status, problem, extras);
|
|
732
|
-
}
|
|
733
|
-
const text = await response.text();
|
|
749
|
+
const text = await this.requestText(method, path, options);
|
|
734
750
|
if (!text) {
|
|
735
751
|
return void 0;
|
|
736
752
|
}
|
|
737
753
|
return JSON.parse(text);
|
|
738
754
|
}
|
|
739
|
-
/** 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
|
+
*/
|
|
740
769
|
async requestRaw(method, path, options = {}) {
|
|
741
|
-
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) {
|
|
742
779
|
if (!response.ok) {
|
|
743
780
|
const { problem, extras } = await parseProblem(response);
|
|
744
|
-
throw
|
|
781
|
+
throw this.errorFactory(response, problem, extras);
|
|
745
782
|
}
|
|
746
|
-
return response;
|
|
747
783
|
}
|
|
748
784
|
buildUrl(path, query) {
|
|
749
785
|
const url = new URL(`${this.baseURL}${path}`);
|
|
@@ -770,16 +806,20 @@ var BatchTransport = class {
|
|
|
770
806
|
headers["Idempotency-Key"] = options.idempotencyKey;
|
|
771
807
|
}
|
|
772
808
|
const idempotent = isIdempotent(method, Boolean(options.idempotencyKey));
|
|
809
|
+
const retryStatuses = options.retryStatuses ?? RETRYABLE_STATUSES;
|
|
773
810
|
let attempt = 0;
|
|
774
811
|
while (true) {
|
|
812
|
+
const { signal, clear } = deadline(this.timeoutMs);
|
|
775
813
|
let response;
|
|
776
814
|
try {
|
|
777
815
|
response = await fetch(url.toString(), {
|
|
778
816
|
method,
|
|
779
817
|
headers,
|
|
780
|
-
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
|
|
781
820
|
});
|
|
782
821
|
} catch (error) {
|
|
822
|
+
clear();
|
|
783
823
|
if (idempotent && attempt < this.retries) {
|
|
784
824
|
await sleep(backoffMs(attempt));
|
|
785
825
|
attempt += 1;
|
|
@@ -787,13 +827,16 @@ var BatchTransport = class {
|
|
|
787
827
|
}
|
|
788
828
|
throw error;
|
|
789
829
|
}
|
|
790
|
-
if (idempotent && attempt < this.retries &&
|
|
830
|
+
if (idempotent && attempt < this.retries && retryStatuses.has(response.status)) {
|
|
831
|
+
response.body?.cancel().catch(() => {
|
|
832
|
+
});
|
|
833
|
+
clear();
|
|
791
834
|
const wait = retryAfterMs(response) ?? backoffMs(attempt);
|
|
792
835
|
await sleep(wait);
|
|
793
836
|
attempt += 1;
|
|
794
837
|
continue;
|
|
795
838
|
}
|
|
796
|
-
return response;
|
|
839
|
+
return { response, clear };
|
|
797
840
|
}
|
|
798
841
|
}
|
|
799
842
|
};
|
|
@@ -820,7 +863,7 @@ async function pollUntil(fetch2, {
|
|
|
820
863
|
backoff = 1.5,
|
|
821
864
|
jitter = 0.2
|
|
822
865
|
}) {
|
|
823
|
-
const
|
|
866
|
+
const deadline2 = Date.now() + timeout * 1e3;
|
|
824
867
|
let interval = initialInterval;
|
|
825
868
|
while (true) {
|
|
826
869
|
const value = await fetch2();
|
|
@@ -833,10 +876,10 @@ async function pollUntil(fetch2, {
|
|
|
833
876
|
);
|
|
834
877
|
}
|
|
835
878
|
const now = Date.now();
|
|
836
|
-
if (now >=
|
|
879
|
+
if (now >= deadline2) {
|
|
837
880
|
throw new WaiterTimeoutError(`waiter: timed out after ${timeout}s waiting for target state`);
|
|
838
881
|
}
|
|
839
|
-
const remainingSeconds = (
|
|
882
|
+
const remainingSeconds = (deadline2 - now) / 1e3;
|
|
840
883
|
const sleepSeconds = Math.min(interval, remainingSeconds) * (1 + jitter * (Math.random() * 2 - 1));
|
|
841
884
|
if (sleepSeconds > 0) {
|
|
842
885
|
await new Promise((resolve) => setTimeout(resolve, sleepSeconds * 1e3));
|
|
@@ -1234,6 +1277,220 @@ var ZenRowsBatchClient = class {
|
|
|
1234
1277
|
}
|
|
1235
1278
|
};
|
|
1236
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
|
+
|
|
1237
1494
|
// src/batch/schedule.ts
|
|
1238
1495
|
var DAYS_OF_WEEK = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"];
|
|
1239
1496
|
function validateTimezone(tz, field) {
|
|
@@ -1403,10 +1660,13 @@ var ZenRows = class {
|
|
|
1403
1660
|
fetchWithRetry;
|
|
1404
1661
|
/** Client for the Batch API (async job/run/task model) — see `./batch.ts`. */
|
|
1405
1662
|
batch;
|
|
1663
|
+
/** Client for the Crawl API (new; one start URL in, the URLs behind it out). See `./crawl/client.ts`. */
|
|
1664
|
+
crawl;
|
|
1406
1665
|
constructor(apiKey, clientConfig = {}) {
|
|
1407
1666
|
this.apiKey = apiKey;
|
|
1408
1667
|
this.clientConfig = clientConfig;
|
|
1409
1668
|
this.batch = new ZenRowsBatchClient(apiKey);
|
|
1669
|
+
this.crawl = new ZenRowsCrawlClient(apiKey, clientConfig.crawl);
|
|
1410
1670
|
const retries = this.clientConfig.retries ?? 0;
|
|
1411
1671
|
this.queue = import_fastq.default.promise(this, this.worker, this.clientConfig.concurrency ?? 5);
|
|
1412
1672
|
this.fetchWithRetry = (0, import_fetch_retry.default)(fetch, {
|
|
@@ -1538,6 +1798,7 @@ var ZenRows = class {
|
|
|
1538
1798
|
AUTO_MIN_CREDITS,
|
|
1539
1799
|
At,
|
|
1540
1800
|
BASE_CREDITS,
|
|
1801
|
+
CRAWL_NOT_ENABLED_CODE,
|
|
1541
1802
|
Calendar,
|
|
1542
1803
|
CurrentRun,
|
|
1543
1804
|
DEFAULT_MAX_BYTES_PER_FILE,
|
|
@@ -1562,6 +1823,8 @@ var ZenRows = class {
|
|
|
1562
1823
|
ZenRows,
|
|
1563
1824
|
ZenRowsBatchClient,
|
|
1564
1825
|
ZenRowsBatchError,
|
|
1826
|
+
ZenRowsCrawlClient,
|
|
1827
|
+
ZenRowsCrawlError,
|
|
1565
1828
|
downloadTaskToFile,
|
|
1566
1829
|
downloadTaskToMemory,
|
|
1567
1830
|
downloadToDir,
|