commit 5cb347a44b46e9ac58fde694a31b761a99b18b3a Author: Iliyas Kyrykbayev Date: Mon Aug 24 18:16:46 2026 +0500 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 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..bcb7540 --- /dev/null +++ b/.env.example @@ -0,0 +1,7 @@ +# ClickHouse connection used by tools/fetch_clickhouse.py. +# Without these, metrics.json keeps its "demo" label and the UI says so. +CLICKHOUSE_HOST=http://10.0.0.5:8123 +CLICKHOUSE_USER=default +CLICKHOUSE_PASSWORD= +CLICKHOUSE_DB=default +CLICKHOUSE_TABLE=amplitude_events diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e697404 --- /dev/null +++ b/.gitignore @@ -0,0 +1,37 @@ +# Dependencies & build output +node_modules +dist +dist-ssr +*.local + +# Secrets — never commit +.env +.env.local + +# Python caches +__pycache__/ +*.pyc + +# Generated working files +public/data/*.bak +public/data/*.tmp +public/assets/screens/*_result.json + +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + +# Editors / OS +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/.oxlintrc.json b/.oxlintrc.json new file mode 100644 index 0000000..6fa991d --- /dev/null +++ b/.oxlintrc.json @@ -0,0 +1,8 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["react", "typescript", "oxc"], + "rules": { + "react/rules-of-hooks": "error", + "react/only-export-components": ["warn", { "allowConstantExport": true }] + } +} diff --git a/PROJECT_DOCUMENTATION.md b/PROJECT_DOCUMENTATION.md new file mode 100644 index 0000000..8f9e1c3 --- /dev/null +++ b/PROJECT_DOCUMENTATION.md @@ -0,0 +1,551 @@ +# 📡 TelecomKz Analytics Mapper & Visual Simulator +### Архитектура, принципы работы и результаты аудита + +> Ревизия документа: 2026-08-24. Проверено на реальном устройстве +> Samsung SM-S938B (Android 16, 1080×2340, WebView Chrome 151), приложение +> `kz.telecom.app`, WebView `https://customer.telecom.kz/`. + +--- + +## 📑 Содержание +1. [О проекте](#-1-о-проекте) +2. [Архитектура](#-2-архитектура) +3. [Система координат — главное, что нужно понять](#-3-система-координат--главное-что-нужно-понять) +4. [Достоверность данных](#-4-достоверность-данных) +5. [Функционал](#-5-функционал) +6. [Результаты аудита: что было сломано и как исправлено](#-6-результаты-аудита-что-было-сломано-и-как-исправлено) +7. [Структура проекта](#-7-структура-проекта) +8. [Запуск и типовые сценарии](#-8-запуск-и-типовые-сценарии) + +--- + +## 🎯 1. О проекте + +Визуальный инструмент для сквозного маппинга UI-элементов мобильного приложения +Казахтелеком на события Amplitude и таблицы ClickHouse: наведение на любую кнопку +показывает ключ события и продуктовые метрики, а разметка снимается прямо с +подключённого по USB смартфона. + +Приложение гибридное: нативная оболочка (тулбар сверху, таб-бар снизу) плюс +Vue.js-WebView в середине. Из этого следует всё остальное — и способ съёмки, и способ +получения координат, и способ перехвата событий. + +--- + +## 🏗️ 2. Архитектура + +```mermaid +flowchart TD + subgraph Device ["📱 Android (ADB)"] + Native["Нативная оболочка: тулбар + таб-бар"] + WV["WebView (Vue, customer.telecom.kz)"] + end + + subgraph Core ["⚙️ tools/telecom_cdp.py — общее ядро"] + ADB["ADB: screencap, uiautomator, input"] + Sock["Поиск сокета webview_devtools_remote_<pid>"] + CDP["CDP-клиент с сопоставлением id ответов"] + Geom["probe_native_layout(): реальные границы WebView и таб-бара"] + end + + subgraph Tools ["🐍 Инструменты"] + Cap["capture_screen.py — сшивка по смещению прокрутки"] + Live["live_auto_recorder.py — пересборка хотспотов"] + Mon["monitor_live_events.py — перехват событий"] + Crawl["crawl_and_map_all.py — автообход"] + CH["fetch_clickhouse.py — метрики"] + end + + subgraph Server ["🚀 Vite dev server"] + A1["/api/capture-adb"] + A2["/api/live-sync-webview"] + A3["/api/save-app-map"] + A4["/api/device-status"] + end + + subgraph UI ["💻 React 19 + TypeScript"] + Sim["PhoneSimulator"] + Tip["HotspotTooltip"] + Ed["GuiEditor"] + end + + Native --> ADB + WV --> CDP + Sock --> CDP + ADB --> Geom + Core --> Tools + Tools --> Server + Server --> UI +``` + +**Стек.** React 19 + TypeScript + Vite; Python 3.10+ (Pillow, OpenCV, websockets); +ADB + `uiautomator`; Chrome DevTools Protocol через `adb forward`. + +Ключевое архитектурное решение аудита: весь доступ к устройству идёт через +единственный модуль `tools/telecom_cdp.py`. Раньше каждый скрипт заново реализовывал +подключение — и каждый воспроизводил одни и те же три ошибки (см. §6). + +--- + +## 📐 3. Система координат — главное, что нужно понять + +Все прямоугольники хотспотов живут в **пиксельной сетке сшитого скриншота**: + +``` +строки 0 .. webviewTop нативный тулбар +webviewTop .. +contentHeight полный контент WebView +последние navHeight строк нативный таб-бар +``` + +Элемент DOM со смещением в документе `(left, top + scrollY)` попадает в + +``` +x = round(left * scale) +y = webviewTop + round((top + scrollY) * scale), где scale = ширина экрана / window.innerWidth +``` + +На проверенном устройстве: `webviewTop = 255`, `webviewBottom = 2139`, +`scale = 1080 / 384 = 2.8125`. Но **эти числа измеряются на каждом запуске** через +`uiautomator` (границы `android.webkit.WebView` и `kz.telecom.app:id/bottomNavigation`), +а не задаются константами. + +### Почему сшивка сделана именно так + +Скриншот снимается покадрово: страница прокручивается командой `window.scrollTo` +через CDP, после каждого шага реальное значение `window.scrollY` **читается обратно**, +и кадр вставляется на строку `round(scrollY * scale)`. Совпадение координат +получается по построению, без поиска совпадений и без накопления ошибки. + +Два отвергнутых варианта: + +* **Template matching (старая реализация).** Высота результата непредсказуема и + никак не связана с высотой документа, из которой считались координаты хотспотов. + При неудачном совпадении код молча подставлял смещение 750 px. +* **`Page.captureScreenshot` с `captureBeyondViewport`.** Проверено на живом + приложении: карусель во второй строке не отрисовывается, вместо неё дублируется + верх страницы. Изображение 1080×2531 выглядит правдоподобно, но нижняя треть + не соответствует DOM. + +--- + +## 🔍 4. Достоверность данных + +Инструмент показывает числа аналитику, поэтому источник каждого числа обозначен явно. + +### 4.1 Источник метрик — `public/data/metrics.json`, поле `source` + +* `clickhouse` — выгружено `tools/fetch_clickhouse.py`; +* `demo` — демонстрационные значения, **не аналитика**. + +Если ключа события нет в каталоге, у хотспота **нет блока `metrics`**, и интерфейс +пишет «Нет данных». Ноль в этом месте означал бы «кнопку не нажимают» — совсем другое +утверждение. + +### 4.2 Достоверность ключа — поле `keyConfidence` + +`observed` › `dom-attribute` › `manual` › `rule` › `guessed`. + +Только `observed` означает, что ключ действительно снят с приложения. + +**Важно.** Ключи вида `PAYMENTS_CLICK`, `SERVICES_CLICK`, `MENUCLICKED` в поставляемой +разметке имеют статус `rule` — это соглашение проекта. Реальные ключи, снятые с живого +приложения 2026-08-24, выглядят иначе: + +``` +POST https://api.amplitude.com/ +POST https://mc.yandex.ru/watch/96490559/1?page-url=goal://customer.telecom.kz/HOMEPAGEPAYMENTS +POST https://mc.yandex.ru/watch/96490559/1?page-url=goal://customer.telecom.kz/OPENWINDOWPAYMENT +``` + +То есть `HOMEPAGEPAYMENTS`, а не `PAYMENTS_CLICK`. Перевести ключи в `observed`: + +```bash +py tools/monitor_live_events.py --seconds 120 --map main_dashboard +``` + +--- + +## 🔑 4.3 Реальные ключи событий, снятые с приложения + +36 ключей подтверждено на устройстве. Каждый снят с провода +(`amplitude` — тело запроса к `api.amplitude.com`), ни один не выведен из подписи. + +| Экран | Элемент | Ключ | +|---|---|---| +| `main_dashboard` | AITU Music Слушайте без ограничений | `aitu_music_banner_clicked` | +| `main_dashboard` | NEW TURBO Увеличьте скорость интернета | `turbo_widget_clicked` | +| `main_dashboard` | TV+ Смотрите все новинки | `HOMEADDSERVICETVPLUS` | +| `main_dashboard` | Telecom Shop Покупайте выгодно | `OPENINGWINDOWTELECOMSHOP` | +| `main_dashboard` | Заявки | `HOMETAPORDERS` | +| `main_dashboard` | Лицевой счет 11440366 г.Алматы, прс.До | `SELECTACCOUNT` | +| `main_dashboard` | Меню (правый навбар) | `notautho_main` | +| `main_dashboard` | Мои услуги | `HOMETAPMYSERVICES` | +| `main_dashboard` | Платежи | `HOMEPAGEPAYMENTS` | +| `main_dashboard` | Подключить интернет Скидки и бонусы | `fd_auth_tariffs_opened` | +| `main_dashboard` | Свободные средства 16 484.62 ₸ | `detalization_widget_open` | +| `main_dashboard` | Трафик | `HOMEDETALIZATION` | +| `main_dashboard` | Удв. лич. | `ID_CARD_HOME_CLICK` | +| `orders_screen` | Создать заявку | `ORDERSOPENSCREENCREATEORDER` | +| `payments_screen` | История платежей | `PAYMENTTAPPAYMENTSTORIE` | +| `payments_screen` | Мой автоплатеж | `PAYMENTSTAPMYAUTOPAYMENT` | +| `services_screen` | Подробнее | `click_details_mp` | +| `side_menu` | Заявки | `ORDERS` | +| `side_menu` | Мои услуги | `MYSERVICES` | +| `side_menu` | Оферта | `IMPORTANTDOCUMENTS` | +| `side_menu` | Платежи | `PAYMENTS` | +| `side_menu` | Помощь | `HELP` | +| `side_menu` | Профиль телеком | `PROFILETELECOM` | +| `side_menu` | Трафик | `DETALIZATION` | +| `tariff_bereket` | Подробнее о скидке | `fd_auth_bereket_constructor_opened` | +| `tariff_internet_200` | Подробнее о скидке | `fd_auth_internet200_constructor_opened` | +| `tariff_internet_500` | Подробнее о скидке | `fd_auth_internet500_constructor_opened` | +| `tariff_keremet_mobile` | Подробнее о скидке | `fd_auth_keremet_mobile_constructor_opened` | +| `tariff_keremet_tv` | Подробнее о скидке | `fd_auth_keremet_tv_constructor_opened` | +| `tariffs_full_digital` | Подробнее | `constructor_opened` | +| `tariffs_full_digital` | Подробнее | `constructor_opened` | +| `tariffs_full_digital` | Подробнее | `constructor_opened` | +| `tariffs_full_digital` | Подробнее | `constructor_opened` | +| `tariffs_full_digital` | Подробнее | `constructor_opened` | +| `traffic_screen` | Домашний интернет | `button_click_home_internet` | +| `traffic_screen` | Мобильная связь | `DETALIZATIONMOBILEINTERNET` | + +### Что из этого важно + +* **Ни один ключ не совпал с тем, что предлагало правило по подписи.** «Трафик» — + это `HOMEDETALIZATION`, «Заявки» — `HOMETAPORDERS`, «Свободные средства» — + `detalization_widget_open`. +* **Один и тот же раздел из разных точек входа даёт разные ключи.** Плитка на + главном экране и строка бокового меню ведут на один маршрут, но событие разное: + + | Раздел | Плитка на главной | Строка бокового меню | + |---|---|---| + | Платежи | `HOMEPAGEPAYMENTS` | `PAYMENTS` | + | Мои услуги | `HOMETAPMYSERVICES` | `MYSERVICES` | + | Трафик | `HOMEDETALIZATION` | `DETALIZATION` | + | Заявки | `HOMETAPORDERS` | `ORDERS` | + + Это ровно та разница, ради которой инструмент и делался: по одному ключу + «переход в Платежи» нельзя понять, откуда пришёл пользователь. +* **Именование в приложении несогласованное**: `HOMETAPORDERS` рядом с + `turbo_widget_clicked`, `button_click_home_internet` и `click_details_mp`. +* **Не инструментированы**: «Сервисы», «QR», «Мои бонусы» — переходят на свой + маршрут, но не отправляют события. + +--- + +## 🗺️ 4.4 Инвентарь экранов + +| Экран | Маршрут / поверхность | Зон | Подтверждено | +|---|---|---|---| +| `main_dashboard` | / | 24 | 13 | +| `services_screen` | /services | 7 | 1 | +| `traffic_screen` | /detalization | 10 | 2 | +| `payments_screen` | /payments | 10 | 2 | +| `orders_screen` | /appeals | 7 | 1 | +| `music_screen` | native | 20 | 0 | +| `side_menu` | / | 15 | 7 | +| `payments_auto` | /payments/auto | 7 | 0 | +| `payments_history` | /payments/history | 7 | 0 | +| `detalization_internet` | /detalization/internet | 7 | 0 | +| `detalization_mobile` | /detalization/mobile | 7 | 0 | +| `appeals_create` | /appeals/create-appeal | 13 | 0 | +| `service_details` | /ont/3608039 | 8 | 0 | +| `profile_settings` | /settings | 10 | 0 | +| `balance_detail` | /detalization/balance/current | 12 | 0 | +| `bonuses_screen` | /bonuses | 7 | 0 | +| `qr_scan` | /scan-qr | 6 | 0 | +| `id_card` | /digital-document/id-card | 8 | 0 | +| `services_catalog` | /extra-services | 12 | 0 | +| `tariffs_full_digital` | /full-digital | 16 | 5 | +| `tv_plus` | /tv-plus | 14 | 0 | +| `turbo_landing` | /turbo | 7 | 0 | +| `help_screen` | /help | 10 | 0 | +| `important_docs` | /important-docs | 9 | 0 | +| `aitu_music` | /aitu/music | 12 | 0 | +| `tariff_bereket` | /full-digital/auth/tariff | 10 | 1 | +| `connect_service` | /full-digital/auth/connect-service | 10 | 0 | +| `tariff_keremet_mobile` | /full-digital/auth/tariff | 10 | 1 | +| `tariff_keremet_tv` | /full-digital/auth/tariff | 10 | 1 | +| `tariff_internet_500` | /full-digital/auth/tariff | 10 | 1 | +| `tariff_internet_200` | /full-digital/auth/tariff | 10 | 1 | +| `account_selector` | / | 11 | 0 | + +Названия маршрутов не совпадают с подписями: «Трафик» → `/detalization`, +«Заявки» → `/appeals`, «Подключить интернет» → `/full-digital`, +«Удв. лич.» → `/digital-document/id-card`. + +### Три режима съёмки + +* **обычный** — сшивка по прокрутке для WebView-страниц; +* **`--no-scroll`** — один экран без прокрутки; нужен для оверлеев: боковое меню + закрывается при `window.scrollTo`; +* **`--native`** — экран без WebView (Музыка): разметка из `uiautomator`. + +### Заметки по подэкранам + +* **Один ключ на пять кнопок — это не ошибка.** Все пять «Подробнее» в каталоге + тарифов отправляют `constructor_opened`; различить тариф можно только по + следующему событию — `fd_auth_<тариф>_constructor_opened` у «Подробнее о скидке». +* **Экраны с задержкой отрисовки.** «Удв. лич.» и каталог тарифов рисуют скелетон + и наполняют его через 4-5 секунд. Съёмка ждёт, пока страница перестанет меняться + (`--wait`, по умолчанию 20 с); в разметке остаётся флаг `renderSettled`. +* **Выбор лицевого счёта** — это нижняя шторка на маршруте `/`, а не отдельный + маршрут. Снимается с `--no-scroll`. +* **Повторяющиеся подписи.** На экране может быть пять кнопок «Подробнее»; обход + адресует их по порядковому номеру, иначе все пять кликают в первую. +* **`/full-digital/auth/tariff`** — один маршрут для всех тарифов, поэтому каждый + снят под своим id (`tariff_bereket`, `tariff_keremet_mobile`, ...). + +### Ограничения перехвата + +* **Нативные вкладки** (Кабинет / TV+ / Музыка / Чаты / Бизнес), **аватар** и + **уведомления** отправляют события из нативного SDK. Через WebView их не видно. +* **Экран выбора лицевого счёта** отправляет `SELECTACCOUNT`, но затем требует + пин-код — снять его скриншот автоматически нельзя. +* **Цепочки событий.** Одно нажатие порождает несколько событий; при частых + нажатиях позднее событие приписывается следующему элементу. Пауза 5+ секунд + и повтор в двух прогонах. + +--- + +## ⚡ 5. Функционал + +1. **Симулятор** — мокап смартфона с прокруткой, переходами и историей «Назад». + Переходы на несуществующие экраны не «проглатываются»: такая зона не кликабельна. +2. **Тултипы метрик** — ключ события, русское название, число событий, уникальные + пользователи, события на пользователя, доля кликов, целевой экран; плюс пометки + «демо-значения» и «ключ не проверен». +3. **Тепловая карта** — нормировка только по измеренным зонам; зоны без метрик серые. +4. **Редактор разметки** — координаты, ключ события с автодополнением из каталога, + целевой экран. **Сохранение на диск** через `/api/save-app-map` (с резервной копией). +5. **Съёмка экрана с телефона** — полная прокрутка, нативная разметка из `uiautomator`, + DOM-элементы из CDP, обрезка выходящих за экран каруселей, дедупликация. +6. **Перехват событий** — `fetch`, `XMLHttpRequest`, `sendBeacon` и (как запасной + источник) analytics-connector; плюс цель Яндекс.Метрики как подтверждение. +7. **Автообход** — переход по разделам через DOM, съёмка каждого, запись пойманных + ключей. +8. **Индикатор устройства** — различает «телефон не подключен», «нет WebView» и + «сервер не отвечает». + +--- + +## 🛠️ 6. Результаты аудита: что было сломано и как исправлено + +### 6.1 Координаты хотспотов не совпадали с картинкой + +Разметка считалась в координатах документа WebView +(`docHeight = scrollHeight * scale + 455`), а картинка бралась либо одиночным +скриншотом 2340 px, либо непредсказуемой сшивкой. На момент аудита +`main_dashboard` имел `totalHeight: 3886` при изображении высотой 2340: нижнее меню +и часть кнопок оказывались за пределами картинки и были недоступны. + +*Исправлено:* детерминированная сшивка по прочитанному `scrollY` (§3). Проверено: +изображение 1080×2989, все 24 зоны совпадают с элементами. + +### 6.2 `live_auto_recorder.py` игнорировал аргумент + +Скрипт читал `sys.argv`, но `__main__` вызывал +`sync_current_screen_from_webview("main_dashboard")` жёстко. Любой запрос +`/api/live-sync-webview?screenId=…` перезаписывал главный экран. Воспроизведено: +запрос для `services_screen` изменил `main_dashboard`. + +*Исправлено:* аргументы через `argparse`, валидация `screenId`. + +### 6.3 Жёстко зашитый PID сокета WebView + +`webview_devtools_remote_18732` в двух скриптах. PID меняется при каждом перезапуске +приложения. + +*Исправлено:* `find_webview_socket()` — `pidof` с проверкой по `/proc/net/unix`. + +### 6.4 Выдуманные метрики выдавались за аналитику + +`totalEvents: 1200 + len(hotspots) * 410` в трёх скриптах; `metrics.json` целиком +синтетический; `fetch_clickhouse.py` указывал на `http://100.x.x.x:8123`. + +*Исправлено:* генерация чисел удалена; `attach_metrics()` либо ставит реальную строку +каталога, либо не ставит ничего; каталог помечается `source`; коннектор ClickHouse +переписан на серверные параметры (`{name:Type}`) вместо подстановки в SQL. + +### 6.5 Перехват событий не работал + +Хук ставился на `analyticsConnectorInstances['$default_instance'].eventBridge`. +Проверено: хук цепляется (две инстанции, обе с `setEventReceiver`), но **не срабатывает +ни разу** — приложение отправляет события напрямую HTTP-запросом на +`api.amplitude.com`. + +*Исправлено:* `tools/amplitude_hook.py` оборачивает `fetch`, `XMLHttpRequest` и +`sendBeacon`, разбирает `events[].event_type` из payload Amplitude HTTP V2 и +дополнительно снимает цель Яндекс.Метрики. Именно так были получены реальные ключи +из §4.2. + +### 6.6 API возвращал успех после неудачи + +`/api/capture-adb` читал `${screenId}_result.json` от предыдущего запуска и возвращал +его даже при падении Python-скрипта — интерфейс показывал «✅ обновлено» со старыми +данными. + +*Исправлено:* решение принимается по JSON-вердикту скрипта; ошибка отдаётся с 502. + +### 6.7 Подстановка `screenId` в командную строку + +`exec(\`py tools/capture_screen.py ${screenId} …\`)` — параметр запроса попадал в +шелл. + +*Исправлено:* `execFile` с массивом аргументов плюс проверка `^[A-Za-z0-9_-]{1,64}$` +на обеих сторонах. + +### 6.8 Гонка записи + +Middleware запускал `capture_screen.py` и `live_auto_recorder.py` одновременно, оба +писали `telecomkz_app_map.json`. + +*Исправлено:* очередь на сервере, атомарная запись через временный файл. + +### 6.9 Правки редактора нельзя было сохранить + +Существовал только экспорт файла в «Загрузки»; всё, что редактировалось, терялось при +перезагрузке, а Live-синхронизация раз в 2 секунды затирала форму. + +*Исправлено:* эндпойнт `/api/save-app-map` с резервной копией, кнопка «Сохранить +разметку на диск», индикатор несохранённых изменений; Live-синхронизация +приостанавливается в режиме редактора и при несохранённых правках. + +### 6.10 Ошибки CDP-протокола во всех скриптах + +`await ws.recv()` после `Page.enable` / `Network.enable` считался ответом на команду, +хотя CDP присылает события вперемешку с ответами; целевая страница выбиралась как +`pages[0]` без проверки типа. + +*Исправлено:* `CdpSession.send()` сопоставляет ответы по `id` и буферизует события; +`pick_page_target()` фильтрует по `type == "page"` и предпочитает `customer.telecom.kz`. + +### 6.11 Найдено при доснятии остальных экранов + +* **Повторная съёмка стирала подтверждённые ключи.** `capture()` полностью заменяет + хотспоты экрана, поэтому каждый пересъём терял все `observed`-ключи. Добавлен + перенос доверенных ключей (`observed` / `manual` / `dom-attribute`) по подписи + элемента: `telecom_cdp._merge_trusted_keys()`. +* **Оверлей дублировался на скриншоте.** Боковое меню — `position: fixed` поверх + документа, который всё ещё сообщает о прокрутке: `scrollY` менялся, пиксели — нет, + и второй кадр вклеивался ниже, повторяя интерфейс. Теперь кадры сравниваются, и + сшивка останавливается, как только картинка перестаёт меняться. +* **Скрытые за оверлеем элементы попадали в разметку.** Добавлена проверка + `document.elementFromPoint` по центру видимой части элемента. Для прокручиваемых + экранов она применяется только к полностью видимым элементам — иначе карточки под + «липким» баннером на нулевой прокрутке отбрасывались бы, хотя ниже по сшитому + скриншоту они видны. +* **Боковое меню отсутствовало в разметке.** Его строки — обычные `div.nav-link` + без `role` и `href`, ни один селектор их не покрывал. +* **Совпадающие ключи.** «Мой автоплатеж» и «История платежей» оба содержат + «платеж» и получали один `PAYMENTS_CLICK`. Ключи на экране теперь уникальны. +* **Блокировка экрана портила данные.** Заблокированный телефон продолжает отвечать + на `screencap` и `uiautomator`, поэтому съёмка молча записывала экран блокировки + поверх настоящего. Добавлена проверка `require_awake()`. +* **Обрыв CDP терял собранное.** Переход на экран, заменяющий WebView, рвал сокет, и + `ConnectionClosedError` уходил мимо обработчика — прогон падал вместе с уже + пойманными событиями. Теперь это `DeviceError`, и монитор завершается штатно. + +--- + +### 6.12 Прочее + +* Замороженный процесс приложения не отвечает по сокету — добавлена проверка + переднего плана и вывод приложения на экран перед подключением. +* `build_smart_hotspots()` игнорировала снятый дамп `uiautomator` и возвращала + захардкоженную разметку главного экрана для **любого** `screenId`. Теперь нативные + элементы берутся из дампа по `resource-id`, включая активную вкладку + (`clickable="false"` у выбранного таба). +* Кириллические ключи (`CLICK_ЧАТЫ`) заменены транслитерацией. +* Элементы горизонтальных каруселей выходили за правый край (x + width = 1347 при + ширине 1080) — теперь обрезаются по канве. +* Порядок правил классификации: «Подключить интернет Скидки и бонусы» попадал в + `BONUSES_CLICK`; правила переупорядочены от частных к общим. +* Пути в скриптах резолвятся от корня проекта, а не от текущего каталога. +* 11 дублирующих скриптов перенесены в `tools/legacy/`, на их местах — заглушки, + перенаправляющие на рабочие реализации. + +--- + +## 📂 7. Структура проекта + +``` +telecomkz_scraper/ +├── public/ +│ ├── assets/screens/ скриншоты + *_result.json +│ └── data/ +│ ├── telecomkz_app_map.json экраны, зоны, связи +│ └── metrics.json каталог метрик с полем source +├── src/ +│ ├── components/ +│ │ ├── PhoneSimulator.tsx мокап, оверлеи, тепловая карта +│ │ ├── HotspotTooltip.tsx карточка метрик + пометки достоверности +│ │ ├── GuiEditor.tsx редактор зон +│ │ └── Sidebar.tsx экраны, режимы, съёмка, сохранение +│ ├── types/simulator.ts типы + KeyConfidence / MetricsSource +│ └── App.tsx контроллер +├── tools/ +│ ├── telecom_cdp.py ядро: ADB, CDP, геометрия, app map +│ ├── amplitude_hook.py перехват событий в странице +│ ├── capture_screen.py съёмка + разметка +│ ├── live_auto_recorder.py пересборка хотспотов экрана +│ ├── monitor_live_events.py живой монитор событий +│ ├── crawl_and_map_all.py автообход +│ ├── fetch_clickhouse.py метрики +│ ├── reclassify_hotspots.py пересчёт ключей без телефона +│ ├── inspect_page.py диагностика WebView +│ ├── device_status.py статус устройства +│ └── legacy/ старые версии, не используются +├── vite.config.ts dev-сервер + API-мост +└── .env.example настройки ClickHouse +``` + +--- + +## 🚀 8. Запуск и типовые сценарии + +```bash +npm install +py -m pip install opencv-python numpy pillow websockets requests +npm run dev # http://localhost:5173 +``` + +**Проверить связь с телефоном** + +```bash +py tools/device_status.py +``` + +**Снять текущий экран** + +```bash +py tools/capture_screen.py main_dashboard --name "Главный экран (Кабинет)" +``` + +**Узнать настоящие ключи событий** (главный сценарий для аналитика) + +```bash +py tools/monitor_live_events.py --seconds 120 --map main_dashboard +``` + +**Подтянуть метрики** + +```bash +cp .env.example .env # указать CLICKHOUSE_HOST +py tools/fetch_clickhouse.py --days 30 +py tools/reclassify_hotspots.py +``` + +**Диагностика WebView** + +```bash +py tools/inspect_page.py amplitude bridge vue +``` + +### Известные ограничения + +* Если приложение уходит в фон, Android замораживает процесс и отладочный сокет + перестаёт отвечать. Инструменты сами выводят приложение на передний план. +* Ключи событий в поставляемой разметке имеют статус `rule` и требуют подтверждения + через `monitor_live_events.py` — до этого join с ClickHouse не даст результата. +* `metrics.json` в репозитории помечен `source: "demo"`. diff --git a/README.md b/README.md new file mode 100644 index 0000000..6220483 --- /dev/null +++ b/README.md @@ -0,0 +1,135 @@ +# 📡 TelecomKz Analytics Mapper & Visual Simulator + +Интерактивный симулятор мобильного приложения **Казахтелеком (TelecomKz**, `kz.telecom.app`**)** +для продуктового маппинга экранов, кнопок и метрик Amplitude / ClickHouse. + +👉 Подробности архитектуры — в [PROJECT_DOCUMENTATION.md](PROJECT_DOCUMENTATION.md). + +--- + +## ⚡ Быстрый старт + +```bash +npm install +py -m pip install opencv-python numpy pillow websockets requests +npm run dev +``` + +Откройте **http://localhost:5173**. + +Требования: Node.js 18+, Python 3.10+, Android-смартфон с включённой отладкой по USB +и **открытым на переднем плане** приложением TelecomKz. + +Приложение работает и без телефона: экраны и разметка читаются из +`public/data/telecomkz_app_map.json`, а панель сверху честно показывает +«Телефон не подключен». + +--- + +## 🧭 Что нужно понимать про данные + +Два разных признака достоверности, оба видны в интерфейсе. + +**1. Источник метрик** (`metrics.json` → поле `source`) + +| Значение | Что означает | +|---|---| +| `clickhouse` | Числа выгружены из ClickHouse через `tools/fetch_clickhouse.py`. | +| `demo` | **Демонстрационные значения.** Это не аналитика. | + +В репозитории по умолчанию лежит `demo`. Чтобы получить реальные метрики, +скопируйте `.env.example` в `.env`, укажите `CLICKHOUSE_HOST` и выполните: + +```bash +py tools/fetch_clickhouse.py --days 30 +``` + +Хотспот, ключа которого нет в каталоге, **не получает метрик вообще** — в тултипе +пишется «Нет данных». Ноль вместо этого читался бы как «на кнопку никто не нажимает». + +**2. Достоверность ключа события** (`keyConfidence` у каждого хотспота) + +| Значение | Что означает | +|---|---| +| `observed` | Ключ перехвачен в момент отправки из приложения. **Только это — настоящий ключ.** | +| `dom-attribute` | Взят из атрибута `data-event` / `data-analytics` в DOM. | +| `manual` | Задан аналитиком вручную. | +| `rule` | Выведен из подписи кнопки по таблице `EVENT_RULES`. Не проверен. | +| `guessed` | Транслитерирован из подписи, правило не сработало. Не проверен. | + +⚠️ Ключи вида `PAYMENTS_CLICK` в поставляемой разметке имеют статус `rule` — это +**соглашение этого проекта, а не ключи приложения**. Реальные ключи, снятые с живого +приложения, выглядят иначе: `HOMEPAGEPAYMENTS`, `OPENWINDOWPAYMENT`. Пока ключ не +переведён в `observed`, join с ClickHouse ничего не вернёт. + +Снять настоящие ключи: + +```bash +py tools/monitor_live_events.py --seconds 120 --map main_dashboard +``` + +Потыкайте кнопки на телефоне — каждый пойманный ключ запишется в разметку +со статусом `observed`. + +--- + +## 🛠 Инструменты + +| Скрипт | Назначение | +|---|---| +| `tools/telecom_cdp.py` | Общее ядро: ADB, поиск сокета WebView, CDP-клиент, геометрия экрана. | +| `tools/capture_screen.py` | Снимок экрана со сшивкой всей прокрутки + разметка хотспотов. | +| `tools/live_auto_recorder.py` | Быстрая пересборка хотспотов одного экрана по живому DOM. | +| `tools/monitor_live_events.py` | Перехват реальных событий аналитики в момент нажатия. | +| `tools/crawl_and_map_all.py` | Автообход разделов с захватом экранов и событий. | +| `tools/fetch_clickhouse.py` | Выгрузка метрик из ClickHouse. | +| `tools/map_screen_controls.py` | Обход всех кнопок экрана: снятие реальных ключей + захват экранов, куда они ведут. | +| `tools/reclassify_hotspots.py` | Пересчёт ключей, дедупликация и чистка мёртвых ссылок без телефона. | +| `tools/rename_screen.py` | Переименование снятого экрана вместе с файлами и ссылками. | +| `tools/inspect_page.py` | Диагностика WebView (Amplitude, мосты, Vue, storage). | +| `tools/device_status.py` | Проверка «телефон + WebView доступны». | + +Скрипты в `tools/legacy/` не используются — см. `tools/legacy/README.md`. + +--- + +## 🗺️ Экраны и режимы съёмки + +| Экран | Маршрут / поверхность | Команда | +|---|---|---| +| Главный | `/` | `py tools/capture_screen.py main_dashboard` | +| Мои услуги | `/services` | `py tools/capture_screen.py services_screen` | +| Трафик | `/detalization` | `py tools/capture_screen.py traffic_screen` | +| Платежи | `/payments` | `py tools/capture_screen.py payments_screen` | +| Заявки | `/appeals` | `py tools/capture_screen.py orders_screen` | +| Боковое меню | `/` (оверлей) | `py tools/capture_screen.py side_menu --no-scroll` | +| Музыка | нативный экран | `py tools/capture_screen.py music_screen --native` | + +* `--no-scroll` — для оверлеев: прокрутка закрывает боковое меню. +* `--native` — для экранов без WebView: разметка берётся из `uiautomator`. + +Полный обход экрана (ключи + экраны назначения одним проходом): + +```bash +py tools/map_screen_controls.py payments_screen --via "Платежи" --capture-new +``` + +Инструмент сам возвращается назад **внутри WebView** (`history.back()`), а не +аппаратной кнопкой: та закрывает приложение и вызывает запрос пин-кода. + +Съёмка **не стирает** подтверждённые (`observed`) ключи — они переносятся по подписи +элемента. Пересъём безопасен. + +--- + +## 🌟 Возможности + +- Мокап смартфона с прокруткой длинных экранов и переходами между разделами. +- Тултипы с метриками Amplitude/ClickHouse и явной пометкой источника. +- Тепловая карта кликов; зоны без измерений остаются серыми, а не «холодными». +- Редактор разметки с сохранением **на диск** (`Сохранить разметку на диск`), + а не только выгрузкой файла. +- Захват экрана с телефона: сшивка ведётся по реальному смещению прокрутки, поэтому + координаты хотспотов совпадают с картинкой пиксель в пиксель. +- Чтение DOM WebView через Chrome DevTools Protocol и нативной разметки через + `uiautomator` — границы тулбара и таб-бара измеряются, а не задаются константами. diff --git a/index.html b/index.html new file mode 100644 index 0000000..33adf53 --- /dev/null +++ b/index.html @@ -0,0 +1,17 @@ + + + + + + + + TelecomKz Analytics Mapper & Interactive Simulator + + + + + +
+ + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..8cd99e3 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1359 @@ +{ + "name": "telecomkz_scraper", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "telecomkz_scraper", + "version": "0.0.0", + "dependencies": { + "lucide-react": "^1.33.0", + "react": "^19.2.8", + "react-dom": "^19.2.8" + }, + "devDependencies": { + "@types/node": "^24.13.3", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "@vitejs/plugin-react": "^6.1.0", + "oxlint": "^1.79.0", + "typescript": "~6.0.2", + "vite": "^8.2.2" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.146.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.146.0.tgz", + "integrity": "sha512-XC0QsnnhVe7sLIWmYmdPw7x5P0h4W8vUU3Nv1ySgWXtvCz8NizoAEpGXA0sOYoJQV2Rl13LgURAHQ5cI5ILCSA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@oxlint/binding-android-arm-eabi": { + "version": "1.79.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.79.0.tgz", + "integrity": "sha512-TebFaaMklO/RXzTv7PucaCq9l3X6D1gA+C8H6K4njtjFOV+zWE9MKLpulcJZN9bzytbUbQIY0mZuz12nQ5Kv4Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-android-arm64": { + "version": "1.79.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.79.0.tgz", + "integrity": "sha512-KqqnOtAVgNsPPF0YSodkFZA1O80jcKoCZCTu3bgsszxA+MrMP9TLzfXitKjEj1FmrPprKDMdRDMmY3weESO9sg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-arm64": { + "version": "1.79.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.79.0.tgz", + "integrity": "sha512-BVC2nsMzqQzRDPc5RhixkZ+m1p7iH4bxRRvqkbwDXX0PlQKm1BPy8J8cRjnAFafOq2QzI+BfO3vE8w2GZ3CBag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-x64": { + "version": "1.79.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.79.0.tgz", + "integrity": "sha512-p6Lm+snmhGuLKL1+CpCV8L6ijkE/qJzK2H2jG9+eKJT0n31RbY4FLsdhexekgP3bLpw4Kgde+9DZuDZQ4yIInA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-freebsd-x64": { + "version": "1.79.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.79.0.tgz", + "integrity": "sha512-qDMm0dXZnoHyRqSL4N4xUq82T4sqK5cbKSjvd/dF/YbMUXc2R1wEPf+vmA5S0qUmi0nwXfNbjXBtZaIqzQLIMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-gnueabihf": { + "version": "1.79.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.79.0.tgz", + "integrity": "sha512-2od7s0nuKPzqyUZAWk9KkCyGg7eI9dwFPZg+20lB15fKFkVZ0c9ZFxqPfiBAyDTlTkh9stPI0t+JlPCqMbItVA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-musleabihf": { + "version": "1.79.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.79.0.tgz", + "integrity": "sha512-ZOQUjkzDnvlhSE3+tWC3YXx94MMl+sYMlwH+u1+YGApGHOJP/YAc8ZBRFOXZ6eOBmxtXAWuS/fBcdZr8qqNO1A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-gnu": { + "version": "1.79.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.79.0.tgz", + "integrity": "sha512-lu158FR4nGqGeRS3BQvtG85wRgU/Fy4MD5Cxp1hzJXizGiLo6u2742wJSCDKh8cFcZntvX7fcxlq4mMmfryH1g==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-musl": { + "version": "1.79.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.79.0.tgz", + "integrity": "sha512-mbpKQeE2aflTjddaHK7MP8KP/OFbUM++lt5M635ENM8IyIdK0jm2t9pb+2v9mVVIvhF6TqA4l7F79Pll1mi+uw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-ppc64-gnu": { + "version": "1.79.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.79.0.tgz", + "integrity": "sha512-WpGNua7gaxaHnpSDeog2ji8IDHn/QLPl9LPzwkR/FvVv58vT5BcXjRXnU+wbu3N75cpeha8CdC7ho/U2OIsB4g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-gnu": { + "version": "1.79.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.79.0.tgz", + "integrity": "sha512-tK1E93A5LVzISg4ngpKJnfTs7EqtIUceGI7MQ4GyDjJiLi8wPCkEyKlj2xkyKWZ1yzkDJyLHTBJ5/iFWRdnJvg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-musl": { + "version": "1.79.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.79.0.tgz", + "integrity": "sha512-qhQvUIrngXivA2A9pQ+xPCychztn/5qUv7yS3gDwXv3w7Rag+eTeeXWmRyx+t7XsW5x6LuY/8AsTq36UgFIblg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-s390x-gnu": { + "version": "1.79.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.79.0.tgz", + "integrity": "sha512-sv6AaVgU/eE6u+6WFiQVDcPPwTxP6IJMSB9k701W2r/r6Tx465e8vPvVyRxquNH4Vy6KwRNu90mVbxXJN8+5gg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-gnu": { + "version": "1.79.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.79.0.tgz", + "integrity": "sha512-iFZL02deziHslb3jEX9KdqlAkYoo4fGyotchKDzdfK1f5mxlIBeiQeHhvK3iFpuEJSB4ma/qeFn9oxPiwnhUPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-musl": { + "version": "1.79.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.79.0.tgz", + "integrity": "sha512-3DtZR2raqObnh7wXZoFYFd0Fw7skBvcb3f7A+/lkEiDuh8hrE6vv9b/62Qxao1a9/OeHLw/FcXlXzgsW9wTRFg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-openharmony-arm64": { + "version": "1.79.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.79.0.tgz", + "integrity": "sha512-Oatt4GuA1WJkqzk2ozx4HrWROOi7opV3AKDw/U8qDIqeTqzsjn5K2x3REJMNjU3/KU/Bkq96Zi3CknaiDTaC/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-arm64-msvc": { + "version": "1.79.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.79.0.tgz", + "integrity": "sha512-NAgZr9Qp8nIA9rpo0JEvwiabTF/2UVqBNnupBG9X4kxXcQoScJUTi+qHhvabb9s/thgj5wQ4XcIaJvb+ZMgoKw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-ia32-msvc": { + "version": "1.79.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.79.0.tgz", + "integrity": "sha512-+KyXjIvcpaXmWW/j9NNY5yWjrIVxaX18VyIheQy3jwc2GSYgpCr7MGI/HxIGQ/shAL5IWEKbhsqoMpAO5Stiog==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-x64-msvc": { + "version": "1.79.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.79.0.tgz", + "integrity": "sha512-mEelcCMMBS57sIXh2veGMNy+pQwuGtcMxHxGIZWQ5Ba9pJ5jCCUFOZB9E2JhBaxGsURe+WGe0zJp4RVre52gpQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.5.tgz", + "integrity": "sha512-DLe/i+l8ynIBY7XEQ191TeZvCoowIGa18R+dIV30GW7DiOtp74i/xX8hs8GUjW5ARV7VZuie3d6AumSmCwbeRA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.5.tgz", + "integrity": "sha512-zXcwKlQApYAOELHd8PwKDFkagYF9Wy4e0RJ+0qnzl9Pjnpj75TEG8ufv40p2J7kCEfwZAsNiuzRIyNNMWT38ig==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.5.tgz", + "integrity": "sha512-dK4QakI42nzWgJT5sm4y4y/O//D4OxM75/cH28RLV+nzIN9AY+YsbuUVrUTjlLjXR6vpyxFbSsbmNuJ6BP9sww==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.5.tgz", + "integrity": "sha512-fqSALaUu1Wjd1nK2uW2kJDWdLCc8lx1IcY+MTY26Aurfdx19anlzhqXOgCFbBFQnlFDTn4TC1/7Nz4Bl2mLP3A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.5.tgz", + "integrity": "sha512-/vCnNxlkxs9tKxNDcyWUePpJ/PgTzxIaVhoM5SmG8UV+GR/IcPam4VYxi7GIMo7PSDuNqlJqvprqii9NqqVCMw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.5.tgz", + "integrity": "sha512-abk0NLA519LxRCszmbE0jYKuQ9YPocOXTiOXOo6Yr+YAT95VH+PtqYAjOJvGKt3viEd/x4qzabAlwd5bHOOARg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.5.tgz", + "integrity": "sha512-Y7eALiJ8lr0M2HH103Js+g7V34wf6snlpZLAsHI90uLhr3PVlNsbFVAXJC9d/V6BnPyKtpSwI+NcB/RLxsQxuA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.5.tgz", + "integrity": "sha512-xMvZgnbZg4YVnR/AX2b3oOPDTFYJvUVaJg5FedA/LuvexAtXibZQej4cnTkw3rjsJ/ggUROB64TdtETiim+FYA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.5.tgz", + "integrity": "sha512-GRjeqTUDHTo5GwntsLaAMcBahG3nlpjftXWZLN73HiYQlhwEowvarFgQnRnQZtIp4keXX7quXFbG38uPZBa2EA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.5.tgz", + "integrity": "sha512-vLNTR45F2Uwc8AufkNXPmB4VliaXs+FvcheEogIzOXzO4l+LzieXF5A/TWxLy5HtqpsRCHUfd0lPVrrdgXdLHQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.5.tgz", + "integrity": "sha512-Mgj59/HTuYeK9Gz2MA+mBWKnHsAgkBSec15ZMb1st3oIfFbX7gCjOae7GydHhzcyQi9Z/7M1QuN9bR3oFqF0jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.5.tgz", + "integrity": "sha512-mY8AP0/ichsbhAxGnLa3d3+MwV0EfgrPND2bplI3Ym8T6R2pJ0N87bvrKVwNXmdy3jnr6eQBecdqx/HMknBmpA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.5.tgz", + "integrity": "sha512-8SLssA2oweAxyRgDp789ACfRb/3P+zNRJpzZxSizxF9m8NUDQ4+3xjo8ttjhVGGw6Qxb70oZiEtIjaKikCO7Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.5.tgz", + "integrity": "sha512-vGbruD5zquhoc8D9SViXgN2FBJtNdTyQ4DtG+SWiEGlJiAzoKcZ2xp+xuXCffhubVdt0NJlTZqkeRuERy7g8Cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.5.tgz", + "integrity": "sha512-e/SXpgISz+IoqVcSSI0rx/d/he8zqLex+/rCWpnHpmVfmPIUjag9H6P7zotf0gJHwPUhQxZ/mF8tr6acebT9yw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.18", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", + "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.5.tgz", + "integrity": "sha512-fMPwH9v7r/pp43yUd2/Mbiex5KouJwwR3dzHkhLREUC6764VyDsqxhAxv6OFEYR1RhjOyD1naqba8ECDBe7ZQg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.0.tgz", + "integrity": "sha512-qd2BzUBehkov86WFhg0JkEFEYyCLG9uPCe6qWTY/kRlss9OvJrOF2UbIWT7p+8IzZHkEu0DNGHc4HSv+JdDLsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "oxc-transform-react": "^0.145.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "oxc-transform-react": { + "optional": true + } + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lucide-react": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.33.0.tgz", + "integrity": "sha512-MTRwMy0ZlL8Ur/vOAiJ9XGHE+kFPC7brq6MxAm0GiGXEBj0qy0jA/pG4N675oSzciO/UCdX8T+5yUQdmDeTLxg==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/oxlint": { + "version": "1.79.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.79.0.tgz", + "integrity": "sha512-hVJ9hq9m2unPS+Of4eJJgCPdIeCC+3DHEUX3tkmrPJr3OK2hz7PhXwgC+ZP71ZcYu8cCDEtQrqLxWNvxBppBVg==", + "dev": true, + "license": "MIT", + "bin": { + "oxlint": "bin/oxlint" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxlint/binding-android-arm-eabi": "1.79.0", + "@oxlint/binding-android-arm64": "1.79.0", + "@oxlint/binding-darwin-arm64": "1.79.0", + "@oxlint/binding-darwin-x64": "1.79.0", + "@oxlint/binding-freebsd-x64": "1.79.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.79.0", + "@oxlint/binding-linux-arm-musleabihf": "1.79.0", + "@oxlint/binding-linux-arm64-gnu": "1.79.0", + "@oxlint/binding-linux-arm64-musl": "1.79.0", + "@oxlint/binding-linux-ppc64-gnu": "1.79.0", + "@oxlint/binding-linux-riscv64-gnu": "1.79.0", + "@oxlint/binding-linux-riscv64-musl": "1.79.0", + "@oxlint/binding-linux-s390x-gnu": "1.79.0", + "@oxlint/binding-linux-x64-gnu": "1.79.0", + "@oxlint/binding-linux-x64-musl": "1.79.0", + "@oxlint/binding-openharmony-arm64": "1.79.0", + "@oxlint/binding-win32-arm64-msvc": "1.79.0", + "@oxlint/binding-win32-ia32-msvc": "1.79.0", + "@oxlint/binding-win32-x64-msvc": "1.79.0" + }, + "peerDependencies": { + "oxlint-tsgolint": ">=7.0.2001", + "vite-plus": "*" + }, + "peerDependenciesMeta": { + "oxlint-tsgolint": { + "optional": true + }, + "vite-plus": { + "optional": true + } + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.8" + } + }, + "node_modules/rolldown": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.5.tgz", + "integrity": "sha512-VD2IE5PUG4Oj8zz2VGykiYd5wbnjdIiSsNQb8Qu5B+noEp+A78mu2iVvpp27g8es14Tk9rofNs5Tku9iQCS4fA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.146.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.5", + "@rolldown/binding-android-arm64": "1.2.5", + "@rolldown/binding-darwin-arm64": "1.2.5", + "@rolldown/binding-darwin-x64": "1.2.5", + "@rolldown/binding-freebsd-x64": "1.2.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.5", + "@rolldown/binding-linux-arm64-gnu": "1.2.5", + "@rolldown/binding-linux-arm64-musl": "1.2.5", + "@rolldown/binding-linux-ppc64-gnu": "1.2.5", + "@rolldown/binding-linux-s390x-gnu": "1.2.5", + "@rolldown/binding-linux-x64-gnu": "1.2.5", + "@rolldown/binding-linux-x64-musl": "1.2.5", + "@rolldown/binding-openharmony-arm64": "1.2.5", + "@rolldown/binding-win32-arm64-msvc": "1.2.5", + "@rolldown/binding-win32-x64-msvc": "1.2.5" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz", + "integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.26", + "rolldown": "~1.2.4", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0 || ^0.5.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..1ac09fe --- /dev/null +++ b/package.json @@ -0,0 +1,26 @@ +{ + "name": "telecomkz_scraper", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "oxlint", + "preview": "vite preview" + }, + "dependencies": { + "lucide-react": "^1.33.0", + "react": "^19.2.8", + "react-dom": "^19.2.8" + }, + "devDependencies": { + "@types/node": "^24.13.3", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "@vitejs/plugin-react": "^6.1.0", + "oxlint": "^1.79.0", + "typescript": "~6.0.2", + "vite": "^8.2.2" + } +} diff --git a/public/assets/screens/account_selector_long.png b/public/assets/screens/account_selector_long.png new file mode 100644 index 0000000..d6bdd34 Binary files /dev/null and b/public/assets/screens/account_selector_long.png differ diff --git a/public/assets/screens/aitu_music_long.png b/public/assets/screens/aitu_music_long.png new file mode 100644 index 0000000..7805c9e Binary files /dev/null and b/public/assets/screens/aitu_music_long.png differ diff --git a/public/assets/screens/appeals_create_long.png b/public/assets/screens/appeals_create_long.png new file mode 100644 index 0000000..af057bb Binary files /dev/null and b/public/assets/screens/appeals_create_long.png differ diff --git a/public/assets/screens/balance_detail_long.png b/public/assets/screens/balance_detail_long.png new file mode 100644 index 0000000..9eeae67 Binary files /dev/null and b/public/assets/screens/balance_detail_long.png differ diff --git a/public/assets/screens/bonuses_screen_long.png b/public/assets/screens/bonuses_screen_long.png new file mode 100644 index 0000000..7dade2f Binary files /dev/null and b/public/assets/screens/bonuses_screen_long.png differ diff --git a/public/assets/screens/connect_service_long.png b/public/assets/screens/connect_service_long.png new file mode 100644 index 0000000..fb6e0df Binary files /dev/null and b/public/assets/screens/connect_service_long.png differ diff --git a/public/assets/screens/detalization_internet_long.png b/public/assets/screens/detalization_internet_long.png new file mode 100644 index 0000000..8273523 Binary files /dev/null and b/public/assets/screens/detalization_internet_long.png differ diff --git a/public/assets/screens/detalization_mobile_long.png b/public/assets/screens/detalization_mobile_long.png new file mode 100644 index 0000000..c671c1c Binary files /dev/null and b/public/assets/screens/detalization_mobile_long.png differ diff --git a/public/assets/screens/help_screen_long.png b/public/assets/screens/help_screen_long.png new file mode 100644 index 0000000..a05548d Binary files /dev/null and b/public/assets/screens/help_screen_long.png differ diff --git a/public/assets/screens/id_card_long.png b/public/assets/screens/id_card_long.png new file mode 100644 index 0000000..003eaaf Binary files /dev/null and b/public/assets/screens/id_card_long.png differ diff --git a/public/assets/screens/important_docs_long.png b/public/assets/screens/important_docs_long.png new file mode 100644 index 0000000..6965c94 Binary files /dev/null and b/public/assets/screens/important_docs_long.png differ diff --git a/public/assets/screens/main_dashboard.png b/public/assets/screens/main_dashboard.png new file mode 100644 index 0000000..9afc6cd Binary files /dev/null and b/public/assets/screens/main_dashboard.png differ diff --git a/public/assets/screens/main_dashboard_long.png b/public/assets/screens/main_dashboard_long.png new file mode 100644 index 0000000..213ee4e Binary files /dev/null and b/public/assets/screens/main_dashboard_long.png differ diff --git a/public/assets/screens/main_screen.png b/public/assets/screens/main_screen.png new file mode 100644 index 0000000..6c6908c Binary files /dev/null and b/public/assets/screens/main_screen.png differ diff --git a/public/assets/screens/main_screen_elements.json b/public/assets/screens/main_screen_elements.json new file mode 100644 index 0000000..9c9cce9 --- /dev/null +++ b/public/assets/screens/main_screen_elements.json @@ -0,0 +1,613 @@ +[ + { + "resourceId": "com.twitter.android:id/action_bar_root", + "text": "", + "contentDesc": "", + "clickable": false, + "className": "android.widget.LinearLayout", + "rect": { + "x": 0, + "y": 0, + "width": 1080, + "height": 2340 + } + }, + { + "resourceId": "android:id/content", + "text": "", + "contentDesc": "", + "clickable": false, + "className": "android.widget.FrameLayout", + "rect": { + "x": 0, + "y": 0, + "width": 1080, + "height": 2340 + } + }, + { + "resourceId": "PostDetail", + "text": "", + "contentDesc": "", + "clickable": false, + "className": "android.view.View", + "rect": { + "x": 0, + "y": 0, + "width": 1080, + "height": 2340 + } + }, + { + "resourceId": "", + "text": "", + "contentDesc": "", + "clickable": true, + "className": "android.view.View", + "rect": { + "x": 34, + "y": 367, + "width": 733, + "height": 135 + } + }, + { + "resourceId": "", + "text": "", + "contentDesc": "", + "clickable": true, + "className": "android.view.View", + "rect": { + "x": 790, + "y": 367, + "width": 166, + "height": 135 + } + }, + { + "resourceId": "", + "text": "Читать", + "contentDesc": "", + "clickable": false, + "className": "android.widget.TextView", + "rect": { + "x": 824, + "y": 411, + "width": 132, + "height": 47 + } + }, + { + "resourceId": "", + "text": "", + "contentDesc": "Варианты публикации поста", + "clickable": true, + "className": "android.widget.ImageView", + "rect": { + "x": 956, + "y": 367, + "width": 124, + "height": 135 + } + }, + { + "resourceId": "", + "text": "16:39 · 23 авг. 26 • 1K Просмотры", + "contentDesc": "", + "clickable": false, + "className": "android.widget.TextView", + "rect": { + "x": 34, + "y": 1598, + "width": 648, + "height": 50 + } + }, + { + "resourceId": "", + "text": "", + "contentDesc": "", + "clickable": true, + "className": "android.view.View", + "rect": { + "x": 0, + "y": 1651, + "width": 240, + "height": 135 + } + }, + { + "resourceId": "", + "text": "", + "contentDesc": "Ответить", + "clickable": false, + "className": "android.view.View", + "rect": { + "x": 39, + "y": 1698, + "width": 68, + "height": 68 + } + }, + { + "resourceId": "", + "text": "1", + "contentDesc": "", + "clickable": false, + "className": "android.widget.TextView", + "rect": { + "x": 118, + "y": 1712, + "width": 15, + "height": 40 + } + }, + { + "resourceId": "", + "text": "", + "contentDesc": "", + "clickable": true, + "className": "android.view.View", + "rect": { + "x": 240, + "y": 1651, + "width": 240, + "height": 135 + } + }, + { + "resourceId": "", + "text": "", + "contentDesc": "Сделать репост", + "clickable": false, + "className": "android.view.View", + "rect": { + "x": 279, + "y": 1698, + "width": 68, + "height": 68 + } + }, + { + "resourceId": "", + "text": "4", + "contentDesc": "", + "clickable": false, + "className": "android.widget.TextView", + "rect": { + "x": 358, + "y": 1712, + "width": 21, + "height": 40 + } + }, + { + "resourceId": "", + "text": "", + "contentDesc": "", + "clickable": true, + "className": "android.view.View", + "rect": { + "x": 480, + "y": 1651, + "width": 240, + "height": 135 + } + }, + { + "resourceId": "", + "text": "", + "contentDesc": "Нравится", + "clickable": false, + "className": "android.view.View", + "rect": { + "x": 519, + "y": 1698, + "width": 68, + "height": 68 + } + }, + { + "resourceId": "", + "text": "6", + "contentDesc": "", + "clickable": false, + "className": "android.widget.TextView", + "rect": { + "x": 598, + "y": 1712, + "width": 20, + "height": 40 + } + }, + { + "resourceId": "", + "text": "", + "contentDesc": "", + "clickable": true, + "className": "android.view.View", + "rect": { + "x": 720, + "y": 1651, + "width": 232, + "height": 135 + } + }, + { + "resourceId": "", + "text": "", + "contentDesc": "Добавить в закладки", + "clickable": false, + "className": "android.view.View", + "rect": { + "x": 759, + "y": 1698, + "width": 68, + "height": 68 + } + }, + { + "resourceId": "", + "text": "1", + "contentDesc": "", + "clickable": false, + "className": "android.widget.TextView", + "rect": { + "x": 838, + "y": 1712, + "width": 15, + "height": 40 + } + }, + { + "resourceId": "", + "text": "", + "contentDesc": "", + "clickable": true, + "className": "android.view.View", + "rect": { + "x": 952, + "y": 1651, + "width": 128, + "height": 135 + } + }, + { + "resourceId": "", + "text": "", + "contentDesc": "Поделиться", + "clickable": false, + "className": "android.view.View", + "rect": { + "x": 977, + "y": 1698, + "width": 68, + "height": 68 + } + }, + { + "resourceId": "", + "text": "", + "contentDesc": "", + "clickable": true, + "className": "android.view.View", + "rect": { + "x": 0, + "y": 1811, + "width": 1080, + "height": 529 + } + }, + { + "resourceId": "", + "text": "", + "contentDesc": "猫田", + "clickable": true, + "className": "android.widget.ImageView", + "rect": { + "x": 23, + "y": 1834, + "width": 135, + "height": 135 + } + }, + { + "resourceId": "timeline_post", + "text": "", + "contentDesc": "", + "clickable": false, + "className": "android.view.View", + "rect": { + "x": 170, + "y": 1845, + "width": 700, + "height": 61 + } + }, + { + "resourceId": "", + "text": "猫田", + "contentDesc": "", + "clickable": false, + "className": "android.widget.TextView", + "rect": { + "x": 170, + "y": 1845, + "width": 80, + "height": 61 + } + }, + { + "resourceId": "", + "text": "@mxz2q", + "contentDesc": "", + "clickable": false, + "className": "android.widget.TextView", + "rect": { + "x": 259, + "y": 1854, + "width": 166, + "height": 50 + } + }, + { + "resourceId": "", + "text": "· 18 ч.", + "contentDesc": "", + "clickable": false, + "className": "android.widget.TextView", + "rect": { + "x": 434, + "y": 1854, + "width": 108, + "height": 50 + } + }, + { + "resourceId": "", + "text": "", + "contentDesc": "Объяснить этот пост с помощью Grok", + "clickable": true, + "className": "android.widget.ImageView", + "rect": { + "x": 874, + "y": 1811, + "width": 82, + "height": 101 + } + }, + { + "resourceId": "", + "text": "", + "contentDesc": "Варианты публикации поста", + "clickable": true, + "className": "android.widget.ImageView", + "rect": { + "x": 956, + "y": 1811, + "width": 124, + "height": 132 + } + }, + { + "resourceId": "", + "text": "秒で買いました😭😭😭\nほんとにありがとうございます😭😭😭😭神様🙏", + "contentDesc": "", + "clickable": false, + "className": "android.widget.TextView", + "rect": { + "x": 170, + "y": 1912, + "width": 876, + "height": 175 + } + }, + { + "resourceId": "", + "text": "", + "contentDesc": "Изображение", + "clickable": true, + "className": "android.widget.ImageView", + "rect": { + "x": 170, + "y": 2110, + "width": 876, + "height": 230 + } + }, + { + "resourceId": "", + "text": "", + "contentDesc": "", + "clickable": true, + "className": "android.view.View", + "rect": { + "x": 23, + "y": 119, + "width": 135, + "height": 135 + } + }, + { + "resourceId": "", + "text": "", + "contentDesc": "Назад", + "clickable": false, + "className": "android.view.View", + "rect": { + "x": 57, + "y": 153, + "width": 68, + "height": 68 + } + }, + { + "resourceId": "", + "text": "Опубликовать", + "contentDesc": "", + "clickable": false, + "className": "android.widget.TextView", + "rect": { + "x": 169, + "y": 157, + "width": 710, + "height": 58 + } + }, + { + "resourceId": "", + "text": "", + "contentDesc": "", + "clickable": true, + "className": "android.view.View", + "rect": { + "x": 924, + "y": 119, + "width": 135, + "height": 135 + } + }, + { + "resourceId": "", + "text": "", + "contentDesc": "Объяснить этот пост с помощью Grok", + "clickable": false, + "className": "android.view.View", + "rect": { + "x": 958, + "y": 153, + "width": 68, + "height": 68 + } + }, + { + "resourceId": "", + "text": "", + "contentDesc": "", + "clickable": true, + "className": "android.view.View", + "rect": { + "x": 23, + "y": 2118, + "width": 1034, + "height": 157 + } + }, + { + "resourceId": "", + "text": "", + "contentDesc": "", + "clickable": true, + "className": "android.view.View", + "rect": { + "x": 24, + "y": 2130, + "width": 135, + "height": 135 + } + }, + { + "resourceId": "post-detail-reply-text-field", + "text": "", + "contentDesc": "", + "clickable": true, + "className": "android.widget.EditText", + "rect": { + "x": 159, + "y": 2130, + "width": 470, + "height": 135 + } + }, + { + "resourceId": "", + "text": "Опубликовать ответ", + "contentDesc": "", + "clickable": false, + "className": "android.widget.TextView", + "rect": { + "x": 159, + "y": 2172, + "width": 399, + "height": 50 + } + }, + { + "resourceId": "", + "text": "", + "contentDesc": "", + "clickable": true, + "className": "android.view.View", + "rect": { + "x": 629, + "y": 2129, + "width": 135, + "height": 135 + } + }, + { + "resourceId": "", + "text": "", + "contentDesc": "Фотографии", + "clickable": false, + "className": "android.view.View", + "rect": { + "x": 663, + "y": 2163, + "width": 68, + "height": 68 + } + }, + { + "resourceId": "", + "text": "", + "contentDesc": "", + "clickable": true, + "className": "android.view.View", + "rect": { + "x": 764, + "y": 2129, + "width": 135, + "height": 135 + } + }, + { + "resourceId": "", + "text": "", + "contentDesc": "GIF-файл", + "clickable": false, + "className": "android.view.View", + "rect": { + "x": 798, + "y": 2163, + "width": 68, + "height": 68 + } + }, + { + "resourceId": "", + "text": "", + "contentDesc": "", + "clickable": true, + "className": "android.view.View", + "rect": { + "x": 899, + "y": 2129, + "width": 135, + "height": 135 + } + }, + { + "resourceId": "", + "text": "", + "contentDesc": "Открыть полноэкранный редактор", + "clickable": false, + "className": "android.view.View", + "rect": { + "x": 933, + "y": 2163, + "width": 68, + "height": 68 + } + } +] \ No newline at end of file diff --git a/public/assets/screens/music_screen.png b/public/assets/screens/music_screen.png new file mode 100644 index 0000000..d4089fe Binary files /dev/null and b/public/assets/screens/music_screen.png differ diff --git a/public/assets/screens/music_screen_long.png b/public/assets/screens/music_screen_long.png new file mode 100644 index 0000000..328a4b4 Binary files /dev/null and b/public/assets/screens/music_screen_long.png differ diff --git a/public/assets/screens/orders_screen.png b/public/assets/screens/orders_screen.png new file mode 100644 index 0000000..a1089a2 Binary files /dev/null and b/public/assets/screens/orders_screen.png differ diff --git a/public/assets/screens/orders_screen_long.png b/public/assets/screens/orders_screen_long.png new file mode 100644 index 0000000..fa70f81 Binary files /dev/null and b/public/assets/screens/orders_screen_long.png differ diff --git a/public/assets/screens/payments_auto_long.png b/public/assets/screens/payments_auto_long.png new file mode 100644 index 0000000..86f1957 Binary files /dev/null and b/public/assets/screens/payments_auto_long.png differ diff --git a/public/assets/screens/payments_history_long.png b/public/assets/screens/payments_history_long.png new file mode 100644 index 0000000..71dfb64 Binary files /dev/null and b/public/assets/screens/payments_history_long.png differ diff --git a/public/assets/screens/payments_screen.png b/public/assets/screens/payments_screen.png new file mode 100644 index 0000000..b9020fe Binary files /dev/null and b/public/assets/screens/payments_screen.png differ diff --git a/public/assets/screens/payments_screen_long.png b/public/assets/screens/payments_screen_long.png new file mode 100644 index 0000000..44fcd08 Binary files /dev/null and b/public/assets/screens/payments_screen_long.png differ diff --git a/public/assets/screens/profile_settings_long.png b/public/assets/screens/profile_settings_long.png new file mode 100644 index 0000000..d6b96f4 Binary files /dev/null and b/public/assets/screens/profile_settings_long.png differ diff --git a/public/assets/screens/qr_scan_long.png b/public/assets/screens/qr_scan_long.png new file mode 100644 index 0000000..94e802c Binary files /dev/null and b/public/assets/screens/qr_scan_long.png differ diff --git a/public/assets/screens/screen_1787555219317_long.png b/public/assets/screens/screen_1787555219317_long.png new file mode 100644 index 0000000..5112d83 Binary files /dev/null and b/public/assets/screens/screen_1787555219317_long.png differ diff --git a/public/assets/screens/service_details_long.png b/public/assets/screens/service_details_long.png new file mode 100644 index 0000000..b868a38 Binary files /dev/null and b/public/assets/screens/service_details_long.png differ diff --git a/public/assets/screens/services_catalog_long.png b/public/assets/screens/services_catalog_long.png new file mode 100644 index 0000000..c59e62a Binary files /dev/null and b/public/assets/screens/services_catalog_long.png differ diff --git a/public/assets/screens/services_screen.png b/public/assets/screens/services_screen.png new file mode 100644 index 0000000..ecb1cef Binary files /dev/null and b/public/assets/screens/services_screen.png differ diff --git a/public/assets/screens/services_screen_long.png b/public/assets/screens/services_screen_long.png new file mode 100644 index 0000000..346ddd0 Binary files /dev/null and b/public/assets/screens/services_screen_long.png differ diff --git a/public/assets/screens/side_menu.png b/public/assets/screens/side_menu.png new file mode 100644 index 0000000..c066358 Binary files /dev/null and b/public/assets/screens/side_menu.png differ diff --git a/public/assets/screens/side_menu_long.png b/public/assets/screens/side_menu_long.png new file mode 100644 index 0000000..b4a9879 Binary files /dev/null and b/public/assets/screens/side_menu_long.png differ diff --git a/public/assets/screens/tariff_bereket_long.png b/public/assets/screens/tariff_bereket_long.png new file mode 100644 index 0000000..9d2424a Binary files /dev/null and b/public/assets/screens/tariff_bereket_long.png differ diff --git a/public/assets/screens/tariff_internet_200_long.png b/public/assets/screens/tariff_internet_200_long.png new file mode 100644 index 0000000..c427205 Binary files /dev/null and b/public/assets/screens/tariff_internet_200_long.png differ diff --git a/public/assets/screens/tariff_internet_500_long.png b/public/assets/screens/tariff_internet_500_long.png new file mode 100644 index 0000000..2824353 Binary files /dev/null and b/public/assets/screens/tariff_internet_500_long.png differ diff --git a/public/assets/screens/tariff_keremet_mobile_long.png b/public/assets/screens/tariff_keremet_mobile_long.png new file mode 100644 index 0000000..7698105 Binary files /dev/null and b/public/assets/screens/tariff_keremet_mobile_long.png differ diff --git a/public/assets/screens/tariff_keremet_tv_long.png b/public/assets/screens/tariff_keremet_tv_long.png new file mode 100644 index 0000000..c957ea6 Binary files /dev/null and b/public/assets/screens/tariff_keremet_tv_long.png differ diff --git a/public/assets/screens/tariffs_full_digital_long.png b/public/assets/screens/tariffs_full_digital_long.png new file mode 100644 index 0000000..022eaf0 Binary files /dev/null and b/public/assets/screens/tariffs_full_digital_long.png differ diff --git a/public/assets/screens/telecomkz_main.png b/public/assets/screens/telecomkz_main.png new file mode 100644 index 0000000..701e71c Binary files /dev/null and b/public/assets/screens/telecomkz_main.png differ diff --git a/public/assets/screens/telecomkz_main_elements.json b/public/assets/screens/telecomkz_main_elements.json new file mode 100644 index 0000000..2a16d02 --- /dev/null +++ b/public/assets/screens/telecomkz_main_elements.json @@ -0,0 +1,665 @@ +[ + { + "resourceId": "kz.telecom.app:id/action_bar_root", + "text": "", + "contentDesc": "", + "clickable": false, + "className": "android.widget.FrameLayout", + "rect": { + "x": 0, + "y": 0, + "width": 1080, + "height": 2340 + } + }, + { + "resourceId": "android:id/content", + "text": "", + "contentDesc": "", + "clickable": false, + "className": "android.widget.FrameLayout", + "rect": { + "x": 0, + "y": 0, + "width": 1080, + "height": 2340 + } + }, + { + "resourceId": "kz.telecom.app:id/contentLayout", + "text": "", + "contentDesc": "", + "clickable": false, + "className": "android.widget.LinearLayout", + "rect": { + "x": 0, + "y": 0, + "width": 1080, + "height": 2340 + } + }, + { + "resourceId": "kz.telecom.app:id/fragment_tab_container", + "text": "", + "contentDesc": "", + "clickable": false, + "className": "android.view.ViewGroup", + "rect": { + "x": 0, + "y": 0, + "width": 1080, + "height": 2139 + } + }, + { + "resourceId": "kz.telecom.app:id/rootLayout", + "text": "", + "contentDesc": "", + "clickable": false, + "className": "android.widget.ScrollView", + "rect": { + "x": 0, + "y": 0, + "width": 1080, + "height": 2139 + } + }, + { + "resourceId": "kz.telecom.app:id/appBarLayout", + "text": "", + "contentDesc": "", + "clickable": false, + "className": "android.widget.LinearLayout", + "rect": { + "x": 0, + "y": 0, + "width": 1080, + "height": 255 + } + }, + { + "resourceId": "kz.telecom.app:id/toolbar", + "text": "", + "contentDesc": "", + "clickable": false, + "className": "android.view.ViewGroup", + "rect": { + "x": 0, + "y": 96, + "width": 1080, + "height": 159 + } + }, + { + "resourceId": "kz.telecom.app:id/toolbarAvatarImageView", + "text": "", + "contentDesc": "", + "clickable": true, + "className": "android.widget.ImageView", + "rect": { + "x": 45, + "y": 119, + "width": 113, + "height": 113 + } + }, + { + "resourceId": "kz.telecom.app:id/toolbarTitleTextView", + "text": "Мой Telecom", + "contentDesc": "", + "clickable": false, + "className": "android.widget.TextView", + "rect": { + "x": 181, + "y": 137, + "width": 582, + "height": 76 + } + }, + { + "resourceId": "", + "text": "", + "contentDesc": "notifications", + "clickable": true, + "className": "android.widget.Button", + "rect": { + "x": 808, + "y": 107, + "width": 136, + "height": 135 + } + }, + { + "resourceId": "", + "text": "", + "contentDesc": "right-navbar", + "clickable": true, + "className": "android.widget.Button", + "rect": { + "x": 944, + "y": 107, + "width": 136, + "height": 135 + } + }, + { + "resourceId": "kz.telecom.app:id/webViewContainer", + "text": "", + "contentDesc": "", + "clickable": false, + "className": "android.widget.FrameLayout", + "rect": { + "x": 0, + "y": 255, + "width": 1080, + "height": 1884 + } + }, + { + "resourceId": "kz.telecom.app:id/webView", + "text": "", + "contentDesc": "", + "clickable": true, + "className": "android.webkit.WebView", + "rect": { + "x": 0, + "y": 255, + "width": 1080, + "height": 1884 + } + }, + { + "resourceId": "kz.telecom.app:id/urlTextView", + "text": "", + "contentDesc": "", + "clickable": false, + "className": "android.widget.TextView", + "rect": { + "x": 0, + "y": 255, + "width": 1080, + "height": 32 + } + }, + { + "resourceId": "kz.telecom.app:id/bottomNavigation", + "text": "", + "contentDesc": "", + "clickable": false, + "className": "android.widget.FrameLayout", + "rect": { + "x": 0, + "y": 2140, + "width": 1080, + "height": 200 + } + }, + { + "resourceId": "kz.telecom.app:id/action_telecomkz_account", + "text": "", + "contentDesc": "Кабинет", + "clickable": false, + "className": "android.widget.FrameLayout", + "rect": { + "x": 0, + "y": 2152, + "width": 216, + "height": 134 + } + }, + { + "resourceId": "kz.telecom.app:id/navigation_bar_item_content_container", + "text": "", + "contentDesc": "", + "clickable": false, + "className": "android.widget.LinearLayout", + "rect": { + "x": 41, + "y": 2169, + "width": 134, + "height": 111 + } + }, + { + "resourceId": "kz.telecom.app:id/navigation_bar_item_icon_container", + "text": "", + "contentDesc": "", + "clickable": false, + "className": "android.widget.FrameLayout", + "rect": { + "x": 74, + "y": 2169, + "width": 68, + "height": 68 + } + }, + { + "resourceId": "kz.telecom.app:id/navigation_bar_item_inner_content_container", + "text": "", + "contentDesc": "", + "clickable": false, + "className": "android.widget.LinearLayout", + "rect": { + "x": 74, + "y": 2169, + "width": 68, + "height": 68 + } + }, + { + "resourceId": "kz.telecom.app:id/navigation_bar_item_icon_view", + "text": "", + "contentDesc": "", + "clickable": false, + "className": "android.widget.ImageView", + "rect": { + "x": 74, + "y": 2169, + "width": 68, + "height": 68 + } + }, + { + "resourceId": "kz.telecom.app:id/navigation_bar_item_labels_group", + "text": "", + "contentDesc": "", + "clickable": false, + "className": "android.view.ViewGroup", + "rect": { + "x": 41, + "y": 2240, + "width": 134, + "height": 40 + } + }, + { + "resourceId": "kz.telecom.app:id/navigation_bar_item_large_label_view", + "text": "Кабинет", + "contentDesc": "", + "clickable": false, + "className": "android.widget.TextView", + "rect": { + "x": 41, + "y": 2240, + "width": 134, + "height": 40 + } + }, + { + "resourceId": "kz.telecom.app:id/action_tv_plus", + "text": "", + "contentDesc": "TV+", + "clickable": true, + "className": "android.widget.FrameLayout", + "rect": { + "x": 216, + "y": 2152, + "width": 216, + "height": 134 + } + }, + { + "resourceId": "kz.telecom.app:id/navigation_bar_item_content_container", + "text": "", + "contentDesc": "", + "clickable": false, + "className": "android.widget.LinearLayout", + "rect": { + "x": 290, + "y": 2175, + "width": 68, + "height": 111 + } + }, + { + "resourceId": "kz.telecom.app:id/navigation_bar_item_icon_container", + "text": "", + "contentDesc": "", + "clickable": false, + "className": "android.widget.FrameLayout", + "rect": { + "x": 290, + "y": 2175, + "width": 68, + "height": 68 + } + }, + { + "resourceId": "kz.telecom.app:id/navigation_bar_item_inner_content_container", + "text": "", + "contentDesc": "", + "clickable": false, + "className": "android.widget.LinearLayout", + "rect": { + "x": 290, + "y": 2175, + "width": 68, + "height": 68 + } + }, + { + "resourceId": "kz.telecom.app:id/navigation_bar_item_icon_view", + "text": "", + "contentDesc": "", + "clickable": false, + "className": "android.widget.ImageView", + "rect": { + "x": 290, + "y": 2175, + "width": 68, + "height": 68 + } + }, + { + "resourceId": "kz.telecom.app:id/navigation_bar_item_labels_group", + "text": "", + "contentDesc": "", + "clickable": false, + "className": "android.view.ViewGroup", + "rect": { + "x": 293, + "y": 2246, + "width": 62, + "height": 40 + } + }, + { + "resourceId": "kz.telecom.app:id/navigation_bar_item_small_label_view", + "text": "TV+", + "contentDesc": "", + "clickable": false, + "className": "android.widget.TextView", + "rect": { + "x": 298, + "y": 2252, + "width": 52, + "height": 33 + } + }, + { + "resourceId": "kz.telecom.app:id/action_music", + "text": "", + "contentDesc": "Музыка", + "clickable": true, + "className": "android.widget.FrameLayout", + "rect": { + "x": 432, + "y": 2152, + "width": 216, + "height": 134 + } + }, + { + "resourceId": "kz.telecom.app:id/navigation_bar_item_content_container", + "text": "", + "contentDesc": "", + "clickable": false, + "className": "android.widget.LinearLayout", + "rect": { + "x": 477, + "y": 2175, + "width": 126, + "height": 111 + } + }, + { + "resourceId": "kz.telecom.app:id/navigation_bar_item_icon_container", + "text": "", + "contentDesc": "", + "clickable": false, + "className": "android.widget.FrameLayout", + "rect": { + "x": 506, + "y": 2175, + "width": 68, + "height": 68 + } + }, + { + "resourceId": "kz.telecom.app:id/navigation_bar_item_inner_content_container", + "text": "", + "contentDesc": "", + "clickable": false, + "className": "android.widget.LinearLayout", + "rect": { + "x": 506, + "y": 2175, + "width": 68, + "height": 68 + } + }, + { + "resourceId": "kz.telecom.app:id/navigation_bar_item_icon_view", + "text": "", + "contentDesc": "", + "clickable": false, + "className": "android.widget.ImageView", + "rect": { + "x": 506, + "y": 2175, + "width": 68, + "height": 68 + } + }, + { + "resourceId": "kz.telecom.app:id/navigation_bar_item_labels_group", + "text": "", + "contentDesc": "", + "clickable": false, + "className": "android.view.ViewGroup", + "rect": { + "x": 477, + "y": 2246, + "width": 126, + "height": 40 + } + }, + { + "resourceId": "kz.telecom.app:id/navigation_bar_item_small_label_view", + "text": "Музыка", + "contentDesc": "", + "clickable": false, + "className": "android.widget.TextView", + "rect": { + "x": 488, + "y": 2252, + "width": 103, + "height": 33 + } + }, + { + "resourceId": "kz.telecom.app:id/action_chats", + "text": "", + "contentDesc": "Чаты", + "clickable": true, + "className": "android.widget.FrameLayout", + "rect": { + "x": 648, + "y": 2152, + "width": 216, + "height": 134 + } + }, + { + "resourceId": "kz.telecom.app:id/badge", + "text": "2", + "contentDesc": "", + "clickable": false, + "className": "android.widget.TextView", + "rect": { + "x": 765, + "y": 2175, + "width": 50, + "height": 50 + } + }, + { + "resourceId": "kz.telecom.app:id/navigation_bar_item_content_container", + "text": "", + "contentDesc": "", + "clickable": false, + "className": "android.widget.LinearLayout", + "rect": { + "x": 714, + "y": 2175, + "width": 84, + "height": 111 + } + }, + { + "resourceId": "kz.telecom.app:id/navigation_bar_item_icon_container", + "text": "", + "contentDesc": "", + "clickable": false, + "className": "android.widget.FrameLayout", + "rect": { + "x": 722, + "y": 2175, + "width": 68, + "height": 68 + } + }, + { + "resourceId": "kz.telecom.app:id/navigation_bar_item_inner_content_container", + "text": "", + "contentDesc": "", + "clickable": false, + "className": "android.widget.LinearLayout", + "rect": { + "x": 722, + "y": 2175, + "width": 68, + "height": 68 + } + }, + { + "resourceId": "kz.telecom.app:id/navigation_bar_item_icon_view", + "text": "", + "contentDesc": "", + "clickable": false, + "className": "android.widget.ImageView", + "rect": { + "x": 722, + "y": 2175, + "width": 68, + "height": 68 + } + }, + { + "resourceId": "kz.telecom.app:id/navigation_bar_item_labels_group", + "text": "", + "contentDesc": "", + "clickable": false, + "className": "android.view.ViewGroup", + "rect": { + "x": 714, + "y": 2246, + "width": 84, + "height": 40 + } + }, + { + "resourceId": "kz.telecom.app:id/navigation_bar_item_small_label_view", + "text": "Чаты", + "contentDesc": "", + "clickable": false, + "className": "android.widget.TextView", + "rect": { + "x": 721, + "y": 2252, + "width": 69, + "height": 33 + } + }, + { + "resourceId": "kz.telecom.app:id/action_b2b", + "text": "", + "contentDesc": "Бизнес", + "clickable": true, + "className": "android.widget.FrameLayout", + "rect": { + "x": 864, + "y": 2152, + "width": 216, + "height": 134 + } + }, + { + "resourceId": "kz.telecom.app:id/navigation_bar_item_content_container", + "text": "", + "contentDesc": "", + "clickable": false, + "className": "android.widget.LinearLayout", + "rect": { + "x": 915, + "y": 2175, + "width": 114, + "height": 111 + } + }, + { + "resourceId": "kz.telecom.app:id/navigation_bar_item_icon_container", + "text": "", + "contentDesc": "", + "clickable": false, + "className": "android.widget.FrameLayout", + "rect": { + "x": 938, + "y": 2175, + "width": 68, + "height": 68 + } + }, + { + "resourceId": "kz.telecom.app:id/navigation_bar_item_inner_content_container", + "text": "", + "contentDesc": "", + "clickable": false, + "className": "android.widget.LinearLayout", + "rect": { + "x": 938, + "y": 2175, + "width": 68, + "height": 68 + } + }, + { + "resourceId": "kz.telecom.app:id/navigation_bar_item_icon_view", + "text": "", + "contentDesc": "", + "clickable": false, + "className": "android.widget.ImageView", + "rect": { + "x": 938, + "y": 2175, + "width": 68, + "height": 68 + } + }, + { + "resourceId": "kz.telecom.app:id/navigation_bar_item_labels_group", + "text": "", + "contentDesc": "", + "clickable": false, + "className": "android.view.ViewGroup", + "rect": { + "x": 915, + "y": 2246, + "width": 114, + "height": 40 + } + }, + { + "resourceId": "kz.telecom.app:id/navigation_bar_item_small_label_view", + "text": "Бизнес", + "contentDesc": "", + "clickable": false, + "className": "android.widget.TextView", + "rect": { + "x": 925, + "y": 2252, + "width": 94, + "height": 33 + } + } +] \ No newline at end of file diff --git a/public/assets/screens/telecomkz_main_long.png b/public/assets/screens/telecomkz_main_long.png new file mode 100644 index 0000000..7bd3a53 Binary files /dev/null and b/public/assets/screens/telecomkz_main_long.png differ diff --git a/public/assets/screens/traffic_screen.png b/public/assets/screens/traffic_screen.png new file mode 100644 index 0000000..c758aeb Binary files /dev/null and b/public/assets/screens/traffic_screen.png differ diff --git a/public/assets/screens/traffic_screen_long.png b/public/assets/screens/traffic_screen_long.png new file mode 100644 index 0000000..ff726a2 Binary files /dev/null and b/public/assets/screens/traffic_screen_long.png differ diff --git a/public/assets/screens/turbo_landing_long.png b/public/assets/screens/turbo_landing_long.png new file mode 100644 index 0000000..dbf2c4a Binary files /dev/null and b/public/assets/screens/turbo_landing_long.png differ diff --git a/public/assets/screens/tv_plus_long.png b/public/assets/screens/tv_plus_long.png new file mode 100644 index 0000000..b8024bc Binary files /dev/null and b/public/assets/screens/tv_plus_long.png differ diff --git a/public/data/metrics.json b/public/data/metrics.json new file mode 100644 index 0000000..98c3bc8 --- /dev/null +++ b/public/data/metrics.json @@ -0,0 +1,99 @@ +{ + "source": "demo", + "fetchedAt": null, + "table": null, + "windowDays": 30, + "note": "Демонстрационные значения. Это НЕ данные ClickHouse. Запустите tools/fetch_clickhouse.py, чтобы заменить их реальными.", + "events": [ + { + "eventKey": "MENUCLICKED", + "eventNameRu": "Нажатие на Меню", + "totalEvents": 10680, + "uniqueUsers": 3478, + "uniqueDevices": 3510, + "avgEventsPerUser": 3.07, + "shareOfClicks": 18.5, + "category": "Навигация" + }, + { + "eventKey": "ACCOUNT_SELECTOR_CLICK", + "eventNameRu": "Выбор Лицевого Счета (ЛС)", + "totalEvents": 24150, + "uniqueUsers": 8920, + "uniqueDevices": 9100, + "avgEventsPerUser": 2.7, + "shareOfClicks": 41.8, + "category": "Лицевой счет" + }, + { + "eventKey": "PAYMENTS_CLICK", + "eventNameRu": "Переход в «Платежи»", + "totalEvents": 29800, + "uniqueUsers": 12400, + "uniqueDevices": 12800, + "avgEventsPerUser": 2.4, + "shareOfClicks": 51.6, + "category": "Платежи" + }, + { + "eventKey": "DETAILS_CLICK", + "eventNameRu": "Переход в «Детализацию»", + "totalEvents": 18450, + "uniqueUsers": 7120, + "uniqueDevices": 7300, + "avgEventsPerUser": 2.59, + "shareOfClicks": 32.0, + "category": "Финансы" + }, + { + "eventKey": "SERVICES_CLICK", + "eventNameRu": "Переход в «Мои услуги»", + "totalEvents": 14200, + "uniqueUsers": 5890, + "uniqueDevices": 6050, + "avgEventsPerUser": 2.41, + "shareOfClicks": 24.6, + "category": "Услуги" + }, + { + "eventKey": "MAIN_BANNER_CLICK", + "eventNameRu": "Клик по главному баннеру", + "totalEvents": 8920, + "uniqueUsers": 4150, + "uniqueDevices": 4210, + "avgEventsPerUser": 2.15, + "shareOfClicks": 15.4, + "category": "Маркетинг" + }, + { + "eventKey": "PROFILE_ICON_CLICK", + "eventNameRu": "Переход в Профиль", + "totalEvents": 6840, + "uniqueUsers": 2190, + "uniqueDevices": 2240, + "avgEventsPerUser": 3.12, + "shareOfClicks": 11.8, + "category": "Профиль" + }, + { + "eventKey": "BALANCE_CARD_PAY_CLICK", + "eventNameRu": "Быстрая оплата баланса", + "totalEvents": 22100, + "uniqueUsers": 9450, + "uniqueDevices": 9700, + "avgEventsPerUser": 2.34, + "shareOfClicks": 38.3, + "category": "Платежи" + }, + { + "eventKey": "SUPPORT_CHAT_CLICK", + "eventNameRu": "Открытие чата поддержки", + "totalEvents": 4320, + "uniqueUsers": 1890, + "uniqueDevices": 1920, + "avgEventsPerUser": 2.28, + "shareOfClicks": 7.5, + "category": "Поддержка" + } + ] +} \ No newline at end of file diff --git a/public/data/telecomkz_app_map.json b/public/data/telecomkz_app_map.json new file mode 100644 index 0000000..58c4aab --- /dev/null +++ b/public/data/telecomkz_app_map.json @@ -0,0 +1,6291 @@ +{ + "project": "TelecomKz Mobile Analytics", + "version": "1.3.0", + "defaultScreenId": "main_dashboard", + "screens": [ + { + "id": "main_dashboard", + "name": "Главный экран (Кабинет)", + "category": "Основное", + "image": "/assets/screens/main_dashboard_long.png", + "isScrollable": true, + "viewportWidth": 1080, + "viewportHeight": 2340, + "totalHeight": 2989, + "route": "/", + "capturedAt": 1787571242774, + "hotspots": [ + { + "id": "hs_dom_1", + "label": "Лицевой счет 11440366 г.Алматы, прс.Достык, дом 89, кв(оф) 4", + "rect": { + "x": 90, + "y": 660, + "width": 607, + "height": 115 + }, + "eventKey": "SELECTACCOUNT", + "eventNameRu": "Выбор Лицевого Счета", + "category": "action", + "targetScreenId": "account_selector", + "source": "webview-dom", + "keyConfidence": "observed", + "metricsSource": "none" + }, + { + "id": "hs_dom_2", + "label": "Свободные средства 16 484.62 ₸", + "rect": { + "x": 45, + "y": 853, + "width": 484, + "height": 287 + }, + "eventKey": "detalization_widget_open", + "eventNameRu": "Быстрая оплата баланса", + "category": "action", + "targetScreenId": "balance_detail", + "source": "webview-dom", + "keyConfidence": "observed", + "metricsSource": "none" + }, + { + "id": "hs_dom_3", + "label": "Мои бонусы 0 акции и бонусы ждут тебя!", + "rect": { + "x": 551, + "y": 853, + "width": 484, + "height": 287 + }, + "eventKey": "BONUSES_CLICK", + "eventNameRu": "Раздел «Мои бонусы»", + "category": "action", + "targetScreenId": "bonuses_screen", + "source": "webview-dom", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_dom_4", + "label": "Мои услуги", + "rect": { + "x": 34, + "y": 1223, + "width": 253, + "height": 174 + }, + "eventKey": "HOMETAPMYSERVICES", + "eventNameRu": "Переход в «Мои услуги»", + "category": "action", + "targetScreenId": "services_screen", + "source": "webview-dom", + "keyConfidence": "observed", + "metricsSource": "none" + }, + { + "id": "hs_dom_5", + "label": "Трафик", + "rect": { + "x": 287, + "y": 1223, + "width": 253, + "height": 174 + }, + "eventKey": "HOMEDETALIZATION", + "eventNameRu": "Просмотр «Трафик»", + "category": "action", + "targetScreenId": "traffic_screen", + "source": "webview-dom", + "keyConfidence": "observed", + "metricsSource": "none" + }, + { + "id": "hs_dom_6", + "label": "Платежи", + "rect": { + "x": 540, + "y": 1223, + "width": 253, + "height": 174 + }, + "eventKey": "HOMEPAGEPAYMENTS", + "eventNameRu": "Переход в «Платежи»", + "category": "action", + "targetScreenId": "payments_screen", + "source": "webview-dom", + "keyConfidence": "observed", + "metricsSource": "none" + }, + { + "id": "hs_dom_7", + "label": "Заявки", + "rect": { + "x": 793, + "y": 1223, + "width": 253, + "height": 174 + }, + "eventKey": "HOMETAPORDERS", + "eventNameRu": "Раздел «Заявки»", + "category": "action", + "targetScreenId": "orders_screen", + "source": "webview-dom", + "keyConfidence": "observed", + "metricsSource": "none" + }, + { + "id": "hs_dom_8", + "label": "Музыка", + "rect": { + "x": 34, + "y": 1431, + "width": 253, + "height": 174 + }, + "eventKey": "MUSIC_CLICK", + "eventNameRu": "Переход в «Музыка»", + "category": "action", + "targetScreenId": "music_screen", + "source": "webview-dom", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_dom_9", + "label": "QR", + "rect": { + "x": 287, + "y": 1431, + "width": 253, + "height": 174 + }, + "eventKey": "QR_PAY_CLICK", + "eventNameRu": "Оплата по QR", + "category": "action", + "targetScreenId": "qr_scan", + "source": "webview-dom", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_dom_10", + "label": "Удв. лич.", + "rect": { + "x": 540, + "y": 1431, + "width": 253, + "height": 174 + }, + "eventKey": "ID_CARD_HOME_CLICK", + "eventNameRu": "Удостоверение личности", + "category": "action", + "targetScreenId": "id_card", + "source": "webview-dom", + "keyConfidence": "observed", + "metricsSource": "none" + }, + { + "id": "hs_dom_11", + "label": "Сервисы", + "rect": { + "x": 793, + "y": 1431, + "width": 253, + "height": 174 + }, + "eventKey": "SERVICES_CATALOG_CLICK", + "eventNameRu": "Каталог сервисов", + "category": "action", + "targetScreenId": "services_catalog", + "source": "webview-dom", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_dom_12", + "label": "Подключить интернет Скидки и бонусы", + "rect": { + "x": 45, + "y": 1673, + "width": 419, + "height": 398 + }, + "eventKey": "fd_auth_tariffs_opened", + "eventNameRu": "Подключение интернета", + "category": "action", + "targetScreenId": "tariffs_full_digital", + "source": "webview-dom", + "keyConfidence": "observed", + "metricsSource": "none" + }, + { + "id": "hs_dom_13", + "label": "TV+ Смотрите все новинки", + "rect": { + "x": 45, + "y": 2104, + "width": 419, + "height": 386 + }, + "eventKey": "HOMEADDSERVICETVPLUS", + "eventNameRu": "Открытие TV+ с главной", + "category": "action", + "targetScreenId": "tv_plus", + "source": "webview-dom", + "keyConfidence": "observed", + "metricsSource": "none" + }, + { + "id": "hs_dom_14", + "label": "NEW TURBO Увеличьте скорость интернета", + "rect": { + "x": 487, + "y": 1673, + "width": 419, + "height": 398 + }, + "eventKey": "turbo_widget_clicked", + "eventNameRu": "Подключение Turbo-скорости", + "category": "action", + "targetScreenId": "turbo_landing", + "source": "webview-dom", + "keyConfidence": "observed", + "metricsSource": "none" + }, + { + "id": "hs_dom_15", + "label": "AITU Music Слушайте без ограничений", + "rect": { + "x": 487, + "y": 2104, + "width": 419, + "height": 386 + }, + "eventKey": "aitu_music_banner_clicked", + "eventNameRu": "Открытие AITU Music", + "category": "action", + "targetScreenId": "aitu_music", + "source": "webview-dom", + "keyConfidence": "observed", + "metricsSource": "none" + }, + { + "id": "hs_dom_16", + "label": "Telecom Shop Покупайте выгодно", + "rect": { + "x": 928, + "y": 1673, + "width": 152, + "height": 398 + }, + "eventKey": "OPENINGWINDOWTELECOMSHOP", + "eventNameRu": "Открытие Telecom Shop", + "category": "action", + "targetScreenId": null, + "source": "webview-dom", + "keyConfidence": "observed", + "metricsSource": "none" + }, + { + "id": "hs_native_toolbarAvatarImageView", + "label": "Аватар профиля", + "rect": { + "x": 45, + "y": 119, + "width": 113, + "height": 113 + }, + "eventKey": "PROFILE_ICON_CLICK", + "eventNameRu": "Переход в Профиль", + "category": "navigation", + "source": "native-uiautomator", + "keyConfidence": "rule", + "metrics": { + "totalEvents": 6840, + "uniqueUsers": 2190, + "avgEventsPerUser": 3.12, + "shareOfClicks": 11.8, + "lastUpdated": null + }, + "metricsSource": "demo" + }, + { + "id": "hs_native_1", + "label": "Уведомления", + "rect": { + "x": 808, + "y": 107, + "width": 136, + "height": 135 + }, + "eventKey": "NOTIFICATIONS_OPEN", + "eventNameRu": "Открытие уведомлений", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_2", + "label": "Меню (правый навбар)", + "rect": { + "x": 944, + "y": 107, + "width": 136, + "height": 135 + }, + "eventKey": "notautho_main", + "eventNameRu": "Открытие бокового меню", + "category": "navigation", + "targetScreenId": "side_menu", + "source": "native-uiautomator", + "keyConfidence": "observed", + "metricsSource": "none" + }, + { + "id": "hs_native_action_telecomkz_account", + "label": "Вкладка «Кабинет»", + "rect": { + "x": 0, + "y": 2801, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_CABINET_CLICK", + "eventNameRu": "Вкладка «Кабинет»", + "category": "navigation", + "targetScreenId": "main_dashboard", + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_tv_plus", + "label": "Вкладка «TV+»", + "rect": { + "x": 216, + "y": 2801, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_TV_PLUS_CLICK", + "eventNameRu": "Вкладка «TV+»", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_music", + "label": "Вкладка «Музыка»", + "rect": { + "x": 432, + "y": 2801, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_MUSIC_CLICK", + "eventNameRu": "Вкладка «Музыка»", + "category": "navigation", + "targetScreenId": "music_screen", + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_chats", + "label": "Вкладка «Чаты»", + "rect": { + "x": 648, + "y": 2801, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_CHATS_CLICK", + "eventNameRu": "Вкладка «Чаты»", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_b2b", + "label": "Вкладка «Бизнес»", + "rect": { + "x": 864, + "y": 2801, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_BUSINESS_CLICK", + "eventNameRu": "Вкладка «Бизнес»", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + } + ], + "carriedKeys": 11 + }, + { + "id": "services_screen", + "name": "Экран «Мои услуги»", + "category": "Услуги", + "image": "/assets/screens/services_screen_long.png", + "isScrollable": true, + "viewportWidth": 1080, + "viewportHeight": 2340, + "totalHeight": 3886, + "route": "/services", + "capturedAt": 1787570242809, + "hotspots": [ + { + "id": "hs_dom_1", + "label": "Подробнее", + "rect": { + "x": 650, + "y": 2870, + "width": 324, + "height": 112 + }, + "eventKey": "click_details_mp", + "eventNameRu": "Нажатие «Подробнее»", + "category": "action", + "targetScreenId": "service_details", + "source": "webview-dom", + "keyConfidence": "observed", + "metricsSource": "none" + }, + { + "id": "hs_native_0", + "label": "Уведомления", + "rect": { + "x": 944, + "y": 107, + "width": 136, + "height": 135 + }, + "eventKey": "NOTIFICATIONS_OPEN", + "eventNameRu": "Открытие уведомлений", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_telecomkz_account", + "label": "Вкладка «Кабинет»", + "rect": { + "x": 0, + "y": 3698, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_CABINET_CLICK", + "eventNameRu": "Вкладка «Кабинет»", + "category": "navigation", + "targetScreenId": "main_dashboard", + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_tv_plus", + "label": "Вкладка «TV+»", + "rect": { + "x": 216, + "y": 3698, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_TV_PLUS_CLICK", + "eventNameRu": "Вкладка «TV+»", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_music", + "label": "Вкладка «Музыка»", + "rect": { + "x": 432, + "y": 3698, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_MUSIC_CLICK", + "eventNameRu": "Вкладка «Музыка»", + "category": "navigation", + "targetScreenId": "music_screen", + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_chats", + "label": "Вкладка «Чаты»", + "rect": { + "x": 648, + "y": 3698, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_CHATS_CLICK", + "eventNameRu": "Вкладка «Чаты»", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_b2b", + "label": "Вкладка «Бизнес»", + "rect": { + "x": 864, + "y": 3698, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_BUSINESS_CLICK", + "eventNameRu": "Вкладка «Бизнес»", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + } + ], + "carriedKeys": 0 + }, + { + "id": "traffic_screen", + "name": "Экран «Трафик»", + "category": "Услуги", + "image": "/assets/screens/traffic_screen_long.png", + "isScrollable": false, + "viewportWidth": 1080, + "viewportHeight": 2340, + "totalHeight": 2339, + "route": "/detalization", + "capturedAt": 1787569996509, + "hotspots": [ + { + "id": "hs_dom_1", + "label": "Домашний интернет", + "rect": { + "x": 45, + "y": 299, + "width": 990, + "height": 208 + }, + "eventKey": "button_click_home_internet", + "eventNameRu": "Нажатие «Домашний интернет»", + "category": "action", + "targetScreenId": "detalization_internet", + "source": "webview-dom", + "keyConfidence": "observed", + "metricsSource": "none" + }, + { + "id": "hs_dom_2", + "label": "Мобильная связь", + "rect": { + "x": 45, + "y": 541, + "width": 990, + "height": 208 + }, + "eventKey": "DETALIZATIONMOBILEINTERNET", + "eventNameRu": "Нажатие «Мобильная связь»", + "category": "action", + "targetScreenId": "detalization_mobile", + "source": "webview-dom", + "keyConfidence": "observed", + "metricsSource": "none" + }, + { + "id": "hs_dom_3", + "label": "Домашний телефон", + "rect": { + "x": 45, + "y": 783, + "width": 990, + "height": 208 + }, + "eventKey": "CLICK_DOMASHNIY_TELEFON", + "eventNameRu": "Нажатие «Домашний телефон»", + "category": "action", + "targetScreenId": null, + "source": "webview-dom", + "keyConfidence": "guessed", + "metricsSource": "none" + }, + { + "id": "hs_native_0", + "label": "Нативный элемент", + "rect": { + "x": 0, + "y": 96, + "width": 158, + "height": 158 + }, + "eventKey": "CLICK_NATIVE", + "eventNameRu": "Нажатие «native»", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "guessed", + "metricsSource": "none" + }, + { + "id": "hs_native_1", + "label": "Уведомления", + "rect": { + "x": 944, + "y": 107, + "width": 136, + "height": 135 + }, + "eventKey": "NOTIFICATIONS_OPEN", + "eventNameRu": "Открытие уведомлений", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_telecomkz_account", + "label": "Вкладка «Кабинет»", + "rect": { + "x": 0, + "y": 2151, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_CABINET_CLICK", + "eventNameRu": "Вкладка «Кабинет»", + "category": "navigation", + "targetScreenId": "main_dashboard", + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_tv_plus", + "label": "Вкладка «TV+»", + "rect": { + "x": 216, + "y": 2151, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_TV_PLUS_CLICK", + "eventNameRu": "Вкладка «TV+»", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_music", + "label": "Вкладка «Музыка»", + "rect": { + "x": 432, + "y": 2151, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_MUSIC_CLICK", + "eventNameRu": "Вкладка «Музыка»", + "category": "navigation", + "targetScreenId": "music_screen", + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_chats", + "label": "Вкладка «Чаты»", + "rect": { + "x": 648, + "y": 2151, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_CHATS_CLICK", + "eventNameRu": "Вкладка «Чаты»", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_b2b", + "label": "Вкладка «Бизнес»", + "rect": { + "x": 864, + "y": 2151, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_BUSINESS_CLICK", + "eventNameRu": "Вкладка «Бизнес»", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + } + ], + "carriedKeys": 0 + }, + { + "id": "payments_screen", + "name": "Экран «Платежи»", + "category": "Финансы", + "image": "/assets/screens/payments_screen_long.png", + "isScrollable": false, + "viewportWidth": 1080, + "viewportHeight": 2340, + "totalHeight": 2339, + "route": "/payments", + "capturedAt": 1787570032442, + "hotspots": [ + { + "id": "hs_dom_1", + "label": "Мой автоплатеж", + "rect": { + "x": 45, + "y": 299, + "width": 990, + "height": 208 + }, + "eventKey": "PAYMENTSTAPMYAUTOPAYMENT", + "eventNameRu": "Переход в «Платежи»", + "category": "action", + "targetScreenId": "payments_auto", + "source": "webview-dom", + "keyConfidence": "observed", + "metricsSource": "none" + }, + { + "id": "hs_dom_2", + "label": "История платежей", + "rect": { + "x": 45, + "y": 541, + "width": 990, + "height": 208 + }, + "eventKey": "PAYMENTTAPPAYMENTSTORIE", + "eventNameRu": "Переход в «Платежи»", + "category": "action", + "targetScreenId": "payments_history", + "source": "webview-dom", + "keyConfidence": "observed", + "metricsSource": "none" + }, + { + "id": "hs_dom_3", + "label": "Выставленные счета", + "rect": { + "x": 45, + "y": 783, + "width": 990, + "height": 208 + }, + "eventKey": "CLICK_VYSTAVLENNYE_SCHETA", + "eventNameRu": "Нажатие «Выставленные счета»", + "category": "action", + "targetScreenId": null, + "source": "webview-dom", + "keyConfidence": "guessed", + "metricsSource": "none" + }, + { + "id": "hs_dom_4", + "label": "Способ доставки квитанции", + "rect": { + "x": 45, + "y": 1025, + "width": 990, + "height": 219 + }, + "eventKey": "CLICK_SPOSOB_DOSTAVKI_KVITANTSII", + "eventNameRu": "Нажатие «Способ доставки квитанции»", + "category": "action", + "targetScreenId": null, + "source": "webview-dom", + "keyConfidence": "guessed", + "metricsSource": "none" + }, + { + "id": "hs_native_0", + "label": "Уведомления", + "rect": { + "x": 944, + "y": 107, + "width": 136, + "height": 135 + }, + "eventKey": "NOTIFICATIONS_OPEN", + "eventNameRu": "Открытие уведомлений", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_telecomkz_account", + "label": "Вкладка «Кабинет»", + "rect": { + "x": 0, + "y": 2151, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_CABINET_CLICK", + "eventNameRu": "Вкладка «Кабинет»", + "category": "navigation", + "targetScreenId": "main_dashboard", + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_tv_plus", + "label": "Вкладка «TV+»", + "rect": { + "x": 216, + "y": 2151, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_TV_PLUS_CLICK", + "eventNameRu": "Вкладка «TV+»", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_music", + "label": "Вкладка «Музыка»", + "rect": { + "x": 432, + "y": 2151, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_MUSIC_CLICK", + "eventNameRu": "Вкладка «Музыка»", + "category": "navigation", + "targetScreenId": "music_screen", + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_chats", + "label": "Вкладка «Чаты»", + "rect": { + "x": 648, + "y": 2151, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_CHATS_CLICK", + "eventNameRu": "Вкладка «Чаты»", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_b2b", + "label": "Вкладка «Бизнес»", + "rect": { + "x": 864, + "y": 2151, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_BUSINESS_CLICK", + "eventNameRu": "Вкладка «Бизнес»", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + } + ], + "carriedKeys": 0 + }, + { + "id": "orders_screen", + "name": "Экран «Заявки»", + "category": "Услуги", + "image": "/assets/screens/orders_screen_long.png", + "isScrollable": false, + "viewportWidth": 1080, + "viewportHeight": 2340, + "totalHeight": 2339, + "route": "/appeals", + "capturedAt": 1787570194797, + "hotspots": [ + { + "id": "hs_dom_1", + "label": "Создать заявку", + "rect": { + "x": 45, + "y": 1959, + "width": 990, + "height": 135 + }, + "eventKey": "ORDERSOPENSCREENCREATEORDER", + "eventNameRu": "Раздел «Заявки»", + "category": "action", + "targetScreenId": "appeals_create", + "source": "webview-dom", + "keyConfidence": "observed", + "metricsSource": "none" + }, + { + "id": "hs_native_0", + "label": "Уведомления", + "rect": { + "x": 944, + "y": 107, + "width": 136, + "height": 135 + }, + "eventKey": "NOTIFICATIONS_OPEN", + "eventNameRu": "Открытие уведомлений", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_telecomkz_account", + "label": "Вкладка «Кабинет»", + "rect": { + "x": 0, + "y": 2151, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_CABINET_CLICK", + "eventNameRu": "Вкладка «Кабинет»", + "category": "navigation", + "targetScreenId": "main_dashboard", + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_tv_plus", + "label": "Вкладка «TV+»", + "rect": { + "x": 216, + "y": 2151, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_TV_PLUS_CLICK", + "eventNameRu": "Вкладка «TV+»", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_music", + "label": "Вкладка «Музыка»", + "rect": { + "x": 432, + "y": 2151, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_MUSIC_CLICK", + "eventNameRu": "Вкладка «Музыка»", + "category": "navigation", + "targetScreenId": "music_screen", + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_chats", + "label": "Вкладка «Чаты»", + "rect": { + "x": 648, + "y": 2151, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_CHATS_CLICK", + "eventNameRu": "Вкладка «Чаты»", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_b2b", + "label": "Вкладка «Бизнес»", + "rect": { + "x": 864, + "y": 2151, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_BUSINESS_CLICK", + "eventNameRu": "Вкладка «Бизнес»", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + } + ], + "carriedKeys": 0 + }, + { + "id": "music_screen", + "name": "Экран «Музыка»", + "category": "Развлечения", + "image": "/assets/screens/music_screen_long.png", + "isScrollable": false, + "viewportWidth": 1080, + "viewportHeight": 2340, + "totalHeight": 2340, + "surface": "native", + "capturedAt": 1787571497554, + "hotspots": [ + { + "id": "hs_native_userAvatarImageView", + "label": "userAvatarImageView", + "rect": { + "x": 45, + "y": 118, + "width": 113, + "height": 113 + }, + "eventKey": "CLICK_USERAVATARIMAGEVIEW", + "eventNameRu": "Нажатие «userAvatarImageView»", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "guessed", + "metricsSource": "none" + }, + { + "id": "hs_native_action_music_collection", + "label": "Моя медиатека", + "rect": { + "x": 672, + "y": 107, + "width": 136, + "height": 135 + }, + "eventKey": "CLICK_MOYA_MEDIATEKA", + "eventNameRu": "Нажатие «Моя медиатека»", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "guessed", + "metricsSource": "none" + }, + { + "id": "hs_native_action_notification", + "label": "Уведомления", + "rect": { + "x": 808, + "y": 107, + "width": 136, + "height": 135 + }, + "eventKey": "NOTIFICATIONS_OPEN", + "eventNameRu": "Открытие уведомлений", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_search", + "label": "Поиск", + "rect": { + "x": 944, + "y": 107, + "width": 136, + "height": 135 + }, + "eventKey": "CLICK_POISK", + "eventNameRu": "Нажатие «Поиск»", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "guessed", + "metricsSource": "none" + }, + { + "id": "hs_native_4", + "label": "Banner Cover Привет, лето! Горячие треки PlayPauseIcon", + "rect": { + "x": 45, + "y": 277, + "width": 641, + "height": 904 + }, + "eventKey": "CLICK_BANNER_COVER_PRIVET_LETO_GORYACHIE", + "eventNameRu": "Нажатие «Banner Cover Привет, лето! Горячие треки»", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "guessed", + "metricsSource": "none" + }, + { + "id": "hs_native_5", + "label": "PlayPauseIcon", + "rect": { + "x": 517, + "y": 1012, + "width": 135, + "height": 135 + }, + "eventKey": "CLICK_PLAYPAUSEICON", + "eventNameRu": "Нажатие «PlayPauseIcon»", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "guessed", + "metricsSource": "none" + }, + { + "id": "hs_native_6", + "label": "Banner Cover Мелодии степей Этнические мотивы", + "rect": { + "x": 709, + "y": 277, + "width": 371, + "height": 904 + }, + "eventKey": "CLICK_BANNER_COVER_MELODII_STEPEY_ETNICH", + "eventNameRu": "Нажатие «Banner Cover Мелодии степей Этнические м»", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "guessed", + "metricsSource": "none" + }, + { + "id": "hs_native_7", + "label": "Все", + "rect": { + "x": 921, + "y": 1193, + "width": 159, + "height": 124 + }, + "eventKey": "CLICK_VSE", + "eventNameRu": "Нажатие «Все»", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "guessed", + "metricsSource": "none" + }, + { + "id": "hs_native_8", + "label": "Лучшее: Май 2026 30 треков", + "rect": { + "x": 45, + "y": 1317, + "width": 405, + "height": 510 + }, + "eventKey": "CLICK_LUCHSHEE_MAY_2026_30_TREKOV", + "eventNameRu": "Нажатие «Лучшее: Май 2026 30 треков»", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "guessed", + "metricsSource": "none" + }, + { + "id": "hs_native_9", + "label": "808NINJA., LO-FI BEATS Love \"Unreturned\" 8 треков", + "rect": { + "x": 473, + "y": 1317, + "width": 405, + "height": 510 + }, + "eventKey": "CLICK_808NINJA_LO_FI_BEATS_LOVE_UNRETURN", + "eventNameRu": "Нажатие «808NINJA., LO-FI BEATS Love \"Unreturned\"»", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "guessed", + "metricsSource": "none" + }, + { + "id": "hs_native_10", + "label": "Мельница \"Химера\" 11 треков", + "rect": { + "x": 901, + "y": 1317, + "width": 179, + "height": 510 + }, + "eventKey": "CLICK_MELNITSA_HIMERA_11_TREKOV", + "eventNameRu": "Нажатие «Мельница \"Химера\" 11 треков»", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "guessed", + "metricsSource": "none" + }, + { + "id": "hs_native_11", + "label": "Все", + "rect": { + "x": 921, + "y": 1862, + "width": 159, + "height": 124 + }, + "eventKey": "CLICK_VSE_2", + "eventNameRu": "Нажатие «Все»", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "guessed", + "metricsSource": "none" + }, + { + "id": "hs_native_12", + "label": "Neo-Ethno-Folk group Arkaiym", + "rect": { + "x": 45, + "y": 1986, + "width": 405, + "height": 153 + }, + "eventKey": "CLICK_NEO_ETHNO_FOLK_GROUP_ARKAIYM", + "eventNameRu": "Нажатие «Neo-Ethno-Folk group Arkaiym»", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "guessed", + "metricsSource": "none" + }, + { + "id": "hs_native_13", + "label": "Вечные казахстанские хиты", + "rect": { + "x": 473, + "y": 1986, + "width": 405, + "height": 153 + }, + "eventKey": "CLICK_VECHNYE_KAZAHSTANSKIE_HITY", + "eventNameRu": "Нажатие «Вечные казахстанские хиты»", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "guessed", + "metricsSource": "none" + }, + { + "id": "hs_native_14", + "label": "Qanay", + "rect": { + "x": 901, + "y": 1986, + "width": 179, + "height": 153 + }, + "eventKey": "CLICK_QANAY", + "eventNameRu": "Нажатие «Qanay»", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "guessed", + "metricsSource": "none" + }, + { + "id": "hs_native_action_telecomkz_account", + "label": "Кабинет", + "rect": { + "x": 0, + "y": 2152, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_CABINET_CLICK", + "eventNameRu": "Вкладка «Кабинет»", + "category": "navigation", + "targetScreenId": "main_dashboard", + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_tv_plus", + "label": "TV+", + "rect": { + "x": 216, + "y": 2152, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_TV_PLUS_CLICK", + "eventNameRu": "Вкладка «TV+»", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_music", + "label": "Музыка", + "rect": { + "x": 432, + "y": 2152, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_MUSIC_CLICK", + "eventNameRu": "Вкладка «Музыка»", + "category": "navigation", + "targetScreenId": "music_screen", + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_chats", + "label": "Чаты 3", + "rect": { + "x": 648, + "y": 2152, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_CHATS_CLICK", + "eventNameRu": "Вкладка «Чаты»", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_b2b", + "label": "Бизнес", + "rect": { + "x": 864, + "y": 2152, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_BUSINESS_CLICK", + "eventNameRu": "Вкладка «Бизнес»", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + } + ], + "carriedKeys": 0 + }, + { + "id": "side_menu", + "name": "Боковое меню / Еще", + "category": "Навигация", + "image": "/assets/screens/side_menu_long.png", + "isScrollable": false, + "viewportWidth": 1080, + "viewportHeight": 2340, + "totalHeight": 2339, + "route": "/", + "capturedAt": 1787571148536, + "hotspots": [ + { + "id": "hs_dom_1", + "label": "Профиль телеком", + "rect": { + "x": 45, + "y": 282, + "width": 990, + "height": 146 + }, + "eventKey": "PROFILETELECOM", + "eventNameRu": "Нажатие «Профиль телеком»", + "category": "action", + "targetScreenId": "profile_settings", + "source": "webview-dom", + "keyConfidence": "observed", + "metricsSource": "none" + }, + { + "id": "hs_dom_2", + "label": "Платежи", + "rect": { + "x": 45, + "y": 428, + "width": 990, + "height": 146 + }, + "eventKey": "PAYMENTS", + "eventNameRu": "Переход в «Платежи»", + "category": "action", + "targetScreenId": "payments_screen", + "source": "webview-dom", + "keyConfidence": "observed", + "metricsSource": "none" + }, + { + "id": "hs_dom_3", + "label": "Мои услуги", + "rect": { + "x": 45, + "y": 575, + "width": 990, + "height": 146 + }, + "eventKey": "MYSERVICES", + "eventNameRu": "Переход в «Мои услуги»", + "category": "action", + "targetScreenId": "services_screen", + "source": "webview-dom", + "keyConfidence": "observed", + "metricsSource": "none" + }, + { + "id": "hs_dom_4", + "label": "Трафик", + "rect": { + "x": 45, + "y": 721, + "width": 990, + "height": 146 + }, + "eventKey": "DETALIZATION", + "eventNameRu": "Просмотр «Трафик»", + "category": "action", + "targetScreenId": "traffic_screen", + "source": "webview-dom", + "keyConfidence": "observed", + "metricsSource": "none" + }, + { + "id": "hs_dom_5", + "label": "Заявки", + "rect": { + "x": 45, + "y": 867, + "width": 990, + "height": 146 + }, + "eventKey": "ORDERS", + "eventNameRu": "Раздел «Заявки»", + "category": "action", + "targetScreenId": "orders_screen", + "source": "webview-dom", + "keyConfidence": "observed", + "metricsSource": "none" + }, + { + "id": "hs_dom_6", + "label": "Помощь", + "rect": { + "x": 45, + "y": 1013, + "width": 990, + "height": 146 + }, + "eventKey": "HELP", + "eventNameRu": "Нажатие «Помощь»", + "category": "action", + "targetScreenId": "help_screen", + "source": "webview-dom", + "keyConfidence": "observed", + "metricsSource": "none" + }, + { + "id": "hs_dom_7", + "label": "Оферта", + "rect": { + "x": 45, + "y": 1160, + "width": 990, + "height": 146 + }, + "eventKey": "IMPORTANTDOCUMENTS", + "eventNameRu": "Нажатие «Оферта»", + "category": "action", + "targetScreenId": "important_docs", + "source": "webview-dom", + "keyConfidence": "observed", + "metricsSource": "none" + }, + { + "id": "hs_native_0", + "label": "Нативный элемент", + "rect": { + "x": 0, + "y": 96, + "width": 158, + "height": 158 + }, + "eventKey": "CLICK_NATIVE", + "eventNameRu": "Нажатие «native»", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "guessed", + "metricsSource": "none" + }, + { + "id": "hs_native_1", + "label": "Уведомления", + "rect": { + "x": 808, + "y": 107, + "width": 136, + "height": 135 + }, + "eventKey": "NOTIFICATIONS_OPEN", + "eventNameRu": "Открытие уведомлений", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_2", + "label": "Меню (правый навбар)", + "rect": { + "x": 944, + "y": 107, + "width": 136, + "height": 135 + }, + "eventKey": "MENUCLICKED", + "eventNameRu": "Нажатие на Меню", + "category": "navigation", + "targetScreenId": "side_menu", + "source": "native-uiautomator", + "keyConfidence": "rule", + "metrics": { + "totalEvents": 10680, + "uniqueUsers": 3478, + "avgEventsPerUser": 3.07, + "shareOfClicks": 18.5, + "lastUpdated": null + }, + "metricsSource": "demo" + }, + { + "id": "hs_native_action_telecomkz_account", + "label": "Вкладка «Кабинет»", + "rect": { + "x": 0, + "y": 2151, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_CABINET_CLICK", + "eventNameRu": "Вкладка «Кабинет»", + "category": "navigation", + "targetScreenId": "main_dashboard", + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_tv_plus", + "label": "Вкладка «TV+»", + "rect": { + "x": 216, + "y": 2151, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_TV_PLUS_CLICK", + "eventNameRu": "Вкладка «TV+»", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_music", + "label": "Вкладка «Музыка»", + "rect": { + "x": 432, + "y": 2151, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_MUSIC_CLICK", + "eventNameRu": "Вкладка «Музыка»", + "category": "navigation", + "targetScreenId": "music_screen", + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_chats", + "label": "Вкладка «Чаты»", + "rect": { + "x": 648, + "y": 2151, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_CHATS_CLICK", + "eventNameRu": "Вкладка «Чаты»", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_b2b", + "label": "Вкладка «Бизнес»", + "rect": { + "x": 864, + "y": 2151, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_BUSINESS_CLICK", + "eventNameRu": "Вкладка «Бизнес»", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + } + ], + "carriedKeys": 0 + }, + { + "id": "payments_auto", + "name": "Мой автоплатеж", + "category": "Финансы", + "image": "/assets/screens/payments_auto_long.png", + "isScrollable": false, + "viewportWidth": 1080, + "viewportHeight": 2340, + "totalHeight": 2339, + "route": "/payments/auto", + "capturedAt": 1787573142456, + "hotspots": [ + { + "id": "hs_dom_1", + "label": "Изменить настройки", + "rect": { + "x": 45, + "y": 1959, + "width": 990, + "height": 135 + }, + "eventKey": "CLICK_IZMENIT_NASTROYKI", + "eventNameRu": "Нажатие «Изменить настройки»", + "category": "action", + "targetScreenId": null, + "source": "webview-dom", + "keyConfidence": "guessed", + "metricsSource": "none" + }, + { + "id": "hs_native_0", + "label": "Уведомления", + "rect": { + "x": 944, + "y": 107, + "width": 136, + "height": 135 + }, + "eventKey": "NOTIFICATIONS_OPEN", + "eventNameRu": "Открытие уведомлений", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_telecomkz_account", + "label": "Вкладка «Кабинет»", + "rect": { + "x": 0, + "y": 2151, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_CABINET_CLICK", + "eventNameRu": "Вкладка «Кабинет»", + "category": "navigation", + "targetScreenId": "main_dashboard", + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_tv_plus", + "label": "Вкладка «TV+»", + "rect": { + "x": 216, + "y": 2151, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_TV_PLUS_CLICK", + "eventNameRu": "Вкладка «TV+»", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_music", + "label": "Вкладка «Музыка»", + "rect": { + "x": 432, + "y": 2151, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_MUSIC_CLICK", + "eventNameRu": "Вкладка «Музыка»", + "category": "navigation", + "targetScreenId": "music_screen", + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_chats", + "label": "Вкладка «Чаты»", + "rect": { + "x": 648, + "y": 2151, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_CHATS_CLICK", + "eventNameRu": "Вкладка «Чаты»", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_b2b", + "label": "Вкладка «Бизнес»", + "rect": { + "x": 864, + "y": 2151, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_BUSINESS_CLICK", + "eventNameRu": "Вкладка «Бизнес»", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + } + ], + "carriedKeys": 0 + }, + { + "id": "payments_history", + "name": "История платежей", + "category": "Финансы", + "image": "/assets/screens/payments_history_long.png", + "isScrollable": false, + "viewportWidth": 1080, + "viewportHeight": 2340, + "totalHeight": 2339, + "route": "/payments/history", + "capturedAt": 1787573177021, + "hotspots": [ + { + "id": "hs_dom_1", + "label": "Период: за Август 2026", + "rect": { + "x": 45, + "y": 299, + "width": 821, + "height": 135 + }, + "eventKey": "CLICK_PERIOD_ZA_AVGUST_2026", + "eventNameRu": "Нажатие «Период: за Август 2026»", + "category": "action", + "targetScreenId": null, + "source": "webview-dom", + "keyConfidence": "guessed", + "metricsSource": "none" + }, + { + "id": "hs_native_0", + "label": "Уведомления", + "rect": { + "x": 944, + "y": 107, + "width": 136, + "height": 135 + }, + "eventKey": "NOTIFICATIONS_OPEN", + "eventNameRu": "Открытие уведомлений", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_telecomkz_account", + "label": "Вкладка «Кабинет»", + "rect": { + "x": 0, + "y": 2151, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_CABINET_CLICK", + "eventNameRu": "Вкладка «Кабинет»", + "category": "navigation", + "targetScreenId": "main_dashboard", + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_tv_plus", + "label": "Вкладка «TV+»", + "rect": { + "x": 216, + "y": 2151, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_TV_PLUS_CLICK", + "eventNameRu": "Вкладка «TV+»", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_music", + "label": "Вкладка «Музыка»", + "rect": { + "x": 432, + "y": 2151, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_MUSIC_CLICK", + "eventNameRu": "Вкладка «Музыка»", + "category": "navigation", + "targetScreenId": "music_screen", + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_chats", + "label": "Вкладка «Чаты»", + "rect": { + "x": 648, + "y": 2151, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_CHATS_CLICK", + "eventNameRu": "Вкладка «Чаты»", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_b2b", + "label": "Вкладка «Бизнес»", + "rect": { + "x": 864, + "y": 2151, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_BUSINESS_CLICK", + "eventNameRu": "Вкладка «Бизнес»", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + } + ] + }, + { + "id": "detalization_internet", + "name": "Детализация: Домашний интернет", + "category": "Услуги", + "image": "/assets/screens/detalization_internet_long.png", + "isScrollable": false, + "viewportWidth": 1080, + "viewportHeight": 2340, + "totalHeight": 2339, + "route": "/detalization/internet", + "capturedAt": 1787573331330, + "hotspots": [ + { + "id": "hs_dom_1", + "label": "Подробнее", + "rect": { + "x": 45, + "y": 1959, + "width": 990, + "height": 135 + }, + "eventKey": "CLICK_PODROBNEE", + "eventNameRu": "Нажатие «Подробнее»", + "category": "action", + "targetScreenId": null, + "source": "webview-dom", + "keyConfidence": "guessed", + "metricsSource": "none" + }, + { + "id": "hs_native_0", + "label": "Уведомления", + "rect": { + "x": 944, + "y": 107, + "width": 136, + "height": 135 + }, + "eventKey": "NOTIFICATIONS_OPEN", + "eventNameRu": "Открытие уведомлений", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_telecomkz_account", + "label": "Вкладка «Кабинет»", + "rect": { + "x": 0, + "y": 2151, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_CABINET_CLICK", + "eventNameRu": "Вкладка «Кабинет»", + "category": "navigation", + "targetScreenId": "main_dashboard", + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_tv_plus", + "label": "Вкладка «TV+»", + "rect": { + "x": 216, + "y": 2151, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_TV_PLUS_CLICK", + "eventNameRu": "Вкладка «TV+»", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_music", + "label": "Вкладка «Музыка»", + "rect": { + "x": 432, + "y": 2151, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_MUSIC_CLICK", + "eventNameRu": "Вкладка «Музыка»", + "category": "navigation", + "targetScreenId": "music_screen", + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_chats", + "label": "Вкладка «Чаты»", + "rect": { + "x": 648, + "y": 2151, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_CHATS_CLICK", + "eventNameRu": "Вкладка «Чаты»", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_b2b", + "label": "Вкладка «Бизнес»", + "rect": { + "x": 864, + "y": 2151, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_BUSINESS_CLICK", + "eventNameRu": "Вкладка «Бизнес»", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + } + ] + }, + { + "id": "detalization_mobile", + "name": "Детализация: Мобильная связь", + "category": "Услуги", + "image": "/assets/screens/detalization_mobile_long.png", + "isScrollable": true, + "viewportWidth": 1080, + "viewportHeight": 2340, + "totalHeight": 3377, + "route": "/detalization/mobile", + "capturedAt": 1787573367435, + "hotspots": [ + { + "id": "hs_dom_1", + "label": "7754072251", + "rect": { + "x": 47, + "y": 301, + "width": 986, + "height": 115 + }, + "eventKey": "CLICK_7754072251", + "eventNameRu": "Нажатие «7754072251»", + "category": "action", + "targetScreenId": null, + "source": "webview-dom", + "keyConfidence": "guessed", + "metricsSource": "none" + }, + { + "id": "hs_native_0", + "label": "Уведомления", + "rect": { + "x": 944, + "y": 107, + "width": 136, + "height": 135 + }, + "eventKey": "NOTIFICATIONS_OPEN", + "eventNameRu": "Открытие уведомлений", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_telecomkz_account", + "label": "Вкладка «Кабинет»", + "rect": { + "x": 0, + "y": 3189, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_CABINET_CLICK", + "eventNameRu": "Вкладка «Кабинет»", + "category": "navigation", + "targetScreenId": "main_dashboard", + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_tv_plus", + "label": "Вкладка «TV+»", + "rect": { + "x": 216, + "y": 3189, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_TV_PLUS_CLICK", + "eventNameRu": "Вкладка «TV+»", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_music", + "label": "Вкладка «Музыка»", + "rect": { + "x": 432, + "y": 3189, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_MUSIC_CLICK", + "eventNameRu": "Вкладка «Музыка»", + "category": "navigation", + "targetScreenId": "music_screen", + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_chats", + "label": "Вкладка «Чаты»", + "rect": { + "x": 648, + "y": 3189, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_CHATS_CLICK", + "eventNameRu": "Вкладка «Чаты»", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_b2b", + "label": "Вкладка «Бизнес»", + "rect": { + "x": 864, + "y": 3189, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_BUSINESS_CLICK", + "eventNameRu": "Вкладка «Бизнес»", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + } + ] + }, + { + "id": "appeals_create", + "name": "Создание заявки", + "category": "Услуги", + "image": "/assets/screens/appeals_create_long.png", + "isScrollable": false, + "viewportWidth": 1080, + "viewportHeight": 2340, + "totalHeight": 2339, + "route": "/appeals/create-appeal", + "capturedAt": 1787573411874, + "hotspots": [ + { + "id": "hs_dom_1", + "label": "Переоформление", + "rect": { + "x": 45, + "y": 696, + "width": 990, + "height": 135 + }, + "eventKey": "CLICK_PEREOFORMLENIE", + "eventNameRu": "Нажатие «Переоформление»", + "category": "action", + "targetScreenId": null, + "source": "webview-dom", + "keyConfidence": "guessed", + "metricsSource": "none" + }, + { + "id": "hs_dom_2", + "label": "Качество услуг", + "rect": { + "x": 45, + "y": 864, + "width": 990, + "height": 135 + }, + "eventKey": "SERVICES_CLICK", + "eventNameRu": "Переход в «Мои услуги»", + "category": "action", + "targetScreenId": "services_screen", + "source": "webview-dom", + "keyConfidence": "rule", + "metrics": { + "totalEvents": 14200, + "uniqueUsers": 5890, + "avgEventsPerUser": 2.41, + "shareOfClicks": 24.6, + "lastUpdated": null + }, + "metricsSource": "demo" + }, + { + "id": "hs_dom_3", + "label": "Вопросы по начислению", + "rect": { + "x": 45, + "y": 1033, + "width": 990, + "height": 135 + }, + "eventKey": "CLICK_VOPROSY_PO_NACHISLENIYU", + "eventNameRu": "Нажатие «Вопросы по начислению»", + "category": "action", + "targetScreenId": null, + "source": "webview-dom", + "keyConfidence": "guessed", + "metricsSource": "none" + }, + { + "id": "hs_dom_4", + "label": "Качество сервиса", + "rect": { + "x": 45, + "y": 1202, + "width": 990, + "height": 135 + }, + "eventKey": "SERVICES_CATALOG_CLICK", + "eventNameRu": "Каталог сервисов", + "category": "action", + "targetScreenId": null, + "source": "webview-dom", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_dom_5", + "label": "Новое подключение", + "rect": { + "x": 45, + "y": 1371, + "width": 990, + "height": 135 + }, + "eventKey": "CLICK_NOVOE_PODKLYUCHENIE", + "eventNameRu": "Нажатие «Новое подключение»", + "category": "action", + "targetScreenId": null, + "source": "webview-dom", + "keyConfidence": "guessed", + "metricsSource": "none" + }, + { + "id": "hs_dom_6", + "label": "Отключение услуг", + "rect": { + "x": 45, + "y": 1539, + "width": 990, + "height": 135 + }, + "eventKey": "SERVICES_CLICK_2", + "eventNameRu": "Переход в «Мои услуги»", + "category": "action", + "targetScreenId": "services_screen", + "source": "webview-dom", + "keyConfidence": "rule", + "metricsSource": "demo", + "metrics": { + "totalEvents": 14200, + "uniqueUsers": 5890, + "avgEventsPerUser": 2.41, + "shareOfClicks": 24.6, + "lastUpdated": null + } + }, + { + "id": "hs_dom_7", + "label": "Консультация", + "rect": { + "x": 45, + "y": 1708, + "width": 990, + "height": 135 + }, + "eventKey": "CLICK_KONSULTATSIYA", + "eventNameRu": "Нажатие «Консультация»", + "category": "action", + "targetScreenId": null, + "source": "webview-dom", + "keyConfidence": "guessed", + "metricsSource": "none" + }, + { + "id": "hs_native_0", + "label": "Уведомления", + "rect": { + "x": 944, + "y": 107, + "width": 136, + "height": 135 + }, + "eventKey": "NOTIFICATIONS_OPEN", + "eventNameRu": "Открытие уведомлений", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_telecomkz_account", + "label": "Вкладка «Кабинет»", + "rect": { + "x": 0, + "y": 2151, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_CABINET_CLICK", + "eventNameRu": "Вкладка «Кабинет»", + "category": "navigation", + "targetScreenId": "main_dashboard", + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_tv_plus", + "label": "Вкладка «TV+»", + "rect": { + "x": 216, + "y": 2151, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_TV_PLUS_CLICK", + "eventNameRu": "Вкладка «TV+»", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_music", + "label": "Вкладка «Музыка»", + "rect": { + "x": 432, + "y": 2151, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_MUSIC_CLICK", + "eventNameRu": "Вкладка «Музыка»", + "category": "navigation", + "targetScreenId": "music_screen", + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_chats", + "label": "Вкладка «Чаты»", + "rect": { + "x": 648, + "y": 2151, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_CHATS_CLICK", + "eventNameRu": "Вкладка «Чаты»", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_b2b", + "label": "Вкладка «Бизнес»", + "rect": { + "x": 864, + "y": 2151, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_BUSINESS_CLICK", + "eventNameRu": "Вкладка «Бизнес»", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + } + ] + }, + { + "id": "service_details", + "name": "Карточка услуги (ONT)", + "category": "Услуги", + "image": "/assets/screens/service_details_long.png", + "isScrollable": true, + "viewportWidth": 1080, + "viewportHeight": 2340, + "totalHeight": 5582, + "route": "/ont/3608039", + "capturedAt": 1787573453806, + "hotspots": [ + { + "id": "hs_dom_1", + "label": "Обновить статус", + "rect": { + "x": 45, + "y": 1790, + "width": 990, + "height": 135 + }, + "eventKey": "CLICK_OBNOVIT_STATUS", + "eventNameRu": "Нажатие «Обновить статус»", + "category": "action", + "targetScreenId": null, + "source": "webview-dom", + "keyConfidence": "guessed", + "metricsSource": "none" + }, + { + "id": "hs_dom_2", + "label": "Создать обращение", + "rect": { + "x": 45, + "y": 1959, + "width": 990, + "height": 135 + }, + "eventKey": "CLICK_SOZDAT_OBRASCHENIE", + "eventNameRu": "Нажатие «Создать обращение»", + "category": "action", + "targetScreenId": null, + "source": "webview-dom", + "keyConfidence": "guessed", + "metricsSource": "none" + }, + { + "id": "hs_native_0", + "label": "Уведомления", + "rect": { + "x": 944, + "y": 107, + "width": 136, + "height": 135 + }, + "eventKey": "NOTIFICATIONS_OPEN", + "eventNameRu": "Открытие уведомлений", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_telecomkz_account", + "label": "Вкладка «Кабинет»", + "rect": { + "x": 0, + "y": 5394, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_CABINET_CLICK", + "eventNameRu": "Вкладка «Кабинет»", + "category": "navigation", + "targetScreenId": "main_dashboard", + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_tv_plus", + "label": "Вкладка «TV+»", + "rect": { + "x": 216, + "y": 5394, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_TV_PLUS_CLICK", + "eventNameRu": "Вкладка «TV+»", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_music", + "label": "Вкладка «Музыка»", + "rect": { + "x": 432, + "y": 5394, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_MUSIC_CLICK", + "eventNameRu": "Вкладка «Музыка»", + "category": "navigation", + "targetScreenId": "music_screen", + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_chats", + "label": "Вкладка «Чаты»", + "rect": { + "x": 648, + "y": 5394, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_CHATS_CLICK", + "eventNameRu": "Вкладка «Чаты»", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_b2b", + "label": "Вкладка «Бизнес»", + "rect": { + "x": 864, + "y": 5394, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_BUSINESS_CLICK", + "eventNameRu": "Вкладка «Бизнес»", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + } + ] + }, + { + "id": "profile_settings", + "name": "Профиль телеком (настройки)", + "category": "Профиль", + "image": "/assets/screens/profile_settings_long.png", + "isScrollable": false, + "viewportWidth": 1080, + "viewportHeight": 2340, + "totalHeight": 2339, + "route": "/settings", + "capturedAt": 1787573539560, + "hotspots": [ + { + "id": "hs_dom_1", + "label": "Личные данные", + "rect": { + "x": 45, + "y": 299, + "width": 990, + "height": 208 + }, + "eventKey": "CLICK_LICHNYE_DANNYE", + "eventNameRu": "Нажатие «Личные данные»", + "category": "action", + "targetScreenId": null, + "source": "webview-dom", + "keyConfidence": "guessed", + "metricsSource": "none" + }, + { + "id": "hs_dom_2", + "label": "Мои документы", + "rect": { + "x": 45, + "y": 541, + "width": 990, + "height": 208 + }, + "eventKey": "CLICK_MOI_DOKUMENTY", + "eventNameRu": "Нажатие «Мои документы»", + "category": "action", + "targetScreenId": null, + "source": "webview-dom", + "keyConfidence": "guessed", + "metricsSource": "none" + }, + { + "id": "hs_dom_3", + "label": "Изменить пин-код", + "rect": { + "x": 45, + "y": 783, + "width": 990, + "height": 208 + }, + "eventKey": "CLICK_IZMENIT_PIN_KOD", + "eventNameRu": "Нажатие «Изменить пин-код»", + "category": "action", + "targetScreenId": null, + "source": "webview-dom", + "keyConfidence": "guessed", + "metricsSource": "none" + }, + { + "id": "hs_dom_4", + "label": "Вход по биометрии Если биометрия не работает, проверьте наст", + "rect": { + "x": 45, + "y": 1025, + "width": 990, + "height": 306 + }, + "eventKey": "CLICK_VHOD_PO_BIOMETRII_ESLI_BIOMETRIYA_", + "eventNameRu": "Нажатие «Вход по биометрии Если биометрия не рабо»", + "category": "action", + "targetScreenId": null, + "source": "webview-dom", + "keyConfidence": "guessed", + "metricsSource": "none" + }, + { + "id": "hs_native_0", + "label": "Уведомления", + "rect": { + "x": 944, + "y": 107, + "width": 136, + "height": 135 + }, + "eventKey": "NOTIFICATIONS_OPEN", + "eventNameRu": "Открытие уведомлений", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_telecomkz_account", + "label": "Вкладка «Кабинет»", + "rect": { + "x": 0, + "y": 2151, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_CABINET_CLICK", + "eventNameRu": "Вкладка «Кабинет»", + "category": "navigation", + "targetScreenId": "main_dashboard", + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_tv_plus", + "label": "Вкладка «TV+»", + "rect": { + "x": 216, + "y": 2151, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_TV_PLUS_CLICK", + "eventNameRu": "Вкладка «TV+»", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_music", + "label": "Вкладка «Музыка»", + "rect": { + "x": 432, + "y": 2151, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_MUSIC_CLICK", + "eventNameRu": "Вкладка «Музыка»", + "category": "navigation", + "targetScreenId": "music_screen", + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_chats", + "label": "Вкладка «Чаты»", + "rect": { + "x": 648, + "y": 2151, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_CHATS_CLICK", + "eventNameRu": "Вкладка «Чаты»", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_b2b", + "label": "Вкладка «Бизнес»", + "rect": { + "x": 864, + "y": 2151, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_BUSINESS_CLICK", + "eventNameRu": "Вкладка «Бизнес»", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + } + ] + }, + { + "id": "balance_detail", + "name": "Детализация баланса", + "category": "Финансы", + "image": "/assets/screens/balance_detail_long.png", + "isScrollable": false, + "viewportWidth": 1080, + "viewportHeight": 2340, + "totalHeight": 2339, + "route": "/detalization/balance/current", + "capturedAt": 1787574023277, + "hotspots": [ + { + "id": "hs_dom_1", + "label": "Подробнее", + "rect": { + "x": 851, + "y": 461, + "width": 184, + "height": 84 + }, + "eventKey": "CLICK_PODROBNEE", + "eventNameRu": "Нажатие «Подробнее»", + "category": "action", + "targetScreenId": null, + "source": "webview-dom", + "keyConfidence": "guessed", + "metricsSource": "none" + }, + { + "id": "hs_dom_2", + "label": "Подробнее", + "rect": { + "x": 238, + "y": 804, + "width": 184, + "height": 84 + }, + "eventKey": "CLICK_PODROBNEE_2", + "eventNameRu": "Нажатие «Подробнее»", + "category": "action", + "targetScreenId": null, + "source": "webview-dom", + "keyConfidence": "guessed", + "metricsSource": "none" + }, + { + "id": "hs_dom_3", + "label": "Квитанция", + "rect": { + "x": 832, + "y": 1141, + "width": 169, + "height": 51 + }, + "eventKey": "CLICK_KVITANTSIYA", + "eventNameRu": "Нажатие «Квитанция»", + "category": "action", + "targetScreenId": null, + "source": "webview-dom", + "keyConfidence": "guessed", + "metricsSource": "none" + }, + { + "id": "hs_dom_4", + "label": "Получить справку о задолженности", + "rect": { + "x": 45, + "y": 1259, + "width": 990, + "height": 135 + }, + "eventKey": "CLICK_POLUCHIT_SPRAVKU_O_ZADOLZHENNOSTI", + "eventNameRu": "Нажатие «Получить справку о задолженности»", + "category": "action", + "targetScreenId": null, + "source": "webview-dom", + "keyConfidence": "guessed", + "metricsSource": "none" + }, + { + "id": "hs_dom_5", + "label": "Настроить автоплатеж", + "rect": { + "x": 45, + "y": 1802, + "width": 990, + "height": 135 + }, + "eventKey": "PAYMENTS_CLICK", + "eventNameRu": "Переход в «Платежи»", + "category": "action", + "targetScreenId": "payments_screen", + "source": "webview-dom", + "keyConfidence": "rule", + "metrics": { + "totalEvents": 29800, + "uniqueUsers": 12400, + "avgEventsPerUser": 2.4, + "shareOfClicks": 51.6, + "lastUpdated": null + }, + "metricsSource": "demo" + }, + { + "id": "hs_dom_6", + "label": "Пополнить баланс", + "rect": { + "x": 45, + "y": 1959, + "width": 990, + "height": 135 + }, + "eventKey": "PAYMENTS_CLICK_2", + "eventNameRu": "Переход в «Платежи»", + "category": "action", + "targetScreenId": "payments_screen", + "source": "webview-dom", + "keyConfidence": "rule", + "metricsSource": "demo", + "metrics": { + "totalEvents": 29800, + "uniqueUsers": 12400, + "avgEventsPerUser": 2.4, + "shareOfClicks": 51.6, + "lastUpdated": null + } + }, + { + "id": "hs_native_0", + "label": "Уведомления", + "rect": { + "x": 944, + "y": 107, + "width": 136, + "height": 135 + }, + "eventKey": "NOTIFICATIONS_OPEN", + "eventNameRu": "Открытие уведомлений", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_telecomkz_account", + "label": "Вкладка «Кабинет»", + "rect": { + "x": 0, + "y": 2151, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_CABINET_CLICK", + "eventNameRu": "Вкладка «Кабинет»", + "category": "navigation", + "targetScreenId": "main_dashboard", + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_tv_plus", + "label": "Вкладка «TV+»", + "rect": { + "x": 216, + "y": 2151, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_TV_PLUS_CLICK", + "eventNameRu": "Вкладка «TV+»", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_music", + "label": "Вкладка «Музыка»", + "rect": { + "x": 432, + "y": 2151, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_MUSIC_CLICK", + "eventNameRu": "Вкладка «Музыка»", + "category": "navigation", + "targetScreenId": "music_screen", + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_chats", + "label": "Вкладка «Чаты»", + "rect": { + "x": 648, + "y": 2151, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_CHATS_CLICK", + "eventNameRu": "Вкладка «Чаты»", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_b2b", + "label": "Вкладка «Бизнес»", + "rect": { + "x": 864, + "y": 2151, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_BUSINESS_CLICK", + "eventNameRu": "Вкладка «Бизнес»", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + } + ], + "carriedKeys": 0 + }, + { + "id": "bonuses_screen", + "name": "Мои бонусы", + "category": "Финансы", + "image": "/assets/screens/bonuses_screen_long.png", + "isScrollable": true, + "viewportWidth": 1080, + "viewportHeight": 2340, + "totalHeight": 3562, + "route": "/bonuses", + "capturedAt": 1787574043836, + "hotspots": [ + { + "id": "hs_dom_1", + "label": "История", + "rect": { + "x": 45, + "y": 631, + "width": 990, + "height": 135 + }, + "eventKey": "CLICK_ISTORIYA", + "eventNameRu": "Нажатие «История»", + "category": "action", + "targetScreenId": null, + "source": "webview-dom", + "keyConfidence": "guessed", + "metricsSource": "none" + }, + { + "id": "hs_native_0", + "label": "Уведомления", + "rect": { + "x": 944, + "y": 107, + "width": 136, + "height": 135 + }, + "eventKey": "NOTIFICATIONS_OPEN", + "eventNameRu": "Открытие уведомлений", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_telecomkz_account", + "label": "Вкладка «Кабинет»", + "rect": { + "x": 0, + "y": 3374, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_CABINET_CLICK", + "eventNameRu": "Вкладка «Кабинет»", + "category": "navigation", + "targetScreenId": "main_dashboard", + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_tv_plus", + "label": "Вкладка «TV+»", + "rect": { + "x": 216, + "y": 3374, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_TV_PLUS_CLICK", + "eventNameRu": "Вкладка «TV+»", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_music", + "label": "Вкладка «Музыка»", + "rect": { + "x": 432, + "y": 3374, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_MUSIC_CLICK", + "eventNameRu": "Вкладка «Музыка»", + "category": "navigation", + "targetScreenId": "music_screen", + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_chats", + "label": "Вкладка «Чаты»", + "rect": { + "x": 648, + "y": 3374, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_CHATS_CLICK", + "eventNameRu": "Вкладка «Чаты»", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_b2b", + "label": "Вкладка «Бизнес»", + "rect": { + "x": 864, + "y": 3374, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_BUSINESS_CLICK", + "eventNameRu": "Вкладка «Бизнес»", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + } + ], + "carriedKeys": 0 + }, + { + "id": "qr_scan", + "name": "Оплата по QR", + "category": "Финансы", + "image": "/assets/screens/qr_scan_long.png", + "isScrollable": false, + "viewportWidth": 1080, + "viewportHeight": 2340, + "totalHeight": 2339, + "route": "/scan-qr", + "capturedAt": 1787574143021, + "hotspots": [ + { + "id": "hs_native_0", + "label": "Уведомления", + "rect": { + "x": 944, + "y": 107, + "width": 136, + "height": 135 + }, + "eventKey": "NOTIFICATIONS_OPEN", + "eventNameRu": "Открытие уведомлений", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_telecomkz_account", + "label": "Вкладка «Кабинет»", + "rect": { + "x": 0, + "y": 2151, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_CABINET_CLICK", + "eventNameRu": "Вкладка «Кабинет»", + "category": "navigation", + "targetScreenId": "main_dashboard", + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_tv_plus", + "label": "Вкладка «TV+»", + "rect": { + "x": 216, + "y": 2151, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_TV_PLUS_CLICK", + "eventNameRu": "Вкладка «TV+»", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_music", + "label": "Вкладка «Музыка»", + "rect": { + "x": 432, + "y": 2151, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_MUSIC_CLICK", + "eventNameRu": "Вкладка «Музыка»", + "category": "navigation", + "targetScreenId": "music_screen", + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_chats", + "label": "Вкладка «Чаты»", + "rect": { + "x": 648, + "y": 2151, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_CHATS_CLICK", + "eventNameRu": "Вкладка «Чаты»", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_b2b", + "label": "Вкладка «Бизнес»", + "rect": { + "x": 864, + "y": 2151, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_BUSINESS_CLICK", + "eventNameRu": "Вкладка «Бизнес»", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + } + ] + }, + { + "id": "id_card", + "name": "Удостоверение личности", + "category": "Профиль", + "image": "/assets/screens/id_card_long.png", + "isScrollable": true, + "viewportWidth": 1080, + "viewportHeight": 2340, + "totalHeight": 2376, + "route": "/digital-document/id-card", + "capturedAt": 1787575923257, + "hotspots": [ + { + "id": "hs_dom_1", + "label": "Предъявить документ", + "rect": { + "x": 45, + "y": 1790, + "width": 990, + "height": 135 + }, + "eventKey": "CLICK_PREDYAVIT_DOKUMENT", + "eventNameRu": "Нажатие «Предъявить документ»", + "category": "action", + "targetScreenId": null, + "source": "webview-dom", + "keyConfidence": "guessed", + "metricsSource": "none" + }, + { + "id": "hs_dom_2", + "label": "Отправить документ", + "rect": { + "x": 45, + "y": 1959, + "width": 990, + "height": 135 + }, + "eventKey": "CLICK_OTPRAVIT_DOKUMENT", + "eventNameRu": "Нажатие «Отправить документ»", + "category": "action", + "targetScreenId": null, + "source": "webview-dom", + "keyConfidence": "guessed", + "metricsSource": "none" + }, + { + "id": "hs_native_0", + "label": "Уведомления", + "rect": { + "x": 944, + "y": 107, + "width": 136, + "height": 135 + }, + "eventKey": "NOTIFICATIONS_OPEN", + "eventNameRu": "Открытие уведомлений", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_telecomkz_account", + "label": "Вкладка «Кабинет»", + "rect": { + "x": 0, + "y": 2188, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_CABINET_CLICK", + "eventNameRu": "Вкладка «Кабинет»", + "category": "navigation", + "targetScreenId": "main_dashboard", + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_tv_plus", + "label": "Вкладка «TV+»", + "rect": { + "x": 216, + "y": 2188, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_TV_PLUS_CLICK", + "eventNameRu": "Вкладка «TV+»", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_music", + "label": "Вкладка «Музыка»", + "rect": { + "x": 432, + "y": 2188, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_MUSIC_CLICK", + "eventNameRu": "Вкладка «Музыка»", + "category": "navigation", + "targetScreenId": "music_screen", + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_chats", + "label": "Вкладка «Чаты»", + "rect": { + "x": 648, + "y": 2188, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_CHATS_CLICK", + "eventNameRu": "Вкладка «Чаты»", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_b2b", + "label": "Вкладка «Бизнес»", + "rect": { + "x": 864, + "y": 2188, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_BUSINESS_CLICK", + "eventNameRu": "Вкладка «Бизнес»", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + } + ], + "carriedKeys": 0, + "redacted": true, + "redactionNote": "Изображение удостоверения личности закрашено вручную (персональные данные). Разметка не затронута." + }, + { + "id": "services_catalog", + "name": "Каталог сервисов", + "category": "Услуги", + "image": "/assets/screens/services_catalog_long.png", + "isScrollable": false, + "viewportWidth": 1080, + "viewportHeight": 2340, + "totalHeight": 2339, + "route": "/extra-services", + "capturedAt": 1787574182096, + "hotspots": [ + { + "id": "hs_dom_1", + "label": "Онлайн очередь Забронируйте посещение", + "rect": { + "x": 45, + "y": 299, + "width": 990, + "height": 208 + }, + "eventKey": "CLICK_ONLAYN_OCHERED_ZABRONIRUYTE_POSESC", + "eventNameRu": "Нажатие «Онлайн очередь Забронируйте посещение»", + "category": "action", + "targetScreenId": null, + "source": "webview-dom", + "keyConfidence": "guessed", + "metricsSource": "none" + }, + { + "id": "hs_dom_2", + "label": "Переоформление Переоформите лицевой счет на себя", + "rect": { + "x": 45, + "y": 541, + "width": 990, + "height": 255 + }, + "eventKey": "ACCOUNT_SELECTOR_CLICK", + "eventNameRu": "Выбор Лицевого Счета", + "category": "action", + "targetScreenId": null, + "source": "webview-dom", + "keyConfidence": "rule", + "metrics": { + "totalEvents": 24150, + "uniqueUsers": 8920, + "avgEventsPerUser": 2.7, + "shareOfClicks": 41.8, + "lastUpdated": null + }, + "metricsSource": "demo" + }, + { + "id": "hs_dom_3", + "label": "Перенос услуг Перенесите услуги на новый адрес", + "rect": { + "x": 45, + "y": 830, + "width": 990, + "height": 208 + }, + "eventKey": "SERVICES_CLICK", + "eventNameRu": "Переход в «Мои услуги»", + "category": "action", + "targetScreenId": "services_screen", + "source": "webview-dom", + "keyConfidence": "rule", + "metrics": { + "totalEvents": 14200, + "uniqueUsers": 5890, + "avgEventsPerUser": 2.41, + "shareOfClicks": 24.6, + "lastUpdated": null + }, + "metricsSource": "demo" + }, + { + "id": "hs_dom_4", + "label": "Проверка тех. возможности Узнайте, подключен ли ваш дом к оп", + "rect": { + "x": 45, + "y": 1072, + "width": 990, + "height": 255 + }, + "eventKey": "CLICK_PROVERKA_TEH_VOZMOZHNOSTI_UZNAYTE_", + "eventNameRu": "Нажатие «Проверка тех. возможности Узнайте, подкл»", + "category": "action", + "targetScreenId": null, + "source": "webview-dom", + "keyConfidence": "guessed", + "metricsSource": "none" + }, + { + "id": "hs_dom_5", + "label": "Госуслуги Новое", + "rect": { + "x": 45, + "y": 1360, + "width": 990, + "height": 208 + }, + "eventKey": "SERVICES_CLICK_2", + "eventNameRu": "Переход в «Мои услуги»", + "category": "action", + "targetScreenId": "services_screen", + "source": "webview-dom", + "keyConfidence": "rule", + "metricsSource": "demo", + "metrics": { + "totalEvents": 14200, + "uniqueUsers": 5890, + "avgEventsPerUser": 2.41, + "shareOfClicks": 24.6, + "lastUpdated": null + } + }, + { + "id": "hs_dom_6", + "label": "Перенос денежных средств Перенос денежных средств Скоро", + "rect": { + "x": 45, + "y": 1602, + "width": 990, + "height": 269 + }, + "eventKey": "CLICK_PERENOS_DENEZHNYH_SREDSTV_PERENOS_", + "eventNameRu": "Нажатие «Перенос денежных средств Перенос денежны»", + "category": "action", + "targetScreenId": null, + "source": "webview-dom", + "keyConfidence": "guessed", + "metricsSource": "none" + }, + { + "id": "hs_native_0", + "label": "Уведомления", + "rect": { + "x": 944, + "y": 107, + "width": 136, + "height": 135 + }, + "eventKey": "NOTIFICATIONS_OPEN", + "eventNameRu": "Открытие уведомлений", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_telecomkz_account", + "label": "Вкладка «Кабинет»", + "rect": { + "x": 0, + "y": 2151, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_CABINET_CLICK", + "eventNameRu": "Вкладка «Кабинет»", + "category": "navigation", + "targetScreenId": "main_dashboard", + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_tv_plus", + "label": "Вкладка «TV+»", + "rect": { + "x": 216, + "y": 2151, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_TV_PLUS_CLICK", + "eventNameRu": "Вкладка «TV+»", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_music", + "label": "Вкладка «Музыка»", + "rect": { + "x": 432, + "y": 2151, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_MUSIC_CLICK", + "eventNameRu": "Вкладка «Музыка»", + "category": "navigation", + "targetScreenId": "music_screen", + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_chats", + "label": "Вкладка «Чаты»", + "rect": { + "x": 648, + "y": 2151, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_CHATS_CLICK", + "eventNameRu": "Вкладка «Чаты»", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_b2b", + "label": "Вкладка «Бизнес»", + "rect": { + "x": 864, + "y": 2151, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_BUSINESS_CLICK", + "eventNameRu": "Вкладка «Бизнес»", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + } + ] + }, + { + "id": "tariffs_full_digital", + "name": "Тарифы (Подключить интернет)", + "category": "Услуги", + "image": "/assets/screens/tariffs_full_digital_long.png", + "isScrollable": true, + "viewportWidth": 1080, + "viewportHeight": 2340, + "totalHeight": 5720, + "route": "/full-digital", + "capturedAt": 1787574203573, + "hotspots": [ + { + "id": "hs_dom_1", + "label": "Bereket 2 SIM / 4 SIM от 12 999 6 500 ₸/месяц", + "rect": { + "x": 90, + "y": 894, + "width": 900, + "height": 300 + }, + "eventKey": "CLICK_BEREKET_2_SIM_4_SIM_OT_12_999_6_50", + "eventNameRu": "Нажатие «Bereket 2 SIM / 4 SIM от 12 999 6 500 ₸/»", + "category": "action", + "targetScreenId": null, + "source": "webview-dom", + "keyConfidence": "guessed", + "metricsSource": "none" + }, + { + "id": "hs_dom_2", + "label": "Подробнее", + "rect": { + "x": 90, + "y": 1801, + "width": 900, + "height": 135 + }, + "eventKey": "constructor_opened", + "eventNameRu": "Нажатие «Подробнее»", + "category": "action", + "source": "webview-dom", + "keyConfidence": "observed", + "metricsSource": "none", + "targetScreenId": "tariff_bereket" + }, + { + "id": "hs_dom_3", + "label": "Keremet Mobile 1 SIM от 9 999 5 000 ₸/месяц", + "rect": { + "x": 90, + "y": 2054, + "width": 900, + "height": 300 + }, + "eventKey": "CLICK_KEREMET_MOBILE_1_SIM_OT_9_999_5_00", + "eventNameRu": "Нажатие «Keremet Mobile 1 SIM от 9 999 5 000 ₸/ме»", + "category": "action", + "targetScreenId": null, + "source": "webview-dom", + "keyConfidence": "guessed", + "metricsSource": "none" + }, + { + "id": "hs_dom_4", + "label": "Подробнее", + "rect": { + "x": 90, + "y": 2781, + "width": 900, + "height": 135 + }, + "eventKey": "constructor_opened", + "eventNameRu": "Нажатие «Подробнее»", + "category": "action", + "source": "webview-dom", + "keyConfidence": "observed", + "metricsSource": "none", + "targetScreenId": "tariff_keremet_mobile" + }, + { + "id": "hs_dom_5", + "label": "Keremet TV от 8 999 4 500 ₸/месяц", + "rect": { + "x": 90, + "y": 3035, + "width": 900, + "height": 232 + }, + "eventKey": "CLICK_KEREMET_TV_OT_8_999_4_500_MESYATS", + "eventNameRu": "Нажатие «Keremet TV от 8 999 4 500 ₸/месяц»", + "category": "action", + "targetScreenId": null, + "source": "webview-dom", + "keyConfidence": "guessed", + "metricsSource": "none" + }, + { + "id": "hs_dom_6", + "label": "Подробнее", + "rect": { + "x": 90, + "y": 3604, + "width": 900, + "height": 135 + }, + "eventKey": "constructor_opened", + "eventNameRu": "Нажатие «Подробнее»", + "category": "action", + "source": "webview-dom", + "keyConfidence": "observed", + "metricsSource": "none", + "targetScreenId": "tariff_keremet_tv" + }, + { + "id": "hs_dom_7", + "label": "Интернет 500 от 9 321 4 661 ₸/месяц", + "rect": { + "x": 90, + "y": 3858, + "width": 900, + "height": 232 + }, + "eventKey": "CLICK_INTERNET_500_OT_9_321_4_661_MESYAT", + "eventNameRu": "Нажатие «Интернет 500 от 9 321 4 661 ₸/месяц»", + "category": "action", + "targetScreenId": null, + "source": "webview-dom", + "keyConfidence": "guessed", + "metricsSource": "none" + }, + { + "id": "hs_dom_8", + "label": "Подробнее", + "rect": { + "x": 90, + "y": 4247, + "width": 900, + "height": 135 + }, + "eventKey": "constructor_opened", + "eventNameRu": "Нажатие «Подробнее»", + "category": "action", + "source": "webview-dom", + "keyConfidence": "observed", + "metricsSource": "none", + "targetScreenId": "tariff_internet_500" + }, + { + "id": "hs_dom_9", + "label": "Интернет 200 от 7 249 3 625 ₸/месяц", + "rect": { + "x": 90, + "y": 4501, + "width": 900, + "height": 232 + }, + "eventKey": "CLICK_INTERNET_200_OT_7_249_3_625_MESYAT", + "eventNameRu": "Нажатие «Интернет 200 от 7 249 3 625 ₸/месяц»", + "category": "action", + "targetScreenId": null, + "source": "webview-dom", + "keyConfidence": "guessed", + "metricsSource": "none" + }, + { + "id": "hs_dom_10", + "label": "Подробнее", + "rect": { + "x": 90, + "y": 4890, + "width": 900, + "height": 135 + }, + "eventKey": "constructor_opened", + "eventNameRu": "Нажатие «Подробнее»", + "category": "action", + "source": "webview-dom", + "keyConfidence": "observed", + "metricsSource": "none", + "targetScreenId": "tariff_internet_200" + }, + { + "id": "hs_native_0", + "label": "Уведомления", + "rect": { + "x": 944, + "y": 107, + "width": 136, + "height": 135 + }, + "eventKey": "NOTIFICATIONS_OPEN", + "eventNameRu": "Открытие уведомлений", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_telecomkz_account", + "label": "Вкладка «Кабинет»", + "rect": { + "x": 0, + "y": 5532, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_CABINET_CLICK", + "eventNameRu": "Вкладка «Кабинет»", + "category": "navigation", + "targetScreenId": "main_dashboard", + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_tv_plus", + "label": "Вкладка «TV+»", + "rect": { + "x": 216, + "y": 5532, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_TV_PLUS_CLICK", + "eventNameRu": "Вкладка «TV+»", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_music", + "label": "Вкладка «Музыка»", + "rect": { + "x": 432, + "y": 5532, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_MUSIC_CLICK", + "eventNameRu": "Вкладка «Музыка»", + "category": "navigation", + "targetScreenId": "music_screen", + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_chats", + "label": "Вкладка «Чаты»", + "rect": { + "x": 648, + "y": 5532, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_CHATS_CLICK", + "eventNameRu": "Вкладка «Чаты»", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_b2b", + "label": "Вкладка «Бизнес»", + "rect": { + "x": 864, + "y": 5532, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_BUSINESS_CLICK", + "eventNameRu": "Вкладка «Бизнес»", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + } + ] + }, + { + "id": "tv_plus", + "name": "Экран TV+", + "category": "Развлечения", + "image": "/assets/screens/tv_plus_long.png", + "isScrollable": true, + "viewportWidth": 1080, + "viewportHeight": 2340, + "totalHeight": 6532, + "route": "/tv-plus", + "capturedAt": 1787574226385, + "hotspots": [ + { + "id": "hs_dom_1", + "label": "Ваш контент всегда под рукой", + "rect": { + "x": 45, + "y": 299, + "width": 990, + "height": 1052 + }, + "eventKey": "CLICK_VASH_KONTENT_VSEGDA_POD_RUKOY", + "eventNameRu": "Нажатие «Ваш контент всегда под рукой»", + "category": "action", + "targetScreenId": null, + "source": "webview-dom", + "keyConfidence": "guessed", + "metricsSource": "none" + }, + { + "id": "hs_dom_2", + "label": "Ваш контент всегда под рукой", + "rect": { + "x": 0, + "y": 254, + "width": 1080, + "height": 1052 + }, + "eventKey": "CLICK_VASH_KONTENT_VSEGDA_POD_RUKOY_2", + "eventNameRu": "Нажатие «Ваш контент всегда под рукой»", + "category": "action", + "targetScreenId": null, + "source": "webview-dom", + "keyConfidence": "guessed", + "metricsSource": "none" + }, + { + "id": "hs_dom_3", + "label": "Ваш контент всегда под рукой", + "rect": { + "x": 202, + "y": 606, + "width": 675, + "height": 349 + }, + "eventKey": "CLICK_VASH_KONTENT_VSEGDA_POD_RUKOY_3", + "eventNameRu": "Нажатие «Ваш контент всегда под рукой»", + "category": "action", + "targetScreenId": null, + "source": "webview-dom", + "keyConfidence": "guessed", + "metricsSource": "none" + }, + { + "id": "hs_dom_4", + "label": "Подключить", + "rect": { + "x": 101, + "y": 2491, + "width": 731, + "height": 135 + }, + "eventKey": "CLICK_PODKLYUCHIT", + "eventNameRu": "Нажатие «Подключить»", + "category": "action", + "targetScreenId": null, + "source": "webview-dom", + "keyConfidence": "guessed", + "metricsSource": "none" + }, + { + "id": "hs_dom_5", + "label": "Подключить", + "rect": { + "x": 992, + "y": 2489, + "width": 88, + "height": 135 + }, + "eventKey": "CLICK_PODKLYUCHIT_2", + "eventNameRu": "Нажатие «Подключить»", + "category": "action", + "targetScreenId": null, + "source": "webview-dom", + "keyConfidence": "guessed", + "metricsSource": "none" + }, + { + "id": "hs_dom_6", + "label": "Выбрать тариф", + "rect": { + "x": 45, + "y": 5761, + "width": 990, + "height": 135 + }, + "eventKey": "CLICK_VYBRAT_TARIF", + "eventNameRu": "Нажатие «Выбрать тариф»", + "category": "action", + "targetScreenId": null, + "source": "webview-dom", + "keyConfidence": "guessed", + "metricsSource": "none" + }, + { + "id": "hs_dom_7", + "label": "Как активировать подписку", + "rect": { + "x": 45, + "y": 5986, + "width": 990, + "height": 137 + }, + "eventKey": "CLICK_KAK_AKTIVIROVAT_PODPISKU", + "eventNameRu": "Нажатие «Как активировать подписку»", + "category": "action", + "targetScreenId": null, + "source": "webview-dom", + "keyConfidence": "guessed", + "metricsSource": "none" + }, + { + "id": "hs_dom_8", + "label": "Инструкция по пользованию TV+", + "rect": { + "x": 45, + "y": 6151, + "width": 990, + "height": 137 + }, + "eventKey": "TV_PLUS_CLICK", + "eventNameRu": "Переход в «TV+»", + "category": "action", + "targetScreenId": null, + "source": "webview-dom", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_0", + "label": "Уведомления", + "rect": { + "x": 944, + "y": 107, + "width": 136, + "height": 135 + }, + "eventKey": "NOTIFICATIONS_OPEN", + "eventNameRu": "Открытие уведомлений", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_telecomkz_account", + "label": "Вкладка «Кабинет»", + "rect": { + "x": 0, + "y": 6344, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_CABINET_CLICK", + "eventNameRu": "Вкладка «Кабинет»", + "category": "navigation", + "targetScreenId": "main_dashboard", + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_tv_plus", + "label": "Вкладка «TV+»", + "rect": { + "x": 216, + "y": 6344, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_TV_PLUS_CLICK", + "eventNameRu": "Вкладка «TV+»", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_music", + "label": "Вкладка «Музыка»", + "rect": { + "x": 432, + "y": 6344, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_MUSIC_CLICK", + "eventNameRu": "Вкладка «Музыка»", + "category": "navigation", + "targetScreenId": "music_screen", + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_chats", + "label": "Вкладка «Чаты»", + "rect": { + "x": 648, + "y": 6344, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_CHATS_CLICK", + "eventNameRu": "Вкладка «Чаты»", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_b2b", + "label": "Вкладка «Бизнес»", + "rect": { + "x": 864, + "y": 6344, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_BUSINESS_CLICK", + "eventNameRu": "Вкладка «Бизнес»", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + } + ] + }, + { + "id": "turbo_landing", + "name": "TURBO — увеличение скорости", + "category": "Услуги", + "image": "/assets/screens/turbo_landing_long.png", + "isScrollable": true, + "viewportWidth": 1080, + "viewportHeight": 2340, + "totalHeight": 2682, + "route": "/turbo", + "capturedAt": 1787574247600, + "hotspots": [ + { + "id": "hs_dom_1", + "label": "Увеличить скорость", + "rect": { + "x": 45, + "y": 1982, + "width": 990, + "height": 135 + }, + "eventKey": "CLICK_UVELICHIT_SKOROST", + "eventNameRu": "Нажатие «Увеличить скорость»", + "category": "action", + "targetScreenId": null, + "source": "webview-dom", + "keyConfidence": "guessed", + "metricsSource": "none" + }, + { + "id": "hs_native_0", + "label": "full-digital-close", + "rect": { + "x": 944, + "y": 107, + "width": 136, + "height": 135 + }, + "eventKey": "CLICK_FULL_DIGITAL_CLOSE", + "eventNameRu": "Нажатие «full-digital-close»", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "guessed", + "metricsSource": "none" + }, + { + "id": "hs_native_action_telecomkz_account", + "label": "Вкладка «Кабинет»", + "rect": { + "x": 0, + "y": 2494, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_CABINET_CLICK", + "eventNameRu": "Вкладка «Кабинет»", + "category": "navigation", + "targetScreenId": "main_dashboard", + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_tv_plus", + "label": "Вкладка «TV+»", + "rect": { + "x": 216, + "y": 2494, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_TV_PLUS_CLICK", + "eventNameRu": "Вкладка «TV+»", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_music", + "label": "Вкладка «Музыка»", + "rect": { + "x": 432, + "y": 2494, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_MUSIC_CLICK", + "eventNameRu": "Вкладка «Музыка»", + "category": "navigation", + "targetScreenId": "music_screen", + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_chats", + "label": "Вкладка «Чаты»", + "rect": { + "x": 648, + "y": 2494, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_CHATS_CLICK", + "eventNameRu": "Вкладка «Чаты»", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_b2b", + "label": "Вкладка «Бизнес»", + "rect": { + "x": 864, + "y": 2494, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_BUSINESS_CLICK", + "eventNameRu": "Вкладка «Бизнес»", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + } + ] + }, + { + "id": "help_screen", + "name": "Помощь", + "category": "Поддержка", + "image": "/assets/screens/help_screen_long.png", + "isScrollable": false, + "viewportWidth": 1080, + "viewportHeight": 2340, + "totalHeight": 2339, + "route": "/help", + "capturedAt": 1787574669857, + "hotspots": [ + { + "id": "hs_dom_1", + "label": "Часто задаваемые вопросы", + "rect": { + "x": 45, + "y": 299, + "width": 990, + "height": 208 + }, + "eventKey": "CLICK_CHASTO_ZADAVAEMYE_VOPROSY", + "eventNameRu": "Нажатие «Часто задаваемые вопросы»", + "category": "action", + "targetScreenId": null, + "source": "webview-dom", + "keyConfidence": "guessed", + "metricsSource": "none" + }, + { + "id": "hs_dom_2", + "label": "Адреса отделений", + "rect": { + "x": 45, + "y": 541, + "width": 990, + "height": 208 + }, + "eventKey": "CLICK_ADRESA_OTDELENIY", + "eventNameRu": "Нажатие «Адреса отделений»", + "category": "action", + "targetScreenId": null, + "source": "webview-dom", + "keyConfidence": "guessed", + "metricsSource": "none" + }, + { + "id": "hs_dom_3", + "label": "Мне помог сотрудник", + "rect": { + "x": 45, + "y": 783, + "width": 990, + "height": 208 + }, + "eventKey": "CLICK_MNE_POMOG_SOTRUDNIK", + "eventNameRu": "Нажатие «Мне помог сотрудник»", + "category": "action", + "targetScreenId": null, + "source": "webview-dom", + "keyConfidence": "guessed", + "metricsSource": "none" + }, + { + "id": "hs_native_0", + "label": "Нативный элемент", + "rect": { + "x": 0, + "y": 96, + "width": 158, + "height": 158 + }, + "eventKey": "CLICK_NATIVE", + "eventNameRu": "Нажатие «native»", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "guessed", + "metricsSource": "none" + }, + { + "id": "hs_native_1", + "label": "Уведомления", + "rect": { + "x": 944, + "y": 107, + "width": 136, + "height": 135 + }, + "eventKey": "NOTIFICATIONS_OPEN", + "eventNameRu": "Открытие уведомлений", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_telecomkz_account", + "label": "Вкладка «Кабинет»", + "rect": { + "x": 0, + "y": 2151, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_CABINET_CLICK", + "eventNameRu": "Вкладка «Кабинет»", + "category": "navigation", + "targetScreenId": "main_dashboard", + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_tv_plus", + "label": "Вкладка «TV+»", + "rect": { + "x": 216, + "y": 2151, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_TV_PLUS_CLICK", + "eventNameRu": "Вкладка «TV+»", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_music", + "label": "Вкладка «Музыка»", + "rect": { + "x": 432, + "y": 2151, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_MUSIC_CLICK", + "eventNameRu": "Вкладка «Музыка»", + "category": "navigation", + "targetScreenId": "music_screen", + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_chats", + "label": "Вкладка «Чаты»", + "rect": { + "x": 648, + "y": 2151, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_CHATS_CLICK", + "eventNameRu": "Вкладка «Чаты»", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_b2b", + "label": "Вкладка «Бизнес»", + "rect": { + "x": 864, + "y": 2151, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_BUSINESS_CLICK", + "eventNameRu": "Вкладка «Бизнес»", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + } + ] + }, + { + "id": "important_docs", + "name": "Оферта и документы", + "category": "Поддержка", + "image": "/assets/screens/important_docs_long.png", + "isScrollable": false, + "viewportWidth": 1080, + "viewportHeight": 2340, + "totalHeight": 2339, + "route": "/important-docs", + "capturedAt": 1787574692511, + "hotspots": [ + { + "id": "hs_dom_1", + "label": "Публичный договор", + "rect": { + "x": 45, + "y": 299, + "width": 990, + "height": 172 + }, + "eventKey": "CLICK_PUBLICHNYY_DOGOVOR", + "eventNameRu": "Нажатие «Публичный договор»", + "category": "action", + "targetScreenId": null, + "source": "webview-dom", + "keyConfidence": "guessed", + "metricsSource": "none" + }, + { + "id": "hs_dom_2", + "label": "Правила оказания услуг связи", + "rect": { + "x": 45, + "y": 504, + "width": 990, + "height": 219 + }, + "eventKey": "SERVICES_CLICK", + "eventNameRu": "Переход в «Мои услуги»", + "category": "action", + "targetScreenId": "services_screen", + "source": "webview-dom", + "keyConfidence": "rule", + "metrics": { + "totalEvents": 14200, + "uniqueUsers": 5890, + "avgEventsPerUser": 2.41, + "shareOfClicks": 24.6, + "lastUpdated": null + }, + "metricsSource": "demo" + }, + { + "id": "hs_native_0", + "label": "Нативный элемент", + "rect": { + "x": 0, + "y": 96, + "width": 158, + "height": 158 + }, + "eventKey": "CLICK_NATIVE", + "eventNameRu": "Нажатие «native»", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "guessed", + "metricsSource": "none" + }, + { + "id": "hs_native_1", + "label": "Уведомления", + "rect": { + "x": 944, + "y": 107, + "width": 136, + "height": 135 + }, + "eventKey": "NOTIFICATIONS_OPEN", + "eventNameRu": "Открытие уведомлений", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_telecomkz_account", + "label": "Вкладка «Кабинет»", + "rect": { + "x": 0, + "y": 2151, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_CABINET_CLICK", + "eventNameRu": "Вкладка «Кабинет»", + "category": "navigation", + "targetScreenId": "main_dashboard", + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_tv_plus", + "label": "Вкладка «TV+»", + "rect": { + "x": 216, + "y": 2151, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_TV_PLUS_CLICK", + "eventNameRu": "Вкладка «TV+»", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_music", + "label": "Вкладка «Музыка»", + "rect": { + "x": 432, + "y": 2151, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_MUSIC_CLICK", + "eventNameRu": "Вкладка «Музыка»", + "category": "navigation", + "targetScreenId": "music_screen", + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_chats", + "label": "Вкладка «Чаты»", + "rect": { + "x": 648, + "y": 2151, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_CHATS_CLICK", + "eventNameRu": "Вкладка «Чаты»", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_b2b", + "label": "Вкладка «Бизнес»", + "rect": { + "x": 864, + "y": 2151, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_BUSINESS_CLICK", + "eventNameRu": "Вкладка «Бизнес»", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + } + ] + }, + { + "id": "aitu_music", + "name": "AITU Music", + "category": "Развлечения", + "image": "/assets/screens/aitu_music_long.png", + "isScrollable": true, + "viewportWidth": 1080, + "viewportHeight": 2340, + "totalHeight": 5225, + "route": "/aitu/music", + "capturedAt": 1787574727620, + "hotspots": [ + { + "id": "hs_dom_1", + "label": "Слушайте без рекламы и без интернета Подключить", + "rect": { + "x": 45, + "y": 299, + "width": 990, + "height": 1014 + }, + "eventKey": "CLICK_SLUSHAYTE_BEZ_REKLAMY_I_BEZ_INTERN", + "eventNameRu": "Нажатие «Слушайте без рекламы и без интернета Под»", + "category": "action", + "targetScreenId": null, + "source": "webview-dom", + "keyConfidence": "guessed", + "metricsSource": "none" + }, + { + "id": "hs_dom_2", + "label": "Слушайте без рекламы и без интернета", + "rect": { + "x": 0, + "y": 254, + "width": 1080, + "height": 879 + }, + "eventKey": "CLICK_SLUSHAYTE_BEZ_REKLAMY_I_BEZ_INTE_2", + "eventNameRu": "Нажатие «Слушайте без рекламы и без интернета»", + "category": "action", + "targetScreenId": null, + "source": "webview-dom", + "keyConfidence": "guessed", + "metricsSource": "none" + }, + { + "id": "hs_dom_3", + "label": "Слушайте без рекламы и без интернета", + "rect": { + "x": 202, + "y": 764, + "width": 675, + "height": 124 + }, + "eventKey": "CLICK_SLUSHAYTE_BEZ_REKLAMY_I_BEZ_INTE_3", + "eventNameRu": "Нажатие «Слушайте без рекламы и без интернета»", + "category": "action", + "targetScreenId": null, + "source": "webview-dom", + "keyConfidence": "guessed", + "metricsSource": "none" + }, + { + "id": "hs_dom_4", + "label": "Подключить", + "rect": { + "x": 45, + "y": 1178, + "width": 990, + "height": 135 + }, + "eventKey": "CLICK_PODKLYUCHIT", + "eventNameRu": "Нажатие «Подключить»", + "category": "action", + "targetScreenId": null, + "source": "webview-dom", + "keyConfidence": "guessed", + "metricsSource": "none" + }, + { + "id": "hs_dom_5", + "label": "Подключить", + "rect": { + "x": 45, + "y": 4843, + "width": 990, + "height": 135 + }, + "eventKey": "CLICK_PODKLYUCHIT_2", + "eventNameRu": "Нажатие «Подключить»", + "category": "action", + "targetScreenId": null, + "source": "webview-dom", + "keyConfidence": "guessed", + "metricsSource": "none" + }, + { + "id": "hs_native_0", + "label": "Нативный элемент", + "rect": { + "x": 0, + "y": 96, + "width": 158, + "height": 158 + }, + "eventKey": "CLICK_NATIVE", + "eventNameRu": "Нажатие «native»", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "guessed", + "metricsSource": "none" + }, + { + "id": "hs_native_1", + "label": "Уведомления", + "rect": { + "x": 944, + "y": 107, + "width": 136, + "height": 135 + }, + "eventKey": "NOTIFICATIONS_OPEN", + "eventNameRu": "Открытие уведомлений", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_telecomkz_account", + "label": "Вкладка «Кабинет»", + "rect": { + "x": 0, + "y": 5037, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_CABINET_CLICK", + "eventNameRu": "Вкладка «Кабинет»", + "category": "navigation", + "targetScreenId": "main_dashboard", + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_tv_plus", + "label": "Вкладка «TV+»", + "rect": { + "x": 216, + "y": 5037, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_TV_PLUS_CLICK", + "eventNameRu": "Вкладка «TV+»", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_music", + "label": "Вкладка «Музыка»", + "rect": { + "x": 432, + "y": 5037, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_MUSIC_CLICK", + "eventNameRu": "Вкладка «Музыка»", + "category": "navigation", + "targetScreenId": "music_screen", + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_chats", + "label": "Вкладка «Чаты»", + "rect": { + "x": 648, + "y": 5037, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_CHATS_CLICK", + "eventNameRu": "Вкладка «Чаты»", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_b2b", + "label": "Вкладка «Бизнес»", + "rect": { + "x": 864, + "y": 5037, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_BUSINESS_CLICK", + "eventNameRu": "Вкладка «Бизнес»", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + } + ] + }, + { + "id": "tariff_bereket", + "name": "Тариф «Bereket»", + "category": "Услуги", + "image": "/assets/screens/tariff_bereket_long.png", + "isScrollable": true, + "viewportWidth": 1080, + "viewportHeight": 2340, + "totalHeight": 6777, + "route": "/full-digital/auth/tariff", + "capturedAt": 1787576299290, + "renderSettled": true, + "hotspots": [ + { + "id": "hs_dom_1", + "label": "Bereket", + "rect": { + "x": 45, + "y": 299, + "width": 990, + "height": 197 + }, + "eventKey": "CLICK_BEREKET", + "eventNameRu": "Нажатие «Bereket»", + "category": "action", + "targetScreenId": null, + "source": "webview-dom", + "keyConfidence": "guessed", + "metricsSource": "none" + }, + { + "id": "hs_dom_2", + "label": "Продолжить", + "rect": { + "x": 45, + "y": 1839, + "width": 990, + "height": 135 + }, + "eventKey": "CLICK_PRODOLZHIT", + "eventNameRu": "Нажатие «Продолжить»", + "category": "action", + "targetScreenId": "connect_service", + "source": "webview-dom", + "keyConfidence": "guessed", + "metricsSource": "none" + }, + { + "id": "hs_dom_3", + "label": "Подробнее о скидке", + "rect": { + "x": 45, + "y": 1997, + "width": 990, + "height": 97 + }, + "eventKey": "fd_auth_bereket_constructor_opened", + "eventNameRu": "Нажатие «Подробнее о скидке»", + "category": "action", + "targetScreenId": null, + "source": "webview-dom", + "keyConfidence": "observed", + "metricsSource": "none" + }, + { + "id": "hs_native_0", + "label": "Нативный элемент", + "rect": { + "x": 0, + "y": 96, + "width": 158, + "height": 158 + }, + "eventKey": "CLICK_NATIVE", + "eventNameRu": "Нажатие «native»", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "guessed", + "metricsSource": "none" + }, + { + "id": "hs_native_1", + "label": "Уведомления", + "rect": { + "x": 944, + "y": 107, + "width": 136, + "height": 135 + }, + "eventKey": "NOTIFICATIONS_OPEN", + "eventNameRu": "Открытие уведомлений", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_telecomkz_account", + "label": "Вкладка «Кабинет»", + "rect": { + "x": 0, + "y": 6589, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_CABINET_CLICK", + "eventNameRu": "Вкладка «Кабинет»", + "category": "navigation", + "targetScreenId": "main_dashboard", + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_tv_plus", + "label": "Вкладка «TV+»", + "rect": { + "x": 216, + "y": 6589, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_TV_PLUS_CLICK", + "eventNameRu": "Вкладка «TV+»", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_music", + "label": "Вкладка «Музыка»", + "rect": { + "x": 432, + "y": 6589, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_MUSIC_CLICK", + "eventNameRu": "Вкладка «Музыка»", + "category": "navigation", + "targetScreenId": "music_screen", + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_chats", + "label": "Вкладка «Чаты»", + "rect": { + "x": 648, + "y": 6589, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_CHATS_CLICK", + "eventNameRu": "Вкладка «Чаты»", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_b2b", + "label": "Вкладка «Бизнес»", + "rect": { + "x": 864, + "y": 6589, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_BUSINESS_CLICK", + "eventNameRu": "Вкладка «Бизнес»", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + } + ] + }, + { + "id": "connect_service", + "name": "Подключение услуги", + "category": "Услуги", + "image": "/assets/screens/connect_service_long.png", + "isScrollable": true, + "viewportWidth": 1080, + "viewportHeight": 2340, + "totalHeight": 3110, + "route": "/full-digital/auth/connect-service", + "capturedAt": 1787576324720, + "renderSettled": true, + "hotspots": [ + { + "id": "hs_dom_1", + "label": "Выберите из списка", + "rect": { + "x": 92, + "y": 1583, + "width": 896, + "height": 132 + }, + "eventKey": "CLICK_VYBERITE_IZ_SPISKA", + "eventNameRu": "Нажатие «Выберите из списка»", + "category": "action", + "targetScreenId": null, + "source": "webview-dom", + "keyConfidence": "guessed", + "metricsSource": "none" + }, + { + "id": "hs_dom_2", + "label": "Выберите из списка", + "rect": { + "x": 92, + "y": 1820, + "width": 896, + "height": 132 + }, + "eventKey": "CLICK_VYBERITE_IZ_SPISKA_2", + "eventNameRu": "Нажатие «Выберите из списка»", + "category": "action", + "targetScreenId": null, + "source": "webview-dom", + "keyConfidence": "guessed", + "metricsSource": "none" + }, + { + "id": "hs_dom_3", + "label": "Моего адреса нет в списке", + "rect": { + "x": 45, + "y": 2561, + "width": 990, + "height": 135 + }, + "eventKey": "CLICK_MOEGO_ADRESA_NET_V_SPISKE", + "eventNameRu": "Нажатие «Моего адреса нет в списке»", + "category": "action", + "targetScreenId": null, + "source": "webview-dom", + "keyConfidence": "guessed", + "metricsSource": "none" + }, + { + "id": "hs_dom_4", + "label": "Подключить", + "rect": { + "x": 45, + "y": 2730, + "width": 990, + "height": 135 + }, + "eventKey": "CLICK_PODKLYUCHIT", + "eventNameRu": "Нажатие «Подключить»", + "category": "action", + "targetScreenId": null, + "source": "webview-dom", + "keyConfidence": "guessed", + "metricsSource": "none" + }, + { + "id": "hs_native_0", + "label": "Уведомления", + "rect": { + "x": 944, + "y": 107, + "width": 136, + "height": 135 + }, + "eventKey": "NOTIFICATIONS_OPEN", + "eventNameRu": "Открытие уведомлений", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_telecomkz_account", + "label": "Вкладка «Кабинет»", + "rect": { + "x": 0, + "y": 2922, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_CABINET_CLICK", + "eventNameRu": "Вкладка «Кабинет»", + "category": "navigation", + "targetScreenId": "main_dashboard", + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_tv_plus", + "label": "Вкладка «TV+»", + "rect": { + "x": 216, + "y": 2922, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_TV_PLUS_CLICK", + "eventNameRu": "Вкладка «TV+»", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_music", + "label": "Вкладка «Музыка»", + "rect": { + "x": 432, + "y": 2922, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_MUSIC_CLICK", + "eventNameRu": "Вкладка «Музыка»", + "category": "navigation", + "targetScreenId": "music_screen", + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_chats", + "label": "Вкладка «Чаты»", + "rect": { + "x": 648, + "y": 2922, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_CHATS_CLICK", + "eventNameRu": "Вкладка «Чаты»", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_b2b", + "label": "Вкладка «Бизнес»", + "rect": { + "x": 864, + "y": 2922, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_BUSINESS_CLICK", + "eventNameRu": "Вкладка «Бизнес»", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + } + ] + }, + { + "id": "tariff_keremet_mobile", + "name": "Тариф «Keremet Mobile»", + "category": "Услуги", + "image": "/assets/screens/tariff_keremet_mobile_long.png", + "isScrollable": true, + "viewportWidth": 1080, + "viewportHeight": 2340, + "totalHeight": 5812, + "route": "/full-digital/auth/tariff", + "capturedAt": 1787576361050, + "renderSettled": true, + "hotspots": [ + { + "id": "hs_dom_1", + "label": "Keremet Mobile", + "rect": { + "x": 45, + "y": 299, + "width": 990, + "height": 197 + }, + "eventKey": "CLICK_KEREMET_MOBILE", + "eventNameRu": "Нажатие «Keremet Mobile»", + "category": "action", + "targetScreenId": null, + "source": "webview-dom", + "keyConfidence": "guessed", + "metricsSource": "none" + }, + { + "id": "hs_dom_2", + "label": "Продолжить", + "rect": { + "x": 45, + "y": 1839, + "width": 990, + "height": 135 + }, + "eventKey": "CLICK_PRODOLZHIT", + "eventNameRu": "Нажатие «Продолжить»", + "category": "action", + "targetScreenId": "connect_service", + "source": "webview-dom", + "keyConfidence": "guessed", + "metricsSource": "none" + }, + { + "id": "hs_dom_3", + "label": "Подробнее о скидке", + "rect": { + "x": 45, + "y": 1997, + "width": 990, + "height": 97 + }, + "eventKey": "fd_auth_keremet_mobile_constructor_opened", + "eventNameRu": "Нажатие «Подробнее о скидке»", + "category": "action", + "targetScreenId": null, + "source": "webview-dom", + "keyConfidence": "observed", + "metricsSource": "none" + }, + { + "id": "hs_native_0", + "label": "Нативный элемент", + "rect": { + "x": 0, + "y": 96, + "width": 158, + "height": 158 + }, + "eventKey": "CLICK_NATIVE", + "eventNameRu": "Нажатие «native»", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "guessed", + "metricsSource": "none" + }, + { + "id": "hs_native_1", + "label": "Уведомления", + "rect": { + "x": 944, + "y": 107, + "width": 136, + "height": 135 + }, + "eventKey": "NOTIFICATIONS_OPEN", + "eventNameRu": "Открытие уведомлений", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_telecomkz_account", + "label": "Вкладка «Кабинет»", + "rect": { + "x": 0, + "y": 5624, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_CABINET_CLICK", + "eventNameRu": "Вкладка «Кабинет»", + "category": "navigation", + "targetScreenId": "main_dashboard", + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_tv_plus", + "label": "Вкладка «TV+»", + "rect": { + "x": 216, + "y": 5624, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_TV_PLUS_CLICK", + "eventNameRu": "Вкладка «TV+»", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_music", + "label": "Вкладка «Музыка»", + "rect": { + "x": 432, + "y": 5624, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_MUSIC_CLICK", + "eventNameRu": "Вкладка «Музыка»", + "category": "navigation", + "targetScreenId": "music_screen", + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_chats", + "label": "Вкладка «Чаты»", + "rect": { + "x": 648, + "y": 5624, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_CHATS_CLICK", + "eventNameRu": "Вкладка «Чаты»", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_b2b", + "label": "Вкладка «Бизнес»", + "rect": { + "x": 864, + "y": 5624, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_BUSINESS_CLICK", + "eventNameRu": "Вкладка «Бизнес»", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + } + ] + }, + { + "id": "tariff_keremet_tv", + "name": "Тариф «Keremet TV»", + "category": "Услуги", + "image": "/assets/screens/tariff_keremet_tv_long.png", + "isScrollable": true, + "viewportWidth": 1080, + "viewportHeight": 2340, + "totalHeight": 5031, + "route": "/full-digital/auth/tariff", + "capturedAt": 1787576408547, + "renderSettled": true, + "hotspots": [ + { + "id": "hs_dom_1", + "label": "Keremet TV", + "rect": { + "x": 45, + "y": 299, + "width": 990, + "height": 197 + }, + "eventKey": "CLICK_KEREMET_TV", + "eventNameRu": "Нажатие «Keremet TV»", + "category": "action", + "targetScreenId": null, + "source": "webview-dom", + "keyConfidence": "guessed", + "metricsSource": "none" + }, + { + "id": "hs_dom_2", + "label": "Продолжить", + "rect": { + "x": 45, + "y": 1839, + "width": 990, + "height": 135 + }, + "eventKey": "CLICK_PRODOLZHIT", + "eventNameRu": "Нажатие «Продолжить»", + "category": "action", + "targetScreenId": "connect_service", + "source": "webview-dom", + "keyConfidence": "guessed", + "metricsSource": "none" + }, + { + "id": "hs_dom_3", + "label": "Подробнее о скидке", + "rect": { + "x": 45, + "y": 1997, + "width": 990, + "height": 97 + }, + "eventKey": "fd_auth_keremet_tv_constructor_opened", + "eventNameRu": "Нажатие «Подробнее о скидке»", + "category": "action", + "targetScreenId": null, + "source": "webview-dom", + "keyConfidence": "observed", + "metricsSource": "none" + }, + { + "id": "hs_native_0", + "label": "Нативный элемент", + "rect": { + "x": 0, + "y": 96, + "width": 158, + "height": 158 + }, + "eventKey": "CLICK_NATIVE", + "eventNameRu": "Нажатие «native»", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "guessed", + "metricsSource": "none" + }, + { + "id": "hs_native_1", + "label": "Уведомления", + "rect": { + "x": 944, + "y": 107, + "width": 136, + "height": 135 + }, + "eventKey": "NOTIFICATIONS_OPEN", + "eventNameRu": "Открытие уведомлений", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_telecomkz_account", + "label": "Вкладка «Кабинет»", + "rect": { + "x": 0, + "y": 4843, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_CABINET_CLICK", + "eventNameRu": "Вкладка «Кабинет»", + "category": "navigation", + "targetScreenId": "main_dashboard", + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_tv_plus", + "label": "Вкладка «TV+»", + "rect": { + "x": 216, + "y": 4843, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_TV_PLUS_CLICK", + "eventNameRu": "Вкладка «TV+»", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_music", + "label": "Вкладка «Музыка»", + "rect": { + "x": 432, + "y": 4843, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_MUSIC_CLICK", + "eventNameRu": "Вкладка «Музыка»", + "category": "navigation", + "targetScreenId": "music_screen", + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_chats", + "label": "Вкладка «Чаты»", + "rect": { + "x": 648, + "y": 4843, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_CHATS_CLICK", + "eventNameRu": "Вкладка «Чаты»", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_b2b", + "label": "Вкладка «Бизнес»", + "rect": { + "x": 864, + "y": 4843, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_BUSINESS_CLICK", + "eventNameRu": "Вкладка «Бизнес»", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + } + ] + }, + { + "id": "tariff_internet_500", + "name": "Тариф «Интернет 500»", + "category": "Услуги", + "image": "/assets/screens/tariff_internet_500_long.png", + "isScrollable": true, + "viewportWidth": 1080, + "viewportHeight": 2340, + "totalHeight": 3996, + "route": "/full-digital/auth/tariff", + "capturedAt": 1787576454816, + "renderSettled": true, + "hotspots": [ + { + "id": "hs_dom_1", + "label": "Интернет 500", + "rect": { + "x": 45, + "y": 299, + "width": 990, + "height": 197 + }, + "eventKey": "CLICK_INTERNET_500", + "eventNameRu": "Нажатие «Интернет 500»", + "category": "action", + "targetScreenId": null, + "source": "webview-dom", + "keyConfidence": "guessed", + "metricsSource": "none" + }, + { + "id": "hs_dom_2", + "label": "Продолжить", + "rect": { + "x": 45, + "y": 1839, + "width": 990, + "height": 135 + }, + "eventKey": "CLICK_PRODOLZHIT", + "eventNameRu": "Нажатие «Продолжить»", + "category": "action", + "targetScreenId": "connect_service", + "source": "webview-dom", + "keyConfidence": "guessed", + "metricsSource": "none" + }, + { + "id": "hs_dom_3", + "label": "Подробнее о скидке", + "rect": { + "x": 45, + "y": 1997, + "width": 990, + "height": 97 + }, + "eventKey": "fd_auth_internet500_constructor_opened", + "eventNameRu": "Нажатие «Подробнее о скидке»", + "category": "action", + "targetScreenId": null, + "source": "webview-dom", + "keyConfidence": "observed", + "metricsSource": "none" + }, + { + "id": "hs_native_0", + "label": "Нативный элемент", + "rect": { + "x": 0, + "y": 96, + "width": 158, + "height": 158 + }, + "eventKey": "CLICK_NATIVE", + "eventNameRu": "Нажатие «native»", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "guessed", + "metricsSource": "none" + }, + { + "id": "hs_native_1", + "label": "Уведомления", + "rect": { + "x": 944, + "y": 107, + "width": 136, + "height": 135 + }, + "eventKey": "NOTIFICATIONS_OPEN", + "eventNameRu": "Открытие уведомлений", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_telecomkz_account", + "label": "Вкладка «Кабинет»", + "rect": { + "x": 0, + "y": 3808, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_CABINET_CLICK", + "eventNameRu": "Вкладка «Кабинет»", + "category": "navigation", + "targetScreenId": "main_dashboard", + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_tv_plus", + "label": "Вкладка «TV+»", + "rect": { + "x": 216, + "y": 3808, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_TV_PLUS_CLICK", + "eventNameRu": "Вкладка «TV+»", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_music", + "label": "Вкладка «Музыка»", + "rect": { + "x": 432, + "y": 3808, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_MUSIC_CLICK", + "eventNameRu": "Вкладка «Музыка»", + "category": "navigation", + "targetScreenId": "music_screen", + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_chats", + "label": "Вкладка «Чаты»", + "rect": { + "x": 648, + "y": 3808, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_CHATS_CLICK", + "eventNameRu": "Вкладка «Чаты»", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_b2b", + "label": "Вкладка «Бизнес»", + "rect": { + "x": 864, + "y": 3808, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_BUSINESS_CLICK", + "eventNameRu": "Вкладка «Бизнес»", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + } + ] + }, + { + "id": "tariff_internet_200", + "name": "Тариф «Интернет 200»", + "category": "Услуги", + "image": "/assets/screens/tariff_internet_200_long.png", + "isScrollable": true, + "viewportWidth": 1080, + "viewportHeight": 2340, + "totalHeight": 3996, + "route": "/full-digital/auth/tariff", + "capturedAt": 1787576501033, + "renderSettled": true, + "hotspots": [ + { + "id": "hs_dom_1", + "label": "Интернет 200", + "rect": { + "x": 45, + "y": 299, + "width": 990, + "height": 197 + }, + "eventKey": "CLICK_INTERNET_200", + "eventNameRu": "Нажатие «Интернет 200»", + "category": "action", + "targetScreenId": null, + "source": "webview-dom", + "keyConfidence": "guessed", + "metricsSource": "none" + }, + { + "id": "hs_dom_2", + "label": "Продолжить", + "rect": { + "x": 45, + "y": 1839, + "width": 990, + "height": 135 + }, + "eventKey": "CLICK_PRODOLZHIT", + "eventNameRu": "Нажатие «Продолжить»", + "category": "action", + "targetScreenId": "connect_service", + "source": "webview-dom", + "keyConfidence": "guessed", + "metricsSource": "none" + }, + { + "id": "hs_dom_3", + "label": "Подробнее о скидке", + "rect": { + "x": 45, + "y": 1997, + "width": 990, + "height": 97 + }, + "eventKey": "fd_auth_internet200_constructor_opened", + "eventNameRu": "Нажатие «Подробнее о скидке»", + "category": "action", + "targetScreenId": null, + "source": "webview-dom", + "keyConfidence": "observed", + "metricsSource": "none" + }, + { + "id": "hs_native_0", + "label": "Нативный элемент", + "rect": { + "x": 0, + "y": 96, + "width": 158, + "height": 158 + }, + "eventKey": "CLICK_NATIVE", + "eventNameRu": "Нажатие «native»", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "guessed", + "metricsSource": "none" + }, + { + "id": "hs_native_1", + "label": "Уведомления", + "rect": { + "x": 944, + "y": 107, + "width": 136, + "height": 135 + }, + "eventKey": "NOTIFICATIONS_OPEN", + "eventNameRu": "Открытие уведомлений", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_telecomkz_account", + "label": "Вкладка «Кабинет»", + "rect": { + "x": 0, + "y": 3808, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_CABINET_CLICK", + "eventNameRu": "Вкладка «Кабинет»", + "category": "navigation", + "targetScreenId": "main_dashboard", + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_tv_plus", + "label": "Вкладка «TV+»", + "rect": { + "x": 216, + "y": 3808, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_TV_PLUS_CLICK", + "eventNameRu": "Вкладка «TV+»", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_music", + "label": "Вкладка «Музыка»", + "rect": { + "x": 432, + "y": 3808, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_MUSIC_CLICK", + "eventNameRu": "Вкладка «Музыка»", + "category": "navigation", + "targetScreenId": "music_screen", + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_chats", + "label": "Вкладка «Чаты»", + "rect": { + "x": 648, + "y": 3808, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_CHATS_CLICK", + "eventNameRu": "Вкладка «Чаты»", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_b2b", + "label": "Вкладка «Бизнес»", + "rect": { + "x": 864, + "y": 3808, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_BUSINESS_CLICK", + "eventNameRu": "Вкладка «Бизнес»", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + } + ] + }, + { + "id": "account_selector", + "name": "Управление счетами (выбор ЛС)", + "category": "Основное", + "image": "/assets/screens/account_selector_long.png", + "isScrollable": false, + "viewportWidth": 1080, + "viewportHeight": 2340, + "totalHeight": 2339, + "route": "/", + "capturedAt": 1787576632595, + "renderSettled": true, + "hotspots": [ + { + "id": "hs_dom_1", + "label": "Лицевой счет 11440366 г.Алматы, прс.Достык, дом 89, кв(оф) 4", + "rect": { + "x": 56, + "y": 1529, + "width": 968, + "height": 166 + }, + "eventKey": "ACCOUNT_SELECTOR_CLICK", + "eventNameRu": "Выбор Лицевого Счета", + "category": "action", + "targetScreenId": null, + "source": "webview-dom", + "keyConfidence": "rule", + "metrics": { + "totalEvents": 24150, + "uniqueUsers": 8920, + "avgEventsPerUser": 2.7, + "shareOfClicks": 41.8, + "lastUpdated": null + }, + "metricsSource": "demo" + }, + { + "id": "hs_dom_2", + "label": "Лицевой счет 11812248 г.Алматы, ул.Навои, дом 74, кв(оф) 112", + "rect": { + "x": 56, + "y": 1718, + "width": 968, + "height": 166 + }, + "eventKey": "ACCOUNT_SELECTOR_CLICK_2", + "eventNameRu": "Выбор Лицевого Счета", + "category": "action", + "targetScreenId": null, + "source": "webview-dom", + "keyConfidence": "rule", + "metricsSource": "demo", + "metrics": { + "totalEvents": 24150, + "uniqueUsers": 8920, + "avgEventsPerUser": 2.7, + "shareOfClicks": 41.8, + "lastUpdated": null + } + }, + { + "id": "hs_dom_3", + "label": "Лицевой счет 11851173 г.Алматы, мкр.АКСАЙ-2, дом 17, кв(оф) ", + "rect": { + "x": 56, + "y": 1906, + "width": 968, + "height": 166 + }, + "eventKey": "ACCOUNT_SELECTOR_CLICK_3", + "eventNameRu": "Выбор Лицевого Счета", + "category": "action", + "targetScreenId": null, + "source": "webview-dom", + "keyConfidence": "rule", + "metricsSource": "demo", + "metrics": { + "totalEvents": 24150, + "uniqueUsers": 8920, + "avgEventsPerUser": 2.7, + "shareOfClicks": 41.8, + "lastUpdated": null + } + }, + { + "id": "hs_native_toolbarAvatarImageView", + "label": "Аватар профиля", + "rect": { + "x": 45, + "y": 119, + "width": 113, + "height": 113 + }, + "eventKey": "PROFILE_ICON_CLICK", + "eventNameRu": "Переход в Профиль", + "category": "navigation", + "source": "native-uiautomator", + "keyConfidence": "rule", + "metrics": { + "totalEvents": 6840, + "uniqueUsers": 2190, + "avgEventsPerUser": 3.12, + "shareOfClicks": 11.8, + "lastUpdated": null + }, + "metricsSource": "demo" + }, + { + "id": "hs_native_1", + "label": "Уведомления", + "rect": { + "x": 808, + "y": 107, + "width": 136, + "height": 135 + }, + "eventKey": "NOTIFICATIONS_OPEN", + "eventNameRu": "Открытие уведомлений", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_2", + "label": "Меню (правый навбар)", + "rect": { + "x": 944, + "y": 107, + "width": 136, + "height": 135 + }, + "eventKey": "MENUCLICKED", + "eventNameRu": "Нажатие на Меню", + "category": "navigation", + "targetScreenId": "side_menu", + "source": "native-uiautomator", + "keyConfidence": "rule", + "metrics": { + "totalEvents": 10680, + "uniqueUsers": 3478, + "avgEventsPerUser": 3.07, + "shareOfClicks": 18.5, + "lastUpdated": null + }, + "metricsSource": "demo" + }, + { + "id": "hs_native_action_telecomkz_account", + "label": "Вкладка «Кабинет»", + "rect": { + "x": 0, + "y": 2151, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_CABINET_CLICK", + "eventNameRu": "Вкладка «Кабинет»", + "category": "navigation", + "targetScreenId": "main_dashboard", + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_tv_plus", + "label": "Вкладка «TV+»", + "rect": { + "x": 216, + "y": 2151, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_TV_PLUS_CLICK", + "eventNameRu": "Вкладка «TV+»", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_music", + "label": "Вкладка «Музыка»", + "rect": { + "x": 432, + "y": 2151, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_MUSIC_CLICK", + "eventNameRu": "Вкладка «Музыка»", + "category": "navigation", + "targetScreenId": "music_screen", + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_chats", + "label": "Вкладка «Чаты»", + "rect": { + "x": 648, + "y": 2151, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_CHATS_CLICK", + "eventNameRu": "Вкладка «Чаты»", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + }, + { + "id": "hs_native_action_b2b", + "label": "Вкладка «Бизнес»", + "rect": { + "x": 864, + "y": 2151, + "width": 216, + "height": 134 + }, + "eventKey": "TAB_BUSINESS_CLICK", + "eventNameRu": "Вкладка «Бизнес»", + "category": "navigation", + "targetScreenId": null, + "source": "native-uiautomator", + "keyConfidence": "rule", + "metricsSource": "none" + } + ] + } + ], + "metricsSource": "demo" +} \ No newline at end of file diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/icons.svg b/public/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/App.css b/src/App.css new file mode 100644 index 0000000..f90339d --- /dev/null +++ b/src/App.css @@ -0,0 +1,184 @@ +.counter { + font-size: 16px; + padding: 5px 10px; + border-radius: 5px; + color: var(--accent); + background: var(--accent-bg); + border: 2px solid transparent; + transition: border-color 0.3s; + margin-bottom: 24px; + + &:hover { + border-color: var(--accent-border); + } + &:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + } +} + +.hero { + position: relative; + + .base, + .framework, + .vite { + inset-inline: 0; + margin: 0 auto; + } + + .base { + width: 170px; + position: relative; + z-index: 0; + } + + .framework, + .vite { + position: absolute; + } + + .framework { + z-index: 1; + top: 34px; + height: 28px; + transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg) + scale(1.4); + } + + .vite { + z-index: 0; + top: 107px; + height: 26px; + width: auto; + transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg) + scale(0.8); + } +} + +#center { + display: flex; + flex-direction: column; + gap: 25px; + place-content: center; + place-items: center; + flex-grow: 1; + + @media (max-width: 1024px) { + padding: 32px 20px 24px; + gap: 18px; + } +} + +#next-steps { + display: flex; + border-top: 1px solid var(--border); + text-align: left; + + & > div { + flex: 1 1 0; + padding: 32px; + @media (max-width: 1024px) { + padding: 24px 20px; + } + } + + .icon { + margin-bottom: 16px; + width: 22px; + height: 22px; + } + + @media (max-width: 1024px) { + flex-direction: column; + text-align: center; + } +} + +#docs { + border-right: 1px solid var(--border); + + @media (max-width: 1024px) { + border-right: none; + border-bottom: 1px solid var(--border); + } +} + +#next-steps ul { + list-style: none; + padding: 0; + display: flex; + gap: 8px; + margin: 32px 0 0; + + .logo { + height: 18px; + } + + a { + color: var(--text-h); + font-size: 16px; + border-radius: 6px; + background: var(--social-bg); + display: flex; + padding: 6px 12px; + align-items: center; + gap: 8px; + text-decoration: none; + transition: box-shadow 0.3s; + + &:hover { + box-shadow: var(--shadow); + } + .button-icon { + height: 18px; + width: 18px; + } + } + + @media (max-width: 1024px) { + margin-top: 20px; + flex-wrap: wrap; + justify-content: center; + + li { + flex: 1 1 calc(50% - 8px); + } + + a { + width: 100%; + justify-content: center; + box-sizing: border-box; + } + } +} + +#spacer { + height: 88px; + border-top: 1px solid var(--border); + @media (max-width: 1024px) { + height: 48px; + } +} + +.ticks { + position: relative; + width: 100%; + + &::before, + &::after { + content: ''; + position: absolute; + top: -4.5px; + border: 5px solid transparent; + } + + &::before { + left: 0; + border-left-color: var(--border); + } + &::after { + right: 0; + border-right-color: var(--border); + } +} diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..33021e6 --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,897 @@ +import { useState, useEffect, useCallback } from 'react'; +import type { + AppMapConfig, + Hotspot, + ViewMode, + MetricsCatalogEntry, + MetricsDocument, + MetricsSource, + DeviceStatus +} from './types/simulator'; +import { PhoneSimulator } from './components/PhoneSimulator'; +import { GuiEditor } from './components/GuiEditor'; +import { Sidebar } from './components/Sidebar'; +import { + Layers, + Smartphone, + TrendingUp, + Info, + Camera, + Radio, + FlaskConical, + Usb, +} from 'lucide-react'; + +export function App() { + const [appConfig, setAppConfig] = useState(null); + const [currentScreenId, setCurrentScreenId] = useState('main_dashboard'); + const [navHistory, setNavHistory] = useState([]); + const [mode, setMode] = useState('simulator'); + const [selectedHotspotId, setSelectedHotspotId] = useState(null); + const [isSyncing, setIsSyncing] = useState(false); + const [isCapturingAdb, setIsCapturingAdb] = useState(false); + const [isLiveAutoSyncActive, setIsLiveAutoSyncActive] = useState(false); + // Lazy initialiser: calling Date.now() during render makes the render impure. + const [imageTimestamp, setImageTimestamp] = useState(() => Date.now()); + const [metricsCatalog, setMetricsCatalog] = useState([]); + const [metricsSource, setMetricsSource] = useState('none'); + const [metricsFetchedAt, setMetricsFetchedAt] = useState(null); + const [deviceStatus, setDeviceStatus] = useState(null); + const [isDirty, setIsDirty] = useState(false); + const [isSavingMap, setIsSavingMap] = useState(false); + const [toastMessage, setToastMessage] = useState(null); + + // New screen creation modal state + const [isNewScreenModalOpen, setIsNewScreenModalOpen] = useState(false); + const [newScreenName, setNewScreenName] = useState(''); + const [newScreenCategory, setNewScreenCategory] = useState('Раздел'); + + const showToast = (msg: string) => { + setToastMessage(msg); + setTimeout(() => setToastMessage(null), 4000); + }; + + const loadAppMap = useCallback(() => { + return fetch('/data/telecomkz_app_map.json?t=' + Date.now()) + .then(res => res.json()) + .then((data: AppMapConfig) => { + setAppConfig(data); + setIsDirty(false); + return data; + }) + .catch(err => console.error('Error loading app map:', err)); + }, []); + + const loadMetrics = useCallback(() => { + return fetch('/data/metrics.json?t=' + Date.now()) + .then(res => res.json()) + .then((doc: MetricsDocument | MetricsCatalogEntry[]) => { + // Accept the legacy bare-array file, but treat it as unlabelled demo data. + const normalised: MetricsDocument = Array.isArray(doc) + ? { source: 'demo', events: doc } + : doc; + setMetricsCatalog(normalised.events || []); + setMetricsSource(normalised.source || 'demo'); + setMetricsFetchedAt(normalised.fetchedAt || null); + return normalised; + }) + .catch(err => console.error('Error loading metrics:', err)); + }, []); + + const refreshDeviceStatus = useCallback(() => { + return fetch('/api/device-status') + .then(res => res.json()) + .then((data: DeviceStatus) => { + setDeviceStatus(data); + return data; + }) + .catch(() => setDeviceStatus({ connected: false, error: 'Сервер не отвечает' })); + }, []); + + // Load initial app config and metrics on mount + useEffect(() => { + loadAppMap(); + loadMetrics(); + refreshDeviceStatus(); + }, [loadAppMap, loadMetrics, refreshDeviceStatus]); + + // Live Auto-Sync polling loop. + // Suspended in editor mode and while there are unsaved edits: the poll replaces the + // whole app map, which would silently discard whatever the analyst is typing. + useEffect(() => { + if (!isLiveAutoSyncActive) return; + if (mode === 'editor' || isDirty) return; + + let cancelled = false; + const interval = setInterval(async () => { + try { + const res = await fetch( + `/api/live-sync-webview?screenId=${encodeURIComponent(currentScreenId)}` + ); + const data = await res.json(); + if (cancelled) return; + if (data.success && data.appMap) { + setAppConfig(data.appMap); + } else if (data.error) { + setIsLiveAutoSyncActive(false); + showToast(`⚠️ Live-синхронизация остановлена: ${data.error}`); + } + } catch { + /* transient network hiccup - keep polling */ + } + }, 2500); + + return () => { + cancelled = true; + clearInterval(interval); + }; + }, [isLiveAutoSyncActive, currentScreenId, mode, isDirty]); + + // Trigger ADB capture for a specific screen. + // The capture always stitches the full scrollable page: the hotspot grid is only + // valid against a screenshot that covers the whole document. + const handleTriggerAdbCapture = async (targetScreenId?: string) => { + const sId = targetScreenId || currentScreenId; + const screenObj = appConfig?.screens.find(s => s.id === sId); + const screenName = screenObj ? screenObj.name : sId; + + setIsCapturingAdb(true); + showToast(`📸 Снимаем «${screenName}» с телефона и считываем DOM WebView...`); + + try { + const params = new URLSearchParams({ screenId: sId }); + if (screenObj) { + params.set('name', screenObj.name); + params.set('category', screenObj.category); + } + const res = await fetch(`/api/capture-adb?${params.toString()}`); + const data = await res.json(); + + if (data.success) { + await loadAppMap(); + setImageTimestamp(Date.now()); + refreshDeviceStatus(); + const count = data.hotspots ? data.hotspots.length : 0; + const dims = data.dimensions ? ` (${data.dimensions.width}×${data.dimensions.height})` : ''; + showToast(`✅ «${screenName}» обновлен${dims}. Размечено элементов: ${count}`); + } else { + showToast(`❌ Ошибка захвата: ${data.error || 'Проверьте соединение ADB'}`); + } + } catch { + showToast('⚠️ Не удалось связаться с dev-сервером'); + } finally { + setIsCapturingAdb(false); + } + }; + + // Persist the current map to public/data/telecomkz_app_map.json on disk. + const handleSaveAppMap = async () => { + if (!appConfig) return; + setIsSavingMap(true); + try { + const res = await fetch('/api/save-app-map', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(appConfig) + }); + const data = await res.json(); + if (data.success) { + setIsDirty(false); + showToast(`💾 Сохранено на диск: ${data.screens} экранов`); + } else { + showToast(`❌ Не удалось сохранить: ${data.error}`); + } + } catch { + showToast('⚠️ Сервер недоступен, изменения не сохранены'); + } finally { + setIsSavingMap(false); + } + }; + + // Add a brand new screen from ADB + const handleCreateNewScreenWithAdb = async (e: React.FormEvent) => { + e.preventDefault(); + if (!newScreenName.trim() || !appConfig) return; + + const screenId = `screen_${Date.now()}`; + const screenName = newScreenName.trim(); + setIsNewScreenModalOpen(false); + setIsCapturingAdb(true); + showToast(`📸 Создаем новый экран «${screenName}» со смартфона...`); + + try { + const params = new URLSearchParams({ + screenId, + name: screenName, + category: newScreenCategory + }); + const res = await fetch(`/api/capture-adb?${params.toString()}`); + const data = await res.json(); + + if (data.success) { + // capture_screen.py has already written the screen into the app map on disk, + // so re-read it instead of reconstructing a second copy in the browser. + await loadAppMap(); + setCurrentScreenId(screenId); + setImageTimestamp(Date.now()); + setNewScreenName(''); + const count: number = Array.isArray(data.hotspots) ? data.hotspots.length : 0; + showToast(`🎉 Экран «${screenName}» добавлен, размечено элементов: ${count}`); + } else { + showToast(`❌ Ошибка: ${data.error || 'Проверьте ADB'}`); + } + } catch { + showToast('⚠️ Ошибка при создании экрана'); + } finally { + setIsCapturingAdb(false); + } + }; + + // Re-apply the metrics catalog to every hotspot. + // Keys missing from the catalog have their metrics REMOVED, not left stale: a + // number that no longer has a matching row is worse than no number at all. + const handleSyncClickhouse = async () => { + setIsSyncing(true); + showToast('Обновление метрик из каталога...'); + try { + const doc = await loadMetrics(); + if (!doc) throw new Error('metrics.json недоступен'); + + const byKey = new Map(doc.events.map(m => [m.eventKey, m])); + let matched = 0; + let missing = 0; + + if (appConfig) { + const updatedScreens = appConfig.screens.map(screen => ({ + ...screen, + hotspots: screen.hotspots.map(hs => { + const row = byKey.get(hs.eventKey); + if (!row) { + missing += 1; + const { metrics: _dropped, ...rest } = hs; + return { ...rest, metricsSource: 'none' as const }; + } + matched += 1; + return { + ...hs, + eventNameRu: hs.eventNameRu || row.eventNameRu || hs.label, + metricsSource: doc.source, + metrics: { + totalEvents: row.totalEvents ?? 0, + uniqueUsers: row.uniqueUsers ?? 0, + avgEventsPerUser: row.avgEventsPerUser ?? null, + shareOfClicks: row.shareOfClicks ?? null + } + }; + }) + })); + + setAppConfig({ ...appConfig, screens: updatedScreens, metricsSource: doc.source }); + setIsDirty(true); + } + + const label = doc.source === 'clickhouse' ? 'ClickHouse' : 'демо-каталога'; + showToast(`✅ Метрики из ${label}: сопоставлено ${matched}, без данных ${missing}`); + } catch (err) { + showToast(`❌ Ошибка синхронизации: ${(err as Error).message}`); + } finally { + setIsSyncing(false); + } + }; + + // Screen Navigation handlers + const handleNavigate = (targetScreenId: string) => { + if (!appConfig) return; + const exists = appConfig.screens.some(s => s.id === targetScreenId); + if (exists && targetScreenId !== currentScreenId) { + setNavHistory(prev => [...prev, currentScreenId]); + setCurrentScreenId(targetScreenId); + setSelectedHotspotId(null); + } + }; + + const handleBack = () => { + if (navHistory.length === 0) return; + const previous = navHistory[navHistory.length - 1]; + setNavHistory(prev => prev.slice(0, prev.length - 1)); + setCurrentScreenId(previous); + setSelectedHotspotId(null); + }; + + // Hotspot Editing Handlers + const handleSaveHotspot = (updatedHotspot: Hotspot) => { + if (!appConfig) return; + const updatedScreens = appConfig.screens.map(s => { + if (s.id === currentScreenId) { + const index = s.hotspots.findIndex(h => h.id === updatedHotspot.id); + const newHotspots = [...s.hotspots]; + if (index >= 0) { + newHotspots[index] = updatedHotspot; + } else { + newHotspots.push(updatedHotspot); + } + return { ...s, hotspots: newHotspots }; + } + return s; + }); + + setAppConfig({ ...appConfig, screens: updatedScreens }); + setIsDirty(true); + }; + + const handleDeleteHotspot = (id: string) => { + if (!appConfig) return; + const updatedScreens = appConfig.screens.map(s => { + if (s.id === currentScreenId) { + return { + ...s, + hotspots: s.hotspots.filter(h => h.id !== id) + }; + } + return s; + }); + setAppConfig({ ...appConfig, screens: updatedScreens }); + setIsDirty(true); + setSelectedHotspotId(null); + }; + + const handleAddNewHotspot = () => { + if (!appConfig) return; + const currentScreen = appConfig.screens.find(s => s.id === currentScreenId); + if (!currentScreen) return; + + const newId = `hs_${Date.now()}`; + const newHotspot: Hotspot = { + id: newId, + label: `Новая зона ${currentScreen.hotspots.length + 1}`, + rect: { x: 100, y: 300, width: 300, height: 120 }, + eventKey: '', + eventNameRu: 'Новое событие', + category: 'action', + source: 'manual', + keyConfidence: 'manual', + metricsSource: 'none' + }; + + handleSaveHotspot(newHotspot); + setSelectedHotspotId(newId); + }; + + // Export / Import Map JSON + const handleExportJson = () => { + if (!appConfig) return; + const jsonStr = JSON.stringify(appConfig, null, 2); + const blob = new Blob([jsonStr], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'telecomkz_app_map.json'; + a.click(); + URL.revokeObjectURL(url); + }; + + const handleImportJson = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = (event) => { + try { + const parsed = JSON.parse(event.target?.result as string); + setAppConfig(parsed); + if (parsed.defaultScreenId) { + setCurrentScreenId(parsed.defaultScreenId); + } + setIsDirty(true); + showToast('✅ Конфигурация импортирована (не забудьте «Сохранить на диск»)'); + } catch { + showToast('❌ Ошибка чтения JSON файла'); + } + }; + reader.readAsText(file); + }; + + if (!appConfig) { + return ( +
+ Загрузка симулятора TelecomKz... +
+ ); + } + + const currentScreen = appConfig.screens.find(s => s.id === currentScreenId) || appConfig.screens[0]; + const totalHotspotsAll = appConfig.screens.reduce((sum, s) => sum + s.hotspots.length, 0); + const deviceLive = !!deviceStatus?.webviewReachable; + + return ( +
+ {/* Toast Notification */} + {toastMessage && ( +
+ {toastMessage} +
+ )} + + {/* New Screen Creator Modal */} + {isNewScreenModalOpen && ( +
+
+

+ Снять новый экран с Android +

+

+ Откройте нужный экран на смартфоне (например, «Детализация», «TV+» или «Боковое меню») и укажите название: +

+ +
+
+ + setNewScreenName(e.target.value)} + style={{ + width: '100%', + padding: '10px 14px', + background: 'rgba(0,0,0,0.3)', + border: '1px solid rgba(0, 163, 255, 0.4)', + borderRadius: '8px', + color: '#fff', + fontSize: '0.9rem' + }} + /> +
+ +
+ + +
+ +
+ + +
+
+
+
+ )} + + {/* Top Header Bar */} +
+ {/* Logo & Product Title */} +
+
+ 📡 +
+
+
+

+ TelecomKz Analytics Mapper +

+ + v5.0 + +
+

+ Визуальная симуляция приложения и маппинг событий Amplitude & ClickHouse +

+
+
+ + {/* Global Summary Metric Badges */} +
+ {/* Live Auto-Sync Toggle Button */} + + + {/* Device status: distinguishes "phone unplugged" from "server down" */} + + + {/* Provenance of every number on screen - never leave this implicit */} +
+ +
+
Источник метрик
+ + {metricsSource === 'clickhouse' ? 'ClickHouse' : 'Демо-данные'} + +
+
+ +
+ +
+
Экранов
+ {appConfig.screens.length} +
+
+ +
+ +
+
Размечено кнопок
+ {totalHotspotsAll} +
+
+ + +
+
+ + {/* Main Workspace Layout */} +
+ {/* Left Sidebar */} + { + setCurrentScreenId(id); + setSelectedHotspotId(null); + }} + mode={mode} + onSetMode={setMode} + onSyncClickhouse={handleSyncClickhouse} + isSyncing={isSyncing} + onExportJson={handleExportJson} + onImportJson={handleImportJson} + onTriggerAdbCapture={(sId) => handleTriggerAdbCapture(sId)} + onAddNewScreenWithAdb={() => setIsNewScreenModalOpen(true)} + isCapturing={isCapturingAdb} + onSaveAppMap={handleSaveAppMap} + isSavingMap={isSavingMap} + isDirty={isDirty} + metricsSource={metricsSource} + deviceStatus={deviceStatus} + /> + + {/* Center: Mobile Simulator */} +
+ {/* Active Screen Title Banner */} +
+ Текущий экран: + {currentScreen.name} + ({currentScreen.hotspots.length} зон) +
+ + {currentScreen.screenshotStale && ( +
+ Страница в приложении стала выше сохраненного скриншота — снимите экран заново, + иначе часть зон окажется за пределами картинки. +
+ )} + + 0} + selectedHotspotId={selectedHotspotId} + onSelectHotspot={setSelectedHotspotId} + imageTimestamp={imageTimestamp} + /> +
+ + {/* Right Inspector Panel */} + {mode === 'editor' ? ( + + ) : ( + /* Simulator & Analytics Overview Inspector */ +
+

+ Аналитика экрана +

+

+ События и клики по кнопкам на экране «{currentScreen.name}»: +

+ + {/* List of buttons with click percentages */} +
+ {currentScreen.hotspots.map(hs => { + const hasMetrics = !!hs.metrics; + const total = hs.metrics?.totalEvents; + const uniques = hs.metrics?.uniqueUsers; + const share = hs.metrics?.shareOfClicks || 0; + + return ( +
{ + if (hs.targetScreenId) { + handleNavigate(hs.targetScreenId); + } + }} + > +
+ + {hs.eventNameRu || hs.label} + + + {hs.eventKey} + +
+ + {hasMetrics ? ( +
+ Событий: {(total ?? 0).toLocaleString('ru-RU')} + Уникалов: {(uniques ?? 0).toLocaleString('ru-RU')} +
+ ) : ( +
+ Нет данных по этому ключу в каталоге метрик +
+ )} + + {/* Progress Bar for Share of Clicks */} + {share > 0 && ( +
+
+ Доля кликов на экране + {share}% +
+
+
+
+
+ )} +
+ ); + })} +
+ + {/* Quick Tip Box */} +
+ +
+ Совет: Наведите мышь на любую кнопку телефона для детального тултипа, или переключитесь в режим «Редактор» для изменения координат. +
+
+
+ )} +
+
+ ); +} + +export default App; diff --git a/src/assets/hero.png b/src/assets/hero.png new file mode 100644 index 0000000..02251f4 Binary files /dev/null and b/src/assets/hero.png differ diff --git a/src/assets/react.svg b/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/vite.svg b/src/assets/vite.svg new file mode 100644 index 0000000..5101b67 --- /dev/null +++ b/src/assets/vite.svg @@ -0,0 +1 @@ +Vite diff --git a/src/components/GuiEditor.tsx b/src/components/GuiEditor.tsx new file mode 100644 index 0000000..fc7847c --- /dev/null +++ b/src/components/GuiEditor.tsx @@ -0,0 +1,362 @@ +import React, { useState } from 'react'; +import type { Hotspot, ScreenData } from '../types/simulator'; +import { Plus, Trash2, Save, Zap, Layers, Check, Tag } from 'lucide-react'; + +interface GuiEditorProps { + currentScreen: ScreenData; + allScreens: ScreenData[]; + selectedHotspotId: string | null; + onSelectHotspot: (id: string | null) => void; + onSaveHotspot: (hotspot: Hotspot) => void; + onDeleteHotspot: (id: string) => void; + onAddNewHotspot: () => void; + availableEvents: { eventKey: string; eventNameRu?: string; totalEvents?: number }[]; +} + +export const GuiEditor: React.FC = ({ + currentScreen, + allScreens, + selectedHotspotId, + onSelectHotspot, + onSaveHotspot, + onDeleteHotspot, + onAddNewHotspot, + availableEvents +}) => { + const selectedHotspot = currentScreen.hotspots.find(h => h.id === selectedHotspotId); + + const [formData, setFormData] = useState(null); + const [savedSuccess, setSavedSuccess] = useState(false); + + // Sync form state when selection changes + React.useEffect(() => { + if (selectedHotspot) { + setFormData({ ...selectedHotspot }); + } else { + setFormData(null); + } + }, [selectedHotspotId, selectedHotspot]); + + const handleInputChange = (field: keyof Hotspot, value: any) => { + if (!formData) return; + setFormData({ + ...formData, + [field]: value + }); + }; + + const handleRectChange = (field: 'x' | 'y' | 'width' | 'height', value: number) => { + if (!formData) return; + setFormData({ + ...formData, + rect: { + ...formData.rect, + [field]: value + } + }); + }; + + const handleSelectPredefinedEvent = (eventKey: string) => { + const ev = availableEvents.find(e => e.eventKey === eventKey); + if (!ev || !formData) return; + setFormData({ + ...formData, + eventKey: ev.eventKey, + eventNameRu: ev.eventNameRu || formData.eventNameRu || ev.eventKey + }); + }; + + const handleSave = (e: React.FormEvent) => { + e.preventDefault(); + if (formData) { + onSaveHotspot(formData); + setSavedSuccess(true); + setTimeout(() => setSavedSuccess(false), 2000); + } + }; + + return ( +
+ {/* Header */} +
+
+

+ Редактор разметки +

+ + Экран: {currentScreen.name} + +
+ + +
+ + {/* Hotspots List Selector */} +
+ +
+ {currentScreen.hotspots.map(hs => ( + + ))} +
+
+ + {/* Selected Hotspot Edit Form */} + {formData ? ( +
+

+ Настройка выбранной зоны +

+ + {/* Label */} +
+ + handleInputChange('label', e.target.value)} + style={{ + width: '100%', + padding: '8px 12px', + background: 'rgba(0,0,0,0.3)', + border: '1px solid rgba(255,255,255,0.12)', + borderRadius: '8px', + color: '#fff', + fontSize: '0.85rem' + }} + /> +
+ + {/* Event Key with autocomplete / select */} +
+ + { + handleInputChange('eventKey', e.target.value); + handleSelectPredefinedEvent(e.target.value); + }} + placeholder="e.g. MENUCLICKED" + style={{ + width: '100%', + padding: '8px 12px', + background: 'rgba(0,0,0,0.3)', + border: '1px solid rgba(0, 163, 255, 0.4)', + borderRadius: '8px', + color: '#00d2ff', + fontFamily: 'var(--font-mono)', + fontSize: '0.85rem' + }} + /> + + {availableEvents.map(e => ( + + ))} + +
+ + {/* Russian Event Name */} +
+ + handleInputChange('eventNameRu', e.target.value)} + placeholder="e.g. Нажатие на Меню" + style={{ + width: '100%', + padding: '8px 12px', + background: 'rgba(0,0,0,0.3)', + border: '1px solid rgba(255,255,255,0.12)', + borderRadius: '8px', + color: '#fff', + fontSize: '0.85rem' + }} + /> +
+ + {/* Target Screen Navigation */} +
+ + +
+ + {/* Coordinates (X, Y, Width, Height) */} +
+ +
+
+ X + handleRectChange('x', parseInt(e.target.value) || 0)} + style={{ width: '100%', padding: '4px', background: 'rgba(0,0,0,0.3)', border: '1px solid rgba(255,255,255,0.1)', color: '#fff', fontSize: '0.75rem', borderRadius: '4px' }} + /> +
+
+ Y + handleRectChange('y', parseInt(e.target.value) || 0)} + style={{ width: '100%', padding: '4px', background: 'rgba(0,0,0,0.3)', border: '1px solid rgba(255,255,255,0.1)', color: '#fff', fontSize: '0.75rem', borderRadius: '4px' }} + /> +
+
+ W + handleRectChange('width', parseInt(e.target.value) || 0)} + style={{ width: '100%', padding: '4px', background: 'rgba(0,0,0,0.3)', border: '1px solid rgba(255,255,255,0.1)', color: '#fff', fontSize: '0.75rem', borderRadius: '4px' }} + /> +
+
+ H + handleRectChange('height', parseInt(e.target.value) || 0)} + style={{ width: '100%', padding: '4px', background: 'rgba(0,0,0,0.3)', border: '1px solid rgba(255,255,255,0.1)', color: '#fff', fontSize: '0.75rem', borderRadius: '4px' }} + /> +
+
+
+ + {/* Action Buttons */} +
+ + + +
+
+ ) : ( +
+ +

Выберите кнопку на экране или нажмите «Новая зона» для добавления

+
+ )} +
+ ); +}; diff --git a/src/components/HotspotTooltip.tsx b/src/components/HotspotTooltip.tsx new file mode 100644 index 0000000..599eff6 --- /dev/null +++ b/src/components/HotspotTooltip.tsx @@ -0,0 +1,187 @@ +import React from 'react'; +import type { Hotspot } from '../types/simulator'; +import { UNVERIFIED_CONFIDENCE } from '../types/simulator'; +import { Users, MousePointerClick, ArrowRight, Zap, TrendingUp, HelpCircle, FlaskConical } from 'lucide-react'; + +interface HotspotTooltipProps { + hotspot: Hotspot | null; + position: { x: number; y: number } | null; + targetScreenName?: string; +} + +export const HotspotTooltip: React.FC = ({ + hotspot, + position, + targetScreenName +}) => { + if (!hotspot || !position) return null; + + const m = hotspot.metrics; + // No catalog row means no measurement. Rendering 0 here would read as "nobody ever + // clicked this", which is a very different claim from "we have not measured it". + const hasMetrics = !!m; + const isDemo = hotspot.metricsSource === 'demo'; + const isUnverifiedKey = UNVERIFIED_CONFIDENCE.has(hotspot.keyConfidence || 'guessed'); + + const style: React.CSSProperties = { + position: 'fixed', + left: Math.min(position.x + 18, window.innerWidth - 340), + top: Math.max(position.y - 40, 20), + zIndex: 9999, + pointerEvents: 'none', + width: '320px', + padding: '16px', + background: 'rgba(13, 20, 38, 0.94)', + backdropFilter: 'blur(20px)', + border: '1px solid rgba(0, 163, 255, 0.4)', + boxShadow: '0 12px 36px rgba(0, 0, 0, 0.7), 0 0 25px rgba(0, 163, 255, 0.25)', + borderRadius: '16px', + animation: 'fadeIn 0.15s ease-out' + }; + + const fmt = (n?: number | null) => + typeof n === 'number' ? n.toLocaleString('ru-RU') : '—'; + + return ( +
+
+ + {hotspot.eventKey || 'КЛЮЧ НЕ ЗАДАН'} + + {hasMetrics && typeof m?.shareOfClicks === 'number' ? ( + + {m.shareOfClicks}% + + ) : null} +
+ + {isUnverifiedKey && ( +
+ + + Ключ выведен из подписи кнопки, а не снят с приложения — требует проверки + через monitor_live_events.py + +
+ )} + +

+ {hotspot.eventNameRu || hotspot.label} +

+ + {hasMetrics ? ( +
+
+ + Всего событий + + + {fmt(m?.totalEvents)} + +
+ +
+ + Уникальные + + + {fmt(m?.uniqueUsers)} + +
+ +
+
+ Событий на пользователя: + + {typeof m?.avgEventsPerUser === 'number' + ? m.avgEventsPerUser.toFixed(2) + : m && m.uniqueUsers + ? (m.totalEvents / m.uniqueUsers).toFixed(2) + : '—'} + +
+
+ + {isDemo && ( +
+ Демо-значения, не данные ClickHouse +
+ )} +
+ ) : ( +
+ Нет данных + Ключ {hotspot.eventKey} отсутствует + в каталоге метрик. Запустите синхронизацию ClickHouse или задайте верный ключ в редакторе. +
+ )} + + {hotspot.targetScreenId && ( +
+ + Переход на: {targetScreenName || hotspot.targetScreenId} +
+ )} +
+ ); +}; diff --git a/src/components/PhoneSimulator.tsx b/src/components/PhoneSimulator.tsx new file mode 100644 index 0000000..b5049ae --- /dev/null +++ b/src/components/PhoneSimulator.tsx @@ -0,0 +1,371 @@ +import React, { useState, useRef, useEffect } from 'react'; +import type { ScreenData, Hotspot, ViewMode } from '../types/simulator'; +import { HotspotTooltip } from './HotspotTooltip'; +import { Wifi, Battery, ArrowLeft, Loader2 } from 'lucide-react'; + +interface PhoneSimulatorProps { + screen: ScreenData; + allScreens: ScreenData[]; + mode: ViewMode; + onNavigate: (screenId: string) => void; + onBack?: () => void; + canGoBack?: boolean; + selectedHotspotId?: string | null; + onSelectHotspot?: (id: string | null) => void; + imageTimestamp?: number; +} + +export const PhoneSimulator: React.FC = ({ + screen, + allScreens, + mode, + onNavigate, + onBack, + canGoBack = false, + selectedHotspotId, + onSelectHotspot, + imageTimestamp = 0 +}) => { + const [hoveredHotspot, setHoveredHotspot] = useState(null); + const [tooltipPos, setTooltipPos] = useState<{ x: number; y: number } | null>(null); + const [isLoaded, setIsLoaded] = useState(false); + const [hasError, setHasError] = useState(false); + const [imgNaturalSize, setImgNaturalSize] = useState<{ width: number; height: number }>({ + width: screen.viewportWidth || 1080, + height: screen.totalHeight || 2340 + }); + + const scrollContainerRef = useRef(null); + const imgRef = useRef(null); + + // Reset loading state when screen image changes + useEffect(() => { + setIsLoaded(false); + setHasError(false); + }, [screen.image, imageTimestamp]); + + const handleImageLoad = (e: React.SyntheticEvent) => { + const img = e.currentTarget; + if (img.naturalWidth > 0) { + setImgNaturalSize({ + width: img.naturalWidth, + height: img.naturalHeight + }); + setIsLoaded(true); + setHasError(false); + } + }; + + // Find target screen name for tooltip + const getTargetScreenName = (targetId?: string) => { + if (!targetId) return undefined; + const target = allScreens.find(s => s.id === targetId); + return target ? target.name : targetId; + }; + + // Heatmap normalisation uses only hotspots that actually have a measurement. + // Unmeasured hotspots are drawn neutral grey rather than as a cold-blue "zero", + // which would misread as "nobody clicks this". + const measured = screen.hotspots + .map(h => h.metrics?.totalEvents) + .filter((n): n is number => typeof n === 'number'); + const maxEventsOnScreen = measured.length ? Math.max(...measured, 1) : 1; + + const getHeatmapColor = (hs: Hotspot) => { + const events = hs.metrics?.totalEvents; + if (typeof events !== 'number') return 'rgba(100, 116, 139, 0.28)'; + const ratio = Math.min(events / maxEventsOnScreen, 1); + const hue = (1 - ratio) * 220; + const alpha = 0.4 + ratio * 0.45; + return `hsla(${hue}, 95%, 50%, ${alpha})`; + }; + + const screenIds = new Set(allScreens.map(s => s.id)); + + // Live time for status bar + const [currentTime, setCurrentTime] = useState('09:41'); + useEffect(() => { + const updateTime = () => { + const now = new Date(); + setCurrentTime(now.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })); + }; + updateTime(); + const timer = setInterval(updateTime, 10000); + return () => clearInterval(timer); + }, []); + + const imageSrc = `${screen.image}${imageTimestamp ? `?t=${imageTimestamp}` : ''}`; + + return ( +
+ {/* Device Frame */} +
+ {/* Dynamic Island */} +
+
+
+
+ + {/* Status Bar */} +
+ {currentTime} +
+ + +
+
+ + {/* Navigation Bar (if Back is available) */} + {canGoBack && ( + + )} + + {/* Scrollable Viewport */} +
+
+ {/* Loading Indicator */} + {!isLoaded && !hasError && ( +
+ + Загрузка скриншота экрана... +
+ )} + + {/* Screen Screenshot Image */} + {screen.name} { + setHasError(true); + setIsLoaded(false); + }} + style={{ + width: '100%', + display: 'block', + pointerEvents: 'none', + opacity: isLoaded ? 1 : 0, + transition: 'opacity 0.25s ease' + }} + /> + + {/* Error fallback preview if image cannot be loaded */} + {hasError && ( +
+
📱
+

+ Скриншот еще не сохранен +

+

+ Нажмите «Снять экран с Android (ADB)» в меню слева для мгновенного захвата. +

+
+ )} + + {/* Hotspots Interactive Layer */} + {isLoaded && ( +
+ {screen.hotspots.map((hs) => { + const baseW = imgNaturalSize.width || screen.viewportWidth || 1080; + const baseH = imgNaturalSize.height || screen.totalHeight || 2340; + + const leftPct = (hs.rect.x / baseW) * 100; + const topPct = (hs.rect.y / baseH) * 100; + const widthPct = (hs.rect.width / baseW) * 100; + const heightPct = (hs.rect.height / baseH) * 100; + + const isSelected = selectedHotspotId === hs.id; + const isHovered = hoveredHotspot?.id === hs.id; + + const heatmapBg = mode === 'heatmap' ? getHeatmapColor(hs) : undefined; + // A targetScreenId pointing at a screen we never captured is dead: + // show it as non-navigable instead of swallowing the click. + const canNavigate = !!hs.targetScreenId && screenIds.has(hs.targetScreenId); + + return ( +
{ + setHoveredHotspot(hs); + setTooltipPos({ x: e.clientX, y: e.clientY }); + }} + onMouseMove={(e) => { + setTooltipPos({ x: e.clientX, y: e.clientY }); + }} + onMouseLeave={() => { + setHoveredHotspot(null); + setTooltipPos(null); + }} + onClick={(e) => { + e.stopPropagation(); + if (mode === 'editor' && onSelectHotspot) { + onSelectHotspot(hs.id); + } else if (mode === 'simulator' && canNavigate) { + onNavigate(hs.targetScreenId!); + } else if (onSelectHotspot) { + onSelectHotspot(hs.id); + } + }} + > + {/* Hotspot Label indicator */} + {(mode === 'editor' || isHovered) && ( +
+ {hs.eventKey || hs.label} +
+ )} + + {/* Heatmap Event Count Bubble */} + {mode === 'heatmap' && ( +
+ {typeof hs.metrics?.totalEvents === 'number' + ? hs.metrics.totalEvents.toLocaleString('ru-RU') + : '—'} +
+ )} +
+ ); + })} +
+ )} +
+
+ + {/* Home Indicator Bar */} +
+
+ + {/* Floating Analytics Tooltip */} + {hoveredHotspot && ( + + )} +
+ ); +}; diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx new file mode 100644 index 0000000..b8f7ffe --- /dev/null +++ b/src/components/Sidebar.tsx @@ -0,0 +1,397 @@ +import React, { useState } from 'react'; +import type { ScreenData, ViewMode, MetricsSource, DeviceStatus } from '../types/simulator'; +import { + Smartphone, + Layers, + Flame, + Download, + Upload, + Camera, + Search, + Database, + ChevronRight, + PlusCircle, + HardDriveDownload, + AlertTriangle +} from 'lucide-react'; + +interface SidebarProps { + screens: ScreenData[]; + currentScreenId: string; + onSelectScreen: (id: string) => void; + mode: ViewMode; + onSetMode: (mode: ViewMode) => void; + onSyncClickhouse: () => void; + isSyncing: boolean; + onExportJson: () => void; + onImportJson: (e: React.ChangeEvent) => void; + onTriggerAdbCapture: (screenId?: string) => void; + onAddNewScreenWithAdb: () => void; + isCapturing: boolean; + onSaveAppMap: () => void; + isSavingMap: boolean; + isDirty: boolean; + metricsSource: MetricsSource; + deviceStatus: DeviceStatus | null; +} + +export const Sidebar: React.FC = ({ + screens, + currentScreenId, + onSelectScreen, + mode, + onSetMode, + onSyncClickhouse, + isSyncing, + onExportJson, + onImportJson, + onTriggerAdbCapture, + onAddNewScreenWithAdb, + isCapturing, + onSaveAppMap, + isSavingMap, + isDirty, + metricsSource, + deviceStatus +}) => { + const [searchQuery, setSearchQuery] = useState(''); + + // Filter screens or hotspots by search + const filteredScreens = screens.filter(s => + s.name.toLowerCase().includes(searchQuery.toLowerCase()) || + s.hotspots.some(h => + h.label.toLowerCase().includes(searchQuery.toLowerCase()) || + h.eventKey.toLowerCase().includes(searchQuery.toLowerCase()) || + h.eventNameRu.toLowerCase().includes(searchQuery.toLowerCase()) + ) + ); + + const currentScreen = screens.find(s => s.id === currentScreenId); + + return ( +
+ {/* Mode Switcher Buttons */} +
+ +
+ + + + + +
+
+ + {/* Search Input */} +
+ + setSearchQuery(e.target.value)} + style={{ + width: '100%', + padding: '8px 12px 8px 34px', + background: 'rgba(0,0,0,0.3)', + border: '1px solid rgba(255,255,255,0.08)', + borderRadius: '8px', + color: '#fff', + fontSize: '0.8rem' + }} + /> +
+ + {/* Screen Selector List */} +
+
+ + + +
+ + {filteredScreens.map(s => { + const isActive = s.id === currentScreenId; + const measuredCount = s.hotspots.filter(h => !!h.metrics).length; + const totalScreenEvents = s.hotspots.reduce((sum, h) => sum + (h.metrics?.totalEvents || 0), 0); + + return ( +
onSelectScreen(s.id)} + style={{ + padding: '10px 12px', + borderRadius: '10px', + background: isActive ? 'rgba(0, 163, 255, 0.15)' : 'rgba(255,255,255,0.02)', + border: isActive ? '1px solid #00d2ff' : '1px solid rgba(255,255,255,0.05)', + cursor: 'pointer', + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + transition: 'all 0.2s ease' + }} + > +
+
+ {s.name} +
+
+ {s.hotspots.length} зон + • + {measuredCount > 0 ? ( + + {totalScreenEvents.toLocaleString('ru-RU')} событий + + ) : ( + без метрик + )} + {s.screenshotStale && } +
+
+ +
+ ); + })} +
+ + {/* Capture Options & Actions */} +
+ {/* Persist edits. Export downloads a copy; this writes the file the app reads. */} + + + {/* ADB Capture Button for Selected Screen */} + + + {/* ClickHouse Sync Button */} + + + {metricsSource !== 'clickhouse' && ( +
+ + + Показаны демонстрационные числа, а не данные ClickHouse. Укажите CLICKHOUSE_HOST + в .env и запустите py tools/fetch_clickhouse.py. + +
+ )} + + {/* Import & Export JSON buttons */} +
+ + + +
+
+
+ ); +}; diff --git a/src/index.css b/src/index.css new file mode 100644 index 0000000..f13cda5 --- /dev/null +++ b/src/index.css @@ -0,0 +1,243 @@ +@import url('https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap'); + +:root { + --bg-primary: #0a0f1d; + --bg-secondary: #111a2e; + --bg-tertiary: #192540; + --bg-glass: rgba(17, 26, 46, 0.75); + --bg-glass-card: rgba(25, 37, 64, 0.65); + --bg-glass-hover: rgba(35, 50, 85, 0.85); + + --accent-cyan: #00d2ff; + --accent-blue: #0070f3; + --accent-purple: #7928ca; + --accent-emerald: #10b981; + --accent-amber: #f59e0b; + --accent-rose: #f43f5e; + + --telecom-blue: #005bb5; + --telecom-cyan: #00a3ff; + --telecom-gradient: linear-gradient(135deg, #00a3ff 0%, #005bb5 100%); + --accent-glow: 0 0 25px rgba(0, 163, 255, 0.35); + + --text-primary: #f8fafc; + --text-secondary: #94a3b8; + --text-muted: #64748b; + --text-highlight: #38bdf8; + + --border-subtle: rgba(255, 255, 255, 0.08); + --border-active: rgba(0, 163, 255, 0.5); + --border-focus: rgba(0, 210, 255, 0.8); + + --shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.3); + --shadow-md: 0 8px 24px rgba(0, 0, 0, 0.4); + --shadow-lg: 0 20px 40px rgba(0, 0, 0, 0.6); + --shadow-glow: 0 0 30px rgba(0, 163, 255, 0.25); + + --radius-sm: 8px; + --radius-md: 14px; + --radius-lg: 20px; + --radius-xl: 32px; + --radius-full: 9999px; + + --font-sans: 'Outfit', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + --font-mono: 'JetBrains Mono', monospace; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +body { + font-family: var(--font-sans); + background-color: var(--bg-primary); + color: var(--text-primary); + overflow-x: hidden; + min-height: 100vh; + background-image: + radial-gradient(circle at 15% 15%, rgba(0, 163, 255, 0.08) 0%, transparent 40%), + radial-gradient(circle at 85% 85%, rgba(121, 40, 202, 0.06) 0%, transparent 45%), + radial-gradient(circle at 50% 50%, rgba(16, 185, 129, 0.04) 0%, transparent 60%); + background-attachment: fixed; +} + +/* Custom Scrollbars */ +::-webkit-scrollbar { + width: 6px; + height: 6px; +} +::-webkit-scrollbar-track { + background: rgba(10, 15, 29, 0.5); +} +::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.15); + border-radius: var(--radius-full); +} +::-webkit-scrollbar-thumb:hover { + background: rgba(0, 163, 255, 0.5); +} + +/* Glass Panels */ +.glass-panel { + background: var(--bg-glass); + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-md); + box-shadow: var(--shadow-md); +} + +.glass-card { + background: var(--bg-glass-card); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-sm); + transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1); +} + +.glass-card:hover { + background: var(--bg-glass-hover); + border-color: var(--border-active); + transform: translateY(-2px); + box-shadow: var(--shadow-glow); +} + +/* Glow Badges */ +.badge-metric { + font-family: var(--font-mono); + font-size: 0.75rem; + padding: 4px 10px; + border-radius: var(--radius-full); + background: rgba(0, 163, 255, 0.15); + border: 1px solid rgba(0, 163, 255, 0.3); + color: var(--accent-cyan); + display: inline-flex; + align-items: center; + gap: 6px; + font-weight: 600; +} + +/* Pulse Animation */ +@keyframes pulse-glow { + 0%, 100% { + box-shadow: 0 0 10px rgba(0, 163, 255, 0.4); + } + 50% { + box-shadow: 0 0 25px rgba(0, 210, 255, 0.8); + } +} + +.animate-glow { + animation: pulse-glow 2.5s infinite; +} + +/* Mobile Frame Mockup Container */ +.phone-mockup-wrapper { + position: relative; + width: 390px; + height: 800px; + background: #000; + border-radius: 50px; + box-shadow: + 0 0 0 12px #1e293b, + 0 0 0 14px #334155, + 0 25px 50px -12px rgba(0, 0, 0, 0.7), + 0 0 50px rgba(0, 163, 255, 0.25); + overflow: hidden; + border: 4px solid #000; + user-select: none; +} + +.phone-screen-viewport { + width: 100%; + height: 100%; + overflow-y: auto; + overflow-x: hidden; + position: relative; + background: #0f172a; + scrollbar-width: thin; + scrollbar-color: rgba(255, 255, 255, 0.2) transparent; +} + +.phone-screen-viewport::-webkit-scrollbar { + width: 4px; +} +.phone-screen-viewport::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.25); + border-radius: 10px; +} + +/* Dynamic Island / Notch */ +.dynamic-island { + position: absolute; + top: 10px; + left: 50%; + transform: translateX(-50%); + width: 110px; + height: 28px; + background: #000; + border-radius: 20px; + z-index: 100; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 10px; + box-shadow: 0 2px 8px rgba(0,0,0,0.5); +} + +.dynamic-island-lens { + width: 10px; + height: 10px; + border-radius: 50%; + background: #0d1527; + border: 1px solid #1e293b; +} + +.dynamic-island-sensor { + width: 8px; + height: 8px; + border-radius: 50%; + background: #090e17; +} + +/* Hotspot Overlays */ +.hotspot-box { + position: absolute; + border-radius: 8px; + cursor: pointer; + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + z-index: 10; +} + +.hotspot-box.simulator-mode { + border: 2px dashed rgba(0, 163, 255, 0.6); + background: rgba(0, 163, 255, 0.12); +} + +.hotspot-box.simulator-mode:hover { + border: 2px solid #00d2ff; + background: rgba(0, 210, 255, 0.3); + box-shadow: 0 0 20px rgba(0, 210, 255, 0.6); + z-index: 20; +} + +.hotspot-box.heatmap-mode { + border: 1px solid rgba(255, 255, 255, 0.4); + transition: transform 0.2s ease; +} + +.hotspot-box.editor-mode { + border: 2px solid #10b981; + background: rgba(16, 185, 129, 0.2); +} + +.hotspot-box.editor-mode.selected { + border: 2px solid #f59e0b; + background: rgba(245, 158, 11, 0.25); + box-shadow: 0 0 15px rgba(245, 158, 11, 0.5); +} diff --git a/src/main.tsx b/src/main.tsx new file mode 100644 index 0000000..bef5202 --- /dev/null +++ b/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import './index.css' +import App from './App.tsx' + +createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/src/types/simulator.ts b/src/types/simulator.ts new file mode 100644 index 0000000..e9bbb17 --- /dev/null +++ b/src/types/simulator.ts @@ -0,0 +1,109 @@ +export interface Rect { + x: number; + y: number; + width: number; + height: number; +} + +export interface MetricData { + totalEvents: number; + uniqueUsers: number; + avgEventsPerUser?: number | null; + shareOfClicks?: number | null; // percentage of all click events in the window + conversionRate?: number | null; + lastUpdated?: string | null; +} + +/** + * Where a hotspot's numbers came from. `none` means the metrics catalog has no row + * for this event key, so the hotspot carries no `metrics` at all and the UI must + * say so rather than render zeros that look like a measurement. + */ +export type MetricsSource = 'clickhouse' | 'demo' | 'none'; + +/** + * How the event key was determined. + * + * observed - seen leaving the app on the wire. The only value that means the + * key is real and will join against ClickHouse. + * dom-attribute - read from a data-event/data-analytics attribute in the DOM. + * manual - typed in by an analyst. + * rule - derived from the button caption via this project's naming table. + * guessed - transliterated from the caption because no rule matched. + * + * `rule` and `guessed` are both unverified: confirm them with + * `py tools/monitor_live_events.py --seconds 120 --map `. + */ +export type KeyConfidence = 'observed' | 'rule' | 'guessed' | 'dom-attribute' | 'manual'; + +export const UNVERIFIED_CONFIDENCE: ReadonlySet = new Set(['rule', 'guessed']); + +export interface Hotspot { + id: string; + label: string; + rect: Rect; // stitched-screenshot pixel space (see tools/telecom_cdp.py) + eventKey: string; + eventNameRu: string; + category?: 'navigation' | 'action' | 'banner' | 'account' | 'service'; + targetScreenId?: string; + metrics?: MetricData; + metricsSource?: MetricsSource; + source?: 'webview-dom' | 'native-uiautomator' | 'manual'; + keyConfidence?: KeyConfidence; +} + +export interface ScreenData { + id: string; + name: string; + category: string; + image: string; + isScrollable: boolean; + viewportWidth: number; + viewportHeight: number; + totalHeight: number; + hotspots: Hotspot[]; + route?: string; + capturedAt?: number; + syncedAt?: number; + /** Live sync found the page taller than the stored screenshot: re-capture needed. */ + screenshotStale?: boolean; +} + +export interface AppMapConfig { + project: string; + version: string; + screens: ScreenData[]; + defaultScreenId: string; + metricsSource?: MetricsSource; +} + +export interface MetricsCatalogEntry { + eventKey: string; + eventNameRu?: string; + totalEvents?: number; + uniqueUsers?: number; + avgEventsPerUser?: number; + shareOfClicks?: number; +} + +export interface MetricsDocument { + source: MetricsSource; + fetchedAt?: string | null; + table?: string | null; + windowDays?: number; + note?: string; + events: MetricsCatalogEntry[]; +} + +export interface DeviceStatus { + connected: boolean; + device?: string | null; + screenSize?: { width: number; height: number } | null; + appForeground?: boolean; + webviewReachable?: boolean; + route?: string | null; + metricsSource?: MetricsSource; + error?: string | null; +} + +export type ViewMode = 'simulator' | 'editor' | 'heatmap'; diff --git a/tools/amplitude_hook.py b/tools/amplitude_hook.py new file mode 100644 index 0000000..088b76e --- /dev/null +++ b/tools/amplitude_hook.py @@ -0,0 +1,191 @@ +""" +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=&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; })()" diff --git a/tools/capture_long_screen.py b/tools/capture_long_screen.py new file mode 100644 index 0000000..70849c3 --- /dev/null +++ b/tools/capture_long_screen.py @@ -0,0 +1,19 @@ +""" +DEPRECATED - Superseded by capture_screen.py, which produces a screenshot whose height is the WebView document height, so hotspot coordinates line up with the image. + +Kept so existing commands keep working; it forwards to the replacement. +""" + +import runpy +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +print( + "[deprecated] capture_long_screen.py now runs capture_screen.py - see its --help for options.", + file=sys.stderr, +) + +sys.argv[0] = str(Path(__file__).resolve().parent / "capture_screen.py") +runpy.run_module("capture_screen", run_name="__main__") diff --git a/tools/capture_screen.py b/tools/capture_screen.py new file mode 100644 index 0000000..ec55e11 --- /dev/null +++ b/tools/capture_screen.py @@ -0,0 +1,724 @@ +""" +TelecomKz screen capture + hotspot mapping. + +Produces, for one screen id: + public/assets/screens/_long.png the stitched screenshot + public/assets/screens/_result.json the capture result (also merged into the app map) + +How the stitch works +-------------------- +The old implementation swiped blindly and glued frames together with OpenCV +template matching, so the height of the result was unpredictable - while the +hotspot coordinates were computed from the DOM document height. The two never +agreed, which is why hotspots landed off-image. + +This version drives the scroll from the page itself over CDP and reads the real +scroll offset back after every step. A frame captured at scrollY lands at row +round(scrollY * scale) of the content band, by construction. So an element at +document offset y always sits at + + webview_top + round(y * scale) + +in both the image and the hotspot rects. No matching, no drift, no guessing. +""" + +import argparse +import io +import json +import sys +import time + +from PIL import Image + +sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent)) + +import telecom_cdp as T + +# Measure the page in one round trip; every number the stitcher needs comes from here. +PAGE_GEOMETRY_JS = """ +(() => ({ + url: location.href, + route: location.pathname, + title: document.title || '', + innerWidth: window.innerWidth, + innerHeight: window.innerHeight, + scrollHeight: Math.max( + document.documentElement.scrollHeight, + document.body ? document.body.scrollHeight : 0 + ), + scrollY: window.scrollY, + maxScrollY: Math.max( + 0, + Math.max( + document.documentElement.scrollHeight, + document.body ? document.body.scrollHeight : 0 + ) - window.innerHeight + ) +}))() +""" + +# A cheap fingerprint of "what is rendered right now". Some sections (the ID card, +# the tariff catalogue) paint a skeleton first and fill it a second or two later, and +# capturing then yields a screenshot with no controls on it at all. +PAGE_FINGERPRINT_JS = """ +(() => { + const body = document.body; + return JSON.stringify({ + n: document.querySelectorAll('*').length, + h: document.documentElement.scrollHeight, + t: (body ? (body.innerText || '').length : 0), + skeleton: document.querySelectorAll( + '[class*="skeleton"], [class*="loader"], [class*="loading"], [class*="spinner"]' + ).length + }); +})() +""" + +DOM_ELEMENTS_TEMPLATE = """ +((strictOcclusion) => { + const selectors = [ + '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', + // Side-drawer rows (Профиль телеком / Помощь / Оферта). They are plain DIVs with + // no role or href, so nothing else here matches them and the whole drawer was + // invisible to the mapper until this selector was added. + '.nav-link' + ]; + const seen = new Set(); + const out = []; + document.querySelectorAll(selectors.join(', ')).forEach(el => { + const style = window.getComputedStyle(el); + if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') return; + const r = el.getBoundingClientRect(); + if (r.width < 20 || r.height < 15) return; + const text = (el.innerText || el.getAttribute('aria-label') || el.getAttribute('title') || '') + .trim().replace(/\\s+/g, ' '); + if (!text) return; + // Skip anything an overlay is covering. With the side drawer open the dashboard + // behind it is still laid out and would otherwise be mapped as clickable, so the + // element under its own centre point has to actually be this element. + // Occlusion test. What counts as "covered" depends on how the screen is captured: + // + // strict (single-viewport capture): probe the centre of whatever part is on + // screen, so a card half below the fold is still tested. Needed for the side + // drawer, which covers content that straddles the fold. + // + // lenient (scrolling capture): only test elements fully inside the viewport. + // Elements below the fold get scrolled into view later and are captured then; + // judging them against a sticky banner at scroll 0 would wrongly discard them. + const vx0 = Math.max(r.left, 0), vx1 = Math.min(r.right, window.innerWidth); + const vy0 = Math.max(r.top, 0), vy1 = Math.min(r.bottom, window.innerHeight); + const fullyVisible = r.top >= 0 && r.bottom <= window.innerHeight && + r.left >= 0 && r.right <= window.innerWidth; + if (vx1 > vx0 && vy1 > vy0 && (strictOcclusion || fullyVisible)) { + const hit = document.elementFromPoint((vx0 + vx1) / 2, (vy0 + vy1) / 2); + if (hit && !el.contains(hit) && !hit.contains(el)) return; + } + + // A card and its inner link report the same box; keep the outermost only. + const key = [Math.round(r.left), Math.round(r.top + window.scrollY), + Math.round(r.width), Math.round(r.height)].join(':'); + if (seen.has(key)) return; + seen.add(key); + out.push({ + text: text.slice(0, 80), + tag: el.tagName.toLowerCase(), + className: String(el.className || '').slice(0, 120), + domId: el.id || '', + dataEvent: el.getAttribute('data-event') || el.getAttribute('data-analytics') || '', + cssLeft: r.left, + cssTop: r.top + window.scrollY, + cssWidth: r.width, + cssHeight: r.height + }); + }); + return { elements: out }; +}) +""" + + +def dom_elements_js(strict_occlusion): + return "(" + DOM_ELEMENTS_TEMPLATE + ")(" + ("true" if strict_occlusion else "false") + ")" + + +# Back-compat for callers that import the constant directly (live_auto_recorder). +DOM_ELEMENTS_JS = dom_elements_js(False) + +# Text -> Amplitude event key. These are the mappings the analyst confirmed; anything +# else gets a provisional key flagged with `keyConfidence: "guessed"` so it is obvious +# in the editor which rows still need a real key. +# Ordered most-specific first: card labels concatenate their title and subtitle, so +# "Подключить интернет Скидки и бонусы" must hit the internet rule before the bonus one. +EVENT_RULES = [ + (("подключить интернет",), "CONNECT_INTERNET_CLICK", "Подключение интернета", None), + (("turbo",), "TURBO_CLICK", "Подключение Turbo-скорости", None), + (("telecom shop",), "SHOP_CLICK", "Telecom Shop", None), + (("tv+",), "TV_PLUS_CLICK", "Переход в «TV+»", None), + (("aitu music", "музык"), "MUSIC_CLICK", "Переход в «Музыка»", "music_screen"), + (("лицевой счет", "лицевой счёт"), "ACCOUNT_SELECTOR_CLICK", "Выбор Лицевого Счета", None), + (("свободные средств",), "BALANCE_CARD_PAY_CLICK", "Быстрая оплата баланса", "payments_screen"), + (("мои бонусы", "бонус"), "BONUSES_CLICK", "Раздел «Мои бонусы»", None), + (("детализац",), "DETAILS_CLICK", "Переход в «Детализацию»", "details_screen"), + (("мои услуги", "услуг"), "SERVICES_CLICK", "Переход в «Мои услуги»", "services_screen"), + (("трафик",), "TRAFFIC_CLICK", "Просмотр «Трафик»", "traffic_screen"), + (("платеж", "оплат", "пополн"), "PAYMENTS_CLICK", "Переход в «Платежи»", "payments_screen"), + (("заявк",), "ORDERS_CLICK", "Раздел «Заявки»", "orders_screen"), + (("удв. лич", "удостовер"), "IDENTITY_CLICK", "Удостоверение личности", None), + (("qr",), "QR_PAY_CLICK", "Оплата по QR", None), + (("сервис",), "SERVICES_CATALOG_CLICK", "Каталог сервисов", None), + (("баланс",), "BALANCE_CARD_PAY_CLICK", "Быстрая оплата баланса", "payments_screen"), +] + + +def slugify_event_key(text): + """ + Provisional key for an unmapped element. Transliterated, because Amplitude event + keys are ASCII - the old code emitted Cyrillic keys like CLICK_ЧАТЫ that could + never match a real event. + """ + table = { + "а": "A", "б": "B", "в": "V", "г": "G", "д": "D", "е": "E", "ё": "E", "ж": "ZH", + "з": "Z", "и": "I", "й": "Y", "к": "K", "л": "L", "м": "M", "н": "N", "о": "O", + "п": "P", "р": "R", "с": "S", "т": "T", "у": "U", "ф": "F", "х": "H", "ц": "TS", + "ч": "CH", "ш": "SH", "щ": "SCH", "ъ": "", "ы": "Y", "ь": "", "э": "E", "ю": "YU", + "я": "YA", + } + out = [] + for ch in text.lower(): + if ch in table: + out.append(table[ch]) + elif ch.isalnum() and ch.isascii(): + out.append(ch.upper()) + else: + out.append("_") + key = "_".join(filter(None, "".join(out).split("_"))) + return ("CLICK_" + key)[:40] if key else "CLICK_UNKNOWN" + + +def unique_key(key, used): + """ + Keep generated keys distinct within one screen. Two "Все" links or two cards + whose captions collapse to the same slug would otherwise share a key, and a + shared key silently attributes one control's metrics to another. + """ + if key not in used: + used.add(key) + return key + for suffix in range(2, 100): + tail = "_" + str(suffix) + # Trim the stem BEFORE appending. Truncating afterwards chops the suffix off + # again, so two long captions keep colliding on the same 40-char key. + candidate = key[: 40 - len(tail)] + tail + if candidate not in used: + used.add(candidate) + return candidate + return key + + +def classify(text): + """ + Returns (eventKey, eventNameRu, targetScreenId, confidence). + + Confidence "rule" means the key came from the table above - a naming convention + this project chose, NOT a key observed coming out of the app. Verified on the + live app, the real keys look like HOMEPAGEPAYMENTS and OPENWINDOWPAYMENT, so a + rule-derived key will not join against ClickHouse until it has been confirmed + with tools/monitor_live_events.py. Only "observed" means the key is real. + """ + low = text.lower() + for needles, key, name_ru, target in EVENT_RULES: + if any(n in low for n in needles): + return key, name_ru, target, "rule" + return slugify_event_key(text), "Нажатие «" + text[:40] + "»", None, "guessed" + + +def classify_native(element): + """Native chrome hotspots, keyed off resource-id which is stable across releases.""" + rid = element.get("resourceId", "") + label = element.get("contentDesc") or element.get("text") or "" + table = { + "toolbarAvatarImageView": ("PROFILE_ICON_CLICK", "Переход в Профиль", "profile_screen", "Аватар профиля"), + "action_telecomkz_account": ("TAB_CABINET_CLICK", "Вкладка «Кабинет»", "main_dashboard", "Вкладка «Кабинет»"), + "action_tv_plus": ("TAB_TV_PLUS_CLICK", "Вкладка «TV+»", None, "Вкладка «TV+»"), + "action_music": ("TAB_MUSIC_CLICK", "Вкладка «Музыка»", "music_screen", "Вкладка «Музыка»"), + "action_chats": ("TAB_CHATS_CLICK", "Вкладка «Чаты»", None, "Вкладка «Чаты»"), + "action_b2b": ("TAB_BUSINESS_CLICK", "Вкладка «Бизнес»", None, "Вкладка «Бизнес»"), + } + short = rid.split("/")[-1] if rid else "" + if short in table: + key, name_ru, target, label_ru = table[short] + return key, name_ru, target, label_ru, "rule" + + desc = (element.get("contentDesc") or "").lower() + if "notification" in desc or "уведомл" in desc: + return "NOTIFICATIONS_OPEN", "Открытие уведомлений", None, "Уведомления", "rule" + if "navbar" in desc or "меню" in desc or "menu" in desc: + return "MENUCLICKED", "Нажатие на Меню", "side_menu", "Меню (правый навбар)", "rule" + + key, name_ru, target, conf = classify(label or short or "native") + return key, name_ru, target, (label or short or "Нативный элемент"), conf + + +def wait_until_stable(session, timeout=20.0, quiet_polls=3, interval=0.6): + """ + Block until the page stops changing, or `timeout` elapses. + + Returns (stable, seconds_waited). "Stable" means the fingerprint repeated + `quiet_polls` times in a row with no skeleton/loader elements left. Callers get + the flag so a capture taken from a still-moving page can be reported as such + rather than quietly saved as if it were finished. + """ + last = None + repeats = 0 + started = time.time() + while time.time() - started < timeout: + try: + current = json.loads(session.evaluate(PAGE_FINGERPRINT_JS)) + except T.DeviceError: + return False, time.time() - started + fingerprint = (current["n"], current["h"], current["t"]) + if fingerprint == last and current["skeleton"] == 0: + repeats += 1 + if repeats >= quiet_polls: + return True, time.time() - started + else: + repeats = 0 + last = fingerprint + time.sleep(interval) + return False, time.time() - started + + +def capture_stitched(session, device, layout, settle=0.45, no_scroll=False): + """ + Scroll the page from top to bottom, capturing a native frame at each stop, and + compose them onto one canvas positioned by the measured scroll offset. + + Returns (PIL image, geometry dict). + """ + screen_w = layout["screenWidth"] + screen_h = layout["screenHeight"] + wv_top = layout["webviewTop"] + wv_bottom = layout["webviewBottom"] + nav_top = layout["bottomNavTop"] + band_h = wv_bottom - wv_top + + if not no_scroll: + session.evaluate("window.scrollTo(0, 0)") + time.sleep(settle) + geo = json.loads(session.evaluate("JSON.stringify(" + PAGE_GEOMETRY_JS + ")")) + + scale = screen_w / max(geo["innerWidth"], 1) + # An overlay (side drawer, modal) covers a document that still reports a + # scrollable height, and scrolling it dismisses the overlay - so for those the + # capture is a single frame and the content band is exactly one viewport. + max_scroll_css = 0 if no_scroll else max(geo["maxScrollY"], 0) + content_h = round(max_scroll_css * scale) + band_h + + first_png = T.screencap(device) + first = Image.open(io.BytesIO(first_png)).convert("RGB") + top_bar = first.crop((0, 0, screen_w, wv_top)) + bottom_nav = first.crop((0, nav_top, screen_w, screen_h)) + nav_h = bottom_nav.height + + canvas = Image.new("RGB", (screen_w, content_h), (10, 15, 29)) + canvas.paste(first.crop((0, wv_top, screen_w, wv_bottom)), (0, 0)) + + # Overlap each step slightly so a rounding error can never open a seam. + step_css = max(geo["innerHeight"] - 24, 40) + scroll_stops = [] + pos = step_css + while pos < max_scroll_css - 1: + scroll_stops.append(pos) + pos += step_css + if max_scroll_css > 1: + scroll_stops.append(max_scroll_css) + + # A fixed overlay (side menu, modal) sits on top of a document that still reports + # a scrollable height. window.scrollY then changes while the visible pixels do + # not, and pasting the next frame lower down duplicates the whole UI. So compare + # each frame against the last and stop as soon as the screen stops moving. + import numpy as np + + prev_band = np.asarray(canvas.crop((0, 0, screen_w, band_h)), dtype=np.int16) + effective_content_h = content_h + + for target_scroll in scroll_stops: + session.evaluate("window.scrollTo(0, " + str(target_scroll) + ")") + time.sleep(settle) + actual = session.evaluate("window.scrollY") or 0 + frame = Image.open(io.BytesIO(T.screencap(device))).convert("RGB") + band = frame.crop((0, wv_top, screen_w, wv_bottom)) + + band_arr = np.asarray(band, dtype=np.int16) + if np.abs(band_arr - prev_band).mean() < 1.0: + effective_content_h = band_h + break + + canvas.paste(band, (0, round(actual * scale))) + prev_band = band_arr + + if effective_content_h != content_h: + canvas = canvas.crop((0, 0, screen_w, effective_content_h)) + content_h = effective_content_h + + if not no_scroll: + session.evaluate("window.scrollTo(0, 0)") + time.sleep(settle) + + master = Image.new("RGB", (screen_w, wv_top + content_h + nav_h), (10, 15, 29)) + master.paste(top_bar, (0, 0)) + master.paste(canvas, (0, wv_top)) + master.paste(bottom_nav, (0, wv_top + content_h)) + + geo.update( + { + "scale": scale, + "webviewTop": wv_top, + "contentHeight": content_h, + "navHeight": nav_h, + "totalHeight": master.height, + "scrollStops": len(scroll_stops) + 1, + } + ) + return master, geo + + +def build_hotspots(dom_elements, layout, geo, catalog, source, viewport_only=False): + """DOM elements + native chrome -> hotspots in stitched-screen space.""" + scale = geo["scale"] + wv_top = geo["webviewTop"] + screen_w = layout["screenWidth"] + total_h = geo["totalHeight"] + nav_top_in_image = wv_top + geo["contentHeight"] + + hotspots = [] + seen_boxes = set() + used_keys = set() + # In no-scroll mode the image is exactly one viewport, so anything laid out below + # it is not in the picture and must not become a hotspot. + viewport_limit = wv_top + (layout["webviewBottom"] - wv_top) if viewport_only else None + + for el in dom_elements: + x = round(el["cssLeft"] * scale) + y = wv_top + round(el["cssTop"] * scale) + w = round(el["cssWidth"] * scale) + h = round(el["cssHeight"] * scale) + + # Horizontal carousels report boxes that run past the right edge of the + # screen. Clip to the visible canvas and drop anything entirely outside it. + x0, y0 = max(x, 0), max(y, 0) + x1, y1 = min(x + w, screen_w), min(y + h, total_h) + if viewport_limit is not None and y0 >= viewport_limit: + continue + if x1 - x0 < 20 or y1 - y0 < 15: + continue + + box = (x0 // 8, y0 // 8, (x1 - x0) // 8, (y1 - y0) // 8) + if box in seen_boxes: + continue + seen_boxes.add(box) + + key, name_ru, target, confidence = classify(el["text"]) + if el.get("dataEvent"): + key, confidence = el["dataEvent"], "dom-attribute" + elif key in used_keys: + # Two different controls matched the same rule ("Мой автоплатеж" and + # "История платежей" both contain "платеж"). One key cannot describe both, + # and a duplicate silently joins the wrong ClickHouse rows to a button, so + # the later one falls back to a caption slug flagged as a guess. + key, name_ru, target, confidence = ( + slugify_event_key(el["text"]), + "Нажатие «" + el["text"][:40] + "»", + None, + "guessed", + ) + key = unique_key(key, used_keys) + + hotspots.append( + T.attach_metrics( + { + "id": "hs_dom_" + str(len(hotspots) + 1), + "label": el["text"][:60], + "rect": {"x": x0, "y": y0, "width": x1 - x0, "height": y1 - y0}, + "eventKey": key, + "eventNameRu": name_ru, + "category": "action", + "targetScreenId": target, + "source": "webview-dom", + "keyConfidence": confidence, + }, + catalog, + source, + ) + ) + + for i, el in enumerate(layout.get("nativeElements", [])): + rect = dict(el["rect"]) + # Native chrome is fixed on screen: the top bar keeps its own coordinates and + # the bottom nav moves to wherever the nav band ended up in the tall image. + if rect["y"] >= layout["bottomNavTop"] - 4: + rect["y"] = nav_top_in_image + (rect["y"] - layout["bottomNavTop"]) + key, name_ru, target, label_ru, confidence = classify_native(el) + hotspots.append( + T.attach_metrics( + { + "id": "hs_native_" + (el.get("resourceId", "").split("/")[-1] or str(i)), + "label": label_ru, + "rect": rect, + "eventKey": key, + "eventNameRu": name_ru, + "category": "navigation", + "targetScreenId": target, + "source": "native-uiautomator", + "keyConfidence": confidence, + }, + catalog, + source, + ) + ) + + return hotspots + + +def capture_native(screen_id, screen_name=None, category="Основное"): + """ + Capture a screen that has no WebView at all (Музыка, Чаты and the other native + tabs). Everything comes from uiautomator: one screencap plus every clickable + control on it. No scrolling - the accessibility tree only describes what is + currently rendered. + """ + T.validate_screen_id(screen_id) + device = T.get_device() + T.require_awake(device) + T.bring_app_to_front(device) + + catalog = T.load_metrics_catalog() + source = T.metrics_source() + + image = Image.open(io.BytesIO(T.screencap(device))).convert("RGB") + elements = T.collect_native_elements(device) + + hotspots = [] + used_keys = set() + for i, el in enumerate(elements): + label = el.get("label") or el.get("contentDesc") or el.get("text") or "" + short_rid = el.get("resourceId", "").split("/")[-1] + if not label and not short_rid: + continue + + key, name_ru, target, label_ru, confidence = classify_native(el) + if confidence != "rule" or key in used_keys: + key = slugify_event_key(label or short_rid) + name_ru = "Нажатие «" + (label or short_rid)[:40] + "»" + target = None + confidence = "guessed" + key = unique_key(key, used_keys) + + hotspots.append( + T.attach_metrics( + { + "id": "hs_native_" + (short_rid or str(i)), + "label": (label or label_ru or short_rid)[:60], + "rect": el["rect"], + "eventKey": key, + "eventNameRu": name_ru, + "category": "navigation", + "targetScreenId": target, + "source": "native-uiautomator", + "keyConfidence": confidence, + }, + catalog, + source, + ) + ) + + T.SCREENS_DIR.mkdir(parents=True, exist_ok=True) + image_path = T.SCREENS_DIR / (screen_id + "_long.png") + image.save(image_path, "PNG") + + screen = { + "id": screen_id, + "name": screen_name or ("Экран " + screen_id), + "category": category, + "image": "/assets/screens/" + screen_id + "_long.png", + "isScrollable": False, + "viewportWidth": image.width, + "viewportHeight": image.height, + "totalHeight": image.height, + "surface": "native", + "capturedAt": int(time.time() * 1000), + # uiautomator only ever describes what is on screen now, so there is no + # renderer to wait for here. + "renderSettled": True, + "hotspots": hotspots, + } + + app_map = T.load_app_map() + T.upsert_screen(app_map, screen) + app_map["metricsSource"] = source + T.save_app_map(app_map) + + result = { + "success": True, + "screenId": screen_id, + "imageUrl": screen["image"], + "dimensions": {"width": image.width, "height": image.height}, + "isLong": False, + "surface": "native", + "metricsSource": source, + "hotspots": hotspots, + "screen": screen, + "timestamp": int(time.time() * 1000), + } + with open(T.SCREENS_DIR / (screen_id + "_result.json"), "w", encoding="utf-8") as f: + json.dump(result, f, ensure_ascii=False, indent=2) + return result + + +def capture( + screen_id, + screen_name=None, + category="Основное", + settle=0.45, + no_scroll=False, + wait=20.0, +): + T.validate_screen_id(screen_id) + device, target = T.connect() + catalog = T.load_metrics_catalog() + source = T.metrics_source() + + layout = T.probe_native_layout(device) + + with T.CdpSession(target["webSocketDebuggerUrl"]) as session: + stable, waited = (True, 0.0) + if wait > 0: + stable, waited = wait_until_stable(session, timeout=wait) + if not stable: + print( + "warning: " + + screen_id + + " was still rendering after " + + str(round(waited, 1)) + + "s; capturing anyway", + file=sys.stderr, + ) + image, geo = capture_stitched(session, device, layout, settle=settle, no_scroll=no_scroll) + dom = json.loads( + session.evaluate("JSON.stringify(" + dom_elements_js(no_scroll) + ")") + ) + + hotspots = build_hotspots( + dom.get("elements", []), layout, geo, catalog, source, viewport_only=no_scroll + ) + + T.SCREENS_DIR.mkdir(parents=True, exist_ok=True) + image_path = T.SCREENS_DIR / (screen_id + "_long.png") + image.save(image_path, "PNG") + + screen = { + "id": screen_id, + "name": screen_name or ("Экран " + screen_id), + "category": category, + "image": "/assets/screens/" + screen_id + "_long.png", + "isScrollable": image.height > layout["screenHeight"], + "viewportWidth": layout["screenWidth"], + "viewportHeight": layout["screenHeight"], + "totalHeight": image.height, + "route": geo.get("route"), + "capturedAt": int(time.time() * 1000), + # False means the page was still painting when this was taken - the capture + # is kept, but flagged so it is obvious the screen may be incomplete. + "renderSettled": stable, + "hotspots": hotspots, + } + + app_map = T.load_app_map() + T.upsert_screen(app_map, screen) + app_map["metricsSource"] = source + T.save_app_map(app_map) + + result = { + "success": True, + "screenId": screen_id, + "imageUrl": screen["image"], + "dimensions": {"width": image.width, "height": image.height}, + "isLong": screen["isScrollable"], + "route": geo.get("route"), + "scrollStops": geo.get("scrollStops"), + "metricsSource": source, + "hotspots": hotspots, + "screen": screen, + "timestamp": int(time.time() * 1000), + } + with open(T.SCREENS_DIR / (screen_id + "_result.json"), "w", encoding="utf-8") as f: + json.dump(result, f, ensure_ascii=False, indent=2) + return result + + +def main(): + parser = argparse.ArgumentParser(description="Capture a TelecomKz screen and map its hotspots.") + parser.add_argument("screen_id", nargs="?", default="main_dashboard") + parser.add_argument("--name", default=None, help="Human readable screen name") + parser.add_argument("--category", default="Основное") + parser.add_argument("--settle", type=float, default=0.45, help="Seconds to wait after each scroll") + parser.add_argument( + "--wait", + type=float, + default=20.0, + help="Seconds to wait for the page to stop rendering before capturing", + ) + parser.add_argument( + "--native", + action="store_true", + help="Screen has no WebView: map it from uiautomator instead of the DOM", + ) + parser.add_argument( + "--no-scroll", + action="store_true", + help="Capture a single viewport without scrolling (side drawer, modals)", + ) + args = parser.parse_args() + + try: + if args.native: + result = capture_native(args.screen_id, args.name, args.category) + else: + result = capture( + args.screen_id, + args.name, + args.category, + args.settle, + args.no_scroll, + args.wait, + ) + except (T.DeviceError, ValueError) as exc: + print(json.dumps({"success": False, "error": str(exc)}, ensure_ascii=False)) + return 1 + + # stdout is the API contract with the Vite middleware: one JSON object. + print( + json.dumps( + {k: v for k, v in result.items() if k not in ("hotspots", "screen")}, + ensure_ascii=False, + ) + ) + print( + "Captured " + + args.screen_id + + ": " + + str(result["dimensions"]["width"]) + + "x" + + str(result["dimensions"]["height"]) + + ", " + + str(len(result["hotspots"])) + + " hotspots", + file=sys.stderr, + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/cdp_bridge.py b/tools/cdp_bridge.py new file mode 100644 index 0000000..f5ecf98 --- /dev/null +++ b/tools/cdp_bridge.py @@ -0,0 +1,19 @@ +""" +DEPRECATED - Superseded by capture_screen.py. The CDP full-page screenshot it used (Page.captureScreenshot with captureBeyondViewport) mis-renders this app: sticky sections are repainted, so the lower part of the image duplicates the top and the DOM coordinates no longer match the pixels. capture_screen.py stitches real device frames at measured scroll offsets instead. + +Kept so existing commands keep working; it forwards to the replacement. +""" + +import runpy +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +print( + "[deprecated] cdp_bridge.py now runs capture_screen.py - see its --help for options.", + file=sys.stderr, +) + +sys.argv[0] = str(Path(__file__).resolve().parent / "capture_screen.py") +runpy.run_module("capture_screen", run_name="__main__") diff --git a/tools/crawl_and_map_all.py b/tools/crawl_and_map_all.py new file mode 100644 index 0000000..4af3aa3 --- /dev/null +++ b/tools/crawl_and_map_all.py @@ -0,0 +1,202 @@ +""" +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()) diff --git a/tools/crawl_direct_taps.py b/tools/crawl_direct_taps.py new file mode 100644 index 0000000..a55e051 --- /dev/null +++ b/tools/crawl_direct_taps.py @@ -0,0 +1,19 @@ +""" +DEPRECATED - Superseded by crawl_and_map_all.py, which navigates through the DOM and records the Amplitude event each tap really fires. + +Kept so existing commands keep working; it forwards to the replacement. +""" + +import runpy +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +print( + "[deprecated] crawl_direct_taps.py now runs crawl_and_map_all.py - see its --help for options.", + file=sys.stderr, +) + +sys.argv[0] = str(Path(__file__).resolve().parent / "crawl_and_map_all.py") +runpy.run_module("crawl_and_map_all", run_name="__main__") diff --git a/tools/debug_cdp.py b/tools/debug_cdp.py new file mode 100644 index 0000000..09588ec --- /dev/null +++ b/tools/debug_cdp.py @@ -0,0 +1,19 @@ +""" +DEPRECATED - Superseded by inspect_page.py. + +Kept so existing commands keep working; it forwards to the replacement. +""" + +import runpy +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +print( + "[deprecated] debug_cdp.py now runs inspect_page.py - see its --help for options.", + file=sys.stderr, +) + +sys.argv[0] = str(Path(__file__).resolve().parent / "inspect_page.py") +runpy.run_module("inspect_page", run_name="__main__") diff --git a/tools/device_status.py b/tools/device_status.py new file mode 100644 index 0000000..1721427 --- /dev/null +++ b/tools/device_status.py @@ -0,0 +1,57 @@ +""" +One-shot health check for the phone bridge, printed as a single JSON line. + +Answers the question the UI actually needs: is the phone plugged in, is TelecomKz +running, and can we talk to its WebView right now? +""" + +import json +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +import telecom_cdp as T + + +def status(): + report = { + "connected": False, + "device": None, + "screenSize": None, + "appForeground": False, + "webviewReachable": False, + "route": None, + "metricsSource": T.metrics_source(), + "error": None, + } + + try: + device = T.get_device() + except T.DeviceError as exc: + report["error"] = str(exc) + return report + + report["connected"] = True + report["device"] = device + try: + w, h = T.screen_size(device) + report["screenSize"] = {"width": w, "height": h} + report["appForeground"] = T.app_is_foreground(device) + except T.DeviceError as exc: + report["error"] = str(exc) + return report + + try: + _, target = T.connect(device) + report["webviewReachable"] = True + report["appForeground"] = True + report["route"] = target.get("url") + except T.DeviceError as exc: + report["error"] = str(exc) + + return report + + +if __name__ == "__main__": + print(json.dumps(status(), ensure_ascii=False)) diff --git a/tools/fetch_clickhouse.py b/tools/fetch_clickhouse.py new file mode 100644 index 0000000..790e053 --- /dev/null +++ b/tools/fetch_clickhouse.py @@ -0,0 +1,187 @@ +""" +ClickHouse -> public/data/metrics.json + +Writes a labelled document, not a bare array: + + { + "source": "clickhouse" | "demo", + "fetchedAt": "...", "table": "...", "windowDays": 30, + "events": [ { eventKey, totalEvents, uniqueUsers, ... } ] + } + +The `source` label is the point. Every number the simulator shows is tagged with +where it came from, and hotspots whose event key is absent from this file get no +metrics at all rather than a plausible-looking guess. + +Configuration (environment, or a .env file next to this project): + CLICKHOUSE_HOST=http://10.0.0.5:8123 + CLICKHOUSE_USER=default + CLICKHOUSE_PASSWORD=... + CLICKHOUSE_DB=default + CLICKHOUSE_TABLE=amplitude_events +""" + +import argparse +import json +import os +import sys +import urllib.error +import urllib.parse +import urllib.request +from datetime import datetime, timezone +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +import telecom_cdp as T + + +def load_dotenv(): + env_path = T.PROJECT_ROOT / ".env" + if not env_path.exists(): + return + for line in env_path.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, _, value = line.partition("=") + os.environ.setdefault(key.strip(), value.strip().strip('"').strip("'")) + + +load_dotenv() + +HOST = os.getenv("CLICKHOUSE_HOST", "") +USER = os.getenv("CLICKHOUSE_USER", "default") +PASSWORD = os.getenv("CLICKHOUSE_PASSWORD", "") +DATABASE = os.getenv("CLICKHOUSE_DB", "default") +TABLE = os.getenv("CLICKHOUSE_TABLE", "amplitude_events") + + +class ClickHouseError(RuntimeError): + pass + + +def run_query(sql, params=None, host=None, timeout=60): + """ + Execute SQL over the HTTP interface using ClickHouse server-side parameters + (param_ + {name:Type} placeholders) so no user value is ever pasted + into the query text. + """ + host = host or HOST + if not host: + raise ClickHouseError( + "CLICKHOUSE_HOST is not set. Put it in .env or the environment, e.g. " + "CLICKHOUSE_HOST=http://10.0.0.5:8123" + ) + + query = {"query": sql + " FORMAT JSON", "database": DATABASE} + for key, value in (params or {}).items(): + query["param_" + key] = str(value) + + request = urllib.request.Request( + host.rstrip("/") + "/?" + urllib.parse.urlencode(query), + headers={"X-ClickHouse-User": USER, "X-ClickHouse-Key": PASSWORD}, + ) + try: + with urllib.request.urlopen(request, timeout=timeout) as response: + return json.loads(response.read().decode("utf-8")).get("data", []) + except urllib.error.HTTPError as exc: + raise ClickHouseError( + "ClickHouse returned HTTP " + str(exc.code) + ": " + exc.read().decode("utf-8", "replace")[:400] + ) from exc + except Exception as exc: + raise ClickHouseError("Cannot reach ClickHouse at " + host + ": " + str(exc)) from exc + + +def fetch_event_metrics(table=None, window_days=30): + table = table or TABLE + if not table.replace("_", "").replace(".", "").isalnum(): + raise ClickHouseError("Refusing to query a non-identifier table name: " + repr(table)) + + # `shareOfClicks` is each event's share of all click events in the window, so + # the number in the UI has a defined meaning instead of being decorative. + sql = ( + "SELECT event_type AS eventKey," + " count() AS totalEvents," + " uniqExact(user_id) AS uniqueUsers," + " uniqExact(device_id) AS uniqueDevices," + " round(count() / nullIf(uniqExact(user_id), 0), 2) AS avgEventsPerUser," + " round(100 * count() / nullIf(sum(count()) OVER (), 0), 2) AS shareOfClicks" + " FROM " + table + + " WHERE event_time >= now() - INTERVAL {window:UInt32} DAY" + " GROUP BY event_type" + " ORDER BY totalEvents DESC" + ) + return run_query(sql, {"window": window_days}) + + +def fetch_user_session(user_id, table=None, limit=200): + table = table or TABLE + sql = ( + "SELECT event_type AS eventKey, event_time AS timestamp, event_properties AS properties" + " FROM " + table + + " WHERE user_id = {uid:String} OR device_id = {uid:String}" + " ORDER BY event_time ASC LIMIT {lim:UInt32}" + ) + return run_query(sql, {"uid": user_id, "lim": limit}) + + +def write_metrics(events, source, table, window_days): + T.DATA_DIR.mkdir(parents=True, exist_ok=True) + document = { + "source": source, + "fetchedAt": datetime.now(timezone.utc).isoformat(timespec="seconds"), + "table": table, + "windowDays": window_days, + "events": events, + } + tmp = T.METRICS_PATH.with_suffix(".json.tmp") + with open(tmp, "w", encoding="utf-8") as f: + json.dump(document, f, ensure_ascii=False, indent=2) + tmp.replace(T.METRICS_PATH) + return document + + +def main(): + parser = argparse.ArgumentParser(description="Fetch Amplitude event metrics from ClickHouse.") + parser.add_argument("--table", default=TABLE) + parser.add_argument("--days", type=int, default=30) + parser.add_argument("--user", default=None, help="Print one user's event timeline and exit") + args = parser.parse_args() + + if args.user: + try: + for row in fetch_user_session(args.user, args.table): + print(json.dumps(row, ensure_ascii=False)) + except ClickHouseError as exc: + print(json.dumps({"success": False, "error": str(exc)}, ensure_ascii=False)) + return 1 + return 0 + + try: + events = fetch_event_metrics(args.table, args.days) + except ClickHouseError as exc: + print( + json.dumps( + { + "success": False, + "error": str(exc), + "hint": "metrics.json was left untouched; the UI keeps showing its current source label.", + }, + ensure_ascii=False, + ) + ) + return 1 + + doc = write_metrics(events, "clickhouse", args.table, args.days) + print( + json.dumps( + {"success": True, "source": doc["source"], "events": len(events), "table": args.table}, + ensure_ascii=False, + ) + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/hook_amplitude.py b/tools/hook_amplitude.py new file mode 100644 index 0000000..9e5d4a8 --- /dev/null +++ b/tools/hook_amplitude.py @@ -0,0 +1,19 @@ +""" +DEPRECATED - Superseded by monitor_live_events.py, which installs the same connector hook and then actually reads the events back. + +Kept so existing commands keep working; it forwards to the replacement. +""" + +import runpy +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +print( + "[deprecated] hook_amplitude.py now runs monitor_live_events.py - see its --help for options.", + file=sys.stderr, +) + +sys.argv[0] = str(Path(__file__).resolve().parent / "monitor_live_events.py") +runpy.run_module("monitor_live_events", run_name="__main__") diff --git a/tools/inspect_amplitude.py b/tools/inspect_amplitude.py new file mode 100644 index 0000000..58f5120 --- /dev/null +++ b/tools/inspect_amplitude.py @@ -0,0 +1,19 @@ +""" +DEPRECATED - Superseded by inspect_page.py (probe: amplitude). + +Kept so existing commands keep working; it forwards to the replacement. +""" + +import runpy +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +print( + "[deprecated] inspect_amplitude.py now runs inspect_page.py - see its --help for options.", + file=sys.stderr, +) + +sys.argv[0] = str(Path(__file__).resolve().parent / "inspect_page.py") +runpy.run_module("inspect_page", run_name="__main__") diff --git a/tools/inspect_bridge.py b/tools/inspect_bridge.py new file mode 100644 index 0000000..0ec788c --- /dev/null +++ b/tools/inspect_bridge.py @@ -0,0 +1,19 @@ +""" +DEPRECATED - Superseded by inspect_page.py (probe: bridge). + +Kept so existing commands keep working; it forwards to the replacement. +""" + +import runpy +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +print( + "[deprecated] inspect_bridge.py now runs inspect_page.py - see its --help for options.", + file=sys.stderr, +) + +sys.argv[0] = str(Path(__file__).resolve().parent / "inspect_page.py") +runpy.run_module("inspect_page", run_name="__main__") diff --git a/tools/inspect_page.py b/tools/inspect_page.py new file mode 100644 index 0000000..3c181bc --- /dev/null +++ b/tools/inspect_page.py @@ -0,0 +1,142 @@ +""" +Diagnostic probes against the live WebView. + +Consolidates what inspect_amplitude / inspect_bridge / inspect_window / inspect_vue_app / +read_amplitude_events / debug_cdp used to do separately. All of them shared the same +three defects: they assumed an adb forward already existed, they grabbed targets[0] +without checking its type, and they read one WebSocket frame after Page.enable and +treated whatever arrived as the command reply. The shared core fixes all three. + + py tools/inspect_page.py # everything + py tools/inspect_page.py amplitude bridge +""" + +import argparse +import json +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +import telecom_cdp as T + +PROBES = { + "page": """ +(() => ({ + url: location.href, + route: location.pathname, + title: document.title, + innerWidth: window.innerWidth, + innerHeight: window.innerHeight, + devicePixelRatio: window.devicePixelRatio, + scrollHeight: document.documentElement.scrollHeight, + scrollY: window.scrollY, + readyState: document.readyState +}))() +""", + "amplitude": """ +(() => { + const connectors = window.analyticsConnectorInstances || {}; + return { + hasAmplitudeGlobal: typeof window.amplitude !== 'undefined', + connectorInstances: Object.keys(connectors), + connectorShape: Object.keys(connectors).reduce((acc, k) => { + const inst = connectors[k] || {}; + acc[k] = { + keys: Object.keys(inst).slice(0, 20), + hasEventBridge: !!inst.eventBridge, + hasSetEventReceiver: !!(inst.eventBridge && typeof inst.eventBridge.setEventReceiver === 'function') + }; + return acc; + }, {}), + hasYandexMetrika: typeof window.ym === 'function', + hasGtag: typeof window.gtag === 'function' + }; +})() +""", + "bridge": """ +(() => { + const out = {}; + ['AndroidBridge', 'Android', 'Aitu', 'NativeBridge', 'webkit'].forEach(name => { + const obj = window[name]; + if (!obj) return; + const methods = []; + for (const k in obj) { + try { if (typeof obj[k] === 'function') methods.push(k); } catch (e) { /* guarded getter */ } + } + out[name] = methods.slice(0, 60); + }); + return out; +})() +""", + "window": """ +(() => { + const builtin = new Set(Object.getOwnPropertyNames(Object.getPrototypeOf(window))); + const custom = Object.keys(window).filter(k => !builtin.has(k) && !k.startsWith('webkit')); + return { + customGlobals: custom.slice(0, 120), + total: custom.length + }; +})() +""", + "vue": """ +(() => { + const root = document.querySelector('#app') || document.body; + const vue = root && (root.__vue_app__ || root.__vue__); + return { + hasVueApp: !!(root && root.__vue_app__), + hasVueInstance: !!(root && root.__vue__), + version: (window.Vue && window.Vue.version) || (vue && vue.version) || null, + rootId: root ? root.id : null, + routerPath: (window.$nuxt && window.$nuxt.$route && window.$nuxt.$route.path) || location.pathname, + componentClasses: [...new Set([...document.querySelectorAll('[class]')] + .map(e => String(e.className).split(' ')[0]).filter(Boolean))].slice(0, 60) + }; +})() +""", + "storage": """ +(() => { + const pick = store => { + const out = {}; + for (let i = 0; i < store.length; i++) { + const k = store.key(i); + if (!/amplitude|analytics|event|session|device/i.test(k)) continue; + const v = store.getItem(k) || ''; + out[k] = v.length > 400 ? v.slice(0, 400) + '...(truncated)' : v; + } + return out; + }; + return { localStorage: pick(window.localStorage), sessionStorage: pick(window.sessionStorage) }; +})() +""", +} + + +def main(): + parser = argparse.ArgumentParser(description="Probe the TelecomKz WebView.") + parser.add_argument("probes", nargs="*", choices=list(PROBES) + [], default=None) + args = parser.parse_args() + names = args.probes or list(PROBES) + + try: + device, target = T.connect() + except T.DeviceError as exc: + print("Error: " + str(exc)) + return 1 + + print("Device: " + device) + print("Target: " + str(target.get("title")) + " " + str(target.get("url"))) + + with T.CdpSession(target["webSocketDebuggerUrl"]) as session: + for name in names: + print("\n=== " + name + " ===") + try: + value = session.evaluate("JSON.stringify(" + PROBES[name] + ")") + print(json.dumps(json.loads(value), ensure_ascii=False, indent=2)) + except Exception as exc: + print("probe failed: " + str(exc)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/inspect_vue_app.py b/tools/inspect_vue_app.py new file mode 100644 index 0000000..7dc2378 --- /dev/null +++ b/tools/inspect_vue_app.py @@ -0,0 +1,19 @@ +""" +DEPRECATED - Superseded by inspect_page.py (probe: vue). + +Kept so existing commands keep working; it forwards to the replacement. +""" + +import runpy +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +print( + "[deprecated] inspect_vue_app.py now runs inspect_page.py - see its --help for options.", + file=sys.stderr, +) + +sys.argv[0] = str(Path(__file__).resolve().parent / "inspect_page.py") +runpy.run_module("inspect_page", run_name="__main__") diff --git a/tools/inspect_window.py b/tools/inspect_window.py new file mode 100644 index 0000000..c9a33b0 --- /dev/null +++ b/tools/inspect_window.py @@ -0,0 +1,19 @@ +""" +DEPRECATED - Superseded by inspect_page.py (probe: window). + +Kept so existing commands keep working; it forwards to the replacement. +""" + +import runpy +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +print( + "[deprecated] inspect_window.py now runs inspect_page.py - see its --help for options.", + file=sys.stderr, +) + +sys.argv[0] = str(Path(__file__).resolve().parent / "inspect_page.py") +runpy.run_module("inspect_page", run_name="__main__") diff --git a/tools/legacy/README.md b/tools/legacy/README.md new file mode 100644 index 0000000..d18d1d0 --- /dev/null +++ b/tools/legacy/README.md @@ -0,0 +1,16 @@ +# Legacy tools (kept for reference, not used) + +Original versions of scripts that were superseded during the 2026-08-24 audit. +Each had at least one of these defects: + +* hardcoded WebView debug socket `webview_devtools_remote_18732` (the PID changes + every time the app restarts, so they broke on the next launch); +* `targets[0]` without checking the target type; +* one `ws.recv()` after `Page.enable`/`Network.enable` treated as the command reply, + when CDP interleaves events with replies; +* screenshot geometry that did not match the DOM coordinate space used for hotspots; +* synthesised metrics (`1200 + n * 410`) presented as measured analytics. + +The working replacements are `tools/capture_screen.py`, `tools/crawl_and_map_all.py`, +`tools/monitor_live_events.py` and `tools/inspect_page.py`, all built on +`tools/telecom_cdp.py`. diff --git a/tools/legacy/capture_long_screen.py b/tools/legacy/capture_long_screen.py new file mode 100644 index 0000000..0a8d172 --- /dev/null +++ b/tools/legacy/capture_long_screen.py @@ -0,0 +1,124 @@ +""" +TelecomKz Long Screenshot & Multi-screen Capture Script +Captures continuous scroll screenshots via ADB and stitches them cleanly. +""" + +import sys +import subprocess +import time +from pathlib import Path +from PIL import Image +import io + +ADB_PATHS = [ + r"C:\Users\user\AppData\Local\Android\Sdk\platform-tools\adb.exe", + r"C:\Program Files\Netease\MuMuPlayer\nx_main\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 get_devices(): + res = subprocess.run([ADB_BIN, "devices"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + lines = res.stdout.strip().split("\n")[1:] + devices = [] + for line in lines: + parts = line.strip().split("\t") + if len(parts) >= 2 and parts[1] == "device": + devices.append(parts[0]) + return devices + +def capture_single_image(device_id=None): + cmd = [ADB_BIN] + if device_id: + cmd.extend(["-s", device_id]) + cmd.extend(["exec-out", "screencap", "-p"]) + res = subprocess.run(cmd, stdout=subprocess.PIPE) + if res.returncode == 0 and len(res.stdout) > 1000: + return Image.open(io.BytesIO(res.stdout)) + return None + +def capture_long_screenshot(output_path: str, scroll_steps: int = 3, device_id: str = None): + Path(output_path).parent.mkdir(parents=True, exist_ok=True) + images = [] + + print("Capturing top part of screen...") + img1 = capture_single_image(device_id) + if not img1: + print("Failed to capture screen") + return False + images.append(img1) + + w, h = img1.size + # Status bar top ~ 120px, bottom nav bar ~ 200px + top_crop = 250 # Below top bar + bottom_crop = 200 # Above bottom bar + effective_h = h - top_crop - bottom_crop + + for i in range(scroll_steps): + print(f"Scrolling step {i+1}/{scroll_steps}...") + # Swipe up (scroll down) + subprocess.run([ADB_BIN, "shell", "input", "swipe", "540", "1600", "540", "750", "400"]) + time.sleep(1.2) + + img = capture_single_image(device_id) + if img: + images.append(img) + + # If 1 image only + if len(images) == 1: + images[0].save(output_path) + print(f"Single image saved to: {output_path}") + return True + + # Stitch images vertically + # First image: full or crop + total_stitched_height = h + (len(images) - 1) * (effective_h - 100) + stitched = Image.new("RGBA", (w, total_stitched_height), (10, 15, 29, 255)) + + # Paste 1st image + stitched.paste(images[0], (0, 0)) + current_y = h - bottom_crop + + for i in range(1, len(images)): + # Crop the middle content of the scrolled image + crop_box = (0, top_crop, w, h - bottom_crop) + cropped_slice = images[i].crop(crop_box) + stitched.paste(cropped_slice, (0, current_y)) + current_y += cropped_slice.size[1] - 100 + + stitched = stitched.crop((0, 0, w, current_y + bottom_crop)) + stitched.save(output_path, "PNG") + print(f"Long stitched screenshot saved to: {output_path} (Size: {stitched.size[0]}x{stitched.size[1]})") + return True + +def main(): + devices = get_devices() + if not devices: + print("No Android device connected.") + sys.exit(1) + dev = devices[0] + + # Capture single clean screenshot + print("Capturing standard high-res screenshot...") + img = capture_single_image(dev) + if img: + img.save("public/assets/screens/telecomkz_main.png") + print(f"Main screen saved: {img.size[0]}x{img.size[1]}") + + # Also capture stitched long screenshot + print("Capturing stitched long screenshot...") + capture_long_screenshot("public/assets/screens/telecomkz_main_long.png", scroll_steps=2, device_id=dev) + +if __name__ == "__main__": + main() diff --git a/tools/legacy/cdp_bridge.py b/tools/legacy/cdp_bridge.py new file mode 100644 index 0000000..bb721c5 --- /dev/null +++ b/tools/legacy/cdp_bridge.py @@ -0,0 +1,341 @@ +""" +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") diff --git a/tools/legacy/crawl_direct_taps.py b/tools/legacy/crawl_direct_taps.py new file mode 100644 index 0000000..250480a --- /dev/null +++ b/tools/legacy/crawl_direct_taps.py @@ -0,0 +1,156 @@ +import asyncio +import json +import sys +import time +import subprocess +import requests +import websockets +from pathlib import Path +import numpy as np +import cv2 + +if sys.platform == "win32": + try: + sys.stdout.reconfigure(encoding='utf-8') + except Exception: + pass + +ADB_BIN = r"C:\Users\user\AppData\Local\Android\Sdk\platform-tools\adb.exe" + +def capture_screen_direct(name): + res = subprocess.run([ADB_BIN, "exec-out", "screencap", "-p"], stdout=subprocess.PIPE) + screens_dir = Path("public/assets/screens") + screens_dir.mkdir(parents=True, exist_ok=True) + with open(screens_dir / f"{name}.png", "wb") as f: + f.write(res.stdout) + with open(screens_dir / f"{name}_long.png", "wb") as f: + f.write(res.stdout) + return f"/assets/screens/{name}.png" + +async def get_dom_buttons(ws_url): + js = """ + (() => { + const buttons = []; + const scale = 1080 / (window.innerWidth || 384); + const topBarOffset = 255; + + document.querySelectorAll('button, a, [role="button"], [class*="card"], [class*="item"], [class*="btn"]').forEach((el, i) => { + const rect = el.getBoundingClientRect(); + const text = (el.innerText || el.getAttribute('aria-label') || '').trim().replace(/\\n/g, ' '); + + if (rect.width > 20 && rect.height > 15 && text.length > 0 && text.length < 50) { + buttons.push({ + text: text, + rect: { + x: Math.max(0, Math.round(rect.left * scale)), + y: Math.max(0, Math.round(topBarOffset + (rect.top + window.scrollY) * scale)), + width: Math.min(1080, Math.round(rect.width * scale)), + height: Math.round(rect.height * scale) + } + }); + } + }); + return { + route: window.location.pathname, + buttons: buttons + }; + })() + """ + async with websockets.connect(ws_url, max_size=50*1024*1024) as ws: + await ws.send(json.dumps({"id": 1, "method": "Runtime.evaluate", "params": {"expression": js, "returnByValue": True}})) + res = json.loads(await ws.recv()) + return res.get("result", {}).get("result", {}).get("value", {}) + +async def inspect_all_main_routes(): + subprocess.run([ADB_BIN, "forward", "tcp:9222", "localabstract:webview_devtools_remote_18732"]) + pages = requests.get("http://localhost:9222/json/list").json() + ws_url = pages[0]["webSocketDebuggerUrl"] + + # Load current app map + map_file = Path("public/data/telecomkz_app_map.json") + with open(map_file, "r", encoding="utf-8") as f: + app_map = json.load(f) + + screens_map = { s["id"]: s for s in app_map["screens"] } + + # Target screens to capture with direct ADB taps & DOM inspection + # (tap_x, tap_y, screen_id, screen_name, category) + destinations = [ + (160, 1280, "services_screen", "Экран «Мои услуги»", "Услуги"), + (410, 1280, "traffic_screen", "Экран «Трафик»", "Услуги"), + (670, 1280, "payments_screen", "Экран «Платежи»", "Финансы"), + (920, 1280, "orders_screen", "Экран «Заявки»", "Услуги"), + (160, 1490, "music_screen", "Экран «Музыка»", "Развлечения"), + (410, 1490, "qr_screen", "Экран «QR Оплата»", "Финансы"), + (670, 1490, "documents_screen", "Экран «Удостоверение»", "Профиль"), + (920, 1490, "services_catalog", "Каталог сервисов", "Услуги"), + (800, 940, "bonuses_screen", "Экран «Мои бонусы»", "Финансы") + ] + + for tx, ty, sid, sname, scat in destinations: + print(f"\n--- Navigating to {sname} (tap {tx},{ty}) ---") + subprocess.run([ADB_BIN, "shell", "input", "tap", str(tx), str(ty)]) + time.sleep(2.0) + + img_url = capture_screen_direct(sid) + dom_res = await get_dom_buttons(ws_url) + buttons = dom_res.get("buttons", []) + print(f"Captured {len(buttons)} buttons on {sname} (route: {dom_res.get('route')})") + + hotspots = [] + # Back button + hotspots.append({ + "id": f"hs_back_{sid}", + "label": "Назад на Главный", + "rect": { "x": 40, "y": 110, "width": 140, "height": 140 }, + "eventKey": f"{sid.upper()}_BACK_CLICK", + "eventNameRu": f"Назад из «{sname}»", + "category": "navigation", + "targetScreenId": "main_dashboard", + "metrics": { "totalEvents": 4200, "uniqueUsers": 1800, "avgEventsPerUser": 2.3, "shareOfClicks": 28.0 } + }) + + for i, b in enumerate(buttons): + text = b["text"] + ek = "CLICK_" + "".join(c if c.isalnum() else "_" for c in text.upper())[:24] + hotspots.append({ + "id": f"hs_{sid}_{i+1}", + "label": text, + "rect": b["rect"], + "eventKey": ek, + "eventNameRu": f"Нажатие «{text}»", + "category": "action", + "metrics": { + "totalEvents": 1500 + (i * 280), + "uniqueUsers": 600 + (i * 90), + "avgEventsPerUser": 2.5, + "shareOfClicks": 12.0 + } + }) + + screens_map[sid] = { + "id": sid, + "name": sname, + "category": scat, + "image": img_url, + "isScrollable": True, + "viewportWidth": 1080, + "viewportHeight": 2340, + "totalHeight": 2340, + "hotspots": hotspots + } + + # Tap back on phone + subprocess.run([ADB_BIN, "shell", "input", "tap", "90", "180"]) + time.sleep(1.0) + subprocess.run([ADB_BIN, "shell", "input", "keyevent", "4"]) + time.sleep(1.2) + + app_map["screens"] = list(screens_map.values()) + with open(map_file, "w", encoding="utf-8") as f: + json.dump(app_map, f, ensure_ascii=False, indent=2) + + print("\n✅ All screens visited, captured, and written to telecomkz_app_map.json!") + +if __name__ == "__main__": + asyncio.run(inspect_all_main_routes()) diff --git a/tools/legacy/debug_cdp.py b/tools/legacy/debug_cdp.py new file mode 100644 index 0000000..9b1530e --- /dev/null +++ b/tools/legacy/debug_cdp.py @@ -0,0 +1,43 @@ +import asyncio +import json +import websockets +import requests + +async def test_cdp(): + pages = requests.get("http://localhost:9222/json/list").json() + ws_url = pages[0]["webSocketDebuggerUrl"] + print("Connecting to:", ws_url) + + async with websockets.connect(ws_url, max_size=50*1024*1024) as ws: + await ws.send(json.dumps({"id": 1, "method": "Page.enable"})) + print("Page.enable:", await ws.recv()) + + # Check window/document size + await ws.send(json.dumps({ + "id": 2, + "method": "Runtime.evaluate", + "params": { + "expression": "({ width: document.documentElement.scrollWidth, height: document.documentElement.scrollHeight, innerH: window.innerHeight, innerW: window.innerWidth })", + "returnByValue": True + } + })) + print("Doc size:", await ws.recv()) + + # Test screenshot + await ws.send(json.dumps({ + "id": 3, + "method": "Page.captureScreenshot", + "params": { + "format": "png", + "fromSurface": True + } + })) + res = json.loads(await ws.recv()) + print("Shot result keys:", res.keys()) + if "error" in res: + print("Shot error:", res["error"]) + elif "result" in res: + print("Shot result data len:", len(res["result"].get("data", ""))) + +if __name__ == "__main__": + asyncio.run(test_cdp()) diff --git a/tools/legacy/hook_amplitude.py b/tools/legacy/hook_amplitude.py new file mode 100644 index 0000000..11f219b --- /dev/null +++ b/tools/legacy/hook_amplitude.py @@ -0,0 +1,56 @@ +import asyncio +import json +import websockets +import requests + +async def hook_amplitude_events(): + pages = requests.get("http://localhost:9222/json/list").json() + ws_url = pages[0]["webSocketDebuggerUrl"] + + async with websockets.connect(ws_url, max_size=50*1024*1024) as ws: + js = """ + (() => { + window.__liveAmplitudeEvents = window.__liveAmplitudeEvents || []; + + const inst = window.analyticsConnectorInstances && window.analyticsConnectorInstances['$default_instance']; + if (inst && inst.eventBridge) { + const origReceiver = inst.eventBridge.eventReceiver; + inst.eventBridge.setEventReceiver((event) => { + window.__liveAmplitudeEvents.push({ + time: new Date().toISOString(), + event: event + }); + console.log('>>> [AMPLITUDE EVENT CAPTURED]:', JSON.stringify(event)); + if (origReceiver) origReceiver(event); + }); + } + + // Also monitor Yandex Metrika / ym calls if any + const origYm = window.ym; + if (typeof origYm === 'function') { + window.ym = function(...args) { + window.__liveAmplitudeEvents.push({ + time: new Date().toISOString(), + type: 'ym', + args: args + }); + return origYm.apply(this, args); + }; + } + + return { + hooked: true, + existingEventsCount: window.__liveAmplitudeEvents.length + }; + })() + """ + await ws.send(json.dumps({ + "id": 1, + "method": "Runtime.evaluate", + "params": { "expression": js, "returnByValue": True } + })) + res = json.loads(await ws.recv()) + print("Hook setup result:", json.dumps(res.get("result", {}).get("result", {}).get("value", {}), indent=2, ensure_ascii=False)) + +if __name__ == "__main__": + asyncio.run(hook_amplitude_events()) diff --git a/tools/legacy/inspect_amplitude.py b/tools/legacy/inspect_amplitude.py new file mode 100644 index 0000000..94b8de8 --- /dev/null +++ b/tools/legacy/inspect_amplitude.py @@ -0,0 +1,34 @@ +import asyncio +import json +import websockets +import requests + +async def inspect_amplitude(): + pages = requests.get("http://localhost:9222/json/list").json() + ws_url = pages[0]["webSocketDebuggerUrl"] + + async with websockets.connect(ws_url, max_size=50*1024*1024) as ws: + js = """ + (() => { + const res = { + hasAmplitude: typeof window.amplitude !== 'undefined', + hasDataLayer: typeof window.dataLayer !== 'undefined', + hasGtag: typeof window.gtag !== 'undefined', + amplitudeKeys: window.amplitude ? Object.keys(window.amplitude) : [], + localStorageKeys: Object.keys(localStorage).filter(k => k.toLowerCase().includes('amp') || k.toLowerCase().includes('telecom')), + url: window.location.href, + title: document.title + }; + return res; + })() + """ + await ws.send(json.dumps({ + "id": 1, + "method": "Runtime.evaluate", + "params": { "expression": js, "returnByValue": True } + })) + res = json.loads(await ws.recv()) + print("Inspection result:", json.dumps(res.get("result", {}).get("result", {}).get("value", {}), indent=2, ensure_ascii=False)) + +if __name__ == "__main__": + asyncio.run(inspect_amplitude()) diff --git a/tools/legacy/inspect_bridge.py b/tools/legacy/inspect_bridge.py new file mode 100644 index 0000000..5dbcd5a --- /dev/null +++ b/tools/legacy/inspect_bridge.py @@ -0,0 +1,34 @@ +import asyncio +import json +import websockets +import requests + +async def inspect_android_bridge(): + pages = requests.get("http://localhost:9222/json/list").json() + ws_url = pages[0]["webSocketDebuggerUrl"] + + async with websockets.connect(ws_url, max_size=50*1024*1024) as ws: + js = """ + (() => { + const bridgeMethods = []; + if (window.AndroidBridge) { + for (let prop in window.AndroidBridge) { + bridgeMethods.push(prop); + } + } + return { + bridgeMethods: bridgeMethods, + analyticsConnector: window.analyticsConnectorInstances ? Object.keys(window.analyticsConnectorInstances) : [] + }; + })() + """ + await ws.send(json.dumps({ + "id": 1, + "method": "Runtime.evaluate", + "params": { "expression": js, "returnByValue": True } + })) + res = json.loads(await ws.recv()) + print(json.dumps(res.get("result", {}).get("result", {}).get("value", {}), indent=2, ensure_ascii=False)) + +if __name__ == "__main__": + asyncio.run(inspect_android_bridge()) diff --git a/tools/legacy/inspect_vue_app.py b/tools/legacy/inspect_vue_app.py new file mode 100644 index 0000000..6b474a9 --- /dev/null +++ b/tools/legacy/inspect_vue_app.py @@ -0,0 +1,58 @@ +import asyncio +import json +import sys +import websockets +import requests + +if sys.platform == "win32": + try: + sys.stdout.reconfigure(encoding='utf-8') + except Exception: + pass + +async def inspect_vue_app(): + pages = requests.get("http://localhost:9222/json/list").json() + ws_url = pages[0]["webSocketDebuggerUrl"] + + async with websockets.connect(ws_url, max_size=50*1024*1024) as ws: + js = """ + (() => { + const buttons = []; + + document.querySelectorAll('button, a, [role="button"], [class*="card"], [class*="btn"], div[class*="item"], div[class*="action"], [onclick]').forEach((el, i) => { + const rect = el.getBoundingClientRect(); + const text = (el.innerText || el.getAttribute('aria-label') || '').trim(); + + if (rect.width > 20 && rect.height > 15 && text.length > 0) { + buttons.push({ + idx: i, + text: text.slice(0, 50).replace(/\\n/g, ' '), + tag: el.tagName, + cls: el.className, + x: Math.round(rect.left), + y: Math.round(rect.top), + w: Math.round(rect.width), + h: Math.round(rect.height) + }); + } + }); + + return { + url: window.location.href, + route: window.location.pathname, + buttonsCount: buttons.length, + buttons: buttons + }; + })() + """ + await ws.send(json.dumps({ + "id": 1, + "method": "Runtime.evaluate", + "params": { "expression": js, "returnByValue": True } + })) + res = json.loads(await ws.recv()) + val = res.get("result", {}).get("result", {}).get("value", {}) + print(json.dumps(val, indent=2, ensure_ascii=False)) + +if __name__ == "__main__": + asyncio.run(inspect_vue_app()) diff --git a/tools/legacy/inspect_window.py b/tools/legacy/inspect_window.py new file mode 100644 index 0000000..153626b --- /dev/null +++ b/tools/legacy/inspect_window.py @@ -0,0 +1,57 @@ +import asyncio +import json +import websockets +import requests + +async def inspect_window_bridge(): + pages = requests.get("http://localhost:9222/json/list").json() + ws_url = pages[0]["webSocketDebuggerUrl"] + + async with websockets.connect(ws_url, max_size=50*1024*1024) as ws: + js = """ + (() => { + const standardKeys = new Set([ + "window","self","document","name","location","customElements","history","locationbar","menubar", + "personalbar","scrollbars","statusbar","toolbar","status","closed","frames","length","top","opener", + "parent","frameElement","navigator","origin","external","screen","innerWidth","innerHeight", + "scrollX","pageXOffset","scrollY","pageYOffset","visualViewport","screenX","screenY","outerWidth", + "outerHeight","devicePixelRatio","clientInformation","screenLeft","screenTop","defaultStatus", + "defaultstatus","styleMedia","onsearch","isSecureContext","onabort","onblur","oncancel","oncanplay", + "oncanplaythrough","onchange","onclick","onclose","oncontextlost","oncontextmenu","oncontextrestored", + "oncuechange","ondblclick","ondrag","ondragend","ondragenter","ondragleave","ondragover","ondragstart", + "ondrop","ondurationchange","onemptied","onended","onerror","onfocus","onformdata","oninput","oninvalid", + "onkeydown","onkeypress","onkeyup","onload","onloadeddata","onloadedmetadata","onloadstart","onmousedown", + "onmouseenter","onmouseleave","onmousemove","onmouseout","onmouseover","onmouseup","onmousewheel", + "onpause","onplay","onplaying","onprogress","onratechange","onreset","onresize","onscroll","onsecuritypolicyviolation", + "onseeked","onseeking","onselect","onslotchange","onstalled","onsubmit","onsuspend","ontimeupdate","ontoggle", + "onvolumechange","onwaiting","onwebkitanimationend","onwebkitanimationiteration","onwebkitanimationstart", + "onwebkittransitionend","onwheel","onauxclick","ongotpointercapture","onlostpointercapture","onpointerdown", + "onpointermove","onpointerrawupdate","onpointerup","onpointercancel","onpointerover","onpointerout", + "onpointerenter","onpointerleave","onselectstart","onselectionchange","onanimationend","onanimationiteration", + "onanimationstart","ontransitionrun","ontransitionstart","ontransitionend","ontransitioncancel","onafterprint", + "onbeforeprint","onbeforeunload","onhashchange","onlanguagechange","onmessage","onmessageerror","onoffline", + "ononline","onpagehide","onpageshow","onpopstate","onrejectionhandled","onstorage","onunhandledrejection", + "onunload","crossOriginIsolated","scheduler" + ]); + + const customKeys = Object.keys(window).filter(k => !standardKeys.has(k) && !k.startsWith('webkit') && !k.startsWith('on')); + + // Check for network request interception or analytics interceptor + return { + customKeys: customKeys, + angular: typeof window.ng !== 'undefined', + react: typeof window.__REACT_DEVTOOLS_GLOBAL_HOOK__ !== 'undefined', + vue: typeof window.Vue !== 'undefined' + }; + })() + """ + await ws.send(json.dumps({ + "id": 1, + "method": "Runtime.evaluate", + "params": { "expression": js, "returnByValue": True } + })) + res = json.loads(await ws.recv()) + print(json.dumps(res.get("result", {}).get("result", {}).get("value", {}), indent=2, ensure_ascii=False)) + +if __name__ == "__main__": + asyncio.run(inspect_window_bridge()) diff --git a/tools/legacy/read_amplitude_events.py b/tools/legacy/read_amplitude_events.py new file mode 100644 index 0000000..9c808e8 --- /dev/null +++ b/tools/legacy/read_amplitude_events.py @@ -0,0 +1,37 @@ +import asyncio +import json +import websockets +import requests + +async def read_amplitude_storage(): + pages = requests.get("http://localhost:9222/json/list").json() + ws_url = pages[0]["webSocketDebuggerUrl"] + + async with websockets.connect(ws_url, max_size=50*1024*1024) as ws: + js = """ + (() => { + const data = {}; + for (let i = 0; i < localStorage.length; i++) { + const k = localStorage.key(i); + if (k.includes('amplitude')) { + try { + data[k] = JSON.parse(localStorage.getItem(k)); + } catch(e) { + data[k] = localStorage.getItem(k); + } + } + } + return data; + })() + """ + await ws.send(json.dumps({ + "id": 1, + "method": "Runtime.evaluate", + "params": { "expression": js, "returnByValue": True } + })) + res = json.loads(await ws.recv()) + events_data = res.get("result", {}).get("result", {}).get("value", {}) + print(json.dumps(events_data, indent=2, ensure_ascii=False)) + +if __name__ == "__main__": + asyncio.run(read_amplitude_storage()) diff --git a/tools/legacy/stitch_opencv.py b/tools/legacy/stitch_opencv.py new file mode 100644 index 0000000..9563cdc --- /dev/null +++ b/tools/legacy/stitch_opencv.py @@ -0,0 +1,134 @@ +""" +TelecomKz Pixel-Perfect OpenCV Long Screenshot Stitcher +Uses Template Matching to find exact subpixel scroll offsets and preserves fixed top & bottom navigation bars. +""" + +import sys +import subprocess +import time +import io +import json +from pathlib import Path +import numpy as np +import cv2 +from PIL import Image + +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 get_device(): + res = subprocess.run([ADB_BIN, "devices"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + lines = res.stdout.strip().split("\n")[1:] + for line in lines: + parts = line.strip().split("\t") + if len(parts) >= 2 and parts[1] == "device": + return parts[0] + return None + +def capture_frame(dev): + cmd = [ADB_BIN] + if dev: + cmd.extend(["-s", dev]) + cmd.extend(["exec-out", "screencap", "-p"]) + res = subprocess.run(cmd, stdout=subprocess.PIPE) + if res.returncode == 0 and len(res.stdout) > 1000: + arr = np.frombuffer(res.stdout, dtype=np.uint8) + return cv2.imdecode(arr, cv2.IMREAD_UNCHANGED) + return None + +def stitch_perfect_long_screen(screen_id: str = "main_dashboard", scroll_steps: int = 3): + dev = get_device() + if not dev: + print(json.dumps({"success": False, "error": "No Android device connected."})) + return False + + print(f"Connecting to Android device {dev} for pixel-perfect stitching...") + + # 1. Capture Frame 1 (at top of page) + f1 = capture_frame(dev) + if f1 is None: + print("Failed to capture Frame 1") + return False + + h, w = f1.shape[:2] # 2340 x 1080 + top_bar_h = 255 + bottom_nav_y = 2140 + bottom_nav_h = h - bottom_nav_y # 200px + + top_bar = f1[0:top_bar_h, 0:w] + bottom_nav = f1[bottom_nav_y:h, 0:w] + + # Current stitched scrollable canvas starts with Frame 1 middle content + scrollable_canvas = f1[top_bar_h:bottom_nav_y, 0:w] + + # Frames capture loop with template matching + swipes_done = 0 + for step in range(scroll_steps): + # Swipe inside the scrollable window + subprocess.run([ADB_BIN, "-s", dev, "shell", "input", "swipe", "540", "1700", "540", "900", "350"]) + time.sleep(1.0) + swipes_done += 1 + + f_next = capture_frame(dev) + if f_next is None: + break + + f_next_scroll = f_next[top_bar_h:bottom_nav_y, 0:w] + + # Take a 150px template strip from the bottom of current canvas (excluding right edge scrollbar) + template_h = 150 + template_w = w - 80 # ignore right scrollbar zone + template = scrollable_canvas[-template_h:, 20:template_w] + + # Match template in f_next_scroll + res = cv2.matchTemplate(f_next_scroll[:, 20:template_w], template, cv2.TM_CCOEFF_NORMED) + min_val, max_val, min_loc, max_loc = cv2.minMaxLoc(res) + + print(f"Step {step+1}: Match confidence = {max_val:.3f}, matched_y = {max_loc[1]}") + + if max_val > 0.70: + match_y = max_loc[1] + # New unique content is from match_y + template_h to bottom + append_y = match_y + template_h + if append_y < f_next_scroll.shape[0]: + new_slice = f_next_scroll[append_y:, 0:w] + scrollable_canvas = np.vstack([scrollable_canvas, new_slice]) + else: + print(f"Low match confidence ({max_val:.2f}), using fixed fallback slice") + new_slice = f_next_scroll[800:, 0:w] + scrollable_canvas = np.vstack([scrollable_canvas, new_slice]) + + # Assemble master image: [Top Bar] + [Continuous Scrollable Canvas] + [Bottom Navigation Bar] + master_image = np.vstack([top_bar, scrollable_canvas, bottom_nav]) + + # Save image + screens_dir = Path("public/assets/screens") + screens_dir.mkdir(parents=True, exist_ok=True) + out_path = screens_dir / f"{screen_id}_long.png" + + cv2.imwrite(str(out_path), master_image) + print(f"Flawless Stitched Image saved to: {out_path} (Resolution: {master_image.shape[1]}x{master_image.shape[0]})") + + # Scroll back up to initial position + for _ in range(swipes_done): + subprocess.run([ADB_BIN, "-s", dev, "shell", "input", "swipe", "540", "900", "540", "1700", "250"]) + time.sleep(0.3) + + return master_image.shape[1], master_image.shape[0] + +if __name__ == "__main__": + stitch_perfect_long_screen("main_dashboard", scroll_steps=2) diff --git a/tools/live_auto_recorder.py b/tools/live_auto_recorder.py new file mode 100644 index 0000000..421b039 --- /dev/null +++ b/tools/live_auto_recorder.py @@ -0,0 +1,119 @@ +""" +Live WebView -> app map sync. + +Re-reads the DOM of whatever the phone is currently showing and refreshes the +hotspots of one screen, without re-taking the screenshot. Cheap enough to poll. + +Two things it deliberately does NOT do: + * it never touches a screen other than the one it was asked for (the previous + version ignored argv and rewrote main_dashboard on every call); + * it never rewrites the screen's totalHeight to the DOM document height. The + hotspot grid belongs to the stored screenshot; if the page has grown taller + than the last capture, that is reported so the UI can ask for a re-capture + instead of silently producing coordinates that point off the image. +""" + +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 DOM_ELEMENTS_JS, build_hotspots + + +def sync_screen(screen_id): + T.validate_screen_id(screen_id) + device, target = T.connect() + layout = T.probe_native_layout(device) + catalog = T.load_metrics_catalog() + source = T.metrics_source() + + with T.CdpSession(target["webSocketDebuggerUrl"]) as session: + page = json.loads( + session.evaluate( + "JSON.stringify({route: location.pathname, innerWidth: window.innerWidth," + " innerHeight: window.innerHeight, scrollHeight: Math.max(" + "document.documentElement.scrollHeight, document.body ? document.body.scrollHeight : 0)})" + ) + ) + dom = json.loads(session.evaluate("JSON.stringify(" + DOM_ELEMENTS_JS + ")")) + + app_map = T.load_app_map() + existing = next((s for s in app_map.get("screens", []) if s["id"] == screen_id), None) + + scale = layout["screenWidth"] / max(page["innerWidth"], 1) + wv_top = layout["webviewTop"] + band_h = layout["webviewBottom"] - wv_top + nav_h = layout["screenHeight"] - layout["bottomNavTop"] + + # Trust the stored screenshot's geometry when we have one; the overlay is drawn + # on that image, so its height is the authority. + if existing and existing.get("totalHeight"): + total_h = existing["totalHeight"] + content_h = total_h - wv_top - nav_h + else: + content_h = max(round(page["scrollHeight"] * scale), band_h) + total_h = wv_top + content_h + nav_h + + geo = { + "scale": scale, + "webviewTop": wv_top, + "contentHeight": content_h, + "navHeight": nav_h, + "totalHeight": total_h, + } + hotspots = build_hotspots(dom.get("elements", []), layout, geo, catalog, source) + + expected_content_h = max(round(page["scrollHeight"] * scale), band_h) + stale = abs(expected_content_h - content_h) > 24 + + screen = dict(existing) if existing else { + "id": screen_id, + "name": "Экран " + screen_id, + "category": "Основное", + "image": "/assets/screens/" + screen_id + "_long.png", + "viewportWidth": layout["screenWidth"], + "viewportHeight": layout["screenHeight"], + "totalHeight": total_h, + "isScrollable": total_h > layout["screenHeight"], + } + screen["hotspots"] = hotspots + screen["route"] = page.get("route") + screen["syncedAt"] = int(time.time() * 1000) + screen["screenshotStale"] = stale + + T.upsert_screen(app_map, screen) + app_map["metricsSource"] = source + T.save_app_map(app_map) + + return { + "success": True, + "screenId": screen_id, + "route": page.get("route"), + "hotspotCount": len(hotspots), + "screenshotStale": stale, + "metricsSource": source, + } + + +def main(): + parser = argparse.ArgumentParser(description="Sync one screen's hotspots from the live WebView.") + parser.add_argument("screen_id", nargs="?", default="main_dashboard") + args = parser.parse_args() + + try: + result = sync_screen(args.screen_id) + except (T.DeviceError, ValueError) as exc: + print(json.dumps({"success": False, "error": str(exc)}, ensure_ascii=False)) + return 1 + + print(json.dumps(result, ensure_ascii=False)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/map_screen_controls.py b/tools/map_screen_controls.py new file mode 100644 index 0000000..df401f3 --- /dev/null +++ b/tools/map_screen_controls.py @@ -0,0 +1,468 @@ +""" +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()) diff --git a/tools/monitor_live_events.py b/tools/monitor_live_events.py new file mode 100644 index 0000000..1c146e2 --- /dev/null +++ b/tools/monitor_live_events.py @@ -0,0 +1,140 @@ +""" +Watch TelecomKz analytics events fire in real time while you tap the phone. + +This is the fastest way to learn a button's true event key: the Amplitude export and +the ClickHouse stream lag by an hour or more, but the app sends the event the instant +you tap, and this reads it off the wire. + + py tools/monitor_live_events.py --seconds 120 + py tools/monitor_live_events.py --seconds 120 --map main_dashboard + +With --map, every observed key is written onto the hotspot whose caption matched the +tap, with keyConfidence "observed" - the only confidence level in this project that +means the key is real rather than derived from a button label. +""" + +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 amplitude_hook import DRAIN_JS, INSTALL_HOOK_JS + + +def stamp_observed(screen_id, pairs): + """Write observed event keys onto matching hotspots of one screen.""" + if not pairs: + 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", []): + label = (hs.get("label") or "").strip().lower() + if not label: + continue + for text, key, _via in pairs: + if text and (text in label or label in text): + hs["eventKey"] = key + hs["keyConfidence"] = "observed" + T.attach_metrics(hs, catalog, source) + updated += 1 + break + if updated: + T.save_app_map(app_map) + return updated + + +def monitor(seconds, map_screen_id=None, poll=0.6): + device, target = T.connect() + print("Connected to " + str(target.get("url"))) + + with T.CdpSession(target["webSocketDebuggerUrl"]) as session: + hook = json.loads(session.evaluate("JSON.stringify(" + INSTALL_HOOK_JS + ")")) + print("Hooked transports: " + ", ".join(hook.get("transports") or ["(none)"])) + if hook.get("already"): + print("(hook was already installed from an earlier run)") + session.evaluate(DRAIN_JS) + + print("\nTap buttons on the phone now. Listening for " + str(seconds) + "s...\n") + deadline = time.time() + seconds + pending_click = None + pairs = [] + seen_keys = {} + + while time.time() < deadline: + time.sleep(poll) + try: + batch = session.evaluate(DRAIN_JS) or [] + except T.DeviceError as exc: + # Keep going to the summary: events already observed are the whole + # point of the run and must not be thrown away with the connection. + print("\nLost the page: " + str(exc)) + print("Reporting what was captured before the disconnect.") + break + + for item in batch: + kind = item.get("kind") + if kind == "click": + pending_click = (item.get("text") or "").strip() + print(" [tap] " + (pending_click or "(no caption)") + " route=" + str(item.get("route"))) + elif kind in ("amplitude", "connector", "metrika-goal"): + key = item.get("eventType") + if not key: + continue + tag = {"amplitude": "AMPLITUDE", "connector": "CONNECTOR", "metrika-goal": "metrika "}[kind] + print(" [" + tag + "] " + key + (" <- " + pending_click if pending_click else "")) + seen_keys[key] = seen_keys.get(key, 0) + 1 + # Amplitude's own payload is authoritative. The Metrika goal name + # is identical to event_type (verified on matching taps), so it is + # accepted as a fallback when the Amplitude body cannot be read. + if kind in ("amplitude", "connector", "metrika-goal") and pending_click: + pairs.append((pending_click.lower(), key, kind)) + pending_click = None + + print("\n--- summary ---") + if not seen_keys: + print("No analytics events observed. Did you tap anything on the phone?") + for key, count in sorted(seen_keys.items(), key=lambda kv: -kv[1]): + print(" " + str(count) + "x " + key) + + print("\nTap -> event pairs: " + str(len(pairs))) + for text, key, via in pairs: + print(" " + key + " <- " + text + " [" + via + "]") + + if map_screen_id and pairs: + updated = stamp_observed(map_screen_id, pairs) + print("\nWrote " + str(updated) + " observed keys into screen " + map_screen_id + ".") + elif map_screen_id: + print("\nNothing to write into " + map_screen_id + ".") + + +def main(): + parser = argparse.ArgumentParser(description="Live analytics event monitor for TelecomKz.") + parser.add_argument("--seconds", type=int, default=60) + parser.add_argument( + "--map", + dest="map_screen_id", + default=None, + help="Write observed keys onto this screen's hotspots in the app map", + ) + args = parser.parse_args() + try: + monitor(args.seconds, args.map_screen_id) + except T.DeviceError as exc: + print("Error: " + str(exc)) + return 1 + except KeyboardInterrupt: + print("\nStopped.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/read_amplitude_events.py b/tools/read_amplitude_events.py new file mode 100644 index 0000000..ea26e9c --- /dev/null +++ b/tools/read_amplitude_events.py @@ -0,0 +1,19 @@ +""" +DEPRECATED - Superseded by inspect_page.py (probe: storage). + +Kept so existing commands keep working; it forwards to the replacement. +""" + +import runpy +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +print( + "[deprecated] read_amplitude_events.py now runs inspect_page.py - see its --help for options.", + file=sys.stderr, +) + +sys.argv[0] = str(Path(__file__).resolve().parent / "inspect_page.py") +runpy.run_module("inspect_page", run_name="__main__") diff --git a/tools/reclassify_hotspots.py b/tools/reclassify_hotspots.py new file mode 100644 index 0000000..a1af904 --- /dev/null +++ b/tools/reclassify_hotspots.py @@ -0,0 +1,140 @@ +""" +Re-apply the event-key rules and the metrics catalog to an already-captured app map. + +Useful when EVENT_RULES changes or after a fresh ClickHouse pull: it fixes keys and +metrics without going near the phone. Hotspots whose key was observed on the wire +(keyConfidence "observed") or set by hand ("manual") are never touched. + + py tools/reclassify_hotspots.py # all screens + py tools/reclassify_hotspots.py --dry-run + py tools/reclassify_hotspots.py --screen main_dashboard +""" + +import argparse +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +import telecom_cdp as T +from capture_screen import classify + +PROTECTED = {"observed", "manual", "dom-attribute"} + + +def reclassify(screen_filter=None, dry_run=False): + app_map = T.load_app_map() + catalog = T.load_metrics_catalog() + source = T.metrics_source() + + changed_keys = 0 + rechecked = 0 + + for screen in app_map.get("screens", []): + if screen_filter and screen["id"] != screen_filter: + continue + for hs in screen.get("hotspots", []): + rechecked += 1 + if hs.get("keyConfidence") in PROTECTED: + T.attach_metrics(hs, catalog, source) + continue + if hs.get("source") == "native-uiautomator": + T.attach_metrics(hs, catalog, source) + continue + if "source" not in hs: + # Legacy row from before provenance was tracked. Its key is of unknown + # origin, so flag it for review rather than overwrite it with another + # caption-derived guess - several would collapse onto the same key. + hs.setdefault("keyConfidence", "guessed") + hs.setdefault("source", "legacy") + T.attach_metrics(hs, catalog, source) + continue + + key, name_ru, target, confidence = classify(hs.get("label") or "") + if key != hs.get("eventKey"): + print( + " " + + screen["id"] + + ": " + + str(hs.get("eventKey")) + + " -> " + + key + + " (" + + (hs.get("label") or "")[:45] + + ")" + ) + changed_keys += 1 + if not dry_run: + hs["eventKey"] = key + hs["eventNameRu"] = name_ru + hs["keyConfidence"] = confidence + if target and not hs.get("targetScreenId"): + hs["targetScreenId"] = target + if not dry_run: + T.attach_metrics(hs, catalog, source) + + # Two hotspots on one screen must not share an event key, and a targetScreenId + # pointing at a screen that was never captured is a dead link in the simulator. + from capture_screen import slugify_event_key, unique_key + + known_ids = {sc["id"] for sc in app_map.get("screens", [])} + deduped = dangling = 0 + for screen in app_map.get("screens", []): + if screen_filter and screen["id"] != screen_filter: + continue + used = set() + for hs in screen.get("hotspots", []): + target = hs.get("targetScreenId") + if target and target not in known_ids: + print(" " + screen["id"] + ": dropping dead link -> " + target) + hs.pop("targetScreenId", None) + dangling += 1 + + original = hs.get("eventKey") or "" + if hs.get("keyConfidence") == "observed": + used.add(original) # never rename a verified key + continue + fixed = unique_key(original or slugify_event_key(hs.get("label") or ""), used) + if fixed != original: + print(" " + screen["id"] + ": duplicate " + original + " -> " + fixed) + hs["eventKey"] = fixed + deduped += 1 + + print("\nDeduplicated keys: " + str(deduped) + ", dead links removed: " + str(dangling)) + + if not dry_run: + app_map["metricsSource"] = source + T.save_app_map(app_map) + + measured = sum( + 1 + for s in app_map.get("screens", []) + for h in s.get("hotspots", []) + if h.get("metrics") + ) + print( + "\nHotspots checked: " + + str(rechecked) + + ", keys changed: " + + str(changed_keys) + + ", with metrics: " + + str(measured) + + " (source: " + + source + + ")" + ) + if dry_run: + print("Dry run - nothing was written.") + + +def main(): + parser = argparse.ArgumentParser(description="Re-apply event rules and metrics to the app map.") + parser.add_argument("--screen", default=None) + parser.add_argument("--dry-run", action="store_true") + args = parser.parse_args() + reclassify(args.screen, args.dry_run) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/rename_screen.py b/tools/rename_screen.py new file mode 100644 index 0000000..6f9a3c9 --- /dev/null +++ b/tools/rename_screen.py @@ -0,0 +1,86 @@ +""" +Rename a captured screen: its id, its image files and its references in the app map. + +Auto-generated ids (scr_*) are placeholders. Giving them meaningful names keeps the +map readable, and any hotspot pointing at the old id is repointed so no link breaks. + + py tools/rename_screen.py scr_x_1 payments_auto --name "Мой автоплатеж" --category Финансы +""" + +import argparse +import json +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +import telecom_cdp as T + + +def rename(old_id, new_id, name=None, category=None): + T.validate_screen_id(old_id) + T.validate_screen_id(new_id) + + app_map = T.load_app_map() + screen = next((s for s in app_map.get("screens", []) if s["id"] == old_id), None) + if screen is None: + raise ValueError("No screen with id " + old_id) + if any(s["id"] == new_id for s in app_map["screens"]): + raise ValueError("A screen named " + new_id + " already exists") + + for suffix in ("_long.png", ".png", "_result.json"): + src = T.SCREENS_DIR / (old_id + suffix) + if src.exists(): + src.replace(T.SCREENS_DIR / (new_id + suffix)) + + screen["id"] = new_id + screen["image"] = screen["image"].replace(old_id, new_id) + if name: + screen["name"] = name + if category: + screen["category"] = category + + repointed = 0 + for other in app_map["screens"]: + for hs in other.get("hotspots", []): + if hs.get("targetScreenId") == old_id: + hs["targetScreenId"] = new_id + repointed += 1 + + T.save_app_map(app_map) + + result_path = T.SCREENS_DIR / (new_id + "_result.json") + if result_path.exists(): + try: + data = json.loads(result_path.read_text(encoding="utf-8")) + data["screenId"] = new_id + data["imageUrl"] = data.get("imageUrl", "").replace(old_id, new_id) + if isinstance(data.get("screen"), dict): + data["screen"]["id"] = new_id + data["screen"]["image"] = data["screen"].get("image", "").replace(old_id, new_id) + result_path.write_text( + json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8" + ) + except Exception as exc: + print("warning: could not rewrite result JSON: " + str(exc)) + + print(old_id + " -> " + new_id + " (links repointed: " + str(repointed) + ")") + + +def main(): + parser = argparse.ArgumentParser(description="Rename a captured screen.") + parser.add_argument("old_id") + parser.add_argument("new_id") + parser.add_argument("--name", default=None) + parser.add_argument("--category", default=None) + args = parser.parse_args() + try: + rename(args.old_id, args.new_id, args.name, args.category) + except ValueError as exc: + print("Error: " + str(exc)) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/stitch_opencv.py b/tools/stitch_opencv.py new file mode 100644 index 0000000..206cdd0 --- /dev/null +++ b/tools/stitch_opencv.py @@ -0,0 +1,19 @@ +""" +DEPRECATED - Superseded by capture_screen.py. Template-matching produced a canvas of unpredictable height that never matched the DOM coordinate space, and it silently fell back to a fixed 750px offset whenever matching failed. + +Kept so existing commands keep working; it forwards to the replacement. +""" + +import runpy +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +print( + "[deprecated] stitch_opencv.py now runs capture_screen.py - see its --help for options.", + file=sys.stderr, +) + +sys.argv[0] = str(Path(__file__).resolve().parent / "capture_screen.py") +runpy.run_module("capture_screen", run_name="__main__") diff --git a/tools/telecom_cdp.py b/tools/telecom_cdp.py new file mode 100644 index 0000000..248ebcf --- /dev/null +++ b/tools/telecom_cdp.py @@ -0,0 +1,664 @@ +""" +Shared TelecomKz automation core. + +Everything the capture/crawl/live tools need in one place: + * ADB discovery + device selection + * dynamic WebView DevTools socket discovery (no hardcoded PID) + * a CDP client that matches responses to request ids (CDP events interleave + with command replies, so a bare recv() is not the answer to your command) + * native layout probing via uiautomator (real WebView / bottom-nav bounds) + * the single coordinate convention used by the whole project + +Coordinate convention +--------------------- +All hotspot rects live in "stitched screen space": the same pixel grid as the +stitched long screenshot produced by capture_screen.py, which is + + [ native top bar : rows 0 .. webview_top ] + [ full WebView content: webview_top .. webview_top + H ] + [ native bottom nav : the last nav_h rows ] + +so a DOM element at document offset (left, top + scrollY) maps to + + x = round(left * scale) + y = webview_top + round((top + scrollY) * scale) with scale = screen_w / innerWidth + +That identity is what makes the overlay line up. The content band height must be +measured from the device, never assumed. +""" + +import json +import re +import subprocess +import sys +import xml.etree.ElementTree as ET +from pathlib import Path + +if sys.platform == "win32": + try: + sys.stdout.reconfigure(encoding="utf-8") + sys.stderr.reconfigure(encoding="utf-8") + except Exception: + pass + +APP_PACKAGE = "kz.telecom.app" +WEBVIEW_HOST = "customer.telecom.kz" +CDP_PORT = 9222 + +# Project root = parent of tools/. Every path is resolved against it so the +# tools work regardless of the caller's working directory. +PROJECT_ROOT = Path(__file__).resolve().parent.parent +SCREENS_DIR = PROJECT_ROOT / "public" / "assets" / "screens" +DATA_DIR = PROJECT_ROOT / "public" / "data" +APP_MAP_PATH = DATA_DIR / "telecomkz_app_map.json" +METRICS_PATH = DATA_DIR / "metrics.json" + +ADB_CANDIDATES = [ + r"C:\Users\user\AppData\Local\Android\Sdk\platform-tools\adb.exe", + "adb", +] + +_ADB_BIN = None + + +class DeviceError(RuntimeError): + """Raised when the phone / WebView is not reachable.""" + + +# --------------------------------------------------------------------------- adb + + +def find_adb(): + global _ADB_BIN + if _ADB_BIN: + return _ADB_BIN + for candidate in ADB_CANDIDATES: + try: + res = subprocess.run([candidate, "version"], capture_output=True, text=True) + if res.returncode == 0: + _ADB_BIN = candidate + return _ADB_BIN + except Exception: + continue + raise DeviceError("adb.exe not found. Install Android platform-tools or add adb to PATH.") + + +def adb(*args, binary=False, timeout=90, device=None): + cmd = [find_adb()] + if device: + cmd += ["-s", device] + cmd += list(args) + if binary: + return subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=timeout) + return subprocess.run( + cmd, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=timeout + ) + + +def get_device(): + """Return the serial of the connected device, or raise with an actionable message.""" + res = adb("devices") + ready, other = [], [] + for line in res.stdout.strip().splitlines()[1:]: + parts = line.strip().split("\t") + if len(parts) < 2: + continue + if parts[1] == "device": + ready.append(parts[0]) + else: + other.append(parts[0] + " (" + parts[1] + ")") + if not ready: + detail = " Devices seen but not ready: " + ", ".join(other) + "." if other else "" + raise DeviceError( + "No Android device is ready over ADB. Connect the phone by USB, enable USB debugging " + "and confirm the authorisation prompt." + detail + ) + return ready[0] + + +def screen_size(device): + res = adb("shell", "wm", "size", device=device) + m = re.search(r"(\d+)x(\d+)", res.stdout) + if not m: + raise DeviceError("Could not read the device screen size (adb shell wm size).") + return int(m.group(1)), int(m.group(2)) + + +def app_is_foreground(device): + res = adb("shell", "dumpsys", "window", device=device) + return APP_PACKAGE in res.stdout.split("mCurrentFocus=")[-1][:200] + + +def screencap(device): + """Raw PNG bytes of the current screen.""" + res = adb("exec-out", "screencap", "-p", binary=True, device=device) + if res.returncode != 0 or len(res.stdout) < 1000: + raise DeviceError("adb exec-out screencap returned no image.") + return res.stdout + + +# ------------------------------------------------------------------ devtools socket + + +def find_webview_socket(device): + """ + Locate the app's WebView debugging socket. The socket name carries the app PID, + which changes on every app start, so it is always discovered, never hardcoded. + """ + res = adb("shell", "pidof", APP_PACKAGE, device=device) + pid = res.stdout.strip().split(" ")[0] if res.stdout.strip() else "" + probe = adb("shell", "cat", "/proc/net/unix", device=device) + + if pid and ("webview_devtools_remote_" + pid) in probe.stdout: + return "webview_devtools_remote_" + pid + + names = sorted(set(re.findall(r"webview_devtools_remote_\S+", probe.stdout))) + if names: + return names[-1] + + raise DeviceError( + "No WebView debugging socket found. Open " + APP_PACKAGE + " on the phone and make sure " + "WebView debugging is enabled for this build." + ) + + +def forward_devtools(device, port=CDP_PORT): + socket = find_webview_socket(device) + res = adb("forward", "tcp:" + str(port), "localabstract:" + socket, device=device) + if res.returncode != 0: + raise DeviceError("adb forward failed: " + res.stderr.strip()) + return port + + +def list_targets(port=CDP_PORT): + import urllib.request + + with urllib.request.urlopen("http://localhost:" + str(port) + "/json/list", timeout=5) as resp: + return json.loads(resp.read().decode("utf-8")) + + +def pick_page_target(targets): + """The app page, not a service worker and not an ad iframe.""" + pages = [t for t in targets if t.get("type") == "page" and t.get("webSocketDebuggerUrl")] + if not pages: + raise DeviceError("The WebView exposes no debuggable page target.") + for t in pages: + if WEBVIEW_HOST in (t.get("url") or ""): + return t + return pages[0] + + +def screen_is_locked(device): + res = adb("shell", "dumpsys", "window", device=device) + return "mDreamingLockscreen=true" in res.stdout + + +def require_awake(device): + """ + Refuse to capture while the phone is locked. + + A screen that times out mid-session still answers screencap and uiautomator, so a + capture happily records the lock screen and writes it over a real screen's data. + Failing loudly is the only safe behaviour. + """ + if screen_is_locked(device): + raise DeviceError( + "The phone is locked (screen timed out). Unlock it and run the capture again. " + "Tip: Settings > Display > Screen timeout, or `adb shell svc power stayon usb`." + ) + + +def bring_app_to_front(device): + """ + Android freezes cached processes, and a frozen WebView stops answering on its + DevTools socket - the port forward succeeds and every request then times out. + So make sure the app is actually foregrounded before talking to it. + """ + if app_is_foreground(device): + return False + adb( + "shell", + "monkey", + "-p", + APP_PACKAGE, + "-c", + "android.intent.category.LAUNCHER", + "1", + device=device, + ) + import time + + for _ in range(10): + time.sleep(0.5) + if app_is_foreground(device): + return True + raise DeviceError( + "Could not bring " + APP_PACKAGE + " to the foreground. Open the app on the phone manually." + ) + + +def connect(device=None, port=CDP_PORT): + """Full handshake: device -> foreground -> adb forward -> page target.""" + device = device or get_device() + require_awake(device) + bring_app_to_front(device) + forward_devtools(device, port) + try: + targets = list_targets(port) + except Exception as exc: + raise DeviceError( + "The WebView debugger did not answer on port " + + str(port) + + " (" + + type(exc).__name__ + + "). Make sure the TelecomKz screen is open and visible on the phone." + ) from exc + return device, pick_page_target(targets) + + +# ------------------------------------------------------------------------- cdp client + + +class CdpSession: + """ + Minimal synchronous CDP client. + + Command replies are matched by id and CDP events are buffered, so a command + issued after Page.enable / Network.enable still gets its own answer back + instead of swallowing an unrelated event. + """ + + def __init__(self, ws_url, timeout=30): + import websockets.sync.client as ws_client + + self._ws = ws_client.connect(ws_url, max_size=256 * 1024 * 1024, open_timeout=timeout) + self._next_id = 0 + self.events = [] + self.timeout = timeout + self.closed = False + + def __enter__(self): + return self + + def __exit__(self, *exc): + self.close() + + def close(self): + try: + self._ws.close() + except Exception: + pass + + def send(self, method, **params): + """ + Send one command and return its result. + + A page target can vanish mid-run - navigating to another WebView destroys it + and the socket closes. That surfaces as a websockets ConnectionClosed, which + callers should not have to know about, so it is translated into DeviceError: + one exception type means a long-running caller can stop cleanly and still + keep whatever it has already collected. + """ + self._next_id += 1 + msg_id = self._next_id + try: + self._ws.send(json.dumps({"id": msg_id, "method": method, "params": params})) + while True: + msg = json.loads(self._ws.recv(timeout=self.timeout)) + if msg.get("id") == msg_id: + if "error" in msg: + raise DeviceError("CDP " + method + " failed: " + json.dumps(msg["error"])) + return msg.get("result", {}) + if "method" in msg: + self.events.append(msg) + except DeviceError: + raise + except Exception as exc: + self.closed = True + raise DeviceError( + "CDP connection lost during " + method + " (" + type(exc).__name__ + "). " + "The WebView page was probably replaced or closed." + ) from exc + + def drain_events(self, idle=0.05, max_messages=20000): + """ + Read whatever the page has pushed since the last command, then stop. + + `idle` must stay above zero: websockets' sync recv() treats a falsy timeout + as "block forever", so recv(timeout=0) hangs instead of returning empty. + """ + for _ in range(max_messages): + try: + raw = self._ws.recv(timeout=idle) + except Exception: + break + try: + msg = json.loads(raw) + except Exception: + continue + if "method" in msg: + self.events.append(msg) + return self.events + + def evaluate(self, expression, await_promise=False): + res = self.send( + "Runtime.evaluate", + expression=expression, + returnByValue=True, + awaitPromise=await_promise, + ) + if res.get("exceptionDetails"): + desc = res["exceptionDetails"].get("exception", {}).get("description") + raise DeviceError("JS evaluation failed: " + str(desc or res["exceptionDetails"])) + return res.get("result", {}).get("value") + + +def open_session(device=None, port=CDP_PORT): + """Convenience: connect and return (device, target, CdpSession).""" + device, target = connect(device, port) + return device, target, CdpSession(target["webSocketDebuggerUrl"]) + + +# --------------------------------------------------------------- native layout probe + + +def _parse_bounds(text): + m = re.match(r"\[(-?\d+),(-?\d+)\]\[(-?\d+),(-?\d+)\]", text or "") + if not m: + return None + x1, y1, x2, y2 = (int(g) for g in m.groups()) + return {"x": x1, "y": y1, "width": x2 - x1, "height": y2 - y1} + + +def dump_ui_xml(device): + adb("shell", "uiautomator", "dump", "/sdcard/tk_ui_dump.xml", device=device, timeout=120) + res = adb("shell", "cat", "/sdcard/tk_ui_dump.xml", device=device) + xml = res.stdout.strip() + if not xml.startswith(" 0 and rect["height"] > 0: + if cls == "android.webkit.WebView": + webviews.append(rect) + if rid.endswith(":id/bottomNavigation"): + nav_bounds = rect + # The currently selected bottom tab reports clickable="false", so match + # the navigation item ids too or the active tab silently disappears. + is_nav_item = ":id/action_" in rid + if node.attrib.get("clickable") == "true" or is_nav_item: + natives.append( + { + "resourceId": rid, + "text": (node.attrib.get("text") or "").strip(), + "contentDesc": (node.attrib.get("content-desc") or "").strip(), + "className": cls, + "rect": rect, + } + ) + for child in node: + walk(child) + + walk(root) + + if webviews: + outer = max(webviews, key=lambda r: r["width"] * r["height"]) + layout["webviewTop"] = outer["y"] + layout["webviewBottom"] = outer["y"] + outer["height"] + if nav_bounds: + layout["bottomNavTop"] = nav_bounds["y"] + if layout["webviewTop"] is None: + layout["webviewTop"] = round(screen_h * 0.109) + layout["webviewBottom"] = round(screen_h * 0.914) + layout["source"] = "fallback" + if layout["bottomNavTop"] is None: + layout["bottomNavTop"] = layout["webviewBottom"] + + # Keep only native chrome: nodes outside the WebView band. Anything inside it is + # a WebView proxy node, and the DOM gives far better data for those. + top, bottom = layout["webviewTop"], layout["webviewBottom"] + layout["nativeElements"] = [ + el + for el in natives + if el["rect"]["y"] + el["rect"]["height"] <= top + 4 or el["rect"]["y"] >= bottom - 4 + ] + return layout + + +# ------------------------------------------------------------------------ app map I/O + + +def collect_native_elements(device, min_size=24): + """ + Every clickable native control on screen, with a label gathered from its own + text/content-desc or its descendants'. + + probe_native_layout() deliberately returns only the chrome outside the WebView + band, because inside it the DOM is a far better source. That is wrong for screens + with no WebView at all (Музыка, Чаты and the other native tabs), which is what + this is for. + """ + root = dump_ui_xml(device) + if root is None: + return [] + + screen_w, screen_h = screen_size(device) + + def label_of(node): + parts = [] + + def walk(n): + for attr in ("text", "content-desc"): + value = (n.attrib.get(attr) or "").strip() + if value and value not in parts: + parts.append(value) + for child in n: + walk(child) + + walk(node) + return " ".join(parts)[:80] + + elements = [] + seen = set() + + def visit(node): + rect = _parse_bounds(node.attrib.get("bounds", "")) + clickable = node.attrib.get("clickable") == "true" + rid = node.attrib.get("resource-id", "") + if ( + rect + and (clickable or ":id/action_" in rid) + and rect["width"] >= min_size + and rect["height"] >= min_size + # Skip full-screen containers that happen to be clickable. + and not (rect["width"] >= screen_w * 0.98 and rect["height"] >= screen_h * 0.9) + ): + key = (rect["x"], rect["y"], rect["width"], rect["height"]) + if key not in seen: + seen.add(key) + elements.append( + { + "resourceId": rid, + "text": (node.attrib.get("text") or "").strip(), + "contentDesc": (node.attrib.get("content-desc") or "").strip(), + "className": node.attrib.get("class", ""), + "label": label_of(node), + "rect": rect, + } + ) + for child in node: + visit(child) + + visit(root) + return elements + + +def load_json(path, default): + try: + with open(path, "r", encoding="utf-8") as f: + return json.load(f) + except Exception: + return default + + +def load_app_map(): + return load_json( + APP_MAP_PATH, + { + "project": "TelecomKz Mobile Analytics", + "version": "2.0.0", + "defaultScreenId": "main_dashboard", + "screens": [], + }, + ) + + +def save_app_map(app_map): + """Atomic write: a crash mid-write must not leave a truncated map behind.""" + APP_MAP_PATH.parent.mkdir(parents=True, exist_ok=True) + tmp = APP_MAP_PATH.with_suffix(".json.tmp") + with open(tmp, "w", encoding="utf-8") as f: + json.dump(app_map, f, ensure_ascii=False, indent=2) + tmp.replace(APP_MAP_PATH) + + +def load_metrics_catalog(): + """eventKey -> metrics row, from whatever fetch_clickhouse.py last wrote.""" + raw = load_json(METRICS_PATH, []) + rows = raw.get("events", []) if isinstance(raw, dict) else raw + return {r["eventKey"]: r for r in rows if isinstance(r, dict) and r.get("eventKey")} + + +def metrics_source(): + raw = load_json(METRICS_PATH, []) + if isinstance(raw, dict): + return raw.get("source", "demo") + return "demo" + + +def attach_metrics(hotspot, catalog, source): + """ + Attach real metrics if the catalog knows this event key, and leave them out + entirely if it does not. A hotspot with no measurement must never carry an + invented number: the UI renders a explicit "no data" state for a missing + metrics block, which is honest; a synthesised number is not. + """ + row = catalog.get(hotspot.get("eventKey")) + if not row: + hotspot.pop("metrics", None) + hotspot["metricsSource"] = "none" + return hotspot + hotspot["metrics"] = { + "totalEvents": row.get("totalEvents", 0), + "uniqueUsers": row.get("uniqueUsers", 0), + "avgEventsPerUser": row.get("avgEventsPerUser"), + "shareOfClicks": row.get("shareOfClicks"), + "lastUpdated": row.get("lastUpdated"), + } + hotspot["metricsSource"] = source + if not hotspot.get("eventNameRu") and row.get("eventNameRu"): + hotspot["eventNameRu"] = row["eventNameRu"] + return hotspot + + +# Confidence levels that represent real knowledge rather than a caption guess. +# These survive a re-capture; a rule-derived key is disposable and does not. +TRUSTED_CONFIDENCE = ("observed", "manual", "dom-attribute") + + +def _merge_trusted_keys(new_hotspots, old_hotspots): + """ + Carry verified event keys from the previous capture onto the new one. + + A re-capture rebuilds hotspots from scratch, so without this every key that was + confirmed on the wire (keyConfidence "observed") or typed in by an analyst would + be silently replaced by a caption guess - throwing away the only information in + the map that was expensive to obtain. + + Matching is by label, which is the element's own caption and is stable across + captures of the same screen. + """ + trusted = {} + for old in old_hotspots or []: + if old.get("keyConfidence") in TRUSTED_CONFIDENCE: + label = (old.get("label") or "").strip().lower() + if label: + trusted[label] = old + + carried = 0 + for new in new_hotspots: + old = trusted.get((new.get("label") or "").strip().lower()) + if not old: + continue + new["eventKey"] = old["eventKey"] + new["eventNameRu"] = old.get("eventNameRu") or new.get("eventNameRu") + new["keyConfidence"] = old["keyConfidence"] + if old.get("targetScreenId") and not new.get("targetScreenId"): + new["targetScreenId"] = old["targetScreenId"] + carried += 1 + return carried + + +def upsert_screen(app_map, screen): + for i, existing in enumerate(app_map.get("screens", [])): + if existing["id"] == screen["id"]: + # Preserve the human-authored name/category across re-captures. + screen["name"] = existing.get("name") or screen["name"] + screen["category"] = existing.get("category") or screen["category"] + screen["carriedKeys"] = _merge_trusted_keys( + screen.get("hotspots", []), existing.get("hotspots", []) + ) + app_map["screens"][i] = screen + return app_map + app_map.setdefault("screens", []).append(screen) + return app_map + + +SCREEN_ID_RE = re.compile(r"^[A-Za-z0-9_-]{1,64}$") + + +def validate_screen_id(screen_id): + if not SCREEN_ID_RE.match(screen_id or ""): + raise ValueError("Invalid screenId: " + repr(screen_id)) + return screen_id diff --git a/tsconfig.app.json b/tsconfig.app.json new file mode 100644 index 0000000..6830b6f --- /dev/null +++ b/tsconfig.app.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023", "DOM"], + "module": "esnext", + "types": ["vite/client"], + "allowArbitraryExtensions": true, + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/tsconfig.node.json b/tsconfig.node.json new file mode 100644 index 0000000..8455dcb --- /dev/null +++ b/tsconfig.node.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023"], + "types": ["node"], + "skipLibCheck": true, + + /* Bundler mode */ + "module": "nodenext", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["vite.config.ts"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..9e22c86 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,230 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import { execFile } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import type { IncomingMessage, ServerResponse } from 'node:http'; + +const PROJECT_ROOT = process.cwd(); +const APP_MAP_PATH = path.resolve(PROJECT_ROOT, 'public/data/telecomkz_app_map.json'); + +// Screen ids reach the shell as script arguments, so anything that is not a plain +// slug is rejected outright rather than escaped. +const SCREEN_ID_RE = /^[A-Za-z0-9_-]{1,64}$/; + +const PYTHON_BIN = process.platform === 'win32' ? 'py' : 'python3'; + +interface ScriptResult { + code: number; + stdout: string; + stderr: string; +} + +function runPython(script: string, args: string[], timeoutMs: number): Promise { + return new Promise(resolve => { + execFile( + PYTHON_BIN, + [path.join('tools', script), ...args], + { cwd: PROJECT_ROOT, timeout: timeoutMs, maxBuffer: 32 * 1024 * 1024, windowsHide: true }, + (err, stdout, stderr) => { + const code = err && typeof (err as NodeJS.ErrnoException & { code?: number }).code === 'number' + ? ((err as unknown as { code: number }).code) + : err + ? 1 + : 0; + resolve({ code, stdout: stdout || '', stderr: stderr || '' }); + } + ); + }); +} + +function sendJson(res: ServerResponse, status: number, body: unknown) { + res.statusCode = status; + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + res.setHeader('Cache-Control', 'no-store'); + res.end(JSON.stringify(body)); +} + +/** + * The Python tools print exactly one JSON object on stdout as their contract. + * Parse the last JSON-looking line so incidental logging cannot corrupt the reply. + */ +function parseToolOutput(stdout: string): Record | null { + const lines = stdout.split(/\r?\n/).filter(l => l.trim().startsWith('{')); + for (let i = lines.length - 1; i >= 0; i -= 1) { + try { + return JSON.parse(lines[i]); + } catch { + /* keep looking */ + } + } + return null; +} + +function readBody(req: IncomingMessage, limitBytes = 8 * 1024 * 1024): Promise { + return new Promise((resolve, reject) => { + let size = 0; + const chunks: Buffer[] = []; + req.on('data', (chunk: Buffer) => { + size += chunk.length; + if (size > limitBytes) { + reject(new Error('Payload too large')); + req.destroy(); + return; + } + chunks.push(chunk); + }); + req.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8'))); + req.on('error', reject); + }); +} + +function requireScreenId(req: IncomingMessage): string { + const url = new URL(req.url || '', 'http://localhost'); + const screenId = url.searchParams.get('screenId') || 'main_dashboard'; + if (!SCREEN_ID_RE.test(screenId)) { + throw new Error(`Некорректный screenId: ${screenId}`); + } + return screenId; +} + +export default defineConfig({ + plugins: [ + react(), + { + name: 'telecomkz-adb-bridge', + configureServer(server) { + // Serialise device work: ADB screencap, uiautomator and the CDP scroll driver + // all mutate the same phone, and both scripts write the same app-map file. + let deviceQueue: Promise = Promise.resolve(); + const enqueue = (job: () => Promise): Promise => { + const next = deviceQueue.then(job, job); + deviceQueue = next.catch(() => undefined); + return next; + }; + + server.middlewares.use('/api/capture-adb', (req, res) => { + let screenId: string; + let name: string | null; + let category: string; + try { + screenId = requireScreenId(req); + const url = new URL(req.url || '', 'http://localhost'); + name = url.searchParams.get('name'); + category = url.searchParams.get('category') || 'Основное'; + } catch (e) { + sendJson(res, 400, { success: false, error: (e as Error).message }); + return; + } + + const args = [screenId, '--category', category]; + if (name) args.push('--name', name); + + enqueue(async () => { + server.config.logger.info(`[ADB Bridge] capture ${screenId}`); + const out = await runPython('capture_screen.py', args, 180_000); + const parsed = parseToolOutput(out.stdout); + + // Trust the script's own JSON verdict. Reading a leftover *_result.json + // from an earlier run - as the previous version did - reports success for + // a capture that never happened. + if (!parsed || parsed.success !== true) { + sendJson(res, 502, { + success: false, + error: + (parsed && (parsed.error as string)) || + out.stderr.trim() || + `capture_screen.py завершился с кодом ${out.code}` + }); + return; + } + + const resultPath = path.resolve( + PROJECT_ROOT, + `public/assets/screens/${screenId}_result.json` + ); + let full: Record = parsed; + if (fs.existsSync(resultPath)) { + try { + full = JSON.parse(fs.readFileSync(resultPath, 'utf-8')); + } catch (e) { + server.config.logger.warn(`[ADB Bridge] unreadable result JSON: ${e}`); + } + } + if (out.stderr.trim()) server.config.logger.info(`[ADB Bridge] ${out.stderr.trim()}`); + sendJson(res, 200, full); + }).catch(e => sendJson(res, 500, { success: false, error: String(e) })); + }); + + server.middlewares.use('/api/live-sync-webview', (req, res) => { + let screenId: string; + try { + screenId = requireScreenId(req); + } catch (e) { + sendJson(res, 400, { success: false, error: (e as Error).message }); + return; + } + + enqueue(async () => { + const out = await runPython('live_auto_recorder.py', [screenId], 60_000); + const parsed = parseToolOutput(out.stdout); + if (!parsed || parsed.success !== true) { + sendJson(res, 502, { + success: false, + error: + (parsed && (parsed.error as string)) || + out.stderr.trim() || + `live_auto_recorder.py завершился с кодом ${out.code}` + }); + return; + } + let appMap: unknown = null; + try { + appMap = JSON.parse(fs.readFileSync(APP_MAP_PATH, 'utf-8')); + } catch (e) { + server.config.logger.warn(`[Live Sync] unreadable app map: ${e}`); + } + sendJson(res, 200, { ...parsed, appMap, timestamp: Date.now() }); + }).catch(e => sendJson(res, 500, { success: false, error: String(e) })); + }); + + // Persist edits made in the GUI editor. Without this the editor is a scratchpad: + // every change is lost on reload unless the user remembers to export a file. + server.middlewares.use('/api/save-app-map', (req, res) => { + if (req.method !== 'POST') { + sendJson(res, 405, { success: false, error: 'Используйте POST' }); + return; + } + readBody(req) + .then(raw => { + const parsed = JSON.parse(raw); + if (!parsed || !Array.isArray(parsed.screens)) { + throw new Error('Ожидается объект с массивом screens'); + } + fs.mkdirSync(path.dirname(APP_MAP_PATH), { recursive: true }); + const backup = `${APP_MAP_PATH}.bak`; + if (fs.existsSync(APP_MAP_PATH)) fs.copyFileSync(APP_MAP_PATH, backup); + const tmp = `${APP_MAP_PATH}.tmp`; + fs.writeFileSync(tmp, JSON.stringify(parsed, null, 2), 'utf-8'); + fs.renameSync(tmp, APP_MAP_PATH); + sendJson(res, 200, { + success: true, + screens: parsed.screens.length, + savedAt: Date.now() + }); + }) + .catch(e => sendJson(res, 400, { success: false, error: (e as Error).message })); + }); + + // Lets the UI tell "phone unplugged" apart from "server not running". + server.middlewares.use('/api/device-status', (_req, res) => { + enqueue(async () => { + const out = await runPython('device_status.py', [], 45_000); + const parsed = parseToolOutput(out.stdout); + sendJson(res, 200, parsed || { connected: false, error: out.stderr.trim() }); + }).catch(e => sendJson(res, 500, { connected: false, error: String(e) })); + }); + } + } + ] +});