@crawlee/playwright 4.0.0-beta.16 → 4.0.0-beta.161

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/README.md +14 -14
  2. package/index.d.ts +2 -2
  3. package/index.js +1 -1
  4. package/internals/adaptive-playwright-crawler.d.ts +116 -63
  5. package/internals/adaptive-playwright-crawler.js +324 -266
  6. package/internals/enqueue-links/click-elements.d.ts +36 -64
  7. package/internals/enqueue-links/click-elements.js +65 -67
  8. package/internals/playwright-browser-pool.d.ts +71 -0
  9. package/internals/playwright-browser-pool.js +61 -0
  10. package/internals/playwright-crawler.d.ts +180 -125
  11. package/internals/playwright-crawler.js +68 -63
  12. package/internals/playwright-launcher.d.ts +32 -18
  13. package/internals/playwright-launcher.js +23 -17
  14. package/internals/utils/playwright-utils.d.ts +54 -41
  15. package/internals/utils/playwright-utils.js +110 -121
  16. package/internals/utils/rendering-type-prediction.d.ts +25 -11
  17. package/internals/utils/rendering-type-prediction.js +81 -27
  18. package/package.json +14 -18
  19. package/index.d.ts.map +0 -1
  20. package/index.js.map +0 -1
  21. package/internals/adaptive-playwright-crawler.d.ts.map +0 -1
  22. package/internals/adaptive-playwright-crawler.js.map +0 -1
  23. package/internals/enqueue-links/click-elements.d.ts.map +0 -1
  24. package/internals/enqueue-links/click-elements.js.map +0 -1
  25. package/internals/playwright-crawler.d.ts.map +0 -1
  26. package/internals/playwright-crawler.js.map +0 -1
  27. package/internals/playwright-launcher.d.ts.map +0 -1
  28. package/internals/playwright-launcher.js.map +0 -1
  29. package/internals/utils/playwright-utils.d.ts.map +0 -1
  30. package/internals/utils/playwright-utils.js.map +0 -1
  31. package/internals/utils/rendering-type-prediction.d.ts.map +0 -1
  32. package/internals/utils/rendering-type-prediction.js.map +0 -1
@@ -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 { Configuration, RequestHandlerError, RequestHandlerResult, resolveBaseUrlForEnqueueLinksFiltering, Router, Statistics, withCheckedStorageAccess, } from '@crawlee/core';
6
- import { extractUrlsFromCheerio } from '@crawlee/utils';
5
+ import { createStorageTransaction, EnqueueStrategy, OwnedOrInjected, RequestHandlerError, resolveBaseUrlForEnqueueLinksFiltering, Router, Statistics, } from '@crawlee/core';
6
+ import { extractUrlsFromCheerio, parseArgument } 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
- 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
- }
10
+ import { RenderingTypePredictor, } from './utils/rendering-type-prediction.js';
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,42 +64,80 @@ const proxyLogMethods = [
82
64
  * @experimental
83
65
  */
84
66
  export class AdaptivePlaywrightCrawler extends BasicCrawler {
85
- config;
86
- renderingTypePredictor;
87
- resultChecker;
88
- resultComparator;
89
- preventDirectStorageAccess;
90
- staticContextPipeline;
91
- browserContextPipeline;
92
- individualRequestHandlerTimeoutMillis;
93
- resultObjects = new WeakMap();
94
- teardownHooks = [];
95
- constructor(options = {}, config = Configuration.getGlobalConfig()) {
96
- const { requestHandler, renderingTypeDetectionRatio = 0.1, renderingTypePredictor, resultChecker, resultComparator, statisticsOptions, preventDirectStorageAccess = true, requestHandlerTimeoutSecs = 60, errorHandler, failedRequestHandler, preNavigationHooks, postNavigationHooks, extendContext, contextPipelineBuilder, ...rest } = options;
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 = [];
80
+ constructor(options = {}) {
81
+ const { requestHandler, renderingTypeDetectionRatio = 0.1, renderingTypePredictor, resultChecker, shouldPropagateError, resultComparator, statistics, requestHandlerTimeoutSecs = 60, errorHandler, failedRequestHandler, preNavigationHooks = [], postNavigationHooks = [], extendContext, contextPipelineBuilder, transactionalStorage, launchContext, headless, browserPool, remoteBrowser, ...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
+ }
97
103
  super({
98
104
  ...rest,
99
- // Pass error handlers to the "main" crawler - we only pluck them from `rest` so that they don't go to the sub crawlers
100
105
  errorHandler,
101
106
  failedRequestHandler,
102
- // Same for request handler
103
107
  requestHandler,
104
- // The builder intentionally returns null so that it crashes the crawler when it tries to use this instead of one of two the specialized context pipelines
105
- // (that would be a logical error in this class)
106
- contextPipelineBuilder: () => null,
107
- }, config);
108
- this.config = config;
109
- this.individualRequestHandlerTimeoutMillis = requestHandlerTimeoutSecs * 1000;
110
- this.renderingTypePredictor =
111
- renderingTypePredictor ?? new RenderingTypePredictor({ detectionRatio: renderingTypeDetectionRatio });
112
- this.resultChecker = resultChecker ?? (() => true);
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
+ }),
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,
122
+ });
123
+ this.#individualRequestHandlerTimeoutMillis = requestHandlerTimeoutSecs * 1000;
124
+ // `renderingTypeDetectionRatio` only configures the default predictor - an injected one brings its own
125
+ // detection ratio (and its own state), so the option is ignored in that case.
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);
113
133
  if (resultComparator !== undefined) {
114
- this.resultComparator = resultComparator;
134
+ this.#resultComparator = resultComparator;
115
135
  }
