@crawlee/playwright 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.
@@ -1,47 +1,30 @@
1
+ import { setTimeout as delay } from 'node:timers/promises';
1
2
  import { isDeepStrictEqual } from 'node:util';
2
- import { BasicCrawler } from '@crawlee/basic';
3
+ import { BasicCrawler, ContextPipeline, ContextPipelineInitializationError, ContextPipelineInterruptedError, RequestHandlerError, resolveBaseUrlForEnqueueLinksFiltering, Router, Statistics, } from '@crawlee/basic';
3
4
  import { extractUrlsFromPage } from '@crawlee/browser';
4
5
  import { CheerioCrawler } from '@crawlee/cheerio';
5
- import { OwnedOrInjected, RequestHandlerError, RequestHandlerResult, resolveBaseUrlForEnqueueLinksFiltering, Router, serviceLocator, Statistics, withCheckedStorageAccess, } from '@crawlee/core';
6
- import { extractUrlsFromCheerio } from '@crawlee/utils';
6
+ import { createStorageTransaction, EnqueueStrategy, OwnedOrInjected } from '@crawlee/core';
7
+ import { extractUrlsFromCheerio, parseArgument } from '@crawlee/utils/internal';
8
+ import { z } from 'zod';
7
9
  import { addTimeoutToPromise } from '@apify/timeout';
8
10
  import { PlaywrightCrawler } from './playwright-crawler.js';
9
11
  import { RenderingTypePredictor, } from './utils/rendering-type-prediction.js';
