Skip to main content

Bridging playwright-core patch + extending playwright API for stealth injection & user simulation

Project description

🎭 Phantomwright

A patched and undetected Playwright — drop-in replacement that bypasses bot detection.


  • Full Playwright API — All APIs exported from Playwright, no learning curve
  • Fingerprints Evasion — Override browser fingerprints to better evade detection
  • User Simulation — Humanized page interactions for realistic behavior
  • Captcha Solver — Automatic Cloudflare challenge solving with background monitoring

Installation

pip install phantomwright
phantomwright_driver install chromium

Usage

Basic Usage

import asyncio
from phantomwright.async_api import async_playwright

async def main():
    async with async_playwright() as p:
        browser = await p.chromium.launch()
        page = await browser.new_page()
        await page.goto('http://playwright.dev')
        await page.screenshot(path=f'example-{p.chromium.name}.png')
        await browser.close()

asyncio.run(main())

Fingerprints Evasion

import asyncio
from phantomwright.async_api import async_playwright
from phantomwright.stealth import Stealth, ALL_EVASIONS_DISABLED_KWARGS

async def advanced_example():
    # Custom configuration with specific languages
    custom_languages = ("fr-FR", "fr")
    stealth = Stealth(
        navigator_languages_override=custom_languages
    )
    
    async with async_playwright() as p:
        browser = await p.chromium.launch()
        context = await browser.new_context()
        await stealth.apply_stealth_async(context)
        
        # Test stealth on multiple pages
        page_1 = await context.new_page()
        page_2 = await context.new_page()
        
        # Verify language settings
        for i, page in enumerate([page_1, page_2], 1):
            is_mocked = await page.evaluate("navigator.languages") == custom_languages
            print(f"Stealth applied to page {i}: {is_mocked}")

    # Example of selective evasion usage
    no_evasions = Stealth(**ALL_EVASIONS_DISABLED_KWARGS)
    single_evasion = Stealth(**{**ALL_EVASIONS_DISABLED_KWARGS, "navigator_webdriver": True})
    
    print("Total evasions (none):", len(no_evasions.script_payload))
    print("Total evasions (single):", len(single_evasion.script_payload))

asyncio.run(advanced_example())

User Simulation

from playwright.sync_api import sync_playwright
from phantomwright.user_simulator import SyncUserSimulator

with sync_playwright() as p:
    browser = p.chromium.launch(headless=False)
    page = browser.new_page(viewport={"width": 1280, "height": 900})

    # Create simulator
    sim = SyncUserSimulator(page)

    page.goto("https://www.bing.com")

    # Find search box
    search_box = page.locator("#sb_form_q")
    search_box.first.wait_for(timeout=5000)

    # Click with human-like behavior (scrolls into view + moves mouse + clicks)
    sim.click(search_box)

    # Or prepare for interaction without clicking
    # sim.prepare_for_interaction(search_box)

    # Type with human-like delays
    sim.type(search_box, "hello world")

    # Type with simulated typos
    # sim.type(search_box, "hello world", typos=True)

    # Simulate browsing behavior
    sim.simulate_browsing(duration_ms=2000)

    browser.close()

Cloudflare Captcha Solver

import logging
from phantomwright.async_api import async_playwright
from phantomwright.captcha.cloudfare.solver import CloudflareAutoSolver

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

async def main():
    async with async_playwright() as pw:
        browser = await pw.chromium.launch(headless=False)
        context = await browser.new_context()
        solver = CloudflareAutoSolver(
            context,
            max_attempts=3,
            attempt_delay=5,
            log_callback=logger.info,
        )

        solver.start()
        urls = [
            "https://2captcha.com/demo/cloudflare-turnstile", 
            "https://2captcha.com/demo/cloudflare-turnstile-challenge"
            ]
        for url in urls:
            page = await context.new_page()
            await page.goto(url)

Key Features:

  • Seamless Background Solving — Once solve() is called, the solver continuously monitors all pages in the context. No manual intervention required, even across navigations on the same page.
  • Dual Challenge Support — Handles both Cloudflare Turnstile and Interstitial challenge types automatically.
  • Logging Callback — Provides real-time visibility into captcha events via log_callback. Receives JSON strings containing:
    {
      "event": "cloudflare_captcha_solve",
      "url": "https://example.com",
      "challenge_type": "TURNSTILE",
      "success": true,
      "attempts": 1,
      "duration_sec": 2.345,
      "error": null,
      "timestamp": 1736985600.123
    }
    

⚠️ Note: To prevent infinite detection loops caused by automatic page refreshes during captcha resolution, manual refreshes on the same URL will not trigger the resolve process again.

Development

Setup & Test

uv venv
.venv\Scripts\activate
uv sync --extra dev
uv run phantomwright_driver install-deps
uv run phantomwright_driver install
uv run pytest

Clear Cache

uv cache clean

Debug Playwright Core

Phantomwright allows debugging both playwright-python and the underlying Node.js playwright-core process.

  1. Open Chrome and navigate to chrome://inspect
  2. Click "Open dedicated DevTools for Node"
  3. In the Connection tab, add localhost:9229
  4. Select debug session Core Repro: Select Case and pick a minimal repro case

The Node process will pause at the first breakpoint, enabling playwright-core debugging.

Known Limitations