116
136
  else if (resultChecker !== undefined) {
117
- this.resultComparator = (resultA, resultB) => this.resultChecker(resultA) && this.resultChecker(resultB);
137
+ this.#resultComparator = (resultA, resultB) => this.#resultChecker(resultA) && this.#resultChecker(resultB);
118
138
  }
119
139
  else {
120
- this.resultComparator = (resultA, resultB) => {
140
+ this.#resultComparator = (resultA, resultB) => {
121
141
  return (resultA.datasetItems.length === resultB.datasetItems.length &&
122
142
  resultA.datasetItems.every((itemA, i) => {
123
143
  const itemB = resultB.datasetItems[i];
@@ -125,101 +145,98 @@ export class AdaptivePlaywrightCrawler extends BasicCrawler {
125
145
  }));
126
146
  };
127
147
  }
148
+ // `extendContext` is forwarded to the inner crawlers, which run it *before* navigation (see
149
+ // `BasicCrawler`), keeping the behavior consistent with the non-adaptive crawlers: the
150
+ // extension is visible to the pre/post-navigation hooks and the request handler, but cannot
151
+ // access navigation-dependent members (`page`, `response`, `$`, ...).
152
+ //
153
+ // The adaptive hooks target a subset context (`AdaptiveHookContext`); the casts to the inner
154
+ // crawlers' `PlaywrightHook` type relax that nominal difference. The `ContextPipeline` merges
155
+ // each hook's overrides at runtime regardless of the static type.
128
156
  const staticCrawler = new CheerioCrawler({
129
157
  ...rest,
130
- useSessionPool: false,
131
- statisticsOptions: {
132
- persistenceOptions: { enable: false },
133
- },
134
- preNavigationHooks: [
135
- async (context) => {
136
- for (const hook of preNavigationHooks ?? []) {
137
- await hook(context, undefined);
138
- }
139
- },
140
- ],
141
- postNavigationHooks: [
142
- async (context) => {
143
- for (const hook of postNavigationHooks ?? []) {
144
- await hook(context, undefined);
145
- }
146
- },
147
- ],
148
- }, config);
158
+ statistics: new Statistics({ persistenceOptions: { enable: false } }),
159
+ preNavigationHooks,
160
+ postNavigationHooks,
161
+ extendContext,
162
+ });
149
163
  const browserCrawler = new PlaywrightCrawler({
150
164
  ...rest,
151
- useSessionPool: false,
152
- statisticsOptions: {
153
- persistenceOptions: { enable: false },
154
- },
155
- preNavigationHooks: [
156
- async (context, gotoOptions) => {
157
- for (const hook of preNavigationHooks ?? []) {
158
- await hook(context, gotoOptions);
159
- }
160
- },
161
- ],
162
- postNavigationHooks: [
163
- async (context, gotoOptions) => {
164
- for (const hook of postNavigationHooks ?? []) {
165
- await hook(context, gotoOptions);
166
- }
167
- },
168
- ],
169
- }, config);
170
- this.teardownHooks.push(browserCrawler.teardown.bind(browserCrawler));
171
- this.staticContextPipeline = staticCrawler.contextPipeline
172
- .compose({
165
+ statistics: new Statistics({ persistenceOptions: { enable: false } }),
166
+ preNavigationHooks: preNavigationHooks,
167
+ postNavigationHooks: postNavigationHooks,
168
+ extendContext,
169
+ launchContext,
170
+ headless,
171
+ browserPool,
172
+ remoteBrowser,
173
+ });
174
+ this.#teardownHooks.push(browserCrawler.teardown.bind(browserCrawler));
175
+ this.#staticContextPipeline = staticCrawler.contextPipeline.compose({
173
176
  action: this.adaptCheerioContext.bind(this),
