#include "GetLastTransactionsScript.h" #include #include #include #include #include #include "adb/AdbUtils.h" #include "dao/MaterialDAO.h" #include "dao/BankProfileDAO.h" #include "dao/DeviceDAO.h" #include "dao/TransactionDAO.h" #include "AppLogger.h" #include static QString recognizeTextFromImage(const QByteArray &pngData) { auto *api = new tesseract::TessBaseAPI(); if (api->Init(QCoreApplication::applicationDirPath().toUtf8().constData(), "rus+eng")) { qWarning() << "[Dushanbe::OCR] Failed to init Tesseract"; delete api; return {}; } Pix *pix = pixReadMem(reinterpret_cast(pngData.constData()), static_cast(pngData.size())); if (!pix) { api->End(); delete api; return {}; } api->SetImage(pix); char *text = api->GetUTF8Text(); QString result = QString::fromUtf8(text).trimmed(); delete[] text; pixDestroy(&pix); api->End(); delete api; return result; } // Точечный OCR: находит "Номер операции" через общий OCR, затем кропает строку правее // и распознаёт только цифры. Работает с любым разрешением экрана. static QString recognizeOperationId(const QByteArray &pngData) { Pix *pix = pixReadMem(reinterpret_cast(pngData.constData()), static_cast(pngData.size())); if (!pix) return {}; const int w = pixGetWidth(pix); const int h = pixGetHeight(pix); // 1. Полный OCR с координатами слов — ищем "операции" auto *api = new tesseract::TessBaseAPI(); if (api->Init(QCoreApplication::applicationDirPath().toUtf8().constData(), "rus+eng")) { pixDestroy(&pix); delete api; return {}; } api->SetImage(pix); api->Recognize(nullptr); int opLineY = -1; int opLineH = 0; bool prevWasNomer = false; tesseract::ResultIterator *ri = api->GetIterator(); if (ri) { do { const char *word = ri->GetUTF8Text(tesseract::RIL_WORD); if (word) { const QString w_str = QString::fromUtf8(word); delete[] word; if (w_str.contains(QString::fromUtf8("омер")) || w_str.contains(QString::fromUtf8("omep"))) { prevWasNomer = true; continue; } if (prevWasNomer && (w_str.contains(QString::fromUtf8("операции")) || w_str.contains(QString::fromUtf8("onepaции")))) { int x1, y1, x2, y2; ri->BoundingBox(tesseract::RIL_WORD, &x1, &y1, &x2, &y2); opLineY = y1; opLineH = y2 - y1; break; } prevWasNomer = false; } } while (ri->Next(tesseract::RIL_WORD)); } api->End(); delete api; if (opLineY < 0) { pixDestroy(&pix); return {}; } // 2. Кропаем правую половину этой строки и OCR только цифры const int cropY = qMax(0, opLineY - opLineH / 2); const int cropH = opLineH * 2; BOX *box = boxCreate(w / 2, cropY, w / 2, qMin(cropH, h - cropY)); Pix *cropped = pixClipRectangle(pix, box, nullptr); boxDestroy(&box); pixDestroy(&pix); if (!cropped) return {}; auto *api2 = new tesseract::TessBaseAPI(); if (api2->Init(QCoreApplication::applicationDirPath().toUtf8().constData(), "eng")) { pixDestroy(&cropped); delete api2; return {}; } api2->SetVariable("tessedit_char_whitelist", "0123456789"); api2->SetPageSegMode(tesseract::PSM_SINGLE_LINE); api2->SetImage(cropped); char *text = api2->GetUTF8Text(); QString result = QString::fromUtf8(text).trimmed(); delete[] text; pixDestroy(&cropped); api2->End(); delete api2; result.remove(QRegularExpression("[^0-9]")); if (result.length() >= 8) { qDebug() << "[Dushanbe::OCR] Precise operation ID:" << result; return result; } return {}; } struct OcrTransactionDetail { QString date; QString time; QString operationId; QString provider; QString senderAccount; QString receiverAccount; double amount = 0.0; double fee = 0.0; QString status; }; static OcrTransactionDetail parseDetailFromOcr(const QString &ocrText) { OcrTransactionDetail d; const QStringList lines = ocrText.split('\n'); for (const QString &rawLine : lines) { const QString line = rawLine.trimmed(); // "Дата операции: 05.04.2026" или "Дата операции:" на отдельной строке if (line.contains(QString::fromUtf8("Дата операции"))) { QRegularExpression re(R"((\d{2}\.\d{2}\.\d{4}))"); if (auto m = re.match(line); m.hasMatch()) d.date = m.captured(1); } // "Время операции: 18:19:54" else if (line.contains(QString::fromUtf8("Время операции"))) { QRegularExpression re(R"((\d{2}:\d{2}:\d{2}))"); if (auto m = re.match(line); m.hasMatch()) d.time = m.captured(1); } // "Номер операции: 1598279763" else if (line.contains(QString::fromUtf8("омер операции"))) { QRegularExpression re(R"((\d{5,}))"); if (auto m = re.match(line); m.hasMatch()) d.operationId = m.captured(1); } // "Поставщик: DC (по номеру карты)" else if (line.contains(QString::fromUtf8("Поставщик"))) { QRegularExpression re(R"(Поставщик[:\s]*(.+))"); if (auto m = re.match(line); m.hasMatch()) d.provider = m.captured(1).trimmed(); } // "Счет отправителя: 9762***3258" else if (line.contains(QString::fromUtf8("Счет отправителя")) || line.contains(QString::fromUtf8("чет отправителя"))) { QRegularExpression re(R"((\d[\d*]+))"); if (auto m = re.match(line); m.hasMatch()) d.senderAccount = m.captured(1); } // "Счет получателя: 9762000186263942" или "992111100664" else if (line.contains(QString::fromUtf8("Счет получателя")) || line.contains(QString::fromUtf8("чет получателя"))) { QRegularExpression re(R"((\d{6,}))"); if (auto m = re.match(line); m.hasMatch()) d.receiverAccount = m.captured(1); } // "Сумма операции: 1.00" else if (line.contains(QString::fromUtf8("Сумма операции")) || line.contains(QString::fromUtf8("умма операции"))) { QRegularExpression re(R"((\d+[\.,]\d{2}))"); if (auto m = re.match(line); m.hasMatch()) { QString val = m.captured(1); val.replace(',', '.'); d.amount = val.toDouble(); } } // "Комиссия: 0.00" else if (line.contains(QString::fromUtf8("Комиссия")) || line.contains(QString::fromUtf8("омиссия"))) { QRegularExpression re(R"((\d+[\.,]\d{2}))"); if (auto m = re.match(line); m.hasMatch()) { QString val = m.captured(1); val.replace(',', '.'); d.fee = val.toDouble(); } } // "Статус: Успешный" else if (line.contains(QString::fromUtf8("Статус"))) { if (line.contains(QString::fromUtf8("Успешн"))) { d.status = QString::fromUtf8("Успешный"); } } } // Fallback: если дата/время не найдены в "ключ: значение", ищем паттерном if (d.date.isEmpty()) { QRegularExpression re(R"((\d{2}\.\d{2}\.\d{4}))"); if (auto m = re.match(ocrText); m.hasMatch()) d.date = m.captured(1); } if (d.time.isEmpty()) { QRegularExpression re(R"((\d{2}:\d{2}:\d{2}))"); auto it = re.globalMatch(ocrText); QStringList times; while (it.hasNext()) times.append(it.next().captured(1)); if (times.size() >= 2) d.time = times[1]; else if (times.size() == 1) d.time = times[0]; } if (ocrText.contains(QString::fromUtf8("Успешн"))) d.status = QString::fromUtf8("Успешный"); return d; } namespace Dushanbe { GetLastTransactionsScript::GetLastTransactionsScript( QString deviceId, QString appCode, double searchAmount, QDateTime searchTime, QObject *parent ) : CommonScript(parent), m_deviceId(std::move(deviceId)), m_appCode(std::move(appCode)), m_searchAmount(searchAmount), m_searchTime(std::move(searchTime)) { } GetLastTransactionsScript::~GetLastTransactionsScript() = default; void GetLastTransactionsScript::doStart() { DeviceInfo device = DeviceDAO::getDeviceById(m_deviceId); if (device.id.isEmpty()) device = DeviceDAO::findByAdbSerial(m_deviceId); if (!device.adbSerial.isEmpty()) m_deviceId = device.adbSerial; const BankProfileInfo app = BankProfileDAO::getApplication(device.id, m_appCode); if (device.id.isEmpty() || app.id == -1) { m_error = "Device or app not found: " + m_deviceId + " " + m_appCode; qCritical() << m_error; emit finishedWithResult(m_error); return; } xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter)); if (!xmlScreenParser.isHomeScreen()) { qDebug() << "[Dushanbe::GetLastTransactions] Not on home screen, restarting app..."; if (!runAppAndGoToHomeScreen(m_deviceId, app.package, app.pinCode, device.screenWidth, device.screenHeight)) { m_error = "Cannot reach home screen: " + device.name + " (" + m_deviceId + ")"; qWarning() << m_error; AppLogger::log("dushanbe/GetLastTransactions", m_deviceId, m_error, {}, "FETCH_TRANSACTIONS"); AdbUtils::tryToKillApplication(m_deviceId, app.package); emit finishedWithResult(m_error); return; } xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter)); } // Тапаем "История" в нижнем меню Node historyBtn = xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("История")); if (historyBtn.x() == 0 && historyBtn.y() == 0) { m_error = "Cannot find History button: " + m_deviceId; qWarning() << m_error; AdbUtils::tryToKillApplication(m_deviceId, app.package); emit finishedWithResult(m_error); return; } AdbUtils::makeTap(m_deviceId, historyBtn.x(), historyBtn.y()); QThread::msleep(3000); // Ждём экран истории bool historyLoaded = false; for (int attempt = 0; attempt < 10; ++attempt) { xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter)); // Проверяем диалог "Ошибка" — нажимаем "ОК" и обновляем свайпом вниз if (!xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("Ошибка")).isEmpty()) { Node okBtn = xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("ОК")); if (!okBtn.isEmpty()) { qDebug() << "[Dushanbe::GetLastTransactions] Error dialog detected, tapping OK"; AdbUtils::makeTap(m_deviceId, okBtn.x(), okBtn.y()); QThread::msleep(1000); } continue; } if (xmlScreenParser.isHistoryScreen()) { historyLoaded = true; break; } QThread::msleep(1500); } if (!historyLoaded) { m_error = "History screen not loaded: " + m_deviceId; qWarning() << m_error; AdbUtils::tryToKillApplication(m_deviceId, app.package); emit finishedWithResult(m_error); return; } // Переключаемся на вкладку "Выписка" (по умолчанию открыта "Операции") Node vypiskaBtn = xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("Выписка")); if (vypiskaBtn.isEmpty()) { m_error = "Cannot find 'Выписка' tab: " + m_deviceId; qWarning() << m_error; AdbUtils::tryToKillApplication(m_deviceId, app.package); emit finishedWithResult(m_error); return; } qDebug() << "[Dushanbe::GetLastTransactions] Switching to 'Выписка' tab"; AdbUtils::makeTap(m_deviceId, vypiskaBtn.x(), vypiskaBtn.y()); QThread::msleep(3000); // Сохраняем дамп Выписки для анализа формата xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter)); // Закрываем возможный диалог "Ошибка" на Выписке if (!xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("Ошибка")).isEmpty()) { Node okBtn = xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("ОК")); if (!okBtn.isEmpty()) { qDebug() << "[Dushanbe::GetLastTransactions] Error dialog on Выписка, tapping OK"; AdbUtils::makeTap(m_deviceId, okBtn.x(), okBtn.y()); QThread::msleep(1000); xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter)); } } // Находим accountId int accountId = -1; const QList accounts = MaterialDAO::getAccounts(device.id, m_appCode); if (!accounts.isEmpty()) { accountId = accounts.first().id; } else { qWarning() << "[Dushanbe::GetLastTransactions] No account found"; } 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"); const int maxScrolls = 10; struct VypiskaItem { double amount = 0.0; QString time; // "21:31:21" QString date; // "05.04.2026" Node node; }; for (int scroll = 0; scroll <= maxScrolls; ++scroll) { xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter)); // 1. Собираем список дат-групп из родительского ImageView "Обновлено: ...\n05.04.2026\n..." // Поддерживаем "DD.MM.YYYY", "Сегодня", "Вчера" const QDate todayLocal = QDateTime::currentDateTimeUtc().toTimeZone(tjTz).date(); QStringList groupDates; static const QRegularExpression reDate(R"(^\d{2}\.\d{2}\.\d{4}$)"); for (const Node &n : xmlScreenParser.findAllNodes()) { if (n.className != "android.widget.ImageView") continue; if (!n.contentDesc.startsWith(QString::fromUtf8("Обновлено"))) continue; for (const QString &line : n.contentDesc.split('\n')) { const QString t = line.trimmed(); if (reDate.match(t).hasMatch()) { groupDates.append(t); } else if (t.compare(QString::fromUtf8("Сегодня"), Qt::CaseInsensitive) == 0) { groupDates.append(todayLocal.toString("dd.MM.yyyy")); } else if (t.compare(QString::fromUtf8("Вчера"), Qt::CaseInsensitive) == 0) { groupDates.append(todayLocal.addDays(-1).toString("dd.MM.yyyy")); } } break; } // 2. Собираем View-транзакции (формат Выписки) static const QRegularExpression reTime(R"(\d{2}:\d{2}:\d{2})"); QList 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; VypiskaItem it; bool ok = false; it.amount = lines[2].trimmed().toDouble(&ok); if (!ok) continue; it.time = last; it.node = n; items.append(it); } // 3. Сортируем по Y и группируем по разрывам (> 50px → новая группа-день) std::sort(items.begin(), items.end(), [](const VypiskaItem &a, const VypiskaItem &b) { return a.node.y1() < b.node.y1(); }); int groupIdx = 0; int prevY2 = -1; for (VypiskaItem &it : items) { if (prevY2 >= 0 && it.node.y1() - prevY2 > 50) ++groupIdx; if (groupIdx < groupDates.size()) it.date = groupDates[groupIdx]; prevY2 = it.node.y2(); } qDebug() << "[Dushanbe::GetLastTransactions] Vypiska items:" << items.size() << "groups:" << groupDates; for (const auto &tx : items) { if (qAbs(tx.amount - m_searchAmount) > 0.01) continue; qDebug() << "[Dushanbe::GetLastTransactions] Amount match:" << tx.amount << "date:" << tx.date << "time:" << tx.time; // Проверка времени по HH:MM:SS (±1 час, с учётом wraparound полуночи) if (m_searchTime.isValid() && !tx.time.isEmpty()) { const QTime listTime = QTime::fromString(tx.time, "HH:mm:ss"); 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 qDebug() << "[Dushanbe::GetLastTransactions] Time pre-filter skip:" << tx.time << "vs" << searchLocalTime.toString("HH:mm:ss") << "diff=" << diffSecs << "sec"; continue; } } } Node txNode = tx.node; if (txNode.x() == 0 && txNode.y() == 0) { qWarning() << "[Dushanbe::GetLastTransactions] Cannot find node for transaction"; continue; } AdbUtils::makeTap(m_deviceId, txNode.x(), txNode.y()); QThread::msleep(3000); // Ждём экран деталей Выписки — ImageView с content-desc начинающимся с "Успешный платеж" OcrTransactionDetail detail; bool detailLoaded = false; for (int attempt = 0; attempt < 10; ++attempt) { xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter)); for (const Node &n : xmlScreenParser.findAllNodes()) { if (!n.contentDesc.contains(QString::fromUtf8("Успешный платеж"))) continue; if (!n.contentDesc.contains(QString::fromUtf8("Дата:"))) continue; const QStringList lines = n.contentDesc.split('\n'); for (int i = 0; i < lines.size(); ++i) { const QString key = lines[i].trimmed(); if (i + 1 >= lines.size()) continue; const QString val = lines[i + 1].trimmed(); if (key == QString::fromUtf8("Получатель:")) { detail.receiverAccount = val; } else if (key == QString::fromUtf8("Сумма:")) { detail.amount = val.toDouble(); } else if (key == QString::fromUtf8("Дата:")) { detail.date = val; } else if (key == QString::fromUtf8("Время:")) { detail.time = val; } } if (n.contentDesc.contains(QString::fromUtf8("Успешный"))) { detail.status = QString::fromUtf8("Успешный"); } detailLoaded = true; break; } if (detailLoaded) break; QThread::msleep(1500); } if (!detailLoaded) { qWarning() << "[Dushanbe::GetLastTransactions] Detail screen not loaded"; AdbUtils::goBack(m_deviceId); QThread::msleep(1000); continue; } qDebug() << "[Dushanbe::GetLastTransactions] Detail parsed:" << "date=" << detail.date << "time=" << detail.time << "amount=" << detail.amount << "receiver=" << detail.receiverAccount; // Проверяем время ±1 час if (m_searchTime.isValid() && !detail.date.isEmpty() && !detail.time.isEmpty()) { QDateTime txTime = QDateTime::fromString( detail.date + " " + detail.time, "dd.MM.yyyy HH:mm:ss"); if (!txTime.isValid()) { txTime = QDateTime::fromString(detail.date + " " + detail.time, "dd.MM.yyyy HH:mm"); } 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"; AdbUtils::goBack(m_deviceId); QThread::msleep(1000); continue; } } } // Нашли! Сохраняем qDebug() << "[Dushanbe::GetLastTransactions] FOUND:" << "amount=" << detail.amount << "date=" << detail.date << detail.time << "opId=" << detail.operationId << "receiver=" << detail.receiverAccount << "status=" << detail.status; if (accountId != -1) { TransactionInfo saveTx; saveTx.accountId = accountId; saveTx.amount = detail.amount; saveTx.fee = detail.fee; saveTx.bankName = m_appCode; saveTx.bankTime = detail.date + " " + detail.time; saveTx.bankTrExternalId = "dushanbe_" + detail.date + "_" + detail.time; saveTx.description = detail.provider; saveTx.phone = ""; saveTx.name = detail.receiverAccount; saveTx.status = detail.status.contains(QString::fromUtf8("Успешн")) ? TransactionStatus::Complete : TransactionStatus::Unknown; saveTx.type = TransactionType::Phone; saveTx.timestamp = QDateTime::currentDateTimeUtc(); QDateTime txTime = QDateTime::fromString( detail.date + " " + detail.time, "dd.MM.yyyy HH:mm:ss"); if (txTime.isValid()) { txTime.setTimeZone(tjTz); saveTx.completeTime = txTime; } TransactionDAO::insertTransactionIfNotExists(saveTx); } // Назад к истории Node nazadBtn = xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("Назад")); if (nazadBtn.x() > 0 && nazadBtn.y() > 0) { AdbUtils::makeTap(m_deviceId, nazadBtn.x(), nazadBtn.y()); } else { AdbUtils::goBack(m_deviceId); } QThread::msleep(1000); emit finishedWithResult(m_error); // m_error пустой — успех return; } // Скроллим вниз if (scroll < maxScrolls) { const int x = device.screenWidth / 2; const int fromY = device.screenHeight * 3 / 4; const int toY = device.screenHeight / 4; AdbUtils::makeSwipe(m_deviceId, x, fromY, x, toY); QThread::msleep(2000); } } m_error = "Transaction not found: amount=" + QString::number(m_searchAmount, 'f', 2) + " time=" + m_searchTime.toString(Qt::ISODate); qWarning() << "[Dushanbe::GetLastTransactions]" << m_error; // Возвращаемся на главную Node glavnayaBtn = xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("Главная")); if (glavnayaBtn.x() > 0 && glavnayaBtn.y() > 0) { AdbUtils::makeTap(m_deviceId, glavnayaBtn.x(), glavnayaBtn.y()); } else { AdbUtils::goBack(m_deviceId); } QThread::msleep(1000); emit finishedWithResult(m_error); } } // namespace Dushanbe