@crawlee/playwright 4.0.0-beta.240 → 4.0.0-beta.242

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.
@@ -1,6 +1,7 @@
1
- import type { BrowserHook, LoadedRequest, CrawlingRequest, RouterHandler, RouteSchemas, RoutesFromSchemas } from '@crawlee/browser';
1
+ import type { BrowserHook, LoadedRequest, RouterHandler, RouteSchemas, RoutesFromSchemas } from '@crawlee/browser';
2
2
  import type { BasicCrawlerOptions, CrawlingContext, EnqueueLinksOptions, GetUserDataFromRequest, RouterRoutes } from '@crawlee/basic';
3
3
  import { BasicCrawler } from '@crawlee/basic';
4
+ import { CrawlingRequest } from '@crawlee/browser';
4
5
  import type { StorageTransactionView } from '@crawlee/core';
5
6
  import type { Dictionary, Awaitable } from '@crawlee/types';
6
7
  import { type Cheerio, type CheerioAPI } from 'cheerio';
@@ -1,11 +1,11 @@
1
1
  import { setTimeout as delay } from 'node:timers/promises';
2
2
  import { isDeepStrictEqual } from 'node:util';
3
3
  import { BasicCrawler, ContextPipeline, ContextPipelineInitializationError, ContextPipelineInterruptedError, navigationDeadlineKey, RequestHandlerError, resolveBaseUrlForEnqueueLinksFiltering, Router, Statistics, } from '@crawlee/basic';
4
- import { extractUrlsFromPage } from '@crawlee/browser';
4
+ import { CrawlingRequest, extractUrlsFromPage } from '@crawlee/browser';
5
5
  import { CheerioCrawler } from '@crawlee/cheerio';
6
6
  import { ResponseWithUrl } from '@crawlee/http-client';
7
7
  import { createStorageTransaction, EnqueueStrategy, OwnedOrInjected } from '@crawlee/core';
8
- import { extractUrlsFromCheerio, parseArgument } from '@crawlee/utils/internal';
8
+ import { extractUrlsFromCheerio, MutationJournal, parseArgument } from '@crawlee/utils/internal';
9
9
  import { z } from 'zod';
10
10
  import { addTimeoutToPromise } from '@apify/timeout';
11
11
  import { PlaywrightCrawler } from './playwright-crawler.js';
