telecomkz_scraper/tools/crawl_and_map_all.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

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