#include "ScreenXmlParser.h" #include #include #include #include #include #include #include #include #include #include #include "adb/AdbUtils.h" #include "android/xml/Node.h" #include "db/AccountInfo.h" #include "db/TransactionInfo.h" #include "time/DateUtils.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) { // 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::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 Банка: одновременно присутствуют кнопки "Перевести" и "Пополнить" bool hasTransfer = false; bool hasTopUp = false; for (const Node &node: m_nodes) { const QString t = node.text.trimmed(); if (t == "Перевести") hasTransfer = true; if (t == "Пополнить") 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::tryToFindGooglePlayBanner() { // Баннер 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; tapNode.setBounds(540, 100, 541, 101); return tapNode; } } 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 ниже зоны заголовка, выше строки с телефоном. // В profile.xml это единственный короткий нейтральный TextView в диапазоне y ≈ 400–750. static const QSet uiLabels = { "Аккаунт", "Мои документы", "Паспорт и ИНН", "Уровень счёта Лимиты и тарифы", "Повысить", "Изменить код-пароль", "Изменить кодовое слово", "Использовать данные биометрии", "Выйти", "Оформите подписку", "QR-код для пополнений Принимайте чаевые и донаты", "Добавить Почта" }; for (const Node &node: m_nodes) { if (node.className == "android.widget.TextView" && !node.text.trimmed().isEmpty() && node.y() > 400 && node.y() < 750 && !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; if (node.text.trimmed().endsWith(lastNumbers)) { 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 {}; } 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"; // 2. Собираем все кнопки карт (текст заканчивается на 4 цифры) QList cards; for (const Node &node : m_nodes) { if (node.className != "android.widget.Button" || !node.clickable) continue; QString t = node.text.trimmed(); if (!endsWithFourDigits.match(t).hasMatch()) continue; AccountInfo acc; acc.lastNumbers = t.right(4); acc.amount = commonAmount; acc.currency = "RUB"; acc.description = "Виртуальная карта"; qDebug() << "[parseCards] Card found: lastNumbers=" << 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("Номер телефона"); return node.text.left(idx).trimmed(); } } return {}; } QList ScreenXmlParser::findPinCodeNodes(const QString &pinCode) { // Способ 1: ищем кликабельные ноды с текстом-цифрой (0-9) QMap digitMap; for (const Node &node : m_nodes) { if (!node.clickable || node.width() <= 0 || node.height() <= 0) continue; const QString t = node.text.trimmed(); if (t.size() == 1 && t.at(0).isDigit()) { digitMap[t] = node; } } if (digitMap.size() >= 10) { QList result; for (const QChar &c : pinCode) { 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): анонимные FrameLayout — ищем сетку 4x3 QList clickable; for (const Node &node : m_nodes) { if (node.clickable && node.width() > 0 && node.height() > 0) clickable.append(node); } if (clickable.isEmpty()) return {}; int minH = INT_MAX; for (const Node &n : clickable) minH = std::min(minH, n.height()); QList candidates; for (const Node &n : clickable) { if (n.height() <= minH * 3) candidates.append(n); } 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(); }); } QList> gridRows; for (const QList &row : rows) { if (row.size() == 3) gridRows.append(row); } if (gridRows.size() < 4) { qWarning() << "[findPinCodeNodes] grid not found: gridRows=" << gridRows.size(); return {}; } const QStringList digits19 = {"1", "2", "3", "4", "5", "6", "7", "8", "9"}; int idx = 0; for (int r = 0; r < gridRows.size() - 1 && idx < digits19.size(); ++r) { for (const Node &n : gridRows[r]) { if (idx < digits19.size()) digitMap[digits19[idx++]] = n; } } if (gridRows.last().size() >= 2) { digitMap["0"] = gridRows.last()[1]; } QList result; for (const QChar &c : pinCode) { if (const QString d = QString(c); digitMap.contains(d)) { result.append(digitMap[d]); } } 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(); } 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; } QList ScreenXmlParser::parseTodayAndYesterdayHistory() { QDate today = DateUtils::getMoscowNow().date(); QDate yesterday = today.addDays(-1); QDate beforeYesterday = today.addDays(-2); QString todayTitle = QString("сегодня, %1").arg(getDayOfWeekAsString(today.dayOfWeek())); QString yesterdayTitle = QString("вчера, %1").arg(getDayOfWeekAsString(yesterday.dayOfWeek())); QString beforeYesterdayTitle = QString("%1 %2, %3").arg( QString::number(beforeYesterday.day()), getMonthAsString(beforeYesterday.month()), getDayOfWeekAsString(beforeYesterday.dayOfWeek()) ); QDomNode todayNode = findNode(m_xml, todayTitle); QDomNode yesterdayNode = findNode(m_xml, yesterdayTitle); QDomNode parentNode; if (!yesterdayNode.isNull()) { parentNode = yesterdayNode.parentNode(); } else if (!todayNode.isNull()) { parentNode = todayNode.parentNode(); } 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(beforeYesterdayTitle)) { // только сегодня и вчера парсим, позавчера не трогаем break; } if (titleLowerCase.startsWith(todayTitle)) { date = today; transactionShortText = transactionShortText.mid(todayTitle.length() + 1); } if (titleLowerCase.startsWith(yesterdayTitle)) { date = yesterday; transactionShortText = transactionShortText.mid(yesterdayTitle.length() + 1); } 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; } AccountInfo 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\d{4}\s\d{4}\s\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()) { return QString(text).remove(QChar(' ')); } } } 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; for (const Node &node : m_nodes) { if (node.className != "android.widget.Button") continue; const QString text = node.text.trimmed(); if (!text.contains(rubleSign)) continue; // Пропускаем сводки "Расходы", "Доходы" if (text.startsWith("Расходы") || text.startsWith("Доходы")) continue; buttons.append(node); if (buttons.size() >= maxCount) break; } return buttons; } 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 AccountInfo &info: parseAccountsInfo()) { // qDebug().noquote().nospace() << convertAccountToString(info); // } // TransactionInfo info = parseExpandedInfo(root); // qDebug().noquote().nospace() << convertTransactionToString(info); // ЕСПП } } // namespace Ozon