#include "GetLastTransactionsScript.h" #include #include #include #include #include #include #include #include #include "adb/AdbUtils.h" #include "dao/MaterialDAO.h" #include "dao/BankProfileDAO.h" #include "dao/DeviceDAO.h" #include "dao/TransactionDAO.h" #include "time/DateUtils.h" #include "AppLogger.h" #include static QString recognizeTextFromImage(const QByteArray &pngData) { auto *api = new tesseract::TessBaseAPI(); if (api->Init(QCoreApplication::applicationDirPath().toUtf8().constData(), "rus+eng")) { qWarning() << "[OCR] Failed to init Tesseract"; delete api; return {}; } Pix *pix = pixReadMem(reinterpret_cast(pngData.constData()), static_cast(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 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)); 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; } 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 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 { QMap 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 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 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 { const QMap 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 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 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)); 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()) { 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(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 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 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