Files
cannaiq/backend/src/utils/stealthBrowser.ts
2025-11-28 19:45:44 -07:00

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;
}
}