- Add upward scroll handling to recover header visibility when the target header scrolls out of view.
920 lines
43 KiB
C++
920 lines
43 KiB
C++
#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 = 10;
|
||
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
|