@crawlee/basic 4.0.0-beta.99 → 4.0.0-rc.1

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.
Files changed (80) hide show
  1. package/README.md +1 -1
  2. package/index.d.ts +11 -1
  3. package/index.js +11 -0
  4. package/internals/autoscaling/autoscaled_pool.d.ts +178 -0
  5. package/internals/autoscaling/autoscaled_pool.js +387 -0
  6. package/internals/autoscaling/concurrency_system.d.ts +235 -0
  7. package/internals/autoscaling/concurrency_system.js +357 -0
  8. package/internals/autoscaling/cpu_load_signal.d.ts +43 -0
  9. package/internals/autoscaling/cpu_load_signal.js +46 -0
  10. package/internals/autoscaling/event_loop_load_signal.d.ts +51 -0
  11. package/internals/autoscaling/event_loop_load_signal.js +60 -0
  12. package/internals/autoscaling/index.d.ts +9 -0
  13. package/internals/autoscaling/index.js +9 -0
  14. package/internals/autoscaling/load_signal.d.ts +98 -0
  15. package/internals/autoscaling/load_signal.js +103 -0
  16. package/internals/autoscaling/memory_load_signal.d.ts +45 -0
  17. package/internals/autoscaling/memory_load_signal.js +104 -0
  18. package/internals/autoscaling/snapshotter.d.ts +84 -0
  19. package/internals/autoscaling/snapshotter.js +67 -0
  20. package/internals/autoscaling/storage_backend_load_signal.d.ts +56 -0
  21. package/internals/autoscaling/storage_backend_load_signal.js +73 -0
  22. package/internals/autoscaling/system_status.d.ts +112 -0
  23. package/internals/autoscaling/system_status.js +139 -0
  24. package/internals/autoscaling/weighted_avg.d.ts +5 -0
  25. package/internals/autoscaling/weighted_avg.js +14 -0
  26. package/internals/basic-crawler.d.ts +185 -389
  27. package/internals/basic-crawler.js +982 -684
  28. package/internals/cookie_utils.d.ts +40 -0
  29. package/internals/cookie_utils.js +122 -0
  30. package/internals/crawler-run.d.ts +76 -0
  31. package/internals/crawler-run.js +142 -0
  32. package/internals/crawlers/context_pipeline.d.ts +75 -0
  33. package/internals/crawlers/context_pipeline.js +109 -0
  34. package/internals/crawlers/crawler_commons.d.ts +216 -0
  35. package/internals/crawlers/crawler_commons.js +1 -0
  36. package/internals/crawlers/error_snapshotter.d.ts +29 -0
  37. package/internals/crawlers/error_snapshotter.js +116 -0
  38. package/internals/crawlers/error_tracker.d.ts +51 -0
  39. package/internals/crawlers/error_tracker.js +316 -0
  40. package/internals/crawlers/index.d.ts +4 -0
  41. package/internals/crawlers/index.js +3 -0
  42. package/internals/crawlers/internals/types.d.ts +7 -0
  43. package/internals/crawlers/internals/types.js +1 -0
  44. package/internals/crawlers/statistics.d.ts +316 -0
  45. package/internals/crawlers/statistics.js +525 -0
  46. package/internals/crawling_request.d.ts +86 -0
  47. package/internals/crawling_request.js +127 -0
  48. package/internals/enqueue_links/enqueue_links.d.ts +152 -0
  49. package/internals/enqueue_links/enqueue_links.js +76 -0
  50. package/internals/enqueue_links/index.d.ts +3 -0
  51. package/internals/enqueue_links/index.js +3 -0
  52. package/internals/enqueue_links/shared.d.ts +106 -0
  53. package/internals/enqueue_links/shared.js +258 -0
  54. package/internals/errors.d.ts +68 -0
  55. package/internals/errors.js +83 -0
  56. package/internals/http.d.ts +9 -0
  57. package/internals/http.js +28 -0
  58. package/internals/request-timeout.d.ts +6 -0
  59. package/internals/request-timeout.js +27 -2
  60. package/internals/router.d.ts +299 -0
  61. package/internals/router.js +312 -0
  62. package/internals/send-request.d.ts +2 -1
  63. package/internals/send-request.js +1 -0
  64. package/internals/session_pool/consts.d.ts +1 -0
  65. package/internals/session_pool/consts.js +1 -0
  66. package/internals/session_pool/errors.d.ts +7 -0
  67. package/internals/session_pool/errors.js +11 -0
  68. package/internals/session_pool/fingerprint.d.ts +9 -0
  69. package/internals/session_pool/fingerprint.js +30 -0
  70. package/internals/session_pool/index.d.ts +4 -0
  71. package/internals/session_pool/index.js +4 -0
  72. package/internals/session_pool/session.d.ts +151 -0
  73. package/internals/session_pool/session.js +225 -0
  74. package/internals/session_pool/session_pool.d.ts +193 -0
  75. package/internals/session_pool/session_pool.js +453 -0
  76. package/internals/sitemap_request_loader.d.ts +158 -0
  77. package/internals/sitemap_request_loader.js +432 -0
  78. package/internals/throttling_request_manager.d.ts +229 -0
  79. package/internals/throttling_request_manager.js +843 -0
  80. package/package.json +17 -14
@@ -1,21 +1,35 @@
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 { bindMethodsToServiceLocator, Configuration, createStorageTransaction, Request, CriticalError, currentStorageTransaction, Dataset, EnqueueStrategy, EventManager, EventType, getObjectType, KeyValueStore, log, NonRetryableError, OwnedOrInjected, purgeDefaultStorages, RequestManagerTandem, RequestQueue, ServiceLocator, serviceLocator, SessionError, validators, withDirectStorageAccess, } from '@crawlee/core';
4
+ import { BaseHttpClient, FetchHttpClient } from '@crawlee/http-client';
5
+ import { isAsyncIterable, isIterable, parseArgument, ROTATE_PROXY_ERRORS, schemas } 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
- import { addTimeoutToPromise, extendTimeout, TimeoutError } from '@apify/timeout';
10
+ import { addTimeoutToPromise, extendTimeout, storage as timeoutStorage, TimeoutError, tryCancel } from '@apify/timeout';
12
11
  import { cryptoRandomObjectId } from '@apify/utilities';
13
- import { extendTimeoutKey, navigationDeadlineKey, raceWithTimeout, timeoutExpiredKey, } from './request-timeout.js';
12
+ import { ConcurrencySystem } from './autoscaling/concurrency_system.js';
13
+ import { mergeCookies } from './cookie_utils.js';
14
+ import { ContextPipeline } from './crawlers/context_pipeline.js';
15
+ import { CrawlingRequest, RequestState } from './crawling_request.js';
16
+ import { ContextPipelineCleanupError, ContextPipelineInterruptedError, MissingSessionError, NavigationSkippedError, PersistentRateLimitError, ContextPipelineInitializationError, RequestHandlerError, RequestThrottledError, RetryRequestError, } from './errors.js';
17
+ import { Statistics } from './crawlers/statistics.js';
18
+ import { applyRequestTransform, buildEnqueueStrategyPatterns, constructUrlPatternObjects, createRequestOptions, createSkippedRequestArgs, filterRequestOptionsByPatterns, } from './enqueue_links/index.js';
19
+ import { parseRetryAfterHeader } from './http.js';
20
+ import { CrawlerRun } from './crawler-run.js';
21
+ import { extendTimeoutKey, navigationDeadlineKey, raceWithTimeout, suspendTimeoutKey, timeoutExpiredKey, } from './request-timeout.js';
14
22
  import { createSendRequest } from './send-request.js';