10
- class AdaptivePlaywrightCrawlerStatistics extends Statistics {
11
- state = null; // this needs to be assigned for a valid override, but the initialization is done by a reset() call from the parent constructor
12
- constructor(options = {}) {
13
- super(options);
14
- this.reset();
15
- }
16
- reset() {
17
- super.reset();
18
- this.state.httpOnlyRequestHandlerRuns = 0;
19
- this.state.browserRequestHandlerRuns = 0;
20
- this.state.renderingTypeMispredictions = 0;
21
- }
22
- async maybeLoadStatistics() {
23
- await super.maybeLoadStatistics();
24
- const savedState = await this.keyValueStore?.getValue(this.persistStateKey);
25
- if (!savedState) {
26
- return;
27
- }
28
- this.state.httpOnlyRequestHandlerRuns = savedState.httpOnlyRequestHandlerRuns;
29
- this.state.browserRequestHandlerRuns = savedState.browserRequestHandlerRuns;
30
- this.state.renderingTypeMispredictions = savedState.renderingTypeMispredictions;
31
- }
32
- trackHttpOnlyRequestHandlerRun() {
33
- this.state.httpOnlyRequestHandlerRuns ??= 0;
34
- this.state.httpOnlyRequestHandlerRuns += 1;
35
- }
36
- trackBrowserRequestHandlerRun() {
37
- this.state.browserRequestHandlerRuns ??= 0;
38
- this.state.browserRequestHandlerRuns += 1;
39
- }
40
- trackRenderingTypeMisprediction() {
41
- this.state.renderingTypeMispredictions ??= 0;
42
- this.state.renderingTypeMispredictions += 1;
43
- }
44
- }
12
+ const adaptiveStatisticStateSchema = z.object({
13
+ /** How many requests were handled by the HTTP-only request handler. */
14
+ httpOnlyRequestHandlerRuns: z.number().default(0),
15
+ /** How many requests were handled in a browser. */
16
+ browserRequestHandlerRuns: z.number().default(0),
17
+ /** How many times the HTTP-only handler produced a result the `resultChecker` rejected. */
18
+ renderingTypeMispredictions: z.number().default(0),
19
+ });
20
+ /**
21
+ * The {@link AdaptivePlaywrightCrawlerStatisticState} fields as a {@link Statistics} state extension, defaults
22
+ * and all. A {@link Statistics} instance to be injected into an {@link AdaptivePlaywrightCrawler} has to carry
23
+ * them - `deserialize.extend()` your own fields onto this one and pass the result as `stateExtension`.
24
+ */
25
+ export const adaptivePlaywrightCrawlerStatisticState = {
26
+ deserialize: adaptiveStatisticStateSchema,
27
+ };
45
28
  const proxyLogMethods = [
46
29
  'error',
47
30
  'exception',
@@ -82,41 +65,91 @@ const proxyLogMethods = [
82
65
  * @experimental
83
66
  */
84
67
  export class AdaptivePlaywrightCrawler extends BasicCrawler {
85
- renderingTypePredictor;
86
- resultChecker;
87
- shouldPropagateError;
88
- resultComparator;
89
- preventDirectStorageAccess;
90
- staticContextPipeline;
91
- browserContextPipeline;
92
- individualRequestHandlerTimeoutMillis;
93
- resultObjects = new WeakMap();
94
- inFlightRenderingTypeDetections = 0;
95
- teardownHooks = [];
68
+ #renderingTypePredictor;
69
+ #resultChecker;
70
+ #shouldPropagateError;
71
+ #resultComparator;
72
+ #staticContextPipeline;
73
+ #browserContextPipeline;
74
+ #individualRequestHandlerTimeoutMillis;
75
+ /**
76
+ * The write policy of the per-attempt transactions. Defaults the request queue to `deferred`:
77
+ * a discarded attempt's enqueues must never reach the queue.
78
+ */
79
+ #attemptWritePolicy;
80
+ /** Owns the browser pool this crawler's runs use, so its per-run resources are released with ours. */
81
+ #browserCrawler;
82
+ /** Nothing of its state is per-run, but it owns a session pool that outlives one. */
83
+ #staticCrawler;
84
+ /**
85
+ * In-flight rendering type detections, plus the pending results of an asynchronous `storeResult`.
86
+ */
87
+ #activeDetections = new Set();
88
+ /**
89
+ * Set once `teardown()` starts, so that requests still in the pool stop opening new detections.
90
+ */
91
+ #shutDown = false;
96
92
  constructor(options = {}) {
97
- const { requestHandler, renderingTypeDetectionRatio = 0.1, renderingTypePredictor, resultChecker, shouldPropagateError, resultComparator, statisticsOptions, preventDirectStorageAccess = true, requestHandlerTimeoutSecs = 60, errorHandler, failedRequestHandler, preNavigationHooks = [], postNavigationHooks = [], extendContext, contextPipelineBuilder, ...rest } = options;
93
+ const { requestHandler, renderingTypeDetectionRatio = 0.1, renderingTypePredictor, resultChecker, shouldPropagateError, resultComparator, statistics, requestHandlerTimeoutSecs = 60, errorHandler, failedRequestHandler, preNavigationHooks = [], postNavigationHooks = [], extendContext, transactionalStorage, launchContext, headless, browserPool, remoteBrowser, ...rest } = options;
94
+ // The user's value is replaced by `false` in the `super` call below — validate it separately,
95
+ // wrapped in an object so the error still names the field.
96
+ parseArgument({ transactionalStorage }, z.object({ transactionalStorage: BasicCrawler.optionsShape.transactionalStorage }), 'AdaptivePlaywrightCrawlerOptions');
97
+ // The extra fields are only tracked if the injected instance was built with them - the types enforce that,
98
+ // but plain JS callers would otherwise silently increment `undefined` into a sticky `NaN`. Extend
99
+ // `adaptivePlaywrightCrawlerStatisticState` to satisfy this.
100
+ if (statistics !== undefined) {
101
+ parseArgument(statistics.state, z.object({
102
+ httpOnlyRequestHandlerRuns: z.number(),
103
+ browserRequestHandlerRuns: z.number(),
104
+ renderingTypeMispredictions: z.number(),
105
+ }), 'statistics.state');
106
+ }
107
+ // Per-attempt buffering is load-bearing here: the handler runs up to twice per request and the
108
+ // losing attempt's writes must be discardable.
109
+ if (transactionalStorage === false) {
110
+ throw new Error('AdaptivePlaywrightCrawler requires transactional storage - it runs the request handler ' +
111
+ 'multiple times per request and must be able to discard the storage writes of losing ' +
112
+ 'attempts. `transactionalStorage: false` is therefore not supported; a write policy ' +
113
+ 'object is accepted and forwarded to the per-attempt transactions.');
114
+ }
98
115
  super({
99
116
  ...rest,
100
117
  errorHandler,
101
118
  failedRequestHandler,
102
119
  requestHandler,
103
120
  requestHandlerTimeoutSecs,
104
- contextPipelineBuilder: contextPipelineBuilder ?? (() => this.buildContextPipeline()),
121
+ // The base would build a `Statistics` without the adaptive fields, so provide a default that has them.
122
+ // The cast covers a `StatisticStateExtension` that adds further fields - those can only come from an
123
+ // injected instance, in which case this default is never built.
124
+ statistics: statistics ??
125
+ new Statistics({
126
+ logMessage: `${AdaptivePlaywrightCrawler.name} request statistics:`,
127
+ stateExtension: adaptivePlaywrightCrawlerStatisticState,
128
+ }),
129
+ contextPipelineBuilder: () => this.#buildContextPipeline(),
130
+ // The base crawler must not wrap requests in a transaction of its own - this crawler opens
131
+ // one per request handler attempt in `crawlOne` instead, forwarding the write policy of the
132
+ // user-facing option (validated above) to those.
133
+ transactionalStorage: false,
105
134
  });
106
- this.individualRequestHandlerTimeoutMillis = requestHandlerTimeoutSecs * 1000;
135
+ this.#individualRequestHandlerTimeoutMillis = requestHandlerTimeoutSecs * 1000;
107
136
  // `renderingTypeDetectionRatio` only configures the default predictor - an injected one brings its own
108
137
  // detection ratio (and its own state), so the option is ignored in that case.
109
- this.renderingTypePredictor = OwnedOrInjected.resolve(renderingTypePredictor, () => new RenderingTypePredictor({ detectionRatio: renderingTypeDetectionRatio }));
110
- this.resultChecker = resultChecker ?? (() => true);
111
- this.shouldPropagateError = shouldPropagateError ?? (() => false);
138
+ this.#renderingTypePredictor = OwnedOrInjected.resolve(renderingTypePredictor, () => new RenderingTypePredictor({ detectionRatio: renderingTypeDetectionRatio }));
139
+ this.#attemptWritePolicy = {
140
+ requestQueue: 'deferred',
141
+ ...(typeof transactionalStorage === 'object' ? transactionalStorage : {}),
142
+ };
143
+ this.#resultChecker = resultChecker ?? (() => true);
144
+ this.#shouldPropagateError = shouldPropagateError ?? (() => false);
112
145
  if (resultComparator !== undefined) {
113
- this.resultComparator = resultComparator;
146
+ this.#resultComparator = resultComparator;
114
147
  }
115
148
  else if (resultChecker !== undefined) {
116
- this.resultComparator = (resultA, resultB) => this.resultChecker(resultA) && this.resultChecker(resultB);
149
+ this.#resultComparator = (resultA, resultB) => this.#resultChecker(resultA) && this.#resultChecker(resultB);
117
150
  }
118
151
  else {
119
- this.resultComparator = (resultA, resultB) => {
152
+ this.#resultComparator = (resultA, resultB) => {
120
153
  return (resultA.datasetItems.length === resultB.datasetItems.length &&
121
154
  resultA.datasetItems.every((itemA, i) => {
122
155
  const itemB = resultB.datasetItems[i];
@@ -134,75 +167,65 @@ export class AdaptivePlaywrightCrawler extends BasicCrawler {
134
167
  // each hook's overrides at runtime regardless of the static type.
135
168
  const staticCrawler = new CheerioCrawler({
136
169
  ...rest,
137
- statisticsOptions: {
138
- persistenceOptions: { enable: false },
139
- },
170
+ statistics: new Statistics({ persistenceOptions: { enable: false } }),
140
171
  preNavigationHooks,
141
172
  postNavigationHooks,
142
173
  extendContext,
143
174
  });
144
175
  const browserCrawler = new PlaywrightCrawler({
145
176
  ...rest,
146
- statisticsOptions: {
147
- persistenceOptions: { enable: false },
148
- },
177
+ statistics: new Statistics({ persistenceOptions: { enable: false } }),
149
178
  preNavigationHooks: preNavigationHooks,
150
179
  postNavigationHooks: postNavigationHooks,
151
180
  extendContext,
181
+ launchContext,
182
+ headless,
183
+ browserPool,
184
+ remoteBrowser,
152
185
  });
153
- this.teardownHooks.push(browserCrawler.teardown.bind(browserCrawler));
154
- this.staticContextPipeline = staticCrawler.contextPipeline.compose({
155
- action: this.adaptCheerioContext.bind(this),
156
- });
157
- this.browserContextPipeline = browserCrawler.contextPipeline.compose({
158
- action: this.adaptPlaywrightContext.bind(this),
159
- });
160
- this.stats = new AdaptivePlaywrightCrawlerStatistics({
161
- logMessage: `${this.log.getOptions().prefix} request statistics:`,
162
- ...statisticsOptions,
163
- });
164
- this.preventDirectStorageAccess = preventDirectStorageAccess;
186
+ this.#staticCrawler = staticCrawler;
187
+ this.#browserCrawler = browserCrawler;
188
+ this.#staticContextPipeline = staticCrawler.contextPipeline.compose(this.#adaptCheerioContext.bind(this));
189
+ this.#browserContextPipeline = browserCrawler.contextPipeline.compose(this.#adaptPlaywrightContext.bind(this));
165
190
  }
166
- async _init() {
191
+ async init() {
192
+ // A crawler can be run again after a teardown.
193
+ this.#shutDown = false;
167
194
  // Only the predictor we built ourselves is ours to initialize - an injected one is borrowed, so its
168
195
  // lifecycle (including restoring persisted state) stays with whoever created it.
169
- await this.renderingTypePredictor.ifOwned((predictor) => predictor.initialize());
170
- return await super._init();
196
+ await this.#renderingTypePredictor.ifOwned((predictor) => predictor.initialize());
197
+ return await super.init();
171
198
  }
172
- buildContextPipeline() {
199
+ #buildContextPipeline() {
173
200
  const errorMessage = (prop) => `The \`${prop}\` property is not available on the outer context pipeline of AdaptivePlaywrightCrawler - it is provided by the inner (static/browser) pipelines`;
174
- return super.buildContextPipeline().compose({
175
- action: async ({ request }) => ({
176
- get request() {
177
- return request;
178
- },
179
- get response() {
180
- throw new Error(errorMessage('response'));
181
- },
182
- get page() {
183
- throw new Error(errorMessage('page'));
184
- },
185
- get querySelector() {
186
- throw new Error(errorMessage('querySelector'));
187
- },
188
- get querySelectorAll() {
189
- throw new Error(errorMessage('querySelectorAll'));
190
- },
191
- get waitForSelector() {
192
- throw new Error(errorMessage('waitForSelector'));
193
- },
194
- get parseWithCheerio() {
195
- throw new Error(errorMessage('parseWithCheerio'));
196
- },
197
- }),
198
- });
201
+ return ContextPipeline.create().compose(async ({ request }) => ({
202
+ get request() {
203
+ return request;
204
+ },
205
+ get response() {
206
+ throw new Error(errorMessage('response'));
207
+ },
208
+ get page() {
209
+ throw new Error(errorMessage('page'));
210
+ },
211
+ get querySelector() {
212
+ throw new Error(errorMessage('querySelector'));
213
+ },
214
+ get querySelectorAll() {
215
+ throw new Error(errorMessage('querySelectorAll'));
216
+ },
217
+ get waitForSelector() {
218
+ throw new Error(errorMessage('waitForSelector'));
219
+ },
220
+ get parseWithCheerio() {
221
+ throw new Error(errorMessage('parseWithCheerio'));
222
+ },
223
+ get enqueueLinks() {
224
+ throw new Error(errorMessage('enqueueLinks'));
225
+ },
226
+ }));
199
227
  }
200
- async adaptCheerioContext(cheerioContext) {
201
- // Capture the original response to avoid infinite recursion when the getter is copied to the context
202
- const result = this.resultObjects.get(cheerioContext);
203
- if (result === undefined) {
204
- throw new Error('Logical error - `this.resultObjects` does not contain the result object');
205
- }
228
+ async #adaptCheerioContext(cheerioContext) {
206
229
  return {
207
230
  get page() {
208
231
  throw new Error('Page object was used in HTTP-only request handler');
@@ -214,19 +237,15 @@ export class AdaptivePlaywrightCrawler extends BasicCrawler {
214
237
  return cheerioContext.$(selector);
215
238
  },
216
239
  enqueueLinks: async (options = {}) => {
217
- const urls = options.urls ??
218
- extractUrlsFromCheerio(cheerioContext.$, options.selector, options.baseUrl ?? cheerioContext.request.loadedUrl);
219
- return (await this.enqueueLinks({ ...options, urls }, cheerioContext.request, result));
240
+ const urls = extractUrlsFromCheerio(cheerioContext.$, options.selector, options.baseUrl ?? cheerioContext.request.loadedUrl);
241
+ return (await this.#enqueueLinks(urls, options, cheerioContext.request));
220
242
  },
221
243
  response: cheerioContext.response,
222
244
  };
223
245
  }
224
- async adaptPlaywrightContext(playwrightContext) {
246
+ async #adaptPlaywrightContext(playwrightContext) {
247
+ // Capture the original response to avoid infinite recursion when the getter is copied to the context
225
248
  const originalResponse = playwrightContext.response;
226
- const result = this.resultObjects.get(playwrightContext);
227
- if (result === undefined) {
228
- throw new Error('Logical error - `this.resultObjects` does not contain the result object');
229
- }
230
249
  return {
231
250
  response: new Response(Uint8Array.from(await originalResponse.body()), {
232
251
  headers: originalResponse.headers(),
@@ -247,60 +266,58 @@ export class AdaptivePlaywrightCrawler extends BasicCrawler {
247
266
  },
248
267
  enqueueLinks: async (options = {}, timeoutMs = 5000) => {
249
268
  // TODO consider using `context.parseWithCheerio` to make this universal and avoid code duplication
250
- let urls;
251
- if (options.urls === undefined) {
252
- const selector = options.selector ?? 'a';
253
- const locator = playwrightContext.page.locator(selector).first();
254
- await locator.waitFor({ timeout: timeoutMs, state: 'attached' });
255
- urls =
256
- options.urls ??
257
- (await extractUrlsFromPage(playwrightContext.page, selector, options.baseUrl ?? playwrightContext.request.loadedUrl));
258
- }
259
- else {
260
- urls = options.urls;
261
- }
262
- return (await this.enqueueLinks({ ...options, urls }, playwrightContext.request, result));
269
+ const selector = options.selector ?? 'a';
270
+ const locator = playwrightContext.page.locator(selector).first();
271
+ await locator.waitFor({ timeout: timeoutMs, state: 'attached' });
272
+ const urls = await extractUrlsFromPage(playwrightContext.page, selector, options.baseUrl ?? playwrightContext.request.loadedUrl);
273
+ return (await this.#enqueueLinks(urls, options, playwrightContext.request));
263
274
  },
264
275
  };
265
276
  }
266
- async crawlOne(renderingType, context, useStateFunction) {
267
- const result = new RequestHandlerResult(serviceLocator.getConfiguration(), AdaptivePlaywrightCrawler.CRAWLEE_STATE_KEY);
277
+ /**
278
+ * Runs one request handler attempt inside its own {@link StorageTransaction}, wrapping the inner
279
+ * (static or browser) context pipeline. The transaction is pushed to `transactions` *at creation
280
+ * time, before the `try`* - the `ok: false` branch of the returned {@link Result} carries no
281
+ * result, and failed attempts are routine here. The caller owns the outcome and disposal.
282
+ */
283
+ async #crawlOne(renderingType, context, useStateFunction, transactions) {
284
+ const transaction = createStorageTransaction({
285
+ policy: this.#attemptWritePolicy,
286
+ commitTimeoutMillis: this.internalTimeoutMillis,
287
+ });
288
+ transactions.push(transaction);
268
289
  const logs = [];
269
290
  const deferredCleanup = [];
270
- const resultBoundContextHelpers = {
271
- addRequests: result.addRequests,
272
- pushData: result.pushData,
273
- useState: this.allowStorageAccess(useStateFunction),
274
- getKeyValueStore: this.allowStorageAccess(result.getKeyValueStore),
275
- log: this.createLogProxy(context.log, logs),
291
+ const attemptBoundContextHelpers = {
292
+ useState: useStateFunction,
293
+ log: this.#createLogProxy(context.log, logs),
276
294
  registerDeferredCleanup: (cleanup) => deferredCleanup.push(cleanup),
277
295
  };
278
296
  const subCrawlerContext = Object.defineProperties({}, Object.getOwnPropertyDescriptors(context));
279
- // Mark result-bound helpers as non-configurable so they survive the sub-crawler context pipeline
280
- // (which would otherwise override them with the sub-crawler's own versions, losing the result binding).
281
- for (const [key, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(resultBoundContextHelpers))) {
297
+ // Mark attempt-bound helpers as non-configurable so they survive the sub-crawler context pipeline
298
+ // (which would otherwise override them with the sub-crawler's own versions, losing the binding).
299
+ for (const [key, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(attemptBoundContextHelpers))) {
282
300
  Object.defineProperty(subCrawlerContext, key, { ...descriptor, configurable: false });
283
301
  }
284
- this.resultObjects.set(subCrawlerContext, result);
285
302
  try {
303
+ // Any failure - middleware or handler - ends up in the `ok: false` branch below.
304
+ const rethrow = (error) => {
305
+ throw error;
306
+ };
286
307
  const callAdaptiveRequestHandler = async () => {
287
308
  if (renderingType === 'static') {
288
- await this.staticContextPipeline.call(subCrawlerContext, this.requestHandler.bind(this));
309
+ await this.#staticContextPipeline.call(subCrawlerContext, this.requestHandler.bind(this), rethrow);
289
310
  }
290
311
  else if (renderingType === 'clientOnly') {
291
- await this.browserContextPipeline.call(subCrawlerContext, this.requestHandler.bind(this));
312
+ await this.#browserContextPipeline.call(subCrawlerContext, this.requestHandler.bind(this), rethrow);
292
313
  }
293
314
  };
294
315
  // this crawler overrides `runRequestHandler` and times each rendering-type run itself, so it has
295
316
  // to resolve any per-route override too - otherwise routes would be silently ignored here
296
317
  const routeTimeoutSecs = this.requestHandler.getTimeoutSecs?.(context.request.label);
297
- const timeoutMillis = routeTimeoutSecs === undefined ? this.individualRequestHandlerTimeoutMillis : routeTimeoutSecs * 1000;
298
- await addTimeoutToPromise(async () => withCheckedStorageAccess(() => {
299
- if (this.preventDirectStorageAccess) {
300
- throw new Error('Directly accessing storage in a request handler is not allowed in AdaptivePlaywrightCrawler');
301
- }
302
- }, callAdaptiveRequestHandler), timeoutMillis, 'Request handler timed out');
303
- return { result, ok: true, logs };
318
+ const timeoutMillis = routeTimeoutSecs === undefined ? this.#individualRequestHandlerTimeoutMillis : routeTimeoutSecs * 1000;
319
+ await addTimeoutToPromise(async () => transaction.run(callAdaptiveRequestHandler), timeoutMillis, 'Request handler timed out');
320
+ return { result: transaction, ok: true, logs };
304
321
  }
305
322
  catch (error) {
306
323
  return { error, ok: false, logs };
@@ -310,42 +327,45 @@ export class AdaptivePlaywrightCrawler extends BasicCrawler {
310
327
  }
311
328
  }
312
329
  async runRequestHandler(crawlingContext) {
313
- const renderingTypePrediction = this.renderingTypePredictor.value.predict(crawlingContext.request);
314
- const shouldDetectRenderingType = Math.random() < renderingTypePrediction.detectionProbabilityRecommendation;
330
+ const renderingTypePrediction = await this.#renderingTypePredictor.value.predict(crawlingContext.request);
331
+ const shouldDetectRenderingType = !this.#shutDown && Math.random() < renderingTypePrediction.detectionProbabilityRecommendation;
315
332
  if (!shouldDetectRenderingType) {
316
333
  crawlingContext.log.debug(`Predicted rendering type ${renderingTypePrediction.renderingType} for ${crawlingContext.request.url}`);
317
334
  }
318
- if (shouldDetectRenderingType) {
319
- this.inFlightRenderingTypeDetections += 1;
320
- }
335
+ // Every transaction created for this request - up to two, since the static-then-browser
336
+ // fall-through and the browser-then-detection pair are mutually exclusive. Disposed in the
337
+ // `finally` below, not earlier: the comparators read the journals after `crawlOne` returns.
338
+ const transactions = [];
321
339
  try {
322
340
  if (renderingTypePrediction.renderingType === 'static' && !shouldDetectRenderingType) {
323
341
  crawlingContext.log.debug(`Running HTTP-only request handler for ${crawlingContext.request.url}`);
324
- this.stats.trackHttpOnlyRequestHandlerRun();
325
- const plainHTTPRun = await this.crawlOne('static', crawlingContext, crawlingContext.useState);
326
- if (plainHTTPRun.ok && this.resultChecker(plainHTTPRun.result)) {
342
+ this.statistics.state.httpOnlyRequestHandlerRuns++;
343
+ const plainHTTPRun = await this.#crawlOne('static', crawlingContext, crawlingContext.useState, transactions);
344
+ if (plainHTTPRun.ok && this.#resultChecker(plainHTTPRun.result)) {
327
345
  crawlingContext.log.debug(`HTTP-only request handler succeeded for ${crawlingContext.request.url}`);
328
346
  plainHTTPRun.logs?.forEach(([log, method, ...args]) => log[method](...args));
329
- await this.commitResult(crawlingContext, plainHTTPRun.result);
347
+ await plainHTTPRun.result.commit();
330
348
  return;
331
349
  }
332
350
  // Execution will "fall through" and try running the request handler in a browser
333
351
  if (!plainHTTPRun.ok) {
334
- const actualError = plainHTTPRun.error instanceof RequestHandlerError
352
+ const actualError = plainHTTPRun.error instanceof RequestHandlerError ||
353
+ plainHTTPRun.error instanceof ContextPipelineInitializationError
335
354
  ? plainHTTPRun.error.cause
336
355
  : plainHTTPRun.error;
337
- if (await this.shouldPropagateError(actualError, crawlingContext)) {
356
+ if (actualError instanceof ContextPipelineInterruptedError ||
357
+ (await this.#shouldPropagateError(actualError, crawlingContext))) {
338
358
  throw actualError;
339
359
  }
340
360
  crawlingContext.log.exception(actualError, `HTTP-only request handler failed for ${crawlingContext.request.url}`);
341
361
  }
342
362
  else {
343
363
  crawlingContext.log.warning(`HTTP-only request handler returned a suspicious result for ${crawlingContext.request.url}`);
344
- this.stats.trackRenderingTypeMisprediction();
364
+ this.statistics.state.renderingTypeMispredictions++;
345
365
  }
346
366
  }
347
367
  crawlingContext.log.debug(`Running browser request handler for ${crawlingContext.request.url}`);
348
- this.stats.trackBrowserRequestHandlerRun();
368
+ this.statistics.state.browserRequestHandlerRuns++;
349
369
  // Run the request handler in a browser. The copy of the crawler state is kept so that we can perform
350
370
  // a rendering type detection if necessary. Without this measure, the HTTP request handler would run
351
371
  // under different conditions, which could change its behavior. Changes done to the crawler state by
@@ -366,102 +386,157 @@ export class AdaptivePlaywrightCrawler extends BasicCrawler {
366
386
  return this.stateCopy;
367
387
  },
368
388
  };
369
- const browserRun = await this.crawlOne('clientOnly', crawlingContext, stateTracker.getLiveState.bind(stateTracker));
389
+ const browserRun = await this.#crawlOne('clientOnly', crawlingContext, stateTracker.getLiveState.bind(stateTracker), transactions);
370
390
  if (!browserRun.ok) {
371
391
  throw browserRun.error;
372
392
  }
373
393
  browserRun.logs?.forEach(([log, method, ...args]) => log[method](...args));
374
- await this.commitResult(crawlingContext, browserRun.result);
394
+ await browserRun.result.commit();
375
395
  if (shouldDetectRenderingType) {
376
- crawlingContext.log.debug(`Detecting rendering type for ${crawlingContext.request.url}`);
377
- const plainHTTPRun = await this.crawlOne('static', crawlingContext, stateTracker.getStateCopy.bind(stateTracker));
378
- const detectionResult = (() => {
379
- if (!plainHTTPRun.ok) {
380
- return 'clientOnly';
396
+ const detectionPromise = (async () => {
397
+ crawlingContext.log.debug(`Detecting rendering type for ${crawlingContext.request.url}`);
398
+ // The detection attempt's transaction is never committed - its writes exist only for the
399
+ // result comparison.
400
+ const plainHTTPRun = await this.#crawlOne('static', crawlingContext, stateTracker.getStateCopy.bind(stateTracker), transactions);
401
+ const detectionResult = (() => {
402
+ if (!plainHTTPRun.ok) {
403
+ return 'clientOnly';
404
+ }
405
+ const comparisonResult = this.#resultComparator(plainHTTPRun.result, browserRun.result);
406
+ if (comparisonResult === true || comparisonResult === 'equal') {
407
+ return 'static';
408
+ }
409
+ if (comparisonResult === false || comparisonResult === 'different') {
410
+ return 'clientOnly';
411
+ }
412
+ return undefined;
413
+ })();
414
+ crawlingContext.log.debug(`Detected rendering type ${detectionResult} for ${crawlingContext.request.url}`);
415
+ if (detectionResult !== undefined) {
416
+ // Deliberately not awaited: a predictor that persists asynchronously gets to keep
417
+ // batching its writes, and the drain below catches whatever is still pending.
418
+ const stored = this.#renderingTypePredictor.value.storeResult(crawlingContext.request, detectionResult);
419
+ if (stored !== undefined) {
420
+ // Nothing downstream awaits this, so a failed write would otherwise be silent.
421
+ void this.#trackDetection(Promise.resolve(stored).catch((error) => this.log.exception(error, `Failed to store the rendering type detection result for ${crawlingContext.request.url}`)));
422
+ }
381
423
  }
382
- const comparisonResult = this.resultComparator(plainHTTPRun.result, browserRun.result);
383
- if (comparisonResult === true || comparisonResult === 'equal') {
384
- return 'static';
385
- }
386
- if (comparisonResult === false || comparisonResult === 'different') {
387
- return 'clientOnly';
388
- }
389
- return undefined;
390
424
  })();
391
- crawlingContext.log.debug(`Detected rendering type ${detectionResult} for ${crawlingContext.request.url}`);
392
- if (detectionResult !== undefined) {
393
- this.renderingTypePredictor.value.storeResult(crawlingContext.request, detectionResult);
394
- }
425
+ await this.#trackDetection(detectionPromise);
395
426
  }
396
427
  }
397
428
  finally {
398
- if (shouldDetectRenderingType) {
399
- this.inFlightRenderingTypeDetections -= 1;
429
+ // A still-open transaction here belongs to a discarded attempt - roll it back, then release.
430
+ for (const transaction of transactions) {
431
+ transaction.rollback();
432
+ transaction.dispose();
400
433
  }
401
434
  }
402
435
  }
403
- async commitResult(crawlingContext, { calls, keyValueStoreChanges }) {
404
- await Promise.all([
405
- ...calls.pushData.map(async (params) => crawlingContext.pushData(...params)),
406
- ...calls.addRequests.map(async (params) => crawlingContext.addRequests(...params)),
407
- ...Object.entries(keyValueStoreChanges).map(async ([storeIdOrName, changes]) => {
408
- const store = await crawlingContext.getKeyValueStore({ id: storeIdOrName });
409
- await Promise.all(Object.entries(changes).map(async ([key, { changedValue, options }]) => store.setValue(key, changedValue, options)));
410
- }),
411
- ]);
412
- }
413
- allowStorageAccess(func) {
414
- return async (...args) => withCheckedStorageAccess(() => { }, async () => func(...args));
415
- }
416
- /**
417
- * Reading the pending request count queries the underlying request manager, which counts as storage access.
418
- * Since this is internal crawler bookkeeping used to compute the `enqueueLinks` limit (not user-initiated storage
419
- * access), it must be allowed even while a request handler runs inside the storage-access guard.
420
- */
421
- async getPendingRequestCountApproximation() {
422
- return this.allowStorageAccess(() => super.getPendingRequestCountApproximation())();
423
- }
424
- async enqueueLinks(options, request, result) {
436
+ async #enqueueLinks(urls, options, request) {
425
437
  const baseUrl = resolveBaseUrlForEnqueueLinksFiltering({
426
438
  enqueueStrategy: options?.strategy,
427
439
  finalRequestUrl: request.loadedUrl,
428
440
  originalRequestUrl: request.url,
429
441
  userProvidedBaseUrl: options?.baseUrl,
430
442
  });
431
- const addRequestsBatched = async (requests) => {
432
- await result.addRequests(requests);
433
- return {
434
- addedRequests: requests.map(({ uniqueKey, id }) => ({
435
- uniqueKey,
436
- requestId: id ?? '',
437
- wasAlreadyPresent: false,
438
- wasAlreadyHandled: false,
439
- })),
440
- waitForAllRequestsToBeAdded: Promise.resolve([]),
441
- requestsOverLimit: [],
442
- };
443
- };
444
- // We need to use a mock request queue implementation, in order to add the requests into our result object
445
- const mockRequestQueue = { addRequestsBatched };
446
- return await this.enqueueLinksWithCrawlDepth({ ...options, baseUrl }, request, mockRequestQueue);
443
+ const requestsWithDepth = this.addCrawlDepthRequestGenerator(urls, request.crawlDepth + 1);
444
+ // The per-attempt transaction buffers these (the queue policy defaults to `deferred` here),
445
+ // so a discarded attempt's enqueues never reach the queue.
446
+ return await this.addRequests(requestsWithDepth, {
447
+ ...options,
448
+ baseUrl,
449
+ strategy: options.strategy ?? EnqueueStrategy.SameHostname,
450
+ });
447
451
  }
448
- createLogProxy(log, logs) {
452
+ #createLogProxy(log, logs) {
449
453
  return new Proxy(log, {
450
- get(target, propertyName, receiver) {
454
+ get(target, propertyName) {
451
455
  if (proxyLogMethods.includes(propertyName)) {
452
456
  return (...args) => {
453
457
  logs.push([target, propertyName, ...args]);
454
458
  };
455
459
  }
456
- return Reflect.get(target, propertyName, receiver);
460
+ const value = Reflect.get(target, propertyName, target);
461
+ // Bind non-intercepted methods to the target instance so private #-fields
462
+ // (e.g. BaseCrawleeLogger.#options, #warningsLogged) do not throw TypeError at runtime.
463
+ if (typeof value === 'function') {
464
+ return value.bind(target);
465
+ }
466
+ return value;
457
467
  },
458
468
  });
459
469
  }
470
+ #trackDetection(promise) {
471
+ this.#activeDetections.add(promise);
472
+ // Not `finally()`: the promise it derives would reject on its own and go unhandled. A rejection here
473
+ // belongs to whoever awaits the original, or to `allSettled` in the drain.
474
+ void promise.catch(() => { }).then(() => this.#activeDetections.delete(promise));
475
+ return promise;
476
+ }
477
+ /**
478
+ * Number of rendering type detections that have not settled yet, including results the predictor is
479
+ * still persisting.
480
+ */
481
+ get inFlightRenderingTypeDetectionCount() {
482
+ return this.#activeDetections.size;
483
+ }
484
+ /**
485
+ * Waits for in-flight rendering type detections to settle, bounded by `timeoutMillis` (defaults to the
486
+ * internal timeout).
487
+ */
488
+ async drainRenderingDetections({ timeoutMillis } = {}) {
489
+ if (this.#activeDetections.size === 0) {
490
+ return;
491
+ }
492
+ const drained = (async () => {
493
+ while (this.#activeDetections.size > 0) {
494
+ await Promise.allSettled(Array.from(this.#activeDetections));
495
+ }
496
+ })();
497
+ const millis = timeoutMillis ?? this.internalTimeoutMillis;
498
+ // A caller opting out of the bound would otherwise get an immediate spurious timeout - `setTimeout`
499
+ // clamps a non-finite delay to 1ms.
500
+ if (!Number.isFinite(millis)) {
501
+ await drained;
502
+ return;
503
+ }
504
+ const abortTimer = new AbortController();
505
+ try {
506
+ const outcome = await Promise.race([
507
+ drained.then(() => 'drained'),
508
+ delay(millis, 'timedOut', { signal: abortTimer.signal }).catch(() => 'aborted'),
509
+ ]);
510
+ if (outcome === 'timedOut') {
511
+ this.log.warning(`Timed out after ${millis / 1e3} seconds waiting for ${this.#activeDetections.size} rendering type detection(s) to settle - their results may be lost.`);
512
+ }
513
+ }
514
+ finally {
515
+ abortTimer.abort();
516
+ }
517
+ }
518
+ /**
519
+ * Stops the crawler immediately, but not before rendering type detections already under way (and results
520
+ * the predictor is still persisting) have settled - see
521
+ * {@link AdaptivePlaywrightCrawler.drainRenderingDetections|`drainRenderingDetections()`}. Requests
522
+ * that are still running are not waited for, unlike {@link BasicCrawler.stop|`stop()`}.
523
+ */
460
524
  async teardown() {
525
+ // Called from outside `run()` - under `keepAlive`, say - the pool keeps dispatching until
526
+ // `super.teardown()` aborts it, and a request starting during the drain would open a detection the
527
+ // drain has already passed. Closing that first makes the drain a fence.
528
+ this.#shutDown = true;
529
+ await this.drainRenderingDetections();
461
530
  await super.teardown();
462
- for (const hook of this.teardownHooks) {
463
- await hook();
464
- }
531
+ // Mirrors the owned-only `initialize()` in `init()` - without this, the predictor we built keeps its
532
+ // PERSIST_STATE listener registered after the crawl and never gets a final write.
533
+ await this.#renderingTypePredictor.ifOwned((predictor) => predictor.teardown());
534
+ await this.#browserCrawler.teardown();
535
+ }
536
+ async destroy() {
537
+ await super.destroy();
538
+ await this.#staticCrawler.destroy();
539
+ await this.#browserCrawler.destroy();
465
540
  }
466
541
  }
467
542
  export function createAdaptivePlaywrightRouter(routesOrSchemas) {
@@ -493,6 +568,5 @@ export function createAdaptivePlaywrightRouter(routesOrSchemas) {
493
568
  export function fullResultComparator(resultA, resultB) {
494
569
  return (isDeepStrictEqual(resultA.datasetItems, resultB.datasetItems) &&
495
570
  isDeepStrictEqual(resultA.enqueuedUrls, resultB.enqueuedUrls) &&
496
- isDeepStrictEqual(resultA.enqueuedUrlLists, resultB.enqueuedUrlLists) &&
497
571
  isDeepStrictEqual(resultA.keyValueStoreChanges, resultB.keyValueStoreChanges));
498
572
  }