@crawlee/playwright 4.0.0-beta.104 → 4.0.0-beta.106

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,8 +1,8 @@
1
1
  import type { BrowserHook, LoadedRequest, Request, RouterHandler, RouteSchemas, RoutesFromSchemas } from '@crawlee/browser';
2
2
  import type { BasicCrawlerOptions } from '@crawlee/basic';
3
3
  import { BasicCrawler } from '@crawlee/basic';
4
- import type { ContextPipeline, CrawlingContext, EnqueueLinksOptions, GetUserDataFromRequest, RouterRoutes, StatisticsOptions, StatisticState } from '@crawlee/core';
5
- import { RequestHandlerResult, Statistics } from '@crawlee/core';
4
+ import type { ContextPipeline, CrawlingContext, EnqueueLinksOptions, GetUserDataFromRequest, RouterRoutes, StatisticsOptions, StatisticState, StorageTransactionView } from '@crawlee/core';
5
+ import { Statistics } from '@crawlee/core';
6
6
  import type { Dictionary, Awaitable } from '@crawlee/types';
7
7
  import { type CheerioRoot } from '@crawlee/utils';
8
8
  import { type Cheerio } from 'cheerio';
@@ -108,11 +108,12 @@ export interface AdaptivePlaywrightCrawlerOptions<ContextExtension = Dictionary<
108
108
  */
109
109
  renderingTypeDetectionRatio?: number;
110
110
  /**
111
- * An optional callback that is called on dataset items found by the request handler in plain HTTP mode.
111
+ * An optional callback that is called on the storage writes recorded by the request handler in plain
112
+ * HTTP mode (exposed as a read-only {@link StorageTransactionView}).
112
113
  * If it returns false, the request is retried in a browser.
113
- * If no callback is specified, every dataset item is considered valid.
114
+ * If no callback is specified, every result is considered valid.
114
115
  */
115
- resultChecker?: (result: RequestHandlerResult) => boolean;
116
+ resultChecker?: (result: StorageTransactionView) => boolean;
116
117
  /**
117
118
  * An optional callback that decides whether an error thrown during the plain HTTP request handler
118
119
  * should be propagated (instead of falling back to browser navigation).
@@ -135,18 +136,13 @@ export interface AdaptivePlaywrightCrawlerOptions<ContextExtension = Dictionary<
135
136
  *
136
137
  * For a stricter, ready-made comparator that also takes enqueued requests and key-value store changes into account, see {@link fullResultComparator}.
137
138
  */
138
- resultComparator?: (resultA: RequestHandlerResult, resultB: RequestHandlerResult) => boolean | 'equal' | 'different' | 'inconclusive';
139
+ resultComparator?: (resultA: StorageTransactionView, resultB: StorageTransactionView) => boolean | 'equal' | 'different' | 'inconclusive';
139
140
  /**
140
141
  * A custom rendering type predictor. A predictor passed here is borrowed - the crawler never drives its
141
142
  * lifecycle, so set it up yourself (the built-in {@link RenderingTypePredictor} needs `initialize()`).
142
143
  * Omit the option and the crawler builds its own from `renderingTypeDetectionRatio` - and initializes it.
143
144
  */
144
145
  renderingTypePredictor?: IRenderingTypePredictor;
145
- /**
146
- * Prevent direct access to storage in request handlers (only allow using context helpers).
147
- * Defaults to `true`
148
- */
149
- preventDirectStorageAccess?: boolean;
150
146
  }
151
147
  /**
152
148
  * An extension of {@link PlaywrightCrawler} that uses a more limited request handler interface so that it is able to switch to HTTP-only crawling when it detects it may be possible.
@@ -178,20 +174,10 @@ export interface AdaptivePlaywrightCrawlerOptions<ContextExtension = Dictionary<
178
174
  * @experimental
179
175
  */
