""" Walk every control on one screen: record the event key it really fires, and capture whatever screen it opens. This is the workflow that turns a screen from "drawn" into "mapped". For each DOM control it clicks the element, reads the analytics event off the wire, notes whether the route changed, and - with --capture-new - screenshots and maps the destination. py tools/map_screen_controls.py payments_screen --via "Платежи" --capture-new py tools/map_screen_controls.py main_dashboard --capture-new --skip "Лицевой счет" Why clicks and not taps: DOM clicks reach controls below the fold without scrolling maths, and they cannot miss. Coordinates are only used for native chrome. Safety rails, all learned the hard way on this app: * refuses to touch anything once the app leaves the expected route, and never taps into a PIN screen; * reconnects per control, because opening some sections destroys the CDP target; * a settle long enough that an event chain from the previous control cannot be misattributed to the next one. """ import argparse import json import subprocess import sys import time from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent)) import telecom_cdp as T from amplitude_hook import DRAIN_JS, INSTALL_HOOK_JS from capture_screen import capture, dom_elements_js, slugify_event_key FORBIDDEN_ROUTES = ("pincode", "verification", "login", "auth") LIST_CONTROLS_JS_TEMPLATE = """ (() => { const found = %s; // Only controls that are actually on this screen horizontally. The side drawer is // a fixed panel parked off-screen at x ~= 670 when closed, and its rows stay in the // DOM at full size - without this clamp the walk clicks menu rows while the drawer // is shut and files their keys against the dashboard. // Number repeated captions. A tariff list has five identical "Подробнее" // buttons; without an occurrence index every one of them resolves to the first. const seen = {}; return found.elements .filter(e => e.cssLeft < window.innerWidth && e.cssLeft + e.cssWidth > 0) .map((e, i) => { const n = seen[e.text] = (seen[e.text] || 0) + 1; return { i: i, text: e.text, nth: n - 1, top: Math.round(e.cssTop) }; }); })() """ CLICK_NTH_JS = """ ((wanted, nth) => { const found = %s; const el = document.querySelectorAll( 'button, a[href], [role="button"], [onclick], .menu-list-item, .extra-menu__card,' + ' .bonuses-card, .user-balance-card, .customer-account-card__item,' + ' [class*="banner"], [class*="promo"], .swiper-slide, .nav-link' ); // Re-resolve by caption: the DOM can re-render between listing and clicking. // Match only among controls actually on screen. The closed side drawer keeps // full-size rows parked at x ~= 670, several of whose captions are identical to // dashboard tiles ("Мои услуги", "Платежи") - and the drawer row comes first in // document order, so an unclamped match returns MYSERVICES where the tile really // fires HOMETAPMYSERVICES. // Also require the element to be the one a finger would actually hit there. With // the drawer OPEN both its row and the dashboard tile are on screen and share a // caption; only the row is hittable, and clicking the covered tile silently // measures the wrong control (HOMEPAGEPAYMENTS instead of the drawer's PAYMENTS). const candidates = [...el].filter(node => { const t = (node.innerText || node.getAttribute('aria-label') || '').trim().replace(/\\s+/g, ' '); if (!t || t.slice(0, 80) !== wanted) return false; const r = node.getBoundingClientRect(); return r.left < window.innerWidth && r.right > 0; }); const hittable = candidates.filter(node => { const r = node.getBoundingClientRect(); const x0 = Math.max(r.left, 0), x1 = Math.min(r.right, window.innerWidth); const y0 = Math.max(r.top, 0), y1 = Math.min(r.bottom, window.innerHeight); if (x1 <= x0 || y1 <= y0) return true; // off-screen vertically: cannot test const top = document.elementFromPoint((x0 + x1) / 2, (y0 + y1) / 2); return top && (node.contains(top) || top.contains(node)); }); const pool = hittable.length ? hittable : candidates; const hit = pool[nth || 0] || pool[0]; if (!hit) return { clicked: false }; hit.scrollIntoView({ block: 'center' }); hit.click(); return { clicked: true }; }) """ def slug(text, index): """ Screen id from a caption. Transliterates, because these captions are Cyrillic and dropping non-ASCII characters turns every one of them into the same empty stem. """ key = slugify_event_key(text) # CLICK_MOY_AVTOPLATEZH base = key[6:] if key.startswith("CLICK_") else key base = base.strip("_").lower()[:28] return "scr_" + (base or "x") + "_" + str(index) class Walker: def __init__(self, screen_id, route, settle, capture_new, skip, via=None): self.screen_id = screen_id self.route = route self.settle = settle self.capture_new = capture_new self.skip = [s.lower() for s in skip] self.via = via self.device = T.get_device() self.pairs = [] self.discovered = [] self._session = None # --- plumbing --------------------------------------------------------- def session(self): """ Reuse one CDP session across the walk. T.connect() costs about five adb round trips (lock check, foreground check, pidof, socket scan, forward). Paying that for every route poll made a single screen take minutes. The session is only rebuilt when the page really goes away, which is the case the reconnect exists for. """ if self._session is not None and not getattr(self._session, "closed", False): return self._session _, tgt = T.connect(self.device) self._session = T.CdpSession(tgt["webSocketDebuggerUrl"]) return self._session def drop_session(self): if self._session is not None: self._session.close() self._session = None def adb_key(self, code): subprocess.run( [T.find_adb(), "-s", self.device, "shell", "input", "keyevent", str(code)], capture_output=True, ) def go_back(self): """ Step back inside the WebView rather than pressing the hardware back key. The hardware key closes the whole app once the history is empty, and the app then demands a PIN on relaunch. history.back() can never do that, so it is always tried first; the key is only a fallback when the route refuses to move. """ before = self.current_route() try: self.session().evaluate("window.history.back()") time.sleep(2.0) if self.current_route() != before: return True except T.DeviceError: self.drop_session() # Only now risk the hardware key, and never at the app root. if before in (None, "/"): return False self.adb_key(4) time.sleep(2.0) return self.current_route() != before def current_route(self): try: return self.session().evaluate("location.pathname") except T.DeviceError: self.drop_session() return None def navigate_from_home(self): """Click the dashboard entry point that opens this screen.""" if not self.via: return False for _ in range(5): if self.current_route() == "/": break if not self.go_back(): break if self.current_route() != "/": return False try: s = self.session() js = CLICK_NTH_JS % dom_elements_js(False) hit = json.loads( s.evaluate("JSON.stringify((" + js + ")(" + json.dumps(self.via) + ", 0))") ) except T.DeviceError: self.drop_session() return False if not hit.get("clicked"): print(" entry point " + repr(self.via) + " not found on the dashboard") return False time.sleep(self.settle) return self.current_route() == self.route MENU_BUTTON = (1012, 174) def ensure_drawer(self): """Re-open the side drawer if a navigation closed it.""" if self.drawer_is_open(): return True subprocess.run( [T.find_adb(), "-s", self.device, "shell", "input", "tap", str(self.MENU_BUTTON[0]), str(self.MENU_BUTTON[1])], capture_output=True, ) time.sleep(3.0) return self.drawer_is_open() def back_to_screen(self, tries=5): for _ in range(tries): here = self.current_route() if here == self.route: if self.screen_id == "side_menu" and not self.ensure_drawer(): print(" !! could not re-open the drawer") return False return True if here == "/" and self.via: if self.navigate_from_home(): return True if here and any(bad in here.lower() for bad in FORBIDDEN_ROUTES): # A PIN gate is a normal outcome for some controls. Step back out of # it and carry on rather than abandoning the walk - and never tap # anything while it is on screen. print(" (PIN gate reached; backing out without touching it)") if not self.go_back(): print(" !! stuck on " + here + " - stopping this walk") return False continue if not self.go_back(): break return self.current_route() == self.route # --- the walk --------------------------------------------------------- def list_controls(self): js = LIST_CONTROLS_JS_TEMPLATE % dom_elements_js(False) return json.loads(self.session().evaluate("JSON.stringify(" + js + ")")) def probe(self, caption, nth=0): """Click one control, return (event_key, via, new_route).""" try: session = self.session() session.evaluate(INSTALL_HOOK_JS) session.evaluate(DRAIN_JS) js = CLICK_NTH_JS % dom_elements_js(False) hit = json.loads( session.evaluate( "JSON.stringify((" + js + ")(" + json.dumps(caption) + ", " + str(nth) + "))" ) ) if not hit.get("clicked"): return None, "not found", None except T.DeviceError as exc: self.drop_session() return None, "error: " + str(exc), None # Poll rather than sleep: some controls replace the WebView within a second. amplitude, metrika, new_route = [], [], None deadline = time.time() + self.settle while time.time() < deadline: time.sleep(0.4) try: for item in session.evaluate(DRAIN_JS) or []: if item.get("kind") == "amplitude" and item.get("eventType"): amplitude.append(item["eventType"]) elif item.get("kind") == "metrika-goal" and item.get("eventType"): metrika.append(item["eventType"]) new_route = session.evaluate("location.pathname") except T.DeviceError: new_route = None # target destroyed by this control self.drop_session() break if amplitude: return amplitude[0], "amplitude", new_route if metrika: return metrika[0], "metrika-goal", new_route return None, "no event", new_route DRAWER_OPEN_JS = ( "(() => { const p = document.querySelector('.mobile-navigation');" " return !!p && p.getBoundingClientRect().left < window.innerWidth * 0.5; })()" ) def drawer_is_open(self): try: return bool(self.session().evaluate(self.DRAWER_OPEN_JS)) except T.DeviceError: self.drop_session() return False def run(self): if not self.back_to_screen(): print("Cannot reach " + self.route + "; aborting.") return # The drawer and the dashboard share captions ("Платежи", "Мои услуги", ...). # With it open, a click resolves to whichever comes first in the DOM and the # key would be written onto the wrong control - the drawer's PAYMENTS would # overwrite the tile's HOMEPAGEPAYMENTS. Refuse rather than guess. drawer = self.drawer_is_open() if drawer and self.screen_id != "side_menu": print("ABORT: the side drawer is open; close it before walking " + self.screen_id) return if not drawer and self.screen_id == "side_menu": print("ABORT: the side drawer is closed; open it before walking side_menu") return controls = self.list_controls() print("Controls on " + self.screen_id + ": " + str(len(controls))) for c in controls: caption = c["text"] if any(sk in caption.lower() for sk in self.skip): print(" skip " + caption[:44]) continue if not self.back_to_screen(): break key, via, new_route = self.probe(caption, c.get("nth", 0)) changed = new_route not in (None, self.route) note = key + " [" + via + "]" if key else "(" + via + ")" print(" " + caption[:40].ljust(42) + note + (" -> " + str(new_route) if changed else "")) if key and via in ("amplitude", "metrika-goal"): self.pairs.append((caption, key, via, c.get("nth", 0))) if changed and self.capture_new: self.capture_destination(caption, new_route) if new_route is None: # The page was replaced (in-app browser or another WebView); only the # hardware key can leave that, and the app is not at its root here. self.adb_key(4) time.sleep(2.5) self.write_keys() def capture_destination(self, caption, route): # Several controls lead to a screen that is already in the map (the dashboard # tile and the drawer row both open /payments). Re-capturing it under a new id # would just litter the map with duplicates, so reuse the existing screen and # only record the link. existing = next( (s for s in T.load_app_map().get("screens", []) if s.get("route") == route), None, ) if existing is not None: print(" -> already mapped as " + existing["id"] + ", linking only") self.discovered.append( {"id": existing["id"], "from": caption, "route": route, "caption": caption} ) return screen_id = slug(caption, len(self.discovered) + 1) self.drop_session() # capture() opens its own session to the same page try: res = capture(screen_id, "Экран «" + caption[:34] + "»", "Раздел") print( " captured " + screen_id + " " + str(res["dimensions"]["width"]) + "x" + str(res["dimensions"]["height"]) + " (" + str(len(res["hotspots"])) + " зон)" ) self.discovered.append( {"id": screen_id, "from": caption, "route": route, "caption": caption} ) except T.DeviceError as exc: print(" capture failed: " + str(exc)) def write_keys(self): if not self.pairs: print("\nNo event keys observed on " + self.screen_id + ".") return app_map = T.load_app_map() catalog = T.load_metrics_catalog() source = T.metrics_source() updated = 0 for screen in app_map.get("screens", []): if screen["id"] != self.screen_id: continue for hs in screen.get("hotspots", []): label = (hs.get("label") or "").strip().lower() for caption, key, _via, _nth in self.pairs: if not label or label != caption.strip().lower(): continue existing = hs.get("eventKey") if hs.get("keyConfidence") == "observed" and existing != key: # Two verified readings disagree. Keep the earlier one and # report it: silently replacing a confirmed key with another # destroys evidence and hides the discrepancy. print( " ! conflict on " + hs["label"][:30] + ": keeping " + str(existing) + ", not overwriting with " + key ) break hs["eventKey"] = key hs["keyConfidence"] = "observed" T.attach_metrics(hs, catalog, source) updated += 1 break # Point the control at the screen it was observed to open, so the # simulator can actually follow the transition. for found in self.discovered: if label and label == found["caption"].strip().lower(): hs["targetScreenId"] = found["id"] break if updated: T.save_app_map(app_map) print("\nObserved keys written into " + self.screen_id + ": " + str(updated)) for caption, key, via, nth in self.pairs: suffix = " #" + str(nth + 1) if nth else "" print(" " + key + " <- " + caption[:44] + suffix + " [" + via + "]") if self.discovered: print("\nNew screens captured: " + str(len(self.discovered))) for d in self.discovered: print(" " + d["id"] + " <- " + d["from"][:34] + " route " + str(d["route"])) def main(): parser = argparse.ArgumentParser(description="Map every control on one screen.") parser.add_argument("screen_id") parser.add_argument("--route", default=None, help="Expected route (defaults to the map's)") parser.add_argument("--settle", type=float, default=6.0) parser.add_argument("--capture-new", action="store_true", help="Screenshot screens that open") parser.add_argument("--skip", nargs="*", default=[], help="Caption fragments to leave alone") parser.add_argument( "--via", default=None, help="Exact dashboard caption that opens this screen" ) args = parser.parse_args() route = args.route if not route: app_map = T.load_app_map() screen = next((s for s in app_map["screens"] if s["id"] == args.screen_id), None) route = (screen or {}).get("route") or "/" try: Walker( args.screen_id, route, args.settle, args.capture_new, args.skip, args.via ).run() except T.DeviceError as exc: print("Error: " + str(exc)) return 1 return 0 if __name__ == "__main__": sys.exit(main())