mirror of
https://github.com/CloakHQ/CloakBrowser.git
synced 2026-06-23 11:41:46 +02:00
fix(wrapper): track real window geometry on headed launches
Headed launches applied a fixed emulated viewport on top of the real browser window, yielding outerWidth < innerWidth (an impossible window). Default headed new_page()/new_context() to no_viewport so the page tracks the real window; headless keeps a deterministic viewport. Covers Python launch/launch_context/launch_persistent_context (+async) and the JS Playwright/Puppeteer wrappers. Explicit viewport still honored.
This commit is contained in:
@@ -73,3 +73,4 @@ captures
|
|||||||
.dolt/
|
.dolt/
|
||||||
*.db
|
*.db
|
||||||
.beads-credential-key
|
.beads-credential-key
|
||||||
|
.antigravitycli
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ Changes are tagged: **[wrapper]** for Python/JS wrapper, **[binary]** for Chromi
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
- **[wrapper]** Headed launches no longer apply a fixed emulated viewport on top of the real browser window — the page now tracks the actual window so window-geometry stays self-consistent. Headless keeps a deterministic viewport (unchanged). Applies across `launch`, `launch_context`, `launch_persistent_context` (+ async) and the JS Playwright/Puppeteer wrappers. Passing an explicit `viewport=`/`no_viewport` (Python) or `viewport`/`defaultViewport` (JS) still works exactly as before.
|
||||||
- **[wrapper]** **Breaking**: removed the optional `patchright` backend. The `backend` parameter and `CLOAKBROWSER_BACKEND` environment variable no longer exist, and the `cloakbrowser[patchright]` extra is gone. Stock Playwright is now the only backend. The stealth binary handles automation-signal suppression at the C++ level — patchright added no measurable benefit on top of it (identical reCAPTCHA v3 score to plain Playwright) while breaking proxy auth and `add_init_script` (#27). Callers passing `backend=...` will get a `TypeError`; remove the argument.
|
- **[wrapper]** **Breaking**: removed the optional `patchright` backend. The `backend` parameter and `CLOAKBROWSER_BACKEND` environment variable no longer exist, and the `cloakbrowser[patchright]` extra is gone. Stock Playwright is now the only backend. The stealth binary handles automation-signal suppression at the C++ level — patchright added no measurable benefit on top of it (identical reCAPTCHA v3 score to plain Playwright) while breaking proxy auth and `add_init_script` (#27). Callers passing `backend=...` will get a `TypeError`; remove the argument.
|
||||||
|
|
||||||
## [0.3.32] — 2026-06-20
|
## [0.3.32] — 2026-06-20
|
||||||
|
|||||||
+91
-24
@@ -31,6 +31,79 @@ logger = logging.getLogger("cloakbrowser")
|
|||||||
_VIEWPORT_UNSET = object()
|
_VIEWPORT_UNSET = object()
|
||||||
|
|
||||||
|
|
||||||
|
def _default_no_viewport(browser: Any) -> None:
|
||||||
|
"""Default ``new_page()``/``new_context()`` to ``no_viewport=True``.
|
||||||
|
|
||||||
|
``launch()`` returns a raw Playwright ``Browser``; a bare ``browser.new_page()``
|
||||||
|
would otherwise inherit Playwright's emulated 1280x720 viewport, producing
|
||||||
|
``outerWidth < innerWidth`` — a physically impossible window (bot tell). We wrap
|
||||||
|
the two factory methods so pages track the real OS window instead. ``setdefault``
|
||||||
|
only: an explicit ``viewport`` or ``no_viewport`` from the caller is never
|
||||||
|
overridden (Playwright rejects passing both). Applied for headed launches only.
|
||||||
|
Composes under humanize's ``patch_browser`` (apply this first).
|
||||||
|
"""
|
||||||
|
orig_new_context = browser.new_context
|
||||||
|
orig_new_page = browser.new_page
|
||||||
|
|
||||||
|
def _patched_new_context(**kwargs: Any) -> Any:
|
||||||
|
if "viewport" not in kwargs:
|
||||||
|
kwargs.setdefault("no_viewport", True)
|
||||||
|
return orig_new_context(**kwargs)
|
||||||
|
|
||||||
|
def _patched_new_page(**kwargs: Any) -> Any:
|
||||||
|
if "viewport" not in kwargs:
|
||||||
|
kwargs.setdefault("no_viewport", True)
|
||||||
|
return orig_new_page(**kwargs)
|
||||||
|
|
||||||
|
browser.new_context = _patched_new_context
|
||||||
|
browser.new_page = _patched_new_page
|
||||||
|
|
||||||
|
|
||||||
|
def _default_no_viewport_async(browser: Any) -> None:
|
||||||
|
"""Async variant of :func:`_default_no_viewport`."""
|
||||||
|
orig_new_context = browser.new_context
|
||||||
|
orig_new_page = browser.new_page
|
||||||
|
|
||||||
|
async def _patched_new_context(**kwargs: Any) -> Any:
|
||||||
|
if "viewport" not in kwargs:
|
||||||
|
kwargs.setdefault("no_viewport", True)
|
||||||
|
return await orig_new_context(**kwargs)
|
||||||
|
|
||||||
|
async def _patched_new_page(**kwargs: Any) -> Any:
|
||||||
|
if "viewport" not in kwargs:
|
||||||
|
kwargs.setdefault("no_viewport", True)
|
||||||
|
return await orig_new_page(**kwargs)
|
||||||
|
|
||||||
|
browser.new_context = _patched_new_context
|
||||||
|
browser.new_page = _patched_new_page
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_context_viewport(viewport: Any, headless: bool) -> dict[str, Any]:
|
||||||
|
"""Return the viewport kwarg for a context.
|
||||||
|
|
||||||
|
Headed: no emulated viewport so the page tracks the real window (CDP viewport
|
||||||
|
emulation forces outerWidth < innerWidth = a physically impossible window =
|
||||||
|
bot tell). Headless: a fixed ``DEFAULT_VIEWPORT`` stays coherent (outer == inner)
|
||||||
|
and keeps dimensions deterministic. Explicit ``viewport`` / ``None`` honored.
|
||||||
|
"""
|
||||||
|
if viewport is _VIEWPORT_UNSET:
|
||||||
|
return {"viewport": DEFAULT_VIEWPORT} if headless else {"no_viewport": True}
|
||||||
|
if viewport is None:
|
||||||
|
return {"no_viewport": True}
|
||||||
|
return {"viewport": viewport}
|
||||||
|
|
||||||
|
|
||||||
|
def _drop_conflicting_viewport(context_kwargs: dict[str, Any], kwargs: dict[str, Any]) -> None:
|
||||||
|
"""Playwright rejects passing both ``viewport`` and ``no_viewport``. ``viewport`` is a
|
||||||
|
named parameter (never in ``**kwargs``), so the only conflict is a caller passing
|
||||||
|
``no_viewport`` via ``**kwargs`` alongside an explicit ``viewport`` — the explicit
|
||||||
|
``no_viewport`` wins; drop the viewport so Playwright doesn't error.
|
||||||
|
"""
|
||||||
|
if "no_viewport" in kwargs and "viewport" in context_kwargs:
|
||||||
|
logger.debug("Both viewport and no_viewport requested; no_viewport (kwargs) wins")
|
||||||
|
context_kwargs.pop("viewport", None)
|
||||||
|
|
||||||
|
|
||||||
def _resolve_timezone(timezone: str | None, kwargs: dict[str, Any]) -> str | None:
|
def _resolve_timezone(timezone: str | None, kwargs: dict[str, Any]) -> str | None:
|
||||||
"""Accept both timezone and timezone_id — either works, no warning."""
|
"""Accept both timezone and timezone_id — either works, no warning."""
|
||||||
if "timezone_id" in kwargs:
|
if "timezone_id" in kwargs:
|
||||||
@@ -147,6 +220,12 @@ def launch(
|
|||||||
|
|
||||||
browser.close = _close_with_cleanup
|
browser.close = _close_with_cleanup
|
||||||
|
|
||||||
|
# Headed: default new_page()/new_context() to no_viewport so the page tracks the
|
||||||
|
# real window (avoids the impossible-window tell). Headless keeps Playwright's
|
||||||
|
# default viewport (coherent there). Apply before humanize so the wraps compose.
|
||||||
|
if not headless:
|
||||||
|
_default_no_viewport(browser)
|
||||||
|
|
||||||
# Human-like behavioral patching
|
# Human-like behavioral patching
|
||||||
if humanize:
|
if humanize:
|
||||||
from .human import patch_browser
|
from .human import patch_browser
|
||||||
@@ -239,6 +318,10 @@ async def launch_async( # noqa: C901
|
|||||||
|
|
||||||
browser.close = _close_with_cleanup
|
browser.close = _close_with_cleanup
|
||||||
|
|
||||||
|
# Headed: default new_page()/new_context() to no_viewport (see launch()).
|
||||||
|
if not headless:
|
||||||
|
_default_no_viewport_async(browser)
|
||||||
|
|
||||||
# Human-like behavioral patching (async variant)
|
# Human-like behavioral patching (async variant)
|
||||||
if humanize:
|
if humanize:
|
||||||
from .human import patch_browser_async
|
from .human import patch_browser_async
|
||||||
@@ -333,15 +416,11 @@ def launch_persistent_context(
|
|||||||
context_kwargs: dict[str, Any] = {}
|
context_kwargs: dict[str, Any] = {}
|
||||||
if user_agent:
|
if user_agent:
|
||||||
context_kwargs["user_agent"] = user_agent
|
context_kwargs["user_agent"] = user_agent
|
||||||
if viewport is _VIEWPORT_UNSET:
|
context_kwargs.update(_resolve_context_viewport(viewport, headless))
|
||||||
context_kwargs["viewport"] = DEFAULT_VIEWPORT
|
|
||||||
elif viewport is None:
|
|
||||||
context_kwargs["no_viewport"] = True
|
|
||||||
else:
|
|
||||||
context_kwargs["viewport"] = viewport
|
|
||||||
if color_scheme:
|
if color_scheme:
|
||||||
context_kwargs["color_scheme"] = color_scheme
|
context_kwargs["color_scheme"] = color_scheme
|
||||||
context_kwargs.update(kwargs)
|
context_kwargs.update(kwargs)
|
||||||
|
_drop_conflicting_viewport(context_kwargs, kwargs)
|
||||||
|
|
||||||
seed_widevine_hint(user_data_dir, binary_path)
|
seed_widevine_hint(user_data_dir, binary_path)
|
||||||
|
|
||||||
@@ -463,15 +542,11 @@ async def launch_persistent_context_async(
|
|||||||
context_kwargs: dict[str, Any] = {}
|
context_kwargs: dict[str, Any] = {}
|
||||||
if user_agent:
|
if user_agent:
|
||||||
context_kwargs["user_agent"] = user_agent
|
context_kwargs["user_agent"] = user_agent
|
||||||
if viewport is _VIEWPORT_UNSET:
|
context_kwargs.update(_resolve_context_viewport(viewport, headless))
|
||||||
context_kwargs["viewport"] = DEFAULT_VIEWPORT
|
|
||||||
elif viewport is None:
|
|
||||||
context_kwargs["no_viewport"] = True
|
|
||||||
else:
|
|
||||||
context_kwargs["viewport"] = viewport
|
|
||||||
if color_scheme:
|
if color_scheme:
|
||||||
context_kwargs["color_scheme"] = color_scheme
|
context_kwargs["color_scheme"] = color_scheme
|
||||||
context_kwargs.update(kwargs)
|
context_kwargs.update(kwargs)
|
||||||
|
_drop_conflicting_viewport(context_kwargs, kwargs)
|
||||||
|
|
||||||
seed_widevine_hint(user_data_dir, binary_path)
|
seed_widevine_hint(user_data_dir, binary_path)
|
||||||
|
|
||||||
@@ -571,15 +646,11 @@ def launch_context(
|
|||||||
context_kwargs: dict[str, Any] = {}
|
context_kwargs: dict[str, Any] = {}
|
||||||
if user_agent:
|
if user_agent:
|
||||||
context_kwargs["user_agent"] = user_agent
|
context_kwargs["user_agent"] = user_agent
|
||||||
if viewport is _VIEWPORT_UNSET:
|
context_kwargs.update(_resolve_context_viewport(viewport, headless))
|
||||||
context_kwargs["viewport"] = DEFAULT_VIEWPORT
|
|
||||||
elif viewport is None:
|
|
||||||
context_kwargs["no_viewport"] = True
|
|
||||||
else:
|
|
||||||
context_kwargs["viewport"] = viewport
|
|
||||||
if color_scheme:
|
if color_scheme:
|
||||||
context_kwargs["color_scheme"] = color_scheme
|
context_kwargs["color_scheme"] = color_scheme
|
||||||
context_kwargs.update(kwargs)
|
context_kwargs.update(kwargs)
|
||||||
|
_drop_conflicting_viewport(context_kwargs, kwargs)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
context = browser.new_context(**context_kwargs)
|
context = browser.new_context(**context_kwargs)
|
||||||
@@ -691,15 +762,11 @@ async def launch_context_async(
|
|||||||
context_kwargs: dict[str, Any] = {}
|
context_kwargs: dict[str, Any] = {}
|
||||||
if user_agent:
|
if user_agent:
|
||||||
context_kwargs["user_agent"] = user_agent
|
context_kwargs["user_agent"] = user_agent
|
||||||
if viewport is _VIEWPORT_UNSET:
|
context_kwargs.update(_resolve_context_viewport(viewport, headless))
|
||||||
context_kwargs["viewport"] = DEFAULT_VIEWPORT
|
|
||||||
elif viewport is None:
|
|
||||||
context_kwargs["no_viewport"] = True
|
|
||||||
else:
|
|
||||||
context_kwargs["viewport"] = viewport
|
|
||||||
if color_scheme:
|
if color_scheme:
|
||||||
context_kwargs["color_scheme"] = color_scheme
|
context_kwargs["color_scheme"] = color_scheme
|
||||||
context_kwargs.update(kwargs)
|
context_kwargs.update(kwargs)
|
||||||
|
_drop_conflicting_viewport(context_kwargs, kwargs)
|
||||||
|
|
||||||
# Catch BaseException (not just Exception) so that asyncio.CancelledError
|
# Catch BaseException (not just Exception) so that asyncio.CancelledError
|
||||||
# triggers browser cleanup — otherwise the underlying Chromium process
|
# triggers browser cleanup — otherwise the underlying Chromium process
|
||||||
|
|||||||
@@ -55,16 +55,19 @@ def get_default_stealth_args() -> list[str]:
|
|||||||
# Tell the fingerprint patches we're on macOS so GPU/UA match natively
|
# Tell the fingerprint patches we're on macOS so GPU/UA match natively
|
||||||
return base + ["--fingerprint-platform=macos"]
|
return base + ["--fingerprint-platform=macos"]
|
||||||
|
|
||||||
# Linux/Windows: Windows fingerprint profile
|
# Linux/Windows: Windows fingerprint profile.
|
||||||
# Hardware concurrency, device memory, screen, window size, and GPU are
|
# Screen and window size come from the real display, not this flag (verified:
|
||||||
# auto-generated by the binary from the seed (v14+).
|
# identical across seeds), so the wrapper must not emulate a viewport on top in
|
||||||
|
# headed mode — that would break outerWidth >= innerWidth coherence.
|
||||||
return base + ["--fingerprint-platform=windows"]
|
return base + ["--fingerprint-platform=windows"]
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Default viewport — realistic maximized Chrome on 1080p Windows
|
# Default viewport — used for HEADLESS only (headed launches use no_viewport so
|
||||||
# screen=1920x1080, availHeight=1032 (minus 48px taskbar, binary default),
|
# the page tracks the real window). Headless has no window chrome, so a fixed
|
||||||
# innerHeight=947 (minus ~85px Chrome UI: tabs + address bar + bookmarks)
|
# viewport stays coherent (outer == inner) and gives deterministic dimensions.
|
||||||
|
# Models a maximized Chrome on 1080p Windows: screen=1920x1080,
|
||||||
|
# innerHeight=947 (minus ~85px Chrome UI: tabs + address bar + bookmarks).
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
DEFAULT_VIEWPORT = {"width": 1920, "height": 947}
|
DEFAULT_VIEWPORT = {"width": 1920, "height": 947}
|
||||||
|
|
||||||
|
|||||||
+9
-6
@@ -200,9 +200,11 @@ export const IGNORE_DEFAULT_ARGS = ["--enable-automation", "--enable-unsafe-swif
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Default stealth arguments
|
// Default stealth arguments
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Default viewport — realistic maximized Chrome on 1080p Windows
|
// Default viewport — used for HEADLESS only (headed launches use no viewport so
|
||||||
// screen=1920x1080, availHeight=1032 (minus 48px taskbar, binary default),
|
// the page tracks the real window). Headless has no window chrome, so a fixed
|
||||||
// innerHeight=947 (minus ~85px Chrome UI: tabs + address bar + bookmarks)
|
// viewport stays coherent (outer == inner) and gives deterministic dimensions.
|
||||||
|
// Models a maximized Chrome on 1080p Windows: screen=1920x1080,
|
||||||
|
// innerHeight=947 (minus ~85px Chrome UI: tabs + address bar + bookmarks).
|
||||||
export const DEFAULT_VIEWPORT = { width: 1920, height: 947 };
|
export const DEFAULT_VIEWPORT = { width: 1920, height: 947 };
|
||||||
|
|
||||||
export function getDefaultStealthArgs(): string[] {
|
export function getDefaultStealthArgs(): string[] {
|
||||||
@@ -219,8 +221,9 @@ export function getDefaultStealthArgs(): string[] {
|
|||||||
return [...base, "--fingerprint-platform=macos"];
|
return [...base, "--fingerprint-platform=macos"];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Linux/Windows: spoof as Windows desktop
|
// Linux/Windows: spoof as Windows desktop.
|
||||||
// Hardware concurrency, device memory, screen, window size, and GPU are
|
// Screen and window size come from the real display, not this flag (verified:
|
||||||
// auto-generated by the binary from the seed (v14+).
|
// identical across seeds), so the wrapper must not emulate a viewport on top in
|
||||||
|
// headed mode — that would break outerWidth >= innerWidth coherence.
|
||||||
return [...base, "--fingerprint-platform=windows"];
|
return [...base, "--fingerprint-platform=windows"];
|
||||||
}
|
}
|
||||||
|
|||||||
+55
-2
@@ -52,15 +52,43 @@ function filterStealthCtxOptions(ctx?: BrowserContextOptions): Partial<BrowserCo
|
|||||||
* Useful when integrating CloakBrowser with an existing Playwright Browser while
|
* Useful when integrating CloakBrowser with an existing Playwright Browser while
|
||||||
* keeping the wrapper's stealth-safe defaults for `newContext()`.
|
* keeping the wrapper's stealth-safe defaults for `newContext()`.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Effective headless mode for viewport decisions. buildLaunchOptions() spreads
|
||||||
|
* `...options.launchOptions` LAST, so a raw `launchOptions.headless` overrides the
|
||||||
|
* top-level field at the actual chromium.launch() call. Viewport logic must read
|
||||||
|
* the same effective value — otherwise a headed browser gets a fixed viewport
|
||||||
|
* (reintroducing the impossible-window tell). Playwright-specific (Puppeteer
|
||||||
|
* resolves headless the opposite way).
|
||||||
|
*/
|
||||||
|
function effectiveHeadless(options: LaunchOptions): boolean {
|
||||||
|
return (
|
||||||
|
(options.launchOptions as { headless?: boolean } | undefined)?.headless ??
|
||||||
|
options.headless ??
|
||||||
|
true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function buildContextOptions(
|
export function buildContextOptions(
|
||||||
options: LaunchContextOptions = {}
|
options: LaunchContextOptions = {}
|
||||||
): BrowserContextOptions {
|
): BrowserContextOptions {
|
||||||
|
// Headed: viewport=null (no emulation) so the page tracks the real window and
|
||||||
|
// outerWidth >= innerWidth stays coherent — CDP viewport emulation forces
|
||||||
|
// inner > outer = a physically impossible window = bot tell. Headless has no
|
||||||
|
// window chrome (outer == inner), so a fixed viewport stays coherent and keeps
|
||||||
|
// dimensions deterministic. Explicit viewport (incl. null) is always honored.
|
||||||
|
const headless = effectiveHeadless(options);
|
||||||
|
const viewport =
|
||||||
|
options.viewport !== undefined
|
||||||
|
? options.viewport
|
||||||
|
: headless
|
||||||
|
? DEFAULT_VIEWPORT
|
||||||
|
: null;
|
||||||
return {
|
return {
|
||||||
// contextOptions first — explicit wrapper fields below override it.
|
// contextOptions first — explicit wrapper fields below override it.
|
||||||
// filterStealthCtxOptions strips locale/timezoneId to prevent CDP detection.
|
// filterStealthCtxOptions strips locale/timezoneId to prevent CDP detection.
|
||||||
...filterStealthCtxOptions(options.contextOptions),
|
...filterStealthCtxOptions(options.contextOptions),
|
||||||
...(options.userAgent ? { userAgent: options.userAgent } : {}),
|
...(options.userAgent ? { userAgent: options.userAgent } : {}),
|
||||||
viewport: options.viewport === undefined ? DEFAULT_VIEWPORT : options.viewport,
|
viewport,
|
||||||
...(options.colorScheme ? { colorScheme: options.colorScheme } : {}),
|
...(options.colorScheme ? { colorScheme: options.colorScheme } : {}),
|
||||||
} as BrowserContextOptions;
|
} as BrowserContextOptions;
|
||||||
}
|
}
|
||||||
@@ -127,10 +155,33 @@ export async function humanizeBrowser(
|
|||||||
export async function launch(options: LaunchOptions = {}): Promise<Browser> {
|
export async function launch(options: LaunchOptions = {}): Promise<Browser> {
|
||||||
const { chromium } = await import("playwright-core");
|
const { chromium } = await import("playwright-core");
|
||||||
const browser = await chromium.launch(await buildLaunchOptions(options));
|
const browser = await chromium.launch(await buildLaunchOptions(options));
|
||||||
|
// Headed: a bare browser.newPage() would inherit Playwright's emulated 1280x720
|
||||||
|
// viewport -> outerWidth < innerWidth (impossible window = bot tell). Default
|
||||||
|
// newPage()/newContext() to viewport:null so the page tracks the real window.
|
||||||
|
// Headless keeps Playwright's default viewport (coherent there).
|
||||||
|
if (!effectiveHeadless(options)) {
|
||||||
|
applyDefaultNoViewport(browser);
|
||||||
|
}
|
||||||
await humanizeBrowser(browser, options);
|
await humanizeBrowser(browser, options);
|
||||||
return browser;
|
return browser;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrap a Browser's newContext()/newPage() to default to viewport:null (no
|
||||||
|
* emulation) when the caller didn't specify a viewport. setdefault-style: an
|
||||||
|
* explicit viewport (including null) is always honored. Apply before humanize's
|
||||||
|
* patchBrowser so the wraps compose.
|
||||||
|
*/
|
||||||
|
function applyDefaultNoViewport(browser: Browser): void {
|
||||||
|
const origNewContext = browser.newContext.bind(browser);
|
||||||
|
(browser as any).newContext = (options?: Parameters<typeof origNewContext>[0]) =>
|
||||||
|
origNewContext(options?.viewport === undefined ? { ...options, viewport: null } : options);
|
||||||
|
|
||||||
|
const origNewPage = browser.newPage.bind(browser);
|
||||||
|
(browser as any).newPage = (options?: Parameters<typeof origNewPage>[0]) =>
|
||||||
|
origNewPage(options?.viewport === undefined ? { ...options, viewport: null } : options);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Launch stealth browser and return a BrowserContext with common options pre-set.
|
* Launch stealth browser and return a BrowserContext with common options pre-set.
|
||||||
* Closing the context also closes the browser.
|
* Closing the context also closes the browser.
|
||||||
@@ -161,7 +212,9 @@ export async function launchContext(
|
|||||||
// --fingerprint-timezone is process-wide (reads CommandLine in renderer),
|
// --fingerprint-timezone is process-wide (reads CommandLine in renderer),
|
||||||
// so it applies to ALL contexts, not just the default one.
|
// so it applies to ALL contexts, not just the default one.
|
||||||
// locale and timezone are set via binary flags only — no CDP emulation.
|
// locale and timezone are set via binary flags only — no CDP emulation.
|
||||||
const browser = await launch({ ...options, ...resolved, args: launchArgs, geoip: false });
|
// humanize:false on the inner launch — patchContext below applies humanize
|
||||||
|
// exactly once (else launch()'s humanizeBrowser would patch it a second time).
|
||||||
|
const browser = await launch({ ...options, ...resolved, args: launchArgs, geoip: false, humanize: false });
|
||||||
|
|
||||||
let context: BrowserContext;
|
let context: BrowserContext;
|
||||||
try {
|
try {
|
||||||
|
|||||||
+21
-1
@@ -6,13 +6,31 @@
|
|||||||
|
|
||||||
import type { Browser } from "puppeteer-core";
|
import type { Browser } from "puppeteer-core";
|
||||||
import type { LaunchOptions } from "./types.js";
|
import type { LaunchOptions } from "./types.js";
|
||||||
import { IGNORE_DEFAULT_ARGS } from "./config.js";
|
import { DEFAULT_VIEWPORT, IGNORE_DEFAULT_ARGS } from "./config.js";
|
||||||
import { buildArgs } from "./args.js";
|
import { buildArgs } from "./args.js";
|
||||||
import { ensureBinary } from "./download.js";
|
import { ensureBinary } from "./download.js";
|
||||||
import { isSocksProxy, normalizeHttpStringUrl, parseProxyUrl, reconstructHttpUrl, resolveProxyConfig, supportsHttpProxyInlineAuth } from "./proxy.js";
|
import { isSocksProxy, normalizeHttpStringUrl, parseProxyUrl, reconstructHttpUrl, resolveProxyConfig, supportsHttpProxyInlineAuth } from "./proxy.js";
|
||||||
import { maybeResolveGeoip, resolveWebrtcArgs } from "./geoip.js";
|
import { maybeResolveGeoip, resolveWebrtcArgs } from "./geoip.js";
|
||||||
import { seedWidevineHint } from "./widevine.js";
|
import { seedWidevineHint } from "./widevine.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve Puppeteer's defaultViewport. Headed -> null (track the real window so
|
||||||
|
* outerWidth >= innerWidth stays coherent; Puppeteer otherwise forces an 800x600
|
||||||
|
* emulated viewport = a physically impossible window = bot tell). Headless has no
|
||||||
|
* window chrome (outer == inner), so a fixed viewport stays coherent and keeps
|
||||||
|
* dimensions deterministic. A user-supplied launchOptions.defaultViewport wins.
|
||||||
|
*/
|
||||||
|
function resolveDefaultViewport(options: LaunchOptions): { width: number; height: number } | null {
|
||||||
|
const launchOpts = (options.launchOptions ?? {}) as Record<string, unknown>;
|
||||||
|
// A user-supplied defaultViewport wins (incl. explicit null). undefined is NOT
|
||||||
|
// "supplied" — fall through to our default. Puppeteer sets `headless` AFTER the
|
||||||
|
// launchOptions spread, so the top-level field wins at launch — match it here.
|
||||||
|
if (launchOpts.defaultViewport !== undefined) {
|
||||||
|
return launchOpts.defaultViewport as { width: number; height: number } | null;
|
||||||
|
}
|
||||||
|
return (options.headless ?? true) ? DEFAULT_VIEWPORT : null;
|
||||||
|
}
|
||||||
|
|
||||||
/** Resolve binary path, geoip, webrtc, and build final Chrome args. */
|
/** Resolve binary path, geoip, webrtc, and build final Chrome args. */
|
||||||
async function resolveArgs(options: LaunchOptions): Promise<{ binaryPath: string; args: string[] }> {
|
async function resolveArgs(options: LaunchOptions): Promise<{ binaryPath: string; args: string[] }> {
|
||||||
const binaryPath = process.env.CLOAKBROWSER_BINARY_PATH || (await ensureBinary());
|
const binaryPath = process.env.CLOAKBROWSER_BINARY_PATH || (await ensureBinary());
|
||||||
@@ -125,6 +143,7 @@ export async function launch(options: LaunchOptions = {}): Promise<Browser> {
|
|||||||
headless: options.headless ?? true,
|
headless: options.headless ?? true,
|
||||||
args,
|
args,
|
||||||
ignoreDefaultArgs: IGNORE_DEFAULT_ARGS,
|
ignoreDefaultArgs: IGNORE_DEFAULT_ARGS,
|
||||||
|
defaultViewport: resolveDefaultViewport(options),
|
||||||
});
|
});
|
||||||
|
|
||||||
await applyPostLaunch(browser, options, proxyAuth);
|
await applyPostLaunch(browser, options, proxyAuth);
|
||||||
@@ -165,6 +184,7 @@ export async function launchPersistentContext(
|
|||||||
args,
|
args,
|
||||||
ignoreDefaultArgs: IGNORE_DEFAULT_ARGS,
|
ignoreDefaultArgs: IGNORE_DEFAULT_ARGS,
|
||||||
userDataDir: options.userDataDir,
|
userDataDir: options.userDataDir,
|
||||||
|
defaultViewport: resolveDefaultViewport(options),
|
||||||
});
|
});
|
||||||
|
|
||||||
await applyPostLaunch(browser, options, proxyAuth);
|
await applyPostLaunch(browser, options, proxyAuth);
|
||||||
|
|||||||
@@ -84,6 +84,32 @@ describe("composable Playwright launch helpers", () => {
|
|||||||
expect(buildContextOptions({ viewport: null }).viewport).toBeNull();
|
expect(buildContextOptions({ viewport: null }).viewport).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("buildContextOptions uses no viewport (null) when headed, so the page tracks the real window", async () => {
|
||||||
|
const { buildContextOptions } = await import("../src/index.js");
|
||||||
|
|
||||||
|
// Headed: no emulated viewport (CDP emulation would force outerWidth < innerWidth).
|
||||||
|
expect(buildContextOptions({ headless: false }).viewport).toBeNull();
|
||||||
|
// Headless keeps the deterministic default.
|
||||||
|
expect(buildContextOptions({ headless: true }).viewport).toEqual(DEFAULT_VIEWPORT);
|
||||||
|
// Explicit viewport always honored, even headed.
|
||||||
|
const custom = { width: 800, height: 600 };
|
||||||
|
expect(buildContextOptions({ headless: false, viewport: custom }).viewport).toEqual(custom);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("buildContextOptions reads effective headless from launchOptions.headless", async () => {
|
||||||
|
const { buildContextOptions } = await import("../src/index.js");
|
||||||
|
|
||||||
|
// buildLaunchOptions spreads launchOptions LAST, so launchOptions.headless wins
|
||||||
|
// at the actual launch. Viewport must follow it — a raw headless:false (browser
|
||||||
|
// actually headed) must NOT get a fixed viewport (would reintroduce outer<inner).
|
||||||
|
expect(buildContextOptions({ launchOptions: { headless: false } }).viewport).toBeNull();
|
||||||
|
// And launchOptions.headless:true forces the deterministic viewport even if the
|
||||||
|
// top-level field said headed.
|
||||||
|
expect(
|
||||||
|
buildContextOptions({ headless: false, launchOptions: { headless: true } }).viewport,
|
||||||
|
).toEqual(DEFAULT_VIEWPORT);
|
||||||
|
});
|
||||||
|
|
||||||
it("buildLaunchOptions returns Playwright options without launching a browser", async () => {
|
it("buildLaunchOptions returns Playwright options without launching a browser", async () => {
|
||||||
const freshConfig = await import("../src/config.js");
|
const freshConfig = await import("../src/config.js");
|
||||||
vi.spyOn(freshConfig, "getPlatformTag").mockReturnValue("darwin-arm64");
|
vi.spyOn(freshConfig, "getPlatformTag").mockReturnValue("darwin-arm64");
|
||||||
|
|||||||
@@ -65,6 +65,53 @@ describe("puppeteer launch", () => {
|
|||||||
expect(callArgs.args.some((a: string) => a.startsWith("--fingerprint="))).toBe(false);
|
expect(callArgs.args.some((a: string) => a.startsWith("--fingerprint="))).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("headless (default) uses a fixed defaultViewport; headed uses null", async () => {
|
||||||
|
const { DEFAULT_VIEWPORT } = await import("../src/config.js");
|
||||||
|
const { launch } = await import("../src/puppeteer.js");
|
||||||
|
|
||||||
|
// Headless (default): deterministic viewport.
|
||||||
|
await launch();
|
||||||
|
expect(
|
||||||
|
vi.mocked(puppeteerMock.default.launch).mock.calls[0][0].defaultViewport
|
||||||
|
).toEqual(DEFAULT_VIEWPORT);
|
||||||
|
|
||||||
|
// Headed: null so the page tracks the real window (else Puppeteer forces 800x600).
|
||||||
|
vi.mocked(puppeteerMock.default.launch).mockClear();
|
||||||
|
await launch({ headless: false });
|
||||||
|
expect(
|
||||||
|
vi.mocked(puppeteerMock.default.launch).mock.calls[0][0].defaultViewport
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("honors an explicit launchOptions.defaultViewport (incl. null)", async () => {
|
||||||
|
const { launch } = await import("../src/puppeteer.js");
|
||||||
|
|
||||||
|
const custom = { width: 640, height: 480 };
|
||||||
|
await launch({ headless: true, launchOptions: { defaultViewport: custom } });
|
||||||
|
expect(
|
||||||
|
vi.mocked(puppeteerMock.default.launch).mock.calls[0][0].defaultViewport
|
||||||
|
).toEqual(custom);
|
||||||
|
|
||||||
|
// Explicit null honored even in headless (would otherwise default to DEFAULT_VIEWPORT).
|
||||||
|
vi.mocked(puppeteerMock.default.launch).mockClear();
|
||||||
|
await launch({ headless: true, launchOptions: { defaultViewport: null } });
|
||||||
|
expect(
|
||||||
|
vi.mocked(puppeteerMock.default.launch).mock.calls[0][0].defaultViewport
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Puppeteer headless precedence: top-level headless wins over launchOptions.headless", async () => {
|
||||||
|
const { DEFAULT_VIEWPORT } = await import("../src/config.js");
|
||||||
|
const { launch } = await import("../src/puppeteer.js");
|
||||||
|
|
||||||
|
// Puppeteer sets headless AFTER the launchOptions spread, so top-level wins at
|
||||||
|
// launch — the viewport decision must follow the same (top-level) value.
|
||||||
|
await launch({ headless: true, launchOptions: { headless: false } });
|
||||||
|
const opts = vi.mocked(puppeteerMock.default.launch).mock.calls[0][0];
|
||||||
|
expect(opts.headless).toBe(true);
|
||||||
|
expect(opts.defaultViewport).toEqual(DEFAULT_VIEWPORT);
|
||||||
|
});
|
||||||
|
|
||||||
it("adds --proxy-server for string proxy", async () => {
|
it("adds --proxy-server for string proxy", async () => {
|
||||||
const { launch } = await import("../src/puppeteer.js");
|
const { launch } = await import("../src/puppeteer.js");
|
||||||
await launch({ proxy: "http://proxy:8080" });
|
await launch({ proxy: "http://proxy:8080" });
|
||||||
@@ -212,6 +259,22 @@ describe("puppeteer launchPersistentContext", () => {
|
|||||||
expect(callArgs.args.some((a: string) => a.startsWith("--fingerprint="))).toBe(true);
|
expect(callArgs.args.some((a: string) => a.startsWith("--fingerprint="))).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("headed persistent context uses null defaultViewport (tracks real window)", async () => {
|
||||||
|
const { DEFAULT_VIEWPORT } = await import("../src/config.js");
|
||||||
|
const { launchPersistentContext } = await import("../src/puppeteer.js");
|
||||||
|
|
||||||
|
await launchPersistentContext({ userDataDir: "./my-profile", headless: false });
|
||||||
|
expect(
|
||||||
|
vi.mocked(puppeteerMock.default.launch).mock.calls[0][0].defaultViewport
|
||||||
|
).toBeNull();
|
||||||
|
|
||||||
|
vi.mocked(puppeteerMock.default.launch).mockClear();
|
||||||
|
await launchPersistentContext({ userDataDir: "./my-profile", headless: true });
|
||||||
|
expect(
|
||||||
|
vi.mocked(puppeteerMock.default.launch).mock.calls[0][0].defaultViewport
|
||||||
|
).toEqual(DEFAULT_VIEWPORT);
|
||||||
|
});
|
||||||
|
|
||||||
it("uses page.authenticate fallback for http proxy in persistent context on unsupported platform", async () => {
|
it("uses page.authenticate fallback for http proxy in persistent context on unsupported platform", async () => {
|
||||||
const config = await import("../src/config.js");
|
const config = await import("../src/config.js");
|
||||||
vi.spyOn(config, "getPlatformTag").mockReturnValue("darwin-arm64");
|
vi.spyOn(config, "getPlatformTag").mockReturnValue("darwin-arm64");
|
||||||
|
|||||||
@@ -33,6 +33,82 @@ def test_default_viewport(mock_launch, _mock_bin):
|
|||||||
assert ctx_kwargs[1]["viewport"] == DEFAULT_VIEWPORT
|
assert ctx_kwargs[1]["viewport"] == DEFAULT_VIEWPORT
|
||||||
|
|
||||||
|
|
||||||
|
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
|
||||||
|
@patch("cloakbrowser.browser.launch")
|
||||||
|
def test_headed_no_viewport(mock_launch, _mock_bin):
|
||||||
|
"""Headed (headless=False): no emulated viewport — no_viewport=True so the page
|
||||||
|
tracks the real window (CDP viewport emulation would force outerWidth < innerWidth)."""
|
||||||
|
browser, context = _make_mock_browser()
|
||||||
|
mock_launch.return_value = browser
|
||||||
|
|
||||||
|
from cloakbrowser.browser import launch_context
|
||||||
|
launch_context(headless=False)
|
||||||
|
|
||||||
|
ctx_kwargs = browser.new_context.call_args[1]
|
||||||
|
assert ctx_kwargs.get("no_viewport") is True
|
||||||
|
assert "viewport" not in ctx_kwargs
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_no_viewport_helper():
|
||||||
|
"""_default_no_viewport defaults new_page()/new_context() to no_viewport=True,
|
||||||
|
but never overrides an explicit viewport (Playwright rejects passing both)."""
|
||||||
|
from cloakbrowser.browser import _default_no_viewport
|
||||||
|
|
||||||
|
browser = MagicMock()
|
||||||
|
orig_new_page = browser.new_page
|
||||||
|
orig_new_context = browser.new_context
|
||||||
|
_default_no_viewport(browser)
|
||||||
|
|
||||||
|
browser.new_page()
|
||||||
|
orig_new_page.assert_called_once_with(no_viewport=True)
|
||||||
|
browser.new_context()
|
||||||
|
orig_new_context.assert_called_once_with(no_viewport=True)
|
||||||
|
|
||||||
|
# Explicit viewport respected — no_viewport NOT injected.
|
||||||
|
orig_new_page.reset_mock()
|
||||||
|
browser.new_page(viewport={"width": 800, "height": 600})
|
||||||
|
orig_new_page.assert_called_once_with(viewport={"width": 800, "height": 600})
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_default_no_viewport_helper_async():
|
||||||
|
"""_default_no_viewport_async mirrors the sync helper for async new_page/new_context."""
|
||||||
|
from cloakbrowser.browser import _default_no_viewport_async
|
||||||
|
|
||||||
|
browser = MagicMock()
|
||||||
|
browser.new_page = AsyncMock()
|
||||||
|
browser.new_context = AsyncMock()
|
||||||
|
orig_new_page = browser.new_page
|
||||||
|
orig_new_context = browser.new_context
|
||||||
|
_default_no_viewport_async(browser)
|
||||||
|
|
||||||
|
await browser.new_page()
|
||||||
|
orig_new_page.assert_awaited_once_with(no_viewport=True)
|
||||||
|
await browser.new_context()
|
||||||
|
orig_new_context.assert_awaited_once_with(no_viewport=True)
|
||||||
|
|
||||||
|
# Explicit viewport respected — no_viewport NOT injected.
|
||||||
|
orig_new_page.reset_mock()
|
||||||
|
await browser.new_page(viewport={"width": 800, "height": 600})
|
||||||
|
orig_new_page.assert_awaited_once_with(viewport={"width": 800, "height": 600})
|
||||||
|
|
||||||
|
|
||||||
|
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
|
||||||
|
@patch("cloakbrowser.browser.launch")
|
||||||
|
def test_conflicting_viewport_kwargs_deduped(mock_launch, _mock_bin):
|
||||||
|
"""If a caller forces no_viewport via **kwargs alongside viewport=, only one
|
||||||
|
reaches Playwright (which rejects both). The explicit kwargs value wins."""
|
||||||
|
browser, context = _make_mock_browser()
|
||||||
|
mock_launch.return_value = browser
|
||||||
|
|
||||||
|
from cloakbrowser.browser import launch_context
|
||||||
|
launch_context(viewport={"width": 1280, "height": 800}, no_viewport=True)
|
||||||
|
|
||||||
|
ctx_kwargs = browser.new_context.call_args[1]
|
||||||
|
assert ctx_kwargs.get("no_viewport") is True
|
||||||
|
assert "viewport" not in ctx_kwargs
|
||||||
|
|
||||||
|
|
||||||
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
|
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
|
||||||
@patch("cloakbrowser.browser.launch")
|
@patch("cloakbrowser.browser.launch")
|
||||||
def test_custom_viewport(mock_launch, _mock_bin):
|
def test_custom_viewport(mock_launch, _mock_bin):
|
||||||
|
|||||||
@@ -55,6 +55,22 @@ def test_persistent_context_default_viewport(_mock_geoip, _mock_bin):
|
|||||||
assert call_kwargs["viewport"] == DEFAULT_VIEWPORT
|
assert call_kwargs["viewport"] == DEFAULT_VIEWPORT
|
||||||
|
|
||||||
|
|
||||||
|
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
|
||||||
|
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None, None))
|
||||||
|
def test_persistent_context_headed_no_viewport(_mock_geoip, _mock_bin):
|
||||||
|
"""Headed (headless=False): no_viewport=True instead of DEFAULT_VIEWPORT so the
|
||||||
|
page tracks the real window (avoids the outerWidth < innerWidth tell)."""
|
||||||
|
pw_cm, pw, context = _make_mock_pw_and_context()
|
||||||
|
|
||||||
|
with patch("playwright.sync_api.sync_playwright", return_value=pw_cm):
|
||||||
|
from cloakbrowser.browser import launch_persistent_context
|
||||||
|
launch_persistent_context("/tmp/profile", headless=False)
|
||||||
|
|
||||||
|
call_kwargs = pw.chromium.launch_persistent_context.call_args[1]
|
||||||
|
assert call_kwargs.get("no_viewport") is True
|
||||||
|
assert "viewport" not in call_kwargs
|
||||||
|
|
||||||
|
|
||||||
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
|
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
|
||||||
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None, None))
|
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None, None))
|
||||||
def test_persistent_context_custom_viewport(_mock_geoip, _mock_bin):
|
def test_persistent_context_custom_viewport(_mock_geoip, _mock_bin):
|
||||||
|
|||||||
Reference in New Issue
Block a user