@crawlee/playwright 4.0.0-beta.99 → 4.0.0-rc.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,46 +2,28 @@ 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';
6
- import { extractUrlsFromCheerio } from '@crawlee/utils';
5
+ import { createStorageTransaction, EnqueueStrategy, OwnedOrInjected, parseArgument, RequestHandlerError, resolveBaseUrlForEnqueueLinksFiltering, Router, Statistics, } from '@crawlee/core';
6
+ import { extractUrlsFromCheerio } from '@crawlee/utils/internal';
7
+ import { z } from 'zod';
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';
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
- }
11
+ const adaptiveStatisticStateSchema = z.object({
12
+ /** How many requests were handled by the HTTP-only request handler. */
13
+ httpOnlyRequestHandlerRuns: z.number().default(0),
14
+ /** How many requests were handled in a browser. */
15
+ browserRequestHandlerRuns: z.number().default(0),
16
+ /** How many times the HTTP-only handler produced a result the `resultChecker` rejected. */
17
+ renderingTypeMispredictions: z.number().default(0),
18
+ });
19
+ /**
20
+ * The {@link AdaptivePlaywrightCrawlerStatisticState} fields as a {@link Statistics} state extension, defaults
21
+ * and all. A {@link Statistics} instance to be injected into an {@link AdaptivePlaywrightCrawler} has to carry
22
+ * them - `deserialize.extend()` your own fields onto this one and pass the result as `stateExtension`.
23
+ */
24
+ export const adaptivePlaywrightCrawlerStatisticState = {
25
+ deserialize: adaptiveStatisticStateSchema,
26
+ };
45
27
  const proxyLogMethods = [
46
28
  'error',
47
29
  'exception',
@@ -82,41 +64,80 @@ const proxyLogMethods = [
82
64
  * @experimental
83
65
  */
84
66
  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 = [];
67
+ #renderingTypePredictor;
68
+ #resultChecker;
69
+ #shouldPropagateError;
70
+ #resultComparator;
71
+ #staticContextPipeline;
72
+ #browserContextPipeline;
73
+ #individualRequestHandlerTimeoutMillis;
74
+ /**
75
+ * The write policy of the per-attempt transactions. Defaults the request queue to `deferred`:
76
+ * a discarded attempt's enqueues must never reach the queue.
77
+ */
78
+ #attemptWritePolicy;
79
+ #teardownHooks = [];
96
80
  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;
81
+ const { requestHandler, renderingTypeDetectionRatio = 0.1, renderingTypePredictor, resultChecker, shouldPropagateError, resultComparator, statistics, requestHandlerTimeoutSecs = 60, errorHandler, failedRequestHandler, preNavigationHooks = [], postNavigationHooks = [], extendContext, contextPipelineBuilder, transactionalStorage, ...rest } = options;
82
+ // The user's value is replaced by `false` in the `super` call below — validate it separately,
83
+ // wrapped in an object so the error still names the field.
84
+ parseArgument({ transactionalStorage }, z.object({ transactionalStorage: BasicCrawler.optionsShape.transactionalStorage }), 'AdaptivePlaywrightCrawlerOptions');
85
+ // The extra fields are only tracked if the injected instance was built with them - the types enforce that,
86
+ // but plain JS callers would otherwise silently increment `undefined` into a sticky `NaN`. Extend
87
+ // `adaptivePlaywrightCrawlerStatisticState` to satisfy this.
88
+ if (statistics !== undefined) {
89
+ parseArgument(statistics.state, z.object({
90
+ httpOnlyRequestHandlerRuns: z.number(),
91
+ browserRequestHandlerRuns: z.number(),
92
+ renderingTypeMispredictions: z.number(),
93
+ }), 'statistics.state');
94
+ }
95
+ // Per-attempt buffering is load-bearing here: the handler runs up to twice per request and the
96
+ // losing attempt's writes must be discardable.
97
+ if (transactionalStorage === false) {
98
+ throw new Error('AdaptivePlaywrightCrawler requires transactional storage - it runs the request handler ' +
99
+ 'multiple times per request and must be able to discard the storage writes of losing ' +
100
+ 'attempts. `transactionalStorage: false` is therefore not supported; a write policy ' +
101
+ 'object is accepted and forwarded to the per-attempt transactions.');
102
+ }
98
103
  super({
99
104
  ...rest,
100
105
  errorHandler,
101
106
  failedRequestHandler,
102
107
  requestHandler,
103
108
  requestHandlerTimeoutSecs,
109
+ // The base would build a `Statistics` without the adaptive fields, so provide a default that has them.
110
+ // The cast covers a `StatisticStateExtension` that adds further fields - those can only come from an
111
+ // injected instance, in which case this default is never built.
112
+ statistics: statistics ??
113
+ new Statistics({
114
+ logMessage: `${AdaptivePlaywrightCrawler.name} request statistics:`,
115
+ stateExtension: adaptivePlaywrightCrawlerStatisticState,
116
+ }),
104
117
  contextPipelineBuilder: contextPipelineBuilder ?? (() => this.buildContextPipeline()),
118
+ // The base crawler must not wrap requests in a transaction of its own - this crawler opens
119
+ // one per request handler attempt in `crawlOne` instead, forwarding the write policy of the
120
+ // user-facing option (validated above) to those.
121
+ transactionalStorage: false,
105
122
  });
106
- this.individualRequestHandlerTimeoutMillis = requestHandlerTimeoutSecs * 1000;
123
+ this.#individualRequestHandlerTimeoutMillis = requestHandlerTimeoutSecs * 1000;
107
124
  // `renderingTypeDetectionRatio` only configures the default predictor - an injected one brings its own
108
125
  // 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);
126
+ this.#renderingTypePredictor = OwnedOrInjected.resolve(renderingTypePredictor, () => new RenderingTypePredictor({ detectionRatio: renderingTypeDetectionRatio }));
127
+ this.#attemptWritePolicy = {
128
+ requestQueue: 'deferred',
129
+ ...(typeof transactionalStorage === 'object' ? transactionalStorage : {}),
130
+ };
131
+ this.#resultChecker = resultChecker ?? (() => true);
132
+ this.#shouldPropagateError = shouldPropagateError ?? (() => false);
112
133
  if (resultComparator !== undefined) {
113
- this.resultComparator = resultComparator;
134
+ this.#resultComparator = resultComparator;
114
135
  }
115
136
  else if (resultChecker !== undefined) {
116
- this.resultComparator = (resultA, resultB) => this.resultChecker(resultA) && this.resultChecker(resultB);
137
+ this.#resultComparator = (resultA, resultB) => this.#resultChecker(resultA) && this.#resultChecker(resultB);
117
138
  }
118
139
  else {
119
- this.resultComparator = (resultA, resultB) => {
140
+ this.#resultComparator = (resultA, resultB) => {
120
141
  return (resultA.datasetItems.length === resultB.datasetItems.length &&
121
142
  resultA.datasetItems.every((itemA, i) => {
122
143
  const itemB = resultB.datasetItems[i];
@@ -134,40 +155,31 @@ export class AdaptivePlaywrightCrawler extends BasicCrawler {
134
155
  // each hook's overrides at runtime regardless of the static type.
135
156
  const staticCrawler = new CheerioCrawler({
136
157
  ...rest,
137
- statisticsOptions: {
138
- persistenceOptions: { enable: false },
139
- },
158
+ statistics: new Statistics({ persistenceOptions: { enable: false } }),
140
159
  preNavigationHooks,
141
160
  postNavigationHooks,
142
161
  extendContext,
143
162
  });
144
163
  const browserCrawler = new PlaywrightCrawler({
145
164
  ...rest,
146
- statisticsOptions: {
147
- persistenceOptions: { enable: false },
148
- },
165
+ statistics: new Statistics({ persistenceOptions: { enable: false } }),
149
166
  preNavigationHooks: preNavigationHooks,
150
167
  postNavigationHooks: postNavigationHooks,
151
168
  extendContext,
152
169
  });
153
- this.teardownHooks.push(browserCrawler.teardown.bind(browserCrawler));
154
- this.staticContextPipeline = staticCrawler.contextPipeline.compose({
170
+ this.#teardownHooks.push(browserCrawler.teardown.bind(browserCrawler));
171
+ this.#staticContextPipeline = staticCrawler.contextPipeline.compose({
155
172
  action: this.adaptCheerioContext.bind(this),
156
173
  });
157
- this.browserContextPipeline = browserCrawler.contextPipeline.compose({
174
+ this.#browserContextPipeline = browserCrawler.contextPipeline.compose({
158
175
  action: this.adaptPlaywrightContext.bind(this),
159
176
  });
160
- this.stats = new AdaptivePlaywrightCrawlerStatistics({
161
- logMessage: `${this.log.getOptions().prefix} request statistics:`,
162
- ...statisticsOptions,
163
- });
164
- this.preventDirectStorageAccess = preventDirectStorageAccess;
165
177
  }
166
- async _init() {
178
+ async init() {
167
179
  // Only the predictor we built ourselves is ours to initialize - an injected one is borrowed, so its
168
180
  // lifecycle (including restoring persisted state) stays with whoever created it.
169
- await this.renderingTypePredictor.ifOwned((predictor) => predictor.initialize());
170
- return await super._init();
181
+ await this.#renderingTypePredictor.ifOwned((predictor) => predictor.initialize());
182
+ return await super.init();
171
183
  }
172
184
  buildContextPipeline() {
173
185
  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`;
@@ -194,15 +206,13 @@ export class AdaptivePlaywrightCrawler extends BasicCrawler {
194
206
  get parseWithCheerio() {
195
207
  throw new Error(errorMessage('parseWithCheerio'));
196
208
  },
209
+ get enqueueLinks() {
210
+ throw new Error(errorMessage('enqueueLinks'));
211
+ },
197
212
  }),
198
213
  });
199
214
  }
200
215
  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
- }
206
216
  return {
207
217
  get page() {
208
218
  throw new Error('Page object was used in HTTP-only request handler');
@@ -214,19 +224,15 @@ export class AdaptivePlaywrightCrawler extends BasicCrawler {
214
224
  return cheerioContext.$(selector);
215
225
  },
216
226
  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));
227
+ const urls = extractUrlsFromCheerio(cheerioContext.$, options.selector, options.baseUrl ?? cheerioContext.request.loadedUrl);
228
+ return (await this.enqueueLinks(urls, options, cheerioContext.request));
220
229
  },
221
230
  response: cheerioContext.response,
222
231
  };
223
232
  }
224
233
  async adaptPlaywrightContext(playwrightContext) {
234
+ // Capture the original response to avoid infinite recursion when the getter is copied to the context
225
235
  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
236
  return {
231
237
  response: new Response(Uint8Array.from(await originalResponse.body()), {
232
238
  headers: originalResponse.headers(),
@@ -247,60 +253,54 @@ export class AdaptivePlaywrightCrawler extends BasicCrawler {
247
253
  },
248
254
  enqueueLinks: async (options = {}, timeoutMs = 5000) => {
249
255
  // 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));
256
+ const selector = options.selector ?? 'a';
257
+ const locator = playwrightContext.page.locator(selector).first();
258
+ await locator.waitFor({ timeout: timeoutMs, state: 'attached' });
259
+ const urls = await extractUrlsFromPage(playwrightContext.page, selector, options.baseUrl ?? playwrightContext.request.loadedUrl);
260
+ return (await this.enqueueLinks(urls, options, playwrightContext.request));
263
261
  },
264
262
  };
265
263
  }
266
- async crawlOne(renderingType, context, useStateFunction) {
267
- const result = new RequestHandlerResult(serviceLocator.getConfiguration(), AdaptivePlaywrightCrawler.CRAWLEE_STATE_KEY);
264
+ /**
265
+ * Runs one request handler attempt inside its own {@link StorageTransaction}, wrapping the inner
266
+ * (static or browser) context pipeline. The transaction is pushed to `transactions` *at creation
267
+ * time, before the `try`* - the `ok: false` branch of the returned {@link Result} carries no
268
+ * result, and failed attempts are routine here. The caller owns the outcome and disposal.
269
+ */
270
+ async crawlOne(renderingType, context, useStateFunction, transactions) {
271
+ const transaction = createStorageTransaction({
272
+ policy: this.#attemptWritePolicy,
273
+ commitTimeoutMillis: this.internalTimeoutMillis,
274
+ });
275
+ transactions.push(transaction);
268
276
  const logs = [];
269
277
  const deferredCleanup = [];
270
- const resultBoundContextHelpers = {
271
- addRequests: result.addRequests,
272
- pushData: result.pushData,
273
- useState: this.allowStorageAccess(useStateFunction),
274
- getKeyValueStore: this.allowStorageAccess(result.getKeyValueStore),
278
+ const attemptBoundContextHelpers = {
279
+ useState: useStateFunction,
275
280
  log: this.createLogProxy(context.log, logs),
276
281
  registerDeferredCleanup: (cleanup) => deferredCleanup.push(cleanup),
277
282
  };
278
283
  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))) {
284
+ // Mark attempt-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 binding).
286
+ for (const [key, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(attemptBoundContextHelpers))) {
282
287
  Object.defineProperty(subCrawlerContext, key, { ...descriptor, configurable: false });
283
288
  }
284
- this.resultObjects.set(subCrawlerContext, result);
285
289
  try {
286
290
  const callAdaptiveRequestHandler = async () => {
287
291
  if (renderingType === 'static') {
288
- await this.staticContextPipeline.call(subCrawlerContext, this.requestHandler.bind(this));
292
+ await this.#staticContextPipeline.call(subCrawlerContext, this.requestHandler.bind(this));
289
293
  }
290
294
  else if (renderingType === 'clientOnly') {
291
- await this.browserContextPipeline.call(subCrawlerContext, this.requestHandler.bind(this));
295
+ await this.#browserContextPipeline.call(subCrawlerContext, this.requestHandler.bind(this));
292
296
  }
293
297
  };
294
298
  // this crawler overrides `runRequestHandler` and times each rendering-type run itself, so it has
295
299
  // to resolve any per-route override too - otherwise routes would be silently ignored here
296
300
  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 };
301
+ const timeoutMillis = routeTimeoutSecs === undefined ? this.#individualRequestHandlerTimeoutMillis : routeTimeoutSecs * 1000;
302
+ await addTimeoutToPromise(async () => transaction.run(callAdaptiveRequestHandler), timeoutMillis, 'Request handler timed out');
303
+ return { result: transaction, ok: true, logs };
304
304
  }
305
305
  catch (error) {
306
306
  return { error, ok: false, logs };
@@ -310,23 +310,24 @@ export class AdaptivePlaywrightCrawler extends BasicCrawler {
310
310
  }
311
311
  }
312
312
  async runRequestHandler(crawlingContext) {
313
- const renderingTypePrediction = this.renderingTypePredictor.value.predict(crawlingContext.request);
313
+ const renderingTypePrediction = this.#renderingTypePredictor.value.predict(crawlingContext.request);
314
314
  const shouldDetectRenderingType = Math.random() < renderingTypePrediction.detectionProbabilityRecommendation;
315
315
  if (!shouldDetectRenderingType) {
316
316
  crawlingContext.log.debug(`Predicted rendering type ${renderingTypePrediction.renderingType} for ${crawlingContext.request.url}`);
317
317
  }
318
- if (shouldDetectRenderingType) {
319
- this.inFlightRenderingTypeDetections += 1;
320
- }
318
+ // Every transaction created for this request - up to two, since the static-then-browser
319
+ // fall-through and the browser-then-detection pair are mutually exclusive. Disposed in the
320
+ // `finally` below, not earlier: the comparators read the journals after `crawlOne` returns.
321
+ const transactions = [];
321
322
  try {
322
323
  if (renderingTypePrediction.renderingType === 'static' && !shouldDetectRenderingType) {
323
324
  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)) {
325
+ this.statistics.state.httpOnlyRequestHandlerRuns++;
326
+ const plainHTTPRun = await this.crawlOne('static', crawlingContext, crawlingContext.useState, transactions);
327
+ if (plainHTTPRun.ok && this.#resultChecker(plainHTTPRun.result)) {
327
328
  crawlingContext.log.debug(`HTTP-only request handler succeeded for ${crawlingContext.request.url}`);
328
329
  plainHTTPRun.logs?.forEach(([log, method, ...args]) => log[method](...args));
329
- await this.commitResult(crawlingContext, plainHTTPRun.result);
330
+ await plainHTTPRun.result.commit();
330
331
  return;
331
332
  }
332
333
  // Execution will "fall through" and try running the request handler in a browser
@@ -334,18 +335,18 @@ export class AdaptivePlaywrightCrawler extends BasicCrawler {
334
335
  const actualError = plainHTTPRun.error instanceof RequestHandlerError
335
336
  ? plainHTTPRun.error.cause
336
337
  : plainHTTPRun.error;
337
- if (await this.shouldPropagateError(actualError, crawlingContext)) {
338
+ if (await this.#shouldPropagateError(actualError, crawlingContext)) {
338
339
  throw actualError;
339
340
  }
340
341
  crawlingContext.log.exception(actualError, `HTTP-only request handler failed for ${crawlingContext.request.url}`);
341
342
  }
342
343
  else {
343
344
  crawlingContext.log.warning(`HTTP-only request handler returned a suspicious result for ${crawlingContext.request.url}`);
344
- this.stats.trackRenderingTypeMisprediction();
345
+ this.statistics.state.renderingTypeMispredictions++;
345
346
  }
346
347
  }
347
348
  crawlingContext.log.debug(`Running browser request handler for ${crawlingContext.request.url}`);
348
- this.stats.trackBrowserRequestHandlerRun();
349
+ this.statistics.state.browserRequestHandlerRuns++;
349
350
  // Run the request handler in a browser. The copy of the crawler state is kept so that we can perform
350
351
  // a rendering type detection if necessary. Without this measure, the HTTP request handler would run
351
352
  // under different conditions, which could change its behavior. Changes done to the crawler state by
@@ -366,20 +367,22 @@ export class AdaptivePlaywrightCrawler extends BasicCrawler {
366
367
  return this.stateCopy;
367
368
  },
368
369
  };
369
- const browserRun = await this.crawlOne('clientOnly', crawlingContext, stateTracker.getLiveState.bind(stateTracker));
370
+ const browserRun = await this.crawlOne('clientOnly', crawlingContext, stateTracker.getLiveState.bind(stateTracker), transactions);
370
371
  if (!browserRun.ok) {
371
372
  throw browserRun.error;
372
373
  }
373
374
  browserRun.logs?.forEach(([log, method, ...args]) => log[method](...args));
374
- await this.commitResult(crawlingContext, browserRun.result);
375
+ await browserRun.result.commit();
375
376
  if (shouldDetectRenderingType) {
376
377
  crawlingContext.log.debug(`Detecting rendering type for ${crawlingContext.request.url}`);
377
- const plainHTTPRun = await this.crawlOne('static', crawlingContext, stateTracker.getStateCopy.bind(stateTracker));
378
+ // The detection attempt's transaction is never committed - its writes exist only for the
379
+ // result comparison.
380
+ const plainHTTPRun = await this.crawlOne('static', crawlingContext, stateTracker.getStateCopy.bind(stateTracker), transactions);
378
381
  const detectionResult = (() => {
379
382
  if (!plainHTTPRun.ok) {
380
383
  return 'clientOnly';
381
384
  }
382
- const comparisonResult = this.resultComparator(plainHTTPRun.result, browserRun.result);
385
+ const comparisonResult = this.#resultComparator(plainHTTPRun.result, browserRun.result);
383
386
  if (comparisonResult === true || comparisonResult === 'equal') {
384
387
  return 'static';
385
388
  }
@@ -390,60 +393,33 @@ export class AdaptivePlaywrightCrawler extends BasicCrawler {
390
393
  })();
391
394
  crawlingContext.log.debug(`Detected rendering type ${detectionResult} for ${crawlingContext.request.url}`);
392
395
  if (detectionResult !== undefined) {
393
- this.renderingTypePredictor.value.storeResult(crawlingContext.request, detectionResult);
396
+ this.#renderingTypePredictor.value.storeResult(crawlingContext.request, detectionResult);
394
397
  }
395
398
  }
396
399
  }
397
400
  finally {
398
- if (shouldDetectRenderingType) {
399
- this.inFlightRenderingTypeDetections -= 1;
401
+ // A still-open transaction here belongs to a discarded attempt - roll it back, then release.
402
+ for (const transaction of transactions) {
403
+ transaction.rollback();
404
+ transaction.dispose();
400
405
  }
401
406
  }
402
407
  }
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) {
408
+ async enqueueLinks(urls, options, request) {
425
409
  const baseUrl = resolveBaseUrlForEnqueueLinksFiltering({
426
410
  enqueueStrategy: options?.strategy,
427
411
  finalRequestUrl: request.loadedUrl,
428
412
  originalRequestUrl: request.url,
429
413
  userProvidedBaseUrl: options?.baseUrl,
430
414
  });
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);
415
+ const requestsWithDepth = this.addCrawlDepthRequestGenerator(urls, request.crawlDepth + 1);
416
+ // The per-attempt transaction buffers these (the queue policy defaults to `deferred` here),
417
+ // so a discarded attempt's enqueues never reach the queue.
418
+ return await this.addRequests(requestsWithDepth, {
419
+ ...options,
420
+ baseUrl,
421
+ strategy: options.strategy ?? EnqueueStrategy.SameHostname,
422
+ });
447
423
  }
448
424
  createLogProxy(log, logs) {
449
425
  return new Proxy(log, {
@@ -459,7 +435,7 @@ export class AdaptivePlaywrightCrawler extends BasicCrawler {
459
435
  }
460
436
  async teardown() {
461
437
  await super.teardown();
462
- for (const hook of this.teardownHooks) {
438
+ for (const hook of this.#teardownHooks) {
463
439
  await hook();
464
440
  }
465
441
  }
@@ -493,6 +469,5 @@ export function createAdaptivePlaywrightRouter(routesOrSchemas) {
493
469
  export function fullResultComparator(resultA, resultB) {
494
470
  return (isDeepStrictEqual(resultA.datasetItems, resultB.datasetItems) &&
495
471
  isDeepStrictEqual(resultA.enqueuedUrls, resultB.enqueuedUrls) &&
496
- isDeepStrictEqual(resultA.enqueuedUrlLists, resultB.enqueuedUrlLists) &&
497
472
  isDeepStrictEqual(resultA.keyValueStoreChanges, resultB.keyValueStoreChanges));
498
473
  }
@@ -1,4 +1,4 @@
1
- import type { GlobInput, IRequestManager, PseudoUrlInput, RegExpInput, RequestTransform, SkippedRequestCallback } from '@crawlee/browser';
1
+ import type { IRequestManager, RequestTransform, SkippedRequestCallback, UrlPatternInput } from '@crawlee/browser';
2
2
  import type { BatchAddRequestsResult, Dictionary } from '@crawlee/types';
3
3
  // @ts-ignore optional peer dependency or compatibility with es2022
4
4
  import type { Page } from 'playwright';
@@ -26,64 +26,29 @@ export interface EnqueueLinksByClickingElementsOptions {
26
26
  */
27
27
  clickOptions?: ClickOptions;
28
28
  /**
29
- * An array of glob pattern strings or plain objects
30
- * containing glob pattern strings matching the URLs to be enqueued.
29
+ * An array of URL patterns that URLs must match to be enqueued.
31
30
  *
32
- * The plain objects must include at least the `glob` property, which holds the glob pattern string.
33
- * All remaining keys will be used as request options for the corresponding enqueued {@link Request} objects.
34
- *
35
- * The matching is always case-insensitive.
36
- * If you need case-sensitive matching, use `regexps` property directly.
37
- *
38
- * If `globs` is an empty array or `undefined`, then the function
39
- * enqueues all the intercepted navigation requests produced by the page
40
- * after clicking on elements matching the provided CSS selector.
41
- */
42
- globs?: GlobInput[];
43
- /**
44
- * An array of glob pattern strings, regexp patterns or plain objects
45
- * containing patterns matching URLs that will **never** be enqueued.
46
- *
47
- * The plain objects must include either the `glob` property or the `regexp` property.
31
+ * Accepts glob pattern strings, `{ glob: string }` objects, `RegExp` instances, or `{ regexp: RegExp }` objects.
48
32
  *
49
33
  * Glob matching is always case-insensitive.
50
- * If you need case-sensitive matching, provide a regexp.
51
- */
52
- exclude?: readonly (GlobInput | RegExpInput)[];
53
- /**
54
- * An array of regular expressions or plain objects
55
- * containing regular expressions matching the URLs to be enqueued.
56
- *
57
- * The plain objects must include at least the `regexp` property, which holds the regular expression.
58
- * All remaining keys will be used as request options for the corresponding enqueued {@link Request} objects.
34
+ * If you need case-sensitive matching, use a `RegExp`.
59
35
  *
60
- * If `regexps` is an empty array or `undefined`, then the function
36
+ * If `include` is an empty array or `undefined`, then the function
61
37
  * enqueues all the intercepted navigation requests produced by the page
62
38
  * after clicking on elements matching the provided CSS selector.
63
39
  */
64
- regexps?: RegExpInput[];
40
+ include?: UrlPatternInput[];
65
41
  /**
66
- * *NOTE:* In future versions of SDK the options will be removed.
67
- * Please use `globs` or `regexps` instead.
68
- *
69
- * An array of {@link PseudoUrl} strings or plain objects
70
- * containing {@link PseudoUrl} strings matching the URLs to be enqueued.
71
- *
72
- * The plain objects must include at least the `purl` property, which holds the pseudo-URL pattern string.
73
- * All remaining keys will be used as request options for the corresponding enqueued {@link Request} objects.
42
+ * An array of URL patterns. Matching URLs will **not** be enqueued.
74
43
  *
75
- * With a pseudo-URL string, the matching is always case-insensitive.
76
- * If you need case-sensitive matching, use `regexps` property directly.
44
+ * Accepts glob pattern strings, `{ glob: string }` objects, `RegExp` instances, or `{ regexp: RegExp }` objects.
77
45
  *
78
- * If `pseudoUrls` is an empty array or `undefined`, then the function
79
- * enqueues all the intercepted navigation requests produced by the page
80
- * after clicking on elements matching the provided CSS selector.
81
- *
82
- * @deprecated prefer using `globs` or `regexps` instead
46
+ * Glob matching is always case-insensitive.
47
+ * If you need case-sensitive matching, use a `RegExp`.
83
48
  */
84
- pseudoUrls?: PseudoUrlInput[];
49
+ exclude?: readonly UrlPatternInput[];
85
50
  /**
86
- * After {@link Request} objects are constructed and filtered by URL patterns (`globs`, `regexps`, `pseudoUrls`),
51
+ * After request options are filtered by `include`/`exclude` patterns,
87
52
  * this function can be used to remove them or modify their contents such as `userData`, `payload` or, most importantly
88
53
  * `uniqueKey`. This is useful when you need to enqueue multiple `Requests` to the queue that share the same URL,
89
54
  * but differ in methods or payloads, or to dynamically update or create `userData`.
@@ -98,8 +63,8 @@ export interface EnqueueLinksByClickingElementsOptions {
98
63
  * }
99
64
  * ```
100
65
  *
101
- * Note that `transformRequestFunction` has the highest priority and can overwrite request options
102
- * specified in `globs`, `regexps`, or `pseudoUrls` objects, as well as the global `label` option.
66
+ * Note that `transformRequestFunction` has the highest priority and can overwrite
67
+ * the global `label` option.
103
68
  *
104
69
  * The function receives a {@link RequestOptions} object and can return either:
105
70
  * - The modified {@link RequestOptions} object
@@ -160,8 +125,7 @@ export interface EnqueueLinksByClickingElementsOptions {
160
125
  * in `href` elements, but rather navigations are triggered in click handlers.
161
126
  * If you're looking to find URLs in `href` attributes of the page, see {@link enqueueLinks}.
162
127
  *
163
- * Optionally, the function allows you to filter the target links' URLs using an array of {@link PseudoUrl} objects
164
- * and override settings of the enqueued {@link Request} objects.
128
+ * Optionally, the function allows you to filter the target links' URLs using an array of glob or regexp patterns.
165
129
  *
166
130
  * **IMPORTANT**: To be able to do this, this function uses various mutations on the page,
167
131
  * such as changing the Z-index of elements being clicked and their visibility. Therefore,
@@ -183,9 +147,9 @@ export interface EnqueueLinksByClickingElementsOptions {
183
147
  * page,
184
148
  * requestManager,
185
149
  * selector: 'a.product-detail',
186
- * pseudoUrls: [
187
- * 'https://www.example.com/handbags/[.*]'
188
- * 'https://www.example.com/purses/[.*]'
150
+ * include: [
151
+ * 'https://www.example.com/handbags/*',
152
+ * 'https://www.example.com/purses/*',
189
153
  * ],
190
154
  * });
191
155
  * ```