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