1
0
forked from BRT/arc

Refine transaction fetching and parsing:

- Add date header parsing and swipe functionality for streamlined `Ozon` transactions list processing.
- Improve deduplication logic, date assignment, and scroll behavior in `Dushanbe` and `Ozon` scripts.
- Introduce screenshot handling for debugging transaction fetching workflows.
- Add cancellation logic by `materialId` in `EventDAO`.
- Update mock server for enhanced filename generation and PATCH support for bank profiles.
This commit is contained in:
trnsmkot 2026-04-13 22:41:15 +07:00
parent c16b1a78f1
commit ce14d5eef3
10 changed files with 467 additions and 133 deletions

View File

@ -328,6 +328,16 @@ void GetLastTransactionsScript::doStart() {
AdbUtils::makeTap(m_deviceId, vypiskaBtn.x(), vypiskaBtn.y()); AdbUtils::makeTap(m_deviceId, vypiskaBtn.x(), vypiskaBtn.y());
QThread::msleep(3000); QThread::msleep(3000);
// Pull-to-refresh: свайп сверху вниз для обновления списка
{
const int x = device.screenWidth / 2;
const int fromY = device.screenHeight / 4;
const int toY = device.screenHeight * 3 / 4;
AdbUtils::makeSwipe(m_deviceId, x, fromY, x, toY);
QThread::msleep(3000);
qDebug() << "[Dushanbe::GetLastTransactions] Pull-to-refresh on Выписка";
}
// Сохраняем дамп Выписки для анализа формата // Сохраняем дамп Выписки для анализа формата
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter)); xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
@ -342,6 +352,9 @@ void GetLastTransactionsScript::doStart() {
} }
} }
// Скриншот списка транзакций — отправим при ошибке, удалим при успехе
const QByteArray listScreenshot = AdbUtils::captureScreenshotBytes(m_deviceId);
// Находим accountId // Находим accountId
int accountId = -1; int accountId = -1;
const QList<MaterialInfo> accounts = MaterialDAO::getAccounts(device.id, m_appCode); const QList<MaterialInfo> accounts = MaterialDAO::getAccounts(device.id, m_appCode);
@ -354,10 +367,8 @@ void GetLastTransactionsScript::doStart() {
qDebug() << "[Dushanbe::GetLastTransactions] Searching: amount=" << m_searchAmount qDebug() << "[Dushanbe::GetLastTransactions] Searching: amount=" << m_searchAmount
<< "time=" << m_searchTime.toString(Qt::ISODate); << "time=" << m_searchTime.toString(Qt::ISODate);
// Время в банковском приложении показывается в таймзоне устройства // Время в банковском приложении показывается в таймзоне Душанбе (UTC+5), не устройства
const QString tzName = AdbUtils::getDeviceTimezone(m_deviceId); const QTimeZone tjTz("Asia/Dushanbe");
QTimeZone tjTz(tzName.toUtf8());
if (!tjTz.isValid()) tjTz = QTimeZone("Asia/Dushanbe");
const int maxScrolls = 4; const int maxScrolls = 4;
auto interrupted = []() { auto interrupted = []() {
@ -421,22 +432,45 @@ void GetLastTransactionsScript::doStart() {
} }
// 2. Собираем View-транзакции (формат Выписки) // 2. Собираем View-транзакции (формат Выписки)
// Два формата content-desc:
// a) DCWallet/*** — старый: DCWallet*WDC...\ncard\namount\nописание\n HH:MM:SS
// b) P2P — новый: Сегодня|dd.MM.yyyy\ncard\ncard\namount\nописание\n HH:MM:SS
static const QRegularExpression reTime(R"(\d{2}:\d{2}:\d{2})"); static const QRegularExpression reTime(R"(\d{2}:\d{2}:\d{2})");
static const QRegularExpression reDateOrToday(
QString("^(?:\\d{2}\\.\\d{2}\\.\\d{4}|%1|%2)$")
.arg(QString::fromUtf8("Сегодня"), QString::fromUtf8("Вчера")));
QList<VypiskaItem> items; QList<VypiskaItem> items;
for (const Node &n : xmlScreenParser.findAllNodes()) { for (const Node &n : xmlScreenParser.findAllNodes()) {
if (n.className != "android.view.View") continue; if (n.className != "android.view.View") continue;
if (!n.contentDesc.contains("DCWallet") && !n.contentDesc.contains("***")) continue;
const QStringList lines = n.contentDesc.split('\n'); const QStringList lines = n.contentDesc.split('\n');
if (lines.size() < 5) continue; if (lines.size() < 5) continue;
const QString last = lines.last().trimmed(); const QString last = lines.last().trimmed();
if (!reTime.match(last).hasMatch()) continue; if (!reTime.match(last).hasMatch()) continue;
// Определяем формат: если первая строка — дата/Сегодня/Вчера, это P2P формат
const QString firstLine = lines.first().trimmed();
const bool isP2P = reDateOrToday.match(firstLine).hasMatch();
if (!isP2P && !n.contentDesc.contains("DCWallet") && !n.contentDesc.contains("***")) continue;
VypiskaItem it; VypiskaItem it;
bool ok = false; bool ok = false;
it.amount = lines[2].trimmed().toDouble(&ok); // В P2P формате amount на позиции [3] (после даты, двух карт), в DCWallet — [2]
const int amtIdx = isP2P ? 3 : 2;
if (amtIdx >= lines.size()) continue;
it.amount = lines[amtIdx].trimmed().toDouble(&ok);
if (!ok) continue; if (!ok) continue;
it.time = last; it.time = last;
it.node = n; it.node = n;
// В P2P формате дата встроена в первую строку
if (isP2P) {
if (firstLine.compare(QString::fromUtf8("Сегодня"), Qt::CaseInsensitive) == 0)
it.date = todayLocal.toString("dd.MM.yyyy");
else if (firstLine.compare(QString::fromUtf8("Вчера"), Qt::CaseInsensitive) == 0)
it.date = todayLocal.addDays(-1).toString("dd.MM.yyyy");
else
it.date = firstLine;
}
items.append(it); items.append(it);
} }
@ -497,7 +531,9 @@ void GetLastTransactionsScript::doStart() {
int prevY2 = -1; int prevY2 = -1;
for (VypiskaItem &it : items) { for (VypiskaItem &it : items) {
if (prevY2 >= 0 && it.node.y1() - prevY2 > 30) ++groupIdx; if (prevY2 >= 0 && it.node.y1() - prevY2 > 30) ++groupIdx;
if (groupIdx < groupDates.size()) it.date = groupDates[groupIdx]; // Не перезаписываем дату если она уже установлена (P2P формат)
if (it.date.isEmpty() && groupIdx < groupDates.size())
it.date = groupDates[groupIdx];
prevY2 = it.node.y2(); prevY2 = it.node.y2();
} }
@ -539,7 +575,7 @@ void GetLastTransactionsScript::doStart() {
const QTime searchLocalTime = m_searchTime.toTimeZone(tjTz).time(); const QTime searchLocalTime = m_searchTime.toTimeZone(tjTz).time();
if (listTime.isValid() && searchLocalTime.isValid()) { if (listTime.isValid() && searchLocalTime.isValid()) {
const int diffSecs = qAbs(listTime.secsTo(searchLocalTime)); const int diffSecs = qAbs(listTime.secsTo(searchLocalTime));
if (diffSecs > 3600 && diffSecs < 82800) { // 82800 = 23h if (diffSecs > 600 && diffSecs < 85800) { // 85800 = 23h50m
qDebug() << "[Dushanbe::GetLastTransactions] Time pre-filter skip:" qDebug() << "[Dushanbe::GetLastTransactions] Time pre-filter skip:"
<< tx.time << "vs" << searchLocalTime.toString("HH:mm:ss") << tx.time << "vs" << searchLocalTime.toString("HH:mm:ss")
<< "diff=" << diffSecs << "sec"; << "diff=" << diffSecs << "sec";
@ -617,8 +653,8 @@ void GetLastTransactionsScript::doStart() {
if (txTime.isValid()) { if (txTime.isValid()) {
txTime.setTimeZone(tjTz); txTime.setTimeZone(tjTz);
const qint64 diffSecs = qAbs(txTime.toUTC().secsTo(m_searchTime.toUTC())); const qint64 diffSecs = qAbs(txTime.toUTC().secsTo(m_searchTime.toUTC()));
if (diffSecs > 3600) { if (diffSecs > 600) {
qDebug() << "[Dushanbe::GetLastTransactions] Time mismatch: diff=" << diffSecs << "sec, skipping"; qDebug() << "[Dushanbe::GetLastTransactions] Time mismatch: diff=" << diffSecs << "sec (>10min), skipping";
AdbUtils::goBack(m_deviceId); AdbUtils::goBack(m_deviceId);
QThread::msleep(1000); QThread::msleep(1000);
continue; continue;
@ -699,6 +735,9 @@ void GetLastTransactionsScript::doStart() {
+ " time=" + m_searchTime.toString(Qt::ISODate); + " time=" + m_searchTime.toString(Qt::ISODate);
qWarning() << "[Dushanbe::GetLastTransactions]" << m_error; qWarning() << "[Dushanbe::GetLastTransactions]" << m_error;
// Отправляем скриншот списка транзакций, сделанный до начала поиска
if (!listScreenshot.isEmpty()) emit screenshotReady(listScreenshot);
// Возвращаемся на главную // Возвращаемся на главную
Node glavnayaBtn = xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("Главная")); Node glavnayaBtn = xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("Главная"));
if (glavnayaBtn.x() > 0 && glavnayaBtn.y() > 0) { if (glavnayaBtn.x() > 0 && glavnayaBtn.y() > 0) {

View File

@ -27,6 +27,9 @@ signals:
// повторного чтения из БД. // повторного чтения из БД.
void transactionParsed(const QJsonObject &tx); void transactionParsed(const QJsonObject &tx);
// Скриншот списка транзакций (при ошибке отправляется в мониторинг)
void screenshotReady(const QByteArray &screenshot);
protected: protected:
void doStart() override; void doStart() override;

View File

@ -205,6 +205,9 @@ bool GetLastTransactionsScript::navigateToTransactionList(const DeviceInfo &devi
} }
void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int accountId) { void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int accountId) {
// Скриншот списка транзакций — отправим при ошибке, удалим при успехе
const QByteArray listScreenshot = AdbUtils::captureScreenshotBytes(m_deviceId);
// Время в Ozon показывается в таймзоне устройства, а не в MSK // Время в Ozon показывается в таймзоне устройства, а не в MSK
const QString tzName = AdbUtils::getDeviceTimezone(m_deviceId); const QString tzName = AdbUtils::getDeviceTimezone(m_deviceId);
QTimeZone deviceTz(tzName.toUtf8()); QTimeZone deviceTz(tzName.toUtf8());
@ -267,6 +270,8 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int
QThread::msleep(2000); QThread::msleep(2000);
} }
// Скроллим к нужной дате, запоминаем количество скроллов
int scrollsToDate = 0;
bool dateFound = false; bool dateFound = false;
for (int scroll = 0; scroll < maxScrolls; ++scroll) { for (int scroll = 0; scroll < maxScrolls; ++scroll) {
if (QThread::currentThread()->isInterruptionRequested()) { if (QThread::currentThread()->isInterruptionRequested()) {
@ -276,7 +281,8 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter)); xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
if (hasDateOnScreen()) { if (hasDateOnScreen()) {
dateFound = true; dateFound = true;
qDebug() << "[FetchTransactions] Found target date on screen"; scrollsToDate = scroll;
qDebug() << "[FetchTransactions] Found target date on screen after" << scroll << "scrolls";
break; break;
} }
if (hasOlderDateOnScreen()) { if (hasOlderDateOnScreen()) {
@ -291,13 +297,33 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int
if (!dateFound) { if (!dateFound) {
m_error = "Date not found in transaction list: " + searchDate.toString("dd.MM.yyyy"); m_error = "Date not found in transaction list: " + searchDate.toString("dd.MM.yyyy");
qWarning() << "[FetchTransactions]" << m_error; qWarning() << "[FetchTransactions]" << m_error;
m_resultScreenshot = AdbUtils::captureScreenshotBytes(m_deviceId); m_resultScreenshot = listScreenshot.isEmpty()
? AdbUtils::captureScreenshotBytes(m_deviceId) : listScreenshot;
if (!m_resultScreenshot.isEmpty()) emit screenshotReady(m_resultScreenshot); if (!m_resultScreenshot.isEmpty()) emit screenshotReady(m_resultScreenshot);
return; return;
} }
// ── Шаг 2: тапаем по кнопкам с нужной суммой, проверяем дату/время в деталях ─ // Хелпер: находит Y-диапазон [dateY, nextDateY) для нужной даты на экране.
// Запоминаем уже проверенные (date+time из деталей) чтобы не застревать // Кнопки транзакций между этими Y принадлежат нужному дню.
auto findDateYRange = [&]() -> QPair<int, int> {
int dateY = -1;
int nextDateY = device.screenHeight;
for (const Node &node : xmlScreenParser.getNodes()) {
if (node.className != "android.widget.TextView") continue;
const QDate d = parseDateHeader(node.text.trimmed());
if (!d.isValid()) continue;
if (d == searchDate) {
dateY = node.y();
} else if (dateY >= 0 && node.y() > dateY) {
// Первый заголовок другой даты после нашей
nextDateY = node.y();
break;
}
}
return {dateY, nextDateY};
};
// ── Шаг 2: тапаем только по кнопкам под заголовком нужной даты ───
QSet<QString> checkedDetails; QSet<QString> checkedDetails;
for (int attempt = 0; attempt < 15; ++attempt) { for (int attempt = 0; attempt < 15; ++attempt) {
@ -306,6 +332,19 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int
return; return;
} }
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter)); xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
// Находим Y-диапазон заголовка нужной даты
const auto [dateHeaderY, nextDateHeaderY] = findDateYRange();
if (dateHeaderY < 0) {
qDebug() << "[FetchTransactions] Date header not on screen, scrolling";
AdbUtils::makeSwipe(m_deviceId, device.screenWidth / 2,
device.screenHeight * 3 / 4,
device.screenWidth / 2,
device.screenHeight / 4, 300);
QThread::msleep(1000);
continue;
}
QList<Node> txButtons = xmlScreenParser.findTransactionButtons(20); QList<Node> txButtons = xmlScreenParser.findTransactionButtons(20);
bool tapped = false; bool tapped = false;
@ -315,16 +354,18 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int
return; return;
} }
// Пропускаем кнопки вне Y-диапазона нужной даты
const int btnY = txButtons[bi].y();
if (btnY <= dateHeaderY || btnY >= nextDateHeaderY) continue;
if (btnY <= 0 || btnY >= navBarTop) continue;
const TransactionInfo parsed = xmlScreenParser.parseTransactionButtonText(txButtons[bi].text); const TransactionInfo parsed = xmlScreenParser.parseTransactionButtonText(txButtons[bi].text);
if (qAbs(parsed.amount - m_searchAmount) > 0.01) continue; if (qAbs(parsed.amount - m_searchAmount) > 0.01) continue;
// Пропускаем кнопки за экраном
const int btnY = txButtons[bi].y();
if (btnY <= 0 || btnY >= navBarTop) continue;
const Node &visibleBtn = txButtons[bi]; const Node &visibleBtn = txButtons[bi];
qDebug() << "[FetchTransactions] Amount match:" << parsed.amount qDebug() << "[FetchTransactions] Amount match:" << parsed.amount
<< "at" << visibleBtn.x() << visibleBtn.y(); << "at" << visibleBtn.x() << visibleBtn.y()
<< "(dateY:" << dateHeaderY << "-" << nextDateHeaderY << ")";
// Тапаем и ждём деталей // Тапаем и ждём деталей
AdbUtils::makeTap(m_deviceId, visibleBtn.x(), visibleBtn.y()); AdbUtils::makeTap(m_deviceId, visibleBtn.x(), visibleBtn.y());
@ -367,23 +408,15 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int
qDebug() << "[FetchTransactions] Already checked:" << dtStr << "- skipping"; qDebug() << "[FetchTransactions] Already checked:" << dtStr << "- skipping";
AdbUtils::goBack(m_deviceId); AdbUtils::goBack(m_deviceId);
QThread::msleep(500); QThread::msleep(500);
continue; // пробуем следующую кнопку continue;
} }
checkedDetails.insert(dtStr); checkedDetails.insert(dtStr);
tapped = true; tapped = true;
// Проверяем дату // Проверяем время (±10 минут)
if (txTime.isValid() && txTime.date() != searchDate) {
qDebug() << "[FetchTransactions] Wrong date:" << dtStr << "expected" << searchDate;
AdbUtils::goBack(m_deviceId);
QThread::msleep(1000);
continue;
}
// Проверяем время (±1 час)
if (txTime.isValid() && m_searchTime.isValid()) { if (txTime.isValid() && m_searchTime.isValid()) {
const qint64 diffSecs = qAbs(txTime.toUTC().secsTo(m_searchTime.toUTC())); const qint64 diffSecs = qAbs(txTime.toUTC().secsTo(m_searchTime.toUTC()));
if (diffSecs > 3600) { if (diffSecs > 600) {
qDebug() << "[FetchTransactions] Time mismatch:" << dtStr qDebug() << "[FetchTransactions] Time mismatch:" << dtStr
<< "expected" << searchLocal.toString("dd.MM.yyyy, HH:mm") << "expected" << searchLocal.toString("dd.MM.yyyy, HH:mm")
<< "diff=" << diffSecs << "sec"; << "diff=" << diffSecs << "sec";
@ -515,7 +548,9 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int
m_error = "Transaction not found: amount=" + QString::number(m_searchAmount, 'f', 2) m_error = "Transaction not found: amount=" + QString::number(m_searchAmount, 'f', 2)
+ " date=" + searchDate.toString("dd.MM.yyyy"); + " date=" + searchDate.toString("dd.MM.yyyy");
qWarning() << "[FetchTransactions]" << m_error; qWarning() << "[FetchTransactions]" << m_error;
m_resultScreenshot = AdbUtils::captureScreenshotBytes(m_deviceId); // Отправляем скриншот списка транзакций, сделанный до начала поиска
m_resultScreenshot = listScreenshot.isEmpty()
? AdbUtils::captureScreenshotBytes(m_deviceId) : listScreenshot;
if (!m_resultScreenshot.isEmpty()) emit screenshotReady(m_resultScreenshot); if (!m_resultScreenshot.isEmpty()) emit screenshotReady(m_resultScreenshot);
} }

View File

@ -272,7 +272,74 @@ bool EventDAO::existsActiveByExternalId(const QString &externalId) {
return query.next(); return query.next();
} }
bool EventDAO::cancelWaitingByType(const QString &deviceId, EventType type, const QString &bankName) { QList<EventInfo> EventDAO::cancelWaitingByMaterial(const QString &materialId, EventType type) {
QList<EventInfo> cancelled;
QSqlQuery selectQuery(DatabaseManager::instance().database());
selectQuery.prepare(R"(
SELECT id, external_id, bank_name, type FROM events
WHERE external_id = :material_id AND type = :type AND status = :wait
)");
selectQuery.bindValue(":material_id", materialId);
selectQuery.bindValue(":type", eventTypeToString(type));
selectQuery.bindValue(":wait", eventStatusToString(EventStatus::Wait));
if (selectQuery.exec()) {
while (selectQuery.next()) {
EventInfo e;
e.id = selectQuery.value(0).toInt();
e.externalId = selectQuery.value(1).toString();
e.bankName = selectQuery.value(2).toString();
e.type = eventTypeFromString(selectQuery.value(3).toString());
cancelled.append(e);
}
}
QSqlQuery query(DatabaseManager::instance().database());
query.prepare(R"(
UPDATE events SET status = :cancelled, comment = 'Cancelled: replaced by new task'
WHERE external_id = :material_id AND type = :type AND status = :wait
)");
query.bindValue(":cancelled", eventStatusToString(EventStatus::Error));
query.bindValue(":material_id", materialId);
query.bindValue(":type", eventTypeToString(type));
query.bindValue(":wait", eventStatusToString(EventStatus::Wait));
if (!query.exec()) {
qWarning() << "Ошибка при cancelWaitingByMaterial:" << query.lastError().text();
}
return cancelled;
}
QList<EventInfo> EventDAO::cancelWaitingByType(const QString &deviceId, EventType type, const QString &bankName) {
QList<EventInfo> cancelled;
QSqlQuery selectQuery(DatabaseManager::instance().database());
if (bankName.isEmpty()) {
selectQuery.prepare(R"(
SELECT id, external_id, bank_name, type FROM events
WHERE device_id = :device_id AND type = :type AND status = :wait
)");
} else {
selectQuery.prepare(R"(
SELECT id, external_id, bank_name, type FROM events
WHERE device_id = :device_id AND type = :type AND status = :wait AND bank_name = :bank_name
)");
selectQuery.bindValue(":bank_name", bankName);
}
selectQuery.bindValue(":device_id", deviceId);
selectQuery.bindValue(":type", eventTypeToString(type));
selectQuery.bindValue(":wait", eventStatusToString(EventStatus::Wait));
if (selectQuery.exec()) {
while (selectQuery.next()) {
EventInfo e;
e.id = selectQuery.value(0).toInt();
e.externalId = selectQuery.value(1).toString();
e.bankName = selectQuery.value(2).toString();
e.type = eventTypeFromString(selectQuery.value(3).toString());
cancelled.append(e);
}
}
QSqlQuery query(DatabaseManager::instance().database()); QSqlQuery query(DatabaseManager::instance().database());
if (bankName.isEmpty()) { if (bankName.isEmpty()) {
query.prepare(R"( query.prepare(R"(
@ -293,9 +360,8 @@ bool EventDAO::cancelWaitingByType(const QString &deviceId, EventType type, cons
if (!query.exec()) { if (!query.exec()) {
qWarning() << "Ошибка при cancelWaitingByType:" << query.lastError().text(); qWarning() << "Ошибка при cancelWaitingByType:" << query.lastError().text();
return false;
} }
return true; return cancelled;
} }
QList<EventInfo> EventDAO::cancelWaitingByBank(const QString &deviceId, const QString &bankName) { QList<EventInfo> EventDAO::cancelWaitingByBank(const QString &deviceId, const QString &bankName) {

View File

@ -19,7 +19,9 @@ public:
[[nodiscard]] static bool existsActiveByExternalId(const QString &externalId); [[nodiscard]] static bool existsActiveByExternalId(const QString &externalId);
[[nodiscard]] static bool cancelWaitingByType(const QString &deviceId, EventType type, const QString &bankName = {}); [[nodiscard]] static QList<EventInfo> cancelWaitingByType(const QString &deviceId, EventType type, const QString &bankName = {});
[[nodiscard]] static QList<EventInfo> cancelWaitingByMaterial(const QString &materialId, EventType type);
[[nodiscard]] static QList<EventInfo> cancelWaitingByBank(const QString &deviceId, const QString &bankName); [[nodiscard]] static QList<EventInfo> cancelWaitingByBank(const QString &deviceId, const QString &bankName);

View File

@ -115,9 +115,13 @@ void EventHandler::onTasksReceived(const QJsonArray &tasks) {
continue; continue;
} }
// FETCH_PROFILE: отменяем старые ожидающие задачи такого же типа на этом устройстве+банк // FETCH_PROFILE: отменяем старые ожидающие задачи такого же типа для этого материала
if (eventType == EventType::FetchProfile) { if (eventType == EventType::FetchProfile) {
EventDAO::cancelWaitingByType(account.deviceId, EventType::FetchProfile, bankName); const QList<EventInfo> cancelled = EventDAO::cancelWaitingByMaterial(materialId, EventType::FetchProfile);
for (const EventInfo &c : cancelled) {
sendTaskError(eventTypeToString(c.type), c.externalId, c.bankName, "Cancelled: replaced by new task");
EventDAO::markSentToServer(c.id);
}
} }
EventInfo event; EventInfo event;
@ -396,6 +400,18 @@ void EventHandler::updateEventThread(const QString &key, const EventInfo &event,
} }
m_threads.remove(key); m_threads.remove(key);
// Убиваем банковское приложение на устройстве после завершения задачи
{
const MaterialInfo acc = MaterialDAO::getAccountById(event.accountId);
const BankProfileInfo app = BankProfileDAO::getApplication(event.deviceId, acc.appCode);
if (!app.package.isEmpty()) {
const DeviceInfo device = DeviceDAO::getDeviceById(event.deviceId);
const QString adbSerial = device.adbSerial.isEmpty() ? event.deviceId : device.adbSerial;
AdbUtils::tryToKillApplication(adbSerial, app.package);
qDebug() << "[EventHandler] Killed app" << app.package << "on device" << adbSerial;
}
}
// Сразу пробуем запустить следующую задачу для этого устройства // Сразу пробуем запустить следующую задачу для этого устройства
processNextTask(key); processNextTask(key);
} }
@ -463,6 +479,9 @@ void EventHandler::startThreadForTask(const MaterialInfo &account, const EventIn
connect(w, &Dushanbe::GetLastTransactionsScript::transactionParsed, this, connect(w, &Dushanbe::GetLastTransactionsScript::transactionParsed, this,
[this, key](const QJsonObject &tx) { m_parsedTxs[key] = tx; }, [this, key](const QJsonObject &tx) { m_parsedTxs[key] = tx; },
Qt::DirectConnection); Qt::DirectConnection);
connect(w, &Dushanbe::GetLastTransactionsScript::screenshotReady, this,
[this, key](const QByteArray &s) { m_screenshots[key] = s; },
Qt::DirectConnection);
} }
else qWarning() << "[EventHandler] FETCH_TRANSACTIONS: unknown appCode:" << appCode; else qWarning() << "[EventHandler] FETCH_TRANSACTIONS: unknown appCode:" << appCode;
} }
@ -519,6 +538,18 @@ void EventHandler::startThreadForTask(const MaterialInfo &account, const EventIn
sendTaskError(eventTypeToString(event.type), event.externalId, event.bankName, "Script timeout (5 min)"); sendTaskError(eventTypeToString(event.type), event.externalId, event.bankName, "Script timeout (5 min)");
EventDAO::markSentToServer(event.id); EventDAO::markSentToServer(event.id);
// Убиваем банковское приложение на устройстве после таймаута
{
const MaterialInfo acc = MaterialDAO::getAccountById(event.accountId);
const BankProfileInfo app = BankProfileDAO::getApplication(event.deviceId, acc.appCode);
if (!app.package.isEmpty()) {
const DeviceInfo device = DeviceDAO::getDeviceById(event.deviceId);
const QString adbSerial = device.adbSerial.isEmpty() ? event.deviceId : device.adbSerial;
AdbUtils::tryToKillApplication(adbSerial, app.package);
qDebug() << "[EventHandler] Killed app" << app.package << "on device" << adbSerial << "(timeout)";
}
}
processNextTask(key); processNextTask(key);
}); });

View File

@ -1,27 +1,7 @@
{ {
"bank_profiles": [ "bank_profiles": [
{ {
"id": "dd0ba55d-0a02-40ab-8b31-cf6a7529269f", "id": "85f577bc-20be-40fd-ab47-315af3aadfb9",
"bank_name": "dushanbe_city_bank",
"state": "enabled",
"phone_number": "+992185555519",
"first_name": "Мехрубон",
"middle_name": null,
"last_name": "Имомкулзода",
"currency": 972,
"device_id": "986cb259-dfec-4421-8735-4fb126cd1917",
"device_label": "Pixel 7",
"materials": [
{
"id": "61d967d1-8d3e-4d05-ba01-8c8ab327b945",
"state": "enabled",
"card_number": "9762000207113258",
"currency": 972
}
]
},
{
"id": "86bcdee7-1c64-40e3-bc8f-132d153a9d28",
"bank_name": "ozon", "bank_name": "ozon",
"state": "enabled", "state": "enabled",
"phone_number": "+79538038318", "phone_number": "+79538038318",
@ -29,33 +9,19 @@
"middle_name": null, "middle_name": null,
"last_name": "Терехина", "last_name": "Терехина",
"currency": 643, "currency": 643,
"device_id": "986cb259-dfec-4421-8735-4fb126cd1917", "device_id": "c9d3ad7e-659d-4adf-815e-9f234e743078",
"device_label": "Pixel 7", "device_label": "Pixel 7",
"materials": [ "materials": [
{ {
"id": "30bc106c-6935-4f4b-85f3-f1a06adc5c13", "id": "e6c6e12a-3e4e-444c-9e11-62543fb5f4f9",
"state": "enabled", "state": "enabled",
"card_number": "2204321035207919", "card_number": "2204321035207919",
"currency": 643 "currency": 643
} },
]
},
{
"id": "e8c5f3b2-c710-421c-b304-93353ccfbfd5",
"bank_name": "ozon",
"state": "enabled",
"phone_number": "+79855532896",
"first_name": "Владислав",
"middle_name": null,
"last_name": "Касаткин",
"currency": 643,
"device_id": "ec6397fb-89b1-41e1-9b6b-303d6f1e943f",
"device_label": "TECNO KM4",
"materials": [
{ {
"id": "49001bb5-3b24-478d-b913-c2242aea0732", "id": "39592a22-3e79-4438-89d7-778aea72a5e9",
"state": "enabled", "state": "enabled",
"card_number": "2204320439130982", "card_number": "2204321086109865",
"currency": 643 "currency": 643
} }
] ]

View File

@ -160,30 +160,92 @@ def back(serial):
adb(serial, "shell", "input", "keyevent", "KEYCODE_BACK") adb(serial, "shell", "input", "keyevent", "KEYCODE_BACK")
def swipe_up(serial, duration=500):
"""Swipe up to scroll the list down (small step)."""
adb(serial, "shell", "input", "swipe", "540", "1400", "540", "900", str(duration))
# ---------------------------------------------------------------- Ozon # ---------------------------------------------------------------- Ozon
MONTH_MAP = {
"января": 1, "февраля": 2, "марта": 3, "апреля": 4,
"мая": 5, "июня": 6, "июля": 7, "августа": 8,
"сентября": 9, "октября": 10, "ноября": 11, "декабря": 12,
}
DATE_HEADER_RE = re.compile(r"(\d{1,2})\s+(\S+),\s+\S+")
def parse_ozon_date_headers(xml, device_tz):
"""Parse date headers (TextView) and return list of (y, date_str 'dd.MM.yyyy')."""
today = datetime.now(tz=device_tz).date()
headers = []
for m in re.finditer(
r'text="([^"]*)"[^>]*class="android.widget.TextView"'
r'[^>]*bounds="(\[\d+,\d+\]\[\d+,\d+\])"', xml):
text, bounds = m.group(1).strip(), m.group(2)
bm = BOUNDS_RE.match(bounds)
if not bm:
continue
x1, y1, x2, y2 = map(int, bm.groups())
h = y2 - y1
if h < 5 or y1 < 700:
continue # за экраном или в зоне шапки
d = None
if text == "Сегодня":
d = today
elif text == "Вчера":
d = today - timedelta(days=1)
else:
dm = DATE_HEADER_RE.match(text)
if dm:
day = int(dm.group(1))
month = MONTH_MAP.get(dm.group(2), 0)
if month:
d = today.replace(month=month, day=day)
if d > today:
d = d.replace(year=today.year - 1)
if d:
headers.append({"y": y1, "date": d.strftime("%d.%m.%Y")})
headers.sort(key=lambda h: h["y"])
return headers
def parse_ozon_rows(xml): def parse_ozon_rows(xml):
rows = [] rows = []
for m in re.finditer( for m in re.finditer(
r'<node\s[^>]*?text="([^"]*)"' r'text="([^"]*)"'
r'[^>]*?bounds="(\[\d+,\d+\]\[\d+,\d+\])"', xml): r'[^>]*?bounds="(\[\d+,\d+\]\[\d+,\d+\])"', xml):
text, bounds = m.group(1), m.group(2) text, bounds = m.group(1), m.group(2)
bm = BOUNDS_RE.match(bounds) bm = BOUNDS_RE.match(bounds)
if not bm: if not bm:
continue continue
x1, y1, x2, y2 = map(int, bm.groups()) x1, y1, x2, y2 = map(int, bm.groups())
if (y2 - y1) < 50 or "Расход" in text or "Доход" in text: h = y2 - y1
cy = (y1 + y2) // 2
if h < 50 or h < 5 or cy < 800:
continue
if "Расход" in text or "Доход" in text:
continue continue
am = re.search(r"([+\-])\s*(\d[\d\s\u202f]*)", text) am = re.search(r"([+\-])\s*(\d[\d\s\u202f]*)", text)
if not am: if not am:
continue continue
sign = -1 if am.group(1) in ("-", "") else 1 sign = -1 if am.group(1) in ("-", "") else 1
amount = sign * int(re.sub(r"[\s\u202f]", "", am.group(2))) amount = sign * int(re.sub(r"[\s\u202f]", "", am.group(2)))
rows.append({"text": text, "cx": (x1+x2)//2, "cy": (y1+y2)//2, "amount": amount}) rows.append({"text": text, "cx": (x1+x2)//2, "cy": cy, "amount": amount})
rows.sort(key=lambda r: r["cy"]) rows.sort(key=lambda r: r["cy"])
return rows return rows
def assign_dates_to_rows(rows, headers):
"""Assign date from nearest preceding header to each row."""
for row in rows:
row["date"] = None
for h in reversed(headers):
if h["y"] < row["cy"]:
row["date"] = h["date"]
break
def is_ozon_list(xml): def is_ozon_list(xml):
return "Операции" in xml return "Операции" in xml
@ -266,71 +328,181 @@ def collect_tasks(serial, bank, mid, device_tz, kopecks, prefix):
if is_ozon: if is_ozon:
print("Detected: Ozon") print("Detected: Ozon")
headers = parse_ozon_date_headers(xml, device_tz)
baseline = parse_ozon_rows(xml) baseline = parse_ozon_rows(xml)
print(f"rows: {len(baseline)}") assign_dates_to_rows(baseline, headers)
print(f"date headers: {[h['date'] for h in headers]}")
print(f"visible rows: {len(baseline)}")
for i, r in enumerate(baseline, 1): for i, r in enumerate(baseline, 1):
print(f" {i}. ({r['cx']},{r['cy']}) {r['amount']:+d} ₽ | {r['text'][:50]}") print(f" {i}. ({r['cx']},{r['cy']}) {r['amount']:+d} date={r.get('date','-')} | {r['text'][:40]}")
for idx in range(1, len(baseline) + 1): task_num = 0
processed_keys = set() # (amount, created_at) — финальная дедупликация
completed_dates = set() # даты где все строки уже обработаны
date_row_counts = {} # date -> сколько строк видели для этой даты (макс)
max_scrolls = 20
scroll_count = 0
while True:
xml = dump_xml(serial) xml = dump_xml(serial)
if not is_ozon_list(xml): if not is_ozon_list(xml):
print(f"[{idx}] left list, stop"); break print("Left list, stop"); break
headers = parse_ozon_date_headers(xml, device_tz)
rows = parse_ozon_rows(xml) rows = parse_ozon_rows(xml)
if idx > len(rows): assign_dates_to_rows(rows, headers)
break
row = rows[idx - 1] if not rows:
print(f"\n[{idx}] tap ({row['cx']},{row['cy']}) {row['amount']:+d}") print("No rows found, stop"); break
tap(serial, row["cx"], row["cy"])
time.sleep(2.5) # Фильтруем строки от уже завершённых дат
d_xml = dump_xml(serial) rows = [r for r in rows if r.get("date") and r["date"] not in completed_dates]
date_s, time_s = extract_ozon_detail_time(d_xml) if not rows:
print(f" {date_s} {time_s}") print("All visible dates already processed, scrolling...")
back(serial); time.sleep(1.3) scroll_count += 1
if not date_s or not time_s: if scroll_count >= max_scrolls:
print(" SKIP"); continue print(f"\nReached max scrolls ({max_scrolls}), stop"); break
dt = datetime.strptime(f"{date_s} {time_s}", "%d.%m.%Y %H:%M:%S").replace(tzinfo=device_tz) swipe_up(serial)
cat = dt.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") time.sleep(1.5)
label = short_label(row["amount"], time_s) continue
while label in seen: label += "_2"
seen.add(label) # Считаем строки по датам на текущем экране
tasks.append({"bank_name": bank, "material_id": mid, "type": "FETCH_TRANSACTIONS", screen_date_counts = {}
"body": {"amount": row["amount"] * kopecks, "created_at": cat}, for row in rows:
"_tag": f"{prefix}_{idx:02d}_{label}"}) d = row["date"]
screen_date_counts[d] = screen_date_counts.get(d, 0) + 1
new_on_screen = 0
for row in rows:
date_from_header = row["date"]
print(f"\n [{date_from_header}] tap ({row['cx']},{row['cy']}) {row['amount']:+d}")
tap(serial, row["cx"], row["cy"])
time.sleep(3)
# Ждём деталей — нужно только время (дата уже из заголовка)
time_s = None
for attempt in range(3):
d_xml = dump_xml(serial)
_, time_s = extract_ozon_detail_time(d_xml)
if time_s:
break
if is_ozon_list(d_xml):
print(f" retry {attempt+1}: still on list, re-tapping")
tap(serial, row["cx"], row["cy"])
time.sleep(2)
else:
print(f" retry {attempt+1}: waiting for time...")
time.sleep(2)
back(serial); time.sleep(1.5)
if not time_s:
print(f" SKIP (no time from detail)"); continue
print(f" {date_from_header} {time_s}")
dt = datetime.strptime(f"{date_from_header} {time_s}", "%d.%m.%Y %H:%M:%S").replace(tzinfo=device_tz)
cat = dt.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
dedup_key = (row["amount"], cat)
if dedup_key in processed_keys:
print(" DUPLICATE, skip"); continue
processed_keys.add(dedup_key)
new_on_screen += 1
task_num += 1
label = short_label(row["amount"], time_s)
while label in seen: label += "_2"
seen.add(label)
tasks.append({"bank_name": bank, "material_id": mid, "type": "FETCH_TRANSACTIONS",
"body": {"amount": row["amount"] * kopecks, "created_at": cat},
"_tag": f"{prefix}_{task_num:02d}_{label}"})
# Помечаем даты как завершённые: все даты кроме последней на экране
# (последняя может быть обрезана скроллом)
visible_dates = list(dict.fromkeys(r["date"] for r in rows)) # ordered unique
if len(visible_dates) > 1:
for d in visible_dates[:-1]:
completed_dates.add(d)
if new_on_screen == 0:
# Последняя дата на экране — тоже завершена
if visible_dates:
completed_dates.add(visible_dates[-1])
print("\nNo new transactions on screen, scrolling past...")
scroll_count += 1
if scroll_count >= max_scrolls:
print(f"\nReached max scrolls ({max_scrolls}), stop"); break
swipe_up(serial)
time.sleep(1.5)
continue
scroll_count += 1
if scroll_count >= max_scrolls:
print(f"\nReached max scrolls ({max_scrolls}), stop"); break
print(f"\n--- scroll {scroll_count}/{max_scrolls} ---")
swipe_up(serial)
time.sleep(1.5)
elif is_dush: elif is_dush:
print("Detected: Dushanbe") print("Detected: Dushanbe")
baseline = parse_dushanbe_rows(xml) baseline = parse_dushanbe_rows(xml)
print(f"rows: {len(baseline)}") print(f"visible rows: {len(baseline)}")
for i, r in enumerate(baseline, 1): for i, r in enumerate(baseline, 1):
print(f" {i}. ({r['cx']},{r['cy']}) amt={r['amount']} time={r['time']}") print(f" {i}. ({r['cx']},{r['cy']}) amt={r['amount']} time={r['time']}")
for idx in range(1, len(baseline) + 1): task_num = 0
processed_keys = set()
max_scrolls = 30
scroll_count = 0
while True:
xml = dump_xml(serial) xml = dump_xml(serial)
if not is_dushanbe_list(xml): if not is_dushanbe_list(xml):
print(f"[{idx}] left list, stop"); break print("Left list, stop"); break
rows = parse_dushanbe_rows(xml) rows = parse_dushanbe_rows(xml)
if idx > len(rows): if not rows:
break print("No rows found, stop"); break
row = rows[idx - 1]
print(f"\n[{idx}] tap ({row['cx']},{row['cy']}) amt={row['amount']}") tapped_any = False
tap(serial, row["cx"], row["cy"]) for row in rows:
time.sleep(2.5) print(f"\n tap ({row['cx']},{row['cy']}) amt={row['amount']}")
d_xml = dump_xml(serial) tap(serial, row["cx"], row["cy"])
amt_s, date_s, time_s = extract_dushanbe_detail(d_xml) time.sleep(2.5)
print(f" amt={amt_s} {date_s} {time_s}") d_xml = dump_xml(serial)
back(serial); time.sleep(1.3) amt_s, date_s, time_s = extract_dushanbe_detail(d_xml)
if not date_s or not time_s: print(f" amt={amt_s} {date_s} {time_s}")
print(" SKIP"); continue back(serial); time.sleep(1.3)
amount = int(float(amt_s)) if amt_s else (row["amount"] or 0) if not date_s or not time_s:
fmt = "%d.%m.%Y %H:%M:%S" if len(time_s) > 5 else "%d.%m.%Y %H:%M" print(" SKIP"); continue
dt = datetime.strptime(f"{date_s} {time_s}", fmt).replace(tzinfo=device_tz)
cat = dt.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") amount = int(float(amt_s)) if amt_s else (row["amount"] or 0)
label = short_label(amount, time_s) fmt = "%d.%m.%Y %H:%M:%S" if len(time_s) > 5 else "%d.%m.%Y %H:%M"
while label in seen: label += "_2" dt = datetime.strptime(f"{date_s} {time_s}", fmt).replace(tzinfo=device_tz)
seen.add(label) cat = dt.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
tasks.append({"bank_name": bank, "material_id": mid, "type": "FETCH_TRANSACTIONS", dedup_key = (amount, cat)
"body": {"amount": amount * kopecks, "created_at": cat}, if dedup_key in processed_keys:
"_tag": f"{prefix}_{idx:02d}_{label}"}) print(" DUPLICATE, skip"); continue
processed_keys.add(dedup_key)
tapped_any = True
task_num += 1
label = short_label(amount, time_s)
while label in seen: label += "_2"
seen.add(label)
tasks.append({"bank_name": bank, "material_id": mid, "type": "FETCH_TRANSACTIONS",
"body": {"amount": amount * kopecks, "created_at": cat},
"_tag": f"{prefix}_{task_num:02d}_{label}"})
scroll_count += 1
if scroll_count >= max_scrolls:
print(f"\nReached max scrolls ({max_scrolls}), stop"); break
if not tapped_any:
print("\nNo new transactions on screen, stop"); break
print(f"\n--- scrolling down ({scroll_count}/{max_scrolls}) ---")
swipe_up(serial)
time.sleep(1.5)
return tasks return tasks
@ -409,7 +581,8 @@ def main():
serial = args.serial or o["serial"] serial = args.serial or o["serial"]
bank = o["bank_name"] bank = o["bank_name"]
mid = o["material_id"] mid = o["material_id"]
prefix = f"{o['label'].lower().replace(' ', '_')}_{bank}" card = o.get('last_numbers') or o['material_id'][:8]
prefix = f"{o['label'].lower().replace(' ', '_')}_{bank}_{card}"
device_tz = get_device_tz(serial) device_tz = get_device_tz(serial)
print(f"\n{'='*60}") print(f"\n{'='*60}")
print(f"Scanning: {o['label']} / {bank} ({mid[:8]}…)") print(f"Scanning: {o['label']} / {bank} ({mid[:8]}…)")
@ -429,7 +602,8 @@ def main():
# Remove old files for scanned prefixes # Remove old files for scanned prefixes
scanned_prefixes = set() scanned_prefixes = set()
for o in to_scan: for o in to_scan:
scanned_prefixes.add(f"{o['label'].lower().replace(' ', '_')}_{o['bank_name']}") card = o.get('last_numbers') or o['material_id'][:8]
scanned_prefixes.add(f"{o['label'].lower().replace(' ', '_')}_{o['bank_name']}_{card}")
for old in POOL_DIR.glob("*.json"): for old in POOL_DIR.glob("*.json"):
for pfx in scanned_prefixes: for pfx in scanned_prefixes:
if old.name.startswith(pfx + "_ft_"): if old.name.startswith(pfx + "_ft_"):

View File

@ -170,7 +170,8 @@ def generate(materials: list, out_dir: Path):
for row in materials: for row in materials:
dev_short = device_label(row["device_name"]).lower().replace(" ", "_") dev_short = device_label(row["device_name"]).lower().replace(" ", "_")
bank = row["bank_name"] bank = row["bank_name"]
fname = f"{dev_short}_{bank}_fetch_profile.json" card = row["last_numbers"] or row["material_id"][:8]
fname = f"{dev_short}_{bank}_{card}_fetch_profile.json"
task = { task = {
"bank_name": bank, "bank_name": bank,
"material_id": row["material_id"], "material_id": row["material_id"],
@ -183,7 +184,7 @@ def generate(materials: list, out_dir: Path):
print(f" wrote scenarios/{fname}") print(f" wrote scenarios/{fname}")
devices_seen.setdefault(dev_short, []).append( devices_seen.setdefault(dev_short, []).append(
f"{bank} ({row['material_id'][:8]}…)" f"{bank}/{card} ({row['material_id'][:8]}…)"
) )
# Summary # Summary

View File

@ -45,6 +45,7 @@ import threading
import time import time
import uuid import uuid
from collections import defaultdict, deque from collections import defaultdict, deque
from datetime import datetime, timedelta, timezone
from pathlib import Path from pathlib import Path
from flask import Flask, Response, jsonify, request from flask import Flask, Response, jsonify, request
@ -297,6 +298,22 @@ def bank_profile_toggle(bp_id, state):
return ok({"id": bp_id, "state": state}) return ok({"id": bp_id, "state": state})
@app.patch("/api/v1/profile/bank_profile/<bp_id>")
def bank_profile_update(bp_id):
body = request.get_json(silent=True) or {}
log("PATCH /bank_profile", {"id": bp_id, **body})
for bp in _bank_profiles:
if bp.get("id") == bp_id:
bp.update(body)
return ok(bp)
# Не найден — создаём
bp = dict(body)
bp["id"] = bp_id
bp.setdefault("materials", [])
_bank_profiles.append(bp)
return ok(bp)
@app.post("/api/v1/profile/material") @app.post("/api/v1/profile/material")
def material_create(): def material_create():
body = request.get_json(silent=True) or {} body = request.get_json(silent=True) or {}