@@ -267,10 +267,13 @@ export class AdaptivePlaywrightCrawler extends BasicCrawler {
267
267
  /**
268
268
  * Runs one request handler attempt inside its own {@link StorageTransaction}, wrapping the inner
269
269
  * (static or browser) context pipeline. The transaction is pushed to `transactions` *at creation
270
- * time, before the `try`* - the `ok: false` branch of the returned {@link Result} carries no
271
- * result, and failed attempts are routine here. The caller owns the outcome and disposal.
270
+ * time, before the `try`*, as failed attempts are routine here. The caller owns the outcome and
271
+ * disposal, including closing or reverting the {@link ContextModifications}.
272
+ *
273
+ * `request` defaults to the context's; a detection rerun passes a copy so it sees the request as it
274
+ * was before the browser attempt.
272
275
  */
273
- async #crawlOne(renderingType, context, useStateFunction, transactions) {
276
+ async #crawlOne(renderingType, context, useStateFunction, transactions, request = context.request) {
274
277
  const transaction = createStorageTransaction({
275
278
  policy: this.#attemptWritePolicy,
276
279
  commitTimeoutMillis: this.internalTimeoutMillis,
@@ -278,9 +281,14 @@ export class AdaptivePlaywrightCrawler extends BasicCrawler {
278
281
  transactions.push(transaction);
279
282
  const logs = [];
280
283
  const deferredCleanup = [];
284
+ const contextModifications = {
285
+ request: new MutationJournal(),
286
+ state: new MutationJournal(),
287
+ };
281
288
  const subCrawlerContext = Object.defineProperties({}, Object.getOwnPropertyDescriptors(context));
282
289
  const attemptBoundContextHelpers = {
283
- useState: useStateFunction,
290
+ request: contextModifications.request.track(request),
291
+ useState: async (defaultValue) => contextModifications.state.track(await useStateFunction(defaultValue)),
284
292
  log: this.#createLogProxy(context.log, logs),
285
293
  registerDeferredCleanup: (cleanup) => deferredCleanup.push(cleanup),
286
294
  // The sub-crawler pipeline keeps this attempt's navigation deadline on `subCrawlerContext`, but the
@@ -315,10 +323,10 @@ export class AdaptivePlaywrightCrawler extends BasicCrawler {
315
323
  const routeTimeoutSecs = this.requestHandler.getTimeoutSecs?.(context.request.label);
316
324
  const timeoutMillis = routeTimeoutSecs === undefined ? this.#individualRequestHandlerTimeoutMillis : routeTimeoutSecs * 1000;
317
325
  await addTimeoutToPromise(async () => transaction.run(callAdaptiveRequestHandler), timeoutMillis, 'Request handler timed out');
318
- return { result: transaction, ok: true, logs };
326
+ return { ok: true, transaction, contextModifications, logs };
319
327
  }
320
328
  catch (error) {
321
- return { error, ok: false, logs };
329
+ return { ok: false, error, transaction, contextModifications, logs };
322
330
  }
323
331
  finally {
324
332
  await Promise.all(deferredCleanup.map((cleanup) => cleanup()));
@@ -342,10 +350,12 @@ export class AdaptivePlaywrightCrawler extends BasicCrawler {
342
350
  crawlingContext.log.debug(`Running HTTP-only request handler for ${crawlingContext.request.url}`);
343
351
  this.statistics.state.httpOnlyRequestHandlerRuns++;
344
352
  const plainHTTPRun = await this.#crawlOne('static', crawlingContext, crawlingContext.useState, transactions);
345
- if (plainHTTPRun.ok && this.#resultChecker(plainHTTPRun.result, { request: crawlingContext.request })) {
353
+ if (plainHTTPRun.ok &&
354
+ this.#resultChecker(plainHTTPRun.transaction, { request: crawlingContext.request })) {
346
355
  crawlingContext.log.debug(`HTTP-only request handler succeeded for ${crawlingContext.request.url}`);
347
- plainHTTPRun.logs?.forEach(([log, method, ...args]) => log[method](...args));
348
- await plainHTTPRun.result.commit();
356
+ plainHTTPRun.logs.forEach(([log, method, ...args]) => log[method](...args));
357
+ this.#keepContextModifications(plainHTTPRun.contextModifications);
358
+ await plainHTTPRun.transaction.commit();
349
359
  return;
350
360
  }
351
361
  // Execution will "fall through" and try running the request handler in a browser
@@ -356,6 +366,7 @@ export class AdaptivePlaywrightCrawler extends BasicCrawler {
356
366
  : plainHTTPRun.error;
357
367
  if (actualError instanceof ContextPipelineInterruptedError ||
358
368
  (await this.#shouldPropagateError(actualError, crawlingContext))) {
369
+ this.#keepContextModifications(plainHTTPRun.contextModifications);
359
370
  throw actualError;
360
371
  }
361
372
  crawlingContext.log.exception(actualError, `HTTP-only request handler failed for ${crawlingContext.request.url}`);
@@ -365,35 +376,20 @@ export class AdaptivePlaywrightCrawler extends BasicCrawler {
365
376
  this.statistics.state.renderingTypeMispredictions++;
366
377
  httpResultRejected = true;
367
378
  }
379
+ // The browser attempt starts from the request and state as they were before the rejected one.
380
+ this.#discardContextModifications(plainHTTPRun.contextModifications, crawlingContext);
368
381
  }
369
382
  crawlingContext.log.debug(`Running browser request handler for ${crawlingContext.request.url}`);
370
383
  this.statistics.state.browserRequestHandlerRuns++;
371
- // Run the request handler in a browser. The copy of the crawler state is kept so that we can perform
372
- // a rendering type detection if necessary. Without this measure, the HTTP request handler would run
373
- // under different conditions, which could change its behavior. Changes done to the crawler state by
374
- // the HTTP request handler will not be committed to the actual storage.
375
- const stateTracker = {
376
- stateCopy: null,
377
- async getLiveState(defaultValue = {}) {
378
- const state = await crawlingContext.useState(defaultValue);
379
- if (this.stateCopy === null) {
380
- this.stateCopy = JSON.parse(JSON.stringify(state));
381
- }
382
- return state;
383
- },
384
- async getStateCopy(defaultValue = {}) {
385
- if (this.stateCopy === null) {
386
- return defaultValue;
387
- }
388
- return this.stateCopy;
389
- },
390
- };
391
- const browserRun = await this.#crawlOne('clientOnly', crawlingContext, stateTracker.getLiveState.bind(stateTracker), transactions);
384
+ const browserRun = await this.#crawlOne('clientOnly', crawlingContext, crawlingContext.useState, transactions);
385
+ // A failed browser attempt is handed to the retry logic with its writes - `userData` stashed for the
386
+ // next attempt must survive.
387
+ this.#keepContextModifications(browserRun.contextModifications);
392
388
  if (!browserRun.ok) {
393
389
  throw browserRun.error;
394
390
  }
395
- browserRun.logs?.forEach(([log, method, ...args]) => log[method](...args));
396
- await browserRun.result.commit();
391
+ browserRun.logs.forEach(([log, method, ...args]) => log[method](...args));
392
+ await browserRun.transaction.commit();
397
393
  // Past the shutdown fence, `teardown()` has already drained - and possibly torn down the predictor.
398
394
  if (httpResultRejected && !this.#shutDown) {
399
395
  this.#storeRenderingType(crawlingContext.request, 'clientOnly');
@@ -402,13 +398,17 @@ export class AdaptivePlaywrightCrawler extends BasicCrawler {
402
398
  const detectionPromise = (async () => {
403
399
  crawlingContext.log.debug(`Detecting rendering type for ${crawlingContext.request.url}`);
404
400
  // The detection attempt's transaction is never committed - its writes exist only for the
405
- // result comparison.
406
- const plainHTTPRun = await this.#crawlOne('static', crawlingContext, stateTracker.getStateCopy.bind(stateTracker), transactions);
401
+ // result comparison. It runs on copies of the request and the crawler state with the browser
402
+ // attempt's writes undone, so both attempts start from the same conditions, and the copies go
403
+ // away with it.
404
+ const { request: requestJournal, state: stateJournal } = browserRun.contextModifications;
405
+ let stateCopy;
406
+ 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)))));
407
407
  const detectionResult = (() => {
408
408
  if (!plainHTTPRun.ok) {
409
409
  return 'clientOnly';
410
410
  }
411
- const comparisonResult = this.#resultComparator(plainHTTPRun.result, browserRun.result, {
411
+ const comparisonResult = this.#resultComparator(plainHTTPRun.transaction, browserRun.transaction, {
412
412
  request: crawlingContext.request,
413
413
  });
414
414
  if (comparisonResult === true || comparisonResult === 'equal') {
@@ -435,6 +435,22 @@ export class AdaptivePlaywrightCrawler extends BasicCrawler {
435
435
  }
436
436
  }
437
437
  }
438
+ #keepContextModifications(modifications) {
439
+ modifications.request.close();
440
+ modifications.state.close();
441
+ }
442
+ #discardContextModifications(modifications, { request, log }) {
443
+ // Nothing else writes to a request while its handler runs, so a conflict means a write that bypassed
444
+ // the context (e.g. through a stashed reference) - it stays, but it is not what the retry logic expects.
445
+ modifications.request.revert({
446
+ onConflict: (key) => log.warning(`Request ${request.url}: '${String(key)}' was modified outside the request handler`),
447
+ });
448
+ // The state is shared with concurrent handlers; a conflict is one of them having written the same
449
+ // property meanwhile. Their value wins, and this attempt's write stays with it.
450
+ modifications.state.revert({
451
+ onConflict: (key) => log.debug(`Crawler state '${String(key)}' was changed concurrently; keeping the newer value`),
452
+ });
453
+ }
438
454
  #storeRenderingType(request, renderingType) {
439
455
  // Deliberately not awaited: a predictor that persists asynchronously gets to keep batching its writes,
440
456
  // and `drainRenderingDetections()` catches whatever is still pending.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crawlee/playwright",
3
- "version": "4.0.0-beta.240",
3
+ "version": "4.0.0-beta.242",
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,14 @@
49
49
  "dependencies": {
50
50
  "@apify/datastructures": "^3.0.1",
51
51
  "@apify/timeout": "^1.0.1",
52
- "@crawlee/basic": "4.0.0-beta.240",
53
- "@crawlee/browser": "4.0.0-beta.240",
54
- "@crawlee/browser-pool": "4.0.0-beta.240",
55
- "@crawlee/cheerio": "4.0.0-beta.240",
56
- "@crawlee/core": "4.0.0-beta.240",
57
- "@crawlee/http-client": "4.0.0-beta.240",
58
- "@crawlee/types": "4.0.0-beta.240",
59
- "@crawlee/utils": "4.0.0-beta.240",
52
+ "@crawlee/basic": "4.0.0-beta.242",
53
+ "@crawlee/browser": "4.0.0-beta.242",
54
+ "@crawlee/browser-pool": "4.0.0-beta.242",
55
+ "@crawlee/cheerio": "4.0.0-beta.242",
56
+ "@crawlee/core": "4.0.0-beta.242",
57
+ "@crawlee/http-client": "4.0.0-beta.242",
58
+ "@crawlee/types": "4.0.0-beta.242",
59
+ "@crawlee/utils": "4.0.0-beta.242",
60
60
  "cheerio": "^1.0.0",
61
61
  "domhandler": "^6.0.0",
62
62
  "jquery": "^3.7.1",
@@ -81,5 +81,5 @@
81
81
  }
82
82
  }
83
83
  },
84
- "gitHead": "040d91e9f36dfd4c99f73b63abd97b92b82d89a4"
84
+ "gitHead": "9bd381d862366b6b874938fb0717f21fd430b9de"
85
85
  }