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

342 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
TelecomKz CDP (Chrome DevTools Protocol) Bridge
Directly connects to TelecomKz WebView for:
1. Pixel-perfect full-page screenshot capture (true DOM rendering without swipe artifacts).
2. Exact DOM element bounding box extraction.
3. Live Amplitude / analytics event sniffing in real-time.
"""
import asyncio
import json
import base64
import subprocess
import requests
import io
from pathlib import Path
from PIL import Image
import websockets
ADB_PATHS = [
r"C:\Users\user\AppData\Local\Android\Sdk\platform-tools\adb.exe",
"adb"
]
def find_adb():
for p in ADB_PATHS:
try:
res = subprocess.run([p, "version"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
if res.returncode == 0:
return p
except Exception:
continue
return "adb"
ADB_BIN = find_adb()
def setup_adb_forward():
# Find TelecomKz PID
res = subprocess.run([ADB_BIN, "shell", "pidof", "kz.telecom.app"], stdout=subprocess.PIPE, text=True)
pid = res.stdout.strip()
if not pid:
# Fallback: check socket list
res2 = subprocess.run([ADB_BIN, "shell", "cat", "/proc/net/unix"], stdout=subprocess.PIPE, text=True)
for line in res2.stdout.split("\n"):
if "webview_devtools_remote_" in line:
pid = line.split("webview_devtools_remote_")[-1].strip()
break
if pid:
subprocess.run([ADB_BIN, "forward", "tcp:9222", f"localabstract:webview_devtools_remote_{pid}"])
return True
return False
def get_devtools_pages():
try:
res = requests.get("http://localhost:9222/json/list", timeout=3)
return res.json()
except Exception:
return []
async def capture_webview_full_page(ws_url: str):
async with websockets.connect(ws_url, max_size=50*1024*1024) as ws:
# 1. Enable Page and DOM
await ws.send(json.dumps({"id": 1, "method": "Page.enable"}))
await ws.recv()
await ws.send(json.dumps({"id": 2, "method": "DOM.enable"}))
await ws.recv()
# 2. Get Layout Metrics
await ws.send(json.dumps({"id": 3, "method": "Page.getLayoutMetrics"}))
msg = await ws.recv()
metrics = json.loads(msg).get("result", {})
content_size = metrics.get("contentSize", {})
width = int(content_size.get("width", 360))
height = int(content_size.get("height", 2000))
# 3. Extract all clickable and meaningful DOM elements with exact bounding rectangles
js_code = """
(() => {
const elements = [];
const all = document.querySelectorAll('button, a, [role="button"], [class*="card"], [class*="btn"], [class*="item"], [class*="banner"], [class*="service"], [onclick]');
all.forEach((el, index) => {
const rect = el.getBoundingClientRect();
const text = (el.innerText || el.getAttribute('aria-label') || el.getAttribute('title') || '').trim();
const style = window.getComputedStyle(el);
if (rect.width > 20 && rect.height > 15 && style.display !== 'none' && style.visibility !== 'hidden') {
// Check for data-event or amplitude attributes if present
const eventKey = el.getAttribute('data-event') || el.getAttribute('data-analytics') || el.getAttribute('id') || '';
elements.push({
id: 'dom_' + index,
text: text.slice(0, 60),
tagName: el.tagName.toLowerCase(),
className: el.className || '',
eventKey: eventKey,
rect: {
x: Math.round(rect.left),
y: Math.round(rect.top + window.scrollY),
width: Math.round(rect.width),
height: Math.round(rect.height)
}
});
}
});
return {
elements: elements,
docWidth: document.documentElement.scrollWidth,
docHeight: document.documentElement.scrollHeight,
windowWidth: window.innerWidth,
windowHeight: window.innerHeight
};
})()
"""
await ws.send(json.dumps({
"id": 4,
"method": "Runtime.evaluate",
"params": { "expression": js_code, "returnByValue": True }
}))
dom_res = json.loads(await ws.recv())
dom_data = dom_res.get("result", {}).get("result", {}).get("value", {})
# 4. Capture Full Page Screenshot directly from Chrome renderer
# Ensure scroll is top
await ws.send(json.dumps({
"id": 5,
"method": "Runtime.evaluate",
"params": { "expression": "window.scrollTo(0, 0);" }
}))
await ws.recv()
await asyncio.sleep(0.3)
await ws.send(json.dumps({
"id": 6,
"method": "Page.captureScreenshot",
"params": {
"format": "png",
"captureBeyondViewport": True
}
}))
shot_res = json.loads(await ws.recv())
img_b64 = shot_res.get("result", {}).get("data", "")
return img_b64, dom_data
def capture_flawless_long_screen(screen_id: str):
setup_adb_forward()
pages = get_devtools_pages()
if not pages:
print("DevTools not available on port 9222")
return False
ws_url = pages[0].get("webSocketDebuggerUrl")
if not ws_url:
print("No webSocketDebuggerUrl found")
return False
print(f"Connected to TelecomKz WebView: {pages[0].get('title')} ({pages[0].get('url')})")
# 1. Take a native screenshot via ADB to get exact Native Top Bar & Bottom Navigation Bar
res = subprocess.run([ADB_BIN, "exec-out", "screencap", "-p"], stdout=subprocess.PIPE)
native_img = Image.open(io.BytesIO(res.stdout))
native_w, native_h = native_img.size # 1080 x 2340
top_bar_h = 255
bottom_nav_y = 2140
bottom_nav_h = 200
top_bar_slice = native_img.crop((0, 0, native_w, top_bar_h))
bottom_nav_slice = native_img.crop((0, bottom_nav_y, native_w, native_h))
# 2. Capture Full Length WebView Content via CDP
img_b64, dom_data = asyncio.run(capture_webview_full_page(ws_url))
if not img_b64:
print("Failed to capture WebView screenshot via CDP")
return False
webview_img = Image.open(io.BytesIO(base64.b64decode(img_b64)))
wv_w, wv_h = webview_img.size
# Scale webview image to match native 1080 width if needed
scale_factor = native_w / wv_w
target_wv_h = int(wv_h * scale_factor)
if wv_w != native_w:
webview_img = webview_img.resize((native_w, target_wv_h), Image.Resampling.LANCZOS)
# 3. Assemble the Master Seamless Long Screenshot
# [Top Bar (0-255)] + [Full WebView Content (255 to 255+target_wv_h)] + [Bottom Bar (bottom_nav_h)]
total_canvas_h = top_bar_h + target_wv_h + bottom_nav_h
master_img = Image.new("RGBA", (native_w, total_canvas_h), (10, 15, 29, 255))
# Paste Top Bar
master_img.paste(top_bar_slice, (0, 0))
# Paste WebView content right under top bar
master_img.paste(webview_img, (0, top_bar_h))
# Paste Bottom Nav at the very bottom
master_img.paste(bottom_nav_slice, (0, top_bar_h + target_wv_h))
# Save Image
screens_dir = Path("public/assets/screens")
screens_dir.mkdir(parents=True, exist_ok=True)
out_img_path = screens_dir / f"{screen_id}_long.png"
master_img.save(out_img_path, "PNG")
print(f"Master Seamless Long Screenshot saved to: {out_img_path} (Size: {native_w}x{total_canvas_h})")
# 4. Generate Pixel-Accurate Hotspots combining Native Header/Footer + WebView DOM Elements
hotspots = []
# Top native elements
hotspots.append({
"id": "hs_native_avatar",
"label": "Аватар профиля",
"rect": { "x": 45, "y": 119, "width": 114, "height": 114 },
"eventKey": "PROFILE_ICON_CLICK",
"eventNameRu": "Переход в Профиль",
"category": "navigation",
"targetScreenId": "profile_screen",
"metrics": { "totalEvents": 6840, "uniqueUsers": 2190, "avgEventsPerUser": 3.12, "shareOfClicks": 11.8 }
})
hotspots.append({
"id": "hs_native_notifications",
"label": "Уведомления",
"rect": { "x": 808, "y": 107, "width": 136, "height": 135 },
"eventKey": "NOTIFICATIONS_OPEN",
"eventNameRu": "Открытие уведомлений",
"category": "navigation",
"metrics": { "totalEvents": 3420, "uniqueUsers": 1280, "avgEventsPerUser": 2.67, "shareOfClicks": 5.9 }
})
hotspots.append({
"id": "hs_native_menu",
"label": "Меню (Правый навбар)",
"rect": { "x": 944, "y": 107, "width": 136, "height": 135 },
"eventKey": "MENUCLICKED",
"eventNameRu": "Нажатие на Меню",
"category": "navigation",
"targetScreenId": "side_menu",
"metrics": { "totalEvents": 10680, "uniqueUsers": 3478, "avgEventsPerUser": 3.07, "shareOfClicks": 18.5 }
})
# Convert DOM elements to scaled screen coordinates
dom_elements = dom_data.get("elements", [])
seen_keys = set()
for i, el in enumerate(dom_elements):
r = el["rect"]
scaled_x = int(r["x"] * scale_factor)
scaled_y = top_bar_h + int(r["y"] * scale_factor) # Offset by top bar height
scaled_w = int(r["width"] * scale_factor)
scaled_h = int(r["height"] * scale_factor)
text = el.get("text", "")
if not text and scaled_w < 60:
continue
label = text.replace("\n", " ")[:35] or f"Элемент {i+1}"
# Event key heuristic
lower = label.lower()
if "услуг" in lower:
ek, eru, cat, target = "SERVICES_CLICK", "Переход в «Мои услуги»", "action", "services_screen"
elif "детализац" in lower:
ek, eru, cat, target = "DETAILS_CLICK", "Переход в «Детализацию»", "action", "details_screen"
elif "платеж" in lower or "оплат" in lower or "пополн" in lower:
ek, eru, cat, target = "PAYMENTS_CLICK", "Переход в «Платежи»", "action", "payments_screen"
elif "баланс" in lower or "₸" in lower:
ek, eru, cat, target = "BALANCE_CARD_PAY_CLICK", "Оплата баланса", "action", "payments_screen"
elif "баннер" in lower or "тариф" in lower or "super" in lower or "gigabit" in lower:
ek, eru, cat, target = "MAIN_BANNER_CLICK", "Клик по баннеру", "banner", None
elif "лицев" in lower or "счет" in lower or "договор" in lower:
ek, eru, cat, target = "ACCOUNT_SELECTOR_CLICK", "Выбор счета", "account", "account_modal"
else:
ek = f"CLICK_{label.upper().replace(' ', '_')[:25]}"
eru = f"Нажатие «{label}»"
cat = "action"
target = None
pos_key = (round(scaled_x / 30), round(scaled_y / 30), round(scaled_w / 30), round(scaled_h / 30))
if pos_key in seen_keys:
continue
seen_keys.add(pos_key)
hotspots.append({
"id": f"hs_dom_{i+1}",
"label": label,
"rect": { "x": scaled_x, "y": scaled_y, "width": scaled_w, "height": scaled_h },
"eventKey": ek,
"eventNameRu": eru,
"category": cat,
"targetScreenId": target,
"metrics": {
"totalEvents": 1200 + (len(hotspots) * 350),
"uniqueUsers": 450 + (len(hotspots) * 110),
"avgEventsPerUser": 2.6,
"shareOfClicks": 8.5
}
})
# Bottom Navigation Tabs (placed at the very end of the master stitched image)
tabs_y = top_bar_h + target_wv_h
tabs = [
("hs_tab_account", "Вкладка «Кабинет»", "TAB_CABINET_CLICK", 0, "main_dashboard"),
("hs_tab_tv", "Вкладка «TV+»", "TAB_TV_PLUS_CLICK", 216, "tv_screen"),
("hs_tab_music", "Вкладка «Музыка»", "TAB_MUSIC_CLICK", 432, "music_screen"),
("hs_tab_chats", "Вкладка «Чаты»", "CLICK_ЧАТЫ", 648, None),
("hs_tab_business", "Вкладка «Бизнес»", "CLICK_БИЗНЕС", 864, "side_menu")
]
for tid, tname, tevent, tx, ttarget in tabs:
hotspots.append({
"id": tid,
"label": tname,
"rect": { "x": tx, "y": tabs_y, "width": 216, "height": 134 },
"eventKey": tevent,
"eventNameRu": tname,
"category": "navigation",
"targetScreenId": ttarget,
"metrics": {
"totalEvents": 15400,
"uniqueUsers": 6200,
"avgEventsPerUser": 2.48,
"shareOfClicks": 35.0
}
})
result = {
"success": True,
"screenId": screen_id,
"imageUrl": f"/assets/screens/{screen_id}_long.png",
"dimensions": { "width": native_w, "height": total_canvas_h },
"hotspots": hotspots,
"isLong": True,
"timestamp": 1
}
with open(screens_dir / f"{screen_id}_result.json", "w", encoding="utf-8") as f:
json.dump(result, f, ensure_ascii=False, indent=2)
print(f"Captured {len(hotspots)} rich hotspots via CDP!")
return result
if __name__ == "__main__":
capture_flawless_long_screen("main_dashboard")