180
176
  export declare class AdaptivePlaywrightCrawler<ContextExtension = Dictionary<never>, ExtendedContext extends AdaptivePlaywrightCrawlerContext = AdaptivePlaywrightCrawlerContext & ContextExtension, Routes extends Record<keyof Routes, Dictionary> = Record<string, GetUserDataFromRequest<AdaptivePlaywrightCrawlerContext['request']>>> extends BasicCrawler<AdaptivePlaywrightCrawlerContext, ContextExtension, ExtendedContext, Routes> {
181
- private renderingTypePredictor;
182
- private resultChecker;
183
- private shouldPropagateError;
184
- private resultComparator;
185
- private preventDirectStorageAccess;
186
- private staticContextPipeline;
187
- private browserContextPipeline;
188
- private individualRequestHandlerTimeoutMillis;
177
+ #private;
189
178
  get stats(): AdaptivePlaywrightCrawlerStatistics;
190
- private resultObjects;
191
- private inFlightRenderingTypeDetections;
192
- private teardownHooks;
193
179
  constructor(options?: AdaptivePlaywrightCrawlerOptions<ContextExtension, ExtendedContext, Routes>);
194
- protected _init(): Promise<void>;
180
+ protected init(): Promise<void>;
195
181
  protected buildContextPipeline(): ContextPipeline<CrawlingContext<Dictionary>, CrawlingContext<Dictionary> & {
196
182
  readonly request: LoadedRequest<Request<Dictionary>>;
197
183
  readonly response: Response;
@@ -203,16 +189,14 @@ export declare class AdaptivePlaywrightCrawler<ContextExtension = Dictionary<nev
203
189
  }>;
204
190
  private adaptCheerioContext;
205
191
  private adaptPlaywrightContext;
206
- private crawlOne;
207
- protected runRequestHandler(crawlingContext: CrawlingContext): Promise<void>;
208
- private commitResult;
209
- private allowStorageAccess;
210
192
  /**
211
- * Reading the pending request count queries the underlying request manager, which counts as storage access.
212
- * Since this is internal crawler bookkeeping used to compute the `enqueueLinks` limit (not user-initiated storage
213
- * access), it must be allowed even while a request handler runs inside the storage-access guard.
193
+ * Runs one request handler attempt inside its own {@link StorageTransaction}, wrapping the inner
194
+ * (static or browser) context pipeline. The transaction is pushed to `transactions` *at creation
195
+ * time, before the `try`* - the `ok: false` branch of the returned {@link Result} carries no
196
+ * result, and failed attempts are routine here. The caller owns the outcome and disposal.
214
197
  */
215
- protected getPendingRequestCountApproximation(): Promise<number>;
198
+ private crawlOne;
199
+ protected runRequestHandler(crawlingContext: CrawlingContext): Promise<void>;
216
200
  private enqueueLinks;
217
201
  private createLogProxy;
218
202
  teardown(): Promise<void>;
@@ -243,5 +227,5 @@ export declare function createAdaptivePlaywrightRouter<Context extends AdaptiveP
243
227
  * });
