@crawlee/playwright 4.0.0-beta.231 → 4.0.0-beta.233

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.
@@ -43,16 +43,17 @@ export interface AdaptivePlaywrightCrawlerContext<UserData extends Dictionary =
43
43
  * Playwright Page object. If accessed in HTTP-only rendering, this will throw an error and make the AdaptivePlaywrightCrawlerContext retry the request in a browser.
44
44
  */
45
45
  page: Page;
46
- /**
47
- * Wait for an element matching the selector to appear and return a Cheerio object of the first matched element.
48
- * Timeout defaults to 5s.
49
- */
50
- querySelector(selector: string, timeoutMs?: number): Promise<Cheerio<AnyNode>>;
51
46
  /**
52
47
  * Wait for an element matching the selector to appear and return a Cheerio object of all matched elements.
48
+ * Throws when nothing matches - in the HTTP-only run, that makes the crawler retry the request in a browser.
53
49
  * Timeout defaults to 5s.
54
50
  */
55
51
  querySelectorAll(selector: string, timeoutMs?: number): Promise<Cheerio<AnyNode>>;
52
+ /**
53
+ * Like {@link AdaptivePlaywrightCrawlerContext.querySelectorAll|`querySelectorAll`}, but returns only the
54
+ * first match.
55
+ */
56
+ querySelectorOne(selector: string, timeoutMs?: number): Promise<Cheerio<AnyNode>>;
56
57
  /**
57
58
  * Wait for an element matching the selector to appear.
58
59
  * Timeout defaults to 5s.
@@ -91,6 +92,11 @@ type AdaptiveHook<ContextExtension = Dictionary<never>> = BrowserHook<AdaptiveHo
91
92
  type AdaptivePostNavigationHook<ContextExtension = Dictionary<never>> = BrowserHook<Omit<AdaptiveHookContext, 'request'> & {
92
93
  request: LoadedRequest<CrawlingRequest>;
93
94
  }, ContextExtension>;
95
+ /** Passed to {@link AdaptivePlaywrightCrawlerOptions.resultChecker} and `resultComparator` alongside the results. */
96
+ export interface AdaptiveResultContext {
97
+ /** The request whose handler produced the results. */
98
+ request: CrawlingRequest;
99
+ }
94
100
  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'> {
95
101
  /**
96
102
  * Async functions that are sequentially evaluated before the navigation. Good for setting additional cookies.
@@ -121,7 +127,7 @@ export interface AdaptivePlaywrightCrawlerOptions<ContextExtension = Dictionary<
121
127
  * If it returns false, the request is retried in a browser.
122
128
  * If no callback is specified, every result is considered valid.
123
129
  */
124
- resultChecker?: (result: StorageTransactionView) => boolean;
130
+ resultChecker?: (result: StorageTransactionView, context: AdaptiveResultContext) => boolean;
125
131
  /**
126
132
  * An optional callback that decides whether an error thrown during the plain HTTP request handler
127
133
  * should be propagated (instead of falling back to browser navigation).
@@ -144,7 +150,7 @@ export interface AdaptivePlaywrightCrawlerOptions<ContextExtension = Dictionary<
144
150
  *
145
151
  * For a stricter, ready-made comparator that also takes enqueued requests and key-value store changes into account, see {@link fullResultComparator}.
146
152
  */
