@crawlee/playwright 4.0.0-rc.0 → 4.0.0-rc.1

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.
package/README.md CHANGED
@@ -34,7 +34,7 @@ Crawlee is available as the [`crawlee`](https://www.npmjs.com/package/crawlee) N
34
34
 
35
35
  We recommend visiting the [Introduction tutorial](https://crawlee.dev/js/docs/introduction) in Crawlee documentation for more information.
36
36
 
37
- > Crawlee requires **Node.js 16 or higher**.
37
+ > Crawlee requires **Node.js 22.13 or higher**.
38
38
 
39
39
  ### With Crawlee CLI
40
40
 
package/index.d.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  export * from '@crawlee/browser';
2
2
  export * from './internals/playwright-browser-pool.js';
3
3
  export * from './internals/playwright-crawler.js';
4
- export * from './internals/playwright-launcher.js';
4
+ export { launchPlaywright } from './internals/playwright-launcher.js';
5
+ export type { PlaywrightLaunchContext } from './internals/playwright-launcher.js';
5
6
  export * from './internals/adaptive-playwright-crawler.js';
6
7
  export { RenderingTypePredictor } from './internals/utils/rendering-type-prediction.js';
7
8
  export * as playwrightUtils from './internals/utils/playwright-utils.js';
package/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  export * from '@crawlee/browser';
2
2
  export * from './internals/playwright-browser-pool.js';
3
3
  export * from './internals/playwright-crawler.js';
4
- export * from './internals/playwright-launcher.js';
4
+ export { launchPlaywright } from './internals/playwright-launcher.js';
5
5
  export * from './internals/adaptive-playwright-crawler.js';
6
6
  export { RenderingTypePredictor } from './internals/utils/rendering-type-prediction.js';
7
7
  export * as playwrightUtils from './internals/utils/playwright-utils.js';
@@ -1,15 +1,14 @@
1
- import type { BrowserHook, LoadedRequest, Request, RouterHandler, RouteSchemas, RoutesFromSchemas } from '@crawlee/browser';
2
- import type { BasicCrawlerOptions } from '@crawlee/basic';
1
+ import type { BrowserHook, LoadedRequest, CrawlingRequest, RouterHandler, RouteSchemas, RoutesFromSchemas } from '@crawlee/browser';
2
+ import type { BasicCrawlerOptions, CrawlingContext, EnqueueLinksOptions, GetUserDataFromRequest, RouterRoutes } from '@crawlee/basic';
3
3
  import { BasicCrawler } from '@crawlee/basic';
4
- import type { ContextPipeline, CrawlingContext, EnqueueLinksOptions, GetUserDataFromRequest, RouterRoutes, StorageTransactionView } from '@crawlee/core';
4
+ import type { StorageTransactionView } from '@crawlee/core';
5
5
  import type { Dictionary, Awaitable } from '@crawlee/types';
6
- import { type CheerioRoot } from '@crawlee/utils/internal';
7
- import { type Cheerio } from 'cheerio';
6
+ import { type Cheerio, type CheerioAPI } from 'cheerio';
8
7
  import type { AnyNode } from 'domhandler';
9
8
  // @ts-ignore optional peer dependency or compatibility with es2022
10
9
  import type { Page } from 'playwright';
11
10
  import { z } from 'zod';
12
- import type { PlaywrightCrawlingContext, PlaywrightGotoOptions } from './playwright-crawler.js';
11
+ import type { PlaywrightCrawlerOptions, PlaywrightCrawlingContext, PlaywrightGotoOptions } from './playwright-crawler.js';
13
12
  import { type IRenderingTypePredictor } from './utils/rendering-type-prediction.js';
14
13
  declare const adaptiveStatisticStateSchema: z.ZodObject<{
15
14
  httpOnlyRequestHandlerRuns: z.ZodDefault<z.ZodNumber>;
@@ -35,7 +34,7 @@ export declare const adaptivePlaywrightCrawlerStatisticState: {
35
34
  }, z.core.$strip>;
36
35
  };
37
36
  export interface AdaptivePlaywrightCrawlerContext<UserData extends Dictionary = any> extends CrawlingContext<UserData> {
38
- request: LoadedRequest<Request<UserData>>;
37
+ request: LoadedRequest<CrawlingRequest<UserData>>;
39
38
  /**
40
39
  * The HTTP response, either from the HTTP client or from the initial request from playwright's navigation.
41
40
  */
@@ -80,19 +79,19 @@ export interface AdaptivePlaywrightCrawlerContext<UserData extends Dictionary =
80
79
  * });
81
80
  * ```
82
81
  */
83
- parseWithCheerio(selector?: string, timeoutMs?: number): Promise<CheerioRoot>;
82
+ parseWithCheerio(selector?: string, timeoutMs?: number): Promise<CheerioAPI>;
84
83
  enqueueLinks(options?: EnqueueLinksOptions): Promise<unknown>;
85
84
  }
86
85
  interface AdaptiveHookContext extends Pick<AdaptivePlaywrightCrawlerContext, 'id' | 'session' | 'proxyInfo' | 'log'> {
87
86
  page?: Page;
88
- request: Request;
87
+ request: CrawlingRequest;
89
88
  gotoOptions?: PlaywrightGotoOptions;
90
89
  }
91
90
  type AdaptiveHook<ContextExtension = Dictionary<never>> = BrowserHook<AdaptiveHookContext, ContextExtension>;
92
91
  type AdaptivePostNavigationHook<ContextExtension = Dictionary<never>> = BrowserHook<Omit<AdaptiveHookContext, 'request'> & {
93
- request: LoadedRequest<Request>;
92
+ request: LoadedRequest<CrawlingRequest>;
94
93
  }, ContextExtension>;
95
- export interface AdaptivePlaywrightCrawlerOptions<ContextExtension = Dictionary<never>, ExtendedContext extends AdaptivePlaywrightCrawlerContext = AdaptivePlaywrightCrawlerContext & ContextExtension, Routes extends Record<keyof Routes, Dictionary> = Record<string, GetUserDataFromRequest<AdaptivePlaywrightCrawlerContext['request']>>, StatisticStateExtension extends AdaptivePlaywrightCrawlerStatisticState = AdaptivePlaywrightCrawlerStatisticState> extends Omit<BasicCrawlerOptions<AdaptivePlaywrightCrawlerContext, ContextExtension, ExtendedContext, Routes, StatisticStateExtension>, 'preNavigationHooks' | 'postNavigationHooks'> {
94
+ export interface AdaptivePlaywrightCrawlerOptions<ContextExtension = Dictionary<never>, ExtendedContext extends AdaptivePlaywrightCrawlerContext = AdaptivePlaywrightCrawlerContext & ContextExtension, Routes extends Record<keyof Routes, Dictionary> = Record<string, GetUserDataFromRequest<AdaptivePlaywrightCrawlerContext['request']>>, StatisticStateExtension extends AdaptivePlaywrightCrawlerStatisticState = AdaptivePlaywrightCrawlerStatisticState> extends Omit<BasicCrawlerOptions<AdaptivePlaywrightCrawlerContext, ContextExtension, ExtendedContext, Routes, StatisticStateExtension>, 'preNavigationHooks' | 'postNavigationHooks' | 'contextPipelineBuilder'>, Pick<PlaywrightCrawlerOptions, 'launchContext' | 'headless' | 'browserPool' | 'remoteBrowser'> {
96
95
  /**
97
96
  * Async functions that are sequentially evaluated before the navigation. Good for setting additional cookies.
98
97
  * The function accepts a subset of the crawling context. If you attempt to access the `page` property during HTTP-only crawling,
@@ -186,29 +185,27 @@ export declare class AdaptivePlaywrightCrawler<ContextExtension = Dictionary<nev
186
185
  #private;
187
186
  constructor(options?: AdaptivePlaywrightCrawlerOptions<ContextExtension, ExtendedContext, Routes, StatisticStateExtension>);
188
187
  protected init(): Promise<void>;
189
- protected buildContextPipeline(): ContextPipeline<CrawlingContext<Dictionary>, CrawlingContext<Dictionary> & {
190
- readonly request: LoadedRequest<Request<Dictionary>>;
191
- readonly response: Response;
192
- readonly page: Page;
193
- readonly querySelector: AdaptivePlaywrightCrawlerContext["querySelector"];
194
- readonly querySelectorAll: AdaptivePlaywrightCrawlerContext["querySelectorAll"];
195
- readonly waitForSelector: AdaptivePlaywrightCrawlerContext["waitForSelector"];
196
- readonly parseWithCheerio: AdaptivePlaywrightCrawlerContext["parseWithCheerio"];
197
- readonly enqueueLinks: AdaptivePlaywrightCrawlerContext["enqueueLinks"];
198
- }>;
199
- private adaptCheerioContext;
200
- private adaptPlaywrightContext;
201
- /**
202
- * Runs one request handler attempt inside its own {@link StorageTransaction}, wrapping the inner
203
- * (static or browser) context pipeline. The transaction is pushed to `transactions` *at creation
204
- * time, before the `try`* - the `ok: false` branch of the returned {@link Result} carries no
205
- * result, and failed attempts are routine here. The caller owns the outcome and disposal.
206
- */
207
- private crawlOne;
208
188
  protected runRequestHandler(crawlingContext: CrawlingContext): Promise<void>;
209
- private enqueueLinks;
210
- private createLogProxy;
189
+ /**
190
+ * Number of rendering type detections that have not settled yet, including results the predictor is
191
+ * still persisting.
192
+ */
193
+ get inFlightRenderingTypeDetectionCount(): number;
194
+ /**
195
+ * Waits for in-flight rendering type detections to settle, bounded by `timeoutMillis` (defaults to the
196
+ * internal timeout).
197
+ */
198
+ drainRenderingDetections({ timeoutMillis }?: {
199
+ timeoutMillis?: number;
200
+ }): Promise<void>;
201
+ /**
202
+ * Stops the crawler immediately, but not before rendering type detections already under way (and results
203
+ * the predictor is still persisting) have settled - see
204
+ * {@link AdaptivePlaywrightCrawler.drainRenderingDetections|`drainRenderingDetections()`}. Requests
205
+ * that are still running are not waited for, unlike {@link BasicCrawler.stop|`stop()`}.
206
+ */
211
207
  teardown(): Promise<void>;
208
+ destroy(): Promise<void>;
212
209
  }
213
210
  export declare function createAdaptivePlaywrightRouter<Context extends AdaptivePlaywrightCrawlerContext = AdaptivePlaywrightCrawlerContext, Routes extends Record<keyof Routes, Dictionary> = Record<string, GetUserDataFromRequest<Context['request']>>>(routes?: RouterRoutes<Context, Routes>): RouterHandler<Context, Routes>;
214
211
  export declare function createAdaptivePlaywrightRouter<Context extends AdaptivePlaywrightCrawlerContext = AdaptivePlaywrightCrawlerContext, UserData extends Dictionary = GetUserDataFromRequest<Context['request']>>(routes?: RouterRoutes<Context, Record<string, UserData>>): RouterHandler<Context, Record<string, UserData>>;
@@ -1,9 +1,10 @@
1
+ import { setTimeout as delay } from 'node:timers/promises';
1
2
  import { isDeepStrictEqual } from 'node:util';
2
- import { BasicCrawler } from '@crawlee/basic';
3
+ import { BasicCrawler, ContextPipeline, ContextPipelineInitializationError, ContextPipelineInterruptedError, RequestHandlerError, resolveBaseUrlForEnqueueLinksFiltering, Router, Statistics, } from '@crawlee/basic';
3
4
  import { extractUrlsFromPage } from '@crawlee/browser';
4
5
  import { CheerioCrawler } from '@crawlee/cheerio';
5
- import { createStorageTransaction, EnqueueStrategy, OwnedOrInjected, parseArgument, RequestHandlerError, resolveBaseUrlForEnqueueLinksFiltering, Router, Statistics, } from '@crawlee/core';
6
- import { extractUrlsFromCheerio } from '@crawlee/utils/internal';
6
+ import { createStorageTransaction, EnqueueStrategy, OwnedOrInjected } from '@crawlee/core';
7
+ import { extractUrlsFromCheerio, parseArgument } from '@crawlee/utils/internal';
7
8
  import { z } from 'zod';
8
9
  import { addTimeoutToPromise } from '@apify/timeout';
9
10
  import { PlaywrightCrawler } from './playwright-crawler.js';
@@ -76,9 +77,20 @@ export class AdaptivePlaywrightCrawler extends BasicCrawler {
76
77
  * a discarded attempt's enqueues must never reach the queue.
77
78
  */
78
79
  #attemptWritePolicy;
79
- #teardownHooks = [];
80
+ /** Owns the browser pool this crawler's runs use, so its per-run resources are released with ours. */
81
+ #browserCrawler;
82
+ /** Nothing of its state is per-run, but it owns a session pool that outlives one. */
83
+ #staticCrawler;
84
+ /**
85
+ * In-flight rendering type detections, plus the pending results of an asynchronous `storeResult`.
86
+ */
87
+ #activeDetections = new Set();
88
+ /**
89
+ * Set once `teardown()` starts, so that requests still in the pool stop opening new detections.
90
+ */
91
+ #shutDown = false;
80
92
  constructor(options = {}) {
81
- const { requestHandler, renderingTypeDetectionRatio = 0.1, renderingTypePredictor, resultChecker, shouldPropagateError, resultComparator, statistics, requestHandlerTimeoutSecs = 60, errorHandler, failedRequestHandler, preNavigationHooks = [], postNavigationHooks = [], extendContext, contextPipelineBuilder, transactionalStorage, ...rest } = options;
93
+ const { requestHandler, renderingTypeDetectionRatio = 0.1, renderingTypePredictor, resultChecker, shouldPropagateError, resultComparator, statistics, requestHandlerTimeoutSecs = 60, errorHandler, failedRequestHandler, preNavigationHooks = [], postNavigationHooks = [], extendContext, transactionalStorage, launchContext, headless, browserPool, remoteBrowser, ...rest } = options;
82
94
  // The user's value is replaced by `false` in the `super` call below — validate it separately,
83
95
  // wrapped in an object so the error still names the field.
84
96
  parseArgument({ transactionalStorage }, z.object({ transactionalStorage: BasicCrawler.optionsShape.transactionalStorage }), 'AdaptivePlaywrightCrawlerOptions');
@@ -114,7 +126,7 @@ export class AdaptivePlaywrightCrawler extends BasicCrawler {
114
126
  logMessage: `${AdaptivePlaywrightCrawler.name} request statistics:`,
115
127
  stateExtension: adaptivePlaywrightCrawlerStatisticState,
116
128
  }),
117
- contextPipelineBuilder: contextPipelineBuilder ?? (() => this.buildContextPipeline()),
129
+ contextPipelineBuilder: () => this.#buildContextPipeline(),
118
130
  // The base crawler must not wrap requests in a transaction of its own - this crawler opens
119
131
  // one per request handler attempt in `crawlOne` instead, forwarding the write policy of the
120
132
  // user-facing option (validated above) to those.
@@ -166,53 +178,54 @@ export class AdaptivePlaywrightCrawler extends BasicCrawler {
166
178
  preNavigationHooks: preNavigationHooks,
167
179
  postNavigationHooks: postNavigationHooks,
168
180
  extendContext,
181
+ launchContext,
182
+ headless,
183
+ browserPool,
184
+ remoteBrowser,
169
185
  });
170
- this.#teardownHooks.push(browserCrawler.teardown.bind(browserCrawler));
171
- this.#staticContextPipeline = staticCrawler.contextPipeline.compose({
172
- action: this.adaptCheerioContext.bind(this),
173
- });
174
- this.#browserContextPipeline = browserCrawler.contextPipeline.compose({
175
- action: this.adaptPlaywrightContext.bind(this),
176
- });
186
+ this.#staticCrawler = staticCrawler;
187
+ this.#browserCrawler = browserCrawler;
188
+ this.#staticContextPipeline = staticCrawler.contextPipeline.compose(this.#adaptCheerioContext.bind(this));
189
+ this.#browserContextPipeline = browserCrawler.contextPipeline.compose(this.#adaptPlaywrightContext.bind(this));
177
190
  }
178
191
  async init() {
192
+ // A crawler can be run again after a teardown.
193
+ this.#shutDown = false;
179
194
  // Only the predictor we built ourselves is ours to initialize - an injected one is borrowed, so its
180
195
  // lifecycle (including restoring persisted state) stays with whoever created it.
181
196
  await this.#renderingTypePredictor.ifOwned((predictor) => predictor.initialize());
182
197
  return await super.init();
183
198
  }
184
- buildContextPipeline() {
199
+ #buildContextPipeline() {
185
200
  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`;
186
- return super.buildContextPipeline().compose({
187
- action: async ({ request }) => ({
188
- get request() {
189
- return request;
190
- },
191
- get response() {
192
- throw new Error(errorMessage('response'));
193
- },
194
- get page() {
195
- throw new Error(errorMessage('page'));
196
- },
197
- get querySelector() {
198
- throw new Error(errorMessage('querySelector'));
199
- },
200
- get querySelectorAll() {
201
- throw new Error(errorMessage('querySelectorAll'));
202
- },
203
- get waitForSelector() {
204
- throw new Error(errorMessage('waitForSelector'));
205
- },
206
- get parseWithCheerio() {
207
- throw new Error(errorMessage('parseWithCheerio'));
208
- },
209
- get enqueueLinks() {
210
- throw new Error(errorMessage('enqueueLinks'));
211
- },
212
- }),
213
- });
201
+ return ContextPipeline.create().compose(async ({ request }) => ({
202
+ get request() {
203
+ return request;
204
+ },
205
+ get response() {
206
+ throw new Error(errorMessage('response'));
207
+ },
208
+ get page() {
209
+ throw new Error(errorMessage('page'));
210
+ },
211
+ get querySelector() {
212
+ throw new Error(errorMessage('querySelector'));
213
+ },
214
+ get querySelectorAll() {
215
+ throw new Error(errorMessage('querySelectorAll'));
216
+ },
217
+ get waitForSelector() {
218
+ throw new Error(errorMessage('waitForSelector'));
219
+ },
220
+ get parseWithCheerio() {
221
+ throw new Error(errorMessage('parseWithCheerio'));
222
+ },
223
+ get enqueueLinks() {
224
+ throw new Error(errorMessage('enqueueLinks'));
225
+ },
226
+ }));
214
227
  }
