@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.
@@ -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, Dataset, enqueueLinks, EnqueueStrategy, getObjectType, KeyValueStore, log, LogLevel, mergeCookies, MissingSessionError, NavigationSkippedError, NonRetryableError, OwnedOrInjected, purgeDefaultStorages, RequestHandlerError, RequestManagerTandem, RequestQueue, RequestState, RetryRequestError, Router, ServiceLocator, serviceLocator, Session, SessionError, SessionPool, Statistics, validateUserData, validators, } from '@crawlee/core';
4
- import { FetchHttpClient } from '@crawlee/http-client';
5
- import { isAsyncIterable, isIterable, RobotsTxtFile, ROTATE_PROXY_ERRORS } from '@crawlee/utils';
6
- import { stringify } from 'csv-stringify/sync';
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
- _delegatePromise;
14
+ class LazyDefaultHttpClient extends BaseHttpClient {
15
+ #delegatePromise;
17
16
  constructor(options) {
18
- this._delegatePromise = import('@crawlee/impit-client')
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._delegatePromise).sendRequest(...args);
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
- * A reference to the underlying {@link Statistics} class that collects and logs run statistics for requests.
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
- stats;
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.sessionPoolDep.value;
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 `_init()`. Absent until the first run, so a `teardown()` before it is a no-op. */
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.concurrencySystemDep?.maybeValue;
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
- _basicContextPipeline;
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._basicContextPipeline === undefined) {
139
- this._basicContextPipeline = this.buildBasicContextPipeline();
174
+ if (this.#basicContextPipeline === undefined) {
175
+ this.#basicContextPipeline = this.buildBasicContextPipeline();
140
176
  }
141
- return this._basicContextPipeline;
177
+ return this.#basicContextPipeline;
142
178
  }