15
- class LazyDefaultHttpClient {
16
- _delegatePromise;
23
+ import { Router, validateUserData } from './router.js';
24
+ import { BLOCKED_STATUS_CODES } from './session_pool/consts.js';
25
+ import { Session } from './session_pool/session.js';
26
+ import { SessionPool } from './session_pool/session_pool.js';
27
+ import { ThrottlingRequestManager } from './throttling_request_manager.js';
28
+ class LazyDefaultHttpClient extends BaseHttpClient {
29
+ #delegatePromise;
17
30
  constructor(options) {
18
- this._delegatePromise = import('@crawlee/impit-client')
31
+ super(options);
32
+ this.#delegatePromise = import('@crawlee/impit-client')
19
33
  .then(({ ImpitHttpClient }) => new ImpitHttpClient(options))
20
34
  .catch(() => {
21
35
  (options?.logger ?? log).warning('Optional dependency @crawlee/impit-client is not installed. ' +
@@ -23,8 +37,11 @@ class LazyDefaultHttpClient {
23
37
  return new FetchHttpClient(options);
24
38
  });
25
39
  }
40
+ fetch() {
41
+ throw new Error('LazyDefaultHttpClient delegates `sendRequest` entirely; `fetch` is never called.');
42
+ }
26
43
  async sendRequest(...args) {
27
- return (await this._delegatePromise).sendRequest(...args);
44
+ return (await this.#delegatePromise).sendRequest(...args);
28
45
  }
29
46
  }
30
47
  /**
@@ -37,84 +54,117 @@ class LazyDefaultHttpClient {
37
54
  * @ignore
38
55
  */
39
56
  const SAFE_MIGRATION_WAIT_MILLIS = 20000;
40
- const deferredCleanupKey = Symbol('deferredCleanup');
41
57
  // The request timeout plumbing (the window helper, the context symbols, and the race) lives in its own module.
42
58
  export { navigationDeadlineKey, remainingNavigationWindowMillis } from './request-timeout.js';
59
+ const urlPatternSchema = z.union([
60
+ z.string(),
61
+ z.instanceof(RegExp),
62
+ schemas.objectWithKeys(['glob']),
63
+ schemas.objectWithKeys(['regexp']),
64
+ ]);
65
+ // `looseObject` (rather than `strictObject`) lets subclasses forward their own extraction-only options
66
+ // (e.g. `selector`) straight through without having to strip them out first.
67
+ const addRequestsOptionsSchema = z.looseObject({
68
+ forefront: z.boolean().optional(),
69
+ cache: z.boolean().optional(),
70
+ waitForAllRequestsToBeAdded: z.boolean().optional(),
71
+ batchSize: schemas.anyNumber.optional(),
72
+ waitBetweenBatchesMillis: schemas.anyNumber.optional(),
73
+ maxNewRequests: schemas.anyNumber.optional(),
74
+ limit: schemas.anyNumber.optional(),
75
+ baseUrl: z.string().optional(),
76
+ userData: schemas.anyObject.optional(),
77
+ label: z.string().optional(),
78
+ sessionId: z.string().optional(),
79
+ skipNavigation: z.boolean().optional(),
80
+ include: schemas.arrayOf(urlPatternSchema, 'URL patterns').min(1).optional(),
81
+ exclude: schemas.arrayOf(urlPatternSchema, 'URL patterns').optional(),
82
+ transformRequestFunction: schemas.anyFunction.optional(),
83
+ strategy: z.enum(EnqueueStrategy).optional(),
84
+ onSkippedRequest: schemas.anyFunction.optional(),
85
+ });
43
86
  export class BasicCrawler {
44
- static CRAWLEE_STATE_KEY = 'CRAWLEE_STATE';
87
+ static #CRAWLEE_STATE_KEY = 'CRAWLEE_STATE';
45
88
  /**
46
89
  * Tracks the number of crawler instances created. The first crawler uses the default
47
90
  * request queue; subsequent ones get their own queue via a unique alias so they don't
48
91
  * collide.
49
92
  */
50
- static instanceCount = 0;
93
+ static #instanceCount = 0;
51
94
  /**
52
95
  * Tracks crawler instances that accessed shared state without having an explicit id.
53
96
  * Used to detect and warn about multiple crawlers sharing the same state.
54
97
  */
55
- static useStateAnonymousIndices = new Set();
98
+ static #useStateAnonymousIndices = new Set();
99
+ /** Backs the {@link BasicCrawler.statistics|`statistics`} getter. */
100
+ #statisticsDep;
56
101
  /**
57
- * A reference to the underlying {@link Statistics} class that collects and logs run statistics for requests.
102
+ * The statistics instance collecting the crawler's run statistics - either the injected `statistics` option or a
103
+ * crawler-built default. Typed as {@link IStatistics} so custom implementations can be plugged in.
58
104
  */
59
- stats;
105
+ get statistics() {
106
+ return this.#statisticsDep.value;
107
+ }
108
+ #requestManager;
60
109
  /**
61
110
  * The main request-handling component of the crawler. It manages the requests that the crawler processes,
62
111
  * combining any provided request loader and/or queue. It's initialized during the crawler startup or lazily
63
112
  * via {@link BasicCrawler.getRequestManager|`getRequestManager()`}.
64
113
  */
65
- requestManager;
114
+ get requestManager() {
115
+ return this.#requestManager;
116
+ }
66
117
  /** Backs the {@link BasicCrawler.sessionPool|`sessionPool`} getter. */
67
- sessionPoolDep;
118
+ #sessionPoolDep;
68
119
  /**
69
120
  * A reference to the underlying session pool that manages the crawler's {@link Session|sessions}. Typed as
70
121
  * {@link ISessionPool} so custom implementations can be plugged in via the `sessionPool` constructor option.
71
122
  */
72
123
  get sessionPool() {
73
- return this.sessionPoolDep.value;
124
+ return this.#sessionPoolDep.value;
74
125
  }
75
- /**
76
- * Tracks **only** the queue the crawler opens for itself — not the {@link RequestManagerTandem} that may wrap it
77
- * around a user-supplied `requestList` — so the owned-only purge between repeated `run()` calls never reaches
78
- * through to a borrowed loader. Filled lazily in {@link BasicCrawler.openOwnedRequestQueue|`openOwnedRequestQueue()`}.
79
- */
80
- ownedRequestQueue = OwnedOrInjected.resolve();
81
126
  /**
82
127
  * Whether the request-processing-time hint has already been forwarded to the request manager. The hint
83
128
  * derives only from `requestHandlerTimeoutMillis` (constant for the crawler's lifetime) and is raise-only,
84
129
  * so it only needs to be applied once, at the first async access of the manager.
85
130
  */
86
- requestManagerTimeoutsApplied = false;
131
+ #requestManagerTimeoutsApplied = false;
87
132
  /**
88
133
  * Resolves the governor for one run: either the injected
89
134
  * {@link BasicCrawlerOptions.concurrencySystem|`concurrencySystem`} (borrowed) or a freshly built default with
90
135
  * the concurrency shortcuts folded in (owned, so the crawler starts and stops it).
91
136
  */
92
- resolveConcurrencySystem;
93
- /** As resolved by `_init()`. Absent until the first run, so a `teardown()` before it is a no-op. */
94
- concurrencySystemDep;
137
+ #resolveConcurrencySystem;
138
+ /** The last run started on this instance. It outlives the run, so anything that drives one goes through `#liveRun`. */
139
+ #run;
140
+ #concurrencySystem;
141
+ /** The run in progress, if any — a finished run is history and cannot be driven. */
142
+ get #liveRun() {
143
+ return this.#run?.isLive === true ? this.#run : undefined;
144
+ }
145
+ /** The run whose task loop is dispatching, if any — only that one can be paused and resumed. */
146
+ get #dispatchingRun() {
147
+ return this.#liveRun?.isDispatching === true ? this.#liveRun : undefined;
148
+ }
149
+ /** Whether a {@link BasicCrawler.run|`run()`} is in progress on this instance. */
150
+ get running() {
151
+ return this.#liveRun !== undefined;
152
+ }
95
153
  /**
96
- * The concurrency governor this run is booking its requests against — either the
154
+ * The concurrency governor the run in progress is booking its requests against — either the
97
155
  * {@link BasicCrawlerOptions.concurrencySystem|`concurrencySystem`} that was injected, or the default the
98
156
  * crawler built for itself. Read it for telemetry: `desiredConcurrency`, `currentConcurrency`, `isRunning`.
99
157
  *
100
- * > *NOTE:* `undefined` until {@link BasicCrawler.run|`crawler.run()`} has resolved it. A crawler-owned default
101
- * is also rebuilt for every run, so the instance is not stable across runs.
158
+ * > *NOTE:* `undefined` outside a run, and a crawler-owned default is rebuilt for every run — so read it
159
+ * during a run rather than caching it across runs.
102
160
  *
103
161
  * {@link IConcurrencySystem} is deliberately read-only. Tuning concurrency *while a crawl is running* means
104
162
  * owning the instance: build a {@link ConcurrencySystem} yourself and inject it, then set
105
163
  * `minConcurrency`/`maxConcurrency`/`desiredConcurrency` on your own reference.
106
164
  */
107
165
  get concurrencySystem() {
108
- return this.concurrencySystemDep?.maybeValue;
166
+ return this.#concurrencySystem?.value;
109
167
  }
110
- /**
111
- * The task loop that dispatches this run's requests. Private on purpose — it is a bare parallel task runner with
112
- * no configuration left of its own (see {@link ConcurrencySystem}), and everything a caller legitimately did
113
- * with it now has a crawler-level counterpart: {@link BasicCrawler.pause|`pause()`},
114
- * {@link BasicCrawler.resume|`resume()`}, {@link BasicCrawler.teardown|`teardown()`} and
115
- * {@link BasicCrawler.concurrencySystem|`concurrencySystem`}.
116
- */
117
- autoscaledPool;
118
168
  /**
119
169
  * A reference to the underlying {@link IProxyConfiguration} instance that manages the crawler's proxies.
120
170
  * Only available if used by the crawler.
@@ -125,132 +175,148 @@ export class BasicCrawler {
125
175
  * See {@link Router.addHandler|`router.addHandler()`} and {@link Router.addDefaultHandler|`router.addDefaultHandler()`}.
126
176
  */
127
177
  router = Router.create();
128
- _basicContextPipeline;
178
+ #basicContextPipeline;
129
179
  /**
130
180
  * The basic part of the context pipeline. Unlike the subclass pipeline, this
131
181
  * part has no major side effects (e.g. launching a browser). It also makes typing more explicit, as subclass
132
182
  * pipelines expect the basic crawler fields to already be present in the context at runtime.
133
183
  *
134
184
  * Context built with this pipeline can be passed into multiple crawler pipelines at once.
135
- * This is used e.g. in the {@link AdaptivePlaywrightCrawler|`AdaptivePlaywrightCrawler`}.
136
185
  */
137
- get basicContextPipeline() {
138
- if (this._basicContextPipeline === undefined) {
139
- this._basicContextPipeline = this.buildBasicContextPipeline();
140
- }
141
- return this._basicContextPipeline;
186
+ get #basicPipeline() {
187
+ this.#basicContextPipeline ??= this.#buildBasicContextPipeline();
188
+ return this.#basicContextPipeline;
142
189
  }
143
- _contextPipeline;
190
+ #contextPipeline;
191
+ /** @internal */
144
192
  get contextPipeline() {
145
- if (this._contextPipeline === undefined) {
146
- this._contextPipeline = this.buildFinalContextPipeline();
193
+ if (this.#contextPipeline === undefined) {
194
+ this.#contextPipeline = this.#buildFinalContextPipeline();
147
195
  }
148
- return this._contextPipeline;
196
+ return this.#contextPipeline;
197
+ }
198
+ #hasFinishedBefore = false;
199
+ /** Whether a `run()` on this instance has already finished - a repeated one continues where it left off. */
200
+ get hasFinishedBefore() {
201
+ return this.#hasFinishedBefore;
149
202
  }
150
- running = false;
151
- hasFinishedBefore = false;
152
- unexpectedStop = false;
153
203
  #log;
154
204
  get log() {
155
205
  return this.#log;
156
206
  }
157
207
  requestHandler;
158
- errorHandler;
159
- failedRequestHandler;
160
- requestHandlerTimeoutMillis;
208
+ #errorHandler;
209
+ #failedRequestHandler;
210
+ #requestHandlerTimeoutMillis;
161
211
  internalTimeoutMillis;
162
- maxRequestRetries;
163
- maxCrawlDepth;
164
- sameDomainDelayMillis;
165
- domainAccessedTime;
166
- maxRequestsPerCrawl;
167
- get handledRequestsCount() {
168
- return this.stats.state.requestsFinished + this.stats.state.requestsFailed;
169
- }
170
- statusMessageLoggingInterval;
171
- statusMessageCallback;
212
+ #maxRequestRetries;
213
+ #maxCrawlDepth;
214
+ #maxRequestsPerCrawl;
215
+ get #handledRequestsCount() {
216
+ return this.statistics.state.requestsSucceeded + this.statistics.state.requestsFailed;
217
+ }
218
+ #statusMessageLoggingInterval;
219
+ #statusMessageCallback;
172
220
  blockedStatusCodes = new Set();
173
221
  additionalHttpErrorStatusCodes;
174
- ignoreHttpErrorStatusCodes;
222
+ #ignoreHttpErrorStatusCodes;
175
223
  /**
176
224
  * The resolved options for the crawler's own task loop — the crawler-owned `runTaskFunction`, the (possibly
177
225
  * user-overridden) ready/finished predicates and cadence/logging. Concurrency configuration lives on the
178
226
  * {@link ConcurrencySystem} instead, and the loop's `consumer` identity is the crawler's own, so neither is
179
227
  * settable here.
180
228
  */
181
- taskLoopOptions;
229
+ #taskLoopOptions;
182
230
  httpClient;
183
231
  retryOnBlocked;
184
- respectRobotsTxtFile;
185
- onSkippedRequest;
186
- _closeEvents;
187
- loggedPerRun = new Set();
188
- robotsTxtFileCache;
189
- identity;
190
- contextPipelineOptions;
232
+ #respectRobotsTxtFile;
233
+ /** Whether `runInStorageTransaction()` opens a transaction at all. */
234
+ #transactionalStorageEnabled;
235
+ /** The resolved per-storage-type write policy overrides forwarded to each request's transaction. */
236
+ #storageWritePolicy;
237
+ #onSkippedRequest;
238
+ #robotsTxtFileCache;
239
+ #identity;
240
+ #contextPipelineOptions;
241
+ /**
242
+ * @internal
243
+ */
191
244
  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),
245
+ contextPipelineBuilder: schemas.anyObject.optional(),
246
+ extendContext: schemas.anyFunction.optional(),
247
+ requestList: validators.requestList.optional(),
248
+ requestQueue: validators.requestQueue.optional(),
249
+ requestManager: validators.requestManager.optional(),
196
250
  // Subclasses override this function instead of passing it
197
251
  // in constructor, so this validation needs to apply only
198
252
  // 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,
253
+ requestHandler: schemas.anyFunction.optional(),
254
+ requestHandlerTimeoutSecs: schemas.anyNumber.optional(),
255
+ errorHandler: schemas.anyFunction.optional(),
256
+ failedRequestHandler: schemas.anyFunction.optional(),
257
+ maxRequestRetries: schemas.anyNumber.default(3),
258
+ sameDomainDelaySecs: schemas.anyNumber.default(0),
259
+ maxRequestsPerCrawl: schemas.anyNumber.optional(),
260
+ maxCrawlDepth: schemas.anyNumber.optional(),
261
+ // No zod default — subclasses provide their own fallback (e.g. HTTP-optimized pool options).
262
+ taskLoopOptions: schemas.anyObject.optional(),
263
+ concurrencySystem: schemas.anyObject.optional(),
264
+ sessionPool: validators.sessionPool.optional(),
265
+ proxyConfiguration: validators.proxyConfiguration.optional(),
266
+ statusMessageLoggingInterval: schemas.anyNumber.default(10),
267
+ statusMessageCallback: schemas.anyFunction.optional(),
268
+ additionalHttpErrorStatusCodes: schemas.arrayOf(schemas.anyNumber, 'numbers').default(() => []),
269
+ ignoreHttpErrorStatusCodes: schemas.arrayOf(schemas.anyNumber, 'numbers').default(() => []),
270
+ blockedStatusCodes: schemas.arrayOf(schemas.anyNumber, 'numbers').optional(),
271
+ retryOnBlocked: z.boolean().default(false),
272
+ respectRobotsTxtFile: z.union([z.boolean(), schemas.anyObject]).default(false),
273
+ transactionalStorage: z
274
+ .union([z.boolean(), z.strictObject({ requestQueue: z.enum(['deferred', 'writeThrough']).optional() })])
275
+ .optional(),
276
+ onSkippedRequest: schemas.anyFunction.optional(),
277
+ httpClient: schemas.httpClient.optional(),
278
+ configuration: z.instanceof(Configuration).optional(),
279
+ storageBackend: validators.storageBackend.optional(),
280
+ eventManager: z.instanceof(EventManager).optional(),
281
+ logger: validators.logger.optional(),
224
282
  // 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,
283
+ minConcurrency: schemas.anyNumber.optional(),
284
+ maxConcurrency: schemas.anyNumber.optional(),
285
+ initialConcurrency: schemas.anyNumber.optional(),
286
+ maxRequestsPerMinute: schemas.anyNumber
287
+ .refine((value) => Number.isInteger(value) || value === Infinity, 'Expected an integer or infinite number')
288
+ .refine((value) => value >= 1, 'Expected a number greater than or equal to 1')
289
+ .optional(),
290
+ keepAlive: z.boolean().optional(),
291
+ statistics: schemas.anyObject.optional(),
292
+ id: z.string().optional(),
231
293
  };
294
+ static #optionsSchema = z.strictObject(BasicCrawler.optionsShape);
232
295
  /**
233
296
  * All `BasicCrawler` parameters are passed via an options object.
234
297
  */
235
298
  constructor(options = {}) {
236
- ow(options, 'BasicCrawlerOptions', ow.object.exactShape(BasicCrawler.optionsShape));
299
+ const parsedOptions = parseArgument(options, BasicCrawler.#optionsSchema, 'BasicCrawlerOptions');
237
300
  const {
238
301
  // oxlint-disable-next-line typescript/no-deprecated -- still accepted and folded into `requestManager` for back-compat
239
302
  requestList,
240
303
  // 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 = [],
304
+ requestQueue, requestManager, maxRequestRetries, sameDomainDelaySecs, maxRequestsPerCrawl, maxCrawlDepth, taskLoopOptions = {}, concurrencySystem, keepAlive, sessionPool, proxyConfiguration, additionalHttpErrorStatusCodes, ignoreHttpErrorStatusCodes,
242
305
  // Service locator options
243
306
  configuration, storageBackend, eventManager, logger,
244
307
  // AutoscaledPool shorthands
245
- minConcurrency, maxConcurrency, maxRequestsPerMinute, blockedStatusCodes: blockedStatusCodesInput, retryOnBlocked = false, respectRobotsTxtFile = false, onSkippedRequest, requestHandler, requestHandlerTimeoutSecs, errorHandler, failedRequestHandler, statusMessageLoggingInterval = 10, statusMessageCallback, statisticsOptions, httpClient, id, } = options;
308
+ minConcurrency, maxConcurrency, initialConcurrency, maxRequestsPerMinute, blockedStatusCodes: blockedStatusCodesInput, retryOnBlocked, respectRobotsTxtFile, transactionalStorage, onSkippedRequest, requestHandler, requestHandlerTimeoutSecs, errorHandler, failedRequestHandler, statusMessageLoggingInterval, statusMessageCallback, statistics, httpClient, id, } = parsedOptions;
246
309
  // All concurrency configuration lives on the `ConcurrencySystem`, so the shortcuts have nowhere to go once
247
310
  // one is supplied - and silently dropping a `maxConcurrency` the user asked for is how crawls end up
248
311
  // hammering a site.
249
312
  if (concurrencySystem !== undefined &&
250
- (minConcurrency !== undefined || maxConcurrency !== undefined || maxRequestsPerMinute !== undefined)) {
251
- throw new ArgumentError('The `minConcurrency`/`maxConcurrency`/`maxRequestsPerMinute` shortcuts cannot be combined with ' +
252
- '`concurrencySystem` - they configure the default `ConcurrencySystem` that a supplied one ' +
253
- 'replaces. Pass them to the `ConcurrencySystem` constructor instead.', this.constructor);
313
+ (minConcurrency !== undefined ||
314
+ maxConcurrency !== undefined ||
315
+ initialConcurrency !== undefined ||
316
+ maxRequestsPerMinute !== undefined)) {
317
+ throw new Error('The `minConcurrency`/`maxConcurrency`/`initialConcurrency`/`maxRequestsPerMinute` shortcuts ' +
318
+ 'cannot be combined with `concurrencySystem` - they configure the default `ConcurrencySystem` ' +
319
+ 'that a supplied one replaces. Pass them to the `ConcurrencySystem` constructor instead.');
254
320
  }
255
321
  // Create per-crawler service locator if custom services were provided.
256
322
  // This wraps every method on the crawler instance so that calls to the global `serviceLocator`
@@ -262,96 +328,121 @@ export class BasicCrawler {
262
328
  eventManager ||
263
329
  logger ||
264
330
  (configuration !== undefined && configuration !== serviceLocator.getConfiguration())) {
265
- const scopedServiceLocator = new ServiceLocator(configuration, eventManager, storageBackend, logger);
331
+ // Inherit the ambient locator's already-set services for anything not explicitly
332
+ // provided - e.g. only passing a `logger` must not detach the crawler from a globally
333
+ // configured storage backend.
334
+ const ambientServices = serviceLocator.getServicesIfSet();
335
+ const scopedServiceLocator = new ServiceLocator(configuration ?? ambientServices.configuration, eventManager ?? ambientServices.eventManager, storageBackend ?? ambientServices.storageBackend, logger ?? ambientServices.logger);
266
336
  serviceLocatorScope = bindMethodsToServiceLocator(scopedServiceLocator, this);
267
337
  }
268
338
  try {
269
339
  serviceLocatorScope.enterScope();
270
- this.contextPipelineOptions = {
271
- contextPipelineBuilder: options.contextPipelineBuilder,
272
- extendContext: options.extendContext,
340
+ this.#contextPipelineOptions = {
341
+ contextPipelineBuilder: parsedOptions.contextPipelineBuilder,
342
+ extendContext: parsedOptions.extendContext,
273
343
  };
274
344
  this.#log = serviceLocator.getLogger().child({ prefix: this.constructor.name });
275
345
  // Initialize the Configuration instance to avoid lazy loading in the components
276
346
  serviceLocator.getConfiguration();
277
- const instanceIndex = BasicCrawler.instanceCount++;
278
- this.identity = { instanceIndex, hasExplicitId: id !== undefined, id: id ?? String(instanceIndex) };
279
- if (requestManager !== undefined) {
280
- if (requestList !== undefined || requestQueue !== undefined) {
281
- throw new Error('The `requestManager` option cannot be used in conjunction with `requestList` and/or `requestQueue`');
282
- }
283
- this.requestManager = requestManager;
347
+ const instanceIndex = BasicCrawler.#instanceCount++;
348
+ this.#identity = { instanceIndex, hasExplicitId: id !== undefined, id: id ?? String(instanceIndex) };
349
+ if (requestManager !== undefined && (requestList !== undefined || requestQueue !== undefined)) {
350
+ throw new Error('The `requestManager` option cannot be used in conjunction with `requestList` and/or `requestQueue`');
284
351
  }
285
- else if (requestList !== undefined && requestQueue !== undefined) {
286
- // Combine the read-only list with the writable queue into a tandem.
287
- this.requestManager = new RequestManagerTandem(requestList, requestQueue);
352
+ const suppliedManager = requestManager ?? requestQueue;
353
+ // Offered before building a pacer of our own: anything that paces takes the floor - through any
354
+ // number of wrappers, since they all forward - so no domain ends up with two clocks.
355
+ const floorTaken = sameDomainDelaySecs > 0 &&
356
+ (suppliedManager?.recordPacingSignal({
357
+ reason: 'minIntervalEverywhere',
358
+ intervalMs: sameDomainDelaySecs * 1000,
359
+ // What `sameDomainDelaySecs` has always meant: one clock per site, subdomains included.
360
+ scope: 'registrableDomain',
361
+ }) ??
362
+ false);
363
+ const pacerNeeded = sameDomainDelaySecs > 0 && !floorTaken;
364
+ // Built here rather than at first use so it can sit *inside* the tandem below, which is where a
365
+ // loader's transferred requests pass through it.
366
+ const writableManager = pacerNeeded
367
+ ? new ThrottlingRequestManager({
368
+ domains: 'all',
369
+ minCrawlDelaySecs: sameDomainDelaySecs,
370
+ throttleBy: 'registrableDomain',
371
+ persistStateKey: `CRAWLEE_THROTTLED_DOMAINS_${this.#identity.id}`,
372
+ // A factory, because the default queue is only opened on first use.
373
+ inner: suppliedManager ?? (() => this.#openOwnedRequestQueue()),
374
+ })
375
+ : suppliedManager;
376
+ if (requestList !== undefined) {
377
+ // The list is read first, while new requests still have somewhere writable to go.
378
+ this.#requestManager = new RequestManagerTandem(requestList, writableManager ?? (() => this.#openOwnedRequestQueue()));
288
379
  }
289
- else if (requestQueue !== undefined) {
380
+ else if (writableManager !== undefined) {
290
381
  // A RequestQueue is itself a request manager.
291
- this.requestManager = requestQueue;
292
- }
293
- else if (requestList !== undefined) {
294
- // A lone read-only `requestList` (deprecated option) is combined with a lazily-opened default queue
295
- // into a tandem, so that its requests are read first and new ones can still be enqueued during the
296
- // crawl. The queue is opened on first use; the tandem also forwards `persistState()` to the loader.
297
- this.requestManager = new RequestManagerTandem(requestList, () => this.openOwnedRequestQueue());
382
+ this.#requestManager = writableManager;
298
383
  }
299
384
  this.httpClient = httpClient ?? new LazyDefaultHttpClient({ logger: this.log });
300
385
  this.proxyConfiguration = proxyConfiguration;
301
- this.statusMessageLoggingInterval = statusMessageLoggingInterval;
302
- this.statusMessageCallback = statusMessageCallback;
303
- this.domainAccessedTime = new Map();
304
- this.robotsTxtFileCache = new LruCache({ maxLength: 1000 });
305
- this.handleSkippedRequest = this.handleSkippedRequest.bind(this);
386
+ this.#statusMessageLoggingInterval = statusMessageLoggingInterval;
387
+ this.#statusMessageCallback = statusMessageCallback;
388
+ this.#robotsTxtFileCache = new LruCache({ maxLength: 1000 });
306
389
  this.additionalHttpErrorStatusCodes = new Set([...additionalHttpErrorStatusCodes]);
307
- this.ignoreHttpErrorStatusCodes = new Set([...ignoreHttpErrorStatusCodes]);
390
+ this.#ignoreHttpErrorStatusCodes = new Set([...ignoreHttpErrorStatusCodes]);
308
391
  this.requestHandler = requestHandler ?? this.router;
309
- this.failedRequestHandler = failedRequestHandler;
310
- this.errorHandler = errorHandler;
392
+ this.#failedRequestHandler = failedRequestHandler;
393
+ this.#errorHandler = errorHandler;
311
394
  if (requestHandlerTimeoutSecs) {
312
- this.requestHandlerTimeoutMillis = requestHandlerTimeoutSecs * 1000;
395
+ this.#requestHandlerTimeoutMillis = requestHandlerTimeoutSecs * 1000;
313
396
  }
314
397
  else {
315
- this.requestHandlerTimeoutMillis = 60_000;
398
+ this.#requestHandlerTimeoutMillis = 60_000;
316
399
  }
317
400
  this.retryOnBlocked = retryOnBlocked;
318
- this.respectRobotsTxtFile = respectRobotsTxtFile;
319
- this.onSkippedRequest = onSkippedRequest;
401
+ this.#respectRobotsTxtFile = respectRobotsTxtFile;
402
+ // The cast undoes ow's assertion signature, which mangles `boolean | object` unions.
403
+ const transactionalStorageOption = transactionalStorage;
404
+ this.#transactionalStorageEnabled = transactionalStorageOption !== false;
405
+ this.#storageWritePolicy = typeof transactionalStorageOption === 'object' ? transactionalStorageOption : {};
406
+ this.#onSkippedRequest = onSkippedRequest;
320
407
  // allow at least 5min for internal timeouts
321
408
  this.internalTimeoutMillis =
322
409
  serviceLocator.getConfiguration().internalTimeoutMillis ??
323
- Math.max(this.requestHandlerTimeoutMillis * 2, 300e3);
324
- this.maxRequestRetries = maxRequestRetries;
325
- this.maxCrawlDepth = maxCrawlDepth;
326
- this.sameDomainDelayMillis = sameDomainDelaySecs * 1000;
327
- this.stats = new Statistics({
410
+ Math.max(this.#requestHandlerTimeoutMillis * 2, 300e3);
411
+ this.#maxRequestRetries = maxRequestRetries;
412
+ this.#maxCrawlDepth = maxCrawlDepth;
413
+ this.#statisticsDep = OwnedOrInjected.resolve(statistics,
414
+ // A crawler-built default tracks the built-in fields only. A non-empty `StatisticStateExtension` can
415
+ // only be satisfied by an injected instance carrying the matching `state`, so this branch does
416
+ // not run in that case - hence the cast.
417
+ () => new Statistics({
328
418
  logMessage: `${this.constructor.name} request statistics:`,
329
419
  log: this.log,
330
- id: this.identity.id,
331
- ...statisticsOptions,
332
- });
420
+ id: this.#identity.id,
421
+ }));
333
422
  if (sessionPool && proxyConfiguration) {
334
423
  this.log.warning('Both `sessionPool` and `proxyConfiguration` were provided to the crawler. ' +
335
424
  'The `proxyConfiguration` is ignored - sessions from the supplied pool keep whatever ' +
336
425
  '`proxyInfo` they were created with. Configure proxies on the pool instead, ' +
337
426
  'e.g. via `addSession({ proxyInfo })` or a custom `createSessionFunction`.');
338
427
  }
339
- this.sessionPoolDep = OwnedOrInjected.resolve(sessionPool, () => new SessionPool({
428
+ this.#sessionPoolDep = OwnedOrInjected.resolve(sessionPool, () => new SessionPool({
340
429
  createSessionFunction: async (opts) => new Session({
341
430
  ...opts?.sessionOptions,
342
- proxyInfo: opts?.sessionOptions?.proxyInfo ?? (await this.proxyConfiguration?.newProxyInfo()),
431
+ proxyInfo: this.proxyConfiguration
432
+ ? await this.proxyConfiguration.newProxyInfo(opts?.sessionOptions?.proxyInfo)
433
+ : opts?.sessionOptions?.proxyInfo,
343
434
  }),
344
435
  }));
345
436
  this.blockedStatusCodes = new Set(blockedStatusCodesInput ?? BLOCKED_STATUS_CODES);
346
437
  const maxSignedInteger = 2 ** 31 - 1;
347
- if (this.requestHandlerTimeoutMillis > maxSignedInteger) {
348
- this.log.warning(`requestHandlerTimeoutMillis ${this.requestHandlerTimeoutMillis}` +
438
+ if (this.#requestHandlerTimeoutMillis > maxSignedInteger) {
439
+ this.log.warning(`requestHandlerTimeoutMillis ${this.#requestHandlerTimeoutMillis}` +
349
440
  ` does not fit a signed 32-bit integer. Limiting the value to ${maxSignedInteger}`);
350
- this.requestHandlerTimeoutMillis = maxSignedInteger;
441
+ this.#requestHandlerTimeoutMillis = maxSignedInteger;
351
442
  }
352
443
  this.internalTimeoutMillis = Math.min(this.internalTimeoutMillis, maxSignedInteger);
353
- this.maxRequestsPerCrawl = maxRequestsPerCrawl;
354
- const isMaxPagesExceeded = () => this.maxRequestsPerCrawl && this.maxRequestsPerCrawl <= this.handledRequestsCount;
444
+ this.#maxRequestsPerCrawl = maxRequestsPerCrawl;
445
+ const isMaxPagesExceeded = () => this.#maxRequestsPerCrawl && this.#maxRequestsPerCrawl <= this.#handledRequestsCount;
355
446
  // eslint-disable-next-line prefer-const
356
447
  let { isFinishedFunction, isTaskReadyFunction } = taskLoopOptions;
357
448
  // override even if `isFinishedFunction` provided by user - `keepAlive` has higher priority
@@ -363,82 +454,74 @@ export class BasicCrawler {
363
454
  const source = this.requestManager;
364
455
  if (!source)
365
456
  throw new Error('Request provider is not initialized!');
366
- const request = await this.resolveRequest();
367
- if (!request || this.delayRequest(request, source)) {
457
+ const request = await this.#resolveRequest();
458
+ if (!request) {
368
459
  return;
369
460
  }
370
461
  // Started here, rather than in `handleRequest`, so that a failure during context pipeline
371
462
  // initialization (e.g. a browser page timing out before the request handler ever runs) is
372
- // still accounted for by `failJob` below - which is a no-op without a matching `startJob`.
373
- this.stats.startJob(request.id || request.uniqueKey);
463
+ // still accounted for by `recordRequestFailure` below - which is a no-op without a
464
+ // matching `recordRequestStart`.
465
+ this.statistics.recordRequestStart(request.id || request.uniqueKey);
374
466
  const crawlingContext = { request };
375
467
  try {
468
+ // The transaction spans the whole pipeline call, covering the navigation hooks
469
+ // and `extendContext` too; `handleRequest` commits it and `handleRequestFailure` rolls it back.
470
+ await this.runInStorageTransaction(async () =>
376
471
  // Navigation, the navigation hooks and the request handler are timed individually, but the
377
472
  // phases between them are not, so a request could still get stuck indefinitely. This is the
378
473
  // catch-all for that - see `raceWithTimeout` for why it is a bare timer, not a timeout frame.
379
- await this.withRequestTimeout(crawlingContext, this.basicContextPipeline
380
- .chain(this.contextPipeline)
381
- .call(crawlingContext, (ctx) => this.handleRequest(ctx, source, request)));
474
+ await this.#withRequestTimeout(crawlingContext, this.#basicPipeline.chain(this.contextPipeline).call(crawlingContext, (ctx) => this.handleRequest(ctx, source, request), (error) => this.#handleRequestFailure(error, crawlingContext, source, request))));
382
475
  }
383
476
  catch (error) {
384
- // ContextPipelineInterruptedError means the request was intentionally skipped
385
- // (e.g., doesn't match enqueue strategy after redirect). Just return gracefully.
386
- if (error instanceof ContextPipelineInterruptedError) {
387
- this.stats.discardJob(request.id || request.uniqueKey);
388
- 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
- return;
390
- }
391
- // If the error happened during pipeline initialization (e.g., navigation timeout, session/proxy error,
392
- // i.e. not in user's requestHandler), handle it through the normal error flow. A bare `TimeoutError`
393
- // here is the internal timeout above firing - anything else thrown inside the pipeline arrives wrapped.
394
- const isPipelineError = error instanceof ContextPipelineInitializationError ||
395
- error instanceof SessionError ||
396
- error instanceof TimeoutError;
397
- if (isPipelineError) {
398
- const unwrappedError = this.unwrapError(error);
477
+ // The internal timeout above fired, or `handleRequestFailure` threw a `SessionError` - both
478
+ // take the normal error flow. Anything else is fatal.
479
+ if (error instanceof SessionError || crawlingContext[timeoutExpiredKey]?.()) {
480
+ const unwrappedError = this.#unwrapError(error);
399
481
  await this.requestFunctionErrorHandler(unwrappedError, crawlingContext, request, this.requestManager);
400
- // SessionError already retired the session in `requestFunctionErrorHandler`;
401
- // skip `markBad` to avoid double-counting usage/error score.
402
- if (!(unwrappedError instanceof SessionError)) {
482
+ if (!this.#errorAbsolvesSession(unwrappedError)) {
403
483
  crawlingContext.session?.markBad();
404
484
  }
405
485
  return;
406
486
  }
407
- throw this.unwrapError(error);
408
- }
409
- finally {
410
- // Run request-scoped deferred cleanups only after the whole request lifecycle - including the user's error handler - has finished.
411
- const deferredCleanup = crawlingContext[deferredCleanupKey] ?? [];
412
- await Promise.all(deferredCleanup.map((fn) => fn().catch((cleanupError) => this.log.debug('Error in deferred cleanup', { error: cleanupError }))));
487
+ throw this.#unwrapError(error);
413
488
  }
414
489
  },
415
490
  isTaskReadyFunction: async () => {
416
491
  if (isMaxPagesExceeded()) {
417
- this.logOncePerRun('shuttingDown', 'Crawler reached the maxRequestsPerCrawl limit of ' +
418
- `${this.maxRequestsPerCrawl} requests and will shut down soon. Requests that are in progress will be allowed to finish.`);
492
+ this.#liveRun?.logOnce('shuttingDown', 'Crawler reached the maxRequestsPerCrawl limit of ' +
493
+ `${this.#maxRequestsPerCrawl} requests and will shut down soon. Requests that are in progress will be allowed to finish.`);
419
494
  return false;
420
495
  }
421
- if (this.unexpectedStop) {
422
- this.logOncePerRun('shuttingDown', 'No new requests are allowed because the `stop()` method has been called. ' +
496
+ if (this.#run?.stopRequested) {
497
+ this.#liveRun?.logOnce('shuttingDown', 'No new requests are allowed because the `stop()` method has been called. ' +
423
498
  'Ongoing requests will be allowed to complete.');
424
499
  return false;
425
500
  }
426
- return isTaskReadyFunction ? await isTaskReadyFunction() : await this.isTaskReadyFunction();
501
+ return isTaskReadyFunction ? await isTaskReadyFunction() : await this.#isTaskReadyFunction();
427
502
  },
428
503
  isFinishedFunction: async () => {
429
504
  if (isMaxPagesExceeded()) {
430
- this.log.info(`Earlier, the crawler reached the maxRequestsPerCrawl limit of ${this.maxRequestsPerCrawl} requests ` +
505
+ this.log.info(`Earlier, the crawler reached the maxRequestsPerCrawl limit of ${this.#maxRequestsPerCrawl} requests ` +
431
506
  'and all requests that were in progress at that time have now finished. ' +
432
- `In total, the crawler processed ${this.handledRequestsCount} requests and will shut down.`);
507
+ `In total, the crawler processed ${this.#handledRequestsCount} requests and will shut down.`);
433
508
  return true;
434
509
  }
435
- if (this.unexpectedStop) {
510
+ if (this.#run?.stopRequested) {
436
511
  this.log.info('The crawler has finished all the remaining ongoing requests and will shut down now.');
437
512
  return true;
438
513
  }
514
+ // `maybeFinish()` calls this only once nothing is in flight (`autoscaled_pool.ts`) - the point
515
+ // where a crawl that cannot progress becomes distinguishable from one that is merely waiting,
516
+ // and the only place where throwing does not abandon requests mid-processing.
517
+ const state = await this.requestManager?.checkReadiness();
518
+ // Under `keepAlive`, outliving a domain that will not let us through is the whole point.
519
+ if (state?.status === 'stalled' && !keepAlive) {
520
+ throw new PersistentRateLimitError(`Giving up: ${state.reason}`);
521
+ }
439
522
  const isFinished = isFinishedFunction
440
523
  ? await isFinishedFunction()
441
- : await this.defaultIsFinishedFunction();
524
+ : state === undefined || state.status === 'finished';
442
525
  if (isFinished) {
443
526
  const reason = isFinishedFunction
444
527
  ? "Crawler's custom isFinishedFunction() returned true, the crawler will shut down."
@@ -449,11 +532,14 @@ export class BasicCrawler {
449
532
  },
450
533
  log: this.log,
451
534
  };
452
- this.taskLoopOptions = { ...taskLoopOptions, ...crawlerOwnedTaskLoopConfiguration };
453
- this.resolveConcurrencySystem = () => OwnedOrInjected.resolve(concurrencySystem, () => this.createDefaultConcurrencySystem({
535
+ this.#taskLoopOptions = { ...taskLoopOptions, ...crawlerOwnedTaskLoopConfiguration };
536
+ this.#resolveConcurrencySystem = () => OwnedOrInjected.resolve(concurrencySystem, () => this.createDefaultConcurrencySystem({
454
537
  minConcurrency,
455
538
  maxConcurrency,
456
539
  maxTasksPerMinute: maxRequestsPerMinute,
540
+ // Spread conditionally - an explicit `undefined` would clobber a subclass default, see
541
+ // `HTTP_OPTIMIZED_CONCURRENCY_SYSTEM_OPTIONS`.
542
+ ...(initialConcurrency !== undefined && { desiredConcurrency: initialConcurrency }),
457
543
  log: this.log,
458
544
  }));
459
545
  }
@@ -463,7 +549,7 @@ export class BasicCrawler {
463
549
  }
464
550
  /**
465
551
  * Builds the crawler-owned default {@link ConcurrencySystem} from the resolved
466
- * `minConcurrency`/`maxConcurrency`/`maxRequestsPerMinute` shortcuts. Not called when a
552
+ * `minConcurrency`/`maxConcurrency`/`initialConcurrency`/`maxRequestsPerMinute` shortcuts. Not called when a
467
553
  * {@link BasicCrawlerOptions.concurrencySystem|`concurrencySystem`} was injected.
468
554
  *
469
555
  * Subclasses may override this to tune the default system (e.g. {@link HttpCrawler} raises the starting
@@ -479,7 +565,7 @@ export class BasicCrawler {
479
565
  * @returns `true` if the status code is considered an error, `false` otherwise
480
566
  */
481
567
  isErrorStatusCode(status) {
482
- const excludeError = this.ignoreHttpErrorStatusCodes.has(status);
568
+ const excludeError = this.#ignoreHttpErrorStatusCodes.has(status);
483
569
  const includeError = this.additionalHttpErrorStatusCodes.has(status);
484
570
  return (status >= 500 && !excludeError) || includeError;
485
571
  }
@@ -487,35 +573,21 @@ export class BasicCrawler {
487
573
  * Builds the basic context pipeline that transforms `{ request }` into a full `CrawlingContext`.
488
574
  * This handles base context creation, session resolution, and context helpers.
489
575
  */
490
- buildBasicContextPipeline() {
576
+ #buildBasicContextPipeline() {
491
577
  return ContextPipeline.create()
492
- .compose({ action: this.checkRobotsTxt.bind(this) })
493
- .compose({ action: (context) => this.createBaseContext(context) })
494
- .compose({ action: this.resolveSession.bind(this) })
495
- .compose({ action: this.createContextHelpers.bind(this) });
578
+ .compose(this.#checkRobotsTxt.bind(this))
579
+ .compose(this.#createBaseContext.bind(this))
580
+ .compose(this.#resolveSession.bind(this))
581
+ .compose(this.#createContextHelpers.bind(this));
496
582
  }
497
- async checkRobotsTxt({ request }) {
498
- if (!(await this.isAllowedBasedOnRobotsTxtFile(request.url))) {
583
+ async #checkRobotsTxt({ request }) {
584
+ if (!(await this.#isAllowedBasedOnRobotsTxtFile(request.url))) {
499
585
  this.log.warning(`Skipping request ${request.url} (${request.id}) because it is disallowed based on robots.txt`);
500
- request.state = RequestState.SKIPPED;
501
- request.noRetry = true;
502
- await this.handleSkippedRequest({
503
- url: request.url,
504
- reason: 'robotsTxt',
505
- });
506
- throw new ContextPipelineInterruptedError(`Skipping request ${request.url} as disallowed by robots.txt`);
586
+ throw new ContextPipelineInterruptedError('robotsTxt');
507
587
  }
508
588
  return {};
509
589
  }
510
- /**
511
- * Builds the subclass-specific context pipeline that transforms a `CrawlingContext` into the crawler's target context type.
512
- * Subclasses should override this to add their own pipeline stages.
513
- */
514
- buildContextPipeline() {
515
- return ContextPipeline.create();
516
- }
517
- createBaseContext(context) {
518
- const deferredCleanup = [];
590
+ #createBaseContext(context, onCleanup) {
519
591
  return {
520
592
  id: cryptoRandomObjectId(10),
521
593
  log: this.log,
@@ -523,7 +595,23 @@ export class BasicCrawler {
523
595
  useState: this.useState.bind(this),
524
596
  getKeyValueStore: async (identifier) => KeyValueStore.open(identifier),
525
597
  registerDeferredCleanup: (cleanup) => {
526
- deferredCleanup.push(cleanup);
598
+ onCleanup(async () => {
599
+ try {
600
+ await cleanup();
601
+ }
602
+ catch (error) {
603
+ this.log.debug('Error in deferred cleanup', { error });
604
+ }
605
+ });
606
+ },
607
+ afterStorageCommit: (callback) => {
608
+ const transaction = currentStorageTransaction();
609
+ if (transaction === undefined) {
610
+ throw new Error('afterStorageCommit() needs an active storage transaction, and there is none. ' +
611
+ "With `transactionalStorage: false` the request's writes are applied as they are " +
612
+ 'made, so they throw at the call site instead of at commit time.');
613
+ }
614
+ transaction.afterCommit(callback);
527
615
  },
528
616
  extendTimeout: (secs) => {
529
617
  const extraMillis = secs * 1000;
@@ -536,44 +624,54 @@ export class BasicCrawler {
536
624
  if (context[navigationDeadlineKey] !== undefined) {
537
625
  context[navigationDeadlineKey] += extraMillis;
538
626
  }
627
+ // Extension failure must not fail the request that asked for more time.
628
+ this.requestManager?.extendRequestProcessingTimeSecs?.(context.request, secs)?.catch((error) => {
629
+ this.log.debug('Extending the request processing time failed', { url: context.request.url, error });
630
+ });
631
+ },
632
+ skipRequest: (message) => {
633
+ this.log.debug(`Skipping request ${context.request.url} (${context.request.id})${message ? `: ${message}` : ''}`);
634
+ throw new ContextPipelineInterruptedError('manual', message);
539
635
  },
540
- [deferredCleanupKey]: deferredCleanup,
541
636
  };
542
637
  }
543
- async resolveRequest() {
544
- const request = await this.timeoutAndRetry(this.fetchNextRequest.bind(this), this.internalTimeoutMillis, `Fetching next request timed out after ${this.internalTimeoutMillis / 1e3} seconds.`);
545
- // Reset loadedUrl so an old one is not carried over to retries.
546
- if (request) {
547
- request.loadedUrl = undefined;
638
+ /**
639
+ * Fetches the next request and rebuilds it as a {@link CrawlingRequest}. A copy rather than the manager's
640
+ * instance: managers key on `uniqueKey`/`id`, and it is this copy that flows back into `reclaimRequest` /
641
+ * `markRequestAsHandled`, so nothing observes the difference.
642
+ */
643
+ async #resolveRequest() {
644
+ const fetched = await this.#timeoutAndRetry(this.#fetchNextRequest.bind(this), this.internalTimeoutMillis, `Fetching next request timed out after ${this.internalTimeoutMillis / 1e3} seconds.`);
645
+ if (!fetched) {
646
+ return null;
548
647
  }
648
+ const request = CrawlingRequest.fromSchema(fetched);
649
+ // Reset loadedUrl so an old one is not carried over to retries.
650
+ request.loadedUrl = undefined;
549
651
  return request;
550
652
  }
551
- async resolveSession({ request }) {
552
- const session = await this.timeoutAndRetry(async () => {
653
+ async #resolveSession({ request }) {
654
+ const session = await this.#timeoutAndRetry(async () => {
553
655
  const existingSession = await this.sessionPool.getSession(request.sessionId);
554
656
  if (!existingSession) {
555
- throw new ContextPipelineInitializationError(new MissingSessionError(request.sessionId));
657
+ throw new MissingSessionError(request.sessionId);
556
658
  }
557
659
  return existingSession;
558
660
  }, this.internalTimeoutMillis, `Fetching session timed out after ${this.internalTimeoutMillis / 1e3} seconds.`);
559
661
  return { session, proxyInfo: session?.proxyInfo };
560
662
  }
561
- async createContextHelpers({ request, session }) {
562
- const enqueueLinksWrapper = async (options) => {
563
- const requestManager = await this.getRequestManager();
564
- return await this.enqueueLinksWithCrawlDepth(options, request, requestManager);
565
- };
663
+ async #createContextHelpers({ request, session }) {
566
664
  const addRequests = async (requests, options = {}) => {
567
665
  const newCrawlDepth = request.crawlDepth + 1;
568
666
  const requestsGenerator = this.addCrawlDepthRequestGenerator(requests, newCrawlDepth);
569
- await this.addRequests(requestsGenerator, options);
667
+ return await this.addRequests(requestsGenerator, options);
570
668
  };
571
669
  const sendRequest = createSendRequest(this.httpClient, request, session);
572
- return { enqueueLinks: enqueueLinksWrapper, addRequests, sendRequest };
670
+ return { addRequests, sendRequest };
573
671
  }
574
- buildFinalContextPipeline() {
575
- const subclassPipeline = (this.contextPipelineOptions.contextPipelineBuilder?.() ??
576
- this.buildContextPipeline());
672
+ #buildFinalContextPipeline() {
673
+ const subclassPipeline = (this.#contextPipelineOptions.contextPipelineBuilder?.() ??
674
+ ContextPipeline.create());
577
675
  // `extendContext` runs *before* the subclass navigation pipeline (which includes the
578
676
  // pre/post-navigation hooks). This makes the extension visible to those hooks and to the
579
677
  // request handler alike. The trade-off is that `extendContext` cannot access
@@ -584,30 +682,25 @@ export class BasicCrawler {
584
682
  // TypeScript cannot express that `Context` transitively includes `ContextExtension` here. The
585
683
  // casts below are sound because `buildFinalContextPipeline` is declared to return the fully
586
684
  // resolved `ExtendedContext` (= `Context & ContextExtension`).
587
- const { extendContext } = this.contextPipelineOptions;
685
+ const { extendContext } = this.#contextPipelineOptions;
588
686
  let contextPipeline;
589
687
  if (extendContext !== undefined) {
590
688
  contextPipeline = ContextPipeline.create()
591
- .compose({ action: async (context) => await extendContext(context) })
689
+ .compose(async (context) => await extendContext(context))
592
690
  .chain(subclassPipeline);
593
691
  }
594
692
  else {
595
693
  contextPipeline = subclassPipeline;
596
694
  }
597
- contextPipeline = contextPipeline.compose({
598
- action: async (context) => {
599
- const { request } = context;
600
- if (request && !this.requestMatchesEnqueueStrategy(request)) {
601
- // eslint-disable-next-line dot-notation
602
- const message = `Skipping request ${request.id} (starting url: ${request.url} -> loaded url: ${request.loadedUrl}) because it does not match the enqueue strategy (${request['enqueueStrategy']}).`;
603
- this.log.debug(message);
604
- request.noRetry = true;
605
- request.state = RequestState.SKIPPED;
606
- await this.handleSkippedRequest({ url: request.url, reason: 'redirect' });
607
- throw new ContextPipelineInterruptedError(message);
608
- }
609
- return context;
610
- },
695
+ contextPipeline = contextPipeline.compose(async (context) => {
696
+ const { request } = context;
697
+ if (request && !this.#requestMatchesEnqueueStrategy(request)) {
698
+ // eslint-disable-next-line dot-notation
699
+ const message = `Skipping request ${request.id} (starting url: ${request.url} -> loaded url: ${request.loadedUrl}) because it does not match the enqueue strategy (${request['enqueueStrategy']}).`;
700
+ this.log.debug(message);
701
+ throw new ContextPipelineInterruptedError('redirect');
702
+ }
703
+ return context;
611
704
  });
612
705
  return contextPipeline;
613
706
  }
@@ -618,7 +711,7 @@ export class BasicCrawler {
618
711
  * @param error The error to check.
619
712
  */
620
713
  isProxyError(error) {
621
- return ROTATE_PROXY_ERRORS.some((x) => this._getMessageFromError(error)?.includes(x));
714
+ return ROTATE_PROXY_ERRORS.some((x) => this.getMessageFromError(error).includes(x));
622
715
  }
623
716
  /**
624
717
  * Sets the status message for the current crawler run.
@@ -631,24 +724,26 @@ export class BasicCrawler {
631
724
  */
632
725
  setStatusMessage(message, options = {}) {
633
726
  const data = options.isStatusMessageTerminal != null ? { terminal: options.isStatusMessageTerminal } : undefined;
634
- this.log.logWithLevel(LogLevel[options.level ?? 'DEBUG'], message, data);
727
+ // Each allowed level has its own method on the logger, so this goes through them rather than through
728
+ // `logWithLevel`, which is abstract and therefore cannot be instrumented.
729
+ this.log[{ DEBUG: 'debug', INFO: 'info', WARNING: 'warning', ERROR: 'error' }[options.level ?? 'DEBUG']](message, data);
635
730
  // Broadcast the status message through the event system. Consumers (e.g. the Apify SDK) can
636
731
  // subscribe to `EventType.STATUS_MESSAGE` and propagate it to their status-reporting backend.
637
732
  // Setting the status message is not a storage concern, so we intentionally don't route it
638
733
  // through the storage client anymore.
639
- serviceLocator.getEventManager().emit("statusMessage" /* EventType.STATUS_MESSAGE */, {
640
- crawlerId: this.identity.id,
734
+ serviceLocator.getEventManager().emit(EventType.STATUS_MESSAGE, {
735
+ crawlerId: this.#identity.id,
641
736
  message,
642
737
  isStatusMessageTerminal: options.isStatusMessageTerminal,
643
738
  level: options.level,
644
739
  });
645
740
  }
646
- getPeriodicLogger() {
647
- let previousState = { ...this.stats.state };
741
+ #getPeriodicLogger() {
742
+ let previousState = { ...this.statistics.state };
648
743
  const getOperationMode = () => {
649
- const { requestsFailed } = this.stats.state;
744
+ const { requestsFailed } = this.statistics.state;
650
745
  const { requestsFailed: previousRequestsFailed } = previousState;
651
- previousState = { ...this.stats.state };
746
+ previousState = { ...this.statistics.state };
652
747
  const failedDelta = requestsFailed - previousRequestsFailed;
653
748
  if (failedDelta > 0) {
654
749
  return { mode: 'ERROR', failedDelta };
@@ -659,16 +754,16 @@ export class BasicCrawler {
659
754
  const { mode: operationMode, failedDelta } = getOperationMode();
660
755
  let message;
661
756
  if (operationMode === 'ERROR') {
662
- message = `Experiencing problems, ${failedDelta} failed requests in the past ${this.statusMessageLoggingInterval} seconds.`;
757
+ message = `Experiencing problems, ${failedDelta} failed requests in the past ${this.#statusMessageLoggingInterval} seconds.`;
663
758
  }
664
759
  else {
665
760
  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}.`;
761
+ message = `Crawled ${this.statistics.state.requestsSucceeded}${total ? `/${total}` : ''} pages, ${this.statistics.state.requestsFailed} failed requests, desired concurrency ${this.concurrencySystem?.desiredConcurrency ?? 0}.`;
667
762
  }
668
- if (this.statusMessageCallback) {
669
- await this.statusMessageCallback({
763
+ if (this.#statusMessageCallback) {
764
+ await this.#statusMessageCallback({
670
765
  crawler: this,
671
- state: this.stats.state,
766
+ state: this.statistics.state,
672
767
  previousState,
673
768
  message,
674
769
  });
@@ -676,7 +771,7 @@ export class BasicCrawler {
676
771
  }
677
772
  this.setStatusMessage(message);
678
773
  };
679
- const interval = setInterval(log, this.statusMessageLoggingInterval * 1e3);
774
+ const interval = setInterval(log, this.#statusMessageLoggingInterval * 1e3);
680
775
  return { log, stop: () => clearInterval(interval) };
681
776
  }
682
777
  /**
@@ -687,111 +782,141 @@ export class BasicCrawler {
687
782
  * We can use the `requests` parameter to enqueue the initial requests — it is a shortcut for
688
783
  * running {@link BasicCrawler.addRequests|`crawler.addRequests()`} before {@link BasicCrawler.run|`crawler.run()`}.
689
784
  *
785
+ * Calling `run()` again on the same instance keeps crawling the same request manager - requests the previous
786
+ * run handled (a failed one counts as handled) are not processed again. Purge the queue or open a fresh one
787
+ * if that is what you want.
788
+ *
690
789
  * @param [requests] The requests to add.
691
- * @param [options] Options for the request queue.
790
+ * @param [options] Options for adding the initial requests.
692
791
  */
693
792
  async run(requests, options) {
694
- if (this.running) {
695
- throw new Error('This crawler instance is already running, you can add more requests to it via `crawler.addRequests()`.');
696
- }
697
- const { purgeRequestQueue, ...addRequestsOptions } = options ?? {};
698
- if (this.hasFinishedBefore) {
699
- // When executing the run method for the second time explicitly,
700
- // we need to purge the RQ to allow processing the same requests again — this is important so users can
701
- // pass in failed requests back to the `crawler.run()`, otherwise they would be considered as handled and
702
- // ignored — as a failed request is still handled.
703
- // By default (`purgeRequestQueue` unset), only the queue we opened ourselves is purged.
704
- // When `purgeRequestQueue` is explicitly `true`, we also purge a user-supplied manager.
705
- // When `purgeRequestQueue` is explicitly `false`, nothing is purged.
706
- const shouldPurge = purgeRequestQueue !== false;
707
- const managerToPurge = this.ownedRequestQueue.maybeValue ?? (purgeRequestQueue === true ? this.requestManager : undefined);
708
- if (managerToPurge?.purge && shouldPurge) {
709
- await managerToPurge.purge();
793
+ // A crawl is the top level of its own transaction and timeout scope, not a participant in the caller's.
794
+ return withDirectStorageAccess(async () => timeoutStorage.exit(async () => {
795
+ if (this.running) {
796
+ throw new Error('This crawler instance is already running, you can add more requests to it via `crawler.addRequests()`.');
710
797
  }
711
- this.stats.reset();
712
- await this.stats.resetStore();
713
- await this.sessionPoolDep.ifOwned((pool) => pool.resetStore());
714
- }
715
- this.unexpectedStop = false;
716
- this.running = true;
717
- this.loggedPerRun.clear();
718
- await purgeDefaultStorages({
719
- onlyPurgeOnce: true,
720
- storageBackend: serviceLocator.getStorageBackend(),
721
- configuration: serviceLocator.getConfiguration(),
722
- });
723
- if (requests) {
724
- await this.addRequests(requests, addRequestsOptions);
725
- }
726
- try {
727
- await this._init();
728
- await this.stats.startCapturing();
729
- }
730
- catch (error) {
731
- // Clean up here before propagating, otherwise a failed startup would leave the process hanging.
732
- await this.teardown().catch((teardownError) => {
733
- this.log.exception(teardownError, 'Cleaning up after a failed crawler startup failed.');
798
+ if (this.#hasFinishedBefore) {
799
+ // A supplied statistics instance keeps whatever state it was handed - only wipe a default we built.
800
+ await this.#statisticsDep.ifOwned(async (stats) => {
801
+ stats.reset();
802
+ await stats.resetStore();
803
+ });
804
+ // A session pool the crawler built follows the same blank-slate rule as its statistics.
805
+ await this.#sessionPoolDep.ifOwned(async (pool) => {
806
+ pool.reset();
807
+ await pool.resetStore();
808
+ });
809
+ }
810
+ // An owned governor is rebuilt for every run, so it always starts from a clean slate — stale
811
+ // resource snapshots or a previous run's scaled desired concurrency would otherwise distort this
812
+ // run's scaling. An injected one is long-lived and its lifecycle belongs to the caller.
813
+ this.#concurrencySystem = this.#resolveConcurrencySystem();
814
+ const run = new CrawlerRun({
815
+ log: this.log,
816
+ concurrencySystem: this.#concurrencySystem.value,
817
+ taskLoopOptions: this.#taskLoopOptions,
818
+ consumer: this.#identity,
734
819
  });
735
- // The run never began, so let the instance be run again instead of leaving it wedged as `running`.
736
- this.running = false;
737
- throw error;
738
- }
739
- const periodicLogger = this.getPeriodicLogger();
740
- this.setStatusMessage('Starting the crawler.', { level: 'INFO' });
741
- const sigintHandler = async () => {
742
- this.log.warning('Pausing... Press CTRL+C again to force exit. To resume, do: CRAWLEE_PURGE_ON_START=0 npm start');
743
- await this.pauseOnMigration();
744
- await this.autoscaledPool.abort();
745
- };
746
- // Attach a listener to handle migration and aborting events gracefully.
747
- const boundPauseOnMigration = this.pauseOnMigration.bind(this);
748
- process.once('SIGINT', sigintHandler);
749
- const eventManager = serviceLocator.getEventManager();
750
- eventManager.on("migrating" /* EventType.MIGRATING */, boundPauseOnMigration);
751
- eventManager.on("aborting" /* EventType.ABORTING */, boundPauseOnMigration);
752
- let stats = {};
753
- try {
754
- await this.autoscaledPool.run();
755
- }
756
- finally {
757
- await this.teardown();
758
- await this.stats.stopCapturing();
759
- process.off('SIGINT', sigintHandler);
760
- eventManager.off("migrating" /* EventType.MIGRATING */, boundPauseOnMigration);
761
- eventManager.off("aborting" /* EventType.ABORTING */, boundPauseOnMigration);
762
- const finalStats = this.stats.calculate();
763
- stats = {
764
- requestsFinished: this.stats.state.requestsFinished,
765
- requestsFailed: this.stats.state.requestsFailed,
766
- retryHistogram: this.stats.requestRetryHistogram,
767
- ...finalStats,
768
- };
769
- this.log.info('Final request statistics:', stats);
770
- if (this.stats.errorTracker.total !== 0) {
771
- const prettify = ([count, info]) => `${count}x: ${info.at(-1).trim()} (${info[0]})`;
772
- 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),
820
+ this.#run = run;
821
+ try {
822
+ await purgeDefaultStorages({
823
+ onlyPurgeOnce: true,
824
+ storageBackend: serviceLocator.getStorageBackend(),
825
+ configuration: serviceLocator.getConfiguration(),
776
826
  });
827
+ if (requests) {
828
+ await this.addRequests(requests, options);
829
+ }
830
+ // An injected governor is the caller's to run; an owned one is started here and stopped again
831
+ // below if the startup fails. Either way the task loop rejects one that is not running.
832
+ await this.#concurrencySystem.ifOwned((system) => system.start());
833
+ await this.init();
834
+ await this.statistics.startCapturing();
835
+ }
836
+ catch (error) {
837
+ // Clean up here before propagating, otherwise a failed startup would leave the process hanging.
838
+ await this.teardown().catch((teardownError) => {
839
+ this.log.exception(teardownError, 'Cleaning up after a failed crawler startup failed.');
840
+ });
841
+ // The run never began, so let the instance be run again instead of leaving it wedged as `running`.
842
+ await run.finish();
843
+ await this.#concurrencySystem.ifOwned((system) => system.stop());
844
+ throw error;
845
+ }
846
+ const periodicLogger = this.#getPeriodicLogger();
847
+ this.setStatusMessage('Starting the crawler.', { level: 'INFO' });
848
+ const sigintHandler = async () => {
849
+ this.log.warning('Pausing... Press CTRL+C again to force exit. To resume, do: CRAWLEE_PURGE_ON_START=0 npm start');
850
+ await this.#pauseOnMigration();
851
+ await run.abort();
852
+ };
853
+ // Attach a listener to handle migration and aborting events gracefully.
854
+ const boundPauseOnMigration = this.#pauseOnMigration.bind(this);
855
+ process.once('SIGINT', sigintHandler);
856
+ const eventManager = serviceLocator.getEventManager();
857
+ eventManager.on(EventType.MIGRATING, boundPauseOnMigration);
858
+ eventManager.on(EventType.ABORTING, boundPauseOnMigration);
859
+ let stats = {};
860
+ try {
861
+ await run.dispatchRequests();
777
862
  }
778
- const client = serviceLocator.getStorageBackend();
779
- if (client.teardown) {
780
- let finished = false;
781
- setTimeout(() => {
782
- if (!finished) {
783
- this.log.info('Waiting for the storage to write its state to file system.');
863
+ finally {
864
+ await this.statistics.stopCapturing();
865
+ await this.teardown();
866
+ process.off('SIGINT', sigintHandler);
867
+ eventManager.off(EventType.MIGRATING, boundPauseOnMigration);
868
+ eventManager.off(EventType.ABORTING, boundPauseOnMigration);
869
+ const finalStats = this.statistics.calculate();
870
+ stats = {
871
+ requestsSucceeded: this.statistics.state.requestsSucceeded,
872
+ requestsFailed: this.statistics.state.requestsFailed,
873
+ retryHistogram: this.statistics.requestRetryHistogram,
874
+ ...finalStats,
875
+ };
876
+ this.log.info('Final request statistics:', stats);
877
+ // A crawl that did nothing while the manager holds only handled requests is a mistake whoever
878
+ // handled them - this run, another crawler on the same queue, or a previous process. Starting
879
+ // against handled requests is not: that is what resuming a crawl looks like.
880
+ if (stats.requestsSucceeded + stats.requestsFailed === 0) {
881
+ // Never let the diagnostic itself break the run.
882
+ const alreadyHandled = (await this.requestManager?.getHandledCount().catch(() => 0)) ?? 0;
883
+ if (alreadyHandled > 0) {
884
+ this.log.warningOnce('This crawl processed no requests - the request manager holds ' +
885
+ `${alreadyHandled} request${alreadyHandled === 1 ? '' : 's'}, all of them ` +
886
+ 'already handled, and a failed request counts as handled too. Nothing ' +
887
+ 'empties a queue between runs, so to crawl them again, purge it ' +
888
+ '(`await queue.purge()`) or use a fresh one (e.g. ' +
889
+ '`RequestQueue.open({ alias: "second-run" })`) with a freshly created ' +
890
+ 'crawler instance.');
784
891
  }
785
- }, 1000);
786
- await client.teardown();
787
- finished = true;
892
+ }
893
+ if (this.statistics.errorTracker.total !== 0) {
894
+ const prettify = ([count, info]) => `${count}x: ${info.at(-1).trim()} (${info[0]})`;
895
+ this.log.info(`Error analysis:`, {
896
+ totalErrors: this.statistics.errorTracker.total,
897
+ uniqueErrors: this.statistics.errorTracker.getUniqueErrorCount(),
898
+ mostCommonErrors: this.statistics.errorTracker.getMostPopularErrors(3).map(prettify),
899
+ });
900
+ }
901
+ const client = serviceLocator.getStorageBackend();
902
+ if (client.teardown) {
903
+ let finished = false;
904
+ setTimeout(() => {
905
+ if (!finished) {
906
+ this.log.info('Waiting for the storage to write its state to file system.');
907
+ }
908
+ }, 1000);
909
+ await client.teardown();
910
+ finished = true;
911
+ }
912
+ periodicLogger.stop();
913
+ this.setStatusMessage(`Finished! Total ${this.statistics.state.requestsSucceeded + this.statistics.state.requestsFailed} requests: ${this.statistics.state.requestsSucceeded} succeeded, ${this.statistics.state.requestsFailed} failed.`, { isStatusMessageTerminal: true, level: 'INFO' });
914
+ await run.finish();
915
+ await this.#concurrencySystem.ifOwned((system) => system.stop());
916
+ this.#hasFinishedBefore = true;
788
917
  }
789
- 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' });
791
- this.running = false;
792
- this.hasFinishedBefore = true;
793
- }
794
- return stats;
918
+ return stats;
919
+ }));
795
920
  }
796
921
  /**
797
922
  * Gracefully stops the current run of the crawler.
@@ -801,11 +926,12 @@ export class BasicCrawler {
801
926
  * To stop the crawler immediately, use {@link BasicCrawler.teardown|`crawler.teardown()`} instead.
802
927
  */
803
928
  stop(reason = 'The crawler has been gracefully stopped.') {
804
- if (this.unexpectedStop) {
929
+ const run = this.#liveRun;
930
+ if (run === undefined) {
931
+ this.log.warning('Cannot stop a crawler that is not running.');
805
932
  return;
806
933
  }
807
- this.log.info(reason);
808
- this.unexpectedStop = true;
934
+ run.stop(reason);
809
935
  }
810
936
  /**
811
937
  * Stops dispatching new requests, letting the in-progress ones finish. Resolves once they have settled, or rejects
@@ -816,39 +942,41 @@ export class BasicCrawler {
816
942
  * throughout, since a shared one may still be serving other crawlers.
817
943
  */
818
944
  async pause(timeoutSecs) {
819
- if (!this.autoscaledPool) {
945
+ const run = this.#dispatchingRun;
946
+ if (run === undefined) {
820
947
  this.log.warning('Cannot pause a crawler that is not running.');
821
948
  return;
822
949
  }
823
- await this.autoscaledPool.pause(timeoutSecs);
950
+ await run.pause(timeoutSecs);
824
951
  }
825
952
  /**
826
953
  * Resumes a run suspended with {@link BasicCrawler.pause|`pause()`}, letting the crawler dispatch requests
827
954
  * again. A no-op on a crawler that is not paused.
828
955
  */
829
956
  resume() {
830
- if (!this.autoscaledPool) {
957
+ const run = this.#dispatchingRun;
958
+ if (run === undefined) {
831
959
  this.log.warning('Cannot resume a crawler that is not running.');
832
960
  return;
833
961
  }
834
- this.autoscaledPool.resume();
962
+ run.resume();
835
963
  }
836
964
  /**
837
965
  * Returns the crawler's {@link IRequestManager|request manager}, opening the default {@link RequestQueue}
838
966
  * if none has been configured or opened yet.
839
967
  */
840
968
  async getRequestManager() {
841
- if (!this.requestManager) {
842
- this.requestManager = await this.openOwnedRequestQueue();
969
+ if (!this.#requestManager) {
970
+ this.#requestManager = await this.#openOwnedRequestQueue();
843
971
  }
844
972
  // Apply the processing-time hint here (an async lifecycle point) rather than in the constructor,
845
973
  // now that `setExpectedRequestProcessingTimeSecs` is async. The hint is raise-only and idempotent,
846
974
  // but guard so we do not re-issue it on every call.
847
- if (!this.requestManagerTimeoutsApplied) {
848
- this.requestManagerTimeoutsApplied = true;
849
- await this.applyRequestManagerTimeouts(this.requestManager);
975
+ if (!this.#requestManagerTimeoutsApplied) {
976
+ this.#requestManagerTimeoutsApplied = true;
977
+ await this.#applyRequestManagerTimeouts(this.#requestManager);
850
978
  }
851
- return this.requestManager;
979
+ return this.#requestManager;
852
980
  }
853
981
  /**
854
982
  * @deprecated Use {@link BasicCrawler.getRequestManager|`getRequestManager()`} instead. This returns the
@@ -858,16 +986,13 @@ export class BasicCrawler {
858
986
  return this.getRequestManager();
859
987
  }
860
988
  /**
861
- * Opens the default {@link RequestQueue}, applies the crawler's timeouts to it and records it as the
862
- * crawler-owned queue (so it gets purged between repeated `run()` calls).
863
- * @private
989
+ * Opens the default {@link RequestQueue} — the crawler's own, read from when the caller supplied nothing.
864
990
  */
865
- async openOwnedRequestQueue() {
991
+ async #openOwnedRequestQueue() {
866
992
  // The first crawler instance uses the default queue (null identifier);
867
993
  // subsequent instances get their own queue via a unique alias so they don't collide.
868
- const identifier = this.identity.instanceIndex === 0 ? null : { alias: `__default_${this.identity.id}__` };
869
- const requestQueue = await RequestQueue.open(identifier, { configuration: serviceLocator.getConfiguration() });
870
- return this.ownedRequestQueue.set(requestQueue);
994
+ const identifier = this.#identity.instanceIndex === 0 ? null : { alias: `__default_${this.#identity.id}__` };
995
+ return RequestQueue.open(identifier, { configuration: serviceLocator.getConfiguration() });
871
996
  }
872
997
  /**
873
998
  * Tells a request manager how long we expect to hold a fetched request, so that one backed by a
@@ -876,12 +1001,12 @@ export class BasicCrawler {
876
1001
  * request from being handed out a second time while it is still being processed — and it works
877
1002
  * regardless of whether the manager is a plain {@link RequestQueue} or a `RequestManagerTandem`.
878
1003
  */
879
- async applyRequestManagerTimeouts(requestManager) {
1004
+ async #applyRequestManagerTimeouts(requestManager) {
880
1005
  // A router route may hold a request for longer than the crawler's own timeout, and we cannot know
881
1006
  // which routes a run will hit, so reserve for the longest one any route asked for. The hint is
882
1007
  // raise-only, so erring high here is safe.
883
1008
  const maxRouteTimeoutSecs = this.requestHandler.getMaxTimeoutSecs?.() ?? 0;
884
- const handlerTimeoutSecs = Math.max(this.requestHandlerTimeoutMillis / 1000, maxRouteTimeoutSecs);
1009
+ const handlerTimeoutSecs = Math.max(this.#requestHandlerTimeoutMillis / 1000, maxRouteTimeoutSecs);
885
1010
  await requestManager.setExpectedRequestProcessingTimeSecs?.(Math.max(handlerTimeoutSecs + 5, 60));
886
1011
  }
887
1012
  /**
@@ -891,7 +1016,7 @@ export class BasicCrawler {
891
1016
  * the request's label. Applied by the crawler on the add paths it owns — `crawler.addRequests`,
892
1017
  * `crawler.run`, `context.addRequests` and `context.enqueueLinks`.
893
1018
  */
894
- async validateRequestUserData(source) {
1019
+ async #validateRequestUserData(source) {
895
1020
  if (typeof source === 'string') {
896
1021
  return;
897
1022
  }
@@ -912,12 +1037,12 @@ export class BasicCrawler {
912
1037
  }
913
1038
  async useState(defaultValue = {}) {
914
1039
  const kvs = await KeyValueStore.open(null, { configuration: serviceLocator.getConfiguration() });
915
- if (this.identity.hasExplicitId) {
916
- const stateKey = `${BasicCrawler.CRAWLEE_STATE_KEY}_${this.identity.id}`;
1040
+ if (this.#identity.hasExplicitId) {
1041
+ const stateKey = `${BasicCrawler.#CRAWLEE_STATE_KEY}_${this.#identity.id}`;
917
1042
  return kvs.getAutoSavedValue(stateKey, defaultValue);
918
1043
  }
919
- BasicCrawler.useStateAnonymousIndices.add(this.identity.instanceIndex);
920
- if (BasicCrawler.useStateAnonymousIndices.size > 1) {
1044
+ BasicCrawler.#useStateAnonymousIndices.add(this.#identity.instanceIndex);
1045
+ if (BasicCrawler.#useStateAnonymousIndices.size > 1) {
921
1046
  serviceLocator
922
1047
  .getLogger()
923
1048
  .warningOnce('Multiple crawler instances are calling useState() without an explicit `id` option. \n' +
@@ -925,33 +1050,35 @@ export class BasicCrawler {
925
1050
  'To fix this, provide a unique `id` option to each crawler instance. \n' +
926
1051
  'Example: new BasicCrawler({ id: "my-crawler-1", ... })');
927
1052
  }
928
- return kvs.getAutoSavedValue(BasicCrawler.CRAWLEE_STATE_KEY, defaultValue);
1053
+ return kvs.getAutoSavedValue(BasicCrawler.#CRAWLEE_STATE_KEY, defaultValue);
929
1054
  }
930
- async getPendingRequestCountApproximation() {
1055
+ async #getPendingRequestCountApproximation() {
931
1056
  return (await this.requestManager?.getPendingCount()) ?? 0;
932
1057
  }
933
- async calculateEnqueuedRequestLimit(explicitLimit) {
934
- if (this.maxRequestsPerCrawl === undefined) {
1058
+ async #calculateEnqueuedRequestLimit(explicitLimit) {
1059
+ if (this.#maxRequestsPerCrawl === undefined) {
935
1060
  return explicitLimit;
936
1061
  }
937
- const limit = Math.max(0, this.maxRequestsPerCrawl - this.handledRequestsCount - (await this.getPendingRequestCountApproximation()));
1062
+ const limit = Math.max(0, this.#maxRequestsPerCrawl -
1063
+ this.#handledRequestsCount -
1064
+ (await this.#getPendingRequestCountApproximation()));
938
1065
  return Math.min(limit, explicitLimit ?? Infinity);
939
1066
  }
940
- 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);
949
- }
950
- logOncePerRun(key, message) {
951
- if (!this.loggedPerRun.has(key)) {
952
- this.log.info(message);
953
- this.loggedPerRun.add(key);
954
- }
1067
+ async #handleSkippedRequest(options) {
1068
+ // A skipped request is a *successful* outcome, but the interrupt still unwinds through the
1069
+ // transaction scope, which rolls back - so the skip bookkeeping must write directly.
1070
+ await withDirectStorageAccess(async () => {
1071
+ // Enqueueing happens outside a run too - `addRequests()` before the first `run()` - so these dedupe
1072
+ // against the logger for the crawler's lifetime rather than per run.
1073
+ if (options.reason === 'limit') {
1074
+ this.log.info('The number of requests enqueued by the crawler reached the maxRequestsPerCrawl limit of ' +
1075
+ `${this.#maxRequestsPerCrawl} requests and no further requests will be added.`, undefined, { once: true });
1076
+ }
1077
+ if (options.reason === 'depth') {
1078
+ this.log.info(`The crawler reached the maxCrawlDepth limit of ${this.#maxCrawlDepth} and no further requests will be enqueued.`, undefined, { once: true });
1079
+ }
1080
+ await this.#onSkippedRequest?.(options);
1081
+ });
955
1082
  }
956
1083
  /**
957
1084
  * Adds requests to the queue in batches. By default, it will resolve after the initial batch is added, and continue
@@ -959,6 +1086,11 @@ export class BasicCrawler {
959
1086
  * the batches via `waitBetweenBatchesMillis`. If you want to wait for all batches to be added to the queue, you can use
960
1087
  * the `waitForAllRequestsToBeAdded` promise you get in the response object.
961
1088
  *
1089
+ * Optionally, the requests can be filtered using `include`/`exclude` glob or regexp patterns and an
1090
+ * enqueue `strategy` (both AND-ed together, same as {@link CrawlingContext.enqueueLinks|`enqueueLinks`}),
1091
+ * relative to `baseUrl`. Unlike `enqueueLinks`, there is no implicit "current page" to anchor the strategy
1092
+ * to, so `strategy` defaults to {@link EnqueueStrategy.All|`all`} here.
1093
+ *
962
1094
  * This is an alias for calling `addRequestsBatched()` on the implicit `RequestQueue` for this crawler instance.
963
1095
  *
964
1096
  * @param requests The requests to add
@@ -966,62 +1098,118 @@ export class BasicCrawler {
966
1098
  */
967
1099
  async addRequests(requests, options = {}) {
968
1100
  await this.getRequestManager();
969
- const requestLimit = await this.calculateEnqueuedRequestLimit();
970
- const skippedBecauseOfRobots = new Set();
971
- const skippedBecauseOfMaxCrawlDepth = new Set();
972
- const isAllowedBasedOnRobotsTxtFile = this.isAllowedBasedOnRobotsTxtFile.bind(this);
973
- const maxCrawlDepth = this.maxCrawlDepth;
974
- 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)}`));
1101
+ if (!isIterable(requests) && !isAsyncIterable(requests)) {
1102
+ throw new Error(`Expected an iterable or async iterable, got ${getObjectType(requests)}`);
1103
+ }
1104
+ parseArgument(options, addRequestsOptionsSchema, 'EnqueueUrlsOptions');
1105
+ // `label`/`userData` apply to every request this call produces, so a single upfront validation
1106
+ // against the label's schema covers them all and fails the whole call fast, rather than failing
1107
+ // lazily once the generator below is drained. Skipped when neither is set - each item still gets
1108
+ // its own per-item validation below, and validating an absent label/userData here would spuriously
1109
+ // check them against a registered default-route schema.
1110
+ if (options.label !== undefined || options.userData !== undefined) {
1111
+ await this.#validateRequestUserData({ label: options.label, userData: options.userData });
1112
+ }
1113
+ const requestLimit = await this.#calculateEnqueuedRequestLimit(options.limit);
1114
+ const strategy = options.strategy ?? EnqueueStrategy.All;
1115
+ const urlExcludePatternObjects = options.exclude?.length ? constructUrlPatternObjects(options.exclude) : [];
1116
+ const urlPatternObjects = options.include?.length ? constructUrlPatternObjects(options.include) : [];
1117
+ // The strategy always applies, even when `include` patterns are provided - the two are AND-ed together
1118
+ // (a URL must match an `include` pattern *and* satisfy the strategy). This mirrors crawlee-python.
1119
+ const enqueueStrategyPatterns = options.baseUrl ? buildEnqueueStrategyPatterns(options.baseUrl, strategy) : [];
1120
+ const isAllowedBasedOnRobotsTxtFile = this.#isAllowedBasedOnRobotsTxtFile.bind(this);
1121
+ const maxCrawlDepth = this.#maxCrawlDepth;
1122
+ const validateRequestUserData = this.#validateRequestUserData.bind(this);
1123
+ const allSkipped = [];
1124
+ // A skipped source (which can carry arbitrary userData) is only retained if something reads it -
1125
+ // otherwise the URL alone is enough to build the callback argument and to log with.
1126
+ const hasSkippedRequestCallback = this.#onSkippedRequest !== undefined || options.onSkippedRequest !== undefined;
1127
+ const keepSkippedSource = (source) => (hasSkippedRequestCallback ? source : source.url);
1128
+ const reportSkippedRequests = async () => {
1129
+ const skippedRequests = allSkipped.splice(0);
1130
+ if (skippedRequests.length === 0) {
1131
+ return;
1132
+ }
1133
+ const skippedRobotsUrls = skippedRequests
1134
+ .filter((s) => s.reason === 'robotsTxt')
1135
+ .map(({ source }) => (typeof source === 'string' ? source : source.url));
1136
+ if (skippedRobotsUrls.length > 0) {
1137
+ this.log.warning(`Some requests were skipped because they were disallowed based on the robots.txt file`, { skipped: skippedRobotsUrls });
1138
+ }
1139
+ // Only log the limit message when an explicit `limit` was passed (not the internal
1140
+ // `maxRequestsPerCrawl`-derived one), and only once per call.
1141
+ if (options.limit !== undefined && skippedRequests.some((s) => s.reason === 'limit')) {
1142
+ this.log.info(requestLimit === options.limit
1143
+ ? `Skipping requests in this call due to the enqueueLinks limit of ${options.limit}.`
1144
+ : `Skipping requests in this call due to the remaining maxRequestsPerCrawl budget of ${requestLimit}, which is lower than the enqueueLinks limit of ${options.limit}.`);
1145
+ }
1146
+ await Promise.all(skippedRequests.map(async ({ source, reason }) => {
1147
+ const args = createSkippedRequestArgs(source, reason);
1148
+ await this.#handleSkippedRequest(args);
1149
+ await options.onSkippedRequest?.(args);
1150
+ }));
1151
+ };
978
1152
  async function* filteredRequests() {
979
1153
  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);
1154
+ const [requestOptions] = createRequestOptions([typeof request === 'string' ? request : request], { ...options, strategy });
1155
+ if (!requestOptions) {
1156
+ continue; // invalid URL, silently dropped (matches `createRequestOptions`'s own filtering)
1157
+ }
1158
+ if (maxCrawlDepth !== undefined && requestOptions.crawlDepth > maxCrawlDepth) {
1159
+ allSkipped.push({ source: keepSkippedSource(requestOptions), reason: 'depth' });
983
1160
  continue;
984
1161
  }
985
- if (await isAllowedBasedOnRobotsTxtFile(url)) {
986
- await validateRequestUserData(request);
987
- yield request;
1162
+ if (!(await isAllowedBasedOnRobotsTxtFile(requestOptions.url))) {
1163
+ allSkipped.push({ source: keepSkippedSource(requestOptions), reason: 'robotsTxt' });
1164
+ continue;
988
1165
  }
989
- else {
990
- skippedBecauseOfRobots.add(url);
1166
+ const onSkippedByFilter = (opts) => allSkipped.push({ source: keepSkippedSource(opts), reason: 'filters' });
1167
+ // Filter by user patterns first (with exclude)...
1168
+ let filtered = filterRequestOptionsByPatterns([requestOptions], urlPatternObjects.length > 0 ? urlPatternObjects : undefined, urlExcludePatternObjects, strategy, onSkippedByFilter);
1169
+ // ...then filter by the enqueue strategy (making this an AND check)
1170
+ filtered = filterRequestOptionsByPatterns(filtered, enqueueStrategyPatterns.length > 0 ? enqueueStrategyPatterns : undefined, [], strategy, onSkippedByFilter);
1171
+ if (filtered.length === 0) {
1172
+ continue;
1173
+ }
1174
+ let [finalOptions] = filtered;
1175
+ if (options.transformRequestFunction) {
1176
+ const transformed = applyRequestTransform([finalOptions], options.transformRequestFunction, (r) => allSkipped.push({ source: keepSkippedSource(r), reason: 'transform' }));
1177
+ if (transformed.length === 0) {
1178
+ continue;
1179
+ }
1180
+ [finalOptions] = transformed;
991
1181
  }
1182
+ await validateRequestUserData(finalOptions);
1183
+ yield new Request(finalOptions);
992
1184
  }
993
1185
  }
994
1186
  const result = await this.requestManager.addRequestsBatched(filteredRequests(), {
995
- ...options,
1187
+ forefront: options.forefront,
1188
+ waitForAllRequestsToBeAdded: options.waitForAllRequestsToBeAdded,
1189
+ batchSize: options.batchSize,
1190
+ waitBetweenBatchesMillis: options.waitBetweenBatchesMillis,
996
1191
  maxNewRequests: requestLimit,
997
1192
  });
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
- })));
1018
- }
1019
- return result;
1193
+ // Report requests skipped due to the maxNewRequests budget (i.e. maxRequestsPerCrawl limit, or an
1194
+ // explicit `limit` option)
1195
+ for (const request of result.requestsOverLimit ?? []) {
1196
+ allSkipped.push({ source: request, reason: 'limit' });
1197
+ }
1198
+ await reportSkippedRequests();
1199
+ const waitForAllRequestsToBeAdded = result.waitForAllRequestsToBeAdded.then(async (addedRequests) => {
1200
+ await reportSkippedRequests();
1201
+ return addedRequests;
1202
+ });
1203
+ // Keep callback failures observable to callers that await this promise without emitting an unhandled rejection
1204
+ // when callers intentionally leave background additions running, matching `drainRequestBatches` behavior.
1205
+ void waitForAllRequestsToBeAdded.catch(() => { });
1206
+ return { ...result, waitForAllRequestsToBeAdded };
1020
1207
  }
1021
1208
  /**
1022
1209
  * Pushes data to the specified {@link Dataset}, or the default crawler {@link Dataset} by calling {@link Dataset.pushData}.
1023
1210
  */
1024
1211
  async pushData(data, datasetIdentifier) {
1212
+ tryCancel();
1025
1213
  const dataset = await this.getDataset(datasetIdentifier);
1026
1214
  return dataset.pushData(data);
1027
1215
  }
@@ -1067,6 +1255,7 @@ export class BasicCrawler {
1067
1255
  const keys = options?.collectAllKeys
1068
1256
  ? Array.from(new Set(items.flatMap(Object.keys)))
1069
1257
  : Object.keys(items[0]);
1258
+ const { stringify } = await import('csv-stringify/sync');
1070
1259
  value = stringify([
1071
1260
  keys,
1072
1261
  ...items.map((item) => {
@@ -1074,13 +1263,13 @@ export class BasicCrawler {
1074
1263
  }),
1075
1264
  ]);
1076
1265
  }
1077
- await ensureDir(dirname(path));
1266
+ await mkdir(dirname(path), { recursive: true });
1078
1267
  await writeFile(path, value);
1079
1268
  this.log.info(`Export to ${path} finished!`);
1080
1269
  }
1081
1270
  if (format === 'json') {
1082
- await ensureDir(dirname(path));
1083
- await writeJSON(path, items, { spaces: 4 });
1271
+ await mkdir(dirname(path), { recursive: true });
1272
+ await writeFile(path, `${JSON.stringify(items, null, 4)}\n`);
1084
1273
  this.log.info(`Export to ${path} finished!`);
1085
1274
  }
1086
1275
  return items;
@@ -1088,32 +1277,26 @@ export class BasicCrawler {
1088
1277
  /**
1089
1278
  * Initializes the crawler.
1090
1279
  */
1091
- async _init() {
1280
+ async init() {
1281
+ const run = this.#liveRun;
1282
+ if (run === undefined) {
1283
+ throw new Error('There is no crawl in progress - `run()` is the only thing that starts one.');
1284
+ }
1092
1285
  const eventManager = serviceLocator.getEventManager();
1093
1286
  if (!eventManager.isInitialized()) {
1094
1287
  await eventManager.init();
1095
- this._closeEvents = true;
1288
+ run.ownsEventManager = true;
1096
1289
  }
1097
1290
  // Warn once at startup if the internal timeout is shorter than the phases it is meant to outlast. It is
1098
1291
  // floored per request so it will not actually cut them short, but the configured value is then effectively
1099
1292
  // ignored, which is worth flagging. Checked here (not in the constructor) because a subclass sets its
1100
1293
  // navigation timeout only after `super()`.
1101
- const phasesMillis = this.getNavigationTimeoutMillis() + this.resolveRequestHandlerTimeoutMillis(undefined);
1294
+ const phasesMillis = this.getNavigationTimeoutMillis() + this.#resolveRequestHandlerTimeoutMillis(undefined);
1102
1295
  if (this.internalTimeoutMillis < phasesMillis) {
1103
1296
  this.log.warning(`CRAWLEE_INTERNAL_TIMEOUT (${this.internalTimeoutMillis / 1000}s) is shorter than the navigation ` +
1104
1297
  `and request handler timeouts combined (${phasesMillis / 1000}s); it will be raised per request ` +
1105
1298
  `so it does not cut them short.`);
1106
1299
  }
1107
- // An owned governor is rebuilt (and started) for every run, so it always starts from a clean slate — stale
1108
- // resource snapshots or a previous run's scaled desired concurrency would otherwise distort this run's
1109
- // 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({
1113
- ...this.taskLoopOptions,
1114
- concurrencySystem: this.concurrencySystemDep.value,
1115
- consumer: this.identity,
1116
- });
1117
1300
  await this.getRequestManager();
1118
1301
  }
1119
1302
  /**
@@ -1130,9 +1313,9 @@ export class BasicCrawler {
1130
1313
  * request, a per-route override, or a low `CRAWLEE_INTERNAL_TIMEOUT` is not cut short mid-phase. It takes
1131
1314
  * whichever is larger: the configured internal timeout, or this request's combined phase budget.
1132
1315
  */
1133
- async withRequestTimeout(crawlingContext, work) {
1316
+ async #withRequestTimeout(crawlingContext, work) {
1134
1317
  const { request } = crawlingContext;
1135
- const phasesMillis = this.getNavigationTimeoutMillis() + this.resolveRequestHandlerTimeoutMillis(request.label);
1318
+ const phasesMillis = this.getNavigationTimeoutMillis() + this.#resolveRequestHandlerTimeoutMillis(request.label);
1136
1319
  const timeoutMillis = Math.max(this.internalTimeoutMillis, phasesMillis);
1137
1320
  await raceWithTimeout(crawlingContext, work, { timeoutMillis, requestId: request.id });
1138
1321
  }
@@ -1143,14 +1326,14 @@ export class BasicCrawler {
1143
1326
  * @param label The request's route label, or `undefined` for the default route / no specific request.
1144
1327
  * @param fallbackMillis Timeout to use when no route overrides it.
1145
1328
  */
1146
- resolveRequestHandlerTimeoutMillis(label, fallbackMillis = this.requestHandlerTimeoutMillis) {
1147
- return this.getRouteTimeoutMillis(label) ?? fallbackMillis;
1329
+ #resolveRequestHandlerTimeoutMillis(label, fallbackMillis = this.#requestHandlerTimeoutMillis) {
1330
+ return this.#getRouteTimeoutMillis(label) ?? fallbackMillis;
1148
1331
  }
1149
1332
  /**
1150
1333
  * The timeout the router route with the given label asked for, or `undefined` when it did not override one
1151
1334
  * (or the request handler is not a router at all).
1152
1335
  */
1153
- getRouteTimeoutMillis(label) {
1336
+ #getRouteTimeoutMillis(label) {
1154
1337
  const getTimeoutSecs = this.requestHandler.getTimeoutSecs;
1155
1338
  if (typeof getTimeoutSecs !== 'function') {
1156
1339
  return undefined;
@@ -1159,39 +1342,121 @@ export class BasicCrawler {
1159
1342
  return timeoutSecs === undefined ? undefined : timeoutSecs * 1000;
1160
1343
  }
1161
1344
  async runRequestHandler(crawlingContext) {
1162
- const timeoutMillis = this.resolveRequestHandlerTimeoutMillis(crawlingContext.request.label);
1345
+ const timeoutMillis = this.#resolveRequestHandlerTimeoutMillis(crawlingContext.request.label);
1163
1346
  await addTimeoutToPromise(async () => this.requestHandler(crawlingContext), timeoutMillis, `requestHandler timed out after ${timeoutMillis / 1000} seconds (${crawlingContext.request.id}).`);
1164
1347
  }
1348
+ /**
1349
+ * Runs `callback` inside a {@link StorageTransaction}, unless transactional storage is disabled.
1350
+ * Deliberately does **not** commit on return - the pipeline hands failures to `handleRequestFailure`, so a
1351
+ * normal return says nothing about success. `handleRequest` commits on success.
1352
+ */
1353
+ // oxlint-disable-next-line crawlee/prefer-private-fields -- accessed by tests
1354
+ async runInStorageTransaction(callback) {
1355
+ if (!this.#transactionalStorageEnabled) {
1356
+ return callback();
1357
+ }
1358
+ const transaction = createStorageTransaction({
1359
+ policy: this.#storageWritePolicy,
1360
+ commitTimeoutMillis: this.internalTimeoutMillis,
1361
+ });
1362
+ let threw = true;
1363
+ try {
1364
+ const result = await transaction.run(callback);
1365
+ threw = false;
1366
+ return result;
1367
+ }
1368
+ finally {
1369
+ if (transaction.state === 'open') {
1370
+ // `handleRequest` commits or `handleRequestFailure` rolls back on every normal path, so an open transaction on
1371
+ // a normal return is a wiring bug; on a propagating throw (a pipeline-level failure) it is
1372
+ // expected. Either way, discard the unvalidated writes; only the former is worth flagging.
1373
+ if (!threw) {
1374
+ this.log.error('Internal error: a storage transaction was still open after the request pipeline ' +
1375
+ 'returned normally. Its writes are being discarded. Please report this.');
1376
+ }
1377
+ transaction.rollback();
1378
+ }
1379
+ // Unconditional: `failed` is a terminal state that the branch above never reaches.
1380
+ transaction.dispose();
1381
+ }
1382
+ }
1165
1383
  /**
1166
1384
  * Handles blocked request
1167
1385
  */
1168
- _throwOnBlockedRequest(statusCode) {
1386
+ throwOnBlockedRequest(statusCode) {
1169
1387
  if (this.retryOnBlocked)
1170
1388
  return;
1171
1389
  if (this.blockedStatusCodes.has(statusCode)) {
1172
1390
  throw new SessionError(`Request blocked - received ${statusCode} status code.`);
1173
1391
  }
1174
1392
  }
1175
- async isAllowedBasedOnRobotsTxtFile(url) {
1176
- if (!this.respectRobotsTxtFile) {
1393
+ async #isAllowedBasedOnRobotsTxtFile(url) {
1394
+ if (!this.#respectRobotsTxtFile) {
1177
1395
  return true;
1178
1396
  }
1179
1397
  const robotsTxtFile = await this.getRobotsTxtFileForUrl(url);
1180
- const userAgent = typeof this.respectRobotsTxtFile === 'object' ? this.respectRobotsTxtFile?.userAgent : '*';
1398
+ const userAgent = typeof this.#respectRobotsTxtFile === 'object' ? this.#respectRobotsTxtFile?.userAgent : '*';
1399
+ if (robotsTxtFile) {
1400
+ const crawlDelay = robotsTxtFile.getCrawlDelay(userAgent);
1401
+ if (crawlDelay !== undefined) {
1402
+ this.#applyCrawlDelay(url, crawlDelay);
1403
+ }
1404
+ }
1181
1405
  return !robotsTxtFile || robotsTxtFile.isAllowed(url, userAgent);
1182
1406
  }
1407
+ /**
1408
+ * Records an HTTP 429 against the URL's domain so the request manager can hold the retry back.
1409
+ *
1410
+ * @param retryAfterHeader The raw `Retry-After` response header, if the server sent one.
1411
+ * @returns `true` if the manager took responsibility for the delay, in which case the caller should throw
1412
+ * {@link RequestThrottledError} rather than treating the response as a blocked session.
1413
+ */
1414
+ recordDomainRateLimit(url, retryAfterHeader) {
1415
+ if (this.requestManager?.recordPacingSignal({
1416
+ reason: 'rateLimited',
1417
+ url,
1418
+ waitMs: parseRetryAfterHeader(retryAfterHeader) ?? undefined,
1419
+ })) {
1420
+ return true;
1421
+ }
1422
+ const domain = hostnameOrUrl(url);
1423
+ this.log.warningOnce(`"${domain}" responded with HTTP 429 (Too Many Requests), but the crawler's request manager does not ` +
1424
+ 'pace that domain, so the response is handled like any other, with no per-domain delay. Set ' +
1425
+ `\`sameDomainDelaySecs\`, or pass a \`ThrottlingRequestManager\` covering "${domain}" as ` +
1426
+ '`requestManager`, to honour `Retry-After` and apply exponential backoff instead.');
1427
+ return false;
1428
+ }
1429
+ /** Hands a robots.txt `Crawl-delay` to the request manager, warning if it will not be honoured. */
1430
+ #applyCrawlDelay(url, delaySeconds) {
1431
+ // robots.txt is per-origin; `hostname` is the closest pacing scope and errs wide (http and https to one
1432
+ // host share a clock).
1433
+ if (this.requestManager?.recordPacingSignal({
1434
+ reason: 'minInterval',
1435
+ url,
1436
+ intervalMs: delaySeconds * 1000,
1437
+ scope: 'hostname',
1438
+ })) {
1439
+ return;
1440
+ }
1441
+ const domain = hostnameOrUrl(url);
1442
+ this.log.warningOnce(`robots.txt for "${domain}" defines a crawl-delay of ${delaySeconds}s, but the crawler's request ` +
1443
+ 'manager does not pace that domain, so its requests will not be paced. Set ' +
1444
+ `\`sameDomainDelaySecs\`, or pass a \`ThrottlingRequestManager\` covering "${domain}" as ` +
1445
+ '`requestManager`.');
1446
+ }
1447
+ /** @internal */
1183
1448
  async getRobotsTxtFileForUrl(url) {
1184
- if (!this.respectRobotsTxtFile) {
1449
+ if (!this.#respectRobotsTxtFile) {
1185
1450
  return undefined;
1186
1451
  }
1187
1452
  try {
1188
1453
  const origin = new URL(url).origin;
1189
- const cachedRobotsTxtFile = this.robotsTxtFileCache.get(origin);
1454
+ const cachedRobotsTxtFile = this.#robotsTxtFileCache.get(origin);
1190
1455
  if (cachedRobotsTxtFile) {
1191
1456
  return cachedRobotsTxtFile;
1192
1457
  }
1193
1458
  const robotsTxtFile = await RobotsTxtFile.find(url, { logger: this.log });
1194
- this.robotsTxtFileCache.add(origin, robotsTxtFile);
1459
+ this.#robotsTxtFileCache.add(origin, robotsTxtFile);
1195
1460
  return robotsTxtFile;
1196
1461
  }
1197
1462
  catch (e) {
@@ -1199,10 +1464,10 @@ export class BasicCrawler {
1199
1464
  return undefined;
1200
1465
  }
1201
1466
  }
1202
- async pauseOnMigration() {
1203
- if (this.autoscaledPool) {
1204
- // if run wasn't called, this is going to crash
1205
- await this.autoscaledPool.pause(SAFE_MIGRATION_WAIT_MILLIS).catch((err) => {
1467
+ async #pauseOnMigration() {
1468
+ const run = this.#dispatchingRun;
1469
+ if (run) {
1470
+ await run.pause(SAFE_MIGRATION_WAIT_MILLIS).catch((err) => {
1206
1471
  if (err.message.includes('running tasks did not finish')) {
1207
1472
  this.log.error('The crawler was paused due to migration to another host, ' +
1208
1473
  "but some requests did not finish in time. Those requests' results may be duplicated.");
@@ -1212,61 +1477,21 @@ export class BasicCrawler {
1212
1477
  }
1213
1478
  });
1214
1479
  }
1215
- const requestManagerPersistPromise = (async () => {
1216
- // The request manager persists its read-only loader's state, if it has one that supports persistence
1217
- // (e.g. a tandem wrapping a `RequestList`). For a plain `RequestQueue`, this is a no-op.
1218
- if (this.requestManager?.persistState) {
1219
- if (await this.requestManager.isFinished())
1220
- return;
1221
- await this.requestManager.persistState().catch((err) => {
1222
- if (err.message.includes('Cannot persist state.')) {
1223
- this.log.error("The crawler attempted to persist its request list's state and failed due to missing or " +
1224
- 'invalid configuration. Make sure to use either RequestList.open() or the "stateKeyPrefix" option of RequestList ' +
1225
- 'constructor to ensure your crawling state is persisted through host migrations and restarts.');
1226
- }
1227
- else {
1228
- this.log.exception(err, 'An unexpected error occurred when the crawler ' +
1229
- "attempted to persist its request list's state.");
1230
- }
1231
- });
1232
- }
1233
- })();
1234
- await Promise.all([requestManagerPersistPromise, this.stats.persistState()]);
1480
+ // Captures the statistics changed while draining above. No PERSIST_STATE event is tied to the drain:
1481
+ // the periodic one is unrelated, and the one the platform emits on migration arrives before any of this.
1482
+ await this.statistics.persistState?.();
1235
1483
  }
1236
1484
  /**
1237
1485
  * Fetches the next request to process from the underlying request provider.
1238
1486
  */
1239
- async fetchNextRequest() {
1487
+ async #fetchNextRequest() {
1240
1488
  if (this.requestManager === undefined) {
1241
1489
  throw new Error(`fetchNextRequest called on an uninitialized crawler`);
1242
1490
  }
1243
1491
  return this.requestManager.fetchNextRequest();
1244
1492
  }
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
- /** Handles a single request - runs the request handler with retries, error handling, and lifecycle management. */
1493
+ /** Runs the request handler and marks the request as handled. Failures are left to `handleRequestFailure`. */
1494
+ // oxlint-disable-next-line crawlee/prefer-private-fields -- patched by @crawlee/otel
1270
1495
  async handleRequest(crawlingContext, requestSource, request) {
1271
1496
  // An earlier phase we cannot cancel (e.g. a slow `extendContext`) may have run past the internal timeout,
1272
1497
  // which already failed the request in `runTaskFunction`. Bail before running the handler so it does not
@@ -1274,107 +1499,89 @@ export class BasicCrawler {
1274
1499
  if (crawlingContext[timeoutExpiredKey]?.()) {
1275
1500
  return;
1276
1501
  }
1277
- const statisticsId = request.id || request.uniqueKey;
1278
- let isRequestLocked = true;
1502
+ request.state = RequestState.REQUEST_HANDLER;
1503
+ await this.runRequestHandler(crawlingContext);
1504
+ // Commit *before* marking the request as handled, so a commit failure fails the request and
1505
+ // it is retried. This also closes the transaction, so everything below passes through.
1506
+ // Opened by `runInStorageTransaction`; absent when disabled or when the subclass opens its own.
1507
+ await currentStorageTransaction()?.commit();
1508
+ await this.#timeoutAndRetry(async () => requestSource.markRequestAsHandled(request), this.internalTimeoutMillis, `Marking request ${request.url} (${request.id}) as handled timed out after ${this.internalTimeoutMillis / 1e3} seconds.`);
1509
+ this.statistics.recordRequestSuccess(request.id || request.uniqueKey, request.retryCount);
1510
+ // reclaim session if request finishes successfully
1511
+ request.state = RequestState.DONE;
1512
+ crawlingContext.session.markGood();
1513
+ }
1514
+ /**
1515
+ * Handles a request that failed anywhere in the context pipeline - a middleware (e.g. navigation or its hooks)
1516
+ * or `handleRequest`. Runs inside the pipeline call, so the cleanups (e.g. closing the page) only fire after the
1517
+ * error handler has seen the context.
1518
+ */
1519
+ async #handleRequestFailure(rawError, crawlingContext, requestSource, request) {
1520
+ // Roll back *before* any error handler runs - error handlers write to real storage precisely
1521
+ // because the transaction is already closed. A no-op when the commit in `handleRequest` succeeded.
1522
+ currentStorageTransaction()?.rollback();
1523
+ const resumeRequestTimeout = crawlingContext[suspendTimeoutKey]?.();
1279
1524
  try {
1280
- request.state = RequestState.REQUEST_HANDLER;
1281
- await this.runRequestHandler(crawlingContext);
1282
- 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
- isRequestLocked = false; // markRequestAsHandled succeeded and unlocked the request
1284
- this.stats.finishJob(statisticsId, request.retryCount);
1285
- // reclaim session if request finishes successfully
1286
- request.state = RequestState.DONE;
1287
- crawlingContext.session.markGood();
1288
- }
1289
- catch (rawError) {
1290
- const err = this.unwrapError(rawError);
1291
- try {
1292
- request.state = RequestState.ERROR_HANDLER;
1293
- 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.`);
1294
- if (!(err instanceof CriticalError)) {
1295
- isRequestLocked = false; // requestFunctionErrorHandler calls either markRequestAsHandled or reclaimRequest
1296
- }
1297
- request.state = RequestState.DONE;
1298
- }
1299
- catch (secondaryError) {
1300
- const unwrappedSecondaryError = this.unwrapError(secondaryError);
1301
- if (!unwrappedSecondaryError.triggeredFromUserHandler &&
1302
- // avoid reprinting the same critical error multiple times, as it will be printed by Nodejs at the end anyway
1303
- !(unwrappedSecondaryError instanceof CriticalError)) {
1304
- const apifySpecific = process.env.APIFY_IS_AT_HOME
1305
- ? `This may have happened due to an internal error of Apify's API or due to a misconfigured crawler.`
1306
- : '';
1307
- this.log.exception(unwrappedSecondaryError, 'An exception occurred during handling of failed request. ' +
1308
- `This places the crawler and its underlying storages into an unknown state and crawling will be terminated. ${apifySpecific}`);
1309
- }
1310
- request.state = RequestState.ERROR;
1311
- throw unwrappedSecondaryError;
1312
- }
1313
- // decrease the session score if the request fails (but the error handler did not throw);
1314
- // skip when the error is a SessionError, which already retired the session
1315
- if (!(err instanceof SessionError)) {
1316
- crawlingContext.session.markBad();
1525
+ const unwrappedError = this.#unwrapError(rawError);
1526
+ // A failing or timed out `onSkippedRequest` makes this an ordinary failure, so the request gets retried.
1527
+ const err = unwrappedError instanceof ContextPipelineInterruptedError
1528
+ ? await this.#trySkipRequest(unwrappedError, request, requestSource)
1529
+ : unwrappedError;
1530
+ if (err) {
1531
+ await this.#runErrorHandler(err, crawlingContext, requestSource, request);
1317
1532
  }
1318
1533
  }
1319
1534
  finally {
1320
- // Safety net - return the request to the queue if nobody managed to mark it as handled
1321
- // or reclaim it before (e.g. after a CriticalError). Reclaiming a request that is no longer
1322
- // in progress is a harmless no-op on the storage backend.
1323
- if (isRequestLocked && requestSource instanceof RequestQueue) {
1324
- try {
1325
- await requestSource.reclaimRequest(request);
1326
- }
1327
- catch {
1328
- // The request was never in progress, or could not be reclaimed. Either way it's fine.
1329
- }
1330
- }
1535
+ resumeRequestTimeout?.();
1331
1536
  }
1332
1537
  }
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;
1538
+ async #runErrorHandler(err, context, requestSource, request) {
1539
+ const retryCountBefore = request.retryCount;
1540
+ try {
1541
+ request.state = RequestState.ERROR_HANDLER;
1542
+ await addTimeoutToPromise(async () => this.requestFunctionErrorHandler(err, context, request, requestSource), this.internalTimeoutMillis, `Handling request failure of ${request.url} (${request.id}) timed out after ${this.internalTimeoutMillis / 1e3} seconds.`);
1543
+ // The error handler may have set `SKIPPED`; TS keeps the narrowing from the assignment above.
1544
+ if (request.state !== RequestState.SKIPPED) {
1545
+ request.state = RequestState.DONE;
1349
1546
  }
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;
1547
+ }
1548
+ catch (secondaryError) {
1549
+ const unwrappedSecondaryError = this.#unwrapError(secondaryError);
1550
+ const timedOut = unwrappedSecondaryError instanceof TimeoutError;
1551
+ request.state = RequestState.ERROR;
1552
+ if (timedOut) {
1553
+ // Not worth crashing the crawler over. The request's state is unknown, so it gets retried - and the
1554
+ // retry is counted, so that an error handler that always times out does not retry it forever.
1555
+ this.log.warning(unwrappedSecondaryError.message);
1556
+ if (request.retryCount === retryCountBefore) {
1557
+ request.retryCount++;
1361
1558
  }
1362
1559
  }
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
- });
1560
+ else if (!(unwrappedSecondaryError instanceof CriticalError)) {
1561
+ // avoid reprinting the same critical error multiple times, as it will be printed by Nodejs at the end anyway
1562
+ const apifySpecific = process.env.APIFY_IS_AT_HOME
1563
+ ? `This may have happened due to an internal error of Apify's API or due to a misconfigured crawler.`
1564
+ : '';
1565
+ this.log.exception(unwrappedSecondaryError, 'An exception occurred during handling of failed request. ' +
1566
+ `This places the crawler and its underlying storages into an unknown state and crawling will be terminated. ${apifySpecific}`);
1567
+ }
1568
+ // Safety net - the error handler neither marked the request as handled nor reclaimed it (e.g. after a
1569
+ // CriticalError). Reclaiming a request that is no longer in progress is a harmless no-op on the storage backend.
1570
+ try {
1571
+ await addTimeoutToPromise(async () => requestSource.reclaimRequest(request), this.internalTimeoutMillis, `Reclaiming request ${request.url} (${request.id}) timed out.`);
1572
+ }
1573
+ catch {
1574
+ // The request was never in progress, or could not be reclaimed. Either way it's fine.
1575
+ }
1576
+ if (!timedOut) {
1577
+ throw unwrappedSecondaryError;
1578
+ }
1579
+ }
1580
+ // decrease the session score if the request fails (but the error handler did not throw);
1581
+ // skip when the error is a SessionError, which already retired the session
1582
+ if (!this.#errorAbsolvesSession(err)) {
1583
+ context.session?.markBad();
1584
+ }
1378
1585
  }
1379
1586
  /**
1380
1587
  * Generator function that yields requests injected with the given crawl depth.
@@ -1385,6 +1592,11 @@ export class BasicCrawler {
1385
1592
  if (typeof request === 'string') {
1386
1593
  yield { url: request, crawlDepth: newRequestDepth };
1387
1594
  }
1595
+ else if (request instanceof Request) {
1596
+ const crawlee = (request.userData.__crawlee ??= {});
1597
+ crawlee.crawlDepth ??= newRequestDepth;
1598
+ yield request;
1599
+ }
1388
1600
  else {
1389
1601
  request.crawlDepth ??= newRequestDepth;
1390
1602
  yield request;
@@ -1395,7 +1607,7 @@ export class BasicCrawler {
1395
1607
  * Run async callback with given timeout and retry. Returns the result of the callback.
1396
1608
  * @ignore
1397
1609
  */
1398
- async timeoutAndRetry(handler, timeout, error, maxRetries = 3, retried = 1) {
1610
+ async #timeoutAndRetry(handler, timeout, error, maxRetries = 3, retried = 1) {
1399
1611
  try {
1400
1612
  return await addTimeoutToPromise(handler, timeout, error);
1401
1613
  }
@@ -1403,32 +1615,34 @@ export class BasicCrawler {
1403
1615
  if (retried <= maxRetries) {
1404
1616
  // we retry on any error, not just timeout
1405
1617
  this.log.warning(`${e.message} (retrying ${retried}/${maxRetries})`);
1406
- return this.timeoutAndRetry(handler, timeout, error, maxRetries, retried + 1);
1618
+ return this.#timeoutAndRetry(handler, timeout, error, maxRetries, retried + 1);
1407
1619
  }
1408
1620
  throw e;
1409
1621
  }
1410
1622
  }
1411
1623
  /**
1412
- * Returns true if either RequestList or RequestQueue have a request ready for processing.
1624
+ * Whether the request manager has a request ready for processing. A manager that is only `waiting` also gets a
1625
+ * wake-up scheduled, so a paced crawl resumes on its clock rather than on the task loop's polling interval.
1413
1626
  */
1414
- async isTaskReadyFunction() {
1415
- return this.requestManager !== undefined && !(await this.requestManager.isEmpty());
1416
- }
1417
- /**
1418
- * Returns true if both RequestList and RequestQueue have all requests finished.
1419
- */
1420
- async defaultIsFinishedFunction() {
1421
- return !this.requestManager || (await this.requestManager.isFinished());
1627
+ async #isTaskReadyFunction() {
1628
+ if (this.requestManager === undefined) {
1629
+ return false;
1630
+ }
1631
+ const state = await this.requestManager.checkReadiness();
1632
+ if (state.status === 'waiting' && state.readyAt !== undefined) {
1633
+ this.#run?.scheduleWake(state.readyAt);
1634
+ }
1635
+ return state.status === 'ready';
1422
1636
  }
1423
1637
  /**
1424
1638
  * Unwraps errors thrown by the context pipeline to get the actual user error.
1425
- * RequestHandlerError and ContextPipelineInitializationError wrap the actual error.
1639
+ * RequestHandlerError, ContextPipelineInitializationError and ContextPipelineCleanupError wrap the actual error.
1426
1640
  */
1427
- unwrapError(error) {
1641
+ #unwrapError(error) {
1428
1642
  if (error instanceof RequestHandlerError ||
1429
1643
  error instanceof ContextPipelineInitializationError ||
1430
1644
  error instanceof ContextPipelineCleanupError) {
1431
- return this.unwrapError(error.cause);
1645
+ return this.#unwrapError(error.cause);
1432
1646
  }
1433
1647
  return error;
1434
1648
  }
@@ -1437,16 +1651,45 @@ export class BasicCrawler {
1437
1651
  *
1438
1652
  * @param request The request object, passed separately to circumvent potential dynamic logic in crawlingContext.request
1439
1653
  */
1654
+ // oxlint-disable-next-line crawlee/prefer-private-fields -- patched by @crawlee/otel
1440
1655
  async requestFunctionErrorHandler(error, crawlingContext, request, source) {
1656
+ if (error instanceof RequestThrottledError) {
1657
+ // The domain told us to come back later, so the request was never really attempted. Put it back where
1658
+ // it was, without recording a failure - it costs neither a retry nor session reputation.
1659
+ this.log.debug(`Deferring request because its domain is rate-limiting us. ${error.message}`, {
1660
+ id: request.id,
1661
+ url: request.url,
1662
+ });
1663
+ await source.reclaimRequest(request, { forefront: true });
1664
+ return;
1665
+ }
1441
1666
  request.pushErrorMessage(error);
1442
1667
  if (error instanceof CriticalError) {
1443
1668
  throw error;
1444
1669
  }
1445
- const shouldRetryRequest = this.canRequestBeRetried(request, error);
1670
+ const shouldRetryRequest = this.#canRequestBeRetried(request, error);
1446
1671
  if (shouldRetryRequest) {
1447
- await this.stats.errorTrackerRetry.addAsync(error, crawlingContext);
1448
- await this.errorHandler?.(crawlingContext, // valid cast - ExtendedContext transitively extends CrawlingContext
1449
- error);
1672
+ await this.statistics.errorTrackerRetry.addAsync(error, crawlingContext);
1673
+ try {
1674
+ await this.#errorHandler?.(crawlingContext, // valid cast - ExtendedContext transitively extends CrawlingContext
1675
+ error);
1676
+ }
1677
+ catch (handlerError) {
1678
+ if (handlerError instanceof ContextPipelineInterruptedError) {
1679
+ const skipError = await this.#trySkipRequest(handlerError, request, source);
1680
+ if (!skipError) {
1681
+ return;
1682
+ }
1683
+ // Retry for the original error. Routing `skipError` through `errorHandler` again
1684
+ // would loop forever with a handler that always skips.
1685
+ this.log.warning(`Skipping request ${request.url} failed, retrying it instead.`, {
1686
+ error: skipError.message,
1687
+ });
1688
+ }
1689
+ else {
1690
+ throw handlerError;
1691
+ }
1692
+ }
1450
1693
  if (error instanceof SessionError) {
1451
1694
  crawlingContext.session?.retire();
1452
1695
  }
@@ -1455,13 +1698,13 @@ export class BasicCrawler {
1455
1698
  const { url, retryCount, id } = request;
1456
1699
  // We don't want to see the stack trace in the logs by default, when we are going to retry the request.
1457
1700
  // Thus, we print the full stack trace only when CRAWLEE_VERBOSE_LOG environment variable is set to true.
1458
- const message = this._getMessageFromError(error);
1701
+ const message = this.getMessageFromError(error);
1459
1702
  this.log.warning(`Reclaiming failed request back to the list or queue. ${message}`, {
1460
1703
  id,
1461
1704
  url,
1462
1705
  retryCount,
1463
1706
  });
1464
- await source.reclaimRequest(request, { forefront: request.userData?.__crawlee?.forefront });
1707
+ await source.reclaimRequest(request);
1465
1708
  return;
1466
1709
  }
1467
1710
  }
@@ -1473,34 +1716,54 @@ export class BasicCrawler {
1473
1716
  // This is to make sure the error snapshot is not duplicated in the errorTrackerRetry and errorTracker objects.
1474
1717
  const { noRetry, maxRetries } = request;
1475
1718
  if (noRetry || !maxRetries) {
1476
- await this.stats.errorTracker.addAsync(error, crawlingContext);
1719
+ await this.statistics.errorTracker.addAsync(error, crawlingContext);
1477
1720
  }
1478
1721
  else {
1479
- this.stats.errorTracker.add(error);
1722
+ this.statistics.errorTracker.add(error);
1480
1723
  }
1481
1724
  // If we get here, the request is either not retryable
1482
1725
  // or failed more than retryCount times and will not be retried anymore.
1483
1726
  // Mark the request as failed and do not retry.
1484
1727
  await source.markRequestAsHandled(request);
1485
- this.stats.failJob(request.id || request.uniqueKey, request.retryCount);
1728
+ this.statistics.recordRequestFailure(request.id || request.uniqueKey, request.retryCount);
1486
1729
  await this.handleFailedRequestHandler(crawlingContext, error); // This function prints an error message.
1487
1730
  }
1731
+ /** Settles a skipped request. Returns the error that prevented that, leaving the request untouched if `onSkippedRequest` failed. */
1732
+ async #trySkipRequest(error, request, source) {
1733
+ try {
1734
+ await addTimeoutToPromise(async () => this.#handleSkippedRequest({ request, reason: error.reason, message: error.skipMessage }), this.internalTimeoutMillis, `onSkippedRequest for ${request.url} (${request.id}) timed out after ${this.internalTimeoutMillis / 1e3} seconds.`);
1735
+ request.state = RequestState.SKIPPED;
1736
+ request.noRetry = true;
1737
+ await this.#timeoutAndRetry(async () => source.markRequestAsHandled(request), this.internalTimeoutMillis, `Marking request ${request.url} (${request.id}) as handled timed out after ${this.internalTimeoutMillis / 1e3} seconds.`);
1738
+ this.statistics.discardRequestRecord(request.id || request.uniqueKey);
1739
+ return undefined;
1740
+ }
1741
+ catch (settleError) {
1742
+ return this.#unwrapError(settleError);
1743
+ }
1744
+ }
1745
+ // oxlint-disable-next-line crawlee/prefer-private-fields -- patched by @crawlee/otel
1488
1746
  async handleFailedRequestHandler(crawlingContext, error) {
1489
1747
  // Always log the last error regardless if the user provided a failedRequestHandler
1490
1748
  const { id, url, method, uniqueKey } = crawlingContext.request;
1491
- const message = this._getMessageFromError(error, true);
1749
+ const message = this.getMessageFromError(error, true);
1492
1750
  this.log.error(`Request failed and reached maximum retries. ${message}`, { id, url, method, uniqueKey });
1493
- if (this.failedRequestHandler) {
1494
- await this.failedRequestHandler?.(crawlingContext, // valid cast - ExtendedContext transitively extends CrawlingContext
1751
+ try {
1752
+ await this.#failedRequestHandler?.(crawlingContext, // valid cast - ExtendedContext transitively extends CrawlingContext
1495
1753
  error);
1496
1754
  }
1755
+ catch (handlerError) {
1756
+ if (!(handlerError instanceof ContextPipelineInterruptedError))
1757
+ throw handlerError;
1758
+ this.log.warning(`skipRequest() has no effect in failedRequestHandler, request ${url} (${id}) has already failed.`);
1759
+ }
1497
1760
  }
1498
1761
  /**
1499
1762
  * Resolves the most verbose error message from a thrown error
1500
1763
  * @param error The error received
1501
1764
  * @returns The message to be logged
1502
1765
  */
1503
- _getMessageFromError(error, forceStack = false) {
1766
+ getMessageFromError(error, forceStack = false) {
1504
1767
  if ([TypeError, SyntaxError, ReferenceError].some((type) => error instanceof type)) {
1505
1768
  forceStack = true;
1506
1769
  }
@@ -1508,13 +1771,23 @@ export class BasicCrawler {
1508
1771
  const baseDir = process.cwd();
1509
1772
  const userLine = stackLines.find((line) => line.includes(baseDir) && !line.includes('node_modules'));
1510
1773
  if (error instanceof TimeoutError) {
1511
- return process.env.CRAWLEE_VERBOSE_LOG ? error.stack : error.message || error; // stack in timeout errors does not really help
1774
+ // stack in timeout errors does not really help
1775
+ return (process.env.CRAWLEE_VERBOSE_LOG && error.stack) || error.message;
1512
1776
  }
1513
1777
  return process.env.CRAWLEE_VERBOSE_LOG || forceStack
1514
1778
  ? (error.stack ?? [error.message || error, ...stackLines].join('\n'))
1515
1779
  : [error.message || error, userLine].join('\n');
1516
1780
  }
1517
- canRequestBeRetried(request, error) {
1781
+ /**
1782
+ * Whether the session should be spared for this error - either because it was already retired, or because the
1783
+ * failure says nothing about the session (a rate limit is a property of the domain).
1784
+ */
1785
+ #errorAbsolvesSession(error) {
1786
+ return (error instanceof SessionError ||
1787
+ error instanceof RequestThrottledError ||
1788
+ error instanceof ContextPipelineInterruptedError);
1789
+ }
1790
+ #canRequestBeRetried(request, error) {
1518
1791
  // Request should never be retried, or the error encountered makes it not able to be retried.
1519
1792
  if (request.noRetry || error instanceof NonRetryableError) {
1520
1793
  return false;
@@ -1524,33 +1797,55 @@ export class BasicCrawler {
1524
1797
  return true;
1525
1798
  }
1526
1799
  // Ensure there are more retries available for the request
1527
- const maxRequestRetries = request.maxRetries ?? this.maxRequestRetries;
1800
+ const maxRequestRetries = request.maxRetries ?? this.#maxRequestRetries;
1528
1801
  return request.retryCount < maxRequestRetries;
1529
1802
  }
1530
1803
  /**
1531
- * Stops the crawler immediately.
1804
+ * Ends the run in progress immediately, without waiting for the requests already in flight.
1532
1805
  *
1533
- * This method doesn't wait for currently active requests to finish.
1806
+ * This runs at the end of every {@link BasicCrawler.run|`run()`}, so what it releases is per-run only, and
1807
+ * a repeated `run()` has to find everything it needs either intact or re-establishable. What outlives a run is
1808
+ * released by {@link BasicCrawler.destroy|`destroy()`} instead.
1534
1809
  *
1535
1810
  * To stop the crawler gracefully (waiting for all running requests to finish), use {@link BasicCrawler.stop|`crawler.stop()`} instead.
1536
1811
  */
1537
1812
  async teardown() {
1538
- serviceLocator.getEventManager().emit("persistState" /* EventType.PERSIST_STATE */, { isMigrating: false });
1539
- if (this._closeEvents) {
1540
- await serviceLocator.getEventManager().close();
1813
+ const eventManager = serviceLocator.getEventManager();
1814
+ const ownsEventManager = this.#run?.ownsEventManager === true;
1815
+ // First, so that the pool writes its final state once - its teardown stops it listening before the
1816
+ // persistence event below goes out.
1817
+ await this.#sessionPoolDep.ifOwned(async (pool) => pool.teardown());
1818
+ // When this crawler initialized the event manager, its close() call emits
1819
+ // the final persistence event after the crawler-specific state has been
1820
+ // saved. External event managers still need an explicit event here.
1821
+ if (!ownsEventManager) {
1822
+ eventManager.emit(EventType.PERSIST_STATE, { isMigrating: false });
1541
1823
  }
1542
- await this.sessionPoolDep.ifOwned((pool) => pool.teardown());
1543
- await this.autoscaledPool?.abort();
1544
- await this.concurrencySystemDep?.ifOwned((system) => system.stop());
1824
+ if (ownsEventManager) {
1825
+ await eventManager.close();
1826
+ }
1827
+ await this.#run?.end();
1828
+ }
1829
+ /**
1830
+ * Releases what the crawler owns beyond a single run — in the browser crawlers, the browser pool. A finished
1831
+ * `run()` has already released everything a run owns, so this is only needed before dropping a crawler you
1832
+ * could otherwise have run again.
1833
+ */
1834
+ async destroy() {
1835
+ // An abandoned run - one whose `run()` never returned - would otherwise keep dispatching requests.
1836
+ await this.#run?.end();
1837
+ }
1838
+ async [Symbol.asyncDispose]() {
1839
+ await this.destroy();
1545
1840
  }
1546
- _getCookieHeaderFromRequest(request) {
1841
+ getCookieHeaderFromRequest(request) {
1547
1842
  if (request.headers?.Cookie && request.headers?.cookie) {
1548
1843
  this.log.warning(`Encountered mixed casing for the cookie headers for request ${request.url} (${request.id}). Their values will be merged.`);
1549
1844
  return mergeCookies(request.url, [request.headers.cookie, request.headers.Cookie]);
1550
1845
  }
1551
1846
  return request.headers?.Cookie || request.headers?.cookie || '';
1552
1847
  }
1553
- requestMatchesEnqueueStrategy(request) {
1848
+ #requestMatchesEnqueueStrategy(request) {
1554
1849
  // If `skipNavigation` was used, just return `true`
1555
1850
  try {
1556
1851
  // eslint-disable-next-line @typescript-eslint/no-unused-expressions
@@ -1563,8 +1858,7 @@ export class BasicCrawler {
1563
1858
  throw err;
1564
1859
  }
1565
1860
  const { url, loadedUrl } = request;
1566
- // eslint-disable-next-line dot-notation -- private access
1567
- const strategy = request['enqueueStrategy'];
1861
+ const strategy = request.enqueueStrategy;
1568
1862
  // No strategy set, so we assume it matches, or it was added outside of enqueueLinks
1569
1863
  if (!strategy) {
1570
1864
  return true;
@@ -1599,6 +1893,10 @@ export class BasicCrawler {
1599
1893
  }
1600
1894
  }
1601
1895
  }
1896
+ /** The hostname of `url`, falling back to the whole string when it is not parseable - for log messages only. */
1897
+ function hostnameOrUrl(url) {
1898
+ return URL.canParse(url) ? new URL(url).hostname : url;
1899
+ }
1602
1900
  export function createBasicRouter(routes) {
1603
1901
  return Router.create(routes);
1604
1902
  }