@crawlee/playwright 4.0.0-beta.26 → 4.0.0-beta.260

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 +15 -15
  2. package/index.d.ts +4 -3
  3. package/index.js +2 -2
  4. package/internals/adaptive-playwright-crawler.d.ts +145 -74
  5. package/internals/adaptive-playwright-crawler.js +492 -296
  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 +55 -0
  9. package/internals/playwright-browser-pool.js +34 -0
  10. package/internals/playwright-crawler.d.ts +179 -149
  11. package/internals/playwright-crawler.js +93 -79
  12. package/internals/playwright-launcher.d.ts +30 -20
  13. package/internals/playwright-launcher.js +24 -18
  14. package/internals/utils/playwright-utils.d.ts +64 -56
  15. package/internals/utils/playwright-utils.js +117 -138
  16. package/internals/utils/rendering-type-prediction.d.ts +34 -14
  17. package/internals/utils/rendering-type-prediction.js +87 -35
  18. package/package.json +19 -22
  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
@@ -1,47 +1,31 @@
1
+ import { setTimeout as delay } from 'node:timers/promises';
1
2
  import { isDeepStrictEqual } from 'node:util';
2
- import { BasicCrawler } from '@crawlee/basic';
3
- import { extractUrlsFromPage } from '@crawlee/browser';
3
+ import { BasicCrawler, ContextPipeline, ContextPipelineInitializationError, ContextPipelineInterruptedError, navigationDeadlineKey, RequestHandlerError, resolveBaseUrlForEnqueueLinksFiltering, Router, Statistics, } from '@crawlee/basic';
4
+ import { CrawlingRequest, extractUrlsFromPage } from '@crawlee/browser';
4
5
  import { CheerioCrawler } from '@crawlee/cheerio';
5
- import { Configuration, RequestHandlerError, RequestHandlerResult, resolveBaseUrlForEnqueueLinksFiltering, Router, Statistics, withCheckedStorageAccess, } from '@crawlee/core';
6
- import { extractUrlsFromCheerio } from '@crawlee/utils';
6
+ import { ResponseWithUrl } from '@crawlee/http-client';
7
+ import { createStorageTransaction, EnqueueStrategy, OwnedOrInjected } from '@crawlee/core';
8
+ import { extractUrlsFromCheerio, MutationJournal, parseArgument } from '@crawlee/utils/internal';
9
+ import { z } from 'zod';
7
10
  import { addTimeoutToPromise } from '@apify/timeout';
8
11
  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
