@crawlee/playwright 4.0.0-beta.1 → 4.0.0-beta.100

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 +117 -63
  5. package/internals/adaptive-playwright-crawler.js +359 -214
  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 +107 -86
  9. package/internals/playwright-crawler.js +87 -43
  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 +62 -27
  13. package/internals/utils/playwright-utils.js +99 -86
  14. package/internals/utils/rendering-type-prediction.d.ts +27 -12
  15. package/internals/utils/rendering-type-prediction.js +67 -26
  16. package/package.json +17 -12
  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
@@ -1,11 +1,12 @@
1
+ import { isDeepStrictEqual } from 'node:util';
2
+ import { BasicCrawler } from '@crawlee/basic';
1
3
  import { extractUrlsFromPage } from '@crawlee/browser';
2
- import { Configuration, RequestHandlerResult, Router, Statistics, withCheckedStorageAccess } from '@crawlee/core';
4
+ import { CheerioCrawler } from '@crawlee/cheerio';
5
+ import { OwnedOrInjected, RequestHandlerError, RequestHandlerResult, resolveBaseUrlForEnqueueLinksFiltering, Router, serviceLocator, Statistics, withCheckedStorageAccess, } from '@crawlee/core';
3
6
  import { extractUrlsFromCheerio } from '@crawlee/utils';
4
- import { load } from 'cheerio';
5
- import isEqual from 'lodash.isequal';
6
7
  import { addTimeoutToPromise } from '@apify/timeout';
7
8
  import { PlaywrightCrawler } from './playwright-crawler.js';
