1506 lines
59 KiB
C++
1506 lines
59 KiB
C++
#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) {
|
||
// 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;
|
||
}
|
||
}
|
||
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 ниже заголовка "Аккаунт",
|
||
// который не является известным элементом 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("Номер телефона");
|
||
return node.text.left(idx).trimmed();
|
||
}
|
||
}
|
||
return {};
|
||
}
|
||
|
||
QList<Node> ScreenXmlParser::findPinCodeNodes(const QString &pinCode) {
|
||
const QString cleanPin = QString(pinCode).remove(' ');
|
||
// Способ 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);
|
||
if (candidates.size() < 10)
|
||
candidates = fuzzyGroup(false);
|
||
|
||
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]);
|
||
}
|
||
}
|
||
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"([^\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<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;
|
||
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 MaterialInfo &info: parseAccountsInfo()) {
|
||
// qDebug().noquote().nospace() << convertAccountToString(info);
|
||
// }
|
||
// TransactionInfo info = parseExpandedInfo(root);
|
||
// qDebug().noquote().nospace() << convertTransactionToString(info);
|
||
// ЕСПП
|
||
}
|
||
|
||
} // namespace Ozon
|