@crawlee/playwright 4.0.0-beta.99 → 4.0.0-rc.1

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.
@@ -19,12 +19,11 @@
19
19
  */
20
20
  import { Configuration, type Request } from '@crawlee/browser';
21
21
  import type { BatchAddRequestsResult, Dictionary } from '@crawlee/types';
22
- import { type CheerioRoot } from '@crawlee/utils';
22
+ import type { CheerioAPI } from 'cheerio';
23
23
  // @ts-ignore optional peer dependency or compatibility with es2022
24
24
  import type { Download, Page, Response } from 'playwright';
25
25
  import type { EnqueueLinksByClickingElementsOptions } from '../enqueue-links/click-elements.js';
26
26
  import { enqueueLinksByClickingElements } from '../enqueue-links/click-elements.js';
27
- import { RenderingTypePredictor } from './rendering-type-prediction.js';
28
27
  export interface InjectFileOptions {
29
28
  /**
30
29
  * Enables the injected script to survive page navigations and reloads without need to be re-injected manually.
@@ -287,8 +286,7 @@ export declare function saveSnapshot(page: Page, options?: SaveSnapshotOptions):
287
286
  * @param page Playwright [`Page`](https://playwright.dev/docs/api/class-page) object.
288
287
  * @param ignoreShadowRoots
289
288
  */
290
- export declare function parseWithCheerio(page: Page, ignoreShadowRoots?: boolean, ignoreIframes?: boolean): Promise<CheerioRoot>;
291
- export declare function closeCookieModals(page: Page): Promise<void>;
289
+ export declare function parseWithCheerio(page: Page, ignoreShadowRoots?: boolean, ignoreIframes?: boolean): Promise<CheerioAPI>;
292
290
  export interface HandleCloudflareChallengeOptions {
293
291
  /** Logging defaults to the `debug` level, use this flag to log to `info` level instead. */
294
292
  verbose?: boolean;
@@ -335,7 +333,7 @@ export interface HandleCloudflareChallengeOptions {
335
333
  * @param url current URL for request identification, only used for logging
336
334
  * @param [options]
337
335
  */
338
- declare function handleCloudflareChallenge(page: Page, url: string, options?: HandleCloudflareChallengeOptions): Promise<Response | undefined>;
336
+ export declare function handleCloudflareChallenge(page: Page, url: string, options?: HandleCloudflareChallengeOptions): Promise<Response | undefined>;
339
337
  export interface PlaywrightContextUtils {
340
338
  /**
341
339
  * Injects a JavaScript file into current `page`.
@@ -434,7 +432,7 @@ export interface PlaywrightContextUtils {
434
432
  * });
435
433
  * ```
436
434
  */
437
- parseWithCheerio(selector?: string, timeoutMs?: number): Promise<CheerioRoot>;
435
+ parseWithCheerio(selector?: string, timeoutMs?: number): Promise<CheerioAPI>;
438
436
  /**
439
437
  * Scrolls to the bottom of a page, or until it times out.
440
438
  * Loads dynamic content when it hits the bottom of a page, and then continues scrolling.
@@ -454,8 +452,7 @@ export interface PlaywrightContextUtils {
454
452
  * in `href` elements, but rather navigations are triggered in click handlers.
455
453
  * If you're looking to find URLs in `href` attributes of the page, see {@link enqueueLinks}.
456
454
  *
457
- * Optionally, the function allows you to filter the target links' URLs using an array of {@link PseudoUrl} objects
458
- * and override settings of the enqueued {@link Request} objects.
455
+ * Optionally, the function allows you to filter the target links' URLs using an array of glob or regexp patterns.
459
456
  *
460
457
  * **IMPORTANT**: To be able to do this, this function uses various mutations on the page,
461
458
  * such as changing the Z-index of elements being clicked and their visibility. Therefore,
@@ -476,9 +473,9 @@ export interface PlaywrightContextUtils {
476
473
  * async requestHandler({ enqueueLinksByClickingElements }) {
477
474
  * await enqueueLinksByClickingElements({
478
475
  * selector: 'a.product-detail',
479
- * globs: [
480
- * 'https://www.example.com/handbags/**'
481
- * 'https://www.example.com/purses/**'
476
+ * include: [
477
+ * 'https://www.example.com/handbags/**',
478
+ * 'https://www.example.com/purses/**',
482
479
  * ],
483
480
  * });
484
481
  * });
@@ -514,19 +511,6 @@ export interface PlaywrightContextUtils {
514
511
  * secured copies beforehand.
515
512
  */
516
513
  compileScript(scriptString: string, ctx?: Dictionary): CompiledScriptFunction;
517
- /**
518
- * Tries to close cookie consent modals on the page. Based on the I Don't Care About Cookies browser extension.
519
- *
520
- * Note that this method requires the idcac-playwright package to be installed.
521
- * Crawlee does not include it by default due to licensing issues.
522
- *
523
- * To use this method, please install the package manually by running:
524
- *
525
- * ```bash
526
- * npm install idcac-playwright
527
- * ```
528
- */
529
- closeCookieModals(): Promise<void>;
530
514
  /**
531
515
  * This helper tries to solve the Cloudflare challenge automatically by clicking on the checkbox.
532
516
  * It will try to detect the Cloudflare page, click on the checkbox, and wait for 10 seconds (configurable
@@ -573,18 +557,3 @@ export interface PlaywrightContextUtils {
573
557
  listDownloads(): Promise<Download[]>;
574
558
  }
575
559
  export { enqueueLinksByClickingElements };
576
- /** @internal */
577
- export declare const playwrightUtils: {
578
- injectFile: typeof injectFile;
579
- injectJQuery: typeof injectJQuery;
580
- gotoExtended: typeof gotoExtended;
581
- blockRequests: typeof blockRequests;
582
- enqueueLinksByClickingElements: typeof enqueueLinksByClickingElements;
583
- parseWithCheerio: typeof parseWithCheerio;
584
- infiniteScroll: typeof infiniteScroll;
585
- saveSnapshot: typeof saveSnapshot;
586
- compileScript: typeof compileScript;
587
- closeCookieModals: typeof closeCookieModals;
588
- RenderingTypePredictor: typeof RenderingTypePredictor;
589
- handleCloudflareChallenge: typeof handleCloudflareChallenge;
590
- };
@@ -21,17 +21,46 @@ import { readFile } from 'node:fs/promises';
21
21
  import { createRequire } from 'node:module';
22
22
  import vm from 'node:vm';
23
23
  import { Configuration, KeyValueStore, serviceLocator, SessionError, validators } from '@crawlee/browser';
24
- import { expandShadowRoots, sleep } from '@crawlee/utils';
25
- import * as cheerio from 'cheerio';
26
- import ow from 'ow';
24
+ import { sleep } from '@crawlee/utils';
25
+ import { expandShadowRoots, parseArgument, schemas } from '@crawlee/utils/internal';
26
+ import { z } from 'zod';
27
27
  import { LruCache } from '@apify/datastructures';
28
28
  import { enqueueLinksByClickingElements } from '../enqueue-links/click-elements.js';
29
- import { RenderingTypePredictor } from './rendering-type-prediction.js';
30
29
  const getLog = () => serviceLocator.getChildLog('Playwright Utils');
31
30
  const require = createRequire(import.meta.url);
32
31
  const jqueryPath = require.resolve('jquery');
33
32
  const MAX_INJECT_FILE_CACHE_SIZE = 10;
34
33
  const DEFAULT_BLOCK_REQUEST_URL_PATTERNS = ['.css', '.jpg', '.jpeg', '.png', '.svg', '.gif', '.woff', '.pdf', '.zip'];
34
+ const filePathSchema = z.string();
35
+ const injectFileOptionsSchema = z.strictObject({
36
+ surviveNavigations: z.boolean().optional(),
37
+ });
38
+ const gotoExtendedRequestSchema = z.looseObject({
39
+ url: z.url(),
40
+ method: z.string().optional(),
41
+ headers: schemas.anyObject.optional(),
42
+ payload: z.union([z.string(), z.instanceof(Uint8Array)]).optional(),
43
+ });
44
+ const blockRequestsOptionsSchema = z.strictObject({
45
+ urlPatterns: schemas.arrayOf(z.string(), 'strings').default(DEFAULT_BLOCK_REQUEST_URL_PATTERNS),
46
+ extraUrlPatterns: schemas.arrayOf(z.string(), 'strings').default(() => []),
47
+ });
48
+ const infiniteScrollOptionsSchema = z.strictObject({
49
+ timeoutSecs: schemas.anyNumber.default(0),
50
+ maxScrollHeight: schemas.anyNumber.default(0),
51
+ waitForSecs: schemas.anyNumber.default(4),
52
+ scrollDownAndUp: z.boolean().default(false),
53
+ buttonSelector: z.string().optional(),
54
+ stopScrollCallback: schemas.anyFunction.optional(),
55
+ });
56
+ const saveSnapshotOptionsSchema = z.strictObject({
57
+ key: z.string().min(1).default('SNAPSHOT'),
58
+ screenshotQuality: schemas.anyNumber.default(50),
59
+ saveScreenshot: z.boolean().default(true),
60
+ saveHtml: z.boolean().default(true),
61
+ keyValueStoreName: z.string().optional(),
62
+ configuration: schemas.anyObject.optional(),
63
+ });
35
64
  /**
36
65
  * Cache contents of previously injected files to limit file system access.
37
66
  */
@@ -48,18 +77,16 @@ const injectedFilesCache = new LruCache({ maxLength: MAX_INJECT_FILE_CACHE_SIZE
48
77
  * @param [options]
49
78
  */
50
79
  export async function injectFile(page, filePath, options = {}) {
51
- ow(page, ow.object.validate(validators.browserPage));
52
- ow(filePath, ow.string);
53
- ow(options, ow.object.exactShape({
54
- surviveNavigations: ow.optional.boolean,
55
- }));
80
+ parseArgument(page, validators.browserPage);
81
+ parseArgument(filePath, filePathSchema);
82
+ const { surviveNavigations } = parseArgument(options, injectFileOptionsSchema);
56
83
  let contents = injectedFilesCache.get(filePath);
57
84
  if (!contents) {
58
85
  contents = await readFile(filePath, 'utf8');
59
86
  injectedFilesCache.add(filePath, contents);
60
87
  }
61
88
  const evalP = page.evaluate(contents);
62
- if (options.surviveNavigations) {
89
+ if (surviveNavigations) {
63
90
  page.on('framenavigated', async () => page
64
91
  .evaluate(contents)
65
92
  .catch((error) => getLog().warning('An error occurred during the script injection!', { error })));
@@ -93,7 +120,7 @@ export async function injectFile(page, filePath, options = {}) {
93
120
  * @param [options.surviveNavigations] Opt-out option to disable the JQuery reinjection after navigation.
94
121
  */
95
122
  export async function injectJQuery(page, options) {
96
- ow(page, ow.object.validate(validators.browserPage));
123
+ parseArgument(page, validators.browserPage);
97
124
  return injectFile(page, jqueryPath, { surviveNavigations: options?.surviveNavigations ?? true });
98
125
  }
99
126
  /**
@@ -109,14 +136,9 @@ export async function injectJQuery(page, options) {
109
136
  * @param [gotoOptions] Custom options for `page.goto()`.
110
137
  */
111
138
  export async function gotoExtended(page, request, gotoOptions = {}) {
112
- ow(page, ow.object.validate(validators.browserPage));
113
- ow(request, ow.object.partialShape({
114
- url: ow.string.url,
115
- method: ow.optional.string,
116
- headers: ow.optional.object,
117
- payload: ow.optional.any(ow.string, ow.uint8Array),
118
- }));
119
- ow(gotoOptions, ow.object);
139
+ parseArgument(page, validators.browserPage);
140
+ parseArgument(request, gotoExtendedRequestSchema);
141
+ parseArgument(gotoOptions, schemas.anyObject);
120
142
  const { url, method, headers, payload } = request;
121
143
  const isEmpty = (o) => !o || Object.keys(o).length === 0;
122
144
  if (method !== 'GET' || payload) {
@@ -201,12 +223,8 @@ export async function gotoExtended(page, request, gotoOptions = {}) {
201
223
  * @param [options]
202
224
  */
203
225
  export async function blockRequests(page, options = {}) {
204
- ow(page, ow.object.validate(validators.browserPage));
205
- ow(options, ow.object.exactShape({
206
- urlPatterns: ow.optional.array.ofType(ow.string),
207
- extraUrlPatterns: ow.optional.array.ofType(ow.string),
208
- }));
209
- const { urlPatterns = DEFAULT_BLOCK_REQUEST_URL_PATTERNS, extraUrlPatterns = [] } = options;
226
+ parseArgument(page, validators.browserPage);
227
+ const { urlPatterns, extraUrlPatterns } = parseArgument(options, blockRequestsOptionsSchema);
210
228
  const patternsToBlock = [...urlPatterns, ...extraUrlPatterns];
211
229
  try {
212
230
  const client = await page.context().newCDPSession(page);
@@ -264,16 +282,8 @@ export function compileScript(scriptString, context = Object.create(null)) {
264
282
  * @param [options]
265
283
  */
266
284
  export async function infiniteScroll(page, options = {}) {
267
- ow(page, ow.object.validate(validators.browserPage));
268
- ow(options, ow.object.exactShape({
269
- timeoutSecs: ow.optional.number,
270
- maxScrollHeight: ow.optional.number,
271
- waitForSecs: ow.optional.number,
272
- scrollDownAndUp: ow.optional.boolean,
273
- buttonSelector: ow.optional.string,
274
- stopScrollCallback: ow.optional.function,
275
- }));
276
- const { timeoutSecs = 0, maxScrollHeight = 0, waitForSecs = 4, scrollDownAndUp = false, buttonSelector, stopScrollCallback, } = options;
285
+ parseArgument(page, validators.browserPage);
286
+ const { timeoutSecs, maxScrollHeight, waitForSecs, scrollDownAndUp, buttonSelector, stopScrollCallback } = parseArgument(options, infiniteScrollOptionsSchema);
277
287
  let finished;
278
288
  const startTime = Date.now();
279
289
  const CHECK_INTERVAL_MILLIS = 1000;
@@ -350,16 +360,8 @@ export async function infiniteScroll(page, options = {}) {
350
360
  * @param [options]
351
361
  */
352
362
  export async function saveSnapshot(page, options = {}) {
353
- ow(page, ow.object.validate(validators.browserPage));
354
- ow(options, ow.object.exactShape({
355
- key: ow.optional.string.nonEmpty,
356
- screenshotQuality: ow.optional.number,
357
- saveScreenshot: ow.optional.boolean,
358
- saveHtml: ow.optional.boolean,
359
- keyValueStoreName: ow.optional.string,
360
- configuration: ow.optional.object,
361
- }));
362
- const { key = 'SNAPSHOT', screenshotQuality = 50, saveScreenshot = true, saveHtml = true, keyValueStoreName, configuration, } = options;
363
+ parseArgument(page, validators.browserPage);
364
+ const { key, screenshotQuality, saveScreenshot, saveHtml, keyValueStoreName, configuration } = parseArgument(options, saveSnapshotOptionsSchema);
363
365
  try {
364
366
  const store = await KeyValueStore.open(keyValueStoreName ? { name: keyValueStoreName } : null, {
365
367
  configuration: configuration ?? Configuration.getGlobalConfiguration(),
@@ -397,12 +399,14 @@ export async function saveSnapshot(page, options = {}) {
397
399
  * @param ignoreShadowRoots
398
400
  */
399
401
  export async function parseWithCheerio(page, ignoreShadowRoots = false, ignoreIframes = false) {
400
- ow(page, ow.object.validate(validators.browserPage));
402
+ parseArgument(page, validators.browserPage);
401
403
  const html = ignoreShadowRoots
402
404
  ? null
403
405
  : (await page.evaluate(`(${expandShadowRoots.toString()})(document)`));
404
406
  const pageContent = html || (await page.content());
405
- const $ = cheerio.load(pageContent);
407
+ // Full cheerio (parse5) import for browser compliant parser
408
+ const { load } = await import('cheerio');
409
+ const $ = load(pageContent);
406
410
  if (page.frames().length > 1 && !ignoreIframes) {
407
411
  const frames = await page.$$('iframe');
408
412
  const cheerioIframes = $('iframe').toArray();
@@ -434,35 +438,6 @@ export async function parseWithCheerio(page, ignoreShadowRoots = false, ignoreIf
434
438
  }
435
439
  return $;
436
440
  }
437
- let idcacPlaywright = null;
438
- async function getIdcacPlaywright() {
439
- if (idcacPlaywright)
440
- return idcacPlaywright;
441
- try {
442
- idcacPlaywright = await import('idcac-playwright');
443
- }
444
- catch (error) {
445
- getLog().warning(`Failed to import 'idcac-playwright'.
446
-
447
- We recently made idcac-playwright an optional dependency due to licensing issues.
448
- To use this feature, please install it manually by running
449
-
450
- npm install idcac-playwright
451
-
452
- Original error message follows:
453
-
454
- ${error.message}
455
- `);
456
- }
457
- return idcacPlaywright;
458
- }
459
- export async function closeCookieModals(page) {
460
- ow(page, ow.object.validate(validators.browserPage));
461
- const idcac = await getIdcacPlaywright();
462
- if (idcac?.getInjectableScript()) {
463
- await page.evaluate(idcac.getInjectableScript());
464
- }
465
- }
466
441
  /**
467
442
  * This helper tries to solve the Cloudflare challenge automatically by clicking on the checkbox.
468
443
  * It will try to detect the Cloudflare page, click on the checkbox, and wait for 10 seconds (configurable
@@ -487,7 +462,7 @@ export async function closeCookieModals(page) {
487
462
  * @param url current URL for request identification, only used for logging
488
463
  * @param [options]
489
464
  */
490
- async function handleCloudflareChallenge(page, url, options = {}) {
465
+ export async function handleCloudflareChallenge(page, url, options = {}) {
491
466
  options.isBlockedCallback ??= async () => {
492
467
  const isBlocked = await page.evaluate(() => {
493
468
  return document.querySelector('h1')?.textContent?.trim().includes('Sorry, you have been blocked');
@@ -496,9 +471,9 @@ async function handleCloudflareChallenge(page, url, options = {}) {
496
471
  };
497
472
  options.isChallengeCallback ??= async () => {
498
473
  return await page.evaluate(async () => {
499
- // Cloudflare nests the ray ID under varying wrapper elements, so we match by descendants
500
- // instead of a direct-child chain (e.g. a `.footer-wrapper` was inserted in between).
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 () => {
@@ -509,7 +484,11 @@ async function handleCloudflareChallenge(page, url, options = {}) {
509
484
  };
510
485
  // check if we ended up on the CF challenge page
511
486
  const isChallenge = async () => {
512
- return options.isChallengeCallback(page).catch(() => false);
487
+ return options.isChallengeCallback(page).catch(async () => {
488
+ // the challenge page navigates itself, which destroys the context of an in-flight check
489
+ await page.waitForLoadState('domcontentloaded').catch(() => { });
490
+ return options.isChallengeCallback(page).catch(() => false);
491
+ });
513
492
  };
514
493
  if (!(await isChallenge())) {
515
494
  await retryBlocked();
@@ -573,18 +552,3 @@ async function handleCloudflareChallenge(page, url, options = {}) {
573
552
  return (await page.reload()) ?? undefined;
574
553
  }
575
554
  export { enqueueLinksByClickingElements };
576
- /** @internal */
577
- export const playwrightUtils = {
578
- injectFile,
579
- injectJQuery,
580
- gotoExtended,
581
- blockRequests,
582
- enqueueLinksByClickingElements,
583
- parseWithCheerio,
584
- infiniteScroll,
585
- saveSnapshot,
586
- compileScript,
587
- closeCookieModals,
588
- RenderingTypePredictor,
589
- handleCloudflareChallenge,
590
- };
@@ -1,4 +1,5 @@
1
1
  import type { RecoverableStatePersistenceOptions, Request } from '@crawlee/core';
2
+ import type { Awaitable } from '@crawlee/types';
2
3
  export type RenderingType = 'clientOnly' | 'static';
3
4
  export interface RenderingTypePredictorOptions {
4
5
  /** A number between 0 and 1 that determines the desired ratio of rendering type detections */
@@ -12,13 +13,24 @@ export interface RenderingTypePredictorOptions {
12
13
  * @experimental
13
14
  */
14
15
  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): {
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<{
17
23
  renderingType: RenderingType;
18
24
  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;
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>;
22
34
  }
23
35
  /**
24
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.
@@ -26,13 +38,17 @@ export interface IRenderingTypePredictor {
26
38
  * @experimental
27
39
  */
28
40
  export declare class RenderingTypePredictor implements IRenderingTypePredictor {
29
- private detectionRatio;
30
- private state;
41
+ #private;
31
42
  constructor({ detectionRatio, persistenceOptions }: RenderingTypePredictorOptions);
32
43
  /**
33
44
  * Initialize the predictor by restoring persisted state.
34
45
  */
35
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>;
36
52
  /**
37
53
  * Predict the rendering type for a given URL and request label.
38
54
  */
@@ -44,7 +60,4 @@ export declare class RenderingTypePredictor implements IRenderingTypePredictor {
44
60
  * Store the rendering type for a given URL and request label. This updates the underlying prediction model, which may be costly.
45
61
  */
46
62
  storeResult(requests: Request | Request[], renderingType: RenderingType): void;
47
- private resultCount;
48
- private calculateFeatureVector;
49
- private retrain;
50
63
  }
@@ -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
  };
@@ -24,41 +25,56 @@ const calculateUrlSimilarity = (a, b) => {
24
25
  };
25
26
  const sum = (values) => values.reduce((acc, value) => acc + value);
26
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
+ });
27
63
  /**
28
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.
29
65
  *
30
66
  * @experimental
31
67
  */
32
68
  export class RenderingTypePredictor {
33
- detectionRatio;
34
- state;
69
+ #detectionRatio;
70
+ #state;
35
71
  constructor({ detectionRatio, persistenceOptions }) {
36
- this.detectionRatio = detectionRatio;
37
- this.state = new RecoverableState({
38
- defaultState: {
39
- logreg: new LogisticRegression({ numSteps: 1000, learningRate: 0.05 }),
40
- detectionResults: new Map(),
41
- },
42
- serialize: (state) => JSON.stringify({
43
- logreg: state.logreg.toJSON(),
44
- detectionResults: Array.from(state.detectionResults.entries()).map(([renderingType, urlPartsByLabel]) => ({
45
- renderingType,
46
- urlPartsByLabel: Array.from(urlPartsByLabel.entries()).map(([label, urlParts]) => ({
47
- label,
48
- urlParts,
49
- })),
50
- })),
51
- }),
52
- deserialize: (serializedState) => {
53
- const { logreg, detectionResults = [] } = JSON.parse(serializedState);
54
- return {
55
- logreg: LogisticRegression.load(logreg),
56
- detectionResults: new Map(detectionResults.map((serializedItem) => [
57
- serializedItem.renderingType,
58
- new Map(serializedItem.urlPartsByLabel.map((item) => [item.label, item.urlParts])),
59
- ])),
60
- };
61
- },
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),
62
78
  persistStateKey: 'rendering-type-predictor-state',
63
79
  persistenceEnabled: true,
64
80
  ...persistenceOptions,
@@ -68,32 +84,41 @@ export class RenderingTypePredictor {
68
84
  * Initialize the predictor by restoring persisted state.
69
85
  */
70
86
  async initialize() {
71
- 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();
72
97
  }
73
98
  /**
74
99
  * Predict the rendering type for a given URL and request label.
75
100
  */
76
101
  predict({ url, loadedUrl, label }) {
77
- const { logreg } = this.state.currentValue;
102
+ const { logreg } = this.#state.currentValue;
78
103
  if (logreg.classifiers.length === 0) {
79
104
  return { renderingType: 'clientOnly', detectionProbabilityRecommendation: 1 };
80
105
  }
81
106
  const predictionUrl = new URL(loadedUrl ?? url);
82
- const urlFeature = new Matrix([this.calculateFeatureVector(urlComponents(predictionUrl), label)]);
107
+ const urlFeature = new Matrix([this.#calculateFeatureVector(urlComponents(predictionUrl), label)]);
83
108
  const [prediction] = logreg.predict(urlFeature);
84
109
  const scores = [logreg.classifiers[0].testScores(urlFeature), logreg.classifiers[1].testScores(urlFeature)];
85
110
  return {
86
111
  renderingType: prediction === 1 ? 'static' : 'clientOnly',
87
112
  detectionProbabilityRecommendation: Math.abs(scores[0] - scores[1]) < 0.1
88
113
  ? 1
89
- : this.detectionRatio * Math.max(1, 5 - this.resultCount(label)),
114
+ : this.#detectionRatio * Math.max(1, 5 - this.#resultCount(label)),
90
115
  };
91
116
  }
92
117
  /**
93
118
  * Store the rendering type for a given URL and request label. This updates the underlying prediction model, which may be costly.
94
119
  */
95
120
  storeResult(requests, renderingType) {
96
- const state = this.state.currentValue;
121
+ const state = this.#state.currentValue;
97
122
  for (const { url, loadedUrl, label } of Array.isArray(requests) ? requests : [requests]) {
98
123
  const resultUrl = new URL(loadedUrl ?? url);
99
124
  if (!state.detectionResults.has(renderingType)) {
@@ -104,33 +129,33 @@ export class RenderingTypePredictor {
104
129
  }
105
130
  state.detectionResults.get(renderingType).get(label).push(urlComponents(resultUrl));
106
131
  }
107
- this.retrain();
132
+ this.#retrain();
108
133
  }
109
- resultCount(label) {
110
- return Array.from(this.state.currentValue.detectionResults.values())
134
+ #resultCount(label) {
135
+ return Array.from(this.#state.currentValue.detectionResults.values())
111
136
  .map((results) => results.get(label)?.length ?? 0)
112
137
  .reduce((acc, value) => acc + value, 0);
113
138
  }
114
- calculateFeatureVector(url, label) {
139
+ #calculateFeatureVector(url, label) {
115
140
  return [
116
- mean((this.state.currentValue.detectionResults.get('static')?.get(label) ?? []).map((otherUrl) => calculateUrlSimilarity(url, otherUrl) ?? 0)) ?? 0,
117
- mean((this.state.currentValue.detectionResults.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,
118
143
  ];
119
144
  }
120
- retrain() {
145
+ #retrain() {
121
146
  const X = [
122
147
  [0, 1],
123
148
  [1, 0],
124
149
  ];
125
150
  const Y = [0, 1];
126
- for (const [renderingType, urlsByLabel] of this.state.currentValue.detectionResults.entries()) {
151
+ for (const [renderingType, urlsByLabel] of this.#state.currentValue.detectionResults.entries()) {
127
152
  for (const [label, urls] of urlsByLabel) {
128
153
  for (const url of urls) {
129
- X.push(this.calculateFeatureVector(url, label));
154
+ X.push(this.#calculateFeatureVector(url, label));
130
155
  Y.push(renderingType === 'static' ? 1 : 0);
131
156
  }
132
157
  }
133
158
  }
134
- this.state.currentValue.logreg.train(new Matrix(X), Matrix.columnVector(Y));
159
+ this.#state.currentValue.logreg.train(new Matrix(X), Matrix.columnVector(Y));
135
160
  }
136
161
  }