@crawlee/playwright 4.0.0-beta.12 → 4.0.0-beta.121

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