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

1002 lines
49 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 "dump/TaskDumpRecorder.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,
QList<QPair<double, QDateTime>> targets,
QString materialId,
QObject *parent
) : CommonScript(parent),
m_deviceId(std::move(deviceId)),
m_appCode(std::move(appCode)),
m_maxCount(maxCount),
m_targets(std::move(targets)),
m_materialId(std::move(materialId)) {
}
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;
}
const double firstAmount = m_targets.isEmpty() ? 0.0 : m_targets.first().first;
const TaskDumpMeta dumpMeta{m_materialId, firstAmount, {}, {}, app.bankProfileId, app.phone, device.id};
TaskDumpRecorder::Scope dumpScope("ozon", "FETCH_TRANSACTIONS", m_deviceId, dumpMeta, &m_error);
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 (!m_targets.isEmpty()) {
// Ищем каждую цель из списка. Найденные отдаём сразу через
// transactionParsed; ненайденные копим и репортим одной ошибкой в конце.
QStringList notFound;
for (int i = 0; i < m_targets.size(); ++i) {
if (QThread::currentThread()->isInterruptionRequested()) {
m_error = "Interrupted";
emit finishedWithResult(m_error);
return;
}
const double amount = m_targets[i].first;
const QDateTime time = m_targets[i].second;
// Перед каждой целью (кроме первой) возвращаемся к началу списка,
// т.к. предыдущий поиск мог проскроллить вниз к другой дате.
if (i > 0) scrollListToTop(device);
if (!searchOne(device, accountId, amount, time)) {
notFound << QString("amount=%1 time=%2")
.arg(QString::number(amount, 'f', 2), time.toString(Qt::ISODate));
}
}
m_error = notFound.isEmpty()
? QString()
: QString("Transactions not found (%1/%2): %3")
.arg(notFound.size()).arg(m_targets.size()).arg(notFound.join("; "));
} 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));
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
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;
}
bool GetLastTransactionsScript::searchOne(const DeviceInfo &device, int accountId,
double searchAmount, const QDateTime &searchTime) {
// Скриншот списка транзакций — отправим при неудаче, удалим при успехе
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;
// Реальный y верхней границы нижнего нав-холдера. Если в XML есть
// ru.ozon.fintech.finance:id/main_activity_bottom_navigation_holder,
// используем его y1; иначе грубая оценка ~10% снизу.
auto detectNavBarTop = [&]() -> int {
for (const Node &n : xmlScreenParser.getNodes()) {
if (n.resourceId == "ru.ozon.fintech.finance:id/main_activity_bottom_navigation_holder"
&& n.height() > 0) {
return n.y1();
}
}
return device.screenHeight - (device.screenHeight / 10);
};
int navBarTop = detectNavBarTop();
// Целевая дата в таймзоне устройства для поиска заголовка дня в списке
const QDateTime searchLocal = searchTime.toTimeZone(deviceTz);
const QDate searchDate = searchLocal.date();
qDebug() << "[FetchTransactions] Searching: device=" << m_deviceId
<< "amount=" << searchAmount
<< "time=" << 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 false;
}
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) {
qWarning() << "[FetchTransactions] Date not found in transaction list: "
<< searchDate.toString("dd.MM.yyyy");
m_resultScreenshot = listScreenshot.isEmpty()
? AdbUtils::captureScreenshotBytes(m_deviceId) : listScreenshot;
if (!m_resultScreenshot.isEmpty()) emit screenshotReady(m_resultScreenshot);
return false;
}
// Хелпер: находит Y-диапазон [dateY, nextDateY) для нужной даты на экране.
// Кнопки транзакций между этими Y принадлежат нужному дню.
// Возвращает специальные коды через dateY:
// dateY >= 0 : header виден на этом Y (нормальный случай)
// dateY == 0 : header выше viewport, есть более старая видимая дата
// dateY == -1 : header не виден / лежит в нижней sticky-пачке
// (скролл вниз, чтобы target выехал в видимую часть)
// dateY == -2 : header в верхней sticky-пачке (несколько дат свёрнуто
// над viewport) — скролл вверх, чтобы пачка распалась
auto findDateYRange = [&]() -> QPair<int, int> {
const QMap<int, int> yCount = computeDateYCount();
auto isAnchored = [&](int y) { return y > 0 && yCount.value(y, 0) == 1; };
auto isPiled = [&](int y) { return y > 0 && yCount.value(y, 0) > 1; };
const int viewportMid = device.screenHeight / 2;
int dateY = -1;
int nextDateY = device.screenHeight;
int targetPileY = -1;
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 (isAnchored(node.y())) dateY = node.y();
else if (isPiled(node.y())) targetPileY = node.y();
} else if (d < searchDate) {
foundOlderDate = true;
if (isAnchored(node.y())
&& (dateY < 0 || node.y() > dateY)
&& node.y() < nextDateY) {
nextDateY = node.y();
}
}
}
// Target в sticky-пачке: пачка над viewport (малый Y) → секция уже
// прокручена выше, надо вернуться (dateY = -2). Пачка снизу viewport
// (большой Y) → target ещё не доехал до экрана, скролл вниз (dateY = -1).
if (dateY < 0 && targetPileY >= 0) {
dateY = (targetPileY < viewportMid) ? -2 : -1;
}
// Target-header не виден, но видна более старая дата → target ушёл вверх:
// всё выше nextDateY принадлежит нашему дню.
if (dateY < 0 && nextDateY < device.screenHeight && targetPileY < 0) {
dateY = 0;
}
qDebug() << "[FetchTransactions] findDateYRange: dateY=" << dateY
<< "nextDateY=" << nextDateY << "foundOlderDate=" << foundOlderDate
<< "targetPileY=" << targetPileY;
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 false;
}
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 == -2) {
// Target застрял в sticky-пачке (Ozon стекает заголовки выше viewport
// в одну Y-позицию). Ниже пачки есть anchored более старая дата →
// секцию target уже проскроллили мимо. Скроллим обратно вверх,
// чтобы target распался из пачки и занял реальный Y.
qDebug() << "[FetchTransactions] Target piled in sticky band — scrolling up to detach";
AdbUtils::makeSwipe(m_deviceId, device.screenWidth / 2,
device.screenHeight / 3,
device.screenWidth / 2,
device.screenHeight * 2 / 3, 600);
QThread::msleep(1500);
continue;
}
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 false;
}
// Пропускаем кнопки вне Y-диапазона нужной даты.
// Используем y1 (верх кнопки), а не центр — длинные кнопки могут
// заходить под нав-бар, но всё равно tappable (тапнем по верхней части).
const int btnY1 = txButtons[bi].y1();
const int btnY = txButtons[bi].y();
if (btnY1 <= dateHeaderY || btnY1 >= nextDateHeaderY) continue;
if (btnY1 <= 0 || btnY1 >= navBarTop) continue;
++buttonsInRange;
const TransactionInfo parsed = xmlScreenParser.parseTransactionButtonText(txButtons[bi].text);
qDebug() << "[FetchTransactions] Button" << bi << "y=" << btnY
<< "parsed amount=" << parsed.amount << "search=" << searchAmount
<< "text=" << txButtons[bi].text.left(60);
if (qAbs(parsed.amount - searchAmount) > 0.01) continue;
const Node &visibleBtn = txButtons[bi];
// Зажимаем тап-Y на видимую часть кнопки: если кнопка заходит под
// нав-бар, центр окажется на нав-баре и тап улетит мимо.
const int safeMaxY = navBarTop - 20;
int tapY = visibleBtn.y();
if (tapY > safeMaxY) {
tapY = qMax(visibleBtn.y1() + 30, safeMaxY);
}
qDebug() << "[FetchTransactions] Amount match:" << parsed.amount
<< "at" << visibleBtn.x() << tapY
<< "(btn y1=" << visibleBtn.y1() << "y2=" << visibleBtn.y2()
<< "navBarTop=" << navBarTop
<< "dateY:" << dateHeaderY << "-" << nextDateHeaderY << ")";
// Тапаем и ждём деталей
AdbUtils::makeTap(m_deviceId, visibleBtn.x(), tapY);
QThread::msleep(2000);
TransactionInfo detail;
for (int da = 0; da < 10; ++da) {
if (QThread::currentThread()->isInterruptionRequested()) {
m_error = "Interrupted";
return false;
}
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() && searchTime.isValid()) {
const qint64 diffSecs = qAbs(txTime.toUTC().secsTo(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;
// Скриншот экрана деталей транзакции — отдаём в EventHandler.
// Раньше дополнительно тапали "Документы" + OCR ради ID операции,
// но это медленно и нестабильно. Теперь полагаемся на дату+время+
// сумму как идентификатор.
m_resultScreenshot = AdbUtils::captureScreenshotBytes(m_deviceId);
QString externalId = detail.bankTrExternalId;
// Сохраняем
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 true; // успех
}
// Отслеживаем прогресс: "без прогресса" только если были кнопки с СОВПАДАЮЩЕЙ
// суммой (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) {
// Признак того, что под фолдом ещё есть не раскрытые транзакции
// (Ozon хранит off-screen-row как android.view.View с height=0).
const bool moreBelow = xmlScreenParser.hasCollapsedTransactionsBelow(device.screenHeight);
qDebug() << "[FetchTransactions] Scrolling (tapped=" << tapped
<< "hasNew=" << hasNewButton << "inRange=" << buttonsInRange
<< "noProgress=" << noProgressCount
<< "dateHeaderY=" << dateHeaderY
<< "nextDateHeaderY=" << nextDateHeaderY
<< "moreBelow=" << moreBelow << ")";
const int cx = device.screenWidth / 2;
if (dateHeaderY == 0 && nextDateHeaderY < device.screenHeight) {
// Target-header ушёл вверх — свайпим вниз чтоб вернуть его.
AdbUtils::makeSwipe(m_deviceId, cx,
device.screenHeight * 2 / 5, cx,
device.screenHeight * 3 / 4, 600);
QThread::msleep(1500);
} else {
// Скролл вниз. Ozon WebView ведёт себя странно: ОДИН медленный
// свайп иногда игнорируется (видимо, расценивается как hold).
// Стратегия: серия из 3 коротких быстрых свайпов внутри
// безопасной зоны (выше нав-бара). Это надёжно триггерит
// нативный fling в WebView.
// safeBottom — верхняя граница нав-бара минус запас.
// Когда target-секция уже у нижнего края (dateHeaderY >= safeBottom),
// зона "внутри секции" схлопывается — свайпаем фиксированным
// диапазоном относительно safeBottom, чтобы вытащить контент
// секции из-под нав-бара.
const int safeBottom = navBarTop - 30;
int amplitude;
if (dateHeaderY > 0 && dateHeaderY < safeBottom - 100) {
// Header виден достаточно высоко — скроллим внутри его секции.
const int safeTop = qMax(dateHeaderY + 80, device.screenHeight / 4);
amplitude = qMin(safeBottom - safeTop, 700);
} else {
// Header у самого низа или ниже нав-бара — большой свайп.
amplitude = qMin(safeBottom - device.screenHeight / 4, 700);
}
amplitude = qMax(amplitude, 300); // минимум 300px чтобы было ощутимо
qDebug() << "[FetchTransactions] Triple-swipe scroll:"
<< "safeBottom=" << safeBottom << "amplitude=" << amplitude
<< "(dateHeaderY=" << dateHeaderY << " moreBelow=" << moreBelow << ")";
for (int i = 0; i < 3; ++i) {
AdbUtils::makeSwipe(m_deviceId, cx, safeBottom,
cx, safeBottom - amplitude, 250);
QThread::msleep(350);
}
QThread::msleep(800);
}
}
}
// Эту цель не нашли. m_error не трогаем — агрегированную ошибку по всем
// ненайденным целям соберёт оркестратор в doStart.
qWarning() << "[FetchTransactions] Transaction not found: device=" << m_deviceId
<< "amount=" << QString::number(searchAmount, 'f', 2)
<< "date=" << searchDate.toString("dd.MM.yyyy");
// Отправляем скриншот списка транзакций, сделанный до начала поиска
m_resultScreenshot = listScreenshot.isEmpty()
? AdbUtils::captureScreenshotBytes(m_deviceId) : listScreenshot;
if (!m_resultScreenshot.isEmpty()) emit screenshotReady(m_resultScreenshot);
return false;
}
void GetLastTransactionsScript::scrollListToTop(const DeviceInfo &device) {
// Несколько быстрых свайпов вверх, чтобы вернуться к началу списка операций.
for (int i = 0; i < 5; ++i) {
if (QThread::currentThread()->isInterruptionRequested()) return;
AdbUtils::makeSwipe(m_deviceId, device.screenWidth / 2,
device.screenHeight / 3,
device.screenWidth / 2,
device.screenHeight * 2 / 3, 200);
QThread::msleep(400);
}
QThread::msleep(800);
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
}
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));
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
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));
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
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));
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
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