147
- resultComparator?: (resultA: StorageTransactionView, resultB: StorageTransactionView) => boolean | 'equal' | 'different' | 'inconclusive';
153
+ resultComparator?: (resultA: StorageTransactionView, resultB: StorageTransactionView, context: AdaptiveResultContext) => boolean | 'equal' | 'different' | 'inconclusive';
148
154
  /**
149
155
  * A custom rendering type predictor. A predictor passed here is borrowed - the crawler never drives its
150
156
  * lifecycle, so set it up yourself (the built-in {@link RenderingTypePredictor} needs `initialize()`).
@@ -160,9 +166,9 @@ export interface AdaptivePlaywrightCrawlerOptions<ContextExtension = Dictionary<
160
166
  * ```javascript
161
167
  * const crawler = new AdaptivePlaywrightCrawler({
162
168
  * renderingTypeDetectionRatio: 0.1,
163
- * async requestHandler({ querySelector, pushData, enqueueLinks, request, log }) {
169
+ * async requestHandler({ querySelectorAll, pushData, enqueueLinks, request, log }) {
164
170
  * // This function is called to extract data from a single web page
165
- * const $prices = await querySelector('span.price')
171
+ * const $prices = await querySelectorAll('span.price')
166
172
  *
167
173
  * await pushData({
168
174
  * url: request.url,
@@ -3,6 +3,7 @@ import { isDeepStrictEqual } from 'node:util';
3
3
  import { BasicCrawler, ContextPipeline, ContextPipelineInitializationError, ContextPipelineInterruptedError, RequestHandlerError, resolveBaseUrlForEnqueueLinksFiltering, Router, Statistics, } from '@crawlee/basic';
4
4
  import { extractUrlsFromPage } from '@crawlee/browser';
5
5
  import { CheerioCrawler } from '@crawlee/cheerio';
6
+ import { ResponseWithUrl } from '@crawlee/http-client';
6
7
  import { createStorageTransaction, EnqueueStrategy, OwnedOrInjected } from '@crawlee/core';
7
8
  import { extractUrlsFromCheerio, parseArgument } from '@crawlee/utils/internal';
8
9
  import { z } from 'zod';
@@ -43,9 +44,9 @@ const proxyLogMethods = [
43
44
  * ```javascript
44
45
  * const crawler = new AdaptivePlaywrightCrawler({
45
46
  * renderingTypeDetectionRatio: 0.1,
46
- * async requestHandler({ querySelector, pushData, enqueueLinks, request, log }) {
47
+ * async requestHandler({ querySelectorAll, pushData, enqueueLinks, request, log }) {
47
48
  * // This function is called to extract data from a single web page
48
- * const $prices = await querySelector('span.price')
49
+ * const $prices = await querySelectorAll('span.price')
49
50
  *
50
51
  * await pushData({
51
52
  * url: request.url,
@@ -146,7 +147,7 @@ export class AdaptivePlaywrightCrawler extends BasicCrawler {
146
147
  this.#resultComparator = resultComparator;
147
148
  }
148
149
  else if (resultChecker !== undefined) {
149
- this.#resultComparator = (resultA, resultB) => this.#resultChecker(resultA) && this.#resultChecker(resultB);
150
+ this.#resultComparator = (resultA, resultB, context) => this.#resultChecker(resultA, context) && this.#resultChecker(resultB, context);
150
151
  }
151
152
  else {
152
153
  this.#resultComparator = (resultA, resultB) => {
@@ -208,12 +209,12 @@ export class AdaptivePlaywrightCrawler extends BasicCrawler {
208
209
  get page() {
209
210
  throw new Error(errorMessage('page'));
210
211
  },
211
- get querySelector() {
212
- throw new Error(errorMessage('querySelector'));
213
- },
214
212
  get querySelectorAll() {
215
213
  throw new Error(errorMessage('querySelectorAll'));
216
214
  },
215
+ get querySelectorOne() {
216
+ throw new Error(errorMessage('querySelectorOne'));
217
+ },
217
218
  get waitForSelector() {
218
219
  throw new Error(errorMessage('waitForSelector'));
219
220
  },
@@ -230,45 +231,34 @@ export class AdaptivePlaywrightCrawler extends BasicCrawler {
230
231
  get page() {
231
232
  throw new Error('Page object was used in HTTP-only request handler');
232
233
  },
233
- async querySelector(selector) {
234
- return cheerioContext.$(selector).first();
235
- },
236
- async querySelectorAll(selector) {
237
- return cheerioContext.$(selector);
238
- },
239
234
  enqueueLinks: async (options = {}) => {
240
235
  const urls = extractUrlsFromCheerio(cheerioContext.$, options.selector, options.baseUrl ?? cheerioContext.request.loadedUrl);
241
236
  return (await this.#enqueueLinks(urls, options, cheerioContext.request));
242
237
  },
243
- response: cheerioContext.response,
238
+ // The HTTP crawler has already consumed the body; hand out a fresh copy, like the browser side does.
239
+ // HTML bodies are already decoded to UTF-8, so `arrayBuffer()` may disagree with the original headers.
240
+ response: new ResponseWithUrl(cheerioContext.body, cheerioContext.response),
244
241
  };
245
242
  }
246
243
  async #adaptPlaywrightContext(playwrightContext) {
247
244
  // Capture the original response to avoid infinite recursion when the getter is copied to the context
248
245
  const originalResponse = playwrightContext.response;
249
246
  return {
250
- response: new Response(Uint8Array.from(await originalResponse.body()), {
247
+ response: new ResponseWithUrl(Uint8Array.from(await originalResponse.body()), {
251
248
  headers: originalResponse.headers(),
252
249
  status: originalResponse.status(),
253
250
  statusText: originalResponse.statusText(),
251
+ url: originalResponse.url(),
254
252
  }),
255
- async querySelector(selector, timeoutMs = 5000) {
256
- const locator = playwrightContext.page.locator(selector).first();
257
- await locator.waitFor({ timeout: timeoutMs, state: 'attached' });
258
- const $ = await playwrightContext.parseWithCheerio();
259
- return $(selector).first();
260
- },
261
- async querySelectorAll(selector, timeoutMs = 5000) {
262
- const locator = playwrightContext.page.locator(selector).first();
263
- await locator.waitFor({ timeout: timeoutMs, state: 'attached' });
264
- const $ = await playwrightContext.parseWithCheerio();
265
- return $(selector);
266
- },
267
253
  enqueueLinks: async (options = {}, timeoutMs = 5000) => {
268
254
  // TODO consider using `context.parseWithCheerio` to make this universal and avoid code duplication
269
255
  const selector = options.selector ?? 'a';
270
256
  const locator = playwrightContext.page.locator(selector).first();
271
- await locator.waitFor({ timeout: timeoutMs, state: 'attached' });
257
+ // Wait for client-rendered links, but like the HTTP side, enqueue nothing when there are none.
258
+ await locator.waitFor({ timeout: timeoutMs, state: 'attached' }).catch((error) => {
259
+ if (error.name !== 'TimeoutError')
260
+ throw error;
261
+ });
272
262
  const urls = await extractUrlsFromPage(playwrightContext.page, selector, options.baseUrl ?? playwrightContext.request.loadedUrl);
273
263
  return (await this.#enqueueLinks(urls, options, playwrightContext.request));
274
264
  },
@@ -337,11 +327,14 @@ export class AdaptivePlaywrightCrawler extends BasicCrawler {
337
327
  // `finally` below, not earlier: the comparators read the journals after `crawlOne` returns.
338
328
  const transactions = [];
339
329
  try {
330
+ // A result the `resultChecker` rejected is as good as a detection saying the page needs a browser - but
331
+ // only once the browser run has succeeded. A thrown error says nothing about rendering.
332
+ let httpResultRejected = false;
340
333
  if (renderingTypePrediction.renderingType === 'static' && !shouldDetectRenderingType) {
341
334
  crawlingContext.log.debug(`Running HTTP-only request handler for ${crawlingContext.request.url}`);
342
335
  this.statistics.state.httpOnlyRequestHandlerRuns++;
343
336
  const plainHTTPRun = await this.#crawlOne('static', crawlingContext, crawlingContext.useState, transactions);
344
- if (plainHTTPRun.ok && this.#resultChecker(plainHTTPRun.result)) {
337
+ if (plainHTTPRun.ok && this.#resultChecker(plainHTTPRun.result, { request: crawlingContext.request })) {
345
338
  crawlingContext.log.debug(`HTTP-only request handler succeeded for ${crawlingContext.request.url}`);
346
339
  plainHTTPRun.logs?.forEach(([log, method, ...args]) => log[method](...args));
347
340
  await plainHTTPRun.result.commit();
@@ -362,6 +355,7 @@ export class AdaptivePlaywrightCrawler extends BasicCrawler {
362
355
  else {
363
356
  crawlingContext.log.warning(`HTTP-only request handler returned a suspicious result for ${crawlingContext.request.url}`);
364
357
  this.statistics.state.renderingTypeMispredictions++;
358
+ httpResultRejected = true;
365
359
  }
366
360
  }
367
361
  crawlingContext.log.debug(`Running browser request handler for ${crawlingContext.request.url}`);
@@ -392,6 +386,10 @@ export class AdaptivePlaywrightCrawler extends BasicCrawler {
392
386
  }
393
387
  browserRun.logs?.forEach(([log, method, ...args]) => log[method](...args));
394
388
  await browserRun.result.commit();
389
+ // Past the shutdown fence, `teardown()` has already drained - and possibly torn down the predictor.
390
+ if (httpResultRejected && !this.#shutDown) {
391
+ this.#storeRenderingType(crawlingContext.request, 'clientOnly');
392
+ }
395
393
  if (shouldDetectRenderingType) {
396
394
  const detectionPromise = (async () => {
397
395
  crawlingContext.log.debug(`Detecting rendering type for ${crawlingContext.request.url}`);
@@ -402,7 +400,9 @@ export class AdaptivePlaywrightCrawler extends BasicCrawler {
402
400
  if (!plainHTTPRun.ok) {
403
401
  return 'clientOnly';
404
402
  }
405
- const comparisonResult = this.#resultComparator(plainHTTPRun.result, browserRun.result);
403
+ const comparisonResult = this.#resultComparator(plainHTTPRun.result, browserRun.result, {
404
+ request: crawlingContext.request,
405
+ });
406
406
  if (comparisonResult === true || comparisonResult === 'equal') {
407
407
  return 'static';
408
408
  }
@@ -413,13 +413,7 @@ export class AdaptivePlaywrightCrawler extends BasicCrawler {
413
413
  })();
414
414
  crawlingContext.log.debug(`Detected rendering type ${detectionResult} for ${crawlingContext.request.url}`);
415
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
- }
416
+ this.#storeRenderingType(crawlingContext.request, detectionResult);
423
417
  }
424
418
  })();
425
419
  await this.#trackDetection(detectionPromise);
@@ -433,6 +427,15 @@ export class AdaptivePlaywrightCrawler extends BasicCrawler {
433
427
  }
434
428
  }
435
429
  }
430
+ #storeRenderingType(request, renderingType) {
431
+ // Deliberately not awaited: a predictor that persists asynchronously gets to keep batching its writes,
432
+ // and `drainRenderingDetections()` catches whatever is still pending.
433
+ const stored = this.#renderingTypePredictor.value.storeResult(request, renderingType);
434
+ if (stored !== undefined) {
435
+ // Nothing downstream awaits this, so a failed write would otherwise be silent.
436
+ void this.#trackDetection(Promise.resolve(stored).catch((error) => this.log.exception(error, `Failed to store the rendering type detection result for ${request.url}`)));
437
+ }
438
+ }
436
439
  async #enqueueLinks(urls, options, request) {
437
440
  const baseUrl = resolveBaseUrlForEnqueueLinksFiltering({
438
441
  enqueueStrategy: options?.strategy,
@@ -114,6 +114,21 @@ export class PlaywrightCrawler extends BrowserCrawler {
114
114
  const locator = context.page.locator(selector).first();
115
115
  await locator.waitFor({ timeout: timeoutMs, state: 'attached' });
116
116
  };
117
+ const parseWithCheerioFn = async (selector, timeoutMs = 5_000) => {
118
+ if (selector) {
119
+ await waitForSelector(selector, timeoutMs);
120
+ }
121
+ return parseWithCheerio(context.page, this.ignoreShadowRoots, this.ignoreIframes);
122
+ };
123
+ const querySelectorAll = async (selector, timeoutMs) => {
124
+ const matches = (await parseWithCheerioFn(selector, timeoutMs))(selector);
125
+ // The browser located it but the Cheerio snapshot does not have it - a Playwright-only selector,
126
+ // or an element under a shadow root/iframe the snapshot was told to ignore.
127
+ if (matches.length === 0) {
128
+ throw new Error(`Selector '${selector}' not found in the page snapshot.`);
129
+ }
130
+ return matches;
131
+ };
117
132
  const downloads = [];
118
133
  context.page.on('download', (download) => downloads.push(download));
119
134
  return {
@@ -128,12 +143,9 @@ export class PlaywrightCrawler extends BrowserCrawler {
128
143
  },
129
144
  blockRequests: async (options) => blockRequests(context.page, options),
130
145
  waitForSelector,
131
- parseWithCheerio: async (selector, timeoutMs = 5_000) => {
132
- if (selector) {
133
- await waitForSelector(selector, timeoutMs);
134
- }
135
- return parseWithCheerio(context.page, this.ignoreShadowRoots, this.ignoreIframes);
136
- },
146
+ parseWithCheerio: parseWithCheerioFn,
147
+ querySelectorAll,
148
+ querySelectorOne: async (selector, timeoutMs) => (await querySelectorAll(selector, timeoutMs)).first(),
137
149
  infiniteScroll: async (options) => infiniteScroll(context.page, options),
138
150
  listDownloads: async () => downloads,
139
151
  saveSnapshot: async (options) => saveSnapshot(context.page, {
@@ -19,7 +19,8 @@
19
19
  */
