474 lines
14 KiB
JavaScript
474 lines
14 KiB
JavaScript
/**
|
|
* Human-like behavior utilities for Playwright to avoid bot detection
|
|
* Includes realistic mouse movements, scrolling, and timing variations
|
|
*/
|
|
|
|
/**
|
|
* Generate a random number between min and max (inclusive)
|
|
*/
|
|
function randomInt(min, max) {
|
|
return Math.floor(Math.random() * (max - min + 1)) + min;
|
|
}
|
|
|
|
/**
|
|
* Generate a random float between min and max
|
|
*/
|
|
function randomFloat(min, max) {
|
|
return Math.random() * (max - min) + min;
|
|
}
|
|
|
|
/**
|
|
* Sleep for a random duration within a range
|
|
*/
|
|
export async function randomDelay(minMs = 100, maxMs = 500) {
|
|
const delay = randomInt(minMs, maxMs);
|
|
await new Promise(resolve => setTimeout(resolve, delay));
|
|
}
|
|
|
|
/**
|
|
* Generate bezier curve points for smooth mouse movement
|
|
* Uses cubic bezier with random control points for natural curves
|
|
*/
|
|
function generateBezierPath(start, end, steps = 25) {
|
|
const points = [];
|
|
|
|
// Add some randomness to control points
|
|
const cp1x = start.x + (end.x - start.x) * randomFloat(0.25, 0.4);
|
|
const cp1y = start.y + (end.y - start.y) * randomFloat(-0.2, 0.2);
|
|
const cp2x = start.x + (end.x - start.x) * randomFloat(0.6, 0.75);
|
|
const cp2y = start.y + (end.y - start.y) * randomFloat(-0.2, 0.2);
|
|
|
|
for (let i = 0; i <= steps; i++) {
|
|
const t = i / steps;
|
|
const t2 = t * t;
|
|
const t3 = t2 * t;
|
|
const mt = 1 - t;
|
|
const mt2 = mt * mt;
|
|
const mt3 = mt2 * mt;
|
|
|
|
const x = mt3 * start.x +
|
|
3 * mt2 * t * cp1x +
|
|
3 * mt * t2 * cp2x +
|
|
t3 * end.x;
|
|
const y = mt3 * start.y +
|
|
3 * mt2 * t * cp1y +
|
|
3 * mt * t2 * cp2y +
|
|
t3 * end.y;
|
|
|
|
points.push({ x: Math.round(x), y: Math.round(y) });
|
|
}
|
|
|
|
return points;
|
|
}
|
|
|
|
/**
|
|
* Move mouse in a realistic, smooth path with occasional overshooting
|
|
* @param {Page} page - Playwright page object
|
|
* @param {Object} target - Target coordinates {x, y}
|
|
* @param {Object} options - Movement options
|
|
*/
|
|
export async function humanMouseMove(page, target, options = {}) {
|
|
const {
|
|
overshootChance = 0.15, // 15% chance to overshoot
|
|
overshootDistance = 20, // pixels to overshoot by
|
|
steps = 25, // number of steps in the path
|
|
stepDelay = 10 // ms between steps
|
|
} = options;
|
|
|
|
// Get current mouse position (or start from a random position)
|
|
const viewport = page.viewportSize();
|
|
const start = {
|
|
x: randomInt(viewport.width * 0.3, viewport.width * 0.7),
|
|
y: randomInt(viewport.height * 0.3, viewport.height * 0.7)
|
|
};
|
|
|
|
// Decide if we should overshoot
|
|
const shouldOvershoot = Math.random() < overshootChance;
|
|
|
|
let finalTarget = target;
|
|
if (shouldOvershoot) {
|
|
// Calculate overshoot position (slightly past the target)
|
|
const angle = Math.atan2(target.y - start.y, target.x - start.x);
|
|
const overshoot = {
|
|
x: target.x + Math.cos(angle) * randomInt(5, overshootDistance),
|
|
y: target.y + Math.sin(angle) * randomInt(5, overshootDistance)
|
|
};
|
|
|
|
// Move to overshoot position first
|
|
const overshootPath = generateBezierPath(start, overshoot, steps);
|
|
for (const point of overshootPath) {
|
|
await page.mouse.move(point.x, point.y);
|
|
await new Promise(resolve => setTimeout(resolve, stepDelay));
|
|
}
|
|
|
|
// Then correct back to target
|
|
const correctionPath = generateBezierPath(overshoot, target, Math.floor(steps * 0.3));
|
|
for (const point of correctionPath) {
|
|
await page.mouse.move(point.x, point.y);
|
|
await new Promise(resolve => setTimeout(resolve, stepDelay));
|
|
}
|
|
} else {
|
|
// Normal smooth movement
|
|
const path = generateBezierPath(start, target, steps);
|
|
for (const point of path) {
|
|
await page.mouse.move(point.x, point.y);
|
|
await new Promise(resolve => setTimeout(resolve, stepDelay));
|
|
}
|
|
}
|
|
|
|
// Add a tiny random pause after reaching target
|
|
await randomDelay(50, 150);
|
|
}
|
|
|
|
/**
|
|
* Perform random mouse movements to simulate human reading/scanning
|
|
*/
|
|
export async function randomMouseMovements(page, count = 3) {
|
|
const viewport = page.viewportSize();
|
|
|
|
for (let i = 0; i < count; i++) {
|
|
const target = {
|
|
x: randomInt(100, viewport.width - 100),
|
|
y: randomInt(100, viewport.height - 100)
|
|
};
|
|
|
|
await humanMouseMove(page, target, {
|
|
overshootChance: 0.1,
|
|
steps: randomInt(15, 30)
|
|
});
|
|
|
|
await randomDelay(200, 800);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Scroll page in a human-like manner with random intervals and amounts
|
|
* @param {Page} page - Playwright page object
|
|
* @param {Object} options - Scrolling options
|
|
*/
|
|
export async function humanScroll(page, options = {}) {
|
|
const {
|
|
direction = 'down', // 'down' or 'up'
|
|
scrollCount = 3, // number of scroll actions
|
|
minScroll = 100, // minimum pixels per scroll
|
|
maxScroll = 400, // maximum pixels per scroll
|
|
minDelay = 500, // minimum delay between scrolls
|
|
maxDelay = 2000, // maximum delay between scrolls
|
|
randomDirection = false // occasionally scroll in opposite direction
|
|
} = options;
|
|
|
|
for (let i = 0; i < scrollCount; i++) {
|
|
// Determine scroll direction
|
|
let scrollDir = direction;
|
|
if (randomDirection && Math.random() < 0.15) {
|
|
scrollDir = direction === 'down' ? 'up' : 'down';
|
|
}
|
|
|
|
// Random scroll amount
|
|
const scrollAmount = randomInt(minScroll, maxScroll);
|
|
const scrollValue = scrollDir === 'down' ? scrollAmount : -scrollAmount;
|
|
|
|
// Perform scroll in small increments for smoothness
|
|
const increments = randomInt(5, 12);
|
|
const incrementValue = scrollValue / increments;
|
|
|
|
for (let j = 0; j < increments; j++) {
|
|
await page.evaluate((delta) => {
|
|
window.scrollBy(0, delta);
|
|
}, incrementValue);
|
|
await new Promise(resolve => setTimeout(resolve, randomInt(20, 50)));
|
|
}
|
|
|
|
// Random pause between scrolls (simulating reading)
|
|
await randomDelay(minDelay, maxDelay);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Scroll to a specific element in a human-like way
|
|
*/
|
|
export async function scrollToElement(page, selector, options = {}) {
|
|
const element = await page.locator(selector).first();
|
|
|
|
// Get element position
|
|
const box = await element.boundingBox();
|
|
if (!box) {
|
|
console.warn(`Element ${selector} not found or not visible`);
|
|
return;
|
|
}
|
|
|
|
// Get current scroll position
|
|
const currentScroll = await page.evaluate(() => window.scrollY);
|
|
const viewportHeight = page.viewportSize().height;
|
|
|
|
// Calculate target scroll position (element near middle of viewport)
|
|
const targetScroll = box.y + currentScroll - (viewportHeight / 2);
|
|
const scrollDistance = targetScroll - currentScroll;
|
|
|
|
// Scroll in chunks
|
|
const chunks = Math.max(3, Math.abs(Math.floor(scrollDistance / 200)));
|
|
const chunkSize = scrollDistance / chunks;
|
|
|
|
for (let i = 0; i < chunks; i++) {
|
|
await page.evaluate((delta) => {
|
|
window.scrollBy(0, delta);
|
|
}, chunkSize);
|
|
await randomDelay(50, 150);
|
|
}
|
|
|
|
await randomDelay(300, 700);
|
|
}
|
|
|
|
/**
|
|
* Click an element with human-like behavior
|
|
*/
|
|
export async function humanClick(page, selector, options = {}) {
|
|
const {
|
|
moveToElement = true,
|
|
doubleClickChance = 0.02 // 2% chance of accidental double-click
|
|
} = options;
|
|
|
|
const element = await page.locator(selector).first();
|
|
const box = await element.boundingBox();
|
|
|
|
if (!box) {
|
|
throw new Error(`Element ${selector} not found or not visible`);
|
|
}
|
|
|
|
// Calculate click position (slightly random within element bounds)
|
|
const target = {
|
|
x: box.x + randomInt(box.width * 0.3, box.width * 0.7),
|
|
y: box.y + randomInt(box.height * 0.3, box.height * 0.7)
|
|
};
|
|
|
|
if (moveToElement) {
|
|
await humanMouseMove(page, target);
|
|
}
|
|
|
|
// Random pre-click pause
|
|
await randomDelay(100, 300);
|
|
|
|
// Click
|
|
await page.mouse.click(target.x, target.y);
|
|
|
|
// Occasional accidental double-click
|
|
if (Math.random() < doubleClickChance) {
|
|
await randomDelay(50, 150);
|
|
await page.mouse.click(target.x, target.y);
|
|
}
|
|
|
|
await randomDelay(200, 500);
|
|
}
|
|
|
|
/**
|
|
* Type text with human-like timing variations
|
|
*/
|
|
export async function humanType(page, selector, text, options = {}) {
|
|
const {
|
|
minDelay = 50,
|
|
maxDelay = 150,
|
|
mistakes = 0.02 // 2% chance of typo
|
|
} = options;
|
|
|
|
await page.click(selector);
|
|
await randomDelay(200, 400);
|
|
|
|
const chars = text.split('');
|
|
let typedText = '';
|
|
|
|
for (let i = 0; i < chars.length; i++) {
|
|
const char = chars[i];
|
|
|
|
// Occasional typo
|
|
if (Math.random() < mistakes && i < chars.length - 1) {
|
|
// Type wrong char
|
|
const wrongChar = String.fromCharCode(char.charCodeAt(0) + randomInt(-2, 2));
|
|
await page.keyboard.type(wrongChar);
|
|
await randomDelay(minDelay, maxDelay);
|
|
|
|
// Pause (realize mistake)
|
|
await randomDelay(200, 500);
|
|
|
|
// Backspace
|
|
await page.keyboard.press('Backspace');
|
|
await randomDelay(100, 200);
|
|
}
|
|
|
|
// Type correct char
|
|
await page.keyboard.type(char);
|
|
|
|
// Variable delay based on character type
|
|
let delay;
|
|
if (char === ' ') {
|
|
delay = randomInt(maxDelay * 1.5, maxDelay * 2);
|
|
} else if (char.match(/[.!?,]/)) {
|
|
delay = randomInt(maxDelay * 1.2, maxDelay * 2);
|
|
} else {
|
|
delay = randomInt(minDelay, maxDelay);
|
|
}
|
|
|
|
await new Promise(resolve => setTimeout(resolve, delay));
|
|
}
|
|
|
|
await randomDelay(300, 600);
|
|
}
|
|
|
|
/**
|
|
* Wait for page load with random human-like observation time
|
|
*/
|
|
export async function humanWaitForLoad(page, options = {}) {
|
|
const {
|
|
minWait = 1000,
|
|
maxWait = 3000
|
|
} = options;
|
|
|
|
// Wait for network to be idle
|
|
await page.waitForLoadState('networkidle', { timeout: 30000 });
|
|
|
|
// Additional random observation time (simulating reading/scanning)
|
|
await randomDelay(minWait, maxWait);
|
|
}
|
|
|
|
/**
|
|
* Simulate reading behavior - random scrolls and mouse movements
|
|
*/
|
|
export async function simulateReading(page, duration = 5000) {
|
|
const endTime = Date.now() + duration;
|
|
|
|
while (Date.now() < endTime) {
|
|
const action = Math.random();
|
|
|
|
if (action < 0.4) {
|
|
// Scroll a bit
|
|
await humanScroll(page, {
|
|
scrollCount: 1,
|
|
minScroll: 50,
|
|
maxScroll: 200,
|
|
minDelay: 800,
|
|
maxDelay: 1500
|
|
});
|
|
} else if (action < 0.7) {
|
|
// Move mouse randomly
|
|
await randomMouseMovements(page, 1);
|
|
} else {
|
|
// Just wait (reading)
|
|
await randomDelay(1000, 2000);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Configure browser context with realistic human-like settings
|
|
*/
|
|
export async function getHumanizedContext(browser, options = {}) {
|
|
const {
|
|
locale = 'en-CA',
|
|
timezone = 'America/Toronto',
|
|
viewport = null
|
|
} = options;
|
|
|
|
// Random but realistic viewport sizes
|
|
const viewports = [
|
|
{ width: 1920, height: 1080 },
|
|
{ width: 1366, height: 768 },
|
|
{ width: 1536, height: 864 },
|
|
{ width: 1440, height: 900 },
|
|
{ width: 2560, height: 1440 }
|
|
];
|
|
|
|
const selectedViewport = viewport || viewports[randomInt(0, viewports.length - 1)];
|
|
|
|
// Realistic user agents (updated to current versions)
|
|
const userAgents = [
|
|
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
|
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
|
|
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36',
|
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36',
|
|
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'
|
|
];
|
|
|
|
const context = await browser.newContext({
|
|
viewport: selectedViewport,
|
|
userAgent: userAgents[randomInt(0, userAgents.length - 1)],
|
|
locale,
|
|
timezoneId: timezone,
|
|
permissions: [],
|
|
geolocation: { latitude: 43.6532, longitude: -79.3832 }, // Toronto
|
|
colorScheme: 'light', // Always light for consistency
|
|
deviceScaleFactor: 1, // Standard scaling
|
|
hasTouch: false,
|
|
isMobile: false,
|
|
javaScriptEnabled: true,
|
|
// Add realistic headers
|
|
extraHTTPHeaders: {
|
|
'Accept-Language': 'en-CA,en-US;q=0.9,en;q=0.8',
|
|
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8',
|
|
'Sec-Fetch-Site': 'none',
|
|
'Sec-Fetch-Mode': 'navigate',
|
|
'Sec-Fetch-User': '?1',
|
|
'Sec-Fetch-Dest': 'document',
|
|
'Sec-Ch-Ua': '"Google Chrome";v="131", "Chromium";v="131", "Not_A Brand";v="24"',
|
|
'Sec-Ch-Ua-Mobile': '?0',
|
|
'Sec-Ch-Ua-Platform': '"macOS"',
|
|
'Upgrade-Insecure-Requests': '1'
|
|
}
|
|
});
|
|
|
|
// Inject additional fingerprint randomization and anti-detection
|
|
await context.addInitScript(() => {
|
|
// Remove webdriver property
|
|
Object.defineProperty(navigator, 'webdriver', {
|
|
get: () => undefined
|
|
});
|
|
|
|
// Override permissions
|
|
const originalQuery = window.navigator.permissions.query;
|
|
window.navigator.permissions.query = (parameters) => (
|
|
parameters.name === 'notifications' ?
|
|
Promise.resolve({ state: Notification.permission }) :
|
|
originalQuery(parameters)
|
|
);
|
|
|
|
// Add chrome property
|
|
window.chrome = {
|
|
runtime: {}
|
|
};
|
|
|
|
// Override plugins
|
|
Object.defineProperty(navigator, 'plugins', {
|
|
get: () => [
|
|
{
|
|
0: { type: 'application/x-google-chrome-pdf', suffixes: 'pdf', description: 'Portable Document Format' },
|
|
description: 'Portable Document Format',
|
|
filename: 'internal-pdf-viewer',
|
|
length: 1,
|
|
name: 'Chrome PDF Plugin'
|
|
},
|
|
{
|
|
0: { type: 'application/pdf', suffixes: 'pdf', description: '' },
|
|
description: '',
|
|
filename: 'mhjfbmdgcfjbbpaeojofohoefgiehjai',
|
|
length: 1,
|
|
name: 'Chrome PDF Viewer'
|
|
}
|
|
]
|
|
});
|
|
});
|
|
|
|
return context;
|
|
}
|
|
|
|
export default {
|
|
randomDelay,
|
|
humanMouseMove,
|
|
randomMouseMovements,
|
|
humanScroll,
|
|
scrollToElement,
|
|
humanClick,
|
|
humanType,
|
|
humanWaitForLoad,
|
|
simulateReading,
|
|
getHumanizedContext
|
|
};
|
|
|