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>
203 lines
8.1 KiB
Python
203 lines
8.1 KiB
Python
"""
|
|
Automated crawler: walk the main sections, capture each one, record which analytics
|
|
event the app actually fired for the tap, and write the whole thing into the app map.
|
|
|
|
The event interception is the point of this tool. Keys it records are observed on the
|
|
wire (see tools/amplitude_hook.py), so those hotspots get keyConfidence "observed"
|
|
instead of a guess derived from the button caption. Real keys look like
|
|
HOMEPAGEPAYMENTS, not PAYMENTS_CLICK - the difference decides whether a ClickHouse
|
|
join returns anything.
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
|
|
import telecom_cdp as T
|
|
from capture_screen import capture
|
|
|
|
from amplitude_hook import DRAIN_JS, INSTALL_HOOK_JS
|
|
|
|
|
|
def click_by_text(session, needle):
|
|
"""Click the smallest element whose caption contains `needle`. Returns what it hit."""
|
|
js = (
|
|
"(() => {"
|
|
" const needle = " + json.dumps(needle) + ".toLowerCase();"
|
|
" const sel = 'button, a[href], [role=\"button\"], [onclick], .menu-list-item,"
|
|
" .extra-menu__card, .bonuses-card, .user-balance-card, .customer-account-card__item';"
|
|
" const hits = [...document.querySelectorAll(sel)].filter(el => {"
|
|
" const t = (el.innerText || el.getAttribute('aria-label') || '').toLowerCase();"
|
|
" const r = el.getBoundingClientRect();"
|
|
" return t.includes(needle) && r.width > 20 && r.height > 15;"
|
|
" });"
|
|
" if (!hits.length) return { clicked: false };"
|
|
# The caption also matches every ancestor card; the tightest box is the control.
|
|
" hits.sort((a, b) => {"
|
|
" const ra = a.getBoundingClientRect(), rb = b.getBoundingClientRect();"
|
|
" return (ra.width * ra.height) - (rb.width * rb.height);"
|
|
" });"
|
|
" const el = hits[0];"
|
|
" const before = location.pathname;"
|
|
" el.click();"
|
|
" return { clicked: true, text: (el.innerText || '').trim().slice(0, 60), before: before };"
|
|
"})()"
|
|
)
|
|
return json.loads(session.evaluate("JSON.stringify(" + js + ")"))
|
|
|
|
|
|
def go_home(session, device, home_route="/"):
|
|
"""Return to the dashboard, preferring in-page history over the hardware key."""
|
|
for _ in range(4):
|
|
route = session.evaluate("location.pathname")
|
|
if route == home_route:
|
|
return True
|
|
session.evaluate("window.history.back()")
|
|
time.sleep(1.2)
|
|
T.adb("shell", "input", "keyevent", "4", device=device)
|
|
time.sleep(1.2)
|
|
return session.evaluate("location.pathname") == home_route
|
|
|
|
|
|
DEFAULT_TARGETS = [
|
|
("Мои услуги", "services_screen", "Экран «Мои услуги»", "Услуги"),
|
|
("Трафик", "traffic_screen", "Экран «Трафик»", "Услуги"),
|
|
("Платежи", "payments_screen", "Экран «Платежи»", "Финансы"),
|
|
("Заявки", "orders_screen", "Экран «Заявки»", "Услуги"),
|
|
("Сервисы", "services_catalog", "Каталог сервисов", "Услуги"),
|
|
("Мои бонусы", "bonuses_screen", "Экран «Бонусы»", "Финансы"),
|
|
]
|
|
|
|
|
|
def apply_observed_events(screen_id, observed):
|
|
"""
|
|
Stamp the events the app really fired onto the hotspot that triggered them,
|
|
replacing the caption-derived guess.
|
|
"""
|
|
if not observed:
|
|
return 0
|
|
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"] != screen_id:
|
|
continue
|
|
for hs in screen.get("hotspots", []):
|
|
if hs.get("label", "").strip().lower() in observed:
|
|
hs["eventKey"] = observed[hs["label"].strip().lower()]
|
|
hs["keyConfidence"] = "observed"
|
|
T.attach_metrics(hs, catalog, source)
|
|
updated += 1
|
|
if updated:
|
|
T.save_app_map(app_map)
|
|
return updated
|
|
|
|
|
|
def crawl(targets=DEFAULT_TARGETS, settle=2.0):
|
|
device, target = T.connect()
|
|
print("Crawling on device " + device)
|
|
|
|
home = capture("main_dashboard", "Главный экран (Кабинет)", "Основное")
|
|
print(
|
|
"main_dashboard: "
|
|
+ str(home["dimensions"]["width"])
|
|
+ "x"
|
|
+ str(home["dimensions"]["height"])
|
|
+ ", "
|
|
+ str(len(home["hotspots"]))
|
|
+ " hotspots"
|
|
)
|
|
|
|
session = T.CdpSession(target["webSocketDebuggerUrl"])
|
|
try:
|
|
home_route = session.evaluate("location.pathname") or "/"
|
|
print("Amplitude hook: " + json.dumps(json.loads(session.evaluate("JSON.stringify(" + INSTALL_HOOK_JS + ")"))))
|
|
session.evaluate(DRAIN_JS)
|
|
|
|
observed_on_home = {}
|
|
visited = []
|
|
|
|
for needle, screen_id, name, category in targets:
|
|
print("\n--- " + needle + " -> " + screen_id + " ---")
|
|
hit = click_by_text(session, needle)
|
|
if not hit.get("clicked"):
|
|
print(" no element matching " + repr(needle) + ", skipping")
|
|
continue
|
|
|
|
time.sleep(settle)
|
|
records = json.loads(session.evaluate("JSON.stringify(" + DRAIN_JS + ")")) or []
|
|
# Amplitude's own payload is authoritative; the Metrika goal corroborates it.
|
|
keys = [r.get("eventType") for r in records if r.get("kind") == "amplitude"]
|
|
corroborating = [r.get("eventType") for r in records if r.get("kind") == "metrika-goal"]
|
|
if keys:
|
|
observed_on_home[hit["text"].strip().lower()] = keys[0]
|
|
print(" observed event keys: " + ", ".join(k for k in keys if k))
|
|
elif corroborating:
|
|
observed_on_home[hit["text"].strip().lower()] = corroborating[0]
|
|
print(" observed via Metrika goal: " + ", ".join(k for k in corroborating if k))
|
|
else:
|
|
print(" no analytics event observed for this tap")
|
|
|
|
route = session.evaluate("location.pathname")
|
|
if route == home_route:
|
|
print(" route did not change - probably a modal, skipping capture")
|
|
go_home(session, device, home_route)
|
|
continue
|
|
|
|
# capture() drives its own CDP session; close ours so both do not fight
|
|
# over the same scroll position.
|
|
session.close()
|
|
try:
|
|
result = capture(screen_id, name, category)
|
|
print(
|
|
" captured "
|
|
+ str(result["dimensions"]["width"])
|
|
+ "x"
|
|
+ str(result["dimensions"]["height"])
|
|
+ ", "
|
|
+ str(len(result["hotspots"]))
|
|
+ " hotspots, route "
|
|
+ str(result.get("route"))
|
|
)
|
|
visited.append(screen_id)
|
|
except T.DeviceError as exc:
|
|
print(" capture failed: " + str(exc))
|
|
|
|
_, target = T.connect(device)
|
|
session = T.CdpSession(target["webSocketDebuggerUrl"])
|
|
session.evaluate(INSTALL_HOOK_JS)
|
|
go_home(session, device, home_route)
|
|
time.sleep(1.0)
|
|
|
|
stamped = apply_observed_events("main_dashboard", observed_on_home)
|
|
print("\nCrawl finished. Screens captured: " + str(len(visited) + 1))
|
|
print("Hotspots with an observed (not guessed) event key: " + str(stamped))
|
|
if not observed_on_home:
|
|
print(
|
|
"Note: no analytics events were intercepted, so every event key stays "
|
|
"caption-derived (keyConfidence 'guessed'). Check tools/inspect_page.py amplitude."
|
|
)
|
|
finally:
|
|
session.close()
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description="Crawl TelecomKz and build the full app map.")
|
|
parser.add_argument("--settle", type=float, default=2.0, help="Seconds to wait after each tap")
|
|
args = parser.parse_args()
|
|
try:
|
|
crawl(settle=args.settle)
|
|
except T.DeviceError as exc:
|
|
print(json.dumps({"success": False, "error": str(exc)}, ensure_ascii=False))
|
|
return 1
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|