20
20
  import { Configuration, type Request } from '@crawlee/browser';
21
21
  import type { BatchAddRequestsResult, Dictionary } from '@crawlee/types';
22
- import type { CheerioAPI } from 'cheerio';
22
+ import type { Cheerio, CheerioAPI } from 'cheerio';
23
+ import type { AnyNode } from 'domhandler';
23
24
  // @ts-ignore optional peer dependency or compatibility with es2022
24
25
  import type { Download, Page, Response } from 'playwright';
25
26
  import type { EnqueueLinksByClickingElementsOptions } from '../enqueue-links/click-elements.js';
@@ -420,6 +421,15 @@ export interface PlaywrightContextUtils {
420
421
  * ```
421
422
  */
422
423
  waitForSelector(selector: string, timeoutMs?: number): Promise<void>;
424
+ /**
425
+ * Like {@link PlaywrightContextUtils.waitForSelector|`waitForSelector`}, but returns the matched elements
426
+ * as a Cheerio object. Throws when nothing matches.
427
+ */
428
+ querySelectorAll(selector: string, timeoutMs?: number): Promise<Cheerio<AnyNode>>;
429
+ /**
430
+ * Like {@link PlaywrightContextUtils.querySelectorAll|`querySelectorAll`}, but returns only the first match.
431
+ */
432
+ querySelectorOne(selector: string, timeoutMs?: number): Promise<Cheerio<AnyNode>>;
423
433
  /**
424
434
  * Returns Cheerio handle for `page.content()`, allowing to work with the data same way as with {@link CheerioCrawler}.
425
435
  * When provided with the `selector` argument, it waits for it to be available first.
@@ -24,7 +24,8 @@ export interface IRenderingTypePredictor {
24
24
  detectionProbabilityRecommendation: number;
25
25
  }>;
26
26
  /**
27
- * Report a detected rendering type, so that future predictions can take it into account.
27
+ * Report a rendering type established by a detection, or `clientOnly` when the `resultChecker` rejected an
28
+ * HTTP-only result, so that future predictions can take it into account.
28
29
  *
29
30
  * The crawler does not await this per detection - it collects the returned promises and drains them in
30
31
  * `teardown()`, so an implementation that persists results asynchronously can keep batching its writes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crawlee/playwright",
3
- "version": "4.0.0-beta.231",
3
+ "version": "4.0.0-beta.233",
4
4
  "description": "The scalable web crawling and scraping library for JavaScript/Node.js. Enables development of data extraction and web automation jobs (not only) with headless Chrome and Puppeteer.",
5
5
  "engines": {
6
6
  "node": ">=22.13.0"
@@ -49,14 +49,16 @@
49
49
  "dependencies": {
50
50
  "@apify/datastructures": "^3.0.1",
51
51
  "@apify/timeout": "^1.0.1",
52
- "@crawlee/basic": "4.0.0-beta.231",
53
- "@crawlee/browser": "4.0.0-beta.231",
54
- "@crawlee/browser-pool": "4.0.0-beta.231",
55
- "@crawlee/cheerio": "4.0.0-beta.231",
56
- "@crawlee/core": "4.0.0-beta.231",
57
- "@crawlee/types": "4.0.0-beta.231",
58
- "@crawlee/utils": "4.0.0-beta.231",
52
+ "@crawlee/basic": "4.0.0-beta.233",
53
+ "@crawlee/browser": "4.0.0-beta.233",
54
+ "@crawlee/browser-pool": "4.0.0-beta.233",
55
+ "@crawlee/cheerio": "4.0.0-beta.233",
56
+ "@crawlee/core": "4.0.0-beta.233",
57
+ "@crawlee/http-client": "4.0.0-beta.233",
58
+ "@crawlee/types": "4.0.0-beta.233",
59
+ "@crawlee/utils": "4.0.0-beta.233",
59
60
  "cheerio": "^1.0.0",
61
+ "domhandler": "^6.0.0",
60
62
  "jquery": "^3.7.1",
61
63
  "ml-logistic-regression": "^2.0.0",
62
64
  "ml-matrix": "^6.12.1",
@@ -79,5 +81,5 @@
79
81
  }
80
82
  }
81
83
  },
82
- "gitHead": "bc264d72aeef35d0915047359e96ca3635af4cea"
84
+ "gitHead": "3f074fb4fc8bf016aa1740cac0feac5ae84eca36"
83
85
  }