244
228
  * ```
245
229
  */
246
- export declare function fullResultComparator(resultA: RequestHandlerResult, resultB: RequestHandlerResult): boolean;
230
+ export declare function fullResultComparator(resultA: StorageTransactionView, resultB: StorageTransactionView): boolean;
247
231
  export {};
@@ -2,8 +2,9 @@ import { isDeepStrictEqual } from 'node:util';
2
2
  import { BasicCrawler } from '@crawlee/basic';
3
3
  import { extractUrlsFromPage } from '@crawlee/browser';
4
4
  import { CheerioCrawler } from '@crawlee/cheerio';
5
- import { OwnedOrInjected, RequestHandlerError, RequestHandlerResult, resolveBaseUrlForEnqueueLinksFiltering, Router, serviceLocator, Statistics, withCheckedStorageAccess, } from '@crawlee/core';
5
+ import { createStorageTransaction, OwnedOrInjected, RequestHandlerError, resolveBaseUrlForEnqueueLinksFiltering, Router, Statistics, } from '@crawlee/core';
6
6
  import { extractUrlsFromCheerio } from '@crawlee/utils';
7
+ import ow from 'ow';
7
8
  import { addTimeoutToPromise } from '@apify/timeout';
8
9
  import { PlaywrightCrawler } from './playwright-crawler.js';
9
10
  import { RenderingTypePredictor, } from './utils/rendering-type-prediction.js';
@@ -82,23 +83,35 @@ const proxyLogMethods = [
82
83
  * @experimental
83
84
  */
84
85
  export class AdaptivePlaywrightCrawler extends BasicCrawler {
85
- renderingTypePredictor;
86
- resultChecker;
87
- shouldPropagateError;
88
- resultComparator;
89
- preventDirectStorageAccess;
90
- staticContextPipeline;
91
- browserContextPipeline;
92
- individualRequestHandlerTimeoutMillis;
86
+ #renderingTypePredictor;
87
+ #resultChecker;
88
+ #shouldPropagateError;
89
+ #resultComparator;
90
+ #staticContextPipeline;
91
+ #browserContextPipeline;
92
+ #individualRequestHandlerTimeoutMillis;
93
93
  // The constructor always injects an `AdaptivePlaywrightCrawlerStatistics`, so narrowing the cast is sound.
94
94
  get stats() {
95
95
  return super.stats;
96
96
  }
97
- resultObjects = new WeakMap();
98
- inFlightRenderingTypeDetections = 0;
99
- teardownHooks = [];
97
+ /**
98
+ * The write policy of the per-attempt transactions. Defaults the request queue to `deferred`:
99
+ * a discarded attempt's enqueues must never reach the queue.
100
+ */
101
+ #attemptWritePolicy;
102
+ #teardownHooks = [];
100
103
  constructor(options = {}) {
101
- const { requestHandler, renderingTypeDetectionRatio = 0.1, renderingTypePredictor, resultChecker, shouldPropagateError, resultComparator, statistics, preventDirectStorageAccess = true, requestHandlerTimeoutSecs = 60, errorHandler, failedRequestHandler, preNavigationHooks = [], postNavigationHooks = [], extendContext, contextPipelineBuilder, ...rest } = options;
104
+ const { requestHandler, renderingTypeDetectionRatio = 0.1, renderingTypePredictor, resultChecker, shouldPropagateError, resultComparator, statistics, requestHandlerTimeoutSecs = 60, errorHandler, failedRequestHandler, preNavigationHooks = [], postNavigationHooks = [], extendContext, contextPipelineBuilder, transactionalStorage, ...rest } = options;
105
+ // The user's value is replaced by `false` in the `super` call below — validate it separately.
106
+ ow(transactionalStorage, 'transactionalStorage', BasicCrawler.optionsShape.transactionalStorage);
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
+ }
102
115
  if (statistics !== undefined && !(statistics instanceof AdaptivePlaywrightCrawlerStatistics)) {
103
116
  throw new Error('AdaptivePlaywrightCrawler tracks extra fields on its own Statistics subclass and cannot use a ' +
104
117
  'plain `statistics` instance. Omit the option to let the crawler build its own.');
@@ -115,21 +128,29 @@ export class AdaptivePlaywrightCrawler extends BasicCrawler {
115
128
  logMessage: `${AdaptivePlaywrightCrawler.name} request statistics:`,
116
129
  }),
117
130
  contextPipelineBuilder: contextPipelineBuilder ?? (() => this.buildContextPipeline()),
131
+ // The base crawler must not wrap requests in a transaction of its own - this crawler opens
132
+ // one per request handler attempt in `crawlOne` instead, forwarding the write policy of the
133
+ // user-facing option (validated above) to those.
134
+ transactionalStorage: false,
118
135
  });
119
- this.individualRequestHandlerTimeoutMillis = requestHandlerTimeoutSecs * 1000;
136
+ this.#individualRequestHandlerTimeoutMillis = requestHandlerTimeoutSecs * 1000;
120
137
  // `renderingTypeDetectionRatio` only configures the default predictor - an injected one brings its own
121
138
  // detection ratio (and its own state), so the option is ignored in that case.
122
- this.renderingTypePredictor = OwnedOrInjected.resolve(renderingTypePredictor, () => new RenderingTypePredictor({ detectionRatio: renderingTypeDetectionRatio }));
123
- this.resultChecker = resultChecker ?? (() => true);
124
- this.shouldPropagateError = shouldPropagateError ?? (() => false);
139
+ this.#renderingTypePredictor = OwnedOrInjected.resolve(renderingTypePredictor, () => new RenderingTypePredictor({ detectionRatio: renderingTypeDetectionRatio }));
140
+ this.#attemptWritePolicy = {
141
+ requestQueue: 'deferred',
142
+ ...(typeof transactionalStorage === 'object' ? transactionalStorage : {}),
143
+ };
144
+ this.#resultChecker = resultChecker ?? (() => true);
145
+ this.#shouldPropagateError = shouldPropagateError ?? (() => false);
125
146
  if (resultComparator !== undefined) {
126
- this.resultComparator = resultComparator;
147
+ this.#resultComparator = resultComparator;
127
148
  }
128
149
  else if (resultChecker !== undefined) {
129
- this.resultComparator = (resultA, resultB) => this.resultChecker(resultA) && this.resultChecker(resultB);
150
+ this.#resultComparator = (resultA, resultB) => this.#resultChecker(resultA) && this.#resultChecker(resultB);
130
151
  }
131
152
  else {
132
- this.resultComparator = (resultA, resultB) => {
153
+ this.#resultComparator = (resultA, resultB) => {
133
154
  return (resultA.datasetItems.length === resultB.datasetItems.length &&
134
155
  resultA.datasetItems.every((itemA, i) => {
135
156
  const itemB = resultB.datasetItems[i];
@@ -159,20 +180,19 @@ export class AdaptivePlaywrightCrawler extends BasicCrawler {
159
180
  postNavigationHooks: postNavigationHooks,
160
181
  extendContext,
161
182
  });
162
- this.teardownHooks.push(browserCrawler.teardown.bind(browserCrawler));
163
- this.staticContextPipeline = staticCrawler.contextPipeline.compose({
183
+ this.#teardownHooks.push(browserCrawler.teardown.bind(browserCrawler));
184
+ this.#staticContextPipeline = staticCrawler.contextPipeline.compose({
164
185
  action: this.adaptCheerioContext.bind(this),
165
186
  });
166
- this.browserContextPipeline = browserCrawler.contextPipeline.compose({
187
+ this.#browserContextPipeline = browserCrawler.contextPipeline.compose({
167
188
  action: this.adaptPlaywrightContext.bind(this),
168
189
  });
169
- this.preventDirectStorageAccess = preventDirectStorageAccess;
170
190
  }
171
- async _init() {
191
+ async init() {
172
192
  // Only the predictor we built ourselves is ours to initialize - an injected one is borrowed, so its
173
193
  // lifecycle (including restoring persisted state) stays with whoever created it.
174
- await this.renderingTypePredictor.ifOwned((predictor) => predictor.initialize());
175
- return await super._init();
194
+ await this.#renderingTypePredictor.ifOwned((predictor) => predictor.initialize());
195
+ return await super.init();
176
196
  }
177
197
  buildContextPipeline() {
178
198
  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`;
