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

192 lines
7.2 KiB
Python

"""
The in-page hook that captures TelecomKz analytics events as they are sent.
Why this exists in this shape
----------------------------
The previous implementation hooked
window.analyticsConnectorInstances['$default_instance'].eventBridge.setEventReceiver
Verified against the live app on 2026-08-24: that hook attaches (two connector
instances, both exposing setEventReceiver) but never fires. The connector bridge is
Amplitude's cross-SDK channel for Experiment/Session Replay, not the send path.
What the app actually does, captured over CDP Network while tapping "Платежи":
POST https://api.amplitude.com/ (XHR, body type String)
checksum=cca2ae...&client=<apiKey>&e=%5B%7B...%22event_type%22%3A%22HOMEPAGEPAYMENTS%22...
POST https://mc.yandex.ru/watch/96490559/1?page-url=goal://customer.telecom.kz/HOMEPAGEPAYMENTS
Two things that decide how this file is written:
1. It is the LEGACY Amplitude HTTP API v1: an application/x-www-form-urlencoded
body whose `e` parameter holds a URL-encoded JSON array of events. It is NOT
the V2 shape {"events":[...]}, so JSON.parse() on the raw body throws. Both
shapes are handled below, v1 first.
2. The Yandex Metrika goal name is identical to the Amplitude event_type - verified
on the same taps (HOMEPAGEPAYMENTS, OPENWINDOWPAYMENT in both channels). Metrika
is therefore a usable fallback when the Amplitude body cannot be read.
Real key shape: HOMEPAGEPAYMENTS, HOMETAPMYSERVICES, OPENSCREENAPPEALS. Not
PAYMENTS_CLICK. Keys invented from button captions match nothing in ClickHouse.
The Amplitude payload also carries user_id, device_id and the project write key.
This hook deliberately extracts only event_type and never persists the raw body.
"""
INSTALL_HOOK_JS = r"""
(() => {
window.__tkEvents = window.__tkEvents || [];
if (window.__tkHookInstalled) return { already: true, transports: window.__tkTransports || [] };
const push = (kind, payload) =>
window.__tkEvents.push(Object.assign({ kind: kind, t: Date.now(), route: location.pathname }, payload));
const emitEvents = events => {
if (!Array.isArray(events)) return false;
let emitted = false;
events.forEach(ev => {
if (ev && ev.event_type) {
push('amplitude', { eventType: ev.event_type });
emitted = true;
}
});
return emitted;
};
const readAmplitudeBody = body => {
if (!body || typeof body !== 'string') return;
// Legacy HTTP API v1: form-urlencoded, events live in the `e` parameter.
if (body.indexOf('e=') !== -1 && body.indexOf('checksum=') !== -1) {
try {
const params = new URLSearchParams(body);
const raw = params.get('e');
if (raw && emitEvents(JSON.parse(raw))) return;
} catch (err) { /* fall through to V2 */ }
}
// HTTP API V2: {"api_key": "...", "events": [...]}
try {
const parsed = JSON.parse(body);
emitEvents(parsed && parsed.events);
} catch (err) { /* not a payload we understand */ }
};
// Metrika encodes the goal name in the page-url parameter as goal://host/NAME.
const readMetrikaGoal = url => {
const m = /goal(?:%3A%2F%2F|:\/\/)[^/%]*(?:%2F|\/)([A-Z0-9_]+)/i.exec(String(url));
if (m) push('metrika-goal', { eventType: m[1] });
};
const inspect = (url, body) => {
const u = String(url || '');
if (/api\.amplitude\.com|amplitude\.com\/2\/httpapi/i.test(u)) readAmplitudeBody(body);
if (/mc\.yandex\.ru/i.test(u)) readMetrikaGoal(u);
};
const transports = [];
// 1. fetch
const origFetch = window.fetch;
if (origFetch && !origFetch.__tkWrapped) {
const wrapped = function (...args) {
try {
const req = args[0];
const url = typeof req === 'string' ? req : (req && req.url) || '';
const body = args[1] && args[1].body;
inspect(url, typeof body === 'string' ? body : null);
} catch (e) { /* never break the app */ }
return origFetch.apply(this, args);
};
wrapped.__tkWrapped = true;
window.fetch = wrapped;
transports.push('fetch');
}
// 2. XMLHttpRequest - what the Amplitude browser SDK actually uses
const XHR = window.XMLHttpRequest && window.XMLHttpRequest.prototype;
if (XHR && !XHR.__tkWrapped) {
const origOpen = XHR.open;
const origSend = XHR.send;
XHR.open = function (method, url, ...rest) {
this.__tkUrl = url;
return origOpen.call(this, method, url, ...rest);
};
XHR.send = function (body) {
try { inspect(this.__tkUrl, typeof body === 'string' ? body : null); } catch (e) { /* ignore */ }
return origSend.call(this, body);
};
XHR.__tkWrapped = true;
transports.push('xhr');
}
// 3. sendBeacon - used on page hide
if (navigator.sendBeacon && !navigator.sendBeacon.__tkWrapped) {
const origBeacon = navigator.sendBeacon.bind(navigator);
const wrapped = function (url, data) {
try { inspect(url, typeof data === 'string' ? data : null); } catch (e) { /* ignore */ }
return origBeacon(url, data);
};
wrapped.__tkWrapped = true;
navigator.sendBeacon = wrapped;
transports.push('beacon');
}
// 4. The analytics connector, kept as a fourth source in case a build uses it.
const instances = window.analyticsConnectorInstances || {};
let connectors = 0;
Object.keys(instances).forEach(name => {
const inst = instances[name];
if (inst && inst.eventBridge && typeof inst.eventBridge.setEventReceiver === 'function') {
const prev = inst.eventBridge.eventReceiver;
inst.eventBridge.setEventReceiver(evt => {
if (evt && evt.eventType) push('connector', { eventType: evt.eventType });
if (prev) prev(evt);
});
connectors += 1;
}
});
if (connectors) transports.push('connector:' + connectors);
// 5. What the user physically touched, so a key can be attributed to a control.
// Taps usually land on an icon with no text of its own. Climb until an ancestor
// carries a caption, so the event key can be attributed to a named control -
// without this every tap reported "(no caption)" and nothing could be paired.
const captionFor = start => {
let el = start;
for (let depth = 0; el && depth < 6; depth += 1, el = el.parentElement) {
const label = (el.getAttribute && el.getAttribute('aria-label')) || '';
const text = (label || el.innerText || '').trim().replace(/\s+/g, ' ');
const r = el.getBoundingClientRect();
// Reject the page-sized wrappers near the top of the climb.
if (text && text.length <= 60 && r.height < window.innerHeight * 0.6) {
return { text: text, el: el };
}
}
return { text: '', el: start };
};
document.addEventListener('click', e => {
const found = captionFor(e.target);
const el = found.el;
const r = el.getBoundingClientRect();
push('click', {
text: found.text.slice(0, 60),
className: String(el.className || '').slice(0, 80),
rect: {
cssLeft: r.left, cssTop: r.top + window.scrollY,
cssWidth: r.width, cssHeight: r.height
}
});
}, true);
transports.push('click');
window.__tkTransports = transports;
window.__tkHookInstalled = true;
return { already: false, transports: transports };
})()
"""
DRAIN_JS = "(() => { const e = window.__tkEvents || []; window.__tkEvents = []; return e; })()"