143
- _contextPipeline;
179
+ #contextPipeline;
144
180
  get contextPipeline() {
145
- if (this._contextPipeline === undefined) {
146
- this._contextPipeline = this.buildFinalContextPipeline();
181
+ if (this.#contextPipeline === undefined) {
182
+ this.#contextPipeline = this.buildFinalContextPipeline();
147
183
  }
148
- return this._contextPipeline;
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
- sameDomainDelayMillis;
165
- domainAccessedTime;
201
+ #sameDomainDelaySecs;
166
202
  maxRequestsPerCrawl;
167
203
  get handledRequestsCount() {
168
- return this.stats.state.requestsFinished + this.stats.state.requestsFailed;
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
- _closeEvents;
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: ow.optional.object,
193
- extendContext: ow.optional.function,
194
- requestList: ow.optional.object.validate(validators.requestList),
195
- requestQueue: ow.optional.object.validate(validators.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: ow.optional.function,
200
- requestHandlerTimeoutSecs: ow.optional.number,
201
- errorHandler: ow.optional.function,
202
- failedRequestHandler: ow.optional.function,
203
- maxRequestRetries: ow.optional.number,
204
- sameDomainDelaySecs: ow.optional.number,
205
- maxRequestsPerCrawl: ow.optional.number,
206
- maxCrawlDepth: ow.optional.number,
207
- taskLoopOptions: ow.optional.object,
208
- concurrencySystem: ow.optional.object,
209
- sessionPool: ow.optional.object.validate(validators.sessionPool),
210
- proxyConfiguration: ow.optional.object.validate(validators.proxyConfiguration),
211
- statusMessageLoggingInterval: ow.optional.number,
212
- statusMessageCallback: ow.optional.function,
213
- additionalHttpErrorStatusCodes: ow.optional.array.ofType(ow.number),
214
- ignoreHttpErrorStatusCodes: ow.optional.array.ofType(ow.number),
215
- blockedStatusCodes: ow.optional.array.ofType(ow.number),
216
- retryOnBlocked: ow.optional.boolean,
217
- respectRobotsTxtFile: ow.optional.any(ow.boolean, ow.object),
218
- onSkippedRequest: ow.optional.function,
219
- httpClient: ow.optional.object,
220
- configuration: ow.optional.object,
221
- storageBackend: ow.optional.object,
222
- eventManager: ow.optional.object,
223
- logger: ow.optional.object,
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: ow.optional.number,
226
- maxConcurrency: ow.optional.number,
227
- maxRequestsPerMinute: ow.optional.number.integerOrInfinite.positive.greaterThanOrEqual(1),
228
- keepAlive: ow.optional.boolean,
229
- statisticsOptions: ow.optional.object,
230
- id: ow.optional.string,
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
- ow(options, 'BasicCrawlerOptions', ow.object.exactShape(BasicCrawler.optionsShape));
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 = 3, sameDomainDelaySecs = 0, maxRequestsPerCrawl, maxCrawlDepth, taskLoopOptions = {}, concurrencySystem, keepAlive, sessionPool, proxyConfiguration, additionalHttpErrorStatusCodes = [], ignoreHttpErrorStatusCodes = [],
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 = false, respectRobotsTxtFile = false, onSkippedRequest, requestHandler, requestHandlerTimeoutSecs, errorHandler, failedRequestHandler, statusMessageLoggingInterval = 10, statusMessageCallback, statisticsOptions, httpClient, id, } = options;
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 ArgumentError('The `minConcurrency`/`maxConcurrency`/`maxRequestsPerMinute` shortcuts cannot be combined with ' +
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.', this.constructor);
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
- const scopedServiceLocator = new ServiceLocator(configuration, eventManager, storageBackend, logger);
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.contextPipelineOptions = {
271
- contextPipelineBuilder: options.contextPipelineBuilder,
272
- extendContext: options.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.statusMessageLoggingInterval = statusMessageLoggingInterval;
302
- this.statusMessageCallback = statusMessageCallback;
303
- this.domainAccessedTime = new Map();
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.ignoreHttpErrorStatusCodes = new Set([...ignoreHttpErrorStatusCodes]);
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.respectRobotsTxtFile = respectRobotsTxtFile;
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.sameDomainDelayMillis = sameDomainDelaySecs * 1000;
327
- this.stats = new Statistics({
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
- ...statisticsOptions,
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.sessionPoolDep = OwnedOrInjected.resolve(sessionPool, () => new SessionPool({
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 || this.delayRequest(request, source)) {
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.stats.startJob(request.id || request.uniqueKey);
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.stats.discardJob(request.id || request.uniqueKey);
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 instanceof SessionError)) {
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.unexpectedStop) {
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.unexpectedStop) {
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.resolveConcurrencySystem = () => OwnedOrInjected.resolve(concurrencySystem, () => this.createDefaultConcurrencySystem({
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.ignoreHttpErrorStatusCodes.has(status);
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 { enqueueLinks: enqueueLinksWrapper, addRequests, sendRequest };
642
+ return { addRequests, sendRequest };
573
643
  }
574
644
  buildFinalContextPipeline() {
575
- const subclassPipeline = (this.contextPipelineOptions.contextPipelineBuilder?.() ??
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.contextPipelineOptions;
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._getMessageFromError(error)?.includes(x));
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("statusMessage" /* EventType.STATUS_MESSAGE */, {
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.stats.state };
717
+ let previousState = { ...this.statistics.state };
648
718
  const getOperationMode = () => {
649
- const { requestsFailed } = this.stats.state;
719
+ const { requestsFailed } = this.statistics.state;
650
720
  const { requestsFailed: previousRequestsFailed } = previousState;
651
- previousState = { ...this.stats.state };
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.statusMessageLoggingInterval} seconds.`;
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.stats.state.requestsFinished}${total ? `/${total}` : ''} pages, ${this.stats.state.requestsFailed} failed requests, desired concurrency ${this.concurrencySystem?.desiredConcurrency ?? 0}.`;
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.statusMessageCallback) {
669
- await this.statusMessageCallback({
738
+ if (this.#statusMessageCallback) {
739
+ await this.#statusMessageCallback({
670
740
  crawler: this,
671
- state: this.stats.state,
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.statusMessageLoggingInterval * 1e3);
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.ownedRequestQueue.maybeValue ?? (purgeRequestQueue === true ? this.requestManager : undefined);
708
- if (managerToPurge?.purge && shouldPurge) {
709
- await managerToPurge.purge();
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
- this.stats.reset();
712
- await this.stats.resetStore();
713
- await this.sessionPoolDep.ifOwned((pool) => pool.resetStore());
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.unexpectedStop = false;
794
+ this.#unexpectedStop = false;
716
795
  this.running = true;
717
- this.loggedPerRun.clear();
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._init();
728
- await this.stats.startCapturing();
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.autoscaledPool.abort();
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("migrating" /* EventType.MIGRATING */, boundPauseOnMigration);
751
- eventManager.on("aborting" /* EventType.ABORTING */, boundPauseOnMigration);
829
+ eventManager.on(EventType.MIGRATING, boundPauseOnMigration);
830
+ eventManager.on(EventType.ABORTING, boundPauseOnMigration);
752
831
  let stats = {};
753
832
  try {
754
- await this.autoscaledPool.run();
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("migrating" /* EventType.MIGRATING */, boundPauseOnMigration);
761
- eventManager.off("aborting" /* EventType.ABORTING */, boundPauseOnMigration);
762
- const finalStats = this.stats.calculate();
839
+ eventManager.off(EventType.MIGRATING, boundPauseOnMigration);
840
+ eventManager.off(EventType.ABORTING, boundPauseOnMigration);
841
+ const finalStats = this.statistics.calculate();
763
842
  stats = {
764
- requestsFinished: this.stats.state.requestsFinished,
765
- requestsFailed: this.stats.state.requestsFailed,
766
- retryHistogram: this.stats.requestRetryHistogram,
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.stats.errorTracker.total !== 0) {
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.stats.errorTracker.total,
774
- uniqueErrors: this.stats.errorTracker.getUniqueErrorCount(),
775
- mostCommonErrors: this.stats.errorTracker.getMostPopularErrors(3).map(prettify),
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.stats.state.requestsFinished + this.stats.state.requestsFailed} requests: ${this.stats.state.requestsFinished} succeeded, ${this.stats.state.requestsFailed} failed.`, { isStatusMessageTerminal: true, level: 'INFO' });
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.unexpectedStop) {
883
+ if (this.#unexpectedStop) {
805
884
  return;
806
885
  }
807
886
  this.log.info(reason);
808
- this.unexpectedStop = true;
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.autoscaledPool) {
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.autoscaledPool.pause(timeoutSecs);
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.autoscaledPool) {
909
+ if (!this.#autoscaledPool) {
831
910
  this.log.warning('Cannot resume a crawler that is not running.');
832
911
  return;
833
912
  }
834
- this.autoscaledPool.resume();
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.requestManagerTimeoutsApplied) {
848
- this.requestManagerTimeoutsApplied = true;
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.ownedRequestQueue.set(requestQueue);
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.useStateAnonymousIndices.add(this.identity.instanceIndex);
920
- if (BasicCrawler.useStateAnonymousIndices.size > 1) {
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
- if (options.reason === 'limit') {
942
- this.logOncePerRun('maxRequestsPerCrawl', 'The number of requests enqueued by the crawler reached the maxRequestsPerCrawl limit of ' +
943
- `${this.maxRequestsPerCrawl} requests and no further requests will be added.`);
944
- }
945
- if (options.reason === 'depth') {
946
- this.logOncePerRun('maxCrawlDepth', `The crawler reached the maxCrawlDepth limit of ${this.maxCrawlDepth} and no further requests will be enqueued.`);
947
- }
948
- await this.onSkippedRequest?.(options);
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.loggedPerRun.has(key)) {
952
- this.log.info(message);
953
- this.loggedPerRun.add(key);
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
- const requestLimit = await this.calculateEnqueuedRequestLimit();
970
- const skippedBecauseOfRobots = new Set();
971
- const skippedBecauseOfMaxCrawlDepth = new Set();
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
- ow(requests, ow.object
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 url = typeof request === 'string' ? request : request.url;
981
- if (maxCrawlDepth !== undefined && request.crawlDepth > maxCrawlDepth) {
982
- skippedBecauseOfMaxCrawlDepth.add(url);
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
- await validateRequestUserData(request);
987
- yield request;
1109
+ if (!(await isAllowedBasedOnRobotsTxtFile(requestOptions.url))) {
1110
+ allSkipped.push({ url: requestOptions.url, reason: 'robotsTxt' });
1111
+ continue;
988
1112
  }
989
- else {
990
- skippedBecauseOfRobots.add(url);
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
- ...options,
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
- const skippedBecauseOfLimit = result.requestsOverLimit ?? [];
1000
- if (skippedBecauseOfRobots.size > 0) {
1001
- this.log.warning(`Some requests were skipped because they were disallowed based on the robots.txt file`, {
1002
- skipped: [...skippedBecauseOfRobots],
1003
- });
1004
- }
1005
- if (skippedBecauseOfRobots.size > 0 ||
1006
- skippedBecauseOfLimit.length > 0 ||
1007
- skippedBecauseOfMaxCrawlDepth.size > 0) {
1008
- await Promise.all([...skippedBecauseOfRobots]
1009
- .map((url) => {
1010
- return this.handleSkippedRequest({ url, reason: 'robotsTxt' });
1011
- })
1012
- .concat(skippedBecauseOfLimit.map((request) => {
1013
- const url = typeof request === 'string' ? request : request.url;
1014
- return this.handleSkippedRequest({ url, reason: 'limit' });
1015
- }), [...skippedBecauseOfMaxCrawlDepth].map((url) => {
1016
- return this.handleSkippedRequest({ url, reason: 'depth' });
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 ensureDir(dirname(path));
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 ensureDir(dirname(path));
1083
- await writeJSON(path, items, { spaces: 4 });
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 _init() {
1235
+ async init() {
1092
1236
  const eventManager = serviceLocator.getEventManager();
1093
1237
  if (!eventManager.isInitialized()) {
1094
1238
  await eventManager.init();
1095
- this._closeEvents = true;
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.concurrencySystemDep = this.resolveConcurrencySystem();
1111
- await this.concurrencySystemDep.ifOwned((system) => system.start());
1112
- this.autoscaledPool = new AutoscaledPool({
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.concurrencySystemDep.value,
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
- _throwOnBlockedRequest(statusCode) {
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.respectRobotsTxtFile) {
1354
+ if (!this.#respectRobotsTxtFile) {
1177
1355
  return true;
1178
1356
  }
1179
1357
  const robotsTxtFile = await this.getRobotsTxtFileForUrl(url);
1180
- const userAgent = typeof this.respectRobotsTxtFile === 'object' ? this.respectRobotsTxtFile?.userAgent : '*';
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.respectRobotsTxtFile) {
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.robotsTxtFileCache.get(origin);
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.robotsTxtFileCache.add(origin, robotsTxtFile);
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.autoscaledPool) {
1421
+ if (this.#autoscaledPool) {
1204
1422
  // if run wasn't called, this is going to crash
1205
- await this.autoscaledPool.pause(SAFE_MIGRATION_WAIT_MILLIS).catch((err) => {
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.stats.persistState()]);
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.stats.finishJob(statisticsId, request.retryCount);
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 instanceof SessionError)) {
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.stats.errorTrackerRetry.addAsync(error, crawlingContext);
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._getMessageFromError(error);
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.stats.errorTracker.addAsync(error, crawlingContext);
1642
+ await this.statistics.errorTracker.addAsync(error, crawlingContext);
1477
1643
  }
1478
1644
  else {
1479
- this.stats.errorTracker.add(error);
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.stats.failJob(request.id || request.uniqueKey, request.retryCount);
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._getMessageFromError(error, true);
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
- _getMessageFromError(error, forceStack = false) {
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
- serviceLocator.getEventManager().emit("persistState" /* EventType.PERSIST_STATE */, { isMigrating: false });
1539
- if (this._closeEvents) {
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.sessionPoolDep.ifOwned((pool) => pool.teardown());
1543
- await this.autoscaledPool?.abort();
1544
- await this.concurrencySystemDep?.ifOwned((system) => system.stop());
1721
+ await this.#autoscaledPool?.abort();
1722
+ await this.#concurrencySystemDep?.ifOwned((system) => system.stop());
1545
1723
  }
1546
- _getCookieHeaderFromRequest(request) {
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
  }