8
- import { RenderingTypePredictor } from './utils/rendering-type-prediction.js';
9
+ import { RenderingTypePredictor, } from './utils/rendering-type-prediction.js';
9
10
  class AdaptivePlaywrightCrawlerStatistics extends Statistics {
10
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
11
12
  constructor(options = {}) {
@@ -18,8 +19,8 @@ class AdaptivePlaywrightCrawlerStatistics extends Statistics {
18
19
  this.state.browserRequestHandlerRuns = 0;
19
20
  this.state.renderingTypeMispredictions = 0;
20
21
  }
21
- async _maybeLoadStatistics() {
22
- await super._maybeLoadStatistics();
22
+ async maybeLoadStatistics() {
23
+ await super.maybeLoadStatistics();
23
24
  const savedState = await this.keyValueStore?.getValue(this.persistStateKey);
24
25
  if (!savedState) {
25
26
  return;
@@ -80,27 +81,34 @@ const proxyLogMethods = [
80
81
  *
81
82
  * @experimental
82
83
  */
83
- export class AdaptivePlaywrightCrawler extends PlaywrightCrawler {
84
- config;
85
- adaptiveRequestHandler;
84
+ export class AdaptivePlaywrightCrawler extends BasicCrawler {
86
85
  renderingTypePredictor;
87
86
  resultChecker;
87
+ shouldPropagateError;
88
88
  resultComparator;
89
89
  preventDirectStorageAccess;
90
- /**
91
- * Default {@link Router} instance that will be used if we don't specify any {@link AdaptivePlaywrightCrawlerOptions.requestHandler|`requestHandler`}.
92
- * See {@link Router.addHandler|`router.addHandler()`} and {@link Router.addDefaultHandler|`router.addDefaultHandler()`}.
93
- */
94
- // @ts-ignore
95
- router = Router.create();
96
- constructor(options = {}, config = Configuration.getGlobalConfig()) {
97
- const { requestHandler, renderingTypeDetectionRatio = 0.1, renderingTypePredictor, resultChecker, resultComparator, statisticsOptions, preventDirectStorageAccess = true, ...rest } = options;
98
- super(rest, config);
99
- this.config = config;
100
- this.adaptiveRequestHandler = requestHandler ?? this.router;
101
- this.renderingTypePredictor =
102
- renderingTypePredictor ?? new RenderingTypePredictor({ detectionRatio: renderingTypeDetectionRatio });
90
+ staticContextPipeline;
91
+ browserContextPipeline;
92
+ individualRequestHandlerTimeoutMillis;
93
+ resultObjects = new WeakMap();
94
+ inFlightRenderingTypeDetections = 0;
95
+ teardownHooks = [];
96
+ 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;
98
+ super({
99
+ ...rest,
100
+ errorHandler,
101
+ failedRequestHandler,
102
+ requestHandler,
103
+ requestHandlerTimeoutSecs,
104
+ contextPipelineBuilder: contextPipelineBuilder ?? (() => this.buildContextPipeline()),
105
+ });
106
+ this.individualRequestHandlerTimeoutMillis = requestHandlerTimeoutSecs * 1000;
107
+ // `renderingTypeDetectionRatio` only configures the default predictor - an injected one brings its own
108
+ // detection ratio (and its own state), so the option is ignored in that case.
109
+ this.renderingTypePredictor = OwnedOrInjected.resolve(renderingTypePredictor, () => new RenderingTypePredictor({ detectionRatio: renderingTypeDetectionRatio }));
103
110
  this.resultChecker = resultChecker ?? (() => true);
111
+ this.shouldPropagateError = shouldPropagateError ?? (() => false);
104
112
  if (resultComparator !== undefined) {
105
113
  this.resultComparator = resultComparator;
106
114
  }
@@ -112,75 +120,292 @@ export class AdaptivePlaywrightCrawler extends PlaywrightCrawler {
112
120
  return (resultA.datasetItems.length === resultB.datasetItems.length &&
113
121
  resultA.datasetItems.every((itemA, i) => {
114
122
  const itemB = resultB.datasetItems[i];
115
- return isEqual(itemA, itemB);
123
+ return isDeepStrictEqual(itemA, itemB);
116
124
  }));
117
125
  };
118
126
  }
127
+ // `extendContext` is forwarded to the inner crawlers, which run it *before* navigation (see
128
+ // `BasicCrawler`), keeping the behavior consistent with the non-adaptive crawlers: the
129
+ // extension is visible to the pre/post-navigation hooks and the request handler, but cannot
130
+ // access navigation-dependent members (`page`, `response`, `$`, ...).
131
+ //
132
+ // The adaptive hooks target a subset context (`AdaptiveHookContext`); the casts to the inner
133
+ // crawlers' `PlaywrightHook` type relax that nominal difference. The `ContextPipeline` merges
134
+ // each hook's overrides at runtime regardless of the static type.
135
+ const staticCrawler = new CheerioCrawler({
136
+ ...rest,
137
+ statisticsOptions: {
138
+ persistenceOptions: { enable: false },
139
+ },
140
+ preNavigationHooks,
141
+ postNavigationHooks,
142
+ extendContext,
143
+ });
144
+ const browserCrawler = new PlaywrightCrawler({
145
+ ...rest,
146
+ statisticsOptions: {
147
+ persistenceOptions: { enable: false },
148
+ },
149
+ preNavigationHooks: preNavigationHooks,
150
+ postNavigationHooks: postNavigationHooks,
151
+ extendContext,
152
+ });
153
+ this.teardownHooks.push(browserCrawler.teardown.bind(browserCrawler));
154
+ this.staticContextPipeline = staticCrawler.contextPipeline.compose({
155
+ action: this.adaptCheerioContext.bind(this),
156
+ });
157
+ this.browserContextPipeline = browserCrawler.contextPipeline.compose({
158
+ action: this.adaptPlaywrightContext.bind(this),
159
+ });
119
160
  this.stats = new AdaptivePlaywrightCrawlerStatistics({
120
161
  logMessage: `${this.log.getOptions().prefix} request statistics:`,
121
- config,
122
162
  ...statisticsOptions,
123
163
  });
124
164
  this.preventDirectStorageAccess = preventDirectStorageAccess;
125
165
  }
126
- async _runRequestHandler(crawlingContext) {
127
- const renderingTypePrediction = this.renderingTypePredictor.predict(crawlingContext.request);
166
+ async _init() {
167
+ // Only the predictor we built ourselves is ours to initialize - an injected one is borrowed, so its
168
+ // lifecycle (including restoring persisted state) stays with whoever created it.
169
+ await this.renderingTypePredictor.ifOwned((predictor) => predictor.initialize());
170
+ return await super._init();
171
+ }
172
+ buildContextPipeline() {
173
+ const errorMessage = (prop) => `The \`${prop}\` property is not available on the outer context pipeline of AdaptivePlaywrightCrawler - it is provided by the inner (static/browser) pipelines`;
174
+ return super.buildContextPipeline().compose({
175
+ action: async ({ request }) => ({
176
+ get request() {
177
+ return request;
178
+ },
179
+ get response() {
180
+ throw new Error(errorMessage('response'));
181
+ },
182
+ get page() {
183
+ throw new Error(errorMessage('page'));
184
+ },
185
+ get querySelector() {
186
+ throw new Error(errorMessage('querySelector'));
187
+ },
188
+ get querySelectorAll() {
189
+ throw new Error(errorMessage('querySelectorAll'));
190
+ },
191
+ get waitForSelector() {
192
+ throw new Error(errorMessage('waitForSelector'));
193
+ },
194
+ get parseWithCheerio() {
195
+ throw new Error(errorMessage('parseWithCheerio'));
196
+ },
197
+ }),
198
+ });
199
+ }
200
+ async adaptCheerioContext(cheerioContext) {
201
+ // Capture the original response to avoid infinite recursion when the getter is copied to the context
202
+ const result = this.resultObjects.get(cheerioContext);
203
+ if (result === undefined) {
204
+ throw new Error('Logical error - `this.resultObjects` does not contain the result object');
205
+ }
206
+ return {
207
+ get page() {
208
+ throw new Error('Page object was used in HTTP-only request handler');
209
+ },
210
+ async querySelector(selector) {
211
+ return cheerioContext.$(selector).first();
212
+ },
213
+ async querySelectorAll(selector) {
214
+ return cheerioContext.$(selector);
215
+ },
216
+ 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));
220
+ },
221
+ response: cheerioContext.response,
222
+ };
223
+ }
224
+ async adaptPlaywrightContext(playwrightContext) {
225
+ 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
+ return {
231
+ response: new Response(Uint8Array.from(await originalResponse.body()), {
232
+ headers: originalResponse.headers(),
233
+ status: originalResponse.status(),
234
+ statusText: originalResponse.statusText(),
235
+ }),
236
+ async querySelector(selector, timeoutMs = 5000) {
237
+ const locator = playwrightContext.page.locator(selector).first();
238
+ await locator.waitFor({ timeout: timeoutMs, state: 'attached' });
239
+ const $ = await playwrightContext.parseWithCheerio();
240
+ return $(selector).first();
241
+ },
242
+ async querySelectorAll(selector, timeoutMs = 5000) {
243
+ const locator = playwrightContext.page.locator(selector).first();
244
+ await locator.waitFor({ timeout: timeoutMs, state: 'attached' });
245
+ const $ = await playwrightContext.parseWithCheerio();
246
+ return $(selector);
247
+ },
248
+ enqueueLinks: async (options = {}, timeoutMs = 5000) => {
249
+ // 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));
263
+ },
264
+ };
265
+ }
266
+ async crawlOne(renderingType, context, useStateFunction) {
267
+ const result = new RequestHandlerResult(serviceLocator.getConfiguration(), AdaptivePlaywrightCrawler.CRAWLEE_STATE_KEY);
268
+ const logs = [];
269
+ const deferredCleanup = [];
270
+ const resultBoundContextHelpers = {
271
+ addRequests: result.addRequests,
272
+ pushData: result.pushData,
273
+ useState: this.allowStorageAccess(useStateFunction),
274
+ getKeyValueStore: this.allowStorageAccess(result.getKeyValueStore),
275
+ log: this.createLogProxy(context.log, logs),
276
+ registerDeferredCleanup: (cleanup) => deferredCleanup.push(cleanup),
277
+ };
278
+ 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))) {
282
+ Object.defineProperty(subCrawlerContext, key, { ...descriptor, configurable: false });
283
+ }
284
+ this.resultObjects.set(subCrawlerContext, result);
285
+ try {
286
+ const callAdaptiveRequestHandler = async () => {
287
+ if (renderingType === 'static') {
288
+ await this.staticContextPipeline.call(subCrawlerContext, this.requestHandler.bind(this));
289
+ }
290
+ else if (renderingType === 'clientOnly') {
291
+ await this.browserContextPipeline.call(subCrawlerContext, this.requestHandler.bind(this));
292
+ }
293
+ };
294
+ // this crawler overrides `runRequestHandler` and times each rendering-type run itself, so it has
295
+ // to resolve any per-route override too - otherwise routes would be silently ignored here
296
+ 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 };
304
+ }
305
+ catch (error) {
306
+ return { error, ok: false, logs };
307
+ }
308
+ finally {
309
+ await Promise.all(deferredCleanup.map((cleanup) => cleanup()));
310
+ }
311
+ }
312
+ async runRequestHandler(crawlingContext) {
313
+ const renderingTypePrediction = this.renderingTypePredictor.value.predict(crawlingContext.request);
128
314
  const shouldDetectRenderingType = Math.random() < renderingTypePrediction.detectionProbabilityRecommendation;
129
315
  if (!shouldDetectRenderingType) {
130
316
  crawlingContext.log.debug(`Predicted rendering type ${renderingTypePrediction.renderingType} for ${crawlingContext.request.url}`);
131
317
  }
132
- if (renderingTypePrediction.renderingType === 'static' && !shouldDetectRenderingType) {
133
- crawlingContext.log.debug(`Running HTTP-only request handler for ${crawlingContext.request.url}`);
134
- this.stats.trackHttpOnlyRequestHandlerRun();
135
- const plainHTTPRun = await this.runRequestHandlerWithPlainHTTP(crawlingContext);
136
- if (plainHTTPRun.ok && this.resultChecker(plainHTTPRun.result)) {
137
- crawlingContext.log.debug(`HTTP-only request handler succeeded for ${crawlingContext.request.url}`);
138
- plainHTTPRun.logs?.forEach(([log, method, ...args]) => log[method](...args));
139
- await this.commitResult(crawlingContext, plainHTTPRun.result);
140
- return;
141
- }
142
- if (!plainHTTPRun.ok) {
143
- crawlingContext.log.exception(plainHTTPRun.error, `HTTP-only request handler failed for ${crawlingContext.request.url}`);
144
- }
145
- else {
146
- crawlingContext.log.warning(`HTTP-only request handler returned a suspicious result for ${crawlingContext.request.url}`);
147
- this.stats.trackRenderingTypeMisprediction();
148
- }
149
- }
150
- crawlingContext.log.debug(`Running browser request handler for ${crawlingContext.request.url}`);
151
- this.stats.trackBrowserRequestHandlerRun();
152
- // Run the request handler in a browser. The copy of the crawler state is kept so that we can perform
153
- // a rendering type detection if necessary. Without this measure, the HTTP request handler would run
154
- // under different conditions, which could change its behavior. Changes done to the crawler state by
155
- // the HTTP request handler will not be committed to the actual storage.
156
- const { result: browserRun, initialStateCopy } = await this.runRequestHandlerInBrowser(crawlingContext);
157
- if (!browserRun.ok) {
158
- throw browserRun.error;
159
- }
160
- await this.commitResult(crawlingContext, browserRun.result);
161
318
  if (shouldDetectRenderingType) {
162
- crawlingContext.log.debug(`Detecting rendering type for ${crawlingContext.request.url}`);
163
- const plainHTTPRun = await this.runRequestHandlerWithPlainHTTP(crawlingContext, initialStateCopy);
164
- const detectionResult = (() => {
319
+ this.inFlightRenderingTypeDetections += 1;
320
+ }
321
+ try {
322
+ if (renderingTypePrediction.renderingType === 'static' && !shouldDetectRenderingType) {
323
+ 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)) {
327
+ crawlingContext.log.debug(`HTTP-only request handler succeeded for ${crawlingContext.request.url}`);
328
+ plainHTTPRun.logs?.forEach(([log, method, ...args]) => log[method](...args));
329
+ await this.commitResult(crawlingContext, plainHTTPRun.result);
330
+ return;
331
+ }
332
+ // Execution will "fall through" and try running the request handler in a browser
165
333
  if (!plainHTTPRun.ok) {
166
- return 'clientOnly';
334
+ const actualError = plainHTTPRun.error instanceof RequestHandlerError
335
+ ? plainHTTPRun.error.cause
336
+ : plainHTTPRun.error;
337
+ if (await this.shouldPropagateError(actualError, crawlingContext)) {
338
+ throw actualError;
339
+ }
340
+ crawlingContext.log.exception(actualError, `HTTP-only request handler failed for ${crawlingContext.request.url}`);
341
+ }
342
+ else {
343
+ crawlingContext.log.warning(`HTTP-only request handler returned a suspicious result for ${crawlingContext.request.url}`);
344
+ this.stats.trackRenderingTypeMisprediction();
167
345
  }
168
- if (this.resultComparator(plainHTTPRun.result, browserRun.result)) {
169
- return 'static';
346
+ }
347
+ crawlingContext.log.debug(`Running browser request handler for ${crawlingContext.request.url}`);
348
+ this.stats.trackBrowserRequestHandlerRun();
349
+ // Run the request handler in a browser. The copy of the crawler state is kept so that we can perform
350
+ // a rendering type detection if necessary. Without this measure, the HTTP request handler would run
351
+ // under different conditions, which could change its behavior. Changes done to the crawler state by
352
+ // the HTTP request handler will not be committed to the actual storage.
353
+ const stateTracker = {
354
+ stateCopy: null,
355
+ async getLiveState(defaultValue = {}) {
356
+ const state = await crawlingContext.useState(defaultValue);
357
+ if (this.stateCopy === null) {
358
+ this.stateCopy = JSON.parse(JSON.stringify(state));
359
+ }
360
+ return state;
361
+ },
362
+ async getStateCopy(defaultValue = {}) {
363
+ if (this.stateCopy === null) {
364
+ return defaultValue;
365
+ }
366
+ return this.stateCopy;
367
+ },
368
+ };
369
+ const browserRun = await this.crawlOne('clientOnly', crawlingContext, stateTracker.getLiveState.bind(stateTracker));
370
+ if (!browserRun.ok) {
371
+ throw browserRun.error;
372
+ }
373
+ browserRun.logs?.forEach(([log, method, ...args]) => log[method](...args));
374
+ await this.commitResult(crawlingContext, browserRun.result);
375
+ if (shouldDetectRenderingType) {
376
+ crawlingContext.log.debug(`Detecting rendering type for ${crawlingContext.request.url}`);
377
+ const plainHTTPRun = await this.crawlOne('static', crawlingContext, stateTracker.getStateCopy.bind(stateTracker));
378
+ const detectionResult = (() => {
379
+ if (!plainHTTPRun.ok) {
380
+ return 'clientOnly';
381
+ }
382
+ const comparisonResult = this.resultComparator(plainHTTPRun.result, browserRun.result);
383
+ if (comparisonResult === true || comparisonResult === 'equal') {
384
+ return 'static';
385
+ }
386
+ if (comparisonResult === false || comparisonResult === 'different') {
387
+ return 'clientOnly';
388
+ }
389
+ return undefined;
390
+ })();
391
+ crawlingContext.log.debug(`Detected rendering type ${detectionResult} for ${crawlingContext.request.url}`);
392
+ if (detectionResult !== undefined) {
393
+ this.renderingTypePredictor.value.storeResult(crawlingContext.request, detectionResult);
170
394
  }
171
- return 'clientOnly';
172
- })();
173
- crawlingContext.log.debug(`Detected rendering type ${detectionResult} for ${crawlingContext.request.url}`);
174
- this.renderingTypePredictor.storeResult(crawlingContext.request, detectionResult);
395
+ }
396
+ }
397
+ finally {
398
+ if (shouldDetectRenderingType) {
399
+ this.inFlightRenderingTypeDetections -= 1;
400
+ }
175
401
  }
176
402
  }
