@crawlee/basic 4.0.0-beta.7 → 4.0.0-beta.70
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -13
- package/index.d.ts +1 -1
- package/index.d.ts.map +1 -1
- package/index.js +0 -1
- package/index.js.map +1 -1
- package/internals/basic-crawler.d.ts +370 -180
- package/internals/basic-crawler.d.ts.map +1 -1
- package/internals/basic-crawler.js +817 -425
- package/internals/basic-crawler.js.map +1 -1
- package/internals/send-request.d.ts +3 -5
- package/internals/send-request.d.ts.map +1 -1
- package/internals/send-request.js +21 -25
- package/internals/send-request.js.map +1 -1
- package/package.json +10 -8
- package/internals/constants.d.ts +0 -7
- package/internals/constants.d.ts.map +0 -1
- package/internals/constants.js +0 -7
- package/internals/constants.js.map +0 -1
- package/tsconfig.build.tsbuildinfo +0 -1
|
@@ -1,16 +1,31 @@
|
|
|
1
1
|
import { writeFile } from 'node:fs/promises';
|
|
2
2
|
import { dirname } from 'node:path';
|
|
3
|
-
import { AutoscaledPool,
|
|
4
|
-
import {
|
|
3
|
+
import { AutoscaledPool, bindMethodsToServiceLocator, BLOCKED_STATUS_CODES, ContextPipeline, ContextPipelineCleanupError, ContextPipelineInitializationError, ContextPipelineInterruptedError, CriticalError, Dataset, enqueueLinks, EnqueueStrategy, KeyValueStore, log, LogLevel, mergeCookies, MissingSessionError, NavigationSkippedError, NonRetryableError, purgeDefaultStorages, RequestHandlerError, RequestManagerTandem, RequestQueue, RequestState, RetryRequestError, Router, ServiceLocator, serviceLocator, Session, SessionError, SessionPool, Statistics, validators, } from '@crawlee/core';
|
|
4
|
+
import { FetchHttpClient } from '@crawlee/http-client';
|
|
5
|
+
import { getObjectType, isAsyncIterable, isIterable, RobotsTxtFile, ROTATE_PROXY_ERRORS } from '@crawlee/utils';
|
|
5
6
|
import { stringify } from 'csv-stringify/sync';
|
|
6
7
|
import { ensureDir, writeJSON } from 'fs-extra/esm';
|
|
7
8
|
import ow from 'ow';
|
|
8
9
|
import { getDomain } from 'tldts';
|
|
9
10
|
import { LruCache } from '@apify/datastructures';
|
|
10
|
-
import
|
|
11
|
-
import { addTimeoutToPromise, TimeoutError, tryCancel } from '@apify/timeout';
|
|
11
|
+
import { addTimeoutToPromise, TimeoutError } from '@apify/timeout';
|
|
12
12
|
import { cryptoRandomObjectId } from '@apify/utilities';
|
|
13
13
|
import { createSendRequest } from './send-request.js';
|
|
14
|
+
class LazyDefaultHttpClient {
|
|
15
|
+
_delegatePromise;
|
|
16
|
+
constructor(options) {
|
|
17
|
+
this._delegatePromise = import('@crawlee/impit-client')
|
|
18
|
+
.then(({ ImpitHttpClient }) => new ImpitHttpClient(options))
|
|
19
|
+
.catch(() => {
|
|
20
|
+
(options?.logger ?? log).warning('Optional dependency @crawlee/impit-client is not installed. ' +
|
|
21
|
+
'Falling back to native fetch — proxy support and browser fingerprinting are unavailable.');
|
|
22
|
+
return new FetchHttpClient(options);
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
async sendRequest(...args) {
|
|
26
|
+
return (await this._delegatePromise).sendRequest(...args);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
14
29
|
/**
|
|
15
30
|
* Since there's no set number of seconds before the container is terminated after
|
|
16
31
|
* a migration event, we need some reasonable number to use for RequestList persistence.
|
|
@@ -21,6 +36,7 @@ import { createSendRequest } from './send-request.js';
|
|
|
21
36
|
* @ignore
|
|
22
37
|
*/
|
|
23
38
|
const SAFE_MIGRATION_WAIT_MILLIS = 20000;
|
|
39
|
+
const deferredCleanupKey = Symbol('deferredCleanup');
|
|
24
40
|
/**
|
|
25
41
|
* Provides a simple framework for parallel crawling of web pages.
|
|
26
42
|
* The URLs to crawl are fed either from a static list of URLs
|
|
@@ -33,15 +49,22 @@ const SAFE_MIGRATION_WAIT_MILLIS = 20000;
|
|
|
33
49
|
*
|
|
34
50
|
* `BasicCrawler` invokes the user-provided {@link BasicCrawlerOptions.requestHandler|`requestHandler`}
|
|
35
51
|
* for each {@link Request} object, which represents a single URL to crawl.
|
|
36
|
-
* The {@link Request} objects are fed from the {@link
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
52
|
+
* The {@link Request} objects are fed from the {@link IRequestManager|request manager} provided via the
|
|
53
|
+
* {@link BasicCrawlerOptions.requestManager|`requestManager`} constructor option (a {@link RequestQueue} is
|
|
54
|
+
* itself a request manager). If no `requestManager` is provided, the crawler opens the default {@link RequestQueue}
|
|
55
|
+
* either when the {@link BasicCrawler.addRequests|`crawler.addRequests()`} function is called, or if the `requests`
|
|
56
|
+
* parameter (representing the initial requests) of the {@link BasicCrawler.run|`crawler.run()`} function is provided.
|
|
41
57
|
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
58
|
+
* To read requests from a read-only source such as a {@link RequestList} or {@link SitemapRequestLoader} while
|
|
59
|
+
* still being able to enqueue new ones, combine the loader with a queue into a {@link RequestManagerTandem} using
|
|
60
|
+
* {@link IRequestLoader.toTandem|`requestLoader.toTandem()`} and pass the result as `requestManager`. The tandem
|
|
61
|
+
* first processes URLs from the loader and automatically enqueues them into the queue, ensuring a single URL is not
|
|
62
|
+
* crawled multiple times.
|
|
63
|
+
*
|
|
64
|
+
* > The legacy {@link BasicCrawlerOptions.requestList|`requestList`} and
|
|
65
|
+
* > {@link BasicCrawlerOptions.requestQueue|`requestQueue`} options are deprecated. They are still accepted and
|
|
66
|
+
* > folded into a single `requestManager` (combined into a tandem when both are given), but new code should use
|
|
67
|
+
* > `requestManager` directly.
|
|
45
68
|
*
|
|
46
69
|
* The crawler finishes if there are no more {@link Request} objects to crawl.
|
|
47
70
|
*
|
|
@@ -86,28 +109,52 @@ const SAFE_MIGRATION_WAIT_MILLIS = 20000;
|
|
|
86
109
|
* @category Crawlers
|
|
87
110
|
*/
|
|
88
111
|
export class BasicCrawler {
|
|
89
|
-
config;
|
|
90
112
|
static CRAWLEE_STATE_KEY = 'CRAWLEE_STATE';
|
|
113
|
+
/**
|
|
114
|
+
* Tracks crawler instances that accessed shared state without having an explicit id.
|
|
115
|
+
* Used to detect and warn about multiple crawlers sharing the same state.
|
|
116
|
+
*/
|
|
117
|
+
static useStateCrawlerIds = new Set();
|
|
118
|
+
/**
|
|
119
|
+
* Tracks the number of crawler instances created. The first crawler uses the default
|
|
120
|
+
* request queue; subsequent ones get their own queue via a unique alias so they don't
|
|
121
|
+
* collide.
|
|
122
|
+
*/
|
|
123
|
+
static instanceCount = 0;
|
|
91
124
|
/**
|
|
92
125
|
* A reference to the underlying {@link Statistics} class that collects and logs run statistics for requests.
|
|
93
126
|
*/
|
|
94
127
|
stats;
|
|
95
128
|
/**
|
|
96
|
-
*
|
|
97
|
-
*
|
|
129
|
+
* The main request-handling component of the crawler. It manages the requests that the crawler processes,
|
|
130
|
+
* combining any provided request loader and/or queue. It's initialized during the crawler startup or lazily
|
|
131
|
+
* via {@link BasicCrawler.getRequestManager|`getRequestManager()`}.
|
|
98
132
|
*/
|
|
99
|
-
|
|
133
|
+
requestManager;
|
|
100
134
|
/**
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
* Only available if used by the crawler.
|
|
135
|
+
* A reference to the underlying session pool that manages the crawler's {@link Session|sessions}. Typed as
|
|
136
|
+
* {@link ISessionPool} so custom implementations can be plugged in via the `sessionPool` constructor option.
|
|
104
137
|
*/
|
|
105
|
-
|
|
138
|
+
sessionPool;
|
|
106
139
|
/**
|
|
107
|
-
*
|
|
108
|
-
*
|
|
140
|
+
* Set when the crawler constructed its own {@link SessionPool} (no `sessionPool` option was provided).
|
|
141
|
+
* Holds the same instance as `sessionPool`, but typed as the concrete class so the crawler can call
|
|
142
|
+
* lifecycle methods (`resetStore`, `teardown`) that aren't part of {@link ISessionPool}. A user-supplied
|
|
143
|
+
* pool is never owned and never torn down by the crawler.
|
|
109
144
|
*/
|
|
110
|
-
|
|
145
|
+
ownedSessionPool;
|
|
146
|
+
/**
|
|
147
|
+
* Set when the crawler constructed its own request manager (no `requestManager`, `requestQueue`, or `requestList`
|
|
148
|
+
* option was provided). The owned manager is purged (not dropped) between repeated `run()` calls.
|
|
149
|
+
* A user-supplied manager is never purged by the crawler.
|
|
150
|
+
*/
|
|
151
|
+
ownedRequestManager;
|
|
152
|
+
/**
|
|
153
|
+
* Whether the request-processing-time hint has already been forwarded to the request manager. The hint
|
|
154
|
+
* derives only from `requestHandlerTimeoutMillis` (constant for the crawler's lifetime) and is raise-only,
|
|
155
|
+
* so it only needs to be applied once, at the first async access of the manager.
|
|
156
|
+
*/
|
|
157
|
+
requestManagerTimeoutsApplied = false;
|
|
111
158
|
/**
|
|
112
159
|
* A reference to the underlying {@link AutoscaledPool} class that manages the concurrency of the crawler.
|
|
113
160
|
* > *NOTE:* This property is only initialized after calling the {@link BasicCrawler.run|`crawler.run()`} function.
|
|
@@ -116,40 +163,76 @@ export class BasicCrawler {
|
|
|
116
163
|
* or to abort it by calling {@link AutoscaledPool.abort|`autoscaledPool.abort()`}.
|
|
117
164
|
*/
|
|
118
165
|
autoscaledPool;
|
|
166
|
+
/**
|
|
167
|
+
* A reference to the underlying {@link ProxyConfiguration} class that manages the crawler's proxies.
|
|
168
|
+
* Only available if used by the crawler.
|
|
169
|
+
*/
|
|
170
|
+
proxyConfiguration;
|
|
119
171
|
/**
|
|
120
172
|
* Default {@link Router} instance that will be used if we don't specify any {@link BasicCrawlerOptions.requestHandler|`requestHandler`}.
|
|
121
173
|
* See {@link Router.addHandler|`router.addHandler()`} and {@link Router.addDefaultHandler|`router.addDefaultHandler()`}.
|
|
122
174
|
*/
|
|
123
175
|
router = Router.create();
|
|
176
|
+
_basicContextPipeline;
|
|
177
|
+
/**
|
|
178
|
+
* The basic part of the context pipeline. Unlike the subclass pipeline, this
|
|
179
|
+
* part has no major side effects (e.g. launching a browser). It also makes typing more explicit, as subclass
|
|
180
|
+
* pipelines expect the basic crawler fields to already be present in the context at runtime.
|
|
181
|
+
*
|
|
182
|
+
* Context built with this pipeline can be passed into multiple crawler pipelines at once.
|
|
183
|
+
* This is used e.g. in the {@link AdaptivePlaywrightCrawler|`AdaptivePlaywrightCrawler`}.
|
|
184
|
+
*/
|
|
185
|
+
get basicContextPipeline() {
|
|
186
|
+
if (this._basicContextPipeline === undefined) {
|
|
187
|
+
this._basicContextPipeline = this.buildBasicContextPipeline();
|
|
188
|
+
}
|
|
189
|
+
return this._basicContextPipeline;
|
|
190
|
+
}
|
|
191
|
+
_contextPipeline;
|
|
192
|
+
get contextPipeline() {
|
|
193
|
+
if (this._contextPipeline === undefined) {
|
|
194
|
+
this._contextPipeline = this.buildFinalContextPipeline();
|
|
195
|
+
}
|
|
196
|
+
return this._contextPipeline;
|
|
197
|
+
}
|
|
124
198
|
running = false;
|
|
125
199
|
hasFinishedBefore = false;
|
|
126
|
-
|
|
200
|
+
unexpectedStop = false;
|
|
201
|
+
#log;
|
|
202
|
+
get log() {
|
|
203
|
+
return this.#log;
|
|
204
|
+
}
|
|
127
205
|
requestHandler;
|
|
128
206
|
errorHandler;
|
|
129
207
|
failedRequestHandler;
|
|
130
208
|
requestHandlerTimeoutMillis;
|
|
131
209
|
internalTimeoutMillis;
|
|
132
210
|
maxRequestRetries;
|
|
211
|
+
maxCrawlDepth;
|
|
133
212
|
sameDomainDelayMillis;
|
|
134
213
|
domainAccessedTime;
|
|
135
|
-
|
|
136
|
-
handledRequestsCount;
|
|
214
|
+
maxRequestsPerCrawl;
|
|
215
|
+
handledRequestsCount = 0;
|
|
137
216
|
statusMessageLoggingInterval;
|
|
138
217
|
statusMessageCallback;
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
218
|
+
blockedStatusCodes = new Set();
|
|
219
|
+
additionalHttpErrorStatusCodes;
|
|
220
|
+
ignoreHttpErrorStatusCodes;
|
|
142
221
|
autoscaledPoolOptions;
|
|
143
|
-
events;
|
|
144
222
|
httpClient;
|
|
145
223
|
retryOnBlocked;
|
|
146
224
|
respectRobotsTxtFile;
|
|
147
225
|
onSkippedRequest;
|
|
148
226
|
_closeEvents;
|
|
149
|
-
|
|
227
|
+
loggedPerRun = new Set();
|
|
150
228
|
robotsTxtFileCache;
|
|
151
|
-
|
|
229
|
+
crawlerId;
|
|
230
|
+
hasExplicitId;
|
|
231
|
+
crawlerInstanceIndex;
|
|
232
|
+
contextPipelineOptions;
|
|
152
233
|
static optionsShape = {
|
|
234
|
+
contextPipelineBuilder: ow.optional.object,
|
|
235
|
+
extendContext: ow.optional.function,
|
|
153
236
|
requestList: ow.optional.object.validate(validators.requestList),
|
|
154
237
|
requestQueue: ow.optional.object.validate(validators.requestQueue),
|
|
155
238
|
// Subclasses override this function instead of passing it
|
|
@@ -161,145 +244,360 @@ export class BasicCrawler {
|
|
|
161
244
|
failedRequestHandler: ow.optional.function,
|
|
162
245
|
maxRequestRetries: ow.optional.number,
|
|
163
246
|
sameDomainDelaySecs: ow.optional.number,
|
|
164
|
-
maxSessionRotations: ow.optional.number,
|
|
165
247
|
maxRequestsPerCrawl: ow.optional.number,
|
|
248
|
+
maxCrawlDepth: ow.optional.number,
|
|
166
249
|
autoscaledPoolOptions: ow.optional.object,
|
|
167
|
-
|
|
168
|
-
|
|
250
|
+
sessionPool: ow.optional.object.validate(validators.sessionPool),
|
|
251
|
+
proxyConfiguration: ow.optional.object.validate(validators.proxyConfiguration),
|
|
169
252
|
statusMessageLoggingInterval: ow.optional.number,
|
|
170
253
|
statusMessageCallback: ow.optional.function,
|
|
254
|
+
additionalHttpErrorStatusCodes: ow.optional.array.ofType(ow.number),
|
|
255
|
+
ignoreHttpErrorStatusCodes: ow.optional.array.ofType(ow.number),
|
|
256
|
+
blockedStatusCodes: ow.optional.array.ofType(ow.number),
|
|
171
257
|
retryOnBlocked: ow.optional.boolean,
|
|
172
|
-
respectRobotsTxtFile: ow.optional.boolean,
|
|
258
|
+
respectRobotsTxtFile: ow.optional.any(ow.boolean, ow.object),
|
|
173
259
|
onSkippedRequest: ow.optional.function,
|
|
174
260
|
httpClient: ow.optional.object,
|
|
261
|
+
configuration: ow.optional.object,
|
|
262
|
+
storageBackend: ow.optional.object,
|
|
263
|
+
eventManager: ow.optional.object,
|
|
264
|
+
logger: ow.optional.object,
|
|
175
265
|
// AutoscaledPool shorthands
|
|
176
266
|
minConcurrency: ow.optional.number,
|
|
177
267
|
maxConcurrency: ow.optional.number,
|
|
178
268
|
maxRequestsPerMinute: ow.optional.number.integerOrInfinite.positive.greaterThanOrEqual(1),
|
|
179
269
|
keepAlive: ow.optional.boolean,
|
|
180
|
-
// internal
|
|
181
|
-
log: ow.optional.object,
|
|
182
|
-
experiments: ow.optional.object,
|
|
183
270
|
statisticsOptions: ow.optional.object,
|
|
271
|
+
id: ow.optional.string,
|
|
184
272
|
};
|
|
185
273
|
/**
|
|
186
274
|
* All `BasicCrawler` parameters are passed via an options object.
|
|
187
275
|
*/
|
|
188
|
-
constructor(options = {}
|
|
189
|
-
this.config = config;
|
|
276
|
+
constructor(options = {}) {
|
|
190
277
|
ow(options, 'BasicCrawlerOptions', ow.object.exactShape(BasicCrawler.optionsShape));
|
|
191
|
-
const {
|
|
278
|
+
const {
|
|
279
|
+
// oxlint-disable-next-line typescript/no-deprecated -- still accepted and folded into `requestManager` for back-compat
|
|
280
|
+
requestList,
|
|
281
|
+
// oxlint-disable-next-line typescript/no-deprecated -- still accepted and folded into `requestManager` for back-compat
|
|
282
|
+
requestQueue, requestManager, maxRequestRetries = 3, sameDomainDelaySecs = 0, maxRequestsPerCrawl, maxCrawlDepth, autoscaledPoolOptions = {}, keepAlive, sessionPool, proxyConfiguration, additionalHttpErrorStatusCodes = [], ignoreHttpErrorStatusCodes = [],
|
|
283
|
+
// Service locator options
|
|
284
|
+
configuration, storageBackend, eventManager, logger,
|
|
192
285
|
// AutoscaledPool shorthands
|
|
193
|
-
minConcurrency, maxConcurrency, maxRequestsPerMinute, retryOnBlocked = false, respectRobotsTxtFile = false, onSkippedRequest, requestHandler, requestHandlerTimeoutSecs, errorHandler, failedRequestHandler, statusMessageLoggingInterval = 10, statusMessageCallback, statisticsOptions, httpClient,
|
|
194
|
-
//
|
|
195
|
-
|
|
196
|
-
this
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
this.requestHandler = requestHandler ?? this.router;
|
|
207
|
-
this.failedRequestHandler = failedRequestHandler;
|
|
208
|
-
this.errorHandler = errorHandler;
|
|
209
|
-
if (requestHandlerTimeoutSecs) {
|
|
210
|
-
this.requestHandlerTimeoutMillis = requestHandlerTimeoutSecs * 1000;
|
|
286
|
+
minConcurrency, maxConcurrency, maxRequestsPerMinute, blockedStatusCodes: blockedStatusCodesInput, retryOnBlocked = false, respectRobotsTxtFile = false, onSkippedRequest, requestHandler, requestHandlerTimeoutSecs, errorHandler, failedRequestHandler, statusMessageLoggingInterval = 10, statusMessageCallback, statisticsOptions, httpClient, id, } = options;
|
|
287
|
+
// Create per-crawler service locator if custom services were provided.
|
|
288
|
+
// This wraps every method on the crawler instance so that calls to the global `serviceLocator`
|
|
289
|
+
// (via AsyncLocalStorage) resolve to this scoped instance instead.
|
|
290
|
+
// We also enter the scope for the rest of the constructor body, so that any code below
|
|
291
|
+
// that accesses `serviceLocator` will see the correct (scoped) instance.
|
|
292
|
+
let serviceLocatorScope = { enterScope: () => { }, exitScope: () => { } };
|
|
293
|
+
if (storageBackend ||
|
|
294
|
+
eventManager ||
|
|
295
|
+
logger ||
|
|
296
|
+
(configuration !== undefined && configuration !== serviceLocator.getConfiguration())) {
|
|
297
|
+
const scopedServiceLocator = new ServiceLocator(configuration, eventManager, storageBackend, logger);
|
|
298
|
+
serviceLocatorScope = bindMethodsToServiceLocator(scopedServiceLocator, this);
|
|
211
299
|
}
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
this.maxRequestRetries = maxRequestRetries;
|
|
230
|
-
this.sameDomainDelayMillis = sameDomainDelaySecs * 1000;
|
|
231
|
-
this.maxSessionRotations = maxSessionRotations;
|
|
232
|
-
this.handledRequestsCount = 0;
|
|
233
|
-
this.stats = new Statistics({
|
|
234
|
-
logMessage: `${log.getOptions().prefix} request statistics:`,
|
|
235
|
-
log,
|
|
236
|
-
config,
|
|
237
|
-
...statisticsOptions,
|
|
238
|
-
});
|
|
239
|
-
this.sessionPoolOptions = {
|
|
240
|
-
...sessionPoolOptions,
|
|
241
|
-
log,
|
|
242
|
-
};
|
|
243
|
-
if (this.retryOnBlocked) {
|
|
244
|
-
this.sessionPoolOptions.blockedStatusCodes = sessionPoolOptions.blockedStatusCodes ?? [];
|
|
245
|
-
if (this.sessionPoolOptions.blockedStatusCodes.length !== 0) {
|
|
246
|
-
log.warning(`Both 'blockedStatusCodes' and 'retryOnBlocked' are set. Please note that the 'retryOnBlocked' feature might not work as expected.`);
|
|
300
|
+
try {
|
|
301
|
+
serviceLocatorScope.enterScope();
|
|
302
|
+
this.contextPipelineOptions = {
|
|
303
|
+
contextPipelineBuilder: options.contextPipelineBuilder,
|
|
304
|
+
extendContext: options.extendContext,
|
|
305
|
+
};
|
|
306
|
+
this.#log = serviceLocator.getLogger().child({ prefix: this.constructor.name });
|
|
307
|
+
// Store whether the user explicitly provided an ID
|
|
308
|
+
this.hasExplicitId = id !== undefined;
|
|
309
|
+
// Store the user-provided ID, or generate a unique one for tracking purposes (not for state key)
|
|
310
|
+
this.crawlerId = id ?? cryptoRandomObjectId();
|
|
311
|
+
this.crawlerInstanceIndex = BasicCrawler.instanceCount++;
|
|
312
|
+
if (requestManager !== undefined) {
|
|
313
|
+
if (requestList !== undefined || requestQueue !== undefined) {
|
|
314
|
+
throw new Error('The `requestManager` option cannot be used in conjunction with `requestList` and/or `requestQueue`');
|
|
315
|
+
}
|
|
316
|
+
this.requestManager = requestManager;
|
|
247
317
|
}
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
318
|
+
else if (requestList !== undefined && requestQueue !== undefined) {
|
|
319
|
+
// Combine the read-only list with the writable queue into a tandem.
|
|
320
|
+
this.requestManager = new RequestManagerTandem(requestList, requestQueue);
|
|
321
|
+
}
|
|
322
|
+
else if (requestQueue !== undefined) {
|
|
323
|
+
// A RequestQueue is itself a request manager.
|
|
324
|
+
this.requestManager = requestQueue;
|
|
325
|
+
}
|
|
326
|
+
else if (requestList !== undefined) {
|
|
327
|
+
// A lone read-only `requestList` (deprecated option) is combined with a lazily-opened default queue
|
|
328
|
+
// into a tandem, so that its requests are read first and new ones can still be enqueued during the
|
|
329
|
+
// crawl. The queue is opened on first use; the tandem also forwards `persistState()` to the loader.
|
|
330
|
+
this.requestManager = new RequestManagerTandem(requestList, () => this.openOwnedRequestQueue());
|
|
331
|
+
}
|
|
332
|
+
this.httpClient = httpClient ?? new LazyDefaultHttpClient({ logger: this.log });
|
|
333
|
+
this.proxyConfiguration = proxyConfiguration;
|
|
334
|
+
this.statusMessageLoggingInterval = statusMessageLoggingInterval;
|
|
335
|
+
this.statusMessageCallback = statusMessageCallback;
|
|
336
|
+
this.domainAccessedTime = new Map();
|
|
337
|
+
this.robotsTxtFileCache = new LruCache({ maxLength: 1000 });
|
|
338
|
+
this.handleSkippedRequest = this.handleSkippedRequest.bind(this);
|
|
339
|
+
this.additionalHttpErrorStatusCodes = new Set([...additionalHttpErrorStatusCodes]);
|
|
340
|
+
this.ignoreHttpErrorStatusCodes = new Set([...ignoreHttpErrorStatusCodes]);
|
|
341
|
+
this.requestHandler = requestHandler ?? this.router;
|
|
342
|
+
this.failedRequestHandler = failedRequestHandler;
|
|
343
|
+
this.errorHandler = errorHandler;
|
|
344
|
+
if (requestHandlerTimeoutSecs) {
|
|
345
|
+
this.requestHandlerTimeoutMillis = requestHandlerTimeoutSecs * 1000;
|
|
346
|
+
}
|
|
347
|
+
else {
|
|
348
|
+
this.requestHandlerTimeoutMillis = 60_000;
|
|
349
|
+
}
|
|
350
|
+
this.retryOnBlocked = retryOnBlocked;
|
|
351
|
+
this.respectRobotsTxtFile = respectRobotsTxtFile;
|
|
352
|
+
this.onSkippedRequest = onSkippedRequest;
|
|
353
|
+
const tryEnv = (val) => (val == null ? null : +val);
|
|
354
|
+
// allow at least 5min for internal timeouts
|
|
355
|
+
this.internalTimeoutMillis =
|
|
356
|
+
tryEnv(process.env.CRAWLEE_INTERNAL_TIMEOUT) ?? Math.max(this.requestHandlerTimeoutMillis * 2, 300e3);
|
|
357
|
+
this.maxRequestRetries = maxRequestRetries;
|
|
358
|
+
this.maxCrawlDepth = maxCrawlDepth;
|
|
359
|
+
this.sameDomainDelayMillis = sameDomainDelaySecs * 1000;
|
|
360
|
+
this.stats = new Statistics({
|
|
361
|
+
logMessage: `${this.constructor.name} request statistics:`,
|
|
362
|
+
log: this.log,
|
|
363
|
+
...(this.hasExplicitId ? { id: this.crawlerId } : {}),
|
|
364
|
+
...statisticsOptions,
|
|
365
|
+
});
|
|
366
|
+
if (sessionPool && proxyConfiguration) {
|
|
367
|
+
this.log.warning('Both `sessionPool` and `proxyConfiguration` were provided to the crawler. ' +
|
|
368
|
+
'The `proxyConfiguration` is ignored - sessions from the supplied pool keep whatever ' +
|
|
369
|
+
'`proxyInfo` they were created with. Configure proxies on the pool instead, ' +
|
|
370
|
+
'e.g. via `addSession({ proxyInfo })` or a custom `createSessionFunction`.');
|
|
371
|
+
}
|
|
372
|
+
if (sessionPool) {
|
|
373
|
+
this.sessionPool = sessionPool;
|
|
374
|
+
}
|
|
375
|
+
else {
|
|
376
|
+
this.ownedSessionPool = new SessionPool({
|
|
377
|
+
createSessionFunction: async (opts) => new Session({
|
|
378
|
+
...opts?.sessionOptions,
|
|
379
|
+
proxyInfo: opts?.sessionOptions?.proxyInfo ?? (await this.proxyConfiguration?.newProxyInfo()),
|
|
380
|
+
}),
|
|
381
|
+
});
|
|
382
|
+
this.sessionPool = this.ownedSessionPool;
|
|
383
|
+
}
|
|
384
|
+
this.blockedStatusCodes = new Set(blockedStatusCodesInput ?? BLOCKED_STATUS_CODES);
|
|
385
|
+
const maxSignedInteger = 2 ** 31 - 1;
|
|
386
|
+
if (this.requestHandlerTimeoutMillis > maxSignedInteger) {
|
|
387
|
+
this.log.warning(`requestHandlerTimeoutMillis ${this.requestHandlerTimeoutMillis}` +
|
|
388
|
+
` does not fit a signed 32-bit integer. Limiting the value to ${maxSignedInteger}`);
|
|
389
|
+
this.requestHandlerTimeoutMillis = maxSignedInteger;
|
|
390
|
+
}
|
|
391
|
+
this.internalTimeoutMillis = Math.min(this.internalTimeoutMillis, maxSignedInteger);
|
|
392
|
+
this.maxRequestsPerCrawl = maxRequestsPerCrawl;
|
|
393
|
+
const isMaxPagesExceeded = () => this.maxRequestsPerCrawl && this.maxRequestsPerCrawl <= this.handledRequestsCount;
|
|
394
|
+
// eslint-disable-next-line prefer-const
|
|
395
|
+
let { isFinishedFunction, isTaskReadyFunction } = autoscaledPoolOptions;
|
|
396
|
+
// override even if `isFinishedFunction` provided by user - `keepAlive` has higher priority
|
|
397
|
+
if (keepAlive) {
|
|
398
|
+
isFinishedFunction = async () => false;
|
|
399
|
+
}
|
|
400
|
+
const basicCrawlerAutoscaledPoolConfiguration = {
|
|
401
|
+
minConcurrency: minConcurrency ?? autoscaledPoolOptions?.minConcurrency,
|
|
402
|
+
maxConcurrency: maxConcurrency ?? autoscaledPoolOptions?.maxConcurrency,
|
|
403
|
+
maxTasksPerMinute: maxRequestsPerMinute ?? autoscaledPoolOptions?.maxTasksPerMinute,
|
|
404
|
+
runTaskFunction: async () => {
|
|
405
|
+
const source = this.requestManager;
|
|
406
|
+
if (!source)
|
|
407
|
+
throw new Error('Request provider is not initialized!');
|
|
408
|
+
const request = await this.resolveRequest();
|
|
409
|
+
if (!request || this.delayRequest(request, source)) {
|
|
410
|
+
return;
|
|
277
411
|
}
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
412
|
+
const crawlingContext = { request };
|
|
413
|
+
try {
|
|
414
|
+
await this.basicContextPipeline
|
|
415
|
+
.chain(this.contextPipeline)
|
|
416
|
+
.call(crawlingContext, (ctx) => this.handleRequest(ctx, source, request));
|
|
417
|
+
}
|
|
418
|
+
catch (error) {
|
|
419
|
+
// ContextPipelineInterruptedError means the request was intentionally skipped
|
|
420
|
+
// (e.g., doesn't match enqueue strategy after redirect). Just return gracefully.
|
|
421
|
+
if (error instanceof ContextPipelineInterruptedError) {
|
|
422
|
+
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.`);
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
// If the error happened during pipeline initialization (e.g., navigation timeout, session/proxy error,
|
|
426
|
+
// i.e. not in user's requestHandler), handle it through the normal error flow.
|
|
427
|
+
const isPipelineError = error instanceof ContextPipelineInitializationError || error instanceof SessionError;
|
|
428
|
+
if (isPipelineError) {
|
|
429
|
+
const unwrappedError = this.unwrapError(error);
|
|
430
|
+
await this._requestFunctionErrorHandler(unwrappedError, crawlingContext, request, this.requestManager);
|
|
431
|
+
// SessionError already retired the session in `_requestFunctionErrorHandler`;
|
|
432
|
+
// skip `markBad` to avoid double-counting usage/error score.
|
|
433
|
+
if (!(unwrappedError instanceof SessionError)) {
|
|
434
|
+
crawlingContext.session?.markBad();
|
|
435
|
+
}
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
throw this.unwrapError(error);
|
|
439
|
+
}
|
|
440
|
+
},
|
|
441
|
+
isTaskReadyFunction: async () => {
|
|
442
|
+
if (isMaxPagesExceeded()) {
|
|
443
|
+
this.logOncePerRun('shuttingDown', 'Crawler reached the maxRequestsPerCrawl limit of ' +
|
|
444
|
+
`${this.maxRequestsPerCrawl} requests and will shut down soon. Requests that are in progress will be allowed to finish.`);
|
|
445
|
+
return false;
|
|
446
|
+
}
|
|
447
|
+
if (this.unexpectedStop) {
|
|
448
|
+
this.logOncePerRun('shuttingDown', 'No new requests are allowed because the `stop()` method has been called. ' +
|
|
449
|
+
'Ongoing requests will be allowed to complete.');
|
|
450
|
+
return false;
|
|
451
|
+
}
|
|
452
|
+
return isTaskReadyFunction ? await isTaskReadyFunction() : await this._isTaskReadyFunction();
|
|
453
|
+
},
|
|
454
|
+
isFinishedFunction: async () => {
|
|
455
|
+
if (isMaxPagesExceeded()) {
|
|
456
|
+
this.log.info(`Earlier, the crawler reached the maxRequestsPerCrawl limit of ${this.maxRequestsPerCrawl} requests ` +
|
|
457
|
+
'and all requests that were in progress at that time have now finished. ' +
|
|
458
|
+
`In total, the crawler processed ${this.handledRequestsCount} requests and will shut down.`);
|
|
459
|
+
return true;
|
|
460
|
+
}
|
|
461
|
+
if (this.unexpectedStop) {
|
|
462
|
+
this.log.info('The crawler has finished all the remaining ongoing requests and will shut down now.');
|
|
463
|
+
return true;
|
|
464
|
+
}
|
|
465
|
+
const isFinished = isFinishedFunction
|
|
466
|
+
? await isFinishedFunction()
|
|
467
|
+
: await this._defaultIsFinishedFunction();
|
|
468
|
+
if (isFinished) {
|
|
469
|
+
const reason = isFinishedFunction
|
|
470
|
+
? "Crawler's custom isFinishedFunction() returned true, the crawler will shut down."
|
|
471
|
+
: 'All requests from the queue have been processed, the crawler will shut down.';
|
|
472
|
+
this.log.info(reason);
|
|
473
|
+
}
|
|
474
|
+
return isFinished;
|
|
475
|
+
},
|
|
476
|
+
log: this.log,
|
|
477
|
+
};
|
|
478
|
+
this.autoscaledPoolOptions = { ...autoscaledPoolOptions, ...basicCrawlerAutoscaledPoolConfiguration };
|
|
479
|
+
}
|
|
480
|
+
finally {
|
|
481
|
+
serviceLocatorScope.exitScope();
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
/**
|
|
485
|
+
* Determines if the given HTTP status code is an error status code given
|
|
486
|
+
* the default behaviour and user-set preferences.
|
|
487
|
+
* @param status
|
|
488
|
+
* @returns `true` if the status code is considered an error, `false` otherwise
|
|
489
|
+
*/
|
|
490
|
+
isErrorStatusCode(status) {
|
|
491
|
+
const excludeError = this.ignoreHttpErrorStatusCodes.has(status);
|
|
492
|
+
const includeError = this.additionalHttpErrorStatusCodes.has(status);
|
|
493
|
+
return (status >= 500 && !excludeError) || includeError;
|
|
494
|
+
}
|
|
495
|
+
/**
|
|
496
|
+
* Builds the basic context pipeline that transforms `{ request }` into a full `CrawlingContext`.
|
|
497
|
+
* This handles base context creation, session resolution, and context helpers.
|
|
498
|
+
*/
|
|
499
|
+
buildBasicContextPipeline() {
|
|
500
|
+
return ContextPipeline.create()
|
|
501
|
+
.compose({ action: this.checkRobotsTxt.bind(this) })
|
|
502
|
+
.compose({
|
|
503
|
+
action: () => this.createBaseContext(),
|
|
504
|
+
cleanup: async (context) => {
|
|
505
|
+
await Promise.all(context[deferredCleanupKey].map((fn) => fn()));
|
|
281
506
|
},
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
507
|
+
})
|
|
508
|
+
.compose({ action: this.resolveSession.bind(this) })
|
|
509
|
+
.compose({ action: this.createContextHelpers.bind(this) });
|
|
510
|
+
}
|
|
511
|
+
async checkRobotsTxt({ request }) {
|
|
512
|
+
if (!(await this.isAllowedBasedOnRobotsTxtFile(request.url))) {
|
|
513
|
+
this.log.warning(`Skipping request ${request.url} (${request.id}) because it is disallowed based on robots.txt`);
|
|
514
|
+
request.state = RequestState.SKIPPED;
|
|
515
|
+
request.noRetry = true;
|
|
516
|
+
await this.handleSkippedRequest({
|
|
517
|
+
url: request.url,
|
|
518
|
+
reason: 'robotsTxt',
|
|
519
|
+
});
|
|
520
|
+
throw new ContextPipelineInterruptedError(`Skipping request ${request.url} as disallowed by robots.txt`);
|
|
521
|
+
}
|
|
522
|
+
return {};
|
|
523
|
+
}
|
|
524
|
+
/**
|
|
525
|
+
* Builds the subclass-specific context pipeline that transforms a `CrawlingContext` into the crawler's target context type.
|
|
526
|
+
* Subclasses should override this to add their own pipeline stages.
|
|
527
|
+
*/
|
|
528
|
+
buildContextPipeline() {
|
|
529
|
+
return ContextPipeline.create();
|
|
530
|
+
}
|
|
531
|
+
createBaseContext() {
|
|
532
|
+
const deferredCleanup = [];
|
|
533
|
+
return {
|
|
534
|
+
id: cryptoRandomObjectId(10),
|
|
535
|
+
log: this.log,
|
|
536
|
+
pushData: this.pushData.bind(this),
|
|
537
|
+
useState: this.useState.bind(this),
|
|
538
|
+
getKeyValueStore: async (identifier) => KeyValueStore.open(identifier),
|
|
539
|
+
registerDeferredCleanup: (cleanup) => {
|
|
540
|
+
deferredCleanup.push(cleanup);
|
|
299
541
|
},
|
|
300
|
-
|
|
542
|
+
[deferredCleanupKey]: deferredCleanup,
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
async resolveRequest() {
|
|
546
|
+
const request = await this._timeoutAndRetry(this._fetchNextRequest.bind(this), this.internalTimeoutMillis, `Fetching next request timed out after ${this.internalTimeoutMillis / 1e3} seconds.`);
|
|
547
|
+
// Reset loadedUrl so an old one is not carried over to retries.
|
|
548
|
+
if (request) {
|
|
549
|
+
request.loadedUrl = undefined;
|
|
550
|
+
}
|
|
551
|
+
return request;
|
|
552
|
+
}
|
|
553
|
+
async resolveSession({ request }) {
|
|
554
|
+
const session = await this._timeoutAndRetry(async () => {
|
|
555
|
+
const existingSession = await this.sessionPool.getSession(request.sessionId);
|
|
556
|
+
if (!existingSession) {
|
|
557
|
+
throw new ContextPipelineInitializationError(new MissingSessionError(request.sessionId));
|
|
558
|
+
}
|
|
559
|
+
return existingSession;
|
|
560
|
+
}, this.internalTimeoutMillis, `Fetching session timed out after ${this.internalTimeoutMillis / 1e3} seconds.`);
|
|
561
|
+
return { session, proxyInfo: session?.proxyInfo };
|
|
562
|
+
}
|
|
563
|
+
async createContextHelpers({ request, session }) {
|
|
564
|
+
const enqueueLinksWrapper = async (options) => {
|
|
565
|
+
const requestManager = await this.getRequestManager();
|
|
566
|
+
return await this.enqueueLinksWithCrawlDepth(options, request, requestManager);
|
|
567
|
+
};
|
|
568
|
+
const addRequests = async (requests, options = {}) => {
|
|
569
|
+
const newCrawlDepth = request.crawlDepth + 1;
|
|
570
|
+
const requestsGenerator = this.addCrawlDepthRequestGenerator(requests, newCrawlDepth);
|
|
571
|
+
await this.addRequests(requestsGenerator, options);
|
|
301
572
|
};
|
|
302
|
-
|
|
573
|
+
const sendRequest = createSendRequest(this.httpClient, request, session);
|
|
574
|
+
return { enqueueLinks: enqueueLinksWrapper, addRequests, sendRequest };
|
|
575
|
+
}
|
|
576
|
+
buildFinalContextPipeline() {
|
|
577
|
+
let contextPipeline = (this.contextPipelineOptions.contextPipelineBuilder?.() ??
|
|
578
|
+
this.buildContextPipeline());
|
|
579
|
+
const { extendContext } = this.contextPipelineOptions;
|
|
580
|
+
if (extendContext !== undefined) {
|
|
581
|
+
contextPipeline = contextPipeline.compose({
|
|
582
|
+
action: async (context) => await extendContext(context),
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
contextPipeline = contextPipeline.compose({
|
|
586
|
+
action: async (context) => {
|
|
587
|
+
const { request } = context;
|
|
588
|
+
if (request && !this.requestMatchesEnqueueStrategy(request)) {
|
|
589
|
+
// eslint-disable-next-line dot-notation
|
|
590
|
+
const message = `Skipping request ${request.id} (starting url: ${request.url} -> loaded url: ${request.loadedUrl}) because it does not match the enqueue strategy (${request['enqueueStrategy']}).`;
|
|
591
|
+
this.log.debug(message);
|
|
592
|
+
request.noRetry = true;
|
|
593
|
+
request.state = RequestState.SKIPPED;
|
|
594
|
+
await this.handleSkippedRequest({ url: request.url, reason: 'redirect' });
|
|
595
|
+
throw new ContextPipelineInterruptedError(message);
|
|
596
|
+
}
|
|
597
|
+
return context;
|
|
598
|
+
},
|
|
599
|
+
});
|
|
600
|
+
return contextPipeline;
|
|
303
601
|
}
|
|
304
602
|
/**
|
|
305
603
|
* Checks if the given error is a proxy error by comparing its message to a list of known proxy error messages.
|
|
@@ -311,25 +609,27 @@ export class BasicCrawler {
|
|
|
311
609
|
return ROTATE_PROXY_ERRORS.some((x) => this._getMessageFromError(error)?.includes(x));
|
|
312
610
|
}
|
|
313
611
|
/**
|
|
314
|
-
*
|
|
315
|
-
*
|
|
316
|
-
* @param _crawlingContext The crawling context to check.
|
|
317
|
-
*/
|
|
318
|
-
async isRequestBlocked(_crawlingContext) {
|
|
319
|
-
throw new Error('the "isRequestBlocked" method is not implemented in this crawler.');
|
|
320
|
-
}
|
|
321
|
-
/**
|
|
612
|
+
* Sets the status message for the current crawler run.
|
|
613
|
+
*
|
|
322
614
|
* This method is periodically called by the crawler, every `statusMessageLoggingInterval` seconds.
|
|
615
|
+
*
|
|
616
|
+
* The message is logged and broadcast via the {@link EventType.STATUS_MESSAGE|`statusMessage`}
|
|
617
|
+
* event. Integrations such as the Apify SDK subscribe to that event and forward the message to
|
|
618
|
+
* their status-reporting backend (e.g. the Apify platform).
|
|
323
619
|
*/
|
|
324
|
-
|
|
620
|
+
setStatusMessage(message, options = {}) {
|
|
325
621
|
const data = options.isStatusMessageTerminal != null ? { terminal: options.isStatusMessageTerminal } : undefined;
|
|
326
|
-
this.log.
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
622
|
+
this.log.logWithLevel(LogLevel[options.level ?? 'DEBUG'], message, data);
|
|
623
|
+
// Broadcast the status message through the event system. Consumers (e.g. the Apify SDK) can
|
|
624
|
+
// subscribe to `EventType.STATUS_MESSAGE` and propagate it to their status-reporting backend.
|
|
625
|
+
// Setting the status message is not a storage concern, so we intentionally don't route it
|
|
626
|
+
// through the storage client anymore.
|
|
627
|
+
serviceLocator.getEventManager().emit("statusMessage" /* EventType.STATUS_MESSAGE */, {
|
|
628
|
+
crawlerId: this.crawlerId,
|
|
629
|
+
message,
|
|
630
|
+
isStatusMessageTerminal: options.isStatusMessageTerminal,
|
|
631
|
+
level: options.level,
|
|
632
|
+
});
|
|
333
633
|
}
|
|
334
634
|
getPeriodicLogger() {
|
|
335
635
|
let previousState = { ...this.stats.state };
|
|
@@ -349,7 +649,7 @@ export class BasicCrawler {
|
|
|
349
649
|
message = `Experiencing problems, ${this.stats.state.requestsFailed - previousState.requestsFailed || this.stats.state.requestsFailed} failed requests in the past ${this.statusMessageLoggingInterval} seconds.`;
|
|
350
650
|
}
|
|
351
651
|
else {
|
|
352
|
-
const total = this.
|
|
652
|
+
const total = await this.requestManager?.getTotalCount();
|
|
353
653
|
message = `Crawled ${this.stats.state.requestsFinished}${total ? `/${total}` : ''} pages, ${this.stats.state.requestsFailed} failed requests, desired concurrency ${this.autoscaledPool?.desiredConcurrency ?? 0}.`;
|
|
354
654
|
}
|
|
355
655
|
if (this.statusMessageCallback) {
|
|
@@ -361,7 +661,7 @@ export class BasicCrawler {
|
|
|
361
661
|
});
|
|
362
662
|
return;
|
|
363
663
|
}
|
|
364
|
-
|
|
664
|
+
this.setStatusMessage(message);
|
|
365
665
|
};
|
|
366
666
|
const interval = setInterval(log, this.statusMessageLoggingInterval * 1e3);
|
|
367
667
|
return { log, stop: () => clearInterval(interval) };
|
|
@@ -380,29 +680,40 @@ export class BasicCrawler {
|
|
|
380
680
|
if (this.running) {
|
|
381
681
|
throw new Error('This crawler instance is already running, you can add more requests to it via `crawler.addRequests()`.');
|
|
382
682
|
}
|
|
383
|
-
const { purgeRequestQueue
|
|
683
|
+
const { purgeRequestQueue, ...addRequestsOptions } = options ?? {};
|
|
384
684
|
if (this.hasFinishedBefore) {
|
|
385
685
|
// When executing the run method for the second time explicitly,
|
|
386
|
-
// we need to purge the
|
|
686
|
+
// we need to purge the RQ to allow processing the same requests again — this is important so users can
|
|
387
687
|
// pass in failed requests back to the `crawler.run()`, otherwise they would be considered as handled and
|
|
388
|
-
// ignored
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
688
|
+
// ignored — as a failed request is still handled.
|
|
689
|
+
// By default (purgeRequestQueue unset or true), only the manager we created ourselves (ownedRequestManager) is purged.
|
|
690
|
+
// When `purgeRequestQueue` is explicitly `true`, we also purge a user-supplied manager.
|
|
691
|
+
// When `purgeRequestQueue` is explicitly `false`, nothing is purged.
|
|
692
|
+
const shouldPurge = purgeRequestQueue !== false;
|
|
693
|
+
const managerToPurge = this.ownedRequestManager ?? (purgeRequestQueue === true ? this.requestManager : undefined);
|
|
694
|
+
if (managerToPurge?.purge && shouldPurge) {
|
|
695
|
+
await managerToPurge.purge();
|
|
696
|
+
this.handledRequestsCount = 0; // This would've been reset by this._init() further down below, but at that point `handledRequestsCount` could prevent `addRequests` from adding the initial requests
|
|
392
697
|
}
|
|
393
698
|
this.stats.reset();
|
|
394
699
|
await this.stats.resetStore();
|
|
395
|
-
await this.
|
|
700
|
+
await this.ownedSessionPool?.resetStore();
|
|
396
701
|
}
|
|
702
|
+
this.unexpectedStop = false;
|
|
397
703
|
this.running = true;
|
|
398
|
-
|
|
704
|
+
this.loggedPerRun.clear();
|
|
705
|
+
await purgeDefaultStorages({
|
|
706
|
+
onlyPurgeOnce: true,
|
|
707
|
+
storageBackend: serviceLocator.getStorageBackend(),
|
|
708
|
+
config: serviceLocator.getConfiguration(),
|
|
709
|
+
});
|
|
399
710
|
if (requests) {
|
|
400
711
|
await this.addRequests(requests, addRequestsOptions);
|
|
401
712
|
}
|
|
402
713
|
await this._init();
|
|
403
714
|
await this.stats.startCapturing();
|
|
404
715
|
const periodicLogger = this.getPeriodicLogger();
|
|
405
|
-
|
|
716
|
+
this.setStatusMessage('Starting the crawler.', { level: 'INFO' });
|
|
406
717
|
const sigintHandler = async () => {
|
|
407
718
|
this.log.warning('Pausing... Press CTRL+C again to force exit. To resume, do: CRAWLEE_PURGE_ON_START=0 npm start');
|
|
408
719
|
await this._pauseOnMigration();
|
|
@@ -411,8 +722,9 @@ export class BasicCrawler {
|
|
|
411
722
|
// Attach a listener to handle migration and aborting events gracefully.
|
|
412
723
|
const boundPauseOnMigration = this._pauseOnMigration.bind(this);
|
|
413
724
|
process.once('SIGINT', sigintHandler);
|
|
414
|
-
|
|
415
|
-
|
|
725
|
+
const eventManager = serviceLocator.getEventManager();
|
|
726
|
+
eventManager.on("migrating" /* EventType.MIGRATING */, boundPauseOnMigration);
|
|
727
|
+
eventManager.on("aborting" /* EventType.ABORTING */, boundPauseOnMigration);
|
|
416
728
|
let stats = {};
|
|
417
729
|
try {
|
|
418
730
|
await this.autoscaledPool.run();
|
|
@@ -421,8 +733,8 @@ export class BasicCrawler {
|
|
|
421
733
|
await this.teardown();
|
|
422
734
|
await this.stats.stopCapturing();
|
|
423
735
|
process.off('SIGINT', sigintHandler);
|
|
424
|
-
|
|
425
|
-
|
|
736
|
+
eventManager.off("migrating" /* EventType.MIGRATING */, boundPauseOnMigration);
|
|
737
|
+
eventManager.off("aborting" /* EventType.ABORTING */, boundPauseOnMigration);
|
|
426
738
|
const finalStats = this.stats.calculate();
|
|
427
739
|
stats = {
|
|
428
740
|
requestsFinished: this.stats.state.requestsFinished,
|
|
@@ -439,7 +751,7 @@ export class BasicCrawler {
|
|
|
439
751
|
mostCommonErrors: this.stats.errorTracker.getMostPopularErrors(3).map(prettify),
|
|
440
752
|
});
|
|
441
753
|
}
|
|
442
|
-
const client =
|
|
754
|
+
const client = serviceLocator.getStorageBackend();
|
|
443
755
|
if (client.teardown) {
|
|
444
756
|
let finished = false;
|
|
445
757
|
setTimeout(() => {
|
|
@@ -451,7 +763,7 @@ export class BasicCrawler {
|
|
|
451
763
|
finished = true;
|
|
452
764
|
}
|
|
453
765
|
periodicLogger.stop();
|
|
454
|
-
|
|
766
|
+
this.setStatusMessage(`Finished! Total ${this.stats.state.requestsFinished + this.stats.state.requestsFailed} requests: ${this.stats.state.requestsFinished} succeeded, ${this.stats.state.requestsFailed} failed.`, { isStatusMessageTerminal: true, level: 'INFO' });
|
|
455
767
|
this.running = false;
|
|
456
768
|
this.hasFinishedBefore = true;
|
|
457
769
|
}
|
|
@@ -461,29 +773,106 @@ export class BasicCrawler {
|
|
|
461
773
|
* Gracefully stops the current run of the crawler.
|
|
462
774
|
*
|
|
463
775
|
* All the tasks active at the time of calling this method will be allowed to finish.
|
|
776
|
+
*
|
|
777
|
+
* To stop the crawler immediately, use {@link BasicCrawler.teardown|`crawler.teardown()`} instead.
|
|
464
778
|
*/
|
|
465
|
-
stop(
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
779
|
+
stop(reason = 'The crawler has been gracefully stopped.') {
|
|
780
|
+
if (this.unexpectedStop) {
|
|
781
|
+
return;
|
|
782
|
+
}
|
|
783
|
+
this.log.info(reason);
|
|
784
|
+
this.unexpectedStop = true;
|
|
785
|
+
}
|
|
786
|
+
/**
|
|
787
|
+
* Returns the crawler's {@link IRequestManager|request manager}, opening the default {@link RequestQueue}
|
|
788
|
+
* if none has been configured or opened yet.
|
|
789
|
+
*/
|
|
790
|
+
async getRequestManager() {
|
|
791
|
+
if (!this.requestManager) {
|
|
792
|
+
this.requestManager = await this.openOwnedRequestQueue();
|
|
793
|
+
}
|
|
794
|
+
// Apply the processing-time hint here (an async lifecycle point) rather than in the constructor,
|
|
795
|
+
// now that `setExpectedRequestProcessingTimeSecs` is async. The hint is raise-only and idempotent,
|
|
796
|
+
// but guard so we do not re-issue it on every call.
|
|
797
|
+
if (!this.requestManagerTimeoutsApplied) {
|
|
798
|
+
this.requestManagerTimeoutsApplied = true;
|
|
799
|
+
await this.applyRequestManagerTimeouts(this.requestManager);
|
|
800
|
+
}
|
|
801
|
+
return this.requestManager;
|
|
475
802
|
}
|
|
803
|
+
/**
|
|
804
|
+
* @deprecated Use {@link BasicCrawler.getRequestManager|`getRequestManager()`} instead. This returns the
|
|
805
|
+
* crawler's request manager, which is no longer guaranteed to be a {@link RequestQueue}.
|
|
806
|
+
*/
|
|
476
807
|
async getRequestQueue() {
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
808
|
+
return this.getRequestManager();
|
|
809
|
+
}
|
|
810
|
+
/**
|
|
811
|
+
* Opens the default {@link RequestQueue}, applies the crawler's timeouts to it and records it as the
|
|
812
|
+
* crawler-owned manager (so it gets purged between repeated `run()` calls).
|
|
813
|
+
* @private
|
|
814
|
+
*/
|
|
815
|
+
async openOwnedRequestQueue() {
|
|
816
|
+
// The first crawler instance uses the default queue (null identifier);
|
|
817
|
+
// subsequent instances get their own queue via a unique alias so they don't collide.
|
|
818
|
+
const identifier = this.crawlerInstanceIndex === 0 ? null : { alias: `__default_${this.crawlerInstanceIndex}__` };
|
|
819
|
+
const requestQueue = await RequestQueue.open(identifier, { config: serviceLocator.getConfiguration() });
|
|
820
|
+
this.ownedRequestManager = requestQueue;
|
|
821
|
+
return requestQueue;
|
|
822
|
+
}
|
|
823
|
+
/**
|
|
824
|
+
* Tells a request manager how long we expect to hold a fetched request, so that one backed by a
|
|
825
|
+
* locking storage backend keeps it reserved for slightly longer than the request handler timeout
|
|
826
|
+
* (with some padding for overhead), but never for less than a minute. This prevents a long-running
|
|
827
|
+
* request from being handed out a second time while it is still being processed — and it works
|
|
828
|
+
* regardless of whether the manager is a plain {@link RequestQueue} or a `RequestManagerTandem`.
|
|
829
|
+
*/
|
|
830
|
+
async applyRequestManagerTimeouts(requestManager) {
|
|
831
|
+
await requestManager.setExpectedRequestProcessingTimeSecs?.(Math.max(this.requestHandlerTimeoutMillis / 1000 + 5, 60));
|
|
482
832
|
}
|
|
483
833
|
async useState(defaultValue = {}) {
|
|
484
|
-
const kvs = await KeyValueStore.open(null, { config:
|
|
834
|
+
const kvs = await KeyValueStore.open(null, { config: serviceLocator.getConfiguration() });
|
|
835
|
+
if (this.hasExplicitId) {
|
|
836
|
+
const stateKey = `${BasicCrawler.CRAWLEE_STATE_KEY}_${this.crawlerId}`;
|
|
837
|
+
return kvs.getAutoSavedValue(stateKey, defaultValue);
|
|
838
|
+
}
|
|
839
|
+
BasicCrawler.useStateCrawlerIds.add(this.crawlerId);
|
|
840
|
+
if (BasicCrawler.useStateCrawlerIds.size > 1) {
|
|
841
|
+
serviceLocator
|
|
842
|
+
.getLogger()
|
|
843
|
+
.warningOnce('Multiple crawler instances are calling useState() without an explicit `id` option. \n' +
|
|
844
|
+
'This means they will share the same state object, which is likely unintended. \n' +
|
|
845
|
+
'To fix this, provide a unique `id` option to each crawler instance. \n' +
|
|
846
|
+
'Example: new BasicCrawler({ id: "my-crawler-1", ... })');
|
|
847
|
+
}
|
|
485
848
|
return kvs.getAutoSavedValue(BasicCrawler.CRAWLEE_STATE_KEY, defaultValue);
|
|
486
849
|
}
|
|
850
|
+
async getPendingRequestCountApproximation() {
|
|
851
|
+
return (await this.requestManager?.getPendingCount()) ?? 0;
|
|
852
|
+
}
|
|
853
|
+
async calculateEnqueuedRequestLimit(explicitLimit) {
|
|
854
|
+
if (this.maxRequestsPerCrawl === undefined) {
|
|
855
|
+
return explicitLimit;
|
|
856
|
+
}
|
|
857
|
+
const limit = Math.max(0, this.maxRequestsPerCrawl - this.handledRequestsCount - (await this.getPendingRequestCountApproximation()));
|
|
858
|
+
return Math.min(limit, explicitLimit ?? Infinity);
|
|
859
|
+
}
|
|
860
|
+
async handleSkippedRequest(options) {
|
|
861
|
+
if (options.reason === 'limit') {
|
|
862
|
+
this.logOncePerRun('maxRequestsPerCrawl', 'The number of requests enqueued by the crawler reached the maxRequestsPerCrawl limit of ' +
|
|
863
|
+
`${this.maxRequestsPerCrawl} requests and no further requests will be added.`);
|
|
864
|
+
}
|
|
865
|
+
if (options.reason === 'depth') {
|
|
866
|
+
this.logOncePerRun('maxCrawlDepth', `The crawler reached the maxCrawlDepth limit of ${this.maxCrawlDepth} and no further requests will be enqueued.`);
|
|
867
|
+
}
|
|
868
|
+
await this.onSkippedRequest?.(options);
|
|
869
|
+
}
|
|
870
|
+
logOncePerRun(key, message) {
|
|
871
|
+
if (!this.loggedPerRun.has(key)) {
|
|
872
|
+
this.log.info(message);
|
|
873
|
+
this.loggedPerRun.add(key);
|
|
874
|
+
}
|
|
875
|
+
}
|
|
487
876
|
/**
|
|
488
877
|
* Adds requests to the queue in batches. By default, it will resolve after the initial batch is added, and continue
|
|
489
878
|
* adding the rest in background. You can configure the batch size via `batchSize` option and the sleep time in between
|
|
@@ -496,46 +885,72 @@ export class BasicCrawler {
|
|
|
496
885
|
* @param options Options for the request queue
|
|
497
886
|
*/
|
|
498
887
|
async addRequests(requests, options = {}) {
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
const
|
|
504
|
-
const
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
888
|
+
await this.getRequestManager();
|
|
889
|
+
const requestLimit = await this.calculateEnqueuedRequestLimit();
|
|
890
|
+
const skippedBecauseOfRobots = new Set();
|
|
891
|
+
const skippedBecauseOfLimit = new Set();
|
|
892
|
+
const skippedBecauseOfMaxCrawlDepth = new Set();
|
|
893
|
+
const isAllowedBasedOnRobotsTxtFile = this.isAllowedBasedOnRobotsTxtFile.bind(this);
|
|
894
|
+
const maxCrawlDepth = this.maxCrawlDepth;
|
|
895
|
+
ow(requests, ow.object
|
|
896
|
+
.is((value) => isIterable(value) || isAsyncIterable(value))
|
|
897
|
+
.message((value) => `Expected an iterable or async iterable, got ${getObjectType(value)}`));
|
|
898
|
+
async function* filteredRequests() {
|
|
899
|
+
let yieldedRequestCount = 0;
|
|
900
|
+
for await (const request of requests) {
|
|
901
|
+
const url = typeof request === 'string' ? request : request.url;
|
|
902
|
+
if (requestLimit !== undefined && yieldedRequestCount >= requestLimit) {
|
|
903
|
+
skippedBecauseOfLimit.add(url);
|
|
904
|
+
continue;
|
|
905
|
+
}
|
|
906
|
+
if (maxCrawlDepth !== undefined && request.crawlDepth > maxCrawlDepth) {
|
|
907
|
+
skippedBecauseOfMaxCrawlDepth.add(url);
|
|
908
|
+
continue;
|
|
909
|
+
}
|
|
910
|
+
if (await isAllowedBasedOnRobotsTxtFile(url)) {
|
|
911
|
+
yield request;
|
|
912
|
+
yieldedRequestCount += 1;
|
|
913
|
+
}
|
|
914
|
+
else {
|
|
915
|
+
skippedBecauseOfRobots.add(url);
|
|
916
|
+
}
|
|
513
917
|
}
|
|
514
918
|
}
|
|
515
|
-
|
|
919
|
+
const result = await this.requestManager.addRequestsBatched(filteredRequests(), options);
|
|
920
|
+
if (skippedBecauseOfRobots.size > 0) {
|
|
516
921
|
this.log.warning(`Some requests were skipped because they were disallowed based on the robots.txt file`, {
|
|
517
|
-
skipped: [...
|
|
922
|
+
skipped: [...skippedBecauseOfRobots],
|
|
518
923
|
});
|
|
519
|
-
if (this.onSkippedRequest) {
|
|
520
|
-
await Promise.all([...skipped].map((url) => {
|
|
521
|
-
return this.onSkippedRequest({ url, reason: 'robotsTxt' });
|
|
522
|
-
}));
|
|
523
|
-
}
|
|
524
924
|
}
|
|
525
|
-
|
|
925
|
+
if (skippedBecauseOfRobots.size > 0 ||
|
|
926
|
+
skippedBecauseOfLimit.size > 0 ||
|
|
927
|
+
skippedBecauseOfMaxCrawlDepth.size > 0) {
|
|
928
|
+
await Promise.all([...skippedBecauseOfRobots]
|
|
929
|
+
.map((url) => {
|
|
930
|
+
return this.handleSkippedRequest({ url, reason: 'robotsTxt' });
|
|
931
|
+
})
|
|
932
|
+
.concat([...skippedBecauseOfLimit].map((url) => {
|
|
933
|
+
return this.handleSkippedRequest({ url, reason: 'limit' });
|
|
934
|
+
}), [...skippedBecauseOfMaxCrawlDepth].map((url) => {
|
|
935
|
+
return this.handleSkippedRequest({ url, reason: 'depth' });
|
|
936
|
+
})));
|
|
937
|
+
}
|
|
938
|
+
return result;
|
|
526
939
|
}
|
|
527
940
|
/**
|
|
528
941
|
* Pushes data to the specified {@link Dataset}, or the default crawler {@link Dataset} by calling {@link Dataset.pushData}.
|
|
529
942
|
*/
|
|
530
|
-
async pushData(data,
|
|
531
|
-
const dataset = await this.getDataset(
|
|
943
|
+
async pushData(data, datasetIdentifier) {
|
|
944
|
+
const dataset = await this.getDataset(datasetIdentifier);
|
|
532
945
|
return dataset.pushData(data);
|
|
533
946
|
}
|
|
534
947
|
/**
|
|
535
948
|
* Retrieves the specified {@link Dataset}, or the default crawler {@link Dataset}.
|
|
536
949
|
*/
|
|
537
|
-
async getDataset(
|
|
538
|
-
return Dataset.open(
|
|
950
|
+
async getDataset(identifier) {
|
|
951
|
+
return Dataset.open(identifier, {
|
|
952
|
+
config: serviceLocator.getConfiguration(),
|
|
953
|
+
});
|
|
539
954
|
}
|
|
540
955
|
/**
|
|
541
956
|
* Retrieves data from the default crawler {@link Dataset} by calling {@link Dataset.getData}.
|
|
@@ -550,8 +965,9 @@ export class BasicCrawler {
|
|
|
550
965
|
*/
|
|
551
966
|
async exportData(path, format, options) {
|
|
552
967
|
const supportedFormats = ['json', 'csv'];
|
|
553
|
-
|
|
554
|
-
|
|
968
|
+
const formatMatch = /\.(json|csv)$/i.exec(path);
|
|
969
|
+
if (!format && formatMatch) {
|
|
970
|
+
format = formatMatch[1].toLowerCase();
|
|
555
971
|
}
|
|
556
972
|
if (!format) {
|
|
557
973
|
throw new Error(`Failed to infer format from the path: '${path}'. Supported formats: ${supportedFormats.join(', ')}`);
|
|
@@ -562,7 +978,21 @@ export class BasicCrawler {
|
|
|
562
978
|
const dataset = await this.getDataset();
|
|
563
979
|
const items = await dataset.export(options);
|
|
564
980
|
if (format === 'csv') {
|
|
565
|
-
|
|
981
|
+
let value;
|
|
982
|
+
if (items.length === 0) {
|
|
983
|
+
value = '';
|
|
984
|
+
}
|
|
985
|
+
else {
|
|
986
|
+
const keys = options?.collectAllKeys
|
|
987
|
+
? Array.from(new Set(items.flatMap(Object.keys)))
|
|
988
|
+
: Object.keys(items[0]);
|
|
989
|
+
value = stringify([
|
|
990
|
+
keys,
|
|
991
|
+
...items.map((item) => {
|
|
992
|
+
return keys.map((k) => item[k]);
|
|
993
|
+
}),
|
|
994
|
+
]);
|
|
995
|
+
}
|
|
566
996
|
await ensureDir(dirname(path));
|
|
567
997
|
await writeFile(path, value);
|
|
568
998
|
this.log.info(`Export to ${path} finished!`);
|
|
@@ -574,32 +1004,33 @@ export class BasicCrawler {
|
|
|
574
1004
|
}
|
|
575
1005
|
return items;
|
|
576
1006
|
}
|
|
1007
|
+
/**
|
|
1008
|
+
* Initializes the crawler.
|
|
1009
|
+
*/
|
|
577
1010
|
async _init() {
|
|
578
|
-
|
|
579
|
-
|
|
1011
|
+
const eventManager = serviceLocator.getEventManager();
|
|
1012
|
+
if (!eventManager.isInitialized()) {
|
|
1013
|
+
await eventManager.init();
|
|
580
1014
|
this._closeEvents = true;
|
|
581
1015
|
}
|
|
582
1016
|
// Initialize AutoscaledPool before awaiting _loadHandledRequestCount(),
|
|
583
1017
|
// so that the caller can get a reference to it before awaiting the promise returned from run()
|
|
584
1018
|
// (otherwise there would be no way)
|
|
585
|
-
this.autoscaledPool = new AutoscaledPool(this.autoscaledPoolOptions
|
|
586
|
-
|
|
587
|
-
this.sessionPool = await SessionPool.open(this.sessionPoolOptions, this.config);
|
|
588
|
-
// Assuming there are not more than 20 browsers running at once;
|
|
589
|
-
this.sessionPool.setMaxListeners(20);
|
|
590
|
-
}
|
|
1019
|
+
this.autoscaledPool = new AutoscaledPool(this.autoscaledPoolOptions);
|
|
1020
|
+
await this.getRequestManager();
|
|
591
1021
|
await this._loadHandledRequestCount();
|
|
592
1022
|
}
|
|
593
|
-
async
|
|
594
|
-
await this.requestHandler(crawlingContext);
|
|
1023
|
+
async runRequestHandler(crawlingContext) {
|
|
1024
|
+
await addTimeoutToPromise(async () => this.requestHandler(crawlingContext), this.requestHandlerTimeoutMillis, `requestHandler timed out after ${this.requestHandlerTimeoutMillis / 1000} seconds (${crawlingContext.request.id}).`);
|
|
595
1025
|
}
|
|
596
1026
|
/**
|
|
597
1027
|
* Handles blocked request
|
|
598
1028
|
*/
|
|
599
|
-
_throwOnBlockedRequest(
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
1029
|
+
_throwOnBlockedRequest(statusCode) {
|
|
1030
|
+
if (this.retryOnBlocked)
|
|
1031
|
+
return;
|
|
1032
|
+
if (this.blockedStatusCodes.has(statusCode)) {
|
|
1033
|
+
throw new SessionError(`Request blocked - received ${statusCode} status code.`);
|
|
603
1034
|
}
|
|
604
1035
|
}
|
|
605
1036
|
async isAllowedBasedOnRobotsTxtFile(url) {
|
|
@@ -607,7 +1038,8 @@ export class BasicCrawler {
|
|
|
607
1038
|
return true;
|
|
608
1039
|
}
|
|
609
1040
|
const robotsTxtFile = await this.getRobotsTxtFileForUrl(url);
|
|
610
|
-
|
|
1041
|
+
const userAgent = typeof this.respectRobotsTxtFile === 'object' ? this.respectRobotsTxtFile?.userAgent : '*';
|
|
1042
|
+
return !robotsTxtFile || robotsTxtFile.isAllowed(url, userAgent);
|
|
611
1043
|
}
|
|
612
1044
|
async getRobotsTxtFileForUrl(url) {
|
|
613
1045
|
if (!this.respectRobotsTxtFile) {
|
|
@@ -619,7 +1051,7 @@ export class BasicCrawler {
|
|
|
619
1051
|
if (cachedRobotsTxtFile) {
|
|
620
1052
|
return cachedRobotsTxtFile;
|
|
621
1053
|
}
|
|
622
|
-
const robotsTxtFile = await RobotsTxtFile.find(url);
|
|
1054
|
+
const robotsTxtFile = await RobotsTxtFile.find(url, { logger: this.log });
|
|
623
1055
|
this.robotsTxtFileCache.add(origin, robotsTxtFile);
|
|
624
1056
|
return robotsTxtFile;
|
|
625
1057
|
}
|
|
@@ -641,11 +1073,13 @@ export class BasicCrawler {
|
|
|
641
1073
|
}
|
|
642
1074
|
});
|
|
643
1075
|
}
|
|
644
|
-
const
|
|
645
|
-
if
|
|
646
|
-
|
|
1076
|
+
const requestManagerPersistPromise = (async () => {
|
|
1077
|
+
// The request manager persists its read-only loader's state, if it has one that supports persistence
|
|
1078
|
+
// (e.g. a tandem wrapping a `RequestList`). For a plain `RequestQueue`, this is a no-op.
|
|
1079
|
+
if (this.requestManager?.persistState) {
|
|
1080
|
+
if (await this.requestManager.isFinished())
|
|
647
1081
|
return;
|
|
648
|
-
await this.
|
|
1082
|
+
await this.requestManager.persistState().catch((err) => {
|
|
649
1083
|
if (err.message.includes('Cannot persist state.')) {
|
|
650
1084
|
this.log.error("The crawler attempted to persist its request list's state and failed due to missing or " +
|
|
651
1085
|
'invalid config. Make sure to use either RequestList.open() or the "stateKeyPrefix" option of RequestList ' +
|
|
@@ -658,39 +1092,17 @@ export class BasicCrawler {
|
|
|
658
1092
|
});
|
|
659
1093
|
}
|
|
660
1094
|
})();
|
|
661
|
-
await Promise.all([
|
|
1095
|
+
await Promise.all([requestManagerPersistPromise, this.stats.persistState()]);
|
|
662
1096
|
}
|
|
663
1097
|
/**
|
|
664
|
-
* Fetches request
|
|
665
|
-
* and RequestQueue is present then enqueues it to the queue first.
|
|
1098
|
+
* Fetches the next request to process from the underlying request provider.
|
|
666
1099
|
*/
|
|
667
1100
|
async _fetchNextRequest() {
|
|
668
|
-
if (
|
|
669
|
-
|
|
670
|
-
}
|
|
671
|
-
const request = await this.requestList.fetchNextRequest();
|
|
672
|
-
if (!this.requestQueue)
|
|
673
|
-
return request;
|
|
674
|
-
if (!request)
|
|
675
|
-
return this.requestQueue.fetchNextRequest();
|
|
676
|
-
try {
|
|
677
|
-
await this.requestQueue.addRequest(request, { forefront: true });
|
|
1101
|
+
if (this.requestManager === undefined) {
|
|
1102
|
+
throw new Error(`_fetchNextRequest called on an uninitialized crawler`);
|
|
678
1103
|
}
|
|
679
|
-
|
|
680
|
-
// If requestQueue.addRequest() fails here then we must reclaim it back to
|
|
681
|
-
// the RequestList because probably it's not yet in the queue!
|
|
682
|
-
this.log.error('Adding of request from the RequestList to the RequestQueue failed, reclaiming request back to the list.', { request });
|
|
683
|
-
await this.requestList.reclaimRequest(request);
|
|
684
|
-
return null;
|
|
685
|
-
}
|
|
686
|
-
await this.requestList.markRequestHandled(request);
|
|
687
|
-
return this.requestQueue.fetchNextRequest();
|
|
1104
|
+
return this.requestManager.fetchNextRequest();
|
|
688
1105
|
}
|
|
689
|
-
/**
|
|
690
|
-
* Executed when `errorHandler` finishes or the request is successful.
|
|
691
|
-
* Can be used to clean up orphaned browser pages.
|
|
692
|
-
*/
|
|
693
|
-
async _cleanupContext(_crawlingContext) { }
|
|
694
1106
|
/**
|
|
695
1107
|
* Delays processing of the request based on the `sameDomainDelaySecs` option,
|
|
696
1108
|
* adding it back to the queue after the timeout passes. Returns `true` if the request
|
|
@@ -707,149 +1119,144 @@ export class BasicCrawler {
|
|
|
707
1119
|
this.domainAccessedTime.set(domain, now);
|
|
708
1120
|
return false;
|
|
709
1121
|
}
|
|
710
|
-
if (source instanceof RequestQueueV1) {
|
|
711
|
-
// eslint-disable-next-line dot-notation
|
|
712
|
-
source['inProgress']?.delete(request.id);
|
|
713
|
-
}
|
|
714
1122
|
const delay = lastAccessTime + this.sameDomainDelayMillis - now;
|
|
715
1123
|
this.log.debug(`Request ${request.url} (${request.id}) will be reclaimed after ${delay} milliseconds due to same domain delay`);
|
|
716
1124
|
setTimeout(async () => {
|
|
717
1125
|
this.log.debug(`Adding request ${request.url} (${request.id}) back to the queue`);
|
|
718
|
-
if (source instanceof RequestQueueV1) {
|
|
719
|
-
// eslint-disable-next-line dot-notation
|
|
720
|
-
source['inProgress'].add(request.id);
|
|
721
|
-
}
|
|
722
1126
|
await source.reclaimRequest(request, { forefront: request.userData?.__crawlee?.forefront });
|
|
723
1127
|
}, delay);
|
|
724
1128
|
return true;
|
|
725
1129
|
}
|
|
726
|
-
/**
|
|
727
|
-
|
|
728
|
-
* then retries them in a case of an error, etc.
|
|
729
|
-
*/
|
|
730
|
-
async _runTaskFunction() {
|
|
731
|
-
const source = this.requestQueue || this.requestList || (await this.getRequestQueue());
|
|
732
|
-
let request;
|
|
733
|
-
let session;
|
|
734
|
-
await this._timeoutAndRetry(async () => {
|
|
735
|
-
request = await this._fetchNextRequest();
|
|
736
|
-
}, this.internalTimeoutMillis, `Fetching next request timed out after ${this.internalTimeoutMillis / 1e3} seconds.`);
|
|
737
|
-
tryCancel();
|
|
738
|
-
if (this.useSessionPool) {
|
|
739
|
-
await this._timeoutAndRetry(async () => {
|
|
740
|
-
session = await this.sessionPool.getSession();
|
|
741
|
-
}, this.internalTimeoutMillis, `Fetching session timed out after ${this.internalTimeoutMillis / 1e3} seconds.`);
|
|
742
|
-
}
|
|
743
|
-
tryCancel();
|
|
744
|
-
if (!request || this.delayRequest(request, source)) {
|
|
745
|
-
return;
|
|
746
|
-
}
|
|
747
|
-
if (!(await this.isAllowedBasedOnRobotsTxtFile(request.url))) {
|
|
748
|
-
this.log.warning(`Skipping request ${request.url} (${request.id}) because it is disallowed based on robots.txt`);
|
|
749
|
-
request.state = RequestState.SKIPPED;
|
|
750
|
-
request.noRetry = true;
|
|
751
|
-
await source.markRequestHandled(request);
|
|
752
|
-
await this.onSkippedRequest?.({
|
|
753
|
-
url: request.url,
|
|
754
|
-
reason: 'robotsTxt',
|
|
755
|
-
});
|
|
756
|
-
return;
|
|
757
|
-
}
|
|
758
|
-
// Reset loadedUrl so an old one is not carried over to retries.
|
|
759
|
-
request.loadedUrl = undefined;
|
|
1130
|
+
/** Handles a single request - runs the request handler with retries, error handling, and lifecycle management. */
|
|
1131
|
+
async handleRequest(crawlingContext, requestSource, request) {
|
|
760
1132
|
const statisticsId = request.id || request.uniqueKey;
|
|
761
1133
|
this.stats.startJob(statisticsId);
|
|
762
|
-
// Shared crawling context
|
|
763
|
-
// @ts-expect-error
|
|
764
|
-
// All missing properties (that extend CrawlingContext) are set dynamically,
|
|
765
|
-
// but TS does not know that, so otherwise it would throw when compiling.
|
|
766
|
-
const crawlingContext = {
|
|
767
|
-
id: cryptoRandomObjectId(10),
|
|
768
|
-
crawler: this,
|
|
769
|
-
log: this.log,
|
|
770
|
-
request,
|
|
771
|
-
session,
|
|
772
|
-
enqueueLinks: async (options) => {
|
|
773
|
-
return enqueueLinks({
|
|
774
|
-
// specify the RQ first to allow overriding it
|
|
775
|
-
requestQueue: await this.getRequestQueue(),
|
|
776
|
-
robotsTxtFile: await this.getRobotsTxtFileForUrl(request.url),
|
|
777
|
-
onSkippedRequest: this.onSkippedRequest,
|
|
778
|
-
...options,
|
|
779
|
-
});
|
|
780
|
-
},
|
|
781
|
-
addRequests: this.addRequests.bind(this),
|
|
782
|
-
pushData: this.pushData.bind(this),
|
|
783
|
-
useState: this.useState.bind(this),
|
|
784
|
-
sendRequest: createSendRequest(this.httpClient, request, session, () => crawlingContext.proxyInfo?.url),
|
|
785
|
-
getKeyValueStore: async (idOrName) => KeyValueStore.open(idOrName, { config: this.config }),
|
|
786
|
-
};
|
|
787
|
-
this.crawlingContexts.set(crawlingContext.id, crawlingContext);
|
|
788
1134
|
let isRequestLocked = true;
|
|
789
1135
|
try {
|
|
790
1136
|
request.state = RequestState.REQUEST_HANDLER;
|
|
791
|
-
await
|
|
792
|
-
await this._timeoutAndRetry(async () =>
|
|
793
|
-
isRequestLocked = false; //
|
|
1137
|
+
await this.runRequestHandler(crawlingContext);
|
|
1138
|
+
await this._timeoutAndRetry(async () => requestSource.markRequestAsHandled(request), this.internalTimeoutMillis, `Marking request ${request.url} (${request.id}) as handled timed out after ${this.internalTimeoutMillis / 1e3} seconds.`);
|
|
1139
|
+
isRequestLocked = false; // markRequestAsHandled succeeded and unlocked the request
|
|
794
1140
|
this.stats.finishJob(statisticsId, request.retryCount);
|
|
795
1141
|
this.handledRequestsCount++;
|
|
796
1142
|
// reclaim session if request finishes successfully
|
|
797
1143
|
request.state = RequestState.DONE;
|
|
798
|
-
crawlingContext.session
|
|
1144
|
+
crawlingContext.session.markGood();
|
|
799
1145
|
}
|
|
800
|
-
catch (
|
|
1146
|
+
catch (rawError) {
|
|
1147
|
+
const err = this.unwrapError(rawError);
|
|
801
1148
|
try {
|
|
802
1149
|
request.state = RequestState.ERROR_HANDLER;
|
|
803
|
-
await addTimeoutToPromise(async () => this._requestFunctionErrorHandler(err, crawlingContext,
|
|
1150
|
+
await addTimeoutToPromise(async () => this._requestFunctionErrorHandler(err, crawlingContext, request, requestSource), this.internalTimeoutMillis, `Handling request failure of ${request.url} (${request.id}) timed out after ${this.internalTimeoutMillis / 1e3} seconds.`);
|
|
804
1151
|
if (!(err instanceof CriticalError)) {
|
|
805
|
-
isRequestLocked = false; // _requestFunctionErrorHandler calls either
|
|
1152
|
+
isRequestLocked = false; // _requestFunctionErrorHandler calls either markRequestAsHandled or reclaimRequest
|
|
806
1153
|
}
|
|
807
1154
|
request.state = RequestState.DONE;
|
|
808
1155
|
}
|
|
809
1156
|
catch (secondaryError) {
|
|
810
|
-
|
|
1157
|
+
const unwrappedSecondaryError = this.unwrapError(secondaryError);
|
|
1158
|
+
if (!unwrappedSecondaryError.triggeredFromUserHandler &&
|
|
811
1159
|
// avoid reprinting the same critical error multiple times, as it will be printed by Nodejs at the end anyway
|
|
812
|
-
!(
|
|
1160
|
+
!(unwrappedSecondaryError instanceof CriticalError)) {
|
|
813
1161
|
const apifySpecific = process.env.APIFY_IS_AT_HOME
|
|
814
1162
|
? `This may have happened due to an internal error of Apify's API or due to a misconfigured crawler.`
|
|
815
1163
|
: '';
|
|
816
|
-
this.log.exception(
|
|
1164
|
+
this.log.exception(unwrappedSecondaryError, 'An exception occurred during handling of failed request. ' +
|
|
817
1165
|
`This places the crawler and its underlying storages into an unknown state and crawling will be terminated. ${apifySpecific}`);
|
|
818
1166
|
}
|
|
819
1167
|
request.state = RequestState.ERROR;
|
|
820
|
-
throw
|
|
1168
|
+
throw unwrappedSecondaryError;
|
|
1169
|
+
}
|
|
1170
|
+
// decrease the session score if the request fails (but the error handler did not throw);
|
|
1171
|
+
// skip when the error is a SessionError, which already retired the session
|
|
1172
|
+
if (!(err instanceof SessionError)) {
|
|
1173
|
+
crawlingContext.session.markBad();
|
|
821
1174
|
}
|
|
822
|
-
// decrease the session score if the request fails (but the error handler did not throw)
|
|
823
|
-
crawlingContext.session?.markBad();
|
|
824
1175
|
}
|
|
825
1176
|
finally {
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
//
|
|
829
|
-
if (isRequestLocked &&
|
|
1177
|
+
// Safety net - return the request to the queue if nobody managed to mark it as handled
|
|
1178
|
+
// or reclaim it before (e.g. after a CriticalError). Reclaiming a request that is no longer
|
|
1179
|
+
// in progress is a harmless no-op on the storage backend.
|
|
1180
|
+
if (isRequestLocked && requestSource instanceof RequestQueue) {
|
|
830
1181
|
try {
|
|
831
|
-
await
|
|
1182
|
+
await requestSource.reclaimRequest(request);
|
|
832
1183
|
}
|
|
833
1184
|
catch {
|
|
834
|
-
//
|
|
1185
|
+
// The request was never in progress, or could not be reclaimed. Either way it's fine.
|
|
835
1186
|
}
|
|
836
1187
|
}
|
|
837
1188
|
}
|
|
838
1189
|
}
|
|
839
1190
|
/**
|
|
840
|
-
*
|
|
1191
|
+
* Wrapper around the crawling context's `enqueueLinks` method:
|
|
1192
|
+
* - Injects `crawlDepth` to each request being added based on the crawling context request.
|
|
1193
|
+
* - Provides defaults for the `enqueueLinks` options based on the crawler configuration.
|
|
1194
|
+
* - These options can be overridden by the user.
|
|
1195
|
+
* @internal
|
|
1196
|
+
*/
|
|
1197
|
+
async enqueueLinksWithCrawlDepth(options, request, requestManager) {
|
|
1198
|
+
const transformRequestFunctionWrapper = (requestOptions) => {
|
|
1199
|
+
requestOptions.crawlDepth = request.crawlDepth + 1;
|
|
1200
|
+
if (this.maxCrawlDepth !== undefined && requestOptions.crawlDepth > this.maxCrawlDepth) {
|
|
1201
|
+
// Setting `skippedReason` before returning `false` ensures that `reportSkippedRequests`
|
|
1202
|
+
// reports `'depth'` as the reason (via `request.skippedReason ?? reason` fallback),
|
|
1203
|
+
// rather than the generic `'transform'` reason.
|
|
1204
|
+
requestOptions.skippedReason = 'depth';
|
|
1205
|
+
return false;
|
|
1206
|
+
}
|
|
1207
|
+
// After injecting the crawlDepth, we call the user-provided transform function, if there is one.
|
|
1208
|
+
return options.transformRequestFunction?.(requestOptions) ?? requestOptions;
|
|
1209
|
+
};
|
|
1210
|
+
// Create a request-scoped callback that logs enqueueLimit once per request handler call
|
|
1211
|
+
// Only log if an explicit limit was passed to enqueueLinks (not the internal maxRequestsPerCrawl-derived limit)
|
|
1212
|
+
let loggedEnqueueLimitForThisRequest = false;
|
|
1213
|
+
const onSkippedRequest = async (skippedOptions) => {
|
|
1214
|
+
if (skippedOptions.reason === 'enqueueLimit') {
|
|
1215
|
+
if (!loggedEnqueueLimitForThisRequest && options.limit !== undefined) {
|
|
1216
|
+
this.log.info(`Skipping URLs in the handler for ${request.url} due to the enqueueLinks limit of ${options.limit}.`);
|
|
1217
|
+
loggedEnqueueLimitForThisRequest = true;
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
await this.handleSkippedRequest(skippedOptions);
|
|
1221
|
+
};
|
|
1222
|
+
return await enqueueLinks({
|
|
1223
|
+
requestManager,
|
|
1224
|
+
robotsTxtFile: await this.getRobotsTxtFileForUrl(request.url),
|
|
1225
|
+
onSkippedRequest,
|
|
1226
|
+
limit: await this.calculateEnqueuedRequestLimit(options.limit),
|
|
1227
|
+
// Allow user options to override defaults set above ⤴
|
|
1228
|
+
...options,
|
|
1229
|
+
transformRequestFunction: transformRequestFunctionWrapper,
|
|
1230
|
+
});
|
|
1231
|
+
}
|
|
1232
|
+
/**
|
|
1233
|
+
* Generator function that yields requests injected with the given crawl depth.
|
|
1234
|
+
* @internal
|
|
1235
|
+
*/
|
|
1236
|
+
async *addCrawlDepthRequestGenerator(requests, newRequestDepth) {
|
|
1237
|
+
for await (const request of requests) {
|
|
1238
|
+
if (typeof request === 'string') {
|
|
1239
|
+
yield { url: request, crawlDepth: newRequestDepth };
|
|
1240
|
+
}
|
|
1241
|
+
else {
|
|
1242
|
+
request.crawlDepth ??= newRequestDepth;
|
|
1243
|
+
yield request;
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
/**
|
|
1248
|
+
* Run async callback with given timeout and retry. Returns the result of the callback.
|
|
841
1249
|
* @ignore
|
|
842
1250
|
*/
|
|
843
1251
|
async _timeoutAndRetry(handler, timeout, error, maxRetries = 3, retried = 1) {
|
|
844
1252
|
try {
|
|
845
|
-
await addTimeoutToPromise(handler, timeout, error);
|
|
1253
|
+
return await addTimeoutToPromise(handler, timeout, error);
|
|
846
1254
|
}
|
|
847
1255
|
catch (e) {
|
|
848
1256
|
if (retried <= maxRetries) {
|
|
849
1257
|
// we retry on any error, not just timeout
|
|
850
1258
|
this.log.warning(`${e.message} (retrying ${retried}/${maxRetries})`);
|
|
851
|
-
|
|
852
|
-
return;
|
|
1259
|
+
return this._timeoutAndRetry(handler, timeout, error, maxRetries, retried + 1);
|
|
853
1260
|
}
|
|
854
1261
|
throw e;
|
|
855
1262
|
}
|
|
@@ -858,36 +1265,32 @@ export class BasicCrawler {
|
|
|
858
1265
|
* Returns true if either RequestList or RequestQueue have a request ready for processing.
|
|
859
1266
|
*/
|
|
860
1267
|
async _isTaskReadyFunction() {
|
|
861
|
-
|
|
862
|
-
const isRequestListEmpty = this.requestList ? await this.requestList.isEmpty() : true;
|
|
863
|
-
// If RequestList is not empty, task is ready, no reason to check RequestQueue.
|
|
864
|
-
if (!isRequestListEmpty)
|
|
865
|
-
return true;
|
|
866
|
-
// If RequestQueue is not empty, task is ready, return true, otherwise false.
|
|
867
|
-
return this.requestQueue ? !(await this.requestQueue.isEmpty()) : false;
|
|
1268
|
+
return this.requestManager !== undefined && !(await this.requestManager.isEmpty());
|
|
868
1269
|
}
|
|
869
1270
|
/**
|
|
870
1271
|
* Returns true if both RequestList and RequestQueue have all requests finished.
|
|
871
1272
|
*/
|
|
872
1273
|
async _defaultIsFinishedFunction() {
|
|
873
|
-
|
|
874
|
-
this.requestList ? this.requestList.isFinished() : true,
|
|
875
|
-
this.requestQueue ? this.requestQueue.isFinished() : true,
|
|
876
|
-
]);
|
|
877
|
-
// If both are finished, return true, otherwise return false.
|
|
878
|
-
return isRequestListFinished && isRequestQueueFinished;
|
|
1274
|
+
return !this.requestManager || (await this.requestManager.isFinished());
|
|
879
1275
|
}
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
1276
|
+
/**
|
|
1277
|
+
* Unwraps errors thrown by the context pipeline to get the actual user error.
|
|
1278
|
+
* RequestHandlerError and ContextPipelineInitializationError wrap the actual error.
|
|
1279
|
+
*/
|
|
1280
|
+
unwrapError(error) {
|
|
1281
|
+
if (error instanceof RequestHandlerError ||
|
|
1282
|
+
error instanceof ContextPipelineInitializationError ||
|
|
1283
|
+
error instanceof ContextPipelineCleanupError) {
|
|
1284
|
+
return this.unwrapError(error.cause);
|
|
1285
|
+
}
|
|
1286
|
+
return error;
|
|
885
1287
|
}
|
|
886
1288
|
/**
|
|
887
1289
|
* Handles errors thrown by user provided requestHandler()
|
|
1290
|
+
*
|
|
1291
|
+
* @param request The request object, passed separately to circumvent potential dynamic logic in crawlingContext.request
|
|
888
1292
|
*/
|
|
889
|
-
async _requestFunctionErrorHandler(error, crawlingContext, source) {
|
|
890
|
-
const { request } = crawlingContext;
|
|
1293
|
+
async _requestFunctionErrorHandler(error, crawlingContext, request, source) {
|
|
891
1294
|
request.pushErrorMessage(error);
|
|
892
1295
|
if (error instanceof CriticalError) {
|
|
893
1296
|
throw error;
|
|
@@ -895,9 +1298,10 @@ export class BasicCrawler {
|
|
|
895
1298
|
const shouldRetryRequest = this._canRequestBeRetried(request, error);
|
|
896
1299
|
if (shouldRetryRequest) {
|
|
897
1300
|
await this.stats.errorTrackerRetry.addAsync(error, crawlingContext);
|
|
898
|
-
await this.errorHandler?.(crawlingContext,
|
|
1301
|
+
await this.errorHandler?.(crawlingContext, // valid cast - ExtendedContext transitively extends CrawlingContext
|
|
1302
|
+
error);
|
|
899
1303
|
if (error instanceof SessionError) {
|
|
900
|
-
|
|
1304
|
+
crawlingContext.session?.retire();
|
|
901
1305
|
}
|
|
902
1306
|
if (!request.noRetry) {
|
|
903
1307
|
request.retryCount++;
|
|
@@ -914,6 +1318,9 @@ export class BasicCrawler {
|
|
|
914
1318
|
return;
|
|
915
1319
|
}
|
|
916
1320
|
}
|
|
1321
|
+
if (error instanceof SessionError) {
|
|
1322
|
+
crawlingContext.session?.retire();
|
|
1323
|
+
}
|
|
917
1324
|
// If the request is non-retryable, the error and snapshot aren't saved in the errorTrackerRetry object.
|
|
918
1325
|
// Therefore, we pass the crawlingContext to the errorTracker.add method, enabling snapshot capture.
|
|
919
1326
|
// This is to make sure the error snapshot is not duplicated in the errorTrackerRetry and errorTracker objects.
|
|
@@ -928,7 +1335,7 @@ export class BasicCrawler {
|
|
|
928
1335
|
// or failed more than retryCount times and will not be retried anymore.
|
|
929
1336
|
// Mark the request as failed and do not retry.
|
|
930
1337
|
this.handledRequestsCount++;
|
|
931
|
-
await source.
|
|
1338
|
+
await source.markRequestAsHandled(request);
|
|
932
1339
|
this.stats.failJob(request.id || request.uniqueKey, request.retryCount);
|
|
933
1340
|
await this._handleFailedRequestHandler(crawlingContext, error); // This function prints an error message.
|
|
934
1341
|
}
|
|
@@ -947,7 +1354,8 @@ export class BasicCrawler {
|
|
|
947
1354
|
const message = this._getMessageFromError(error, true);
|
|
948
1355
|
this.log.error(`Request failed and reached maximum retries. ${message}`, { id, url, method, uniqueKey });
|
|
949
1356
|
if (this.failedRequestHandler) {
|
|
950
|
-
await this.failedRequestHandler?.(crawlingContext,
|
|
1357
|
+
await this.failedRequestHandler?.(crawlingContext, // valid cast - ExtendedContext transitively extends CrawlingContext
|
|
1358
|
+
error);
|
|
951
1359
|
}
|
|
952
1360
|
}
|
|
953
1361
|
/**
|
|
@@ -970,10 +1378,8 @@ export class BasicCrawler {
|
|
|
970
1378
|
: [error.message || error, userLine].join('\n');
|
|
971
1379
|
}
|
|
972
1380
|
_canRequestBeRetried(request, error) {
|
|
973
|
-
// Request should never be retried, or the error encountered makes it not able to be retried
|
|
974
|
-
if (request.noRetry ||
|
|
975
|
-
error instanceof NonRetryableError ||
|
|
976
|
-
(error instanceof SessionError && this.maxSessionRotations <= (request.sessionRotationCount ?? 0))) {
|
|
1381
|
+
// Request should never be retried, or the error encountered makes it not able to be retried.
|
|
1382
|
+
if (request.noRetry || error instanceof NonRetryableError) {
|
|
977
1383
|
return false;
|
|
978
1384
|
}
|
|
979
1385
|
// User requested retry (we ignore retry count here as its explicitly told by the user to retry)
|
|
@@ -985,40 +1391,26 @@ export class BasicCrawler {
|
|
|
985
1391
|
return request.retryCount < maxRequestRetries;
|
|
986
1392
|
}
|
|
987
1393
|
/**
|
|
988
|
-
* Updates handledRequestsCount from possibly stored counts,
|
|
989
|
-
* usually after worker migration. Since one of the stores
|
|
990
|
-
* needs to have priority when both are present,
|
|
991
|
-
* it is the request queue, because generally, the request
|
|
992
|
-
* list will first be dumped into the queue and then left
|
|
993
|
-
* empty.
|
|
1394
|
+
* Updates handledRequestsCount from possibly stored counts, usually after worker migration.
|
|
994
1395
|
*/
|
|
995
1396
|
async _loadHandledRequestCount() {
|
|
996
|
-
if (this.
|
|
997
|
-
this.handledRequestsCount = await this.
|
|
998
|
-
}
|
|
999
|
-
else if (this.requestList) {
|
|
1000
|
-
this.handledRequestsCount = this.requestList.handledCount();
|
|
1001
|
-
}
|
|
1002
|
-
}
|
|
1003
|
-
async _executeHooks(hooks, ...args) {
|
|
1004
|
-
if (Array.isArray(hooks) && hooks.length) {
|
|
1005
|
-
for (const hook of hooks) {
|
|
1006
|
-
await hook(...args);
|
|
1007
|
-
}
|
|
1397
|
+
if (this.requestManager) {
|
|
1398
|
+
this.handledRequestsCount = await this.requestManager.getHandledCount();
|
|
1008
1399
|
}
|
|
1009
1400
|
}
|
|
1010
1401
|
/**
|
|
1011
|
-
*
|
|
1012
|
-
*
|
|
1402
|
+
* Stops the crawler immediately.
|
|
1403
|
+
*
|
|
1404
|
+
* This method doesn't wait for currently active requests to finish.
|
|
1405
|
+
*
|
|
1406
|
+
* To stop the crawler gracefully (waiting for all running requests to finish), use {@link BasicCrawler.stop|`crawler.stop()`} instead.
|
|
1013
1407
|
*/
|
|
1014
1408
|
async teardown() {
|
|
1015
|
-
|
|
1016
|
-
if (this.useSessionPool) {
|
|
1017
|
-
await this.sessionPool.teardown();
|
|
1018
|
-
}
|
|
1409
|
+
serviceLocator.getEventManager().emit("persistState" /* EventType.PERSIST_STATE */, { isMigrating: false });
|
|
1019
1410
|
if (this._closeEvents) {
|
|
1020
|
-
await
|
|
1411
|
+
await serviceLocator.getEventManager().close();
|
|
1021
1412
|
}
|
|
1413
|
+
await this.ownedSessionPool?.teardown();
|
|
1022
1414
|
await this.autoscaledPool?.abort();
|
|
1023
1415
|
}
|
|
1024
1416
|
_getCookieHeaderFromRequest(request) {
|
|
@@ -1028,18 +1420,18 @@ export class BasicCrawler {
|
|
|
1028
1420
|
}
|
|
1029
1421
|
return request.headers?.Cookie || request.headers?.cookie || '';
|
|
1030
1422
|
}
|
|
1031
|
-
|
|
1032
|
-
//
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1423
|
+
requestMatchesEnqueueStrategy(request) {
|
|
1424
|
+
// If `skipNavigation` was used, just return `true`
|
|
1425
|
+
try {
|
|
1426
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
|
1427
|
+
request.loadedUrl;
|
|
1428
|
+
}
|
|
1429
|
+
catch (err) {
|
|
1430
|
+
if (err instanceof NavigationSkippedError) {
|
|
1431
|
+
return true;
|
|
1037
1432
|
}
|
|
1038
|
-
|
|
1433
|
+
throw err;
|
|
1039
1434
|
}
|
|
1040
|
-
return RequestQueue.open(null, { config: this.config });
|
|
1041
|
-
}
|
|
1042
|
-
requestMatchesEnqueueStrategy(request) {
|
|
1043
1435
|
const { url, loadedUrl } = request;
|
|
1044
1436
|
// eslint-disable-next-line dot-notation -- private access
|
|
1045
1437
|
const strategy = request['enqueueStrategy'];
|