@crawlee/playwright 4.0.0-beta.9 → 4.0.0-beta.91
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 +17 -13
- package/index.d.ts +1 -2
- package/index.js +0 -1
- package/internals/adaptive-playwright-crawler.d.ts +117 -63
- package/internals/adaptive-playwright-crawler.js +354 -214
- package/internals/enqueue-links/click-elements.d.ts +32 -14
- package/internals/enqueue-links/click-elements.js +57 -25
- package/internals/playwright-crawler.d.ts +104 -83
- package/internals/playwright-crawler.js +85 -41
- package/internals/playwright-launcher.d.ts +6 -5
- package/internals/playwright-launcher.js +10 -11
- package/internals/utils/playwright-utils.d.ts +58 -21
- package/internals/utils/playwright-utils.js +99 -86
- package/internals/utils/rendering-type-prediction.d.ts +27 -12
- package/internals/utils/rendering-type-prediction.js +67 -26
- package/package.json +16 -11
- package/index.d.ts.map +0 -1
- package/index.js.map +0 -1
- package/internals/adaptive-playwright-crawler.d.ts.map +0 -1
- package/internals/adaptive-playwright-crawler.js.map +0 -1
- package/internals/enqueue-links/click-elements.d.ts.map +0 -1
- package/internals/enqueue-links/click-elements.js.map +0 -1
- package/internals/playwright-crawler.d.ts.map +0 -1
- package/internals/playwright-crawler.js.map +0 -1
- package/internals/playwright-launcher.d.ts.map +0 -1
- package/internals/playwright-launcher.js.map +0 -1
- package/internals/utils/playwright-utils.d.ts.map +0 -1
- package/internals/utils/playwright-utils.js.map +0 -1
- package/internals/utils/rendering-type-prediction.d.ts.map +0 -1
- package/internals/utils/rendering-type-prediction.js.map +0 -1
- package/tsconfig.build.tsbuildinfo +0 -1
|
@@ -20,16 +20,14 @@
|
|
|
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,
|
|
23
|
+
import { Configuration, KeyValueStore, serviceLocator, SessionError, validators } from '@crawlee/browser';
|
|
24
24
|
import { expandShadowRoots, sleep } from '@crawlee/utils';
|
|
25
25
|
import * as cheerio from 'cheerio';
|
|
26
|
-
import { getInjectableScript as getCookieClosingScript } from 'idcac-playwright';
|
|
27
26
|
import ow from 'ow';
|
|
28
27
|
import { LruCache } from '@apify/datastructures';
|
|
29
|
-
import log_ from '@apify/log';
|
|
30
28
|
import { enqueueLinksByClickingElements } from '../enqueue-links/click-elements.js';
|
|
31
29
|
import { RenderingTypePredictor } from './rendering-type-prediction.js';
|
|
32
|
-
const
|
|
30
|
+
const getLog = () => serviceLocator.getChildLog('Playwright Utils');
|
|
33
31
|
const require = createRequire(import.meta.url);
|
|
34
32
|
const jqueryPath = require.resolve('jquery');
|
|
35
33
|
const MAX_INJECT_FILE_CACHE_SIZE = 10;
|
|
@@ -64,7 +62,7 @@ export async function injectFile(page, filePath, options = {}) {
|
|
|
64
62
|
if (options.surviveNavigations) {
|
|
65
63
|
page.on('framenavigated', async () => page
|
|
66
64
|
.evaluate(contents)
|
|
67
|
-
.catch((error) =>
|
|
65
|
+
.catch((error) => getLog().warning('An error occurred during the script injection!', { error })));
|
|
68
66
|
}
|
|
69
67
|
return evalP;
|
|
70
68
|
}
|
|
@@ -121,9 +119,9 @@ export async function gotoExtended(page, request, gotoOptions = {}) {
|
|
|
121
119
|
ow(gotoOptions, ow.object);
|
|
122
120
|
const { url, method, headers, payload } = request;
|
|
123
121
|
const isEmpty = (o) => !o || Object.keys(o).length === 0;
|
|
124
|
-
if (method !== 'GET' || payload
|
|
122
|
+
if (method !== 'GET' || payload) {
|
|
125
123
|
// This is not deprecated, we use it to log only once.
|
|
126
|
-
|
|
124
|
+
getLog().deprecated('Using other request methods than GET, rewriting headers and adding payloads has a high impact on performance ' +
|
|
127
125
|
'in recent versions of Playwright. Use only when necessary.');
|
|
128
126
|
let wasCalled = false;
|
|
129
127
|
const interceptRequestHandler = async (route) => {
|
|
@@ -144,12 +142,15 @@ export async function gotoExtended(page, request, gotoOptions = {}) {
|
|
|
144
142
|
await route.continue(overrides);
|
|
145
143
|
}
|
|
146
144
|
catch (error) {
|
|
147
|
-
|
|
145
|
+
getLog().debug('Error inside request interceptor', { error });
|
|
148
146
|
}
|
|
149
147
|
return undefined;
|
|
150
148
|
};
|
|
151
149
|
await page.route('**/*', interceptRequestHandler);
|
|
152
150
|
}
|
|
151
|
+
else if (!isEmpty(headers)) {
|
|
152
|
+
await page.setExtraHTTPHeaders(headers);
|
|
153
|
+
}
|
|
153
154
|
return page.goto(url, gotoOptions);
|
|
154
155
|
}
|
|
155
156
|
/**
|
|
@@ -213,7 +214,7 @@ export async function blockRequests(page, options = {}) {
|
|
|
213
214
|
await client.send('Network.setBlockedURLs', { urls: patternsToBlock });
|
|
214
215
|
}
|
|
215
216
|
catch {
|
|
216
|
-
|
|
217
|
+
getLog().warning('blockRequests() helper is incompatible with non-Chromium browsers.');
|
|
217
218
|
}
|
|
218
219
|
}
|
|
219
220
|
/**
|
|
@@ -249,7 +250,7 @@ export function compileScript(scriptString, context = Object.create(null)) {
|
|
|
249
250
|
func = vm.runInNewContext(funcString, context); // "Secure" the context by removing prototypes, unless custom context is provided.
|
|
250
251
|
}
|
|
251
252
|
catch (err) {
|
|
252
|
-
|
|
253
|
+
getLog().exception(err, 'Cannot compile script!');
|
|
253
254
|
throw err;
|
|
254
255
|
}
|
|
255
256
|
if (typeof func !== 'function')
|
|
@@ -356,12 +357,12 @@ export async function saveSnapshot(page, options = {}) {
|
|
|
356
357
|
saveScreenshot: ow.optional.boolean,
|
|
357
358
|
saveHtml: ow.optional.boolean,
|
|
358
359
|
keyValueStoreName: ow.optional.string,
|
|
359
|
-
|
|
360
|
+
configuration: ow.optional.object,
|
|
360
361
|
}));
|
|
361
|
-
const { key = 'SNAPSHOT', screenshotQuality = 50, saveScreenshot = true, saveHtml = true, keyValueStoreName,
|
|
362
|
+
const { key = 'SNAPSHOT', screenshotQuality = 50, saveScreenshot = true, saveHtml = true, keyValueStoreName, configuration, } = options;
|
|
362
363
|
try {
|
|
363
|
-
const store = await KeyValueStore.open(keyValueStoreName, {
|
|
364
|
-
|
|
364
|
+
const store = await KeyValueStore.open(keyValueStoreName ? { name: keyValueStoreName } : null, {
|
|
365
|
+
configuration: configuration ?? Configuration.getGlobalConfiguration(),
|
|
365
366
|
});
|
|
366
367
|
if (saveScreenshot) {
|
|
367
368
|
const screenshotName = `${key}.jpg`;
|
|
@@ -397,35 +398,70 @@ export async function saveSnapshot(page, options = {}) {
|
|
|
397
398
|
*/
|
|
398
399
|
export async function parseWithCheerio(page, ignoreShadowRoots = false, ignoreIframes = false) {
|
|
399
400
|
ow(page, ow.object.validate(validators.browserPage));
|
|
401
|
+
const html = ignoreShadowRoots
|
|
402
|
+
? null
|
|
403
|
+
: (await page.evaluate(`(${expandShadowRoots.toString()})(document)`));
|
|
404
|
+
const pageContent = html || (await page.content());
|
|
405
|
+
const $ = cheerio.load(pageContent);
|
|
400
406
|
if (page.frames().length > 1 && !ignoreIframes) {
|
|
401
407
|
const frames = await page.$$('iframe');
|
|
402
|
-
|
|
408
|
+
const cheerioIframes = $('iframe').toArray();
|
|
409
|
+
if (frames.length !== cheerioIframes.length) {
|
|
410
|
+
serviceLocator
|
|
411
|
+
.getLogger()
|
|
412
|
+
.warning(`parseWithCheerio: iframe count mismatch between live DOM (${frames.length}) and page snapshot (${cheerioIframes.length}). Some iframes may not be expanded.`);
|
|
413
|
+
}
|
|
414
|
+
await Promise.all(frames.map(async (frame, index) => {
|
|
403
415
|
try {
|
|
404
416
|
const iframe = await frame.contentFrame();
|
|
405
|
-
if (iframe) {
|
|
406
|
-
const
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
417
|
+
if (iframe && cheerioIframes[index]) {
|
|
418
|
+
const getIframeHTML = async () => {
|
|
419
|
+
try {
|
|
420
|
+
return iframe.locator('body').first().innerHTML();
|
|
421
|
+
}
|
|
422
|
+
catch {
|
|
423
|
+
return iframe.content();
|
|
424
|
+
}
|
|
425
|
+
};
|
|
426
|
+
const contents = await getIframeHTML();
|
|
427
|
+
$(cheerioIframes[index]).replaceWith(`<div class="crawlee-iframe-replacement">${contents}</div>`);
|
|
413
428
|
}
|
|
414
429
|
}
|
|
415
430
|
catch (error) {
|
|
416
|
-
|
|
431
|
+
getLog().warning(`Failed to extract iframe content: ${error}`);
|
|
417
432
|
}
|
|
418
433
|
}));
|
|
419
434
|
}
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
435
|
+
return $;
|
|
436
|
+
}
|
|
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;
|
|
425
458
|
}
|
|
426
459
|
export async function closeCookieModals(page) {
|
|
427
460
|
ow(page, ow.object.validate(validators.browserPage));
|
|
428
|
-
await
|
|
461
|
+
const idcac = await getIdcacPlaywright();
|
|
462
|
+
if (idcac?.getInjectableScript()) {
|
|
463
|
+
await page.evaluate(idcac.getInjectableScript());
|
|
464
|
+
}
|
|
429
465
|
}
|
|
430
466
|
/**
|
|
431
467
|
* This helper tries to solve the Cloudflare challenge automatically by clicking on the checkbox.
|
|
@@ -434,30 +470,24 @@ export async function closeCookieModals(page) {
|
|
|
434
470
|
* result in a SessionError which will be automatically retried, so only successful requests will get
|
|
435
471
|
* into the `requestHandler`.
|
|
436
472
|
*
|
|
473
|
+
* On a successfully solved challenge the page is reloaded and the new {@link Response} is returned, so
|
|
474
|
+
* it can be propagated back to the crawling context via a hook return value (see
|
|
475
|
+
* {@link handleCloudflareChallengeHook}).
|
|
476
|
+
*
|
|
437
477
|
* Works best with camoufox.
|
|
438
478
|
*
|
|
439
479
|
* **Example usage**
|
|
440
480
|
* ```ts
|
|
441
481
|
* postNavigationHooks: [
|
|
442
|
-
* async ({ handleCloudflareChallenge })
|
|
443
|
-
* await handleCloudflareChallenge();
|
|
444
|
-
* },
|
|
482
|
+
* async (context) => ({ response: await context.handleCloudflareChallenge() }),
|
|
445
483
|
* ],
|
|
446
484
|
* ```
|
|
447
485
|
*
|
|
448
486
|
* @param page Playwright [`Page`](https://playwright.dev/docs/api/class-page) object
|
|
449
487
|
* @param url current URL for request identification, only used for logging
|
|
450
|
-
* @param [session] current session object
|
|
451
488
|
* @param [options]
|
|
452
489
|
*/
|
|
453
|
-
async function handleCloudflareChallenge(page, url,
|
|
454
|
-
// eslint-disable-next-line dot-notation
|
|
455
|
-
const blockedStatusCodes = session?.['sessionPool']['blockedStatusCodes'];
|
|
456
|
-
// Cloudflare pages are 403, which are blocked by default
|
|
457
|
-
if (blockedStatusCodes?.includes(403)) {
|
|
458
|
-
const idx = blockedStatusCodes.indexOf(403);
|
|
459
|
-
blockedStatusCodes.splice(idx, 1);
|
|
460
|
-
}
|
|
490
|
+
async function handleCloudflareChallenge(page, url, options = {}) {
|
|
461
491
|
options.isBlockedCallback ??= async () => {
|
|
462
492
|
const isBlocked = await page.evaluate(() => {
|
|
463
493
|
return document.querySelector('h1')?.textContent?.trim().includes('Sorry, you have been blocked');
|
|
@@ -466,7 +496,9 @@ async function handleCloudflareChallenge(page, url, session, options = {}) {
|
|
|
466
496
|
};
|
|
467
497
|
options.isChallengeCallback ??= async () => {
|
|
468
498
|
return await page.evaluate(async () => {
|
|
469
|
-
|
|
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');
|
|
470
502
|
});
|
|
471
503
|
};
|
|
472
504
|
const retryBlocked = async () => {
|
|
@@ -481,31 +513,41 @@ async function handleCloudflareChallenge(page, url, session, options = {}) {
|
|
|
481
513
|
};
|
|
482
514
|
if (!(await isChallenge())) {
|
|
483
515
|
await retryBlocked();
|
|
484
|
-
return;
|
|
516
|
+
return undefined;
|
|
485
517
|
}
|
|
486
518
|
const logLevel = options.verbose ? 'info' : 'debug';
|
|
487
|
-
|
|
519
|
+
getLog()[logLevel](`Detected Cloudflare challenge at ${url}, trying to solve it. This can take up to ${10 + (options.sleepSecs ?? 10)} seconds.`);
|
|
488
520
|
const bb = await page
|
|
489
521
|
.evaluate(() => {
|
|
490
|
-
|
|
522
|
+
// Prefer the actual challenge widget (the box holding the Turnstile checkbox input);
|
|
523
|
+
// fall back to the first content div for older challenge layouts.
|
|
524
|
+
const div = document.querySelector('.main-content div:has(input[id^="cf-chl-widget-"])') ??
|
|
525
|
+
document.querySelector('.main-content div');
|
|
491
526
|
return div?.getBoundingClientRect();
|
|
492
527
|
})
|
|
493
528
|
.catch(() => undefined);
|
|
494
529
|
if (!bb) {
|
|
495
|
-
return;
|
|
530
|
+
return undefined;
|
|
496
531
|
}
|
|
497
532
|
const randomOffset = (range) => {
|
|
498
533
|
return Math.round(100 * range * Math.random()) / 100;
|
|
499
534
|
};
|
|
500
|
-
|
|
501
|
-
|
|
535
|
+
let x = bb.x + 30;
|
|
536
|
+
let y = bb.y + 25;
|
|
502
537
|
// try to click the checkbox every second
|
|
503
538
|
for (let i = 0; i < 10; i++) {
|
|
504
|
-
await sleep(1000);
|
|
539
|
+
await sleep((options.preChallengeSleepSecs ?? 1) * 1000);
|
|
505
540
|
// break early if we are no longer on the CF challenge page
|
|
506
541
|
if (!(await isChallenge())) {
|
|
507
542
|
break;
|
|
508
543
|
}
|
|
544
|
+
if (options.clickPositionCallback) {
|
|
545
|
+
const pos = await options.clickPositionCallback(page);
|
|
546
|
+
if (pos) {
|
|
547
|
+
x = pos.x;
|
|
548
|
+
y = pos.y;
|
|
549
|
+
}
|
|
550
|
+
}
|
|
509
551
|
if (options.clickCallback) {
|
|
510
552
|
await options.clickCallback(page, { x, y });
|
|
511
553
|
continue;
|
|
@@ -513,7 +555,10 @@ async function handleCloudflareChallenge(page, url, session, options = {}) {
|
|
|
513
555
|
// we can click on the text too, so X can be a bit larger
|
|
514
556
|
const xRandomized = x + randomOffset(10);
|
|
515
557
|
const yRandomized = y + randomOffset(10);
|
|
516
|
-
|
|
558
|
+
getLog()[logLevel](`Trying to click on the Cloudflare checkbox at ${url}`, {
|
|
559
|
+
x: xRandomized,
|
|
560
|
+
y: yRandomized,
|
|
561
|
+
});
|
|
517
562
|
await page.mouse.click(xRandomized, yRandomized);
|
|
518
563
|
// sometimes the checkbox is lower (could be caused by a lag when rendering the logo)
|
|
519
564
|
await page.mouse.click(xRandomized, yRandomized + 35);
|
|
@@ -523,40 +568,9 @@ async function handleCloudflareChallenge(page, url, session, options = {}) {
|
|
|
523
568
|
throw new SessionError(`Blocked by Cloudflare when processing ${url}`);
|
|
524
569
|
}
|
|
525
570
|
await retryBlocked();
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
context.injectJQuery = async () => {
|
|
530
|
-
if (context.request.state === RequestState.BEFORE_NAV) {
|
|
531
|
-
log.warning('Using injectJQuery() in preNavigationHooks leads to unstable results. Use it in a postNavigationHook or a requestHandler instead.');
|
|
532
|
-
await injectJQuery(context.page);
|
|
533
|
-
return;
|
|
534
|
-
}
|
|
535
|
-
await injectJQuery(context.page, { surviveNavigations: false });
|
|
536
|
-
};
|
|
537
|
-
context.blockRequests = async (options) => blockRequests(context.page, options);
|
|
538
|
-
context.waitForSelector = async (selector, timeoutMs = 5_000) => {
|
|
539
|
-
const locator = context.page.locator(selector).first();
|
|
540
|
-
await locator.waitFor({ timeout: timeoutMs, state: 'attached' });
|
|
541
|
-
};
|
|
542
|
-
context.parseWithCheerio = async (selector, timeoutMs = 5_000) => {
|
|
543
|
-
if (selector) {
|
|
544
|
-
await context.waitForSelector(selector, timeoutMs);
|
|
545
|
-
}
|
|
546
|
-
return parseWithCheerio(context.page, crawlerOptions.ignoreShadowRoots, crawlerOptions.ignoreIframes);
|
|
547
|
-
};
|
|
548
|
-
context.infiniteScroll = async (options) => infiniteScroll(context.page, options);
|
|
549
|
-
context.saveSnapshot = async (options) => saveSnapshot(context.page, { ...options, config: context.crawler.config });
|
|
550
|
-
context.enqueueLinksByClickingElements = async (options) => enqueueLinksByClickingElements({
|
|
551
|
-
...options,
|
|
552
|
-
page: context.page,
|
|
553
|
-
requestQueue: context.crawler.requestQueue,
|
|
554
|
-
});
|
|
555
|
-
context.compileScript = (scriptString, ctx) => compileScript(scriptString, ctx);
|
|
556
|
-
context.closeCookieModals = async () => closeCookieModals(context.page);
|
|
557
|
-
context.handleCloudflareChallenge = async (options) => {
|
|
558
|
-
return handleCloudflareChallenge(context.page, context.request.url, context.session, options);
|
|
559
|
-
};
|
|
571
|
+
// Reload to obtain a fresh Response without the challenge interstitial, which the caller can
|
|
572
|
+
// propagate back into the crawling context so downstream status-code checks see the new value.
|
|
573
|
+
return (await page.reload()) ?? undefined;
|
|
560
574
|
}
|
|
561
575
|
export { enqueueLinksByClickingElements };
|
|
562
576
|
/** @internal */
|
|
@@ -574,4 +588,3 @@ export const playwrightUtils = {
|
|
|
574
588
|
RenderingTypePredictor,
|
|
575
589
|
handleCloudflareChallenge,
|
|
576
590
|
};
|
|
577
|
-
//# sourceMappingURL=playwright-utils.js.map
|
|
@@ -1,21 +1,38 @@
|
|
|
1
|
-
import type { Request } from '@crawlee/core';
|
|
1
|
+
import type { RecoverableStatePersistenceOptions, Request } from '@crawlee/core';
|
|
2
2
|
export type RenderingType = 'clientOnly' | 'static';
|
|
3
|
-
type URLComponents = string[];
|
|
4
|
-
type FeatureVector = [staticResultsSimilarity: number, clientOnlyResultsSimilarity: number];
|
|
5
3
|
export interface RenderingTypePredictorOptions {
|
|
6
4
|
/** A number between 0 and 1 that determines the desired ratio of rendering type detections */
|
|
7
5
|
detectionRatio: number;
|
|
6
|
+
persistenceOptions?: Partial<RecoverableStatePersistenceOptions>;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Minimal contract that any object passed to {@link AdaptivePlaywrightCrawler} as its
|
|
10
|
+
* `renderingTypePredictor` option must satisfy.
|
|
11
|
+
*
|
|
12
|
+
* @experimental
|
|
13
|
+
*/
|
|
14
|
+
export interface IRenderingTypePredictor {
|
|
15
|
+
/** Predict the rendering type for a request, and how likely the crawler should be to verify it. */
|
|
16
|
+
predict(request: Request): {
|
|
17
|
+
renderingType: RenderingType;
|
|
18
|
+
detectionProbabilityRecommendation: number;
|
|
19
|
+
};
|
|
20
|
+
/** Report a detected rendering type, so that future predictions can take it into account. */
|
|
21
|
+
storeResult(requests: Request | Request[], renderingType: RenderingType): void;
|
|
8
22
|
}
|
|
9
23
|
/**
|
|
10
24
|
* Stores rendering type information for previously crawled URLs and predicts the rendering type for URLs that have yet to be crawled and recommends when rendering type detection should be performed.
|
|
11
25
|
*
|
|
12
26
|
* @experimental
|
|
13
27
|
*/
|
|
14
|
-
export declare class RenderingTypePredictor {
|
|
15
|
-
private renderingTypeDetectionResults;
|
|
28
|
+
export declare class RenderingTypePredictor implements IRenderingTypePredictor {
|
|
16
29
|
private detectionRatio;
|
|
17
|
-
private
|
|
18
|
-
constructor({ detectionRatio }: RenderingTypePredictorOptions);
|
|
30
|
+
private state;
|
|
31
|
+
constructor({ detectionRatio, persistenceOptions }: RenderingTypePredictorOptions);
|
|
32
|
+
/**
|
|
33
|
+
* Initialize the predictor by restoring persisted state.
|
|
34
|
+
*/
|
|
35
|
+
initialize(): Promise<void>;
|
|
19
36
|
/**
|
|
20
37
|
* Predict the rendering type for a given URL and request label.
|
|
21
38
|
*/
|
|
@@ -26,10 +43,8 @@ export declare class RenderingTypePredictor {
|
|
|
26
43
|
/**
|
|
27
44
|
* Store the rendering type for a given URL and request label. This updates the underlying prediction model, which may be costly.
|
|
28
45
|
*/
|
|
29
|
-
storeResult(
|
|
46
|
+
storeResult(requests: Request | Request[], renderingType: RenderingType): void;
|
|
30
47
|
private resultCount;
|
|
31
|
-
|
|
32
|
-
|
|
48
|
+
private calculateFeatureVector;
|
|
49
|
+
private retrain;
|
|
33
50
|
}
|
|
34
|
-
export {};
|
|
35
|
-
//# sourceMappingURL=rendering-type-prediction.d.ts.map
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { RecoverableState } from '@crawlee/core';
|
|
1
2
|
import LogisticRegression from 'ml-logistic-regression';
|
|
2
3
|
import { Matrix } from 'ml-matrix';
|
|
3
4
|
import stringComparison from 'string-comparison';
|
|
@@ -9,10 +10,17 @@ const calculateUrlSimilarity = (a, b) => {
|
|
|
9
10
|
if (a[0] !== b[0]) {
|
|
10
11
|
return 0;
|
|
11
12
|
}
|
|
12
|
-
|
|
13
|
+
const maxLength = Math.max(a.length, b.length);
|
|
14
|
+
// Only the hostname is present (no path components to compare) - the hosts already match.
|
|
15
|
+
if (maxLength <= 1) {
|
|
16
|
+
return 1;
|
|
17
|
+
}
|
|
18
|
+
for (let i = 1; i < maxLength; i++) {
|
|
13
19
|
values.push(stringComparison.jaroWinkler.similarity(a[i] ?? '', b[i] ?? '') > 0.8 ? 1 : 0);
|
|
14
20
|
}
|
|
15
|
-
|
|
21
|
+
// The first component (index 0, the hostname) is excluded from the comparison above,
|
|
22
|
+
// so it must also be excluded from the denominator of the weighted average.
|
|
23
|
+
return sum(values) / (maxLength - 1);
|
|
16
24
|
};
|
|
17
25
|
const sum = (values) => values.reduce((acc, value) => acc + value);
|
|
18
26
|
const mean = (values) => (values.length > 0 ? sum(values) / values.length : undefined);
|
|
@@ -22,27 +30,58 @@ const mean = (values) => (values.length > 0 ? sum(values) / values.length : unde
|
|
|
22
30
|
* @experimental
|
|
23
31
|
*/
|
|
24
32
|
export class RenderingTypePredictor {
|
|
25
|
-
renderingTypeDetectionResults = new Map();
|
|
26
33
|
detectionRatio;
|
|
27
|
-
|
|
28
|
-
constructor({ detectionRatio }) {
|
|
34
|
+
state;
|
|
35
|
+
constructor({ detectionRatio, persistenceOptions }) {
|
|
29
36
|
this.detectionRatio = detectionRatio;
|
|
30
|
-
this.
|
|
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
|
+
},
|
|
62
|
+
persistStateKey: 'rendering-type-predictor-state',
|
|
63
|
+
persistenceEnabled: true,
|
|
64
|
+
...persistenceOptions,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Initialize the predictor by restoring persisted state.
|
|
69
|
+
*/
|
|
70
|
+
async initialize() {
|
|
71
|
+
await this.state.initialize();
|
|
31
72
|
}
|
|
32
73
|
/**
|
|
33
74
|
* Predict the rendering type for a given URL and request label.
|
|
34
75
|
*/
|
|
35
76
|
predict({ url, loadedUrl, label }) {
|
|
36
|
-
|
|
77
|
+
const { logreg } = this.state.currentValue;
|
|
78
|
+
if (logreg.classifiers.length === 0) {
|
|
37
79
|
return { renderingType: 'clientOnly', detectionProbabilityRecommendation: 1 };
|
|
38
80
|
}
|
|
39
81
|
const predictionUrl = new URL(loadedUrl ?? url);
|
|
40
82
|
const urlFeature = new Matrix([this.calculateFeatureVector(urlComponents(predictionUrl), label)]);
|
|
41
|
-
const [prediction] =
|
|
42
|
-
const scores = [
|
|
43
|
-
this.logreg.classifiers[0].testScores(urlFeature),
|
|
44
|
-
this.logreg.classifiers[1].testScores(urlFeature),
|
|
45
|
-
];
|
|
83
|
+
const [prediction] = logreg.predict(urlFeature);
|
|
84
|
+
const scores = [logreg.classifiers[0].testScores(urlFeature), logreg.classifiers[1].testScores(urlFeature)];
|
|
46
85
|
return {
|
|
47
86
|
renderingType: prediction === 1 ? 'static' : 'clientOnly',
|
|
48
87
|
detectionProbabilityRecommendation: Math.abs(scores[0] - scores[1]) < 0.1
|
|
@@ -53,26 +92,29 @@ export class RenderingTypePredictor {
|
|
|
53
92
|
/**
|
|
54
93
|
* Store the rendering type for a given URL and request label. This updates the underlying prediction model, which may be costly.
|
|
55
94
|
*/
|
|
56
|
-
storeResult(
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
95
|
+
storeResult(requests, renderingType) {
|
|
96
|
+
const state = this.state.currentValue;
|
|
97
|
+
for (const { url, loadedUrl, label } of Array.isArray(requests) ? requests : [requests]) {
|
|
98
|
+
const resultUrl = new URL(loadedUrl ?? url);
|
|
99
|
+
if (!state.detectionResults.has(renderingType)) {
|
|
100
|
+
state.detectionResults.set(renderingType, new Map());
|
|
101
|
+
}
|
|
102
|
+
if (!state.detectionResults.get(renderingType).has(label)) {
|
|
103
|
+
state.detectionResults.get(renderingType).set(label, []);
|
|
104
|
+
}
|
|
105
|
+
state.detectionResults.get(renderingType).get(label).push(urlComponents(resultUrl));
|
|
63
106
|
}
|
|
64
|
-
this.renderingTypeDetectionResults.get(renderingType).get(label).push(urlComponents(resultUrl));
|
|
65
107
|
this.retrain();
|
|
66
108
|
}
|
|
67
109
|
resultCount(label) {
|
|
68
|
-
return Array.from(this.
|
|
110
|
+
return Array.from(this.state.currentValue.detectionResults.values())
|
|
69
111
|
.map((results) => results.get(label)?.length ?? 0)
|
|
70
112
|
.reduce((acc, value) => acc + value, 0);
|
|
71
113
|
}
|
|
72
114
|
calculateFeatureVector(url, label) {
|
|
73
115
|
return [
|
|
74
|
-
mean((this.
|
|
75
|
-
mean((this.
|
|
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,
|
|
76
118
|
];
|
|
77
119
|
}
|
|
78
120
|
retrain() {
|
|
@@ -81,7 +123,7 @@ export class RenderingTypePredictor {
|
|
|
81
123
|
[1, 0],
|
|
82
124
|
];
|
|
83
125
|
const Y = [0, 1];
|
|
84
|
-
for (const [renderingType, urlsByLabel] of this.
|
|
126
|
+
for (const [renderingType, urlsByLabel] of this.state.currentValue.detectionResults.entries()) {
|
|
85
127
|
for (const [label, urls] of urlsByLabel) {
|
|
86
128
|
for (const url of urls) {
|
|
87
129
|
X.push(this.calculateFeatureVector(url, label));
|
|
@@ -89,7 +131,6 @@ export class RenderingTypePredictor {
|
|
|
89
131
|
}
|
|
90
132
|
}
|
|
91
133
|
}
|
|
92
|
-
this.logreg.train(new Matrix(X), Matrix.columnVector(Y));
|
|
134
|
+
this.state.currentValue.logreg.train(new Matrix(X), Matrix.columnVector(Y));
|
|
93
135
|
}
|
|
94
136
|
}
|
|
95
|
-
//# sourceMappingURL=rendering-type-prediction.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crawlee/playwright",
|
|
3
|
-
"version": "4.0.0-beta.
|
|
3
|
+
"version": "4.0.0-beta.91",
|
|
4
4
|
"description": "The scalable web crawling and scraping library for JavaScript/Node.js. Enables development of data extraction and web automation jobs (not only) with headless Chrome and Puppeteer.",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": ">=22.0.0"
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
},
|
|
39
39
|
"homepage": "https://crawlee.dev",
|
|
40
40
|
"scripts": {
|
|
41
|
-
"build": "
|
|
41
|
+
"build": "pnpm clean && pnpm compile && pnpm copy",
|
|
42
42
|
"clean": "rimraf ./dist",
|
|
43
43
|
"compile": "tsc -p tsconfig.build.json",
|
|
44
44
|
"copy": "tsx ../../scripts/copy.ts"
|
|
@@ -48,27 +48,32 @@
|
|
|
48
48
|
},
|
|
49
49
|
"dependencies": {
|
|
50
50
|
"@apify/datastructures": "^2.0.3",
|
|
51
|
-
"@apify/log": "^2.5.18",
|
|
52
51
|
"@apify/timeout": "^0.3.2",
|
|
53
|
-
"@crawlee/
|
|
54
|
-
"@crawlee/browser
|
|
55
|
-
"@crawlee/
|
|
56
|
-
"@crawlee/
|
|
57
|
-
"@crawlee/
|
|
52
|
+
"@crawlee/basic": "4.0.0-beta.91",
|
|
53
|
+
"@crawlee/browser": "4.0.0-beta.91",
|
|
54
|
+
"@crawlee/browser-pool": "4.0.0-beta.91",
|
|
55
|
+
"@crawlee/cheerio": "4.0.0-beta.91",
|
|
56
|
+
"@crawlee/core": "4.0.0-beta.91",
|
|
57
|
+
"@crawlee/types": "4.0.0-beta.91",
|
|
58
|
+
"@crawlee/utils": "4.0.0-beta.91",
|
|
58
59
|
"cheerio": "^1.0.0",
|
|
59
60
|
"idcac-playwright": "^0.1.3",
|
|
60
61
|
"jquery": "^3.7.1",
|
|
61
|
-
"lodash.isequal": "^4.5.0",
|
|
62
62
|
"ml-logistic-regression": "^2.0.0",
|
|
63
63
|
"ml-matrix": "^6.12.1",
|
|
64
64
|
"ow": "^2.0.0",
|
|
65
65
|
"string-comparison": "^1.3.0",
|
|
66
|
-
"tslib": "^2.8.1"
|
|
66
|
+
"tslib": "^2.8.1",
|
|
67
|
+
"type-fest": "^4.0.0"
|
|
67
68
|
},
|
|
68
69
|
"peerDependencies": {
|
|
70
|
+
"idcac-playwright": "^0.2.0",
|
|
69
71
|
"playwright": "*"
|
|
70
72
|
},
|
|
71
73
|
"peerDependenciesMeta": {
|
|
74
|
+
"idcac-playwright": {
|
|
75
|
+
"optional": true
|
|
76
|
+
},
|
|
72
77
|
"playwright": {
|
|
73
78
|
"optional": true
|
|
74
79
|
}
|
|
@@ -80,5 +85,5 @@
|
|
|
80
85
|
}
|
|
81
86
|
}
|
|
82
87
|
},
|
|
83
|
-
"gitHead": "
|
|
88
|
+
"gitHead": "1e3e1ca10f24be53e1d527b3ea25f456baebecbf"
|
|
84
89
|
}
|
package/index.d.ts.map
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,kBAAkB,CAAC;AACjC,cAAc,mCAAmC,CAAC;AAClD,cAAc,oCAAoC,CAAC;AACnD,cAAc,4CAA4C,CAAC;AAC3D,OAAO,EAAE,sBAAsB,EAAE,MAAM,gDAAgD,CAAC;AAExF,OAAO,KAAK,eAAe,MAAM,uCAAuC,CAAC;AACzE,OAAO,KAAK,uBAAuB,MAAM,6CAA6C,CAAC;AACvF,YAAY,EAAE,uBAAuB,IAAI,iCAAiC,EAAE,MAAM,uCAAuC,CAAC;AAC1H,YAAY,EAAE,aAAa,EAAE,MAAM,gDAAgD,CAAC"}
|
package/index.js.map
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,kBAAkB,CAAC;AACjC,cAAc,mCAAmC,CAAC;AAClD,cAAc,oCAAoC,CAAC;AACnD,cAAc,4CAA4C,CAAC;AAC3D,OAAO,EAAE,sBAAsB,EAAE,MAAM,gDAAgD,CAAC;AAExF,OAAO,KAAK,eAAe,MAAM,uCAAuC,CAAC;AACzE,OAAO,KAAK,uBAAuB,MAAM,6CAA6C,CAAC"}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"adaptive-playwright-crawler.d.ts","sourceRoot":"","sources":["../../src/internals/adaptive-playwright-crawler.ts"],"names":[],"mappings":"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"}
|