Active Bugs

None currently.

Won't Fix

Console Domain Disabled

Runtime.enable removal disables Runtime.consoleAPICalled event. The following APIs are unavailable:

  • WebError

    page.context.on("weberror", lambda web_error: print(f"uncaught exception: {web_error.error}"))
    page.context.expect_event("weberror")
    
  • PageError

    page.on("pageerror", lambda exc: print(f"uncaught exception: {exc}"))
    page.expect_event("pageerror")
    page.page_errors()
    
  • ConsoleMessage

    page.on("console", lambda msg: print(msg.text))
    page.expect_console_message()
    page.console_messages()
    page.context.wait_for_event("console")
    page.expect_popup()
    

WebSocketRoute Disabled

CDP does not provide endpoints to manipulate WebSocket. Supporting this would require injecting init scripts into MainWorld, which is detectable.

  • WebSocketRoute
    await page.route_web_socket("/ws", handler)
    

add_init_script Not Compatible with Edge New Tab Page Prerender

As Edge NTP's prerendered New Tab page won't install Playwright route, add_init_script won't work in a prerendered session, plz ensure you disabled Edge's prerender when launch browser.

add_init_script Timing Issue

add_init_script cannot directly call bindings exposed by expose_function/expose_binding. Init scripts are injected into the HTML document and execute before exposed APIs are available.

  • ❌ Won't work:

    args = []
    await context.expose_function("woof", lambda arg: args.append(arg))
    await context.add_init_script("woof('context')")
    await context.new_page()
    assert args == ["context"]
    
  • ✅ Works:

    args = []
    await context.expose_function("woof", lambda arg: args.append(arg))
    page = await context.new_page()
    await page.evaluate("woof('context')")
    assert args == ["context"]
    

add_init_script Doesn't Affect Special URLs

Patchright init scripts use routing, which doesn't trigger for about:blank, Data-URIs, file:// URLs, as well as privilege pages such as edge://newtab.

  • Data-URIs

    await page.add_init_script("window.injected = 123")
    await page.goto("data:text/html,<script>window.result = window.injected</script>")
    
  • about:blank

    await page.add_init_script("window.injected = 123")
    await page.goto("about:blank")
    
  • file://

    await page.add_init_script("window.injected = 123")
    await page.goto("file://app/test.html")
    

add_init_script Only Affects Main World

Init scripts only execute in the main world, not isolated worlds.

await page.add_init_script("window.injected = 123")

# Main world (browser top context)
window.injected  # 123

# Isolated world (utility context)
window.injected  # undefined

Popup Blocking Enabled

--disable-popup-blocking is removed by default. Can be re-enabled if popup support is needed.

Selector Engines Aren't Atomic

import asyncio
from phantomwright.async_api import async_playwright

async def main():
    async with async_playwright() as p:
        create_dummy_selector = """({
            create(root, target) { },
            query(root, selector) {
              const result = root.querySelector(selector);
              if (result)
                Promise.resolve().then(() => result.textContent = 'modified');
              return result;
            },
            queryAll(root, selector) {
              const result = Array.from(root.querySelectorAll(selector));
              for (const e of result)
                Promise.resolve().then(() => e.textContent = 'modified');
              return result;
            }
        })"""
        
        await p.selectors.register("innerHtml", create_dummy_selector, content_script=False)
        
        browser = await p.chromium.launch(
            channel="chrome",
            headless=False
        )
        context = await browser.new_context(viewport=None)
        page = await context.new_page()
        
        await page.set_content("<div>Hello</div>")
        inner = await page.inner_html("innerHtml=div")
        evaluate = await page.evaluate("() => document.querySelector('div').textContent")
        
        print(f"text content via inner HTML = {inner}")
        print(f"text content via evaluate = {evaluate}")
        
        await browser.close()

asyncio.run(main())

Phantomwright results:

text content via inner HTML = modified
text content via evaluate = modified

Playwright results:

text content via inner HTML = Hello
text content via evaluate = modified

Acknowledgments

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

phantomwright-0.2.0.tar.gz (52.6 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

phantomwright-0.2.0-py3-none-any.whl (76.0 kB view details)

Uploaded Python 3

File details

Details for the file phantomwright-0.2.0.tar.gz.

File metadata

  • Download URL: phantomwright-0.2.0.tar.gz
  • Upload date:
  • Size: 52.6 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.9.8

File hashes

Hashes for phantomwright-0.2.0.tar.gz
Algorithm Hash digest
SHA256 86d8de9458395cb6286c04b20a395d36b682942cc1769b60dc8ae0da5c67cce6
MD5 e50c60a0e086444329f9031b8d9ddfd6
BLAKE2b-256 28e88456586444b3e4367c961f6224d0cd20a0f00092329df5cee42a7384bedd

See more details on using hashes here.

File details

Details for the file phantomwright-0.2.0-py3-none-any.whl.

File metadata

File hashes

Hashes for phantomwright-0.2.0-py3-none-any.whl
Algorithm Hash digest
SHA256 ce458b3c7dd445f21328c207b03c71323e9e092f7a5b0710460deb4070f64b55
MD5 0af7bbbda3340042daa34444adab2982
BLAKE2b-256 bc74389e6498af56c7bd85b96bf7a72e911ff981bc031c414c364e5da0c39470

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page