@crawlee/playwright 4.0.0-beta.19 → 4.0.0-beta.191

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 +137 -65
  5. package/internals/adaptive-playwright-crawler.js +416 -274
  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 +111 -121
  16. package/internals/utils/rendering-type-prediction.d.ts +37 -11
  17. package/internals/utils/rendering-type-prediction.js +81 -27
  18. package/package.json +15 -19
  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,26 @@ 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
+ // Full cheerio (parse5) import for browser compliant parser
409
+ const { load } = await import('cheerio');
410
+ const $ = load(pageContent);
399
411
  if (page.frames().length > 1 && !ignoreIframes) {
400
412
  const frames = await page.$$('iframe');
401
- await Promise.all(frames.map(async (frame) => {
413
+ const cheerioIframes = $('iframe').toArray();
414
+ if (frames.length !== cheerioIframes.length) {
415
+ serviceLocator
416
+ .getLogger()
417
+ .warning(`parseWithCheerio: iframe count mismatch between live DOM (${frames.length}) and page snapshot (${cheerioIframes.length}). Some iframes may not be expanded.`);
418
+ }
419
+ await Promise.all(frames.map(async (frame, index) => {
402
420
  try {
403
421
  const iframe = await frame.contentFrame();
404
- if (iframe) {
422
+ if (iframe && cheerioIframes[index]) {
405
423
  const getIframeHTML = async () => {
406
424
  try {
407
425
  return iframe.locator('body').first().innerHTML();
@@ -411,53 +429,15 @@ export async function parseWithCheerio(page, ignoreShadowRoots = false, ignoreIf
411
429
  }
412
430
  };
413
431
  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);
432
+ $(cheerioIframes[index]).replaceWith(`<div class="crawlee-iframe-replacement">${contents}</div>`);
420
433
  }
421
434
  }
422
435
  catch (error) {
423
- log.warning(`Failed to extract iframe content: ${error}`);
436
+ getLog().warning(`Failed to extract iframe content: ${error}`);
424
437
  }
425
438
  }));
426
439
  }
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
- }
440
+ return $;
461
441
  }
462
442
  /**
463
443
  * This helper tries to solve the Cloudflare challenge automatically by clicking on the checkbox.
@@ -466,30 +446,24 @@ export async function closeCookieModals(page) {
466
446
  * result in a SessionError which will be automatically retried, so only successful requests will get
467
447
  * into the `requestHandler`.
468
448
  *
449
+ * On a successfully solved challenge the page is reloaded and the new {@link Response} is returned, so
450
+ * it can be propagated back to the crawling context via a hook return value (see
451
+ * {@link handleCloudflareChallengeHook}).
452
+ *
469
453
  * Works best with camoufox.
470
454
  *
471
455
  * **Example usage**
472
456
  * ```ts
473
457
  * postNavigationHooks: [
474
- * async ({ handleCloudflareChallenge }) => {
475
- * await handleCloudflareChallenge();
476
- * },
458
+ * async (context) => ({ response: await context.handleCloudflareChallenge() }),
477
459
  * ],
478
460
  * ```
479
461
  *
480
462
  * @param page Playwright [`Page`](https://playwright.dev/docs/api/class-page) object
481
463
  * @param url current URL for request identification, only used for logging
482
- * @param [session] current session object
483
464
  * @param [options]
484
465
  */
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
- }
466
+ async function handleCloudflareChallenge(page, url, options = {}) {
493
467
  options.isBlockedCallback ??= async () => {
494
468
  const isBlocked = await page.evaluate(() => {
495
469
  return document.querySelector('h1')?.textContent?.trim().includes('Sorry, you have been blocked');
@@ -498,7 +472,9 @@ async function handleCloudflareChallenge(page, url, session, options = {}) {
498
472
  };
499
473
  options.isChallengeCallback ??= async () => {
500
474
  return await page.evaluate(async () => {
501
- return !!document.querySelector('.footer > .footer-inner > .diagnostic-wrapper > .ray-id');
475
+ // Cloudflare keeps reshuffling the wrapper elements between `.footer-inner` and `.ray-id`,
476
+ // so only the stable outer classes are matched.
477
+ return !!document.querySelector('.footer .footer-inner .ray-id');
502
478
  });
503
479
  };
504
480
  const retryBlocked = async () => {
@@ -513,31 +489,41 @@ async function handleCloudflareChallenge(page, url, session, options = {}) {
513
489
  };
514
490
  if (!(await isChallenge())) {
515
491
  await retryBlocked();
516
- return;
492
+ return undefined;
517
493
  }
518
494
  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.`);
495
+ getLog()[logLevel](`Detected Cloudflare challenge at ${url}, trying to solve it. This can take up to ${10 + (options.sleepSecs ?? 10)} seconds.`);
520
496
  const bb = await page
521
497
  .evaluate(() => {
522
- const div = document.querySelector('.main-content div');
498
+ // Prefer the actual challenge widget (the box holding the Turnstile checkbox input);
499
+ // fall back to the first content div for older challenge layouts.
500
+ const div = document.querySelector('.main-content div:has(input[id^="cf-chl-widget-"])') ??
501
+ document.querySelector('.main-content div');
523
502
  return div?.getBoundingClientRect();
524
503
  })
525
504
  .catch(() => undefined);
526
505
  if (!bb) {
527
- return;
506
+ return undefined;
528
507
  }
529
508
  const randomOffset = (range) => {
530
509
  return Math.round(100 * range * Math.random()) / 100;
531
510
  };
532
- const x = bb.x + 30;
533
- const y = bb.y + 25;
511
+ let x = bb.x + 30;
512
+ let y = bb.y + 25;
534
513
  // try to click the checkbox every second
535
514
  for (let i = 0; i < 10; i++) {
536
- await sleep(1000);
515
+ await sleep((options.preChallengeSleepSecs ?? 1) * 1000);
537
516
  // break early if we are no longer on the CF challenge page
538
517
  if (!(await isChallenge())) {
539
518
  break;
540
519
  }
520
+ if (options.clickPositionCallback) {
521
+ const pos = await options.clickPositionCallback(page);
522
+ if (pos) {
523
+ x = pos.x;
524
+ y = pos.y;
525
+ }
526
+ }
541
527
  if (options.clickCallback) {
542
528
  await options.clickCallback(page, { x, y });
543
529
  continue;
@@ -545,7 +531,10 @@ async function handleCloudflareChallenge(page, url, session, options = {}) {
545
531
  // we can click on the text too, so X can be a bit larger
546
532
  const xRandomized = x + randomOffset(10);
547
533
  const yRandomized = y + randomOffset(10);
548
- log[logLevel](`Trying to click on the Cloudflare checkbox at ${url}`, { x: xRandomized, y: yRandomized });
534
+ getLog()[logLevel](`Trying to click on the Cloudflare checkbox at ${url}`, {
535
+ x: xRandomized,
536
+ y: yRandomized,
537
+ });
549
538
  await page.mouse.click(xRandomized, yRandomized);
550
539
  // sometimes the checkbox is lower (could be caused by a lag when rendering the logo)
551
540
  await page.mouse.click(xRandomized, yRandomized + 35);
@@ -555,6 +544,9 @@ async function handleCloudflareChallenge(page, url, session, options = {}) {
555
544
  throw new SessionError(`Blocked by Cloudflare when processing ${url}`);
556
545
  }
557
546
  await retryBlocked();
547
+ // Reload to obtain a fresh Response without the challenge interstitial, which the caller can
548
+ // propagate back into the crawling context so downstream status-code checks see the new value.
549
+ return (await page.reload()) ?? undefined;
558
550
  }
559
551
  export { enqueueLinksByClickingElements };
560
552
  /** @internal */
@@ -568,8 +560,6 @@ export const playwrightUtils = {
568
560
  infiniteScroll,
569
561
  saveSnapshot,
570
562
  compileScript,
571
- closeCookieModals,
572
563
  RenderingTypePredictor,
573
564
  handleCloudflareChallenge,
574
565
  };
575
- //# sourceMappingURL=playwright-utils.js.map
@@ -1,26 +1,54 @@
1
1
  import type { RecoverableStatePersistenceOptions, Request } from '@crawlee/core';
2
+ import type { Awaitable } from '@crawlee/types';
2
3
  export type RenderingType = 'clientOnly' | 'static';
3
- type URLComponents = string[];
4
- type FeatureVector = [staticResultsSimilarity: number, clientOnlyResultsSimilarity: number];
5
4
  export interface RenderingTypePredictorOptions {
6
5
  /** A number between 0 and 1 that determines the desired ratio of rendering type detections */
7
6
  detectionRatio: number;
8
7
  persistenceOptions?: Partial<RecoverableStatePersistenceOptions>;
9
8
  }
9
+ /**
10
+ * Minimal contract that any object passed to {@link AdaptivePlaywrightCrawler} as its
11
+ * `renderingTypePredictor` option must satisfy.
12
+ *
13
+ * @experimental
14
+ */
15
+ export interface IRenderingTypePredictor {
16
+ /**
17
+ * Predict the rendering type for a request, and how likely the crawler should be to verify it.
18
+ *
19
+ * Called once per request, before navigation - the crawler awaits it, so prefer loading whatever the
20
+ * prediction needs up front over per-request I/O.
21
+ */
22
+ predict(request: Request): Awaitable<{
23
+ renderingType: RenderingType;
24
+ detectionProbabilityRecommendation: number;
25
+ }>;
26
+ /**
27
+ * Report a detected rendering type, so that future predictions can take it into account.
28
+ *
29
+ * The crawler does not await this per detection - it collects the returned promises and drains them in
30
+ * `teardown()`, so an implementation that persists results asynchronously can keep batching its writes
31
+ * and still be sure they land before the crawl ends.
32
+ */
33
+ storeResult(requests: Request | Request[], renderingType: RenderingType): Awaitable<void>;
34
+ }
10
35
  /**
11
36
  * 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
37
  *
13
38
  * @experimental
14
39
  */
15
- export declare class RenderingTypePredictor {
16
- private renderingTypeDetectionResults;
17
- private detectionRatio;
18
- private state;
40
+ export declare class RenderingTypePredictor implements IRenderingTypePredictor {
41
+ #private;
19
42
  constructor({ detectionRatio, persistenceOptions }: RenderingTypePredictorOptions);
20
43
  /**
21
44
  * Initialize the predictor by restoring persisted state.
22
45
  */
23
46
  initialize(): Promise<void>;
47
+ /**
48
+ * Stop persisting the model, writing it out one last time. `initialize()` reopens the persistence window.
49
+ */
50
+ teardown(): Promise<void>;
51
+ [Symbol.asyncDispose](): Promise<void>;
24
52
  /**
25
53
  * Predict the rendering type for a given URL and request label.
26
54
  */
@@ -31,10 +59,8 @@ export declare class RenderingTypePredictor {
31
59
  /**
32
60
  * Store the rendering type for a given URL and request label. This updates the underlying prediction model, which may be costly.
33
61
  */
34
- storeResult({ url, loadedUrl, label }: Request, renderingType: RenderingType): void;
62
+ storeResult(requests: Request | Request[], renderingType: RenderingType): void;
35
63
  private resultCount;
36
- protected calculateFeatureVector(url: URLComponents, label: string | undefined): FeatureVector;
37
- protected retrain(): void;
64
+ private calculateFeatureVector;
65
+ private retrain;
38
66
  }
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