diff --git a/CHANGELOG.md b/CHANGELOG.md index c3a938f..408cc21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ Changes are tagged: **[wrapper]** for Python/JS wrapper, **[binary]** for Chromi ## [Unreleased] +- **[wrapper]** Element reads are now humanized: `page.input_value()`, `page.text_content()`, `page.inner_text()`, `page.inner_html()`, `page.get_attribute()` and `dispatch_event()` (page, frame, locator and element handle; sync and async) resolve and read through the humanize isolated world, like every other humanized call. A dispatched event is built by Playwright's own helper, so a page listener sees the same event it would from any other dispatch. `page._original.input_value(...)` / `page._original.dispatch_event(...)` still give the raw Playwright path. Python, JavaScript and .NET (`InputValueAsync`, `TextContentAsync`, `InnerTextAsync`, `InnerHTMLAsync`, `GetAttributeAsync`, `DispatchEventAsync` on every wrapper; `Original` / `Humanize.Unwrap(page)` for the raw path). + - **[wrapper]** Humanized `select_option()` on a dropdown now types the first letters of the option, like a person does on a long list, plus a few arrow keys when that is shorter, instead of pressing an arrow key once per row. A pick on a 200-option list takes about 1.5-2s instead of up to 30s, and `timeout=` now bounds it (#581). The keys typed land on the same option wherever the open popup's highlight starts, so a popup that opens under the mouse pointer no longer picks the wrong option on headed Linux (#580). On a macOS host the shortest unique prefix of the label is typed instead of the whole label. Python, JavaScript Playwright, and .NET. - **[wrapper]** Humanized `select_option()` now reaches dropdown options whose label starts with an emoji (e.g. a flag before a country name) with arrow keys, because the browser's dropdown search ignores a typed emoji. On a macOS host, where the dropdown also ignores arrow keys, such an option raises a clear error that points to `page._original.select_option(...)` instead of failing with a mismatched selection. Python, JavaScript Playwright, and .NET. diff --git a/README.md b/README.md index 9d4c936..3a5ba57 100644 --- a/README.md +++ b/README.md @@ -601,7 +601,7 @@ clearCache(); ## Human Behavior -Pass `humanize=True` to make all mouse, keyboard, and scroll interactions indistinguishable from real users. All Playwright calls (`page.click()`, `page.fill()`, `page.type()`, `page.mouse.*`, `page.keyboard.*`, Locator API) and Puppeteer calls (`page.click()`, `page.type()`, `page.mouse.*`, `page.keyboard.*`, ElementHandle API) are automatically replaced with human-like equivalents. No code changes needed. +Pass `humanize=True` to make all mouse, keyboard, scroll and element-read interactions indistinguishable from real users. All Playwright calls (`page.click()`, `page.fill()`, `page.type()`, `page.mouse.*`, `page.keyboard.*`, `page.input_value()`, `page.text_content()`, `page.inner_text()`, `page.inner_html()`, `page.get_attribute()`, Locator API) and Puppeteer calls (`page.click()`, `page.type()`, `page.mouse.*`, `page.keyboard.*`, ElementHandle API) are automatically replaced with human-like equivalents. No code changes needed. ```python browser = launch(humanize=True) @@ -666,7 +666,9 @@ const browser = await launch({ }); ``` -Access the original un-patched Playwright page at `page._original` if you need raw speed for a specific call. +Access the original un-patched Playwright page at `page._original` if you need raw speed for a specific call — this includes the reads (`page._original.input_value(...)`, `text_content`, `inner_text`, `inner_html`, `get_attribute`), which are humanized by default. + +For an arbitrary expression, `page._stealth_world.evaluate("…")` evaluates it in the isolated world the humanize engine uses. > **Note (Playwright):** Always use `page.click(selector)`, `page.type(selector, text)`, `page.hover(selector)`, or `page.locator(selector).*` — these go through the full humanize pipeline. Avoid `page.query_selector()` — `ElementHandle` objects bypass all patches, so mouse movement teleports, keyboard events fire without timing, and scroll has no human curve. > diff --git a/cloakbrowser/human/engine.py b/cloakbrowser/human/engine.py index 4695547..1b733ee 100644 --- a/cloakbrowser/human/engine.py +++ b/cloakbrowser/human/engine.py @@ -26,6 +26,7 @@ import time from typing import Any, Awaitable, Callable, Dict, List, Optional, Sequence, Tuple from playwright._impl._errors import Error, TimeoutError +from playwright._impl._js_handle import JSHandle from . import fields from .config import HumanConfig, merge_config, rand, rand_int_range, rand_range @@ -109,6 +110,23 @@ class _Retry(Exception): self.reason = reason +class RawFallback(Exception): + """A read / dispatch on an ElementHandle the isolated world cannot locate + (no box: hidden; or a box shared with another element). The patched method + runs Playwright's own call instead, which reads hidden elements. Also a + dispatch_event whose event_init holds a JSHandle (it lives in the page's + main world, where Playwright dispatches).""" + + +def _has_handle(v: Any) -> bool: + """A JSHandle anywhere in ``v`` (public wrapper or impl object).""" + if isinstance(v, dict): + return any(_has_handle(x) for x in v.values()) + if isinstance(v, (list, tuple)): + return any(_has_handle(x) for x in v) + return isinstance(getattr(v, "_impl_obj", v), JSHandle) + + # --------------------------------------------------------------------------- # Human # --------------------------------------------------------------------------- @@ -242,16 +260,21 @@ class Human: frame = child raise Error("unreachable") # pragma: no cover - async def _resolve_handle(self, handle: Any, deadline: _Deadline) -> Resolved: + async def _resolve_handle(self, handle: Any, deadline: _Deadline, exact: bool = False) -> Resolved: + """``exact`` (reads): skip the cache (it may hold a click's pick from a + same-box chain) and accept only the one element with this box; anything + else raises RawFallback.""" frame = handle._frame rec = await self.worlds._ready(frame) cached = self._handle_ids.get(handle._guid) - if cached and cached[0] is frame and cached[1] == rec.ctx: + if not exact and cached and cached[0] is frame and cached[1] == rec.ctx: return Resolved(frame, cached[2]) # An ElementHandle carries no identity our context can read, so find # the element with exactly its border box. box = await handle.bounding_box() if box is None: + if exact: + raise RawFallback() if cached and cached[0] is frame: # Resolved in an earlier document of this frame and gone now # (a same-document navigation keeps the element, and its box). @@ -259,8 +282,10 @@ class Human: raise _Retry("element is not visible") ox, oy, _ = await self.worlds.frame_geometry(frame) res = await self.worlds.call(frame, "matchRect", box["x"] - ox, box["y"] - oy, - box["width"], box["height"]) + box["width"], box["height"], exact) if res.get("count") != 1: + if exact: + raise RawFallback() raise Error( "cloakbrowser humanize: cannot identify the element behind this ElementHandle " "inside the isolated world (it shares its box with another element); " @@ -291,6 +316,46 @@ class Human: raise _Retry("element was detached from the DOM, retrying") raise _Retry(f"element is not {missing}") + async def _handle_or_raw(self, target: Target, deadline: _Deadline, api: str) -> None: + """For an ElementHandle: cache its exact isolated-world id, or raise + RawFallback at once when it has no box or shares it (Playwright reads + hidden elements; a read must not land on a same-box wrapper).""" + if target.handle is not None: + await self.retry(api, target, deadline, + lambda: self._resolve_handle(target.handle, deadline, exact=True)) + + async def read(self, target: Target, op: str, opts: dict, api: str, *extra: Any) -> Any: + """Read a property through the isolated world. + + Resolve until the selector matches once (Playwright's read semantics), + then read the property in the world the engine already runs in. + """ + deadline = self.deadline(target, opts) + await self._handle_or_raw(target, deadline, api) + r = await self.wait_for(target, [], deadline, api) + res = await self.worlds.call(r.frame, op, r.id, *extra) + if isinstance(res, dict): + if "error" in res: + raise Error(f"{api}: Error: {res['error']}") + return res.get("value") + return res + + async def dispatch(self, target: Target, opts: dict, api: str, event_type: str, + event_init: Any = None) -> None: + """Dispatch a DOM event on the resolved element. + + The event is built by Playwright's own injected helper, evaluated in the + isolated world instead of the page's world. + """ + if _has_handle(event_init): + raise RawFallback() + deadline = self.deadline(target, opts) + await self._handle_or_raw(target, deadline, api) + r = await self.wait_for(target, [], deadline, api) + res = await self.worlds.call(r.frame, "dispatchEvent", r.id, event_type, event_init) + if isinstance(res, dict) and "error" in res: + raise Error(f"{api}: Error: {res['error']}") + async def retry(self, api: str, target: Target, deadline: _Deadline, attempt: Callable[[], Awaitable[Any]]) -> Any: reasons: List[str] = [] diff --git a/cloakbrowser/human/patch.py b/cloakbrowser/human/patch.py index 8646d5f..9f25f40 100644 --- a/cloakbrowser/human/patch.py +++ b/cloakbrowser/human/patch.py @@ -12,7 +12,10 @@ Playwright method. Every humanized method: ``click_count``, ``modifiers``, ``position``, ``trial``, ``force``, ``timeout`` including page defaults and ``0`` = no limit, ``strict``); * raises Playwright ``Error`` / ``TimeoutError`` instead of silently falling - back to Playwright's stock implementation. + back to Playwright's stock implementation. One exception: an ElementHandle + read / dispatch_event on an element the isolated world cannot locate (hidden, + or sharing its box), and any dispatch_event whose event_init holds a JSHandle, + run Playwright's own call (``RawFallback``). """ from __future__ import annotations @@ -24,7 +27,7 @@ import types from typing import Any, Awaitable, Callable, Dict, List, Optional, Tuple from .config import HumanConfig, rand -from .engine import CursorState, Human, Target +from .engine import CursorState, Human, RawFallback, Target _PATCHED = {"sync": False, "async": False} # (class, method name) -> original function @@ -206,6 +209,29 @@ def _handlers(kind: str) -> Dict[str, Handler]: options, elements = _select_options(a) return await h.select_option(t, options, elements, a, api=f"{api}.select_option") + async def input_value(h: Human, impl: Any, a: dict) -> Any: + return await h.read(_target(kind, impl, a), "inputValue", a, api=f"{api}.input_value") + + async def text_content(h: Human, impl: Any, a: dict) -> Any: + return await h.read(_target(kind, impl, a), "textContent", a, api=f"{api}.text_content") + + async def inner_text(h: Human, impl: Any, a: dict) -> Any: + return await h.read(_target(kind, impl, a), "innerText", a, api=f"{api}.inner_text") + + async def inner_html(h: Human, impl: Any, a: dict) -> Any: + return await h.read(_target(kind, impl, a), "innerHTML", a, api=f"{api}.inner_html") + + async def get_attribute(h: Human, impl: Any, a: dict) -> Any: + t = _target(kind, impl, a) + name = a.pop("name") + return await h.read(t, "getAttribute", a, f"{api}.get_attribute", name) + + async def dispatch_event(h: Human, impl: Any, a: dict) -> None: + t = _target(kind, impl, a) + event_type = a.pop("type") + event_init = a.pop("event_init", None) + await h.dispatch(t, a, f"{api}.dispatch_event", event_type, event_init) + async def focus(h: Human, impl: Any, a: dict) -> None: await h.focus(_target(kind, impl, a), a, api=f"{api}.focus", move=kind == "ElementHandle") @@ -227,6 +253,8 @@ def _handlers(kind: str) -> Dict[str, Handler]: "click": click, "dblclick": dblclick, "hover": hover, "tap": tap, "fill": fill, "type": type_, "press": press, "check": check, "uncheck": uncheck, "set_checked": set_checked, "select_option": select_option, "focus": focus, + "input_value": input_value, "text_content": text_content, "inner_text": inner_text, + "inner_html": inner_html, "get_attribute": get_attribute, "dispatch_event": dispatch_event, } if kind in ("Page", "Frame"): return {**common, "drag_and_drop": drag_and_drop} @@ -283,14 +311,20 @@ def _wrap(kind: str, cls: type, name: str, handler: Handler, is_async: bool) -> h = _human_for(kind, self) if h is None: return await orig(self, *args, **kwargs) - return await handler(h, self._impl_obj, bind(self, args, kwargs, human_config)) + try: + return await handler(h, self._impl_obj, bind(self, args, kwargs, human_config)) + except RawFallback: + return await orig(self, *args, **kwargs) else: @functools.wraps(orig) def wrapper(self: Any, *args: Any, human_config: Optional[dict] = None, **kwargs: Any) -> Any: h = _human_for(kind, self) if h is None: return orig(self, *args, **kwargs) - return self._sync(handler(h, self._impl_obj, bind(self, args, kwargs, human_config))) + try: + return self._sync(handler(h, self._impl_obj, bind(self, args, kwargs, human_config))) + except RawFallback: + return orig(self, *args, **kwargs) setattr(cls, name, wrapper) @@ -324,7 +358,9 @@ class _Originals: """``page._original``: raw, un-humanized Playwright calls for this page.""" _PAGE = ("click", "type", "fill", "hover", "dblclick", "select_option", "check", "uncheck", - "press", "tap", "focus", "set_checked", "drag_and_drop", "goto") + "press", "tap", "focus", "set_checked", "drag_and_drop", "goto", + "input_value", "text_content", "inner_text", "inner_html", "get_attribute", + "dispatch_event") def __init__(self, page: Any) -> None: cls, mouse, kb = type(page), page.mouse, page.keyboard diff --git a/cloakbrowser/human/world.py b/cloakbrowser/human/world.py index f0010af..aee17da 100644 --- a/cloakbrowser/human/world.py +++ b/cloakbrowser/human/world.py @@ -68,7 +68,7 @@ const contentsBox = (root) => { const parentOrHost = (n) => n.parentElement || (n.parentNode && n.parentNode.host) || null; const H = { el: (id) => get(id), - matchRect(x, y, w, h) { + matchRect(x, y, w, h, exact) { // An ElementHandle carries no identity this context can read, but // Playwright reports its border box. Find the element with exactly that box. const out = []; @@ -82,6 +82,8 @@ const H = { }; visit(document); if (out.length < 2) return out.length ? { count: 1, id: put(out[0]) } : { count: 0 }; + // A read needs the handle's own element, not a click-equivalent one. + if (exact) return { count: out.length }; // A wrapper and its same-size descendants form one nested chain: a click // lands the same on any of them. Take the element a real click would focus // (the deepest focusable one), else the outermost. Unrelated elements that @@ -120,6 +122,24 @@ const H = { }, connected(id) { return get(id).isConnected; }, value(id) { const e = get(id); return textValue(I.retarget(e, 'follow-label') || e); }, + inputValue(id) { + const e = I.retarget(get(id), 'follow-label'); + if (!e || (e.nodeName !== 'INPUT' && e.nodeName !== 'TEXTAREA' && e.nodeName !== 'SELECT')) + return { error: 'Node is not an ,