@crawlee/playwright 4.0.0-beta.14 → 4.0.0-beta.141

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