262 lines
7.4 KiB
TypeScript
262 lines
7.4 KiB
TypeScript
import { chromium, Browser, BrowserContext, Page } from 'playwright';
|
|
import { chromium as playwrightExtra } from 'playwright-extra';
|
|
import StealthPlugin from 'puppeteer-extra-plugin-stealth';
|
|
|
|
// Add stealth plugin
|
|
playwrightExtra.use(StealthPlugin());
|
|
|
|
interface StealthBrowserOptions {
|
|
proxy?: {
|
|
server: string;
|
|
username?: string;
|
|
password?: string;
|
|
};
|
|
headless?: boolean;
|
|
userAgent?: string;
|
|
}
|
|
|
|
/**
|
|
* Create a stealth browser instance with anti-detection measures
|
|
*/
|
|
export async function createStealthBrowser(options: StealthBrowserOptions = {}): Promise<Browser> {
|
|
const launchOptions: any = {
|
|
headless: options.headless !== false,
|
|
args: [
|
|
'--disable-blink-features=AutomationControlled',
|
|
'--disable-features=IsolateOrigins,site-per-process',
|
|
'--disable-web-security',
|
|
'--disable-features=VizDisplayCompositor',
|
|
'--no-sandbox',
|
|
'--disable-setuid-sandbox',
|
|
'--disable-dev-shm-usage',
|
|
'--disable-accelerated-2d-canvas',
|
|
'--no-first-run',
|
|
'--no-zygote',
|
|
'--disable-gpu',
|
|
],
|
|
};
|
|
|
|
if (options.proxy) {
|
|
launchOptions.proxy = options.proxy;
|
|
}
|
|
|
|
const browser = await playwrightExtra.launch(launchOptions);
|
|
return browser as Browser;
|
|
}
|
|
|
|
/**
|
|
* Create a stealth context with realistic browser fingerprint
|
|
*/
|
|
export async function createStealthContext(
|
|
browser: Browser,
|
|
options: { userAgent?: string; state?: string } = {}
|
|
): Promise<BrowserContext> {
|
|
const userAgent =
|
|
options.userAgent ||
|
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
|
|
|
|
const context = await browser.newContext({
|
|
userAgent,
|
|
viewport: { width: 1920, height: 1080 },
|
|
locale: 'en-US',
|
|
timezoneId: 'America/Phoenix',
|
|
permissions: ['geolocation'],
|
|
geolocation: { latitude: 33.4484, longitude: -112.074 }, // Phoenix, AZ
|
|
colorScheme: 'light',
|
|
deviceScaleFactor: 1,
|
|
hasTouch: false,
|
|
isMobile: false,
|
|
javaScriptEnabled: true,
|
|
extraHTTPHeaders: {
|
|
'Accept-Language': 'en-US,en;q=0.9',
|
|
'Accept-Encoding': 'gzip, deflate, br',
|
|
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',
|
|
'Upgrade-Insecure-Requests': '1',
|
|
},
|
|
});
|
|
|
|
// Set age verification cookies for Dutchie
|
|
await context.addCookies([
|
|
{
|
|
name: 'age_verified',
|
|
value: 'true',
|
|
domain: '.dutchie.com',
|
|
path: '/',
|
|
expires: Math.floor(Date.now() / 1000) + 86400 * 30, // 30 days
|
|
},
|
|
{
|
|
name: 'initial_location',
|
|
value: JSON.stringify({ state: options.state || 'Arizona' }),
|
|
domain: '.dutchie.com',
|
|
path: '/',
|
|
expires: Math.floor(Date.now() / 1000) + 86400 * 30,
|
|
},
|
|
]);
|
|
|
|
return context;
|
|
}
|
|
|
|
/**
|
|
* Random delay between min and max milliseconds
|
|
*/
|
|
export function randomDelay(min: number, max: number): Promise<void> {
|
|
const delay = Math.floor(Math.random() * (max - min + 1)) + min;
|
|
return new Promise((resolve) => setTimeout(resolve, delay));
|
|
}
|
|
|
|
/**
|
|
* Simulate human-like mouse movement
|
|
*/
|
|
export async function humanMouseMove(page: Page, x: number, y: number): Promise<void> {
|
|
const steps = 20;
|
|
const currentPos = await page.evaluate(() => ({ x: 0, y: 0 }));
|
|
|
|
for (let i = 0; i <= steps; i++) {
|
|
const progress = i / steps;
|
|
const easeProgress = easeInOutQuad(progress);
|
|
|
|
const nextX = currentPos.x + (x - currentPos.x) * easeProgress;
|
|
const nextY = currentPos.y + (y - currentPos.y) * easeProgress;
|
|
|
|
await page.mouse.move(nextX, nextY);
|
|
await randomDelay(5, 15);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Easing function for smooth mouse movement
|
|
*/
|
|
function easeInOutQuad(t: number): number {
|
|
return t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t;
|
|
}
|
|
|
|
/**
|
|
* Simulate human-like scrolling
|
|
*/
|
|
export async function humanScroll(page: Page, scrollAmount: number = 500): Promise<void> {
|
|
const scrollSteps = 10;
|
|
const stepSize = scrollAmount / scrollSteps;
|
|
|
|
for (let i = 0; i < scrollSteps; i++) {
|
|
await page.mouse.wheel(0, stepSize);
|
|
await randomDelay(50, 150);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Simulate human-like typing
|
|
*/
|
|
export async function humanType(page: Page, selector: string, text: string): Promise<void> {
|
|
await page.click(selector);
|
|
await randomDelay(100, 300);
|
|
|
|
for (const char of text) {
|
|
await page.keyboard.type(char);
|
|
await randomDelay(50, 150);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Random realistic behavior before interacting with page
|
|
*/
|
|
export async function simulateHumanBehavior(page: Page): Promise<void> {
|
|
// Random small mouse movements
|
|
for (let i = 0; i < 3; i++) {
|
|
const x = Math.random() * 500 + 100;
|
|
const y = Math.random() * 300 + 100;
|
|
await humanMouseMove(page, x, y);
|
|
await randomDelay(200, 500);
|
|
}
|
|
|
|
// Small scroll
|
|
await humanScroll(page, 100);
|
|
await randomDelay(300, 700);
|
|
}
|
|
|
|
/**
|
|
* Wait for page to be fully loaded with human-like delay
|
|
*/
|
|
export async function waitForPageLoad(page: Page, timeout: number = 60000): Promise<void> {
|
|
try {
|
|
await page.waitForLoadState('networkidle', { timeout });
|
|
await randomDelay(500, 1500); // Random delay after load
|
|
} catch (error) {
|
|
// If networkidle times out, try domcontentloaded as fallback
|
|
console.log('⚠️ networkidle timeout, waiting for domcontentloaded...');
|
|
await page.waitForLoadState('domcontentloaded', { timeout: 30000 });
|
|
await randomDelay(1000, 2000);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if we're on a Cloudflare challenge page
|
|
*/
|
|
export async function isCloudflareChallenge(page: Page): Promise<boolean> {
|
|
const title = await page.title();
|
|
const content = await page.content();
|
|
|
|
return (
|
|
title.includes('Cloudflare') ||
|
|
title.includes('Just a moment') ||
|
|
title.includes('Attention Required') ||
|
|
content.includes('challenge-platform') ||
|
|
content.includes('cf-challenge') ||
|
|
content.includes('Checking your browser')
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Wait for Cloudflare challenge to complete
|
|
*/
|
|
export async function waitForCloudflareChallenge(page: Page, maxWaitMs: number = 60000): Promise<boolean> {
|
|
const startTime = Date.now();
|
|
let attempts = 0;
|
|
|
|
while (Date.now() - startTime < maxWaitMs) {
|
|
attempts++;
|
|
|
|
if (!(await isCloudflareChallenge(page))) {
|
|
console.log(`✅ Cloudflare challenge passed after ${attempts} attempts (${Math.floor((Date.now() - startTime) / 1000)}s)`);
|
|
return true;
|
|
}
|
|
|
|
const remaining = Math.floor((maxWaitMs - (Date.now() - startTime)) / 1000);
|
|
console.log(`⏳ Waiting for Cloudflare challenge... (attempt ${attempts}, ${remaining}s remaining)`);
|
|
|
|
// Random delay between checks
|
|
await randomDelay(2000, 3000);
|
|
}
|
|
|
|
console.log('❌ Cloudflare challenge timeout - may need residential proxy or manual intervention');
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Save session cookies to file
|
|
*/
|
|
export async function saveCookies(context: BrowserContext, filepath: string): Promise<void> {
|
|
const cookies = await context.cookies();
|
|
const fs = await import('fs/promises');
|
|
await fs.writeFile(filepath, JSON.stringify(cookies, null, 2));
|
|
}
|
|
|
|
/**
|
|
* Load session cookies from file
|
|
*/
|
|
export async function loadCookies(context: BrowserContext, filepath: string): Promise<boolean> {
|
|
try {
|
|
const fs = await import('fs/promises');
|
|
const cookiesString = await fs.readFile(filepath, 'utf-8');
|
|
const cookies = JSON.parse(cookiesString);
|
|
await context.addCookies(cookies);
|
|
return true;
|
|
} catch (error) {
|
|
return false;
|
|
}
|
|
}
|