1
0
forked from BRT/arc
arc/android/dushanbe/GetLastTransactionsScript.cpp

535 lines
23 KiB
C++
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.

#include "GetLastTransactionsScript.h"
#include <QRegularExpression>
#include <QThread>
#include <QTimeZone>
#include <tesseract/baseapi.h>
#include <leptonica/allheaders.h>
#include "adb/AdbUtils.h"
#include "dao/MaterialDAO.h"
#include "dao/BankProfileDAO.h"
#include "dao/DeviceDAO.h"
#include "dao/TransactionDAO.h"
#include "AppLogger.h"
#include <QCoreApplication>
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<const l_uint8 *>(pngData.constData()),
static_cast<size_t>(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<const l_uint8 *>(pngData.constData()),
static_cast<size_t>(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;
}
// Переключаемся на вкладку "Выписка" (по умолчанию открыта "Операции")
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);
}
const int x = device.screenWidth / 2;
AdbUtils::makeSwipe(m_deviceId, x, device.screenHeight / 4, x, device.screenHeight * 3 / 4);
QThread::msleep(3000);
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
}
// Находим accountId
int accountId = -1;
const QList<MaterialInfo> 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<ScreenXmlParser::TransactionItem> 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 = "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