177
403
  async commitResult(crawlingContext, { calls, keyValueStoreChanges }) {
178
404
  await Promise.all([
179
405
  ...calls.pushData.map(async (params) => crawlingContext.pushData(...params)),
180
- ...calls.enqueueLinks.map(async (params) => await crawlingContext.enqueueLinks(...params)),
181
406
  ...calls.addRequests.map(async (params) => crawlingContext.addRequests(...params)),
182
407
  ...Object.entries(keyValueStoreChanges).map(async ([storeIdOrName, changes]) => {
183
- const store = await crawlingContext.getKeyValueStore(storeIdOrName);
408
+ const store = await crawlingContext.getKeyValueStore({ id: storeIdOrName });
184
409
  await Promise.all(Object.entries(changes).map(async ([key, { changedValue, options }]) => store.setValue(key, changedValue, options)));
185
410
  }),
186
411
  ]);
@@ -188,151 +413,37 @@ export class AdaptivePlaywrightCrawler extends PlaywrightCrawler {
188
413
  allowStorageAccess(func) {
189
414
  return async (...args) => withCheckedStorageAccess(() => { }, async () => func(...args));
190
415
  }
191
- async runRequestHandlerInBrowser(crawlingContext) {
192
- const result = new RequestHandlerResult(this.config, AdaptivePlaywrightCrawler.CRAWLEE_STATE_KEY);
193
- let initialStateCopy;
194
- try {
195
- await super._runRequestHandler.call(new Proxy(this, {
196
- get: (target, propertyName, receiver) => {
197
- if (propertyName === 'userProvidedRequestHandler') {
198
- return async (playwrightContext) => withCheckedStorageAccess(() => {
199
- if (this.preventDirectStorageAccess) {
200
- throw new Error('Directly accessing storage in a request handler is not allowed in AdaptivePlaywrightCrawler');
201
- }
202
- }, () => this.adaptiveRequestHandler({
203
- id: crawlingContext.id,
204
- session: crawlingContext.session,
205
- proxyInfo: crawlingContext.proxyInfo,
206
- request: crawlingContext.request,
207
- response: {
208
- url: crawlingContext.response.url(),
209
- statusCode: crawlingContext.response.status(),
210
- headers: crawlingContext.response.headers(),
211
- trailers: {},
212
- complete: true,
213
- redirectUrls: [],
214
- },
215
- log: crawlingContext.log,
216
- page: crawlingContext.page,
217
- querySelector: async (selector, timeoutMs = 5_000) => {
218
- const locator = playwrightContext.page.locator(selector).first();
219
- await locator.waitFor({ timeout: timeoutMs, state: 'attached' });
220
- const $ = await playwrightContext.parseWithCheerio();
221
- return $(selector);
222
- },
223
- async waitForSelector(selector, timeoutMs = 5_000) {
224
- const locator = playwrightContext.page.locator(selector).first();
225
- await locator.waitFor({ timeout: timeoutMs, state: 'attached' });
226
- },
227
- async parseWithCheerio(selector, timeoutMs = 5_000) {
228
- if (selector) {
229
- const locator = playwrightContext.page.locator(selector).first();
230
- await locator.waitFor({ timeout: timeoutMs, state: 'attached' });
231
- }
232
- return playwrightContext.parseWithCheerio();
233
- },
234
- async enqueueLinks(options = {}, timeoutMs = 5_000) {
235
- const selector = options.selector ?? 'a';
236
- const locator = playwrightContext.page.locator(selector).first();
237
- await locator.waitFor({ timeout: timeoutMs, state: 'attached' });
238
- const urls = await extractUrlsFromPage(playwrightContext.page, selector, options.baseUrl ??
239
- playwrightContext.request.loadedUrl ??
240
- playwrightContext.request.url);
241
- await result.enqueueLinks({ ...options, urls });
242
- },
243
- addRequests: result.addRequests,
244
- pushData: result.pushData,
245
- useState: this.allowStorageAccess(async (defaultValue) => {
246
- const state = await result.useState(defaultValue);
247
- if (initialStateCopy === undefined) {
248
- initialStateCopy = JSON.parse(JSON.stringify(state));
249
- }
250
- return state;
251
- }),
252
- getKeyValueStore: this.allowStorageAccess(result.getKeyValueStore),
253
- }));
254
- }
255
- return Reflect.get(target, propertyName, receiver);
256
- },
257
- }), crawlingContext);
258
- return { result: { result, ok: true }, initialStateCopy };
259
- }
260
- catch (error) {
261
- return { result: { error, ok: false }, initialStateCopy };
262
- }
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())();
263
423
  }
264
- async runRequestHandlerWithPlainHTTP(crawlingContext, oldStateCopy) {
265
- const result = new RequestHandlerResult(this.config, AdaptivePlaywrightCrawler.CRAWLEE_STATE_KEY);
266
- const logs = [];
267
- const pageGotoOptions = { timeout: this.navigationTimeoutMillis }; // Irrelevant, but required by BrowserCrawler
268
- try {
269
- await withCheckedStorageAccess(() => {
270
- if (this.preventDirectStorageAccess) {
271
- throw new Error('Directly accessing storage in a request handler is not allowed in AdaptivePlaywrightCrawler');
272
- }
273
- }, async () => addTimeoutToPromise(async () => {
274
- const hookContext = {
275
- id: crawlingContext.id,
276
- session: crawlingContext.session,
277
- proxyInfo: crawlingContext.proxyInfo,
278
- request: crawlingContext.request,
279
- log: this.createLogProxy(crawlingContext.log, logs),
280
- };
281
- await this._executeHooks(this.preNavigationHooks, {
282
- ...hookContext,
283
- get page() {
284
- throw new Error('Page object was used in HTTP-only pre-navigation hook');
285
- },
286
- }, // This is safe because `executeHooks` just passes the context to the hooks which accept the partial context
287
- pageGotoOptions);
288
- const response = await crawlingContext.sendRequest({});
289
- const loadedUrl = response.url;
290
- crawlingContext.request.loadedUrl = loadedUrl;
291
- const $ = load(response.body);
292
- await this.adaptiveRequestHandler({
293
- ...hookContext,
294
- request: crawlingContext.request,
295
- response,
296
- get page() {
297
- throw new Error('Page object was used in HTTP-only request handler');
298
- },
299
- async querySelector(selector, _timeoutMs) {
300
- return $(selector);
301
- },
302
- async waitForSelector(selector, _timeoutMs) {
303
- if ($(selector).get().length === 0) {
304
- throw new Error(`Selector '${selector}' not found.`);
305
- }
306
- },
307
- async parseWithCheerio(selector, _timeoutMs) {
308
- if (selector && $(selector).get().length === 0) {
309
- throw new Error(`Selector '${selector}' not found.`);
310
- }
311
- return $;
312
- },
313
- async enqueueLinks(options = {}) {
314
- const urls = extractUrlsFromCheerio($, options.selector, options.baseUrl ?? loadedUrl);
315
- await result.enqueueLinks({ ...options, urls });
316
- },
317
- addRequests: result.addRequests,
318
- pushData: result.pushData,
319
- useState: async (defaultValue) => {
320
- // return the old state before the browser handler was executed
321
- // when rerunning the handler via HTTP for detection
322
- if (oldStateCopy !== undefined) {
323
- return oldStateCopy ?? defaultValue; // fallback to the default for `null`
324
- }
325
- return this.allowStorageAccess(result.useState)(defaultValue);
326
- },
327
- getKeyValueStore: this.allowStorageAccess(result.getKeyValueStore),
328
- });
329
- await this._executeHooks(this.postNavigationHooks, crawlingContext, pageGotoOptions);
330
- }, this.requestHandlerTimeoutInnerMillis, 'Request handler timed out'));
331
- return { result, logs, ok: true };
332
- }
333
- catch (error) {
334
- return { error, logs, ok: false };
335
- }
424
+ async enqueueLinks(options, request, result) {
425
+ const baseUrl = resolveBaseUrlForEnqueueLinksFiltering({
426
+ enqueueStrategy: options?.strategy,
427
+ finalRequestUrl: request.loadedUrl,
428
+ originalRequestUrl: request.url,
429
+ userProvidedBaseUrl: options?.baseUrl,
430
+ });
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);
336
447
  }
337
448
  createLogProxy(log, logs) {
338
449
  return new Proxy(log, {
@@ -346,8 +457,42 @@ export class AdaptivePlaywrightCrawler extends PlaywrightCrawler {
346
457
  },
347
458
  });
348
459
  }
460
+ async teardown() {
461
+ await super.teardown();
462
+ for (const hook of this.teardownHooks) {
463
+ await hook();
464
+ }
465
+ }
466
+ }
467
+ export function createAdaptivePlaywrightRouter(routesOrSchemas) {
468
+ return Router.create(routesOrSchemas);
349
469
  }