215
- async adaptCheerioContext(cheerioContext) {
228
+ async #adaptCheerioContext(cheerioContext) {
216
229
  return {
217
230
  get page() {
218
231
  throw new Error('Page object was used in HTTP-only request handler');
@@ -225,12 +238,12 @@ export class AdaptivePlaywrightCrawler extends BasicCrawler {
225
238
  },
226
239
  enqueueLinks: async (options = {}) => {
227
240
  const urls = extractUrlsFromCheerio(cheerioContext.$, options.selector, options.baseUrl ?? cheerioContext.request.loadedUrl);
228
- return (await this.enqueueLinks(urls, options, cheerioContext.request));
241
+ return (await this.#enqueueLinks(urls, options, cheerioContext.request));
229
242
  },
230
243
  response: cheerioContext.response,
231
244
  };
232
245
  }
233
- async adaptPlaywrightContext(playwrightContext) {
246
+ async #adaptPlaywrightContext(playwrightContext) {
234
247
  // Capture the original response to avoid infinite recursion when the getter is copied to the context
235
248
  const originalResponse = playwrightContext.response;
236
249
  return {
@@ -257,7 +270,7 @@ export class AdaptivePlaywrightCrawler extends BasicCrawler {
257
270
  const locator = playwrightContext.page.locator(selector).first();
258
271
  await locator.waitFor({ timeout: timeoutMs, state: 'attached' });
259
272
  const urls = await extractUrlsFromPage(playwrightContext.page, selector, options.baseUrl ?? playwrightContext.request.loadedUrl);
260
- return (await this.enqueueLinks(urls, options, playwrightContext.request));
273
+ return (await this.#enqueueLinks(urls, options, playwrightContext.request));
261
274
  },
262
275
  };
263
276
  }
@@ -267,7 +280,7 @@ export class AdaptivePlaywrightCrawler extends BasicCrawler {
267
280
  * time, before the `try`* - the `ok: false` branch of the returned {@link Result} carries no
268
281
  * result, and failed attempts are routine here. The caller owns the outcome and disposal.
269
282
  */
270
- async crawlOne(renderingType, context, useStateFunction, transactions) {
283
+ async #crawlOne(renderingType, context, useStateFunction, transactions) {
271
284
  const transaction = createStorageTransaction({
272
285
  policy: this.#attemptWritePolicy,
273
286
  commitTimeoutMillis: this.internalTimeoutMillis,
@@ -277,7 +290,7 @@ export class AdaptivePlaywrightCrawler extends BasicCrawler {
277
290
  const deferredCleanup = [];
278
291
  const attemptBoundContextHelpers = {
279
292
  useState: useStateFunction,
280
- log: this.createLogProxy(context.log, logs),
293
+ log: this.#createLogProxy(context.log, logs),
281
294
  registerDeferredCleanup: (cleanup) => deferredCleanup.push(cleanup),
282
295
  };
283
296
  const subCrawlerContext = Object.defineProperties({}, Object.getOwnPropertyDescriptors(context));
@@ -287,12 +300,16 @@ export class AdaptivePlaywrightCrawler extends BasicCrawler {
287
300
  Object.defineProperty(subCrawlerContext, key, { ...descriptor, configurable: false });
288
301
  }
289
302
  try {
303
+ // Any failure - middleware or handler - ends up in the `ok: false` branch below.
304
+ const rethrow = (error) => {
305
+ throw error;
306
+ };
290
307
  const callAdaptiveRequestHandler = async () => {
291
308
  if (renderingType === 'static') {
292
- await this.#staticContextPipeline.call(subCrawlerContext, this.requestHandler.bind(this));
309
+ await this.#staticContextPipeline.call(subCrawlerContext, this.requestHandler.bind(this), rethrow);
293
310
  }
294
311
  else if (renderingType === 'clientOnly') {
295
- await this.#browserContextPipeline.call(subCrawlerContext, this.requestHandler.bind(this));
312
+ await this.#browserContextPipeline.call(subCrawlerContext, this.requestHandler.bind(this), rethrow);
296
313
  }
297
314
  };
298
315
  // this crawler overrides `runRequestHandler` and times each rendering-type run itself, so it has
@@ -310,8 +327,8 @@ export class AdaptivePlaywrightCrawler extends BasicCrawler {
310
327
  }
311
328
  }
312
329
  async runRequestHandler(crawlingContext) {
313
- const renderingTypePrediction = this.#renderingTypePredictor.value.predict(crawlingContext.request);
314
- const shouldDetectRenderingType = Math.random() < renderingTypePrediction.detectionProbabilityRecommendation;
330
+ const renderingTypePrediction = await this.#renderingTypePredictor.value.predict(crawlingContext.request);
331
+ const shouldDetectRenderingType = !this.#shutDown && Math.random() < renderingTypePrediction.detectionProbabilityRecommendation;
315
332
  if (!shouldDetectRenderingType) {
316
333
  crawlingContext.log.debug(`Predicted rendering type ${renderingTypePrediction.renderingType} for ${crawlingContext.request.url}`);
317
334
  }
@@ -323,7 +340,7 @@ export class AdaptivePlaywrightCrawler extends BasicCrawler {
323
340
  if (renderingTypePrediction.renderingType === 'static' && !shouldDetectRenderingType) {
324
341
  crawlingContext.log.debug(`Running HTTP-only request handler for ${crawlingContext.request.url}`);
325
342
  this.statistics.state.httpOnlyRequestHandlerRuns++;
326
- const plainHTTPRun = await this.crawlOne('static', crawlingContext, crawlingContext.useState, transactions);
343
+ const plainHTTPRun = await this.#crawlOne('static', crawlingContext, crawlingContext.useState, transactions);
327
344
  if (plainHTTPRun.ok && this.#resultChecker(plainHTTPRun.result)) {
328
345
  crawlingContext.log.debug(`HTTP-only request handler succeeded for ${crawlingContext.request.url}`);
329
346
  plainHTTPRun.logs?.forEach(([log, method, ...args]) => log[method](...args));
@@ -332,10 +349,12 @@ export class AdaptivePlaywrightCrawler extends BasicCrawler {
332
349
  }
333
350
  // Execution will "fall through" and try running the request handler in a browser
334
351
  if (!plainHTTPRun.ok) {
335
- const actualError = plainHTTPRun.error instanceof RequestHandlerError
352
+ const actualError = plainHTTPRun.error instanceof RequestHandlerError ||
353
+ plainHTTPRun.error instanceof ContextPipelineInitializationError
336
354
  ? plainHTTPRun.error.cause
337
355
  : plainHTTPRun.error;
338
- if (await this.#shouldPropagateError(actualError, crawlingContext)) {
356
+ if (actualError instanceof ContextPipelineInterruptedError ||
357
+ (await this.#shouldPropagateError(actualError, crawlingContext))) {
339
358
  throw actualError;
340
359
  }
341
360
  crawlingContext.log.exception(actualError, `HTTP-only request handler failed for ${crawlingContext.request.url}`);
@@ -367,34 +386,43 @@ export class AdaptivePlaywrightCrawler extends BasicCrawler {
367
386
  return this.stateCopy;
368
387
  },
369
388
  };
370
- const browserRun = await this.crawlOne('clientOnly', crawlingContext, stateTracker.getLiveState.bind(stateTracker), transactions);
389
+ const browserRun = await this.#crawlOne('clientOnly', crawlingContext, stateTracker.getLiveState.bind(stateTracker), transactions);
371
390
  if (!browserRun.ok) {
372
391
  throw browserRun.error;
373
392
  }
374
393
  browserRun.logs?.forEach(([log, method, ...args]) => log[method](...args));
375
394
  await browserRun.result.commit();
376
395
  if (shouldDetectRenderingType) {
377
- crawlingContext.log.debug(`Detecting rendering type for ${crawlingContext.request.url}`);
378
- // The detection attempt's transaction is never committed - its writes exist only for the
379
- // result comparison.
380
- const plainHTTPRun = await this.crawlOne('static', crawlingContext, stateTracker.getStateCopy.bind(stateTracker), transactions);
381
- const detectionResult = (() => {
382
- if (!plainHTTPRun.ok) {
383
- return 'clientOnly';
384
- }
385
- const comparisonResult = this.#resultComparator(plainHTTPRun.result, browserRun.result);
386
- if (comparisonResult === true || comparisonResult === 'equal') {
387
- return 'static';
388
- }
389
- if (comparisonResult === false || comparisonResult === 'different') {
390
- return 'clientOnly';
396
+ const detectionPromise = (async () => {
397
+ crawlingContext.log.debug(`Detecting rendering type for ${crawlingContext.request.url}`);
398
+ // The detection attempt's transaction is never committed - its writes exist only for the
399
+ // result comparison.
400
+ const plainHTTPRun = await this.#crawlOne('static', crawlingContext, stateTracker.getStateCopy.bind(stateTracker), transactions);
401
+ const detectionResult = (() => {
402
+ if (!plainHTTPRun.ok) {
403
+ return 'clientOnly';
404
+ }
405
+ const comparisonResult = this.#resultComparator(plainHTTPRun.result, browserRun.result);
406
+ if (comparisonResult === true || comparisonResult === 'equal') {
407
+ return 'static';
408
+ }
409
+ if (comparisonResult === false || comparisonResult === 'different') {
410
+ return 'clientOnly';
411
+ }
412
+ return undefined;
413
+ })();
414
+ crawlingContext.log.debug(`Detected rendering type ${detectionResult} for ${crawlingContext.request.url}`);
415
+ if (detectionResult !== undefined) {
416
+ // Deliberately not awaited: a predictor that persists asynchronously gets to keep
417
+ // batching its writes, and the drain below catches whatever is still pending.
418
+ const stored = this.#renderingTypePredictor.value.storeResult(crawlingContext.request, detectionResult);
419
+ if (stored !== undefined) {
420
+ // Nothing downstream awaits this, so a failed write would otherwise be silent.
421
+ void this.#trackDetection(Promise.resolve(stored).catch((error) => this.log.exception(error, `Failed to store the rendering type detection result for ${crawlingContext.request.url}`)));
422
+ }
391
423
  }
392
- return undefined;
393
424
  })();
394
- crawlingContext.log.debug(`Detected rendering type ${detectionResult} for ${crawlingContext.request.url}`);
395
- if (detectionResult !== undefined) {
396
- this.#renderingTypePredictor.value.storeResult(crawlingContext.request, detectionResult);
397
- }
425
+ await this.#trackDetection(detectionPromise);
398
426
  }
399
427
  }
400
428
  finally {
@@ -405,7 +433,7 @@ export class AdaptivePlaywrightCrawler extends BasicCrawler {
405
433
  }
406
434
  }
407
435
  }
408
- async enqueueLinks(urls, options, request) {
436
+ async #enqueueLinks(urls, options, request) {
409
437
  const baseUrl = resolveBaseUrlForEnqueueLinksFiltering({
410
438
  enqueueStrategy: options?.strategy,
411
439
  finalRequestUrl: request.loadedUrl,
@@ -421,23 +449,94 @@ export class AdaptivePlaywrightCrawler extends BasicCrawler {
421
449
  strategy: options.strategy ?? EnqueueStrategy.SameHostname,
422
450
  });
423
451
  }
424
- createLogProxy(log, logs) {
452
+ #createLogProxy(log, logs) {
425
453
  return new Proxy(log, {
426
- get(target, propertyName, receiver) {
454
+ get(target, propertyName) {
427
455
  if (proxyLogMethods.includes(propertyName)) {
428
456
  return (...args) => {
429
457
  logs.push([target, propertyName, ...args]);
430
458
  };
431
459
  }
432
- return Reflect.get(target, propertyName, receiver);
460
+ const value = Reflect.get(target, propertyName, target);
461
+ // Bind non-intercepted methods to the target instance so private #-fields
462
+ // (e.g. BaseCrawleeLogger.#options, #warningsLogged) do not throw TypeError at runtime.
463
+ if (typeof value === 'function') {
464
+ return value.bind(target);
465
+ }
466
+ return value;
433
467
  },
434
468
  });
435
469
  }
470
+ #trackDetection(promise) {
471
+ this.#activeDetections.add(promise);
472
+ // Not `finally()`: the promise it derives would reject on its own and go unhandled. A rejection here
473
+ // belongs to whoever awaits the original, or to `allSettled` in the drain.
474
+ void promise.catch(() => { }).then(() => this.#activeDetections.delete(promise));
475
+ return promise;
476
+ }
477
+ /**
478
+ * Number of rendering type detections that have not settled yet, including results the predictor is
479
+ * still persisting.
480
+ */
481
+ get inFlightRenderingTypeDetectionCount() {
482
+ return this.#activeDetections.size;
483
+ }
484
+ /**
485
+ * Waits for in-flight rendering type detections to settle, bounded by `timeoutMillis` (defaults to the
486
+ * internal timeout).
487
+ */
488
+ async drainRenderingDetections({ timeoutMillis } = {}) {
489
+ if (this.#activeDetections.size === 0) {
490
+ return;
491
+ }
492
+ const drained = (async () => {
493
+ while (this.#activeDetections.size > 0) {
494
+ await Promise.allSettled(Array.from(this.#activeDetections));
495
+ }
496
+ })();
497
+ const millis = timeoutMillis ?? this.internalTimeoutMillis;
498
+ // A caller opting out of the bound would otherwise get an immediate spurious timeout - `setTimeout`
499
+ // clamps a non-finite delay to 1ms.
500
+ if (!Number.isFinite(millis)) {
501
+ await drained;
502
+ return;
503
+ }
504
+ const abortTimer = new AbortController();
505
+ try {
506
+ const outcome = await Promise.race([
507
+ drained.then(() => 'drained'),
508
+ delay(millis, 'timedOut', { signal: abortTimer.signal }).catch(() => 'aborted'),
509
+ ]);
510
+ if (outcome === 'timedOut') {
511
+ 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.`);
512
+ }
513
+ }
514
+ finally {
515
+ abortTimer.abort();
516
+ }
517
+ }
518
+ /**
519
+ * Stops the crawler immediately, but not before rendering type detections already under way (and results
520
+ * the predictor is still persisting) have settled - see
521
+ * {@link AdaptivePlaywrightCrawler.drainRenderingDetections|`drainRenderingDetections()`}. Requests
522
+ * that are still running are not waited for, unlike {@link BasicCrawler.stop|`stop()`}.
523
+ */
436
524
  async teardown() {
525
+ // Called from outside `run()` - under `keepAlive`, say - the pool keeps dispatching until
526
+ // `super.teardown()` aborts it, and a request starting during the drain would open a detection the
527
+ // drain has already passed. Closing that first makes the drain a fence.
528
+ this.#shutDown = true;
529
+ await this.drainRenderingDetections();
437
530
  await super.teardown();
438
- for (const hook of this.#teardownHooks) {
439
- await hook();
440
- }
531
+ // Mirrors the owned-only `initialize()` in `init()` - without this, the predictor we built keeps its
532
+ // PERSIST_STATE listener registered after the crawl and never gets a final write.
533
+ await this.#renderingTypePredictor.ifOwned((predictor) => predictor.teardown());
534
+ await this.#browserCrawler.teardown();
535
+ }
536
+ async destroy() {
537
+ await super.destroy();
538
+ await this.#staticCrawler.destroy();
539
+ await this.#browserCrawler.destroy();
441
540
  }
442
541
  }
443
542
  export function createAdaptivePlaywrightRouter(routesOrSchemas) {
@@ -1,5 +1,6 @@
1
1
  import { URL } from 'node:url';
2
- import { applyRequestTransform, constructUrlPatternObjects, createRequestOptions, filterRequestOptionsByPatterns, parseArgument, urlPatternSchema, Request as CrawleeRequest, schemas, serviceLocator, } from '@crawlee/browser';
2
+ import { applyRequestTransform, constructUrlPatternObjects, createRequestOptions, createSkippedRequestArgs, filterRequestOptionsByPatterns, urlPatternSchema, Request as CrawleeRequest, serviceLocator, } from '@crawlee/browser';
3
+ import { parseArgument, schemas } from '@crawlee/utils/internal';
3
4
  import { z } from 'zod';
4
5
  const STARTING_Z_INDEX = 2147400000;
5
6
  const getLog = () => serviceLocator.getChildLog('Playwright Click Elements');
@@ -64,6 +65,7 @@ export async function enqueueLinksByClickingElements(options) {
64
65
  const { page, requestManager, selector, clickOptions, include, exclude, transformRequestFunction, waitForPageIdleSecs, maxWaitForPageIdleSecs, forefront, onSkippedRequest, } = parsedOptions;
65
66
  const waitForPageIdleMillis = waitForPageIdleSecs * 1000;
66
67
  const maxWaitForPageIdleMillis = maxWaitForPageIdleSecs * 1000;
68
+ const hasOnSkippedRequest = onSkippedRequest !== undefined;
67
69
  const urlExcludePatternObjects = exclude?.length ? constructUrlPatternObjects(exclude) : [];
68
70
  const urlPatternObjects = include?.length ? constructUrlPatternObjects(include) : [];
69
71
  const interceptedRequests = await clickElementsAndInterceptNavigationRequests({
@@ -75,15 +77,15 @@ export async function enqueueLinksByClickingElements(options) {
75
77
  });
76
78
  const requestOptions = createRequestOptions(interceptedRequests, parsedOptions);
77
79
  const skippedByFilters = [];
78
- let filteredOptions = filterRequestOptionsByPatterns(requestOptions, urlPatternObjects.length > 0 ? urlPatternObjects : undefined, urlExcludePatternObjects, undefined, (url) => skippedByFilters.push(url));
80
+ let filteredOptions = filterRequestOptionsByPatterns(requestOptions, urlPatternObjects.length > 0 ? urlPatternObjects : undefined, urlExcludePatternObjects, undefined, hasOnSkippedRequest ? (opts) => skippedByFilters.push(opts) : undefined);
79
81
  if (onSkippedRequest && skippedByFilters.length > 0) {
80
- await Promise.all(skippedByFilters.map(async (url) => onSkippedRequest({ url, reason: 'filters' })));
82
+ await Promise.all(skippedByFilters.map(async (opts) => onSkippedRequest(createSkippedRequestArgs(opts, 'filters'))));
81
83
  }
82
84
  if (transformRequestFunction) {
83
85
  const skippedByTransform = [];
84
- filteredOptions = applyRequestTransform(filteredOptions, transformRequestFunction, (r) => skippedByTransform.push(r));
86
+ filteredOptions = applyRequestTransform(filteredOptions, transformRequestFunction, hasOnSkippedRequest ? (r) => skippedByTransform.push(r) : undefined);
85
87
  if (onSkippedRequest && skippedByTransform.length > 0) {
86
- await Promise.all(skippedByTransform.map(async (r) => onSkippedRequest({ url: r.url, reason: 'transform' })));
88
+ await Promise.all(skippedByTransform.map(async (r) => onSkippedRequest(createSkippedRequestArgs(r, 'transform'))));
87
89
  }
88
90
  }
89
91
  const requests = filteredOptions.map((opts) => new CrawleeRequest(opts));