- }
12
+ import { RenderingTypePredictor, } from './utils/rendering-type-prediction.js';
13
+ const adaptiveStatisticStateSchema = z.object({
14
+ /** How many requests were handled by the HTTP-only request handler. */
15
+ httpOnlyRequestHandlerRuns: z.number().default(0),
16
+ /** How many requests were handled in a browser. */
17
+ browserRequestHandlerRuns: z.number().default(0),
18
+ /** How many times the HTTP-only handler produced a result the `resultChecker` rejected. */
19
+ renderingTypeMispredictions: z.number().default(0),
20
+ });
21
+ /**
22
+ * The {@link AdaptivePlaywrightCrawlerStatisticState} fields as a {@link Statistics} state extension, defaults
23
+ * and all. A {@link Statistics} instance to be injected into an {@link AdaptivePlaywrightCrawler} has to carry
24
+ * them - `deserialize.extend()` your own fields onto this one and pass the result as `stateExtension`.
25
+ */
26
+ export const adaptivePlaywrightCrawlerStatisticState = {
27
+ deserialize: adaptiveStatisticStateSchema,
28
+ };
45
29
  const proxyLogMethods = [
46
30
  'error',
47
31
  'exception',
@@ -60,9 +44,9 @@ const proxyLogMethods = [
60
44
  * ```javascript
61
45
  * const crawler = new AdaptivePlaywrightCrawler({
62
46
  * renderingTypeDetectionRatio: 0.1,
63
- * async requestHandler({ querySelector, pushData, enqueueLinks, request, log }) {
47
+ * async requestHandler({ querySelectorAll, pushData, enqueueLinks, request, log }) {
64
48
  * // This function is called to extract data from a single web page
65
- * const $prices = await querySelector('span.price')
49
+ * const $prices = await querySelectorAll('span.price')
66
50
  *
67
51
  * await pushData({
68
52
  * url: request.url,
@@ -78,46 +62,93 @@ const proxyLogMethods = [
78
62
  * 'http://www.example.com/page-2',
79
63
  * ]);
80
64
  * ```
81
- *
82
- * @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
+ /** Owns the browser pool this crawler's runs use, so its per-run resources are released with ours. */
80
+ #browserCrawler;
81
+ /** Nothing of its state is per-run, but it owns a session pool that outlives one. */
82
+ #staticCrawler;
83
+ /**
84
+ * In-flight rendering type detections, plus the pending results of an asynchronous `storeResult`.
85
+ */
86
+ #activeDetections = new Set();
87
+ /**
88
+ * Set once `teardown()` starts, so that requests still in the pool stop opening new detections.
89
+ */
90
+ #shutDown = false;
91
+ constructor(options = {}) {
92
+ const { requestHandler, renderingTypeDetectionRatio = 0.1, renderingTypePredictor, resultChecker, shouldPropagateError, resultComparator, statistics, requestHandlerTimeoutSecs = 60, errorHandler, failedRequestHandler, preNavigationHooks = [], postNavigationHooks = [], extendContext, transactionalStorage, navigationTimeoutSecs, launchContext, headless, browserPool, remoteBrowser, ...rest } = options;
93
+ // The user's value is replaced by `false` in the `super` call below — validate it separately,
94
+ // wrapped in an object so the error still names the field.
95
+ parseArgument({ transactionalStorage }, z.object({ transactionalStorage: BasicCrawler.optionsShape.transactionalStorage }), 'AdaptivePlaywrightCrawlerOptions');
96
+ // The extra fields are only tracked if the injected instance was built with them - the types enforce that,
97
+ // but plain JS callers would otherwise silently increment `undefined` into a sticky `NaN`. Extend
98
+ // `adaptivePlaywrightCrawlerStatisticState` to satisfy this.
99
+ if (statistics !== undefined) {
100
+ parseArgument(statistics.state, z.object({
101
+ httpOnlyRequestHandlerRuns: z.number(),
102
+ browserRequestHandlerRuns: z.number(),
103
+ renderingTypeMispredictions: z.number(),
104
+ }), 'statistics.state');
105
+ }
106
+ // Per-attempt buffering is load-bearing here: the handler runs up to twice per request and the
107
+ // losing attempt's writes must be discardable.
108
+ if (transactionalStorage === false) {
109
+ throw new Error('AdaptivePlaywrightCrawler requires transactional storage - it runs the request handler ' +
110
+ 'multiple times per request and must be able to discard the storage writes of losing ' +
111
+ 'attempts. `transactionalStorage: false` is therefore not supported; a write policy ' +
112
+ 'object is accepted and forwarded to the per-attempt transactions.');
113
+ }
97
114
  super({
98
115
  ...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
116
  errorHandler,
101
117
  failedRequestHandler,
102
- // Same for request handler
103
118
  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);
119
+ requestHandlerTimeoutSecs,
120
+ // The base would build a `Statistics` without the adaptive fields, so provide a default that has them.
121
+ // The cast covers a `StatisticStateExtension` that adds further fields - those can only come from an
122
+ // injected instance, in which case this default is never built.
123
+ statistics: statistics ??
124
+ new Statistics({
125
+ logMessage: `${AdaptivePlaywrightCrawler.name} request statistics:`,
126
+ stateExtension: adaptivePlaywrightCrawlerStatisticState,
127
+ }),
128
+ 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, context) => this.#resultChecker(resultA, context) && this.#resultChecker(resultB, context);
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,304 +156,469 @@ 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
+ navigationTimeoutSecs,
174
+ });
149
175
  const browserCrawler = new PlaywrightCrawler({
150
176
  ...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);
177
+ statistics: new Statistics({ persistenceOptions: { enable: false } }),
178
+ preNavigationHooks: preNavigationHooks,
179
+ postNavigationHooks: postNavigationHooks,
180
+ extendContext,
181
+ navigationTimeoutSecs,
182
+ launchContext,
183
+ headless,
184
+ browserPool,
185
+ remoteBrowser,
186
+ });
187
+ this.#staticCrawler = staticCrawler;
188
+ this.#browserCrawler = browserCrawler;
189
+ // Each attempt needs its own deferred cleanups, and they must share the attempt's cleanup stack: resources the
190
+ // pipeline defers releasing (the page) have to outlive the user's cleanups, which may still use them.
191
+ const deferredCleanup = ContextPipeline.create().compose((_context, onCleanup) => ({
192
+ registerDeferredCleanup: (cleanup) => {
193
+ onCleanup(async () => {
194
+ try {
195
+ await cleanup();
159
196
  }
160
- },
161
- ],
162
- postNavigationHooks: [
163
- async (context, gotoOptions) => {
164
- for (const hook of postNavigationHooks ?? []) {
165
- await hook(context, gotoOptions);
197
+ catch (error) {
198
+ this.log.debug('Error in deferred cleanup', { error });
166
199
  }
167
- },
168
- ],
169
- }, config);
170
- this.teardownHooks.push(browserCrawler.teardown.bind(browserCrawler));
171
- this.staticContextPipeline = staticCrawler.contextPipeline
172
- .compose({
173
- action: this.adaptCheerioContext.bind(this),
174
- })
175
- .compose({
176
- action: async (context) => extendContext ? await extendContext(context) : context,
177
- });
178
- this.browserContextPipeline = browserCrawler.contextPipeline
179
- .compose({
180
- action: this.adaptPlaywrightContext.bind(this),
181
- })
182
- .compose({
183
- action: async (context) => extendContext ? await extendContext(context) : context,
184
- });
185
- this.stats = new AdaptivePlaywrightCrawlerStatistics({
186
- logMessage: `${this.log.getOptions().prefix} request statistics:`,
187
- config,
188
- ...statisticsOptions,
189
- });
190
- this.preventDirectStorageAccess = preventDirectStorageAccess;
200
+ });
201
+ },
202
+ }));
203
+ this.#staticContextPipeline = deferredCleanup
204
+ .chain(staticCrawler.contextPipeline)
205
+ .compose(this.#adaptCheerioContext.bind(this));
206
+ this.#browserContextPipeline = deferredCleanup
207
+ .chain(browserCrawler.contextPipeline)
208
+ .compose(this.#adaptPlaywrightContext.bind(this));
191
209
  }
192
- async _init() {
193
- await this.renderingTypePredictor.initialize();
194
- return await super._init();
210
+ async init() {
211
+ // A crawler can be run again after a teardown.
212
+ this.#shutDown = false;
213
+ // Only the predictor we built ourselves is ours to initialize - an injected one is borrowed, so its
214
+ // lifecycle (including restoring persisted state) stays with whoever created it.
215
+ await this.#renderingTypePredictor.ifOwned((predictor) => predictor.initialize());
216
+ return await super.init();
195
217
  }
196
- 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
- }
218
+ #buildContextPipeline() {
219
+ 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`;
220
+ return ContextPipeline.create().compose(async ({ request }) => ({
221
+ get request() {
222
+ return request;
223
+ },
224
+ get response() {
225
+ throw new Error(errorMessage('response'));
226
+ },
227
+ get page() {
228
+ throw new Error(errorMessage('page'));
229
+ },
230
+ get querySelectorAll() {
231
+ throw new Error(errorMessage('querySelectorAll'));
232
+ },
233
+ get querySelectorOne() {
234
+ throw new Error(errorMessage('querySelectorOne'));
235
+ },
236
+ get waitForSelector() {
237
+ throw new Error(errorMessage('waitForSelector'));
238
+ },
239
+ get parseWithCheerio() {
240
+ throw new Error(errorMessage('parseWithCheerio'));
241
+ },
242
+ get enqueueLinks() {
243
+ throw new Error(errorMessage('enqueueLinks'));
244
+ },
245
+ }));
246
+ }
247
+ async #adaptCheerioContext(cheerioContext) {
202
248
  return {
203
249
  get page() {
204
250
  throw new Error('Page object was used in HTTP-only request handler');
205
251
  },
206
- async querySelector(selector) {
207
- return cheerioContext.$(selector);
208
- },
209
252
  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));
253
+ const urls = extractUrlsFromCheerio(cheerioContext.$, options.selector, options.baseUrl ?? cheerioContext.request.loadedUrl);
254
+ return (await this.#enqueueLinks(urls, options, cheerioContext.request));
213
255
  },
214
- response: cheerioContext.response,
256
+ // The HTTP crawler has already consumed the body; hand out a fresh copy, like the browser side does.
257
+ // HTML bodies are already decoded to UTF-8, so `arrayBuffer()` may disagree with the original headers.
258
+ response: new ResponseWithUrl(cheerioContext.body, cheerioContext.response),
215
259
  };
216
260
  }
217
- async adaptPlaywrightContext(playwrightContext) {
261
+ async #adaptPlaywrightContext(playwrightContext) {
262
+ // Capture the original response to avoid infinite recursion when the getter is copied to the context
218
263
  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');
264
+ // `headers()` can return repeated headers (e.g. `set-cookie`) joined with `\n`, which `Headers` rejects
265
+ const headers = new Headers();
266
+ for (const { name, value } of await originalResponse.headersArray()) {
267
+ headers.append(name, value);
222
268
  }
223
269
  return {
224
- response: new Response(Uint8Array.from(await originalResponse.body()), {
225
- headers: originalResponse.headers(),
270
+ response: new ResponseWithUrl(Uint8Array.from(await originalResponse.body()), {
271
+ headers,
226
272
  status: originalResponse.status(),
227
273
  statusText: originalResponse.statusText(),
274
+ url: originalResponse.url(),
228
275
  }),
229
- async querySelector(selector, timeoutMs = 5000) {
230
- const locator = playwrightContext.page.locator(selector).first();
231
- await locator.waitFor({ timeout: timeoutMs, state: 'attached' });
232
- const $ = await playwrightContext.parseWithCheerio();
233
- return $(selector);
234
- },
235
276
  enqueueLinks: async (options = {}, timeoutMs = 5000) => {
236
277
  // 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));
278
+ const selector = options.selector ?? 'a';
279
+ const locator = playwrightContext.page.locator(selector).first();
280
+ // Wait for client-rendered links, but like the HTTP side, enqueue nothing when there are none.
281
+ await locator.waitFor({ timeout: timeoutMs, state: 'attached' }).catch((error) => {
282
+ if (error.name !== 'TimeoutError')
283
+ throw error;
284
+ });
285
+ const urls = await extractUrlsFromPage(playwrightContext.page, selector, options.baseUrl ?? playwrightContext.request.loadedUrl);
286
+ return (await this.#enqueueLinks(urls, options, playwrightContext.request));
250
287
  },
251
288
  };
252
289
  }
253
- async crawlOne(renderingType, context, useStateFunction) {
254
- const result = new RequestHandlerResult(this.config, AdaptivePlaywrightCrawler.CRAWLEE_STATE_KEY);
290
+ /**
291
+ * Runs one request handler attempt inside its own {@link StorageTransaction}, wrapping the inner
292
+ * (static or browser) context pipeline. The transaction is pushed to `transactions` *at creation
293
+ * time, before the `try`*, as failed attempts are routine here. The caller owns the outcome and
294
+ * disposal, including closing or reverting the {@link ContextModifications}.
295
+ *
296
+ * `request` defaults to the context's; a detection rerun passes a copy so it sees the request as it
297
+ * was before the browser attempt.
298
+ */
299
+ async #crawlOne(renderingType, context, useStateFunction, transactions, request = context.request) {
300
+ const transaction = createStorageTransaction({
301
+ policy: this.#attemptWritePolicy,
302
+ commitTimeoutMillis: this.internalTimeoutMillis,
303
+ });
304
+ transactions.push(transaction);
255
305
  const logs = [];
256
- 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);
306
+ const contextModifications = {
307
+ request: new MutationJournal(),
308
+ state: new MutationJournal(),
309
+ };
310
+ const subCrawlerContext = Object.defineProperties({}, Object.getOwnPropertyDescriptors(context));
311
+ const attemptBoundContextHelpers = {
312
+ request: contextModifications.request.track(request),
313
+ useState: async (defaultValue) => contextModifications.state.track(await useStateFunction(defaultValue)),
314
+ log: this.#createLogProxy(context.log, logs),
315
+ // The sub-crawler pipeline keeps this attempt's navigation deadline on `subCrawlerContext`, but the
316
+ // outer `extendTimeout` only pushes the deadline it finds on `context`, so push this one as well.
317
+ extendTimeout: (secs) => {
318
+ context.extendTimeout(secs);
319
+ if (subCrawlerContext[navigationDeadlineKey] !== undefined) {
320
+ subCrawlerContext[navigationDeadlineKey] += secs * 1000;
321
+ }
264
322
  },
265
- log: this.createLogProxy(context.log, logs),
266
- registerDeferredCleanup: (cleanup) => deferredCleanup.push(cleanup),
267
323
  };
268
- const subCrawlerContext = { ...context, ...resultBoundContextHelpers };
269
- this.resultObjects.set(subCrawlerContext, result);
324
+ // Mark attempt-bound helpers as non-configurable so they survive the sub-crawler context pipeline
325
+ // (which would otherwise override them with the sub-crawler's own versions, losing the binding).
326
+ for (const [key, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(attemptBoundContextHelpers))) {
327
+ Object.defineProperty(subCrawlerContext, key, { ...descriptor, configurable: false });
328
+ }
270
329
  try {
330
+ // Any failure - middleware or handler - ends up in the `ok: false` branch below.
331
+ const rethrow = (error) => {
332
+ throw error;
333
+ };
271
334
  const callAdaptiveRequestHandler = async () => {
272
335
  if (renderingType === 'static') {
273
- await this.staticContextPipeline.call(subCrawlerContext, async (finalContext) => await this.requestHandler(finalContext));
336
+ await this.#staticContextPipeline.call(subCrawlerContext, this.requestHandler.bind(this), rethrow);
274
337
  }
275
338
  else if (renderingType === 'clientOnly') {
276
- await this.browserContextPipeline.call(subCrawlerContext, async (finalContext) => await this.requestHandler(finalContext));
339
+ await this.#browserContextPipeline.call(subCrawlerContext, this.requestHandler.bind(this), rethrow);
277
340
  }
278
341
  };
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 };
342
+ // this crawler overrides `runRequestHandler` and times each rendering-type run itself, so it has
343
+ // to resolve any per-route override too - otherwise routes would be silently ignored here
344
+ const routeTimeoutSecs = this.requestHandler.getTimeoutSecs?.(context.request.label);
345
+ const timeoutMillis = routeTimeoutSecs === undefined ? this.#individualRequestHandlerTimeoutMillis : routeTimeoutSecs * 1000;
346
+ await addTimeoutToPromise(async () => transaction.run(callAdaptiveRequestHandler), timeoutMillis, 'Request handler timed out');
347
+ return { ok: true, transaction, contextModifications, logs };
285
348
  }
286
349
  catch (error) {
287
- return { error, ok: false, logs };
288
- }
289
- finally {
290
- await Promise.all(deferredCleanup.map((cleanup) => cleanup()));
350
+ return { ok: false, error, transaction, contextModifications, logs };
291
351
  }
292
352
  }
293
353
  async runRequestHandler(crawlingContext) {
294
- const renderingTypePrediction = this.renderingTypePredictor.predict(crawlingContext.request);
295
- const shouldDetectRenderingType = Math.random() < renderingTypePrediction.detectionProbabilityRecommendation;
354
+ const renderingTypePrediction = await this.#renderingTypePredictor.value.predict(crawlingContext.request);
355
+ const shouldDetectRenderingType = !this.#shutDown && Math.random() < renderingTypePrediction.detectionProbabilityRecommendation;
296
356
  if (!shouldDetectRenderingType) {
297
357
  crawlingContext.log.debug(`Predicted rendering type ${renderingTypePrediction.renderingType} for ${crawlingContext.request.url}`);
298
358
  }
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;
359
+ // Every transaction created for this request - up to two, since the static-then-browser
360
+ // fall-through and the browser-then-detection pair are mutually exclusive. Disposed in the
361
+ // `finally` below, not earlier: the comparators read the journals after `crawlOne` returns.
362
+ const transactions = [];
363
+ try {
364
+ // A result the `resultChecker` rejected is as good as a detection saying the page needs a browser - but
365
+ // only once the browser run has succeeded. A thrown error says nothing about rendering.
366
+ let httpResultRejected = false;
367
+ if (renderingTypePrediction.renderingType === 'static' && !shouldDetectRenderingType) {
368
+ crawlingContext.log.debug(`Running HTTP-only request handler for ${crawlingContext.request.url}`);
369
+ this.statistics.state.httpOnlyRequestHandlerRuns++;
370
+ const plainHTTPRun = await this.#crawlOne('static', crawlingContext, crawlingContext.useState, transactions);
371
+ if (plainHTTPRun.ok &&
372
+ this.#resultChecker(plainHTTPRun.transaction, { request: crawlingContext.request })) {
373
+ crawlingContext.log.debug(`HTTP-only request handler succeeded for ${crawlingContext.request.url}`);
374
+ plainHTTPRun.logs.forEach(([log, method, ...args]) => log[method](...args));
375
+ this.#keepContextModifications(plainHTTPRun.contextModifications);
376
+ await plainHTTPRun.transaction.commit();
377
+ return;
339
378
  }
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 = (() => {
379
+ // Execution will "fall through" and try running the request handler in a browser
352
380
  if (!plainHTTPRun.ok) {
353
- return 'clientOnly';
354
- }
355
- const comparisonResult = this.resultComparator(plainHTTPRun.result, browserRun.result);
356
- if (comparisonResult === true || comparisonResult === 'equal') {
357
- return 'static';
381
+ const actualError = plainHTTPRun.error instanceof RequestHandlerError ||
382
+ plainHTTPRun.error instanceof ContextPipelineInitializationError
383
+ ? plainHTTPRun.error.cause
384
+ : plainHTTPRun.error;
385
+ if (actualError instanceof ContextPipelineInterruptedError ||
386
+ (await this.#shouldPropagateError(actualError, crawlingContext))) {
387
+ this.#keepContextModifications(plainHTTPRun.contextModifications);
388
+ throw actualError;
389
+ }
390
+ crawlingContext.log.exception(actualError, `HTTP-only request handler failed for ${crawlingContext.request.url}`);
358
391
  }
359
- if (comparisonResult === false || comparisonResult === 'different') {
360
- return 'clientOnly';
392
+ else {
393
+ crawlingContext.log.warning(`HTTP-only request handler returned a suspicious result for ${crawlingContext.request.url}`);
394
+ this.statistics.state.renderingTypeMispredictions++;
395
+ httpResultRejected = true;
361
396
  }
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);
397
+ // The browser attempt starts from the request and state as they were before the rejected one.
398
+ this.#discardContextModifications(plainHTTPRun.contextModifications, crawlingContext);
399
+ }
400
+ crawlingContext.log.debug(`Running browser request handler for ${crawlingContext.request.url}`);
401
+ this.statistics.state.browserRequestHandlerRuns++;
402
+ const browserRun = await this.#crawlOne('clientOnly', crawlingContext, crawlingContext.useState, transactions);
403
+ if (!browserRun.ok) {
404
+ // A failed browser attempt is handed to the retry logic with its writes - `userData` stashed for the
405
+ // next attempt must survive.
406
+ this.#keepContextModifications(browserRun.contextModifications);
407
+ throw browserRun.error;
408
+ }
409
+ try {
410
+ browserRun.logs.forEach(([log, method, ...args]) => log[method](...args));
411
+ // The `afterStorageCommit` callbacks belong to this attempt, so the detection rerun undoes their
412
+ // writes too - the journals stay open until they have run.
413
+ await browserRun.transaction.commit();
414
+ }
415
+ finally {
416
+ this.#keepContextModifications(browserRun.contextModifications);
417
+ }
418
+ // Past the shutdown fence, `teardown()` has already drained - and possibly torn down the predictor.
419
+ if (httpResultRejected && !this.#shutDown) {
420
+ this.#storeRenderingType(crawlingContext.request, 'clientOnly');
421
+ }
422
+ if (shouldDetectRenderingType) {
423
+ const detectionPromise = (async () => {
424
+ crawlingContext.log.debug(`Detecting rendering type for ${crawlingContext.request.url}`);
425
+ // The detection attempt's transaction is never committed - its writes exist only for the
426
+ // result comparison. It runs on copies of the request and the crawler state with the browser
427
+ // attempt's writes undone, so both attempts start from the same conditions, and the copies go
428
+ // away with it.
429
+ const { request: requestJournal, state: stateJournal } = browserRun.contextModifications;
430
+ let stateCopy;
431
+ const plainHTTPRun = await this.#crawlOne('static', crawlingContext, async (defaultValue) => (stateCopy ??= stateJournal.undoOnto(JSON.parse(JSON.stringify(await crawlingContext.useState(defaultValue))))), transactions, requestJournal.undoOnto(CrawlingRequest.fromSchema(JSON.parse(JSON.stringify(crawlingContext.request)))));
432
+ const detectionResult = (() => {
433
+ if (!plainHTTPRun.ok) {
434
+ return 'clientOnly';
435
+ }
436
+ const comparisonResult = this.#resultComparator(plainHTTPRun.transaction, browserRun.transaction, {
437
+ request: crawlingContext.request,
438
+ });
439
+ if (comparisonResult === true || comparisonResult === 'equal') {
440
+ return 'static';
441
+ }
442
+ if (comparisonResult === false || comparisonResult === 'different') {
443
+ return 'clientOnly';
444
+ }
445
+ return undefined;
446
+ })();
447
+ crawlingContext.log.debug(`Detected rendering type ${detectionResult} for ${crawlingContext.request.url}`);
448
+ if (detectionResult !== undefined) {
449
+ this.#storeRenderingType(crawlingContext.request, detectionResult);
450
+ }
451
+ })();
452
+ await this.#trackDetection(detectionPromise);
453
+ }
454
+ }
455
+ finally {
456
+ // A still-open transaction here belongs to a discarded attempt - roll it back, then release.
457
+ for (const transaction of transactions) {
458
+ transaction.rollback();
459
+ transaction.dispose();
367
460
  }
368
461
  }
369
462
  }
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
- ]);
463
+ #keepContextModifications(modifications) {
464
+ modifications.request.close();
465
+ modifications.state.close();
379
466
  }
380
- allowStorageAccess(func) {
381
- return async (...args) => withCheckedStorageAccess(() => { }, async () => func(...args));
467
+ #discardContextModifications(modifications, { request, log }) {
468
+ // Nothing else writes to a request while its handler runs, so a conflict means a write that bypassed
469
+ // the context (e.g. through a stashed reference) - it stays, but it is not what the retry logic expects.
470
+ modifications.request.revert({
471
+ onConflict: (key) => log.warning(`Request ${request.url}: '${String(key)}' was modified outside the request handler`),
472
+ });
473
+ // The state is shared with concurrent handlers; a conflict is one of them having written the same
474
+ // property meanwhile. Their value wins, and this attempt's write stays with it.
475
+ modifications.state.revert({
476
+ onConflict: (key) => log.debug(`Crawler state '${String(key)}' was changed concurrently; keeping the newer value`),
477
+ });
382
478
  }
383
- async enqueueLinks(options, request, result) {
479
+ #storeRenderingType(request, renderingType) {
480
+ // Deliberately not awaited: a predictor that persists asynchronously gets to keep batching its writes,
481
+ // and `drainRenderingDetections()` catches whatever is still pending.
482
+ const stored = this.#renderingTypePredictor.value.storeResult(request, renderingType);
483
+ if (stored !== undefined) {
484
+ // Nothing downstream awaits this, so a failed write would otherwise be silent.
485
+ void this.#trackDetection(Promise.resolve(stored).catch((error) => this.log.exception(error, `Failed to store the rendering type detection result for ${request.url}`)));
486
+ }
487
+ }
488
+ async #enqueueLinks(urls, options, request) {
384
489
  const baseUrl = resolveBaseUrlForEnqueueLinksFiltering({
385
490
  enqueueStrategy: options?.strategy,
386
491
  finalRequestUrl: request.loadedUrl,
387
492
  originalRequestUrl: request.url,
388
493
  userProvidedBaseUrl: options?.baseUrl,
389
494
  });
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);
495
+ const requestsWithDepth = this.addCrawlDepthRequestGenerator(urls, request.crawlDepth + 1);
496
+ // The per-attempt transaction buffers these (the queue policy defaults to `deferred` here),
497
+ // so a discarded attempt's enqueues never reach the queue.
498
+ return await this.addRequests(requestsWithDepth, {
499
+ ...options,
500
+ baseUrl,
501
+ strategy: options.strategy ?? EnqueueStrategy.SameHostname,
502
+ });
405
503
  }
406
- createLogProxy(log, logs) {
504
+ #createLogProxy(log, logs) {
407
505
  return new Proxy(log, {
408
- get(target, propertyName, receiver) {
506
+ get(target, propertyName) {
409
507
  if (proxyLogMethods.includes(propertyName)) {
410
508
  return (...args) => {
411
509
  logs.push([target, propertyName, ...args]);
412
510
  };
413
511
  }
414
- return Reflect.get(target, propertyName, receiver);
512
+ const value = Reflect.get(target, propertyName, target);
513
+ // Bind non-intercepted methods to the target instance so private #-fields
514
+ // (e.g. BaseCrawleeLogger.#options, #warningsLogged) do not throw TypeError at runtime.
515
+ if (typeof value === 'function') {
516
+ return value.bind(target);
517
+ }
518
+ return value;
415
519
  },
416
520
  });
417
521
  }
522
+ #trackDetection(promise) {
523
+ this.#activeDetections.add(promise);
524
+ // Not `finally()`: the promise it derives would reject on its own and go unhandled. A rejection here
525
+ // belongs to whoever awaits the original, or to `allSettled` in the drain.
526
+ void promise.catch(() => { }).then(() => this.#activeDetections.delete(promise));
527
+ return promise;
528
+ }
529
+ /**
530
+ * Number of rendering type detections that have not settled yet, including results the predictor is
531
+ * still persisting.
532
+ */
533
+ get inFlightRenderingTypeDetectionCount() {
534
+ return this.#activeDetections.size;
535
+ }
536
+ /**
537
+ * Waits for in-flight rendering type detections to settle, bounded by `timeoutMillis` (defaults to the
538
+ * internal timeout).
539
+ */
540
+ async drainRenderingDetections({ timeoutMillis } = {}) {
541
+ if (this.#activeDetections.size === 0) {
542
+ return;
543
+ }
544
+ const drained = (async () => {
545
+ while (this.#activeDetections.size > 0) {
546
+ await Promise.allSettled(Array.from(this.#activeDetections));
547
+ }
548
+ })();
549
+ const millis = timeoutMillis ?? this.internalTimeoutMillis;
550
+ // A caller opting out of the bound would otherwise get an immediate spurious timeout - `setTimeout`
551
+ // clamps a non-finite delay to 1ms.
552
+ if (!Number.isFinite(millis)) {
553
+ await drained;
554
+ return;
555
+ }
556
+ const abortTimer = new AbortController();
557
+ try {
558
+ const outcome = await Promise.race([
559
+ drained.then(() => 'drained'),
560
+ delay(millis, 'timedOut', { signal: abortTimer.signal }).catch(() => 'aborted'),
561
+ ]);
562
+ if (outcome === 'timedOut') {
563
+ this.log.warning(`Timed out after ${millis / 1e3} seconds waiting for ${this.#activeDetections.size} rendering type detection(s) to settle - their results may be lost.`);
564
+ }
565
+ }
566
+ finally {
567
+ abortTimer.abort();
568
+ }
569
+ }
570
+ /**
571
+ * Stops the crawler immediately, but not before rendering type detections already under way (and results
572
+ * the predictor is still persisting) have settled - see
573
+ * {@link AdaptivePlaywrightCrawler.drainRenderingDetections|`drainRenderingDetections()`}. Requests
574
+ * that are still running are not waited for, unlike {@link BasicCrawler.stop|`stop()`}.
575
+ */
418
576
  async teardown() {
577
+ // Called from outside `run()` - under `keepAlive`, say - the pool keeps dispatching until
578
+ // `super.teardown()` aborts it, and a request starting during the drain would open a detection the
579
+ // drain has already passed. Closing that first makes the drain a fence.
580
+ this.#shutDown = true;
581
+ await this.drainRenderingDetections();
419
582
  await super.teardown();
420
- for (const hook of this.teardownHooks) {
421
- await hook();
422
- }
583
+ // Mirrors the owned-only `initialize()` in `init()` - without this, the predictor we built keeps its
584
+ // PERSIST_STATE listener registered after the crawl and never gets a final write.
585
+ await this.#renderingTypePredictor.ifOwned((predictor) => predictor.teardown());
586
+ await this.#browserCrawler.teardown();
423
587
  }
