1
0
forked from BRT/arc
arc/android/ozon/ScreenXmlParser.cpp
slava 832fb03aac Add EventWindow, database migrations, and enhanced DAO methods
Implemented `EventWindow` for viewing events with pagination and table-based UI. Added database migration to remove `UNIQUE` constraint on `external_id` in `events`. Enhanced DAO methods for event retrieval, pagination, and transaction updates. Integrated OCR recognition in transaction scripts and streamlined event handling in services.
2026-03-04 22:56:47 +07:00

1402 lines
55 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/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()) {
// <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::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::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<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 ниже зоны заголовка, выше строки с телефоном.
// В profile.xml это единственный короткий нейтральный TextView в диапазоне y ≈ 400750.
static const QSet<QString> 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<AccountInfo> 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<AccountInfo> 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<Node> ScreenXmlParser::findPinCodeNodes(const QString &pinCode) {
// Ozon PIN кнопки — анонимные FrameLayout без text/content-desc.
// Алгоритм не зависит от разрешения: ищем строки из ровно 3 кликабельных узлов.
// 1. Собираем все кликабельные узлы с ненулевым размером
QList<Node> clickable;
for (const Node &node: m_nodes) {
if (node.clickable && node.width() > 0 && node.height() > 0) {
clickable.append(node);
}
}
if (clickable.isEmpty()) return {};
// 2. Находим минимальную высоту — это примерный размер одной кнопки.
// Исключаем полноэкранные элементы (WebView и т.п.) фильтром height <= minH * 3.
int minH = INT_MAX;
for (const Node &n: clickable) minH = std::min(minH, n.height());
QList<Node> candidates;
for (const Node &n: clickable) {
if (n.height() <= minH * 3) candidates.append(n);
}
// 3. Сортируем по y
std::sort(candidates.begin(), candidates.end(), [](const Node &a, const Node &b) {
return a.y() < b.y();
});
// 4. Группируем в строки: узлы с разницей y < height первого узла строки — одна строка
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});
}
// 5. Внутри каждой строки сортируем по x (слева направо)
for (QList<Node> &row: rows) {
std::sort(row.begin(), row.end(), [](const Node &a, const Node &b) {
return a.x() < b.x();
});
}
// 6. PIN-сетка — строки ровно с 3 узлами (3 столбца)
// Строки 1-3: цифры 1-9
// Строка 4: (биометрия) [0] (backspace) → берём средний элемент
qDebug() << "[findPinCodeNodes] clickable:" << clickable.size()
<< "minH:" << minH << "candidates:" << candidates.size() << "rows:" << rows.size();
for (int i = 0; i < rows.size(); ++i) {
QStringList info;
for (const Node &n : rows[i])
info << QString("(%1,%2 %3x%4 '%5')").arg(n.x()).arg(n.y()).arg(n.width()).arg(n.height()).arg(n.text.left(15));
qDebug() << "[findPinCodeNodes] row" << i << "size:" << rows[i].size() << info.join(" | ");
}
QList<QList<Node>> gridRows;
for (const QList<Node> &row: rows) {
if (row.size() == 3) gridRows.append(row);
}
qDebug() << "[findPinCodeNodes] gridRows:" << gridRows.size() << "pinCode len:" << pinCode.size();
QMap<QString, Node> digitMap;
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.isEmpty() && gridRows.last().size() >= 2) {
digitMap["0"] = gridRows.last()[1]; // средний столбец последней строки
}
QList<Node> result;
for (const QChar &c: pinCode) {
if (const QString d = QString(c); digitMap.contains(d)) {
result.append(digitMap[d]);
}
}
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();
}
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<AccountInfo, Node> > ScreenXmlParser::parseAccountsInfo() {
QList<QPair<AccountInfo, 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;
}
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<Node> ScreenXmlParser::findTransactionButtons(const int maxCount) {
const QChar rubleSign(0x20BD); // ₽
QList<Node> 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