@crawlee/basic 4.0.0-beta.98 → 4.0.0-rc.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.d.ts +1 -1
- package/internals/basic-crawler.d.ts +179 -150
- package/internals/basic-crawler.js +495 -313
- package/internals/send-request.d.ts +2 -1
- package/internals/send-request.js +1 -0
- package/package.json +9 -10
|
@@ -1,21 +1,21 @@
|
|
|
1
|
-
import { writeFile } from 'node:fs/promises';
|
|
1
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
2
2
|
import { dirname } from 'node:path';
|
|
3
|
-
import { AutoscaledPool, bindMethodsToServiceLocator, BLOCKED_STATUS_CODES, ConcurrencySystem, ContextPipeline, ContextPipelineCleanupError, ContextPipelineInitializationError, ContextPipelineInterruptedError, CriticalError,
|
|
4
|
-
import { FetchHttpClient } from '@crawlee/http-client';
|
|
5
|
-
import { isAsyncIterable, isIterable,
|
|
6
|
-
import {
|
|
7
|
-
import { ensureDir, writeJSON } from 'fs-extra/esm';
|
|
8
|
-
import ow, { ArgumentError } from 'ow';
|
|
3
|
+
import { applyRequestTransform, AutoscaledPool, bindMethodsToServiceLocator, BLOCKED_STATUS_CODES, buildEnqueueStrategyPatterns, ConcurrencySystem, Configuration, constructUrlPatternObjects, ContextPipeline, ContextPipelineCleanupError, ContextPipelineInitializationError, ContextPipelineInterruptedError, createRequestOptions, createStorageTransaction, Request, CriticalError, currentStorageTransaction, Dataset, EnqueueStrategy, EventManager, EventType, filterRequestOptionsByPatterns, getObjectType, KeyValueStore, log, LogLevel, mergeCookies, MissingSessionError, NavigationSkippedError, NonRetryableError, OwnedOrInjected, parseArgument, purgeDefaultStorages, RequestHandlerError, parseRetryAfterHeader, RequestThrottledError, RequestManagerTandem, RequestQueue, RequestState, RetryRequestError, supportsDomainThrottling, Router, schemas, ServiceLocator, serviceLocator, Session, SessionError, SessionPool, Statistics, ThrottlingRequestManager, validateUserData, validators, withDirectStorageAccess, } from '@crawlee/core';
|
|
4
|
+
import { BaseHttpClient, FetchHttpClient } from '@crawlee/http-client';
|
|
5
|
+
import { isAsyncIterable, isIterable, ROTATE_PROXY_ERRORS } from '@crawlee/utils/internal';
|
|
6
|
+
import { RobotsTxtFile } from '@crawlee/utils';
|
|
9
7
|
import { getDomain } from 'tldts';
|
|
8
|
+
import { z } from 'zod';
|
|
10
9
|
import { LruCache } from '@apify/datastructures';
|
|
11
10
|
import { addTimeoutToPromise, extendTimeout, TimeoutError } from '@apify/timeout';
|
|
12
11
|
import { cryptoRandomObjectId } from '@apify/utilities';
|
|
13
12
|
import { extendTimeoutKey, navigationDeadlineKey, raceWithTimeout, timeoutExpiredKey, } from './request-timeout.js';
|
|
14
13
|
import { createSendRequest } from './send-request.js';
|
|
15
|
-
class LazyDefaultHttpClient {
|
|
16
|
-
|
|
14
|
+
class LazyDefaultHttpClient extends BaseHttpClient {
|
|
15
|
+
#delegatePromise;
|
|
17
16
|
constructor(options) {
|
|
18
|
-
|
|
17
|
+
super(options);
|
|
18
|
+
this.#delegatePromise = import('@crawlee/impit-client')
|
|
19
19
|
.then(({ ImpitHttpClient }) => new ImpitHttpClient(options))
|
|
20
20
|
.catch(() => {
|
|
21
21
|
(options?.logger ?? log).warning('Optional dependency @crawlee/impit-client is not installed. ' +
|
|
@@ -23,8 +23,11 @@ class LazyDefaultHttpClient {
|
|
|
23
23
|
return new FetchHttpClient(options);
|
|
24
24
|
});
|
|
25
25
|
}
|
|
26
|
+
fetch() {
|
|
27
|
+
throw new Error('LazyDefaultHttpClient delegates `sendRequest` entirely; `fetch` is never called.');
|
|
28
|
+
}
|
|
26
29
|
async sendRequest(...args) {
|
|
27
|
-
return (await this
|
|
30
|
+
return (await this.#delegatePromise).sendRequest(...args);
|
|
28
31
|
}
|
|
29
32
|
}
|
|
30
33
|
/**
|
|
@@ -40,6 +43,33 @@ const SAFE_MIGRATION_WAIT_MILLIS = 20000;
|
|
|
40
43
|
const deferredCleanupKey = Symbol('deferredCleanup');
|
|
41
44
|
// The request timeout plumbing (the window helper, the context symbols, and the race) lives in its own module.
|
|
42
45
|
export { navigationDeadlineKey, remainingNavigationWindowMillis } from './request-timeout.js';
|
|
46
|
+
const urlPatternSchema = z.union([
|
|
47
|
+
z.string(),
|
|
48
|
+
z.instanceof(RegExp),
|
|
49
|
+
schemas.objectWithKeys(['glob']),
|
|
50
|
+
schemas.objectWithKeys(['regexp']),
|
|
51
|
+
]);
|
|
52
|
+
// `looseObject` (rather than `strictObject`) lets subclasses forward their own extraction-only options
|
|
53
|
+
// (e.g. `selector`) straight through without having to strip them out first.
|
|
54
|
+
const addRequestsOptionsSchema = z.looseObject({
|
|
55
|
+
forefront: z.boolean().optional(),
|
|
56
|
+
cache: z.boolean().optional(),
|
|
57
|
+
waitForAllRequestsToBeAdded: z.boolean().optional(),
|
|
58
|
+
batchSize: schemas.anyNumber.optional(),
|
|
59
|
+
waitBetweenBatchesMillis: schemas.anyNumber.optional(),
|
|
60
|
+
maxNewRequests: schemas.anyNumber.optional(),
|
|
61
|
+
limit: schemas.anyNumber.optional(),
|
|
62
|
+
baseUrl: z.string().optional(),
|
|
63
|
+
userData: schemas.anyObject.optional(),
|
|
64
|
+
label: z.string().optional(),
|
|
65
|
+
sessionId: z.string().optional(),
|
|
66
|
+
skipNavigation: z.boolean().optional(),
|
|
67
|
+
include: schemas.arrayOf(urlPatternSchema, 'URL patterns').min(1).optional(),
|
|
68
|
+
exclude: schemas.arrayOf(urlPatternSchema, 'URL patterns').optional(),
|
|
69
|
+
transformRequestFunction: schemas.anyFunction.optional(),
|
|
70
|
+
strategy: z.enum(EnqueueStrategy).optional(),
|
|
71
|
+
onSkippedRequest: schemas.anyFunction.optional(),
|
|
72
|
+
});
|
|
43
73
|
export class BasicCrawler {
|
|
44
74
|
static CRAWLEE_STATE_KEY = 'CRAWLEE_STATE';
|
|
45
75
|
/**
|
|
@@ -47,16 +77,22 @@ export class BasicCrawler {
|
|
|
47
77
|
* request queue; subsequent ones get their own queue via a unique alias so they don't
|
|
48
78
|
* collide.
|
|
49
79
|
*/
|
|
80
|
+
// kept as TS-private: tests reset the counter at runtime
|
|
50
81
|
static instanceCount = 0;
|
|
51
82
|
/**
|
|
52
83
|
* Tracks crawler instances that accessed shared state without having an explicit id.
|
|
53
84
|
* Used to detect and warn about multiple crawlers sharing the same state.
|
|
54
85
|
*/
|
|
55
|
-
static useStateAnonymousIndices = new Set();
|
|
86
|
+
static #useStateAnonymousIndices = new Set();
|
|
87
|
+
/** Backs the {@link BasicCrawler.statistics|`statistics`} getter. */
|
|
88
|
+
#statisticsDep;
|
|
56
89
|
/**
|
|
57
|
-
*
|
|
90
|
+
* The statistics instance collecting the crawler's run statistics - either the injected `statistics` option or a
|
|
91
|
+
* crawler-built default. Typed as {@link IStatistics} so custom implementations can be plugged in.
|
|
58
92
|
*/
|
|
59
|
-
|
|
93
|
+
get statistics() {
|
|
94
|
+
return this.#statisticsDep.value;
|
|
95
|
+
}
|
|
60
96
|
/**
|
|
61
97
|
* The main request-handling component of the crawler. It manages the requests that the crawler processes,
|
|
62
98
|
* combining any provided request loader and/or queue. It's initialized during the crawler startup or lazily
|
|
@@ -64,34 +100,34 @@ export class BasicCrawler {
|
|
|
64
100
|
*/
|
|
65
101
|
requestManager;
|
|
66
102
|
/** Backs the {@link BasicCrawler.sessionPool|`sessionPool`} getter. */
|
|
67
|
-
sessionPoolDep;
|
|
103
|
+
#sessionPoolDep;
|
|
68
104
|
/**
|
|
69
105
|
* A reference to the underlying session pool that manages the crawler's {@link Session|sessions}. Typed as
|
|
70
106
|
* {@link ISessionPool} so custom implementations can be plugged in via the `sessionPool` constructor option.
|
|
71
107
|
*/
|
|
72
108
|
get sessionPool() {
|
|
73
|
-
return this
|
|
109
|
+
return this.#sessionPoolDep.value;
|
|
74
110
|
}
|
|
75
111
|
/**
|
|
76
112
|
* Tracks **only** the queue the crawler opens for itself — not the {@link RequestManagerTandem} that may wrap it
|
|
77
113
|
* around a user-supplied `requestList` — so the owned-only purge between repeated `run()` calls never reaches
|
|
78
114
|
* through to a borrowed loader. Filled lazily in {@link BasicCrawler.openOwnedRequestQueue|`openOwnedRequestQueue()`}.
|
|
79
115
|
*/
|
|
80
|
-
ownedRequestQueue = OwnedOrInjected.resolve();
|
|
116
|
+
#ownedRequestQueue = OwnedOrInjected.resolve();
|
|
81
117
|
/**
|
|
82
118
|
* Whether the request-processing-time hint has already been forwarded to the request manager. The hint
|
|
83
119
|
* derives only from `requestHandlerTimeoutMillis` (constant for the crawler's lifetime) and is raise-only,
|
|
84
120
|
* so it only needs to be applied once, at the first async access of the manager.
|
|
85
121
|
*/
|
|
86
|
-
requestManagerTimeoutsApplied = false;
|
|
122
|
+
#requestManagerTimeoutsApplied = false;
|
|
87
123
|
/**
|
|
88
124
|
* Resolves the governor for one run: either the injected
|
|
89
125
|
* {@link BasicCrawlerOptions.concurrencySystem|`concurrencySystem`} (borrowed) or a freshly built default with
|
|
90
126
|
* the concurrency shortcuts folded in (owned, so the crawler starts and stops it).
|
|
91
127
|
*/
|
|
92
|
-
resolveConcurrencySystem;
|
|
93
|
-
/** As resolved by `
|
|
94
|
-
concurrencySystemDep;
|
|
128
|
+
#resolveConcurrencySystem;
|
|
129
|
+
/** As resolved by `init()`. Absent until the first run, so a `teardown()` before it is a no-op. */
|
|
130
|
+
#concurrencySystemDep;
|
|
95
131
|
/**
|
|
96
132
|
* The concurrency governor this run is booking its requests against — either the
|
|
97
133
|
* {@link BasicCrawlerOptions.concurrencySystem|`concurrencySystem`} that was injected, or the default the
|
|
@@ -105,7 +141,7 @@ export class BasicCrawler {
|
|
|
105
141
|
* `minConcurrency`/`maxConcurrency`/`desiredConcurrency` on your own reference.
|
|
106
142
|
*/
|
|
107
143
|
get concurrencySystem() {
|
|
108
|
-
return this
|
|
144
|
+
return this.#concurrencySystemDep?.maybeValue;
|
|
109
145
|
}
|
|
110
146
|
/**
|
|
111
147
|
* The task loop that dispatches this run's requests. Private on purpose — it is a bare parallel task runner with
|
|
@@ -114,7 +150,7 @@ export class BasicCrawler {
|
|
|
114
150
|
* {@link BasicCrawler.resume|`resume()`}, {@link BasicCrawler.teardown|`teardown()`} and
|
|
115
151
|
* {@link BasicCrawler.concurrencySystem|`concurrencySystem`}.
|
|
116
152
|
*/
|
|
117
|
-
autoscaledPool;
|
|
153
|
+
#autoscaledPool;
|
|
118
154
|
/**
|
|
119
155
|
* A reference to the underlying {@link IProxyConfiguration} instance that manages the crawler's proxies.
|
|
120
156
|
* Only available if used by the crawler.
|
|
@@ -125,7 +161,7 @@ export class BasicCrawler {
|
|
|
125
161
|
* See {@link Router.addHandler|`router.addHandler()`} and {@link Router.addDefaultHandler|`router.addDefaultHandler()`}.
|
|
126
162
|
*/
|
|
127
163
|
router = Router.create();
|
|
128
|
-
|
|
164
|
+
#basicContextPipeline;
|
|
129
165
|
/**
|
|
130
166
|
* The basic part of the context pipeline. Unlike the subclass pipeline, this
|
|
131
167
|
* part has no major side effects (e.g. launching a browser). It also makes typing more explicit, as subclass
|
|
@@ -135,21 +171,21 @@ export class BasicCrawler {
|
|
|
135
171
|
* This is used e.g. in the {@link AdaptivePlaywrightCrawler|`AdaptivePlaywrightCrawler`}.
|
|
136
172
|
*/
|
|
137
173
|
get basicContextPipeline() {
|
|
138
|
-
if (this
|
|
139
|
-
this
|
|
174
|
+
if (this.#basicContextPipeline === undefined) {
|
|
175
|
+
this.#basicContextPipeline = this.buildBasicContextPipeline();
|
|
140
176
|
}
|
|
141
|
-
return this
|
|
177
|
+
return this.#basicContextPipeline;
|
|
142
178
|
}
|
|
143
|
-
|
|
179
|
+
#contextPipeline;
|
|
144
180
|
get contextPipeline() {
|
|
145
|
-
if (this
|
|
146
|
-
this
|
|
181
|
+
if (this.#contextPipeline === undefined) {
|
|
182
|
+
this.#contextPipeline = this.buildFinalContextPipeline();
|
|
147
183
|
}
|
|
148
|
-
return this
|
|
184
|
+
return this.#contextPipeline;
|
|
149
185
|
}
|
|
150
186
|
running = false;
|
|
151
187
|
hasFinishedBefore = false;
|
|
152
|
-
unexpectedStop = false;
|
|
188
|
+
#unexpectedStop = false;
|
|
153
189
|
#log;
|
|
154
190
|
get log() {
|
|
155
191
|
return this.#log;
|
|
@@ -157,100 +193,114 @@ export class BasicCrawler {
|
|
|
157
193
|
requestHandler;
|
|
158
194
|
errorHandler;
|
|
159
195
|
failedRequestHandler;
|
|
196
|
+
// kept as TS-private: tests read it at runtime
|
|
160
197
|
requestHandlerTimeoutMillis;
|
|
161
198
|
internalTimeoutMillis;
|
|
162
199
|
maxRequestRetries;
|
|
163
200
|
maxCrawlDepth;
|
|
164
|
-
|
|
165
|
-
domainAccessedTime;
|
|
201
|
+
#sameDomainDelaySecs;
|
|
166
202
|
maxRequestsPerCrawl;
|
|
167
203
|
get handledRequestsCount() {
|
|
168
|
-
return this.
|
|
204
|
+
return this.statistics.state.requestsFinished + this.statistics.state.requestsFailed;
|
|
169
205
|
}
|
|
170
|
-
statusMessageLoggingInterval;
|
|
171
|
-
statusMessageCallback;
|
|
206
|
+
#statusMessageLoggingInterval;
|
|
207
|
+
#statusMessageCallback;
|
|
172
208
|
blockedStatusCodes = new Set();
|
|
173
209
|
additionalHttpErrorStatusCodes;
|
|
174
|
-
ignoreHttpErrorStatusCodes;
|
|
210
|
+
#ignoreHttpErrorStatusCodes;
|
|
175
211
|
/**
|
|
176
212
|
* The resolved options for the crawler's own task loop — the crawler-owned `runTaskFunction`, the (possibly
|
|
177
213
|
* user-overridden) ready/finished predicates and cadence/logging. Concurrency configuration lives on the
|
|
178
214
|
* {@link ConcurrencySystem} instead, and the loop's `consumer` identity is the crawler's own, so neither is
|
|
179
215
|
* settable here.
|
|
180
216
|
*/
|
|
217
|
+
// kept as TS-private: tests mutate it at runtime
|
|
181
218
|
taskLoopOptions;
|
|
182
219
|
httpClient;
|
|
183
220
|
retryOnBlocked;
|
|
184
|
-
respectRobotsTxtFile;
|
|
221
|
+
#respectRobotsTxtFile;
|
|
222
|
+
/** Whether `runInStorageTransaction()` opens a transaction at all. */
|
|
223
|
+
#transactionalStorageEnabled;
|
|
224
|
+
/** The resolved per-storage-type write policy overrides forwarded to each request's transaction. */
|
|
225
|
+
#storageWritePolicy;
|
|
185
226
|
onSkippedRequest;
|
|
186
|
-
|
|
187
|
-
loggedPerRun = new Set();
|
|
188
|
-
robotsTxtFileCache;
|
|
227
|
+
#closeEvents;
|
|
228
|
+
#loggedPerRun = new Set();
|
|
229
|
+
#robotsTxtFileCache;
|
|
189
230
|
identity;
|
|
190
|
-
contextPipelineOptions;
|
|
231
|
+
#contextPipelineOptions;
|
|
191
232
|
static optionsShape = {
|
|
192
|
-
contextPipelineBuilder:
|
|
193
|
-
extendContext:
|
|
194
|
-
requestList:
|
|
195
|
-
requestQueue:
|
|
233
|
+
contextPipelineBuilder: schemas.anyObject.optional(),
|
|
234
|
+
extendContext: schemas.anyFunction.optional(),
|
|
235
|
+
requestList: validators.requestList.optional(),
|
|
236
|
+
requestQueue: validators.requestQueue.optional(),
|
|
237
|
+
requestManager: validators.requestManager.optional(),
|
|
196
238
|
// Subclasses override this function instead of passing it
|
|
197
239
|
// in constructor, so this validation needs to apply only
|
|
198
240
|
// if the user creates an instance of BasicCrawler directly.
|
|
199
|
-
requestHandler:
|
|
200
|
-
requestHandlerTimeoutSecs:
|
|
201
|
-
errorHandler:
|
|
202
|
-
failedRequestHandler:
|
|
203
|
-
maxRequestRetries:
|
|
204
|
-
sameDomainDelaySecs:
|
|
205
|
-
maxRequestsPerCrawl:
|
|
206
|
-
maxCrawlDepth:
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
241
|
+
requestHandler: schemas.anyFunction.optional(),
|
|
242
|
+
requestHandlerTimeoutSecs: schemas.anyNumber.optional(),
|
|
243
|
+
errorHandler: schemas.anyFunction.optional(),
|
|
244
|
+
failedRequestHandler: schemas.anyFunction.optional(),
|
|
245
|
+
maxRequestRetries: schemas.anyNumber.default(3),
|
|
246
|
+
sameDomainDelaySecs: schemas.anyNumber.default(0),
|
|
247
|
+
maxRequestsPerCrawl: schemas.anyNumber.optional(),
|
|
248
|
+
maxCrawlDepth: schemas.anyNumber.optional(),
|
|
249
|
+
// No zod default — subclasses provide their own fallback (e.g. HTTP-optimized pool options).
|
|
250
|
+
taskLoopOptions: schemas.anyObject.optional(),
|
|
251
|
+
concurrencySystem: schemas.anyObject.optional(),
|
|
252
|
+
sessionPool: validators.sessionPool.optional(),
|
|
253
|
+
proxyConfiguration: validators.proxyConfiguration.optional(),
|
|
254
|
+
statusMessageLoggingInterval: schemas.anyNumber.default(10),
|
|
255
|
+
statusMessageCallback: schemas.anyFunction.optional(),
|
|
256
|
+
additionalHttpErrorStatusCodes: schemas.arrayOf(schemas.anyNumber, 'numbers').default(() => []),
|
|
257
|
+
ignoreHttpErrorStatusCodes: schemas.arrayOf(schemas.anyNumber, 'numbers').default(() => []),
|
|
258
|
+
blockedStatusCodes: schemas.arrayOf(schemas.anyNumber, 'numbers').optional(),
|
|
259
|
+
retryOnBlocked: z.boolean().default(false),
|
|
260
|
+
respectRobotsTxtFile: z.union([z.boolean(), schemas.anyObject]).default(false),
|
|
261
|
+
transactionalStorage: z
|
|
262
|
+
.union([z.boolean(), z.strictObject({ requestQueue: z.enum(['deferred', 'writeThrough']).optional() })])
|
|
263
|
+
.optional(),
|
|
264
|
+
onSkippedRequest: schemas.anyFunction.optional(),
|
|
265
|
+
httpClient: schemas.httpClient.optional(),
|
|
266
|
+
configuration: z.instanceof(Configuration).optional(),
|
|
267
|
+
storageBackend: validators.storageBackend.optional(),
|
|
268
|
+
eventManager: z.instanceof(EventManager).optional(),
|
|
269
|
+
logger: validators.logger.optional(),
|
|
224
270
|
// AutoscaledPool shorthands
|
|
225
|
-
minConcurrency:
|
|
226
|
-
maxConcurrency:
|
|
227
|
-
maxRequestsPerMinute:
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
271
|
+
minConcurrency: schemas.anyNumber.optional(),
|
|
272
|
+
maxConcurrency: schemas.anyNumber.optional(),
|
|
273
|
+
maxRequestsPerMinute: schemas.anyNumber
|
|
274
|
+
.refine((value) => Number.isInteger(value) || value === Infinity, 'Expected an integer or infinite number')
|
|
275
|
+
.refine((value) => value >= 1, 'Expected a number greater than or equal to 1')
|
|
276
|
+
.optional(),
|
|
277
|
+
keepAlive: z.boolean().optional(),
|
|
278
|
+
statistics: schemas.anyObject.optional(),
|
|
279
|
+
id: z.string().optional(),
|
|
231
280
|
};
|
|
281
|
+
static optionsSchema = z.strictObject(BasicCrawler.optionsShape);
|
|
232
282
|
/**
|
|
233
283
|
* All `BasicCrawler` parameters are passed via an options object.
|
|
234
284
|
*/
|
|
235
285
|
constructor(options = {}) {
|
|
236
|
-
|
|
286
|
+
const parsedOptions = parseArgument(options, BasicCrawler.optionsSchema, 'BasicCrawlerOptions');
|
|
237
287
|
const {
|
|
238
288
|
// oxlint-disable-next-line typescript/no-deprecated -- still accepted and folded into `requestManager` for back-compat
|
|
239
289
|
requestList,
|
|
240
290
|
// oxlint-disable-next-line typescript/no-deprecated -- still accepted and folded into `requestManager` for back-compat
|
|
241
|
-
requestQueue, requestManager, maxRequestRetries
|
|
291
|
+
requestQueue, requestManager, maxRequestRetries, sameDomainDelaySecs, maxRequestsPerCrawl, maxCrawlDepth, taskLoopOptions = {}, concurrencySystem, keepAlive, sessionPool, proxyConfiguration, additionalHttpErrorStatusCodes, ignoreHttpErrorStatusCodes,
|
|
242
292
|
// Service locator options
|
|
243
293
|
configuration, storageBackend, eventManager, logger,
|
|
244
294
|
// AutoscaledPool shorthands
|
|
245
|
-
minConcurrency, maxConcurrency, maxRequestsPerMinute, blockedStatusCodes: blockedStatusCodesInput, retryOnBlocked
|
|
295
|
+
minConcurrency, maxConcurrency, maxRequestsPerMinute, blockedStatusCodes: blockedStatusCodesInput, retryOnBlocked, respectRobotsTxtFile, transactionalStorage, onSkippedRequest, requestHandler, requestHandlerTimeoutSecs, errorHandler, failedRequestHandler, statusMessageLoggingInterval, statusMessageCallback, statistics, httpClient, id, } = parsedOptions;
|
|
246
296
|
// All concurrency configuration lives on the `ConcurrencySystem`, so the shortcuts have nowhere to go once
|
|
247
297
|
// one is supplied - and silently dropping a `maxConcurrency` the user asked for is how crawls end up
|
|
248
298
|
// hammering a site.
|
|
249
299
|
if (concurrencySystem !== undefined &&
|
|
250
300
|
(minConcurrency !== undefined || maxConcurrency !== undefined || maxRequestsPerMinute !== undefined)) {
|
|
251
|
-
throw new
|
|
301
|
+
throw new Error('The `minConcurrency`/`maxConcurrency`/`maxRequestsPerMinute` shortcuts cannot be combined with ' +
|
|
252
302
|
'`concurrencySystem` - they configure the default `ConcurrencySystem` that a supplied one ' +
|
|
253
|
-
'replaces. Pass them to the `ConcurrencySystem` constructor instead.'
|
|
303
|
+
'replaces. Pass them to the `ConcurrencySystem` constructor instead.');
|
|
254
304
|
}
|
|
255
305
|
// Create per-crawler service locator if custom services were provided.
|
|
256
306
|
// This wraps every method on the crawler instance so that calls to the global `serviceLocator`
|
|
@@ -262,14 +312,18 @@ export class BasicCrawler {
|
|
|
262
312
|
eventManager ||
|
|
263
313
|
logger ||
|
|
264
314
|
(configuration !== undefined && configuration !== serviceLocator.getConfiguration())) {
|
|
265
|
-
|
|
315
|
+
// Inherit the ambient locator's already-set services for anything not explicitly
|
|
316
|
+
// provided - e.g. only passing a `logger` must not detach the crawler from a globally
|
|
317
|
+
// configured storage backend.
|
|
318
|
+
const ambientServices = serviceLocator.getServicesIfSet();
|
|
319
|
+
const scopedServiceLocator = new ServiceLocator(configuration ?? ambientServices.configuration, eventManager ?? ambientServices.eventManager, storageBackend ?? ambientServices.storageBackend, logger ?? ambientServices.logger);
|
|
266
320
|
serviceLocatorScope = bindMethodsToServiceLocator(scopedServiceLocator, this);
|
|
267
321
|
}
|
|
268
322
|
try {
|
|
269
323
|
serviceLocatorScope.enterScope();
|
|
270
|
-
this
|
|
271
|
-
contextPipelineBuilder:
|
|
272
|
-
extendContext:
|
|
324
|
+
this.#contextPipelineOptions = {
|
|
325
|
+
contextPipelineBuilder: parsedOptions.contextPipelineBuilder,
|
|
326
|
+
extendContext: parsedOptions.extendContext,
|
|
273
327
|
};
|
|
274
328
|
this.#log = serviceLocator.getLogger().child({ prefix: this.constructor.name });
|
|
275
329
|
// Initialize the Configuration instance to avoid lazy loading in the components
|
|
@@ -280,6 +334,12 @@ export class BasicCrawler {
|
|
|
280
334
|
if (requestList !== undefined || requestQueue !== undefined) {
|
|
281
335
|
throw new Error('The `requestManager` option cannot be used in conjunction with `requestList` and/or `requestQueue`');
|
|
282
336
|
}
|
|
337
|
+
// Both would pace the same domains, from different keys and with no idea of one another.
|
|
338
|
+
if (sameDomainDelaySecs > 0 && supportsDomainThrottling(requestManager)) {
|
|
339
|
+
throw new Error('The `sameDomainDelaySecs` option cannot be combined with a `requestManager` that throttles ' +
|
|
340
|
+
'per domain on its own. Configure the delay on the manager instead, via the ' +
|
|
341
|
+
'`minCrawlDelaySecs` option of `ThrottlingRequestManager`.');
|
|
342
|
+
}
|
|
283
343
|
this.requestManager = requestManager;
|
|
284
344
|
}
|
|
285
345
|
else if (requestList !== undefined && requestQueue !== undefined) {
|
|
@@ -298,13 +358,12 @@ export class BasicCrawler {
|
|
|
298
358
|
}
|
|
299
359
|
this.httpClient = httpClient ?? new LazyDefaultHttpClient({ logger: this.log });
|
|
300
360
|
this.proxyConfiguration = proxyConfiguration;
|
|
301
|
-
this
|
|
302
|
-
this
|
|
303
|
-
this
|
|
304
|
-
this.robotsTxtFileCache = new LruCache({ maxLength: 1000 });
|
|
361
|
+
this.#statusMessageLoggingInterval = statusMessageLoggingInterval;
|
|
362
|
+
this.#statusMessageCallback = statusMessageCallback;
|
|
363
|
+
this.#robotsTxtFileCache = new LruCache({ maxLength: 1000 });
|
|
305
364
|
this.handleSkippedRequest = this.handleSkippedRequest.bind(this);
|
|
306
365
|
this.additionalHttpErrorStatusCodes = new Set([...additionalHttpErrorStatusCodes]);
|
|
307
|
-
this
|
|
366
|
+
this.#ignoreHttpErrorStatusCodes = new Set([...ignoreHttpErrorStatusCodes]);
|
|
308
367
|
this.requestHandler = requestHandler ?? this.router;
|
|
309
368
|
this.failedRequestHandler = failedRequestHandler;
|
|
310
369
|
this.errorHandler = errorHandler;
|
|
@@ -315,7 +374,11 @@ export class BasicCrawler {
|
|
|
315
374
|
this.requestHandlerTimeoutMillis = 60_000;
|
|
316
375
|
}
|
|
317
376
|
this.retryOnBlocked = retryOnBlocked;
|
|
318
|
-
this
|
|
377
|
+
this.#respectRobotsTxtFile = respectRobotsTxtFile;
|
|
378
|
+
// The cast undoes ow's assertion signature, which mangles `boolean | object` unions.
|
|
379
|
+
const transactionalStorageOption = transactionalStorage;
|
|
380
|
+
this.#transactionalStorageEnabled = transactionalStorageOption !== false;
|
|
381
|
+
this.#storageWritePolicy = typeof transactionalStorageOption === 'object' ? transactionalStorageOption : {};
|
|
319
382
|
this.onSkippedRequest = onSkippedRequest;
|
|
320
383
|
// allow at least 5min for internal timeouts
|
|
321
384
|
this.internalTimeoutMillis =
|
|
@@ -323,20 +386,23 @@ export class BasicCrawler {
|
|
|
323
386
|
Math.max(this.requestHandlerTimeoutMillis * 2, 300e3);
|
|
324
387
|
this.maxRequestRetries = maxRequestRetries;
|
|
325
388
|
this.maxCrawlDepth = maxCrawlDepth;
|
|
326
|
-
this
|
|
327
|
-
this
|
|
389
|
+
this.#sameDomainDelaySecs = sameDomainDelaySecs;
|
|
390
|
+
this.#statisticsDep = OwnedOrInjected.resolve(statistics,
|
|
391
|
+
// A crawler-built default tracks the built-in fields only. A non-empty `StatisticStateExtension` can
|
|
392
|
+
// only be satisfied by an injected instance carrying the matching `state`, so this branch does
|
|
393
|
+
// not run in that case - hence the cast.
|
|
394
|
+
() => new Statistics({
|
|
328
395
|
logMessage: `${this.constructor.name} request statistics:`,
|
|
329
396
|
log: this.log,
|
|
330
397
|
id: this.identity.id,
|
|
331
|
-
|
|
332
|
-
});
|
|
398
|
+
}));
|
|
333
399
|
if (sessionPool && proxyConfiguration) {
|
|
334
400
|
this.log.warning('Both `sessionPool` and `proxyConfiguration` were provided to the crawler. ' +
|
|
335
401
|
'The `proxyConfiguration` is ignored - sessions from the supplied pool keep whatever ' +
|
|
336
402
|
'`proxyInfo` they were created with. Configure proxies on the pool instead, ' +
|
|
337
403
|
'e.g. via `addSession({ proxyInfo })` or a custom `createSessionFunction`.');
|
|
338
404
|
}
|
|
339
|
-
this
|
|
405
|
+
this.#sessionPoolDep = OwnedOrInjected.resolve(sessionPool, () => new SessionPool({
|
|
340
406
|
createSessionFunction: async (opts) => new Session({
|
|
341
407
|
...opts?.sessionOptions,
|
|
342
408
|
proxyInfo: opts?.sessionOptions?.proxyInfo ?? (await this.proxyConfiguration?.newProxyInfo()),
|
|
@@ -364,27 +430,30 @@ export class BasicCrawler {
|
|
|
364
430
|
if (!source)
|
|
365
431
|
throw new Error('Request provider is not initialized!');
|
|
366
432
|
const request = await this.resolveRequest();
|
|
367
|
-
if (!request
|
|
433
|
+
if (!request) {
|
|
368
434
|
return;
|
|
369
435
|
}
|
|
370
436
|
// Started here, rather than in `handleRequest`, so that a failure during context pipeline
|
|
371
437
|
// initialization (e.g. a browser page timing out before the request handler ever runs) is
|
|
372
438
|
// still accounted for by `failJob` below - which is a no-op without a matching `startJob`.
|
|
373
|
-
this.
|
|
439
|
+
this.statistics.startJob(request.id || request.uniqueKey);
|
|
374
440
|
const crawlingContext = { request };
|
|
375
441
|
try {
|
|
442
|
+
// The transaction spans the whole pipeline call, covering the navigation hooks
|
|
443
|
+
// and `extendContext` too; `handleRequest` drives its outcome explicitly.
|
|
444
|
+
await this.runInStorageTransaction(async () =>
|
|
376
445
|
// Navigation, the navigation hooks and the request handler are timed individually, but the
|
|
377
446
|
// phases between them are not, so a request could still get stuck indefinitely. This is the
|
|
378
447
|
// catch-all for that - see `raceWithTimeout` for why it is a bare timer, not a timeout frame.
|
|
379
448
|
await this.withRequestTimeout(crawlingContext, this.basicContextPipeline
|
|
380
449
|
.chain(this.contextPipeline)
|
|
381
|
-
.call(crawlingContext, (ctx) => this.handleRequest(ctx, source, request)));
|
|
450
|
+
.call(crawlingContext, (ctx) => this.handleRequest(ctx, source, request))));
|
|
382
451
|
}
|
|
383
452
|
catch (error) {
|
|
384
453
|
// ContextPipelineInterruptedError means the request was intentionally skipped
|
|
385
454
|
// (e.g., doesn't match enqueue strategy after redirect). Just return gracefully.
|
|
386
455
|
if (error instanceof ContextPipelineInterruptedError) {
|
|
387
|
-
this.
|
|
456
|
+
this.statistics.discardJob(request.id || request.uniqueKey);
|
|
388
457
|
await this.timeoutAndRetry(async () => this.requestManager?.markRequestAsHandled(request), this.internalTimeoutMillis, `Marking request ${crawlingContext.request.url} (${crawlingContext.request.id}) as handled timed out after ${this.internalTimeoutMillis / 1e3} seconds.`);
|
|
389
458
|
return;
|
|
390
459
|
}
|
|
@@ -399,7 +468,7 @@ export class BasicCrawler {
|
|
|
399
468
|
await this.requestFunctionErrorHandler(unwrappedError, crawlingContext, request, this.requestManager);
|
|
400
469
|
// SessionError already retired the session in `requestFunctionErrorHandler`;
|
|
401
470
|
// skip `markBad` to avoid double-counting usage/error score.
|
|
402
|
-
if (!(unwrappedError
|
|
471
|
+
if (!this.errorAbsolvesSession(unwrappedError)) {
|
|
403
472
|
crawlingContext.session?.markBad();
|
|
404
473
|
}
|
|
405
474
|
return;
|
|
@@ -418,7 +487,7 @@ export class BasicCrawler {
|
|
|
418
487
|
`${this.maxRequestsPerCrawl} requests and will shut down soon. Requests that are in progress will be allowed to finish.`);
|
|
419
488
|
return false;
|
|
420
489
|
}
|
|
421
|
-
if (this
|
|
490
|
+
if (this.#unexpectedStop) {
|
|
422
491
|
this.logOncePerRun('shuttingDown', 'No new requests are allowed because the `stop()` method has been called. ' +
|
|
423
492
|
'Ongoing requests will be allowed to complete.');
|
|
424
493
|
return false;
|
|
@@ -432,10 +501,15 @@ export class BasicCrawler {
|
|
|
432
501
|
`In total, the crawler processed ${this.handledRequestsCount} requests and will shut down.`);
|
|
433
502
|
return true;
|
|
434
503
|
}
|
|
435
|
-
if (this
|
|
504
|
+
if (this.#unexpectedStop) {
|
|
436
505
|
this.log.info('The crawler has finished all the remaining ongoing requests and will shut down now.');
|
|
437
506
|
return true;
|
|
438
507
|
}
|
|
508
|
+
// Checked here because this runs only once nothing is in flight, which is exactly when a
|
|
509
|
+
// crawl that cannot progress looks indistinguishable from one that is merely waiting.
|
|
510
|
+
if (!keepAlive && supportsDomainThrottling(this.requestManager)) {
|
|
511
|
+
await this.requestManager.assertNoStalledDomains();
|
|
512
|
+
}
|
|
439
513
|
const isFinished = isFinishedFunction
|
|
440
514
|
? await isFinishedFunction()
|
|
441
515
|
: await this.defaultIsFinishedFunction();
|
|
@@ -450,7 +524,7 @@ export class BasicCrawler {
|
|
|
450
524
|
log: this.log,
|
|
451
525
|
};
|
|
452
526
|
this.taskLoopOptions = { ...taskLoopOptions, ...crawlerOwnedTaskLoopConfiguration };
|
|
453
|
-
this
|
|
527
|
+
this.#resolveConcurrencySystem = () => OwnedOrInjected.resolve(concurrencySystem, () => this.createDefaultConcurrencySystem({
|
|
454
528
|
minConcurrency,
|
|
455
529
|
maxConcurrency,
|
|
456
530
|
maxTasksPerMinute: maxRequestsPerMinute,
|
|
@@ -479,7 +553,7 @@ export class BasicCrawler {
|
|
|
479
553
|
* @returns `true` if the status code is considered an error, `false` otherwise
|
|
480
554
|
*/
|
|
481
555
|
isErrorStatusCode(status) {
|
|
482
|
-
const excludeError = this
|
|
556
|
+
const excludeError = this.#ignoreHttpErrorStatusCodes.has(status);
|
|
483
557
|
const includeError = this.additionalHttpErrorStatusCodes.has(status);
|
|
484
558
|
return (status >= 500 && !excludeError) || includeError;
|
|
485
559
|
}
|
|
@@ -559,20 +633,16 @@ export class BasicCrawler {
|
|
|
559
633
|
return { session, proxyInfo: session?.proxyInfo };
|
|
560
634
|
}
|
|
561
635
|
async createContextHelpers({ request, session }) {
|
|
562
|
-
const enqueueLinksWrapper = async (options) => {
|
|
563
|
-
const requestManager = await this.getRequestManager();
|
|
564
|
-
return await this.enqueueLinksWithCrawlDepth(options, request, requestManager);
|
|
565
|
-
};
|
|
566
636
|
const addRequests = async (requests, options = {}) => {
|
|
567
637
|
const newCrawlDepth = request.crawlDepth + 1;
|
|
568
638
|
const requestsGenerator = this.addCrawlDepthRequestGenerator(requests, newCrawlDepth);
|
|
569
|
-
await this.addRequests(requestsGenerator, options);
|
|
639
|
+
return await this.addRequests(requestsGenerator, options);
|
|
570
640
|
};
|
|
571
641
|
const sendRequest = createSendRequest(this.httpClient, request, session);
|
|
572
|
-
return {
|
|
642
|
+
return { addRequests, sendRequest };
|
|
573
643
|
}
|
|
574
644
|
buildFinalContextPipeline() {
|
|
575
|
-
const subclassPipeline = (this
|
|
645
|
+
const subclassPipeline = (this.#contextPipelineOptions.contextPipelineBuilder?.() ??
|
|
576
646
|
this.buildContextPipeline());
|
|
577
647
|
// `extendContext` runs *before* the subclass navigation pipeline (which includes the
|
|
578
648
|
// pre/post-navigation hooks). This makes the extension visible to those hooks and to the
|
|
@@ -584,7 +654,7 @@ export class BasicCrawler {
|
|
|
584
654
|
// TypeScript cannot express that `Context` transitively includes `ContextExtension` here. The
|
|
585
655
|
// casts below are sound because `buildFinalContextPipeline` is declared to return the fully
|
|
586
656
|
// resolved `ExtendedContext` (= `Context & ContextExtension`).
|
|
587
|
-
const { extendContext } = this
|
|
657
|
+
const { extendContext } = this.#contextPipelineOptions;
|
|
588
658
|
let contextPipeline;
|
|
589
659
|
if (extendContext !== undefined) {
|
|
590
660
|
contextPipeline = ContextPipeline.create()
|
|
@@ -618,7 +688,7 @@ export class BasicCrawler {
|
|
|
618
688
|
* @param error The error to check.
|
|
619
689
|
*/
|
|
620
690
|
isProxyError(error) {
|
|
621
|
-
return ROTATE_PROXY_ERRORS.some((x) => this.
|
|
691
|
+
return ROTATE_PROXY_ERRORS.some((x) => this.getMessageFromError(error)?.includes(x));
|
|
622
692
|
}
|
|
623
693
|
/**
|
|
624
694
|
* Sets the status message for the current crawler run.
|
|
@@ -636,7 +706,7 @@ export class BasicCrawler {
|
|
|
636
706
|
// subscribe to `EventType.STATUS_MESSAGE` and propagate it to their status-reporting backend.
|
|
637
707
|
// Setting the status message is not a storage concern, so we intentionally don't route it
|
|
638
708
|
// through the storage client anymore.
|
|
639
|
-
serviceLocator.getEventManager().emit(
|
|
709
|
+
serviceLocator.getEventManager().emit(EventType.STATUS_MESSAGE, {
|
|
640
710
|
crawlerId: this.identity.id,
|
|
641
711
|
message,
|
|
642
712
|
isStatusMessageTerminal: options.isStatusMessageTerminal,
|
|
@@ -644,11 +714,11 @@ export class BasicCrawler {
|
|
|
644
714
|
});
|
|
645
715
|
}
|
|
646
716
|
getPeriodicLogger() {
|
|
647
|
-
let previousState = { ...this.
|
|
717
|
+
let previousState = { ...this.statistics.state };
|
|
648
718
|
const getOperationMode = () => {
|
|
649
|
-
const { requestsFailed } = this.
|
|
719
|
+
const { requestsFailed } = this.statistics.state;
|
|
650
720
|
const { requestsFailed: previousRequestsFailed } = previousState;
|
|
651
|
-
previousState = { ...this.
|
|
721
|
+
previousState = { ...this.statistics.state };
|
|
652
722
|
const failedDelta = requestsFailed - previousRequestsFailed;
|
|
653
723
|
if (failedDelta > 0) {
|
|
654
724
|
return { mode: 'ERROR', failedDelta };
|
|
@@ -659,16 +729,16 @@ export class BasicCrawler {
|
|
|
659
729
|
const { mode: operationMode, failedDelta } = getOperationMode();
|
|
660
730
|
let message;
|
|
661
731
|
if (operationMode === 'ERROR') {
|
|
662
|
-
message = `Experiencing problems, ${failedDelta} failed requests in the past ${this
|
|
732
|
+
message = `Experiencing problems, ${failedDelta} failed requests in the past ${this.#statusMessageLoggingInterval} seconds.`;
|
|
663
733
|
}
|
|
664
734
|
else {
|
|
665
735
|
const total = await this.requestManager?.getTotalCount();
|
|
666
|
-
message = `Crawled ${this.
|
|
736
|
+
message = `Crawled ${this.statistics.state.requestsFinished}${total ? `/${total}` : ''} pages, ${this.statistics.state.requestsFailed} failed requests, desired concurrency ${this.concurrencySystem?.desiredConcurrency ?? 0}.`;
|
|
667
737
|
}
|
|
668
|
-
if (this
|
|
669
|
-
await this
|
|
738
|
+
if (this.#statusMessageCallback) {
|
|
739
|
+
await this.#statusMessageCallback({
|
|
670
740
|
crawler: this,
|
|
671
|
-
state: this.
|
|
741
|
+
state: this.statistics.state,
|
|
672
742
|
previousState,
|
|
673
743
|
message,
|
|
674
744
|
});
|
|
@@ -676,7 +746,7 @@ export class BasicCrawler {
|
|
|
676
746
|
}
|
|
677
747
|
this.setStatusMessage(message);
|
|
678
748
|
};
|
|
679
|
-
const interval = setInterval(log, this
|
|
749
|
+
const interval = setInterval(log, this.#statusMessageLoggingInterval * 1e3);
|
|
680
750
|
return { log, stop: () => clearInterval(interval) };
|
|
681
751
|
}
|
|
682
752
|
/**
|
|
@@ -704,17 +774,26 @@ export class BasicCrawler {
|
|
|
704
774
|
// When `purgeRequestQueue` is explicitly `true`, we also purge a user-supplied manager.
|
|
705
775
|
// When `purgeRequestQueue` is explicitly `false`, nothing is purged.
|
|
706
776
|
const shouldPurge = purgeRequestQueue !== false;
|
|
707
|
-
const managerToPurge = this
|
|
708
|
-
if (
|
|
709
|
-
await managerToPurge
|
|
777
|
+
const managerToPurge = this.#ownedRequestQueue.maybeValue ?? (purgeRequestQueue === true ? this.requestManager : undefined);
|
|
778
|
+
if (shouldPurge) {
|
|
779
|
+
await managerToPurge?.purge?.();
|
|
780
|
+
// The per-domain queues a `sameDomainDelaySecs` wrapper created are the crawler's own, whatever
|
|
781
|
+
// sits underneath them - so they are emptied even when the manager they wrap is spared. Purging
|
|
782
|
+
// the wrapper itself has already covered them.
|
|
783
|
+
if (this.requestManager instanceof ThrottlingRequestManager && managerToPurge !== this.requestManager) {
|
|
784
|
+
await this.requestManager.purgeDomainQueues();
|
|
785
|
+
}
|
|
710
786
|
}
|
|
711
|
-
|
|
712
|
-
await this.stats
|
|
713
|
-
|
|
787
|
+
// A supplied statistics instance keeps whatever state it was handed - only wipe a default we built.
|
|
788
|
+
await this.#statisticsDep.ifOwned(async (stats) => {
|
|
789
|
+
stats.reset();
|
|
790
|
+
await stats.resetStore();
|
|
791
|
+
});
|
|
792
|
+
await this.#sessionPoolDep.ifOwned((pool) => pool.resetStore());
|
|
714
793
|
}
|
|
715
|
-
this
|
|
794
|
+
this.#unexpectedStop = false;
|
|
716
795
|
this.running = true;
|
|
717
|
-
this
|
|
796
|
+
this.#loggedPerRun.clear();
|
|
718
797
|
await purgeDefaultStorages({
|
|
719
798
|
onlyPurgeOnce: true,
|
|
720
799
|
storageBackend: serviceLocator.getStorageBackend(),
|
|
@@ -724,8 +803,8 @@ export class BasicCrawler {
|
|
|
724
803
|
await this.addRequests(requests, addRequestsOptions);
|
|
725
804
|
}
|
|
726
805
|
try {
|
|
727
|
-
await this.
|
|
728
|
-
await this.
|
|
806
|
+
await this.init();
|
|
807
|
+
await this.statistics.startCapturing();
|
|
729
808
|
}
|
|
730
809
|
catch (error) {
|
|
731
810
|
// Clean up here before propagating, otherwise a failed startup would leave the process hanging.
|
|
@@ -741,38 +820,38 @@ export class BasicCrawler {
|
|
|
741
820
|
const sigintHandler = async () => {
|
|
742
821
|
this.log.warning('Pausing... Press CTRL+C again to force exit. To resume, do: CRAWLEE_PURGE_ON_START=0 npm start');
|
|
743
822
|
await this.pauseOnMigration();
|
|
744
|
-
await this
|
|
823
|
+
await this.#autoscaledPool.abort();
|
|
745
824
|
};
|
|
746
825
|
// Attach a listener to handle migration and aborting events gracefully.
|
|
747
826
|
const boundPauseOnMigration = this.pauseOnMigration.bind(this);
|
|
748
827
|
process.once('SIGINT', sigintHandler);
|
|
749
828
|
const eventManager = serviceLocator.getEventManager();
|
|
750
|
-
eventManager.on(
|
|
751
|
-
eventManager.on(
|
|
829
|
+
eventManager.on(EventType.MIGRATING, boundPauseOnMigration);
|
|
830
|
+
eventManager.on(EventType.ABORTING, boundPauseOnMigration);
|
|
752
831
|
let stats = {};
|
|
753
832
|
try {
|
|
754
|
-
await this
|
|
833
|
+
await this.#autoscaledPool.run();
|
|
755
834
|
}
|
|
756
835
|
finally {
|
|
836
|
+
await this.statistics.stopCapturing();
|
|
757
837
|
await this.teardown();
|
|
758
|
-
await this.stats.stopCapturing();
|
|
759
838
|
process.off('SIGINT', sigintHandler);
|
|
760
|
-
eventManager.off(
|
|
761
|
-
eventManager.off(
|
|
762
|
-
const finalStats = this.
|
|
839
|
+
eventManager.off(EventType.MIGRATING, boundPauseOnMigration);
|
|
840
|
+
eventManager.off(EventType.ABORTING, boundPauseOnMigration);
|
|
841
|
+
const finalStats = this.statistics.calculate();
|
|
763
842
|
stats = {
|
|
764
|
-
requestsFinished: this.
|
|
765
|
-
requestsFailed: this.
|
|
766
|
-
retryHistogram: this.
|
|
843
|
+
requestsFinished: this.statistics.state.requestsFinished,
|
|
844
|
+
requestsFailed: this.statistics.state.requestsFailed,
|
|
845
|
+
retryHistogram: this.statistics.requestRetryHistogram,
|
|
767
846
|
...finalStats,
|
|
768
847
|
};
|
|
769
848
|
this.log.info('Final request statistics:', stats);
|
|
770
|
-
if (this.
|
|
849
|
+
if (this.statistics.errorTracker.total !== 0) {
|
|
771
850
|
const prettify = ([count, info]) => `${count}x: ${info.at(-1).trim()} (${info[0]})`;
|
|
772
851
|
this.log.info(`Error analysis:`, {
|
|
773
|
-
totalErrors: this.
|
|
774
|
-
uniqueErrors: this.
|
|
775
|
-
mostCommonErrors: this.
|
|
852
|
+
totalErrors: this.statistics.errorTracker.total,
|
|
853
|
+
uniqueErrors: this.statistics.errorTracker.getUniqueErrorCount(),
|
|
854
|
+
mostCommonErrors: this.statistics.errorTracker.getMostPopularErrors(3).map(prettify),
|
|
776
855
|
});
|
|
777
856
|
}
|
|
778
857
|
const client = serviceLocator.getStorageBackend();
|
|
@@ -787,7 +866,7 @@ export class BasicCrawler {
|
|
|
787
866
|
finished = true;
|
|
788
867
|
}
|
|
789
868
|
periodicLogger.stop();
|
|
790
|
-
this.setStatusMessage(`Finished! Total ${this.
|
|
869
|
+
this.setStatusMessage(`Finished! Total ${this.statistics.state.requestsFinished + this.statistics.state.requestsFailed} requests: ${this.statistics.state.requestsFinished} succeeded, ${this.statistics.state.requestsFailed} failed.`, { isStatusMessageTerminal: true, level: 'INFO' });
|
|
791
870
|
this.running = false;
|
|
792
871
|
this.hasFinishedBefore = true;
|
|
793
872
|
}
|
|
@@ -801,11 +880,11 @@ export class BasicCrawler {
|
|
|
801
880
|
* To stop the crawler immediately, use {@link BasicCrawler.teardown|`crawler.teardown()`} instead.
|
|
802
881
|
*/
|
|
803
882
|
stop(reason = 'The crawler has been gracefully stopped.') {
|
|
804
|
-
if (this
|
|
883
|
+
if (this.#unexpectedStop) {
|
|
805
884
|
return;
|
|
806
885
|
}
|
|
807
886
|
this.log.info(reason);
|
|
808
|
-
this
|
|
887
|
+
this.#unexpectedStop = true;
|
|
809
888
|
}
|
|
810
889
|
/**
|
|
811
890
|
* Stops dispatching new requests, letting the in-progress ones finish. Resolves once they have settled, or rejects
|
|
@@ -816,22 +895,22 @@ export class BasicCrawler {
|
|
|
816
895
|
* throughout, since a shared one may still be serving other crawlers.
|
|
817
896
|
*/
|
|
818
897
|
async pause(timeoutSecs) {
|
|
819
|
-
if (!this
|
|
898
|
+
if (!this.#autoscaledPool) {
|
|
820
899
|
this.log.warning('Cannot pause a crawler that is not running.');
|
|
821
900
|
return;
|
|
822
901
|
}
|
|
823
|
-
await this
|
|
902
|
+
await this.#autoscaledPool.pause(timeoutSecs);
|
|
824
903
|
}
|
|
825
904
|
/**
|
|
826
905
|
* Resumes a run suspended with {@link BasicCrawler.pause|`pause()`}, letting the crawler dispatch requests
|
|
827
906
|
* again. A no-op on a crawler that is not paused.
|
|
828
907
|
*/
|
|
829
908
|
resume() {
|
|
830
|
-
if (!this
|
|
909
|
+
if (!this.#autoscaledPool) {
|
|
831
910
|
this.log.warning('Cannot resume a crawler that is not running.');
|
|
832
911
|
return;
|
|
833
912
|
}
|
|
834
|
-
this
|
|
913
|
+
this.#autoscaledPool.resume();
|
|
835
914
|
}
|
|
836
915
|
/**
|
|
837
916
|
* Returns the crawler's {@link IRequestManager|request manager}, opening the default {@link RequestQueue}
|
|
@@ -841,11 +920,24 @@ export class BasicCrawler {
|
|
|
841
920
|
if (!this.requestManager) {
|
|
842
921
|
this.requestManager = await this.openOwnedRequestQueue();
|
|
843
922
|
}
|
|
923
|
+
// Wrapped here rather than in the constructor, because the manager being wrapped may only be opened at
|
|
924
|
+
// this point - and because everything that enqueues goes through here first, so nothing slips past the
|
|
925
|
+
// wrapper into the queue it hides.
|
|
926
|
+
if (this.#sameDomainDelaySecs > 0 && !supportsDomainThrottling(this.requestManager)) {
|
|
927
|
+
this.requestManager = new ThrottlingRequestManager({
|
|
928
|
+
inner: this.requestManager,
|
|
929
|
+
domains: 'all',
|
|
930
|
+
minCrawlDelaySecs: this.#sameDomainDelaySecs,
|
|
931
|
+
// What `sameDomainDelaySecs` has always meant: one clock for a site, subdomains included.
|
|
932
|
+
throttleBy: 'registrableDomain',
|
|
933
|
+
persistStateKey: `CRAWLEE_THROTTLED_DOMAINS_${this.identity.id}`,
|
|
934
|
+
});
|
|
935
|
+
}
|
|
844
936
|
// Apply the processing-time hint here (an async lifecycle point) rather than in the constructor,
|
|
845
937
|
// now that `setExpectedRequestProcessingTimeSecs` is async. The hint is raise-only and idempotent,
|
|
846
938
|
// but guard so we do not re-issue it on every call.
|
|
847
|
-
if (!this
|
|
848
|
-
this
|
|
939
|
+
if (!this.#requestManagerTimeoutsApplied) {
|
|
940
|
+
this.#requestManagerTimeoutsApplied = true;
|
|
849
941
|
await this.applyRequestManagerTimeouts(this.requestManager);
|
|
850
942
|
}
|
|
851
943
|
return this.requestManager;
|
|
@@ -867,7 +959,7 @@ export class BasicCrawler {
|
|
|
867
959
|
// subsequent instances get their own queue via a unique alias so they don't collide.
|
|
868
960
|
const identifier = this.identity.instanceIndex === 0 ? null : { alias: `__default_${this.identity.id}__` };
|
|
869
961
|
const requestQueue = await RequestQueue.open(identifier, { configuration: serviceLocator.getConfiguration() });
|
|
870
|
-
return this
|
|
962
|
+
return this.#ownedRequestQueue.set(requestQueue);
|
|
871
963
|
}
|
|
872
964
|
/**
|
|
873
965
|
* Tells a request manager how long we expect to hold a fetched request, so that one backed by a
|
|
@@ -916,8 +1008,8 @@ export class BasicCrawler {
|
|
|
916
1008
|
const stateKey = `${BasicCrawler.CRAWLEE_STATE_KEY}_${this.identity.id}`;
|
|
917
1009
|
return kvs.getAutoSavedValue(stateKey, defaultValue);
|
|
918
1010
|
}
|
|
919
|
-
BasicCrawler
|
|
920
|
-
if (BasicCrawler
|
|
1011
|
+
BasicCrawler.#useStateAnonymousIndices.add(this.identity.instanceIndex);
|
|
1012
|
+
if (BasicCrawler.#useStateAnonymousIndices.size > 1) {
|
|
921
1013
|
serviceLocator
|
|
922
1014
|
.getLogger()
|
|
923
1015
|
.warningOnce('Multiple crawler instances are calling useState() without an explicit `id` option. \n' +
|
|
@@ -938,19 +1030,23 @@ export class BasicCrawler {
|
|
|
938
1030
|
return Math.min(limit, explicitLimit ?? Infinity);
|
|
939
1031
|
}
|
|
940
1032
|
async handleSkippedRequest(options) {
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
1033
|
+
// A skipped request is a *successful* outcome, but the interrupt still unwinds through the
|
|
1034
|
+
// transaction scope, which rolls back - so the skip bookkeeping must write directly.
|
|
1035
|
+
await withDirectStorageAccess(async () => {
|
|
1036
|
+
if (options.reason === 'limit') {
|
|
1037
|
+
this.logOncePerRun('maxRequestsPerCrawl', 'The number of requests enqueued by the crawler reached the maxRequestsPerCrawl limit of ' +
|
|
1038
|
+
`${this.maxRequestsPerCrawl} requests and no further requests will be added.`);
|
|
1039
|
+
}
|
|
1040
|
+
if (options.reason === 'depth') {
|
|
1041
|
+
this.logOncePerRun('maxCrawlDepth', `The crawler reached the maxCrawlDepth limit of ${this.maxCrawlDepth} and no further requests will be enqueued.`);
|
|
1042
|
+
}
|
|
1043
|
+
await this.onSkippedRequest?.(options);
|
|
1044
|
+
});
|
|
949
1045
|
}
|
|
950
|
-
logOncePerRun(key, message) {
|
|
951
|
-
if (!this
|
|
952
|
-
this.log
|
|
953
|
-
this
|
|
1046
|
+
logOncePerRun(key, message, level = 'info') {
|
|
1047
|
+
if (!this.#loggedPerRun.has(key)) {
|
|
1048
|
+
this.log[level](message);
|
|
1049
|
+
this.#loggedPerRun.add(key);
|
|
954
1050
|
}
|
|
955
1051
|
}
|
|
956
1052
|
/**
|
|
@@ -959,6 +1055,11 @@ export class BasicCrawler {
|
|
|
959
1055
|
* the batches via `waitBetweenBatchesMillis`. If you want to wait for all batches to be added to the queue, you can use
|
|
960
1056
|
* the `waitForAllRequestsToBeAdded` promise you get in the response object.
|
|
961
1057
|
*
|
|
1058
|
+
* Optionally, the requests can be filtered using `include`/`exclude` glob or regexp patterns and an
|
|
1059
|
+
* enqueue `strategy` (both AND-ed together, same as {@link CrawlingContext.enqueueLinks|`enqueueLinks`}),
|
|
1060
|
+
* relative to `baseUrl`. Unlike `enqueueLinks`, there is no implicit "current page" to anchor the strategy
|
|
1061
|
+
* to, so `strategy` defaults to {@link EnqueueStrategy.All|`all`} here.
|
|
1062
|
+
*
|
|
962
1063
|
* This is an alias for calling `addRequestsBatched()` on the implicit `RequestQueue` for this crawler instance.
|
|
963
1064
|
*
|
|
964
1065
|
* @param requests The requests to add
|
|
@@ -966,55 +1067,97 @@ export class BasicCrawler {
|
|
|
966
1067
|
*/
|
|
967
1068
|
async addRequests(requests, options = {}) {
|
|
968
1069
|
await this.getRequestManager();
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
1070
|
+
if (!isIterable(requests) && !isAsyncIterable(requests)) {
|
|
1071
|
+
throw new Error(`Expected an iterable or async iterable, got ${getObjectType(requests)}`);
|
|
1072
|
+
}
|
|
1073
|
+
parseArgument(options, addRequestsOptionsSchema, 'EnqueueUrlsOptions');
|
|
1074
|
+
// `label`/`userData` apply to every request this call produces, so a single upfront validation
|
|
1075
|
+
// against the label's schema covers them all and fails the whole call fast, rather than failing
|
|
1076
|
+
// lazily once the generator below is drained. Skipped when neither is set - each item still gets
|
|
1077
|
+
// its own per-item validation below, and validating an absent label/userData here would spuriously
|
|
1078
|
+
// check them against a registered default-route schema.
|
|
1079
|
+
if (options.label !== undefined || options.userData !== undefined) {
|
|
1080
|
+
await this.validateRequestUserData({ label: options.label, userData: options.userData });
|
|
1081
|
+
}
|
|
1082
|
+
const requestLimit = await this.calculateEnqueuedRequestLimit(options.limit);
|
|
1083
|
+
const strategy = options.strategy ?? EnqueueStrategy.All;
|
|
1084
|
+
const urlExcludePatternObjects = options.exclude?.length
|
|
1085
|
+
? constructUrlPatternObjects(options.exclude)
|
|
1086
|
+
: [];
|
|
1087
|
+
const urlPatternObjects = options.include?.length
|
|
1088
|
+
? constructUrlPatternObjects(options.include)
|
|
1089
|
+
: [];
|
|
1090
|
+
// The strategy always applies, even when `include` patterns are provided - the two are AND-ed together
|
|
1091
|
+
// (a URL must match an `include` pattern *and* satisfy the strategy). This mirrors crawlee-python.
|
|
1092
|
+
const enqueueStrategyPatterns = options.baseUrl
|
|
1093
|
+
? buildEnqueueStrategyPatterns(options.baseUrl, strategy)
|
|
1094
|
+
: [];
|
|
972
1095
|
const isAllowedBasedOnRobotsTxtFile = this.isAllowedBasedOnRobotsTxtFile.bind(this);
|
|
973
1096
|
const maxCrawlDepth = this.maxCrawlDepth;
|
|
974
1097
|
const validateRequestUserData = this.validateRequestUserData.bind(this);
|
|
975
|
-
|
|
976
|
-
.is((value) => isIterable(value) || isAsyncIterable(value))
|
|
977
|
-
.message((value) => `Expected an iterable or async iterable, got ${getObjectType(value)}`));
|
|
1098
|
+
const allSkipped = [];
|
|
978
1099
|
async function* filteredRequests() {
|
|
979
1100
|
for await (const request of requests) {
|
|
980
|
-
const
|
|
981
|
-
if (
|
|
982
|
-
|
|
1101
|
+
const [requestOptions] = createRequestOptions([typeof request === 'string' ? request : request], { ...options, strategy });
|
|
1102
|
+
if (!requestOptions) {
|
|
1103
|
+
continue; // invalid URL, silently dropped (matches `createRequestOptions`'s own filtering)
|
|
1104
|
+
}
|
|
1105
|
+
if (maxCrawlDepth !== undefined && requestOptions.crawlDepth > maxCrawlDepth) {
|
|
1106
|
+
allSkipped.push({ url: requestOptions.url, reason: 'depth' });
|
|
983
1107
|
continue;
|
|
984
1108
|
}
|
|
985
|
-
if (await isAllowedBasedOnRobotsTxtFile(url)) {
|
|
986
|
-
|
|
987
|
-
|
|
1109
|
+
if (!(await isAllowedBasedOnRobotsTxtFile(requestOptions.url))) {
|
|
1110
|
+
allSkipped.push({ url: requestOptions.url, reason: 'robotsTxt' });
|
|
1111
|
+
continue;
|
|
988
1112
|
}
|
|
989
|
-
|
|
990
|
-
|
|
1113
|
+
const onSkippedFilterUrl = (url) => allSkipped.push({ url, reason: 'filters' });
|
|
1114
|
+
// Filter by user patterns first (with exclude)...
|
|
1115
|
+
let filtered = filterRequestOptionsByPatterns([requestOptions], urlPatternObjects.length > 0 ? urlPatternObjects : undefined, urlExcludePatternObjects, strategy, onSkippedFilterUrl);
|
|
1116
|
+
// ...then filter by the enqueue strategy (making this an AND check)
|
|
1117
|
+
filtered = filterRequestOptionsByPatterns(filtered, enqueueStrategyPatterns.length > 0 ? enqueueStrategyPatterns : undefined, [], strategy, onSkippedFilterUrl);
|
|
1118
|
+
if (filtered.length === 0) {
|
|
1119
|
+
continue;
|
|
1120
|
+
}
|
|
1121
|
+
let [finalOptions] = filtered;
|
|
1122
|
+
if (options.transformRequestFunction) {
|
|
1123
|
+
const transformed = applyRequestTransform([finalOptions], options.transformRequestFunction, (r) => allSkipped.push({ url: r.url, reason: r.skippedReason ?? 'transform' }));
|
|
1124
|
+
if (transformed.length === 0) {
|
|
1125
|
+
continue;
|
|
1126
|
+
}
|
|
1127
|
+
[finalOptions] = transformed;
|
|
991
1128
|
}
|
|
1129
|
+
await validateRequestUserData(finalOptions);
|
|
1130
|
+
yield new Request(finalOptions);
|
|
992
1131
|
}
|
|
993
1132
|
}
|
|
994
1133
|
const result = await this.requestManager.addRequestsBatched(filteredRequests(), {
|
|
995
|
-
|
|
1134
|
+
forefront: options.forefront,
|
|
1135
|
+
waitForAllRequestsToBeAdded: options.waitForAllRequestsToBeAdded,
|
|
1136
|
+
batchSize: options.batchSize,
|
|
1137
|
+
waitBetweenBatchesMillis: options.waitBetweenBatchesMillis,
|
|
996
1138
|
maxNewRequests: requestLimit,
|
|
997
1139
|
});
|
|
998
|
-
// Report requests skipped due to the maxNewRequests budget (i.e. maxRequestsPerCrawl limit
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1140
|
+
// Report requests skipped due to the maxNewRequests budget (i.e. maxRequestsPerCrawl limit, or an
|
|
1141
|
+
// explicit `limit` option)
|
|
1142
|
+
for (const request of result.requestsOverLimit ?? []) {
|
|
1143
|
+
allSkipped.push({ url: typeof request === 'string' ? request : request.url, reason: 'limit' });
|
|
1144
|
+
}
|
|
1145
|
+
if (allSkipped.length > 0) {
|
|
1146
|
+
const skippedRobotsUrls = allSkipped.filter((s) => s.reason === 'robotsTxt').map((s) => s.url);
|
|
1147
|
+
if (skippedRobotsUrls.length > 0) {
|
|
1148
|
+
this.log.warning(`Some requests were skipped because they were disallowed based on the robots.txt file`, { skipped: skippedRobotsUrls });
|
|
1149
|
+
}
|
|
1150
|
+
// Only log the limit message when an explicit `limit` was passed (not the internal
|
|
1151
|
+
// `maxRequestsPerCrawl`-derived one), and only once per call.
|
|
1152
|
+
if (options.limit !== undefined && allSkipped.some((s) => s.reason === 'limit')) {
|
|
1153
|
+
this.log.info(requestLimit === options.limit
|
|
1154
|
+
? `Skipping requests in this call due to the enqueueLinks limit of ${options.limit}.`
|
|
1155
|
+
: `Skipping requests in this call due to the remaining maxRequestsPerCrawl budget of ${requestLimit}, which is lower than the enqueueLinks limit of ${options.limit}.`);
|
|
1156
|
+
}
|
|
1157
|
+
await Promise.all(allSkipped.map(async ({ url, reason }) => {
|
|
1158
|
+
await this.handleSkippedRequest({ url, reason });
|
|
1159
|
+
await options.onSkippedRequest?.({ url, reason });
|
|
1160
|
+
}));
|
|
1018
1161
|
}
|
|
1019
1162
|
return result;
|
|
1020
1163
|
}
|
|
@@ -1067,6 +1210,7 @@ export class BasicCrawler {
|
|
|
1067
1210
|
const keys = options?.collectAllKeys
|
|
1068
1211
|
? Array.from(new Set(items.flatMap(Object.keys)))
|
|
1069
1212
|
: Object.keys(items[0]);
|
|
1213
|
+
const { stringify } = await import('csv-stringify/sync');
|
|
1070
1214
|
value = stringify([
|
|
1071
1215
|
keys,
|
|
1072
1216
|
...items.map((item) => {
|
|
@@ -1074,13 +1218,13 @@ export class BasicCrawler {
|
|
|
1074
1218
|
}),
|
|
1075
1219
|
]);
|
|
1076
1220
|
}
|
|
1077
|
-
await
|
|
1221
|
+
await mkdir(dirname(path), { recursive: true });
|
|
1078
1222
|
await writeFile(path, value);
|
|
1079
1223
|
this.log.info(`Export to ${path} finished!`);
|
|
1080
1224
|
}
|
|
1081
1225
|
if (format === 'json') {
|
|
1082
|
-
await
|
|
1083
|
-
await
|
|
1226
|
+
await mkdir(dirname(path), { recursive: true });
|
|
1227
|
+
await writeFile(path, `${JSON.stringify(items, null, 4)}\n`);
|
|
1084
1228
|
this.log.info(`Export to ${path} finished!`);
|
|
1085
1229
|
}
|
|
1086
1230
|
return items;
|
|
@@ -1088,11 +1232,11 @@ export class BasicCrawler {
|
|
|
1088
1232
|
/**
|
|
1089
1233
|
* Initializes the crawler.
|
|
1090
1234
|
*/
|
|
1091
|
-
async
|
|
1235
|
+
async init() {
|
|
1092
1236
|
const eventManager = serviceLocator.getEventManager();
|
|
1093
1237
|
if (!eventManager.isInitialized()) {
|
|
1094
1238
|
await eventManager.init();
|
|
1095
|
-
this
|
|
1239
|
+
this.#closeEvents = true;
|
|
1096
1240
|
}
|
|
1097
1241
|
// Warn once at startup if the internal timeout is shorter than the phases it is meant to outlast. It is
|
|
1098
1242
|
// floored per request so it will not actually cut them short, but the configured value is then effectively
|
|
@@ -1107,11 +1251,11 @@ export class BasicCrawler {
|
|
|
1107
1251
|
// An owned governor is rebuilt (and started) for every run, so it always starts from a clean slate — stale
|
|
1108
1252
|
// resource snapshots or a previous run's scaled desired concurrency would otherwise distort this run's
|
|
1109
1253
|
// scaling. An injected one is long-lived and its lifecycle belongs to the caller.
|
|
1110
|
-
this
|
|
1111
|
-
await this
|
|
1112
|
-
this
|
|
1254
|
+
this.#concurrencySystemDep = this.#resolveConcurrencySystem();
|
|
1255
|
+
await this.#concurrencySystemDep.ifOwned((system) => system.start());
|
|
1256
|
+
this.#autoscaledPool = new AutoscaledPool({
|
|
1113
1257
|
...this.taskLoopOptions,
|
|
1114
|
-
concurrencySystem: this
|
|
1258
|
+
concurrencySystem: this.#concurrencySystemDep.value,
|
|
1115
1259
|
consumer: this.identity,
|
|
1116
1260
|
});
|
|
1117
1261
|
await this.getRequestManager();
|
|
@@ -1162,10 +1306,44 @@ export class BasicCrawler {
|
|
|
1162
1306
|
const timeoutMillis = this.resolveRequestHandlerTimeoutMillis(crawlingContext.request.label);
|
|
1163
1307
|
await addTimeoutToPromise(async () => this.requestHandler(crawlingContext), timeoutMillis, `requestHandler timed out after ${timeoutMillis / 1000} seconds (${crawlingContext.request.id}).`);
|
|
1164
1308
|
}
|
|
1309
|
+
/**
|
|
1310
|
+
* Runs `callback` inside a {@link StorageTransaction}, unless transactional storage is disabled.
|
|
1311
|
+
* Deliberately does **not** commit on return - `handleRequest` swallows request handler failures, so
|
|
1312
|
+
* a normal return says nothing about success. `handleRequest` owns the outcome.
|
|
1313
|
+
*/
|
|
1314
|
+
async runInStorageTransaction(callback) {
|
|
1315
|
+
if (!this.#transactionalStorageEnabled) {
|
|
1316
|
+
return callback();
|
|
1317
|
+
}
|
|
1318
|
+
const transaction = createStorageTransaction({
|
|
1319
|
+
policy: this.#storageWritePolicy,
|
|
1320
|
+
commitTimeoutMillis: this.internalTimeoutMillis,
|
|
1321
|
+
});
|
|
1322
|
+
let threw = true;
|
|
1323
|
+
try {
|
|
1324
|
+
const result = await transaction.run(callback);
|
|
1325
|
+
threw = false;
|
|
1326
|
+
return result;
|
|
1327
|
+
}
|
|
1328
|
+
finally {
|
|
1329
|
+
if (transaction.state === 'open') {
|
|
1330
|
+
// `handleRequest` commits or rolls back on every normal path, so an open transaction on
|
|
1331
|
+
// a normal return is a wiring bug; on a propagating throw (a pipeline-level failure) it is
|
|
1332
|
+
// expected. Either way, discard the unvalidated writes; only the former is worth flagging.
|
|
1333
|
+
if (!threw) {
|
|
1334
|
+
this.log.error('Internal error: a storage transaction was still open after the request pipeline ' +
|
|
1335
|
+
'returned normally. Its writes are being discarded. Please report this.');
|
|
1336
|
+
}
|
|
1337
|
+
transaction.rollback();
|
|
1338
|
+
}
|
|
1339
|
+
// Unconditional: `failed` is a terminal state that the branch above never reaches.
|
|
1340
|
+
transaction.dispose();
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1165
1343
|
/**
|
|
1166
1344
|
* Handles blocked request
|
|
1167
1345
|
*/
|
|
1168
|
-
|
|
1346
|
+
throwOnBlockedRequest(statusCode) {
|
|
1169
1347
|
if (this.retryOnBlocked)
|
|
1170
1348
|
return;
|
|
1171
1349
|
if (this.blockedStatusCodes.has(statusCode)) {
|
|
@@ -1173,25 +1351,65 @@ export class BasicCrawler {
|
|
|
1173
1351
|
}
|
|
1174
1352
|
}
|
|
1175
1353
|
async isAllowedBasedOnRobotsTxtFile(url) {
|
|
1176
|
-
if (!this
|
|
1354
|
+
if (!this.#respectRobotsTxtFile) {
|
|
1177
1355
|
return true;
|
|
1178
1356
|
}
|
|
1179
1357
|
const robotsTxtFile = await this.getRobotsTxtFileForUrl(url);
|
|
1180
|
-
const userAgent = typeof this
|
|
1358
|
+
const userAgent = typeof this.#respectRobotsTxtFile === 'object' ? this.#respectRobotsTxtFile?.userAgent : '*';
|
|
1359
|
+
if (robotsTxtFile) {
|
|
1360
|
+
const crawlDelay = robotsTxtFile.getCrawlDelay(userAgent);
|
|
1361
|
+
if (crawlDelay !== undefined) {
|
|
1362
|
+
this.applyCrawlDelay(url, crawlDelay);
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1181
1365
|
return !robotsTxtFile || robotsTxtFile.isAllowed(url, userAgent);
|
|
1182
1366
|
}
|
|
1367
|
+
/**
|
|
1368
|
+
* Records an HTTP 429 against the URL's domain so the request manager can pace the retry.
|
|
1369
|
+
*
|
|
1370
|
+
* @param retryAfterHeader The raw `Retry-After` response header, if the server sent one.
|
|
1371
|
+
* @returns `true` if a manager took responsibility for the delay, in which case the caller should throw
|
|
1372
|
+
* {@link RequestThrottledError} rather than treating the response as a blocked session.
|
|
1373
|
+
*/
|
|
1374
|
+
recordDomainRateLimit(url, retryAfterHeader) {
|
|
1375
|
+
if (supportsDomainThrottling(this.requestManager) &&
|
|
1376
|
+
this.requestManager.recordDomainDelay(url, parseRetryAfterHeader(retryAfterHeader))) {
|
|
1377
|
+
return true;
|
|
1378
|
+
}
|
|
1379
|
+
const domain = hostnameOrUrl(url);
|
|
1380
|
+
this.logOncePerRun(`rateLimitNotThrottled:${domain}`, `"${domain}" responded with HTTP 429 (Too Many Requests), but nothing is set up to back off from it, ` +
|
|
1381
|
+
'so the response is handled like any other, with no per-domain delay. ' +
|
|
1382
|
+
`Pass a \`ThrottlingRequestManager\` as \`requestManager\` and include "${domain}" in its \`domains\` ` +
|
|
1383
|
+
'option to honour `Retry-After` and apply exponential backoff instead.', 'warning');
|
|
1384
|
+
return false;
|
|
1385
|
+
}
|
|
1386
|
+
/**
|
|
1387
|
+
* Hands a robots.txt `Crawl-delay` to the request manager, warning if nothing is able to honour it.
|
|
1388
|
+
*
|
|
1389
|
+
* The warning is driven by whether the delay was actually accepted rather than by the type of the manager,
|
|
1390
|
+
* because a manager that does throttle still drops the delay for a domain missing from its `domains` list.
|
|
1391
|
+
*/
|
|
1392
|
+
applyCrawlDelay(url, delaySeconds) {
|
|
1393
|
+
if (supportsDomainThrottling(this.requestManager) && this.requestManager.setCrawlDelay(url, delaySeconds)) {
|
|
1394
|
+
return;
|
|
1395
|
+
}
|
|
1396
|
+
const domain = hostnameOrUrl(url);
|
|
1397
|
+
this.logOncePerRun(`crawlDelayIgnored:${domain}`, `robots.txt for "${domain}" defines a crawl-delay of ${delaySeconds}s, but nothing is set up to honour it, ` +
|
|
1398
|
+
'so requests to that domain will not be paced. Pass a `ThrottlingRequestManager` as `requestManager` ' +
|
|
1399
|
+
`and include "${domain}" in its \`domains\` option to enforce the delay.`, 'warning');
|
|
1400
|
+
}
|
|
1183
1401
|
async getRobotsTxtFileForUrl(url) {
|
|
1184
|
-
if (!this
|
|
1402
|
+
if (!this.#respectRobotsTxtFile) {
|
|
1185
1403
|
return undefined;
|
|
1186
1404
|
}
|
|
1187
1405
|
try {
|
|
1188
1406
|
const origin = new URL(url).origin;
|
|
1189
|
-
const cachedRobotsTxtFile = this
|
|
1407
|
+
const cachedRobotsTxtFile = this.#robotsTxtFileCache.get(origin);
|
|
1190
1408
|
if (cachedRobotsTxtFile) {
|
|
1191
1409
|
return cachedRobotsTxtFile;
|
|
1192
1410
|
}
|
|
1193
1411
|
const robotsTxtFile = await RobotsTxtFile.find(url, { logger: this.log });
|
|
1194
|
-
this
|
|
1412
|
+
this.#robotsTxtFileCache.add(origin, robotsTxtFile);
|
|
1195
1413
|
return robotsTxtFile;
|
|
1196
1414
|
}
|
|
1197
1415
|
catch (e) {
|
|
@@ -1200,9 +1418,9 @@ export class BasicCrawler {
|
|
|
1200
1418
|
}
|
|
1201
1419
|
}
|
|
1202
1420
|
async pauseOnMigration() {
|
|
1203
|
-
if (this
|
|
1421
|
+
if (this.#autoscaledPool) {
|
|
1204
1422
|
// if run wasn't called, this is going to crash
|
|
1205
|
-
await this
|
|
1423
|
+
await this.#autoscaledPool.pause(SAFE_MIGRATION_WAIT_MILLIS).catch((err) => {
|
|
1206
1424
|
if (err.message.includes('running tasks did not finish')) {
|
|
1207
1425
|
this.log.error('The crawler was paused due to migration to another host, ' +
|
|
1208
1426
|
"but some requests did not finish in time. Those requests' results may be duplicated.");
|
|
@@ -1231,7 +1449,7 @@ export class BasicCrawler {
|
|
|
1231
1449
|
});
|
|
1232
1450
|
}
|
|
1233
1451
|
})();
|
|
1234
|
-
await Promise.all([requestManagerPersistPromise, this.
|
|
1452
|
+
await Promise.all([requestManagerPersistPromise, this.statistics.persistState?.()]);
|
|
1235
1453
|
}
|
|
1236
1454
|
/**
|
|
1237
1455
|
* Fetches the next request to process from the underlying request provider.
|
|
@@ -1242,30 +1460,6 @@ export class BasicCrawler {
|
|
|
1242
1460
|
}
|
|
1243
1461
|
return this.requestManager.fetchNextRequest();
|
|
1244
1462
|
}
|
|
1245
|
-
/**
|
|
1246
|
-
* Delays processing of the request based on the `sameDomainDelaySecs` option,
|
|
1247
|
-
* adding it back to the queue after the timeout passes. Returns `true` if the request
|
|
1248
|
-
* should be ignored and will be reclaimed to the queue once ready.
|
|
1249
|
-
*/
|
|
1250
|
-
delayRequest(request, source) {
|
|
1251
|
-
const domain = getDomain(request.url);
|
|
1252
|
-
if (!domain || !request) {
|
|
1253
|
-
return false;
|
|
1254
|
-
}
|
|
1255
|
-
const now = Date.now();
|
|
1256
|
-
const lastAccessTime = this.domainAccessedTime.get(domain);
|
|
1257
|
-
if (!lastAccessTime || now - lastAccessTime >= this.sameDomainDelayMillis) {
|
|
1258
|
-
this.domainAccessedTime.set(domain, now);
|
|
1259
|
-
return false;
|
|
1260
|
-
}
|
|
1261
|
-
const delay = lastAccessTime + this.sameDomainDelayMillis - now;
|
|
1262
|
-
this.log.debug(`Request ${request.url} (${request.id}) will be reclaimed after ${delay} milliseconds due to same domain delay`);
|
|
1263
|
-
setTimeout(async () => {
|
|
1264
|
-
this.log.debug(`Adding request ${request.url} (${request.id}) back to the queue`);
|
|
1265
|
-
await source.reclaimRequest(request, { forefront: request.userData?.__crawlee?.forefront });
|
|
1266
|
-
}, delay);
|
|
1267
|
-
return true;
|
|
1268
|
-
}
|
|
1269
1463
|
/** Handles a single request - runs the request handler with retries, error handling, and lifecycle management. */
|
|
1270
1464
|
async handleRequest(crawlingContext, requestSource, request) {
|
|
1271
1465
|
// An earlier phase we cannot cancel (e.g. a slow `extendContext`) may have run past the internal timeout,
|
|
@@ -1275,18 +1469,26 @@ export class BasicCrawler {
|
|
|
1275
1469
|
return;
|
|
1276
1470
|
}
|
|
1277
1471
|
const statisticsId = request.id || request.uniqueKey;
|
|
1472
|
+
// Opened by `runInStorageTransaction`; absent when disabled or when the subclass opens its own.
|
|
1473
|
+
const transaction = currentStorageTransaction();
|
|
1278
1474
|
let isRequestLocked = true;
|
|
1279
1475
|
try {
|
|
1280
1476
|
request.state = RequestState.REQUEST_HANDLER;
|
|
1281
1477
|
await this.runRequestHandler(crawlingContext);
|
|
1478
|
+
// Commit *before* marking the request as handled, so a commit failure fails the request and
|
|
1479
|
+
// it is retried. This also closes the transaction, so everything below passes through.
|
|
1480
|
+
await transaction?.commit();
|
|
1282
1481
|
await this.timeoutAndRetry(async () => requestSource.markRequestAsHandled(request), this.internalTimeoutMillis, `Marking request ${request.url} (${request.id}) as handled timed out after ${this.internalTimeoutMillis / 1e3} seconds.`);
|
|
1283
1482
|
isRequestLocked = false; // markRequestAsHandled succeeded and unlocked the request
|
|
1284
|
-
this.
|
|
1483
|
+
this.statistics.finishJob(statisticsId, request.retryCount);
|
|
1285
1484
|
// reclaim session if request finishes successfully
|
|
1286
1485
|
request.state = RequestState.DONE;
|
|
1287
1486
|
crawlingContext.session.markGood();
|
|
1288
1487
|
}
|
|
1289
1488
|
catch (rawError) {
|
|
1489
|
+
// Roll back *before* any error handler runs - error handlers write to real storage precisely
|
|
1490
|
+
// because the transaction is already closed. A no-op when the commit above succeeded.
|
|
1491
|
+
transaction?.rollback();
|
|
1290
1492
|
const err = this.unwrapError(rawError);
|
|
1291
1493
|
try {
|
|
1292
1494
|
request.state = RequestState.ERROR_HANDLER;
|
|
@@ -1312,7 +1514,7 @@ export class BasicCrawler {
|
|
|
1312
1514
|
}
|
|
1313
1515
|
// decrease the session score if the request fails (but the error handler did not throw);
|
|
1314
1516
|
// skip when the error is a SessionError, which already retired the session
|
|
1315
|
-
if (!(err
|
|
1517
|
+
if (!this.errorAbsolvesSession(err)) {
|
|
1316
1518
|
crawlingContext.session.markBad();
|
|
1317
1519
|
}
|
|
1318
1520
|
}
|
|
@@ -1330,52 +1532,6 @@ export class BasicCrawler {
|
|
|
1330
1532
|
}
|
|
1331
1533
|
}
|
|
1332
1534
|
}
|
|
1333
|
-
/**
|
|
1334
|
-
* Wrapper around the crawling context's `enqueueLinks` method:
|
|
1335
|
-
* - Injects `crawlDepth` to each request being added based on the crawling context request.
|
|
1336
|
-
* - Provides defaults for the `enqueueLinks` options based on the crawler configuration.
|
|
1337
|
-
* - These options can be overridden by the user.
|
|
1338
|
-
* @internal
|
|
1339
|
-
*/
|
|
1340
|
-
async enqueueLinksWithCrawlDepth(options, request, requestManager) {
|
|
1341
|
-
const transformRequestFunctionWrapper = (requestOptions) => {
|
|
1342
|
-
requestOptions.crawlDepth = request.crawlDepth + 1;
|
|
1343
|
-
if (this.maxCrawlDepth !== undefined && requestOptions.crawlDepth > this.maxCrawlDepth) {
|
|
1344
|
-
// Setting `skippedReason` before returning `false` ensures that `reportSkippedRequests`
|
|
1345
|
-
// reports `'depth'` as the reason (via `request.skippedReason ?? reason` fallback),
|
|
1346
|
-
// rather than the generic `'transform'` reason.
|
|
1347
|
-
requestOptions.skippedReason = 'depth';
|
|
1348
|
-
return false;
|
|
1349
|
-
}
|
|
1350
|
-
// After injecting the crawlDepth, we call the user-provided transform function, if there is one.
|
|
1351
|
-
return options.transformRequestFunction?.(requestOptions) ?? requestOptions;
|
|
1352
|
-
};
|
|
1353
|
-
// Create a request-scoped callback that logs enqueueLimit once per request handler call
|
|
1354
|
-
// Only log if an explicit limit was passed to enqueueLinks (not the internal maxRequestsPerCrawl-derived limit)
|
|
1355
|
-
let loggedEnqueueLimitForThisRequest = false;
|
|
1356
|
-
const onSkippedRequest = async (skippedOptions) => {
|
|
1357
|
-
if (skippedOptions.reason === 'enqueueLimit') {
|
|
1358
|
-
if (!loggedEnqueueLimitForThisRequest && options.limit !== undefined) {
|
|
1359
|
-
this.log.info(`Skipping URLs in the handler for ${request.url} due to the enqueueLinks limit of ${options.limit}.`);
|
|
1360
|
-
loggedEnqueueLimitForThisRequest = true;
|
|
1361
|
-
}
|
|
1362
|
-
}
|
|
1363
|
-
await this.handleSkippedRequest(skippedOptions);
|
|
1364
|
-
};
|
|
1365
|
-
// `enqueueLinks` applies `options.label`/`options.userData` to every newly enqueued request, so a single
|
|
1366
|
-
// validation against the label's schema covers them all (a no-op unless the router declares a schema).
|
|
1367
|
-
await this.validateRequestUserData({ label: options.label, userData: options.userData });
|
|
1368
|
-
return await enqueueLinks({
|
|
1369
|
-
requestManager,
|
|
1370
|
-
robotsTxtFile: await this.getRobotsTxtFileForUrl(request.url),
|
|
1371
|
-
respectRobotsTxtFile: this.respectRobotsTxtFile,
|
|
1372
|
-
onSkippedRequest,
|
|
1373
|
-
limit: await this.calculateEnqueuedRequestLimit(options.limit),
|
|
1374
|
-
// Allow user options to override defaults set above ⤴
|
|
1375
|
-
...options,
|
|
1376
|
-
transformRequestFunction: transformRequestFunctionWrapper,
|
|
1377
|
-
});
|
|
1378
|
-
}
|
|
1379
1535
|
/**
|
|
1380
1536
|
* Generator function that yields requests injected with the given crawl depth.
|
|
1381
1537
|
* @internal
|
|
@@ -1438,13 +1594,23 @@ export class BasicCrawler {
|
|
|
1438
1594
|
* @param request The request object, passed separately to circumvent potential dynamic logic in crawlingContext.request
|
|
1439
1595
|
*/
|
|
1440
1596
|
async requestFunctionErrorHandler(error, crawlingContext, request, source) {
|
|
1597
|
+
if (error instanceof RequestThrottledError) {
|
|
1598
|
+
// The domain told us to come back later, so the request was never really attempted. Put it back
|
|
1599
|
+
// without recording a failure - it costs neither a retry nor session reputation.
|
|
1600
|
+
this.log.debug(`Deferring request because its domain is rate-limiting us. ${error.message}`, {
|
|
1601
|
+
id: request.id,
|
|
1602
|
+
url: request.url,
|
|
1603
|
+
});
|
|
1604
|
+
await source.reclaimRequest(request, { forefront: request.userData?.__crawlee?.forefront });
|
|
1605
|
+
return;
|
|
1606
|
+
}
|
|
1441
1607
|
request.pushErrorMessage(error);
|
|
1442
1608
|
if (error instanceof CriticalError) {
|
|
1443
1609
|
throw error;
|
|
1444
1610
|
}
|
|
1445
1611
|
const shouldRetryRequest = this.canRequestBeRetried(request, error);
|
|
1446
1612
|
if (shouldRetryRequest) {
|
|
1447
|
-
await this.
|
|
1613
|
+
await this.statistics.errorTrackerRetry.addAsync(error, crawlingContext);
|
|
1448
1614
|
await this.errorHandler?.(crawlingContext, // valid cast - ExtendedContext transitively extends CrawlingContext
|
|
1449
1615
|
error);
|
|
1450
1616
|
if (error instanceof SessionError) {
|
|
@@ -1455,7 +1621,7 @@ export class BasicCrawler {
|
|
|
1455
1621
|
const { url, retryCount, id } = request;
|
|
1456
1622
|
// We don't want to see the stack trace in the logs by default, when we are going to retry the request.
|
|
1457
1623
|
// Thus, we print the full stack trace only when CRAWLEE_VERBOSE_LOG environment variable is set to true.
|
|
1458
|
-
const message = this.
|
|
1624
|
+
const message = this.getMessageFromError(error);
|
|
1459
1625
|
this.log.warning(`Reclaiming failed request back to the list or queue. ${message}`, {
|
|
1460
1626
|
id,
|
|
1461
1627
|
url,
|
|
@@ -1473,22 +1639,22 @@ export class BasicCrawler {
|
|
|
1473
1639
|
// This is to make sure the error snapshot is not duplicated in the errorTrackerRetry and errorTracker objects.
|
|
1474
1640
|
const { noRetry, maxRetries } = request;
|
|
1475
1641
|
if (noRetry || !maxRetries) {
|
|
1476
|
-
await this.
|
|
1642
|
+
await this.statistics.errorTracker.addAsync(error, crawlingContext);
|
|
1477
1643
|
}
|
|
1478
1644
|
else {
|
|
1479
|
-
this.
|
|
1645
|
+
this.statistics.errorTracker.add(error);
|
|
1480
1646
|
}
|
|
1481
1647
|
// If we get here, the request is either not retryable
|
|
1482
1648
|
// or failed more than retryCount times and will not be retried anymore.
|
|
1483
1649
|
// Mark the request as failed and do not retry.
|
|
1484
1650
|
await source.markRequestAsHandled(request);
|
|
1485
|
-
this.
|
|
1651
|
+
this.statistics.failJob(request.id || request.uniqueKey, request.retryCount);
|
|
1486
1652
|
await this.handleFailedRequestHandler(crawlingContext, error); // This function prints an error message.
|
|
1487
1653
|
}
|
|
1488
1654
|
async handleFailedRequestHandler(crawlingContext, error) {
|
|
1489
1655
|
// Always log the last error regardless if the user provided a failedRequestHandler
|
|
1490
1656
|
const { id, url, method, uniqueKey } = crawlingContext.request;
|
|
1491
|
-
const message = this.
|
|
1657
|
+
const message = this.getMessageFromError(error, true);
|
|
1492
1658
|
this.log.error(`Request failed and reached maximum retries. ${message}`, { id, url, method, uniqueKey });
|
|
1493
1659
|
if (this.failedRequestHandler) {
|
|
1494
1660
|
await this.failedRequestHandler?.(crawlingContext, // valid cast - ExtendedContext transitively extends CrawlingContext
|
|
@@ -1500,7 +1666,7 @@ export class BasicCrawler {
|
|
|
1500
1666
|
* @param error The error received
|
|
1501
1667
|
* @returns The message to be logged
|
|
1502
1668
|
*/
|
|
1503
|
-
|
|
1669
|
+
getMessageFromError(error, forceStack = false) {
|
|
1504
1670
|
if ([TypeError, SyntaxError, ReferenceError].some((type) => error instanceof type)) {
|
|
1505
1671
|
forceStack = true;
|
|
1506
1672
|
}
|
|
@@ -1514,6 +1680,13 @@ export class BasicCrawler {
|
|
|
1514
1680
|
? (error.stack ?? [error.message || error, ...stackLines].join('\n'))
|
|
1515
1681
|
: [error.message || error, userLine].join('\n');
|
|
1516
1682
|
}
|
|
1683
|
+
/**
|
|
1684
|
+
* Whether the session should be spared for this error - either because it was already retired, or because the
|
|
1685
|
+
* failure says nothing about the session (a rate limit is a property of the domain).
|
|
1686
|
+
*/
|
|
1687
|
+
errorAbsolvesSession(error) {
|
|
1688
|
+
return error instanceof SessionError || error instanceof RequestThrottledError;
|
|
1689
|
+
}
|
|
1517
1690
|
canRequestBeRetried(request, error) {
|
|
1518
1691
|
// Request should never be retried, or the error encountered makes it not able to be retried.
|
|
1519
1692
|
if (request.noRetry || error instanceof NonRetryableError) {
|
|
@@ -1535,15 +1708,20 @@ export class BasicCrawler {
|
|
|
1535
1708
|
* To stop the crawler gracefully (waiting for all running requests to finish), use {@link BasicCrawler.stop|`crawler.stop()`} instead.
|
|
1536
1709
|
*/
|
|
1537
1710
|
async teardown() {
|
|
1538
|
-
|
|
1539
|
-
|
|
1711
|
+
// When this crawler initialized the event manager, its close() call emits
|
|
1712
|
+
// the final persistence event after the crawler-specific state has been
|
|
1713
|
+
// saved. External event managers still need an explicit event here.
|
|
1714
|
+
if (!this.#closeEvents) {
|
|
1715
|
+
serviceLocator.getEventManager().emit(EventType.PERSIST_STATE, { isMigrating: false });
|
|
1716
|
+
}
|
|
1717
|
+
await this.#sessionPoolDep.ifOwned(async (pool) => pool.teardown({ persistState: this.#closeEvents ?? false }));
|
|
1718
|
+
if (this.#closeEvents) {
|
|
1540
1719
|
await serviceLocator.getEventManager().close();
|
|
1541
1720
|
}
|
|
1542
|
-
await this
|
|
1543
|
-
await this
|
|
1544
|
-
await this.concurrencySystemDep?.ifOwned((system) => system.stop());
|
|
1721
|
+
await this.#autoscaledPool?.abort();
|
|
1722
|
+
await this.#concurrencySystemDep?.ifOwned((system) => system.stop());
|
|
1545
1723
|
}
|
|
1546
|
-
|
|
1724
|
+
getCookieHeaderFromRequest(request) {
|
|
1547
1725
|
if (request.headers?.Cookie && request.headers?.cookie) {
|
|
1548
1726
|
this.log.warning(`Encountered mixed casing for the cookie headers for request ${request.url} (${request.id}). Their values will be merged.`);
|
|
1549
1727
|
return mergeCookies(request.url, [request.headers.cookie, request.headers.Cookie]);
|
|
@@ -1599,6 +1777,10 @@ export class BasicCrawler {
|
|
|
1599
1777
|
}
|
|
1600
1778
|
}
|
|
1601
1779
|
}
|
|
1780
|
+
/** The hostname of `url`, falling back to the whole string when it is not parseable - for log messages only. */
|
|
1781
|
+
function hostnameOrUrl(url) {
|
|
1782
|
+
return URL.canParse(url) ? new URL(url).hostname : url;
|
|
1783
|
+
}
|
|
1602
1784
|
export function createBasicRouter(routes) {
|
|
1603
1785
|
return Router.create(routes);
|
|
1604
1786
|
}
|