350
- export function createAdaptivePlaywrightRouter(routes) {
351
- return Router.create(routes);
470
+ /**
471
+ * An opt-in {@link AdaptivePlaywrightCrawlerOptions.resultComparator|`resultComparator`} that considers two
472
+ * request handler results equal only if *all* of their observable effects match - the pushed dataset items, the
473
+ * enqueued requests, and the key-value store changes. This is stricter than the default comparator, which only
474
+ * compares dataset items.
475
+ *
476
+ * **Beware:** enqueued URLs are compared exactly. The same page rendered in a browser and via plain HTTP often
477
+ * yields links that differ only in tracking query parameters, for example:
478
+ * - `https://sdk.apify.com/docs/guides/getting-started`
479
+ * - `https://sdk.apify.com/docs/guides/getting-started?__hsfp=1136113150&__hssc=7591405.1.173549427712`
480
+ *
481
+ * Such links are treated as *different*, which will make the crawler favor browser rendering for those pages.
482
+ *
483
+ * **Example usage:**
484
+ * ```ts
485
+ * const crawler = new AdaptivePlaywrightCrawler({
486
+ * resultComparator: fullResultComparator,
487
+ * async requestHandler({ pushData, enqueueLinks }) {
488
+ * // ...
489
+ * },
490
+ * });
491
+ * ```
492
+ */
493
+ export function fullResultComparator(resultA, resultB) {
494
+ return (isDeepStrictEqual(resultA.datasetItems, resultB.datasetItems) &&
495
+ isDeepStrictEqual(resultA.enqueuedUrls, resultB.enqueuedUrls) &&
496
+ isDeepStrictEqual(resultA.enqueuedUrlLists, resultB.enqueuedUrlLists) &&
497
+ isDeepStrictEqual(resultA.keyValueStoreChanges, resultB.keyValueStoreChanges));
352
498
  }
353
- //# sourceMappingURL=adaptive-playwright-crawler.js.map