174
- })
175
- .compose({
176
- action: async (context) => extendContext ? await extendContext(context) : context,
177
177
  });
178
- this.browserContextPipeline = browserCrawler.contextPipeline
179
- .compose({
178
+ this.#browserContextPipeline = browserCrawler.contextPipeline.compose({
180
179
  action: this.adaptPlaywrightContext.bind(this),
181
- })
182
- .compose({
183
- action: async (context) => extendContext ? await extendContext(context) : context,
184
180
  });
185
- this.stats = new AdaptivePlaywrightCrawlerStatistics({
186
- logMessage: `${this.log.getOptions().prefix} request statistics:`,
187
- config,
188
- ...statisticsOptions,
189
- });
190
- this.preventDirectStorageAccess = preventDirectStorageAccess;
191
181
  }
192
- async _init() {
193
- await this.renderingTypePredictor.initialize();
194
- return await super._init();
182
+ async init() {
183
+ // Only the predictor we built ourselves is ours to initialize - an injected one is borrowed, so its
184
+ // lifecycle (including restoring persisted state) stays with whoever created it.
185
+ await this.#renderingTypePredictor.ifOwned((predictor) => predictor.initialize());
186
+ return await super.init();
187
+ }
188
+ buildContextPipeline() {
189
+ 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`;
190
+ return super.buildContextPipeline().compose({
191
+ action: async ({ request }) => ({
192
+ get request() {
193
+ return request;
194
+ },
195
+ get response() {
196
+ throw new Error(errorMessage('response'));
197
+ },
198
+ get page() {
199
+ throw new Error(errorMessage('page'));
200
+ },
201
+ get querySelector() {
202
+ throw new Error(errorMessage('querySelector'));
203
+ },
204
+ get querySelectorAll() {
205
+ throw new Error(errorMessage('querySelectorAll'));
206
+ },
207
+ get waitForSelector() {
208
+ throw new Error(errorMessage('waitForSelector'));
209
+ },
210
+ get parseWithCheerio() {
211
+ throw new Error(errorMessage('parseWithCheerio'));
212
+ },
213
+ get enqueueLinks() {
214
+ throw new Error(errorMessage('enqueueLinks'));
215
+ },
216
+ }),
217
+ });
195
218
  }
196
219
  async adaptCheerioContext(cheerioContext) {
197
- // Capture the original response to avoid infinite recursion when the getter is copied to the context
198
- const result = this.resultObjects.get(cheerioContext);
199
- if (result === undefined) {
200
- throw new Error('Logical error - `this.resultObjects` does not contain the result object');
201
- }
202
220
  return {
203
221
  get page() {
204
222
  throw new Error('Page object was used in HTTP-only request handler');
205
223
  },
206
224
  async querySelector(selector) {
225
+ return cheerioContext.$(selector).first();
226
+ },
227
+ async querySelectorAll(selector) {
207
228
  return cheerioContext.$(selector);
208
229
  },
209
230
  enqueueLinks: async (options = {}) => {
210
- const urls = options.urls ??
211
- extractUrlsFromCheerio(cheerioContext.$, options.selector, options.baseUrl ?? cheerioContext.request.loadedUrl);
212
- return (await this.enqueueLinks({ ...options, urls }, cheerioContext.request, result));
231
+ const urls = extractUrlsFromCheerio(cheerioContext.$, options.selector, options.baseUrl ?? cheerioContext.request.loadedUrl);
232
+ return (await this.enqueueLinks(urls, options, cheerioContext.request));
213
233
  },
214
234
  response: cheerioContext.response,
215
235
  };
216
236
  }
217
237
  async adaptPlaywrightContext(playwrightContext) {
238
+ // Capture the original response to avoid infinite recursion when the getter is copied to the context
218
239
  const originalResponse = playwrightContext.response;
219
- const result = this.resultObjects.get(playwrightContext);
220
- if (result === undefined) {
221
- throw new Error('Logical error - `this.resultObjects` does not contain the result object');
222
- }
223
240
  return {
224
241
  response: new Response(Uint8Array.from(await originalResponse.body()), {
225
242
  headers: originalResponse.headers(),
@@ -227,6 +244,12 @@ export class AdaptivePlaywrightCrawler extends BasicCrawler {
227
244
  statusText: originalResponse.statusText(),
228
245
  }),
229
246
  async querySelector(selector, timeoutMs = 5000) {
247
+ const locator = playwrightContext.page.locator(selector).first();
248
+ await locator.waitFor({ timeout: timeoutMs, state: 'attached' });
249
+ const $ = await playwrightContext.parseWithCheerio();
250
+ return $(selector).first();
251
+ },
252
+ async querySelectorAll(selector, timeoutMs = 5000) {
230
253
  const locator = playwrightContext.page.locator(selector).first();
231
254
  await locator.waitFor({ timeout: timeoutMs, state: 'attached' });
232
255
  const $ = await playwrightContext.parseWithCheerio();
@@ -234,54 +257,54 @@ export class AdaptivePlaywrightCrawler extends BasicCrawler {
234
257
  },
235
258
  enqueueLinks: async (options = {}, timeoutMs = 5000) => {
236
259
  // TODO consider using `context.parseWithCheerio` to make this universal and avoid code duplication
237
- let urls;
238
- if (options.urls === undefined) {
239
- const selector = options.selector ?? 'a';
240
- const locator = playwrightContext.page.locator(selector).first();
241
- await locator.waitFor({ timeout: timeoutMs, state: 'attached' });
242
- urls =
243
- options.urls ??
244
- (await extractUrlsFromPage(playwrightContext.page, selector, options.baseUrl ?? playwrightContext.request.loadedUrl));
245
- }
246
- else {
247
- urls = options.urls;
248
- }
249
- return (await this.enqueueLinks({ ...options, urls }, playwrightContext.request, result));
260
+ const selector = options.selector ?? 'a';
261
+ const locator = playwrightContext.page.locator(selector).first();
262
+ await locator.waitFor({ timeout: timeoutMs, state: 'attached' });
263
+ const urls = await extractUrlsFromPage(playwrightContext.page, selector, options.baseUrl ?? playwrightContext.request.loadedUrl);
264
+ return (await this.enqueueLinks(urls, options, playwrightContext.request));
250
265
  },
251
266
  };
252
267
  }
253
- async crawlOne(renderingType, context, useStateFunction) {
254
- const result = new RequestHandlerResult(this.config, AdaptivePlaywrightCrawler.CRAWLEE_STATE_KEY);
268
+ /**
269
+ * Runs one request handler attempt inside its own {@link StorageTransaction}, wrapping the inner
270
+ * (static or browser) context pipeline. The transaction is pushed to `transactions` *at creation
271
+ * time, before the `try`* - the `ok: false` branch of the returned {@link Result} carries no
272
+ * result, and failed attempts are routine here. The caller owns the outcome and disposal.
273
+ */
274
+ async crawlOne(renderingType, context, useStateFunction, transactions) {
275
+ const transaction = createStorageTransaction({
276
+ policy: this.#attemptWritePolicy,
277
+ commitTimeoutMillis: this.internalTimeoutMillis,
278
+ });
279
+ transactions.push(transaction);
255
280
  const logs = [];
256
281
  const deferredCleanup = [];
257
- const resultBoundContextHelpers = {
258
- addRequests: result.addRequests,
259
- pushData: result.pushData,
260
- useState: this.allowStorageAccess(useStateFunction),
261
- getKeyValueStore: this.allowStorageAccess(result.getKeyValueStore),
262
- enqueueLinks: async (options) => {
263
- return await this.enqueueLinks(options, context.request, result);
264
- },
282
+ const attemptBoundContextHelpers = {
283
+ useState: useStateFunction,
265
284
  log: this.createLogProxy(context.log, logs),
266
285
  registerDeferredCleanup: (cleanup) => deferredCleanup.push(cleanup),
267
286
  };
268
- const subCrawlerContext = { ...context, ...resultBoundContextHelpers };
269
- this.resultObjects.set(subCrawlerContext, result);
287
+ const subCrawlerContext = Object.defineProperties({}, Object.getOwnPropertyDescriptors(context));
288
+ // Mark attempt-bound helpers as non-configurable so they survive the sub-crawler context pipeline
289
+ // (which would otherwise override them with the sub-crawler's own versions, losing the binding).
290
+ for (const [key, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(attemptBoundContextHelpers))) {
291
+ Object.defineProperty(subCrawlerContext, key, { ...descriptor, configurable: false });
292
+ }
270
293
  try {
271
294
  const callAdaptiveRequestHandler = async () => {
272
295
  if (renderingType === 'static') {
273
- await this.staticContextPipeline.call(subCrawlerContext, async (finalContext) => await this.requestHandler(finalContext));
296
+ await this.#staticContextPipeline.call(subCrawlerContext, this.requestHandler.bind(this));
274
297
  }
275
298
  else if (renderingType === 'clientOnly') {
276
- await this.browserContextPipeline.call(subCrawlerContext, async (finalContext) => await this.requestHandler(finalContext));
299
+ await this.#browserContextPipeline.call(subCrawlerContext, this.requestHandler.bind(this));
277
300
  }
278
301
  };
279
- await addTimeoutToPromise(async () => withCheckedStorageAccess(() => {
280
- if (this.preventDirectStorageAccess) {
281
- throw new Error('Directly accessing storage in a request handler is not allowed in AdaptivePlaywrightCrawler');
282
- }
283
- }, callAdaptiveRequestHandler), this.individualRequestHandlerTimeoutMillis, 'Request handler timed out');
284
- return { result, ok: true, logs };
302
+ // this crawler overrides `runRequestHandler` and times each rendering-type run itself, so it has
303
+ // to resolve any per-route override too - otherwise routes would be silently ignored here
304
+ const routeTimeoutSecs = this.requestHandler.getTimeoutSecs?.(context.request.label);
305
+ const timeoutMillis = routeTimeoutSecs === undefined ? this.#individualRequestHandlerTimeoutMillis : routeTimeoutSecs * 1000;
306
+ await addTimeoutToPromise(async () => transaction.run(callAdaptiveRequestHandler), timeoutMillis, 'Request handler timed out');
307
+ return { result: transaction, ok: true, logs };
285
308
  }
286
309
  catch (error) {
287
310
  return { error, ok: false, logs };
@@ -291,138 +314,173 @@ export class AdaptivePlaywrightCrawler extends BasicCrawler {
291
314
  }
292
315
  }
293
316
  async runRequestHandler(crawlingContext) {
294
- const renderingTypePrediction = this.renderingTypePredictor.predict(crawlingContext.request);
317
+ const renderingTypePrediction = this.#renderingTypePredictor.value.predict(crawlingContext.request);
295
318
  const shouldDetectRenderingType = Math.random() < renderingTypePrediction.detectionProbabilityRecommendation;
296
319
  if (!shouldDetectRenderingType) {
297
320
  crawlingContext.log.debug(`Predicted rendering type ${renderingTypePrediction.renderingType} for ${crawlingContext.request.url}`);
298
321
  }
299
- if (renderingTypePrediction.renderingType === 'static' && !shouldDetectRenderingType) {
300
- crawlingContext.log.debug(`Running HTTP-only request handler for ${crawlingContext.request.url}`);
301
- this.stats.trackHttpOnlyRequestHandlerRun();
302
- const plainHTTPRun = await this.crawlOne('static', crawlingContext, crawlingContext.useState);
303
- if (plainHTTPRun.ok && this.resultChecker(plainHTTPRun.result)) {
304
- crawlingContext.log.debug(`HTTP-only request handler succeeded for ${crawlingContext.request.url}`);
305
- plainHTTPRun.logs?.forEach(([log, method, ...args]) => log[method](...args));
306
- await this.commitResult(crawlingContext, plainHTTPRun.result);
307
- return;
308
- }
309
- // Execution will "fall through" and try running the request handler in a browser
310
- if (!plainHTTPRun.ok) {
311
- const actualError = plainHTTPRun.error instanceof RequestHandlerError
312
- ? plainHTTPRun.error.cause
313
- : plainHTTPRun.error;
314
- crawlingContext.log.exception(actualError, `HTTP-only request handler failed for ${crawlingContext.request.url}`);
315
- }
316
- else {
317
- crawlingContext.log.warning(`HTTP-only request handler returned a suspicious result for ${crawlingContext.request.url}`);
318
- this.stats.trackRenderingTypeMisprediction();
319
- }
320
- }
321
- crawlingContext.log.debug(`Running browser request handler for ${crawlingContext.request.url}`);
322
- this.stats.trackBrowserRequestHandlerRun();
323
- // Run the request handler in a browser. The copy of the crawler state is kept so that we can perform
324
- // a rendering type detection if necessary. Without this measure, the HTTP request handler would run
325
- // under different conditions, which could change its behavior. Changes done to the crawler state by
326
- // the HTTP request handler will not be committed to the actual storage.
327
- const stateTracker = {
328
- stateCopy: null,
329
- async getLiveState(defaultValue = {}) {
330
- const state = await crawlingContext.useState(defaultValue);
331
- if (this.stateCopy === null) {
332
- this.stateCopy = JSON.parse(JSON.stringify(state));
333
- }
334
- return state;
335
- },
336
- async getStateCopy(defaultValue = {}) {
337
- if (this.stateCopy === null) {
338
- return defaultValue;
322
+ // Every transaction created for this request - up to two, since the static-then-browser
323
+ // fall-through and the browser-then-detection pair are mutually exclusive. Disposed in the
324
+ // `finally` below, not earlier: the comparators read the journals after `crawlOne` returns.
325
+ const transactions = [];
326
+ try {
327
+ if (renderingTypePrediction.renderingType === 'static' && !shouldDetectRenderingType) {
328
+ crawlingContext.log.debug(`Running HTTP-only request handler for ${crawlingContext.request.url}`);
329
+ this.statistics.state.httpOnlyRequestHandlerRuns++;
330
+ const plainHTTPRun = await this.crawlOne('static', crawlingContext, crawlingContext.useState, transactions);
331
+ if (plainHTTPRun.ok && this.#resultChecker(plainHTTPRun.result)) {
332
+ crawlingContext.log.debug(`HTTP-only request handler succeeded for ${crawlingContext.request.url}`);
333
+ plainHTTPRun.logs?.forEach(([log, method, ...args]) => log[method](...args));
334
+ await plainHTTPRun.result.commit();
335
+ return;
339
336
  }
340
- return this.stateCopy;
341
- },
342
- };
343
- const browserRun = await this.crawlOne('clientOnly', crawlingContext, stateTracker.getLiveState.bind(stateTracker));
344
- if (!browserRun.ok) {
345
- throw browserRun.error;
346
- }
347
- await this.commitResult(crawlingContext, browserRun.result);
348
- if (shouldDetectRenderingType) {
349
- crawlingContext.log.debug(`Detecting rendering type for ${crawlingContext.request.url}`);
350
- const plainHTTPRun = await this.crawlOne('static', crawlingContext, stateTracker.getStateCopy.bind(stateTracker));
351
- const detectionResult = (() => {
337
+ // Execution will "fall through" and try running the request handler in a browser
352
338
  if (!plainHTTPRun.ok) {
353
- return 'clientOnly';
339
+ const actualError = plainHTTPRun.error instanceof RequestHandlerError
340
+ ? plainHTTPRun.error.cause
341
+ : plainHTTPRun.error;
342
+ if (await this.#shouldPropagateError(actualError, crawlingContext)) {
343
+ throw actualError;
344
+ }
345
+ crawlingContext.log.exception(actualError, `HTTP-only request handler failed for ${crawlingContext.request.url}`);
354
346
  }
355
- const comparisonResult = this.resultComparator(plainHTTPRun.result, browserRun.result);
356
- if (comparisonResult === true || comparisonResult === 'equal') {
357
- return 'static';
347
+ else {
348
+ crawlingContext.log.warning(`HTTP-only request handler returned a suspicious result for ${crawlingContext.request.url}`);
349
+ this.statistics.state.renderingTypeMispredictions++;
358
350
  }
359
- if (comparisonResult === false || comparisonResult === 'different') {
360
- return 'clientOnly';
351
+ }
352
+ crawlingContext.log.debug(`Running browser request handler for ${crawlingContext.request.url}`);
353
+ this.statistics.state.browserRequestHandlerRuns++;
354
+ // Run the request handler in a browser. The copy of the crawler state is kept so that we can perform
355
+ // a rendering type detection if necessary. Without this measure, the HTTP request handler would run
356
+ // under different conditions, which could change its behavior. Changes done to the crawler state by
357
+ // the HTTP request handler will not be committed to the actual storage.
358
+ const stateTracker = {
359
+ stateCopy: null,
360
+ async getLiveState(defaultValue = {}) {
361
+ const state = await crawlingContext.useState(defaultValue);
362
+ if (this.stateCopy === null) {
363
+ this.stateCopy = JSON.parse(JSON.stringify(state));
364
+ }
365
+ return state;
366
+ },
367
+ async getStateCopy(defaultValue = {}) {
368
+ if (this.stateCopy === null) {
369
+ return defaultValue;
370
+ }
371
+ return this.stateCopy;
372
+ },
373
+ };
374
+ const browserRun = await this.crawlOne('clientOnly', crawlingContext, stateTracker.getLiveState.bind(stateTracker), transactions);
375
+ if (!browserRun.ok) {
376
+ throw browserRun.error;
377
+ }
378
+ browserRun.logs?.forEach(([log, method, ...args]) => log[method](...args));
379
+ await browserRun.result.commit();
380
+ if (shouldDetectRenderingType) {
381
+ crawlingContext.log.debug(`Detecting rendering type for ${crawlingContext.request.url}`);
382
+ // The detection attempt's transaction is never committed - its writes exist only for the
383
+ // result comparison.
384
+ const plainHTTPRun = await this.crawlOne('static', crawlingContext, stateTracker.getStateCopy.bind(stateTracker), transactions);
385
+ const detectionResult = (() => {
386
+ if (!plainHTTPRun.ok) {
387
+ return 'clientOnly';
388
+ }
389
+ const comparisonResult = this.#resultComparator(plainHTTPRun.result, browserRun.result);
390
+ if (comparisonResult === true || comparisonResult === 'equal') {
391
+ return 'static';
392
+ }
393
+ if (comparisonResult === false || comparisonResult === 'different') {
394
+ return 'clientOnly';
395
+ }
396
+ return undefined;
397
+ })();
398
+ crawlingContext.log.debug(`Detected rendering type ${detectionResult} for ${crawlingContext.request.url}`);
399
+ if (detectionResult !== undefined) {
400
+ this.#renderingTypePredictor.value.storeResult(crawlingContext.request, detectionResult);
361
401
  }
362
- return undefined;
363
- })();
364
- crawlingContext.log.debug(`Detected rendering type ${detectionResult} for ${crawlingContext.request.url}`);
365
- if (detectionResult !== undefined) {
366
- this.renderingTypePredictor.storeResult(crawlingContext.request, detectionResult);
402
+ }
403
+ }
404
+ finally {
405
+ // A still-open transaction here belongs to a discarded attempt - roll it back, then release.
406
+ for (const transaction of transactions) {
407
+ transaction.rollback();
408
+ transaction.dispose();
367
409
  }
368
410
  }
369
411
  }
370
- async commitResult(crawlingContext, { calls, keyValueStoreChanges }) {
371
- await Promise.all([
372
- ...calls.pushData.map(async (params) => crawlingContext.pushData(...params)),
373
- ...calls.addRequests.map(async (params) => crawlingContext.addRequests(...params)),
374
- ...Object.entries(keyValueStoreChanges).map(async ([storeIdOrName, changes]) => {
375
- const store = await crawlingContext.getKeyValueStore(storeIdOrName);
376
- await Promise.all(Object.entries(changes).map(async ([key, { changedValue, options }]) => store.setValue(key, changedValue, options)));
377
- }),
378
- ]);
379
- }
380
- allowStorageAccess(func) {
381
- return async (...args) => withCheckedStorageAccess(() => { }, async () => func(...args));
382
- }
383
- async enqueueLinks(options, request, result) {
412
+ async enqueueLinks(urls, options, request) {
384
413
  const baseUrl = resolveBaseUrlForEnqueueLinksFiltering({
385
414
  enqueueStrategy: options?.strategy,
386
415
  finalRequestUrl: request.loadedUrl,
387
416
  originalRequestUrl: request.url,
388
417
  userProvidedBaseUrl: options?.baseUrl,
389
418
  });
390
- const addRequestsBatched = async (requests) => {
391
- await result.addRequests(requests);
392
- return {
393
- addedRequests: requests.map(({ uniqueKey, id }) => ({
394
- uniqueKey,
395
- requestId: id ?? '',
396
- wasAlreadyPresent: false,
397
- wasAlreadyHandled: false,
398
- })),
399
- waitForAllRequestsToBeAdded: Promise.resolve([]),
400
- };
401
- };
402
- // We need to use a mock request queue implementation, in order to add the requests into our result object
403
- const mockRequestQueue = { addRequestsBatched };
404
- return await this.enqueueLinksWithCrawlDepth({ ...options, baseUrl }, request, mockRequestQueue);
419
+ const requestsWithDepth = this.addCrawlDepthRequestGenerator(urls, request.crawlDepth + 1);
420
+ // The per-attempt transaction buffers these (the queue policy defaults to `deferred` here),
421
+ // so a discarded attempt's enqueues never reach the queue.
422
+ return await this.addRequests(requestsWithDepth, {
423
+ ...options,
424
+ baseUrl,
425
+ strategy: options.strategy ?? EnqueueStrategy.SameHostname,
426
+ });
405
427
  }
406
428
  createLogProxy(log, logs) {
407
429
  return new Proxy(log, {
408
- get(target, propertyName, receiver) {
430
+ get(target, propertyName) {
409
431
  if (proxyLogMethods.includes(propertyName)) {
410
432
  return (...args) => {
411
433
  logs.push([target, propertyName, ...args]);
412
434
  };
413
435
  }
414
- return Reflect.get(target, propertyName, receiver);
436
+ const value = Reflect.get(target, propertyName, target);
437
+ // Bind non-intercepted methods to the target instance so private #-fields
438
+ // (e.g. BaseCrawleeLogger.#options, #warningsLogged) do not throw TypeError at runtime.
439
+ if (typeof value === 'function') {
440
+ return value.bind(target);
441
+ }
442
+ return value;
415
443
  },
416
444
  });
417
445
  }
418
446
  async teardown() {
419
447
  await super.teardown();
420
- for (const hook of this.teardownHooks) {
448
+ // Mirrors the owned-only `initialize()` in `init()` - without this, the predictor we built keeps its
449
+ // PERSIST_STATE listener registered after the crawl and never gets a final write.
450
+ await this.#renderingTypePredictor.ifOwned((predictor) => predictor.teardown());
451
+ for (const hook of this.#teardownHooks) {
421
452
  await hook();
422
453
  }
423
454
  }
424
455
  }
425
- export function createAdaptivePlaywrightRouter(routes) {
426
- return Router.create(routes);
456
+ export function createAdaptivePlaywrightRouter(routesOrSchemas) {
457
+ return Router.create(routesOrSchemas);
458
+ }
459
+ /**
460
+ * An opt-in {@link AdaptivePlaywrightCrawlerOptions.resultComparator|`resultComparator`} that considers two
461
+ * request handler results equal only if *all* of their observable effects match - the pushed dataset items, the
462
+ * enqueued requests, and the key-value store changes. This is stricter than the default comparator, which only
463
+ * compares dataset items.
464
+ *
465
+ * **Beware:** enqueued URLs are compared exactly. The same page rendered in a browser and via plain HTTP often
466
+ * yields links that differ only in tracking query parameters, for example:
467
+ * - `https://sdk.apify.com/docs/guides/getting-started`
468
+ * - `https://sdk.apify.com/docs/guides/getting-started?__hsfp=1136113150&__hssc=7591405.1.173549427712`
469
+ *
470
+ * Such links are treated as *different*, which will make the crawler favor browser rendering for those pages.
471
+ *
472
+ * **Example usage:**
473
+ * ```ts
474
+ * const crawler = new AdaptivePlaywrightCrawler({
475
+ * resultComparator: fullResultComparator,
476
+ * async requestHandler({ pushData, enqueueLinks }) {
477
+ * // ...
478
+ * },
479
+ * });
480
+ * ```
481
+ */
482
+ export function fullResultComparator(resultA, resultB) {
483
+ return (isDeepStrictEqual(resultA.datasetItems, resultB.datasetItems) &&
484
+ isDeepStrictEqual(resultA.enqueuedUrls, resultB.enqueuedUrls) &&
485
+ isDeepStrictEqual(resultA.keyValueStoreChanges, resultB.keyValueStoreChanges));
427
486
  }
428
- //# sourceMappingURL=adaptive-playwright-crawler.js.map