@crawlee/playwright 4.0.0-beta.15 → 4.0.0-beta.151

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 +320 -266
  6. package/internals/enqueue-links/click-elements.d.ts +36 -64
  7. package/internals/enqueue-links/click-elements.js +65 -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 +180 -125
  11. package/internals/playwright-crawler.js +68 -63
  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 -41
  15. package/internals/utils/playwright-utils.js +110 -121
  16. package/internals/utils/rendering-type-prediction.d.ts +25 -11
  17. package/internals/utils/rendering-type-prediction.js +81 -27
  18. package/package.json +14 -18
  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,53 +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);
432
- }
433
- let idcacPlaywright = null;
434
- async function getIdcacPlaywright() {
435
- if (idcacPlaywright)
436
- return idcacPlaywright;
437
- try {
438
- idcacPlaywright = await import('idcac-playwright');
439
- }
440
- catch (error) {
441
- log.warning(`Failed to import 'idcac-playwright'.
442
-
443
- We recently made idcac-playwright an optional dependency due to licensing issues.
444
- To use this feature, please install it manually by running
445
-
446
- npm install idcac-playwright
447
-
448
- Original error message follows:
449
-
450
- ${error.message}
451
- `);
452
- }
453
- return idcacPlaywright;
454
- }
455
- export async function closeCookieModals(page) {
456
- ow(page, ow.object.validate(validators.browserPage));
457
- const idcac = await getIdcacPlaywright();
458
- if (idcac?.getInjectableScript()) {
459
- await page.evaluate(idcac.getInjectableScript());
460
- }
439
+ return $;
461
440
  }
