@crawlee/core 4.0.0-beta.123 → 4.0.0-beta.124
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/events/event_manager.js
CHANGED
|
@@ -18,7 +18,8 @@ export class EventManager {
|
|
|
18
18
|
#persistStateIntervalMillis;
|
|
19
19
|
constructor(options) {
|
|
20
20
|
this.#persistStateIntervalMillis = options.persistStateIntervalMillis;
|
|
21
|
-
|
|
21
|
+
// One MIGRATING listener per RequestQueue, and ThrottlingRequestManager opens one per domain.
|
|
22
|
+
this.events.setMaxListeners(150);
|
|
22
23
|
}
|
|
23
24
|
/**
|
|
24
25
|
* Initializes the event manager by starting the `persistState` event interval.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crawlee/core",
|
|
3
|
-
"version": "4.0.0-beta.
|
|
3
|
+
"version": "4.0.0-beta.124",
|
|
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": ">=22.0.0"
|
|
@@ -52,10 +52,10 @@
|
|
|
52
52
|
"@apify/log": "^2.5.18",
|
|
53
53
|
"@apify/timeout": "^0.4.4",
|
|
54
54
|
"@apify/utilities": "^2.15.5",
|
|
55
|
-
"@crawlee/fs-storage": "4.0.0-beta.
|
|
56
|
-
"@crawlee/http-client": "4.0.0-beta.
|
|
57
|
-
"@crawlee/types": "4.0.0-beta.
|
|
58
|
-
"@crawlee/utils": "4.0.0-beta.
|
|
55
|
+
"@crawlee/fs-storage": "4.0.0-beta.124",
|
|
56
|
+
"@crawlee/http-client": "4.0.0-beta.124",
|
|
57
|
+
"@crawlee/types": "4.0.0-beta.124",
|
|
58
|
+
"@crawlee/utils": "4.0.0-beta.124",
|
|
59
59
|
"@sapphire/async-queue": "^1.5.5",
|
|
60
60
|
"@vladfrangu/async_event_emitter": "^2.4.6",
|
|
61
61
|
"content-type": "^1.0.5",
|
|
@@ -77,5 +77,5 @@
|
|
|
77
77
|
}
|
|
78
78
|
}
|
|
79
79
|
},
|
|
80
|
-
"gitHead": "
|
|
80
|
+
"gitHead": "0694ee1b94c755b98141671baa93cc363f2bf8e3"
|
|
81
81
|
}
|
|
@@ -37,15 +37,58 @@ export interface ThrottlingRequestManagerOptions<T extends IRequestManager = IRe
|
|
|
37
37
|
*/
|
|
38
38
|
inner: T;
|
|
39
39
|
/**
|
|
40
|
-
*
|
|
41
|
-
* supported, so list each subdomain you care about. Requests for any other domain bypass throttling entirely.
|
|
40
|
+
* Which domains to throttle: a list of hostnames, or `'all'` for every domain the crawl encounters.
|
|
42
41
|
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
42
|
+
* Matching a listed hostname is case-insensitive and exact - wildcards such as `*.example.com` are not
|
|
43
|
+
* supported, so list each subdomain you care about (or set
|
|
44
|
+
* {@link ThrottlingRequestManagerOptions.throttleBy|`throttleBy: 'registrableDomain'`}). An
|
|
45
|
+
* internationalized domain may be given in either its unicode or its punycode form, and an IPv6 address has
|
|
46
|
+
* to be bracketed (`[::1]`). Requests for any other domain bypass throttling entirely.
|
|
47
|
+
*
|
|
48
|
+
* `'all'` gives each domain a queue of its own the first time it is seen, so that it can be held back
|
|
49
|
+
* without its requests being repeatedly popped and re-enqueued. One request queue per domain is not free,
|
|
50
|
+
* which is what {@link ThrottlingRequestManagerOptions.maxThrottledDomains|`maxThrottledDomains`} is
|
|
51
|
+
* there to bound.
|
|
52
|
+
*/
|
|
53
|
+
domains: string[] | 'all';
|
|
54
|
+
/**
|
|
55
|
+
* A floor under the crawl delay of every throttled domain, in seconds - the proactive clock described on
|
|
56
|
+
* {@link ThrottlingRequestManager}. A domain whose robots.txt asks for a longer `Crawl-delay` gets the
|
|
57
|
+
* longer one; this is a minimum, not an override.
|
|
58
|
+
* @default 0
|
|
59
|
+
*/
|
|
60
|
+
minCrawlDelaySecs?: number;
|
|
61
|
+
/**
|
|
62
|
+
* What counts as "the same domain": the exact hostname, or the registrable domain it belongs to
|
|
63
|
+
* (`example.com` for `www.example.com`, `a.example.co.uk` and so on). Hosts with no registrable domain -
|
|
64
|
+
* IP addresses, `localhost` - are always throttled per hostname.
|
|
65
|
+
*
|
|
66
|
+
* Grouping by registrable domain gives subdomains a single pair of clocks and a single queue, which is what
|
|
67
|
+
* you want when the pacing is there to be polite to one server rather than to satisfy a specific host's
|
|
68
|
+
* rate limit.
|
|
69
|
+
* @default 'hostname'
|
|
70
|
+
*/
|
|
71
|
+
throttleBy?: 'hostname' | 'registrableDomain';
|
|
72
|
+
/**
|
|
73
|
+
* The most domains a run may throttle at once. Exceeding it throws, rather than silently letting the
|
|
74
|
+
* throttling lapse - one request queue per domain is not free, and a crawl that discovers domains without
|
|
75
|
+
* bound would drown the storage backend in them.
|
|
76
|
+
*
|
|
77
|
+
* Only domains discovered under `domains: 'all'` count against this; an explicit list is taken at face value.
|
|
78
|
+
* @default 100
|
|
45
79
|
*/
|
|
46
|
-
|
|
80
|
+
maxThrottledDomains?: number;
|
|
47
81
|
/**
|
|
48
|
-
*
|
|
82
|
+
* The key under which the discovered domain list is kept in the default key-value store, so that a restart
|
|
83
|
+
* with `purgeOnStart` disabled reopens their queues instead of stranding whatever they still hold. Only
|
|
84
|
+
* written under `domains: 'all'`.
|
|
85
|
+
*
|
|
86
|
+
* Give each manager its own key when running several of them against the same storage.
|
|
87
|
+
* @default 'CRAWLEE_THROTTLED_DOMAINS'
|
|
88
|
+
*/
|
|
89
|
+
persistStateKey?: string;
|
|
90
|
+
/**
|
|
91
|
+
* Opens the per-domain queues, one per throttled domain, each under the alias `throttled-<domain>`.
|
|
49
92
|
* @default RequestQueue.open
|
|
50
93
|
*/
|
|
51
94
|
requestManagerOpener?: RequestManagerOpener<T>;
|
|
@@ -77,22 +120,26 @@ export interface ThrottlingRequestManagerOptions<T extends IRequestManager = IRe
|
|
|
77
120
|
/**
|
|
78
121
|
* A request manager that wraps another one and paces requests per domain.
|
|
79
122
|
*
|
|
80
|
-
* Requests for
|
|
81
|
-
*
|
|
82
|
-
* else goes to the wrapped manager untouched.
|
|
123
|
+
* Requests for a throttled domain are routed into their own queue when they are added, so each request lives in
|
|
124
|
+
* exactly one place and deduplication keeps working. Everything else goes to the wrapped manager untouched.
|
|
83
125
|
*
|
|
84
126
|
* {@link ThrottlingRequestManager.fetchNextRequest|`fetchNextRequest()`} serves the domain that has been waiting
|
|
85
127
|
* longest and skips any that are backing off, falling back to the wrapped manager. It never blocks: while every
|
|
86
128
|
* remaining request belongs to a throttled domain it returns `null` and {@link ThrottlingRequestManager.isEmpty}
|
|
87
129
|
* reports `true`, so the crawler idles instead of holding a concurrency slot open.
|
|
88
130
|
*
|
|
89
|
-
*
|
|
90
|
-
* - HTTP 429 responses
|
|
91
|
-
*
|
|
131
|
+
* Each throttled domain runs two independent clocks, and may be dispatched to once **both** have run out:
|
|
132
|
+
* - **Backoff**, set by HTTP 429 responses - honouring `Retry-After`, and otherwise doubling from `baseDelaySecs`.
|
|
133
|
+
* Reactive and temporary: it decays once the domain stops turning us away. The crawlers report the 429s
|
|
134
|
+
* themselves; a request held back this way is retried later without counting against `maxRequestRetries` and
|
|
92
135
|
* without penalising its session.
|
|
93
|
-
* -
|
|
136
|
+
* - **Crawl delay**, the minimum interval between two dispatches to the domain, armed after each one. Proactive
|
|
137
|
+
* and constant: whatever the domain's robots.txt asks for, floored by
|
|
138
|
+
* {@link ThrottlingRequestManagerOptions.minCrawlDelaySecs|`minCrawlDelaySecs`}. Either may be absent, in
|
|
139
|
+
* which case the other one is the delay.
|
|
94
140
|
*
|
|
95
|
-
*
|
|
141
|
+
* Which domains get those clocks is {@link ThrottlingRequestManagerOptions.domains|`domains`} - a list, or
|
|
142
|
+
* `'all'` for every domain the crawl encounters.
|
|
96
143
|
*
|
|
97
144
|
* **Example usage:**
|
|
98
145
|
*
|
|
@@ -109,40 +156,13 @@ export interface ThrottlingRequestManagerOptions<T extends IRequestManager = IRe
|
|
|
109
156
|
* @category Sources
|
|
110
157
|
*/
|
|
111
158
|
export declare class ThrottlingRequestManager<T extends IRequestManager = IRequestManager> implements IRequestManager, SupportsDomainThrottling {
|
|
159
|
+
#private;
|
|
112
160
|
private readonly config;
|
|
113
|
-
private readonly inner;
|
|
114
|
-
private readonly requestManagerOpener;
|
|
115
|
-
private readonly baseDelayMs;
|
|
116
|
-
private readonly maxDelayMs;
|
|
117
|
-
private readonly maxDomainStallMs;
|
|
118
161
|
private readonly domainStates;
|
|
119
|
-
private readonly subManagers;
|
|
120
162
|
private readonly log;
|
|
121
|
-
/**
|
|
122
|
-
* Sub-managers are keyed by a stable alias, so with `purgeOnStart` disabled they outlive the process. They
|
|
123
|
-
* must therefore be reopened for every configured domain rather than created on first insert - otherwise a
|
|
124
|
-
* restart sees an empty map, reports the crawl finished, and strands whatever the previous run left in them.
|
|
125
|
-
*/
|
|
126
|
-
private subManagersReady?;
|
|
127
|
-
/** Batches still being added in the background; keeps {@link ThrottlingRequestManager.isFinished} honest. */
|
|
128
|
-
private inProgressBatchCount;
|
|
129
|
-
private readonly warnedAbout;
|
|
130
|
-
private get hasThrottledDomains();
|
|
131
163
|
constructor(options: ThrottlingRequestManagerOptions<T>, config?: Configuration);
|
|
132
164
|
/** The wrapped manager, holding every request whose domain is not throttled. */
|
|
133
165
|
get innerManager(): T;
|
|
134
|
-
/** Warns once about sources that cannot be routed by domain, because their URLs are not known yet. */
|
|
135
|
-
private warnIfNotRoutable;
|
|
136
|
-
private warnOnce;
|
|
137
|
-
private extractDomain;
|
|
138
|
-
private getDomainState;
|
|
139
|
-
private selectManager;
|
|
140
|
-
/** Only valid once {@link ThrottlingRequestManager.ensureSubManagers} has resolved. */
|
|
141
|
-
private managerForUrl;
|
|
142
|
-
private ensureSubManagers;
|
|
143
|
-
private getSubManagers;
|
|
144
|
-
/** Configured domains that are not currently backing off, longest-overdue first. */
|
|
145
|
-
private fetchableDomains;
|
|
146
166
|
/**
|
|
147
167
|
* Records a 429 response and puts the URL's domain into backoff.
|
|
148
168
|
*
|
|
@@ -150,11 +170,12 @@ export declare class ThrottlingRequestManager<T extends IRequestManager = IReque
|
|
|
150
170
|
*/
|
|
151
171
|
recordDomainDelay(url: string, retryAfterMs?: number | null): boolean;
|
|
152
172
|
/**
|
|
153
|
-
*
|
|
173
|
+
* Records the `Crawl-delay` a domain's robots.txt asked for, which becomes its crawl delay unless
|
|
174
|
+
* {@link ThrottlingRequestManagerOptions.minCrawlDelaySecs|`minCrawlDelaySecs`} asks for longer.
|
|
154
175
|
*
|
|
155
176
|
* The first value wins, so a robots.txt re-fetch cannot change the cadence mid-crawl.
|
|
156
177
|
*
|
|
157
|
-
* @returns `false` if the domain is not
|
|
178
|
+
* @returns `false` if the domain is not throttled, in which case this is a no-op.
|
|
158
179
|
*/
|
|
159
180
|
setCrawlDelay(url: string, delaySeconds: number): boolean;
|
|
160
181
|
/**
|
|
@@ -167,8 +188,6 @@ export declare class ThrottlingRequestManager<T extends IRequestManager = IReque
|
|
|
167
188
|
* `Crawl-delay` is being obeyed, not stonewalled.
|
|
168
189
|
*/
|
|
169
190
|
assertNoStalledDomains(): Promise<void>;
|
|
170
|
-
/** Records that a domain let a request through, which ends any rate-limit run stall detection was timing. */
|
|
171
|
-
private recordProgress;
|
|
172
191
|
addRequest(requestLike: Source, options?: RequestQueueOperationOptions): Promise<RequestQueueOperationInfo>;
|
|
173
192
|
/**
|
|
174
193
|
* Adds requests in batches, routing each one to the manager that owns its domain.
|
|
@@ -197,10 +216,14 @@ export declare class ThrottlingRequestManager<T extends IRequestManager = IReque
|
|
|
197
216
|
* site rather than of the run, so it survives.
|
|
198
217
|
*/
|
|
199
218
|
purge(): Promise<void>;
|
|
219
|
+
/**
|
|
220
|
+
* Empties the per-domain queues, leaving the wrapped manager alone.
|
|
221
|
+
*
|
|
222
|
+
* Those queues are this manager's own no matter who owns the one it wraps, which is what makes this safe to
|
|
223
|
+
* call where a full {@link ThrottlingRequestManager.purge|`purge()`} would not be.
|
|
224
|
+
*/
|
|
225
|
+
purgeDomainQueues(): Promise<void>;
|
|
200
226
|
setExpectedRequestProcessingTimeSecs(secs: number): Promise<void>;
|
|
201
|
-
private forEachManager;
|
|
202
|
-
private sumOverManagers;
|
|
203
|
-
private everyManager;
|
|
204
227
|
/**
|
|
205
228
|
* Returns the next request from a domain that is not backing off, or from the inner manager.
|
|
206
229
|
*
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { URL } from 'node:url';
|
|
2
|
+
import { getDomain } from 'tldts';
|
|
2
3
|
import { z } from 'zod';
|
|
3
4
|
import { PersistentRateLimitError } from '../errors.js';
|
|
4
5
|
import { asyncifyIterable } from '../iterables.js';
|
|
@@ -6,14 +7,21 @@ import { serviceLocator } from '../service_locator.js';
|
|
|
6
7
|
import { normalizeHostname } from '../url.js';
|
|
7
8
|
import { parseArgument, schemas } from '../validators.js';
|
|
8
9
|
import { drainRequestBatches } from './batched_adds.js';
|
|
10
|
+
import { KeyValueStore } from './key_value_store.js';
|
|
9
11
|
import { RequestQueue } from './request_queue.js';
|
|
10
12
|
const throttlingRequestManagerOptionsSchema = z.strictObject({
|
|
11
13
|
inner: schemas.anyObject,
|
|
12
|
-
domains: schemas.arrayOf(z.string().nonempty(), 'non-empty strings'),
|
|
14
|
+
domains: z.union([schemas.arrayOf(z.string().nonempty(), 'non-empty strings'), z.literal('all')]),
|
|
13
15
|
requestManagerOpener: schemas.anyFunction.optional(),
|
|
14
16
|
baseDelaySecs: schemas.anyNumber.refine((value) => value > 0, 'Expected a number greater than 0').optional(),
|
|
15
17
|
maxDelaySecs: schemas.anyNumber.refine((value) => value > 0, 'Expected a number greater than 0').optional(),
|
|
16
18
|
maxDomainStallSecs: schemas.anyNumber.refine((value) => value > 0, 'Expected a number greater than 0').optional(),
|
|
19
|
+
minCrawlDelaySecs: schemas.anyNumber
|
|
20
|
+
.refine((value) => value >= 0, 'Expected a number greater than or equal to 0')
|
|
21
|
+
.optional(),
|
|
22
|
+
throttleBy: z.enum(['hostname', 'registrableDomain']).optional(),
|
|
23
|
+
maxThrottledDomains: schemas.anyNumber.refine((value) => value > 0, 'Expected a number greater than 0').optional(),
|
|
24
|
+
persistStateKey: z.string().nonempty().optional(),
|
|
17
25
|
});
|
|
18
26
|
/** Whether `manager` can pace requests per domain. */
|
|
19
27
|
export function supportsDomainThrottling(manager) {
|
|
@@ -26,25 +34,46 @@ export function supportsDomainThrottling(manager) {
|
|
|
26
34
|
function throttledUntil(state) {
|
|
27
35
|
return Math.max(state.backoffUntil, state.crawlDelayUntil);
|
|
28
36
|
}
|
|
37
|
+
/** How long a domain must be left alone after a dispatch: what it asked for, or our floor, whichever is longer. */
|
|
38
|
+
function crawlDelayMs(state, minCrawlDelayMs) {
|
|
39
|
+
return Math.max(state.declaredCrawlDelayMs ?? 0, minCrawlDelayMs);
|
|
40
|
+
}
|
|
41
|
+
function newDomainState(domain) {
|
|
42
|
+
return {
|
|
43
|
+
domain,
|
|
44
|
+
backoffUntil: 0,
|
|
45
|
+
crawlDelayUntil: 0,
|
|
46
|
+
backoffDecaysAt: 0,
|
|
47
|
+
consecutive429Count: 0,
|
|
48
|
+
declaredCrawlDelayMs: null,
|
|
49
|
+
rateLimitedSince: 0,
|
|
50
|
+
lastRateLimitedAt: 0,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
const DEFAULT_PERSIST_STATE_KEY = 'CRAWLEE_THROTTLED_DOMAINS';
|
|
29
54
|
/**
|
|
30
55
|
* A request manager that wraps another one and paces requests per domain.
|
|
31
56
|
*
|
|
32
|
-
* Requests for
|
|
33
|
-
*
|
|
34
|
-
* else goes to the wrapped manager untouched.
|
|
57
|
+
* Requests for a throttled domain are routed into their own queue when they are added, so each request lives in
|
|
58
|
+
* exactly one place and deduplication keeps working. Everything else goes to the wrapped manager untouched.
|
|
35
59
|
*
|
|
36
60
|
* {@link ThrottlingRequestManager.fetchNextRequest|`fetchNextRequest()`} serves the domain that has been waiting
|
|
37
61
|
* longest and skips any that are backing off, falling back to the wrapped manager. It never blocks: while every
|
|
38
62
|
* remaining request belongs to a throttled domain it returns `null` and {@link ThrottlingRequestManager.isEmpty}
|
|
39
63
|
* reports `true`, so the crawler idles instead of holding a concurrency slot open.
|
|
40
64
|
*
|
|
41
|
-
*
|
|
42
|
-
* - HTTP 429 responses
|
|
43
|
-
*
|
|
65
|
+
* Each throttled domain runs two independent clocks, and may be dispatched to once **both** have run out:
|
|
66
|
+
* - **Backoff**, set by HTTP 429 responses - honouring `Retry-After`, and otherwise doubling from `baseDelaySecs`.
|
|
67
|
+
* Reactive and temporary: it decays once the domain stops turning us away. The crawlers report the 429s
|
|
68
|
+
* themselves; a request held back this way is retried later without counting against `maxRequestRetries` and
|
|
44
69
|
* without penalising its session.
|
|
45
|
-
* -
|
|
70
|
+
* - **Crawl delay**, the minimum interval between two dispatches to the domain, armed after each one. Proactive
|
|
71
|
+
* and constant: whatever the domain's robots.txt asks for, floored by
|
|
72
|
+
* {@link ThrottlingRequestManagerOptions.minCrawlDelaySecs|`minCrawlDelaySecs`}. Either may be absent, in
|
|
73
|
+
* which case the other one is the delay.
|
|
46
74
|
*
|
|
47
|
-
*
|
|
75
|
+
* Which domains get those clocks is {@link ThrottlingRequestManagerOptions.domains|`domains`} - a list, or
|
|
76
|
+
* `'all'` for every domain the crawl encounters.
|
|
48
77
|
*
|
|
49
78
|
* **Example usage:**
|
|
50
79
|
*
|
|
@@ -62,121 +91,230 @@ function throttledUntil(state) {
|
|
|
62
91
|
*/
|
|
63
92
|
export class ThrottlingRequestManager {
|
|
64
93
|
config;
|
|
65
|
-
inner;
|
|
66
|
-
requestManagerOpener;
|
|
67
|
-
baseDelayMs;
|
|
68
|
-
maxDelayMs;
|
|
69
|
-
maxDomainStallMs;
|
|
94
|
+
#inner;
|
|
95
|
+
#requestManagerOpener;
|
|
96
|
+
#baseDelayMs;
|
|
97
|
+
#maxDelayMs;
|
|
98
|
+
#maxDomainStallMs;
|
|
99
|
+
#minCrawlDelayMs;
|
|
100
|
+
#throttlesEveryDomain;
|
|
101
|
+
#throttleBy;
|
|
102
|
+
#maxThrottledDomains;
|
|
103
|
+
#persistStateKey;
|
|
104
|
+
#subManagers = new Map();
|
|
105
|
+
// Not `#private`, unlike the rest: the tests reach for these two.
|
|
70
106
|
domainStates = new Map();
|
|
71
|
-
subManagers = new Map();
|
|
72
107
|
log;
|
|
108
|
+
/** Domains from the `domains` option, which are throttled whether or not the crawl ever visits them. */
|
|
109
|
+
#listedDomains = new Set();
|
|
110
|
+
/**
|
|
111
|
+
* Domains picked up at runtime under `domains: 'all'`. Persisted, because unlike the listed ones there
|
|
112
|
+
* is nothing to rediscover them from at startup - and a sub-queue nobody reopens is a sub-queue whose
|
|
113
|
+
* requests are never crawled.
|
|
114
|
+
*/
|
|
115
|
+
#discoveredDomains = new Set();
|
|
116
|
+
/**
|
|
117
|
+
* Requests currently held by the consumer that came out of the wrapped manager rather than a sub-queue.
|
|
118
|
+
* They have to go back where they came from: routing them by domain would mark them handled in a queue
|
|
119
|
+
* that has never heard of them, leaving the wrapped manager to hand them out over and over.
|
|
120
|
+
*/
|
|
121
|
+
#inFlightFromInner = new Set();
|
|
122
|
+
#domainListStore;
|
|
123
|
+
#lastDomainListWrite = Promise.resolve();
|
|
124
|
+
#queuedDomainListWrite;
|
|
73
125
|
/**
|
|
74
126
|
* Sub-managers are keyed by a stable alias, so with `purgeOnStart` disabled they outlive the process. They
|
|
75
|
-
* must therefore be reopened for every
|
|
127
|
+
* must therefore be reopened for every known domain rather than created on first insert - otherwise a
|
|
76
128
|
* restart sees an empty map, reports the crawl finished, and strands whatever the previous run left in them.
|
|
77
129
|
*/
|
|
78
|
-
subManagersReady;
|
|
130
|
+
#subManagersReady;
|
|
79
131
|
/** Batches still being added in the background; keeps {@link ThrottlingRequestManager.isFinished} honest. */
|
|
80
|
-
inProgressBatchCount = 0;
|
|
81
|
-
warnedAbout = new Set();
|
|
82
|
-
|
|
83
|
-
|
|
132
|
+
#inProgressBatchCount = 0;
|
|
133
|
+
#warnedAbout = new Set();
|
|
134
|
+
/** Whether any domain at all may end up throttled - listed up front, or discovered as the crawl runs. */
|
|
135
|
+
get #throttlingEnabled() {
|
|
136
|
+
return this.#listedDomains.size > 0 || this.#throttlesEveryDomain;
|
|
84
137
|
}
|
|
85
138
|
constructor(options, config = serviceLocator.getConfiguration()) {
|
|
86
139
|
this.config = config;
|
|
87
140
|
parseArgument(options, throttlingRequestManagerOptionsSchema, 'ThrottlingRequestManagerOptions');
|
|
88
|
-
this
|
|
89
|
-
this
|
|
141
|
+
this.#inner = options.inner;
|
|
142
|
+
this.#requestManagerOpener =
|
|
90
143
|
options.requestManagerOpener ??
|
|
91
144
|
((idOrAlias, opts) => RequestQueue.open(idOrAlias, opts));
|
|
92
|
-
this
|
|
93
|
-
this
|
|
94
|
-
this
|
|
145
|
+
this.#baseDelayMs = (options.baseDelaySecs ?? 2) * 1000;
|
|
146
|
+
this.#maxDelayMs = (options.maxDelaySecs ?? 60) * 1000;
|
|
147
|
+
this.#maxDomainStallMs = (options.maxDomainStallSecs ?? 900) * 1000;
|
|
148
|
+
this.#minCrawlDelayMs = (options.minCrawlDelaySecs ?? 0) * 1000;
|
|
149
|
+
this.#throttlesEveryDomain = options.domains === 'all';
|
|
150
|
+
this.#throttleBy = options.throttleBy ?? 'hostname';
|
|
151
|
+
this.#maxThrottledDomains = options.maxThrottledDomains ?? 100;
|
|
152
|
+
this.#persistStateKey = options.persistStateKey ?? DEFAULT_PERSIST_STATE_KEY;
|
|
95
153
|
this.log = serviceLocator.getLogger().child({ prefix: 'ThrottlingRequestManager' });
|
|
96
|
-
for (const domain of options.domains) {
|
|
154
|
+
for (const domain of Array.isArray(options.domains) ? options.domains : []) {
|
|
97
155
|
let hostname;
|
|
98
156
|
try {
|
|
99
157
|
// These are bare hostnames, so they only reach `URL` - and with it IDNA - via a synthetic URL.
|
|
100
|
-
hostname =
|
|
158
|
+
hostname = new URL(`http://${domain}`).hostname;
|
|
101
159
|
}
|
|
102
160
|
catch {
|
|
103
161
|
throw new Error(`"${domain}" is not a valid hostname. The \`domains\` option takes bare hostnames such as ` +
|
|
104
162
|
`"example.com"; an IPv6 address has to be bracketed, as in "[::1]".`);
|
|
105
163
|
}
|
|
106
|
-
this
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
crawlDelayUntil: 0,
|
|
110
|
-
backoffDecaysAt: 0,
|
|
111
|
-
consecutive429Count: 0,
|
|
112
|
-
crawlDelayMs: null,
|
|
113
|
-
rateLimitedSince: 0,
|
|
114
|
-
lastRateLimitedAt: 0,
|
|
115
|
-
});
|
|
164
|
+
const key = this.#domainKey(hostname);
|
|
165
|
+
this.#listedDomains.add(key);
|
|
166
|
+
this.domainStates.set(key, newDomainState(key));
|
|
116
167
|
}
|
|
117
168
|
}
|
|
169
|
+
/**
|
|
170
|
+
* The key a URL's requests are grouped under - one delay clock and one sub-queue per key.
|
|
171
|
+
*
|
|
172
|
+
* @param hostname A hostname as `URL` reports it, so in punycode and possibly with a root dot.
|
|
173
|
+
*/
|
|
174
|
+
#domainKey(hostname) {
|
|
175
|
+
const normalized = normalizeHostname(hostname);
|
|
176
|
+
if (this.#throttleBy === 'hostname') {
|
|
177
|
+
return normalized;
|
|
178
|
+
}
|
|
179
|
+
// No registrable domain to group by for an IP address or a single-label host such as `localhost`,
|
|
180
|
+
// so those stay paced per hostname.
|
|
181
|
+
return getDomain(normalized, { mixedInputs: false }) ?? normalized;
|
|
182
|
+
}
|
|
118
183
|
/** The wrapped manager, holding every request whose domain is not throttled. */
|
|
119
184
|
get innerManager() {
|
|
120
|
-
return this
|
|
185
|
+
return this.#inner;
|
|
121
186
|
}
|
|
122
187
|
/** Warns once about sources that cannot be routed by domain, because their URLs are not known yet. */
|
|
123
|
-
warnIfNotRoutable(requestLike) {
|
|
124
|
-
if ('requestsFromUrl' in requestLike && requestLike.requestsFromUrl !== undefined && this
|
|
188
|
+
#warnIfNotRoutable(requestLike) {
|
|
189
|
+
if ('requestsFromUrl' in requestLike && requestLike.requestsFromUrl !== undefined && this.#throttlingEnabled) {
|
|
125
190
|
// The URL list is only fetched once the owning manager expands it, so we cannot know which domains
|
|
126
191
|
// it covers and cannot route it. Warn instead of silently exempting those URLs from throttling.
|
|
127
|
-
this
|
|
192
|
+
this.#warnOnce('urlListNotRouted', `Requests loaded via \`requestsFromUrl\` cannot be routed to a per-domain queue, because their URLs ` +
|
|
128
193
|
`are not known at insertion time. They will be added to the inner request manager and will not ` +
|
|
129
194
|
`be throttled, even if they belong to a configured domain.`);
|
|
130
195
|
}
|
|
131
196
|
}
|
|
132
|
-
warnOnce(key, message) {
|
|
133
|
-
if (this
|
|
197
|
+
#warnOnce(key, message) {
|
|
198
|
+
if (this.#warnedAbout.has(key)) {
|
|
134
199
|
return;
|
|
135
200
|
}
|
|
136
|
-
this
|
|
201
|
+
this.#warnedAbout.add(key);
|
|
137
202
|
this.log.warning(message);
|
|
138
203
|
}
|
|
139
|
-
extractDomain(url) {
|
|
204
|
+
#extractDomain(url) {
|
|
140
205
|
try {
|
|
141
|
-
return
|
|
206
|
+
return this.#domainKey(new URL(url).hostname);
|
|
142
207
|
}
|
|
143
208
|
catch {
|
|
144
209
|
return '';
|
|
145
210
|
}
|
|
146
211
|
}
|
|
147
|
-
getDomainState(url) {
|
|
148
|
-
const domain = this
|
|
212
|
+
#getDomainState(url) {
|
|
213
|
+
const domain = this.#extractDomain(url);
|
|
149
214
|
return this.domainStates.get(domain) ?? null;
|
|
150
215
|
}
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
216
|
+
/**
|
|
217
|
+
* The manager that owns a URL's requests, opening a sub-queue for its domain if this is the first time we
|
|
218
|
+
* have seen it and every domain is throttled. `null` means the domain is new and `maxThrottledDomains`
|
|
219
|
+
* leaves no room for it.
|
|
220
|
+
*/
|
|
221
|
+
async #selectManager(url) {
|
|
222
|
+
await this.#ensureSubManagers();
|
|
223
|
+
const domain = this.#extractDomain(url);
|
|
224
|
+
if (!domain || !(this.#listedDomains.has(domain) || this.#throttlesEveryDomain)) {
|
|
225
|
+
return this.#inner;
|
|
226
|
+
}
|
|
227
|
+
if (!this.#listedDomains.has(domain) && !this.#discoveredDomains.has(domain)) {
|
|
228
|
+
if (this.#discoveredDomains.size >= this.#maxThrottledDomains) {
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
this.#discoveredDomains.add(domain);
|
|
232
|
+
// Written before the request lands in the sub-queue: a crash in between would leave that queue
|
|
233
|
+
// with nothing to reopen it, and the crawl would silently drop everything in it.
|
|
234
|
+
await this.#persistDiscoveredDomains();
|
|
235
|
+
}
|
|
236
|
+
return this.#subManagerFor(domain);
|
|
237
|
+
}
|
|
238
|
+
async #selectManagerOrThrow(url) {
|
|
239
|
+
const manager = await this.#selectManager(url);
|
|
240
|
+
if (!manager) {
|
|
241
|
+
throw this.#throttledDomainLimitError([this.#extractDomain(url)]);
|
|
242
|
+
}
|
|
243
|
+
return manager;
|
|
244
|
+
}
|
|
245
|
+
#throttledDomainLimitError(domains) {
|
|
246
|
+
const [first, ...rest] = domains;
|
|
247
|
+
const named = rest.slice(0, 10);
|
|
248
|
+
const ellipsis = rest.length > named.length ? ', ...' : '';
|
|
249
|
+
const others = rest.length > 0 ? ` (and ${rest.length} other new domain(s): ${named.join(', ')}${ellipsis})` : '';
|
|
250
|
+
return new Error(`Refusing to throttle "${first}"${others}: ${this.#maxThrottledDomains} domains are already being ` +
|
|
251
|
+
`throttled (\`maxThrottledDomains\`). Each of them holds a request queue of its own, so a crawl ` +
|
|
252
|
+
`that keeps discovering new domains will bury the storage backend in them. Narrow the crawl down, ` +
|
|
253
|
+
`pace it with \`maxRequestsPerMinute\` instead, or raise \`maxThrottledDomains\` if you are ` +
|
|
254
|
+
`prepared to pay for it.`);
|
|
255
|
+
}
|
|
256
|
+
/** Opens the domain's sub-queue, or returns the one already opened (or being opened) for it. */
|
|
257
|
+
#subManagerFor(domain) {
|
|
258
|
+
let subManager = this.#subManagers.get(domain);
|
|
259
|
+
if (!subManager) {
|
|
260
|
+
subManager = this.#requestManagerOpener(
|
|
261
|
+
// Backends use the alias as a directory name, and an IPv6 literal is full of characters
|
|
262
|
+
// Windows will not accept. Ordinary hostnames survive this untouched.
|
|
263
|
+
{ alias: `throttled-${encodeURIComponent(domain)}` }, { configuration: this.config });
|
|
264
|
+
this.#subManagers.set(domain, subManager);
|
|
265
|
+
this.#ensureDomainState(domain);
|
|
266
|
+
}
|
|
267
|
+
return subManager;
|
|
268
|
+
}
|
|
269
|
+
#ensureDomainState(domain) {
|
|
270
|
+
let state = this.domainStates.get(domain);
|
|
271
|
+
if (!state) {
|
|
272
|
+
state = newDomainState(domain);
|
|
273
|
+
this.domainStates.set(domain, state);
|
|
274
|
+
}
|
|
275
|
+
return state;
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Coalesces the writes of the discovered domain list: callers that arrive while one is in flight share the
|
|
279
|
+
* single write queued behind it, which snapshots the set once it starts and so covers all of them.
|
|
280
|
+
*/
|
|
281
|
+
async #persistDiscoveredDomains() {
|
|
282
|
+
this.#queuedDomainListWrite ??= this.#lastDomainListWrite
|
|
283
|
+
// A failed write must not poison the ones behind it - they will rewrite the whole set anyway.
|
|
284
|
+
.catch(() => { })
|
|
285
|
+
.then(async () => {
|
|
286
|
+
this.#queuedDomainListWrite = undefined;
|
|
287
|
+
await this.#domainListStore.setValue(this.#persistStateKey, Array.from(this.#discoveredDomains));
|
|
288
|
+
});
|
|
289
|
+
this.#lastDomainListWrite = this.#queuedDomainListWrite;
|
|
290
|
+
await this.#queuedDomainListWrite;
|
|
291
|
+
}
|
|
292
|
+
async #ensureSubManagers() {
|
|
293
|
+
this.#subManagersReady ??= (async () => {
|
|
294
|
+
if (this.#throttlesEveryDomain) {
|
|
295
|
+
this.#domainListStore = await KeyValueStore.open(null, { configuration: this.config });
|
|
296
|
+
for (const domain of (await this.#domainListStore.getValue(this.#persistStateKey)) ?? []) {
|
|
297
|
+
this.#discoveredDomains.add(domain);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
await Promise.all(Array.from([...this.#listedDomains, ...this.#discoveredDomains], async (domain) => this.#subManagerFor(domain)));
|
|
168
301
|
})();
|
|
169
|
-
await this
|
|
302
|
+
await this.#subManagersReady;
|
|
170
303
|
}
|
|
171
|
-
async getSubManagers() {
|
|
172
|
-
await this
|
|
173
|
-
return
|
|
304
|
+
async #getSubManagers() {
|
|
305
|
+
await this.#ensureSubManagers();
|
|
306
|
+
return Promise.all(this.#subManagers.values());
|
|
174
307
|
}
|
|
175
|
-
/**
|
|
176
|
-
|
|
308
|
+
/**
|
|
309
|
+
* Throttled domains that are not currently backing off, longest-overdue first.
|
|
310
|
+
*
|
|
311
|
+
* A domain whose sub-queue has not been opened yet is skipped - a robots.txt `Crawl-delay` gives a domain a
|
|
312
|
+
* clock before its first request gives it a queue, and there is nothing to fetch from until then.
|
|
313
|
+
*/
|
|
314
|
+
#fetchableDomains() {
|
|
177
315
|
const now = Date.now();
|
|
178
316
|
return Array.from(this.domainStates.values())
|
|
179
|
-
.filter((state) => now >= throttledUntil(state))
|
|
317
|
+
.filter((state) => now >= throttledUntil(state) && this.#subManagers.has(state.domain))
|
|
180
318
|
.sort((a, b) => throttledUntil(a) - throttledUntil(b))
|
|
181
319
|
.map((state) => state.domain);
|
|
182
320
|
}
|
|
@@ -186,7 +324,7 @@ export class ThrottlingRequestManager {
|
|
|
186
324
|
* @returns `false` if the domain is not configured for throttling, in which case this is a no-op.
|
|
187
325
|
*/
|
|
188
326
|
recordDomainDelay(url, retryAfterMs) {
|
|
189
|
-
const state = this
|
|
327
|
+
const state = this.#getDomainState(url);
|
|
190
328
|
if (!state) {
|
|
191
329
|
return false;
|
|
192
330
|
}
|
|
@@ -211,13 +349,13 @@ export class ThrottlingRequestManager {
|
|
|
211
349
|
}
|
|
212
350
|
state.consecutive429Count += 1;
|
|
213
351
|
const retryAfterGiven = retryAfterMs !== undefined && retryAfterMs !== null;
|
|
214
|
-
let delayMs = retryAfterGiven ? retryAfterMs : this
|
|
215
|
-
if (delayMs > this
|
|
352
|
+
let delayMs = retryAfterGiven ? retryAfterMs : this.#baseDelayMs * Math.pow(2, state.consecutive429Count - 1);
|
|
353
|
+
if (delayMs > this.#maxDelayMs) {
|
|
216
354
|
const source = retryAfterGiven ? 'Retry-After header' : 'exponential backoff';
|
|
217
355
|
this.log.warning(`Capping ${source} delay of ${(delayMs / 1000).toFixed(1)}s for domain "${state.domain}" ` +
|
|
218
|
-
`to maxDelaySecs (${(this
|
|
356
|
+
`to maxDelaySecs (${(this.#maxDelayMs / 1000).toFixed(1)}s); the domain may continue to rate-limit. ` +
|
|
219
357
|
`Consider increasing maxDelaySecs if this recurs.`);
|
|
220
|
-
delayMs = this
|
|
358
|
+
delayMs = this.#maxDelayMs;
|
|
221
359
|
}
|
|
222
360
|
state.backoffUntil = now + delayMs;
|
|
223
361
|
state.backoffDecaysAt = state.backoffUntil + delayMs;
|
|
@@ -226,19 +364,23 @@ export class ThrottlingRequestManager {
|
|
|
226
364
|
return true;
|
|
227
365
|
}
|
|
228
366
|
/**
|
|
229
|
-
*
|
|
367
|
+
* Records the `Crawl-delay` a domain's robots.txt asked for, which becomes its crawl delay unless
|
|
368
|
+
* {@link ThrottlingRequestManagerOptions.minCrawlDelaySecs|`minCrawlDelaySecs`} asks for longer.
|
|
230
369
|
*
|
|
231
370
|
* The first value wins, so a robots.txt re-fetch cannot change the cadence mid-crawl.
|
|
232
371
|
*
|
|
233
|
-
* @returns `false` if the domain is not
|
|
372
|
+
* @returns `false` if the domain is not throttled, in which case this is a no-op.
|
|
234
373
|
*/
|
|
235
374
|
setCrawlDelay(url, delaySeconds) {
|
|
236
|
-
const
|
|
237
|
-
if (!
|
|
375
|
+
const domain = this.#extractDomain(url);
|
|
376
|
+
if (!domain || !(this.#listedDomains.has(domain) || this.#throttlesEveryDomain)) {
|
|
238
377
|
return false;
|
|
239
378
|
}
|
|
240
|
-
|
|
241
|
-
|
|
379
|
+
// The crawler reads robots.txt before it enqueues a domain's first request, so the clock can predate
|
|
380
|
+
// the sub-queue it will end up pacing.
|
|
381
|
+
const state = this.#ensureDomainState(domain);
|
|
382
|
+
if (state.declaredCrawlDelayMs === null) {
|
|
383
|
+
state.declaredCrawlDelayMs = delaySeconds * 1000;
|
|
242
384
|
this.log.debug(`Set crawl-delay for domain "${state.domain}" to ${delaySeconds}s`);
|
|
243
385
|
}
|
|
244
386
|
return true;
|
|
@@ -253,16 +395,19 @@ export class ThrottlingRequestManager {
|
|
|
253
395
|
* `Crawl-delay` is being obeyed, not stonewalled.
|
|
254
396
|
*/
|
|
255
397
|
async assertNoStalledDomains() {
|
|
256
|
-
await this
|
|
398
|
+
await this.#ensureSubManagers();
|
|
257
399
|
const now = Date.now();
|
|
258
400
|
const candidates = Array.from(this.domainStates.values()).filter(
|
|
259
401
|
// Together: it is still turning us away, and has been doing so without a break for longer than the
|
|
260
402
|
// window. A domain that has simply been idle starts this clock at its first 429 rather than
|
|
261
403
|
// arriving with the idle time already on it.
|
|
262
404
|
(state) => state.rateLimitedSince !== 0 &&
|
|
263
|
-
now - state.lastRateLimitedAt <= this
|
|
264
|
-
now - state.rateLimitedSince > this
|
|
265
|
-
const stalled = (await Promise.all(candidates.map(async (state) =>
|
|
405
|
+
now - state.lastRateLimitedAt <= this.#maxDomainStallMs &&
|
|
406
|
+
now - state.rateLimitedSince > this.#maxDomainStallMs);
|
|
407
|
+
const stalled = (await Promise.all(candidates.map(async (state) => {
|
|
408
|
+
const subManager = await this.#subManagers.get(state.domain);
|
|
409
|
+
return subManager && !(await subManager.isEmpty()) ? state : null;
|
|
410
|
+
}))).filter((state) => state !== null);
|
|
266
411
|
if (stalled.length === 0) {
|
|
267
412
|
return;
|
|
268
413
|
}
|
|
@@ -270,21 +415,21 @@ export class ThrottlingRequestManager {
|
|
|
270
415
|
.map((state) => `"${state.domain}" (${((now - state.rateLimitedSince) / 1000).toFixed(0)}s)`)
|
|
271
416
|
.join(', ');
|
|
272
417
|
throw new PersistentRateLimitError(`Giving up: ${summary} rate-limited every request for longer than maxDomainStallSecs ` +
|
|
273
|
-
`(${(this
|
|
418
|
+
`(${(this.#maxDomainStallMs / 1000).toFixed(0)}s). Waiting longer will not help - lower the ` +
|
|
274
419
|
`crawler's concurrency, or drop these domains. Their requests are still queued, so re-running ` +
|
|
275
|
-
`
|
|
420
|
+
`with \`purgeOnStart\` disabled will resume them if the rate limit lifts.`);
|
|
276
421
|
}
|
|
277
422
|
/** Records that a domain let a request through, which ends any rate-limit run stall detection was timing. */
|
|
278
|
-
recordProgress(url) {
|
|
279
|
-
const state = this
|
|
423
|
+
#recordProgress(url) {
|
|
424
|
+
const state = this.#getDomainState(url);
|
|
280
425
|
if (state) {
|
|
281
426
|
state.rateLimitedSince = 0;
|
|
282
427
|
}
|
|
283
428
|
}
|
|
284
429
|
// --- IRequestManager Implementation ---
|
|
285
430
|
async addRequest(requestLike, options) {
|
|
286
|
-
this
|
|
287
|
-
const manager = await this
|
|
431
|
+
this.#warnIfNotRoutable(requestLike);
|
|
432
|
+
const manager = await this.#selectManagerOrThrow(requestLike.url ?? '');
|
|
288
433
|
return manager.addRequest(requestLike, options);
|
|
289
434
|
}
|
|
290
435
|
/**
|
|
@@ -295,7 +440,7 @@ export class ThrottlingRequestManager {
|
|
|
295
440
|
* iterable is never fully materialized.
|
|
296
441
|
*/
|
|
297
442
|
async addRequestsBatched(requests, options = {}) {
|
|
298
|
-
await this
|
|
443
|
+
await this.#ensureSubManagers();
|
|
299
444
|
// Normalized up front so the shared batching helper - and `requestsOverLimit` - only ever see `Source`.
|
|
300
445
|
async function* iterateRequests() {
|
|
301
446
|
for await (const request of asyncifyIterable(requests)) {
|
|
@@ -312,9 +457,16 @@ export class ThrottlingRequestManager {
|
|
|
312
457
|
// deduplication themselves.
|
|
313
458
|
processChunk: async (chunk) => {
|
|
314
459
|
const byManager = new Map();
|
|
460
|
+
// Collected, not thrown on sight, so an overflow does not discard the requests that fit.
|
|
461
|
+
const overflowing = new Set();
|
|
315
462
|
for (const request of chunk) {
|
|
316
|
-
this
|
|
317
|
-
const
|
|
463
|
+
this.#warnIfNotRoutable(request);
|
|
464
|
+
const url = request.url ?? '';
|
|
465
|
+
const manager = await this.#selectManager(url);
|
|
466
|
+
if (!manager) {
|
|
467
|
+
overflowing.add(this.#extractDomain(url));
|
|
468
|
+
continue;
|
|
469
|
+
}
|
|
318
470
|
const bucket = byManager.get(manager);
|
|
319
471
|
if (bucket) {
|
|
320
472
|
bucket.push(request);
|
|
@@ -329,35 +481,49 @@ export class ThrottlingRequestManager {
|
|
|
329
481
|
batchSize: slice.length,
|
|
330
482
|
waitForAllRequestsToBeAdded: true,
|
|
331
483
|
})));
|
|
484
|
+
if (overflowing.size > 0) {
|
|
485
|
+
throw this.#throttledDomainLimitError(Array.from(overflowing));
|
|
486
|
+
}
|
|
332
487
|
return results.flatMap((result) => result.addedRequests);
|
|
333
488
|
},
|
|
334
489
|
// Keeps the crawler from concluding it is finished while batches are still landing.
|
|
335
490
|
trackBackgroundBatches: (batches) => {
|
|
336
|
-
this
|
|
491
|
+
this.#inProgressBatchCount += 1;
|
|
337
492
|
void batches.finally(() => {
|
|
338
|
-
this
|
|
493
|
+
this.#inProgressBatchCount -= 1;
|
|
339
494
|
});
|
|
340
495
|
},
|
|
341
496
|
});
|
|
342
497
|
}
|
|
343
498
|
async reclaimRequest(request, options) {
|
|
344
|
-
const manager = await this
|
|
499
|
+
const manager = await this.#managerHolding(request);
|
|
345
500
|
return manager.reclaimRequest(request, options);
|
|
346
501
|
}
|
|
347
502
|
async markRequestAsHandled(request) {
|
|
348
|
-
const manager = await this
|
|
503
|
+
const manager = await this.#managerHolding(request);
|
|
349
504
|
// Reached whether the request succeeded or ran out of retries; either way the domain answered us.
|
|
350
|
-
this
|
|
505
|
+
this.#recordProgress(request.url);
|
|
351
506
|
return manager.markRequestAsHandled(request);
|
|
352
507
|
}
|
|
508
|
+
/**
|
|
509
|
+
* The manager a request in the consumer's hands has to be given back to - the one it was fetched from,
|
|
510
|
+
* which is only the same as the one its domain routes to if it was routed in the first place.
|
|
511
|
+
*/
|
|
512
|
+
async #managerHolding(request) {
|
|
513
|
+
const key = request.id ?? request.uniqueKey;
|
|
514
|
+
if (this.#inFlightFromInner.delete(key)) {
|
|
515
|
+
return this.#inner;
|
|
516
|
+
}
|
|
517
|
+
return this.#selectManagerOrThrow(request.url);
|
|
518
|
+
}
|
|
353
519
|
async getTotalCount() {
|
|
354
|
-
return this
|
|
520
|
+
return this.#sumOverManagers((manager) => manager.getTotalCount());
|
|
355
521
|
}
|
|
356
522
|
async getPendingCount() {
|
|
357
|
-
return this
|
|
523
|
+
return this.#sumOverManagers((manager) => manager.getPendingCount());
|
|
358
524
|
}
|
|
359
525
|
async getHandledCount() {
|
|
360
|
-
return this
|
|
526
|
+
return this.#sumOverManagers((manager) => manager.getHandledCount());
|
|
361
527
|
}
|
|
362
528
|
/**
|
|
363
529
|
* Whether the next {@link ThrottlingRequestManager.fetchNextRequest} would return `null`.
|
|
@@ -366,24 +532,35 @@ export class ThrottlingRequestManager {
|
|
|
366
532
|
* this idles for the backoff instead of spinning on a fetch that cannot succeed yet.
|
|
367
533
|
*/
|
|
368
534
|
async isEmpty() {
|
|
369
|
-
await this
|
|
370
|
-
const fetchable =
|
|
371
|
-
const results = await Promise.all(fetchable.map((manager) => manager.isEmpty()));
|
|
535
|
+
await this.#ensureSubManagers();
|
|
536
|
+
const fetchable = await Promise.all(this.#fetchableDomains().map(async (domain) => this.#subManagers.get(domain)));
|
|
537
|
+
const results = await Promise.all([this.#inner, ...fetchable].map(async (manager) => manager.isEmpty()));
|
|
372
538
|
return results.every(Boolean);
|
|
373
539
|
}
|
|
374
540
|
/** Unlike {@link ThrottlingRequestManager.isEmpty}, throttled requests still count as outstanding work. */
|
|
375
541
|
async isFinished() {
|
|
376
|
-
if (this
|
|
542
|
+
if (this.#inProgressBatchCount > 0) {
|
|
377
543
|
return false;
|
|
378
544
|
}
|
|
379
|
-
return this
|
|
545
|
+
return this.#everyManager((manager) => manager.isFinished());
|
|
380
546
|
}
|
|
381
547
|
/**
|
|
382
548
|
* Empties every manager and clears the accumulated backoff. A robots.txt `Crawl-delay` is a property of the
|
|
383
549
|
* site rather than of the run, so it survives.
|
|
384
550
|
*/
|
|
385
551
|
async purge() {
|
|
386
|
-
await this.
|
|
552
|
+
await this.#inner.purge?.();
|
|
553
|
+
await this.purgeDomainQueues();
|
|
554
|
+
}
|
|
555
|
+
/**
|
|
556
|
+
* Empties the per-domain queues, leaving the wrapped manager alone.
|
|
557
|
+
*
|
|
558
|
+
* Those queues are this manager's own no matter who owns the one it wraps, which is what makes this safe to
|
|
559
|
+
* call where a full {@link ThrottlingRequestManager.purge|`purge()`} would not be.
|
|
560
|
+
*/
|
|
561
|
+
async purgeDomainQueues() {
|
|
562
|
+
const subManagers = await this.#getSubManagers();
|
|
563
|
+
await Promise.all(subManagers.map(async (manager) => manager.purge?.()));
|
|
387
564
|
for (const state of this.domainStates.values()) {
|
|
388
565
|
state.consecutive429Count = 0;
|
|
389
566
|
state.backoffUntil = 0;
|
|
@@ -394,18 +571,18 @@ export class ThrottlingRequestManager {
|
|
|
394
571
|
}
|
|
395
572
|
}
|
|
396
573
|
async setExpectedRequestProcessingTimeSecs(secs) {
|
|
397
|
-
await this
|
|
574
|
+
await this.#forEachManager((manager) => manager.setExpectedRequestProcessingTimeSecs?.(secs));
|
|
398
575
|
}
|
|
399
|
-
async forEachManager(fn) {
|
|
576
|
+
async #forEachManager(fn) {
|
|
400
577
|
// `fn` targets optional members, so it may return nothing - the wrapper normalizes that for `Promise.all`.
|
|
401
|
-
await Promise.all([this
|
|
578
|
+
await Promise.all([this.#inner, ...(await this.#getSubManagers())].map(async (manager) => fn(manager)));
|
|
402
579
|
}
|
|
403
|
-
async sumOverManagers(fn) {
|
|
404
|
-
const counts = await Promise.all([this
|
|
580
|
+
async #sumOverManagers(fn) {
|
|
581
|
+
const counts = await Promise.all([this.#inner, ...(await this.#getSubManagers())].map(fn));
|
|
405
582
|
return counts.reduce((a, b) => a + b, 0);
|
|
406
583
|
}
|
|
407
|
-
async everyManager(fn) {
|
|
408
|
-
const results = await Promise.all([this
|
|
584
|
+
async #everyManager(fn) {
|
|
585
|
+
const results = await Promise.all([this.#inner, ...(await this.#getSubManagers())].map(fn));
|
|
409
586
|
return results.every(Boolean);
|
|
410
587
|
}
|
|
411
588
|
/**
|
|
@@ -417,24 +594,38 @@ export class ThrottlingRequestManager {
|
|
|
417
594
|
* reports `true` meanwhile so the crawler's task loop idles rather than spins.
|
|
418
595
|
*/
|
|
419
596
|
async fetchNextRequest() {
|
|
420
|
-
await this
|
|
421
|
-
for (const domain of this
|
|
597
|
+
await this.#ensureSubManagers();
|
|
598
|
+
for (const domain of this.#fetchableDomains()) {
|
|
422
599
|
const state = this.domainStates.get(domain);
|
|
423
600
|
// Armed while the fetch below is still suspended, so that a concurrent `fetchNextRequest` cannot
|
|
424
601
|
// find the domain fetchable and dispatch into the same window - which would pace each task
|
|
425
602
|
// rather than the domain.
|
|
426
|
-
const
|
|
427
|
-
|
|
428
|
-
|
|
603
|
+
const crawlDelayUntilBefore = state.crawlDelayUntil;
|
|
604
|
+
const delayMs = crawlDelayMs(state, this.#minCrawlDelayMs);
|
|
605
|
+
if (delayMs > 0) {
|
|
606
|
+
state.crawlDelayUntil = Date.now() + delayMs;
|
|
429
607
|
}
|
|
430
|
-
const request = await this
|
|
608
|
+
const request = await (await this.#subManagers.get(domain)).fetchNextRequest();
|
|
431
609
|
if (request) {
|
|
432
610
|
return request;
|
|
433
611
|
}
|
|
434
612
|
// No dispatch to pace, so the domain keeps its slot.
|
|
435
|
-
state.crawlDelayUntil =
|
|
613
|
+
state.crawlDelayUntil = crawlDelayUntilBefore;
|
|
614
|
+
}
|
|
615
|
+
const request = await this.#inner.fetchNextRequest();
|
|
616
|
+
if (request !== null) {
|
|
617
|
+
this.#inFlightFromInner.add(request.id ?? request.uniqueKey);
|
|
618
|
+
}
|
|
619
|
+
if (request !== null && this.#throttlesEveryDomain) {
|
|
620
|
+
// Requests that were never routed by domain - a `RequestList`, a `requestsFromUrl` expansion - are
|
|
621
|
+
// handed out as fast as the crawler asks for them, because there is no per-domain queue to hold
|
|
622
|
+
// them back in.
|
|
623
|
+
this.#warnOnce('innerNotThrottled', `Requests read directly from the wrapped request manager (for instance from a \`RequestList\` or a ` +
|
|
624
|
+
`\`requestsFromUrl\` list) are not throttled, because they are not stored per domain. Enqueue ` +
|
|
625
|
+
`them through the crawler - \`crawler.run(requests)\` or \`crawler.addRequests()\` - to have ` +
|
|
626
|
+
`their domains paced.`);
|
|
436
627
|
}
|
|
437
|
-
return
|
|
628
|
+
return request;
|
|
438
629
|
}
|
|
439
630
|
async *[Symbol.asyncIterator]() {
|
|
440
631
|
while (true) {
|
|
@@ -445,11 +636,11 @@ export class ThrottlingRequestManager {
|
|
|
445
636
|
}
|
|
446
637
|
}
|
|
447
638
|
async persistState() {
|
|
448
|
-
await this
|
|
639
|
+
await this.#forEachManager((manager) => manager.persistState?.());
|
|
449
640
|
}
|
|
450
641
|
async drop() {
|
|
451
|
-
await this
|
|
452
|
-
this
|
|
453
|
-
this
|
|
642
|
+
await this.#forEachManager((manager) => manager.drop?.());
|
|
643
|
+
this.#subManagers.clear();
|
|
644
|
+
this.#subManagersReady = undefined;
|
|
454
645
|
}
|
|
455
646
|
}
|