telecomkz_scraper/tools/map_screen_controls.py
Iliyas Kyrykbayev 5cb347a44b TelecomKz Analytics Mapper: audit fixes, real event keys, 32 mapped screens
Rebuilt the capture and mapping pipeline after an audit found the simulator's
data could not be trusted:

* Hotspot coordinates never matched the screenshots. Capture now scrolls the
  page over CDP and pastes each frame at the measured scrollY, so image pixels
  and DOM coordinates share one grid by construction.
* Metrics were synthesised (1200 + n*410) and presented as analytics. Numbers
  are now attached only when the catalog has a matching row; metrics.json
  carries a `source` label and the UI says "no data" instead of showing zeros.
* Event interception hooked a connector bridge that never fires. The app posts
  to api.amplitude.com using the legacy form-urlencoded v1 API; the hook now
  reads event_type off the wire. 36 keys are verified as `observed`.
* All device access moved into tools/telecom_cdp.py: dynamic WebView socket
  discovery (the PID was hardcoded), id-matched CDP, measured native geometry.
* Editor edits can now be saved to disk; API failures no longer report success
  from a stale result file; screenId is no longer interpolated into a shell.

Screens went from 7 (with fabricated markup) to 32, all verified: image height
equals map height, no out-of-bounds hotspots, no dead links.

The id_card screenshot has been manually redacted - it showed a national ID.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 18:16:46 +05:00

469 lines
19 KiB
Python

"""
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())