@crawlee/playwright 4.0.0-beta.16 → 4.0.0-beta.161
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 +116 -63
- package/internals/adaptive-playwright-crawler.js +324 -266
- 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 +110 -121
- package/internals/utils/rendering-type-prediction.d.ts +25 -11
- package/internals/utils/rendering-type-prediction.js +81 -27
- package/package.json +14 -18
- 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,25 @@ 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
|
+
const { load } = await import('cheerio');
|
|
409
|
+
const $ = load(pageContent);
|
|
399
410
|
if (page.frames().length > 1 && !ignoreIframes) {
|
|
400
411
|
const frames = await page.$$('iframe');
|
|
401
|
-
|
|
412
|
+
const cheerioIframes = $('iframe').toArray();
|
|
413
|
+
if (frames.length !== cheerioIframes.length) {
|
|
414
|
+
serviceLocator
|
|
415
|
+
.getLogger()
|
|
416
|
+
.warning(`parseWithCheerio: iframe count mismatch between live DOM (${frames.length}) and page snapshot (${cheerioIframes.length}). Some iframes may not be expanded.`);
|
|
417
|
+
}
|
|
418
|
+
await Promise.all(frames.map(async (frame, index) => {
|
|
402
419
|
try {
|
|
403
420
|
const iframe = await frame.contentFrame();
|
|
404
|
-
if (iframe) {
|
|
421
|
+
if (iframe && cheerioIframes[index]) {
|
|
405
422
|
const getIframeHTML = async () => {
|
|
406
423
|
try {
|
|
407
424
|
return iframe.locator('body').first().innerHTML();
|
|
@@ -411,53 +428,15 @@ export async function parseWithCheerio(page, ignoreShadowRoots = false, ignoreIf
|
|
|
411
428
|
}
|
|
412
429
|
};
|
|
413
430
|
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);
|
|
431
|
+
$(cheerioIframes[index]).replaceWith(`<div class="crawlee-iframe-replacement">${contents}</div>`);
|
|
420
432
|
}
|
|
421
433
|
}
|
|
422
434
|
catch (error) {
|
|
423
|
-
|
|
435
|
+
getLog().warning(`Failed to extract iframe content: ${error}`);
|
|
424
436
|
}
|
|
425
437
|
}));
|
|
426
438
|
}
|
|
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
|
-
}
|
|
439
|
+
return $;
|
|
461
440
|
}
|
|
462
441
|
/**
|
|
463
442
|
* This helper tries to solve the Cloudflare challenge automatically by clicking on the checkbox.
|
|
@@ -466,30 +445,24 @@ export async function closeCookieModals(page) {
|
|
|
466
445
|
* result in a SessionError which will be automatically retried, so only successful requests will get
|
|
467
446
|
* into the `requestHandler`.
|
|
468
447
|
*
|
|
448
|
+
* On a successfully solved challenge the page is reloaded and the new {@link Response} is returned, so
|
|
449
|
+
* it can be propagated back to the crawling context via a hook return value (see
|
|
450
|
+
* {@link handleCloudflareChallengeHook}).
|
|
451
|
+
*
|
|
469
452
|
* Works best with camoufox.
|
|
470
453
|
*
|
|
471
454
|
* **Example usage**
|
|
472
455
|
* ```ts
|
|
473
456
|
* postNavigationHooks: [
|
|
474
|
-
* async ({ handleCloudflareChallenge })
|
|
475
|
-
* await handleCloudflareChallenge();
|
|
476
|
-
* },
|
|
457
|
+
* async (context) => ({ response: await context.handleCloudflareChallenge() }),
|
|
477
458
|
* ],
|
|
478
459
|
* ```
|
|
479
460
|
*
|
|
480
461
|
* @param page Playwright [`Page`](https://playwright.dev/docs/api/class-page) object
|
|
481
462
|
* @param url current URL for request identification, only used for logging
|
|
482
|
-
* @param [session] current session object
|
|
483
463
|
* @param [options]
|
|
484
464
|
*/
|
|
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
|
-
}
|
|
465
|
+
async function handleCloudflareChallenge(page, url, options = {}) {
|
|
493
466
|
options.isBlockedCallback ??= async () => {
|
|
494
467
|
const isBlocked = await page.evaluate(() => {
|
|
495
468
|
return document.querySelector('h1')?.textContent?.trim().includes('Sorry, you have been blocked');
|
|
@@ -498,7 +471,9 @@ async function handleCloudflareChallenge(page, url, session, options = {}) {
|
|
|
498
471
|
};
|
|
499
472
|
options.isChallengeCallback ??= async () => {
|
|
500
473
|
return await page.evaluate(async () => {
|
|
501
|
-
|
|
474
|
+
// Cloudflare keeps reshuffling the wrapper elements between `.footer-inner` and `.ray-id`,
|
|
475
|
+
// so only the stable outer classes are matched.
|
|
476
|
+
return !!document.querySelector('.footer .footer-inner .ray-id');
|
|
502
477
|
});
|
|
503
478
|
};
|
|
504
479
|
const retryBlocked = async () => {
|
|
@@ -513,31 +488,41 @@ async function handleCloudflareChallenge(page, url, session, options = {}) {
|
|
|
513
488
|
};
|
|
514
489
|
if (!(await isChallenge())) {
|
|
515
490
|
await retryBlocked();
|
|
516
|
-
return;
|
|
491
|
+
return undefined;
|
|
517
492
|
}
|
|
518
493
|
const logLevel = options.verbose ? 'info' : 'debug';
|
|
519
|
-
|
|
494
|
+
getLog()[logLevel](`Detected Cloudflare challenge at ${url}, trying to solve it. This can take up to ${10 + (options.sleepSecs ?? 10)} seconds.`);
|
|
520
495
|
const bb = await page
|
|
521
496
|
.evaluate(() => {
|
|
522
|
-
|
|
497
|
+
// Prefer the actual challenge widget (the box holding the Turnstile checkbox input);
|
|
498
|
+
// fall back to the first content div for older challenge layouts.
|
|
499
|
+
const div = document.querySelector('.main-content div:has(input[id^="cf-chl-widget-"])') ??
|
|
500
|
+
document.querySelector('.main-content div');
|
|
523
501
|
return div?.getBoundingClientRect();
|
|
524
502
|
})
|
|
525
503
|
.catch(() => undefined);
|
|
526
504
|
if (!bb) {
|
|
527
|
-
return;
|
|
505
|
+
return undefined;
|
|
528
506
|
}
|
|
529
507
|
const randomOffset = (range) => {
|
|
530
508
|
return Math.round(100 * range * Math.random()) / 100;
|
|
531
509
|
};
|
|
532
|
-
|
|
533
|
-
|
|
510
|
+
let x = bb.x + 30;
|
|
511
|
+
let y = bb.y + 25;
|
|
534
512
|
// try to click the checkbox every second
|
|
535
513
|
for (let i = 0; i < 10; i++) {
|
|
536
|
-
await sleep(1000);
|
|
514
|
+
await sleep((options.preChallengeSleepSecs ?? 1) * 1000);
|
|
537
515
|
// break early if we are no longer on the CF challenge page
|
|
538
516
|
if (!(await isChallenge())) {
|
|
539
517
|
break;
|
|
540
518
|
}
|
|
519
|
+
if (options.clickPositionCallback) {
|
|
520
|
+
const pos = await options.clickPositionCallback(page);
|
|
521
|
+
if (pos) {
|
|
522
|
+
x = pos.x;
|
|
523
|
+
y = pos.y;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
541
526
|
if (options.clickCallback) {
|
|
542
527
|
await options.clickCallback(page, { x, y });
|
|
543
528
|
continue;
|
|
@@ -545,7 +530,10 @@ async function handleCloudflareChallenge(page, url, session, options = {}) {
|
|
|
545
530
|
// we can click on the text too, so X can be a bit larger
|
|
546
531
|
const xRandomized = x + randomOffset(10);
|
|
547
532
|
const yRandomized = y + randomOffset(10);
|
|
548
|
-
|
|
533
|
+
getLog()[logLevel](`Trying to click on the Cloudflare checkbox at ${url}`, {
|
|
534
|
+
x: xRandomized,
|
|
535
|
+
y: yRandomized,
|
|
536
|
+
});
|
|
549
537
|
await page.mouse.click(xRandomized, yRandomized);
|
|
550
538
|
// sometimes the checkbox is lower (could be caused by a lag when rendering the logo)
|
|
551
539
|
await page.mouse.click(xRandomized, yRandomized + 35);
|
|
@@ -555,6 +543,9 @@ async function handleCloudflareChallenge(page, url, session, options = {}) {
|
|
|
555
543
|
throw new SessionError(`Blocked by Cloudflare when processing ${url}`);
|
|
556
544
|
}
|
|
557
545
|
await retryBlocked();
|
|
546
|
+
// Reload to obtain a fresh Response without the challenge interstitial, which the caller can
|
|
547
|
+
// propagate back into the crawling context so downstream status-code checks see the new value.
|
|
548
|
+
return (await page.reload()) ?? undefined;
|
|
558
549
|
}
|
|
559
550
|
export { enqueueLinksByClickingElements };
|
|
560
551
|
/** @internal */
|
|
@@ -568,8 +559,6 @@ export const playwrightUtils = {
|
|
|
568
559
|
infiniteScroll,
|
|
569
560
|
saveSnapshot,
|
|
570
561
|
compileScript,
|
|
571
|
-
closeCookieModals,
|
|
572
562
|
RenderingTypePredictor,
|
|
573
563
|
handleCloudflareChallenge,
|
|
574
564
|
};
|
|
575
|
-
//# sourceMappingURL=playwright-utils.js.map
|
|
@@ -1,26 +1,42 @@
|
|
|
1
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;
|
|
8
6
|
persistenceOptions?: Partial<RecoverableStatePersistenceOptions>;
|
|
9
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;
|
|
22
|
+
}
|
|
10
23
|
/**
|
|
11
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.
|
|
12
25
|
*
|
|
13
26
|
* @experimental
|
|
14
27
|
*/
|
|
15
|
-
export declare class RenderingTypePredictor {
|
|
16
|
-
private
|
|
17
|
-
private detectionRatio;
|
|
18
|
-
private state;
|
|
28
|
+
export declare class RenderingTypePredictor implements IRenderingTypePredictor {
|
|
29
|
+
#private;
|
|
19
30
|
constructor({ detectionRatio, persistenceOptions }: RenderingTypePredictorOptions);
|
|
20
31
|
/**
|
|
21
32
|
* Initialize the predictor by restoring persisted state.
|
|
22
33
|
*/
|
|
23
34
|
initialize(): Promise<void>;
|
|
35
|
+
/**
|
|
36
|
+
* Stop persisting the model, writing it out one last time. `initialize()` reopens the persistence window.
|
|
37
|
+
*/
|
|
38
|
+
teardown(): Promise<void>;
|
|
39
|
+
[Symbol.asyncDispose](): Promise<void>;
|
|
24
40
|
/**
|
|
25
41
|
* Predict the rendering type for a given URL and request label.
|
|
26
42
|
*/
|
|
@@ -31,10 +47,8 @@ export declare class RenderingTypePredictor {
|
|
|
31
47
|
/**
|
|
32
48
|
* Store the rendering type for a given URL and request label. This updates the underlying prediction model, which may be costly.
|
|
33
49
|
*/
|
|
34
|
-
storeResult(
|
|
50
|
+
storeResult(requests: Request | Request[], renderingType: RenderingType): void;
|
|
35
51
|
private resultCount;
|
|
36
|
-
|
|
37
|
-
|
|
52
|
+
private calculateFeatureVector;
|
|
53
|
+
private retrain;
|
|
38
54
|
}
|
|
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
|