@crawlee/core 3.17.1-beta.6 → 3.17.1-beta.61
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/autoscaling/autoscaled_pool.js +4 -1
- package/autoscaling/snapshotter.d.ts +2 -2
- package/cookie_utils.js +2 -0
- package/enqueue_links/enqueue_links.d.ts +2 -52
- package/enqueue_links/enqueue_links.js +10 -60
- package/errors.d.ts +20 -0
- package/errors.js +32 -1
- package/http_clients/got-scraping-http-client.js +8 -0
- package/index.mjs +3 -0
- package/package.json +7 -6
- package/router.d.ts +117 -14
- package/router.js +135 -34
- package/session_pool/session_pool.d.ts +4 -1
- package/session_pool/session_pool.js +5 -2
- package/storages/request_dedup_cache.d.ts +23 -0
- package/storages/request_dedup_cache.js +66 -0
- package/storages/request_list.d.ts +6 -0
- package/storages/request_list.js +16 -2
- package/storages/request_provider.d.ts +7 -0
- package/storages/request_provider.js +39 -9
- package/storages/sitemap_request_list.d.ts +12 -0
- package/storages/sitemap_request_list.js +22 -3
|
@@ -415,14 +415,17 @@ class AutoscaledPool {
|
|
|
415
415
|
this.isStopped = true;
|
|
416
416
|
await new Promise((resolve, reject) => {
|
|
417
417
|
let timeout;
|
|
418
|
+
let interval;
|
|
418
419
|
if (timeoutSecs) {
|
|
419
420
|
timeout = setTimeout(() => {
|
|
421
|
+
// Clean up the polling interval to prevent it from leaking on timeout.
|
|
422
|
+
clearInterval(interval);
|
|
420
423
|
const err = new Error("The pool's running tasks did not finish" +
|
|
421
424
|
`in ${timeoutSecs} secs after pool.pause() invocation.`);
|
|
422
425
|
reject(err);
|
|
423
426
|
}, timeoutSecs);
|
|
424
427
|
}
|
|
425
|
-
|
|
428
|
+
interval = setInterval(() => {
|
|
426
429
|
if (this._currentConcurrency <= 0) {
|
|
427
430
|
// Clean up timeout and interval to prevent process hanging.
|
|
428
431
|
if (timeout)
|
|
@@ -35,13 +35,13 @@ export interface SnapshotterOptions {
|
|
|
35
35
|
/**
|
|
36
36
|
* Defines the maximum number of new rate limit errors within
|
|
37
37
|
* the given interval.
|
|
38
|
-
* @default
|
|
38
|
+
* @default 3
|
|
39
39
|
*/
|
|
40
40
|
maxClientErrors?: number;
|
|
41
41
|
/**
|
|
42
42
|
* Sets the interval in seconds for which a history of resource snapshots
|
|
43
43
|
* will be kept. Increasing this to very high numbers will affect performance.
|
|
44
|
-
* @default
|
|
44
|
+
* @default 30
|
|
45
45
|
*/
|
|
46
46
|
snapshotHistorySecs?: number;
|
|
47
47
|
/** @internal */
|
package/cookie_utils.js
CHANGED
|
@@ -107,6 +107,8 @@ function mergeCookies(url, sourceCookies) {
|
|
|
107
107
|
if (!cookieString)
|
|
108
108
|
continue;
|
|
109
109
|
const cookie = tough_cookie_1.Cookie.parse(cookieString);
|
|
110
|
+
if (!cookie)
|
|
111
|
+
throw new errors_1.CookieParseError(cookieString);
|
|
110
112
|
const similarKeyCookie = jar.getCookiesSync(url).find((c) => {
|
|
111
113
|
return cookie.key !== c.key && cookie.key.toLowerCase() === c.key.toLowerCase();
|
|
112
114
|
});
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import type { BatchAddRequestsResult, Dictionary } from '@crawlee/types';
|
|
2
|
-
import { type RobotsTxtFile } from '@crawlee/utils';
|
|
2
|
+
import { EnqueueStrategy, type RobotsTxtFile } from '@crawlee/utils';
|
|
3
3
|
import type { SetRequired } from 'type-fest';
|
|
4
4
|
import type { Request } from '../request';
|
|
5
5
|
import type { AddRequestsBatchedOptions, AddRequestsBatchedResult, RequestProvider, RequestQueueOperationOptions } from '../storages';
|
|
6
6
|
import type { GlobInput, PseudoUrlInput, RegExpInput, RequestTransform, SkippedRequestCallback } from './shared';
|
|
7
|
+
export { EnqueueStrategy };
|
|
7
8
|
export interface EnqueueLinksOptions extends RequestQueueOperationOptions {
|
|
8
9
|
/** Limit the amount of actually enqueued URLs to this number. Useful for testing across the entire crawling scope. */
|
|
9
10
|
limit?: number;
|
|
@@ -156,57 +157,6 @@ export interface EnqueueLinksOptions extends RequestQueueOperationOptions {
|
|
|
156
157
|
*/
|
|
157
158
|
onSkippedRequest?: SkippedRequestCallback;
|
|
158
159
|
}
|
|
159
|
-
/**
|
|
160
|
-
* The different enqueueing strategies available.
|
|
161
|
-
*
|
|
162
|
-
* Depending on the strategy you select, we will only check certain parts of the URLs found. Here is a diagram of each URL part and their name:
|
|
163
|
-
*
|
|
164
|
-
* ```md
|
|
165
|
-
* Protocol Domain
|
|
166
|
-
* ┌────┐ ┌─────────┐
|
|
167
|
-
* https://example.crawlee.dev/...
|
|
168
|
-
* │ └─────────────────┤
|
|
169
|
-
* │ Hostname │
|
|
170
|
-
* │ │
|
|
171
|
-
* └─────────────────────────┘
|
|
172
|
-
* Origin
|
|
173
|
-
*```
|
|
174
|
-
*
|
|
175
|
-
* - The `Protocol` is usually `http` or `https`
|
|
176
|
-
* - The `Domain` represents the path without any possible subdomains to a website. For example, `crawlee.dev` is the domain of `https://example.crawlee.dev/`
|
|
177
|
-
* - The `Hostname` is the full path to a website, including any subdomains. For example, `example.crawlee.dev` is the hostname of `https://example.crawlee.dev/`
|
|
178
|
-
* - The `Origin` is the combination of the `Protocol` and `Hostname`. For example, `https://example.crawlee.dev` is the origin of `https://example.crawlee.dev/`
|
|
179
|
-
*/
|
|
180
|
-
export declare enum EnqueueStrategy {
|
|
181
|
-
/**
|
|
182
|
-
* Matches any URLs found
|
|
183
|
-
*/
|
|
184
|
-
All = "all",
|
|
185
|
-
/**
|
|
186
|
-
* Matches any URLs that have the same hostname.
|
|
187
|
-
* For example, `https://wow.example.com/hello` will be matched for a base url of `https://wow.example.com/`, but
|
|
188
|
-
* `https://example.com/hello` will not be matched.
|
|
189
|
-
*
|
|
190
|
-
* > This strategy will match both `http` and `https` protocols regardless of the base URL protocol.
|
|
191
|
-
*/
|
|
192
|
-
SameHostname = "same-hostname",
|
|
193
|
-
/**
|
|
194
|
-
* Matches any URLs that have the same domain as the base URL.
|
|
195
|
-
* For example, `https://wow.an.example.com` and `https://example.com` will both be matched for a base url of
|
|
196
|
-
* `https://example.com`.
|
|
197
|
-
*
|
|
198
|
-
* > This strategy will match both `http` and `https` protocols regardless of the base URL protocol.
|
|
199
|
-
*/
|
|
200
|
-
SameDomain = "same-domain",
|
|
201
|
-
/**
|
|
202
|
-
* Matches any URLs that have the same hostname and protocol.
|
|
203
|
-
* For example, `https://wow.example.com/hello` will be matched for a base url of `https://wow.example.com/`, but
|
|
204
|
-
* `http://wow.example.com/hello` will not be matched.
|
|
205
|
-
*
|
|
206
|
-
* > This strategy will ensure the protocol of the base URL is the same as the protocol of the URL to be enqueued.
|
|
207
|
-
*/
|
|
208
|
-
SameOrigin = "same-origin"
|
|
209
|
-
}
|
|
210
160
|
/**
|
|
211
161
|
* This function enqueues the urls provided to the {@link RequestQueue} provided. If you want to automatically find and enqueue links,
|
|
212
162
|
* you should use the context-aware `enqueueLinks` function provided on the crawler contexts.
|
|
@@ -4,62 +4,12 @@ exports.EnqueueStrategy = void 0;
|
|
|
4
4
|
exports.enqueueLinks = enqueueLinks;
|
|
5
5
|
exports.resolveBaseUrlForEnqueueLinksFiltering = resolveBaseUrlForEnqueueLinksFiltering;
|
|
6
6
|
const tslib_1 = require("tslib");
|
|
7
|
+
const utils_1 = require("@crawlee/utils");
|
|
8
|
+
Object.defineProperty(exports, "EnqueueStrategy", { enumerable: true, get: function () { return utils_1.EnqueueStrategy; } });
|
|
7
9
|
const ow_1 = tslib_1.__importDefault(require("ow"));
|
|
8
10
|
const tldts_1 = require("tldts");
|
|
9
11
|
const log_1 = tslib_1.__importDefault(require("@apify/log"));
|
|
10
12
|
const shared_1 = require("./shared");
|
|
11
|
-
/**
|
|
12
|
-
* The different enqueueing strategies available.
|
|
13
|
-
*
|
|
14
|
-
* Depending on the strategy you select, we will only check certain parts of the URLs found. Here is a diagram of each URL part and their name:
|
|
15
|
-
*
|
|
16
|
-
* ```md
|
|
17
|
-
* Protocol Domain
|
|
18
|
-
* ┌────┐ ┌─────────┐
|
|
19
|
-
* https://example.crawlee.dev/...
|
|
20
|
-
* │ └─────────────────┤
|
|
21
|
-
* │ Hostname │
|
|
22
|
-
* │ │
|
|
23
|
-
* └─────────────────────────┘
|
|
24
|
-
* Origin
|
|
25
|
-
*```
|
|
26
|
-
*
|
|
27
|
-
* - The `Protocol` is usually `http` or `https`
|
|
28
|
-
* - The `Domain` represents the path without any possible subdomains to a website. For example, `crawlee.dev` is the domain of `https://example.crawlee.dev/`
|
|
29
|
-
* - The `Hostname` is the full path to a website, including any subdomains. For example, `example.crawlee.dev` is the hostname of `https://example.crawlee.dev/`
|
|
30
|
-
* - The `Origin` is the combination of the `Protocol` and `Hostname`. For example, `https://example.crawlee.dev` is the origin of `https://example.crawlee.dev/`
|
|
31
|
-
*/
|
|
32
|
-
var EnqueueStrategy;
|
|
33
|
-
(function (EnqueueStrategy) {
|
|
34
|
-
/**
|
|
35
|
-
* Matches any URLs found
|
|
36
|
-
*/
|
|
37
|
-
EnqueueStrategy["All"] = "all";
|
|
38
|
-
/**
|
|
39
|
-
* Matches any URLs that have the same hostname.
|
|
40
|
-
* For example, `https://wow.example.com/hello` will be matched for a base url of `https://wow.example.com/`, but
|
|
41
|
-
* `https://example.com/hello` will not be matched.
|
|
42
|
-
*
|
|
43
|
-
* > This strategy will match both `http` and `https` protocols regardless of the base URL protocol.
|
|
44
|
-
*/
|
|
45
|
-
EnqueueStrategy["SameHostname"] = "same-hostname";
|
|
46
|
-
/**
|
|
47
|
-
* Matches any URLs that have the same domain as the base URL.
|
|
48
|
-
* For example, `https://wow.an.example.com` and `https://example.com` will both be matched for a base url of
|
|
49
|
-
* `https://example.com`.
|
|
50
|
-
*
|
|
51
|
-
* > This strategy will match both `http` and `https` protocols regardless of the base URL protocol.
|
|
52
|
-
*/
|
|
53
|
-
EnqueueStrategy["SameDomain"] = "same-domain";
|
|
54
|
-
/**
|
|
55
|
-
* Matches any URLs that have the same hostname and protocol.
|
|
56
|
-
* For example, `https://wow.example.com/hello` will be matched for a base url of `https://wow.example.com/`, but
|
|
57
|
-
* `http://wow.example.com/hello` will not be matched.
|
|
58
|
-
*
|
|
59
|
-
* > This strategy will ensure the protocol of the base URL is the same as the protocol of the URL to be enqueued.
|
|
60
|
-
*/
|
|
61
|
-
EnqueueStrategy["SameOrigin"] = "same-origin";
|
|
62
|
-
})(EnqueueStrategy || (exports.EnqueueStrategy = EnqueueStrategy = {}));
|
|
63
13
|
/**
|
|
64
14
|
* This function enqueues the urls provided to the {@link RequestQueue} provided. If you want to automatically find and enqueue links,
|
|
65
15
|
* you should use the context-aware `enqueueLinks` function provided on the crawler contexts.
|
|
@@ -109,7 +59,7 @@ async function enqueueLinks(options) {
|
|
|
109
59
|
exclude: ow_1.default.optional.array.ofType(ow_1.default.any(ow_1.default.string, ow_1.default.regExp, ow_1.default.object.hasKeys('glob'), ow_1.default.object.hasKeys('regexp'))),
|
|
110
60
|
regexps: ow_1.default.optional.array.ofType(ow_1.default.any(ow_1.default.regExp, ow_1.default.object.hasKeys('regexp'))),
|
|
111
61
|
transformRequestFunction: ow_1.default.optional.function,
|
|
112
|
-
strategy: ow_1.default.optional.string.oneOf(Object.values(EnqueueStrategy)),
|
|
62
|
+
strategy: ow_1.default.optional.string.oneOf(Object.values(utils_1.EnqueueStrategy)),
|
|
113
63
|
waitForAllRequestsToBeAdded: ow_1.default.optional.boolean,
|
|
114
64
|
}));
|
|
115
65
|
const { requestQueue, limit, urls, pseudoUrls, exclude, globs, regexps, transformRequestFunction, forefront, waitForAllRequestsToBeAdded, robotsTxtFile, onSkippedRequest, } = options;
|
|
@@ -136,19 +86,19 @@ async function enqueueLinks(options) {
|
|
|
136
86
|
urlPatternObjects.push(...(0, shared_1.constructRegExpObjectsFromRegExps)(regexps));
|
|
137
87
|
}
|
|
138
88
|
if (!urlPatternObjects.length) {
|
|
139
|
-
options.strategy ?? (options.strategy = EnqueueStrategy.SameHostname);
|
|
89
|
+
options.strategy ?? (options.strategy = utils_1.EnqueueStrategy.SameHostname);
|
|
140
90
|
}
|
|
141
91
|
const enqueueStrategyPatterns = [];
|
|
142
92
|
if (options.baseUrl) {
|
|
143
93
|
const url = new URL(options.baseUrl);
|
|
144
94
|
switch (options.strategy) {
|
|
145
|
-
case EnqueueStrategy.SameHostname:
|
|
95
|
+
case utils_1.EnqueueStrategy.SameHostname:
|
|
146
96
|
// We need to get the origin of the passed in domain in the event someone sets baseUrl
|
|
147
97
|
// to an url like https://example.com/deep/default/path and one of the found urls is an
|
|
148
98
|
// absolute relative path (/path/to/page)
|
|
149
99
|
enqueueStrategyPatterns.push({ glob: ignoreHttpSchema(`${url.origin}/**`) });
|
|
150
100
|
break;
|
|
151
|
-
case EnqueueStrategy.SameDomain: {
|
|
101
|
+
case utils_1.EnqueueStrategy.SameDomain: {
|
|
152
102
|
// Get the actual hostname from the base url
|
|
153
103
|
const baseUrlHostname = (0, tldts_1.getDomain)(url.hostname, { mixedInputs: false });
|
|
154
104
|
if (baseUrlHostname) {
|
|
@@ -163,12 +113,12 @@ async function enqueueLinks(options) {
|
|
|
163
113
|
}
|
|
164
114
|
break;
|
|
165
115
|
}
|
|
166
|
-
case EnqueueStrategy.SameOrigin: {
|
|
116
|
+
case utils_1.EnqueueStrategy.SameOrigin: {
|
|
167
117
|
// The same behavior as SameHostname, but respecting the protocol of the URL
|
|
168
118
|
enqueueStrategyPatterns.push({ glob: `${url.origin}/**` });
|
|
169
119
|
break;
|
|
170
120
|
}
|
|
171
|
-
case EnqueueStrategy.All:
|
|
121
|
+
case utils_1.EnqueueStrategy.All:
|
|
172
122
|
default:
|
|
173
123
|
enqueueStrategyPatterns.push({ glob: `http{s,}://**` });
|
|
174
124
|
break;
|
|
@@ -249,13 +199,13 @@ function resolveBaseUrlForEnqueueLinksFiltering({ enqueueStrategy, finalRequestU
|
|
|
249
199
|
const originalUrlOrigin = new URL(originalRequestUrl).origin;
|
|
250
200
|
const finalUrlOrigin = new URL(finalRequestUrl ?? originalRequestUrl).origin;
|
|
251
201
|
// We can assume users want to go off the domain in this case
|
|
252
|
-
if (enqueueStrategy === EnqueueStrategy.All) {
|
|
202
|
+
if (enqueueStrategy === utils_1.EnqueueStrategy.All) {
|
|
253
203
|
return finalUrlOrigin;
|
|
254
204
|
}
|
|
255
205
|
// If the user wants to ensure the same domain is accessed, regardless of subdomains, we check to ensure the domains match
|
|
256
206
|
// Returning undefined here is intentional! If the domains don't match, having no baseUrl in enqueueLinks will cause it to not enqueue anything
|
|
257
207
|
// which is the intended behavior (since we went off domain)
|
|
258
|
-
if (enqueueStrategy === EnqueueStrategy.SameDomain) {
|
|
208
|
+
if (enqueueStrategy === utils_1.EnqueueStrategy.SameDomain) {
|
|
259
209
|
const originalHostname = (0, tldts_1.getDomain)(originalUrlOrigin, { mixedInputs: false });
|
|
260
210
|
const finalHostname = (0, tldts_1.getDomain)(finalUrlOrigin, { mixedInputs: false });
|
|
261
211
|
if (originalHostname === finalHostname) {
|
package/errors.d.ts
CHANGED
|
@@ -14,6 +14,26 @@ export declare class CriticalError extends NonRetryableError {
|
|
|
14
14
|
*/
|
|
15
15
|
export declare class MissingRouteError extends CriticalError {
|
|
16
16
|
}
|
|
17
|
+
/**
|
|
18
|
+
* Thrown when a request's `userData` does not match the {@link RouteSchemas|Standard Schema} registered for its label.
|
|
19
|
+
*
|
|
20
|
+
* As the `userData` does not change between attempts, this error is non-retryable.
|
|
21
|
+
*/
|
|
22
|
+
export declare class RequestValidationError extends NonRetryableError {
|
|
23
|
+
readonly label: string | symbol;
|
|
24
|
+
readonly issues: readonly {
|
|
25
|
+
readonly message: string;
|
|
26
|
+
readonly path?: readonly (PropertyKey | {
|
|
27
|
+
key: PropertyKey;
|
|
28
|
+
})[];
|
|
29
|
+
}[];
|
|
30
|
+
constructor(label: string | symbol, issues: readonly {
|
|
31
|
+
readonly message: string;
|
|
32
|
+
readonly path?: readonly (PropertyKey | {
|
|
33
|
+
key: PropertyKey;
|
|
34
|
+
})[];
|
|
35
|
+
}[]);
|
|
36
|
+
}
|
|
17
37
|
/**
|
|
18
38
|
* Errors of `RetryRequestError` type will always be retried by the crawler.
|
|
19
39
|
*
|
package/errors.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.SessionError = exports.RetryRequestError = exports.MissingRouteError = exports.CriticalError = exports.NonRetryableError = void 0;
|
|
3
|
+
exports.SessionError = exports.RetryRequestError = exports.RequestValidationError = exports.MissingRouteError = exports.CriticalError = exports.NonRetryableError = void 0;
|
|
4
4
|
/**
|
|
5
5
|
* Errors of `NonRetryableError` type will never be retried by the crawler.
|
|
6
6
|
*/
|
|
@@ -20,6 +20,37 @@ exports.CriticalError = CriticalError;
|
|
|
20
20
|
class MissingRouteError extends CriticalError {
|
|
21
21
|
}
|
|
22
22
|
exports.MissingRouteError = MissingRouteError;
|
|
23
|
+
/**
|
|
24
|
+
* Thrown when a request's `userData` does not match the {@link RouteSchemas|Standard Schema} registered for its label.
|
|
25
|
+
*
|
|
26
|
+
* As the `userData` does not change between attempts, this error is non-retryable.
|
|
27
|
+
*/
|
|
28
|
+
class RequestValidationError extends NonRetryableError {
|
|
29
|
+
constructor(label, issues) {
|
|
30
|
+
const details = issues
|
|
31
|
+
.map((issue) => {
|
|
32
|
+
const path = (issue.path ?? [])
|
|
33
|
+
.map((segment) => (typeof segment === 'object' ? segment.key : segment))
|
|
34
|
+
.join('.');
|
|
35
|
+
return `- ${path ? `${path}: ` : ''}${issue.message}`;
|
|
36
|
+
})
|
|
37
|
+
.join('\n');
|
|
38
|
+
super(`Request userData for label '${String(label)}' failed schema validation:\n${details}`);
|
|
39
|
+
Object.defineProperty(this, "label", {
|
|
40
|
+
enumerable: true,
|
|
41
|
+
configurable: true,
|
|
42
|
+
writable: true,
|
|
43
|
+
value: label
|
|
44
|
+
});
|
|
45
|
+
Object.defineProperty(this, "issues", {
|
|
46
|
+
enumerable: true,
|
|
47
|
+
configurable: true,
|
|
48
|
+
writable: true,
|
|
49
|
+
value: issues
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
exports.RequestValidationError = RequestValidationError;
|
|
23
54
|
/**
|
|
24
55
|
* Errors of `RetryRequestError` type will always be retried by the crawler.
|
|
25
56
|
*
|
|
@@ -22,6 +22,14 @@ class GotScrapingHttpClient {
|
|
|
22
22
|
});
|
|
23
23
|
return {
|
|
24
24
|
...gotResult,
|
|
25
|
+
complete: gotResult.complete,
|
|
26
|
+
headers: gotResult.headers,
|
|
27
|
+
ip: gotResult.ip,
|
|
28
|
+
redirectUrls: gotResult.redirectUrls,
|
|
29
|
+
statusCode: gotResult.statusCode,
|
|
30
|
+
statusMessage: gotResult.statusMessage,
|
|
31
|
+
trailers: gotResult.trailers,
|
|
32
|
+
url: gotResult.url,
|
|
25
33
|
body: gotResult.body,
|
|
26
34
|
request: { url: request.url, ...gotResult.request },
|
|
27
35
|
};
|
package/index.mjs
CHANGED
|
@@ -46,6 +46,7 @@ export const RequestQueue = mod.RequestQueue;
|
|
|
46
46
|
export const RequestQueueV1 = mod.RequestQueueV1;
|
|
47
47
|
export const RequestQueueV2 = mod.RequestQueueV2;
|
|
48
48
|
export const RequestState = mod.RequestState;
|
|
49
|
+
export const RequestValidationError = mod.RequestValidationError;
|
|
49
50
|
export const RetryRequestError = mod.RetryRequestError;
|
|
50
51
|
export const Router = mod.Router;
|
|
51
52
|
export const STATE_PERSISTENCE_KEY = mod.STATE_PERSISTENCE_KEY;
|
|
@@ -73,6 +74,7 @@ export const createDeserialize = mod.createDeserialize;
|
|
|
73
74
|
export const createEventLoopLoadSignal = mod.createEventLoopLoadSignal;
|
|
74
75
|
export const createRequestOptions = mod.createRequestOptions;
|
|
75
76
|
export const createRequests = mod.createRequests;
|
|
77
|
+
export const defaultRoute = mod.defaultRoute;
|
|
76
78
|
export const deserializeArray = mod.deserializeArray;
|
|
77
79
|
export const enqueueLinks = mod.enqueueLinks;
|
|
78
80
|
export const evaluateLoadSignalSample = mod.evaluateLoadSignalSample;
|
|
@@ -93,5 +95,6 @@ export const tryAbsoluteURL = mod.tryAbsoluteURL;
|
|
|
93
95
|
export const updateEnqueueLinksPatternCache = mod.updateEnqueueLinksPatternCache;
|
|
94
96
|
export const useState = mod.useState;
|
|
95
97
|
export const validateGlobPattern = mod.validateGlobPattern;
|
|
98
|
+
export const validateUserData = mod.validateUserData;
|
|
96
99
|
export const validators = mod.validators;
|
|
97
100
|
export const withCheckedStorageAccess = mod.withCheckedStorageAccess;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crawlee/core",
|
|
3
|
-
"version": "3.17.1-beta.
|
|
3
|
+
"version": "3.17.1-beta.61",
|
|
4
4
|
"description": "The scalable web crawling and scraping library for JavaScript/Node.js. Enables development of data extraction and web automation jobs (not only) with headless Chrome and Puppeteer.",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": ">=16.0.0"
|
|
@@ -57,12 +57,13 @@
|
|
|
57
57
|
"@apify/datastructures": "^2.0.0",
|
|
58
58
|
"@apify/log": "^2.4.0",
|
|
59
59
|
"@apify/pseudo_url": "^2.0.30",
|
|
60
|
-
"@apify/timeout": "^0.
|
|
60
|
+
"@apify/timeout": "^0.4.0",
|
|
61
61
|
"@apify/utilities": "^2.7.10",
|
|
62
|
-
"@crawlee/memory-storage": "3.17.1-beta.
|
|
63
|
-
"@crawlee/types": "3.17.1-beta.
|
|
64
|
-
"@crawlee/utils": "3.17.1-beta.
|
|
62
|
+
"@crawlee/memory-storage": "3.17.1-beta.61",
|
|
63
|
+
"@crawlee/types": "3.17.1-beta.61",
|
|
64
|
+
"@crawlee/utils": "3.17.1-beta.61",
|
|
65
65
|
"@sapphire/async-queue": "^1.5.1",
|
|
66
|
+
"@standard-schema/spec": "^1.0.0",
|
|
66
67
|
"@vladfrangu/async_event_emitter": "^2.2.2",
|
|
67
68
|
"csv-stringify": "^6.2.0",
|
|
68
69
|
"fs-extra": "^11.0.0",
|
|
@@ -83,5 +84,5 @@
|
|
|
83
84
|
}
|
|
84
85
|
}
|
|
85
86
|
},
|
|
86
|
-
"gitHead": "
|
|
87
|
+
"gitHead": "474ef1ca7e3f599d2159c67abbd26817d472bc11"
|
|
87
88
|
}
|
package/router.d.ts
CHANGED
|
@@ -1,14 +1,57 @@
|
|
|
1
1
|
import type { Dictionary } from '@crawlee/types';
|
|
2
|
+
import type { StandardSchemaV1 } from '@standard-schema/spec';
|
|
2
3
|
import type { CrawlingContext, LoadedRequest, RestrictedCrawlingContext } from './crawlers/crawler_commons';
|
|
3
4
|
import type { Request } from './request';
|
|
4
5
|
import type { Awaitable } from './typedefs';
|
|
5
|
-
|
|
6
|
+
/**
|
|
7
|
+
* The key of the default route — the fallback handler registered via {@link Router.addDefaultHandler}.
|
|
8
|
+
* Use it in a {@link RouteSchemas} map to register a schema that validates the `userData` of every request
|
|
9
|
+
* that falls through to the default handler (i.e. whose label has no route of its own).
|
|
10
|
+
*/
|
|
11
|
+
export declare const defaultRoute: unique symbol;
|
|
12
|
+
/**
|
|
13
|
+
* The crawling context received by a route handler, with `request.userData` narrowed to `UserData`.
|
|
14
|
+
*/
|
|
15
|
+
export type RouterHandlerContext<Context, UserData extends Dictionary> = Omit<Context, 'request'> & {
|
|
16
|
+
request: LoadedRequest<Request<UserData>>;
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* A map of request labels to a [Standard Schema](https://standardschema.dev) (Zod, Valibot, ArkType, …)
|
|
20
|
+
* validating that label's `request.userData`. Pass it to {@link Router.create} or a `createXRouter`
|
|
21
|
+
* factory to derive the per-label `request.userData` types *and* validate them at runtime. The optional
|
|
22
|
+
* {@link defaultRoute} key registers a schema for requests handled by the default route.
|
|
23
|
+
*/
|
|
24
|
+
export type RouteSchemas = Record<string, StandardSchemaV1> & {
|
|
25
|
+
[defaultRoute]?: StandardSchemaV1;
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* Derives a route map (label → `userData` type) from a {@link RouteSchemas} map by inferring each
|
|
29
|
+
* schema's output type. Outputs that are not object-shaped fall back to a plain {@link Dictionary}. The
|
|
30
|
+
* {@link defaultRoute} schema drives runtime validation only, so it is excluded from the typed route map.
|
|
31
|
+
*/
|
|
32
|
+
export type RoutesFromSchemas<Schemas extends RouteSchemas> = {
|
|
33
|
+
[Label in Extract<keyof Schemas, string>]: StandardSchemaV1.InferOutput<Schemas[Label]> extends Dictionary ? StandardSchemaV1.InferOutput<Schemas[Label]> : Dictionary;
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* Validates `userData` against a {@link RouteSchemas|Standard Schema}, returning the parsed (and coerced)
|
|
37
|
+
* value. Throws a {@link RequestValidationError} when validation fails.
|
|
38
|
+
* @internal
|
|
39
|
+
*/
|
|
40
|
+
export declare function validateUserData(label: string | symbol, schema: StandardSchemaV1, userData: unknown): Promise<Dictionary>;
|
|
41
|
+
/**
|
|
42
|
+
* The set of labels accepted by {@link Router.addHandler}. When the router declares a concrete
|
|
43
|
+
* route map (e.g. `{ PRODUCT: ...; CATEGORY: ... }`), only those labels (plus symbols) are
|
|
44
|
+
* allowed — unknown labels become a compile-time error. When the map is left open (the default
|
|
45
|
+
* `Record<string, ...>`), any string or symbol label is accepted, preserving the original behaviour.
|
|
46
|
+
*/
|
|
47
|
+
export type RouterLabel<Routes extends Record<keyof Routes, Dictionary>> = string extends keyof Routes ? string | symbol : (keyof Routes & string) | symbol;
|
|
48
|
+
export interface RouterHandler<Context extends Omit<RestrictedCrawlingContext, 'enqueueLinks'> = CrawlingContext, Routes extends Record<keyof Routes, Dictionary> = Record<string, GetUserDataFromRequest<Context['request']>>> extends Router<Context, Routes> {
|
|
6
49
|
(ctx: Context): Awaitable<void>;
|
|
7
50
|
}
|
|
8
51
|
export type GetUserDataFromRequest<T> = T extends Request<infer Y> ? Y : never;
|
|
9
|
-
export type RouterRoutes<Context,
|
|
10
|
-
[
|
|
11
|
-
request: Request<
|
|
52
|
+
export type RouterRoutes<Context, Routes extends Record<keyof Routes, Dictionary>> = {
|
|
53
|
+
[Label in keyof Routes]: (ctx: Omit<Context, 'request'> & {
|
|
54
|
+
request: Request<Routes[Label]>;
|
|
12
55
|
}) => Awaitable<void>;
|
|
13
56
|
};
|
|
14
57
|
/**
|
|
@@ -75,9 +118,51 @@ export type RouterRoutes<Context, UserData extends Dictionary> = {
|
|
|
75
118
|
* ctx.log.info('...');
|
|
76
119
|
* });
|
|
77
120
|
* ```
|
|
121
|
+
*
|
|
122
|
+
* To get `request.userData` typed per label, declare a route map and pass it as the second
|
|
123
|
+
* type argument. The label passed to {@link Router.addHandler} then drives the type of
|
|
124
|
+
* `request.userData`, and unknown labels are rejected at compile time:
|
|
125
|
+
*
|
|
126
|
+
* ```ts
|
|
127
|
+
* import { createCheerioRouter, CheerioCrawlingContext } from 'crawlee';
|
|
128
|
+
*
|
|
129
|
+
* interface Routes {
|
|
130
|
+
* PRODUCT: { sku: string; price: number };
|
|
131
|
+
* CATEGORY: { categoryId: string };
|
|
132
|
+
* }
|
|
133
|
+
*
|
|
134
|
+
* const router = createCheerioRouter<CheerioCrawlingContext, Routes>();
|
|
135
|
+
*
|
|
136
|
+
* router.addHandler('PRODUCT', async ({ request }) => {
|
|
137
|
+
* request.userData.sku; // string
|
|
138
|
+
* request.userData.price; // number
|
|
139
|
+
* });
|
|
140
|
+
*
|
|
141
|
+
* router.addHandler('TYPO', async () => {}); // compile error: not a known label
|
|
142
|
+
* ```
|
|
143
|
+
*
|
|
144
|
+
* Passing a [Standard Schema](https://standardschema.dev) per label instead of a plain type both infers the
|
|
145
|
+
* `request.userData` types *and* validates them at runtime — when the request is handled, and when it is
|
|
146
|
+
* added to the crawler (`crawler.addRequests`, `context.addRequests`, `enqueueLinks`). A failing request
|
|
147
|
+
* throws a {@link RequestValidationError}.
|
|
148
|
+
*
|
|
149
|
+
* ```ts
|
|
150
|
+
* import { z } from 'zod';
|
|
151
|
+
* import { createCheerioRouter } from 'crawlee';
|
|
152
|
+
*
|
|
153
|
+
* const router = createCheerioRouter({
|
|
154
|
+
* PRODUCT: z.object({ sku: z.string(), price: z.number() }),
|
|
155
|
+
* CATEGORY: z.object({ categoryId: z.string() }),
|
|
156
|
+
* });
|
|
157
|
+
*
|
|
158
|
+
* router.addHandler('PRODUCT', async ({ request }) => {
|
|
159
|
+
* request.userData.price; // number, inferred from the schema and validated at runtime
|
|
160
|
+
* });
|
|
161
|
+
* ```
|
|
78
162
|
*/
|
|
79
|
-
export declare class Router<Context extends Omit<RestrictedCrawlingContext, 'enqueueLinks'
|
|
163
|
+
export declare class Router<Context extends Omit<RestrictedCrawlingContext, 'enqueueLinks'>, Routes extends Record<keyof Routes, Dictionary> = Record<string, GetUserDataFromRequest<Context['request']>>> {
|
|
80
164
|
private readonly routes;
|
|
165
|
+
private readonly schemas;
|
|
81
166
|
private readonly middlewares;
|
|
82
167
|
/**
|
|
83
168
|
* use Router.create() instead!
|
|
@@ -85,17 +170,28 @@ export declare class Router<Context extends Omit<RestrictedCrawlingContext, 'enq
|
|
|
85
170
|
*/
|
|
86
171
|
protected constructor();
|
|
87
172
|
/**
|
|
88
|
-
* Registers new route handler for given label.
|
|
173
|
+
* Registers new route handler for given label. When the router declares a route map, the
|
|
174
|
+
* `label` is restricted to the declared labels and `request.userData` is typed accordingly.
|
|
175
|
+
*/
|
|
176
|
+
addHandler<Label extends keyof Routes & string>(label: Label, handler: (ctx: RouterHandlerContext<Context, Routes[Label]>) => Awaitable<void>): void;
|
|
177
|
+
/**
|
|
178
|
+
* Registers new route handler for given label, explicitly typing `request.userData` via the
|
|
179
|
+
* `UserData` type argument. Useful when the router has no declared route map (the open default)
|
|
180
|
+
* and you want to type a single handler, or to register a handler under a `symbol` label.
|
|
89
181
|
*/
|
|
90
|
-
addHandler<UserData extends Dictionary = GetUserDataFromRequest<Context['request']>>(label:
|
|
91
|
-
request: LoadedRequest<Request<UserData>>;
|
|
92
|
-
}) => Awaitable<void>): void;
|
|
182
|
+
addHandler<UserData extends Dictionary = GetUserDataFromRequest<Context['request']>>(label: RouterLabel<Routes>, handler: (ctx: RouterHandlerContext<Context, UserData>) => Awaitable<void>): void;
|
|
93
183
|
/**
|
|
94
|
-
* Registers default route handler.
|
|
184
|
+
* Registers default route handler. As a fallback it can receive any request (including labels not
|
|
185
|
+
* declared in the route map), so `request.userData` defaults to the context's `userData` type
|
|
186
|
+
* (loosely typed by default). Pass an explicit `UserData` type argument to narrow it.
|
|
95
187
|
*/
|
|
96
|
-
addDefaultHandler<UserData extends Dictionary = GetUserDataFromRequest<Context['request']>>(handler: (ctx:
|
|
97
|
-
|
|
98
|
-
|
|
188
|
+
addDefaultHandler<UserData extends Dictionary = GetUserDataFromRequest<Context['request']>>(handler: (ctx: RouterHandlerContext<Context, UserData>) => Awaitable<void>): void;
|
|
189
|
+
/**
|
|
190
|
+
* Returns the {@link RouteSchemas|Standard Schema} registered for a label, if any. Used by the crawler
|
|
191
|
+
* to validate `request.userData` when requests are added.
|
|
192
|
+
* @internal
|
|
193
|
+
*/
|
|
194
|
+
getSchema(label?: string | symbol): StandardSchemaV1 | undefined;
|
|
99
195
|
/**
|
|
100
196
|
* Registers a middleware that will be fired before the matching route handler.
|
|
101
197
|
* Multiple middlewares can be registered, they will be fired in the same order.
|
|
@@ -105,6 +201,11 @@ export declare class Router<Context extends Omit<RestrictedCrawlingContext, 'enq
|
|
|
105
201
|
* Returns route handler for given label. If no label is provided, the default request handler will be returned.
|
|
106
202
|
*/
|
|
107
203
|
getHandler(label?: string | symbol): (ctx: Context) => Awaitable<void>;
|
|
204
|
+
/**
|
|
205
|
+
* Validates `request.userData` against the schema registered for its label (if any), replacing it with
|
|
206
|
+
* the parsed value. Throws a {@link RequestValidationError} when validation fails.
|
|
207
|
+
*/
|
|
208
|
+
private validateRequest;
|
|
108
209
|
/**
|
|
109
210
|
* Throws when the label already exists in our registry.
|
|
110
211
|
*/
|
|
@@ -129,5 +230,7 @@ export declare class Router<Context extends Omit<RestrictedCrawlingContext, 'enq
|
|
|
129
230
|
* await crawler.run();
|
|
130
231
|
* ```
|
|
131
232
|
*/
|
|
132
|
-
static create<Context extends Omit<RestrictedCrawlingContext, 'enqueueLinks'> = CrawlingContext,
|
|
233
|
+
static create<Context extends Omit<RestrictedCrawlingContext, 'enqueueLinks'> = CrawlingContext, Routes extends Record<keyof Routes, Dictionary> = Record<string, GetUserDataFromRequest<Context['request']>>>(routes?: RouterRoutes<Context, Routes>): RouterHandler<Context, Routes>;
|
|
234
|
+
static create<Context extends Omit<RestrictedCrawlingContext, 'enqueueLinks'> = CrawlingContext, UserData extends Dictionary = GetUserDataFromRequest<Context['request']>>(routes?: RouterRoutes<Context, Record<string, UserData>>): RouterHandler<Context, Record<string, UserData>>;
|
|
235
|
+
static create<Context extends Omit<RestrictedCrawlingContext, 'enqueueLinks'> = CrawlingContext, const Schemas extends RouteSchemas = RouteSchemas>(schemas: Schemas): RouterHandler<Context, RoutesFromSchemas<Schemas>>;
|
|
133
236
|
}
|
package/router.js
CHANGED
|
@@ -1,8 +1,43 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.Router = void 0;
|
|
3
|
+
exports.Router = exports.defaultRoute = void 0;
|
|
4
|
+
exports.validateUserData = validateUserData;
|
|
4
5
|
const errors_1 = require("./errors");
|
|
5
|
-
|
|
6
|
+
/**
|
|
7
|
+
* The key of the default route — the fallback handler registered via {@link Router.addDefaultHandler}.
|
|
8
|
+
* Use it in a {@link RouteSchemas} map to register a schema that validates the `userData` of every request
|
|
9
|
+
* that falls through to the default handler (i.e. whose label has no route of its own).
|
|
10
|
+
*/
|
|
11
|
+
exports.defaultRoute = Symbol('default-route');
|
|
12
|
+
/** Whether a validation issue points at the top-level `label` key. */
|
|
13
|
+
function isLabelIssue(issue) {
|
|
14
|
+
if (issue.path?.length !== 1) {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
const [segment] = issue.path;
|
|
18
|
+
return (typeof segment === 'object' ? segment.key : segment) === 'label';
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Validates `userData` against a {@link RouteSchemas|Standard Schema}, returning the parsed (and coerced)
|
|
22
|
+
* value. Throws a {@link RequestValidationError} when validation fails.
|
|
23
|
+
* @internal
|
|
24
|
+
*/
|
|
25
|
+
async function validateUserData(label, schema, userData) {
|
|
26
|
+
const { label: _label, ...rest } = (userData ?? {});
|
|
27
|
+
// `label` is a Crawlee-managed key that lives inside `userData`, so validating it is opt-in: we validate
|
|
28
|
+
// without it first, letting schemas that don't describe it pass (including `.strict()` ones). A schema that
|
|
29
|
+
// *does* declare `label` reports an issue for the now-missing key — so we re-validate with it included,
|
|
30
|
+
// honouring the declaration. Unlike `userData.__crawlee`, `label` is enumerable, so schemas do see it.
|
|
31
|
+
let result = await schema['~standard'].validate(rest);
|
|
32
|
+
if (result.issues?.some(isLabelIssue)) {
|
|
33
|
+
result = await schema['~standard'].validate({ ...rest, label });
|
|
34
|
+
}
|
|
35
|
+
if (result.issues) {
|
|
36
|
+
throw new errors_1.RequestValidationError(label, result.issues);
|
|
37
|
+
}
|
|
38
|
+
// Restore the label so it survives schemas that strip undeclared keys.
|
|
39
|
+
return { ...result.value, label };
|
|
40
|
+
}
|
|
6
41
|
/**
|
|
7
42
|
* Simple router that works based on request labels. This instance can then serve as a `requestHandler` of your crawler.
|
|
8
43
|
*
|
|
@@ -67,6 +102,47 @@ const defaultRoute = Symbol('default-route');
|
|
|
67
102
|
* ctx.log.info('...');
|
|
68
103
|
* });
|
|
69
104
|
* ```
|
|
105
|
+
*
|
|
106
|
+
* To get `request.userData` typed per label, declare a route map and pass it as the second
|
|
107
|
+
* type argument. The label passed to {@link Router.addHandler} then drives the type of
|
|
108
|
+
* `request.userData`, and unknown labels are rejected at compile time:
|
|
109
|
+
*
|
|
110
|
+
* ```ts
|
|
111
|
+
* import { createCheerioRouter, CheerioCrawlingContext } from 'crawlee';
|
|
112
|
+
*
|
|
113
|
+
* interface Routes {
|
|
114
|
+
* PRODUCT: { sku: string; price: number };
|
|
115
|
+
* CATEGORY: { categoryId: string };
|
|
116
|
+
* }
|
|
117
|
+
*
|
|
118
|
+
* const router = createCheerioRouter<CheerioCrawlingContext, Routes>();
|
|
119
|
+
*
|
|
120
|
+
* router.addHandler('PRODUCT', async ({ request }) => {
|
|
121
|
+
* request.userData.sku; // string
|
|
122
|
+
* request.userData.price; // number
|
|
123
|
+
* });
|
|
124
|
+
*
|
|
125
|
+
* router.addHandler('TYPO', async () => {}); // compile error: not a known label
|
|
126
|
+
* ```
|
|
127
|
+
*
|
|
128
|
+
* Passing a [Standard Schema](https://standardschema.dev) per label instead of a plain type both infers the
|
|
129
|
+
* `request.userData` types *and* validates them at runtime — when the request is handled, and when it is
|
|
130
|
+
* added to the crawler (`crawler.addRequests`, `context.addRequests`, `enqueueLinks`). A failing request
|
|
131
|
+
* throws a {@link RequestValidationError}.
|
|
132
|
+
*
|
|
133
|
+
* ```ts
|
|
134
|
+
* import { z } from 'zod';
|
|
135
|
+
* import { createCheerioRouter } from 'crawlee';
|
|
136
|
+
*
|
|
137
|
+
* const router = createCheerioRouter({
|
|
138
|
+
* PRODUCT: z.object({ sku: z.string(), price: z.number() }),
|
|
139
|
+
* CATEGORY: z.object({ categoryId: z.string() }),
|
|
140
|
+
* });
|
|
141
|
+
*
|
|
142
|
+
* router.addHandler('PRODUCT', async ({ request }) => {
|
|
143
|
+
* request.userData.price; // number, inferred from the schema and validated at runtime
|
|
144
|
+
* });
|
|
145
|
+
* ```
|
|
70
146
|
*/
|
|
71
147
|
class Router {
|
|
72
148
|
/**
|
|
@@ -80,6 +156,12 @@ class Router {
|
|
|
80
156
|
writable: true,
|
|
81
157
|
value: new Map()
|
|
82
158
|
});
|
|
159
|
+
Object.defineProperty(this, "schemas", {
|
|
160
|
+
enumerable: true,
|
|
161
|
+
configurable: true,
|
|
162
|
+
writable: true,
|
|
163
|
+
value: new Map()
|
|
164
|
+
});
|
|
83
165
|
Object.defineProperty(this, "middlewares", {
|
|
84
166
|
enumerable: true,
|
|
85
167
|
configurable: true,
|
|
@@ -87,19 +169,38 @@ class Router {
|
|
|
87
169
|
value: []
|
|
88
170
|
});
|
|
89
171
|
}
|
|
90
|
-
/**
|
|
91
|
-
* Registers new route handler for given label.
|
|
92
|
-
*/
|
|
93
172
|
addHandler(label, handler) {
|
|
94
173
|
this.validate(label);
|
|
95
174
|
this.routes.set(label, handler);
|
|
96
175
|
}
|
|
97
176
|
/**
|
|
98
|
-
* Registers default route handler.
|
|
177
|
+
* Registers default route handler. As a fallback it can receive any request (including labels not
|
|
178
|
+
* declared in the route map), so `request.userData` defaults to the context's `userData` type
|
|
179
|
+
* (loosely typed by default). Pass an explicit `UserData` type argument to narrow it.
|
|
99
180
|
*/
|
|
100
181
|
addDefaultHandler(handler) {
|
|
101
|
-
this.validate(defaultRoute);
|
|
102
|
-
this.routes.set(defaultRoute, handler);
|
|
182
|
+
this.validate(exports.defaultRoute);
|
|
183
|
+
this.routes.set(exports.defaultRoute, handler);
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Returns the {@link RouteSchemas|Standard Schema} registered for a label, if any. Used by the crawler
|
|
187
|
+
* to validate `request.userData` when requests are added.
|
|
188
|
+
* @internal
|
|
189
|
+
*/
|
|
190
|
+
getSchema(label) {
|
|
191
|
+
if (label != null) {
|
|
192
|
+
const schema = this.schemas.get(label);
|
|
193
|
+
if (schema) {
|
|
194
|
+
return schema;
|
|
195
|
+
}
|
|
196
|
+
// A label with its own route is fully specified; don't fall back to the default-route schema.
|
|
197
|
+
if (this.routes.has(label)) {
|
|
198
|
+
return undefined;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
// Requests with no route of their own fall through to the default handler, so validate their
|
|
202
|
+
// `userData` against the default-route schema, if one was registered.
|
|
203
|
+
return this.schemas.get(exports.defaultRoute);
|
|
103
204
|
}
|
|
104
205
|
/**
|
|
105
206
|
* Registers a middleware that will be fired before the matching route handler.
|
|
@@ -115,57 +216,57 @@ class Router {
|
|
|
115
216
|
if (label && this.routes.has(label)) {
|
|
116
217
|
return this.routes.get(label);
|
|
117
218
|
}
|
|
118
|
-
if (this.routes.has(defaultRoute)) {
|
|
119
|
-
return this.routes.get(defaultRoute);
|
|
219
|
+
if (this.routes.has(exports.defaultRoute)) {
|
|
220
|
+
return this.routes.get(exports.defaultRoute);
|
|
120
221
|
}
|
|
121
222
|
throw new errors_1.MissingRouteError(`Route not found for label '${String(label)}'.` +
|
|
122
223
|
' You must set up a route for this label or a default route.' +
|
|
123
224
|
' Use `requestHandler`, `router.addHandler` or `router.addDefaultHandler`.');
|
|
124
225
|
}
|
|
226
|
+
/**
|
|
227
|
+
* Validates `request.userData` against the schema registered for its label (if any), replacing it with
|
|
228
|
+
* the parsed value. Throws a {@link RequestValidationError} when validation fails.
|
|
229
|
+
*/
|
|
230
|
+
async validateRequest(context) {
|
|
231
|
+
const label = context.request.label;
|
|
232
|
+
const schema = this.getSchema(label);
|
|
233
|
+
if (schema) {
|
|
234
|
+
context.request.userData = (await validateUserData(label, schema, context.request.userData));
|
|
235
|
+
}
|
|
236
|
+
}
|
|
125
237
|
/**
|
|
126
238
|
* Throws when the label already exists in our registry.
|
|
127
239
|
*/
|
|
128
240
|
validate(label) {
|
|
129
241
|
if (this.routes.has(label)) {
|
|
130
|
-
const message = label === defaultRoute
|
|
242
|
+
const message = label === exports.defaultRoute
|
|
131
243
|
? `Default route is already defined!`
|
|
132
244
|
: `Route for label '${String(label)}' is already defined!`;
|
|
133
245
|
throw new Error(message);
|
|
134
246
|
}
|
|
135
247
|
}
|
|
136
|
-
|
|
137
|
-
* Creates new router instance. This instance can then serve as a `requestHandler` of your crawler.
|
|
138
|
-
*
|
|
139
|
-
* ```ts
|
|
140
|
-
* import { Router, CheerioCrawler, CheerioCrawlingContext } from 'crawlee';
|
|
141
|
-
*
|
|
142
|
-
* const router = Router.create<CheerioCrawlingContext>();
|
|
143
|
-
* router.addHandler('label-a', async (ctx) => {
|
|
144
|
-
* ctx.log.info('...');
|
|
145
|
-
* });
|
|
146
|
-
* router.addDefaultHandler(async (ctx) => {
|
|
147
|
-
* ctx.log.info('...');
|
|
148
|
-
* });
|
|
149
|
-
*
|
|
150
|
-
* const crawler = new CheerioCrawler({
|
|
151
|
-
* requestHandler: router,
|
|
152
|
-
* });
|
|
153
|
-
* await crawler.run();
|
|
154
|
-
* ```
|
|
155
|
-
*/
|
|
156
|
-
static create(routes) {
|
|
248
|
+
static create(routesOrSchemas) {
|
|
157
249
|
const router = new Router();
|
|
158
250
|
const obj = Object.create(Function.prototype);
|
|
159
251
|
obj.addHandler = router.addHandler.bind(router);
|
|
160
252
|
obj.addDefaultHandler = router.addDefaultHandler.bind(router);
|
|
253
|
+
obj.getSchema = router.getSchema.bind(router);
|
|
161
254
|
obj.getHandler = router.getHandler.bind(router);
|
|
162
255
|
obj.use = router.use.bind(router);
|
|
163
|
-
|
|
164
|
-
|
|
256
|
+
// `Reflect.ownKeys` (unlike `Object.entries`) also yields the `defaultRoute` symbol key.
|
|
257
|
+
for (const label of Reflect.ownKeys(routesOrSchemas ?? {})) {
|
|
258
|
+
const value = routesOrSchemas[label];
|
|
259
|
+
if (typeof value === 'function') {
|
|
260
|
+
router.addHandler(label, value);
|
|
261
|
+
}
|
|
262
|
+
else {
|
|
263
|
+
router.schemas.set(label, value);
|
|
264
|
+
}
|
|
165
265
|
}
|
|
166
266
|
const func = async function (context) {
|
|
167
267
|
const { url, loadedUrl, label } = context.request;
|
|
168
268
|
context.log.debug('Page opened.', { label, url: loadedUrl ?? url });
|
|
269
|
+
await router.validateRequest(context);
|
|
169
270
|
for (const middleware of router.middlewares) {
|
|
170
271
|
await middleware(context);
|
|
171
272
|
}
|
|
@@ -192,8 +192,11 @@ export declare class SessionPool extends EventEmitter {
|
|
|
192
192
|
/**
|
|
193
193
|
* Removes listener from `persistState` event.
|
|
194
194
|
* This function should be called after you are done with using the `SessionPool` instance.
|
|
195
|
+
* @param options - Set `persistState` to false when the final state was already persisted by the event manager.
|
|
195
196
|
*/
|
|
196
|
-
teardown(
|
|
197
|
+
teardown({ persistState }?: {
|
|
198
|
+
persistState?: boolean;
|
|
199
|
+
}): Promise<void>;
|
|
197
200
|
/**
|
|
198
201
|
* SessionPool should not work before initialization.
|
|
199
202
|
*/
|
|
@@ -342,10 +342,13 @@ class SessionPool extends node_events_1.EventEmitter {
|
|
|
342
342
|
/**
|
|
343
343
|
* Removes listener from `persistState` event.
|
|
344
344
|
* This function should be called after you are done with using the `SessionPool` instance.
|
|
345
|
+
* @param options - Set `persistState` to false when the final state was already persisted by the event manager.
|
|
345
346
|
*/
|
|
346
|
-
async teardown() {
|
|
347
|
+
async teardown({ persistState = true } = {}) {
|
|
347
348
|
this.events.off("persistState" /* EventType.PERSIST_STATE */, this._listener);
|
|
348
|
-
|
|
349
|
+
if (persistState) {
|
|
350
|
+
await this.persistState();
|
|
351
|
+
}
|
|
349
352
|
}
|
|
350
353
|
/**
|
|
351
354
|
* SessionPool should not work before initialization.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A fixed-size, direct-mapped cache for `uniqueKey`-based request deduplication.
|
|
3
|
+
*
|
|
4
|
+
* `RequestProvider.requestCache` only remembers the first batch of requests, so repeated
|
|
5
|
+
* `addRequestsBatched()` calls with overlapping URLs re-submit already-enqueued requests
|
|
6
|
+
* (https://github.com/apify/crawlee/issues/3120). This is a separate, cheaper cache we can populate on
|
|
7
|
+
* every batch: a fixed number of slots indexed by a hash of the request's cache key, storing the
|
|
8
|
+
* server-assigned `requestId`. Memory is capped by the slot count regardless of the working set size;
|
|
9
|
+
* a hash collision just overwrites a slot, causing an occasional cache miss (a harmless re-submission)
|
|
10
|
+
* but never a false hit — so a genuinely new request is never dropped.
|
|
11
|
+
*
|
|
12
|
+
* @internal
|
|
13
|
+
*/
|
|
14
|
+
export declare class RequestDeduplicationCache {
|
|
15
|
+
private readonly size;
|
|
16
|
+
private keys;
|
|
17
|
+
private ids;
|
|
18
|
+
constructor(size?: number);
|
|
19
|
+
get(cacheKey: string): string | null;
|
|
20
|
+
add(cacheKey: string, requestId: string): void;
|
|
21
|
+
clear(): void;
|
|
22
|
+
private indexOf;
|
|
23
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.RequestDeduplicationCache = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* A fixed-size, direct-mapped cache for `uniqueKey`-based request deduplication.
|
|
6
|
+
*
|
|
7
|
+
* `RequestProvider.requestCache` only remembers the first batch of requests, so repeated
|
|
8
|
+
* `addRequestsBatched()` calls with overlapping URLs re-submit already-enqueued requests
|
|
9
|
+
* (https://github.com/apify/crawlee/issues/3120). This is a separate, cheaper cache we can populate on
|
|
10
|
+
* every batch: a fixed number of slots indexed by a hash of the request's cache key, storing the
|
|
11
|
+
* server-assigned `requestId`. Memory is capped by the slot count regardless of the working set size;
|
|
12
|
+
* a hash collision just overwrites a slot, causing an occasional cache miss (a harmless re-submission)
|
|
13
|
+
* but never a false hit — so a genuinely new request is never dropped.
|
|
14
|
+
*
|
|
15
|
+
* @internal
|
|
16
|
+
*/
|
|
17
|
+
class RequestDeduplicationCache {
|
|
18
|
+
// The slot count is the same for every queue, so it's a fixed default rather than a per-consumer option.
|
|
19
|
+
constructor(size = 1000000) {
|
|
20
|
+
Object.defineProperty(this, "size", {
|
|
21
|
+
enumerable: true,
|
|
22
|
+
configurable: true,
|
|
23
|
+
writable: true,
|
|
24
|
+
value: size
|
|
25
|
+
});
|
|
26
|
+
Object.defineProperty(this, "keys", {
|
|
27
|
+
enumerable: true,
|
|
28
|
+
configurable: true,
|
|
29
|
+
writable: true,
|
|
30
|
+
value: void 0
|
|
31
|
+
});
|
|
32
|
+
Object.defineProperty(this, "ids", {
|
|
33
|
+
enumerable: true,
|
|
34
|
+
configurable: true,
|
|
35
|
+
writable: true,
|
|
36
|
+
value: void 0
|
|
37
|
+
});
|
|
38
|
+
this.keys = new Array(size);
|
|
39
|
+
this.ids = new Array(size);
|
|
40
|
+
}
|
|
41
|
+
get(cacheKey) {
|
|
42
|
+
const index = this.indexOf(cacheKey);
|
|
43
|
+
return this.keys[index] === cacheKey ? this.ids[index] : null;
|
|
44
|
+
}
|
|
45
|
+
add(cacheKey, requestId) {
|
|
46
|
+
const index = this.indexOf(cacheKey);
|
|
47
|
+
this.keys[index] = cacheKey;
|
|
48
|
+
this.ids[index] = requestId;
|
|
49
|
+
}
|
|
50
|
+
clear() {
|
|
51
|
+
this.keys = new Array(this.size);
|
|
52
|
+
this.ids = new Array(this.size);
|
|
53
|
+
}
|
|
54
|
+
// A cheap FNV-1a hash of the cache key — avoids pulling in a dedicated hashing dependency.
|
|
55
|
+
indexOf(cacheKey) {
|
|
56
|
+
/* eslint-disable no-bitwise */
|
|
57
|
+
let hash = 0x811c9dc5;
|
|
58
|
+
for (let i = 0; i < cacheKey.length; i++) {
|
|
59
|
+
hash ^= cacheKey.charCodeAt(i);
|
|
60
|
+
hash = Math.imul(hash, 0x01000193);
|
|
61
|
+
}
|
|
62
|
+
return (hash >>> 0) % this.size;
|
|
63
|
+
/* eslint-enable no-bitwise */
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
exports.RequestDeduplicationCache = RequestDeduplicationCache;
|
|
@@ -336,6 +336,12 @@ export declare class RequestList implements IRequestList {
|
|
|
336
336
|
* @inheritDoc
|
|
337
337
|
*/
|
|
338
338
|
persistState(): Promise<void>;
|
|
339
|
+
/**
|
|
340
|
+
* Removes the `PERSIST_STATE` event listener registered during initialization and persists
|
|
341
|
+
* the current state one last time. Call this when you are done with the `RequestList` to avoid
|
|
342
|
+
* leaking the listener (and the requests it retains) on the shared event manager.
|
|
343
|
+
*/
|
|
344
|
+
teardown(): Promise<void>;
|
|
339
345
|
/**
|
|
340
346
|
* Unlike persistState(), this is used only internally, since the sources
|
|
341
347
|
* are automatically persisted at RequestList initialization (if the persistRequestsKey is set),
|
package/storages/request_list.js
CHANGED
|
@@ -248,6 +248,7 @@ class RequestList {
|
|
|
248
248
|
this.sourcesFunction = sourcesFunction;
|
|
249
249
|
// The proxy configuration used for `requestsFromUrl` requests.
|
|
250
250
|
this.proxyConfiguration = proxyConfiguration;
|
|
251
|
+
this.persistState = this.persistState.bind(this);
|
|
251
252
|
}
|
|
252
253
|
/**
|
|
253
254
|
* Loads all remote sources of URLs and potentially starts periodic state persistence.
|
|
@@ -273,7 +274,7 @@ class RequestList {
|
|
|
273
274
|
if (this.persistRequestsKey && !this.areRequestsPersisted)
|
|
274
275
|
await this._persistRequests();
|
|
275
276
|
if (this.persistStateKey) {
|
|
276
|
-
this.events.on("persistState" /* EventType.PERSIST_STATE */, this.persistState
|
|
277
|
+
this.events.on("persistState" /* EventType.PERSIST_STATE */, this.persistState);
|
|
277
278
|
}
|
|
278
279
|
return this;
|
|
279
280
|
}
|
|
@@ -324,9 +325,11 @@ class RequestList {
|
|
|
324
325
|
const sourcesFromFunction = await this.sourcesFunction();
|
|
325
326
|
const sourcesFromFunctionCount = sourcesFromFunction.length;
|
|
326
327
|
for (let i = 0; i < sourcesFromFunctionCount; i++) {
|
|
327
|
-
const source = sourcesFromFunction
|
|
328
|
+
const source = sourcesFromFunction[i];
|
|
329
|
+
delete sourcesFromFunction[i];
|
|
328
330
|
this._addRequest(source);
|
|
329
331
|
}
|
|
332
|
+
sourcesFromFunction.length = 0;
|
|
330
333
|
}
|
|
331
334
|
catch (e) {
|
|
332
335
|
const err = e;
|
|
@@ -353,6 +356,17 @@ class RequestList {
|
|
|
353
356
|
this.log.exception(err, 'Attempted to persist state, but failed.');
|
|
354
357
|
}
|
|
355
358
|
}
|
|
359
|
+
/**
|
|
360
|
+
* Removes the `PERSIST_STATE` event listener registered during initialization and persists
|
|
361
|
+
* the current state one last time. Call this when you are done with the `RequestList` to avoid
|
|
362
|
+
* leaking the listener (and the requests it retains) on the shared event manager.
|
|
363
|
+
*/
|
|
364
|
+
async teardown() {
|
|
365
|
+
this.events.off("persistState" /* EventType.PERSIST_STATE */, this.persistState);
|
|
366
|
+
if (this.persistStateKey) {
|
|
367
|
+
await this.persistState();
|
|
368
|
+
}
|
|
369
|
+
}
|
|
356
370
|
/**
|
|
357
371
|
* Unlike persistState(), this is used only internally, since the sources
|
|
358
372
|
* are automatically persisted at RequestList initialization (if the persistRequestsKey is set),
|
|
@@ -5,6 +5,7 @@ import { Configuration } from '../configuration';
|
|
|
5
5
|
import type { ProxyConfiguration } from '../proxy_configuration';
|
|
6
6
|
import type { InternalSource, RequestOptions, Source } from '../request';
|
|
7
7
|
import { Request } from '../request';
|
|
8
|
+
import { RequestDeduplicationCache } from './request_dedup_cache';
|
|
8
9
|
import type { IStorage, StorageManagerOptions } from './storage_manager';
|
|
9
10
|
export type RequestsLike = AsyncIterable<Source | string> | Iterable<Source | string> | (Source | string)[];
|
|
10
11
|
/**
|
|
@@ -74,6 +75,12 @@ export declare abstract class RequestProvider implements IStorage, IRequestManag
|
|
|
74
75
|
private initialHandledCount;
|
|
75
76
|
protected queueHeadIds: ListDictionary<string>;
|
|
76
77
|
protected requestCache: LruCache<RequestLruItem>;
|
|
78
|
+
/**
|
|
79
|
+
* Remembers the `requestId` of every request already submitted to the client — including background
|
|
80
|
+
* batches that `requestCache` skips — so overlapping URL sets aren't re-submitted.
|
|
81
|
+
* See {@link RequestDeduplicationCache} for why this is a separate, cheaper cache.
|
|
82
|
+
*/
|
|
83
|
+
protected requestSeenCache: RequestDeduplicationCache;
|
|
77
84
|
protected recentlyHandledRequestsCache: LruCache<boolean>;
|
|
78
85
|
protected queuePausedForMigration: boolean;
|
|
79
86
|
protected lastActivity: Date;
|
|
@@ -11,8 +11,14 @@ const configuration_1 = require("../configuration");
|
|
|
11
11
|
const log_1 = require("../log");
|
|
12
12
|
const request_1 = require("../request");
|
|
13
13
|
const access_checking_1 = require("./access_checking");
|
|
14
|
+
const request_dedup_cache_1 = require("./request_dedup_cache");
|
|
14
15
|
const storage_manager_1 = require("./storage_manager");
|
|
15
16
|
const utils_2 = require("./utils");
|
|
17
|
+
/**
|
|
18
|
+
* Maximum number of consecutive batch-add attempts that make no progress before the remaining
|
|
19
|
+
* unprocessed requests are skipped, so permanently rejected requests don't retry forever.
|
|
20
|
+
*/
|
|
21
|
+
const MAX_UNPROCESSED_REQUESTS_RETRIES = 3;
|
|
16
22
|
class RequestProvider {
|
|
17
23
|
constructor(options, config = configuration_1.Configuration.getGlobalConfig()) {
|
|
18
24
|
Object.defineProperty(this, "config", {
|
|
@@ -113,6 +119,17 @@ class RequestProvider {
|
|
|
113
119
|
writable: true,
|
|
114
120
|
value: void 0
|
|
115
121
|
});
|
|
122
|
+
/**
|
|
123
|
+
* Remembers the `requestId` of every request already submitted to the client — including background
|
|
124
|
+
* batches that `requestCache` skips — so overlapping URL sets aren't re-submitted.
|
|
125
|
+
* See {@link RequestDeduplicationCache} for why this is a separate, cheaper cache.
|
|
126
|
+
*/
|
|
127
|
+
Object.defineProperty(this, "requestSeenCache", {
|
|
128
|
+
enumerable: true,
|
|
129
|
+
configurable: true,
|
|
130
|
+
writable: true,
|
|
131
|
+
value: void 0
|
|
132
|
+
});
|
|
116
133
|
Object.defineProperty(this, "recentlyHandledRequestsCache", {
|
|
117
134
|
enumerable: true,
|
|
118
135
|
configurable: true,
|
|
@@ -151,6 +168,7 @@ class RequestProvider {
|
|
|
151
168
|
});
|
|
152
169
|
this.proxyConfiguration = options.proxyConfiguration;
|
|
153
170
|
this.requestCache = new datastructures_1.LruCache({ maxLength: options.requestCacheMaxSize });
|
|
171
|
+
this.requestSeenCache = new request_dedup_cache_1.RequestDeduplicationCache();
|
|
154
172
|
this.recentlyHandledRequestsCache = new datastructures_1.LruCache({ maxLength: options.recentlyHandledRequestsMaxSize });
|
|
155
173
|
this.log = log_1.log.child({ prefix: `${options.logPrefix}(${this.id}, ${this.name ?? 'no-name'})` });
|
|
156
174
|
const eventManager = config.getEventManager();
|
|
@@ -227,6 +245,7 @@ class RequestProvider {
|
|
|
227
245
|
};
|
|
228
246
|
const { requestId, wasAlreadyPresent } = queueOperationInfo;
|
|
229
247
|
this._cacheRequest(cacheKey, queueOperationInfo);
|
|
248
|
+
this.requestSeenCache.add(cacheKey, requestId);
|
|
230
249
|
if (!wasAlreadyPresent && !this.recentlyHandledRequestsCache.get(requestId)) {
|
|
231
250
|
this.assumedTotalCount++;
|
|
232
251
|
// Performance optimization: add request straight to head if possible
|
|
@@ -288,16 +307,17 @@ class RequestProvider {
|
|
|
288
307
|
const requestsToAdd = new Map();
|
|
289
308
|
for (const request of requests) {
|
|
290
309
|
const cacheKey = getCachedRequestId(request.uniqueKey);
|
|
310
|
+
// Prefer the full `requestCache` record; fall back to the dedup cache for background batches it skips.
|
|
291
311
|
const cachedInfo = this.requestCache.get(cacheKey);
|
|
292
|
-
|
|
293
|
-
|
|
312
|
+
const knownRequestId = cachedInfo?.id ?? this.requestSeenCache.get(cacheKey);
|
|
313
|
+
if (knownRequestId) {
|
|
314
|
+
request.id = knownRequestId;
|
|
294
315
|
results.processedRequests.push({
|
|
295
316
|
wasAlreadyPresent: true,
|
|
296
|
-
//
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
uniqueKey: cachedInfo.uniqueKey,
|
|
317
|
+
// The dedup cache doesn't track the handled state; only the full record does.
|
|
318
|
+
wasAlreadyHandled: cachedInfo?.isHandled ?? false,
|
|
319
|
+
requestId: knownRequestId,
|
|
320
|
+
uniqueKey: request.uniqueKey,
|
|
301
321
|
});
|
|
302
322
|
}
|
|
303
323
|
else if (!requestsToAdd.has(request.uniqueKey)) {
|
|
@@ -320,6 +340,8 @@ class RequestProvider {
|
|
|
320
340
|
if (cache) {
|
|
321
341
|
this._cacheRequest(cacheKey, { ...newRequest, forefront });
|
|
322
342
|
}
|
|
343
|
+
// Unlike `requestCache`, populate this on every batch (including background ones).
|
|
344
|
+
this.requestSeenCache.add(cacheKey, requestId);
|
|
323
345
|
if (!wasAlreadyPresent && !this.recentlyHandledRequestsCache.get(requestId)) {
|
|
324
346
|
this.assumedTotalCount++;
|
|
325
347
|
// Performance optimization: add request straight to head if possible
|
|
@@ -385,13 +407,20 @@ class RequestProvider {
|
|
|
385
407
|
const requestIterator = generateRequests();
|
|
386
408
|
const chunks = (0, utils_1.peekableAsyncIterable)((0, utils_1.chunkedAsyncIterable)(requestIterator, effectiveChunkSize));
|
|
387
409
|
const chunksIterator = chunks[Symbol.asyncIterator]();
|
|
388
|
-
const attemptToAddToQueueAndAddAnyUnprocessed = async (providedRequests, cache = true) => {
|
|
410
|
+
const attemptToAddToQueueAndAddAnyUnprocessed = async (providedRequests, cache = true, unsuccessfulAttempts = 0) => {
|
|
389
411
|
const resultsToReturn = [];
|
|
390
412
|
const apiResult = await this.addRequests(providedRequests, { forefront: options.forefront, cache });
|
|
391
413
|
resultsToReturn.push(...apiResult.processedRequests);
|
|
392
414
|
if (apiResult.unprocessedRequests.length) {
|
|
415
|
+
// Count attempts that make no progress, so permanently rejected requests (e.g. a malformed
|
|
416
|
+
// `userData` shape causing a 400) don't loop forever. Any progress resets the counter.
|
|
417
|
+
const attempts = apiResult.processedRequests.length ? 0 : unsuccessfulAttempts + 1;
|
|
418
|
+
if (attempts >= MAX_UNPROCESSED_REQUESTS_RETRIES) {
|
|
419
|
+
this.log.warning(`Some requests were consistently rejected by the request queue and will be skipped after ${MAX_UNPROCESSED_REQUESTS_RETRIES} attempts. This usually means the request data is malformed (e.g. an invalid 'userData' shape).`, { unprocessedRequests: apiResult.unprocessedRequests });
|
|
420
|
+
return resultsToReturn;
|
|
421
|
+
}
|
|
393
422
|
await (0, utils_1.sleep)(waitBetweenBatchesMillis);
|
|
394
|
-
resultsToReturn.push(...(await attemptToAddToQueueAndAddAnyUnprocessed(providedRequests.filter((r) => !apiResult.processedRequests.some((pr) => pr.uniqueKey === r.uniqueKey)), false)));
|
|
423
|
+
resultsToReturn.push(...(await attemptToAddToQueueAndAddAnyUnprocessed(providedRequests.filter((r) => !apiResult.processedRequests.some((pr) => pr.uniqueKey === r.uniqueKey)), false, attempts)));
|
|
395
424
|
}
|
|
396
425
|
return resultsToReturn;
|
|
397
426
|
};
|
|
@@ -546,6 +575,7 @@ class RequestProvider {
|
|
|
546
575
|
this.assumedTotalCount = 0;
|
|
547
576
|
this.assumedHandledCount = 0;
|
|
548
577
|
this.requestCache.clear();
|
|
578
|
+
this.requestSeenCache.clear();
|
|
549
579
|
}
|
|
550
580
|
/**
|
|
551
581
|
* Caches information about request to beware of unneeded addRequest() calls.
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type ParseSitemapOptions } from '@crawlee/utils';
|
|
2
2
|
import { Configuration } from '../configuration';
|
|
3
3
|
import type { GlobInput, RegExpInput } from '../enqueue_links';
|
|
4
|
+
import { EnqueueStrategy } from '../enqueue_links';
|
|
4
5
|
import { Request } from '../request';
|
|
5
6
|
import type { IRequestList } from './request_list';
|
|
6
7
|
interface UrlConstraints {
|
|
@@ -76,6 +77,13 @@ export interface SitemapRequestListOptions extends UrlConstraints {
|
|
|
76
77
|
* @default 200
|
|
77
78
|
*/
|
|
78
79
|
maxBufferSize?: number;
|
|
80
|
+
/**
|
|
81
|
+
* Keep only sitemap-derived URLs matching this strategy relative to the parent sitemap URL; non-`http(s)`
|
|
82
|
+
* schemes are always dropped. The filtering stays enforced after navigation (e.g. across redirects).
|
|
83
|
+
* Pass `'all'` to disable host filtering.
|
|
84
|
+
* @default EnqueueStrategy.SameHostname
|
|
85
|
+
*/
|
|
86
|
+
enqueueStrategy?: EnqueueStrategy | `${EnqueueStrategy}`;
|
|
79
87
|
/**
|
|
80
88
|
* Advanced options for the underlying `parseSitemap` call.
|
|
81
89
|
*/
|
|
@@ -133,6 +141,10 @@ export declare class SitemapRequestList implements IRequestList {
|
|
|
133
141
|
* Proxy URL to be used for sitemap loading.
|
|
134
142
|
*/
|
|
135
143
|
private proxyUrl;
|
|
144
|
+
/**
|
|
145
|
+
* Enqueue strategy applied to sitemap-derived URLs and stamped onto the emitted `Request` objects.
|
|
146
|
+
*/
|
|
147
|
+
private enqueueStrategy;
|
|
136
148
|
/**
|
|
137
149
|
* Logger instance.
|
|
138
150
|
*/
|
|
@@ -132,6 +132,15 @@ class SitemapRequestList {
|
|
|
132
132
|
writable: true,
|
|
133
133
|
value: void 0
|
|
134
134
|
});
|
|
135
|
+
/**
|
|
136
|
+
* Enqueue strategy applied to sitemap-derived URLs and stamped onto the emitted `Request` objects.
|
|
137
|
+
*/
|
|
138
|
+
Object.defineProperty(this, "enqueueStrategy", {
|
|
139
|
+
enumerable: true,
|
|
140
|
+
configurable: true,
|
|
141
|
+
writable: true,
|
|
142
|
+
value: void 0
|
|
143
|
+
});
|
|
135
144
|
/**
|
|
136
145
|
* Logger instance.
|
|
137
146
|
*/
|
|
@@ -173,6 +182,7 @@ class SitemapRequestList {
|
|
|
173
182
|
signal: ow_1.default.optional.any(),
|
|
174
183
|
timeoutMillis: ow_1.default.optional.number,
|
|
175
184
|
maxBufferSize: ow_1.default.optional.number,
|
|
185
|
+
enqueueStrategy: ow_1.default.optional.string.oneOf(Object.values(enqueue_links_1.EnqueueStrategy)),
|
|
176
186
|
parseSitemapOptions: ow_1.default.optional.object,
|
|
177
187
|
globs: ow_1.default.optional.array.ofType(ow_1.default.any(ow_1.default.string, ow_1.default.object.hasKeys('glob'))),
|
|
178
188
|
exclude: ow_1.default.optional.array.ofType(ow_1.default.any(ow_1.default.string, ow_1.default.regExp, ow_1.default.object.hasKeys('glob'), ow_1.default.object.hasKeys('regexp'))),
|
|
@@ -200,6 +210,7 @@ class SitemapRequestList {
|
|
|
200
210
|
this.persistStateKey = options.persistStateKey;
|
|
201
211
|
this.persistenceOptions = { enable: true, ...options.persistenceOptions };
|
|
202
212
|
this.proxyUrl = options.proxyUrl;
|
|
213
|
+
this.enqueueStrategy = options.enqueueStrategy ?? enqueue_links_1.EnqueueStrategy.SameHostname;
|
|
203
214
|
this.urlQueueStream = this.createNewStream(options.maxBufferSize ?? 200);
|
|
204
215
|
this.sitemapParsingProgress.pendingSitemapUrls = new Set(options.sitemapUrls);
|
|
205
216
|
this.events = config.getEventManager();
|
|
@@ -308,6 +319,7 @@ class SitemapRequestList {
|
|
|
308
319
|
...parseSitemapOptions,
|
|
309
320
|
maxDepth: 0,
|
|
310
321
|
emitNestedSitemaps: true,
|
|
322
|
+
enqueueStrategy: this.enqueueStrategy,
|
|
311
323
|
})) {
|
|
312
324
|
if (!item.originSitemapUrl) {
|
|
313
325
|
// This is a nested sitemap
|
|
@@ -398,14 +410,21 @@ class SitemapRequestList {
|
|
|
398
410
|
}
|
|
399
411
|
// Create a new stream, as we have read all the URLs from the current one.
|
|
400
412
|
// Pushing the urls back to the original stream might not be possible if it has been ended.
|
|
401
|
-
const
|
|
413
|
+
const previousStream = this.urlQueueStream;
|
|
414
|
+
const newStream = this.createNewStream(previousStream.readableHighWaterMark);
|
|
402
415
|
for (const url of urlQueue) {
|
|
403
416
|
newStream.push(url);
|
|
404
417
|
}
|
|
405
|
-
if (
|
|
418
|
+
if (previousStream.writableEnded) {
|
|
406
419
|
newStream.end();
|
|
407
420
|
}
|
|
408
421
|
this.urlQueueStream = newStream;
|
|
422
|
+
// A `pushNextUrl()` call may be blocked on backpressure, waiting for a `readdata` event on the
|
|
423
|
+
// previous stream. That event is only ever emitted by `readNextUrl()` on the current stream, so
|
|
424
|
+
// after the swap the waiter would never be notified and the background sitemap loading would hang.
|
|
425
|
+
// Re-emit `readdata` on the previous stream to release any such pending waiter (its URL has already
|
|
426
|
+
// been transferred to the new stream above).
|
|
427
|
+
previousStream.emit('readdata');
|
|
409
428
|
await this.store.setValue(this.persistStateKey, {
|
|
410
429
|
sitemapParsingProgress: {
|
|
411
430
|
pendingSitemapUrls: Array.from(this.sitemapParsingProgress.pendingSitemapUrls),
|
|
@@ -457,7 +476,7 @@ class SitemapRequestList {
|
|
|
457
476
|
if (!nextUrl) {
|
|
458
477
|
return null;
|
|
459
478
|
}
|
|
460
|
-
this.requestData.set(nextUrl, new request_1.Request({ url: nextUrl }));
|
|
479
|
+
this.requestData.set(nextUrl, new request_1.Request({ url: nextUrl, enqueueStrategy: this.enqueueStrategy }));
|
|
461
480
|
}
|
|
462
481
|
this.inProgress.add(nextUrl);
|
|
463
482
|
return this.requestData.get(nextUrl);
|