#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); } // Свайп вниз для обновления (pull-to-refresh) const int x = device.screenWidth / 2; AdbUtils::makeSwipe(m_deviceId, x, device.screenHeight / 4, x, device.screenHeight * 3 / 4); QThread::msleep(3000); 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; } // Находим 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 QTimeZone tjTz("Asia/Dushanbe"); // UTC+5 const int maxScrolls = 10; for (int scroll = 0; scroll <= maxScrolls; ++scroll) { xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter)); const QList items = xmlScreenParser.parseTransactionItems(); for (const auto &tx : items) { if (qAbs(tx.amount - m_searchAmount) > 0.01) continue; qDebug() << "[Dushanbe::GetLastTransactions] Amount match:" << tx.amount << "phone:" << tx.phone << "time:" << tx.time; // Предварительная проверка времени из списка (±1 час) if (m_searchTime.isValid() && !tx.time.isEmpty()) { const QTime listTime = QTime::fromString(tx.time, "HH:mm:ss"); const QTime searchLocal = m_searchTime.toTimeZone(tjTz).time(); if (listTime.isValid() && searchLocal.isValid()) { const int diffSecs = qAbs(listTime.secsTo(searchLocal)); if (diffSecs > 3600 && diffSecs < 82800) { // 82800 = 24h - 1h (для полуночи) qDebug() << "[Dushanbe::GetLastTransactions] Time pre-filter skip:" << tx.time << "vs" << searchLocal.toString("HH:mm:ss") << "diff=" << diffSecs << "sec"; continue; } } } // Ищем соответствующий node чтобы по нему тапнуть Node txNode; for (const Node &node : xmlScreenParser.findAllNodes()) { if (node.className != "android.widget.ImageView") continue; if (!node.contentDesc.contains("***")) continue; // Проверяем что содержит нужную сумму и время const QString amountStr = QString::number(tx.amount, 'f', 2); if (node.contentDesc.contains(amountStr) && node.contentDesc.contains(tx.time)) { txNode = node; break; } } 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); // Ждём экран деталей — ищем кнопку "Назад" (признак экрана детали) bool detailLoaded = false; for (int attempt = 0; attempt < 10; ++attempt) { xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter)); if (!xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("Назад")).isEmpty() || !xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("Поделиться")).isEmpty()) { detailLoaded = true; break; } QThread::msleep(1500); } if (!detailLoaded) { qWarning() << "[Dushanbe::GetLastTransactions] Detail screen not loaded"; AdbUtils::goBack(m_deviceId); QThread::msleep(1000); continue; } // OCR скриншота деталей const QByteArray detailScreenshot = AdbUtils::captureScreenshotBytes(m_deviceId); const QString ocrText = recognizeTextFromImage(detailScreenshot); qDebug() << "[Dushanbe::GetLastTransactions] OCR result:" << ocrText; OcrTransactionDetail detail = parseDetailFromOcr(ocrText); // Точечный OCR для номера операции (кроп + только цифры) const QString preciseOpId = recognizeOperationId(detailScreenshot); if (!preciseOpId.isEmpty()) { detail.operationId = preciseOpId; } // Проверяем время ±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 = detail.operationId; saveTx.description = detail.provider; saveTx.phone = detail.receiverAccount; 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