@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.
- package/README.md +1 -1
- package/index.d.ts +3 -1
- package/index.js +2 -1
- package/internals/adaptive-playwright-crawler.d.ts +62 -72
- package/internals/adaptive-playwright-crawler.js +306 -232
- package/internals/enqueue-links/click-elements.d.ts +18 -54
- package/internals/enqueue-links/click-elements.js +33 -55
- package/internals/playwright-browser-pool.d.ts +71 -0
- package/internals/playwright-browser-pool.js +61 -0
- package/internals/playwright-crawler.d.ts +136 -153
- package/internals/playwright-crawler.js +38 -40
- package/internals/playwright-launcher.d.ts +26 -17
- package/internals/playwright-launcher.js +13 -7
- package/internals/utils/playwright-utils.d.ts +8 -39
- package/internals/utils/playwright-utils.js +60 -96
- package/internals/utils/rendering-type-prediction.d.ts +23 -10
- package/internals/utils/rendering-type-prediction.js +68 -43
- package/package.json +13 -19
|
@@ -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 {
|
|
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<
|
|
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<
|
|
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
|
|
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
|
-
*
|
|
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 {
|
|
25
|
-
import
|
|
26
|
-
import
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
205
|
-
|
|
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
|
-
|
|
268
|
-
|
|
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
|
-
|
|
354
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
500
|
-
//
|
|
501
|
-
return !!document.querySelector('.footer .footer-inner .
|
|
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(() =>
|
|
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
|
-
/**
|
|
16
|
-
|
|
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
|
-
/**
|
|
21
|
-
|
|
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
|
|
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
|
|
37
|
-
this
|
|
38
|
-
defaultState: {
|
|
39
|
-
|
|
40
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
132
|
+
this.#retrain();
|
|
108
133
|
}
|
|
109
|
-
resultCount(label) {
|
|
110
|
-
return Array.from(this
|
|
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
|
|
117
|
-
mean((this
|
|
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
|
|
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
|
|
154
|
+
X.push(this.#calculateFeatureVector(url, label));
|
|
130
155
|
Y.push(renderingType === 'static' ? 1 : 0);
|
|
131
156
|
}
|
|
132
157
|
}
|
|
133
158
|
}
|
|
134
|
-
this
|
|
159
|
+
this.#state.currentValue.logreg.train(new Matrix(X), Matrix.columnVector(Y));
|
|
135
160
|
}
|
|
136
161
|
}
|