1
0
forked from BRT/arc
arc/tests/mock_server/gen_fetch_transactions.py
slava efc2da5cd0 Refine transaction amount parsing in mock server:
- Ensure amounts include the ₽ currency symbol for accurate parsing.
2026-04-22 18:03:52 +07:00

663 lines
24 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

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

#!/usr/bin/env python3
"""
Generate FETCH_TRANSACTIONS pool tasks from a live device screen.
Reads config.ini to find the DB, queries active materials, shows a menu
to pick which device+bank to scan, then dumps the transactions list from
the real device, taps into each detail, and generates pool JSON files.
Run from project root while the transactions list is open on the device:
python tests/mock_server/gen_fetch_transactions.py
Or specify config and serial:
python tests/mock_server/gen_fetch_transactions.py --config config.ini -s 148697059H000712
On Windows:
python tests\\mock_server\\gen_fetch_transactions.py
"""
import argparse
import configparser
import json
import os
import re
import sqlite3
import subprocess
import sys
import time
from datetime import datetime, timedelta, timezone
from pathlib import Path
HERE = Path(__file__).parent
PROJECT_ROOT = (HERE / ".." / "..").resolve()
POOL_DIR = HERE / "scenarios" / "pool"
if sys.platform == "win32":
ADB = str(PROJECT_ROOT / "tools" / "adb" / "windows" / "adb.exe")
elif sys.platform == "darwin":
ADB = str(PROJECT_ROOT / "tools" / "adb" / "macos" / "adb")
else:
ADB = "adb"
BOUNDS_RE = re.compile(r"\[(\d+),(\d+)\]\[(\d+),(\d+)\]")
# ---------------------------------------------------------------- config/db
def resolve_db_path(config_path: Path) -> Path:
config = configparser.ConfigParser()
config.read(str(config_path), encoding="utf-8")
raw = config.get("db", "dbPath", fallback="")
if not raw:
print(f"ERROR: [db] dbPath not found in {config_path}", file=sys.stderr)
sys.exit(1)
expanded = os.path.expanduser(raw)
p = Path(expanded)
if not p.is_absolute():
p = config_path.parent / p
return p.resolve()
def find_config(hint):
if hint:
p = Path(hint)
if p.exists():
return p
print(f"ERROR: config not found at {p}", file=sys.stderr)
sys.exit(1)
for c in [Path("config.ini"), Path("build/Release/config.ini"),
Path("build/config.ini"), Path("build/Debug/config.ini")]:
if c.exists():
return c.resolve()
print("ERROR: config.ini not found. Run from project root or use --config.", file=sys.stderr)
sys.exit(1)
def query_active_materials(db_path: Path):
conn = sqlite3.connect(str(db_path))
conn.row_factory = sqlite3.Row
rows = conn.execute("""
SELECT d.id AS device_id, d.api_id, d.name AS device_name,
d.adb_serial,
bp.code AS bank_name, bp.bank_profile_id, bp.full_name,
m.material_id, m.last_numbers, m.card_number
FROM devices d
JOIN bank_profiles bp ON bp.device_id = d.id
JOIN materials m ON m.device_id = d.id AND m.app_code = bp.code
WHERE bp.status = 'active' AND m.status = 'active'
AND m.material_id != '' AND bp.bank_profile_id != ''
ORDER BY d.name, bp.code
""").fetchall()
conn.close()
return [dict(r) for r in rows]
# ---------------------------------------------------------------- ADB helpers
def adb(serial, *args):
cmd = [ADB]
if serial:
cmd += ["-s", serial]
cmd += list(args)
return subprocess.run(cmd, check=True, capture_output=True)
PACKAGE_TO_BANK = {
"ru.ozon.fintech.finance": "ozon",
"tj.dc.next1": "dushanbe_city_bank",
"app.blackwallet.ar": "black",
}
def get_foreground_package(serial) -> str | None:
"""Return the package name of the currently focused app."""
try:
r = adb(serial, "shell", "dumpsys", "window", "windows")
for line in r.stdout.decode("utf-8", errors="replace").splitlines():
if "mCurrentFocus" in line or "mFocusedApp" in line:
# e.g. mCurrentFocus=Window{... u0 tj.dc.next1/tj.dc.next1.MainActivity}
m = re.search(r"u\d+\s+(\S+)/", line)
if m:
return m.group(1)
except Exception:
pass
return None
def dump_xml(serial) -> str:
try:
adb(serial, "shell", "uiautomator", "dump", "/sdcard/_gen_ft.xml")
r = adb(serial, "exec-out", "cat", "/sdcard/_gen_ft.xml")
return r.stdout.decode("utf-8", errors="replace")
except subprocess.CalledProcessError as e:
print(f" WARNING: dump_xml failed: {e}")
return ""
def get_device_tz(serial) -> timezone:
r = adb(serial, "shell", "date", "+%z")
s = r.stdout.decode().strip()
try:
sign = 1 if s[0] == "+" else -1
return timezone(timedelta(hours=sign * int(s[1:3]), minutes=sign * int(s[3:5])))
except (ValueError, IndexError):
print(f" warning: can't parse tz '{s}', assuming UTC+7")
return timezone(timedelta(hours=7))
def get_adb_serials():
r = subprocess.run([ADB, "devices"], check=True, capture_output=True)
serials = []
for line in r.stdout.decode().splitlines()[1:]:
parts = line.split()
if len(parts) >= 2 and parts[1] == "device":
serials.append(parts[0])
return serials
def tap(serial, x, y):
adb(serial, "shell", "input", "tap", str(x), str(y))
def back(serial):
adb(serial, "shell", "input", "keyevent", "KEYCODE_BACK")
def swipe_up(serial, duration=500):
"""Swipe up to scroll the list down (small step)."""
adb(serial, "shell", "input", "swipe", "540", "1400", "540", "900", str(duration))
# ---------------------------------------------------------------- Ozon
MONTH_MAP = {
"января": 1, "февраля": 2, "марта": 3, "апреля": 4,
"мая": 5, "июня": 6, "июля": 7, "августа": 8,
"сентября": 9, "октября": 10, "ноября": 11, "декабря": 12,
}
DATE_HEADER_RE = re.compile(r"(\d{1,2})\s+(\S+),\s+\S+")
def parse_ozon_date_headers(xml, device_tz):
"""Parse date headers (TextView) and return list of (y, date_str 'dd.MM.yyyy')."""
today = datetime.now(tz=device_tz).date()
headers = []
for m in re.finditer(
r'text="([^"]*)"[^>]*class="android.widget.TextView"'
r'[^>]*bounds="(\[\d+,\d+\]\[\d+,\d+\])"', xml):
text, bounds = m.group(1).strip(), m.group(2)
bm = BOUNDS_RE.match(bounds)
if not bm:
continue
x1, y1, x2, y2 = map(int, bm.groups())
h = y2 - y1
if h < 5 or y1 < 700:
continue # за экраном или в зоне шапки
d = None
if text == "Сегодня":
d = today
elif text == "Вчера":
d = today - timedelta(days=1)
else:
dm = DATE_HEADER_RE.match(text)
if dm:
day = int(dm.group(1))
month = MONTH_MAP.get(dm.group(2), 0)
if month:
d = today.replace(month=month, day=day)
if d > today:
d = d.replace(year=today.year - 1)
if d:
headers.append({"y": y1, "date": d.strftime("%d.%m.%Y")})
headers.sort(key=lambda h: h["y"])
return headers
def parse_ozon_rows(xml):
rows = []
for m in re.finditer(
r'text="([^"]*)"'
r'[^>]*?bounds="(\[\d+,\d+\]\[\d+,\d+\])"', xml):
text, bounds = m.group(1), m.group(2)
bm = BOUNDS_RE.match(bounds)
if not bm:
continue
x1, y1, x2, y2 = map(int, bm.groups())
h = y2 - y1
cy = (y1 + y2) // 2
if h < 50 or h < 5 or cy < 800:
continue
if "Расход" in text or "Доход" in text:
continue
am = re.search(r"([+\-])\s*(\d[\d\s\u202f]*(?:[,.]\d{1,2})?)\s*₽", text)
if not am:
continue
sign = -1 if am.group(1) in ("-", "") else 1
raw = re.sub(r"[\s\u202f]", "", am.group(2)).replace(",", ".")
amount = sign * (float(raw) if "." in raw else int(raw))
rows.append({"text": text, "cx": (x1+x2)//2, "cy": cy, "amount": amount})
rows.sort(key=lambda r: r["cy"])
return rows
def assign_dates_to_rows(rows, headers):
"""Assign date from nearest preceding header to each row."""
for row in rows:
row["date"] = None
for h in reversed(headers):
if h["y"] < row["cy"]:
row["date"] = h["date"]
break
def is_ozon_list(xml):
return "Операции" in xml
def extract_ozon_detail_time(xml):
for m in re.finditer(r'text="([^"]+)"', xml):
dm = re.match(r"(\d{2}\.\d{2}\.\d{4}),?\s+(\d{2}:\d{2})", m.group(1))
if dm:
return dm.group(1), dm.group(2) + ":00"
return None, None
# ---------------------------------------------------------------- Dushanbe
def parse_dushanbe_rows(xml):
rows = []
for m in re.finditer(
r'<node\s[^>]*?content-desc="([^"]*часть P2P/операции[^"]*)"'
r'[^>]*?bounds="(\[(\d+),(\d+)\]\[(\d+),(\d+)\])"', xml):
cd = m.group(1)
x1, y1, x2, y2 = int(m.group(3)), int(m.group(4)), int(m.group(5)), int(m.group(6))
h = y2 - y1
if h < 80 or h > 400:
continue
parts = cd.split("\n") if "\n" in cd else cd.split("&#10;")
amount, time_str = None, None
for p in parts:
p = p.strip()
try:
amount = float(p.replace(",", "."))
except ValueError:
pass
if re.match(r"^\s*\d{1,2}:\d{2}(:\d{2})?\s*$", p):
time_str = p.strip()
rows.append({"cx": (x1+x2)//2, "cy": (y1+y2)//2, "amount": amount, "time": time_str})
rows.sort(key=lambda r: r["cy"])
return rows
def is_dushanbe_list(xml):
return "Операции" in xml and "часть P2P" in xml
def extract_dushanbe_detail(xml):
m = re.search(r'content-desc="([^"]{30,})"', xml)
if not m:
return None, None, None
parts = [x.strip() for x in m.group(1).split("&#10;")]
info = {}
for i, x in enumerate(parts):
if x.endswith(":") and i + 1 < len(parts):
info[x] = parts[i + 1]
return info.get("Сумма:"), info.get("Дата:"), info.get("Время:")
# ---------------------------------------------------------------- Main
def short_label(amount, time_str):
sign = "plus" if amount >= 0 else "minus"
t = time_str.replace(":", "") if time_str else "unk"
a = abs(amount)
if isinstance(a, float):
rub = int(a)
kop = int(round((a - rub) * 100))
amt = f"{rub}_{kop:02d}" if kop else f"{rub}"
else:
amt = f"{a}"
return f"{sign}_{amt}_{t}"
def collect_tasks(serial, bank, mid, device_tz, kopecks, prefix):
xml = dump_xml(serial)
# Detect bank by foreground app package first, fall back to XML heuristics
fg_pkg = get_foreground_package(serial)
fg_bank = PACKAGE_TO_BANK.get(fg_pkg) if fg_pkg else None
if fg_bank:
print(f"Foreground app: {fg_pkg} -> {fg_bank}")
is_dush = fg_bank == "dushanbe_city_bank" or "dushanbe" in bank or is_dushanbe_list(xml)
is_ozon = not is_dush and (fg_bank == "ozon" or bank == "ozon" or is_ozon_list(xml))
if not is_ozon and not is_dush:
print("ERROR: not on a recognized transactions list", file=sys.stderr)
return []
tasks = []
seen = set()
try:
return _collect_tasks_inner(serial, bank, mid, device_tz, kopecks, prefix,
xml, is_ozon, is_dush, tasks, seen)
except (subprocess.CalledProcessError, OSError) as e:
print(f"\n ERROR: ADB/device failure: {e}")
print(f" Saving {len(tasks)} tasks collected before crash")
return tasks
def _collect_tasks_inner(serial, bank, mid, device_tz, kopecks, prefix,
xml, is_ozon, is_dush, tasks, seen):
if is_ozon:
print("Detected: Ozon")
headers = parse_ozon_date_headers(xml, device_tz)
baseline = parse_ozon_rows(xml)
assign_dates_to_rows(baseline, headers)
print(f"date headers: {[h['date'] for h in headers]}")
print(f"visible rows: {len(baseline)}")
for i, r in enumerate(baseline, 1):
print(f" {i}. ({r['cx']},{r['cy']}) {r['amount']:+.2f} ₽ date={r.get('date','-')} | {r['text'][:40]}")
task_num = 0
processed_keys = set() # (amount, created_at) — финальная дедупликация
completed_dates = set() # даты где все строки уже обработаны
date_row_counts = {} # date -> сколько строк видели для этой даты (макс)
max_scrolls = 20
scroll_count = 0
while True:
xml = dump_xml(serial)
if not is_ozon_list(xml):
print("Left list, stop"); break
headers = parse_ozon_date_headers(xml, device_tz)
rows = parse_ozon_rows(xml)
assign_dates_to_rows(rows, headers)
if not rows:
print("No rows found, stop"); break
# Фильтруем строки от уже завершённых дат
rows = [r for r in rows if r.get("date") and r["date"] not in completed_dates]
if not rows:
print("All visible dates already processed, scrolling...")
scroll_count += 1
if scroll_count >= max_scrolls:
print(f"\nReached max scrolls ({max_scrolls}), stop"); break
swipe_up(serial)
time.sleep(1.5)
continue
# Считаем строки по датам на текущем экране
screen_date_counts = {}
for row in rows:
d = row["date"]
screen_date_counts[d] = screen_date_counts.get(d, 0) + 1
new_on_screen = 0
for row in rows:
date_from_header = row["date"]
print(f"\n [{date_from_header}] tap ({row['cx']},{row['cy']}) {row['amount']:+.2f}")
tap(serial, row["cx"], row["cy"])
time.sleep(3)
# Ждём деталей — нужно только время (дата уже из заголовка)
time_s = None
for attempt in range(3):
d_xml = dump_xml(serial)
_, time_s = extract_ozon_detail_time(d_xml)
if time_s:
break
if is_ozon_list(d_xml):
print(f" retry {attempt+1}: still on list, re-tapping")
tap(serial, row["cx"], row["cy"])
time.sleep(2)
else:
print(f" retry {attempt+1}: waiting for time...")
time.sleep(2)
back(serial); time.sleep(1.5)
if not time_s:
print(f" SKIP (no time from detail)"); continue
print(f" {date_from_header} {time_s}")
dt = datetime.strptime(f"{date_from_header} {time_s}", "%d.%m.%Y %H:%M:%S").replace(tzinfo=device_tz)
cat = dt.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
dedup_key = (row["amount"], cat)
if dedup_key in processed_keys:
print(" DUPLICATE, skip"); continue
processed_keys.add(dedup_key)
new_on_screen += 1
task_num += 1
label = short_label(row["amount"], time_s)
while label in seen: label += "_2"
seen.add(label)
tasks.append({"bank_name": bank, "material_id": mid, "type": "FETCH_TRANSACTIONS",
"body": {"amount": int(round(row["amount"] * kopecks)), "created_at": cat},
"_tag": f"{prefix}_{task_num:02d}_{label}"})
# Помечаем даты как завершённые: все даты кроме последней на экране
# (последняя может быть обрезана скроллом)
visible_dates = list(dict.fromkeys(r["date"] for r in rows)) # ordered unique
if len(visible_dates) > 1:
for d in visible_dates[:-1]:
completed_dates.add(d)
if new_on_screen == 0:
# Последняя дата на экране — тоже завершена
if visible_dates:
completed_dates.add(visible_dates[-1])
print("\nNo new transactions on screen, scrolling past...")
scroll_count += 1
if scroll_count >= max_scrolls:
print(f"\nReached max scrolls ({max_scrolls}), stop"); break
swipe_up(serial)
time.sleep(1.5)
continue
scroll_count += 1
if scroll_count >= max_scrolls:
print(f"\nReached max scrolls ({max_scrolls}), stop"); break
print(f"\n--- scroll {scroll_count}/{max_scrolls} ---")
swipe_up(serial)
time.sleep(1.5)
elif is_dush:
print("Detected: Dushanbe")
baseline = parse_dushanbe_rows(xml)
print(f"visible rows: {len(baseline)}")
for i, r in enumerate(baseline, 1):
print(f" {i}. ({r['cx']},{r['cy']}) amt={r['amount']} time={r['time']}")
task_num = 0
processed_keys = set()
max_scrolls = 30
scroll_count = 0
while True:
xml = dump_xml(serial)
if not is_dushanbe_list(xml):
print("Left list, stop"); break
rows = parse_dushanbe_rows(xml)
if not rows:
print("No rows found, stop"); break
tapped_any = False
for row in rows:
print(f"\n tap ({row['cx']},{row['cy']}) amt={row['amount']}")
tap(serial, row["cx"], row["cy"])
time.sleep(2.5)
d_xml = dump_xml(serial)
amt_s, date_s, time_s = extract_dushanbe_detail(d_xml)
print(f" amt={amt_s} {date_s} {time_s}")
back(serial); time.sleep(1.3)
if not date_s or not time_s:
print(" SKIP"); continue
amount = float(amt_s.replace(",", ".")) if amt_s else (row["amount"] or 0)
fmt = "%d.%m.%Y %H:%M:%S" if len(time_s) > 5 else "%d.%m.%Y %H:%M"
# Душанбе показывает время в Asia/Dushanbe (UTC+5), не в таймзоне устройства
dushanbe_tz = timezone(timedelta(hours=5))
dt = datetime.strptime(f"{date_s} {time_s}", fmt).replace(tzinfo=dushanbe_tz)
cat = dt.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
dedup_key = (amount, cat)
if dedup_key in processed_keys:
print(" DUPLICATE, skip"); continue
processed_keys.add(dedup_key)
tapped_any = True
task_num += 1
label = short_label(amount, time_s)
while label in seen: label += "_2"
seen.add(label)
tasks.append({"bank_name": bank, "material_id": mid, "type": "FETCH_TRANSACTIONS",
"body": {"amount": int(round(amount * kopecks)), "created_at": cat},
"_tag": f"{prefix}_{task_num:02d}_{label}"})
scroll_count += 1
if scroll_count >= max_scrolls:
print(f"\nReached max scrolls ({max_scrolls}), stop"); break
if not tapped_any:
print("\nNo new transactions on screen, stop"); break
print(f"\n--- scrolling down ({scroll_count}/{max_scrolls}) ---")
swipe_up(serial)
time.sleep(1.5)
return tasks
def main():
parser = argparse.ArgumentParser(description="Generate FETCH_TRANSACTIONS pool from live device")
parser.add_argument("--config", "-c", default=None, help="Path to config.ini")
parser.add_argument("--serial", "-s", default=None, help="ADB serial (prompted if multiple)")
parser.add_argument("--kopecks", type=int, default=100, help="Amount multiplier (default 100)")
parser.add_argument("--mock-host", default="127.0.0.1")
parser.add_argument("--mock-port", type=int, default=8888)
args = parser.parse_args()
# Find config and DB
for c in ([Path(args.config)] if args.config else
[Path("config.ini"), Path("build/Release/config.ini"),
Path("build/config.ini")]):
if c.exists():
config_path = c.resolve(); break
else:
print("ERROR: config.ini not found", file=sys.stderr); return 1
db_path = resolve_db_path(config_path)
print(f"config: {config_path}")
print(f"db: {db_path}")
materials = query_active_materials(db_path)
if not materials:
print("No active materials in DB"); return 1
# Find connected ADB devices
serials = get_adb_serials()
if not serials:
print("ERROR: no ADB devices connected"); return 1
# Match DB devices to connected ADB serials
options = []
for mat in materials:
adb_serial = mat["adb_serial"]
if adb_serial not in serials:
continue
dev_label = re.sub(r"-[a-f0-9]{10,}$", "", mat["device_name"])
options.append({**mat, "serial": adb_serial, "label": dev_label})
if not options:
print("No active materials match connected ADB devices.")
print(f" DB serials: {[m['adb_serial'] for m in materials]}")
print(f" ADB serials: {serials}")
return 1
# Menu
print(f"\nAvailable materials ({len(options)}):")
for i, o in enumerate(options, 1):
print(f" {i}. {o['label']} / {o['bank_name']} / {o['full_name'] or '?'} "
f"(material {o['material_id'][:8]}… serial={o['serial']})")
if len(options) == 1:
choice = 0
print(f"\n→ auto-selected: {options[0]['label']} / {options[0]['bank_name']}")
else:
try:
inp = input(f"\nPick [1-{len(options)}] or 'all': ").strip()
except (EOFError, KeyboardInterrupt):
return 0
if inp.lower() == "all":
choice = -1
else:
choice = int(inp) - 1
if choice < 0 or choice >= len(options):
print("Invalid choice"); return 1
to_scan = options if choice == -1 else [options[choice]]
all_tasks = []
for o in to_scan:
serial = args.serial or o["serial"]
bank = o["bank_name"]
mid = o["material_id"]
card = o.get('last_numbers') or o['material_id'][:8]
prefix = f"{o['label'].lower().replace(' ', '_')}_{bank}_{card}"
device_tz = get_device_tz(serial)
print(f"\n{'='*60}")
print(f"Scanning: {o['label']} / {bank} ({mid[:8]}…)")
print(f"Serial: {serial} TZ: UTC{device_tz.utcoffset(None)}")
print(f"{'='*60}")
input(f"\nOpen {bank} transactions list on {o['label']} and press Enter...")
tasks = collect_tasks(serial, bank, mid, device_tz, args.kopecks, prefix)
all_tasks.extend(tasks)
if not all_tasks:
print("\nNo tasks generated"); return 1
# Write pool
POOL_DIR.mkdir(parents=True, exist_ok=True)
# Remove old files for scanned prefixes
scanned_prefixes = set()
for o in to_scan:
card = o.get('last_numbers') or o['material_id'][:8]
scanned_prefixes.add(f"{o['label'].lower().replace(' ', '_')}_{o['bank_name']}_{card}")
for old in POOL_DIR.glob("*.json"):
for pfx in scanned_prefixes:
if old.name.startswith(pfx + "_ft_"):
old.unlink(); break
for i, t in enumerate(all_tasks, 1):
tag = t["_tag"]
fname = f"{tag.rsplit('_', 1)[0]}_ft_{i:02d}.json"
(POOL_DIR / fname).write_text(json.dumps(t, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
print(f"\n{len(all_tasks)} tasks written to {POOL_DIR}/")
for t in all_tasks:
b = t["body"]
print(f" {t['_tag']}: amount={b['amount']:+.2f} created_at={b['created_at']}")
# Inject into mock if running
try:
import urllib.request
url = f"http://{args.mock_host}:{args.mock_port}/_admin/inject"
req = urllib.request.Request(url, data=json.dumps(all_tasks).encode(),
headers={"Content-Type": "application/json"}, method="POST")
with urllib.request.urlopen(req, timeout=3) as resp:
print(f"\nInjected {len(all_tasks)} tasks into mock -> {resp.status}")
except Exception as e:
print(f"\nMock not reachable ({e}) — tasks saved to files only")
return 0
if __name__ == "__main__":
sys.exit(main())