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/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.0
|
|
8
|
+
version: "2.2.0",
|
|
9
9
|
description: "ZenRows Node SDK",
|
|
10
10
|
main: "./dist/index.cjs",
|
|
11
11
|
module: "./dist/index.js",
|
|
@@ -24,10 +24,19 @@ 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
|
},
|
|
30
|
-
keywords: [
|
|
31
|
+
keywords: [
|
|
32
|
+
"sdk",
|
|
33
|
+
"zenrows",
|
|
34
|
+
"scraping",
|
|
35
|
+
"proxy",
|
|
36
|
+
"data extraction",
|
|
37
|
+
"web scraping",
|
|
38
|
+
"web scraping api"
|
|
39
|
+
],
|
|
31
40
|
author: "ZenRows",
|
|
32
41
|
repository: {
|
|
33
42
|
type: "git",
|
|
@@ -167,12 +176,13 @@ var ZenRowsBatchError = class extends Error {
|
|
|
167
176
|
code;
|
|
168
177
|
extras;
|
|
169
178
|
constructor(status, problem, extras) {
|
|
170
|
-
const
|
|
179
|
+
const code = problem?.code ?? "internal";
|
|
180
|
+
const message = problem ? `${status} ${problem.title ?? "Error"}: ${problem.detail ?? code}` : `${status} (no problem body)`;
|
|
171
181
|
super(message);
|
|
172
182
|
this.name = "ZenRowsBatchError";
|
|
173
183
|
this.status = status;
|
|
174
|
-
this.problem = problem;
|
|
175
|
-
this.code =
|
|
184
|
+
this.problem = problem && { ...problem, code };
|
|
185
|
+
this.code = code;
|
|
176
186
|
this.extras = extras;
|
|
177
187
|
}
|
|
178
188
|
};
|
|
@@ -198,7 +208,7 @@ async function parseProblem(response) {
|
|
|
198
208
|
type: typeof record.type === "string" ? record.type : "about:blank",
|
|
199
209
|
title: typeof record.title === "string" ? record.title : "Error",
|
|
200
210
|
status: typeof record.status === "number" ? record.status : response.status,
|
|
201
|
-
code: typeof record.code === "string" ? record.code :
|
|
211
|
+
code: typeof record.code === "string" ? record.code : void 0,
|
|
202
212
|
detail: typeof record.detail === "string" ? record.detail : void 0,
|
|
203
213
|
instance: typeof record.instance === "string" ? record.instance : void 0
|
|
204
214
|
};
|
|
@@ -621,6 +631,7 @@ var IDEMPOTENT_METHODS = /* @__PURE__ */ new Set(["GET", "PUT", "DELETE", "HEAD"
|
|
|
621
631
|
var BACKOFF_BASE_MS = 250;
|
|
622
632
|
var BACKOFF_CAP_MS = 1e4;
|
|
623
633
|
var DEFAULT_RETRIES = 3;
|
|
634
|
+
var RETRY_AFTER_CAP_MS = 3e4;
|
|
624
635
|
function isIdempotent(method, hasIdempotencyKey) {
|
|
625
636
|
return IDEMPOTENT_METHODS.has(method.toUpperCase()) || method.toUpperCase() === "POST" && hasIdempotencyKey;
|
|
626
637
|
}
|
|
@@ -633,41 +644,71 @@ function retryAfterMs(response) {
|
|
|
633
644
|
if (!raw) return void 0;
|
|
634
645
|
const secs = Number(raw);
|
|
635
646
|
if (Number.isNaN(secs) || secs < 0) return void 0;
|
|
636
|
-
return secs * 1e3;
|
|
647
|
+
return Math.min(secs * 1e3, RETRY_AFTER_CAP_MS);
|
|
637
648
|
}
|
|
638
649
|
function sleep(ms) {
|
|
639
650
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
640
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
|
+
}
|
|
641
663
|
var BatchTransport = class {
|
|
642
|
-
constructor(baseURL, apiKey, retries = DEFAULT_RETRIES) {
|
|
664
|
+
constructor(baseURL, apiKey, retries = DEFAULT_RETRIES, errorFactory = batchError, timeoutMs) {
|
|
643
665
|
this.baseURL = baseURL;
|
|
644
666
|
this.apiKey = apiKey;
|
|
645
667
|
this.retries = retries;
|
|
668
|
+
this.errorFactory = errorFactory;
|
|
669
|
+
this.timeoutMs = timeoutMs;
|
|
646
670
|
}
|
|
647
671
|
baseURL;
|
|
648
672
|
apiKey;
|
|
649
673
|
retries;
|
|
650
|
-
|
|
674
|
+
errorFactory;
|
|
675
|
+
timeoutMs;
|
|
676
|
+
/** Send a request, parse the response, throw the factory's error on non-2xx. */
|
|
651
677
|
async requestJson(method, path, options = {}) {
|
|
652
|
-
const
|
|
653
|
-
if (!response.ok) {
|
|
654
|
-
const { problem, extras } = await parseProblem(response);
|
|
655
|
-
throw new ZenRowsBatchError(response.status, problem, extras);
|
|
656
|
-
}
|
|
657
|
-
const text = await response.text();
|
|
678
|
+
const text = await this.requestText(method, path, options);
|
|
658
679
|
if (!text) {
|
|
659
680
|
return void 0;
|
|
660
681
|
}
|
|
661
682
|
return JSON.parse(text);
|
|
662
683
|
}
|
|
663
|
-
/** Send a request and return
|
|
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
|
+
*/
|
|
664
698
|
async requestRaw(method, path, options = {}) {
|
|
665
|
-
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) {
|
|
666
708
|
if (!response.ok) {
|
|
667
709
|
const { problem, extras } = await parseProblem(response);
|
|
668
|
-
throw
|
|
710
|
+
throw this.errorFactory(response, problem, extras);
|
|
669
711
|
}
|
|
670
|
-
return response;
|
|
671
712
|
}
|
|
672
713
|
buildUrl(path, query) {
|
|
673
714
|
const url = new URL(`${this.baseURL}${path}`);
|
|
@@ -694,16 +735,20 @@ var BatchTransport = class {
|
|
|
694
735
|
headers["Idempotency-Key"] = options.idempotencyKey;
|
|
695
736
|
}
|
|
696
737
|
const idempotent = isIdempotent(method, Boolean(options.idempotencyKey));
|
|
738
|
+
const retryStatuses = options.retryStatuses ?? RETRYABLE_STATUSES;
|
|
697
739
|
let attempt = 0;
|
|
698
740
|
while (true) {
|
|
741
|
+
const { signal, clear } = deadline(this.timeoutMs);
|
|
699
742
|
let response;
|
|
700
743
|
try {
|
|
701
744
|
response = await fetch(url.toString(), {
|
|
702
745
|
method,
|
|
703
746
|
headers,
|
|
704
|
-
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
|
|
705
749
|
});
|
|
706
750
|
} catch (error) {
|
|
751
|
+
clear();
|
|
707
752
|
if (idempotent && attempt < this.retries) {
|
|
708
753
|
await sleep(backoffMs(attempt));
|
|
709
754
|
attempt += 1;
|
|
@@ -711,13 +756,16 @@ var BatchTransport = class {
|
|
|
711
756
|
}
|
|
712
757
|
throw error;
|
|
713
758
|
}
|
|
714
|
-
if (idempotent && attempt < this.retries &&
|
|
759
|
+
if (idempotent && attempt < this.retries && retryStatuses.has(response.status)) {
|
|
760
|
+
response.body?.cancel().catch(() => {
|
|
761
|
+
});
|
|
762
|
+
clear();
|
|
715
763
|
const wait = retryAfterMs(response) ?? backoffMs(attempt);
|
|
716
764
|
await sleep(wait);
|
|
717
765
|
attempt += 1;
|
|
718
766
|
continue;
|
|
719
767
|
}
|
|
720
|
-
return response;
|
|
768
|
+
return { response, clear };
|
|
721
769
|
}
|
|
722
770
|
}
|
|
723
771
|
};
|
|
@@ -744,7 +792,7 @@ async function pollUntil(fetch2, {
|
|
|
744
792
|
backoff = 1.5,
|
|
745
793
|
jitter = 0.2
|
|
746
794
|
}) {
|
|
747
|
-
const
|
|
795
|
+
const deadline2 = Date.now() + timeout * 1e3;
|
|
748
796
|
let interval = initialInterval;
|
|
749
797
|
while (true) {
|
|
750
798
|
const value = await fetch2();
|
|
@@ -757,10 +805,10 @@ async function pollUntil(fetch2, {
|
|
|
757
805
|
);
|
|
758
806
|
}
|
|
759
807
|
const now = Date.now();
|
|
760
|
-
if (now >=
|
|
808
|
+
if (now >= deadline2) {
|
|
761
809
|
throw new WaiterTimeoutError(`waiter: timed out after ${timeout}s waiting for target state`);
|
|
762
810
|
}
|
|
763
|
-
const remainingSeconds = (
|
|
811
|
+
const remainingSeconds = (deadline2 - now) / 1e3;
|
|
764
812
|
const sleepSeconds = Math.min(interval, remainingSeconds) * (1 + jitter * (Math.random() * 2 - 1));
|
|
765
813
|
if (sleepSeconds > 0) {
|
|
766
814
|
await new Promise((resolve) => setTimeout(resolve, sleepSeconds * 1e3));
|
|
@@ -771,7 +819,7 @@ async function pollUntil(fetch2, {
|
|
|
771
819
|
|
|
772
820
|
// src/batch/client.ts
|
|
773
821
|
var DEFAULT_BATCH_API_URL = "https://async.api.zenrows.com/v1";
|
|
774
|
-
var TERMINAL_RUN_STATUSES = /* @__PURE__ */ new Set(["completed", "stopped", "deleted"]);
|
|
822
|
+
var TERMINAL_RUN_STATUSES = /* @__PURE__ */ new Set(["completed", "stopped", "failed", "deleted"]);
|
|
775
823
|
var TERMINAL_EXPORT_STATUSES = /* @__PURE__ */ new Set(["completed", "failed"]);
|
|
776
824
|
function coerceUrl(item) {
|
|
777
825
|
return typeof item === "string" ? { url: item } : item;
|
|
@@ -1134,7 +1182,8 @@ var ZenRowsBatchClient = class {
|
|
|
1134
1182
|
return this._getRunData(jobId, options.runId);
|
|
1135
1183
|
};
|
|
1136
1184
|
return pollUntil(fetchRun, {
|
|
1137
|
-
|
|
1185
|
+
// A status the caller named as a failure throws, even when it is also a target.
|
|
1186
|
+
isDone: (run) => run !== void 0 && target.has(run.status) && !options.failureStatuses?.has(run.status),
|
|
1138
1187
|
isFailure: (run) => run !== void 0 && Boolean(options.failureStatuses?.has(run.status)),
|
|
1139
1188
|
timeout: options.timeout ?? 300,
|
|
1140
1189
|
initialInterval: options.pollInterval ?? 2,
|
|
@@ -1157,6 +1206,220 @@ var ZenRowsBatchClient = class {
|
|
|
1157
1206
|
}
|
|
1158
1207
|
};
|
|
1159
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
|
+
|
|
1160
1423
|
// src/batch/schedule.ts
|
|
1161
1424
|
var DAYS_OF_WEEK = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"];
|
|
1162
1425
|
function validateTimezone(tz, field) {
|
|
@@ -1326,10 +1589,13 @@ var ZenRows = class {
|
|
|
1326
1589
|
fetchWithRetry;
|
|
1327
1590
|
/** Client for the Batch API (async job/run/task model) — see `./batch.ts`. */
|
|
1328
1591
|
batch;
|
|
1592
|
+
/** Client for the Crawl API (new; one start URL in, the URLs behind it out). See `./crawl/client.ts`. */
|
|
1593
|
+
crawl;
|
|
1329
1594
|
constructor(apiKey, clientConfig = {}) {
|
|
1330
1595
|
this.apiKey = apiKey;
|
|
1331
1596
|
this.clientConfig = clientConfig;
|
|
1332
1597
|
this.batch = new ZenRowsBatchClient(apiKey);
|
|
1598
|
+
this.crawl = new ZenRowsCrawlClient(apiKey, clientConfig.crawl);
|
|
1333
1599
|
const retries = this.clientConfig.retries ?? 0;
|
|
1334
1600
|
this.queue = fastq.promise(this, this.worker, this.clientConfig.concurrency ?? 5);
|
|
1335
1601
|
this.fetchWithRetry = fetchRetry(fetch, {
|
|
@@ -1460,6 +1726,7 @@ export {
|
|
|
1460
1726
|
AUTO_MIN_CREDITS,
|
|
1461
1727
|
At,
|
|
1462
1728
|
BASE_CREDITS,
|
|
1729
|
+
CRAWL_NOT_ENABLED_CODE,
|
|
1463
1730
|
Calendar,
|
|
1464
1731
|
CurrentRun,
|
|
1465
1732
|
DEFAULT_MAX_BYTES_PER_FILE,
|
|
@@ -1484,6 +1751,8 @@ export {
|
|
|
1484
1751
|
ZenRows,
|
|
1485
1752
|
ZenRowsBatchClient,
|
|
1486
1753
|
ZenRowsBatchError,
|
|
1754
|
+
ZenRowsCrawlClient,
|
|
1755
|
+
ZenRowsCrawlError,
|
|
1487
1756
|
downloadTaskToFile,
|
|
1488
1757
|
downloadTaskToMemory,
|
|
1489
1758
|
downloadToDir,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zenrows",
|
|
3
|
-
"version": "2.0
|
|
3
|
+
"version": "2.2.0",
|
|
4
4
|
"description": "ZenRows Node SDK",
|
|
5
5
|
"main": "./dist/index.cjs",
|
|
6
6
|
"module": "./dist/index.js",
|
|
@@ -19,13 +19,18 @@
|
|
|
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
|
},
|
|
25
26
|
"keywords": [
|
|
26
27
|
"sdk",
|
|
27
28
|
"zenrows",
|
|
28
|
-
"scraping"
|
|
29
|
+
"scraping",
|
|
30
|
+
"proxy",
|
|
31
|
+
"data extraction",
|
|
32
|
+
"web scraping",
|
|
33
|
+
"web scraping api"
|
|
29
34
|
],
|
|
30
35
|
"author": "ZenRows",
|
|
31
36
|
"repository": {
|
package/vitest.config.ts
CHANGED
|
@@ -6,6 +6,8 @@ export default defineConfig({
|
|
|
6
6
|
globals: true,
|
|
7
7
|
include: ["tests/**/*.test.ts"],
|
|
8
8
|
setupFiles: ["tests/_setup.ts"],
|
|
9
|
+
// Type-level assertions (expectTypeOf) only run under typecheck.
|
|
10
|
+
typecheck: { enabled: true, tsconfig: "./tsconfig.test.json", include: ["tests/**/*.test.ts"] },
|
|
9
11
|
coverage: {
|
|
10
12
|
include: ["src/**"],
|
|
11
13
|
exclude: ["examples/**"],
|