telecomkz_scraper/vite.config.ts
Iliyas Kyrykbayev 5cb347a44b TelecomKz Analytics Mapper: audit fixes, real event keys, 32 mapped screens
Rebuilt the capture and mapping pipeline after an audit found the simulator's
data could not be trusted:

* Hotspot coordinates never matched the screenshots. Capture now scrolls the
  page over CDP and pastes each frame at the measured scrollY, so image pixels
  and DOM coordinates share one grid by construction.
* Metrics were synthesised (1200 + n*410) and presented as analytics. Numbers
  are now attached only when the catalog has a matching row; metrics.json
  carries a `source` label and the UI says "no data" instead of showing zeros.
* Event interception hooked a connector bridge that never fires. The app posts
  to api.amplitude.com using the legacy form-urlencoded v1 API; the hook now
  reads event_type off the wire. 36 keys are verified as `observed`.
* All device access moved into tools/telecom_cdp.py: dynamic WebView socket
  discovery (the PID was hardcoded), id-matched CDP, measured native geometry.
* Editor edits can now be saved to disk; API failures no longer report success
  from a stale result file; screenId is no longer interpolated into a shell.

Screens went from 7 (with fabricated markup) to 32, all verified: image height
equals map height, no out-of-bounds hotspots, no dead links.

The id_card screenshot has been manually redacted - it showed a national ID.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 18:16:46 +05:00

231 lines
8.7 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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