@@ -203,11 +223,6 @@ export class AdaptivePlaywrightCrawler extends BasicCrawler {
203
223
  });
204
224
  }
205
225
  async adaptCheerioContext(cheerioContext) {
206
- // Capture the original response to avoid infinite recursion when the getter is copied to the context
207
- const result = this.resultObjects.get(cheerioContext);
208
- if (result === undefined) {
209
- throw new Error('Logical error - `this.resultObjects` does not contain the result object');
210
- }
211
226
  return {
212
227
  get page() {
213
228
  throw new Error('Page object was used in HTTP-only request handler');
@@ -221,17 +236,14 @@ export class AdaptivePlaywrightCrawler extends BasicCrawler {
221
236
  enqueueLinks: async (options = {}) => {
222
237
  const urls = options.urls ??
223
238
  extractUrlsFromCheerio(cheerioContext.$, options.selector, options.baseUrl ?? cheerioContext.request.loadedUrl);
224
- return (await this.enqueueLinks({ ...options, urls }, cheerioContext.request, result));
239
+ return (await this.enqueueLinks({ ...options, urls }, cheerioContext.request));
225
240
  },
226
241
  response: cheerioContext.response,
227
242
  };
228
243
  }
229
244
  async adaptPlaywrightContext(playwrightContext) {
245
+ // Capture the original response to avoid infinite recursion when the getter is copied to the context
230
246
  const originalResponse = playwrightContext.response;
231
- const result = this.resultObjects.get(playwrightContext);
232
- if (result === undefined) {
233
- throw new Error('Logical error - `this.resultObjects` does not contain the result object');
234
- }
235
247
  return {
236
248
  response: new Response(Uint8Array.from(await originalResponse.body()), {
237
249
  headers: originalResponse.headers(),
@@ -264,48 +276,50 @@ export class AdaptivePlaywrightCrawler extends BasicCrawler {
264
276
  else {
265
277
  urls = options.urls;
266
278
  }
267
- return (await this.enqueueLinks({ ...options, urls }, playwrightContext.request, result));
279
+ return (await this.enqueueLinks({ ...options, urls }, playwrightContext.request));
268
280
  },
269
281
  };
270
282
  }
271
- async crawlOne(renderingType, context, useStateFunction) {
272
- const result = new RequestHandlerResult(serviceLocator.getConfiguration(), AdaptivePlaywrightCrawler.CRAWLEE_STATE_KEY);
283
+ /**
284
+ * Runs one request handler attempt inside its own {@link StorageTransaction}, wrapping the inner
285
+ * (static or browser) context pipeline. The transaction is pushed to `transactions` *at creation
286
+ * time, before the `try`* - the `ok: false` branch of the returned {@link Result} carries no
287
+ * result, and failed attempts are routine here. The caller owns the outcome and disposal.
288
+ */
289
+ async crawlOne(renderingType, context, useStateFunction, transactions) {
290
+ const transaction = createStorageTransaction({
291
+ policy: this.#attemptWritePolicy,
292
+ commitTimeoutMillis: this.internalTimeoutMillis,
293
+ });
294
+ transactions.push(transaction);
273
295
  const logs = [];
274
296
  const deferredCleanup = [];
275
- const resultBoundContextHelpers = {
276
- addRequests: result.addRequests,
277
- pushData: result.pushData,
278
- useState: this.allowStorageAccess(useStateFunction),
279
- getKeyValueStore: this.allowStorageAccess(result.getKeyValueStore),
297
+ const attemptBoundContextHelpers = {
298
+ useState: useStateFunction,
280
299
  log: this.createLogProxy(context.log, logs),
281
300
  registerDeferredCleanup: (cleanup) => deferredCleanup.push(cleanup),
282
301
  };
283
302
  const subCrawlerContext = Object.defineProperties({}, Object.getOwnPropertyDescriptors(context));
284
- // Mark result-bound helpers as non-configurable so they survive the sub-crawler context pipeline
285
- // (which would otherwise override them with the sub-crawler's own versions, losing the result binding).
286
- for (const [key, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(resultBoundContextHelpers))) {
303
+ // Mark attempt-bound helpers as non-configurable so they survive the sub-crawler context pipeline
304
+ // (which would otherwise override them with the sub-crawler's own versions, losing the binding).
305
+ for (const [key, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(attemptBoundContextHelpers))) {
287
306
  Object.defineProperty(subCrawlerContext, key, { ...descriptor, configurable: false });
288
307
  }
289
- this.resultObjects.set(subCrawlerContext, result);
290
308
  try {
291
309
  const callAdaptiveRequestHandler = async () => {
292
310
  if (renderingType === 'static') {
293
- await this.staticContextPipeline.call(subCrawlerContext, this.requestHandler.bind(this));
311
+ await this.#staticContextPipeline.call(subCrawlerContext, this.requestHandler.bind(this));
294
312
  }
295
313
  else if (renderingType === 'clientOnly') {
296
- await this.browserContextPipeline.call(subCrawlerContext, this.requestHandler.bind(this));
314
+ await this.#browserContextPipeline.call(subCrawlerContext, this.requestHandler.bind(this));
297
315
  }
298
316
  };
299
317
  // this crawler overrides `runRequestHandler` and times each rendering-type run itself, so it has
300
318
  // to resolve any per-route override too - otherwise routes would be silently ignored here
301
319
  const routeTimeoutSecs = this.requestHandler.getTimeoutSecs?.(context.request.label);
302
- const timeoutMillis = routeTimeoutSecs === undefined ? this.individualRequestHandlerTimeoutMillis : routeTimeoutSecs * 1000;
303
- await addTimeoutToPromise(async () => withCheckedStorageAccess(() => {
304
- if (this.preventDirectStorageAccess) {
305
- throw new Error('Directly accessing storage in a request handler is not allowed in AdaptivePlaywrightCrawler');
306
- }
307
- }, callAdaptiveRequestHandler), timeoutMillis, 'Request handler timed out');
308
- return { result, ok: true, logs };
320
+ const timeoutMillis = routeTimeoutSecs === undefined ? this.#individualRequestHandlerTimeoutMillis : routeTimeoutSecs * 1000;
321
+ await addTimeoutToPromise(async () => transaction.run(callAdaptiveRequestHandler), timeoutMillis, 'Request handler timed out');
322
+ return { result: transaction, ok: true, logs };
309
323
  }
310
324
  catch (error) {
311
325
  return { error, ok: false, logs };
@@ -315,23 +329,24 @@ export class AdaptivePlaywrightCrawler extends BasicCrawler {
315
329
  }
316
330
  }
317
331
  async runRequestHandler(crawlingContext) {
318
- const renderingTypePrediction = this.renderingTypePredictor.value.predict(crawlingContext.request);
332
+ const renderingTypePrediction = this.#renderingTypePredictor.value.predict(crawlingContext.request);
319
333
  const shouldDetectRenderingType = Math.random() < renderingTypePrediction.detectionProbabilityRecommendation;
320
334
  if (!shouldDetectRenderingType) {
321
335
  crawlingContext.log.debug(`Predicted rendering type ${renderingTypePrediction.renderingType} for ${crawlingContext.request.url}`);
322
336
  }
323
- if (shouldDetectRenderingType) {
324
- this.inFlightRenderingTypeDetections += 1;
325
- }
337
+ // Every transaction created for this request - up to two, since the static-then-browser
338
+ // fall-through and the browser-then-detection pair are mutually exclusive. Disposed in the
339
+ // `finally` below, not earlier: the comparators read the journals after `crawlOne` returns.
340
+ const transactions = [];
326
341
  try {
327
342
  if (renderingTypePrediction.renderingType === 'static' && !shouldDetectRenderingType) {
328
343
  crawlingContext.log.debug(`Running HTTP-only request handler for ${crawlingContext.request.url}`);
329
344
  this.stats.trackHttpOnlyRequestHandlerRun();
330
- const plainHTTPRun = await this.crawlOne('static', crawlingContext, crawlingContext.useState);
331
- if (plainHTTPRun.ok && this.resultChecker(plainHTTPRun.result)) {
345
+ const plainHTTPRun = await this.crawlOne('static', crawlingContext, crawlingContext.useState, transactions);
346
+ if (plainHTTPRun.ok && this.#resultChecker(plainHTTPRun.result)) {
332
347
  crawlingContext.log.debug(`HTTP-only request handler succeeded for ${crawlingContext.request.url}`);
333
348
  plainHTTPRun.logs?.forEach(([log, method, ...args]) => log[method](...args));
334
- await this.commitResult(crawlingContext, plainHTTPRun.result);
349
+ await plainHTTPRun.result.commit();
335
350
  return;
336
351
  }
337
352
  // Execution will "fall through" and try running the request handler in a browser
@@ -339,7 +354,7 @@ export class AdaptivePlaywrightCrawler extends BasicCrawler {
339
354
  const actualError = plainHTTPRun.error instanceof RequestHandlerError
340
355
  ? plainHTTPRun.error.cause
341
356
  : plainHTTPRun.error;
342
- if (await this.shouldPropagateError(actualError, crawlingContext)) {
357
+ if (await this.#shouldPropagateError(actualError, crawlingContext)) {
343
358
  throw actualError;
344
359
  }
345
360
  crawlingContext.log.exception(actualError, `HTTP-only request handler failed for ${crawlingContext.request.url}`);
@@ -371,20 +386,22 @@ export class AdaptivePlaywrightCrawler extends BasicCrawler {
371
386
  return this.stateCopy;
372
387
  },
373
388
  };
374
- const browserRun = await this.crawlOne('clientOnly', crawlingContext, stateTracker.getLiveState.bind(stateTracker));
389
+ const browserRun = await this.crawlOne('clientOnly', crawlingContext, stateTracker.getLiveState.bind(stateTracker), transactions);
375
390
  if (!browserRun.ok) {
376
391
  throw browserRun.error;
377
392
  }
378
393
  browserRun.logs?.forEach(([log, method, ...args]) => log[method](...args));
379
- await this.commitResult(crawlingContext, browserRun.result);
394
+ await browserRun.result.commit();
380
395
  if (shouldDetectRenderingType) {
381
396
  crawlingContext.log.debug(`Detecting rendering type for ${crawlingContext.request.url}`);
382
- const plainHTTPRun = await this.crawlOne('static', crawlingContext, stateTracker.getStateCopy.bind(stateTracker));
397
+ // The detection attempt's transaction is never committed - its writes exist only for the
398
+ // result comparison.
399
+ const plainHTTPRun = await this.crawlOne('static', crawlingContext, stateTracker.getStateCopy.bind(stateTracker), transactions);
383
400
  const detectionResult = (() => {
384
401
  if (!plainHTTPRun.ok) {
385
402
  return 'clientOnly';
386
403
  }
387
- const comparisonResult = this.resultComparator(plainHTTPRun.result, browserRun.result);
404
+ const comparisonResult = this.#resultComparator(plainHTTPRun.result, browserRun.result);
388
405
  if (comparisonResult === true || comparisonResult === 'equal') {
389
406
  return 'static';
390
407
  }
@@ -395,60 +412,28 @@ export class AdaptivePlaywrightCrawler extends BasicCrawler {
395
412
  })();
396
413
  crawlingContext.log.debug(`Detected rendering type ${detectionResult} for ${crawlingContext.request.url}`);
397
414
  if (detectionResult !== undefined) {
398
- this.renderingTypePredictor.value.storeResult(crawlingContext.request, detectionResult);
415
+ this.#renderingTypePredictor.value.storeResult(crawlingContext.request, detectionResult);
399
416
  }
400
417
  }
401
418
  }
402
419
  finally {
403
- if (shouldDetectRenderingType) {
404
- this.inFlightRenderingTypeDetections -= 1;
420
+ // A still-open transaction here belongs to a discarded attempt - roll it back, then release.
421
+ for (const transaction of transactions) {
422
+ transaction.rollback();
423
+ transaction.dispose();
405
424
  }
406
425
  }
407
426
  }
408
- async commitResult(crawlingContext, { calls, keyValueStoreChanges }) {
409
- await Promise.all([
410
- ...calls.pushData.map(async (params) => crawlingContext.pushData(...params)),
411
- ...calls.addRequests.map(async (params) => crawlingContext.addRequests(...params)),
412
- ...Object.entries(keyValueStoreChanges).map(async ([storeIdOrName, changes]) => {
413
- const store = await crawlingContext.getKeyValueStore({ id: storeIdOrName });
414
- await Promise.all(Object.entries(changes).map(async ([key, { changedValue, options }]) => store.setValue(key, changedValue, options)));
415
- }),
416
- ]);
417
- }
418
- allowStorageAccess(func) {
419
- return async (...args) => withCheckedStorageAccess(() => { }, async () => func(...args));
420
- }
421
- /**
422
- * Reading the pending request count queries the underlying request manager, which counts as storage access.
423
- * Since this is internal crawler bookkeeping used to compute the `enqueueLinks` limit (not user-initiated storage
424
- * access), it must be allowed even while a request handler runs inside the storage-access guard.
425
- */
426
- async getPendingRequestCountApproximation() {
427
- return this.allowStorageAccess(() => super.getPendingRequestCountApproximation())();
428
- }
429
- async enqueueLinks(options, request, result) {
427
+ async enqueueLinks(options, request) {
430
428
  const baseUrl = resolveBaseUrlForEnqueueLinksFiltering({
431
429
  enqueueStrategy: options?.strategy,
432
430
  finalRequestUrl: request.loadedUrl,
433
431
  originalRequestUrl: request.url,
434
432
  userProvidedBaseUrl: options?.baseUrl,
435
433
  });
436
- const addRequestsBatched = async (requests) => {
437
- await result.addRequests(requests);
438
- return {
439
- addedRequests: requests.map(({ uniqueKey, id }) => ({
440
- uniqueKey,
441
- requestId: id ?? '',
442
- wasAlreadyPresent: false,
443
- wasAlreadyHandled: false,
444
- })),
445
- waitForAllRequestsToBeAdded: Promise.resolve([]),
446
- requestsOverLimit: [],
447
- };
448
- };
449
- // We need to use a mock request queue implementation, in order to add the requests into our result object
450
- const mockRequestQueue = { addRequestsBatched };
451
- return await this.enqueueLinksWithCrawlDepth({ ...options, baseUrl }, request, mockRequestQueue);
434
+ // The per-attempt transaction buffers these (the queue policy defaults to `deferred` here),
435
+ // so a discarded attempt's enqueues never reach the queue.
436
+ return await this.enqueueLinksWithCrawlDepth({ ...options, baseUrl }, request, await this.getRequestManager());
452
437
  }
453
438
  createLogProxy(log, logs) {
454
439
  return new Proxy(log, {
@@ -464,7 +449,7 @@ export class AdaptivePlaywrightCrawler extends BasicCrawler {
464
449
  }
465
450
  async teardown() {
466
451
  await super.teardown();
467
- for (const hook of this.teardownHooks) {
452
+ for (const hook of this.#teardownHooks) {
468
453
  await hook();
469
454
  }
470
455
  }
@@ -498,6 +483,5 @@ export function createAdaptivePlaywrightRouter(routesOrSchemas) {
498
483
  export function fullResultComparator(resultA, resultB) {
499
484
  return (isDeepStrictEqual(resultA.datasetItems, resultB.datasetItems) &&
500
485
  isDeepStrictEqual(resultA.enqueuedUrls, resultB.enqueuedUrls) &&
501
- isDeepStrictEqual(resultA.enqueuedUrlLists, resultB.enqueuedUrlLists) &&
502
486
  isDeepStrictEqual(resultA.keyValueStoreChanges, resultB.keyValueStoreChanges));
503
487
  }
@@ -215,6 +215,8 @@ export declare class PlaywrightCrawler<ContextExtension = Dictionary<never>, Ext
215
215
  retryOnBlocked: import("ow").BooleanPredicate & import("ow").BasePredicate<boolean | undefined>;
216
216
  // @ts-ignore optional peer dependency or compatibility with es2022
217
217
  respectRobotsTxtFile: import("ow").AnyPredicate<boolean | object>;
218
+ // @ts-ignore optional peer dependency or compatibility with es2022
219
+ transactionalStorage: import("ow").BasePredicate<boolean | Partial<import("@crawlee/browser").StorageWritePolicy> | undefined>;
218
220
  // @ts-ignore optional peer dependency or compatibility with es2022
219
221
  onSkippedRequest: import("ow").Predicate<Function> & import("ow").BasePredicate<Function | undefined>;
220
222
  // @ts-ignore optional peer dependency or compatibility with es2022
@@ -262,7 +264,7 @@ export declare class PlaywrightCrawler<ContextExtension = Dictionary<never>, Ext
262
264
  closeCookieModals: () => Promise<void>;
263
265
  handleCloudflareChallenge: (options?: HandleCloudflareChallengeOptions) => Promise<Response | undefined>;
264
266
  }>;
265
- protected _navigationHandler(crawlingContext: PlaywrightCrawlingContext, gotoOptions: DirectNavigationOptions): Promise<Response | null>;
267
+ protected navigationHandler(crawlingContext: PlaywrightCrawlingContext, gotoOptions: DirectNavigationOptions): Promise<Response | null>;
266
268
  private enhanceContext;
267
269
  }
268
270
  /**
@@ -109,7 +109,7 @@ export class PlaywrightCrawler extends BrowserCrawler {
109
109
  buildContextPipeline() {
110
110
  return super.buildContextPipeline().compose({ action: this.enhanceContext.bind(this) });
111
111
  }
112
- async _navigationHandler(crawlingContext, gotoOptions) {
112
+ async navigationHandler(crawlingContext, gotoOptions) {
113
113
  return gotoExtended(crawlingContext.page, crawlingContext.request, gotoOptions);
114
114
  }
115
115
  async enhanceContext(context) {
@@ -26,7 +26,7 @@ export interface IRenderingTypePredictor {
26
26
  * @experimental
27
27
  */
28
28
  export declare class RenderingTypePredictor implements IRenderingTypePredictor {
29
- private detectionRatio;
29
+ #private;
30
30
  private state;
31
31
  constructor({ detectionRatio, persistenceOptions }: RenderingTypePredictorOptions);
32
32
  /**
@@ -30,10 +30,11 @@ const mean = (values) => (values.length > 0 ? sum(values) / values.length : unde
30
30
  * @experimental
31
31
  */
32
32
  export class RenderingTypePredictor {
33
- detectionRatio;
33
+ #detectionRatio;
34
+ // kept as TS-private: tests reach for it at runtime
34
35
  state;
35
36
  constructor({ detectionRatio, persistenceOptions }) {
36
- this.detectionRatio = detectionRatio;
37
+ this.#detectionRatio = detectionRatio;
37
38
  this.state = new RecoverableState({
38
39
  defaultState: {
39
40
  logreg: new LogisticRegression({ numSteps: 1000, learningRate: 0.05 }),
@@ -86,7 +87,7 @@ export class RenderingTypePredictor {
86
87
  renderingType: prediction === 1 ? 'static' : 'clientOnly',
87
88
  detectionProbabilityRecommendation: Math.abs(scores[0] - scores[1]) < 0.1
88
89
  ? 1
89
- : this.detectionRatio * Math.max(1, 5 - this.resultCount(label)),
90
+ : this.#detectionRatio * Math.max(1, 5 - this.resultCount(label)),
90
91
  };
91
92
  }
92
93
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crawlee/playwright",
3
- "version": "4.0.0-beta.104",
3
+ "version": "4.0.0-beta.106",
4
4
  "description": "The scalable web crawling and scraping library for JavaScript/Node.js. Enables development of data extraction and web automation jobs (not only) with headless Chrome and Puppeteer.",
5
5
  "engines": {
6
6
  "node": ">=22.0.0"
@@ -49,15 +49,14 @@
49
49
  "dependencies": {
50
50
  "@apify/datastructures": "^2.0.3",
51
51
  "@apify/timeout": "^0.4.4",
52
- "@crawlee/basic": "4.0.0-beta.104",
53
- "@crawlee/browser": "4.0.0-beta.104",
54
- "@crawlee/browser-pool": "4.0.0-beta.104",
55
- "@crawlee/cheerio": "4.0.0-beta.104",
56
- "@crawlee/core": "4.0.0-beta.104",
57
- "@crawlee/types": "4.0.0-beta.104",
58
- "@crawlee/utils": "4.0.0-beta.104",
52
+ "@crawlee/basic": "4.0.0-beta.106",
53
+ "@crawlee/browser": "4.0.0-beta.106",
54
+ "@crawlee/browser-pool": "4.0.0-beta.106",
55
+ "@crawlee/cheerio": "4.0.0-beta.106",
56
+ "@crawlee/core": "4.0.0-beta.106",
57
+ "@crawlee/types": "4.0.0-beta.106",
58
+ "@crawlee/utils": "4.0.0-beta.106",
59
59
  "cheerio": "^1.0.0",
60
- "idcac-playwright": "^0.1.3",
61
60
  "jquery": "^3.7.1",
62
61
  "ml-logistic-regression": "^2.0.0",
63
62
  "ml-matrix": "^6.12.1",
@@ -85,5 +84,5 @@
85
84
  }
86
85
  }
87
86
  },
88
- "gitHead": "1c761e689e56ac06b67fc4f1dc091f693e024d12"
87
+ "gitHead": "c622f1fc65e65221ea245817c58ecc0ffb4a5cb0"
89
88
  }