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 ,