@crawlee/playwright 4.0.0-beta.13 → 4.0.0-beta.131

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 (33) hide show
  1. package/README.md +17 -13
  2. package/index.d.ts +2 -2
  3. package/index.js +1 -1
  4. package/internals/adaptive-playwright-crawler.d.ts +128 -59
  5. package/internals/adaptive-playwright-crawler.js +317 -230
  6. package/internals/enqueue-links/click-elements.d.ts +37 -55
  7. package/internals/enqueue-links/click-elements.js +63 -55
  8. package/internals/playwright-browser-pool.d.ts +71 -0
  9. package/internals/playwright-browser-pool.js +61 -0
  10. package/internals/playwright-crawler.d.ts +193 -124
  11. package/internals/playwright-crawler.js +63 -62
  12. package/internals/playwright-launcher.d.ts +28 -18
  13. package/internals/playwright-launcher.js +19 -18
  14. package/internals/utils/playwright-utils.d.ts +61 -24
  15. package/internals/utils/playwright-utils.js +145 -95
  16. package/internals/utils/rendering-type-prediction.d.ts +28 -13
  17. package/internals/utils/rendering-type-prediction.js +87 -29
  18. package/package.json +18 -14
  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
  33. package/tsconfig.build.tsbuildinfo +0 -1
@@ -20,20 +20,47 @@
20
20
  import { readFile } from 'node:fs/promises';
21
21
  import { createRequire } from 'node:module';
22
22
  import vm from 'node:vm';
23
- import { Configuration, KeyValueStore, SessionError, validators } from '@crawlee/browser';
23
+ import { Configuration, KeyValueStore, parseArgument, schemas, serviceLocator, SessionError, validators, } from '@crawlee/browser';
24
24
  import { expandShadowRoots, sleep } from '@crawlee/utils';
25
- import * as cheerio from 'cheerio';
26
- import { getInjectableScript as getCookieClosingScript } from 'idcac-playwright';
27
- import ow from 'ow';
25
+ import { z } from 'zod';
28
26
  import { LruCache } from '@apify/datastructures';
29
- import log_ from '@apify/log';
30
27
  import { enqueueLinksByClickingElements } from '../enqueue-links/click-elements.js';
31
28
  import { RenderingTypePredictor } from './rendering-type-prediction.js';
32
- const log = log_.child({ prefix: 'Playwright Utils' });
29
+ const getLog = () => serviceLocator.getChildLog('Playwright Utils');
33
30
  const require = createRequire(import.meta.url);
34
31
  const jqueryPath = require.resolve('jquery');
35
32
  const MAX_INJECT_FILE_CACHE_SIZE = 10;
36
33
  const DEFAULT_BLOCK_REQUEST_URL_PATTERNS = ['.css', '.jpg', '.jpeg', '.png', '.svg', '.gif', '.woff', '.pdf', '.zip'];
34
+ const filePathSchema = z.string();
35
+ const injectFileOptionsSchema = z.strictObject({
36
+ surviveNavigations: z.boolean().optional(),
37
+ });
38
+ const gotoExtendedRequestSchema = z.looseObject({
39
+ url: z.url(),
40
+ method: z.string().optional(),
41
+ headers: schemas.anyObject.optional(),
42
+ payload: z.union([z.string(), z.instanceof(Uint8Array)]).optional(),
43
+ });
44
+ const blockRequestsOptionsSchema = z.strictObject({
45
+ urlPatterns: schemas.arrayOf(z.string(), 'strings').default(DEFAULT_BLOCK_REQUEST_URL_PATTERNS),
46
+ extraUrlPatterns: schemas.arrayOf(z.string(), 'strings').default(() => []),
47
+ });
48
+ const infiniteScrollOptionsSchema = z.strictObject({
49
+ timeoutSecs: schemas.anyNumber.default(0),
50
+ maxScrollHeight: schemas.anyNumber.default(0),
51
+ waitForSecs: schemas.anyNumber.default(4),
52
+ scrollDownAndUp: z.boolean().default(false),
53
+ buttonSelector: z.string().optional(),
54
+ stopScrollCallback: schemas.anyFunction.optional(),
55
+ });
56
+ const saveSnapshotOptionsSchema = z.strictObject({
57
+ key: z.string().min(1).default('SNAPSHOT'),
58
+ screenshotQuality: schemas.anyNumber.default(50),
59
+ saveScreenshot: z.boolean().default(true),
60
+ saveHtml: z.boolean().default(true),
61
+ keyValueStoreName: z.string().optional(),
62
+ configuration: schemas.anyObject.optional(),
63
+ });
37
64
  /**
38
65
  * Cache contents of previously injected files to limit file system access.
39
66
  */
