1
0
forked from BRT/arc
arc/android/ozon/ScreenXmlParser.cpp
trnsmkot 1346ca3576 Introduce TaskDumpRecorder for centralized task dump management:
- Add TaskDumpRecorder to save task-related XML dumps and upload failure info to Telegram.
- Integrate TaskDumpRecorder into `PayByPhoneScript` and `PayByCardScript` for Dushanbe and Ozon banks.
- Extend ADB utilities to record XML dumps with TaskDumpRecorder and refine keyboard dismissal logic.
- Enhance freeze detection with consecutive dump checks in CommonScript and payment scripts.
- Update mock server profiles and scenario configurations with new material IDs.
2026-04-26 19:22:40 +07:00

1613 lines
64 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#include "ScreenXmlParser.h"
#include <QRegularExpression>
#include <QMap>
#include <QSet>
#include <QList>
#include <QXmlStreamReader>
#include <algorithm>
#include <climits>
#include <QDomDocument>
#include <QFile>
#include <QDateTime>
#include "adb/AdbUtils.h"
#include "android/xml/Node.h"
#include "db/MaterialInfo.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) {
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()) {
// <node class="android.widget.Button" package="ru.rshb.dbo" clickable="true" bounds="[630,142][691,205]"/>
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;
}
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<Node> ScreenXmlParser::findAllButtons() {
QList<Node> buttons;
for (const Node &node : m_nodes) {
if (node.className == "android.widget.Button" && !node.text.isEmpty()) {
buttons.append(node);
}
}
return buttons;
}
const QList<Node> &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::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 {};
}
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<QString> 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<QString> 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;
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<MaterialInfo> 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<MaterialInfo> 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;
MaterialInfo 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("Номер телефона");
QString phone = node.text.left(idx).trimmed();
phone.remove(' ');
phone.remove(QChar(0x00A0));
return phone;
}
}
return {};
}
QList<Node> 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<QString, Node> 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;
const QString t = node.text.trimmed();
if (const auto match = digitRx.match(t); match.hasMatch()) {
digitMap[match.captured(1)] = node;
}
}
qDebug() << "[findPinCodeNodes] digitMap size:" << digitMap.size()
<< "keys:" << digitMap.keys();
if (digitMap.size() >= 10) {
QList<Node> 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<Node> 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<Node> {
QList<QList<Node>> groups;
QList<QPair<int,int>> 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<Node> best;
for (const auto &grp : groups) {
if (grp.size() >= 10 && (best.size() < 10 || grp.size() < best.size()))
best = grp;
}
return best;
};
// Сначала квадратные, потом любые
QList<Node> 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<QList<Node>> rows;
for (const Node &node : candidates) {
bool placed = false;
for (QList<Node> &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<Node> &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<QList<Node>> fullRows; // строки по 3 кнопки
QList<QList<Node>> shortRows; // строки с 1-2 кнопками (последний ряд)
for (const QList<Node> &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<Node> 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<Node> 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<Node> ScreenXmlParser::getLast2DaysTransactions() {
QList<Node> 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;
}
/**
* <node NAF="true" index="1" text="" resource-id=""
class="android.widget.Button" package="ru.rshb.dbo"
content-desc="" checkable="false" checked="false"
clickable="true" enabled="true" focusable="false"
focused="false" scrollable="false"
long-clickable="false" password="false"
selected="false" bounds="[952,159][1039,248]"/>
*/
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"([^\очка])"));
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<TransactionInfo> ScreenXmlParser::parseTodayAndYesterdayReportHistory() {
QList<TransactionInfo> 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<TransactionInfo> 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<TransactionInfo> 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<QPair<MaterialInfo, Node> > ScreenXmlParser::parseAccountsInfo() {
QList<QPair<MaterialInfo, Node> > 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<Node> ScreenXmlParser::findTransactionButtons(const int maxCount) {
const QChar rubleSign(0x20BD); // ₽
QList<Node> 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