import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { execFile } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import type { IncomingMessage, ServerResponse } from 'node:http';
const PROJECT_ROOT = process.cwd();
const APP_MAP_PATH = path.resolve(PROJECT_ROOT, 'public/data/telecomkz_app_map.json');
// Screen ids reach the shell as script arguments, so anything that is not a plain
// slug is rejected outright rather than escaped.
const SCREEN_ID_RE = /^[A-Za-z0-9_-]{1,64}$/;
const PYTHON_BIN = process.platform === 'win32' ? 'py' : 'python3';
interface ScriptResult {
code: number;
stdout: string;
stderr: string;
}
function runPython(script: string, args: string[], timeoutMs: number): Promise<ScriptResult> {
return new Promise(resolve => {
execFile(
PYTHON_BIN,
[path.join('tools', script), ...args],
{ cwd: PROJECT_ROOT, timeout: timeoutMs, maxBuffer: 32 * 1024 * 1024, windowsHide: true },
(err, stdout, stderr) => {
const code = err && typeof (err as NodeJS.ErrnoException & { code?: number }).code === 'number'
? ((err as unknown as { code: number }).code)
: err
? 1
: 0;
resolve({ code, stdout: stdout || '', stderr: stderr || '' });
}
);
});
}
function sendJson(res: ServerResponse, status: number, body: unknown) {
res.statusCode = status;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.setHeader('Cache-Control', 'no-store');
res.end(JSON.stringify(body));
}
/**
* The Python tools print exactly one JSON object on stdout as their contract.
* Parse the last JSON-looking line so incidental logging cannot corrupt the reply.
*/
function parseToolOutput(stdout: string): Record<string, unknown> | null {
const lines = stdout.split(/\r?\n/).filter(l => l.trim().startsWith('{'));
for (let i = lines.length - 1; i >= 0; i -= 1) {
try {
return JSON.parse(lines[i]);
} catch {
/* keep looking */
}
}
return null;
}
function readBody(req: IncomingMessage, limitBytes = 8 * 1024 * 1024): Promise<string> {
return new Promise((resolve, reject) => {
let size = 0;
const chunks: Buffer[] = [];
req.on('data', (chunk: Buffer) => {
size += chunk.length;
if (size > limitBytes) {
reject(new Error('Payload too large'));
req.destroy();
return;
}
chunks.push(chunk);
});
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
req.on('error', reject);
});
}
function requireScreenId(req: IncomingMessage): string {
const url = new URL(req.url || '', 'http://localhost');
const screenId = url.searchParams.get('screenId') || 'main_dashboard';
if (!SCREEN_ID_RE.test(screenId)) {
throw new Error(`Некорректный screenId: ${screenId}`);
}
return screenId;
}
export default defineConfig({
plugins: [
react(),
{
name: 'telecomkz-adb-bridge',
configureServer(server) {
// Serialise device work: ADB screencap, uiautomator and the CDP scroll driver
// all mutate the same phone, and both scripts write the same app-map file.
let deviceQueue: Promise<unknown> = Promise.resolve();
const enqueue = <T,>(job: () => Promise<T>): Promise<T> => {
const next = deviceQueue.then(job, job);
deviceQueue = next.catch(() => undefined);
return next;
};
server.middlewares.use('/api/capture-adb', (req, res) => {
let screenId: string;
let name: string | null;
let category: string;
try {
screenId = requireScreenId(req);
const url = new URL(req.url || '', 'http://localhost');
name = url.searchParams.get('name');
category = url.searchParams.get('category') || 'Основное';
} catch (e) {
sendJson(res, 400, { success: false, error: (e as Error).message });
return;
}
const args = [screenId, '--category', category];
if (name) args.push('--name', name);
enqueue(async () => {
server.config.logger.info(`[ADB Bridge] capture ${screenId}`);
const out = await runPython('capture_screen.py', args, 180_000);
const parsed = parseToolOutput(out.stdout);
// Trust the script's own JSON verdict. Reading a leftover *_result.json
// from an earlier run - as the previous version did - reports success for
// a capture that never happened.
if (!parsed || parsed.success !== true) {
sendJson(res, 502, {
success: false,
error:
(parsed && (parsed.error as string)) ||
out.stderr.trim() ||
`capture_screen.py завершился с кодом ${out.code}`
});
return;
}
const resultPath = path.resolve(
PROJECT_ROOT,
`public/assets/screens/${screenId}_result.json`
);
let full: Record<string, unknown> = parsed;
if (fs.existsSync(resultPath)) {
try {
full = JSON.parse(fs.readFileSync(resultPath, 'utf-8'));
} catch (e) {
server.config.logger.warn(`[ADB Bridge] unreadable result JSON: ${e}`);
}
}
if (out.stderr.trim()) server.config.logger.info(`[ADB Bridge] ${out.stderr.trim()}`);
sendJson(res, 200, full);
}).catch(e => sendJson(res, 500, { success: false, error: String(e) }));
});
server.middlewares.use('/api/live-sync-webview', (req, res) => {
let screenId: string;
try {
screenId = requireScreenId(req);
} catch (e) {
sendJson(res, 400, { success: false, error: (e as Error).message });
return;
}
enqueue(async () => {
const out = await runPython('live_auto_recorder.py', [screenId], 60_000);
const parsed = parseToolOutput(out.stdout);
if (!parsed || parsed.success !== true) {
sendJson(res, 502, {
success: false,
error:
(parsed && (parsed.error as string)) ||
out.stderr.trim() ||
`live_auto_recorder.py завершился с кодом ${out.code}`
});
return;
}
let appMap: unknown = null;
try {
appMap = JSON.parse(fs.readFileSync(APP_MAP_PATH, 'utf-8'));
} catch (e) {
server.config.logger.warn(`[Live Sync] unreadable app map: ${e}`);
}
sendJson(res, 200, { ...parsed, appMap, timestamp: Date.now() });
}).catch(e => sendJson(res, 500, { success: false, error: String(e) }));
});
// Persist edits made in the GUI editor. Without this the editor is a scratchpad:
// every change is lost on reload unless the user remembers to export a file.
server.middlewares.use('/api/save-app-map', (req, res) => {
if (req.method !== 'POST') {
sendJson(res, 405, { success: false, error: 'Используйте POST' });
return;
}
readBody(req)
.then(raw => {
const parsed = JSON.parse(raw);
if (!parsed || !Array.isArray(parsed.screens)) {
throw new Error('Ожидается объект с массивом screens');
}
fs.mkdirSync(path.dirname(APP_MAP_PATH), { recursive: true });
const backup = `${APP_MAP_PATH}.bak`;
if (fs.existsSync(APP_MAP_PATH)) fs.copyFileSync(APP_MAP_PATH, backup);
const tmp = `${APP_MAP_PATH}.tmp`;
fs.writeFileSync(tmp, JSON.stringify(parsed, null, 2), 'utf-8');
fs.renameSync(tmp, APP_MAP_PATH);
sendJson(res, 200, {
success: true,
screens: parsed.screens.length,
savedAt: Date.now()
});
})
.catch(e => sendJson(res, 400, { success: false, error: (e as Error).message }));
});
// Lets the UI tell "phone unplugged" apart from "server not running".
server.middlewares.use('/api/device-status', (_req, res) => {
enqueue(async () => {
const out = await runPython('device_status.py', [], 45_000);
const parsed = parseToolOutput(out.stdout);
sendJson(res, 200, parsed || { connected: false, error: out.stderr.trim() });
}).catch(e => sendJson(res, 500, { connected: false, error: String(e) }));
});
}
}
]
});