@crawlee/playwright 4.0.0-beta.18 → 4.0.0-beta.180
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 +14 -14
- package/index.d.ts +2 -2
- package/index.js +1 -1
- package/internals/adaptive-playwright-crawler.d.ts +135 -63
- package/internals/adaptive-playwright-crawler.js +415 -271
- package/internals/enqueue-links/click-elements.d.ts +36 -64
- package/internals/enqueue-links/click-elements.js +65 -67
- package/internals/playwright-browser-pool.d.ts +71 -0
- package/internals/playwright-browser-pool.js +61 -0
- package/internals/playwright-crawler.d.ts +180 -125
- package/internals/playwright-crawler.js +68 -63
- package/internals/playwright-launcher.d.ts +32 -18
- package/internals/playwright-launcher.js +23 -17
- package/internals/utils/playwright-utils.d.ts +54 -41
- package/internals/utils/playwright-utils.js +111 -121
- package/internals/utils/rendering-type-prediction.d.ts +37 -11
- package/internals/utils/rendering-type-prediction.js +81 -27
- package/package.json +15 -19
- 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
|
@@ -20,19 +20,48 @@
|
|
|
20
20
|
import { readFile } from 'node:fs/promises';
|
|
21
21
|
import { createRequire } from 'node:module';
|
|
22
22
|
import vm from 'node:vm';
|
|
23
|
-
import { Configuration, KeyValueStore, SessionError, validators } from '@crawlee/browser';
|
|
23
|
+
import { Configuration, KeyValueStore, serviceLocator, SessionError, validators } from '@crawlee/browser';
|
|
24
24
|
import { expandShadowRoots, sleep } from '@crawlee/utils';
|
|
25
|
-
import
|
|
26
|
-
import
|
|
25
|
+
import { parseArgument, schemas } from '@crawlee/utils/internal';
|
|
26
|
+
import { z } from 'zod';
|
|
27
27
|
import { LruCache } from '@apify/datastructures';
|
|
28
|
-
import log_ from '@apify/log';
|
|
29
28
|
import { enqueueLinksByClickingElements } from '../enqueue-links/click-elements.js';
|
|
30
29
|
import { RenderingTypePredictor } from './rendering-type-prediction.js';
|
|
31
|
-
const
|
|
30
|
+
const getLog = () => serviceLocator.getChildLog('Playwright Utils');
|
|
32
31
|
const require = createRequire(import.meta.url);
|
|
33
32
|
const jqueryPath = require.resolve('jquery');
|
|
34
33
|
const MAX_INJECT_FILE_CACHE_SIZE = 10;
|
|
35
34
|
const DEFAULT_BLOCK_REQUEST_URL_PATTERNS = ['.css', '.jpg', '.jpeg', '.png', '.svg', '.gif', '.woff', '.pdf', '.zip'];
|
|
35
|
+
const filePathSchema = z.string();
|
|
36
|
+
const injectFileOptionsSchema = z.strictObject({
|
|
37
|
+
surviveNavigations: z.boolean().optional(),
|
|
38
|
+
});
|
|
39
|
+
const gotoExtendedRequestSchema = z.looseObject({
|
|
40
|
+
url: z.url(),
|
|
41
|
+
method: z.string().optional(),
|
|
42
|
+
headers: schemas.anyObject.optional(),
|
|
43
|
+
payload: z.union([z.string(), z.instanceof(Uint8Array)]).optional(),
|
|
44
|
+
});
|
|
45
|
+
const blockRequestsOptionsSchema = z.strictObject({
|
|
46
|
+
urlPatterns: schemas.arrayOf(z.string(), 'strings').default(DEFAULT_BLOCK_REQUEST_URL_PATTERNS),
|
|
47
|
+
extraUrlPatterns: schemas.arrayOf(z.string(), 'strings').default(() => []),
|
|
48
|
+
});
|
|
49
|
+
const infiniteScrollOptionsSchema = z.strictObject({
|
|
50
|
+
timeoutSecs: schemas.anyNumber.default(0),
|
|
51
|
+
maxScrollHeight: schemas.anyNumber.default(0),
|
|
52
|
+
waitForSecs: schemas.anyNumber.default(4),
|
|
53
|
+
scrollDownAndUp: z.boolean().default(false),
|
|
54
|
+
buttonSelector: z.string().optional(),
|
|
55
|
+
stopScrollCallback: schemas.anyFunction.optional(),
|
|
56
|
+
});
|
|
57
|
+
const saveSnapshotOptionsSchema = z.strictObject({
|
|
58
|
+
key: z.string().min(1).default('SNAPSHOT'),
|
|
59
|
+
screenshotQuality: schemas.anyNumber.default(50),
|
|
60
|
+
saveScreenshot: z.boolean().default(true),
|
|
61
|
+
saveHtml: z.boolean().default(true),
|
|
62
|
+
keyValueStoreName: z.string().optional(),
|
|
63
|
+
configuration: schemas.anyObject.optional(),
|
|
64
|
+
});
|
|
36
65
|
/**
|
|
37
66
|
* Cache contents of previously injected files to limit file system access.
|
|
38
67
|
*/
|
|
@@ -49,21 +78,19 @@ const injectedFilesCache = new LruCache({ maxLength: MAX_INJECT_FILE_CACHE_SIZE
|
|
|
49
78
|
* @param [options]
|
|
50
79
|
*/
|
|
51
80
|
export async function injectFile(page, filePath, options = {}) {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
surviveNavigations: ow.optional.boolean,
|
|
56
|
-
}));
|
|
81
|
+
parseArgument(page, validators.browserPage);
|
|
82
|
+
parseArgument(filePath, filePathSchema);
|
|
83
|
+
const { surviveNavigations } = parseArgument(options, injectFileOptionsSchema);
|
|
57
84
|
let contents = injectedFilesCache.get(filePath);
|
|
58
85
|
if (!contents) {
|
|
59
86
|
contents = await readFile(filePath, 'utf8');
|
|
60
87
|
injectedFilesCache.add(filePath, contents);
|
|
61
88
|
}
|
|
62
89
|
const evalP = page.evaluate(contents);
|
|
63
|
-
if (
|
|
90
|
+
if (surviveNavigations) {
|
|
64
91
|
page.on('framenavigated', async () => page
|
|
65
92
|
.evaluate(contents)
|
|
66
|
-
.catch((error) =>
|
|
93
|
+
.catch((error) => getLog().warning('An error occurred during the script injection!', { error })));
|
|
67
94
|
}
|
|
68
95
|
return evalP;
|
|
69
96
|
}
|
|
@@ -94,7 +121,7 @@ export async function injectFile(page, filePath, options = {}) {
|
|
|
94
121
|
* @param [options.surviveNavigations] Opt-out option to disable the JQuery reinjection after navigation.
|
|
95
122
|
*/
|
|
96
123
|
export async function injectJQuery(page, options) {
|
|
97
|
-
|
|
124
|
+
parseArgument(page, validators.browserPage);
|
|
98
125
|
return injectFile(page, jqueryPath, { surviveNavigations: options?.surviveNavigations ?? true });
|
|
99
126
|
}
|
|
100
127
|
/**
|
|
@@ -110,19 +137,14 @@ export async function injectJQuery(page, options) {
|
|
|
110
137
|
* @param [gotoOptions] Custom options for `page.goto()`.
|
|
111
138
|
*/
|
|
112
139
|
export async function gotoExtended(page, request, gotoOptions = {}) {
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
method: ow.optional.string,
|
|
117
|
-
headers: ow.optional.object,
|
|
118
|
-
payload: ow.optional.any(ow.string, ow.uint8Array),
|
|
119
|
-
}));
|
|
120
|
-
ow(gotoOptions, ow.object);
|
|
140
|
+
parseArgument(page, validators.browserPage);
|
|
141
|
+
parseArgument(request, gotoExtendedRequestSchema);
|
|
142
|
+
parseArgument(gotoOptions, schemas.anyObject);
|
|
121
143
|
const { url, method, headers, payload } = request;
|
|
122
144
|
const isEmpty = (o) => !o || Object.keys(o).length === 0;
|
|
123
|
-
if (method !== 'GET' || payload
|
|
145
|
+
if (method !== 'GET' || payload) {
|
|
124
146
|
// This is not deprecated, we use it to log only once.
|
|
125
|
-
|
|
147
|
+
getLog().deprecated('Using other request methods than GET, rewriting headers and adding payloads has a high impact on performance ' +
|
|
126
148
|
'in recent versions of Playwright. Use only when necessary.');
|
|
127
149
|
let wasCalled = false;
|
|
128
150
|
const interceptRequestHandler = async (route) => {
|
|
@@ -143,12 +165,15 @@ export async function gotoExtended(page, request, gotoOptions = {}) {
|
|
|
143
165
|
await route.continue(overrides);
|
|
144
166
|
}
|
|
145
167
|
catch (error) {
|
|
146
|
-
|
|
168
|
+
getLog().debug('Error inside request interceptor', { error });
|
|
147
169
|
}
|
|
148
170
|
return undefined;
|
|
149
171
|
};
|
|
150
172
|
await page.route('**/*', interceptRequestHandler);
|
|
151
173
|
}
|
|
174
|
+
else if (!isEmpty(headers)) {
|
|
175
|
+
await page.setExtraHTTPHeaders(headers);
|
|
176
|
+
}
|
|
152
177
|
return page.goto(url, gotoOptions);
|
|
153
178
|
}
|
|
154
179
|
/**
|
|
@@ -199,12 +224,8 @@ export async function gotoExtended(page, request, gotoOptions = {}) {
|
|
|
199
224
|
* @param [options]
|
|
200
225
|
*/
|
|
201
226
|
export async function blockRequests(page, options = {}) {
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
urlPatterns: ow.optional.array.ofType(ow.string),
|
|
205
|
-
extraUrlPatterns: ow.optional.array.ofType(ow.string),
|
|
206
|
-
}));
|
|
207
|
-
const { urlPatterns = DEFAULT_BLOCK_REQUEST_URL_PATTERNS, extraUrlPatterns = [] } = options;
|
|
227
|
+
parseArgument(page, validators.browserPage);
|
|
228
|
+
const { urlPatterns, extraUrlPatterns } = parseArgument(options, blockRequestsOptionsSchema);
|
|
208
229
|
const patternsToBlock = [...urlPatterns, ...extraUrlPatterns];
|
|
209
230
|
try {
|
|
210
231
|
const client = await page.context().newCDPSession(page);
|
|
@@ -212,7 +233,7 @@ export async function blockRequests(page, options = {}) {
|
|
|
212
233
|
await client.send('Network.setBlockedURLs', { urls: patternsToBlock });
|
|
213
234
|
}
|
|
214
235
|
catch {
|
|
215
|
-
|
|
236
|
+
getLog().warning('blockRequests() helper is incompatible with non-Chromium browsers.');
|
|
216
237
|
}
|
|
217
238
|
}
|
|
218
239
|
/**
|
|
@@ -248,7 +269,7 @@ export function compileScript(scriptString, context = Object.create(null)) {
|
|
|
248
269
|
func = vm.runInNewContext(funcString, context); // "Secure" the context by removing prototypes, unless custom context is provided.
|
|
249
270
|
}
|
|
250
271
|
catch (err) {
|
|
251
|
-
|
|
272
|
+
getLog().exception(err, 'Cannot compile script!');
|
|
252
273
|
throw err;
|
|
253
274
|
}
|
|
254
275
|
if (typeof func !== 'function')
|
|
@@ -262,16 +283,8 @@ export function compileScript(scriptString, context = Object.create(null)) {
|
|
|
262
283
|
* @param [options]
|
|
263
284
|
*/
|
|
264
285
|
export async function infiniteScroll(page, options = {}) {
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
timeoutSecs: ow.optional.number,
|
|
268
|
-
maxScrollHeight: ow.optional.number,
|
|
269
|
-
waitForSecs: ow.optional.number,
|
|
270
|
-
scrollDownAndUp: ow.optional.boolean,
|
|
271
|
-
buttonSelector: ow.optional.string,
|
|
272
|
-
stopScrollCallback: ow.optional.function,
|
|
273
|
-
}));
|
|
274
|
-
const { timeoutSecs = 0, maxScrollHeight = 0, waitForSecs = 4, scrollDownAndUp = false, buttonSelector, stopScrollCallback, } = options;
|
|
286
|
+
parseArgument(page, validators.browserPage);
|
|
287
|
+
const { timeoutSecs, maxScrollHeight, waitForSecs, scrollDownAndUp, buttonSelector, stopScrollCallback } = parseArgument(options, infiniteScrollOptionsSchema);
|
|
275
288
|
let finished;
|
|
276
289
|
const startTime = Date.now();
|
|
277
290
|
const CHECK_INTERVAL_MILLIS = 1000;
|
|
@@ -348,19 +361,11 @@ export async function infiniteScroll(page, options = {}) {
|
|
|
348
361
|
* @param [options]
|
|
349
362
|
*/
|
|
350
363
|
export async function saveSnapshot(page, options = {}) {
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
key: ow.optional.string.nonEmpty,
|
|
354
|
-
screenshotQuality: ow.optional.number,
|
|
355
|
-
saveScreenshot: ow.optional.boolean,
|
|
356
|
-
saveHtml: ow.optional.boolean,
|
|
357
|
-
keyValueStoreName: ow.optional.string,
|
|
358
|
-
config: ow.optional.object,
|
|
359
|
-
}));
|
|
360
|
-
const { key = 'SNAPSHOT', screenshotQuality = 50, saveScreenshot = true, saveHtml = true, keyValueStoreName, config, } = options;
|
|
364
|
+
parseArgument(page, validators.browserPage);
|
|
365
|
+
const { key, screenshotQuality, saveScreenshot, saveHtml, keyValueStoreName, configuration } = parseArgument(options, saveSnapshotOptionsSchema);
|
|
361
366
|
try {
|
|
362
|
-
const store = await KeyValueStore.open(keyValueStoreName, {
|
|
363
|
-
|
|
367
|
+
const store = await KeyValueStore.open(keyValueStoreName ? { name: keyValueStoreName } : null, {
|
|
368
|
+
configuration: configuration ?? Configuration.getGlobalConfiguration(),
|
|
364
369
|
});
|
|
365
370
|
if (saveScreenshot) {
|
|
366
371
|
const screenshotName = `${key}.jpg`;
|
|
@@ -395,13 +400,26 @@ export async function saveSnapshot(page, options = {}) {
|
|
|
395
400
|
* @param ignoreShadowRoots
|
|
396
401
|
*/
|
|
397
402
|
export async function parseWithCheerio(page, ignoreShadowRoots = false, ignoreIframes = false) {
|
|
398
|
-
|
|
403
|
+
parseArgument(page, validators.browserPage);
|
|
404
|
+
const html = ignoreShadowRoots
|
|
405
|
+
? null
|
|
406
|
+
: (await page.evaluate(`(${expandShadowRoots.toString()})(document)`));
|
|
407
|
+
const pageContent = html || (await page.content());
|
|
408
|
+
// Full cheerio (parse5) import for browser compliant parser
|
|
409
|
+
const { load } = await import('cheerio');
|
|
410
|
+
const $ = load(pageContent);
|
|
399
411
|
if (page.frames().length > 1 && !ignoreIframes) {
|
|
400
412
|
const frames = await page.$$('iframe');
|
|
401
|
-
|
|
413
|
+
const cheerioIframes = $('iframe').toArray();
|
|
414
|
+
if (frames.length !== cheerioIframes.length) {
|
|
415
|
+
serviceLocator
|
|
416
|
+
.getLogger()
|
|
417
|
+
.warning(`parseWithCheerio: iframe count mismatch between live DOM (${frames.length}) and page snapshot (${cheerioIframes.length}). Some iframes may not be expanded.`);
|
|
418
|
+
}
|
|
419
|
+
await Promise.all(frames.map(async (frame, index) => {
|
|
402
420
|
try {
|
|
403
421
|
const iframe = await frame.contentFrame();
|
|
404
|
-
if (iframe) {
|
|
422
|
+
if (iframe && cheerioIframes[index]) {
|
|
405
423
|
const getIframeHTML = async () => {
|
|
406
424
|
try {
|
|
407
425
|
return iframe.locator('body').first().innerHTML();
|
|
@@ -411,53 +429,15 @@ export async function parseWithCheerio(page, ignoreShadowRoots = false, ignoreIf
|
|
|
411
429
|
}
|
|
412
430
|
};
|
|
413
431
|
const contents = await getIframeHTML();
|
|
414
|
-
|
|
415
|
-
const replacementNode = document.createElement('div');
|
|
416
|
-
replacementNode.innerHTML = c;
|
|
417
|
-
replacementNode.className = 'crawlee-iframe-replacement';
|
|
418
|
-
f.replaceWith(replacementNode);
|
|
419
|
-
}, contents);
|
|
432
|
+
$(cheerioIframes[index]).replaceWith(`<div class="crawlee-iframe-replacement">${contents}</div>`);
|
|
420
433
|
}
|
|
421
434
|
}
|
|
422
435
|
catch (error) {
|
|
423
|
-
|
|
436
|
+
getLog().warning(`Failed to extract iframe content: ${error}`);
|
|
424
437
|
}
|
|
425
438
|
}));
|
|
426
439
|
}
|
|
427
|
-
|
|
428
|
-
? null
|
|
429
|
-
: (await page.evaluate(`(${expandShadowRoots.toString()})(document)`));
|
|
430
|
-
const pageContent = html || (await page.content());
|
|
431
|
-
return cheerio.load(pageContent);
|
|
432
|
-
}
|
|
433
|
-
let idcacPlaywright = null;
|
|
434
|
-
async function getIdcacPlaywright() {
|
|
435
|
-
if (idcacPlaywright)
|
|
436
|
-
return idcacPlaywright;
|
|
437
|
-
try {
|
|
438
|
-
idcacPlaywright = await import('idcac-playwright');
|
|
439
|
-
}
|
|
440
|
-
catch (error) {
|
|
441
|
-
log.warning(`Failed to import 'idcac-playwright'.
|
|
442
|
-
|
|
443
|
-
We recently made idcac-playwright an optional dependency due to licensing issues.
|
|
444
|
-
To use this feature, please install it manually by running
|
|
445
|
-
|
|
446
|
-
npm install idcac-playwright
|
|
447
|
-
|
|
448
|
-
Original error message follows:
|
|
449
|
-
|
|
450
|
-
${error.message}
|
|
451
|
-
`);
|
|
452
|
-
}
|
|
453
|
-
return idcacPlaywright;
|
|
454
|
-
}
|
|
455
|
-
export async function closeCookieModals(page) {
|
|
456
|
-
ow(page, ow.object.validate(validators.browserPage));
|
|
457
|
-
const idcac = await getIdcacPlaywright();
|
|
458
|
-
if (idcac?.getInjectableScript()) {
|
|
459
|
-
await page.evaluate(idcac.getInjectableScript());
|
|
460
|
-
}
|
|
440
|
+
return $;
|
|
461
441
|
}
|
|
462
442
|
/**
|
|
463
443
|
* This helper tries to solve the Cloudflare challenge automatically by clicking on the checkbox.
|
|
@@ -466,30 +446,24 @@ export async function closeCookieModals(page) {
|
|
|
466
446
|
* result in a SessionError which will be automatically retried, so only successful requests will get
|
|
467
447
|
* into the `requestHandler`.
|
|
468
448
|
*
|
|
449
|
+
* On a successfully solved challenge the page is reloaded and the new {@link Response} is returned, so
|
|
450
|
+
* it can be propagated back to the crawling context via a hook return value (see
|
|
451
|
+
* {@link handleCloudflareChallengeHook}).
|
|
452
|
+
*
|
|
469
453
|
* Works best with camoufox.
|
|
470
454
|
*
|
|
471
455
|
* **Example usage**
|
|
472
456
|
* ```ts
|
|
473
457
|
* postNavigationHooks: [
|
|
474
|
-
* async ({ handleCloudflareChallenge })
|
|
475
|
-
* await handleCloudflareChallenge();
|
|
476
|
-
* },
|
|
458
|
+
* async (context) => ({ response: await context.handleCloudflareChallenge() }),
|
|
477
459
|
* ],
|
|
478
460
|
* ```
|
|
479
461
|
*
|
|
480
462
|
* @param page Playwright [`Page`](https://playwright.dev/docs/api/class-page) object
|
|
481
463
|
* @param url current URL for request identification, only used for logging
|
|
482
|
-
* @param [session] current session object
|
|
483
464
|
* @param [options]
|
|
484
465
|
*/
|
|
485
|
-
async function handleCloudflareChallenge(page, url,
|
|
486
|
-
// eslint-disable-next-line dot-notation
|
|
487
|
-
const blockedStatusCodes = session?.['sessionPool']['blockedStatusCodes'];
|
|
488
|
-
// Cloudflare pages are 403, which are blocked by default
|
|
489
|
-
if (blockedStatusCodes?.includes(403)) {
|
|
490
|
-
const idx = blockedStatusCodes.indexOf(403);
|
|
491
|
-
blockedStatusCodes.splice(idx, 1);
|
|
492
|
-
}
|
|
466
|
+
async function handleCloudflareChallenge(page, url, options = {}) {
|
|
493
467
|
options.isBlockedCallback ??= async () => {
|
|
494
468
|
const isBlocked = await page.evaluate(() => {
|
|
495
469
|
return document.querySelector('h1')?.textContent?.trim().includes('Sorry, you have been blocked');
|
|
@@ -498,7 +472,9 @@ async function handleCloudflareChallenge(page, url, session, options = {}) {
|
|
|
498
472
|
};
|
|
499
473
|
options.isChallengeCallback ??= async () => {
|
|
500
474
|
return await page.evaluate(async () => {
|
|
501
|
-
|
|
475
|
+
// Cloudflare keeps reshuffling the wrapper elements between `.footer-inner` and `.ray-id`,
|
|
476
|
+
// so only the stable outer classes are matched.
|
|
477
|
+
return !!document.querySelector('.footer .footer-inner .ray-id');
|
|
502
478
|
});
|
|
503
479
|
};
|
|
504
480
|
const retryBlocked = async () => {
|
|
@@ -513,31 +489,41 @@ async function handleCloudflareChallenge(page, url, session, options = {}) {
|
|
|
513
489
|
};
|
|
514
490
|
if (!(await isChallenge())) {
|
|
515
491
|
await retryBlocked();
|
|
516
|
-
return;
|
|
492
|
+
return undefined;
|
|
517
493
|
}
|
|
518
494
|
const logLevel = options.verbose ? 'info' : 'debug';
|
|
519
|
-
|
|
495
|
+
getLog()[logLevel](`Detected Cloudflare challenge at ${url}, trying to solve it. This can take up to ${10 + (options.sleepSecs ?? 10)} seconds.`);
|
|
520
496
|
const bb = await page
|
|
521
497
|
.evaluate(() => {
|
|
522
|
-
|
|
498
|
+
// Prefer the actual challenge widget (the box holding the Turnstile checkbox input);
|
|
499
|
+
// fall back to the first content div for older challenge layouts.
|
|
500
|
+
const div = document.querySelector('.main-content div:has(input[id^="cf-chl-widget-"])') ??
|
|
501
|
+
document.querySelector('.main-content div');
|
|
523
502
|
return div?.getBoundingClientRect();
|
|
524
503
|
})
|
|
525
504
|
.catch(() => undefined);
|
|
526
505
|
if (!bb) {
|
|
527
|
-
return;
|
|
506
|
+
return undefined;
|
|
528
507
|
}
|
|
529
508
|
const randomOffset = (range) => {
|
|
530
509
|
return Math.round(100 * range * Math.random()) / 100;
|
|
531
510
|
};
|
|
532
|
-
|
|
533
|
-
|
|
511
|
+
let x = bb.x + 30;
|
|
512
|
+
let y = bb.y + 25;
|
|
534
513
|
// try to click the checkbox every second
|
|
535
514
|
for (let i = 0; i < 10; i++) {
|
|
536
|
-
await sleep(1000);
|
|
515
|
+
await sleep((options.preChallengeSleepSecs ?? 1) * 1000);
|
|
537
516
|
// break early if we are no longer on the CF challenge page
|
|
538
517
|
if (!(await isChallenge())) {
|
|
539
518
|
break;
|
|
540
519
|
}
|
|
520
|
+
if (options.clickPositionCallback) {
|
|
521
|
+
const pos = await options.clickPositionCallback(page);
|
|
522
|
+
if (pos) {
|
|
523
|
+
x = pos.x;
|
|
524
|
+
y = pos.y;
|
|
525
|
+
}
|
|
526
|
+
}
|
|
541
527
|
if (options.clickCallback) {
|
|
542
528
|
await options.clickCallback(page, { x, y });
|
|
543
529
|
continue;
|
|
@@ -545,7 +531,10 @@ async function handleCloudflareChallenge(page, url, session, options = {}) {
|
|
|
545
531
|
// we can click on the text too, so X can be a bit larger
|
|
546
532
|
const xRandomized = x + randomOffset(10);
|
|
547
533
|
const yRandomized = y + randomOffset(10);
|
|
548
|
-
|
|
534
|
+
getLog()[logLevel](`Trying to click on the Cloudflare checkbox at ${url}`, {
|
|
535
|
+
x: xRandomized,
|
|
536
|
+
y: yRandomized,
|
|
537
|
+
});
|
|
549
538
|
await page.mouse.click(xRandomized, yRandomized);
|
|
550
539
|
// sometimes the checkbox is lower (could be caused by a lag when rendering the logo)
|
|
551
540
|
await page.mouse.click(xRandomized, yRandomized + 35);
|
|
@@ -555,6 +544,9 @@ async function handleCloudflareChallenge(page, url, session, options = {}) {
|
|
|
555
544
|
throw new SessionError(`Blocked by Cloudflare when processing ${url}`);
|
|
556
545
|
}
|
|
557
546
|
await retryBlocked();
|
|
547
|
+
// Reload to obtain a fresh Response without the challenge interstitial, which the caller can
|
|
548
|
+
// propagate back into the crawling context so downstream status-code checks see the new value.
|
|
549
|
+
return (await page.reload()) ?? undefined;
|
|
558
550
|
}
|
|
559
551
|
export { enqueueLinksByClickingElements };
|
|
560
552
|
/** @internal */
|
|
@@ -568,8 +560,6 @@ export const playwrightUtils = {
|
|
|
568
560
|
infiniteScroll,
|
|
569
561
|
saveSnapshot,
|
|
570
562
|
compileScript,
|
|
571
|
-
closeCookieModals,
|
|
572
563
|
RenderingTypePredictor,
|
|
573
564
|
handleCloudflareChallenge,
|
|
574
565
|
};
|
|
575
|
-
//# sourceMappingURL=playwright-utils.js.map
|
|
@@ -1,26 +1,54 @@
|
|
|
1
1
|
import type { RecoverableStatePersistenceOptions, Request } from '@crawlee/core';
|
|
2
|
+
import type { Awaitable } from '@crawlee/types';
|
|
2
3
|
export type RenderingType = 'clientOnly' | 'static';
|
|
3
|
-
type URLComponents = string[];
|
|
4
|
-
type FeatureVector = [staticResultsSimilarity: number, clientOnlyResultsSimilarity: number];
|
|
5
4
|
export interface RenderingTypePredictorOptions {
|
|
6
5
|
/** A number between 0 and 1 that determines the desired ratio of rendering type detections */
|
|
7
6
|
detectionRatio: number;
|
|
8
7
|
persistenceOptions?: Partial<RecoverableStatePersistenceOptions>;
|
|
9
8
|
}
|
|
9
|
+
/**
|
|
10
|
+
* Minimal contract that any object passed to {@link AdaptivePlaywrightCrawler} as its
|
|
11
|
+
* `renderingTypePredictor` option must satisfy.
|
|
12
|
+
*
|
|
13
|
+
* @experimental
|
|
14
|
+
*/
|
|
15
|
+
export interface IRenderingTypePredictor {
|
|
16
|
+
/**
|
|
17
|
+
* Predict the rendering type for a request, and how likely the crawler should be to verify it.
|
|
18
|
+
*
|
|
19
|
+
* Called once per request, before navigation - the crawler awaits it, so prefer loading whatever the
|
|
20
|
+
* prediction needs up front over per-request I/O.
|
|
21
|
+
*/
|
|
22
|
+
predict(request: Request): Awaitable<{
|
|
23
|
+
renderingType: RenderingType;
|
|
24
|
+
detectionProbabilityRecommendation: number;
|
|
25
|
+
}>;
|
|
26
|
+
/**
|
|
27
|
+
* Report a detected rendering type, so that future predictions can take it into account.
|
|
28
|
+
*
|
|
29
|
+
* The crawler does not await this per detection - it collects the returned promises and drains them in
|
|
30
|
+
* `teardown()`, so an implementation that persists results asynchronously can keep batching its writes
|
|
31
|
+
* and still be sure they land before the crawl ends.
|
|
32
|
+
*/
|
|
33
|
+
storeResult(requests: Request | Request[], renderingType: RenderingType): Awaitable<void>;
|
|
34
|
+
}
|
|
10
35
|
/**
|
|
11
36
|
* Stores rendering type information for previously crawled URLs and predicts the rendering type for URLs that have yet to be crawled and recommends when rendering type detection should be performed.
|
|
12
37
|
*
|
|
13
38
|
* @experimental
|
|
14
39
|
*/
|
|
15
|
-
export declare class RenderingTypePredictor {
|
|
16
|
-
private
|
|
17
|
-
private detectionRatio;
|
|
18
|
-
private state;
|
|
40
|
+
export declare class RenderingTypePredictor implements IRenderingTypePredictor {
|
|
41
|
+
#private;
|
|
19
42
|
constructor({ detectionRatio, persistenceOptions }: RenderingTypePredictorOptions);
|
|
20
43
|
/**
|
|
21
44
|
* Initialize the predictor by restoring persisted state.
|
|
22
45
|
*/
|
|
23
46
|
initialize(): Promise<void>;
|
|
47
|
+
/**
|
|
48
|
+
* Stop persisting the model, writing it out one last time. `initialize()` reopens the persistence window.
|
|
49
|
+
*/
|
|
50
|
+
teardown(): Promise<void>;
|
|
51
|
+
[Symbol.asyncDispose](): Promise<void>;
|
|
24
52
|
/**
|
|
25
53
|
* Predict the rendering type for a given URL and request label.
|
|
26
54
|
*/
|
|
@@ -31,10 +59,8 @@ export declare class RenderingTypePredictor {
|
|
|
31
59
|
/**
|
|
32
60
|
* Store the rendering type for a given URL and request label. This updates the underlying prediction model, which may be costly.
|
|
33
61
|
*/
|
|
34
|
-
storeResult(
|
|
62
|
+
storeResult(requests: Request | Request[], renderingType: RenderingType): void;
|
|
35
63
|
private resultCount;
|
|
36
|
-
|
|
37
|
-
|
|
64
|
+
private calculateFeatureVector;
|
|
65
|
+
private retrain;
|
|
38
66
|
}
|
|
39
|
-
export {};
|
|
40
|
-
//# sourceMappingURL=rendering-type-prediction.d.ts.map
|
|
@@ -2,6 +2,7 @@ import { RecoverableState } from '@crawlee/core';
|
|
|
2
2
|
import LogisticRegression from 'ml-logistic-regression';
|
|
3
3
|
import { Matrix } from 'ml-matrix';
|
|
4
4
|
import stringComparison from 'string-comparison';
|
|
5
|
+
import { z } from 'zod';
|
|
5
6
|
const urlComponents = (url) => {
|
|
6
7
|
return [url.hostname, ...url.pathname.split('/')];
|
|
7
8
|
};
|
|
@@ -10,28 +11,70 @@ const calculateUrlSimilarity = (a, b) => {
|
|
|
10
11
|
if (a[0] !== b[0]) {
|
|
11
12
|
return 0;
|
|
12
13
|
}
|
|
13
|
-
|
|
14
|
+
const maxLength = Math.max(a.length, b.length);
|
|
15
|
+
// Only the hostname is present (no path components to compare) - the hosts already match.
|
|
16
|
+
if (maxLength <= 1) {
|
|
17
|
+
return 1;
|
|
18
|
+
}
|
|
19
|
+
for (let i = 1; i < maxLength; i++) {
|
|
14
20
|
values.push(stringComparison.jaroWinkler.similarity(a[i] ?? '', b[i] ?? '') > 0.8 ? 1 : 0);
|
|
15
21
|
}
|
|
16
|
-
|
|
22
|
+
// The first component (index 0, the hostname) is excluded from the comparison above,
|
|
23
|
+
// so it must also be excluded from the denominator of the weighted average.
|
|
24
|
+
return sum(values) / (maxLength - 1);
|
|
17
25
|
};
|
|
18
26
|
const sum = (values) => values.reduce((acc, value) => acc + value);
|
|
19
27
|
const mean = (values) => (values.length > 0 ? sum(values) / values.length : undefined);
|
|
28
|
+
const renderingType = z.enum(['clientOnly', 'static']);
|
|
29
|
+
const predictorState = z.object({
|
|
30
|
+
logreg: z.instanceof(LogisticRegression),
|
|
31
|
+
detectionResults: z.map(renderingType, z.map(z.string().optional(), z.array(z.array(z.string())))),
|
|
32
|
+
});
|
|
33
|
+
const persistedState = z.object({
|
|
34
|
+
logreg: z
|
|
35
|
+
.record(z.string(), z.unknown())
|
|
36
|
+
.prefault(() => new LogisticRegression({ numSteps: 1000, learningRate: 0.05 }).toJSON()),
|
|
37
|
+
detectionResults: z
|
|
38
|
+
.array(z.object({
|
|
39
|
+
renderingType,
|
|
40
|
+
urlPartsByLabel: z.array(z.object({
|
|
41
|
+
label: z.string().optional(),
|
|
42
|
+
urlParts: z.array(z.array(z.string())),
|
|
43
|
+
})),
|
|
44
|
+
}))
|
|
45
|
+
.prefault([]),
|
|
46
|
+
});
|
|
47
|
+
const stateCodec = z.codec(persistedState, predictorState, {
|
|
48
|
+
decode: ({ logreg, detectionResults }) => ({
|
|
49
|
+
logreg: LogisticRegression.load(logreg),
|
|
50
|
+
detectionResults: new Map(detectionResults.map(({ renderingType, urlPartsByLabel }) => [
|
|
51
|
+
renderingType,
|
|
52
|
+
new Map(urlPartsByLabel.map(({ label, urlParts }) => [label, urlParts])),
|
|
53
|
+
])),
|
|
54
|
+
}),
|
|
55
|
+
encode: ({ logreg, detectionResults }) => ({
|
|
56
|
+
logreg: logreg.toJSON(),
|
|
57
|
+
detectionResults: Array.from(detectionResults.entries()).map(([renderingType, urlPartsByLabel]) => ({
|
|
58
|
+
renderingType,
|
|
59
|
+
urlPartsByLabel: Array.from(urlPartsByLabel.entries()).map(([label, urlParts]) => ({ label, urlParts })),
|
|
60
|
+
})),
|
|
61
|
+
}),
|
|
62
|
+
});
|
|
20
63
|
/**
|
|
21
64
|
* Stores rendering type information for previously crawled URLs and predicts the rendering type for URLs that have yet to be crawled and recommends when rendering type detection should be performed.
|
|
22
65
|
*
|
|
23
66
|
* @experimental
|
|
24
67
|
*/
|
|
25
68
|
export class RenderingTypePredictor {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
state;
|
|
69
|
+
#detectionRatio;
|
|
70
|
+
#state;
|
|
29
71
|
constructor({ detectionRatio, persistenceOptions }) {
|
|
30
|
-
this
|
|
31
|
-
this
|
|
32
|
-
defaultState:
|
|
33
|
-
|
|
34
|
-
deserialize:
|
|
72
|
+
this.#detectionRatio = detectionRatio;
|
|
73
|
+
this.#state = new RecoverableState({
|
|
74
|
+
defaultState: () => stateCodec.decode({}),
|
|
75
|
+
// The codec validates in the decode direction, so it is a Standard Schema as-is; encoding needs a call.
|
|
76
|
+
deserialize: stateCodec,
|
|
77
|
+
serialize: (state) => stateCodec.encode(state),
|
|
35
78
|
persistStateKey: 'rendering-type-predictor-state',
|
|
36
79
|
persistenceEnabled: true,
|
|
37
80
|
...persistenceOptions,
|
|
@@ -41,13 +84,22 @@ export class RenderingTypePredictor {
|
|
|
41
84
|
* Initialize the predictor by restoring persisted state.
|
|
42
85
|
*/
|
|
43
86
|
async initialize() {
|
|
44
|
-
await this
|
|
87
|
+
await this.#state.initialize();
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Stop persisting the model, writing it out one last time. `initialize()` reopens the persistence window.
|
|
91
|
+
*/
|
|
92
|
+
async teardown() {
|
|
93
|
+
await this.#state.teardown();
|
|
94
|
+
}
|
|
95
|
+
async [Symbol.asyncDispose]() {
|
|
96
|
+
await this.teardown();
|
|
45
97
|
}
|
|
46
98
|
/**
|
|
47
99
|
* Predict the rendering type for a given URL and request label.
|
|
48
100
|
*/
|
|
49
101
|
predict({ url, loadedUrl, label }) {
|
|
50
|
-
const { logreg } = this
|
|
102
|
+
const { logreg } = this.#state.currentValue;
|
|
51
103
|
if (logreg.classifiers.length === 0) {
|
|
52
104
|
return { renderingType: 'clientOnly', detectionProbabilityRecommendation: 1 };
|
|
53
105
|
}
|
|
@@ -59,32 +111,35 @@ export class RenderingTypePredictor {
|
|
|
59
111
|
renderingType: prediction === 1 ? 'static' : 'clientOnly',
|
|
60
112
|
detectionProbabilityRecommendation: Math.abs(scores[0] - scores[1]) < 0.1
|
|
61
113
|
? 1
|
|
62
|
-
: this
|
|
114
|
+
: this.#detectionRatio * Math.max(1, 5 - this.resultCount(label)),
|
|
63
115
|
};
|
|
64
116
|
}
|
|
65
117
|
/**
|
|
66
118
|
* Store the rendering type for a given URL and request label. This updates the underlying prediction model, which may be costly.
|
|
67
119
|
*/
|
|
68
|
-
storeResult(
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
120
|
+
storeResult(requests, renderingType) {
|
|
121
|
+
const state = this.#state.currentValue;
|
|
122
|
+
for (const { url, loadedUrl, label } of Array.isArray(requests) ? requests : [requests]) {
|
|
123
|
+
const resultUrl = new URL(loadedUrl ?? url);
|
|
124
|
+
if (!state.detectionResults.has(renderingType)) {
|
|
125
|
+
state.detectionResults.set(renderingType, new Map());
|
|
126
|
+
}
|
|
127
|
+
if (!state.detectionResults.get(renderingType).has(label)) {
|
|
128
|
+
state.detectionResults.get(renderingType).set(label, []);
|
|
129
|
+
}
|
|
130
|
+
state.detectionResults.get(renderingType).get(label).push(urlComponents(resultUrl));
|
|
75
131
|
}
|
|
76
|
-
this.renderingTypeDetectionResults.get(renderingType).get(label).push(urlComponents(resultUrl));
|
|
77
132
|
this.retrain();
|
|
78
133
|
}
|
|
79
134
|
resultCount(label) {
|
|
80
|
-
return Array.from(this.
|
|
135
|
+
return Array.from(this.#state.currentValue.detectionResults.values())
|
|
81
136
|
.map((results) => results.get(label)?.length ?? 0)
|
|
82
137
|
.reduce((acc, value) => acc + value, 0);
|
|
83
138
|
}
|
|
84
139
|
calculateFeatureVector(url, label) {
|
|
85
140
|
return [
|
|
86
|
-
mean((this.
|
|
87
|
-
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,
|
|
88
143
|
];
|
|
89
144
|
}
|
|
90
145
|
retrain() {
|
|
@@ -93,7 +148,7 @@ export class RenderingTypePredictor {
|
|
|
93
148
|
[1, 0],
|
|
94
149
|
];
|
|
95
150
|
const Y = [0, 1];
|
|
96
|
-
for (const [renderingType, urlsByLabel] of this.
|
|
151
|
+
for (const [renderingType, urlsByLabel] of this.#state.currentValue.detectionResults.entries()) {
|
|
97
152
|
for (const [label, urls] of urlsByLabel) {
|
|
98
153
|
for (const url of urls) {
|
|
99
154
|
X.push(this.calculateFeatureVector(url, label));
|
|
@@ -101,7 +156,6 @@ export class RenderingTypePredictor {
|
|
|
101
156
|
}
|
|
102
157
|
}
|
|
103
158
|
}
|
|
104
|
-
this
|
|
159
|
+
this.#state.currentValue.logreg.train(new Matrix(X), Matrix.columnVector(Y));
|
|
105
160
|
}
|
|
106
161
|
}
|
|
107
|
-
//# sourceMappingURL=rendering-type-prediction.js.map
|