diff --git a/README.md b/README.md index 31f1cef..3179e56 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,13 @@ Botasaurus Driver provides several handy methods for web automation tasks such a element.select_option("select#fruits", index=2) # Select an option ``` + `type`/`send_keys` dispatch real keydown/keyup events (not a synthetic char per character), so pages that listen for `keydown`/`keyup` or inspect `event.code`/`event.keyCode` see a faithful sequence. Characters without a physical key (emoji, non-Latin scripts) fall back to `insertText`. `\n` and `\r\n` each send one Enter: + ```python + driver.type("textarea", "line one\nline two") + driver.type("textarea", "line one\r\nline two") # CRLF collapsed to a single Enter + driver.type("input[name='note']", "你好 👋") # Unmapped characters use insertText + ``` + - Retrieving element properties: ```python header_text = driver.get_text("h1") # Get text content diff --git a/botasaurus_driver/core/element.py b/botasaurus_driver/core/element.py index 95e73de..359018f 100644 --- a/botasaurus_driver/core/element.py +++ b/botasaurus_driver/core/element.py @@ -9,6 +9,7 @@ from ..driver_utils import create_screenshot_filename, get_download_directory, get_download_filename from . import util +from . import keys from ._contradict import ContraDict from .config import PathLike from .. import cdp @@ -758,9 +759,7 @@ def send_keys(self, text: str): """ self.raise_if_disconnected() self.apply("(elem) => elem.focus()") - for char in list(text): - self._tab.send(cdp.input_.dispatch_key_event("char", text=char)) - + keys.type_text(self._tab, text) self.update() def send_file(self, *file_paths): """ diff --git a/botasaurus_driver/core/keys.py b/botasaurus_driver/core/keys.py new file mode 100644 index 0000000..3ce9b48 --- /dev/null +++ b/botasaurus_driver/core/keys.py @@ -0,0 +1,124 @@ +"""US-layout key maps and resolver for real CDP key-event typing. + +Mapped characters are dispatched as keyDown (with text) + keyUp, with Shift +wrapping when needed. Unmapped characters (Unicode, emoji, dead-key output) +fall back to Input.insertText. +""" +from .. import cdp + +KEYBOARD_LAYOUT = { + "a": "KeyA", "b": "KeyB", "c": "KeyC", "d": "KeyD", "e": "KeyE", "f": "KeyF", + "g": "KeyG", "h": "KeyH", "i": "KeyI", "j": "KeyJ", "k": "KeyK", "l": "KeyL", + "m": "KeyM", "n": "KeyN", "o": "KeyO", "p": "KeyP", "q": "KeyQ", "r": "KeyR", + "s": "KeyS", "t": "KeyT", "u": "KeyU", "v": "KeyV", "w": "KeyW", "x": "KeyX", + "y": "KeyY", "z": "KeyZ", + "0": "Digit0", "1": "Digit1", "2": "Digit2", "3": "Digit3", "4": "Digit4", + "5": "Digit5", "6": "Digit6", "7": "Digit7", "8": "Digit8", "9": "Digit9", + " ": "Space", "\n": "Enter", "\r": "Enter", "\t": "Tab", + ".": "Period", ",": "Comma", "-": "Minus", "=": "Equal", "/": "Slash", + "\\": "Backslash", ";": "Semicolon", "'": "Quote", + "[": "BracketLeft", "]": "BracketRight", "`": "Backquote", + "@": "Digit2", "!": "Digit1", "#": "Digit3", "$": "Digit4", "%": "Digit5", + "^": "Digit6", "&": "Digit7", "*": "Digit8", "(": "Digit9", ")": "Digit0", + "_": "Minus", "+": "Equal", +} + +SHIFT_CHARS = set('ABCDEFGHIJKLMNOPQRSTUVWXYZ!@#$%^&*()_+{}|:"<>?~') + +VK_CODES = { + "0": 48, "1": 49, "2": 50, "3": 51, "4": 52, "5": 53, "6": 54, + "7": 55, "8": 56, "9": 57, + "a": 65, "b": 66, "c": 67, "d": 68, "e": 69, "f": 70, "g": 71, + "h": 72, "i": 73, "j": 74, "k": 75, "l": 76, "m": 77, "n": 78, + "o": 79, "p": 80, "q": 81, "r": 82, "s": 83, "t": 84, "u": 85, + "v": 86, "w": 87, "x": 88, "y": 89, "z": 90, + " ": 32, "\n": 13, "\r": 13, "\t": 9, + ".": 190, ",": 188, "-": 189, "=": 187, "/": 191, "\\": 220, + ";": 186, "'": 222, "`": 192, "[": 219, "]": 221, + "@": 50, "!": 49, "#": 51, "$": 52, "%": 53, "^": 54, "&": 55, + "*": 56, "(": 57, ")": 48, "_": 189, "+": 187, + "{": 219, "}": 221, "|": 220, ":": 186, '"': 222, + "<": 188, ">": 190, "?": 191, "~": 192, +} + + +def resolve_key(char): + """Return (code, key, vk, needs_shift) for a US-layout char, else None.""" + if char.isalpha(): + code = KEYBOARD_LAYOUT.get(char.lower()) + else: + code = KEYBOARD_LAYOUT.get(char) + if code is None: + return None + if char in ("\n", "\r"): + return ("Enter", "Enter", 13, False) + if char == "\t": + return ("Tab", "Tab", 9, False) + vk = VK_CODES[char.lower() if char.isalpha() else char] + return (code, char, vk, char in SHIFT_CHARS) + + +def dispatch_key(tab, char): + """Dispatch keyDown + keyUp (with Shift wrapping) for one mapped char.""" + code, key_val, vk, needs_shift = resolve_key(char) + text_val = "\r" if char in ("\n", "\r") else char + modifiers = 8 if needs_shift else 0 + if needs_shift: + tab.send( + cdp.input_.dispatch_key_event( + "rawKeyDown", + code="ShiftLeft", + key="Shift", + windows_virtual_key_code=16, + native_virtual_key_code=16, + modifiers=8, + location=1, + ) + ) + tab.send( + cdp.input_.dispatch_key_event( + "keyDown", + text=text_val, + unmodified_text=char, + code=code, + key=key_val, + windows_virtual_key_code=vk, + native_virtual_key_code=vk, + modifiers=modifiers, + ) + ) + tab.send( + cdp.input_.dispatch_key_event( + "keyUp", + code=code, + key=key_val, + windows_virtual_key_code=vk, + native_virtual_key_code=vk, + modifiers=modifiers, + ) + ) + if needs_shift: + tab.send( + cdp.input_.dispatch_key_event( + "keyUp", + code="ShiftLeft", + key="Shift", + windows_virtual_key_code=16, + native_virtual_key_code=16, + location=1, + ) + ) + + +def type_text(tab, text): + """Type ``text`` via real key events, collapsing ``\\r\\n`` to one Enter.""" + i = 0 + n = len(text) + while i < n: + char = text[i] + crlf = char == "\r" and i + 1 < n and text[i + 1] == "\n" + if resolve_key(char) is None: + tab.send(cdp.input_.insert_text(char)) + else: + dispatch_key(tab, char) + i += 2 if crlf else 1 diff --git a/botasaurus_driver/core/tab.py b/botasaurus_driver/core/tab.py index 8e510e6..2cf875b 100644 --- a/botasaurus_driver/core/tab.py +++ b/botasaurus_driver/core/tab.py @@ -10,6 +10,7 @@ from ..driver_utils import create_screenshot_filename, get_download_directory, get_download_filename from . import element +from . import keys from . import util from .config import PathLike from .connection import Connection @@ -1557,7 +1558,12 @@ def bypass_insecure_connection_warning(self): :rtype: """ body = self.select("body") - body.send_keys("thisisunsafe") + body.apply("(elem) => elem.focus()") + # Type directly on the tab: typing "thisisunsafe" makes Chrome advance + # past the interstitial, which navigates the page and invalidates the + # body node, so Element.send_keys' trailing self.update() would raise a + # stale-node error. Going through the keyboard helper avoids that. + keys.type_text(self, "thisisunsafe") def mouse_move(self, x: float, y: float, steps=10, flash=False): self.send(cdp.input_.dispatch_mouse_event("mouseMoved", x=x, y=y)) diff --git a/demo_typing.py b/demo_typing.py new file mode 100644 index 0000000..770e75d --- /dev/null +++ b/demo_typing.py @@ -0,0 +1,386 @@ +#!/usr/bin/env python3 +"""Gate for real key-event Element.send_keys. Run: python3 demo_typing.py + +Exits non-zero on FAIL. SKIP (unreachable badssl host) is not a failure. +""" +import os +import socket +import string +import sys +import tempfile +import time +from pathlib import Path + +_ROOT = os.path.dirname(os.path.abspath(__file__)) +if _ROOT not in sys.path: + sys.path.insert(0, _ROOT) + +from botasaurus_driver import Driver + +# Characters that are intentionally unmapped and go through Input.insertText. +INSERT_TEXT_CHARS = set('{}|:"<>?~\x0b\x0c') +BADSSL_HOST = "self-signed.badssl.com" +BADSSL_URL = "https://self-signed.badssl.com/" +SAMPLE = "A+b@c_D.e" +UNICODE_SAMPLE = "é中😀" + +HARNESS_HTML = """ + +typing gate + + + +
+ +
+ + + + +""" + + +class Skip(Exception): + pass + + +def check(cond, msg): + if not cond: + raise AssertionError(msg) + + +def mapped_printable(): + return [c for c in string.printable if c not in INSERT_TEXT_CHARS] + + +def js(driver, expr): + return driver.run_js("return " + expr) + + +def get_events(driver): + ev = js(driver, "window.__getEvents()") + return ev if ev else [] + + +def key_seq(events): + return [ + (e.get("type"), e.get("key")) + for e in events + if e.get("type") in ("keydown", "keypress", "keyup") + ] + + +def key_edges(events): + return [e for e in events if e.get("type") in ("keydown", "keyup")] + + +def reset_capture(driver): + driver.run_js("window.__reset();") + + +def type_in(driver, selector, text): + el = driver.select(selector) + check(el is not None, "missing element " + selector) + el.type(text) + return el + + +def clear_value(driver, selector): + el = driver.select(selector) + check(el is not None, "missing element " + selector) + el.run_js("(el) => { el.value = ''; }") + return el + + +def load_harness(driver, uri): + driver.get(uri) + reset_capture(driver) + + +def test_ordering_and_count(driver): + clear_value(driver, "#type-input") + reset_capture(driver) + type_in(driver, "#type-input", "ab") + got = key_seq(get_events(driver)) + want = [ + ("keydown", "a"), + ("keypress", "a"), + ("keyup", "a"), + ("keydown", "b"), + ("keypress", "b"), + ("keyup", "b"), + ] + check(got == want, "ordering/count for 'ab': got %r want %r" % (got, want)) + + +def test_code_never_empty(driver): + for ch in mapped_printable(): + el = clear_value(driver, "#type-input") + el.run_js("(el) => el.focus()") + reset_capture(driver) + el.type(ch) + events = get_events(driver) + edges = key_edges(events) + check( + edges, + "mapped char %r produced no keydown/keyup (missing KEYBOARD_LAYOUT entry?)" + % ch, + ) + for e in edges: + check( + e.get("code"), + "empty code for %r on %s (key=%r)" + % (ch, e.get("type"), e.get("key")), + ) + + +def test_keycode_fidelity(driver): + clear_value(driver, "#type-input") + reset_capture(driver) + type_in(driver, "#type-input", "a") + events = get_events(driver) + downs = [e for e in events if e.get("type") == "keydown" and e.get("key") == "a"] + presses = [e for e in events if e.get("type") == "keypress" and e.get("key") == "a"] + check(downs, "no keydown for 'a'") + check(presses, "no keypress for 'a'") + check( + downs[0].get("keyCode") == 65, + "keydown.keyCode for 'a' is %r want 65" % downs[0].get("keyCode"), + ) + check( + presses[0].get("keyCode") == 97, + "keypress.keyCode for 'a' is %r want 97" % presses[0].get("keyCode"), + ) + + +def test_shift_state_and_pairing(driver): + clear_value(driver, "#type-input") + reset_capture(driver) + type_in(driver, "#type-input", "@") + events = [ + e + for e in get_events(driver) + if e.get("type") in ("keydown", "keypress", "keyup") + ] + shift_downs = [ + i + for i, e in enumerate(events) + if e.get("type") == "keydown" and e.get("key") == "Shift" + ] + at_idxs = [i for i, e in enumerate(events) if e.get("key") == "@"] + check(shift_downs, "no Shift keydown before @") + check(at_idxs, "no @ events") + check( + shift_downs[0] < at_idxs[0], + "Shift keydown must precede @ (Shift at %s, @ at %s)" + % (shift_downs[0], at_idxs[0]), + ) + for e in (events[i] for i in at_idxs): + check(e.get("shiftKey") is True, "@ event missing shiftKey: %r" % e) + check(e.get("code") == "Digit2", "@ code is %r want Digit2" % e.get("code")) + check(e.get("key") == "@", "@ key is %r" % e.get("key")) + shift_events = [e for e in events if e.get("key") == "Shift"] + ups = [e for e in shift_events if e.get("type") == "keyup"] + downs = [e for e in shift_events if e.get("type") == "keydown"] + check(ups, "no Shift keyup after @") + check(ups[-1].get("shiftKey") is False, "Shift keyup should have shiftKey false") + check(len(downs) == len(ups), "orphan Shift: %s downs, %s ups" % (len(downs), len(ups))) + check( + shift_events[-1].get("type") == "keyup", + "last Shift event is %s, not keyup" % shift_events[-1].get("type"), + ) + + +def test_newline_contract(driver): + for text in ("\n", "\r", "\r\n"): + clear_value(driver, "#form-input") + reset_capture(driver) + type_in(driver, "#form-input", text) + events = get_events(driver) + downs = [ + e + for e in events + if e.get("type") == "keydown" + and e.get("key") == "Enter" + and e.get("code") == "Enter" + ] + submits = js(driver, "window.__submits") + label = repr(text) + check(len(downs) == 1, "%s: want 1 Enter keydown, got %s" % (label, len(downs))) + check(submits == 1, "%s: want 1 form submit, got %r" % (label, submits)) + + reset_capture(driver) + type_in(driver, "#type-input", "\t") + events = get_events(driver) + tabs = [ + e + for e in events + if e.get("type") in ("keydown", "keyup") and e.get("key") == "Tab" + ] + check(tabs, "no Tab keydown/keyup") + check( + all(e.get("key") == "Tab" and e.get("code") == "Tab" for e in tabs), + "Tab event key/code mismatch: %r" % tabs, + ) + active = js(driver, "document.activeElement && document.activeElement.id") + check( + active and active != "type-input", + "Tab did not move document.activeElement (still %r)" % active, + ) + + reset_capture(driver) + type_in(driver, "#space-btn", " ") + clicks = js(driver, "window.__clicks") + check(clicks == 1, "space on button: want 1 click, got %r" % clicks) + + +def test_unicode_round_trip(driver): + el = clear_value(driver, "#type-input") + reset_capture(driver) + el.type(UNICODE_SAMPLE) + check( + el.value == UNICODE_SAMPLE, + "unicode value %r want %r" % (el.value, UNICODE_SAMPLE), + ) + + +def test_final_value(driver): + el = clear_value(driver, "#type-input") + reset_capture(driver) + el.type(SAMPLE) + check(el.value == SAMPLE, "final value %r want %r" % (el.value, SAMPLE)) + + +def _badssl_reachable(): + try: + socket.create_connection((BADSSL_HOST, 443), timeout=5).close() + return True + except OSError: + return False + + +def test_bypass_insecure_connection_warning(driver): + if not _badssl_reachable(): + raise Skip("%s unreachable" % BADSSL_URL) + try: + driver.get(BADSSL_URL, timeout=20) + except Exception as e: + raise Skip("could not open %s: %s" % (BADSSL_URL, e)) + driver.allow_insecure_connections() + time.sleep(1.5) + blob = " ".join( + [ + str(driver.title or ""), + str(driver.page_text or ""), + str(js(driver, "location.hostname") or ""), + ] + ).lower() + still_blocked = ( + "your connection is not private" in blob + or "net::err_cert" in blob + or "privacy error" in blob + ) + check(not still_blocked, "still on certificate warning after bypass") + check( + "badssl" in blob or "self-signed" in blob, + "bypass did not reach the badssl page (title/text=%r)" % blob[:200], + ) + + +HARNESS_TESTS = ( + test_ordering_and_count, + test_code_never_empty, + test_keycode_fidelity, + test_shift_state_and_pairing, + test_newline_contract, + test_unicode_round_trip, + test_final_value, +) + + +def main(): + html_path = None + driver = None + failed = 0 + fd, html_path = tempfile.mkstemp(suffix=".html") + try: + with os.fdopen(fd, "w", encoding="utf-8") as fh: + fh.write(HARNESS_HTML) + uri = Path(html_path).as_uri() + driver = Driver(headless=True) + + results = [] + for fn in HARNESS_TESTS: + load_harness(driver, uri) + results.append(_run(fn, driver)) + results.append(_run(test_bypass_insecure_connection_warning, driver)) + + for status, name, detail in results: + if detail: + print("%s %s: %s" % (status, name, detail)) + else: + print("%s %s" % (status, name)) + if status == "FAIL": + failed += 1 + finally: + if driver is not None: + try: + driver.close() + except Exception: + pass + if html_path and os.path.exists(html_path): + os.remove(html_path) + sys.exit(1 if failed else 0) + + +def _run(fn, driver): + name = fn.__name__ + try: + fn(driver) + return ("PASS", name, "") + except Skip as e: + return ("SKIP", name, str(e)) + except Exception as e: + return ("FAIL", name, str(e)) + + +if __name__ == "__main__": + main()