diff --git a/cloakbrowser/__init__.py b/cloakbrowser/__init__.py index 9f1a961..9ca8fe9 100644 --- a/cloakbrowser/__init__.py +++ b/cloakbrowser/__init__.py @@ -11,7 +11,7 @@ Usage: browser.close() """ -from .browser import launch, launch_async, launch_context +from .browser import launch, launch_async, launch_context, launch_persistent_context, launch_persistent_context_async from .config import CHROMIUM_VERSION, get_default_stealth_args from .download import binary_info, check_for_update, clear_cache, ensure_binary from ._version import __version__ @@ -20,6 +20,8 @@ __all__ = [ "launch", "launch_async", "launch_context", + "launch_persistent_context", + "launch_persistent_context_async", "ensure_binary", "clear_cache", "binary_info", diff --git a/cloakbrowser/browser.py b/cloakbrowser/browser.py index 25a52d0..04519a7 100644 --- a/cloakbrowser/browser.py +++ b/cloakbrowser/browser.py @@ -15,6 +15,7 @@ Usage: from __future__ import annotations import logging +import os from typing import Any, Literal from urllib.parse import unquote, urlparse, urlunparse @@ -159,6 +160,200 @@ async def launch_async( return browser +def launch_persistent_context( + user_data_dir: str | os.PathLike, + headless: bool = True, + proxy: str | None = None, + args: list[str] | None = None, + stealth_args: bool = True, + user_agent: str | None = None, + viewport: dict | None = None, + locale: str | None = None, + timezone_id: str | None = None, + color_scheme: Literal["light", "dark", "no-preference"] | None = None, + geoip: bool = False, + **kwargs: Any, +) -> Any: + """Launch stealth browser with a persistent profile and return a BrowserContext. + + This persists cookies, localStorage, cache, and other browser state across + sessions by storing them in ``user_data_dir``. Also avoids incognito detection + by services like BrowserScan (-10% penalty). + + Args: + user_data_dir: Path to the directory where browser profile data is stored. + Created automatically if it doesn't exist. Reuse the same path across + sessions to restore cookies, localStorage, cached credentials, etc. + headless: Run in headless mode (default True). + proxy: Proxy server URL (e.g. 'http://proxy:8080' or 'socks5://proxy:1080'). + args: Additional Chromium CLI arguments. + stealth_args: Include default stealth fingerprint args (default True). + user_agent: Custom user agent string. + viewport: Viewport size dict, e.g. {"width": 1920, "height": 1080}. + locale: Browser locale, e.g. "en-US". + timezone_id: Timezone, e.g. "America/New_York". + color_scheme: Color scheme preference — 'light', 'dark', or 'no-preference'. + Default: None (uses Chromium default, which is 'light'). + geoip: Auto-detect timezone/locale from proxy IP (default False). + Requires ``pip install cloakbrowser[geoip]``. + **kwargs: Passed directly to playwright.chromium.launch_persistent_context(). + + Returns: + Playwright BrowserContext object backed by a persistent profile. + Call ``.close()`` when done — this also stops the Playwright instance. + + Example: + >>> from cloakbrowser import launch_persistent_context + >>> ctx = launch_persistent_context("./my-profile", headless=False) + >>> page = ctx.new_page() + >>> page.goto("https://protected-site.com") + >>> ctx.close() # Profile is saved; re-use path next run to restore state. + """ + from patchright.sync_api import sync_playwright + + binary_path = ensure_binary() + timezone_id, locale = _maybe_resolve_geoip(geoip, proxy, timezone_id, locale) + chrome_args = _build_args(stealth_args, args, timezone=timezone_id, locale=locale) + + logger.debug( + "Launching persistent stealth Chromium (headless=%s, user_data_dir=%s)", + headless, + user_data_dir, + ) + + context_kwargs: dict[str, Any] = {} + if user_agent: + context_kwargs["user_agent"] = user_agent + context_kwargs["viewport"] = viewport or DEFAULT_VIEWPORT + if locale: + context_kwargs["locale"] = locale + if timezone_id: + context_kwargs["timezone_id"] = timezone_id + if color_scheme: + context_kwargs["color_scheme"] = color_scheme + context_kwargs.update(kwargs) + + pw = sync_playwright().start() + context = pw.chromium.launch_persistent_context( + user_data_dir=os.fspath(user_data_dir), + executable_path=binary_path, + headless=headless, + args=chrome_args, + ignore_default_args=["--enable-automation"], + **_build_proxy_kwargs(proxy), + **context_kwargs, + ) + + # Patch close() to also stop the Playwright instance + _original_close = context.close + + def _close_with_cleanup() -> None: + _original_close() + pw.stop() + + context.close = _close_with_cleanup + + return context + + +async def launch_persistent_context_async( + user_data_dir: str | os.PathLike, + headless: bool = True, + proxy: str | None = None, + args: list[str] | None = None, + stealth_args: bool = True, + user_agent: str | None = None, + viewport: dict | None = None, + locale: str | None = None, + timezone_id: str | None = None, + color_scheme: Literal["light", "dark", "no-preference"] | None = None, + geoip: bool = False, + **kwargs: Any, +) -> Any: + """Async version of launch_persistent_context(). + + Launch stealth browser with a persistent profile and return a BrowserContext. + This persists cookies, localStorage, cache, and other browser state across + sessions by storing them in ``user_data_dir``. + + Args: + user_data_dir: Path to the directory where browser profile data is stored. + Created automatically if it doesn't exist. + headless: Run in headless mode (default True). + proxy: Proxy server URL (e.g. 'http://proxy:8080' or 'socks5://proxy:1080'). + args: Additional Chromium CLI arguments. + stealth_args: Include default stealth fingerprint args (default True). + user_agent: Custom user agent string. + viewport: Viewport size dict, e.g. {"width": 1920, "height": 1080}. + locale: Browser locale, e.g. "en-US". + timezone_id: Timezone, e.g. "America/New_York". + color_scheme: Color scheme preference — 'light', 'dark', or 'no-preference'. + geoip: Auto-detect timezone/locale from proxy IP (default False). + **kwargs: Passed directly to playwright.chromium.launch_persistent_context(). + + Returns: + Playwright BrowserContext object backed by a persistent profile (async API). + Call ``await .close()`` when done. + + Example: + >>> import asyncio + >>> from cloakbrowser import launch_persistent_context_async + >>> + >>> async def main(): + ... ctx = await launch_persistent_context_async("./my-profile", headless=False) + ... page = await ctx.new_page() + ... await page.goto("https://protected-site.com") + ... await ctx.close() + >>> + >>> asyncio.run(main()) + """ + from patchright.async_api import async_playwright + + binary_path = ensure_binary() + timezone_id, locale = _maybe_resolve_geoip(geoip, proxy, timezone_id, locale) + chrome_args = _build_args(stealth_args, args, timezone=timezone_id, locale=locale) + + logger.debug( + "Launching persistent stealth Chromium async (headless=%s, user_data_dir=%s)", + headless, + user_data_dir, + ) + + context_kwargs: dict[str, Any] = {} + if user_agent: + context_kwargs["user_agent"] = user_agent + context_kwargs["viewport"] = viewport or DEFAULT_VIEWPORT + if locale: + context_kwargs["locale"] = locale + if timezone_id: + context_kwargs["timezone_id"] = timezone_id + if color_scheme: + context_kwargs["color_scheme"] = color_scheme + context_kwargs.update(kwargs) + + pw = await async_playwright().start() + context = await pw.chromium.launch_persistent_context( + user_data_dir=os.fspath(user_data_dir), + executable_path=binary_path, + headless=headless, + args=chrome_args, + ignore_default_args=["--enable-automation"], + **_build_proxy_kwargs(proxy), + **context_kwargs, + ) + + # Patch close() to also stop the Playwright instance + _original_close = context.close + + async def _close_with_cleanup() -> None: + await _original_close() + await pw.stop() + + context.close = _close_with_cleanup + + return context + + def launch_context( headless: bool = True, proxy: str | None = None, diff --git a/js/src/index.ts b/js/src/index.ts index 5a62ca2..52609b6 100644 --- a/js/src/index.ts +++ b/js/src/index.ts @@ -16,7 +16,7 @@ */ // Launch functions (Playwright API) -export { launch, launchContext } from "./playwright.js"; +export { launch, launchContext, launchPersistentContext } from "./playwright.js"; // Binary management export { ensureBinary, clearCache, binaryInfo, checkForUpdate } from "./download.js"; @@ -25,4 +25,4 @@ export { ensureBinary, clearCache, binaryInfo, checkForUpdate } from "./download export { CHROMIUM_VERSION, getDefaultStealthArgs } from "./config.js"; // Types -export type { LaunchOptions, LaunchContextOptions, BinaryInfo } from "./types.js"; +export type { LaunchOptions, LaunchContextOptions, LaunchPersistentContextOptions, BinaryInfo } from "./types.js"; diff --git a/js/src/playwright.ts b/js/src/playwright.ts index 465e31c..6087a31 100644 --- a/js/src/playwright.ts +++ b/js/src/playwright.ts @@ -4,7 +4,7 @@ */ import type { Browser, BrowserContext } from "playwright-core"; -import type { LaunchOptions, LaunchContextOptions } from "./types.js"; +import type { LaunchOptions, LaunchContextOptions, LaunchPersistentContextOptions } from "./types.js"; import { DEFAULT_VIEWPORT, getDefaultStealthArgs } from "./config.js"; import { ensureBinary } from "./download.js"; import { parseProxyUrl } from "./proxy.js"; @@ -88,6 +88,53 @@ export async function launchContext( return context; } +/** + * Launch stealth browser with a persistent user profile (non-incognito). + * Uses Playwright's chromium.launchPersistentContext() under the hood. + * + * This avoids incognito detection by services like BrowserScan (-10% penalty) + * and enables session persistence (cookies, localStorage) across launches. + * + * @example + * ```ts + * import { launchPersistentContext } from 'cloakbrowser'; + * const context = await launchPersistentContext({ + * userDataDir: './chrome-profile', + * headless: false, + * proxy: 'http://user:pass@host:port', + * geoip: true, + * }); + * const page = context.pages()[0] || await context.newPage(); + * await page.goto('https://example.com'); + * await context.close(); + * ``` + */ +export async function launchPersistentContext( + options: LaunchPersistentContextOptions +): Promise { + const { chromium } = await import("playwright-core"); + + const binaryPath = process.env.CLOAKBROWSER_BINARY_PATH || (await ensureBinary()); + const resolved = await maybeResolveGeoip(options); + const args = buildArgs({ ...options, ...resolved }); + + const context = await chromium.launchPersistentContext(options.userDataDir, { + executablePath: binaryPath, + headless: options.headless ?? true, + args, + ignoreDefaultArgs: ["--enable-automation"], + ...(options.proxy ? { proxy: parseProxyUrl(options.proxy) } : {}), + ...(options.userAgent ? { userAgent: options.userAgent } : {}), + viewport: options.viewport ?? DEFAULT_VIEWPORT, + ...(resolved.locale ? { locale: resolved.locale } : {}), + ...(resolved.timezone ? { timezoneId: resolved.timezone } : {}), + ...(options.colorScheme ? { colorScheme: options.colorScheme } : {}), + ...options.launchOptions, + }); + + return context; +} + // --------------------------------------------------------------------------- // Internal // --------------------------------------------------------------------------- diff --git a/js/src/types.ts b/js/src/types.ts index 089a59a..ba59fc9 100644 --- a/js/src/types.ts +++ b/js/src/types.ts @@ -34,6 +34,11 @@ export interface LaunchContextOptions extends LaunchOptions { colorScheme?: "light" | "dark" | "no-preference"; } +export interface LaunchPersistentContextOptions extends LaunchContextOptions { + /** Path to user data directory for persistent profile. */ + userDataDir: string; +} + export interface BinaryInfo { version: string; platform: string;