1
0
forked from BRT/arc

Refine transaction fetching and parsing:

- Add date header parsing and swipe functionality for streamlined `Ozon` transactions list processing.
- Improve deduplication logic, date assignment, and scroll behavior in `Dushanbe` and `Ozon` scripts.
- Introduce screenshot handling for debugging transaction fetching workflows.
- Add cancellation logic by `materialId` in `EventDAO`.
- Update mock server for enhanced filename generation and PATCH support for bank profiles.
This commit is contained in:
slava 2026-04-13 22:41:15 +07:00
parent a97bdea977
commit 628323184f
10 changed files with 467 additions and 133 deletions

View File

@ -328,6 +328,16 @@ void GetLastTransactionsScript::doStart() {
AdbUtils::makeTap(m_deviceId, vypiskaBtn.x(), vypiskaBtn.y());
QThread::msleep(3000);
// Pull-to-refresh: свайп сверху вниз для обновления списка
{
const int x = device.screenWidth / 2;
const int fromY = device.screenHeight / 4;
const int toY = device.screenHeight * 3 / 4;
AdbUtils::makeSwipe(m_deviceId, x, fromY, x, toY);
QThread::msleep(3000);
qDebug() << "[Dushanbe::GetLastTransactions] Pull-to-refresh on Выписка";
}
// Сохраняем дамп Выписки для анализа формата
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
@ -342,6 +352,9 @@ void GetLastTransactionsScript::doStart() {
}
}
// Скриншот списка транзакций — отправим при ошибке, удалим при успехе
const QByteArray listScreenshot = AdbUtils::captureScreenshotBytes(m_deviceId);
// Находим accountId
int accountId = -1;
const QList<MaterialInfo> accounts = MaterialDAO::getAccounts(device.id, m_appCode);
@ -354,10 +367,8 @@ void GetLastTransactionsScript::doStart() {
qDebug() << "[Dushanbe::GetLastTransactions] Searching: amount=" << m_searchAmount
<< "time=" << m_searchTime.toString(Qt::ISODate);
// Время в банковском приложении показывается в таймзоне устройства
const QString tzName = AdbUtils::getDeviceTimezone(m_deviceId);
QTimeZone tjTz(tzName.toUtf8());
if (!tjTz.isValid()) tjTz = QTimeZone("Asia/Dushanbe");
// Время в банковском приложении показывается в таймзоне Душанбе (UTC+5), не устройства
const QTimeZone tjTz("Asia/Dushanbe");
const int maxScrolls = 4;
auto interrupted = []() {
@ -421,22 +432,45 @@ void GetLastTransactionsScript::doStart() {
}
// 2. Собираем View-транзакции (формат Выписки)
// Два формата content-desc:
// a) DCWallet/*** — старый: DCWallet*WDC...\ncard\namount\nописание\n HH:MM:SS
// b) P2P — новый: Сегодня|dd.MM.yyyy\ncard\ncard\namount\nописание\n HH:MM:SS
static const QRegularExpression reTime(R"(\d{2}:\d{2}:\d{2})");
static const QRegularExpression reDateOrToday(
QString("^(?:\\d{2}\\.\\d{2}\\.\\d{4}|%1|%2)$")
.arg(QString::fromUtf8("Сегодня"), QString::fromUtf8("Вчера")));
QList<VypiskaItem> items;
for (const Node &n : xmlScreenParser.findAllNodes()) {
if (n.className != "android.view.View") continue;
if (!n.contentDesc.contains("DCWallet") && !n.contentDesc.contains("***")) continue;
const QStringList lines = n.contentDesc.split('\n');
if (lines.size() < 5) continue;
const QString last = lines.last().trimmed();
if (!reTime.match(last).hasMatch()) continue;
// Определяем формат: если первая строка — дата/Сегодня/Вчера, это P2P формат
const QString firstLine = lines.first().trimmed();
const bool isP2P = reDateOrToday.match(firstLine).hasMatch();
if (!isP2P && !n.contentDesc.contains("DCWallet") && !n.contentDesc.contains("***")) continue;
VypiskaItem it;
bool ok = false;
it.amount = lines[2].trimmed().toDouble(&ok);
// В P2P формате amount на позиции [3] (после даты, двух карт), в DCWallet — [2]
const int amtIdx = isP2P ? 3 : 2;
if (amtIdx >= lines.size()) continue;
it.amount = lines[amtIdx].trimmed().toDouble(&ok);
if (!ok) continue;
it.time = last;
it.node = n;
// В P2P формате дата встроена в первую строку
if (isP2P) {
if (firstLine.compare(QString::fromUtf8("Сегодня"), Qt::CaseInsensitive) == 0)
it.date = todayLocal.toString("dd.MM.yyyy");
else if (firstLine.compare(QString::fromUtf8("Вчера"), Qt::CaseInsensitive) == 0)
it.date = todayLocal.addDays(-1).toString("dd.MM.yyyy");
else
it.date = firstLine;
}
items.append(it);
}
@ -497,7 +531,9 @@ void GetLastTransactionsScript::doStart() {
int prevY2 = -1;
for (VypiskaItem &it : items) {
if (prevY2 >= 0 && it.node.y1() - prevY2 > 30) ++groupIdx;
if (groupIdx < groupDates.size()) it.date = groupDates[groupIdx];
// Не перезаписываем дату если она уже установлена (P2P формат)
if (it.date.isEmpty() && groupIdx < groupDates.size())
it.date = groupDates[groupIdx];
prevY2 = it.node.y2();
}
@ -539,7 +575,7 @@ void GetLastTransactionsScript::doStart() {
const QTime searchLocalTime = m_searchTime.toTimeZone(tjTz).time();
if (listTime.isValid() && searchLocalTime.isValid()) {
const int diffSecs = qAbs(listTime.secsTo(searchLocalTime));
if (diffSecs > 3600 && diffSecs < 82800) { // 82800 = 23h
if (diffSecs > 600 && diffSecs < 85800) { // 85800 = 23h50m
qDebug() << "[Dushanbe::GetLastTransactions] Time pre-filter skip:"
<< tx.time << "vs" << searchLocalTime.toString("HH:mm:ss")
<< "diff=" << diffSecs << "sec";
@ -617,8 +653,8 @@ void GetLastTransactionsScript::doStart() {
if (txTime.isValid()) {
txTime.setTimeZone(tjTz);
const qint64 diffSecs = qAbs(txTime.toUTC().secsTo(m_searchTime.toUTC()));
if (diffSecs > 3600) {
qDebug() << "[Dushanbe::GetLastTransactions] Time mismatch: diff=" << diffSecs << "sec, skipping";
if (diffSecs > 600) {
qDebug() << "[Dushanbe::GetLastTransactions] Time mismatch: diff=" << diffSecs << "sec (>10min), skipping";
AdbUtils::goBack(m_deviceId);
QThread::msleep(1000);
continue;
@ -699,6 +735,9 @@ void GetLastTransactionsScript::doStart() {
+ " time=" + m_searchTime.toString(Qt::ISODate);
qWarning() << "[Dushanbe::GetLastTransactions]" << m_error;
// Отправляем скриншот списка транзакций, сделанный до начала поиска
if (!listScreenshot.isEmpty()) emit screenshotReady(listScreenshot);
// Возвращаемся на главную
Node glavnayaBtn = xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("Главная"));
if (glavnayaBtn.x() > 0 && glavnayaBtn.y() > 0) {

View File

@ -27,6 +27,9 @@ signals:
// повторного чтения из БД.
void transactionParsed(const QJsonObject &tx);
// Скриншот списка транзакций (при ошибке отправляется в мониторинг)
void screenshotReady(const QByteArray &screenshot);
protected:
void doStart() override;

View File

@ -205,6 +205,9 @@ bool GetLastTransactionsScript::navigateToTransactionList(const DeviceInfo &devi
}
void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int accountId) {
// Скриншот списка транзакций — отправим при ошибке, удалим при успехе
const QByteArray listScreenshot = AdbUtils::captureScreenshotBytes(m_deviceId);
// Время в Ozon показывается в таймзоне устройства, а не в MSK
const QString tzName = AdbUtils::getDeviceTimezone(m_deviceId);
QTimeZone deviceTz(tzName.toUtf8());
@ -267,6 +270,8 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int
QThread::msleep(2000);
}
// Скроллим к нужной дате, запоминаем количество скроллов
int scrollsToDate = 0;
bool dateFound = false;
for (int scroll = 0; scroll < maxScrolls; ++scroll) {
if (QThread::currentThread()->isInterruptionRequested()) {
@ -276,7 +281,8 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
if (hasDateOnScreen()) {
dateFound = true;
qDebug() << "[FetchTransactions] Found target date on screen";
scrollsToDate = scroll;
qDebug() << "[FetchTransactions] Found target date on screen after" << scroll << "scrolls";
break;
}
if (hasOlderDateOnScreen()) {
@ -291,13 +297,33 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int
if (!dateFound) {
m_error = "Date not found in transaction list: " + searchDate.toString("dd.MM.yyyy");
qWarning() << "[FetchTransactions]" << m_error;
m_resultScreenshot = AdbUtils::captureScreenshotBytes(m_deviceId);
m_resultScreenshot = listScreenshot.isEmpty()
? AdbUtils::captureScreenshotBytes(m_deviceId) : listScreenshot;
if (!m_resultScreenshot.isEmpty()) emit screenshotReady(m_resultScreenshot);
return;
}
// ── Шаг 2: тапаем по кнопкам с нужной суммой, проверяем дату/время в деталях ─
// Запоминаем уже проверенные (date+time из деталей) чтобы не застревать
// Хелпер: находит Y-диапазон [dateY, nextDateY) для нужной даты на экране.
// Кнопки транзакций между этими Y принадлежат нужному дню.
auto findDateYRange = [&]() -> QPair<int, int> {
int dateY = -1;
int nextDateY = device.screenHeight;
for (const Node &node : xmlScreenParser.getNodes()) {
if (node.className != "android.widget.TextView") continue;
const QDate d = parseDateHeader(node.text.trimmed());
if (!d.isValid()) continue;
if (d == searchDate) {
dateY = node.y();
} else if (dateY >= 0 && node.y() > dateY) {
// Первый заголовок другой даты после нашей
nextDateY = node.y();
break;
}
}
return {dateY, nextDateY};
};
// ── Шаг 2: тапаем только по кнопкам под заголовком нужной даты ───
QSet<QString> checkedDetails;
for (int attempt = 0; attempt < 15; ++attempt) {
@ -306,6 +332,19 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int
return;
}
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
// Находим Y-диапазон заголовка нужной даты
const auto [dateHeaderY, nextDateHeaderY] = findDateYRange();
if (dateHeaderY < 0) {
qDebug() << "[FetchTransactions] Date header not on screen, scrolling";
AdbUtils::makeSwipe(m_deviceId, device.screenWidth / 2,
device.screenHeight * 3 / 4,
device.screenWidth / 2,
device.screenHeight / 4, 300);
QThread::msleep(1000);
continue;
}
QList<Node> txButtons = xmlScreenParser.findTransactionButtons(20);
bool tapped = false;
@ -315,16 +354,18 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int
return;
}
// Пропускаем кнопки вне Y-диапазона нужной даты
const int btnY = txButtons[bi].y();
if (btnY <= dateHeaderY || btnY >= nextDateHeaderY) continue;
if (btnY <= 0 || btnY >= navBarTop) continue;
const TransactionInfo parsed = xmlScreenParser.parseTransactionButtonText(txButtons[bi].text);
if (qAbs(parsed.amount - m_searchAmount) > 0.01) continue;
// Пропускаем кнопки за экраном
const int btnY = txButtons[bi].y();
if (btnY <= 0 || btnY >= navBarTop) continue;
const Node &visibleBtn = txButtons[bi];
qDebug() << "[FetchTransactions] Amount match:" << parsed.amount
<< "at" << visibleBtn.x() << visibleBtn.y();
<< "at" << visibleBtn.x() << visibleBtn.y()
<< "(dateY:" << dateHeaderY << "-" << nextDateHeaderY << ")";
// Тапаем и ждём деталей
AdbUtils::makeTap(m_deviceId, visibleBtn.x(), visibleBtn.y());
@ -367,23 +408,15 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int
qDebug() << "[FetchTransactions] Already checked:" << dtStr << "- skipping";
AdbUtils::goBack(m_deviceId);
QThread::msleep(500);
continue; // пробуем следующую кнопку
continue;
}
checkedDetails.insert(dtStr);
tapped = true;
// Проверяем дату
if (txTime.isValid() && txTime.date() != searchDate) {
qDebug() << "[FetchTransactions] Wrong date:" << dtStr << "expected" << searchDate;
AdbUtils::goBack(m_deviceId);
QThread::msleep(1000);
continue;
}
// Проверяем время (±1 час)
// Проверяем время (±10 минут)
if (txTime.isValid() && m_searchTime.isValid()) {
const qint64 diffSecs = qAbs(txTime.toUTC().secsTo(m_searchTime.toUTC()));
if (diffSecs > 3600) {
if (diffSecs > 600) {
qDebug() << "[FetchTransactions] Time mismatch:" << dtStr
<< "expected" << searchLocal.toString("dd.MM.yyyy, HH:mm")
<< "diff=" << diffSecs << "sec";
@ -515,7 +548,9 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int
m_error = "Transaction not found: amount=" + QString::number(m_searchAmount, 'f', 2)
+ " date=" + searchDate.toString("dd.MM.yyyy");
qWarning() << "[FetchTransactions]" << m_error;
m_resultScreenshot = AdbUtils::captureScreenshotBytes(m_deviceId);
// Отправляем скриншот списка транзакций, сделанный до начала поиска
m_resultScreenshot = listScreenshot.isEmpty()
? AdbUtils::captureScreenshotBytes(m_deviceId) : listScreenshot;
if (!m_resultScreenshot.isEmpty()) emit screenshotReady(m_resultScreenshot);
}

View File

@ -272,7 +272,74 @@ bool EventDAO::existsActiveByExternalId(const QString &externalId) {
return query.next();
}
bool EventDAO::cancelWaitingByType(const QString &deviceId, EventType type, const QString &bankName) {
QList<EventInfo> EventDAO::cancelWaitingByMaterial(const QString &materialId, EventType type) {
QList<EventInfo> cancelled;
QSqlQuery selectQuery(DatabaseManager::instance().database());
selectQuery.prepare(R"(
SELECT id, external_id, bank_name, type FROM events
WHERE external_id = :material_id AND type = :type AND status = :wait
)");
selectQuery.bindValue(":material_id", materialId);
selectQuery.bindValue(":type", eventTypeToString(type));
selectQuery.bindValue(":wait", eventStatusToString(EventStatus::Wait));
if (selectQuery.exec()) {
while (selectQuery.next()) {
EventInfo e;
e.id = selectQuery.value(0).toInt();
e.externalId = selectQuery.value(1).toString();
e.bankName = selectQuery.value(2).toString();
e.type = eventTypeFromString(selectQuery.value(3).toString());
cancelled.append(e);
}
}
QSqlQuery query(DatabaseManager::instance().database());
query.prepare(R"(
UPDATE events SET status = :cancelled, comment = 'Cancelled: replaced by new task'
WHERE external_id = :material_id AND type = :type AND status = :wait
)");
query.bindValue(":cancelled", eventStatusToString(EventStatus::Error));
query.bindValue(":material_id", materialId);
query.bindValue(":type", eventTypeToString(type));
query.bindValue(":wait", eventStatusToString(EventStatus::Wait));
if (!query.exec()) {
qWarning() << "Ошибка при cancelWaitingByMaterial:" << query.lastError().text();
}
return cancelled;
}
QList<EventInfo> EventDAO::cancelWaitingByType(const QString &deviceId, EventType type, const QString &bankName) {
QList<EventInfo> cancelled;
QSqlQuery selectQuery(DatabaseManager::instance().database());
if (bankName.isEmpty()) {
selectQuery.prepare(R"(
SELECT id, external_id, bank_name, type FROM events
WHERE device_id = :device_id AND type = :type AND status = :wait
)");
} else {
selectQuery.prepare(R"(
SELECT id, external_id, bank_name, type FROM events
WHERE device_id = :device_id AND type = :type AND status = :wait AND bank_name = :bank_name
)");
selectQuery.bindValue(":bank_name", bankName);
}
selectQuery.bindValue(":device_id", deviceId);
selectQuery.bindValue(":type", eventTypeToString(type));
selectQuery.bindValue(":wait", eventStatusToString(EventStatus::Wait));
if (selectQuery.exec()) {
while (selectQuery.next()) {
EventInfo e;
e.id = selectQuery.value(0).toInt();
e.externalId = selectQuery.value(1).toString();
e.bankName = selectQuery.value(2).toString();
e.type = eventTypeFromString(selectQuery.value(3).toString());
cancelled.append(e);
}
}
QSqlQuery query(DatabaseManager::instance().database());
if (bankName.isEmpty()) {
query.prepare(R"(
@ -293,9 +360,8 @@ bool EventDAO::cancelWaitingByType(const QString &deviceId, EventType type, cons
if (!query.exec()) {
qWarning() << "Ошибка при cancelWaitingByType:" << query.lastError().text();
return false;
}
return true;
return cancelled;
}
QList<EventInfo> EventDAO::cancelWaitingByBank(const QString &deviceId, const QString &bankName) {

View File

@ -19,7 +19,9 @@ public:
[[nodiscard]] static bool existsActiveByExternalId(const QString &externalId);
[[nodiscard]] static bool cancelWaitingByType(const QString &deviceId, EventType type, const QString &bankName = {});
[[nodiscard]] static QList<EventInfo> cancelWaitingByType(const QString &deviceId, EventType type, const QString &bankName = {});
[[nodiscard]] static QList<EventInfo> cancelWaitingByMaterial(const QString &materialId, EventType type);
[[nodiscard]] static QList<EventInfo> cancelWaitingByBank(const QString &deviceId, const QString &bankName);

View File

@ -115,9 +115,13 @@ void EventHandler::onTasksReceived(const QJsonArray &tasks) {
continue;
}
// FETCH_PROFILE: отменяем старые ожидающие задачи такого же типа на этом устройстве+банк
// FETCH_PROFILE: отменяем старые ожидающие задачи такого же типа для этого материала
if (eventType == EventType::FetchProfile) {
EventDAO::cancelWaitingByType(account.deviceId, EventType::FetchProfile, bankName);
const QList<EventInfo> cancelled = EventDAO::cancelWaitingByMaterial(materialId, EventType::FetchProfile);
for (const EventInfo &c : cancelled) {
sendTaskError(eventTypeToString(c.type), c.externalId, c.bankName, "Cancelled: replaced by new task");
EventDAO::markSentToServer(c.id);
}
}
EventInfo event;
@ -396,6 +400,18 @@ void EventHandler::updateEventThread(const QString &key, const EventInfo &event,
}
m_threads.remove(key);
// Убиваем банковское приложение на устройстве после завершения задачи
{
const MaterialInfo acc = MaterialDAO::getAccountById(event.accountId);
const BankProfileInfo app = BankProfileDAO::getApplication(event.deviceId, acc.appCode);
if (!app.package.isEmpty()) {
const DeviceInfo device = DeviceDAO::getDeviceById(event.deviceId);
const QString adbSerial = device.adbSerial.isEmpty() ? event.deviceId : device.adbSerial;
AdbUtils::tryToKillApplication(adbSerial, app.package);
qDebug() << "[EventHandler] Killed app" << app.package << "on device" << adbSerial;
}
}
// Сразу пробуем запустить следующую задачу для этого устройства
processNextTask(key);
}
@ -463,6 +479,9 @@ void EventHandler::startThreadForTask(const MaterialInfo &account, const EventIn
connect(w, &Dushanbe::GetLastTransactionsScript::transactionParsed, this,
[this, key](const QJsonObject &tx) { m_parsedTxs[key] = tx; },
Qt::DirectConnection);
connect(w, &Dushanbe::GetLastTransactionsScript::screenshotReady, this,
[this, key](const QByteArray &s) { m_screenshots[key] = s; },
Qt::DirectConnection);
}
else qWarning() << "[EventHandler] FETCH_TRANSACTIONS: unknown appCode:" << appCode;
}
@ -519,6 +538,18 @@ void EventHandler::startThreadForTask(const MaterialInfo &account, const EventIn
sendTaskError(eventTypeToString(event.type), event.externalId, event.bankName, "Script timeout (5 min)");
EventDAO::markSentToServer(event.id);
// Убиваем банковское приложение на устройстве после таймаута
{
const MaterialInfo acc = MaterialDAO::getAccountById(event.accountId);
const BankProfileInfo app = BankProfileDAO::getApplication(event.deviceId, acc.appCode);
if (!app.package.isEmpty()) {
const DeviceInfo device = DeviceDAO::getDeviceById(event.deviceId);
const QString adbSerial = device.adbSerial.isEmpty() ? event.deviceId : device.adbSerial;
AdbUtils::tryToKillApplication(adbSerial, app.package);
qDebug() << "[EventHandler] Killed app" << app.package << "on device" << adbSerial << "(timeout)";
}
}
processNextTask(key);
});

View File

@ -1,27 +1,7 @@
{
"bank_profiles": [
{
"id": "dd0ba55d-0a02-40ab-8b31-cf6a7529269f",
"bank_name": "dushanbe_city_bank",
"state": "enabled",
"phone_number": "+992185555519",
"first_name": "Мехрубон",
"middle_name": null,
"last_name": "Имомкулзода",
"currency": 972,
"device_id": "986cb259-dfec-4421-8735-4fb126cd1917",
"device_label": "Pixel 7",
"materials": [
{
"id": "61d967d1-8d3e-4d05-ba01-8c8ab327b945",
"state": "enabled",
"card_number": "9762000207113258",
"currency": 972
}
]
},
{
"id": "86bcdee7-1c64-40e3-bc8f-132d153a9d28",
"id": "85f577bc-20be-40fd-ab47-315af3aadfb9",
"bank_name": "ozon",
"state": "enabled",
"phone_number": "+79538038318",
@ -29,33 +9,19 @@
"middle_name": null,
"last_name": "Терехина",
"currency": 643,
"device_id": "986cb259-dfec-4421-8735-4fb126cd1917",
"device_id": "c9d3ad7e-659d-4adf-815e-9f234e743078",
"device_label": "Pixel 7",
"materials": [
{
"id": "30bc106c-6935-4f4b-85f3-f1a06adc5c13",
"id": "e6c6e12a-3e4e-444c-9e11-62543fb5f4f9",
"state": "enabled",
"card_number": "2204321035207919",
"currency": 643
}
]
},
{
"id": "e8c5f3b2-c710-421c-b304-93353ccfbfd5",
"bank_name": "ozon",
"state": "enabled",
"phone_number": "+79855532896",
"first_name": "Владислав",
"middle_name": null,
"last_name": "Касаткин",
"currency": 643,
"device_id": "ec6397fb-89b1-41e1-9b6b-303d6f1e943f",
"device_label": "TECNO KM4",
"materials": [
},
{
"id": "49001bb5-3b24-478d-b913-c2242aea0732",
"id": "39592a22-3e79-4438-89d7-778aea72a5e9",
"state": "enabled",
"card_number": "2204320439130982",
"card_number": "2204321086109865",
"currency": 643
}
]

View File

@ -160,30 +160,92 @@ 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'<node\s[^>]*?text="([^"]*)"'
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())
if (y2 - y1) < 50 or "Расход" in text or "Доход" in text:
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]*)", text)
if not am:
continue
sign = -1 if am.group(1) in ("-", "") else 1
amount = sign * int(re.sub(r"[\s\u202f]", "", am.group(2)))
rows.append({"text": text, "cx": (x1+x2)//2, "cy": (y1+y2)//2, "amount": amount})
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
@ -266,71 +328,181 @@ def collect_tasks(serial, bank, mid, device_tz, kopecks, prefix):
if is_ozon:
print("Detected: Ozon")
headers = parse_ozon_date_headers(xml, device_tz)
baseline = parse_ozon_rows(xml)
print(f"rows: {len(baseline)}")
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']:+d} ₽ | {r['text'][:50]}")
print(f" {i}. ({r['cx']},{r['cy']}) {r['amount']:+d} date={r.get('date','-')} | {r['text'][:40]}")
for idx in range(1, len(baseline) + 1):
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(f"[{idx}] left list, stop"); break
print("Left list, stop"); break
headers = parse_ozon_date_headers(xml, device_tz)
rows = parse_ozon_rows(xml)
if idx > len(rows):
break
row = rows[idx - 1]
print(f"\n[{idx}] tap ({row['cx']},{row['cy']}) {row['amount']:+d}")
tap(serial, row["cx"], row["cy"])
time.sleep(2.5)
d_xml = dump_xml(serial)
date_s, time_s = extract_ozon_detail_time(d_xml)
print(f" {date_s} {time_s}")
back(serial); time.sleep(1.3)
if not date_s or not time_s:
print(" SKIP"); continue
dt = datetime.strptime(f"{date_s} {time_s}", "%d.%m.%Y %H:%M:%S").replace(tzinfo=device_tz)
cat = dt.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
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": row["amount"] * kopecks, "created_at": cat},
"_tag": f"{prefix}_{idx:02d}_{label}"})
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']:+d}")
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": 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"rows: {len(baseline)}")
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']}")
for idx in range(1, len(baseline) + 1):
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(f"[{idx}] left list, stop"); break
print("Left list, stop"); break
rows = parse_dushanbe_rows(xml)
if idx > len(rows):
break
row = rows[idx - 1]
print(f"\n[{idx}] 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 = int(float(amt_s)) 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"
dt = datetime.strptime(f"{date_s} {time_s}", fmt).replace(tzinfo=device_tz)
cat = dt.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
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": amount * kopecks, "created_at": cat},
"_tag": f"{prefix}_{idx:02d}_{label}"})
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 = int(float(amt_s)) 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"
dt = datetime.strptime(f"{date_s} {time_s}", fmt).replace(tzinfo=device_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": 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
@ -409,7 +581,8 @@ def main():
serial = args.serial or o["serial"]
bank = o["bank_name"]
mid = o["material_id"]
prefix = f"{o['label'].lower().replace(' ', '_')}_{bank}"
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]}…)")
@ -429,7 +602,8 @@ def main():
# Remove old files for scanned prefixes
scanned_prefixes = set()
for o in to_scan:
scanned_prefixes.add(f"{o['label'].lower().replace(' ', '_')}_{o['bank_name']}")
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_"):

View File

@ -170,7 +170,8 @@ def generate(materials: list, out_dir: Path):
for row in materials:
dev_short = device_label(row["device_name"]).lower().replace(" ", "_")
bank = row["bank_name"]
fname = f"{dev_short}_{bank}_fetch_profile.json"
card = row["last_numbers"] or row["material_id"][:8]
fname = f"{dev_short}_{bank}_{card}_fetch_profile.json"
task = {
"bank_name": bank,
"material_id": row["material_id"],
@ -183,7 +184,7 @@ def generate(materials: list, out_dir: Path):
print(f" wrote scenarios/{fname}")
devices_seen.setdefault(dev_short, []).append(
f"{bank} ({row['material_id'][:8]}…)"
f"{bank}/{card} ({row['material_id'][:8]}…)"
)
# Summary

View File

@ -45,6 +45,7 @@ import threading
import time
import uuid
from collections import defaultdict, deque
from datetime import datetime, timedelta, timezone
from pathlib import Path
from flask import Flask, Response, jsonify, request
@ -297,6 +298,22 @@ def bank_profile_toggle(bp_id, state):
return ok({"id": bp_id, "state": state})
@app.patch("/api/v1/profile/bank_profile/<bp_id>")
def bank_profile_update(bp_id):
body = request.get_json(silent=True) or {}
log("PATCH /bank_profile", {"id": bp_id, **body})
for bp in _bank_profiles:
if bp.get("id") == bp_id:
bp.update(body)
return ok(bp)
# Не найден — создаём
bp = dict(body)
bp["id"] = bp_id
bp.setdefault("materials", [])
_bank_profiles.append(bp)
return ok(bp)
@app.post("/api/v1/profile/material")
def material_create():
body = request.get_json(silent=True) or {}