TelecomKz Analytics Mapper: audit fixes, real event keys, 32 mapped screens
Rebuilt the capture and mapping pipeline after an audit found the simulator's data could not be trusted: * Hotspot coordinates never matched the screenshots. Capture now scrolls the page over CDP and pastes each frame at the measured scrollY, so image pixels and DOM coordinates share one grid by construction. * Metrics were synthesised (1200 + n*410) and presented as analytics. Numbers are now attached only when the catalog has a matching row; metrics.json carries a `source` label and the UI says "no data" instead of showing zeros. * Event interception hooked a connector bridge that never fires. The app posts to api.amplitude.com using the legacy form-urlencoded v1 API; the hook now reads event_type off the wire. 36 keys are verified as `observed`. * All device access moved into tools/telecom_cdp.py: dynamic WebView socket discovery (the PID was hardcoded), id-matched CDP, measured native geometry. * Editor edits can now be saved to disk; API failures no longer report success from a stale result file; screenId is no longer interpolated into a shell. Screens went from 7 (with fabricated markup) to 32, all verified: image height equals map height, no out-of-bounds hotspots, no dead links. The id_card screenshot has been manually redacted - it showed a national ID. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
7
.env.example
Normal file
@ -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
|
||||
37
.gitignore
vendored
Normal file
@ -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?
|
||||
8
.oxlintrc.json
Normal file
@ -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 }]
|
||||
}
|
||||
}
|
||||
551
PROJECT_DOCUMENTATION.md
Normal file
@ -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"`.
|
||||
135
README.md
Normal file
@ -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` — границы тулбара и таб-бара измеряются, а не задаются константами.
|
||||
17
index.html
Normal file
@ -0,0 +1,17 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📡</text></svg>" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="Интерактивный симулятор мобильного приложения TelecomKz с визуальным маппингом событий Amplitude и ClickHouse" />
|
||||
<title>TelecomKz Analytics Mapper & Interactive Simulator</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
1359
package-lock.json
generated
Normal file
26
package.json
Normal file
@ -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"
|
||||
}
|
||||
}
|
||||
BIN
public/assets/screens/account_selector_long.png
Normal file
|
After Width: | Height: | Size: 496 KiB |
BIN
public/assets/screens/aitu_music_long.png
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
public/assets/screens/appeals_create_long.png
Normal file
|
After Width: | Height: | Size: 124 KiB |
BIN
public/assets/screens/balance_detail_long.png
Normal file
|
After Width: | Height: | Size: 142 KiB |
BIN
public/assets/screens/bonuses_screen_long.png
Normal file
|
After Width: | Height: | Size: 515 KiB |
BIN
public/assets/screens/connect_service_long.png
Normal file
|
After Width: | Height: | Size: 197 KiB |
BIN
public/assets/screens/detalization_internet_long.png
Normal file
|
After Width: | Height: | Size: 102 KiB |
BIN
public/assets/screens/detalization_mobile_long.png
Normal file
|
After Width: | Height: | Size: 227 KiB |
BIN
public/assets/screens/help_screen_long.png
Normal file
|
After Width: | Height: | Size: 86 KiB |
BIN
public/assets/screens/id_card_long.png
Normal file
|
After Width: | Height: | Size: 73 KiB |
BIN
public/assets/screens/important_docs_long.png
Normal file
|
After Width: | Height: | Size: 76 KiB |
BIN
public/assets/screens/main_dashboard.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
public/assets/screens/main_dashboard_long.png
Normal file
|
After Width: | Height: | Size: 989 KiB |
BIN
public/assets/screens/main_screen.png
Normal file
|
After Width: | Height: | Size: 572 KiB |
613
public/assets/screens/main_screen_elements.json
Normal file
@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
BIN
public/assets/screens/music_screen.png
Normal file
|
After Width: | Height: | Size: 2.0 MiB |
BIN
public/assets/screens/music_screen_long.png
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
BIN
public/assets/screens/orders_screen.png
Normal file
|
After Width: | Height: | Size: 170 KiB |
BIN
public/assets/screens/orders_screen_long.png
Normal file
|
After Width: | Height: | Size: 100 KiB |
BIN
public/assets/screens/payments_auto_long.png
Normal file
|
After Width: | Height: | Size: 106 KiB |
BIN
public/assets/screens/payments_history_long.png
Normal file
|
After Width: | Height: | Size: 77 KiB |
BIN
public/assets/screens/payments_screen.png
Normal file
|
After Width: | Height: | Size: 163 KiB |
BIN
public/assets/screens/payments_screen_long.png
Normal file
|
After Width: | Height: | Size: 96 KiB |
BIN
public/assets/screens/profile_settings_long.png
Normal file
|
After Width: | Height: | Size: 110 KiB |
BIN
public/assets/screens/qr_scan_long.png
Normal file
|
After Width: | Height: | Size: 220 KiB |
BIN
public/assets/screens/screen_1787555219317_long.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
public/assets/screens/service_details_long.png
Normal file
|
After Width: | Height: | Size: 733 KiB |
BIN
public/assets/screens/services_catalog_long.png
Normal file
|
After Width: | Height: | Size: 210 KiB |
BIN
public/assets/screens/services_screen.png
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
BIN
public/assets/screens/services_screen_long.png
Normal file
|
After Width: | Height: | Size: 414 KiB |
BIN
public/assets/screens/side_menu.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
public/assets/screens/side_menu_long.png
Normal file
|
After Width: | Height: | Size: 94 KiB |
BIN
public/assets/screens/tariff_bereket_long.png
Normal file
|
After Width: | Height: | Size: 706 KiB |
BIN
public/assets/screens/tariff_internet_200_long.png
Normal file
|
After Width: | Height: | Size: 441 KiB |
BIN
public/assets/screens/tariff_internet_500_long.png
Normal file
|
After Width: | Height: | Size: 432 KiB |
BIN
public/assets/screens/tariff_keremet_mobile_long.png
Normal file
|
After Width: | Height: | Size: 601 KiB |
BIN
public/assets/screens/tariff_keremet_tv_long.png
Normal file
|
After Width: | Height: | Size: 550 KiB |
BIN
public/assets/screens/tariffs_full_digital_long.png
Normal file
|
After Width: | Height: | Size: 927 KiB |
BIN
public/assets/screens/telecomkz_main.png
Normal file
|
After Width: | Height: | Size: 92 KiB |
665
public/assets/screens/telecomkz_main_elements.json
Normal file
@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
BIN
public/assets/screens/telecomkz_main_long.png
Normal file
|
After Width: | Height: | Size: 171 KiB |
BIN
public/assets/screens/traffic_screen.png
Normal file
|
After Width: | Height: | Size: 2.4 MiB |
BIN
public/assets/screens/traffic_screen_long.png
Normal file
|
After Width: | Height: | Size: 83 KiB |
BIN
public/assets/screens/turbo_landing_long.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
public/assets/screens/tv_plus_long.png
Normal file
|
After Width: | Height: | Size: 2.7 MiB |
99
public/data/metrics.json
Normal file
@ -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": "Поддержка"
|
||||
}
|
||||
]
|
||||
}
|
||||
6291
public/data/telecomkz_app_map.json
Normal file
1
public/favicon.svg
Normal file
|
After Width: | Height: | Size: 9.3 KiB |
24
public/icons.svg
Normal file
@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
184
src/App.css
Normal file
@ -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);
|
||||
}
|
||||
}
|
||||
897
src/App.tsx
Normal file
@ -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<AppMapConfig | null>(null);
|
||||
const [currentScreenId, setCurrentScreenId] = useState<string>('main_dashboard');
|
||||
const [navHistory, setNavHistory] = useState<string[]>([]);
|
||||
const [mode, setMode] = useState<ViewMode>('simulator');
|
||||
const [selectedHotspotId, setSelectedHotspotId] = useState<string | null>(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<number>(() => Date.now());
|
||||
const [metricsCatalog, setMetricsCatalog] = useState<MetricsCatalogEntry[]>([]);
|
||||
const [metricsSource, setMetricsSource] = useState<MetricsSource>('none');
|
||||
const [metricsFetchedAt, setMetricsFetchedAt] = useState<string | null>(null);
|
||||
const [deviceStatus, setDeviceStatus] = useState<DeviceStatus | null>(null);
|
||||
const [isDirty, setIsDirty] = useState(false);
|
||||
const [isSavingMap, setIsSavingMap] = useState(false);
|
||||
const [toastMessage, setToastMessage] = useState<string | null>(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<HTMLInputElement>) => {
|
||||
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 (
|
||||
<div style={{ display: 'flex', height: '100vh', alignItems: 'center', justifyContent: 'center', color: '#00d2ff' }}>
|
||||
Загрузка симулятора TelecomKz...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', minHeight: '100vh' }}>
|
||||
{/* Toast Notification */}
|
||||
{toastMessage && (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
top: '20px',
|
||||
right: '20px',
|
||||
zIndex: 99999,
|
||||
background: 'rgba(13, 20, 38, 0.95)',
|
||||
border: '1px solid #00d2ff',
|
||||
color: '#fff',
|
||||
padding: '12px 20px',
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 10px 30px rgba(0,0,0,0.8), 0 0 20px rgba(0, 210, 255, 0.3)',
|
||||
fontSize: '0.85rem',
|
||||
fontWeight: 600,
|
||||
animation: 'fadeIn 0.2s ease-out'
|
||||
}}>
|
||||
{toastMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* New Screen Creator Modal */}
|
||||
{isNewScreenModalOpen && (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
zIndex: 99998,
|
||||
background: 'rgba(0, 0, 0, 0.75)',
|
||||
backdropFilter: 'blur(8px)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}>
|
||||
<div className="glass-panel" style={{
|
||||
width: '420px',
|
||||
padding: '28px',
|
||||
background: 'rgba(17, 26, 46, 0.95)',
|
||||
border: '1px solid #00d2ff',
|
||||
boxShadow: '0 20px 50px rgba(0, 0, 0, 0.8), 0 0 30px rgba(0, 163, 255, 0.3)'
|
||||
}}>
|
||||
<h3 style={{ fontSize: '1.2rem', fontWeight: 700, color: '#fff', marginBottom: '8px', display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<Camera size={20} color="#00d2ff" /> Снять новый экран с Android
|
||||
</h3>
|
||||
<p style={{ fontSize: '0.8rem', color: '#94a3b8', marginBottom: '20px' }}>
|
||||
Откройте нужный экран на смартфоне (например, «Детализация», «TV+» или «Боковое меню») и укажите название:
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleCreateNewScreenWithAdb}>
|
||||
<div style={{ marginBottom: '14px' }}>
|
||||
<label style={{ fontSize: '0.75rem', color: '#94a3b8', display: 'block', marginBottom: '6px' }}>
|
||||
Название экрана:
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
placeholder="e.g. Экран Детализации"
|
||||
value={newScreenName}
|
||||
onChange={e => 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'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<label style={{ fontSize: '0.75rem', color: '#94a3b8', display: 'block', marginBottom: '6px' }}>
|
||||
Категория:
|
||||
</label>
|
||||
<select
|
||||
value={newScreenCategory}
|
||||
onChange={e => setNewScreenCategory(e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px 14px',
|
||||
background: '#111a2e',
|
||||
border: '1px solid rgba(255,255,255,0.15)',
|
||||
borderRadius: '8px',
|
||||
color: '#fff',
|
||||
fontSize: '0.85rem'
|
||||
}}
|
||||
>
|
||||
<option value="Основное">Основное</option>
|
||||
<option value="Услуги">Услуги</option>
|
||||
<option value="Финансы">Финансы</option>
|
||||
<option value="Развлечения">Развлечения (TV+/Музыка)</option>
|
||||
<option value="Профиль">Профиль</option>
|
||||
<option value="Поддержка">Поддержка</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '10px' }}>
|
||||
<button
|
||||
type="submit"
|
||||
style={{
|
||||
flex: 1,
|
||||
background: 'var(--telecom-gradient)',
|
||||
border: 'none',
|
||||
color: '#fff',
|
||||
padding: '12px',
|
||||
borderRadius: '8px',
|
||||
fontWeight: 700,
|
||||
fontSize: '0.85rem',
|
||||
cursor: 'pointer',
|
||||
boxShadow: '0 4px 15px rgba(0, 163, 255, 0.4)'
|
||||
}}
|
||||
>
|
||||
📸 Снять и добавить экран
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsNewScreenModalOpen(false)}
|
||||
style={{
|
||||
background: 'rgba(255,255,255,0.05)',
|
||||
border: '1px solid rgba(255,255,255,0.1)',
|
||||
color: '#94a3b8',
|
||||
padding: '12px 18px',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Top Header Bar */}
|
||||
<header className="glass-panel" style={{
|
||||
margin: '12px 20px',
|
||||
padding: '12px 24px',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
borderRadius: '16px'
|
||||
}}>
|
||||
{/* Logo & Product Title */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '14px' }}>
|
||||
<div style={{
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
borderRadius: '12px',
|
||||
background: 'var(--telecom-gradient)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '1.2rem',
|
||||
boxShadow: '0 0 20px rgba(0, 163, 255, 0.4)'
|
||||
}}>
|
||||
📡
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<h1 style={{ fontSize: '1.25rem', fontWeight: 800, color: '#fff', letterSpacing: '-0.3px' }}>
|
||||
TelecomKz <span style={{ color: '#00d2ff' }}>Analytics Mapper</span>
|
||||
</h1>
|
||||
<span style={{
|
||||
background: 'rgba(0, 163, 255, 0.15)',
|
||||
color: '#00d2ff',
|
||||
border: '1px solid rgba(0, 163, 255, 0.3)',
|
||||
fontSize: '0.68rem',
|
||||
padding: '2px 8px',
|
||||
borderRadius: '12px',
|
||||
fontWeight: 600
|
||||
}}>
|
||||
v5.0
|
||||
</span>
|
||||
</div>
|
||||
<p style={{ fontSize: '0.75rem', color: '#94a3b8' }}>
|
||||
Визуальная симуляция приложения и маппинг событий Amplitude & ClickHouse
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Global Summary Metric Badges */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
|
||||
{/* Live Auto-Sync Toggle Button */}
|
||||
<button
|
||||
onClick={() => {
|
||||
const next = !isLiveAutoSyncActive;
|
||||
setIsLiveAutoSyncActive(next);
|
||||
showToast(next ? '🔴 Включена авто-синхронизация кликов с телефона!' : '⏸ Авто-синхронизация приостановлена');
|
||||
}}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
background: isLiveAutoSyncActive ? 'rgba(239, 68, 68, 0.2)' : 'rgba(255, 255, 255, 0.05)',
|
||||
border: isLiveAutoSyncActive ? '1px solid #ef4444' : '1px solid rgba(255, 255, 255, 0.1)',
|
||||
padding: '6px 14px',
|
||||
borderRadius: '20px',
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 600,
|
||||
color: isLiveAutoSyncActive ? '#fca5a5' : '#94a3b8',
|
||||
cursor: 'pointer',
|
||||
boxShadow: isLiveAutoSyncActive ? '0 0 15px rgba(239, 68, 68, 0.4)' : undefined,
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
>
|
||||
<Radio size={14} className={isLiveAutoSyncActive ? 'animate-pulse' : ''} />
|
||||
<span>{isLiveAutoSyncActive ? 'Авто-маппинг с телефона (LIVE)' : 'Включить Live Auto-Sync'}</span>
|
||||
</button>
|
||||
|
||||
{/* Device status: distinguishes "phone unplugged" from "server down" */}
|
||||
<button
|
||||
onClick={() => {
|
||||
refreshDeviceStatus().then(() => showToast('Статус телефона обновлен'));
|
||||
}}
|
||||
title={deviceStatus?.error || deviceStatus?.route || 'Проверить подключение'}
|
||||
className="glass-card"
|
||||
style={{
|
||||
padding: '6px 14px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
cursor: 'pointer',
|
||||
border: deviceLive
|
||||
? '1px solid rgba(16, 185, 129, 0.5)'
|
||||
: '1px solid rgba(239, 68, 68, 0.45)'
|
||||
}}
|
||||
>
|
||||
<Usb size={15} color={deviceLive ? '#10b981' : '#ef4444'} />
|
||||
<div style={{ fontSize: '0.75rem', textAlign: 'left' }}>
|
||||
<div style={{ color: '#94a3b8', fontSize: '0.65rem' }}>Телефон</div>
|
||||
<strong style={{ color: deviceLive ? '#10b981' : '#ef4444' }}>
|
||||
{deviceLive
|
||||
? 'WebView готов'
|
||||
: deviceStatus?.connected
|
||||
? 'Нет WebView'
|
||||
: 'Не подключен'}
|
||||
</strong>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Provenance of every number on screen - never leave this implicit */}
|
||||
<div
|
||||
className="glass-card"
|
||||
title={metricsFetchedAt ? `Обновлено: ${metricsFetchedAt}` : undefined}
|
||||
style={{
|
||||
padding: '6px 14px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
border:
|
||||
metricsSource === 'clickhouse'
|
||||
? '1px solid rgba(16, 185, 129, 0.45)'
|
||||
: '1px solid rgba(251, 191, 36, 0.45)'
|
||||
}}
|
||||
>
|
||||
<FlaskConical size={15} color={metricsSource === 'clickhouse' ? '#10b981' : '#fbbf24'} />
|
||||
<div style={{ fontSize: '0.75rem' }}>
|
||||
<div style={{ color: '#94a3b8', fontSize: '0.65rem' }}>Источник метрик</div>
|
||||
<strong style={{ color: metricsSource === 'clickhouse' ? '#10b981' : '#fbbf24' }}>
|
||||
{metricsSource === 'clickhouse' ? 'ClickHouse' : 'Демо-данные'}
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="glass-card" style={{ padding: '6px 14px', display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<Smartphone size={16} color="#00d2ff" />
|
||||
<div style={{ fontSize: '0.75rem' }}>
|
||||
<div style={{ color: '#94a3b8', fontSize: '0.65rem' }}>Экранов</div>
|
||||
<strong style={{ color: '#fff' }}>{appConfig.screens.length}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="glass-card" style={{ padding: '6px 14px', display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<Layers size={16} color="#10b981" />
|
||||
<div style={{ fontSize: '0.75rem' }}>
|
||||
<div style={{ color: '#94a3b8', fontSize: '0.65rem' }}>Размечено кнопок</div>
|
||||
<strong style={{ color: '#10b981' }}>{totalHotspotsAll}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => handleTriggerAdbCapture(currentScreenId)}
|
||||
disabled={isCapturingAdb}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
background: 'linear-gradient(135deg, #0070f3 0%, #00d2ff 100%)',
|
||||
border: 'none',
|
||||
padding: '8px 16px',
|
||||
borderRadius: '20px',
|
||||
fontSize: '0.78rem',
|
||||
fontWeight: 700,
|
||||
color: '#fff',
|
||||
cursor: 'pointer',
|
||||
boxShadow: '0 0 20px rgba(0, 210, 255, 0.4)'
|
||||
}}
|
||||
>
|
||||
<Camera size={15} />
|
||||
<span>{isCapturingAdb ? 'Считываем WebView...' : 'Снять этот экран с ADB'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main Workspace Layout */}
|
||||
<main style={{
|
||||
display: 'flex',
|
||||
flex: 1,
|
||||
padding: '0 20px 20px 20px',
|
||||
gap: '24px',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'flex-start'
|
||||
}}>
|
||||
{/* Left Sidebar */}
|
||||
<Sidebar
|
||||
screens={appConfig.screens}
|
||||
currentScreenId={currentScreenId}
|
||||
onSelectScreen={(id) => {
|
||||
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 */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', position: 'relative' }}>
|
||||
{/* Active Screen Title Banner */}
|
||||
<div style={{
|
||||
marginBottom: '12px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
background: 'rgba(0,0,0,0.4)',
|
||||
padding: '6px 16px',
|
||||
borderRadius: '20px',
|
||||
border: '1px solid rgba(255,255,255,0.06)'
|
||||
}}>
|
||||
<span style={{ fontSize: '0.8rem', color: '#94a3b8' }}>Текущий экран:</span>
|
||||
<strong style={{ fontSize: '0.85rem', color: '#00d2ff' }}>{currentScreen.name}</strong>
|
||||
<span style={{ fontSize: '0.75rem', color: '#64748b' }}>({currentScreen.hotspots.length} зон)</span>
|
||||
</div>
|
||||
|
||||
{currentScreen.screenshotStale && (
|
||||
<div style={{
|
||||
marginBottom: '10px',
|
||||
padding: '7px 14px',
|
||||
borderRadius: '10px',
|
||||
background: 'rgba(251, 191, 36, 0.12)',
|
||||
border: '1px solid rgba(251, 191, 36, 0.35)',
|
||||
color: '#fbbf24',
|
||||
fontSize: '0.73rem',
|
||||
maxWidth: '420px',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
Страница в приложении стала выше сохраненного скриншота — снимите экран заново,
|
||||
иначе часть зон окажется за пределами картинки.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PhoneSimulator
|
||||
screen={currentScreen}
|
||||
allScreens={appConfig.screens}
|
||||
mode={mode}
|
||||
onNavigate={handleNavigate}
|
||||
onBack={handleBack}
|
||||
canGoBack={navHistory.length > 0}
|
||||
selectedHotspotId={selectedHotspotId}
|
||||
onSelectHotspot={setSelectedHotspotId}
|
||||
imageTimestamp={imageTimestamp}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Right Inspector Panel */}
|
||||
{mode === 'editor' ? (
|
||||
<GuiEditor
|
||||
currentScreen={currentScreen}
|
||||
allScreens={appConfig.screens}
|
||||
selectedHotspotId={selectedHotspotId}
|
||||
onSelectHotspot={setSelectedHotspotId}
|
||||
onSaveHotspot={handleSaveHotspot}
|
||||
onDeleteHotspot={handleDeleteHotspot}
|
||||
onAddNewHotspot={handleAddNewHotspot}
|
||||
availableEvents={metricsCatalog}
|
||||
/>
|
||||
) : (
|
||||
/* Simulator & Analytics Overview Inspector */
|
||||
<div className="glass-panel" style={{ width: '380px', maxHeight: '800px', padding: '24px', overflowY: 'auto' }}>
|
||||
<h3 style={{ fontSize: '1.1rem', fontWeight: 700, color: '#fff', marginBottom: '8px', display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<TrendingUp size={18} color="#00d2ff" /> Аналитика экрана
|
||||
</h3>
|
||||
<p style={{ fontSize: '0.75rem', color: '#94a3b8', marginBottom: '18px' }}>
|
||||
События и клики по кнопкам на экране <strong>«{currentScreen.name}»</strong>:
|
||||
</p>
|
||||
|
||||
{/* List of buttons with click percentages */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
||||
{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 (
|
||||
<div
|
||||
key={hs.id}
|
||||
className="glass-card"
|
||||
style={{
|
||||
padding: '12px 14px',
|
||||
cursor: 'pointer',
|
||||
border: hs.id === selectedHotspotId ? '1px solid #00d2ff' : undefined
|
||||
}}
|
||||
onClick={() => {
|
||||
if (hs.targetScreenId) {
|
||||
handleNavigate(hs.targetScreenId);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '6px' }}>
|
||||
<span style={{ fontSize: '0.85rem', fontWeight: 600, color: '#f8fafc' }}>
|
||||
{hs.eventNameRu || hs.label}
|
||||
</span>
|
||||
<span className="badge-metric" style={{ fontSize: '0.68rem' }}>
|
||||
{hs.eventKey}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{hasMetrics ? (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: '0.75rem', color: '#94a3b8' }}>
|
||||
<span>Событий: <strong style={{ color: '#00d2ff', fontFamily: 'var(--font-mono)' }}>{(total ?? 0).toLocaleString('ru-RU')}</strong></span>
|
||||
<span>Уникалов: <strong style={{ color: '#10b981', fontFamily: 'var(--font-mono)' }}>{(uniques ?? 0).toLocaleString('ru-RU')}</strong></span>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ fontSize: '0.72rem', color: '#64748b', fontStyle: 'italic' }}>
|
||||
Нет данных по этому ключу в каталоге метрик
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Progress Bar for Share of Clicks */}
|
||||
{share > 0 && (
|
||||
<div style={{ marginTop: '8px' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.68rem', color: '#64748b', marginBottom: '3px' }}>
|
||||
<span>Доля кликов на экране</span>
|
||||
<span>{share}%</span>
|
||||
</div>
|
||||
<div style={{ width: '100%', height: '5px', background: 'rgba(255,255,255,0.08)', borderRadius: '4px', overflow: 'hidden' }}>
|
||||
<div style={{ width: `${Math.min(share, 100)}%`, height: '100%', background: 'var(--telecom-gradient)', borderRadius: '4px' }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Quick Tip Box */}
|
||||
<div style={{
|
||||
marginTop: '20px',
|
||||
padding: '12px',
|
||||
borderRadius: '10px',
|
||||
background: 'rgba(0, 163, 255, 0.06)',
|
||||
border: '1px solid rgba(0, 163, 255, 0.15)',
|
||||
display: 'flex',
|
||||
gap: '10px'
|
||||
}}>
|
||||
<Info size={18} color="#00d2ff" style={{ flexShrink: 0, marginTop: '2px' }} />
|
||||
<div style={{ fontSize: '0.72rem', color: '#94a3b8', lineHeight: 1.4 }}>
|
||||
<strong style={{ color: '#00d2ff' }}>Совет:</strong> Наведите мышь на любую кнопку телефона для детального тултипа, или переключитесь в режим <strong>«Редактор»</strong> для изменения координат.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
BIN
src/assets/hero.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
1
src/assets/react.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
1
src/assets/vite.svg
Normal file
|
After Width: | Height: | Size: 8.5 KiB |
362
src/components/GuiEditor.tsx
Normal file
@ -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<GuiEditorProps> = ({
|
||||
currentScreen,
|
||||
allScreens,
|
||||
selectedHotspotId,
|
||||
onSelectHotspot,
|
||||
onSaveHotspot,
|
||||
onDeleteHotspot,
|
||||
onAddNewHotspot,
|
||||
availableEvents
|
||||
}) => {
|
||||
const selectedHotspot = currentScreen.hotspots.find(h => h.id === selectedHotspotId);
|
||||
|
||||
const [formData, setFormData] = useState<Hotspot | null>(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 (
|
||||
<div className="glass-panel" style={{ padding: '24px', width: '380px', maxHeight: '800px', overflowY: 'auto' }}>
|
||||
{/* Header */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}>
|
||||
<div>
|
||||
<h3 style={{ fontSize: '1.15rem', fontWeight: 700, color: '#f8fafc', display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<Layers size={18} color="#00d2ff" /> Редактор разметки
|
||||
</h3>
|
||||
<span style={{ fontSize: '0.75rem', color: '#94a3b8' }}>
|
||||
Экран: <strong style={{ color: '#38bdf8' }}>{currentScreen.name}</strong>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onAddNewHotspot}
|
||||
style={{
|
||||
background: 'var(--telecom-gradient)',
|
||||
border: 'none',
|
||||
color: '#fff',
|
||||
padding: '8px 12px',
|
||||
borderRadius: '8px',
|
||||
fontSize: '0.8rem',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
boxShadow: '0 4px 12px rgba(0, 163, 255, 0.3)'
|
||||
}}
|
||||
>
|
||||
<Plus size={14} /> Новая зона
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Hotspots List Selector */}
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<label style={{ fontSize: '0.78rem', color: '#94a3b8', marginBottom: '8px', display: 'block' }}>
|
||||
Интерактивные элементы на этом экране ({currentScreen.hotspots.length}):
|
||||
</label>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px', maxHeight: '180px', overflowY: 'auto' }}>
|
||||
{currentScreen.hotspots.map(hs => (
|
||||
<button
|
||||
key={hs.id}
|
||||
onClick={() => onSelectHotspot(hs.id)}
|
||||
style={{
|
||||
textAlign: 'left',
|
||||
padding: '8px 12px',
|
||||
borderRadius: '8px',
|
||||
border: hs.id === selectedHotspotId ? '1px solid #00d2ff' : '1px solid rgba(255,255,255,0.06)',
|
||||
background: hs.id === selectedHotspotId ? 'rgba(0, 163, 255, 0.15)' : 'rgba(255,255,255,0.02)',
|
||||
color: '#fff',
|
||||
fontSize: '0.8rem',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
>
|
||||
<div style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
<span style={{ fontWeight: 600 }}>{hs.label}</span>
|
||||
<span style={{ display: 'block', fontSize: '0.7rem', color: '#94a3b8', fontFamily: 'var(--font-mono)' }}>
|
||||
{hs.eventKey || 'Без ключа'}
|
||||
</span>
|
||||
</div>
|
||||
<span style={{ fontSize: '0.75rem', color: '#10b981', fontFamily: 'var(--font-mono)' }}>
|
||||
{hs.metrics?.totalEvents ? `${hs.metrics.totalEvents.toLocaleString()} кл.` : '—'}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Selected Hotspot Edit Form */}
|
||||
{formData ? (
|
||||
<form onSubmit={handleSave} style={{ borderTop: '1px solid rgba(255,255,255,0.08)', paddingTop: '16px' }}>
|
||||
<h4 style={{ fontSize: '0.95rem', fontWeight: 600, color: '#00d2ff', marginBottom: '14px', display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
<Zap size={14} /> Настройка выбранной зоны
|
||||
</h4>
|
||||
|
||||
{/* Label */}
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<label style={{ fontSize: '0.75rem', color: '#94a3b8', display: 'block', marginBottom: '4px' }}>
|
||||
Внутреннее название элемента:
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.label}
|
||||
onChange={e => 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'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Event Key with autocomplete / select */}
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<label style={{ fontSize: '0.75rem', color: '#94a3b8', display: 'block', marginBottom: '4px' }}>
|
||||
Amplitude Event Key (Ключ события):
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
list="amplitude-events-list"
|
||||
value={formData.eventKey}
|
||||
onChange={e => {
|
||||
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'
|
||||
}}
|
||||
/>
|
||||
<datalist id="amplitude-events-list">
|
||||
{availableEvents.map(e => (
|
||||
<option key={e.eventKey} value={e.eventKey}>
|
||||
{e.eventNameRu ? `${e.eventNameRu} (${e.totalEvents || 0} событий)` : e.eventKey}
|
||||
</option>
|
||||
))}
|
||||
</datalist>
|
||||
</div>
|
||||
|
||||
{/* Russian Event Name */}
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<label style={{ fontSize: '0.75rem', color: '#94a3b8', display: 'block', marginBottom: '4px' }}>
|
||||
Понятное название (для тултипа):
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.eventNameRu}
|
||||
onChange={e => 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'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Target Screen Navigation */}
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<label style={{ fontSize: '0.75rem', color: '#94a3b8', display: 'block', marginBottom: '4px' }}>
|
||||
Переход на экран при клике:
|
||||
</label>
|
||||
<select
|
||||
value={formData.targetScreenId || ''}
|
||||
onChange={e => handleInputChange('targetScreenId', e.target.value || undefined)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px 12px',
|
||||
background: '#111a2e',
|
||||
border: '1px solid rgba(255,255,255,0.12)',
|
||||
borderRadius: '8px',
|
||||
color: '#fff',
|
||||
fontSize: '0.85rem'
|
||||
}}
|
||||
>
|
||||
<option value="">Без перехода (только фиксация события)</option>
|
||||
{allScreens.map(s => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name} ({s.id})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Coordinates (X, Y, Width, Height) */}
|
||||
<div style={{ marginBottom: '18px' }}>
|
||||
<label style={{ fontSize: '0.75rem', color: '#94a3b8', display: 'block', marginBottom: '6px' }}>
|
||||
Координаты области (px):
|
||||
</label>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr 1fr', gap: '6px' }}>
|
||||
<div>
|
||||
<span style={{ fontSize: '0.65rem', color: '#64748b' }}>X</span>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.rect.x}
|
||||
onChange={e => 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' }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<span style={{ fontSize: '0.65rem', color: '#64748b' }}>Y</span>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.rect.y}
|
||||
onChange={e => 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' }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<span style={{ fontSize: '0.65rem', color: '#64748b' }}>W</span>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.rect.width}
|
||||
onChange={e => 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' }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<span style={{ fontSize: '0.65rem', color: '#64748b' }}>H</span>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.rect.height}
|
||||
onChange={e => 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' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<button
|
||||
type="submit"
|
||||
style={{
|
||||
flex: 1,
|
||||
background: savedSuccess ? '#10b981' : 'var(--telecom-gradient)',
|
||||
border: 'none',
|
||||
color: '#fff',
|
||||
padding: '10px',
|
||||
borderRadius: '8px',
|
||||
fontWeight: 600,
|
||||
fontSize: '0.85rem',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '6px'
|
||||
}}
|
||||
>
|
||||
{savedSuccess ? <Check size={16} /> : <Save size={16} />}
|
||||
{savedSuccess ? 'Сохранено!' : 'Сохранить'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDeleteHotspot(formData.id)}
|
||||
style={{
|
||||
background: 'rgba(244, 63, 94, 0.15)',
|
||||
border: '1px solid rgba(244, 63, 94, 0.4)',
|
||||
color: '#f43f5e',
|
||||
padding: '10px 14px',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
title="Удалить зону"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
padding: '30px 10px',
|
||||
color: '#64748b',
|
||||
border: '1px dashed rgba(255,255,255,0.1)',
|
||||
borderRadius: '10px'
|
||||
}}>
|
||||
<Tag size={24} style={{ marginBottom: '8px', opacity: 0.5 }} />
|
||||
<p style={{ fontSize: '0.82rem' }}>Выберите кнопку на экране или нажмите «Новая зона» для добавления</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
187
src/components/HotspotTooltip.tsx
Normal file
@ -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<HotspotTooltipProps> = ({
|
||||
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 (
|
||||
<div style={style} className="glass-panel">
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '8px', gap: '6px' }}>
|
||||
<span className="badge-metric" style={{ fontSize: '0.72rem', letterSpacing: '0.5px' }}>
|
||||
<Zap size={13} /> {hotspot.eventKey || 'КЛЮЧ НЕ ЗАДАН'}
|
||||
</span>
|
||||
{hasMetrics && typeof m?.shareOfClicks === 'number' ? (
|
||||
<span style={{
|
||||
fontSize: '0.75rem',
|
||||
color: '#10b981',
|
||||
background: 'rgba(16, 185, 129, 0.12)',
|
||||
padding: '2px 8px',
|
||||
borderRadius: '12px',
|
||||
fontWeight: 600,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
whiteSpace: 'nowrap'
|
||||
}}>
|
||||
<TrendingUp size={12} /> {m.shareOfClicks}%
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{isUnverifiedKey && (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
fontSize: '0.7rem',
|
||||
color: '#fbbf24',
|
||||
background: 'rgba(251, 191, 36, 0.1)',
|
||||
border: '1px solid rgba(251, 191, 36, 0.25)',
|
||||
padding: '5px 8px',
|
||||
borderRadius: '8px',
|
||||
marginBottom: '10px'
|
||||
}}>
|
||||
<HelpCircle size={12} />
|
||||
<span>
|
||||
Ключ выведен из подписи кнопки, а не снят с приложения — требует проверки
|
||||
через monitor_live_events.py
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<h4 style={{
|
||||
fontSize: '1.05rem',
|
||||
fontWeight: 700,
|
||||
color: '#f8fafc',
|
||||
marginBottom: '12px',
|
||||
lineHeight: 1.3
|
||||
}}>
|
||||
{hotspot.eventNameRu || hotspot.label}
|
||||
</h4>
|
||||
|
||||
{hasMetrics ? (
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr 1fr',
|
||||
gap: '8px',
|
||||
background: 'rgba(0, 0, 0, 0.25)',
|
||||
padding: '10px',
|
||||
borderRadius: '10px',
|
||||
marginBottom: '12px'
|
||||
}}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<span style={{ fontSize: '0.7rem', color: '#94a3b8', display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||
<MousePointerClick size={12} color="#00d2ff" /> Всего событий
|
||||
</span>
|
||||
<span style={{ fontSize: '1.1rem', fontWeight: 700, color: '#00d2ff', fontFamily: 'var(--font-mono)' }}>
|
||||
{fmt(m?.totalEvents)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<span style={{ fontSize: '0.7rem', color: '#94a3b8', display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||
<Users size={12} color="#10b981" /> Уникальные
|
||||
</span>
|
||||
<span style={{ fontSize: '1.1rem', fontWeight: 700, color: '#10b981', fontFamily: 'var(--font-mono)' }}>
|
||||
{fmt(m?.uniqueUsers)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gridColumn: 'span 2', paddingTop: '4px', borderTop: '1px solid rgba(255,255,255,0.06)' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span style={{ fontSize: '0.72rem', color: '#64748b' }}>Событий на пользователя:</span>
|
||||
<span style={{ fontSize: '0.8rem', fontWeight: 600, color: '#e2e8f0', fontFamily: 'var(--font-mono)' }}>
|
||||
{typeof m?.avgEventsPerUser === 'number'
|
||||
? m.avgEventsPerUser.toFixed(2)
|
||||
: m && m.uniqueUsers
|
||||
? (m.totalEvents / m.uniqueUsers).toFixed(2)
|
||||
: '—'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isDemo && (
|
||||
<div style={{
|
||||
gridColumn: 'span 2',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '5px',
|
||||
fontSize: '0.68rem',
|
||||
color: '#fbbf24'
|
||||
}}>
|
||||
<FlaskConical size={11} /> Демо-значения, не данные ClickHouse
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{
|
||||
background: 'rgba(0, 0, 0, 0.25)',
|
||||
padding: '12px',
|
||||
borderRadius: '10px',
|
||||
marginBottom: '12px',
|
||||
fontSize: '0.75rem',
|
||||
color: '#94a3b8',
|
||||
lineHeight: 1.45
|
||||
}}>
|
||||
<strong style={{ color: '#cbd5e1', display: 'block', marginBottom: '3px' }}>Нет данных</strong>
|
||||
Ключ <code style={{ color: '#38bdf8', fontFamily: 'var(--font-mono)' }}>{hotspot.eventKey}</code> отсутствует
|
||||
в каталоге метрик. Запустите синхронизацию ClickHouse или задайте верный ключ в редакторе.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hotspot.targetScreenId && (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
fontSize: '0.78rem',
|
||||
color: '#38bdf8',
|
||||
background: 'rgba(0, 163, 255, 0.08)',
|
||||
padding: '6px 10px',
|
||||
borderRadius: '8px'
|
||||
}}>
|
||||
<ArrowRight size={13} />
|
||||
<span>Переход на: <strong>{targetScreenName || hotspot.targetScreenId}</strong></span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
371
src/components/PhoneSimulator.tsx
Normal file
@ -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<PhoneSimulatorProps> = ({
|
||||
screen,
|
||||
allScreens,
|
||||
mode,
|
||||
onNavigate,
|
||||
onBack,
|
||||
canGoBack = false,
|
||||
selectedHotspotId,
|
||||
onSelectHotspot,
|
||||
imageTimestamp = 0
|
||||
}) => {
|
||||
const [hoveredHotspot, setHoveredHotspot] = useState<Hotspot | null>(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<HTMLDivElement>(null);
|
||||
const imgRef = useRef<HTMLImageElement>(null);
|
||||
|
||||
// Reset loading state when screen image changes
|
||||
useEffect(() => {
|
||||
setIsLoaded(false);
|
||||
setHasError(false);
|
||||
}, [screen.image, imageTimestamp]);
|
||||
|
||||
const handleImageLoad = (e: React.SyntheticEvent<HTMLImageElement>) => {
|
||||
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 (
|
||||
<div style={{ position: 'relative', display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
|
||||
{/* Device Frame */}
|
||||
<div className="phone-mockup-wrapper">
|
||||
{/* Dynamic Island */}
|
||||
<div className="dynamic-island">
|
||||
<div className="dynamic-island-lens" />
|
||||
<div className="dynamic-island-sensor" />
|
||||
</div>
|
||||
|
||||
{/* Status Bar */}
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '44px',
|
||||
padding: '0 24px',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
zIndex: 90,
|
||||
background: 'linear-gradient(to bottom, rgba(0,0,0,0.7) 0%, transparent 100%)',
|
||||
fontSize: '0.82rem',
|
||||
fontWeight: 600,
|
||||
color: '#fff'
|
||||
}}>
|
||||
<span>{currentTime}</span>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<Wifi size={14} />
|
||||
<Battery size={16} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation Bar (if Back is available) */}
|
||||
{canGoBack && (
|
||||
<button
|
||||
onClick={onBack}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '48px',
|
||||
left: '16px',
|
||||
zIndex: 85,
|
||||
background: 'rgba(0, 0, 0, 0.75)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.25)',
|
||||
color: '#fff',
|
||||
borderRadius: '20px',
|
||||
padding: '6px 14px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
fontSize: '0.8rem',
|
||||
cursor: 'pointer',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.5)'
|
||||
}}
|
||||
>
|
||||
<ArrowLeft size={14} /> Назад
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Scrollable Viewport */}
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className="phone-screen-viewport"
|
||||
>
|
||||
<div style={{ position: 'relative', width: '100%', minHeight: '100%' }}>
|
||||
{/* Loading Indicator */}
|
||||
{!isLoaded && !hasError && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: '#090e1a',
|
||||
color: '#00d2ff',
|
||||
gap: '12px',
|
||||
zIndex: 5
|
||||
}}>
|
||||
<Loader2 size={32} style={{ animation: 'spin 1s linear infinite' }} />
|
||||
<span style={{ fontSize: '0.85rem', color: '#94a3b8' }}>Загрузка скриншота экрана...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Screen Screenshot Image */}
|
||||
<img
|
||||
ref={imgRef}
|
||||
src={imageSrc}
|
||||
alt={screen.name}
|
||||
onLoad={handleImageLoad}
|
||||
onError={() => {
|
||||
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 && (
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
minHeight: '840px',
|
||||
background: 'linear-gradient(180deg, #0d1e3d 0%, #060d1f 100%)',
|
||||
padding: '54px 16px 30px 16px',
|
||||
color: '#fff',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
textAlign: 'center'
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: '2.5rem', marginBottom: '12px' }}>📱</div>
|
||||
<h4 style={{ fontSize: '1rem', fontWeight: 700, color: '#f8fafc', marginBottom: '6px' }}>
|
||||
Скриншот еще не сохранен
|
||||
</h4>
|
||||
<p style={{ fontSize: '0.78rem', color: '#94a3b8', maxWidth: '240px', lineHeight: 1.4 }}>
|
||||
Нажмите «Снять экран с Android (ADB)» в меню слева для мгновенного захвата.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hotspots Interactive Layer */}
|
||||
{isLoaded && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
pointerEvents: 'auto'
|
||||
}}
|
||||
>
|
||||
{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 (
|
||||
<div
|
||||
key={hs.id}
|
||||
className={`hotspot-box ${mode}-mode ${isSelected ? 'selected' : ''}`}
|
||||
style={{
|
||||
left: `${leftPct}%`,
|
||||
top: `${topPct}%`,
|
||||
width: `${widthPct}%`,
|
||||
height: `${heightPct}%`,
|
||||
backgroundColor: heatmapBg,
|
||||
borderColor: mode === 'heatmap' ? 'rgba(255,255,255,0.6)' : undefined,
|
||||
cursor: mode === 'simulator' && !canNavigate ? 'default' : 'pointer'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
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) && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '-20px',
|
||||
left: '0',
|
||||
background: 'rgba(0, 0, 0, 0.85)',
|
||||
color: '#00d2ff',
|
||||
fontSize: '0.65rem',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '4px',
|
||||
whiteSpace: 'nowrap',
|
||||
pointerEvents: 'none',
|
||||
border: '1px solid rgba(0, 210, 255, 0.4)',
|
||||
zIndex: 30
|
||||
}}
|
||||
>
|
||||
{hs.eventKey || hs.label}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Heatmap Event Count Bubble */}
|
||||
{mode === 'heatmap' && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 700,
|
||||
color: '#fff',
|
||||
textShadow: '0 1px 4px rgba(0,0,0,0.9)',
|
||||
fontFamily: 'var(--font-mono)'
|
||||
}}
|
||||
>
|
||||
{typeof hs.metrics?.totalEvents === 'number'
|
||||
? hs.metrics.totalEvents.toLocaleString('ru-RU')
|
||||
: '—'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Home Indicator Bar */}
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
bottom: '8px',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
width: '130px',
|
||||
height: '4px',
|
||||
background: 'rgba(255, 255, 255, 0.4)',
|
||||
borderRadius: '4px',
|
||||
zIndex: 90,
|
||||
pointerEvents: 'none'
|
||||
}} />
|
||||
</div>
|
||||
|
||||
{/* Floating Analytics Tooltip */}
|
||||
{hoveredHotspot && (
|
||||
<HotspotTooltip
|
||||
hotspot={hoveredHotspot}
|
||||
position={tooltipPos}
|
||||
targetScreenName={getTargetScreenName(hoveredHotspot.targetScreenId)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
397
src/components/Sidebar.tsx
Normal file
@ -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<HTMLInputElement>) => 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<SidebarProps> = ({
|
||||
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 (
|
||||
<div className="glass-panel" style={{
|
||||
width: '350px',
|
||||
height: 'calc(100vh - 110px)',
|
||||
minHeight: '520px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
padding: '20px',
|
||||
gap: '16px'
|
||||
}}>
|
||||
{/* Mode Switcher Buttons */}
|
||||
<div>
|
||||
<label style={{ fontSize: '0.75rem', color: '#94a3b8', textTransform: 'uppercase', letterSpacing: '0.5px', marginBottom: '8px', display: 'block' }}>
|
||||
Режим просмотра:
|
||||
</label>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr 1fr 1fr',
|
||||
gap: '6px',
|
||||
background: 'rgba(0,0,0,0.3)',
|
||||
padding: '4px',
|
||||
borderRadius: '10px',
|
||||
border: '1px solid rgba(255,255,255,0.06)'
|
||||
}}>
|
||||
<button
|
||||
onClick={() => onSetMode('simulator')}
|
||||
style={{
|
||||
background: mode === 'simulator' ? 'var(--telecom-gradient)' : 'transparent',
|
||||
border: 'none',
|
||||
color: mode === 'simulator' ? '#fff' : '#94a3b8',
|
||||
padding: '8px 4px',
|
||||
borderRadius: '8px',
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
>
|
||||
<Smartphone size={15} /> Симулятор
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => onSetMode('heatmap')}
|
||||
style={{
|
||||
background: mode === 'heatmap' ? 'linear-gradient(135deg, #f59e0b 0%, #f43f5e 100%)' : 'transparent',
|
||||
border: 'none',
|
||||
color: mode === 'heatmap' ? '#fff' : '#94a3b8',
|
||||
padding: '8px 4px',
|
||||
borderRadius: '8px',
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
>
|
||||
<Flame size={15} /> Тепловая
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => onSetMode('editor')}
|
||||
style={{
|
||||
background: mode === 'editor' ? 'linear-gradient(135deg, #10b981 0%, #059669 100%)' : 'transparent',
|
||||
border: 'none',
|
||||
color: mode === 'editor' ? '#fff' : '#94a3b8',
|
||||
padding: '8px 4px',
|
||||
borderRadius: '8px',
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
>
|
||||
<Layers size={15} /> Редактор
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search Input */}
|
||||
<div style={{ position: 'relative' }}>
|
||||
<Search size={14} color="#64748b" style={{ position: 'absolute', left: '12px', top: '50%', transform: 'translateY(-50%)' }} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Поиск экранов или событий..."
|
||||
value={searchQuery}
|
||||
onChange={e => 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'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Screen Selector List */}
|
||||
<div style={{ flex: 1, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: '6px' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '4px' }}>
|
||||
<label style={{ fontSize: '0.72rem', color: '#64748b', textTransform: 'uppercase', letterSpacing: '0.5px' }}>
|
||||
Экраны ({filteredScreens.length}):
|
||||
</label>
|
||||
|
||||
<button
|
||||
onClick={onAddNewScreenWithAdb}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: '#00d2ff',
|
||||
fontSize: '0.72rem',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
fontWeight: 600
|
||||
}}
|
||||
>
|
||||
<PlusCircle size={13} /> + Новый экран с ADB
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{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 (
|
||||
<div
|
||||
key={s.id}
|
||||
onClick={() => 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'
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ fontSize: '0.85rem', fontWeight: 600, color: isActive ? '#00d2ff' : '#f1f5f9' }}>
|
||||
{s.name}
|
||||
</div>
|
||||
<div style={{ fontSize: '0.7rem', color: '#94a3b8', display: 'flex', gap: '6px', marginTop: '2px', alignItems: 'center' }}>
|
||||
<span>{s.hotspots.length} зон</span>
|
||||
<span>•</span>
|
||||
{measuredCount > 0 ? (
|
||||
<span style={{ color: '#10b981', fontFamily: 'var(--font-mono)' }}>
|
||||
{totalScreenEvents.toLocaleString('ru-RU')} событий
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ color: '#64748b' }}>без метрик</span>
|
||||
)}
|
||||
{s.screenshotStale && <AlertTriangle size={11} color="#fbbf24" />}
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight size={16} color={isActive ? '#00d2ff' : '#475569'} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Capture Options & Actions */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px', borderTop: '1px solid rgba(255,255,255,0.08)', paddingTop: '14px' }}>
|
||||
{/* Persist edits. Export downloads a copy; this writes the file the app reads. */}
|
||||
<button
|
||||
onClick={onSaveAppMap}
|
||||
disabled={isSavingMap || !isDirty}
|
||||
style={{
|
||||
background: isDirty ? 'linear-gradient(135deg, #10b981 0%, #059669 100%)' : 'rgba(255,255,255,0.04)',
|
||||
border: isDirty ? 'none' : '1px solid rgba(255,255,255,0.08)',
|
||||
color: isDirty ? '#fff' : '#64748b',
|
||||
padding: '10px',
|
||||
borderRadius: '8px',
|
||||
fontSize: '0.8rem',
|
||||
fontWeight: 700,
|
||||
cursor: isDirty && !isSavingMap ? 'pointer' : 'default',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '8px'
|
||||
}}
|
||||
>
|
||||
<HardDriveDownload size={15} />
|
||||
{isSavingMap
|
||||
? 'Сохраняем...'
|
||||
: isDirty
|
||||
? 'Сохранить разметку на диск'
|
||||
: 'Все изменения сохранены'}
|
||||
</button>
|
||||
|
||||
{/* ADB Capture Button for Selected Screen */}
|
||||
<button
|
||||
onClick={() => onTriggerAdbCapture(currentScreenId)}
|
||||
disabled={isCapturing || !deviceStatus?.webviewReachable}
|
||||
style={{
|
||||
background: isCapturing ? 'rgba(0, 210, 255, 0.2)' : 'linear-gradient(135deg, #0070f3 0%, #00d2ff 100%)',
|
||||
border: 'none',
|
||||
color: '#fff',
|
||||
padding: '10px',
|
||||
borderRadius: '8px',
|
||||
fontSize: '0.8rem',
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '8px',
|
||||
boxShadow: '0 4px 15px rgba(0, 163, 255, 0.35)'
|
||||
}}
|
||||
>
|
||||
<Camera size={16} />
|
||||
{isCapturing
|
||||
? 'Снимаем и размечаем экран...'
|
||||
: deviceStatus?.webviewReachable
|
||||
? `Снять «${currentScreen?.name || 'текущий экран'}» с ADB`
|
||||
: 'Телефон недоступен'}
|
||||
</button>
|
||||
|
||||
{/* ClickHouse Sync Button */}
|
||||
<button
|
||||
onClick={onSyncClickhouse}
|
||||
disabled={isSyncing}
|
||||
style={{
|
||||
background: 'rgba(16, 185, 129, 0.12)',
|
||||
border: '1px solid rgba(16, 185, 129, 0.4)',
|
||||
color: '#10b981',
|
||||
padding: '8px',
|
||||
borderRadius: '8px',
|
||||
fontSize: '0.78rem',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '8px'
|
||||
}}
|
||||
>
|
||||
<Database size={14} />
|
||||
{isSyncing
|
||||
? 'Обновляем метрики...'
|
||||
: metricsSource === 'clickhouse'
|
||||
? 'Пересчитать метрики (ClickHouse)'
|
||||
: 'Пересчитать метрики (демо-данные)'}
|
||||
</button>
|
||||
|
||||
{metricsSource !== 'clickhouse' && (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
alignItems: 'flex-start',
|
||||
fontSize: '0.68rem',
|
||||
color: '#fbbf24',
|
||||
background: 'rgba(251, 191, 36, 0.08)',
|
||||
border: '1px solid rgba(251, 191, 36, 0.22)',
|
||||
padding: '8px 10px',
|
||||
borderRadius: '8px',
|
||||
lineHeight: 1.4
|
||||
}}>
|
||||
<AlertTriangle size={13} style={{ flexShrink: 0, marginTop: '1px' }} />
|
||||
<span>
|
||||
Показаны демонстрационные числа, а не данные ClickHouse. Укажите CLICKHOUSE_HOST
|
||||
в .env и запустите py tools/fetch_clickhouse.py.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Import & Export JSON buttons */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px' }}>
|
||||
<button
|
||||
onClick={onExportJson}
|
||||
style={{
|
||||
background: 'rgba(255, 255, 255, 0.05)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
color: '#94a3b8',
|
||||
padding: '7px',
|
||||
borderRadius: '8px',
|
||||
fontSize: '0.73rem',
|
||||
fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '4px'
|
||||
}}
|
||||
>
|
||||
<Download size={12} /> Экспорт JSON
|
||||
</button>
|
||||
|
||||
<label style={{
|
||||
background: 'rgba(255, 255, 255, 0.05)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
color: '#94a3b8',
|
||||
padding: '7px',
|
||||
borderRadius: '8px',
|
||||
fontSize: '0.73rem',
|
||||
fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '4px'
|
||||
}}>
|
||||
<Upload size={12} /> Импорт
|
||||
<input type="file" accept=".json" onChange={onImportJson} style={{ display: 'none' }} />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
243
src/index.css
Normal file
@ -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);
|
||||
}
|
||||
10
src/main.tsx
Normal file
@ -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(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
109
src/types/simulator.ts
Normal file
@ -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 <screenId>`.
|
||||
*/
|
||||
export type KeyConfidence = 'observed' | 'rule' | 'guessed' | 'dom-attribute' | 'manual';
|
||||
|
||||
export const UNVERIFIED_CONFIDENCE: ReadonlySet<string> = 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';
|
||||
191
tools/amplitude_hook.py
Normal file
@ -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=<apiKey>&e=%5B%7B...%22event_type%22%3A%22HOMEPAGEPAYMENTS%22...
|
||||
POST https://mc.yandex.ru/watch/96490559/1?page-url=goal://customer.telecom.kz/HOMEPAGEPAYMENTS
|
||||
|
||||
Two things that decide how this file is written:
|
||||
|
||||
1. It is the LEGACY Amplitude HTTP API v1: an application/x-www-form-urlencoded
|
||||
body whose `e` parameter holds a URL-encoded JSON array of events. It is NOT
|
||||
the V2 shape {"events":[...]}, so JSON.parse() on the raw body throws. Both
|
||||
shapes are handled below, v1 first.
|
||||
|
||||
2. The Yandex Metrika goal name is identical to the Amplitude event_type - verified
|
||||
on the same taps (HOMEPAGEPAYMENTS, OPENWINDOWPAYMENT in both channels). Metrika
|
||||
is therefore a usable fallback when the Amplitude body cannot be read.
|
||||
|
||||
Real key shape: HOMEPAGEPAYMENTS, HOMETAPMYSERVICES, OPENSCREENAPPEALS. Not
|
||||
PAYMENTS_CLICK. Keys invented from button captions match nothing in ClickHouse.
|
||||
|
||||
The Amplitude payload also carries user_id, device_id and the project write key.
|
||||
This hook deliberately extracts only event_type and never persists the raw body.
|
||||
"""
|
||||
|
||||
INSTALL_HOOK_JS = r"""
|
||||
(() => {
|
||||
window.__tkEvents = window.__tkEvents || [];
|
||||
if (window.__tkHookInstalled) return { already: true, transports: window.__tkTransports || [] };
|
||||
|
||||
const push = (kind, payload) =>
|
||||
window.__tkEvents.push(Object.assign({ kind: kind, t: Date.now(), route: location.pathname }, payload));
|
||||
|
||||
const emitEvents = events => {
|
||||
if (!Array.isArray(events)) return false;
|
||||
let emitted = false;
|
||||
events.forEach(ev => {
|
||||
if (ev && ev.event_type) {
|
||||
push('amplitude', { eventType: ev.event_type });
|
||||
emitted = true;
|
||||
}
|
||||
});
|
||||
return emitted;
|
||||
};
|
||||
|
||||
const readAmplitudeBody = body => {
|
||||
if (!body || typeof body !== 'string') return;
|
||||
|
||||
// Legacy HTTP API v1: form-urlencoded, events live in the `e` parameter.
|
||||
if (body.indexOf('e=') !== -1 && body.indexOf('checksum=') !== -1) {
|
||||
try {
|
||||
const params = new URLSearchParams(body);
|
||||
const raw = params.get('e');
|
||||
if (raw && emitEvents(JSON.parse(raw))) return;
|
||||
} catch (err) { /* fall through to V2 */ }
|
||||
}
|
||||
|
||||
// HTTP API V2: {"api_key": "...", "events": [...]}
|
||||
try {
|
||||
const parsed = JSON.parse(body);
|
||||
emitEvents(parsed && parsed.events);
|
||||
} catch (err) { /* not a payload we understand */ }
|
||||
};
|
||||
|
||||
// Metrika encodes the goal name in the page-url parameter as goal://host/NAME.
|
||||
const readMetrikaGoal = url => {
|
||||
const m = /goal(?:%3A%2F%2F|:\/\/)[^/%]*(?:%2F|\/)([A-Z0-9_]+)/i.exec(String(url));
|
||||
if (m) push('metrika-goal', { eventType: m[1] });
|
||||
};
|
||||
|
||||
const inspect = (url, body) => {
|
||||
const u = String(url || '');
|
||||
if (/api\.amplitude\.com|amplitude\.com\/2\/httpapi/i.test(u)) readAmplitudeBody(body);
|
||||
if (/mc\.yandex\.ru/i.test(u)) readMetrikaGoal(u);
|
||||
};
|
||||
|
||||
const transports = [];
|
||||
|
||||
// 1. fetch
|
||||
const origFetch = window.fetch;
|
||||
if (origFetch && !origFetch.__tkWrapped) {
|
||||
const wrapped = function (...args) {
|
||||
try {
|
||||
const req = args[0];
|
||||
const url = typeof req === 'string' ? req : (req && req.url) || '';
|
||||
const body = args[1] && args[1].body;
|
||||
inspect(url, typeof body === 'string' ? body : null);
|
||||
} catch (e) { /* never break the app */ }
|
||||
return origFetch.apply(this, args);
|
||||
};
|
||||
wrapped.__tkWrapped = true;
|
||||
window.fetch = wrapped;
|
||||
transports.push('fetch');
|
||||
}
|
||||
|
||||
// 2. XMLHttpRequest - what the Amplitude browser SDK actually uses
|
||||
const XHR = window.XMLHttpRequest && window.XMLHttpRequest.prototype;
|
||||
if (XHR && !XHR.__tkWrapped) {
|
||||
const origOpen = XHR.open;
|
||||
const origSend = XHR.send;
|
||||
XHR.open = function (method, url, ...rest) {
|
||||
this.__tkUrl = url;
|
||||
return origOpen.call(this, method, url, ...rest);
|
||||
};
|
||||
XHR.send = function (body) {
|
||||
try { inspect(this.__tkUrl, typeof body === 'string' ? body : null); } catch (e) { /* ignore */ }
|
||||
return origSend.call(this, body);
|
||||
};
|
||||
XHR.__tkWrapped = true;
|
||||
transports.push('xhr');
|
||||
}
|
||||
|
||||
// 3. sendBeacon - used on page hide
|
||||
if (navigator.sendBeacon && !navigator.sendBeacon.__tkWrapped) {
|
||||
const origBeacon = navigator.sendBeacon.bind(navigator);
|
||||
const wrapped = function (url, data) {
|
||||
try { inspect(url, typeof data === 'string' ? data : null); } catch (e) { /* ignore */ }
|
||||
return origBeacon(url, data);
|
||||
};
|
||||
wrapped.__tkWrapped = true;
|
||||
navigator.sendBeacon = wrapped;
|
||||
transports.push('beacon');
|
||||
}
|
||||
|
||||
// 4. The analytics connector, kept as a fourth source in case a build uses it.
|
||||
const instances = window.analyticsConnectorInstances || {};
|
||||
let connectors = 0;
|
||||
Object.keys(instances).forEach(name => {
|
||||
const inst = instances[name];
|
||||
if (inst && inst.eventBridge && typeof inst.eventBridge.setEventReceiver === 'function') {
|
||||
const prev = inst.eventBridge.eventReceiver;
|
||||
inst.eventBridge.setEventReceiver(evt => {
|
||||
if (evt && evt.eventType) push('connector', { eventType: evt.eventType });
|
||||
if (prev) prev(evt);
|
||||
});
|
||||
connectors += 1;
|
||||
}
|
||||
});
|
||||
if (connectors) transports.push('connector:' + connectors);
|
||||
|
||||
// 5. What the user physically touched, so a key can be attributed to a control.
|
||||
// Taps usually land on an icon with no text of its own. Climb until an ancestor
|
||||
// carries a caption, so the event key can be attributed to a named control -
|
||||
// without this every tap reported "(no caption)" and nothing could be paired.
|
||||
const captionFor = start => {
|
||||
let el = start;
|
||||
for (let depth = 0; el && depth < 6; depth += 1, el = el.parentElement) {
|
||||
const label = (el.getAttribute && el.getAttribute('aria-label')) || '';
|
||||
const text = (label || el.innerText || '').trim().replace(/\s+/g, ' ');
|
||||
const r = el.getBoundingClientRect();
|
||||
// Reject the page-sized wrappers near the top of the climb.
|
||||
if (text && text.length <= 60 && r.height < window.innerHeight * 0.6) {
|
||||
return { text: text, el: el };
|
||||
}
|
||||
}
|
||||
return { text: '', el: start };
|
||||
};
|
||||
|
||||
document.addEventListener('click', e => {
|
||||
const found = captionFor(e.target);
|
||||
const el = found.el;
|
||||
const r = el.getBoundingClientRect();
|
||||
push('click', {
|
||||
text: found.text.slice(0, 60),
|
||||
className: String(el.className || '').slice(0, 80),
|
||||
rect: {
|
||||
cssLeft: r.left, cssTop: r.top + window.scrollY,
|
||||
cssWidth: r.width, cssHeight: r.height
|
||||
}
|
||||
});
|
||||
}, true);
|
||||
transports.push('click');
|
||||
|
||||
window.__tkTransports = transports;
|
||||
window.__tkHookInstalled = true;
|
||||
return { already: false, transports: transports };
|
||||
})()
|
||||
"""
|
||||
|
||||
DRAIN_JS = "(() => { const e = window.__tkEvents || []; window.__tkEvents = []; return e; })()"
|
||||
19
tools/capture_long_screen.py
Normal file
@ -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__")
|
||||
724
tools/capture_screen.py
Normal file
@ -0,0 +1,724 @@
|
||||
"""
|
||||
TelecomKz screen capture + hotspot mapping.
|
||||
|
||||
Produces, for one screen id:
|
||||
public/assets/screens/<id>_long.png the stitched screenshot
|
||||
public/assets/screens/<id>_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())
|
||||
19
tools/cdp_bridge.py
Normal file
@ -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__")
|
||||
202
tools/crawl_and_map_all.py
Normal file
@ -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())
|
||||
19
tools/crawl_direct_taps.py
Normal file
@ -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__")
|
||||
19
tools/debug_cdp.py
Normal file
@ -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__")
|
||||
57
tools/device_status.py
Normal file
@ -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))
|
||||
187
tools/fetch_clickhouse.py
Normal file
@ -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> + {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())
|
||||
19
tools/hook_amplitude.py
Normal file
@ -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__")
|
||||
19
tools/inspect_amplitude.py
Normal file
@ -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__")
|
||||
19
tools/inspect_bridge.py
Normal file
@ -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__")
|
||||
142
tools/inspect_page.py
Normal file
@ -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())
|
||||
19
tools/inspect_vue_app.py
Normal file
@ -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__")
|
||||
19
tools/inspect_window.py
Normal file
@ -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__")
|
||||
16
tools/legacy/README.md
Normal file
@ -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`.
|
||||
124
tools/legacy/capture_long_screen.py
Normal file
@ -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()
|
||||
341
tools/legacy/cdp_bridge.py
Normal file
@ -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")
|
||||
156
tools/legacy/crawl_direct_taps.py
Normal file
@ -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())
|
||||
43
tools/legacy/debug_cdp.py
Normal file
@ -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())
|
||||
56
tools/legacy/hook_amplitude.py
Normal file
@ -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())
|
||||
34
tools/legacy/inspect_amplitude.py
Normal file
@ -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())
|
||||
34
tools/legacy/inspect_bridge.py
Normal file
@ -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())
|
||||
58
tools/legacy/inspect_vue_app.py
Normal file
@ -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())
|
||||
57
tools/legacy/inspect_window.py
Normal file
@ -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())
|
||||
37
tools/legacy/read_amplitude_events.py
Normal file
@ -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())
|
||||
134
tools/legacy/stitch_opencv.py
Normal file
@ -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)
|
||||
119
tools/live_auto_recorder.py
Normal file
@ -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())
|
||||
468
tools/map_screen_controls.py
Normal file
@ -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())
|
||||
140
tools/monitor_live_events.py
Normal file
@ -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())
|
||||
19
tools/read_amplitude_events.py
Normal file
@ -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__")
|
||||