#include "ScreenXmlParser.h" #include #include #include #include #include #include #include #include #include #include #include "adb/AdbUtils.h" #include "android/xml/Node.h" #include "db/MaterialInfo.h" #include "db/TransactionInfo.h" #include "time/DateUtils.h" #include "BankConfigUtils.h" namespace Ozon { ScreenXmlParser::ScreenXmlParser(QObject *parent) : QObject(parent) { } ScreenXmlParser::~ScreenXmlParser() = default; static const QRegularExpression boundsRegex(R"(\[([0-9]+),([0-9]+)\]\[([0-9]+),([0-9]+)\])"); static const QRegularExpression onlyDigitsRegex(R"([^0-9])"); static const QRegularExpression onlyDigitsAndDotRegex(R"([^0-9.])"); static const QRegularExpression rePhone(R"raw((\+7\(?\d{3}\)?[- ]?\d{3}[- ]?\d{2}[- ]?\d{2}))raw"); static const QRegularExpression reFromBank(R"raw(из (.+?) на)raw"); static const QRegularExpression reToBank(R"raw(в (.+?) через)raw"); static const QRegularExpression reSbpId(R"(СБП(?: С2С)? ([A-Z0-9]{30,}))"); static const QRegularExpression reIdAlt(R"(ID перевода (\d+))"); static const QRegularExpression reDate(R"((?:Дата операции|дата) (\d{2}\.\d{2}\.\d{4}(?: \d{2}:\d{2}:\d{2})?))"); static const QRegularExpression reSender(R"(от ([^\(]+))"); static const QRegularExpression reNameAlt(R"(Перевод ([А-Яа-яЁё\s\*]+) в)"); static const QRegularExpression reCardData(R"(цифрами\s+(\d\s\d\s\d\s\d).*?Сумма:\s([\d\s]+)\sруб)"); Node ScreenXmlParser::tryToFindBannerOrDialog(const int width, const int height) { if (m_nodes.isEmpty()) return {}; // 1. Ищем push banner - он перекрывает весь экран Node first = m_nodes[0]; for (const Node &node: m_nodes) { // Если это однозначно, что пуш-баннер if (node.resourceId == "aft-popup-pushNotification-iconClose" && node.className == "android.widget.Button") { return node; } // Если есть кнопка "Позже" if (node.text.trimmed() == "Позже" && node.className == "android.widget.Button") { return node; } // Если нашли пустую кнпоку if (node.className == "android.widget.Button" && node.clickable && node.text.isEmpty() && node.contentDesc.isEmpty()) { // if (node.x() > width / 2 && node.y() < width / 2) { return node; } } } return {}; } /** * Смотрим что есть текс "Мы заботимся о вашей безопасности" и * кнопка "Отложить" * @return координаты кнопки для закрытия */ Node ScreenXmlParser::tryToFindSecurityBanner() { Node text; Node closeBtn; bool hasBiometryScreen = false; Node notNowNode; for (const Node &node: m_nodes) { if (node.text.trimmed() == "Мы заботимся о вашей безопасности" && node.className == "android.app.Dialog") { text = node; } if (node.text.trimmed() == "Отложить" && node.className == "android.widget.Button") { closeBtn = node; } // Экран биометрии "Быстрый вход" — нажимаем "Не сейчас" if (node.text.trimmed() == "Быстрый вход") { hasBiometryScreen = true; } if (node.text.trimmed() == "Не сейчас") { notNowNode = node; } } if (!text.isEmpty() && !closeBtn.isEmpty()) { return closeBtn; } if (hasBiometryScreen && !notNowNode.isEmpty()) { return notNowNode; } return {}; } Node ScreenXmlParser::tryToFindSelectBottomSheet() { Node closeBtn; for (const Node &node: m_nodes) { if ((node.text.trimmed() == "Отмена" || node.text.trimmed() == "Отложить") && node.className == "android.widget.Button") { closeBtn = node; } } // Не нужно проверять все, если хотя бы одна координата есть if (!closeBtn.isEmpty()) { return closeBtn; } return {}; } Node ScreenXmlParser::findTextNode(const QString &text) { for (const Node &node: m_nodes) { if (node.className == "android.widget.TextView" && node.text.trimmed() == text) { return node; } } return {}; } Node ScreenXmlParser::findTextNode(const QString &text, const int x1, const int y1, const int x2, const int y2) { for (const Node &node: m_nodes) { if (node.className == "android.widget.TextView" && node.text.trimmed() == text && node.x() >= x1 && node.x() <= x2 && node.y() >= y1 && node.y() <= y2) { return node; } } return {}; } Node ScreenXmlParser::findFirstEditText() { for (const Node &node: m_nodes) { if (node.className == "android.widget.EditText" && node.focusable) { return node; } } return {}; } Node ScreenXmlParser::findEditTextByHint(const QString &hintPrefix) { for (const Node &node : m_nodes) { if (node.className == "android.widget.EditText" && node.hint.contains(hintPrefix)) { return node; } } // Некоторые устройства/прошивки (напр. Infinix) в uiautomator-дампе не отдают // атрибут hint для EditText внутри WebView. В этом случае ищем TextView-label // с нужным текстом и ближайший к нему EditText с горизонтальным перекрытием. Node label; for (const Node &node : m_nodes) { if (node.className == "android.widget.TextView" && node.text.contains(hintPrefix)) { label = node; break; } } if (label.isEmpty()) return {}; Node best; int bestDy = INT_MAX; for (const Node &node : m_nodes) { if (node.className != "android.widget.EditText") continue; const bool hOverlap = node.x1() < label.x2() && node.x2() > label.x1(); if (!hOverlap) continue; const int dy = std::abs(node.y() - label.y()); if (dy < bestDy) { bestDy = dy; best = node; } } return best; } static QDomElement findPanIframeElement(const QDomElement &root) { if (root.isNull()) return {}; if (root.attribute("resource-id") == "movable-pan-iframe") return root; for (QDomElement child = root.firstChildElement(); !child.isNull(); child = child.nextSiblingElement()) { QDomElement found = findPanIframeElement(child); if (!found.isNull()) return found; } return {}; } static QDomElement findFirstEditTextDescendant(const QDomElement &root) { if (root.isNull()) return {}; if (root.attribute("class") == "android.widget.EditText") return root; for (QDomElement child = root.firstChildElement(); !child.isNull(); child = child.nextSiblingElement()) { QDomElement found = findFirstEditTextDescendant(child); if (!found.isNull()) return found; } return {}; } Node ScreenXmlParser::findPanCardNumberInput() { const QDomElement iframe = findPanIframeElement(m_xml); if (iframe.isNull()) return {}; const QDomElement edit = findFirstEditTextDescendant(iframe); if (edit.isNull()) return {}; Node node; if (QRegularExpressionMatch m = boundsRegex.match(edit.attribute("bounds")); m.hasMatch()) { node.setBounds(m.captured(1).toInt(), m.captured(2).toInt(), m.captured(3).toInt(), m.captured(4).toInt()); } node.className = edit.attribute("class"); node.text = edit.attribute("text"); node.contentDesc = edit.attribute("content-desc"); node.resourceId = edit.attribute("resource-id"); node.hint = edit.attribute("hint"); node.clickable = edit.attribute("clickable") == "true"; node.focusable = edit.attribute("focusable") == "true"; return node; } Node ScreenXmlParser::findButtonNode(const QString &text, const bool contains) { for (const Node &node: m_nodes) { if (contains) { if (node.className == "android.widget.Button" && node.text.contains(text)) { return node; } } else { if (node.className == "android.widget.Button" && node.text.trimmed() == text) { return node; } } } return {}; } QList ScreenXmlParser::findAllButtons() { QList buttons; for (const Node &node : m_nodes) { if (node.className == "android.widget.Button" && !node.text.isEmpty()) { buttons.append(node); } } return buttons; } const QList &ScreenXmlParser::findAllNodes() const { return m_nodes; } Node ScreenXmlParser::findNodeByContentDesc(const QString &contentDesc) { for (const Node &node: m_nodes) { if (node.clickable && node.contentDesc.trimmed() == contentDesc) { return node; } } return {}; } bool ScreenXmlParser::isHomeScreen() { // Домашний экран Ozon Банка: одновременно присутствуют кнопки "Перевести" и "Пополнить". // Старый UI отдавал их видимым text. Compose-сборки Ozon (>= 19.x) text не отдают — // у кнопок пустой text, а тест-id лежит в content-desc (transfer-button-shared / // replenish-button). Поддерживаем оба варианта. bool hasTransfer = false; bool hasTopUp = false; for (const Node &node: m_nodes) { const QString t = node.text.trimmed(); const QString cd = node.contentDesc.trimmed(); if (t == "Перевести" || cd == "transfer-button-shared") hasTransfer = true; if (t == "Пополнить" || cd == "replenish-button") hasTopUp = true; if (hasTransfer && hasTopUp) return true; } return false; } Node ScreenXmlParser::tryToFindAdBanner() { // Рекламный bottom sheet (home_ad_1) определяется по наличию кликабельного // фонового оверлея "touch_outside" и самого bottom sheet контейнера. bool hasBottomSheet = false; Node touchOutside; for (const Node &node: m_nodes) { if (node.resourceId == "ru.ozon.fintech.finance:id/design_bottom_sheet") { hasBottomSheet = true; } if (node.resourceId == "ru.ozon.fintech.finance:id/touch_outside" && node.clickable) { touchOutside = node; } } if (hasBottomSheet && !touchOutside.isEmpty()) { return touchOutside; } return {}; } Node ScreenXmlParser::tryToFindSystemPermissionDenyButton() { for (const Node &node : m_nodes) { if (node.resourceId == "com.android.permissioncontroller:id/permission_deny_button" && node.clickable) { return node; } } for (const Node &node : m_nodes) { if (node.clickable && node.text.trimmed() == QString::fromUtf8("ЗАПРЕТИТЬ")) { return node; } } return {}; } Node ScreenXmlParser::tryToFindGooglePlayBanner(const int screenWidth, const int screenHeight) { // Баннер Google Play "Доступно обновление" — ищем кнопку закрытия по content-desc for (const Node &node : m_nodes) { if (node.contentDesc.contains("Закрыть диалоговое окно") && node.clickable) { return node; } } // Альтернативно — по content-desc "Доступно обновление" for (const Node &node : m_nodes) { if (node.contentDesc.contains("Доступно обновление")) { // Тапаем в верхнюю часть экрана (выше баннера), чтобы закрыть Node tapNode; const int cx = screenWidth / 2; const int cy = screenHeight / 20; tapNode.setBounds(cx, cy, cx + 1, cy + 1); return tapNode; } } return {}; } Node ScreenXmlParser::tryToFindOnboardingBanner() { // Онбординг "Обновили главную" рендерится во ViewPager с этим resource-id. // Кнопка "Пропустить" — TextView (text_atom) внутри кликабельного контейнера, // занимающего всю ширину верхней полоски. Сам TextView некликабельный, но его // bounds совпадают с кликабельным родителем, поэтому тап по его центру попадает. bool hasOnboarding = false; for (const Node &node : m_nodes) { if (node.resourceId == "ru.ozon.fintech.finance:id/onboarding_viewpager") { hasOnboarding = true; break; } } if (!hasOnboarding) return {}; for (const Node &node : m_nodes) { if (node.text.trimmed() == QString::fromUtf8("Пропустить")) { return node; } } return {}; } bool ScreenXmlParser::isProfileScreen() { // Страница профиля Ozon: есть заголовок "Аккаунт" и кнопка "Выйти" bool hasTitle = false; bool hasExit = false; for (const Node &node: m_nodes) { const QString t = node.text.trimmed(); if (t == "Аккаунт") hasTitle = true; if (t == "Выйти") hasExit = true; if (hasTitle && hasExit) return true; } return false; } Node ScreenXmlParser::findUserNameOnHomeScreen(const int screenHeight) { // Имя пользователя — TextView в верхней части домашнего экрана (y < screenHeight/5). // Тап по его координатам делегируется родительскому кликабельному View. static const QSet knownLabels = { "Финансы", "Платежи", "Выгода", "Чат", "Ещё" }; for (const Node &node: m_nodes) { if (node.className == "android.widget.TextView" && !node.text.trimmed().isEmpty() && node.y() > 0 && node.y() < screenHeight / 5 && !knownLabels.contains(node.text.trimmed())) { return node; } } return {}; } QString ScreenXmlParser::parseProfileFullName() { // Полное имя — первый TextView ниже заголовка "Аккаунт", // который не является известным элементом UI и не содержит спецсимволов. static const QSet uiLabels = { "Аккаунт", "Мои документы", "Паспорт и ИНН", "Уровень счёта Лимиты и тарифы", "Повысить", "Изменить код-пароль", "Изменить кодовое слово", "Использовать данные биометрии", "Выйти", "Оформите подписку", "QR-код для пополнений Принимайте чаевые и донаты", "Добавить Почта" }; // Находим y-координату заголовка "Аккаунт" int titleY = -1; for (const Node &node : m_nodes) { if (node.className == "android.widget.TextView" && node.text.trimmed() == "Аккаунт" && node.width() > 50) { titleY = node.y(); break; } } if (titleY < 0) return {}; // Находим y-координату блока с телефоном (ограничение снизу) int phoneY = INT_MAX; for (const Node &node : m_nodes) { if (node.text.contains("Номер телефона")) { phoneY = node.y(); break; } } // Первый подходящий TextView между заголовком и телефоном for (const Node &node : m_nodes) { if (node.className == "android.widget.TextView" && !node.text.trimmed().isEmpty() && node.y() > titleY && node.y() < phoneY && !uiLabels.contains(node.text.trimmed())) { return node.text.trimmed(); } } return {}; } Node ScreenXmlParser::findCardButtonOnHomeScreen() { // Карточка на домашнем экране — Button, текст которого заканчивается на 4 цифры. // Формат может быть "9865", "•• 9865", "**** 9865" и т.п. static const QRegularExpression endsWithFourDigits(R"(\d{4}\s*$)"); for (const Node &node : m_nodes) { if (node.className == "android.widget.Button" && node.clickable && endsWithFourDigits.match(node.text.trimmed()).hasMatch()) { qDebug() << "[findCardButton] Found:" << node.text << "at" << node.x() << node.y(); return node; } } // Диагностика: выводим все clickable Button'ы чтобы понять что есть на экране qDebug() << "[findCardButton] Not found. All clickable Buttons:"; for (const Node &node : m_nodes) { if (node.className == "android.widget.Button" && node.clickable) { qDebug() << " text=" << node.text << "resId=" << node.resourceId; } } return {}; } Node ScreenXmlParser::findCardButtonByLastNumbers(const QString &lastNumbers) { for (const Node &node : m_nodes) { if (node.className != "android.widget.Button" || !node.clickable) continue; const QString t = node.text.trimmed(); // Старый UI: отдельная кнопка карты оканчивается на 4 цифры. // Compose-UI (>= 19.x): обе карты в одной плитке "… Bank Card 7919 Bank Card 9865", // поэтому endsWith находит только последнюю — ищем ещё "Bank Card NNNN"/"Карта NNNN". const bool matches = t.endsWith(lastNumbers) || t.contains("Bank Card " + lastNumbers) || t.contains(QString::fromUtf8("Карта ") + lastNumbers); if (!matches) continue; // Отсекаем строки из ленты переводов (см. parseCardsOnHomeScreen) if (t.contains(QString::fromUtf8("Переводы")) || t.contains(QStringLiteral("**")) || t.contains(QChar(0x2212)) || t.contains(QChar('+'))) { qDebug() << "[findCardBtn] Skipping transfer-feed row for lastNumbers" << lastNumbers << "text=" << node.text; continue; } qDebug() << "[findCardBtn] Found by lastNumbers" << lastNumbers << "text=" << node.text << "at" << node.x() << node.y(); return node; } qWarning() << "[findCardBtn] Not found for lastNumbers=" << lastNumbers << ". Clickable buttons:"; for (const Node &node : m_nodes) { if (node.className == "android.widget.Button" && node.clickable) qWarning() << " text=" << node.text; } return {}; } QString ScreenXmlParser::parseActiveCardLast4() { // Маска реквизитов активной карты: "•••• •••• •••• 9865". Отличаем от плиток // карусели ("…•• 9865") по наличию группы из 4 точек "••••". static const QRegularExpression tail(R"((\d{4})\s*$)"); for (const Node &node : m_nodes) { const QString t = node.text.trimmed(); if (!t.contains(QString::fromUtf8("••••"))) continue; if (const auto m = tail.match(t); m.hasMatch()) return m.captured(1); } return {}; } Node ScreenXmlParser::findCardCarouselTile(const QString &lastNumbers) { // Плитка карты на экране "Карта": Button "Bank Card Виртуальная карта •• NNNN". for (const Node &node : m_nodes) { if (node.className != "android.widget.Button" || !node.clickable) continue; const QString t = node.text.trimmed(); if (t.contains(QString::fromUtf8("Виртуальная карта")) && t.endsWith(lastNumbers)) return node; } return {}; } Node ScreenXmlParser::findAccountCardButton(const QString &lastNumbers) { const QString needle = "Bank Card " + lastNumbers; for (const Node &node : m_nodes) { if (!node.clickable) continue; if (node.text.trimmed() == needle || node.contentDesc.trimmed() == needle) return node; } return {}; } Node ScreenXmlParser::findMainAccountButton() { for (const Node &node : m_nodes) { if (node.className != "android.widget.Button") continue; QString t = node.text; t.replace(QChar(0x00A0), ' ').replace(QChar(0x202F), ' '); if (t.contains("Основной", Qt::CaseInsensitive) && t.contains("₽")) { return node; } } return {}; } Node ScreenXmlParser::findShowRequisitesButton() { for (const Node &node : m_nodes) { if (node.text.trimmed() == QString::fromUtf8("Посмотреть реквизиты")) { return node; } } return {}; } bool ScreenXmlParser::isAccountRequisitesScreen() { for (const Node &node : m_nodes) { if (node.text.trimmed() == QString::fromUtf8("Реквизиты счёта")) { return true; } } return false; } QString ScreenXmlParser::parseAccountNumberFromRequisites() { // text="Номер счёта получателя 40914810100039708263" // Префикс может варьироваться по регистру/пробелам — берём после первого совпадения цифр в конце. static const QRegularExpression tailDigits(R"((\d{15,}))"); static const QString prefix = QString::fromUtf8("Номер счёта получателя"); for (const Node &node : m_nodes) { const QString t = node.text; if (!t.contains(prefix)) continue; const QRegularExpressionMatch m = tailDigits.match(t); if (m.hasMatch()) return m.captured(1); } return {}; } QList ScreenXmlParser::parseCardsOnHomeScreen() { // "Основной счёт X ₽" — общая кнопка с балансом для всех карт. // Карты — отдельные кнопки с текстом из 4 цифр (могут быть любые non-breaking spaces). // Сумма из "Основной счёт" проставляется на все карты. static const QRegularExpression endsWithFourDigits(R"(\d{4}\s*$)"); // 1. Ищем кнопку "Основной счёт X ₽" — содержит "Основной" и "₽" double commonAmount = 0.0; for (const Node &node : m_nodes) { if (node.className != "android.widget.Button") continue; QString t = node.text; t.replace(QChar(0x00A0), ' ').replace(QChar(0x202F), ' '); if (!t.contains("Основной", Qt::CaseInsensitive) || !t.contains("₽")) continue; qDebug() << "[parseCards] Found balance button, text:" << t; const QRegularExpressionMatch m = QRegularExpression( R"(([\d\s]+(?:[.,]\d+)?)\s*₽)" ).match(t); if (m.hasMatch()) { QString amtStr = m.captured(1).remove(' ').replace(',', '.'); commonAmount = amtStr.toDouble(); qDebug() << "[parseCards] Parsed amount:" << commonAmount; break; } qWarning() << "[parseCards] Amount regex not matched in:" << t; } if (commonAmount == 0.0) qWarning() << "[parseCards] Balance button not found or amount=0"; // Маркеры записи из ленты переводов (не карта): // - слово "Переводы", маска "**" перед цифрами, знак суммы (минус U+2212 или плюс). auto isTransferFeedRow = [](const QString &t) { return t.contains(QString::fromUtf8("Переводы")) || t.contains(QStringLiteral("**")) || t.contains(QChar(0x2212)) || t.contains(QChar('+')); }; // 2. Собираем номера карт. // В Compose-UI Ozon (>= 19.x) ОБЕ карты лежат в тексте балансовой кнопки одной // строкой: "Основной счёт … ₽ Bank Card 7919 Bank Card 9865". Вытаскиваем ВСЕ // вхождения "Bank Card NNNN" / "Карта NNNN" из текста кнопок (а не только t.right(4), // который терял все карты кроме последней). QList cards; QStringList seen; static const QRegularExpression bankCardRx(R"((?:Bank Card|Карта)\s+(\d{4}))"); for (const Node &node : m_nodes) { if (node.className != "android.widget.Button") continue; const QString t = node.text; if (isTransferFeedRow(t)) { qDebug() << "[parseCards] Skipping transfer-feed row:" << t; continue; } auto it = bankCardRx.globalMatch(t); while (it.hasNext()) { const QString ln = it.next().captured(1); if (seen.contains(ln)) continue; seen.append(ln); MaterialInfo acc; acc.lastNumbers = ln; acc.amount = commonAmount; acc.currency = "RUB"; acc.description = "Виртуальная карта"; cards.append(acc); qDebug() << "[parseCards] Card found (Bank Card):" << ln << "from:" << t; } } // 3. Fallback (старый UI): отдельные кнопки, текст которых оканчивается 4 цифрами. if (cards.isEmpty()) { for (const Node &node : m_nodes) { if (node.className != "android.widget.Button" || !node.clickable) continue; const QString t = node.text.trimmed(); if (!endsWithFourDigits.match(t).hasMatch()) continue; if (isTransferFeedRow(t)) { qDebug() << "[parseCards] Skipping transfer-feed row:" << t; continue; } MaterialInfo acc; acc.lastNumbers = t.right(4); acc.amount = commonAmount; acc.currency = "RUB"; acc.description = "Виртуальная карта"; qDebug() << "[parseCards] Card found (fallback):" << acc.lastNumbers << "text=" << node.text << "amount=" << commonAmount; cards.append(acc); } } qDebug() << "[parseCards] Total cards found:" << cards.size(); return cards; } QString ScreenXmlParser::parseProfilePhone() { // Телефон — Button с текстом вида "+7 953 803 83 18 Номер телефона" for (const Node &node: m_nodes) { if (node.text.contains("Номер телефона")) { const int idx = node.text.indexOf("Номер телефона"); QString phone = node.text.left(idx).trimmed(); phone.remove(' '); phone.remove(QChar(0x00A0)); return phone; } } return {}; } QList ScreenXmlParser::findPinCodeNodes(const QString &pinCode) { const QString cleanPin = QString(pinCode).remove(' '); if (cleanPin.isEmpty()) { qWarning() << "[findPinCodeNodes] pinCode is empty — check bank_profiles.pin_code in DB"; return {}; } // Способ 1: ищем кликабельные ноды с текстом-цифрой (0-9) // Поддерживаем форматы: "1", "key 1", и т.п. QMap digitMap; static const QRegularExpression digitRx(R"(^(?:key\s+)?(\d)$)", QRegularExpression::CaseInsensitiveOption); for (const Node &node : m_nodes) { if (!node.clickable || node.width() <= 0 || node.height() <= 0) continue; // Цифра живёт в text ("1"/"key 1") в старом UI, либо — в Compose-сборках Ozon // (>= 19.x) — в content-desc ("key 1") при пустом text. auto match = digitRx.match(node.text.trimmed()); if (!match.hasMatch()) match = digitRx.match(node.contentDesc.trimmed()); if (match.hasMatch()) { digitMap[match.captured(1)] = node; } } qDebug() << "[findPinCodeNodes] digitMap size:" << digitMap.size() << "keys:" << digitMap.keys(); if (digitMap.size() >= 10) { QList result; for (const QChar &c : cleanPin) { const QString d = QString(c); if (digitMap.contains(d)) { result.append(digitMap[d]); } else { qWarning() << "[findPinCodeNodes] digit not found:" << d; return {}; } } return result; } // Способ 2 (fallback): группируем clickable-ноды похожего размера, // ищем сетку 4x3 по паттерну (не привязываясь к координатам экрана) constexpr int SIZE_TOLERANCE = 10; // допуск в пикселях // Собираем все clickable ноды с положительным размером QList allClickable; for (const Node &node : m_nodes) { if (!node.clickable || node.width() <= 0 || node.height() <= 0) continue; allClickable.append(node); } // Группируем с допуском: для каждой ноды проверяем, подходит ли она к существующей группе auto fuzzyGroup = [&](bool squareOnly) -> QList { QList> groups; QList> groupSizes; // эталонный размер каждой группы for (const Node &node : allClickable) { if (squareOnly && std::abs(node.width() - node.height()) > SIZE_TOLERANCE) continue; bool placed = false; for (int g = 0; g < groups.size(); ++g) { if (std::abs(node.width() - groupSizes[g].first) <= SIZE_TOLERANCE && std::abs(node.height() - groupSizes[g].second) <= SIZE_TOLERANCE) { groups[g].append(node); placed = true; break; } } if (!placed) { groups.append({node}); groupSizes.append({node.width(), node.height()}); } } QList best; for (const auto &grp : groups) { if (grp.size() >= 10 && (best.size() < 10 || grp.size() < best.size())) best = grp; } return best; }; // Сначала квадратные, потом любые QList candidates = fuzzyGroup(true); qDebug() << "[findPinCodeNodes] fallback square candidates:" << candidates.size(); if (candidates.size() < 10) { candidates = fuzzyGroup(false); qDebug() << "[findPinCodeNodes] fallback any candidates:" << candidates.size(); } if (candidates.size() < 10) { // Дебаг: выводим все clickable ноды и их размеры qWarning() << "[findPinCodeNodes] grid not found. Total clickable nodes:" << allClickable.size(); for (const Node &n : allClickable) { qDebug() << " clickable:" << n.width() << "x" << n.height() << "pos:" << n.x() << "," << n.y() << "class:" << n.className << "text:" << n.text.left(30); } return {}; } // Сортируем по Y, затем группируем в строки std::sort(candidates.begin(), candidates.end(), [](const Node &a, const Node &b) { return a.y() < b.y(); }); QList> rows; for (const Node &node : candidates) { bool placed = false; for (QList &row : rows) { if (std::abs(node.y() - row.first().y()) < row.first().height()) { row.append(node); placed = true; break; } } if (!placed) rows.append({node}); } for (QList &row : rows) { std::sort(row.begin(), row.end(), [](const Node &a, const Node &b) { return a.x() < b.x(); }); } // Строки по 3 — основные ряды (1-9), строки по 1-3 в конце — ряд с "0" QList> fullRows; // строки по 3 кнопки QList> shortRows; // строки с 1-2 кнопками (последний ряд) for (const QList &row : rows) { if (row.size() == 3) fullRows.append(row); else if (row.size() >= 1 && row.size() <= 2) shortRows.append(row); } if (fullRows.size() < 3) { qWarning() << "[findPinCodeNodes] grid not found: fullRows=" << fullRows.size() << "candidates=" << candidates.size(); return {}; } const QStringList digits19 = {"1", "2", "3", "4", "5", "6", "7", "8", "9"}; int idx = 0; for (int r = 0; r < fullRows.size() && idx < digits19.size(); ++r) { for (const Node &n : fullRows[r]) { if (idx < digits19.size()) digitMap[digits19[idx++]] = n; } } // "0" — в последнем ряду: если 3 кнопки — средняя, если 1 — единственная if (!shortRows.isEmpty()) { const auto &lastRow = shortRows.last(); if (lastRow.size() >= 2) digitMap["0"] = lastRow[1]; // средняя кнопка else digitMap["0"] = lastRow[0]; // единственная кнопка } else if (fullRows.size() >= 4) { // fallback: "0" — средняя кнопка последнего полного ряда digitMap["0"] = fullRows.last()[1]; } QList result; for (const QChar &c : cleanPin) { if (const QString d = QString(c); digitMap.contains(d)) { result.append(digitMap[d]); } else { qWarning() << "[findPinCodeNodes] fallback: digit not in digitMap:" << d; } } qDebug() << "[findPinCodeNodes] fallback result size:" << result.size() << "digitMap size:" << digitMap.size(); return result; } QList ScreenXmlParser::tryToFindPinCode(const QString &pinCode, const QString &title) { bool isPinScreen = false; for (const Node &node: m_nodes) { // Баннер — пин-код не вводить if (node.text.trimmed() == "Отложить" && node.className == "android.widget.Button") { return {}; } // В Ozon PIN экран определяется по ссылке "Не помню код-пароль" if (node.text.trimmed() == "Не помню код-пароль") { isPinScreen = true; } // Fallback: другие банки используют title текст if (!title.isEmpty() && node.text.trimmed() == title) { isPinScreen = true; } } if (isPinScreen) { return findPinCodeNodes(pinCode); } return {}; } QList ScreenXmlParser::getLast2DaysTransactions() { QList nodes; for (const Node &node: m_nodes) { if (node.className == "android.widget.Button" && node.text.contains("рубл") && (node.text.contains("Минус") || node.text.contains("Плюс"))) { nodes.append(node); } } return nodes; } /** * */ void ScreenXmlParser::parseAndSaveXml(const QString &xml) { QDomDocument doc; doc.setContent(xml); m_xml = doc.documentElement(); m_nodes.clear(); QXmlStreamReader reader(xml); while (!reader.atEnd()) { reader.readNext(); if (reader.isStartElement() && reader.name() == "node") { Node node = Node(); if (reader.attributes().hasAttribute("bounds")) { QString bounds = reader.attributes().value("bounds").toString(); if (QRegularExpressionMatch match = boundsRegex.match(bounds); match.hasMatch()) { node.setBounds( match.captured(1).toInt(), match.captured(2).toInt(), match.captured(3).toInt(), match.captured(4).toInt() ); } } if (reader.attributes().hasAttribute("text")) { node.text = reader.attributes().value("text").toString(); } if (reader.attributes().hasAttribute("content-desc")) { node.contentDesc = reader.attributes().value("content-desc").toString(); } if (reader.attributes().hasAttribute("class")) { node.className = reader.attributes().value("class").toString(); } if (reader.attributes().hasAttribute("clickable")) { node.clickable = reader.attributes().value("clickable").toString() == "true"; } if (reader.attributes().hasAttribute("focusable")) { node.focusable = reader.attributes().value("focusable").toString() == "true"; } if (reader.attributes().hasAttribute("resource-id")) { node.resourceId = reader.attributes().value("resource-id").toString(); } if (reader.attributes().hasAttribute("hint")) { node.hint = reader.attributes().value("hint").toString(); } m_nodes.append(node); } } } QString getDayOfWeekAsString(const int day_of_week) { switch (day_of_week) { case 1: return "понедельник"; case 2: return "вторник"; case 3: return "среда"; case 4: return "четверг"; case 5: return "пятница"; case 6: return "суббота"; case 7: return "воскресенье"; default: return ""; } } QString getMonthAsString(const int month) { switch (month) { case 1: return "января"; case 2: return "февраля"; case 3: return "марта"; case 4: return "апреля"; case 5: return "мая"; case 6: return "июня"; case 7: return "июля"; case 8: return "августа"; case 9: return "сентября"; case 10: return "октября"; case 11: return "ноября"; case 12: return "декабря"; default: return ""; } } QDomElement findSubElement(const QDomElement &elem, const QString &index) { if (elem.hasChildNodes()) { QDomNodeList subInfoList = elem.childNodes(); for (QDomNode subInfoNode: subInfoList) { if (!subInfoNode.isElement()) continue; QDomElement subInfo = subInfoNode.toElement(); if (subInfo.attribute("index") == index) { return subInfo; } } } return {}; } QString findSubInfo(const QDomElement &elem, const QString &index) { QDomElement subInfo = findSubElement(elem, index); if (!subInfo.isNull()) { return subInfo.attribute("text"); } return ""; } bool hasTimeComponent(const QString &datetimeStr) { QDateTime dt = QDateTime::fromString(datetimeStr, "dd.MM.yyyy HH:mm:ss"); if (!dt.isValid()) { return false; // Строка не соответствует формату даты и времени } return dt.time() != QTime(0, 0, 0); // Проверка, что время не равно 00:00:00 } QString transformBankDateTime(const QString &date) { QDateTime dt; if (date.contains(",")) { // 10.05.2025, 15:03 dt = QDateTime::fromString(date, "dd.MM.yyyy, HH:mm"); } else { dt = QDateTime::fromString(date, "dd.MM.yyyy HH:mm:ss"); } if (!dt.isValid()) { dt = QDateTime::fromString(date, "dd.MM.yyyy HH:mm"); } if (!dt.isValid()) { return date; // не распарсили } return dt.toString("dd.MM.yyyy HH:mm:ss"); } void parseTransferInfo(const QString &text, TransactionInfo &info) { if (const auto m = reSender.match(text); m.hasMatch()) { info.name = m.captured(1).trimmed(); } else if (const auto m1 = reNameAlt.match(text); m1.hasMatch()) { info.name = m1.captured(1).trimmed(); } if (const auto m = rePhone.match(text); m.hasMatch()) { info.phone = m.captured(1).trimmed().remove(onlyDigitsRegex); } if (const auto m = reFromBank.match(text); m.hasMatch()) { if (const QString bank = m.captured(1).trimmed(); !bank.isEmpty()) { info.bankName = bank; } } else if (const auto m1 = reToBank.match(text); m1.hasMatch()) { if (const QString bank = m1.captured(1).trimmed(); !bank.isEmpty()) { info.bankName = bank; } } if (const auto m = reSbpId.match(text); m.hasMatch()) { info.bankTrExternalId = m.captured(1); } else if (const auto m1 = reIdAlt.match(text); m1.hasMatch()) { info.bankTrExternalId = m1.captured(1); } if (const auto m = reDate.match(text); m.hasMatch()) { const QString date = m.captured(1); // если не пустая и дата у нас с 00:00 // if (hasTimeComponent(date)) { info.bankTime = transformBankDateTime(date); // } } } void traverseNodes(const QDomNode &node) { if (node.isElement()) { QDomElement elem = node.toElement(); if (node.isElement()) { QDomElement elem = node.toElement(); if (elem.tagName() == "node" && elem.attribute("class") == "android.widget.Button" && elem.attribute("text") == "Детали операции") { // Все что ниже деталей операции QDomNodeList detailInfo = elem.parentNode().childNodes(); for (int j = 0; j < detailInfo.count(); ++j) { QDomNode info = detailInfo.at(j); if (!info.isElement()) continue; QDomElement infoElement = info.toElement(); QString index = infoElement.attribute("index"); switch (index.toInt()) { case 2: qDebug() << "Дата операции:" << findSubInfo(infoElement, "1"); break; case 5: qDebug() << "Название:" << findSubInfo(infoElement, "1"); qDebug() << "-------------------------------------"; // parseTransfer(findSubInfo(infoElement)); break; case 7: qDebug() << "Комиссия:" << findSubInfo(infoElement, "1"); break; default: qDebug() << "Прочее:" << findSubInfo(infoElement, "1"); break; } /** Node: "2" "" ... Node: "0" "Дата операции" ... Node: "1" "30.04.2025 17:46:29" Node: "5" "" ... Node: "0" "Название" ... Node: "1" "DBO TRANSFER RSHB INTERNET-BANK" Node: "7" "" ... Node: "0" "Комиссия" ... Node: "1" "50 ₽" */ // if (sibElem.hasChildNodes()) { // QDomNodeList sibChildren = sibElem.childNodes(); // for (int k = 0; k < sibChildren.count(); ++k) { // QDomNode sibChild = sibChildren.at(k); // if (!sibChild.isElement()) continue; // QDomElement sibChildElem = sibChild.toElement(); // qDebug() << "... Node:" // << sibChildElem.attribute("index") // << sibChildElem.attribute("text"); // // << sibChildElem.attribute("class"); // // // if (sibChildElem.hasChildNodes()) { // QDomNodeList sibChildren2 = sibChildElem.childNodes(); // for (int k = 0; k < sibChildren2.count(); ++k) { // QDomNode sibChild2 = sibChildren2.at(k); // if (!sibChild2.isElement()) continue; // QDomElement sibChildElem2 = sibChild2.toElement(); // // qDebug() << "...... Node:" // << sibChildElem2.attribute("index") // << sibChildElem2.attribute("text"); // // << sibChildElem2.attribute("class"); // // if (sibChildElem2.hasChildNodes()) { // QDomNodeList sibChildren3 = sibChildElem2.childNodes(); // for (int k = 0; k < sibChildren3.count(); ++k) { // QDomNode sibChild3 = sibChildren3.at(k); // if (!sibChild3.isElement()) continue; // QDomElement sibChildElem3 = sibChild3.toElement(); // // qDebug() << "......... Node:" // << sibChildElem3.attribute("index") // << sibChildElem3.attribute("text"); // // << sibChildElem2.attribute("class"); // } // } // } // } // } // } } } } } // Рекурсивно обходим всех детей QDomNode child = node.firstChild(); while (!child.isNull()) { traverseNodes(child); child = child.nextSibling(); } } QDomNode findNode(const QDomNode &node, const QString &text) { if (node.isElement()) { QString nodeText = node.toElement().attribute("text"); if (nodeText.toLower().startsWith(text)) { return node; } } QDomNode child = node.firstChild(); while (!child.isNull()) { QDomNode result = findNode(child, text); if (!result.isNull()) { return result; } child = child.nextSibling(); } return {}; } float extractAmounts(const QString &text) { // Разрешаем пробелы внутри чисел, захватываем "рубль", "рубля", "рублей" и т.д. // QRegularExpression re(R"((Плюс|Минус)\s+([\d\s]+)\s+рубл\w*\s+(\d+)\s+копе\w*)"); QRegularExpression re( R"((Плюс|Минус)\s+([\d\s]+)\s+рубл\w*\s+(\d+)\s+копе\w*)", QRegularExpression::UseUnicodePropertiesOption ); QRegularExpressionMatch match = re.match(text); if (match.hasMatch()) { QString signStr = match.captured(1); // "Плюс" или "Минус" QString rublesStr = match.captured(2).remove(' '); // "1 000" → "1000" int rubles = rublesStr.toInt(); int kopecks = match.captured(3).toInt(); int totalKopecks = rubles * 100 + kopecks; if (signStr == "Минус") { totalKopecks *= -1; } return totalKopecks / 100.0f; } return 0; } QString beforeAmount(const QString &text) { int index = text.indexOf(QRegularExpression("Плюс|Минус")); if (index != -1) { return text.left(index).trimmed(); } return text; // если не найдено — вернуть всю строку } double parseAmount(const QString &amountAsString) { QString numberString = amountAsString; numberString.remove(QRegularExpression(R"([^\dточка])")); numberString.replace("точка", "."); double amount = numberString.toDouble(); if (amountAsString.startsWith("-")) { amount *= -1; } return amount; } QDomElement findElementWithListView(const QDomElement &parent) { QDomElement element = parent.firstChildElement(); while (!element.isNull()) { QDomElement child = element.firstChildElement(); while (!child.isNull()) { if (child.hasAttribute("class") && child.attribute("class") == "android.widget.ListView") { return child; } child = child.nextSiblingElement(); } QDomElement found = findElementWithListView(element); if (!found.isNull()) { return found; } element = element.nextSiblingElement(); } return {}; } QList ScreenXmlParser::parseTodayAndYesterdayReportHistory() { QList transactions; QDomElement child = findElementWithListView(m_xml); QDomNodeList listView = findElementWithListView(m_xml).childNodes(); qDebug() << listView.count(); if (!listView.isEmpty()) { QString date; for (QDomNode node: listView) { if (node.hasChildNodes()) { QString text; if (node.childNodes().count() > 1) { date = node.firstChildElement().attribute("text"); text = node.lastChildElement().attribute("text"); } else if (node.childNodes().count() == 1) { text = node.firstChildElement().attribute("text"); } TransactionInfo transaction; if (text.startsWith("Перевод")) { QRegularExpression re(R"([+-] \d{1,3}(?: \d{3})* точка \d{2} Рубль$)"); QRegularExpressionMatch match = re.match(text); if (match.hasMatch()) { QString amount = match.captured(0); transaction.amount = parseAmount(amount.trimmed()); text.chop(amount.length()); } transaction.description = text.trimmed(); parseTransferInfo(text, transaction); if (transaction.bankTime.isEmpty()) { transaction.bankTime = transformBankDateTime(date); } } else { // карта if (text.contains("без учета комиссии")) { QRegularExpression re(R"( без учета комиссии -\d{1,3}(?: \d{3})* точка \d{2} Рубль$)"); QRegularExpressionMatch match = re.match(text); if (match.hasMatch()) { QString fee = match.captured(0); text.chop(fee.length()); transaction.fee = parseAmount(fee.replace("без учета комиссии", "").trimmed()); } } QRegularExpression re(R"([+-] \d{1,3}(?: \d{3})* точка \d{2} Рубль$)"); QRegularExpressionMatch match = re.match(text); if (match.hasMatch()) { QString amount = match.captured(0); transaction.amount = parseAmount(amount.trimmed()); text.chop(amount.length()); } transaction.description = text.trimmed(); if (transaction.bankTime.isEmpty()) { transaction.bankTime = transformBankDateTime(date); } } transactions.append(transaction); } } } else { qDebug() << "ListView is empty"; } return transactions; } // Строит заголовок дня в формате Ozon-Выписки для переданной даты. // offset 0 → "сегодня, ...", 1 → "вчера, ...", иначе "<день> <месяц>, <дн.недели>". static QString ozonDayTitle(const QDate &day, int offset) { if (offset == 0) return QString("сегодня, %1").arg(getDayOfWeekAsString(day.dayOfWeek())); if (offset == 1) return QString("вчера, %1").arg(getDayOfWeekAsString(day.dayOfWeek())); return QString("%1 %2, %3").arg( QString::number(day.day()), getMonthAsString(day.month()), getDayOfWeekAsString(day.dayOfWeek())); } QList ScreenXmlParser::parseTodayAndYesterdayHistory() { // Окно поиска: сегодня + (N-1) предыдущих дней. Настраивается в // config.ini → [ozon]/transaction_search_window_days (дефолт 2 = сегодня+вчера). const int windowDays = BankConfigUtils::transactionSearchWindowDays("ozon"); const QDate today = DateUtils::getMoscowNow().date(); // Заголовки дней внутри окна [сегодня .. сегодня-(windowDays-1)] → дата дня. struct DayTitle { QString title; QDate date; }; QList windowTitles; for (int i = 0; i < windowDays; ++i) { const QDate d = today.addDays(-i); windowTitles.append({ozonDayTitle(d, i), d}); } // Заголовок первого дня ЗА окном — сигнал "дальше старая история, стоп". const QDate boundaryDate = today.addDays(-windowDays); const QString boundaryTitle = ozonDayTitle(boundaryDate, windowDays); // Родительский контейнер списка: берём первый существующий заголовок окна. QDomNode parentNode; for (const DayTitle &dt : windowTitles) { const QDomNode node = findNode(m_xml, dt.title); if (!node.isNull()) { parentNode = node.parentNode(); break; } } QList transactions; if (!parentNode.isNull()) { QDate date = {}; for (QDomNode transactionNode: parentNode.childNodes()) { QString transactionShortText = transactionNode.toElement().attribute("text"); const QString titleLowerCase = transactionShortText.toLower(); // День за пределами окна — дальше только старая история, прекращаем. if (titleLowerCase.startsWith(boundaryTitle)) { break; } // Заголовок дня внутри окна — обновляем текущую дату и срезаем префикс. for (const DayTitle &dt : windowTitles) { if (titleLowerCase.startsWith(dt.title)) { date = dt.date; transactionShortText = transactionShortText.mid(dt.title.length() + 1); break; } } if (transactionShortText.contains("ЕСПП")) { continue; } float amount = extractAmounts(transactionShortText); QString shortDesc = beforeAmount(transactionShortText); TransactionInfo info; info.amount = amount; info.bankTime = date.toString("dd.MM.yyyy"); info.description = shortDesc; transactions.append(info); } } return transactions; } void parse2daysHistory(const QDomNode &node) { QDate today = DateUtils::getMoscowNow().date(); QDate tomorrow = today.addDays(-1); QDate after2Days = today.addDays(-2); QString todayTitle = QString("сегодня, %1").arg(getDayOfWeekAsString(today.dayOfWeek())); QDomNode todayNode = findNode(node, todayTitle); if (todayNode.isNull()) { } else { qDebug() << "todayNode: " << todayNode.toElement().attribute("text"); } QString tomorrowTitle = QString("вчера, %1").arg(getDayOfWeekAsString(tomorrow.dayOfWeek())); QDomNode tomorrowNode = findNode(node, tomorrowTitle); if (tomorrowNode.isNull()) { } else { qDebug() << "tomorrowNode: " << tomorrowNode.toElement().attribute("text"); } // 30 апреля, Среда QString after2DaysTitle = QString("%1 %2, %3").arg( QString::number(after2Days.day()), getMonthAsString(after2Days.month()), getDayOfWeekAsString(after2Days.dayOfWeek())); QDomNode after2DaysNode = findNode(node, after2DaysTitle); if (after2DaysNode.isNull()) { qDebug() << "No after2DaysNode"; } else { qDebug() << "after2DaysNode: " << after2DaysNode.toElement().attribute("text"); } QDomNode parentNode; if (!todayNode.isNull() || !tomorrowNode.isNull() || !after2DaysNode.isNull()) { if (!todayNode.isNull()) { parentNode = todayNode.parentNode(); } if (!tomorrowNode.isNull()) { parentNode = tomorrowNode.parentNode(); } if (!after2DaysNode.isNull()) { parentNode = after2DaysNode.parentNode(); } } if (!parentNode.isNull()) { for (QDomNode child_node: parentNode.childNodes()) { QString child_text = child_node.toElement().attribute("text"); if (child_text.toLower().startsWith(todayTitle)) { qDebug() << "=== Today: " << todayTitle; qDebug() << "node: " << beforeAmount(child_text.mid(todayTitle.length() + 1)); qDebug() << "--> sum: " << QString::number(extractAmounts(child_text), 'f', 2); // qDebug() << ""; continue; } if (child_text.toLower().startsWith(tomorrowTitle)) { qDebug() << "=== Yesterday: " << todayTitle; qDebug() << "node: " << beforeAmount(child_text.mid(tomorrowTitle.length() + 1)); qDebug() << "--> sum: " << QString::number(extractAmounts(child_text), 'f', 2); // qDebug() << ""; continue; } if (child_text.toLower().startsWith(after2DaysTitle)) { qDebug() << "=== Before yesterday: " << todayTitle; qDebug() << "node: " << beforeAmount(child_text.mid(after2DaysTitle.length() + 1)); qDebug() << "--> sum: " << QString::number(extractAmounts(child_text), 'f', 2); // qDebug() << ""; continue; } qDebug() << "node: " << beforeAmount(child_node.toElement().attribute("text")); qDebug() << "--> sum: " << QString::number(extractAmounts(child_node.toElement().attribute("text")), 'f', 2); // qDebug() << ""; } } // QString d = today.day() > 9 ? QString::number(today.day()) : "0" + QString::number(today.day()); // QString m = today.month() > 9 ? QString::number(today.month()) : "0" + QString::number(today.month()); // QString y = QString::number(today.year()); // QString w = getDayOfWeekAsString(today.dayOfWeek()); // QString mm = getMonthAsString(today.month()); // qDebug() << "Date:" << d << m << y << w << mm; // QString d2 = after2Days.day() > 9 ? QString::number(after2Days.day()) : "0" + QString::number(after2Days.day()); // QString m2 = after2Days.month() > 9 // ? QString::number(after2Days.month()) // : "0" + QString::number(after2Days.month()); // QString y2 = QString::number(after2Days.year()); // QString w2 = getDayOfWeekAsString(after2Days.dayOfWeek()); // QString mm2 = getMonthAsString(after2Days.month()); // qDebug() << "Date:" << d2 << m2 << y2 << w2 << mm2; } TransactionInfo parseExpandedInfoHistory(const QDomElement &root) { if (root.isElement()) { QDomElement elem = root.toElement(); if (elem.tagName() == "node" && elem.attribute("class") == "android.widget.Button" && elem.attribute("text") == "Детали операции" ) { TransactionInfo transaction; // Все что ниже деталей операции QDomNodeList detailInfo = elem.parentNode().childNodes(); for (int i = 0; i < detailInfo.count(); ++i) { QDomNode info = detailInfo.at(i); if (!info.isElement()) continue; switch (QDomElement e = info.toElement(); e.attribute("index").toInt()) { case 2: // Дата операции transaction.bankTime = transformBankDateTime(findSubInfo(e, "1")); break; case 5: transaction.description = findSubInfo(e, "1"); parseTransferInfo(transaction.description, transaction); break; case 7: // Комиссия transaction.fee = findSubInfo(e, "1").remove(onlyDigitsRegex).toFloat(); break; default: // qDebug() << "Прочее:" << findSubInfo(e); break; } } return transaction; } } // Рекурсивно обходим всех детей QDomNode child = root.firstChild(); while (!child.isNull()) { if (TransactionInfo info = parseExpandedInfoHistory(child.toElement()); !info.bankTime.isEmpty()) { return info; } child = child.nextSibling(); } return {}; } TransactionInfo ScreenXmlParser::parseTransactionInfoHistory(int screenWidth, int screenHeight) { // 1. Распарсить описание транзакции TransactionInfo transaction = parseExpandedInfoHistory(m_xml); // 1. Найти статус: Исполнен | Операция исполнена transaction.status = TransactionStatus::Wait; Node status1 = findTextNode("Исполнен", 0, 0, screenWidth, screenHeight / 2); Node status2 = findTextNode("Операция исполнена", 0, 0, screenWidth, screenHeight / 2); if (!status1.isEmpty() || !status2.isEmpty()) { transaction.status = TransactionStatus::Complete; } return transaction; } TransactionInfo parseExpandedInfo(const QDomElement &root) { if (root.isElement()) { QDomElement elem = root.toElement(); if (elem.tagName() == "node" && elem.attribute("class") == "android.widget.Button" && elem.attribute("text") == "Детали операции" ) { TransactionInfo transaction; // Все что ниже деталей операции QDomNodeList detailInfo = elem.parentNode().childNodes(); for (int i = 0; i < detailInfo.count(); ++i) { QDomNode info = detailInfo.at(i); if (!info.isElement()) continue; QDomElement infoElement = info.toElement(); QString index = infoElement.attribute("index"); QDomElement ee; switch (index.toInt()) { case 3: transaction.bankTime = transformBankDateTime(findSubInfo(infoElement, "1")); break; case 4: ee = findSubElement(infoElement, "0"); ee = findSubElement(ee, "1"); transaction.phone = findSubInfo(ee, "0").remove(onlyDigitsRegex); break; case 5: transaction.name = findSubInfo(infoElement, "1"); break; case 6: transaction.bankName = findSubInfo(infoElement, "1"); break; case 9: transaction.fee = findSubInfo(infoElement, "1").remove(onlyDigitsAndDotRegex).toFloat(); break; case 10: ee = findSubElement(infoElement, "1"); transaction.bankTrExternalId = findSubInfo(ee, "1"); break; default: break; } } return transaction; } } // Рекурсивно обходим всех детей QDomNode child = root.firstChild(); while (!child.isNull()) { if (TransactionInfo info = parseExpandedInfo(child.toElement()); !info.bankTime.isEmpty()) { return info; } child = child.nextSibling(); } return {}; } TransactionInfo ScreenXmlParser::parseTransactionInfo(int screenWidth, int screenHeight) { // 1. Распарсить описание транзакции TransactionInfo transaction = parseExpandedInfo(m_xml); // 1. Найти статус: Исполнен | Операция исполнена transaction.status = TransactionStatus::Wait; Node status1 = findTextNode("Исполнен", 0, 0, screenWidth, screenHeight / 2); Node status2 = findTextNode("Операция исполнена", 0, 0, screenWidth, screenHeight / 2); if (!status1.isEmpty() || !status2.isEmpty()) { transaction.status = TransactionStatus::Complete; } return transaction; } QList > ScreenXmlParser::parseAccountsInfo() { QList > accounts; for (const Node &node: m_nodes) { const QString text = node.text; // Проверяем только нужные строки if (!text.contains("Номер карты с последними цифрами")) { continue; } QRegularExpressionMatch match = reCardData.match(text); if (!match.hasMatch()) { continue; } MaterialInfo info; info.lastNumbers = match.captured(1).remove(" "); info.amount = match.captured(2).remove(" ").toFloat(); info.description = text.mid(0, text.indexOf("Номер карты")); info.updateTime = DateUtils::getUtcNow(); accounts.append(qMakePair(info, node)); } return accounts; } QString ScreenXmlParser::findFullCardNumber() { static const QRegularExpression cardNumberRegex(R"(^\d{4}[\s\x{00A0}\x{202F}]\d{4}[\s\x{00A0}\x{202F}]\d{4}[\s\x{00A0}\x{202F}]\d{4}$)"); for (const Node &node : m_nodes) { if (node.className == "android.widget.TextView") { const QString text = node.text.trimmed(); if (cardNumberRegex.match(text).hasMatch()) { QString result = text; result.remove(QChar(' ')).remove(QChar(0x00A0)).remove(QChar(0x202F)); return result; } } } return {}; } bool ScreenXmlParser::isTransactionListScreen() { // Экран "Операции": WebView с text="Операции" + наличие кнопок фильтров for (const Node &node : m_nodes) { if (node.className == "android.webkit.WebView" && node.text.trimmed() == "Операции") { return true; } } return false; } QList ScreenXmlParser::findTransactionButtons(const int maxCount) { const QChar rubleSign(0x20BD); // ₽ QList buttons; // В новом Svelte-UI Ozon ноды устроены так: Button-обёртка с пустым text, // а сам текст транзакции живёт во вложенной android.view.View с теми же // bounds. В старом UI текст был прямо в Button. Поддерживаем оба варианта: // сначала сматчим Button с непустым text, затем пройдёмся по пустым // Button-ам и подтянем текст из перекрывающейся View с ₽-знаком. auto isSummary = [](const QString &t) { return t.startsWith("Расходы") || t.startsWith("Доходы"); }; for (const Node &node : m_nodes) { if (node.className != "android.widget.Button") continue; const QString text = node.text.trimmed(); if (text.isEmpty()) { // Ищем View с тем же bounds (или внутри Button-bounds), содержащую ₽. // У транзакционной строки высота ~170px — отсекаем мелкие декоры. if (node.height() < 80 || node.height() > 260) continue; QString viewText; for (const Node &v : m_nodes) { if (v.className != "android.view.View") continue; if (!v.text.contains(rubleSign)) continue; if (v.y1() < node.y1() || v.y2() > node.y2()) continue; if (v.x1() < node.x1() || v.x2() > node.x2()) continue; viewText = v.text.trimmed(); break; } if (viewText.isEmpty() || isSummary(viewText)) continue; // Возвращаем Button с подставленным текстом, чтобы вызывающий код // (parseTransactionButtonText / makeTap по координатам) работал // прозрачно для обоих UI-вариантов. Node enriched = node; enriched.text = viewText; buttons.append(enriched); } else { if (!text.contains(rubleSign)) continue; if (isSummary(text)) continue; buttons.append(node); } if (buttons.size() >= maxCount) break; } return buttons; } bool ScreenXmlParser::hasCollapsedTransactionsBelow(const int screenHeight) { const QChar rubleSign(0x20BD); // Off-screen строка в Ozon WebView выглядит так: bounds=[0,Y][W,Y] (height=0) // на нижнем краю viewport. Текст транзакции лежит во внутреннем // android.widget.Button (внешний wrapper-View имеет пустой text). // Ищем именно Button-ы с h=0 и ₽ в тексте — в этом случае и количество // совпадает с числом скрытых строк. const int bottomHalf = screenHeight / 2; for (const Node &node : m_nodes) { if (node.className != "android.widget.Button") continue; if (node.height() != 0) continue; if (node.y1() < bottomHalf) continue; const QString text = node.text.trimmed(); if (text.isEmpty() || !text.contains(rubleSign)) continue; if (text.startsWith("Расходы") || text.startsWith("Доходы")) continue; return true; } return false; } TransactionInfo ScreenXmlParser::parseTransactionButtonText(const QString &text) { const QChar rubleSign(0x20BD); // ₽ const QChar unicodeMinus(0x2212); // − const QChar nbsp(0x00A0); // non-breaking space TransactionInfo info; const int rubleIdx = text.indexOf(rubleSign); if (rubleIdx < 0) return info; int signIdx = -1; bool isExpense = false; for (int i = rubleIdx - 1; i >= 0; --i) { const QChar ch = text.at(i); if (ch == '+') { signIdx = i; isExpense = false; break; } if (ch == '-' || ch == unicodeMinus) { signIdx = i; isExpense = true; break; } } if (signIdx < 0) return info; info.description = text.left(signIdx).trimmed(); QString amountStr = text.mid(signIdx + 1, rubleIdx - signIdx - 1).trimmed(); amountStr.remove(QChar(' ')); amountStr.remove(nbsp); amountStr.replace(',', '.'); info.amount = amountStr.toDouble(); if (isExpense) info.amount = -info.amount; return info; } TransactionInfo ScreenXmlParser::parseTransactionDetail() { TransactionInfo info; for (const Node &node : m_nodes) { if (node.className != "android.widget.TextView") continue; const QString text = node.text.trimmed(); if (text.isEmpty()) continue; // Дата+время: "15.01.2026, 15:55" if (info.bankTime.isEmpty() && text.contains('.') && text.contains(':') && text.length() <= 25) { info.bankTime = text; continue; } // Телефон: "+7 (985) 553-28-96" if (info.phone.isEmpty() && text.startsWith('+') && text.contains('(')) { info.phone = text; continue; } } // Имя отправителя — TextView прямо перед телефоном (по y-координате чуть выше) if (!info.phone.isEmpty()) { // Ищем ноду телефона и берём ближайший TextView выше int phoneY = -1; for (const Node &node : m_nodes) { if (node.className == "android.widget.TextView" && node.text.trimmed() == info.phone) { phoneY = node.y(); break; } } if (phoneY > 0) { int bestY = -1; for (const Node &node : m_nodes) { if (node.className != "android.widget.TextView") continue; const QString text = node.text.trimmed(); if (text.isEmpty() || text == info.bankTime || text == info.phone) continue; if (node.y() > 0 && node.y() < phoneY && node.y() > bestY) { bestY = node.y(); info.name = text; } } } } return info; } void ScreenXmlParser::test() { // TransactionInfo info2 = parseExpandedInfo(m_xml); // qDebug().noquote().nospace() << convertTransactionToString(info2); // return; QFile file("assets/rshb/all_products.xml"); if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { qDebug() << "Cannot open file"; } QDomDocument doc; if (!doc.setContent(&file)) { qDebug() << "Failed to parse XML"; } file.close(); qDebug() << "Start parsing XML:"; // Номер карты с последними цифрами QDomElement root = doc.documentElement(); parseAndSaveXml(doc.toString()); // for (const MaterialInfo &info: parseAccountsInfo()) { // qDebug().noquote().nospace() << convertAccountToString(info); // } // TransactionInfo info = parseExpandedInfo(root); // qDebug().noquote().nospace() << convertTransactionToString(info); // ЕСПП } } // namespace Ozon