@@ -50,21 +77,19 @@ const injectedFilesCache = new LruCache({ maxLength: MAX_INJECT_FILE_CACHE_SIZE
50
77
  * @param [options]
51
78
  */
52
79
  export async function injectFile(page, filePath, options = {}) {
53
- ow(page, ow.object.validate(validators.browserPage));
54
- ow(filePath, ow.string);
55
- ow(options, ow.object.exactShape({
56
- surviveNavigations: ow.optional.boolean,
57
- }));
80
+ parseArgument(page, validators.browserPage);
81
+ parseArgument(filePath, filePathSchema);
82
+ const { surviveNavigations } = parseArgument(options, injectFileOptionsSchema);
58
83
  let contents = injectedFilesCache.get(filePath);
59
84
  if (!contents) {
60
85
  contents = await readFile(filePath, 'utf8');
61
86
  injectedFilesCache.add(filePath, contents);
62
87
  }
63
88
  const evalP = page.evaluate(contents);
64
- if (options.surviveNavigations) {
89
+ if (surviveNavigations) {
65
90
  page.on('framenavigated', async () => page
66
91
  .evaluate(contents)
67
- .catch((error) => log.warning('An error occurred during the script injection!', { error })));
92
+ .catch((error) => getLog().warning('An error occurred during the script injection!', { error })));
68
93
  }
69
94
  return evalP;
70
95
  }
@@ -95,7 +120,7 @@ export async function injectFile(page, filePath, options = {}) {
95
120
  * @param [options.surviveNavigations] Opt-out option to disable the JQuery reinjection after navigation.
96
121
  */
97
122
  export async function injectJQuery(page, options) {
98
- ow(page, ow.object.validate(validators.browserPage));
123
+ parseArgument(page, validators.browserPage);
99
124
  return injectFile(page, jqueryPath, { surviveNavigations: options?.surviveNavigations ?? true });
100
125
  }
101
126
  /**
@@ -111,19 +136,14 @@ export async function injectJQuery(page, options) {
111
136
  * @param [gotoOptions] Custom options for `page.goto()`.
112
137
  */
113
138
  export async function gotoExtended(page, request, gotoOptions = {}) {
114
- ow(page, ow.object.validate(validators.browserPage));
115
- ow(request, ow.object.partialShape({
116
- url: ow.string.url,
117
- method: ow.optional.string,
118
- headers: ow.optional.object,
119
- payload: ow.optional.any(ow.string, ow.uint8Array),
120
- }));
121
- ow(gotoOptions, ow.object);
139
+ parseArgument(page, validators.browserPage);
140
+ parseArgument(request, gotoExtendedRequestSchema);
141
+ parseArgument(gotoOptions, schemas.anyObject);
122
142
  const { url, method, headers, payload } = request;
123
143
  const isEmpty = (o) => !o || Object.keys(o).length === 0;
124
- if (method !== 'GET' || payload || !isEmpty(headers)) {
144
+ if (method !== 'GET' || payload) {
125
145
  // This is not deprecated, we use it to log only once.
126
- log.deprecated('Using other request methods than GET, rewriting headers and adding payloads has a high impact on performance ' +
146
+ getLog().deprecated('Using other request methods than GET, rewriting headers and adding payloads has a high impact on performance ' +
127
147
  'in recent versions of Playwright. Use only when necessary.');
128
148
  let wasCalled = false;
129
149
  const interceptRequestHandler = async (route) => {
@@ -144,12 +164,15 @@ export async function gotoExtended(page, request, gotoOptions = {}) {
144
164
  await route.continue(overrides);
145
165
  }
146
166
  catch (error) {
147
- log.debug('Error inside request interceptor', { error });
167
+ getLog().debug('Error inside request interceptor', { error });
148
168
  }
149
169
  return undefined;
150
170
  };
151
171
  await page.route('**/*', interceptRequestHandler);
152
172
  }
173
+ else if (!isEmpty(headers)) {
174
+ await page.setExtraHTTPHeaders(headers);
175
+ }
153
176
  return page.goto(url, gotoOptions);
154
177
  }
155
178
  /**
@@ -200,12 +223,8 @@ export async function gotoExtended(page, request, gotoOptions = {}) {
200
223
  * @param [options]
201
224
  */
202
225
  export async function blockRequests(page, options = {}) {
203
- ow(page, ow.object.validate(validators.browserPage));
204
- ow(options, ow.object.exactShape({
205
- urlPatterns: ow.optional.array.ofType(ow.string),
206
- extraUrlPatterns: ow.optional.array.ofType(ow.string),
207
- }));
208
- const { urlPatterns = DEFAULT_BLOCK_REQUEST_URL_PATTERNS, extraUrlPatterns = [] } = options;
226
+ parseArgument(page, validators.browserPage);
227
+ const { urlPatterns, extraUrlPatterns } = parseArgument(options, blockRequestsOptionsSchema);
209
228
  const patternsToBlock = [...urlPatterns, ...extraUrlPatterns];
210
229
  try {
211
230
  const client = await page.context().newCDPSession(page);
@@ -213,7 +232,7 @@ export async function blockRequests(page, options = {}) {
213
232
  await client.send('Network.setBlockedURLs', { urls: patternsToBlock });
214
233
  }
215
234
  catch {
216
- log.warning('blockRequests() helper is incompatible with non-Chromium browsers.');
235
+ getLog().warning('blockRequests() helper is incompatible with non-Chromium browsers.');
217
236
  }
218
237
  }
219
238
  /**
@@ -249,7 +268,7 @@ export function compileScript(scriptString, context = Object.create(null)) {
249
268
  func = vm.runInNewContext(funcString, context); // "Secure" the context by removing prototypes, unless custom context is provided.
250
269
  }
251
270
  catch (err) {
252
- log.exception(err, 'Cannot compile script!');
271
+ getLog().exception(err, 'Cannot compile script!');
253
272
  throw err;
254
273
  }
255
274
  if (typeof func !== 'function')
@@ -263,16 +282,8 @@ export function compileScript(scriptString, context = Object.create(null)) {
263
282
  * @param [options]
264
283
  */
265
284
  export async function infiniteScroll(page, options = {}) {
266
- ow(page, ow.object.validate(validators.browserPage));
267
- ow(options, ow.object.exactShape({
268
- timeoutSecs: ow.optional.number,
269
- maxScrollHeight: ow.optional.number,
270
- waitForSecs: ow.optional.number,
271
- scrollDownAndUp: ow.optional.boolean,
272
- buttonSelector: ow.optional.string,
273
- stopScrollCallback: ow.optional.function,
274
- }));
275
- const { timeoutSecs = 0, maxScrollHeight = 0, waitForSecs = 4, scrollDownAndUp = false, buttonSelector, stopScrollCallback, } = options;
285
+ parseArgument(page, validators.browserPage);
286
+ const { timeoutSecs, maxScrollHeight, waitForSecs, scrollDownAndUp, buttonSelector, stopScrollCallback } = parseArgument(options, infiniteScrollOptionsSchema);
276
287
  let finished;
277
288
  const startTime = Date.now();
278
289
  const CHECK_INTERVAL_MILLIS = 1000;
@@ -349,19 +360,11 @@ export async function infiniteScroll(page, options = {}) {
349
360
  * @param [options]
350
361
  */
351
362
  export async function saveSnapshot(page, options = {}) {
352
- ow(page, ow.object.validate(validators.browserPage));
353
- ow(options, ow.object.exactShape({
354
- key: ow.optional.string.nonEmpty,
355
- screenshotQuality: ow.optional.number,
356
- saveScreenshot: ow.optional.boolean,
357
- saveHtml: ow.optional.boolean,
358
- keyValueStoreName: ow.optional.string,
359
- config: ow.optional.object,
360
- }));
361
- const { key = 'SNAPSHOT', screenshotQuality = 50, saveScreenshot = true, saveHtml = true, keyValueStoreName, config, } = options;
363
+ parseArgument(page, validators.browserPage);
364
+ const { key, screenshotQuality, saveScreenshot, saveHtml, keyValueStoreName, configuration } = parseArgument(options, saveSnapshotOptionsSchema);
362
365
  try {
363
- const store = await KeyValueStore.open(keyValueStoreName, {
364
- config: config ?? Configuration.getGlobalConfig(),
366
+ const store = await KeyValueStore.open(keyValueStoreName ? { name: keyValueStoreName } : null, {
367
+ configuration: configuration ?? Configuration.getGlobalConfiguration(),
365
368
  });
366
369
  if (saveScreenshot) {
367
370
  const screenshotName = `${key}.jpg`;
@@ -396,36 +399,72 @@ export async function saveSnapshot(page, options = {}) {
396
399
  * @param ignoreShadowRoots
397
400
  */
398
401
  export async function parseWithCheerio(page, ignoreShadowRoots = false, ignoreIframes = false) {
399
- ow(page, ow.object.validate(validators.browserPage));
402
+ parseArgument(page, validators.browserPage);
403
+ const html = ignoreShadowRoots
404
+ ? null
405
+ : (await page.evaluate(`(${expandShadowRoots.toString()})(document)`));
406
+ const pageContent = html || (await page.content());
407
+ const { load } = await import('cheerio');
408
+ const $ = load(pageContent);
400
409
  if (page.frames().length > 1 && !ignoreIframes) {
401
410
  const frames = await page.$$('iframe');
402
- await Promise.all(frames.map(async (frame) => {
411
+ const cheerioIframes = $('iframe').toArray();
412
+ if (frames.length !== cheerioIframes.length) {
413
+ serviceLocator
414
+ .getLogger()
415
+ .warning(`parseWithCheerio: iframe count mismatch between live DOM (${frames.length}) and page snapshot (${cheerioIframes.length}). Some iframes may not be expanded.`);
416
+ }
417
+ await Promise.all(frames.map(async (frame, index) => {
403
418
  try {
404
419
  const iframe = await frame.contentFrame();
405
- if (iframe) {
406
- const contents = await iframe.content();
407
- await frame.evaluate((f, c) => {
408
- const replacementNode = document.createElement('div');
409
- replacementNode.innerHTML = c;
410
- replacementNode.className = 'crawlee-iframe-replacement';
411
- f.replaceWith(replacementNode);
412
- }, contents);
420
+ if (iframe && cheerioIframes[index]) {
421
+ const getIframeHTML = async () => {
422
+ try {
423
+ return iframe.locator('body').first().innerHTML();
424
+ }
425
+ catch {
426
+ return iframe.content();
427
+ }
428
+ };
429
+ const contents = await getIframeHTML();
430
+ $(cheerioIframes[index]).replaceWith(`<div class="crawlee-iframe-replacement">${contents}</div>`);
413
431
  }
414
432
  }
415
433
  catch (error) {
416
- log.warning(`Failed to extract iframe content: ${error}`);
434
+ getLog().warning(`Failed to extract iframe content: ${error}`);
417
435
  }
418
436
  }));
419
437
  }
420
- const html = ignoreShadowRoots
421
- ? null
422
- : (await page.evaluate(`(${expandShadowRoots.toString()})(document)`));
423
- const pageContent = html || (await page.content());
424
- return cheerio.load(pageContent);
438
+ return $;
439
+ }
440
+ let idcacPlaywright = null;
441
+ async function getIdcacPlaywright() {
442
+ if (idcacPlaywright)
443
+ return idcacPlaywright;
444
+ try {
445
+ idcacPlaywright = await import('idcac-playwright');
446
+ }
447
+ catch (error) {
448
+ getLog().warning(`Failed to import 'idcac-playwright'.
449
+
450
+ We recently made idcac-playwright an optional dependency due to licensing issues.
451
+ To use this feature, please install it manually by running
452
+
453
+ npm install idcac-playwright
454
+
455
+ Original error message follows:
456
+
457
+ ${error.message}
458
+ `);
459
+ }
460
+ return idcacPlaywright;
425
461
  }
426
462
  export async function closeCookieModals(page) {
427
- ow(page, ow.object.validate(validators.browserPage));
428
- await page.evaluate(getCookieClosingScript());
463
+ parseArgument(page, validators.browserPage);
464
+ const idcac = await getIdcacPlaywright();
465
+ if (idcac?.getInjectableScript()) {
466
+ await page.evaluate(idcac.getInjectableScript());
467
+ }
429
468
  }
430
469
  /**
431
470
  * This helper tries to solve the Cloudflare challenge automatically by clicking on the checkbox.
@@ -434,30 +473,24 @@ export async function closeCookieModals(page) {
434
473
  * result in a SessionError which will be automatically retried, so only successful requests will get
435
474
  * into the `requestHandler`.
436
475
  *
476
+ * On a successfully solved challenge the page is reloaded and the new {@link Response} is returned, so
477
+ * it can be propagated back to the crawling context via a hook return value (see
478
+ * {@link handleCloudflareChallengeHook}).
479
+ *
437
480
  * Works best with camoufox.
438
481
  *
439
482
  * **Example usage**
440
483
  * ```ts
441
484
  * postNavigationHooks: [
442
- * async ({ handleCloudflareChallenge }) => {
443
- * await handleCloudflareChallenge();
444
- * },
485
+ * async (context) => ({ response: await context.handleCloudflareChallenge() }),
445
486
  * ],
446
487
  * ```
447
488
  *
448
489
  * @param page Playwright [`Page`](https://playwright.dev/docs/api/class-page) object
449
490
  * @param url current URL for request identification, only used for logging
450
- * @param [session] current session object
451
491
  * @param [options]
452
492
  */
453
- async function handleCloudflareChallenge(page, url, session, options = {}) {
454
- // eslint-disable-next-line dot-notation
455
- const blockedStatusCodes = session?.['sessionPool']['blockedStatusCodes'];
456
- // Cloudflare pages are 403, which are blocked by default
457
- if (blockedStatusCodes?.includes(403)) {
458
- const idx = blockedStatusCodes.indexOf(403);
459
- blockedStatusCodes.splice(idx, 1);
460
- }
493
+ async function handleCloudflareChallenge(page, url, options = {}) {
461
494
  options.isBlockedCallback ??= async () => {
462
495
  const isBlocked = await page.evaluate(() => {
463
496
  return document.querySelector('h1')?.textContent?.trim().includes('Sorry, you have been blocked');
@@ -466,7 +499,9 @@ async function handleCloudflareChallenge(page, url, session, options = {}) {
466
499
  };
467
500
  options.isChallengeCallback ??= async () => {
468
501
  return await page.evaluate(async () => {
469
- return !!document.querySelector('.footer > .footer-inner > .diagnostic-wrapper > .ray-id');
502
+ // Cloudflare keeps reshuffling the wrapper elements between `.footer-inner` and `.ray-id`,
503
+ // so only the stable outer classes are matched.
504
+ return !!document.querySelector('.footer .footer-inner .ray-id');
470
505
  });
471
506
  };
472
507
  const retryBlocked = async () => {
@@ -481,31 +516,41 @@ async function handleCloudflareChallenge(page, url, session, options = {}) {
481
516
  };
482
517
  if (!(await isChallenge())) {
483
518
  await retryBlocked();
484
- return;
519
+ return undefined;
485
520
  }
486
521
  const logLevel = options.verbose ? 'info' : 'debug';
487
- log[logLevel](`Detected Cloudflare challenge at ${url}, trying to solve it. This can take up to ${10 + (options.sleepSecs ?? 10)} seconds.`);
522
+ getLog()[logLevel](`Detected Cloudflare challenge at ${url}, trying to solve it. This can take up to ${10 + (options.sleepSecs ?? 10)} seconds.`);
488
523
  const bb = await page
489
524
  .evaluate(() => {
490
- const div = document.querySelector('.main-content div');
525
+ // Prefer the actual challenge widget (the box holding the Turnstile checkbox input);
526
+ // fall back to the first content div for older challenge layouts.
527
+ const div = document.querySelector('.main-content div:has(input[id^="cf-chl-widget-"])') ??
528
+ document.querySelector('.main-content div');
491
529
  return div?.getBoundingClientRect();
492
530
  })
493
531
  .catch(() => undefined);
494
532
  if (!bb) {
495
- return;
533
+ return undefined;
496
534
  }
497
535
  const randomOffset = (range) => {
498
536
  return Math.round(100 * range * Math.random()) / 100;
499
537
  };
500
- const x = bb.x + 30;
501
- const y = bb.y + 25;
538
+ let x = bb.x + 30;
539
+ let y = bb.y + 25;
502
540
  // try to click the checkbox every second
503
541
  for (let i = 0; i < 10; i++) {
504
- await sleep(1000);
542
+ await sleep((options.preChallengeSleepSecs ?? 1) * 1000);
505
543
  // break early if we are no longer on the CF challenge page
506
544
  if (!(await isChallenge())) {
507
545
  break;
508
546
  }
547
+ if (options.clickPositionCallback) {
548
+ const pos = await options.clickPositionCallback(page);
549
+ if (pos) {
550
+ x = pos.x;
551
+ y = pos.y;
552
+ }
553
+ }
509
554
  if (options.clickCallback) {
510
555
  await options.clickCallback(page, { x, y });
511
556
  continue;
@@ -513,7 +558,10 @@ async function handleCloudflareChallenge(page, url, session, options = {}) {
513
558
  // we can click on the text too, so X can be a bit larger
514
559
  const xRandomized = x + randomOffset(10);
515
560
  const yRandomized = y + randomOffset(10);
516
- log[logLevel](`Trying to click on the Cloudflare checkbox at ${url}`, { x: xRandomized, y: yRandomized });
561
+ getLog()[logLevel](`Trying to click on the Cloudflare checkbox at ${url}`, {
562
+ x: xRandomized,
563
+ y: yRandomized,
564
+ });
517
565
  await page.mouse.click(xRandomized, yRandomized);
518
566
  // sometimes the checkbox is lower (could be caused by a lag when rendering the logo)
519
567
  await page.mouse.click(xRandomized, yRandomized + 35);
@@ -523,6 +571,9 @@ async function handleCloudflareChallenge(page, url, session, options = {}) {
523
571
  throw new SessionError(`Blocked by Cloudflare when processing ${url}`);
524
572
  }
525
573
  await retryBlocked();
574
+ // Reload to obtain a fresh Response without the challenge interstitial, which the caller can
575
+ // propagate back into the crawling context so downstream status-code checks see the new value.
576
+ return (await page.reload()) ?? undefined;
526
577
  }
527
578
  export { enqueueLinksByClickingElements };
528
579
  /** @internal */
@@ -540,4 +591,3 @@ export const playwrightUtils = {
540
591
  RenderingTypePredictor,
541
592
  handleCloudflareChallenge,
542
593
  };
543
- //# sourceMappingURL=playwright-utils.js.map
@@ -1,21 +1,38 @@
1
- import type { Request } from '@crawlee/core';
1
+ import type { RecoverableStatePersistenceOptions, Request } from '@crawlee/core';
2
2
  export type RenderingType = 'clientOnly' | 'static';
3
- type URLComponents = string[];
4
- type FeatureVector = [staticResultsSimilarity: number, clientOnlyResultsSimilarity: number];
5
3
  export interface RenderingTypePredictorOptions {
6
4
  /** A number between 0 and 1 that determines the desired ratio of rendering type detections */
7
5
  detectionRatio: number;
6
+ persistenceOptions?: Partial<RecoverableStatePersistenceOptions>;
7
+ }
8
+ /**
9
+ * Minimal contract that any object passed to {@link AdaptivePlaywrightCrawler} as its
10
+ * `renderingTypePredictor` option must satisfy.
11
+ *
12
+ * @experimental
13
+ */
14
+ export interface IRenderingTypePredictor {
15
+ /** Predict the rendering type for a request, and how likely the crawler should be to verify it. */
16
+ predict(request: Request): {
17
+ renderingType: RenderingType;
18
+ detectionProbabilityRecommendation: number;
19
+ };
20
+ /** Report a detected rendering type, so that future predictions can take it into account. */
21
+ storeResult(requests: Request | Request[], renderingType: RenderingType): void;
8
22
  }
9
23
  /**
10
24
  * Stores rendering type information for previously crawled URLs and predicts the rendering type for URLs that have yet to be crawled and recommends when rendering type detection should be performed.
11
25
  *
12
26
  * @experimental
13
27
  */
14
- export declare class RenderingTypePredictor {
15
- private renderingTypeDetectionResults;
16
- private detectionRatio;
17
- private logreg;
18
- constructor({ detectionRatio }: RenderingTypePredictorOptions);
28
+ export declare class RenderingTypePredictor implements IRenderingTypePredictor {
29
+ #private;
30
+ private state;
31
+ constructor({ detectionRatio, persistenceOptions }: RenderingTypePredictorOptions);
32
+ /**
33
+ * Initialize the predictor by restoring persisted state.
34
+ */
35
+ initialize(): Promise<void>;
19
36
  /**
20
37
  * Predict the rendering type for a given URL and request label.
21
38
  */
@@ -26,10 +43,8 @@ export declare class RenderingTypePredictor {
26
43
  /**
27
44
  * Store the rendering type for a given URL and request label. This updates the underlying prediction model, which may be costly.
28
45
  */
29
- storeResult({ url, loadedUrl, label }: Request, renderingType: RenderingType): void;
46
+ storeResult(requests: Request | Request[], renderingType: RenderingType): void;
30
47
  private resultCount;
31
- protected calculateFeatureVector(url: URLComponents, label: string | undefined): FeatureVector;
32
- protected retrain(): void;
48
+ private calculateFeatureVector;
49
+ private retrain;
33
50
  }
34
- export {};
35
- //# sourceMappingURL=rendering-type-prediction.d.ts.map
@@ -1,6 +1,8 @@
1
+ import { RecoverableState } from '@crawlee/core';
1
2
  import LogisticRegression from 'ml-logistic-regression';
2
3
  import { Matrix } from 'ml-matrix';
3
4
  import stringComparison from 'string-comparison';
5
+ import { z } from 'zod';
4
6
  const urlComponents = (url) => {
5
7
  return [url.hostname, ...url.pathname.split('/')];
6
8
  };
@@ -9,70 +11,127 @@ const calculateUrlSimilarity = (a, b) => {
9
11
  if (a[0] !== b[0]) {
10
12
  return 0;
11
13
  }
12
- for (let i = 1; i < Math.max(a.length, b.length); i++) {
14
+ const maxLength = Math.max(a.length, b.length);
15
+ // Only the hostname is present (no path components to compare) - the hosts already match.
16
+ if (maxLength <= 1) {
17
+ return 1;
18
+ }
19
+ for (let i = 1; i < maxLength; i++) {
13
20
  values.push(stringComparison.jaroWinkler.similarity(a[i] ?? '', b[i] ?? '') > 0.8 ? 1 : 0);
14
21
  }
15
- return sum(values) / Math.max(a.length, b.length);
22
+ // The first component (index 0, the hostname) is excluded from the comparison above,
23
+ // so it must also be excluded from the denominator of the weighted average.
24
+ return sum(values) / (maxLength - 1);
16
25
  };
17
26
  const sum = (values) => values.reduce((acc, value) => acc + value);
18
27
  const mean = (values) => (values.length > 0 ? sum(values) / values.length : undefined);
28
+ const renderingType = z.enum(['clientOnly', 'static']);
29
+ const predictorState = z.object({
30
+ logreg: z.instanceof(LogisticRegression),
31
+ detectionResults: z.map(renderingType, z.map(z.string().optional(), z.array(z.array(z.string())))),
32
+ });
33
+ const persistedState = z.object({
34
+ logreg: z
35
+ .record(z.string(), z.unknown())
36
+ .prefault(() => new LogisticRegression({ numSteps: 1000, learningRate: 0.05 }).toJSON()),
37
+ detectionResults: z
38
+ .array(z.object({
39
+ renderingType,
40
+ urlPartsByLabel: z.array(z.object({
41
+ label: z.string().optional(),
42
+ urlParts: z.array(z.array(z.string())),
43
+ })),
44
+ }))
45
+ .prefault([]),
46
+ });
47
+ const stateCodec = z.codec(persistedState, predictorState, {
48
+ decode: ({ logreg, detectionResults }) => ({
49
+ logreg: LogisticRegression.load(logreg),
50
+ detectionResults: new Map(detectionResults.map(({ renderingType, urlPartsByLabel }) => [
51
+ renderingType,
52
+ new Map(urlPartsByLabel.map(({ label, urlParts }) => [label, urlParts])),
53
+ ])),
54
+ }),
55
+ encode: ({ logreg, detectionResults }) => ({
56
+ logreg: logreg.toJSON(),
57
+ detectionResults: Array.from(detectionResults.entries()).map(([renderingType, urlPartsByLabel]) => ({
58
+ renderingType,
59
+ urlPartsByLabel: Array.from(urlPartsByLabel.entries()).map(([label, urlParts]) => ({ label, urlParts })),
60
+ })),
61
+ }),
62
+ });
19
63
  /**
20
64
  * Stores rendering type information for previously crawled URLs and predicts the rendering type for URLs that have yet to be crawled and recommends when rendering type detection should be performed.
21
65
  *
22
66
  * @experimental
23
67
  */
24
68
  export class RenderingTypePredictor {
25
- renderingTypeDetectionResults = new Map();
26
- detectionRatio;
27
- logreg;
28
- constructor({ detectionRatio }) {
29
- this.detectionRatio = detectionRatio;
30
- this.logreg = new LogisticRegression({ numSteps: 1000, learningRate: 0.05 });
69
+ #detectionRatio;
70
+ // kept as TS-private: tests reach for it at runtime
71
+ state;
72
+ constructor({ detectionRatio, persistenceOptions }) {
73
+ this.#detectionRatio = detectionRatio;
74
+ this.state = new RecoverableState({
75
+ defaultState: () => stateCodec.decode({}),
76
+ // The codec validates in the decode direction, so it is a Standard Schema as-is; encoding needs a call.
77
+ deserialize: stateCodec,
78
+ serialize: (state) => stateCodec.encode(state),
79
+ persistStateKey: 'rendering-type-predictor-state',
80
+ persistenceEnabled: true,
81
+ ...persistenceOptions,
82
+ });
83
+ }
84
+ /**
85
+ * Initialize the predictor by restoring persisted state.
86
+ */
87
+ async initialize() {
88
+ await this.state.initialize();
31
89
  }
32
90
  /**
33
91
  * Predict the rendering type for a given URL and request label.
34
92
  */
35
93
  predict({ url, loadedUrl, label }) {
36
- if (this.logreg.classifiers.length === 0) {
94
+ const { logreg } = this.state.currentValue;
95
+ if (logreg.classifiers.length === 0) {
37
96
  return { renderingType: 'clientOnly', detectionProbabilityRecommendation: 1 };
38
97
  }
39
98
  const predictionUrl = new URL(loadedUrl ?? url);
40
99
  const urlFeature = new Matrix([this.calculateFeatureVector(urlComponents(predictionUrl), label)]);
41
- const [prediction] = this.logreg.predict(urlFeature);
42
- const scores = [
43
- this.logreg.classifiers[0].testScores(urlFeature),
44
- this.logreg.classifiers[1].testScores(urlFeature),
45
- ];
100
+ const [prediction] = logreg.predict(urlFeature);
101
+ const scores = [logreg.classifiers[0].testScores(urlFeature), logreg.classifiers[1].testScores(urlFeature)];
46
102
  return {
47
103
  renderingType: prediction === 1 ? 'static' : 'clientOnly',
48
104
  detectionProbabilityRecommendation: Math.abs(scores[0] - scores[1]) < 0.1
49
105
  ? 1
50
- : this.detectionRatio * Math.max(1, 5 - this.resultCount(label)),
106
+ : this.#detectionRatio * Math.max(1, 5 - this.resultCount(label)),
51
107
  };
52
108
  }
53
109
  /**
54
110
  * Store the rendering type for a given URL and request label. This updates the underlying prediction model, which may be costly.
55
111
  */
56
- storeResult({ url, loadedUrl, label }, renderingType) {
57
- const resultUrl = new URL(loadedUrl ?? url);
58
- if (!this.renderingTypeDetectionResults.has(renderingType)) {
59
- this.renderingTypeDetectionResults.set(renderingType, new Map());
60
- }
61
- if (!this.renderingTypeDetectionResults.get(renderingType).has(label)) {
62
- this.renderingTypeDetectionResults.get(renderingType).set(label, []);
112
+ storeResult(requests, renderingType) {
113
+ const state = this.state.currentValue;
114
+ for (const { url, loadedUrl, label } of Array.isArray(requests) ? requests : [requests]) {
115
+ const resultUrl = new URL(loadedUrl ?? url);
116
+ if (!state.detectionResults.has(renderingType)) {
117
+ state.detectionResults.set(renderingType, new Map());
118
+ }
119
+ if (!state.detectionResults.get(renderingType).has(label)) {
120
+ state.detectionResults.get(renderingType).set(label, []);
121
+ }
122
+ state.detectionResults.get(renderingType).get(label).push(urlComponents(resultUrl));
63
123
  }
64
- this.renderingTypeDetectionResults.get(renderingType).get(label).push(urlComponents(resultUrl));
65
124
  this.retrain();
66
125
  }
67
126
  resultCount(label) {
68
- return Array.from(this.renderingTypeDetectionResults.values())
127
+ return Array.from(this.state.currentValue.detectionResults.values())
69
128
  .map((results) => results.get(label)?.length ?? 0)
70
129
  .reduce((acc, value) => acc + value, 0);
71
130
  }
72
131
  calculateFeatureVector(url, label) {
73
132
  return [
74
- mean((this.renderingTypeDetectionResults.get('static')?.get(label) ?? []).map((otherUrl) => calculateUrlSimilarity(url, otherUrl) ?? 0)) ?? 0,
75
- mean((this.renderingTypeDetectionResults.get('clientOnly')?.get(label) ?? []).map((otherUrl) => calculateUrlSimilarity(url, otherUrl) ?? 0)) ?? 0,
133
+ mean((this.state.currentValue.detectionResults.get('static')?.get(label) ?? []).map((otherUrl) => calculateUrlSimilarity(url, otherUrl) ?? 0)) ?? 0,
134
+ mean((this.state.currentValue.detectionResults.get('clientOnly')?.get(label) ?? []).map((otherUrl) => calculateUrlSimilarity(url, otherUrl) ?? 0)) ?? 0,
76
135
  ];
77
136
  }
78
137
  retrain() {
@@ -81,7 +140,7 @@ export class RenderingTypePredictor {
81
140
  [1, 0],
82
141
  ];
83
142
  const Y = [0, 1];
84
- for (const [renderingType, urlsByLabel] of this.renderingTypeDetectionResults.entries()) {
143
+ for (const [renderingType, urlsByLabel] of this.state.currentValue.detectionResults.entries()) {
85
144
  for (const [label, urls] of urlsByLabel) {
86
145
  for (const url of urls) {
87
146
  X.push(this.calculateFeatureVector(url, label));
@@ -89,7 +148,6 @@ export class RenderingTypePredictor {
89
148
  }
90
149
  }
91
150
  }
92
- this.logreg.train(new Matrix(X), Matrix.columnVector(Y));
151
+ this.state.currentValue.logreg.train(new Matrix(X), Matrix.columnVector(Y));
93
152
  }
94
153
  }
95
- //# sourceMappingURL=rendering-type-prediction.js.map