462
441
  /**
463
442
  * This helper tries to solve the Cloudflare challenge automatically by clicking on the checkbox.
@@ -466,30 +445,24 @@ export async function closeCookieModals(page) {
466
445
  * result in a SessionError which will be automatically retried, so only successful requests will get
467
446
  * into the `requestHandler`.
468
447
  *
448
+ * On a successfully solved challenge the page is reloaded and the new {@link Response} is returned, so
449
+ * it can be propagated back to the crawling context via a hook return value (see
450
+ * {@link handleCloudflareChallengeHook}).
451
+ *
469
452
  * Works best with camoufox.
470
453
  *
471
454
  * **Example usage**
472
455
  * ```ts
473
456
  * postNavigationHooks: [
474
- * async ({ handleCloudflareChallenge }) => {
475
- * await handleCloudflareChallenge();
476
- * },
457
+ * async (context) => ({ response: await context.handleCloudflareChallenge() }),
477
458
  * ],
478
459
  * ```
479
460
  *
480
461
  * @param page Playwright [`Page`](https://playwright.dev/docs/api/class-page) object
481
462
  * @param url current URL for request identification, only used for logging
482
- * @param [session] current session object
483
463
  * @param [options]
484
464
  */
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
- }
465
+ async function handleCloudflareChallenge(page, url, options = {}) {
493
466
  options.isBlockedCallback ??= async () => {
494
467
  const isBlocked = await page.evaluate(() => {
495
468
  return document.querySelector('h1')?.textContent?.trim().includes('Sorry, you have been blocked');
@@ -498,7 +471,9 @@ async function handleCloudflareChallenge(page, url, session, options = {}) {
498
471
  };
499
472
  options.isChallengeCallback ??= async () => {
500
473
  return await page.evaluate(async () => {
501
- return !!document.querySelector('.footer > .footer-inner > .diagnostic-wrapper > .ray-id');
474
+ // Cloudflare keeps reshuffling the wrapper elements between `.footer-inner` and `.ray-id`,
475
+ // so only the stable outer classes are matched.
476
+ return !!document.querySelector('.footer .footer-inner .ray-id');
502
477
  });
503
478
  };
504
479
  const retryBlocked = async () => {
@@ -513,31 +488,41 @@ async function handleCloudflareChallenge(page, url, session, options = {}) {
513
488
  };
514
489
  if (!(await isChallenge())) {
515
490
  await retryBlocked();
516
- return;
491
+ return undefined;
517
492
  }
518
493
  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.`);
494
+ getLog()[logLevel](`Detected Cloudflare challenge at ${url}, trying to solve it. This can take up to ${10 + (options.sleepSecs ?? 10)} seconds.`);
520
495
  const bb = await page
521
496
  .evaluate(() => {
522
- const div = document.querySelector('.main-content div');
497
+ // Prefer the actual challenge widget (the box holding the Turnstile checkbox input);
498
+ // fall back to the first content div for older challenge layouts.
499
+ const div = document.querySelector('.main-content div:has(input[id^="cf-chl-widget-"])') ??
500
+ document.querySelector('.main-content div');
523
501
  return div?.getBoundingClientRect();
524
502
  })
525
503
  .catch(() => undefined);
526
504
  if (!bb) {
527
- return;
505
+ return undefined;
528
506
  }
529
507
  const randomOffset = (range) => {
530
508
  return Math.round(100 * range * Math.random()) / 100;
531
509
  };
532
- const x = bb.x + 30;
533
- const y = bb.y + 25;
510
+ let x = bb.x + 30;
511
+ let y = bb.y + 25;
534
512
  // try to click the checkbox every second
535
513
  for (let i = 0; i < 10; i++) {
536
- await sleep(1000);
514
+ await sleep((options.preChallengeSleepSecs ?? 1) * 1000);
537
515
  // break early if we are no longer on the CF challenge page
538
516
  if (!(await isChallenge())) {
539
517
  break;
540
518
  }
519
+ if (options.clickPositionCallback) {
520
+ const pos = await options.clickPositionCallback(page);
521
+ if (pos) {
522
+ x = pos.x;
523
+ y = pos.y;
524
+ }
525
+ }
541
526
  if (options.clickCallback) {
542
527
  await options.clickCallback(page, { x, y });
543
528
  continue;
@@ -545,7 +530,10 @@ async function handleCloudflareChallenge(page, url, session, options = {}) {
545
530
  // we can click on the text too, so X can be a bit larger
546
531
  const xRandomized = x + randomOffset(10);
547
532
  const yRandomized = y + randomOffset(10);
548
- log[logLevel](`Trying to click on the Cloudflare checkbox at ${url}`, { x: xRandomized, y: yRandomized });
533
+ getLog()[logLevel](`Trying to click on the Cloudflare checkbox at ${url}`, {
534
+ x: xRandomized,
535
+ y: yRandomized,
536
+ });
549
537
  await page.mouse.click(xRandomized, yRandomized);
550
538
  // sometimes the checkbox is lower (could be caused by a lag when rendering the logo)
551
539
  await page.mouse.click(xRandomized, yRandomized + 35);
@@ -555,6 +543,9 @@ async function handleCloudflareChallenge(page, url, session, options = {}) {
555
543
  throw new SessionError(`Blocked by Cloudflare when processing ${url}`);
556
544
  }
557
545
  await retryBlocked();
546
+ // Reload to obtain a fresh Response without the challenge interstitial, which the caller can
547
+ // propagate back into the crawling context so downstream status-code checks see the new value.
548
+ return (await page.reload()) ?? undefined;
558
549
  }
559
550
  export { enqueueLinksByClickingElements };
560
551
  /** @internal */
@@ -568,8 +559,6 @@ export const playwrightUtils = {
568
559
  infiniteScroll,
569
560
  saveSnapshot,
570
561
  compileScript,
571
- closeCookieModals,
572
562
  RenderingTypePredictor,
573
563
  handleCloudflareChallenge,
574
564
  };
575
- //# sourceMappingURL=playwright-utils.js.map
@@ -1,26 +1,42 @@
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;
18
- private state;
28
+ export declare class RenderingTypePredictor implements IRenderingTypePredictor {
29
+ #private;
19
30
  constructor({ detectionRatio, persistenceOptions }: RenderingTypePredictorOptions);
20
31
  /**
21
32
  * Initialize the predictor by restoring persisted state.
22
33
  */
23
34
  initialize(): Promise<void>;
35
+ /**
36
+ * Stop persisting the model, writing it out one last time. `initialize()` reopens the persistence window.
37
+ */
38
+ teardown(): Promise<void>;
39
+ [Symbol.asyncDispose](): Promise<void>;
24
40
  /**
25
41
  * Predict the rendering type for a given URL and request label.
26
42
  */
@@ -31,10 +47,8 @@ export declare class RenderingTypePredictor {
31
47
  /**
32
48
  * Store the rendering type for a given URL and request label. This updates the underlying prediction model, which may be costly.
33
49
  */
34
- storeResult({ url, loadedUrl, label }: Request, renderingType: RenderingType): void;
50
+ storeResult(requests: Request | Request[], renderingType: RenderingType): void;
35
51
  private resultCount;
36
- protected calculateFeatureVector(url: URLComponents, label: string | undefined): FeatureVector;
37
- protected retrain(): void;
52
+ private calculateFeatureVector;
53
+ private retrain;
38
54
  }
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,70 @@ 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;
28
- state;
69
+ #detectionRatio;
70
+ #state;
29
71
  constructor({ detectionRatio, persistenceOptions }) {
30
- this.detectionRatio = detectionRatio;
31
- 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) }),
72
+ this.#detectionRatio = detectionRatio;
73
+ this.#state = new RecoverableState({
74
+ defaultState: () => stateCodec.decode({}),
75
+ // The codec validates in the decode direction, so it is a Standard Schema as-is; encoding needs a call.
76
+ deserialize: stateCodec,
77
+ serialize: (state) => stateCodec.encode(state),
35
78
  persistStateKey: 'rendering-type-predictor-state',
36
79
  persistenceEnabled: true,
37
80
  ...persistenceOptions,
@@ -41,13 +84,22 @@ export class RenderingTypePredictor {
41
84
  * Initialize the predictor by restoring persisted state.
42
85
  */
43
86
  async initialize() {
44
- await this.state.initialize();
87
+ await this.#state.initialize();
88
+ }
89
+ /**
90
+ * Stop persisting the model, writing it out one last time. `initialize()` reopens the persistence window.
91
+ */
92
+ async teardown() {
93
+ await this.#state.teardown();
94
+ }
95
+ async [Symbol.asyncDispose]() {
96
+ await this.teardown();
45
97
  }
46
98
  /**
47
99
  * Predict the rendering type for a given URL and request label.
48
100
  */
49
101
  predict({ url, loadedUrl, label }) {
50
- const { logreg } = this.state.currentValue;
102
+ const { logreg } = this.#state.currentValue;
51
103
  if (logreg.classifiers.length === 0) {
52
104
  return { renderingType: 'clientOnly', detectionProbabilityRecommendation: 1 };
53
105
  }
@@ -59,32 +111,35 @@ export class RenderingTypePredictor {
59
111
  renderingType: prediction === 1 ? 'static' : 'clientOnly',
60
112
  detectionProbabilityRecommendation: Math.abs(scores[0] - scores[1]) < 0.1
61
113
  ? 1
62
- : this.detectionRatio * Math.max(1, 5 - this.resultCount(label)),
114
+ : this.#detectionRatio * Math.max(1, 5 - this.resultCount(label)),
63
115
  };
64
116
  }
65
117
  /**
66
118
  * Store the rendering type for a given URL and request label. This updates the underlying prediction model, which may be costly.
67
119
  */
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, []);
120
+ storeResult(requests, renderingType) {
121
+ const state = this.#state.currentValue;
122
+ for (const { url, loadedUrl, label } of Array.isArray(requests) ? requests : [requests]) {
123
+ const resultUrl = new URL(loadedUrl ?? url);
124
+ if (!state.detectionResults.has(renderingType)) {
125
+ state.detectionResults.set(renderingType, new Map());
126
+ }
127
+ if (!state.detectionResults.get(renderingType).has(label)) {
128
+ state.detectionResults.get(renderingType).set(label, []);
129
+ }
130
+ state.detectionResults.get(renderingType).get(label).push(urlComponents(resultUrl));
75
131
  }
76
- this.renderingTypeDetectionResults.get(renderingType).get(label).push(urlComponents(resultUrl));
77
132
  this.retrain();
78
133
  }
79
134
  resultCount(label) {
80
- return Array.from(this.renderingTypeDetectionResults.values())
135
+ return Array.from(this.#state.currentValue.detectionResults.values())
81
136
  .map((results) => results.get(label)?.length ?? 0)
82
137
  .reduce((acc, value) => acc + value, 0);
83
138
  }
84
139
  calculateFeatureVector(url, label) {
85
140
  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,
141
+ mean((this.#state.currentValue.detectionResults.get('static')?.get(label) ?? []).map((otherUrl) => calculateUrlSimilarity(url, otherUrl) ?? 0)) ?? 0,
142
+ mean((this.#state.currentValue.detectionResults.get('clientOnly')?.get(label) ?? []).map((otherUrl) => calculateUrlSimilarity(url, otherUrl) ?? 0)) ?? 0,
88
143
  ];
89
144
  }
90
145
  retrain() {
@@ -93,7 +148,7 @@ export class RenderingTypePredictor {
93
148
  [1, 0],
94
149
  ];
95
150
  const Y = [0, 1];
96
- for (const [renderingType, urlsByLabel] of this.renderingTypeDetectionResults.entries()) {
151
+ for (const [renderingType, urlsByLabel] of this.#state.currentValue.detectionResults.entries()) {
97
152
  for (const [label, urls] of urlsByLabel) {
98
153
  for (const url of urls) {
99
154
  X.push(this.calculateFeatureVector(url, label));
@@ -101,7 +156,6 @@ export class RenderingTypePredictor {
101
156
  }
102
157
  }
103
158
  }
104
- this.state.currentValue.logreg.train(new Matrix(X), Matrix.columnVector(Y));
159
+ this.#state.currentValue.logreg.train(new Matrix(X), Matrix.columnVector(Y));
105
160
  }
106
161
  }
107
- //# sourceMappingURL=rendering-type-prediction.js.map