588
+ async destroy() {
589
+ await super.destroy();
590
+ await this.#staticCrawler.destroy();
591
+ await this.#browserCrawler.destroy();
592
+ }
593
+ }
594
+ export function createAdaptivePlaywrightRouter(routesOrSchemas) {
595
+ return Router.create(routesOrSchemas);
424
596
  }
425
- export function createAdaptivePlaywrightRouter(routes) {
426
- return Router.create(routes);
597
+ /**
598
+ * An opt-in {@link AdaptivePlaywrightCrawlerOptions.resultComparator|`resultComparator`} that considers two
599
+ * request handler results equal only if *all* of their observable effects match - the pushed dataset items, the
600
+ * enqueued requests, and the key-value store changes. This is stricter than the default comparator, which only
601
+ * compares dataset items.
602
+ *
603
+ * **Beware:** enqueued URLs are compared exactly. The same page rendered in a browser and via plain HTTP often
604
+ * yields links that differ only in tracking query parameters, for example:
605
+ * - `https://sdk.apify.com/docs/guides/getting-started`
606
+ * - `https://sdk.apify.com/docs/guides/getting-started?__hsfp=1136113150&__hssc=7591405.1.173549427712`
607
+ *
608
+ * Such links are treated as *different*, which will make the crawler favor browser rendering for those pages.
609
+ *
610
+ * **Example usage:**
611
+ * ```ts
612
+ * const crawler = new AdaptivePlaywrightCrawler({
613
+ * resultComparator: fullResultComparator,
614
+ * async requestHandler({ pushData, enqueueLinks }) {
615
+ * // ...
616
+ * },
617
+ * });
618
+ * ```
619
+ */
620
+ export function fullResultComparator(resultA, resultB) {
621
+ return (isDeepStrictEqual(resultA.datasetItems, resultB.datasetItems) &&
622
+ isDeepStrictEqual(resultA.enqueuedUrls, resultB.enqueuedUrls) &&
623
+ isDeepStrictEqual(resultA.keyValueStoreChanges, resultB.keyValueStoreChanges));
427
624
  }
428
- //# sourceMappingURL=adaptive-playwright-crawler.js.map