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

920 lines
43 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 <QJsonDocument>
#include <QJsonObject>
#include <QProcess>
#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 "time/DateUtils.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() << "[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) {
qWarning() << "[OCR] Failed to read image";
api->End();
delete api;
return {};
}
Pix *gray = pixConvertRGBToGray(pix, 0.0f, 0.0f, 0.0f);
pixDestroy(&pix);
if (!gray) {
qWarning() << "[OCR] Failed to convert to grayscale";
api->End();
delete api;
return {};
}
api->SetImage(gray);
api->SetSourceResolution(300);
char *text = api->GetUTF8Text();
QString result = QString::fromUtf8(text).trimmed();
delete[] text;
pixDestroy(&gray);
api->End();
delete api;
return result;
}
static QString extractTransactionId(const QString &ocrText) {
// ID может быть на одной строке: "ID операции B606513042211200000120011700501"
// или с дефисами: "ID операции po-3140eb01-002c-5000-8000-033396b637e1"
// или разбит на 2 строки: "ID ... po-3140eb01-002c-5000-8000-033396b\n ... 637e1"
QRegularExpression reId(R"(ID\s+операции\s*=?\s*([A-Za-z0-9\-]+(?:[^\S\n]+[A-Za-z0-9\-]+)?))");
if (auto m = reId.match(ocrText); m.hasMatch()) {
QString id = m.captured(1);
id.remove(' ');
id.remove(QChar(0x00A0));
return id;
}
QRegularExpression reRef(R"(№\s*(Ф-[\d-]+))");
if (auto m = reRef.match(ocrText); m.hasMatch()) return m.captured(1);
return {};
}
namespace Ozon {
GetLastTransactionsScript::GetLastTransactionsScript(
QString deviceId,
QString appCode,
int maxCount,
double searchAmount,
QDateTime searchTime,
QObject *parent
) : CommonScript(parent),
m_deviceId(std::move(deviceId)),
m_appCode(std::move(appCode)),
m_maxCount(maxCount),
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;
}
if (!navigateToTransactionList(device, app)) {
emit finishedWithResult(m_error);
return;
}
// Находим accountId
int accountId = -1;
const QList<MaterialInfo> accounts = MaterialDAO::getAccounts(device.id, m_appCode);
if (!accounts.isEmpty()) {
accountId = accounts.first().id;
} else {
qWarning() << "[GetLastTransactions] No account found for" << device.id << m_appCode;
}
// Выбор режима: поиск конкретной транзакции или сканирование последних
if (qAbs(m_searchAmount) > 0.01) {
searchTransaction(device, accountId);
} else {
scanRecentTransactions(device, accountId);
}
emit finishedWithResult(m_error);
}
bool GetLastTransactionsScript::navigateToTransactionList(const DeviceInfo &device, const BankProfileInfo &app) {
// Хелпер: если на экране ошибка загрузки ("Обновить"), тапаем один раз
auto tryRefreshIfError = [&]() {
// Кнопка "Обновить" — Button с content-desc="Обновить" (text пустой)
Node refreshBtn = xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("Обновить"));
if (refreshBtn.x() > 0 && refreshBtn.y() > 0) {
qDebug() << "[GetLastTransactions] Page load error, tapping 'Обновить' at"
<< refreshBtn.x() << refreshBtn.y();
AdbUtils::makeTap(m_deviceId, refreshBtn.x(), refreshBtn.y());
QThread::msleep(5000);
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
}
};
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
tryRefreshIfError();
// Закрываем рекламный bottom sheet
if (Node adNode = xmlScreenParser.tryToFindAdBanner(); !adNode.isEmpty()) {
AdbUtils::makeTap(m_deviceId, adNode.x(), adNode.y());
QThread::msleep(1000);
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
if (Node stillAd = xmlScreenParser.tryToFindAdBanner(); !stillAd.isEmpty()) {
AdbUtils::goBack(m_deviceId);
QThread::msleep(1000);
}
}
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
// Если уже на экране операций — скроллим наверх и готово
if (xmlScreenParser.isTransactionListScreen()) {
qDebug() << "[GetLastTransactions] Already on transaction list screen, scrolling to top";
// Несколько быстрых свайпов вверх чтобы вернуться к началу списка
for (int i = 0; i < 5; ++i) {
AdbUtils::makeSwipe(m_deviceId, device.screenWidth / 2,
device.screenHeight / 3,
device.screenWidth / 2,
device.screenHeight * 2 / 3, 200);
QThread::msleep(500);
}
QThread::msleep(1000);
return true;
}
// Если не на домашнем экране — перезапускаем
if (!xmlScreenParser.isHomeScreen()) {
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;
m_resultScreenshot = AdbUtils::captureScreenshotBytes(m_deviceId);
if (!m_resultScreenshot.isEmpty()) emit screenshotReady(m_resultScreenshot);
AppLogger::log("ozon/GetLastTransactions", m_deviceId, m_error, {}, "FETCH_TRANSACTIONS");
AdbUtils::tryToKillApplication(m_deviceId, app.package);
return false;
}
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
tryRefreshIfError();
}
// Закрываем баннеры на главной
tryToCloseAllBannersOnHomePage(m_deviceId, device.screenWidth, device.screenHeight);
QThread::msleep(500);
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
// Скроллим до "Все" и тапаем (быстрый fling чтобы пропустить баннеры)
const int navBarTop = device.screenHeight - (device.screenHeight / 10);
Node allBtn;
for (int i = 0; i < 10; ++i) {
if (QThread::currentThread()->isInterruptionRequested()) return false;
const int x = device.screenWidth / 2;
AdbUtils::makeSwipe(m_deviceId, x, device.screenHeight - 200, x, 200, 100);
QThread::msleep(1500);
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
allBtn = xmlScreenParser.findButtonNode("Все", false);
if (!allBtn.isEmpty() && allBtn.y() > 0 && allBtn.y() < navBarTop) break;
allBtn = {};
}
if (allBtn.isEmpty()) {
m_error = "Button 'Все' not found: " + m_deviceId;
qWarning() << m_error;
return false;
}
AdbUtils::makeTap(m_deviceId, allBtn.x(), allBtn.y());
QThread::msleep(2000);
// Ждём экран "Операции"
for (int attempt = 0; attempt < 10; ++attempt) {
if (QThread::currentThread()->isInterruptionRequested()) return false;
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
if (xmlScreenParser.isTransactionListScreen()) return true;
QThread::msleep(1500);
}
m_error = "Transaction list screen not loaded: " + m_deviceId;
qWarning() << m_error;
return false;
}
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());
if (!deviceTz.isValid()) deviceTz = QTimeZone("Europe/Moscow");
const int maxScrolls = 30;
const int navBarTop = device.screenHeight - (device.screenHeight / 10);
// Целевая дата в таймзоне устройства для поиска заголовка дня в списке
const QDateTime searchLocal = m_searchTime.toTimeZone(deviceTz);
const QDate searchDate = searchLocal.date();
qDebug() << "[FetchTransactions] Searching: device=" << m_deviceId
<< "amount=" << m_searchAmount
<< "time=" << m_searchTime.toString(Qt::ISODate)
<< "tz=" << tzName
<< "date(local)=" << searchDate.toString("dd.MM.yyyy")
<< "screen=" << device.screenWidth << "x" << device.screenHeight;
// ── Шаг 1: скроллим до нужной даты ──────────────────────────────
static const QMap<QString, int> monthMap = {
{"января", 1}, {"февраля", 2}, {"марта", 3}, {"апреля", 4},
{"мая", 5}, {"июня", 6}, {"июля", 7}, {"августа", 8},
{"сентября", 9}, {"октября", 10}, {"ноября", 11}, {"декабря", 12}
};
static const QRegularExpression dateRx(R"((\d{1,2})\s+(\S+),\s+\S+)");
auto parseDateHeader = [&](const QString &t) -> QDate {
const QDate today = QDateTime::currentDateTimeUtc().toTimeZone(deviceTz).date();
if (t.compare("Сегодня", Qt::CaseInsensitive) == 0) return today;
if (t.compare("Вчера", Qt::CaseInsensitive) == 0) return today.addDays(-1);
const auto match = dateRx.match(t);
if (!match.hasMatch()) return {};
const int day = match.captured(1).toInt();
const int month = monthMap.value(match.captured(2), 0);
if (month == 0) return {};
return QDate(searchDate.year(), month, day);
};
// Ozon WebView сворачивает заголовки дат вне вьюпорта в один y (0 или общий
// верхний y). Несколько разных дат на одной y-позиции — pile-up скрытых
// заголовков, их надо считать невидимыми.
auto computeDateYCount = [&]() -> QMap<int, int> {
QMap<int, int> cnt;
for (const Node &node : xmlScreenParser.getNodes()) {
if (node.className != "android.widget.TextView") continue;
if (node.y() <= 0) continue;
if (parseDateHeader(node.text.trimmed()).isValid()) cnt[node.y()]++;
}
return cnt;
};
auto hasDateOnScreen = [&]() -> bool {
const QMap<int, int> yCount = computeDateYCount();
for (const Node &node : xmlScreenParser.getNodes()) {
if (node.className != "android.widget.TextView") continue;
if (node.y() <= 0) continue;
if (yCount.value(node.y(), 0) != 1) continue;
if (parseDateHeader(node.text.trimmed()) == searchDate) return true;
}
return false;
};
auto hasOlderDateOnScreen = [&]() -> bool {
const QMap<int, int> yCount = computeDateYCount();
for (const Node &node : xmlScreenParser.getNodes()) {
if (node.className != "android.widget.TextView") continue;
if (node.y() <= 0) continue;
if (yCount.value(node.y(), 0) != 1) continue;
const QDate d = parseDateHeader(node.text.trimmed());
if (d.isValid() && d < searchDate) return true;
}
return false;
};
// Ждём пока WebView раскроет accessibility tree (иначе dump пустой)
for (int wait = 0; wait < 5; ++wait) {
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
if (xmlScreenParser.findTransactionButtons(1).size() > 0) break;
if (hasDateOnScreen()) break;
qDebug() << "[FetchTransactions] Waiting for WebView content..." << (wait + 1);
QThread::msleep(2000);
}
// Скроллим к нужной дате, запоминаем количество скроллов
int scrollsToDate = 0;
bool dateFound = false;
for (int scroll = 0; scroll < maxScrolls; ++scroll) {
if (QThread::currentThread()->isInterruptionRequested()) {
m_error = "Interrupted";
return;
}
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
if (hasDateOnScreen()) {
dateFound = true;
scrollsToDate = scroll;
qDebug() << "[FetchTransactions] Found target date on screen after" << scroll << "scrolls";
break;
}
if (hasOlderDateOnScreen()) {
qDebug() << "[FetchTransactions] Passed target date, not found in list";
break;
}
AdbUtils::makeSwipe(m_deviceId, device.screenWidth / 2,
device.screenHeight * 2 / 3, device.screenWidth / 2,
device.screenHeight / 3, 600);
QThread::msleep(1500);
}
if (!dateFound) {
m_error = "Date not found in transaction list: " + searchDate.toString("dd.MM.yyyy");
qWarning() << "[FetchTransactions]" << m_error;
m_resultScreenshot = listScreenshot.isEmpty()
? AdbUtils::captureScreenshotBytes(m_deviceId) : listScreenshot;
if (!m_resultScreenshot.isEmpty()) emit screenshotReady(m_resultScreenshot);
return;
}
// Хелпер: находит Y-диапазон [dateY, nextDateY) для нужной даты на экране.
// Кнопки транзакций между этими Y принадлежат нужному дню.
// Если заголовок нужной даты ушёл за верх экрана, но следующая дата ещё
// видна ниже (или не видна вовсе), dateY = 0 — все кнопки выше nextDateY
// принадлежат нашему дню.
auto findDateYRange = [&]() -> QPair<int, int> {
const QMap<int, int> yCount = computeDateYCount();
auto isVisible = [&](int y) { return y > 0 && yCount.value(y, 0) == 1; };
int dateY = -1;
int nextDateY = device.screenHeight;
bool foundOlderDate = false;
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) {
if (isVisible(node.y())) dateY = node.y();
} else if (d < searchDate) {
foundOlderDate = true;
if (isVisible(node.y()) && (dateY < 0 || node.y() > dateY)
&& node.y() < nextDateY) {
nextDateY = node.y();
}
}
}
// Target-header не виден, но видна более старая дата → target ушёл вверх:
// всё выше nextDateY принадлежит нашему дню.
if (dateY < 0 && nextDateY < device.screenHeight) {
dateY = 0;
}
qDebug() << "[FetchTransactions] findDateYRange: dateY=" << dateY
<< "nextDateY=" << nextDateY << "foundOlderDate=" << foundOlderDate;
return {dateY, nextDateY};
};
// ── Шаг 2: тапаем только по кнопкам под заголовком нужной даты ───
QSet<QString> checkedDetails;
int noProgressCount = 0; // Счётчик итераций без прогресса (новых unchecked кнопок)
for (int attempt = 0; attempt < 15; ++attempt) {
if (QThread::currentThread()->isInterruptionRequested()) {
m_error = "Interrupted";
return;
}
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
// Логируем все заголовки дат на экране
{
QStringList datesOnScreen;
for (const Node &node : xmlScreenParser.getNodes()) {
if (node.className != "android.widget.TextView") continue;
const QDate d = parseDateHeader(node.text.trimmed());
if (d.isValid()) {
datesOnScreen << QString("%1@y=%2").arg(d.toString("dd.MM")).arg(node.y());
}
}
qDebug() << "[FetchTransactions] attempt=" << attempt << "dates on screen:" << datesOnScreen;
}
// Находим Y-диапазон заголовка нужной даты
const auto [dateHeaderY, nextDateHeaderY] = findDateYRange();
if (dateHeaderY < 0) {
// Target-header не виден и ни одной более старой даты не видно →
// target скорее всего ниже вьюпорта. Скроллим вниз (swipe вверх).
qDebug() << "[FetchTransactions] Target not visible, no older date — scrolling down";
AdbUtils::makeSwipe(m_deviceId, device.screenWidth / 2,
device.screenHeight * 2 / 3,
device.screenWidth / 2,
device.screenHeight / 3, 500);
QThread::msleep(1500);
continue;
}
if (dateHeaderY == 0 && nextDateHeaderY < device.screenHeight) {
// Target-header ушёл вверх, видна только более старая дата.
// Кнопки выше nextDateHeaderY могут принадлежать промежуточным
// (пропущенным) датам — матчить их опасно. Скроллим обратно вверх.
qDebug() << "[FetchTransactions] Target scrolled above — scrolling up to recover";
AdbUtils::makeSwipe(m_deviceId, device.screenWidth / 2,
device.screenHeight / 3,
device.screenWidth / 2,
device.screenHeight * 2 / 3, 600);
QThread::msleep(1500);
continue;
}
QList<Node> txButtons = xmlScreenParser.findTransactionButtons(20);
qDebug() << "[FetchTransactions] Found" << txButtons.size() << "buttons, dateY range:"
<< dateHeaderY << "-" << nextDateHeaderY;
bool tapped = false;
bool hasNewButton = false; // Есть ли новая (ещё не проверенная) кнопка с нужной суммой
int buttonsInRange = 0; // Кнопки внутри Y-диапазона нужной даты
for (int bi = 0; bi < txButtons.size(); ++bi) {
if (QThread::currentThread()->isInterruptionRequested()) {
m_error = "Interrupted";
return;
}
// Пропускаем кнопки вне Y-диапазона нужной даты
const int btnY = txButtons[bi].y();
if (btnY <= dateHeaderY || btnY >= nextDateHeaderY) continue;
if (btnY <= 0 || btnY >= navBarTop) continue;
++buttonsInRange;
const TransactionInfo parsed = xmlScreenParser.parseTransactionButtonText(txButtons[bi].text);
qDebug() << "[FetchTransactions] Button" << bi << "y=" << btnY
<< "parsed amount=" << parsed.amount << "search=" << m_searchAmount
<< "text=" << txButtons[bi].text.left(60);
if (qAbs(parsed.amount - m_searchAmount) > 0.01) continue;
const Node &visibleBtn = txButtons[bi];
qDebug() << "[FetchTransactions] Amount match:" << parsed.amount
<< "at" << visibleBtn.x() << visibleBtn.y()
<< "(dateY:" << dateHeaderY << "-" << nextDateHeaderY << ")";
// Тапаем и ждём деталей
AdbUtils::makeTap(m_deviceId, visibleBtn.x(), visibleBtn.y());
QThread::msleep(2000);
TransactionInfo detail;
for (int da = 0; da < 10; ++da) {
if (QThread::currentThread()->isInterruptionRequested()) {
m_error = "Interrupted";
return;
}
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
detail = xmlScreenParser.parseTransactionDetail();
if (!detail.bankTime.isEmpty()) break;
if (xmlScreenParser.isTransactionListScreen()) {
AdbUtils::makeTap(m_deviceId, visibleBtn.x(), visibleBtn.y());
}
QThread::msleep(1500);
}
if (detail.bankTime.isEmpty()) {
qWarning() << "[FetchTransactions] Could not load detail, going back";
AdbUtils::goBack(m_deviceId);
QThread::msleep(1000);
continue;
}
// Парсим дату из деталей
QString dtStr = detail.bankTime;
for (int ci = 0; ci < dtStr.size(); ++ci) {
const QChar ch = dtStr.at(ci);
if (ch != ' ' && ch.category() == QChar::Separator_Space) dtStr[ci] = ' ';
}
QDateTime txTime = QDateTime::fromString(dtStr, "dd.MM.yyyy, HH:mm");
if (!txTime.isValid()) txTime = QDateTime::fromString(dtStr, "dd.MM.yyyy,HH:mm");
if (txTime.isValid()) txTime.setTimeZone(deviceTz);
// Пропускаем если уже проверяли эту транзакцию
if (checkedDetails.contains(dtStr)) {
qDebug() << "[FetchTransactions] Already checked:" << dtStr << "- skipping";
AdbUtils::goBack(m_deviceId);
QThread::msleep(500);
continue;
}
checkedDetails.insert(dtStr);
tapped = true;
hasNewButton = true;
// Проверяем что транзакция принадлежит нужной дате (защита от широкого Y-диапазона)
if (txTime.isValid()) {
const QDate txLocalDate = txTime.toTimeZone(deviceTz).date();
if (txLocalDate != searchDate) {
qDebug() << "[FetchTransactions] Wrong date in detail:" << dtStr
<< "expected date" << searchDate.toString("dd.MM.yyyy")
<< "- button belongs to different date, skipping";
AdbUtils::goBack(m_deviceId);
QThread::msleep(1000);
continue;
}
}
// Проверяем время (±10 минут)
if (txTime.isValid() && m_searchTime.isValid()) {
const qint64 diffSecs = qAbs(txTime.toUTC().secsTo(m_searchTime.toUTC()));
if (diffSecs > 600) {
qDebug() << "[FetchTransactions] Time mismatch:" << dtStr
<< "expected" << searchLocal.toString("dd.MM.yyyy, HH:mm")
<< "diff=" << diffSecs << "sec";
AdbUtils::goBack(m_deviceId);
QThread::msleep(1000);
continue;
}
}
// Нашли!
qDebug() << "[FetchTransactions] FOUND transaction: device=" << m_deviceId
<< "amount=" << parsed.amount << "time=" << dtStr
<< "name=" << detail.name;
m_resultScreenshot = AdbUtils::captureScreenshotBytes(m_deviceId);
// Пробуем получить ID через "Документы" → OCR
QString externalId = detail.bankTrExternalId;
if (externalId.isEmpty() && !QThread::currentThread()->isInterruptionRequested()) {
Node docBtn = xmlScreenParser.findButtonNode("Документы", false);
if (!docBtn.isEmpty()) {
qDebug() << "[FetchTransactions] Tapping 'Документы'...";
AdbUtils::makeTap(m_deviceId, docBtn.x(), docBtn.y());
QThread::msleep(2000);
bool docLoaded = false;
for (int da = 0; da < 5; ++da) {
if (QThread::currentThread()->isInterruptionRequested()) break;
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
if (!xmlScreenParser.findNodeByContentDesc("Поделиться").isEmpty()) {
docLoaded = true;
break;
}
QThread::msleep(1500);
}
if (docLoaded) {
QThread::msleep(500);
const QByteArray screenshot = AdbUtils::captureScreenshotBytes(m_deviceId);
if (!screenshot.isEmpty()) {
m_resultScreenshot = screenshot;
const QString ocrText = recognizeTextFromImage(screenshot);
if (!ocrText.isEmpty()) {
qDebug() << "[FetchTransactions] OCR result:" << ocrText;
externalId = extractTransactionId(ocrText);
if (!externalId.isEmpty()) {
qDebug() << "[FetchTransactions] Extracted ID:" << externalId;
}
}
}
}
AdbUtils::goBack(m_deviceId);
QThread::msleep(1000);
}
}
// Сохраняем
if (accountId != -1) {
TransactionInfo tx;
tx.accountId = accountId;
tx.amount = parsed.amount;
tx.bankName = m_appCode;
tx.bankTime = dtStr;
tx.bankTrExternalId = externalId.isEmpty()
? QString("ozon_") + QString(dtStr).remove(',').trimmed().replace(' ', '_')
: externalId;
tx.status = TransactionStatus::Complete;
tx.timestamp = DateUtils::getUtcNow();
if (txTime.isValid()) tx.completeTime = txTime;
if (!detail.name.isEmpty()) tx.name = detail.name;
if (!detail.description.isEmpty()) tx.description = detail.description;
if (!detail.phone.isEmpty()) {
QString cleanPhone;
for (const QChar &ch : detail.phone)
if (ch.isDigit()) cleanPhone.append(ch);
tx.phone = cleanPhone;
tx.type = TransactionType::Phone;
}
TransactionDAO::insertTransactionIfNotExists(tx);
}
// Отдаём результат в EventHandler через сигнал (как Dushanbe)
{
const QString finalExternalId = externalId.isEmpty()
? QString("ozon_") + QString(dtStr).remove(',').trimmed().replace(' ', '_')
: externalId;
QJsonObject txJson;
txJson["bank_transaction_id"] = finalExternalId;
txJson["amount"] = static_cast<int>(parsed.amount * 100);
txJson["currency"] = 643; // RUB
txJson["transaction_type"] = "bank";
QString participator = detail.phone;
if (participator.isEmpty()) participator = detail.name;
txJson["bank_participator_id"] = participator;
txJson["status"] = "completed";
QJsonObject extraObj;
extraObj["fee"] = 0;
txJson["extra"] = extraObj;
txJson["created_at"] = txTime.isValid()
? txTime.toUTC().toString(Qt::ISODate) : QJsonValue(QJsonValue::Null);
qDebug() << "[FetchTransactions] Emitting parsed tx:"
<< QJsonDocument(txJson).toJson(QJsonDocument::Compact);
emit transactionParsed(txJson);
}
if (!m_resultScreenshot.isEmpty())
emit screenshotReady(m_resultScreenshot);
AdbUtils::goBack(m_deviceId);
QThread::msleep(1000);
AdbUtils::goBack(m_deviceId);
QThread::msleep(1000);
return; // успех
}
// Отслеживаем прогресс: "без прогресса" только если были кнопки с СОВПАДАЮЩЕЙ
// суммой (tapped), но все уже checked. Если суммы не совпали — скроллим дальше,
// нужная кнопка может быть ниже.
if (!hasNewButton && tapped) {
++noProgressCount;
if (noProgressCount >= 5) {
qDebug() << "[FetchTransactions] No progress after" << noProgressCount
<< "attempts with matching amount — giving up";
break;
}
} else if (hasNewButton) {
noProgressCount = 0;
}
// Скроллим если не нашли нужную транзакцию
if (!tapped || !hasNewButton) {
qDebug() << "[FetchTransactions] Scrolling (tapped=" << tapped
<< "hasNew=" << hasNewButton << "inRange=" << buttonsInRange
<< "noProgress=" << noProgressCount
<< "dateHeaderY=" << dateHeaderY
<< "nextDateHeaderY=" << nextDateHeaderY << ")";
if (dateHeaderY == 0 && nextDateHeaderY < device.screenHeight) {
// Target-header ушёл вверх, под ним видна более старая дата —
// скроллим обратно наверх (swipe вниз) чтобы вернуться к target.
const int fromY = device.screenHeight / 3;
const int toY = device.screenHeight * 2 / 3;
qDebug() << "[FetchTransactions] Scroll UP: from" << fromY << "to" << toY;
AdbUtils::makeSwipe(m_deviceId, device.screenWidth / 2, fromY,
device.screenWidth / 2, toY, 500);
} else if (dateHeaderY > 0 && dateHeaderY < device.screenHeight / 2) {
// Заголовок в верхней половине — маленький скролл чтобы показать
// больше кнопок под этой датой
const int fromY = device.screenHeight * 2 / 3;
const int toY = device.screenHeight / 3;
qDebug() << "[FetchTransactions] Small scroll down: from" << fromY << "to" << toY
<< "(dateHeaderY=" << dateHeaderY << ")";
AdbUtils::makeSwipe(m_deviceId, device.screenWidth / 2, fromY,
device.screenWidth / 2, toY, 500);
} else {
// Заголовок в нижней половине или не найден — скролл вниз
const int fromY = device.screenHeight * 3 / 4;
const int toY = device.screenHeight / 4;
qDebug() << "[FetchTransactions] Full scroll down: from" << fromY << "to" << toY
<< "(dateHeaderY=" << dateHeaderY << ")";
AdbUtils::makeSwipe(m_deviceId, device.screenWidth / 2, fromY,
device.screenWidth / 2, toY, 400);
}
QThread::msleep(1500);
}
}
m_error = "Transaction not found: device=" + m_deviceId
+ " amount=" + QString::number(m_searchAmount, 'f', 2)
+ " date=" + searchDate.toString("dd.MM.yyyy");
qWarning() << "[FetchTransactions]" << m_error;
// Отправляем скриншот списка транзакций, сделанный до начала поиска
m_resultScreenshot = listScreenshot.isEmpty()
? AdbUtils::captureScreenshotBytes(m_deviceId) : listScreenshot;
if (!m_resultScreenshot.isEmpty()) emit screenshotReady(m_resultScreenshot);
}
void GetLastTransactionsScript::scanRecentTransactions(const DeviceInfo &device, int accountId) {
const QString tzName = AdbUtils::getDeviceTimezone(m_deviceId);
QTimeZone deviceTz(tzName.toUtf8());
if (!deviceTz.isValid()) deviceTz = QTimeZone("Europe/Moscow");
const int navBarTop = device.screenHeight - (device.screenHeight / 10);
// Ждём появления транзакций
QList<Node> txButtons;
for (int attempt = 0; attempt < 5; ++attempt) {
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
txButtons = xmlScreenParser.findTransactionButtons(8);
if (!txButtons.isEmpty()) break;
QThread::msleep(1500);
}
qDebug() << "[GetLastTransactions] Found" << txButtons.size() << "transaction buttons";
QList<TransactionInfo> transactions;
for (int i = 0; i < txButtons.size(); ++i) {
const Node &btn = txButtons[i];
// Скроллим до кнопки если за экраном
if (btn.y() <= 0 || btn.y() >= navBarTop) {
for (int s = 0; s < 5; ++s) {
const int x = device.screenWidth / 2;
const int offset = device.screenHeight / 4;
AdbUtils::makeSwipe(m_deviceId, x, device.screenHeight - offset, x, offset);
QThread::msleep(1000);
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
txButtons = xmlScreenParser.findTransactionButtons(8);
if (i < txButtons.size() && txButtons[i].y() > 0 && txButtons[i].y() < navBarTop) break;
}
if (i >= txButtons.size()) break;
}
const Node &visibleBtn = txButtons[i];
TransactionInfo tx = xmlScreenParser.parseTransactionButtonText(visibleBtn.text);
AdbUtils::makeTap(m_deviceId, visibleBtn.x(), visibleBtn.y());
QThread::msleep(2000);
// Ждём загрузки деталей
TransactionInfo detail;
for (int attempt = 0; attempt < 10; ++attempt) {
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
detail = xmlScreenParser.parseTransactionDetail();
if (!detail.bankTime.isEmpty()) break;
if (xmlScreenParser.isTransactionListScreen()) {
AdbUtils::makeTap(m_deviceId, visibleBtn.x(), visibleBtn.y());
}
QThread::msleep(1500);
}
tx.bankName = m_appCode;
tx.bankTime = detail.bankTime;
tx.bankTrExternalId = detail.bankTrExternalId;
// Если ID не найден в XML — пробуем через "Документы" + OCR
if (tx.bankTrExternalId.isEmpty()) {
Node docBtn = xmlScreenParser.findButtonNode("Документы", false);
if (!docBtn.isEmpty()) {
AdbUtils::makeTap(m_deviceId, docBtn.x(), docBtn.y());
QThread::msleep(2000);
bool docLoaded = false;
for (int da = 0; da < 15; ++da) {
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
if (!xmlScreenParser.findNodeByContentDesc("Поделиться").isEmpty()) {
docLoaded = true;
break;
}
QThread::msleep(1500);
}
if (docLoaded) {
QThread::msleep(500);
const QByteArray screenshot = AdbUtils::captureScreenshotBytes(m_deviceId);
if (!screenshot.isEmpty()) {
const QString ocrText = recognizeTextFromImage(screenshot);
if (!ocrText.isEmpty()) {
tx.bankTrExternalId = extractTransactionId(ocrText);
qDebug() << "[GetLastTransactions] OCR extracted ID:" << tx.bankTrExternalId;
}
}
}
AdbUtils::goBack(m_deviceId);
QThread::msleep(1000);
}
}
tx.status = TransactionStatus::Complete;
tx.timestamp = DateUtils::getUtcNow();
if (!detail.name.isEmpty()) tx.name = detail.name;
if (!detail.phone.isEmpty()) {
QString cleanPhone;
for (const QChar &ch : detail.phone)
if (ch.isDigit()) cleanPhone.append(ch);
tx.phone = cleanPhone;
tx.type = TransactionType::Phone;
}
// Парсим время
if (!tx.bankTime.isEmpty()) {
QString dtStr = tx.bankTime;
for (int ci = 0; ci < dtStr.size(); ++ci) {
const QChar ch = dtStr.at(ci);
if (ch != ' ' && ch.category() == QChar::Separator_Space) dtStr[ci] = ' ';
}
tx.bankTime = dtStr;
QDateTime parsed = QDateTime::fromString(dtStr, "dd.MM.yyyy, HH:mm");
if (!parsed.isValid()) parsed = QDateTime::fromString(dtStr, "dd.MM.yyyy,HH:mm");
if (parsed.isValid()) {
parsed.setTimeZone(deviceTz);
tx.completeTime = parsed;
}
}
// Фильтр по времени (24ч) если нет maxCount
if (m_maxCount <= 0 && tx.completeTime.isValid()) {
const qint64 ageSecs = tx.completeTime.toUTC().secsTo(QDateTime::currentDateTimeUtc());
if (ageSecs > 24 * 3600) {
AdbUtils::goBack(m_deviceId);
QThread::msleep(1000);
break;
}
}
// Сохраняем
if (accountId != -1) {
tx.accountId = accountId;
if (TransactionDAO::insertTransactionIfNotExists(tx)) {
// Скриншот чека
Node docBtn = xmlScreenParser.findButtonNode("Документы", false);
if (!docBtn.isEmpty()) {
AdbUtils::makeTap(m_deviceId, docBtn.x(), docBtn.y());
QThread::msleep(2000);
bool docLoaded = false;
for (int da = 0; da < 20; ++da) {
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
if (!xmlScreenParser.findNodeByContentDesc("Поделиться").isEmpty()) {
docLoaded = true; break;
}
QThread::msleep(1500);
}
if (docLoaded) {
QThread::msleep(500);
QProcess screencapProc;
screencapProc.start("adb", QStringList() << "-s" << m_deviceId << "exec-out" << "screencap" << "-p");
screencapProc.waitForFinished(5000);
const QByteArray screenshot = screencapProc.readAllStandardOutput();
if (!screenshot.isEmpty()) {
TransactionInfo saved = TransactionDAO::findByBankTimeAndAmount(
accountId, tx.bankName, tx.bankTime, tx.amount);
if (saved.id != -1) {
TransactionDAO::updateReceiptImage(saved.id, screenshot);
const QString ocrText = recognizeTextFromImage(screenshot);
if (!ocrText.isEmpty()) {
const QString txId = extractTransactionId(ocrText);
if (!txId.isEmpty()) {
TransactionDAO::updateBankTrExternalId(saved.id, txId);
}
}
}
}
}
AdbUtils::goBack(m_deviceId);
QThread::msleep(1000);
}
}
}
transactions.append(tx);
if (m_maxCount > 0 && transactions.size() >= m_maxCount) {
AdbUtils::goBack(m_deviceId);
QThread::msleep(1000);
break;
}
AdbUtils::goBack(m_deviceId);
QThread::msleep(1500);
for (int attempt = 0; attempt < 5; ++attempt) {
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
if (xmlScreenParser.isTransactionListScreen()) break;
QThread::msleep(1000);
}
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
txButtons = xmlScreenParser.findTransactionButtons(8);
}
}
} // namespace Ozon