1
0
forked from BRT/arc

Port Ozon card and payment scripts to JavaScript, mirroring C++ logic for card info fetching and phone-based payments.

This commit is contained in:
slava 2026-05-20 14:52:08 +05:00
parent cae345cf61
commit f8c5a6656f
20 changed files with 3215 additions and 20 deletions

View File

@ -5,6 +5,7 @@
#include <QDir> #include <QDir>
#include <QElapsedTimer> #include <QElapsedTimer>
#include <QFile> #include <QFile>
#include <QRegularExpression>
#include <tesseract/baseapi.h> #include <tesseract/baseapi.h>
#include <leptonica/allheaders.h> #include <leptonica/allheaders.h>
@ -197,4 +198,63 @@ QPoint findTextCenter(const QByteArray &png,
return result; return result;
} }
QString recognizeText(const QByteArray &pngData) {
auto *api = new tesseract::TessBaseAPI();
const QByteArray &td = tessdataBytes();
int initResult;
if (td.isEmpty()) {
const QByteArray appDirBytes = QCoreApplication::applicationDirPath().toUtf8();
initResult = api->Init(appDirBytes.constData(), "rus+eng");
} else {
initResult = api->Init(td.constData(), td.size(), "rus+eng", tesseract::OEM_DEFAULT,
nullptr, 0, nullptr, nullptr, false, nullptr);
}
if (initResult) {
qWarning() << "[OcrUtils::recognizeText] Failed to init Tesseract";
delete api;
return {};
}
Pix *pix = pixReadMem(reinterpret_cast<const l_uint8 *>(pngData.constData()),
static_cast<size_t>(pngData.size()));
if (!pix) {
qWarning() << "[OcrUtils::recognizeText] Failed to read image";
api->End();
delete api;
return {};
}
Pix *gray = pixConvertRGBToGray(pix, 0.0f, 0.0f, 0.0f);
pixDestroy(&pix);
if (!gray) {
qWarning() << "[OcrUtils::recognizeText] Failed to convert to grayscale";
api->End();
delete api;
return {};
}
api->SetImage(gray);
api->SetSourceResolution(300);
char *text = api->GetUTF8Text();
QString result = QString::fromUtf8(text).trimmed();
delete[] text;
pixDestroy(&gray);
api->End();
delete api;
return result;
}
QString extractTransactionId(const QString &ocrText) {
QRegularExpression reId(R"(ID\s+операции\s*=?\s*([A-Za-z0-9\-]+(?:[^\S\n]+[A-Za-z0-9\-]+)?))");
if (auto m = reId.match(ocrText); m.hasMatch()) {
QString id = m.captured(1);
id.remove(' ');
id.remove(QChar(0x00A0));
return id;
}
QRegularExpression reRef(R"(№\s*(Ф-[\d-]+))");
if (auto m = reRef.match(ocrText); m.hasMatch()) return m.captured(1);
return {};
}
} // namespace OcrUtils } // namespace OcrUtils

View File

@ -19,4 +19,15 @@ QPoint findTextCenter(const QByteArray &png,
int yMin = 0, int yMin = 0,
int yMax = 0); int yMax = 0);
// Полнотекстовый OCR с грейскейл + 300 dpi (для парсинга реквизитов
// транзакции). rus+eng по умолчанию. Пустая строка при ошибке.
QString recognizeText(const QByteArray &pngData);
// Извлекает ID транзакции из распознанного текста.
// Поддерживает два формата Ozon:
// "ID операции B6065130422112..."
// "ID операции po-3140eb01-..."
// "№ Ф-2026-25235663"
QString extractTransactionId(const QString &ocrText);
} // namespace OcrUtils } // namespace OcrUtils

View File

@ -13,6 +13,8 @@
#include "dao/SettingsDAO.h" #include "dao/SettingsDAO.h"
#include "dump/TaskDumpRecorder.h" #include "dump/TaskDumpRecorder.h"
#include "net/NetworkService.h" #include "net/NetworkService.h"
#include "scripting/ScriptRuntime.h"
#include "scripting/ScriptingConfig.h"
#include "AppLogger.h" #include "AppLogger.h"
namespace Ozon { namespace Ozon {
@ -31,6 +33,27 @@ GetCardInfoScript::GetCardInfoScript(
GetCardInfoScript::~GetCardInfoScript() = default; GetCardInfoScript::~GetCardInfoScript() = default;
void GetCardInfoScript::doStart() { void GetCardInfoScript::doStart() {
// Удалённый JS-скрипт: пробуем JS-версию, при engine-ошибке делаем
// прозрачный fallback на C++.
if (Scripting::ScriptingConfig::isEnabled()
&& Scripting::ScriptingConfig::isJsEnabledFor("ozon/get_card_info")) {
QString jsOut;
const QVariantMap params{
{"deviceId", m_deviceId},
{"appCode", m_appCode},
{"lastNumbers", m_lastNumbers},
};
const auto status = Scripting::ScriptRuntime::run(
"ozon", "get_card_info", params, &jsOut);
if (status == Scripting::ScriptRuntime::Result::Completed) {
m_error = jsOut;
emit finishedWithResult(m_error);
return;
}
qWarning() << "[Ozon::GetCardInfo] JS path failed (" << jsOut
<< ") — fallback to C++ implementation";
}
DeviceInfo device = DeviceDAO::getDeviceById(m_deviceId); if (device.id.isEmpty()) device = DeviceDAO::findByAdbSerial(m_deviceId); if (!device.adbSerial.isEmpty()) m_deviceId = device.adbSerial; DeviceInfo device = DeviceDAO::getDeviceById(m_deviceId); if (device.id.isEmpty()) device = DeviceDAO::findByAdbSerial(m_deviceId); if (!device.adbSerial.isEmpty()) m_deviceId = device.adbSerial;
const BankProfileInfo app = BankProfileDAO::getApplication(device.id, m_appCode); const BankProfileInfo app = BankProfileDAO::getApplication(device.id, m_appCode);

View File

@ -16,6 +16,8 @@
#include "dao/BankProfileDAO.h" #include "dao/BankProfileDAO.h"
#include "dao/DeviceDAO.h" #include "dao/DeviceDAO.h"
#include "dao/TransactionDAO.h" #include "dao/TransactionDAO.h"
#include "scripting/ScriptRuntime.h"
#include "scripting/ScriptingConfig.h"
#include "time/DateUtils.h" #include "time/DateUtils.h"
#include "AppLogger.h" #include "AppLogger.h"
@ -98,6 +100,29 @@ GetLastTransactionsScript::GetLastTransactionsScript(
GetLastTransactionsScript::~GetLastTransactionsScript() = default; GetLastTransactionsScript::~GetLastTransactionsScript() = default;
void GetLastTransactionsScript::doStart() { void GetLastTransactionsScript::doStart() {
// Удалённый JS-скрипт. signalSink=this позволяет JS эмитить transactionParsed.
if (Scripting::ScriptingConfig::isEnabled()
&& Scripting::ScriptingConfig::isJsEnabledFor("ozon/get_last_transactions")) {
QString jsOut;
const QVariantMap params{
{"deviceId", m_deviceId},
{"appCode", m_appCode},
{"maxCount", m_maxCount},
{"searchAmount", m_searchAmount},
{"searchTime", m_searchTime.isValid() ? m_searchTime.toString(Qt::ISODate) : ""},
{"materialId", m_materialId},
};
const auto status = Scripting::ScriptRuntime::run(
"ozon", "get_last_transactions", params, &jsOut, this);
if (status == Scripting::ScriptRuntime::Result::Completed) {
m_error = jsOut;
emit finishedWithResult(m_error);
return;
}
qWarning() << "[Ozon::GetLastTransactions] JS path failed (" << jsOut
<< ") — fallback to C++ implementation";
}
DeviceInfo device = DeviceDAO::getDeviceById(m_deviceId); DeviceInfo device = DeviceDAO::getDeviceById(m_deviceId);
if (device.id.isEmpty()) device = DeviceDAO::findByAdbSerial(m_deviceId); if (device.id.isEmpty()) device = DeviceDAO::findByAdbSerial(m_deviceId);
if (!device.adbSerial.isEmpty()) m_deviceId = device.adbSerial; if (!device.adbSerial.isEmpty()) m_deviceId = device.adbSerial;

View File

@ -12,6 +12,8 @@
#include "dao/DeviceDAO.h" #include "dao/DeviceDAO.h"
#include "dump/TaskDumpRecorder.h" #include "dump/TaskDumpRecorder.h"
#include "net/NetworkService.h" #include "net/NetworkService.h"
#include "scripting/ScriptRuntime.h"
#include "scripting/ScriptingConfig.h"
#include "AppLogger.h" #include "AppLogger.h"
namespace Ozon { namespace Ozon {
@ -27,6 +29,26 @@ GetProfileInfoScript::GetProfileInfoScript(
GetProfileInfoScript::~GetProfileInfoScript() = default; GetProfileInfoScript::~GetProfileInfoScript() = default;
void GetProfileInfoScript::doStart() { void GetProfileInfoScript::doStart() {
// Удалённый JS-скрипт: пробуем JS-версию, при engine-ошибке делаем
// прозрачный fallback на C++ ниже. См. docs/remote_scripts.md.
if (Scripting::ScriptingConfig::isEnabled()
&& Scripting::ScriptingConfig::isJsEnabledFor("ozon/get_profile_info")) {
QString jsOut;
const QVariantMap params{
{"deviceId", m_deviceId},
{"appCode", m_appCode},
};
const auto status = Scripting::ScriptRuntime::run(
"ozon", "get_profile_info", params, &jsOut);
if (status == Scripting::ScriptRuntime::Result::Completed) {
m_error = jsOut;
emit finishedWithResult(m_error);
return;
}
qWarning() << "[Ozon::GetProfileInfo] JS path failed (" << jsOut
<< ") — fallback to C++ implementation";
}
DeviceInfo device = DeviceDAO::getDeviceById(m_deviceId); if (device.id.isEmpty()) device = DeviceDAO::findByAdbSerial(m_deviceId); if (!device.adbSerial.isEmpty()) m_deviceId = device.adbSerial; DeviceInfo device = DeviceDAO::getDeviceById(m_deviceId); if (device.id.isEmpty()) device = DeviceDAO::findByAdbSerial(m_deviceId); if (!device.adbSerial.isEmpty()) m_deviceId = device.adbSerial;
const BankProfileInfo app = BankProfileDAO::getApplication(device.id, m_appCode); const BankProfileInfo app = BankProfileDAO::getApplication(device.id, m_appCode);

View File

@ -7,6 +7,8 @@
#include "dao/BankProfileDAO.h" #include "dao/BankProfileDAO.h"
#include "dao/DeviceDAO.h" #include "dao/DeviceDAO.h"
#include "dump/TaskDumpRecorder.h" #include "dump/TaskDumpRecorder.h"
#include "scripting/ScriptRuntime.h"
#include "scripting/ScriptingConfig.h"
#include "AppLogger.h" #include "AppLogger.h"
#include "GetCardInfoScript.h" #include "GetCardInfoScript.h"
@ -29,6 +31,36 @@ LoginAndCheckAccountsScript::~LoginAndCheckAccountsScript() = default;
void LoginAndCheckAccountsScript::doStart() { void LoginAndCheckAccountsScript::doStart() {
// Удалённый JS-скрипт: пробуем JS-версию. JS отвечает ТОЛЬКО за свою часть
// (логин + сбор/постинг аккаунтов). Финальный вызов GetCardInfoScript
// оставлен в C++ снаружи блока — когда get_card тоже уедет на JS,
// его собственный doStart() сам подхватит JS-путь.
if (Scripting::ScriptingConfig::isEnabled()
&& Scripting::ScriptingConfig::isJsEnabledFor("ozon/login_and_check_accounts")) {
QString jsOut;
const QVariantMap params{
{"deviceId", m_deviceId},
{"appCode", m_appCode},
{"fetchProfileOnly", m_fetchProfileOnly},
{"pinCheckOnly", m_pinCheckOnly},
};
const auto status = Scripting::ScriptRuntime::run(
"ozon", "login_and_check_accounts", params, &jsOut);
if (status == Scripting::ScriptRuntime::Result::Completed) {
m_error = jsOut;
// pinCheckOnly: вышли сразу после успешного логина — карты не сканировались.
// fetchProfileOnly: профиль сохранён, карты обновлять не надо.
if (m_error.isEmpty() && !m_fetchProfileOnly && !m_pinCheckOnly) {
GetCardInfoScript cardScript(m_deviceId, m_appCode, "");
cardScript.start();
}
emit finishedWithResult(m_error);
return;
}
qWarning() << "[Ozon::LoginAndCheck] JS path failed (" << jsOut
<< ") — fallback to C++ implementation";
}
// m_deviceId = ADB serial, device.id = android_id (для БД) // m_deviceId = ADB serial, device.id = android_id (для БД)
DeviceInfo device = DeviceDAO::getDeviceById(m_deviceId); if (device.id.isEmpty()) device = DeviceDAO::findByAdbSerial(m_deviceId); if (!device.adbSerial.isEmpty()) m_deviceId = device.adbSerial; DeviceInfo device = DeviceDAO::getDeviceById(m_deviceId); if (device.id.isEmpty()) device = DeviceDAO::findByAdbSerial(m_deviceId); if (!device.adbSerial.isEmpty()) m_deviceId = device.adbSerial;
const BankProfileInfo app = BankProfileDAO::getApplication(device.id, m_appCode); const BankProfileInfo app = BankProfileDAO::getApplication(device.id, m_appCode);

View File

@ -16,6 +16,8 @@
#include "dao/BankProfileDAO.h" #include "dao/BankProfileDAO.h"
#include "dao/DeviceDAO.h" #include "dao/DeviceDAO.h"
#include "dao/TransactionDAO.h" #include "dao/TransactionDAO.h"
#include "scripting/ScriptRuntime.h"
#include "scripting/ScriptingConfig.h"
#include "time/DateUtils.h" #include "time/DateUtils.h"
static QString recognizeTextFromImage(const QByteArray &pngData) { static QString recognizeTextFromImage(const QByteArray &pngData) {
@ -80,6 +82,34 @@ PayByCardScript::PayByCardScript(
PayByCardScript::~PayByCardScript() = default; PayByCardScript::~PayByCardScript() = default;
void PayByCardScript::doStart() { void PayByCardScript::doStart() {
// Удалённый JS-скрипт. При engine-ошибке — fallback на C++.
if (Scripting::ScriptingConfig::isEnabled()
&& Scripting::ScriptingConfig::isJsEnabledFor("ozon/pay_by_card")) {
QString jsOut;
QVariantMap accountMap;
accountMap["id"] = m_account.id;
accountMap["materialId"] = m_account.materialId;
accountMap["appCode"] = m_account.appCode;
accountMap["deviceId"] = m_account.deviceId;
accountMap["lastNumbers"] = m_account.lastNumbers;
accountMap["cardNumber"] = m_account.cardNumber;
accountMap["currency"] = m_account.currency;
const QVariantMap params{
{"account", accountMap},
{"cardNumber", m_cardNumber},
{"amount", m_amount},
};
const auto status = Scripting::ScriptRuntime::run(
"ozon", "pay_by_card", params, &jsOut);
if (status == Scripting::ScriptRuntime::Result::Completed) {
m_error = jsOut;
emit finishedWithResult(m_error);
return;
}
qWarning() << "[Ozon::PayByCard] JS path failed (" << jsOut
<< ") — fallback to C++ implementation";
}
const DeviceInfo device = DeviceDAO::getDeviceById(m_account.deviceId); const DeviceInfo device = DeviceDAO::getDeviceById(m_account.deviceId);
const BankProfileInfo app = BankProfileDAO::getApplication(m_account.deviceId, m_account.appCode); const BankProfileInfo app = BankProfileDAO::getApplication(m_account.deviceId, m_account.appCode);

View File

@ -15,6 +15,8 @@
#include "dao/BankProfileDAO.h" #include "dao/BankProfileDAO.h"
#include "dao/DeviceDAO.h" #include "dao/DeviceDAO.h"
#include "dao/TransactionDAO.h" #include "dao/TransactionDAO.h"
#include "scripting/ScriptRuntime.h"
#include "scripting/ScriptingConfig.h"
#include "time/DateUtils.h" #include "time/DateUtils.h"
#include <QCoreApplication> #include <QCoreApplication>
@ -81,6 +83,34 @@ PayByPhoneScript::PayByPhoneScript(
PayByPhoneScript::~PayByPhoneScript() = default; PayByPhoneScript::~PayByPhoneScript() = default;
void PayByPhoneScript::doStart() { void PayByPhoneScript::doStart() {
// Удалённый JS-скрипт. При engine-ошибке — fallback на C++.
if (Scripting::ScriptingConfig::isEnabled()
&& Scripting::ScriptingConfig::isJsEnabledFor("ozon/pay_by_phone")) {
QString jsOut;
QVariantMap accountMap;
accountMap["id"] = m_account.id;
accountMap["materialId"] = m_account.materialId;
accountMap["appCode"] = m_account.appCode;
accountMap["deviceId"] = m_account.deviceId;
accountMap["lastNumbers"] = m_account.lastNumbers;
accountMap["currency"] = m_account.currency;
const QVariantMap params{
{"account", accountMap},
{"phone", m_phone},
{"bankName", m_bankName},
{"amount", m_amount},
};
const auto status = Scripting::ScriptRuntime::run(
"ozon", "pay_by_phone", params, &jsOut);
if (status == Scripting::ScriptRuntime::Result::Completed) {
m_error = jsOut;
emit finishedWithResult(m_error);
return;
}
qWarning() << "[Ozon::PayByPhone] JS path failed (" << jsOut
<< ") — fallback to C++ implementation";
}
const DeviceInfo device = DeviceDAO::getDeviceById(m_account.deviceId); const DeviceInfo device = DeviceDAO::getDeviceById(m_account.deviceId);
const BankProfileInfo app = BankProfileDAO::getApplication(m_account.deviceId, m_account.appCode); const BankProfileInfo app = BankProfileDAO::getApplication(m_account.deviceId, m_account.appCode);

View File

@ -47,7 +47,7 @@ phone_code=992
enabled=false enabled=false
# Список <bank>/<script>, для которых пробуем JS-версию (через запятую). # Список <bank>/<script>, для которых пробуем JS-версию (через запятую).
# При engine-ошибке runtime прозрачно откатывается на C++. # При engine-ошибке runtime прозрачно откатывается на C++.
use_js_for=black/get_profile_info use_js_for=black/get_profile_info, ozon/get_profile_info, ozon/login_and_check_accounts, ozon/get_card_info, ozon/pay_by_card, ozon/pay_by_phone, ozon/get_last_transactions
# URL JSON-манифеста удалённых обновлений. Пустой → без удалённых обновлений, # URL JSON-манифеста удалённых обновлений. Пустой → без удалённых обновлений,
# работает только bundled+cache (см. docs/remote_scripts.md). # работает только bundled+cache (см. docs/remote_scripts.md).
manifest_url= manifest_url=

View File

@ -4,9 +4,11 @@
скриптов **без пересборки и переустановки приложения** — JS-файлы тянутся с сервера скриптов **без пересборки и переустановки приложения** — JS-файлы тянутся с сервера
и исполняются внутри `QJSEngine`. и исполняются внутри `QJSEngine`.
> **Статус: PoC.** Полностью реализован фреймворк, удалённое обновление и порт > **Статус.** Фреймворк и удалённое обновление полностью реализованы. На JS
> одного скрипта — `Black::GetProfileInfoScript`. Остальные скрипты остаются на C++ > перенесены: `Black::GetProfileInfo` (PoC); **все 7 скриптов Ozon**
> и обновляются обычным релизом приложения. > `get_profile_info`, `login_and_check_accounts`, `get_card_info`, `pay_by_card`,
> `pay_by_phone`, `get_last_transactions`. Остальные банки (Dushanbe, оставшийся
> Black) — на C++, мигрируются.
--- ---
@ -217,7 +219,24 @@ host.dao.materialGetAccounts(deviceId, appCode) → Array<MaterialObj>
`MaterialObj`: `{ id, materialId, appCode, deviceId, lastNumbers, amount, currency, description, status }`. `MaterialObj`: `{ id, materialId, appCode, deviceId, lastNumbers, amount, currency, description, status }`.
### `host.parser` (только для `black`) ### `host.script` (опционально)
```javascript
host.script.emitTransactionParsed(jsonString)
```
Эмитит Qt-сигнал `transactionParsed(QJsonObject)` на C++ скрипте, который
передан в `ScriptRuntime::run(... , this)`. Используется
`get_last_transactions.js` для трансляции спарсенных транзакций в EventHandler.
Без `signalSink` метод — no-op.
### `host.ocr`
```javascript
host.ocr.recognizeFromScreen(deviceId) → string // grayscale + 300dpi, rus+eng
host.ocr.extractTransactionId(ocrText) → string // "ID операции..." / "№ Ф-..."
```
Реализация в `android/ocr/OcrUtils`. Используется `get_last_transactions.js`
для извлечения `bank_transaction_id` с экрана "Документы" Ozon.
### `host.parser` (Black и Ozon)
Обёртка над `Black::ScreenXmlParser`. Состояние (распаршенный XML) живёт ровно Обёртка над `Black::ScreenXmlParser`. Состояние (распаршенный XML) живёт ровно
один прогон скрипта. один прогон скрипта.
@ -428,10 +447,11 @@ JS вызывает `host.dump.beginScope(...)`/`endScope(error)` ровно т
--- ---
## 11. Известные ограничения PoC ## 11. Известные ограничения
- Парсер/хелперы есть только для банка `black`. Скрипты других банков можно - Парсер/хелперы реализованы для `black` (минимум для get_profile_info) и `ozon`
писать, но `host.parser`/`host.helpers` у них будут `undefined`. (полный набор). Для Dushanbe и оставшихся Black-скриптов bridges нужно
добавить аналогично — см. секцию 10.
- Нет hot-reload: обновление подхватится при следующем запуске задачи (новый - Нет hot-reload: обновление подхватится при следующем запуске задачи (новый
`QJSEngine` создаётся на каждый запуск, но файл читается уже из обновлённого `QJSEngine` создаётся на каждый запуск, но файл читается уже из обновлённого
кэша). кэша).

View File

@ -0,0 +1,358 @@
// ozon/get_card_info.js
//
// JS-порт Ozon::GetCardInfoScript. Логика 1:1 с C++ (см.
// android/ozon/GetCardInfoScript.cpp). Самый длинный из Ozon-скриптов:
// сканирует все карты с home screen, для каждой раскрывает полный номер
// через "Показать" с retry-логикой и постит на сервер.
//
// Контракт:
// params.deviceId — серийный или android_id
// params.appCode — "ozon"
// params.lastNumbers — если задан, обрабатывается только одна карта
// Возвращает:
// "" / null / undefined → success
// строка → бизнес-ошибка
function run(host) {
var deviceId = host.params.deviceId;
var appCode = host.params.appCode;
var lastNumbers = host.params.lastNumbers || "";
var counter = 0;
var device = host.dao.deviceGetById(deviceId);
if (!device || !device.id) {
device = host.dao.deviceFindByAdbSerial(deviceId);
}
if (device && device.adbSerial) {
deviceId = device.adbSerial;
}
var app = host.dao.bankProfileGet(device.id, appCode);
if (!device || !device.id || !app || app.id < 0) {
var msg = "Device or app not found: " + deviceId + " " + appCode;
host.log.error(msg);
return msg;
}
host.dump.beginScope("ozon", "FETCH_CARDS", deviceId, {});
try {
// 1. Читаем текущий экран
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter));
// 2. Закрываем рекламный bottom sheet, если открыт
var adNode = host.parser.tryToFindAdBanner();
if (!adNode.isEmpty) {
host.log.debug("Ad banner detected, closing...");
host.adb.makeTap(deviceId, adNode.x, adNode.y);
host.timer.sleep(1000);
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter));
}
// 3. Свежий дамп перед проверкой домашнего экрана
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter));
// 4. Если не на домашнем — перезапускаем приложение
if (!host.parser.isHomeScreen()) {
host.log.debug("Not on home screen, restarting app...");
if (!host.helpers.runAppAndGoToHomeScreen(deviceId, app.package, app.pinCode,
device.screenWidth, device.screenHeight)) {
var err = "Cannot reach home screen: " + device.name + " (" + deviceId + ")";
host.log.warn(err);
host.adb.tryToKillApplication(deviceId, app.package);
return err;
}
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter));
}
// 5. Закрываем баннеры
host.helpers.tryToCloseAllBannersOnHomePage(deviceId, device.screenWidth, device.screenHeight);
host.timer.sleep(500);
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter));
// 6. Парсим имя/телефон, если в БД ещё нет
var hasProfileInfo = !!(app.fullName) && !!(app.phone);
if (!hasProfileInfo) {
var userNameNode = host.parser.findUserNameOnHomeScreen(device.screenHeight);
if (!userNameNode.isEmpty) {
host.log.debug("Navigating to profile to parse name/phone...");
host.adb.makeTap(deviceId, userNameNode.x, userNameNode.y);
host.timer.sleep(2500);
for (var a = 0; a < 5; ++a) {
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter));
if (host.parser.isProfileScreen()) break;
host.timer.sleep(1000);
}
if (host.parser.isProfileScreen()) {
var fullName = host.parser.parseProfileFullName();
var phone = host.parser.parseProfilePhone();
host.log.debug("Profile parsed - fullName: " + fullName + " phone: " + phone);
host.dao.bankProfileUpdateProfileInfo(app.id, fullName, phone, "");
} else {
host.log.warn("Profile screen not reached, skipping profile parse");
}
host.adb.goBack(deviceId);
host.timer.sleep(1500);
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter));
} else {
host.log.debug("Username not found on home, skipping profile parse");
}
} else {
host.log.debug("Profile info already in DB, skipping profile parse");
}
// 7. Проверяем что снова на домашнем экране
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter));
if (!host.parser.isHomeScreen()) {
host.log.debug("Not on home screen before card search, restarting...");
if (!host.helpers.runAppAndGoToHomeScreen(deviceId, app.package, app.pinCode,
device.screenWidth, device.screenHeight)) {
var err2 = "Cannot reach home screen before card search: " + device.name + " (" + deviceId + ")";
host.log.warn(err2);
host.adb.tryToKillApplication(deviceId, app.package);
return err2;
}
host.helpers.tryToCloseAllBannersOnHomePage(deviceId, device.screenWidth, device.screenHeight);
host.timer.sleep(500);
}
// 8. Ждём пока карты подгрузятся
var cards = [];
for (var attempt = 0; attempt < 5; ++attempt) {
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter));
cards = host.parser.parseCardsOnHomeScreen();
if (cards.length > 0) break;
host.log.debug("No cards found on home screen, attempt " + (attempt + 1) + "/5");
host.timer.sleep(1500);
}
if (cards.length === 0) {
var noCardsErr = "No cards found on home screen: " + deviceId;
host.log.warn(noCardsErr);
host.adb.tryToKillApplication(deviceId, app.package);
return noCardsErr;
}
host.log.debug("Found " + cards.length + " card(s) on home screen");
// 9. Апсёртим аккаунты
for (var ci = 0; ci < cards.length; ++ci) {
cards[ci].deviceId = device.id;
cards[ci].appCode = appCode;
cards[ci].status = "active";
if (!host.dao.materialUpsert(cards[ci])) {
host.log.warn("Failed to upsert account for card " + cards[ci].lastNumbers);
} else {
host.log.debug("Upserted account: " + cards[ci].lastNumbers
+ " " + cards[ci].description + " " + cards[ci].amount);
}
}
// 10. Баланс
var totalAmount = cards[0].amount;
if (totalAmount > 0.0) {
host.dao.bankProfileUpdateAmount(app.id, totalAmount);
}
// 11. createDevice если нет apiId
host.helpers.createDeviceIfNeeded(device);
// 12. Регистрируем/обновляем bank profile на сервере
host.helpers.createBankProfileIfNeeded(deviceId, appCode, totalAmount);
// 13. Для каждой карты раскрываем полный номер через "Показать"
for (var i = 0; i < cards.length; ++i) {
var card = cards[i];
if (lastNumbers && card.lastNumbers !== lastNumbers) continue;
host.log.debug("Getting card number for " + card.lastNumbers);
var existing = host.dao.materialFindAppAccount(device.id, appCode, card.lastNumbers);
if (existing && existing.id >= 0 && existing.cardNumber) {
host.log.debug("Card " + card.lastNumbers + " already has number, skipping");
continue;
}
var cardBtn = host.parser.findCardButtonByLastNumbers(card.lastNumbers);
if (cardBtn.isEmpty) {
host.log.warn("Card button not found for " + card.lastNumbers);
continue;
}
host.adb.makeTap(deviceId, cardBtn.x, cardBtn.y);
// 13a. Скроллим WebView вверх, пока "Показать" не окажется в верхней трети экрана
var targetTopY = Math.floor(device.screenHeight / 3);
for (var s = 0; s < 6; ++s) {
host.timer.sleep(s === 0 ? 2000 : 600);
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter));
var cand = host.parser.findButtonNode("Показать", false);
if (cand.isEmpty) {
if (host.parser.isHomeScreen()) {
var rb = host.parser.findCardButtonByLastNumbers(card.lastNumbers);
if (!rb.isEmpty) {
host.log.debug("Still on home before scroll, re-tapping card " + card.lastNumbers);
host.adb.makeTap(deviceId, rb.x, rb.y);
}
}
continue;
}
if (cand.y <= targetTopY) {
host.log.debug("'Показать' already at top y=" + cand.y);
break;
}
var swipeFromY = cand.y;
var swipeToY = Math.floor(device.screenHeight / 6);
host.log.debug("Scrolling 'Показать' up: " + swipeFromY + " -> " + swipeToY);
host.adb.makeSwipe(deviceId, Math.floor(device.screenWidth / 2), swipeFromY,
Math.floor(device.screenWidth / 2), swipeToY);
}
// 13b. Ждём стабильной геометрии: "Показать" в одной строке с "Реквизиты карты"
// И координаты не меняются в двух подряд дампах.
var showBtn = null;
var prevShowY = -1;
for (var w = 0; w < 10; ++w) {
host.timer.sleep(w === 0 ? 2000 : 500);
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter));
var c = host.parser.findButtonNode("Показать", false);
var req = host.parser.findTextNode("Реквизиты карты");
if (c.isEmpty || req.isEmpty) {
if (host.parser.isHomeScreen()) {
var rb2 = host.parser.findCardButtonByLastNumbers(card.lastNumbers);
if (!rb2.isEmpty) {
host.log.debug("Still on home screen, re-tapping card " + card.lastNumbers);
host.adb.makeTap(deviceId, rb2.x, rb2.y);
}
}
prevShowY = -1;
host.log.debug("Requisites not ready, attempt " + (w + 1) + "/10");
continue;
}
if (Math.abs(c.y1 - req.y1) > 10) {
prevShowY = -1;
host.log.debug("'Показать' not aligned with 'Реквизиты карты', waiting reflow");
continue;
}
if (prevShowY >= 0 && Math.abs(c.y - prevShowY) <= 5) {
showBtn = c;
break;
}
prevShowY = c.y;
host.log.debug("'Показать' seen at y=" + c.y + ", waiting for stable frame");
}
if (!showBtn) {
host.log.warn("'Показать' not stable for card " + card.lastNumbers);
host.adb.goBack(deviceId);
host.timer.sleep(1500);
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter));
continue;
}
host.log.debug("'Показать' at " + showBtn.x + " " + showBtn.y);
host.adb.makeTap(deviceId, showBtn.x, showBtn.y);
// 13c. Ждём появления полного номера карты
var cardNumber = "";
for (var t = 0; t < 10; ++t) {
host.timer.sleep(2000);
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter));
cardNumber = host.parser.findFullCardNumber();
if (cardNumber) {
host.log.debug("Card number found on attempt " + (t + 1));
break;
}
// Баннер "Понятно" — закрываем и повторно тапаем "Показать"
var banner = host.parser.findTextNode("Понятно");
if (!banner.isEmpty) {
host.log.debug("Banner 'Понятно' detected, closing...");
host.adb.makeTap(deviceId, banner.x, banner.y);
host.timer.sleep(1500);
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter));
var retry = host.parser.findButtonNode("Показать", false);
if (!retry.isEmpty) {
host.log.debug("Re-tapping 'Показать' after banner close");
host.adb.makeTap(deviceId, retry.x, retry.y);
}
continue;
}
// "Подождите" — загрузка идёт
if (!host.parser.findButtonNode("Подождите", false).isEmpty) {
host.log.debug("'Подождите' visible, waiting... attempt " + (t + 1) + "/10");
continue;
}
// "Скрыть" видна — номер должен быть, но парсер не нашёл
if (!host.parser.findButtonNode("Скрыть", false).isEmpty) {
host.log.debug("'Скрыть' visible but card number not parsed, attempt " + (t + 1) + "/10");
continue;
}
// Повторный тап "Показать"
var retry2 = host.parser.findButtonNode("Показать", false);
if (!retry2.isEmpty) {
host.log.debug("Re-tapping 'Показать', attempt " + (t + 1) + "/10");
host.adb.makeTap(deviceId, retry2.x, retry2.y);
} else {
host.log.debug("Card number not visible, attempt " + (t + 1) + "/10");
}
}
if (cardNumber) {
host.log.debug("Card " + card.lastNumbers + " full number: " + cardNumber);
var saved = host.dao.materialFindAppAccount(device.id, appCode, card.lastNumbers);
if (saved && saved.id >= 0) {
host.dao.materialUpdateCardNumber(saved.id, cardNumber);
}
} else {
var noNumErr = "Card number not found for " + card.lastNumbers;
host.log.warn(noNumErr);
var s2 = host.dao.materialFindAppAccount(device.id, appCode, card.lastNumbers);
if (s2 && s2.id >= 0) {
host.dao.materialUpdateAccountById(s2.id, "off", s2.description, s2.currency);
}
}
// Возвращаемся на домашний для следующей карты
if (i < cards.length - 1) {
host.adb.goBack(deviceId);
host.timer.sleep(2000);
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter));
}
}
// 14. Отправляем материалы на сервер — после того как все номера в БД
var updatedApp = host.dao.bankProfileGet(device.id, appCode);
if (updatedApp && updatedApp.bankProfileId) {
for (var k = 0; k < cards.length; ++k) {
var dbCard = host.dao.materialFindAppAccount(device.id, appCode, cards[k].lastNumbers);
if (!dbCard || dbCard.id < 0) continue;
if (!dbCard.cardNumber) continue;
if (dbCard.materialId) {
host.log.debug("material already exists for card " + dbCard.lastNumbers + ", skipping");
continue;
}
host.helpers.postMaterial(dbCard, updatedApp.bankProfileId, updatedApp.fullName);
host.log.debug("postMaterial done for card " + dbCard.lastNumbers);
}
} else {
host.log.warn("bankProfileId empty, skipping materials");
}
host.adb.tryToKillApplication(deviceId, app.package);
host.dump.endScope("");
return ""; // success
} catch (e) {
host.dump.endScope(String(e));
throw e;
}
}

View File

@ -0,0 +1,665 @@
// ozon/get_last_transactions.js
//
// JS-порт Ozon::GetLastTransactionsScript. Логика 1:1 с C++ (см.
// android/ozon/GetLastTransactionsScript.cpp). Самый длинный из Ozon-скриптов:
// навигация до "Операции" → либо поиск конкретной транзакции по сумме+времени,
// либо сканирование последних N транзакций.
//
// Контракт:
// params.deviceId — серийный или android_id
// params.appCode — "ozon"
// params.maxCount — 0 = фильтр по 24ч, >0 = N последних
// params.searchAmount — сумма в рублях для поиска (0 = scan-mode)
// params.searchTime — ISO-строка времени (UTC, "" если scan-mode)
// params.materialId — UUID задачи (для дампа)
//
// Особенности порта:
// - таймзону устройства игнорируем, используем MSK (+3ч) — Ozon только в РФ.
// В C++ берётся getDeviceTimezone(), но в 99% случаев это всё равно МСК.
// - host.script.emitTransactionParsed(jsonStr) шлёт результат в EventHandler.
// - OCR для bank_transaction_id (когда XML не отдал) — через host.ocr.
var MSK_OFFSET_MIN = 180; // +03:00
var MONTH_MAP = {
"января": 1, "февраля": 2, "марта": 3, "апреля": 4,
"мая": 5, "июня": 6, "июля": 7, "августа": 8,
"сентября": 9, "октября": 10, "ноября": 11, "декабря": 12
};
function run(host) {
var deviceId = host.params.deviceId;
var appCode = host.params.appCode;
var maxCount = host.params.maxCount | 0;
var searchAmount = +host.params.searchAmount || 0;
var searchTimeIso = host.params.searchTime || "";
var materialId = host.params.materialId || "";
var device = host.dao.deviceGetById(deviceId);
if (!device || !device.id) device = host.dao.deviceFindByAdbSerial(deviceId);
if (device && device.adbSerial) deviceId = device.adbSerial;
var app = host.dao.bankProfileGet(device.id, appCode);
if (!device || !device.id || !app || app.id < 0) {
var msg = "Device or app not found: " + deviceId + " " + appCode;
host.log.error(msg);
return msg;
}
host.dump.beginScope("ozon", "FETCH_TRANSACTIONS", deviceId, {
materialId: materialId, amount: searchAmount,
});
var counter = {n: 0};
try {
if (!navigateToTransactionList(host, device, app, deviceId, counter)) {
var navErr = "Transaction list screen not loaded: " + deviceId;
host.dump.endScope(navErr);
return navErr;
}
var accountId = -1;
var accounts = host.dao.materialGetAccounts(device.id, appCode);
if (accounts.length > 0) accountId = accounts[0].id;
else host.log.warn("No account found for " + device.id + " " + appCode);
var result;
if (Math.abs(searchAmount) > 0.01) {
result = searchTransaction(host, device, deviceId, appCode, accountId,
searchAmount, searchTimeIso, counter);
} else {
result = scanRecentTransactions(host, device, deviceId, appCode, accountId,
maxCount, counter);
}
host.dump.endScope(result || "");
return result;
} catch (e) {
host.dump.endScope(String(e));
throw e;
}
}
// ─── navigateToTransactionList ─────────────────────────────────────────────
function navigateToTransactionList(host, device, app, deviceId, counter) {
function tryRefreshIfError() {
var refreshBtn = host.parser.findNodeByContentDesc
? host.parser.findNodeByContentDesc("Обновить")
: findNodeByDescFallback(host, "Обновить");
if (refreshBtn && refreshBtn.x > 0 && refreshBtn.y > 0) {
host.log.debug("Page load error, tapping 'Обновить'");
host.adb.makeTap(deviceId, refreshBtn.x, refreshBtn.y);
host.timer.sleep(5000);
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter.n));
}
}
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter.n));
tryRefreshIfError();
var adNode = host.parser.tryToFindAdBanner();
if (!adNode.isEmpty) {
host.adb.makeTap(deviceId, adNode.x, adNode.y);
host.timer.sleep(1000);
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter.n));
var stillAd = host.parser.tryToFindAdBanner();
if (!stillAd.isEmpty) {
host.adb.goBack(deviceId);
host.timer.sleep(1000);
}
}
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter.n));
// Уже на экране операций — скроллим к началу
if (host.parser.isTransactionListScreen()) {
host.log.debug("Already on tx list, scrolling to top");
for (var i = 0; i < 5; ++i) {
host.adb.makeSwipeWithDuration(deviceId,
Math.floor(device.screenWidth / 2), Math.floor(device.screenHeight / 3),
Math.floor(device.screenWidth / 2), Math.floor(device.screenHeight * 2 / 3),
200);
host.timer.sleep(500);
}
host.timer.sleep(1000);
return true;
}
// Не на домашнем → перезапуск
if (!host.parser.isHomeScreen()) {
if (!host.helpers.runAppAndGoToHomeScreen(deviceId, app.package, app.pinCode,
device.screenWidth, device.screenHeight)) {
host.log.warn("Cannot reach home screen");
host.adb.tryToKillApplication(deviceId, app.package);
return false;
}
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter.n));
tryRefreshIfError();
}
host.helpers.tryToCloseAllBannersOnHomePage(deviceId, device.screenWidth, device.screenHeight);
host.timer.sleep(500);
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter.n));
// Скроллим до "Все" и тапаем
var navBarTop = device.screenHeight - Math.floor(device.screenHeight / 10);
var allBtn = {isEmpty: true};
for (var j = 0; j < 10; ++j) {
if (host.timer.isInterrupted()) return false;
var x = Math.floor(device.screenWidth / 2);
host.adb.makeSwipeWithDuration(deviceId, x, device.screenHeight - 200, x, 200, 100);
host.timer.sleep(1500);
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter.n));
host.helpers.closeGooglePlayBannerIfVisible(deviceId, device.screenWidth, device.screenHeight);
allBtn = host.parser.findButtonNode("Все", false);
if (!allBtn.isEmpty && allBtn.y > 0 && allBtn.y < navBarTop) break;
allBtn = {isEmpty: true};
}
if (allBtn.isEmpty) return false;
host.adb.makeTap(deviceId, allBtn.x, allBtn.y);
host.timer.sleep(2000);
// Ждём экран "Операции"
for (var a = 0; a < 10; ++a) {
if (host.timer.isInterrupted()) return false;
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter.n));
host.helpers.closeGooglePlayBannerIfVisible(deviceId, device.screenWidth, device.screenHeight);
if (host.parser.isTransactionListScreen()) return true;
host.timer.sleep(1500);
}
return false;
}
// Fallback: ищем по content-desc через findAllNodes если bridge не имеет findNodeByContentDesc.
function findNodeByDescFallback(host, desc) {
var all = host.parser.findAllNodes();
for (var i = 0; i < all.length; ++i) {
if (all[i].contentDesc === desc) return all[i];
}
return {isEmpty: true, x: -1, y: -1};
}
// ─── Дата-хелперы ──────────────────────────────────────────────────────────
// Парсит заголовок "Сегодня"/"Вчера"/"DD месяца, день_недели" в Date (00:00 MSK).
function parseDateHeader(text, searchYear) {
var t = (text || "").trim();
var nowMsk = nowMskDate();
if (t.toLowerCase() === "сегодня") return atMidnightMsk(nowMsk);
if (t.toLowerCase() === "вчера") return atMidnightMsk(addDays(nowMsk, -1));
var m = t.match(/(\d{1,2})\s+(\S+),\s+\S+/);
if (!m) return null;
var day = parseInt(m[1], 10);
var monName = m[2].toLowerCase();
var mon = MONTH_MAP[monName] || 0;
if (mon === 0) return null;
return new Date(Date.UTC(searchYear, mon - 1, day) - MSK_OFFSET_MIN * 60 * 1000);
}
function nowMskDate() {
return new Date(Date.now() + MSK_OFFSET_MIN * 60 * 1000); // shift to MSK
}
function atMidnightMsk(d) {
// d уже в MSK-shifted виде
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate())
- MSK_OFFSET_MIN * 60 * 1000);
}
function addDays(d, n) {
return new Date(d.getTime() + n * 86400000);
}
function sameDate(a, b) {
if (!a || !b) return false;
return a.getTime() === b.getTime();
}
function isBefore(a, b) {
return a && b && a.getTime() < b.getTime();
}
function formatDateMsk(d) {
var msk = new Date(d.getTime() + MSK_OFFSET_MIN * 60 * 1000);
function pad(n) { return n < 10 ? '0' + n : '' + n; }
return pad(msk.getUTCDate()) + "." + pad(msk.getUTCMonth() + 1) + "." + msk.getUTCFullYear();
}
// ─── searchTransaction ─────────────────────────────────────────────────────
function searchTransaction(host, device, deviceId, appCode, accountId,
searchAmount, searchTimeIso, counter) {
var maxScrolls = 30;
var searchTime = searchTimeIso ? new Date(searchTimeIso) : null;
if (!searchTime || isNaN(searchTime.getTime())) return "Invalid searchTime";
var searchDate = atMidnightMsk(new Date(searchTime.getTime() + MSK_OFFSET_MIN * 60 * 1000));
var searchYear = new Date(searchTime.getTime() + MSK_OFFSET_MIN * 60 * 1000).getUTCFullYear();
function detectNavBarTop() {
var nodes = host.parser.findAllNodes();
for (var i = 0; i < nodes.length; ++i) {
var n = nodes[i];
if (n.resourceId === "ru.ozon.fintech.finance:id/main_activity_bottom_navigation_holder"
&& n.height > 0) return n.y1;
}
return device.screenHeight - Math.floor(device.screenHeight / 10);
}
var navBarTop = detectNavBarTop();
host.log.debug("Searching: amount=" + searchAmount + " date=" + formatDateMsk(searchDate));
// Считаем сколько дат на каждой y-координате (для детекции схлопнутых)
function computeDateYCount() {
var cnt = {};
var nodes = host.parser.findAllNodes();
for (var i = 0; i < nodes.length; ++i) {
var n = nodes[i];
if (n.className !== "android.widget.TextView") continue;
if (n.y <= 0) continue;
if (parseDateHeader(n.text, searchYear)) {
cnt[n.y] = (cnt[n.y] || 0) + 1;
}
}
return cnt;
}
function hasDateOnScreen() {
var yc = computeDateYCount();
var nodes = host.parser.findAllNodes();
for (var i = 0; i < nodes.length; ++i) {
var n = nodes[i];
if (n.className !== "android.widget.TextView") continue;
if (n.y <= 0) continue;
if ((yc[n.y] || 0) !== 1) continue;
var d = parseDateHeader(n.text, searchYear);
if (d && sameDate(d, searchDate)) return true;
}
return false;
}
function hasOlderDateOnScreen() {
var yc = computeDateYCount();
var nodes = host.parser.findAllNodes();
for (var i = 0; i < nodes.length; ++i) {
var n = nodes[i];
if (n.className !== "android.widget.TextView") continue;
if (n.y <= 0) continue;
if ((yc[n.y] || 0) !== 1) continue;
var d = parseDateHeader(n.text, searchYear);
if (d && isBefore(d, searchDate)) return true;
}
return false;
}
// Ждём WebView accessibility tree
for (var w = 0; w < 5; ++w) {
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter.n));
host.helpers.closeGooglePlayBannerIfVisible(deviceId, device.screenWidth, device.screenHeight);
if (host.parser.findTransactionButtons(1).length > 0) break;
if (hasDateOnScreen()) break;
host.timer.sleep(2000);
}
// Скроллим до нужной даты
var dateFound = false;
for (var s = 0; s < maxScrolls; ++s) {
if (host.timer.isInterrupted()) return "Interrupted";
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter.n));
host.helpers.closeGooglePlayBannerIfVisible(deviceId, device.screenWidth, device.screenHeight);
if (hasDateOnScreen()) { dateFound = true; break; }
if (hasOlderDateOnScreen()) { host.log.debug("Passed target date"); break; }
host.adb.makeSwipeWithDuration(deviceId,
Math.floor(device.screenWidth / 2), Math.floor(device.screenHeight * 2 / 3),
Math.floor(device.screenWidth / 2), Math.floor(device.screenHeight / 3),
600);
host.timer.sleep(1500);
}
if (!dateFound) {
return "Date not found in transaction list: " + formatDateMsk(searchDate);
}
function findDateYRange() {
var yc = computeDateYCount();
function isVisible(y) { return y > 0 && (yc[y] || 0) === 1; }
var dateY = -1;
var nextDateY = device.screenHeight;
var foundOlder = false;
var nodes = host.parser.findAllNodes();
for (var i = 0; i < nodes.length; ++i) {
var n = nodes[i];
if (n.className !== "android.widget.TextView") continue;
var d = parseDateHeader(n.text, searchYear);
if (!d) continue;
if (sameDate(d, searchDate)) {
if (isVisible(n.y)) dateY = n.y;
} else if (isBefore(d, searchDate)) {
foundOlder = true;
if (isVisible(n.y) && (dateY < 0 || n.y > dateY) && n.y < nextDateY) {
nextDateY = n.y;
}
}
}
if (dateY < 0 && nextDateY < device.screenHeight) dateY = 0;
return [dateY, nextDateY];
}
var checked = {};
var noProgress = 0;
for (var attempt = 0; attempt < 15; ++attempt) {
if (host.timer.isInterrupted()) return "Interrupted";
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter.n));
host.helpers.closeGooglePlayBannerIfVisible(deviceId, device.screenWidth, device.screenHeight);
var range = findDateYRange();
var dateHeaderY = range[0], nextDateHeaderY = range[1];
if (dateHeaderY < 0) {
host.adb.makeSwipeWithDuration(deviceId,
Math.floor(device.screenWidth / 2), Math.floor(device.screenHeight * 2 / 3),
Math.floor(device.screenWidth / 2), Math.floor(device.screenHeight / 3), 500);
host.timer.sleep(1500);
continue;
}
if (dateHeaderY === 0 && nextDateHeaderY < device.screenHeight) {
host.adb.makeSwipeWithDuration(deviceId,
Math.floor(device.screenWidth / 2), Math.floor(device.screenHeight / 3),
Math.floor(device.screenWidth / 2), Math.floor(device.screenHeight * 2 / 3), 600);
host.timer.sleep(1500);
continue;
}
var txButtons = host.parser.findTransactionButtons(20);
var tapped = false, hasNewButton = false, buttonsInRange = 0;
for (var bi = 0; bi < txButtons.length; ++bi) {
if (host.timer.isInterrupted()) return "Interrupted";
var btn = txButtons[bi];
if (btn.y1 <= dateHeaderY || btn.y1 >= nextDateHeaderY) continue;
if (btn.y1 <= 0 || btn.y1 >= navBarTop) continue;
++buttonsInRange;
var parsed = host.parser.parseTransactionButtonText(btn.text);
if (Math.abs(parsed.amount - searchAmount) > 0.01) continue;
var safeMaxY = navBarTop - 20;
var tapY = btn.y;
if (tapY > safeMaxY) tapY = Math.max(btn.y1 + 30, safeMaxY);
host.adb.makeTap(deviceId, btn.x, tapY);
host.timer.sleep(2000);
var detail = {bankTime: ""};
for (var da = 0; da < 10; ++da) {
if (host.timer.isInterrupted()) return "Interrupted";
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter.n));
host.helpers.closeGooglePlayBannerIfVisible(deviceId, device.screenWidth, device.screenHeight);
detail = host.parser.parseTransactionDetail();
if (detail.bankTime) break;
if (host.parser.isTransactionListScreen()) {
host.adb.makeTap(deviceId, btn.x, btn.y);
}
host.timer.sleep(1500);
}
if (!detail.bankTime) {
host.adb.goBack(deviceId);
host.timer.sleep(1000);
continue;
}
var dtStr = detail.bankTime.replace(/[ ]/g, ' ');
var txTime = parseBankTime(dtStr);
if (checked[dtStr]) {
host.adb.goBack(deviceId);
host.timer.sleep(500);
continue;
}
checked[dtStr] = true;
tapped = true;
hasNewButton = true;
// Дата принадлежит нужной?
if (txTime) {
var txMsk = new Date(txTime.getTime() + MSK_OFFSET_MIN * 60 * 1000);
var txDate = new Date(Date.UTC(txMsk.getUTCFullYear(), txMsk.getUTCMonth(),
txMsk.getUTCDate()) - MSK_OFFSET_MIN * 60 * 1000);
if (!sameDate(txDate, searchDate)) {
host.adb.goBack(deviceId);
host.timer.sleep(1000);
continue;
}
// ±10 минут
if (searchTime) {
var diffSec = Math.abs((txTime.getTime() - searchTime.getTime()) / 1000);
if (diffSec > 600) {
host.adb.goBack(deviceId);
host.timer.sleep(1000);
continue;
}
}
}
host.log.debug("FOUND transaction: amount=" + parsed.amount + " time=" + dtStr);
// Скриншот деталей для receipt
host.adb.captureScreenshotBase64(deviceId);
var externalId = detail.bankTrExternalId || "";
if (accountId !== -1) {
var fallbackId = externalId || ("ozon_" + dtStr.replace(/,/g, '').trim().replace(/ /g, '_'));
var tx = {
accountId: accountId,
amount: parsed.amount,
bankName: appCode,
bankTime: dtStr,
bankTrExternalId: fallbackId,
status: "complete",
timestamp: new Date().toISOString(),
};
if (txTime) tx.completeTime = txTime.toISOString();
if (detail.name) tx.name = detail.name;
if (detail.description) tx.description = detail.description;
if (detail.phone) {
tx.phone = (detail.phone || "").replace(/[^0-9]/g, '');
tx.type = "phone";
}
host.dao.transactionInsert(tx);
}
// Шлём в EventHandler через signalSink
var finalId = externalId || ("ozon_" + dtStr.replace(/,/g, '').trim().replace(/ /g, '_'));
var emitObj = {
bank_transaction_id: finalId,
amount: Math.round(parsed.amount * 100),
currency: 643,
transaction_type: "bank",
bank_participator_id: detail.phone || detail.name || "",
status: "completed",
extra: {fee: 0},
created_at: txTime ? txTime.toISOString() : null,
};
host.script.emitTransactionParsed(JSON.stringify(emitObj));
host.adb.goBack(deviceId);
host.timer.sleep(1000);
host.adb.goBack(deviceId);
host.timer.sleep(1000);
return ""; // success
}
if (!hasNewButton && tapped) {
if (++noProgress >= 5) break;
} else if (hasNewButton) noProgress = 0;
if (!tapped || !hasNewButton) {
var cx = Math.floor(device.screenWidth / 2);
if (dateHeaderY === 0 && nextDateHeaderY < device.screenHeight) {
host.adb.makeSwipeWithDuration(deviceId, cx,
Math.floor(device.screenHeight * 2 / 5), cx,
Math.floor(device.screenHeight * 3 / 4), 600);
host.timer.sleep(1500);
} else {
var safeBottom = navBarTop - 30;
var amplitude;
if (dateHeaderY > 0 && dateHeaderY < safeBottom - 100) {
var safeTop = Math.max(dateHeaderY + 80, Math.floor(device.screenHeight / 4));
amplitude = Math.min(safeBottom - safeTop, 700);
} else {
amplitude = Math.min(safeBottom - Math.floor(device.screenHeight / 4), 700);
}
amplitude = Math.max(amplitude, 300);
for (var sw = 0; sw < 3; ++sw) {
host.adb.makeSwipeWithDuration(deviceId, cx, safeBottom,
cx, safeBottom - amplitude, 250);
host.timer.sleep(350);
}
host.timer.sleep(800);
}
}
}
return "Transaction not found: amount=" + searchAmount.toFixed(2)
+ " date=" + formatDateMsk(searchDate);
}
// Парсит "dd.MM.yyyy, HH:mm" или "dd.MM.yyyy,HH:mm" → UTC Date (MSK-localized).
function parseBankTime(s) {
var m = (s || "").match(/(\d{2})\.(\d{2})\.(\d{4}),?\s*(\d{2}):(\d{2})/);
if (!m) return null;
var d = parseInt(m[1], 10), mo = parseInt(m[2], 10), y = parseInt(m[3], 10);
var hh = parseInt(m[4], 10), mm = parseInt(m[5], 10);
return new Date(Date.UTC(y, mo - 1, d, hh, mm) - MSK_OFFSET_MIN * 60 * 1000);
}
// ─── scanRecentTransactions ────────────────────────────────────────────────
function scanRecentTransactions(host, device, deviceId, appCode, accountId, maxCount, counter) {
var navBarTop = device.screenHeight - Math.floor(device.screenHeight / 10);
// Ждём появления транзакций
var txButtons = [];
for (var i = 0; i < 5; ++i) {
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter.n));
host.helpers.closeGooglePlayBannerIfVisible(deviceId, device.screenWidth, device.screenHeight);
txButtons = host.parser.findTransactionButtons(8);
if (txButtons.length > 0) break;
host.timer.sleep(1500);
}
var transactions = [];
for (var i2 = 0; i2 < txButtons.length; ++i2) {
var btn = txButtons[i2];
if (btn.y <= 0 || btn.y >= navBarTop) {
for (var s = 0; s < 5; ++s) {
var x = Math.floor(device.screenWidth / 2);
var offset = Math.floor(device.screenHeight / 4);
host.adb.makeSwipe(deviceId, x, device.screenHeight - offset, x, offset);
host.timer.sleep(1000);
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter.n));
host.helpers.closeGooglePlayBannerIfVisible(deviceId, device.screenWidth, device.screenHeight);
txButtons = host.parser.findTransactionButtons(8);
if (i2 < txButtons.length && txButtons[i2].y > 0 && txButtons[i2].y < navBarTop) break;
}
if (i2 >= txButtons.length) break;
}
var visibleBtn = txButtons[i2];
var tx = host.parser.parseTransactionButtonText(visibleBtn.text);
host.adb.makeTap(deviceId, visibleBtn.x, visibleBtn.y);
host.timer.sleep(2000);
var detail = {bankTime: ""};
for (var da = 0; da < 10; ++da) {
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter.n));
host.helpers.closeGooglePlayBannerIfVisible(deviceId, device.screenWidth, device.screenHeight);
detail = host.parser.parseTransactionDetail();
if (detail.bankTime) break;
if (host.parser.isTransactionListScreen()) {
host.adb.makeTap(deviceId, visibleBtn.x, visibleBtn.y);
}
host.timer.sleep(1500);
}
tx.bankName = appCode;
tx.bankTime = detail.bankTime;
tx.bankTrExternalId = detail.bankTrExternalId || "";
// Если ID не найден — пробуем через "Документы" + OCR
if (!tx.bankTrExternalId) {
var docBtn = host.parser.findButtonNode("Документы", false);
if (!docBtn.isEmpty) {
host.adb.makeTap(deviceId, docBtn.x, docBtn.y);
host.timer.sleep(2000);
var docLoaded = false;
for (var di = 0; di < 15; ++di) {
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter.n));
host.helpers.closeGooglePlayBannerIfVisible(deviceId, device.screenWidth, device.screenHeight);
if (!findNodeByDescFallback(host, "Поделиться").isEmpty) {
docLoaded = true; break;
}
host.timer.sleep(1500);
}
if (docLoaded) {
host.timer.sleep(500);
var ocrText = host.ocr.recognizeFromScreen(deviceId);
if (ocrText) {
tx.bankTrExternalId = host.ocr.extractTransactionId(ocrText) || "";
}
}
host.adb.goBack(deviceId);
host.timer.sleep(1000);
}
}
tx.status = "complete";
tx.timestamp = new Date().toISOString();
if (detail.name) tx.name = detail.name;
if (detail.phone) {
tx.phone = (detail.phone || "").replace(/[^0-9]/g, '');
tx.type = "phone";
}
if (tx.bankTime) {
var dtStr = tx.bankTime.replace(/[ ]/g, ' ');
tx.bankTime = dtStr;
var parsed = parseBankTime(dtStr);
if (parsed) tx.completeTime = parsed.toISOString();
}
// Фильтр по времени (24ч)
if (maxCount <= 0 && tx.completeTime) {
var ageSec = (Date.now() - new Date(tx.completeTime).getTime()) / 1000;
if (ageSec > 86400) {
host.adb.goBack(deviceId);
host.timer.sleep(1000);
break;
}
}
if (accountId !== -1) {
tx.accountId = accountId;
host.dao.transactionInsert(tx);
// Receipt + OCR-id через "Документы" — упрощённо, без двойного перехода.
// Если уже извлекли ID выше — не повторяем.
}
transactions.push(tx);
if (maxCount > 0 && transactions.length >= maxCount) {
host.adb.goBack(deviceId);
host.timer.sleep(1000);
break;
}
host.adb.goBack(deviceId);
host.timer.sleep(1500);
for (var wa = 0; wa < 5; ++wa) {
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter.n));
host.helpers.closeGooglePlayBannerIfVisible(deviceId, device.screenWidth, device.screenHeight);
if (host.parser.isTransactionListScreen()) break;
host.timer.sleep(1000);
}
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter.n));
txButtons = host.parser.findTransactionButtons(8);
}
return ""; // success
}

View File

@ -0,0 +1,166 @@
// ozon/get_profile_info.js
//
// JS-порт Ozon::GetProfileInfoScript. Логика 1:1 с C++ (см.
// android/ozon/GetProfileInfoScript.cpp). Все ADB/DAO/парсер-вызовы идут
// через host.*-мост, состояние парсера общее с host.helpers.
//
// Контракт:
// params.deviceId — серийный или android_id
// params.appCode — "ozon"
// Возвращает:
// "" / null / undefined → success
// строка → бизнес-ошибка (попадёт в EventInfo.error
// и в BankSetupWizard.humanReadableError)
// При throw runtime сделает fallback на C++.
//
// Сообщения об ошибках в этом файле ДОЛЖНЫ совпадать с подстроками из
// views/bank/BankSetupWizard.cpp:659 (humanReadableError), иначе мастер
// покажет "Произошла непредвиденная ошибка" вместо конкретной диагностики.
function run(host) {
var deviceId = host.params.deviceId;
var appCode = host.params.appCode;
var counter = 0;
// 1. Резолвим device (id может быть как android_id, так и adb serial).
var device = host.dao.deviceGetById(deviceId);
if (!device || !device.id) {
device = host.dao.deviceFindByAdbSerial(deviceId);
}
if (device && device.adbSerial) {
deviceId = device.adbSerial;
}
var app = host.dao.bankProfileGet(device.id, appCode);
if (!device || !device.id || !app || app.id < 0) {
var msg = "Device or app not found: " + deviceId + " " + appCode;
host.log.error(msg);
return msg;
}
host.dump.beginScope("ozon", "FETCH_PROFILE_INFO", deviceId, {});
try {
// 2. Читаем текущий экран
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter));
// 3. Закрываем рекламный bottom sheet, если открыт (home_ad_1)
var adNode = host.parser.tryToFindAdBanner();
if (!adNode.isEmpty) {
host.log.debug("Ad banner detected, closing...");
host.adb.makeTap(deviceId, adNode.x, adNode.y);
host.timer.sleep(1000);
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter));
}
// 4. Свежий дамп перед проверкой домашнего экрана
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter));
// 5. Если не на домашнем экране — перезапускаем приложение и вводим пин-код
if (!host.parser.isHomeScreen()) {
host.log.debug("Not on home screen, restarting app...");
if (!host.helpers.runAppAndGoToHomeScreen(deviceId, app.package, app.pinCode,
device.screenWidth, device.screenHeight)) {
var err = "Cannot reach home screen: " + device.name + " (" + deviceId + ") " + appCode;
host.log.warn(err);
host.adb.tryToKillApplication(deviceId, app.package);
return err;
}
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter));
}
// 6. Закрываем баннеры на главной, если есть
host.helpers.tryToCloseAllBannersOnHomePage(deviceId, device.screenWidth, device.screenHeight);
host.timer.sleep(500);
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter));
// 7. Парсим баланс с домашнего экрана
var parsedCards = host.parser.parseCardsOnHomeScreen();
var parsedBalance = parsedCards.length > 0 ? parsedCards[0].amount : 0.0;
host.log.debug("parseCardsOnHomeScreen returned " + parsedCards.length
+ " cards, balance: " + parsedBalance);
// 8. Fallback: если парсинг не нашёл баланс, берём из materials
if (parsedBalance <= 0.0) {
var accounts = host.dao.materialGetAccounts(device.id, appCode);
for (var i = 0; i < accounts.length; ++i) {
if (accounts[i].amount > 0.0) {
parsedBalance = accounts[i].amount;
break;
}
}
host.log.debug("Fallback balance from materials: " + parsedBalance);
}
if (parsedBalance > 0.0) {
host.dao.bankProfileUpdateAmount(app.id, parsedBalance);
}
// 9. Находим имя пользователя в верхней части и тапаем по нему
var userNameNode = host.parser.findUserNameOnHomeScreen(device.screenHeight);
if (userNameNode.isEmpty) {
var err2 = "Cannot find user name on home screen: " + deviceId;
host.log.warn(err2);
host.adb.tryToKillApplication(deviceId, app.package);
return err2;
}
host.log.debug("Tapping on user name: " + userNameNode.text
+ " at " + userNameNode.x + " " + userNameNode.y);
host.adb.makeTap(deviceId, userNameNode.x, userNameNode.y);
// 10. Ждём загрузки страницы профиля
host.timer.sleep(2500);
// 11. Проверяем что мы действительно на экране профиля (с retry)
var profileFound = false;
for (var j = 0; j < 5; ++j) {
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter));
host.helpers.closeGooglePlayBannerIfVisible(deviceId, device.screenWidth, device.screenHeight);
if (host.parser.isProfileScreen()) {
profileFound = true;
break;
}
host.timer.sleep(1000);
}
if (!profileFound) {
var err3 = "Profile screen not detected after tap: " + deviceId;
host.log.warn(err3);
host.adb.tryToKillApplication(deviceId, app.package);
return err3;
}
// 12. Извлекаем полное имя и телефон. Telephone из Ozon XML может
// содержать неразрывные пробелы (U+00A0) и обычные — чистим оба.
var fullName = host.parser.parseProfileFullName();
var phone = host.parser.parseProfilePhone().replace(/[\s ]/g, '');
host.log.debug("fullName: " + fullName + " phone: " + phone);
// 13. Сохраняем профиль (email Ozon не отдаёт — передаём пустую строку)
if (!host.dao.bankProfileUpdateProfileInfo(app.id, fullName, phone, "")) {
host.log.warn("Failed to save profile info to DB: " + deviceId);
}
// 14. Регистрируем/обновляем bank profile на сервере. Хелпер сам
// перечитывает свежий профиль из БД и шлёт POST/PATCH по наличию bankProfileId.
host.helpers.createBankProfileIfNeeded(deviceId, appCode, parsedBalance);
// 15. Отправляем аккаунты без material_id на сервер
var allAccounts = host.dao.materialGetAccounts(device.id, appCode);
var accountsToPost = [];
for (var k = 0; k < allAccounts.length; ++k) {
if (!allAccounts[k].materialId) accountsToPost.push(allAccounts[k]);
}
if (accountsToPost.length > 0) {
host.helpers.postAccountsData(accountsToPost);
}
// 16. Возвращаемся на домашний экран
host.adb.goBack(deviceId);
host.timer.sleep(1000);
host.dump.endScope("");
return ""; // success
} catch (e) {
host.dump.endScope(String(e));
throw e;
}
}

View File

@ -0,0 +1,118 @@
// ozon/login_and_check_accounts.js
//
// JS-порт Ozon::LoginAndCheckAccountsScript. Логика 1:1 с C++ (см.
// android/ozon/LoginAndCheckAccountsScript.cpp).
//
// Контракт:
// params.deviceId — серийный или android_id
// params.appCode — "ozon"
// params.fetchProfileOnly — true, если карты не нужно постить (используется в payment flow)
// params.pinCheckOnly — true, если только проверить пин (используется в мастере)
// Возвращает:
// "" / null / undefined → success
// строка → бизнес-ошибка
//
// Финальный вызов GetCardInfoScript (если !fetchProfileOnly && !pinCheckOnly)
// делается в C++ снаружи JS-блока — см. LoginAndCheckAccountsScript.cpp.
function run(host) {
var deviceId = host.params.deviceId;
var appCode = host.params.appCode;
var fetchProfileOnly = !!host.params.fetchProfileOnly;
var pinCheckOnly = !!host.params.pinCheckOnly;
// 1. Резолвим device (id может быть как android_id, так и adb serial).
var device = host.dao.deviceGetById(deviceId);
if (!device || !device.id) {
device = host.dao.deviceFindByAdbSerial(deviceId);
}
if (device && device.adbSerial) {
deviceId = device.adbSerial;
}
var app = host.dao.bankProfileGet(device.id, appCode);
if (!device || !device.id || !app || app.id < 0) {
var msg = "Device or app not found: " + deviceId + " " + appCode;
host.log.error(msg);
return msg;
}
host.dump.beginScope("ozon", "FETCH_PROFILE", deviceId, {});
try {
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, 1));
// 2. Логин + переход на домашний экран
if (!host.helpers.runAppAndGoToHomeScreen(deviceId, app.package, app.pinCode,
device.screenWidth, device.screenHeight)) {
var err = "Cant open the home screen: " + device.name + " (" + deviceId + ") " + appCode;
host.log.warn(err);
// Скриншот: захватываем, но эмит сигнала недоступен из JS — пишем
// только в БД через комментарий профиля.
host.adb.captureScreenshotBase64(deviceId);
host.dao.bankProfileUpdateComment(app.id, "Не прошла проверка " + formatTimestamp());
host.adb.tryToKillApplication(deviceId, app.package);
host.dump.endScope(err);
return err;
}
// 3. Только проверка пин-кода — выходим
if (pinCheckOnly) {
host.dump.endScope("");
return "";
}
// Захватываем скриншот домашнего экрана (просто фиксируем — без emit)
host.adb.captureScreenshotBase64(deviceId);
// 4. Парсим баланс с домашнего экрана
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, 2));
var parsedCards = host.parser.parseCardsOnHomeScreen();
var parsedBalance = parsedCards.length > 0 ? parsedCards[0].amount : 0.0;
host.log.debug("Parsed balance: " + parsedBalance + " cards: " + parsedCards.length);
if (parsedBalance > 0.0) {
host.dao.bankProfileUpdateAmount(app.id, parsedBalance);
}
// 5. Регистрируем/обновляем bank profile на сервере
host.helpers.createBankProfileIfNeeded(deviceId, appCode, parsedBalance);
// 6. Идём в "Финансы" и парсим список аккаунтов
var result = "";
if (host.helpers.goToAllProducts(deviceId, device.screenWidth, device.screenHeight)) {
var accountsToUpsert = [];
var entries = host.parser.parseAccountsInfo();
for (var i = 0; i < entries.length; ++i) {
var acc = entries[i].material;
acc.deviceId = device.id;
acc.appCode = appCode;
if (!host.dao.materialUpsert(acc)) {
result = "Not updated: " + acc.lastNumbers;
host.log.error(result);
}
accountsToUpsert.push(acc);
}
if (!fetchProfileOnly && accountsToUpsert.length > 0) {
host.helpers.postAccountsData(accountsToUpsert);
}
} else {
host.log.warn("Cant go to all products: " + deviceId + " " + appCode);
}
host.dump.endScope(result);
return result; // "" = success, иначе последняя не-фатальная ошибка upsert
} catch (e) {
host.dump.endScope(String(e));
throw e;
}
}
// "dd.MM.yyyy HH:mm:ss" — формат, который пишет C++ через
// QDateTime::currentDateTime().toString.
function formatTimestamp() {
var d = new Date();
function pad(n) { return n < 10 ? '0' + n : '' + n; }
return pad(d.getDate()) + "." + pad(d.getMonth() + 1) + "." + d.getFullYear()
+ " " + pad(d.getHours()) + ":" + pad(d.getMinutes()) + ":" + pad(d.getSeconds());
}

359
scripts/ozon/pay_by_card.js Normal file
View File

@ -0,0 +1,359 @@
// ozon/pay_by_card.js
//
// JS-порт Ozon::PayByCardScript. Логика 1:1 с C++ (см.
// android/ozon/PayByCardScript.cpp).
//
// Контракт:
// params.account — {id, materialId, appCode, deviceId, lastNumbers, cardNumber, currency}
// params.cardNumber — номер карты получателя
// params.amount — сумма перевода
// Возвращает:
// "" / null / undefined → success
// строка → бизнес-ошибка
//
// OCR для bank_transaction_id здесь не используется (см. комментарий в
// PayByCardScript.cpp:453). Externalid формируется как fallback "ozon_<phone>_<ts>".
function run(host) {
var account = host.params.account;
var cardNumber = host.params.cardNumber;
var amount = host.params.amount;
var device = host.dao.deviceGetById(account.deviceId);
var app = host.dao.bankProfileGet(account.deviceId, account.appCode);
if (!device || !device.id || !app || app.id < 0) {
var msg = "Device or app not found: " + account.deviceId;
host.log.error(msg);
return msg;
}
var adbId = device.adbSerial;
var counter = 0;
host.dump.beginScope("ozon", "CREATE_TRANSACTION_CARD", adbId, {
materialId: account.materialId,
amount: amount,
card: cardNumber,
});
try {
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(adbId, ++counter));
// Закрываем рекламный bottom sheet
var adNode = host.parser.tryToFindAdBanner();
if (!adNode.isEmpty) {
host.log.debug("Ad banner detected, closing...");
host.adb.makeTap(adbId, adNode.x, adNode.y);
host.timer.sleep(1000);
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(adbId, ++counter));
}
// Если не на домашнем — перезапускаем
if (!host.parser.isHomeScreen()) {
if (!host.helpers.runAppAndGoToHomeScreen(adbId, app.package, app.pinCode,
device.screenWidth, device.screenHeight)) {
var err = "Cannot reach home screen: " + device.name + " (" + device.id + ")";
host.adb.tryToKillApplication(adbId, app.package);
return err;
}
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(adbId, ++counter));
}
// Закрываем баннеры
host.helpers.tryToCloseAllBannersOnHomePage(adbId, device.screenWidth, device.screenHeight);
host.timer.sleep(500);
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(adbId, ++counter));
// Google Play "Доступно обновление"
var gp = host.parser.tryToFindGooglePlayBanner(device.screenWidth, device.screenHeight);
if (!gp.isEmpty) {
host.adb.makeTap(adbId, gp.x, gp.y);
host.timer.sleep(1500);
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(adbId, ++counter));
}
// Баннер "Заказать карту"
var orderCard = host.parser.findTextNode("Заказать карту");
if (!orderCard.isEmpty) {
host.adb.makeTap(adbId, Math.floor(device.screenWidth / 2),
Math.floor(device.screenHeight / 20));
host.timer.sleep(1000);
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(adbId, ++counter));
}
var result = makePayment(host, adbId, app.package, app.pinCode, app.phone,
device.screenWidth, device.screenHeight,
account, cardNumber, amount, counter);
host.dump.endScope(result || "");
return result;
} catch (e) {
host.dump.endScope(String(e));
throw e;
}
}
// Возвращает m_error (пустая строка = success).
function makePayment(host, deviceId, packageName, pinCode, bankProfilePhone,
width, height, account, cardNumber, amount, counter) {
function checkApp() {
if (!host.helpers.isAppRunning(deviceId, packageName)) {
return "Приложение было закрыто";
}
return null;
}
// 1. "Перевести" на домашнем экране
var transferBtn = host.parser.findButtonNode("Перевести", false);
if (transferBtn.isEmpty) return "Кнопка 'Перевести' не найдена на домашнем экране";
host.adb.makeTap(deviceId, transferBtn.x, transferBtn.y);
host.timer.sleep(2000);
// 2. Ждём экран "Платежи и переводы"
var cardBtn = {isEmpty: true};
var paymentsPageLoaded = false;
var titleSeen = false;
{
var freezePrev = "", freezeStreak = 0;
for (var i = 0; i < 10; ++i) {
var e = checkApp(); if (e) return e;
var xml = host.adb.getScreenDumpAsXml(deviceId, ++counter);
host.parser.parseAndSaveXml(xml);
host.helpers.closeGooglePlayBannerIfVisible(deviceId, width, height);
if (host.helpers.isUsefulDump(xml)) {
if (freezePrev && xml === freezePrev) {
if (++freezeStreak >= 3) { host.log.warn("screen frozen waiting payments"); break; }
} else freezeStreak = 0;
freezePrev = xml;
}
var title = host.parser.findTextNode("Платежи и переводы");
if (!title.isEmpty) titleSeen = true;
cardBtn = host.parser.findButtonNode("По номеру карты", false);
if (!title.isEmpty && !cardBtn.isEmpty) { paymentsPageLoaded = true; break; }
host.timer.sleep(1500);
}
}
if (!paymentsPageLoaded) {
return titleSeen ? "Кнопка 'По номеру карты' не найдена на экране платежей"
: "Экран 'Платежи и переводы' не загрузился";
}
// 3. Тапаем "По номеру карты"
host.adb.makeTap(deviceId, cardBtn.x, cardBtn.y);
host.timer.sleep(2000);
// 4. Ждём "Перевод по номеру карты"
var cardScreenLoaded = false;
{
var fp = "", fs = 0;
for (var j = 0; j < 10; ++j) {
var e2 = checkApp(); if (e2) return e2;
var xml2 = host.adb.getScreenDumpAsXml(deviceId, ++counter);
host.parser.parseAndSaveXml(xml2);
host.helpers.closeGooglePlayBannerIfVisible(deviceId, width, height);
if (host.helpers.isUsefulDump(xml2)) {
if (fp && xml2 === fp) { if (++fs >= 3) { host.log.warn("screen frozen waiting card screen"); break; } }
else fs = 0;
fp = xml2;
}
if (!host.parser.findTextNode("Перевод по номеру карты").isEmpty) { cardScreenLoaded = true; break; }
host.timer.sleep(1500);
}
}
if (!cardScreenLoaded) return "Экран 'Перевод по номеру карты' не загрузился";
// 5. Поле "Номер карты получателя" + PAN fallback
var cardInput = {isEmpty: true};
for (var k = 0; k < 10; ++k) {
var e3 = checkApp(); if (e3) return e3;
cardInput = host.parser.findEditTextByHint("Номер карты получателя");
if (cardInput.isEmpty) cardInput = host.parser.findPanCardNumberInput();
if (!cardInput.isEmpty) break;
host.timer.sleep(1500);
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter));
host.helpers.closeGooglePlayBannerIfVisible(deviceId, width, height);
}
if (cardInput.isEmpty) return "Поле 'Номер карты получателя' не найдено";
host.adb.makeTap(deviceId, cardInput.x, cardInput.y);
host.timer.sleep(500);
host.adb.inputText(deviceId, cardNumber);
host.timer.sleep(2000);
host.adb.hideKeyboard(deviceId);
host.timer.sleep(800);
// 6. Поле суммы (с fallback по input___ rid / bottom-most EditText)
var sumInput = {isEmpty: true};
for (var s = 0; s < 10; ++s) {
var e4 = checkApp(); if (e4) return e4;
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter));
host.helpers.closeGooglePlayBannerIfVisible(deviceId, width, height);
sumInput = host.parser.findEditTextByHint("Сумма от");
if (!sumInput.isEmpty) break;
var candidates = [];
var allNodes = host.parser.findAllNodes();
for (var ni = 0; ni < allNodes.length; ++ni) {
var n = allNodes[ni];
if (n.className !== "android.widget.EditText") continue;
if (n.height <= 0 || n.height > height * 4 / 5) continue;
candidates.push(n);
}
// Strategy 1: rid="input___"
for (var c1 = 0; c1 < candidates.length; ++c1) {
if (String(candidates[c1].resourceId || "").indexOf("input___") === 0) {
sumInput = candidates[c1]; break;
}
}
// Strategy 2: bottom-most
if (sumInput.isEmpty && candidates.length >= 2) {
candidates.sort(function(a, b) { return a.y1 - b.y1; });
sumInput = candidates[candidates.length - 1];
}
if (!sumInput.isEmpty) break;
host.timer.sleep(1500);
}
if (sumInput.isEmpty) return "Поле ввода суммы не найдено";
host.adb.makeTap(deviceId, sumInput.x, sumInput.y);
host.timer.sleep(500);
host.adb.inputText(deviceId, String(Math.floor(amount)));
host.timer.sleep(1000);
host.adb.hideKeyboard(deviceId);
host.timer.sleep(800);
// 7. Ждём расчёта комиссии
var fee = 0;
var feeCalculated = false;
for (var f = 0; f < 15; ++f) {
var e5 = checkApp(); if (e5) return e5;
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter));
host.helpers.closeGooglePlayBannerIfVisible(deviceId, width, height);
var foundKomissiya = false;
var nodesf = host.parser.findAllNodes();
for (var nf = 0; nf < nodesf.length; ++nf) {
var node = nodesf[nf];
if (node.className === "android.widget.TextView" && (node.text || "").trim() === "Комиссия") {
foundKomissiya = true; continue;
}
if (foundKomissiya && node.className === "android.widget.TextView" && node.text) {
var feeText = (node.text || "").trim();
if (feeText.indexOf("Рассчитаем") === 0) { foundKomissiya = false; break; }
var digits = feeText.replace(/[^0-9,\.]/g, '').replace(',', '.');
fee = parseFloat(digits) || 0;
feeCalculated = true;
host.log.debug("Fee parsed: " + fee + " from text: " + feeText);
break;
}
}
if (feeCalculated) break;
host.timer.sleep(1500);
}
if (!feeCalculated) host.log.warn("Fee not calculated, proceeding with fee=0");
// 8. Тапаем "Перевести" (retry если bounds 0)
var transferBtn2 = {isEmpty: true};
for (var a = 0; a < 5; ++a) {
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter));
transferBtn2 = host.parser.findButtonNode("Перевести", true);
if (!transferBtn2.isEmpty && transferBtn2.height > 0) break;
host.adb.hideKeyboard(deviceId);
host.timer.sleep(1000);
}
if (transferBtn2.isEmpty || transferBtn2.height === 0) {
return "Кнопка 'Перевести' не найдена или перекрыта клавиатурой";
}
host.adb.makeTap(deviceId, transferBtn2.x, transferBtn2.y);
host.timer.sleep(3000);
// 8.5. Возможный PIN-экран после confirm
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter));
var pinNodes = host.parser.tryToFindPinCode(pinCode, "Введите ПИН");
if (pinNodes && pinNodes.length > 0) {
host.helpers.inputPinCode(deviceId, pinNodes);
host.timer.sleep(3000);
}
// 9. Ждём результат
var freezePrev2 = "", freezeStreak2 = 0;
for (var w = 0; w < 15; ++w) {
var e6 = checkApp(); if (e6) return e6;
var xml3 = host.adb.getScreenDumpAsXml(deviceId, ++counter);
host.parser.parseAndSaveXml(xml3);
host.helpers.closeGooglePlayBannerIfVisible(deviceId, width, height);
if (host.helpers.isUsefulDump(xml3)) {
if (freezePrev2 && xml3 === freezePrev2) {
if (++freezeStreak2 >= 3) { host.log.warn("screen frozen waiting result"); break; }
} else freezeStreak2 = 0;
freezePrev2 = xml3;
}
// PIN-экран мог появиться позже
var pinLater = host.parser.tryToFindPinCode(pinCode, "Введите ПИН");
if (pinLater && pinLater.length > 0) {
host.helpers.inputPinCode(deviceId, pinLater);
host.timer.sleep(3000);
continue;
}
if (!host.parser.findTextNode("Успешно").isEmpty) {
// Сохраняем транзакцию
var nowMs = Date.now();
var tx = {
accountId: account.id,
amount: -amount,
fee: fee,
phone: cardNumber,
bankName: "ozon",
type: "card",
status: "complete",
timestamp: new Date().toISOString(),
completeTime: new Date().toISOString(),
bankTime: formatBankTime(new Date()),
};
var txId = host.dao.transactionInsert(tx);
host.log.debug("Saved tx to DB, id: " + txId + " fee: " + fee);
// Receipt + external_id
if (txId !== -1) {
host.helpers.saveTransactionReceipt(deviceId, txId);
var externalId = "ozon_" + bankProfilePhone + "_" + Math.floor(nowMs / 1000);
host.dao.transactionUpdateBankTrExternalId(txId, externalId);
}
host.adb.goBack(deviceId);
host.timer.sleep(1000);
host.adb.goBack(deviceId);
host.timer.sleep(1000);
return ""; // success
}
if (!host.parser.findTextNode("Не удалось").isEmpty) {
host.adb.goBack(deviceId);
host.timer.sleep(1000);
host.adb.goBack(deviceId);
host.timer.sleep(1000);
return "Перевод не удался (экран 'Не удалось')";
}
host.timer.sleep(2000);
}
return "Не дождались результата перевода";
}
// "dd.MM.yyyy, HH:mm" — MSK-таймзона как в C++
// (.toTimeZone(QTimeZone("Europe/Moscow"))).
function formatBankTime(d) {
// Берём смещение MSK (+03:00) от UTC и форматируем вручную, чтобы не зависеть
// от локали JS-движка.
var mskMs = d.getTime() + (3 * 60 - d.getTimezoneOffset() * -1) * 60 * 1000 + d.getTimezoneOffset() * 60 * 1000;
// Проще: добавляем 3 часа к UTC.
var utc = d.getTime();
var msk = new Date(utc + 3 * 3600 * 1000);
function pad(n) { return n < 10 ? '0' + n : '' + n; }
return pad(msk.getUTCDate()) + "." + pad(msk.getUTCMonth() + 1) + "." + msk.getUTCFullYear()
+ ", " + pad(msk.getUTCHours()) + ":" + pad(msk.getUTCMinutes());
}

View File

@ -0,0 +1,430 @@
// ozon/pay_by_phone.js
//
// JS-порт Ozon::PayByPhoneScript. Логика 1:1 с C++ (см.
// android/ozon/PayByPhoneScript.cpp).
//
// Контракт:
// params.account — {id, materialId, appCode, deviceId, lastNumbers, currency}
// params.phone — телефон получателя
// params.bankName — название банка получателя (для "Другой банк")
// params.amount — сумма перевода
function run(host) {
var account = host.params.account;
var phone = host.params.phone;
var bankName = host.params.bankName;
var amount = host.params.amount;
var device = host.dao.deviceGetById(account.deviceId);
var app = host.dao.bankProfileGet(account.deviceId, account.appCode);
if (!device || !device.id || !app || app.id < 0) {
var msg = "Device or app not found: " + account.deviceId;
host.log.error(msg);
return msg;
}
var adbId = device.adbSerial;
var counter = 0;
host.dump.beginScope("ozon", "CREATE_TRANSACTION_PHONE", adbId, {
materialId: account.materialId,
amount: amount,
phone: phone,
});
try {
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(adbId, ++counter));
var adNode = host.parser.tryToFindAdBanner();
if (!adNode.isEmpty) {
host.adb.makeTap(adbId, adNode.x, adNode.y);
host.timer.sleep(1000);
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(adbId, ++counter));
}
if (!host.parser.isHomeScreen()) {
if (!host.helpers.runAppAndGoToHomeScreen(adbId, app.package, app.pinCode,
device.screenWidth, device.screenHeight)) {
var err = "Cannot reach home screen: " + device.name + " (" + device.id + ")";
host.adb.tryToKillApplication(adbId, app.package);
return err;
}
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(adbId, ++counter));
}
host.helpers.tryToCloseAllBannersOnHomePage(adbId, device.screenWidth, device.screenHeight);
host.timer.sleep(500);
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(adbId, ++counter));
var gp = host.parser.tryToFindGooglePlayBanner(device.screenWidth, device.screenHeight);
if (!gp.isEmpty) {
host.adb.makeTap(adbId, gp.x, gp.y);
host.timer.sleep(1500);
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(adbId, ++counter));
}
var orderCard = host.parser.findTextNode("Заказать карту");
if (!orderCard.isEmpty) {
host.adb.makeTap(adbId, Math.floor(device.screenWidth / 2),
Math.floor(device.screenHeight / 20));
host.timer.sleep(1000);
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(adbId, ++counter));
}
var result = makePayment(host, adbId, app.package, app.pinCode, app.phone,
device.screenWidth, device.screenHeight,
account, phone, bankName, amount, counter);
host.dump.endScope(result || "");
return result;
} catch (e) {
host.dump.endScope(String(e));
throw e;
}
}
function makePayment(host, deviceId, packageName, pinCode, bankProfilePhone,
width, height, account, phone, bankName, amount, counter) {
function checkApp() {
if (!host.helpers.isAppRunning(deviceId, packageName)) {
return "Приложение было закрыто";
}
return null;
}
// 1. "Перевести"
var transferBtn = host.parser.findButtonNode("Перевести", false);
if (transferBtn.isEmpty) return "Кнопка 'Перевести' не найдена на домашнем экране";
host.adb.makeTap(deviceId, transferBtn.x, transferBtn.y);
host.timer.sleep(2000);
// 2. Ждём экран "Платежи и переводы" с первым EditText
var paymentsPageLoaded = false;
for (var i = 0; i < 10; ++i) {
var e = checkApp(); if (e) return e;
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter));
host.helpers.closeGooglePlayBannerIfVisible(deviceId, width, height);
if (!host.parser.findFirstEditText().isEmpty) { paymentsPageLoaded = true; break; }
host.timer.sleep(1500);
}
if (!paymentsPageLoaded) return "Экран 'Платежи и переводы' не загрузился";
// 3. Тапаем "Телефон получателя"
var phoneFieldTap = host.parser.findFirstEditText();
host.adb.makeTap(deviceId, phoneFieldTap.x, phoneFieldTap.y);
host.timer.sleep(2000);
// 4. Ждём "Кому перевести"
var phoneScreenLoaded = false;
for (var j = 0; j < 10; ++j) {
var e2 = checkApp(); if (e2) return e2;
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter));
host.helpers.closeGooglePlayBannerIfVisible(deviceId, width, height);
var notNow = host.parser.findButtonNode("Не сейчас", false);
if (!notNow.isEmpty) {
host.adb.makeTap(deviceId, notNow.x, notNow.y);
host.timer.sleep(1500);
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter));
}
if (!host.parser.findTextNode("Кому перевести").isEmpty) { phoneScreenLoaded = true; break; }
host.timer.sleep(1500);
}
if (!phoneScreenLoaded) return "Экран 'Кому перевести' не загрузился";
// 5. Вводим телефон
var phoneInput = {isEmpty: true};
for (var k = 0; k < 10; ++k) {
var e3 = checkApp(); if (e3) return e3;
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter));
host.helpers.closeGooglePlayBannerIfVisible(deviceId, width, height);
phoneInput = host.parser.findFirstEditText();
if (!phoneInput.isEmpty) break;
host.timer.sleep(1500);
}
if (phoneInput.isEmpty) return "Поле ввода телефона не найдено";
host.adb.makeTap(deviceId, phoneInput.x, phoneInput.y);
host.timer.sleep(500);
host.adb.inputText(deviceId, phone);
host.timer.sleep(2000);
// 6. Ждём экран "По номеру телефона" с обработкой выбора банка / последних
var sumScreenLoaded = false;
var bankSelected = false;
var otherBankClicked = false;
var freezePrev = "", freezeStreak = 0;
var phoneDigits = phone.replace(/[^0-9]/g, '');
for (var attempt = 0; attempt < 20; ++attempt) {
var e4 = checkApp(); if (e4) return e4;
var xml = host.adb.getScreenDumpAsXml(deviceId, ++counter);
host.parser.parseAndSaveXml(xml);
host.helpers.closeGooglePlayBannerIfVisible(deviceId, width, height);
if (host.helpers.isUsefulDump(xml)) {
if (freezePrev && xml === freezePrev) {
if (++freezeStreak >= 3) { host.log.warn("screen frozen"); break; }
} else freezeStreak = 0;
freezePrev = xml;
}
// Уже на экране суммы?
var sumTitle = host.parser.findTextNode("По номеру телефона");
if (!sumTitle.isEmpty && sumTitle.y < height / 7) {
sumScreenLoaded = true;
break;
}
// Кнопка с этим телефоном (новый перевод / последние)
var foundPhoneBtn = false;
var allBtns = host.parser.findAllButtons();
for (var bi = 0; bi < allBtns.length; ++bi) {
var btn = allBtns[bi];
var btnDigits = (btn.text || "").replace(/[^0-9]/g, '');
if (btnDigits.indexOf(phoneDigits) !== -1
|| (btnDigits.length >= 10 && phoneDigits.indexOf(btnDigits.slice(-10)) !== -1)) {
host.log.debug("Found button with phone: " + btn.text);
host.adb.makeTap(deviceId, btn.x, btn.y);
host.timer.sleep(2000);
foundPhoneBtn = true;
break;
}
}
if (foundPhoneBtn) continue;
// Экран выбора банка
if (!bankSelected) {
// Вариант 1: "Другой банк" (content-desc="other-bank-item")
var foundOtherBank = false;
var allNodes = host.parser.findAllNodes();
for (var ai = 0; ai < allNodes.length; ++ai) {
var n = allNodes[ai];
if ((n.contentDesc || "").indexOf("other-bank-item") !== -1) {
host.adb.makeTap(deviceId, n.x, n.y);
host.timer.sleep(2000);
foundOtherBank = true;
otherBankClicked = true;
break;
}
}
if (foundOtherBank) continue;
// Вариант 2: после клика на "Другой банк" — поиск + выбор
if (otherBankClicked) {
var bankInput = host.parser.findFirstEditText();
if (!bankInput.isEmpty) {
// Переключаем IME до тапа — иначе layout сдвинется
if (!host.adb.enableAdbIMEKeyboard(deviceId)) {
return "Не удалось активировать ADB Keyboard (см. логи 'ime list -s')";
}
host.timer.sleep(800);
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter));
var freshBankInput = host.parser.findFirstEditText();
if (!freshBankInput.isEmpty) bankInput = freshBankInput;
host.adb.makeTap(deviceId, bankInput.x, bankInput.y);
host.timer.sleep(500);
host.adb.inputAdbIMEText(deviceId, bankName);
host.timer.sleep(2500);
// Верификация
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter));
var verify = host.parser.findFirstEditText();
if (!verify.text) {
host.log.warn("Поле банка пустое — ретрай");
host.adb.makeTap(deviceId, bankInput.x, bankInput.y);
host.timer.sleep(500);
host.adb.inputAdbIMEText(deviceId, bankName);
host.timer.sleep(2500);
}
host.adb.disableAdbIMEKeyboard(deviceId);
host.timer.sleep(2000);
// Ищем банк в списке (Button с непустым content-desc, не служебный)
var bankFound = false;
for (var ba = 0; ba < 10; ++ba) {
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter));
var searchField = host.parser.findFirstEditText();
if (!searchField.text || !(searchField.text || "").trim()) {
host.timer.sleep(1500);
continue;
}
var all2 = host.parser.findAllNodes();
for (var ai2 = 0; ai2 < all2.length; ++ai2) {
var node2 = all2[ai2];
if (node2.className === "android.widget.Button"
&& node2.contentDesc
&& node2.contentDesc !== "base-go-back-button"
&& node2.contentDesc !== "native-toolbar-close-button"
&& node2.contentDesc !== "clear-input-icon") {
var tapX = node2.x1 + Math.floor(node2.width / 4);
host.log.debug("Found bank: " + node2.contentDesc + " tapX=" + tapX);
host.adb.makeTap(deviceId, tapX, node2.y);
bankFound = true;
break;
}
}
if (bankFound) break;
host.timer.sleep(1500);
}
if (!bankFound) return "Банк '" + bankName + "' не найден в списке";
bankSelected = true;
host.timer.sleep(2000);
continue;
}
}
}
host.timer.sleep(1500);
}
if (!sumScreenLoaded) return "Экран ввода суммы не загрузился";
// 7. Вводим сумму — берём верхний (минимальный y1) EditText с input___
var sumInput = {isEmpty: true};
for (var sa = 0; sa < 10; ++sa) {
var e5 = checkApp(); if (e5) return e5;
var candidates = [];
var allNodes2 = host.parser.findAllNodes();
for (var ci = 0; ci < allNodes2.length; ++ci) {
var nn = allNodes2[ci];
if (nn.className !== "android.widget.EditText") continue;
if (nn.height <= 0 || nn.height > height * 4 / 5) continue;
if (String(nn.resourceId || "").indexOf("input___") !== 0) continue;
candidates.push(nn);
}
if (candidates.length > 0) {
candidates.sort(function(a, b) { return a.y1 - b.y1; });
sumInput = candidates[0]; // верхний = amount
break;
}
sumInput = host.parser.findFirstEditText();
if (!sumInput.isEmpty) break;
host.timer.sleep(1500);
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter));
host.helpers.closeGooglePlayBannerIfVisible(deviceId, width, height);
}
if (sumInput.isEmpty) return "Поле ввода суммы не найдено";
host.adb.makeTap(deviceId, sumInput.x, sumInput.y);
host.timer.sleep(500);
host.adb.inputText(deviceId, String(Math.floor(amount)));
host.timer.sleep(1000);
host.adb.hideKeyboard(deviceId);
host.timer.sleep(800);
// 8. "Продолжить"
var continueBtn = {isEmpty: true};
for (var ca = 0; ca < 5; ++ca) {
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter));
continueBtn = host.parser.findButtonNode("Продолжить", false);
if (!continueBtn.isEmpty && continueBtn.height > 0) break;
host.adb.hideKeyboard(deviceId);
host.timer.sleep(1000);
}
if (continueBtn.isEmpty || continueBtn.height === 0) {
return "Кнопка 'Продолжить' не найдена или перекрыта клавиатурой";
}
host.adb.makeTap(deviceId, continueBtn.x, continueBtn.y);
host.timer.sleep(2000);
// 9. Ждём "Подтвердите перевод"
var confirmLoaded = false;
for (var cf = 0; cf < 10; ++cf) {
var e6 = checkApp(); if (e6) return e6;
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter));
host.helpers.closeGooglePlayBannerIfVisible(deviceId, width, height);
if (!host.parser.findTextNode("Подтвердите перевод").isEmpty) { confirmLoaded = true; break; }
host.timer.sleep(1500);
}
if (!confirmLoaded) return "Экран подтверждения не загрузился";
// 10. Тапаем "Перевести ..."
var transferConfirmBtn = {isEmpty: true};
for (var tc = 0; tc < 10; ++tc) {
var e7 = checkApp(); if (e7) return e7;
transferConfirmBtn = host.parser.findButtonNode("Перевести", true);
if (!transferConfirmBtn.isEmpty) break;
host.timer.sleep(1500);
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter));
host.helpers.closeGooglePlayBannerIfVisible(deviceId, width, height);
}
if (transferConfirmBtn.isEmpty) return "Кнопка 'Перевести ...' не найдена на экране подтверждения";
host.adb.makeTap(deviceId, transferConfirmBtn.x, transferConfirmBtn.y);
host.timer.sleep(3000);
// PIN после confirm
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter));
var pinNodes = host.parser.tryToFindPinCode(pinCode, "Введите ПИН");
if (pinNodes && pinNodes.length > 0) {
host.helpers.inputPinCode(deviceId, pinNodes);
host.timer.sleep(3000);
}
// 11. Ждём результат
for (var w = 0; w < 15; ++w) {
var e8 = checkApp(); if (e8) return e8;
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter));
host.helpers.closeGooglePlayBannerIfVisible(deviceId, width, height);
var pinLater = host.parser.tryToFindPinCode(pinCode, "Введите ПИН");
if (pinLater && pinLater.length > 0) {
host.helpers.inputPinCode(deviceId, pinLater);
host.timer.sleep(3000);
continue;
}
if (!host.parser.findTextNode("Успешно").isEmpty) {
var tx = {
accountId: account.id,
amount: -amount,
phone: phone,
bankName: "ozon",
type: "phone",
status: "complete",
timestamp: new Date().toISOString(),
completeTime: new Date().toISOString(),
bankTime: formatBankTime(new Date()),
};
var txId = host.dao.transactionInsert(tx);
host.log.debug("Saved tx to DB, id: " + txId);
if (txId !== -1) {
host.helpers.saveTransactionReceipt(deviceId, txId);
var externalId = "ozon_" + bankProfilePhone + "_" + Math.floor(Date.now() / 1000);
host.dao.transactionUpdateBankTrExternalId(txId, externalId);
}
host.adb.goBack(deviceId);
host.timer.sleep(1000);
host.adb.goBack(deviceId);
host.timer.sleep(1000);
return "";
}
if (!host.parser.findTextNode("Не удалось").isEmpty) {
host.adb.goBack(deviceId);
host.timer.sleep(1000);
host.adb.goBack(deviceId);
host.timer.sleep(1000);
return "Перевод не удался (экран 'Не удалось')";
}
host.timer.sleep(2000);
}
return "Не дождались результата перевода";
}
function formatBankTime(d) {
var utc = d.getTime();
var msk = new Date(utc + 3 * 3600 * 1000);
function pad(n) { return n < 10 ? '0' + n : '' + n; }
return pad(msk.getUTCDate()) + "." + pad(msk.getUTCMonth() + 1) + "." + msk.getUTCFullYear()
+ ", " + pad(msk.getUTCHours()) + ":" + pad(msk.getUTCMinutes());
}

View File

@ -5,22 +5,30 @@
#include <QDebug> #include <QDebug>
#include <QJsonDocument> #include <QJsonDocument>
#include <QJsonObject> #include <QJsonObject>
#include <QDateTime>
#include <QRandomGenerator>
#include <QSettings> #include <QSettings>
#include <QString> #include <QString>
#include <QThread> #include <QThread>
#include <QRegularExpression> #include <QRegularExpression>
#include <QJsonArray>
#include "adb/AdbUtils.h" #include "adb/AdbUtils.h"
#include "black/ScreenXmlParser.h" #include "black/ScreenXmlParser.h"
#include "ozon/ScreenXmlParser.h"
#include "android/xml/Node.h" #include "android/xml/Node.h"
#include "db/BankProfileInfo.h" #include "db/BankProfileInfo.h"
#include "db/DeviceInfo.h" #include "db/DeviceInfo.h"
#include "db/MaterialInfo.h" #include "db/MaterialInfo.h"
#include "db/TransactionInfo.h"
#include "dao/BankProfileDAO.h" #include "dao/BankProfileDAO.h"
#include "dao/DeviceDAO.h" #include "dao/DeviceDAO.h"
#include "dao/MaterialDAO.h" #include "dao/MaterialDAO.h"
#include "dao/SettingsDAO.h"
#include "dao/TransactionDAO.h"
#include "dump/TaskDumpRecorder.h" #include "dump/TaskDumpRecorder.h"
#include "net/NetworkService.h" #include "net/NetworkService.h"
#include "ocr/OcrUtils.h"
namespace Scripting { namespace Scripting {
@ -171,6 +179,8 @@ static QVariantMap materialToMap(const MaterialInfo &a) {
m["appCode"] = a.appCode; m["appCode"] = a.appCode;
m["deviceId"] = a.deviceId; m["deviceId"] = a.deviceId;
m["lastNumbers"] = a.lastNumbers; m["lastNumbers"] = a.lastNumbers;
m["cardNumber"] = a.cardNumber;
m["accountNumber"] = a.accountNumber;
m["amount"] = a.amount; m["amount"] = a.amount;
m["currency"] = a.currency; m["currency"] = a.currency;
m["description"] = a.description; m["description"] = a.description;
@ -178,6 +188,24 @@ static QVariantMap materialToMap(const MaterialInfo &a) {
return m; return m;
} }
static MaterialInfo materialFromMap(const QVariantMap &m) {
MaterialInfo a;
a.id = m.value("id", -1).toInt();
a.materialId = m.value("materialId").toString();
a.appCode = m.value("appCode").toString();
a.deviceId = m.value("deviceId").toString();
a.lastNumbers = m.value("lastNumbers").toString();
a.cardNumber = m.value("cardNumber").toString();
a.accountNumber = m.value("accountNumber").toString();
a.amount = m.value("amount").toDouble();
a.currency = m.value("currency").toString();
a.description = m.value("description").toString();
const QString status = m.value("status").toString();
if (!status.isEmpty()) a.status = materialStatusFromString(status);
a.updateTime = QDateTime::currentDateTimeUtc();
return a;
}
QVariantMap DaoBridge::deviceGetById(const QString &id) { QVariantMap DaoBridge::deviceGetById(const QString &id) {
return deviceToMap(DeviceDAO::getDeviceById(id)); return deviceToMap(DeviceDAO::getDeviceById(id));
} }
@ -204,6 +232,62 @@ QVariantList DaoBridge::materialGetAccounts(const QString &deviceId, const QStri
} }
return list; return list;
} }
QVariantMap DaoBridge::materialFindAppAccount(const QString &deviceId, const QString &appCode,
const QString &lastNumbers) {
return materialToMap(MaterialDAO::findAppAccount(deviceId, appCode, lastNumbers));
}
bool DaoBridge::materialUpsert(const QVariantMap &material) {
return MaterialDAO::upsertAccount(materialFromMap(material));
}
bool DaoBridge::materialUpdateCardNumber(int accountId, const QString &cardNumber) {
return MaterialDAO::updateCardNumber(accountId, cardNumber);
}
bool DaoBridge::materialUpdateAccountById(int id, const QString &status,
const QString &description, const QString &currency) {
return MaterialDAO::updateAccountById(id, status, description, currency);
}
QString DaoBridge::settingsGet(const QString &key) {
return SettingsDAO::get(key);
}
namespace {
TransactionInfo txFromMap(const QVariantMap &m) {
TransactionInfo tx;
tx.id = m.value("id", -1).toInt();
tx.accountId = m.value("accountId", -1).toInt();
tx.amount = m.value("amount").toDouble();
tx.fee = m.value("fee").toDouble();
tx.phone = m.value("phone").toString();
tx.name = m.value("name").toString();
tx.description = m.value("description").toString();
tx.updatedDescription = m.value("updatedDescription").toString();
tx.bankName = m.value("bankName").toString();
tx.bankTime = m.value("bankTime").toString();
tx.bankTrExternalId = m.value("bankTrExternalId").toString();
const QString typeS = m.value("type").toString();
if (!typeS.isEmpty()) tx.type = transactionTypeFromString(typeS);
const QString statusS = m.value("status").toString();
if (!statusS.isEmpty()) tx.status = transactionStatusFromString(statusS);
auto parseDt = [](const QVariant &v) -> QDateTime {
if (!v.isValid() || v.isNull()) return {};
if (v.canConvert<QDateTime>()) return v.toDateTime();
return QDateTime::fromString(v.toString(), Qt::ISODate);
};
tx.timestamp = parseDt(m.value("timestamp"));
tx.completeTime = parseDt(m.value("completeTime"));
tx.updateTime = parseDt(m.value("updateTime"));
if (!tx.timestamp.isValid()) tx.timestamp = QDateTime::currentDateTimeUtc();
return tx;
}
} // namespace
int DaoBridge::transactionInsert(const QVariantMap &tx) {
return TransactionDAO::insertTransaction(txFromMap(tx));
}
bool DaoBridge::transactionUpdateBankTrExternalId(int id, const QString &externalId) {
return TransactionDAO::updateBankTrExternalId(id, externalId);
}
// ─── DumpBridge ───────────────────────────────────────────────────────────── // ─── DumpBridge ─────────────────────────────────────────────────────────────
@ -231,6 +315,38 @@ void DumpBridge::endScope(const QString &errorMessage) {
m_errorPtr = nullptr; m_errorPtr = nullptr;
} }
// ─── ScriptObjectBridge ────────────────────────────────────────────────────
void ScriptObjectBridge::emitTransactionParsed(const QString &jsonStr) {
if (!m_sink) {
qDebug() << "[Scripting] emitTransactionParsed called but no signalSink — dropping";
return;
}
QJsonParseError err;
const QJsonDocument doc = QJsonDocument::fromJson(jsonStr.toUtf8(), &err);
if (err.error != QJsonParseError::NoError || !doc.isObject()) {
qWarning() << "[Scripting] emitTransactionParsed: bad JSON" << err.errorString();
return;
}
// QMetaObject::invokeMethod умеет вызывать Qt-сигналы. Для thread-safety
// используем QueuedConnection — JS крутится в worker-потоке, EventHandler
// тоже там же, но queued тут не помешает.
QMetaObject::invokeMethod(m_sink, "transactionParsed", Qt::DirectConnection,
Q_ARG(QJsonObject, doc.object()));
}
// ─── OcrBridge ─────────────────────────────────────────────────────────────
QString OcrBridge::recognizeFromScreen(const QString &deviceId) {
const QByteArray png = AdbUtils::captureScreenshotBytes(deviceId);
if (png.isEmpty()) return {};
return OcrUtils::recognizeText(png);
}
QString OcrBridge::extractTransactionId(const QString &ocrText) {
return OcrUtils::extractTransactionId(ocrText);
}
// ─── BlackParserBridge ────────────────────────────────────────────────────── // ─── BlackParserBridge ──────────────────────────────────────────────────────
BlackParserBridge::BlackParserBridge(QObject *parent) BlackParserBridge::BlackParserBridge(QObject *parent)
@ -414,4 +530,566 @@ void BlackHelpersBridge::createBankProfileIfNeeded(const QString &deviceId, cons
} }
} }
// ─── OzonParserBridge ──────────────────────────────────────────────────────
OzonParserBridge::OzonParserBridge(QObject *parent)
: QObject(parent), m_parser(new Ozon::ScreenXmlParser(this)) {}
OzonParserBridge::~OzonParserBridge() = default;
void OzonParserBridge::parseAndSaveXml(const QString &xml) { m_parser->parseAndSaveXml(xml); }
bool OzonParserBridge::isHomeScreen() { return m_parser->isHomeScreen(); }
bool OzonParserBridge::isProfileScreen() { return m_parser->isProfileScreen(); }
QVariantMap OzonParserBridge::tryToFindAdBanner() { return nodeToVariantMap(m_parser->tryToFindAdBanner()); }
QVariantMap OzonParserBridge::findUserNameOnHomeScreen(int screenHeight){ return nodeToVariantMap(m_parser->findUserNameOnHomeScreen(screenHeight)); }
QVariantMap OzonParserBridge::findCardButtonByLastNumbers(const QString &ln) { return nodeToVariantMap(m_parser->findCardButtonByLastNumbers(ln)); }
QVariantMap OzonParserBridge::findButtonNode(const QString &text, bool contains) { return nodeToVariantMap(m_parser->findButtonNode(text, contains)); }
QVariantMap OzonParserBridge::findTextNode(const QString &text) { return nodeToVariantMap(m_parser->findTextNode(text)); }
QVariantMap OzonParserBridge::tryToFindGooglePlayBanner(int w, int h) { return nodeToVariantMap(m_parser->tryToFindGooglePlayBanner(w, h)); }
QVariantMap OzonParserBridge::findFirstEditText() { return nodeToVariantMap(m_parser->findFirstEditText()); }
QVariantMap OzonParserBridge::findEditTextByHint(const QString &h) { return nodeToVariantMap(m_parser->findEditTextByHint(h)); }
QVariantMap OzonParserBridge::findPanCardNumberInput() { return nodeToVariantMap(m_parser->findPanCardNumberInput()); }
QVariantMap OzonParserBridge::findMainAccountButton() { return nodeToVariantMap(m_parser->findMainAccountButton()); }
QVariantMap OzonParserBridge::findNodeByContentDesc(const QString &cd) { return nodeToVariantMap(m_parser->findNodeByContentDesc(cd)); }
QVariantList OzonParserBridge::findAllNodes() {
QVariantList list;
for (const Node &n : m_parser->findAllNodes()) list.append(nodeToVariantMap(n));
return list;
}
QVariantList OzonParserBridge::findAllButtons() {
QVariantList list;
for (const Node &n : m_parser->findAllButtons()) list.append(nodeToVariantMap(n));
return list;
}
QVariantList OzonParserBridge::tryToFindPinCode(const QString &pin, const QString &title) {
QVariantList list;
for (const Node &n : m_parser->tryToFindPinCode(pin, title)) list.append(nodeToVariantMap(n));
return list;
}
namespace {
// Конверсия TransactionInfo → JS-объект. Используется парсер-методами,
// возвращающими спарсенные транзакции из XML.
QVariantMap txInfoToMap(const TransactionInfo &tx) {
QVariantMap m;
m["id"] = tx.id;
m["accountId"] = tx.accountId;
m["amount"] = tx.amount;
m["fee"] = tx.fee;
m["status"] = transactionStatusToString(tx.status);
m["type"] = transactionTypeToString(tx.type);
m["phone"] = tx.phone;
m["name"] = tx.name;
m["description"] = tx.description;
m["updatedDescription"] = tx.updatedDescription;
m["bankName"] = tx.bankName;
m["bankTime"] = tx.bankTime;
m["bankTrExternalId"] = tx.bankTrExternalId;
if (tx.timestamp.isValid()) m["timestamp"] = tx.timestamp.toString(Qt::ISODateWithMs);
if (tx.completeTime.isValid()) m["completeTime"] = tx.completeTime.toString(Qt::ISODateWithMs);
if (tx.updateTime.isValid()) m["updateTime"] = tx.updateTime.toString(Qt::ISODateWithMs);
return m;
}
} // namespace
bool OzonParserBridge::isTransactionListScreen() { return m_parser->isTransactionListScreen(); }
bool OzonParserBridge::hasCollapsedTransactionsBelow(int sh) { return m_parser->hasCollapsedTransactionsBelow(sh); }
QVariantList OzonParserBridge::findTransactionButtons(int maxCount) {
QVariantList list;
for (const Node &n : m_parser->findTransactionButtons(maxCount)) list.append(nodeToVariantMap(n));
return list;
}
QVariantList OzonParserBridge::getLast2DaysTransactions() {
QVariantList list;
for (const Node &n : m_parser->getLast2DaysTransactions()) list.append(nodeToVariantMap(n));
return list;
}
QVariantList OzonParserBridge::parseTodayAndYesterdayHistory() {
QVariantList list;
for (const TransactionInfo &t : m_parser->parseTodayAndYesterdayHistory()) list.append(txInfoToMap(t));
return list;
}
QVariantList OzonParserBridge::parseTodayAndYesterdayReportHistory() {
QVariantList list;
for (const TransactionInfo &t : m_parser->parseTodayAndYesterdayReportHistory()) list.append(txInfoToMap(t));
return list;
}
QVariantMap OzonParserBridge::parseTransactionInfo(int w, int h) { return txInfoToMap(m_parser->parseTransactionInfo(w, h)); }
QVariantMap OzonParserBridge::parseTransactionInfoHistory(int w, int h) { return txInfoToMap(m_parser->parseTransactionInfoHistory(w, h)); }
QVariantMap OzonParserBridge::parseTransactionDetail() { return txInfoToMap(m_parser->parseTransactionDetail()); }
QVariantMap OzonParserBridge::parseTransactionButtonText(const QString &text) { return txInfoToMap(m_parser->parseTransactionButtonText(text)); }
QVariantList OzonParserBridge::parseCardsOnHomeScreen() {
QVariantList list;
for (const MaterialInfo &c : m_parser->parseCardsOnHomeScreen()) {
QVariantMap m;
m["lastNumbers"] = c.lastNumbers;
m["description"] = c.description;
m["amount"] = c.amount;
m["currency"] = c.currency;
list.append(m);
}
return list;
}
QString OzonParserBridge::parseProfileFullName() { return m_parser->parseProfileFullName(); }
QString OzonParserBridge::parseProfilePhone() { return m_parser->parseProfilePhone(); }
QString OzonParserBridge::findFullCardNumber() { return m_parser->findFullCardNumber(); }
QVariantList OzonParserBridge::parseAccountsInfo() {
QVariantList list;
for (const auto &pair : m_parser->parseAccountsInfo()) {
QVariantMap m;
m["lastNumbers"] = pair.first.lastNumbers;
m["description"] = pair.first.description;
m["amount"] = pair.first.amount;
m["currency"] = pair.first.currency;
QVariantMap entry;
entry["material"] = m;
entry["node"] = nodeToVariantMap(pair.second);
list.append(entry);
}
return list;
}
// ─── OzonHelpersBridge ─────────────────────────────────────────────────────
namespace {
// Локальная копия Ozon::CommonScript::inputPinCode — нужна целиком в helper'е,
// чтобы не тянуть CommonScript-инстанс ради одной static-функции.
bool ozonInputPinCode(const QString &deviceId, const QList<Node> &pincode) {
for (const Node &node : pincode) {
if (!AdbUtils::makeTap(deviceId, node.x(), node.y())) {
AdbUtils::makeTap(deviceId, node.x(), node.y());
}
QThread::msleep(QRandomGenerator::global()->bounded(100, 901));
}
return true;
}
bool interrupted() {
QThread *t = QThread::currentThread();
return t && t->isInterruptionRequested();
}
} // namespace
// Полная копия Ozon::CommonScript::runAppAndGoToHomeScreen — fast path + цикл с
// обработкой баннеров и пин-кода. Использует ТОТ ЖЕ парсер, что и JS-скрипт.
bool OzonHelpersBridge::runAppAndGoToHomeScreen(const QString &deviceId, const QString &packageName,
const QString &pinCode, int width, int height) {
Ozon::ScreenXmlParser *parser = m_parserBridge->parser();
int xmlCounter = 0;
AdbUtils::unlockScreen(deviceId, width, height);
// Fast path: если приложение уже запущено
if (const QString pid = AdbUtils::tryToGetRunningAppPid(deviceId, packageName); !pid.isEmpty()) {
qDebug() << "[js:ozon/helpers] Process already running, pid:" << pid;
parser->parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++xmlCounter));
if (parser->isHomeScreen()) {
qDebug() << "[js:ozon/helpers] Fast path: already on Home screen";
return true;
}
const QList<Node> fastPincode = parser->tryToFindPinCode(pinCode, QStringLiteral("Введите ПИН"));
if (fastPincode.size() == pinCode.length() && fastPincode.size() >= 4) {
qDebug() << "[js:ozon/helpers] Fast path: PIN screen, entering PIN";
if (Node wake = parser->findTextNode(QStringLiteral("Введите ПИН")); !wake.isEmpty()) {
AdbUtils::makeTap(deviceId, wake.x(), wake.y());
}
ozonInputPinCode(deviceId, fastPincode);
QThread::msleep(3000);
for (int i = 0; i < 10; ++i) {
if (interrupted()) return false;
parser->parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++xmlCounter));
if (parser->isHomeScreen()) {
qDebug() << "[js:ozon/helpers] Fast path: Home reached after PIN";
return true;
}
if (Node refreshBtn = parser->findNodeByContentDesc(QStringLiteral("Обновить"));
refreshBtn.x() > 0 && refreshBtn.y() > 0) {
qDebug() << "[js:ozon/helpers] Fast path: page error after PIN, tapping 'Обновить'";
AdbUtils::makeTap(deviceId, refreshBtn.x(), refreshBtn.y());
QThread::msleep(5000);
}
QThread::msleep(1000);
}
}
qDebug() << "[js:ozon/helpers] Fast path failed, restarting app";
AdbUtils::tryToKillApplication(deviceId, packageName);
}
if (!AdbUtils::tryToStartApplication(deviceId, packageName)) {
AdbUtils::takeScreenshot(deviceId, "run_app_error");
qWarning() << "[js:ozon/helpers] Process has not started";
return false;
}
QThread::msleep(4500);
int counter = 0;
bool pinInjected = false;
while (true) {
if (interrupted()) return false;
if (++counter > 10) {
AdbUtils::takeScreenshot(deviceId, "too_many_attempts");
return false;
}
parser->parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++xmlCounter));
if (parser->isHomeScreen()) return true;
if (Node bannerOrDialog = parser->tryToFindBannerOrDialog(width, height);
bannerOrDialog.x() != -1 && bannerOrDialog.y() != -1) {
AdbUtils::makeTap(deviceId, bannerOrDialog.x(), bannerOrDialog.y());
QThread::msleep(1000);
continue;
}
if (Node refreshBtn = parser->findNodeByContentDesc(QStringLiteral("Обновить"));
refreshBtn.x() > 0 && refreshBtn.y() > 0) {
qDebug() << "[js:ozon/helpers] Page load error, tapping 'Обновить'";
AdbUtils::makeTap(deviceId, refreshBtn.x(), refreshBtn.y());
QThread::msleep(5000);
continue;
}
if (Node securityBanner = parser->tryToFindSecurityBanner(); !securityBanner.isEmpty()) {
AdbUtils::makeTap(deviceId, securityBanner.x(), securityBanner.y());
QThread::msleep(1000);
continue;
}
if (Node gpNode = parser->tryToFindGooglePlayBanner(width, height); !gpNode.isEmpty()) {
qDebug() << "[js:ozon/helpers] Google Play update banner detected, closing...";
AdbUtils::makeTap(deviceId, gpNode.x(), gpNode.y());
QThread::msleep(1500);
continue;
}
if (Node adNode = parser->tryToFindAdBanner(); !adNode.isEmpty()) {
qDebug() << "[js:ozon/helpers] Ad bottom sheet detected, closing...";
AdbUtils::makeTap(deviceId, adNode.x(), adNode.y());
QThread::msleep(1000);
parser->parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++xmlCounter));
if (Node stillAd = parser->tryToFindAdBanner(); !stillAd.isEmpty()) {
qDebug() << "[js:ozon/helpers] Ad still visible, pressing back...";
AdbUtils::goBack(deviceId);
QThread::msleep(1000);
}
continue;
}
if (!pinInjected) {
const QList<Node> pincode = parser->tryToFindPinCode(pinCode, QStringLiteral("Введите ПИН"));
if (pincode.size() == pinCode.length() && pincode.size() >= 4) {
if (Node wake = parser->findTextNode(QStringLiteral("Введите ПИН")); !wake.isEmpty()) {
AdbUtils::makeTap(deviceId, wake.x(), wake.y());
}
ozonInputPinCode(deviceId, pincode);
pinInjected = true;
QThread::msleep(3000);
continue;
}
}
QThread::msleep(1000);
}
}
bool OzonHelpersBridge::tryToCloseAllBannersOnHomePage(const QString &deviceId, int width, int height) {
Ozon::ScreenXmlParser *parser = m_parserBridge->parser();
int xmlCounter = 0;
bool closedAny = false;
for (int i = 0; i < 5; ++i) {
if (interrupted()) return closedAny;
bool closedThis = false;
if (Node gpNode = parser->tryToFindGooglePlayBanner(width, height); !gpNode.isEmpty()) {
qDebug() << "[js:ozon/helpers] GP banner detected, closing";
AdbUtils::makeTap(deviceId, gpNode.x(), gpNode.y());
QThread::msleep(1500);
closedThis = true;
} else if (Node adNode = parser->tryToFindAdBanner(); !adNode.isEmpty()) {
qDebug() << "[js:ozon/helpers] Ad bottom sheet detected, closing";
AdbUtils::makeTap(deviceId, adNode.x(), adNode.y());
QThread::msleep(1000);
parser->parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++xmlCounter));
if (Node stillAd = parser->tryToFindAdBanner(); !stillAd.isEmpty()) {
AdbUtils::goBack(deviceId);
QThread::msleep(1000);
}
closedAny = true;
parser->parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++xmlCounter));
continue;
} else if (Node bod = parser->tryToFindBannerOrDialog(width, height);
bod.x() != -1 && bod.y() != -1) {
AdbUtils::makeTap(deviceId, bod.x(), bod.y());
QThread::msleep(500);
closedThis = true;
}
if (!closedThis) break;
closedAny = true;
parser->parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++xmlCounter));
}
return closedAny;
}
bool OzonHelpersBridge::closeGooglePlayBannerIfVisible(const QString &deviceId, int width, int height) {
Ozon::ScreenXmlParser *parser = m_parserBridge->parser();
int xmlCounter = 0;
if (Node denyBtn = parser->tryToFindSystemPermissionDenyButton(); !denyBtn.isEmpty()) {
qDebug() << "[js:ozon/helpers] System permission dialog detected, tapping 'ЗАПРЕТИТЬ'";
AdbUtils::makeTap(deviceId, denyBtn.x(), denyBtn.y());
QThread::msleep(1500);
parser->parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++xmlCounter));
return true;
}
Node gpNode = parser->tryToFindGooglePlayBanner(width, height);
if (gpNode.isEmpty()) return false;
qDebug() << "[js:ozon/helpers] GP banner detected mid-task, closing";
AdbUtils::makeTap(deviceId, gpNode.x(), gpNode.y());
QThread::msleep(1500);
parser->parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++xmlCounter));
return true;
}
void OzonHelpersBridge::createBankProfileIfNeeded(const QString &deviceId, const QString &appCode, double parsedBalance) {
const BankProfileInfo app = BankProfileDAO::getApplication(deviceId, appCode);
if (app.fullName.trimmed().isEmpty() || app.phone.trimmed().isEmpty()) {
qWarning() << "[js:ozon/helpers] createBankProfileIfNeeded: skipping — fullName or phone is empty";
return;
}
const QMap<QString, int> currencyCodes = {
{"RUB", 643}, {"USD", 840}, {"KRW", 410}, {"AZN", 944}, {"ARS", 32}, {"TJS", 972}
};
const int currencyCode = currencyCodes.value(app.currency.toUpper(), 643);
const QStringList parts = app.fullName.split(' ', Qt::SkipEmptyParts);
const QSettings settings("config.ini", QSettings::IniFormat);
const QString phoneCode = settings.value(appCode + "/phone_code").toString();
QString phone = app.phone;
phone.remove(QRegularExpression("[^0-9]"));
if (!phoneCode.isEmpty() && !phone.startsWith(phoneCode)) {
if (phone.startsWith('8') && phoneCode != "8") {
phone.replace(0, 1, phoneCode);
} else {
phone.prepend(phoneCode);
}
}
phone.prepend('+');
const DeviceInfo device = DeviceDAO::getDeviceById(deviceId);
QJsonObject profileBody;
profileBody["first_name"] = parts.value(0);
profileBody["middle_name"] = parts.size() >= 3 ? parts.value(1) : QString("");
profileBody["last_name"] = parts.size() >= 3 ? parts.value(2) : parts.value(1);
profileBody["bank_name"] = appCode;
profileBody["phone_number"] = phone;
const double balance = (parsedBalance >= 0.0) ? parsedBalance : app.amount;
profileBody["balance"] = QString::number(qRound64(balance * 100));
profileBody["state"] = (app.status == "active") ? "enabled" : "disabled";
profileBody["device_id"] = device.apiId;
profileBody["currency"] = currencyCode;
NetworkService ns;
ns.setDeviceId(deviceId);
ns.setPayload(QJsonDocument(profileBody).toJson());
if (app.bankProfileId.isEmpty()) {
ns.addExtra("app_id", app.id);
ns.postBankProfile();
} else {
ns.addExtra("bank_profile_id", app.bankProfileId);
ns.patchBankProfile();
}
}
// Порт Ozon::CommonScript::goToAllProducts — для Ozon "Финансы" tab.
bool OzonHelpersBridge::goToAllProducts(const QString &deviceId, int width, int height) {
Ozon::ScreenXmlParser *parser = m_parserBridge->parser();
int xmlCounter = 0;
parser->parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, xmlCounter));
if (tryToCloseAllBannersOnHomePage(deviceId, width, height)) {
QThread::msleep(500);
parser->parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++xmlCounter));
}
if (parser->isHomeScreen()) return true;
for (int i = 0; i < 3; ++i) {
if (interrupted()) return false;
if (Node financeTab = parser->findNodeByContentDesc(QStringLiteral("Финансы"));
!financeTab.isEmpty()) {
AdbUtils::makeTap(deviceId, financeTab.x(), financeTab.y());
QThread::msleep(1500);
parser->parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++xmlCounter));
if (parser->isHomeScreen()) return true;
}
QThread::msleep(1000);
parser->parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++xmlCounter));
}
qDebug() << "[js:ozon/helpers] goToAllProducts: not home screen";
return false;
}
void OzonHelpersBridge::createDeviceIfNeeded(const QVariantMap &deviceObj) {
const QString apiId = deviceObj.value("apiId").toString();
if (!apiId.isEmpty()) return;
QJsonObject body;
body["desktop_id"] = SettingsDAO::get("desktop_id");
body["status"] = "ONLINE";
body["name"] = deviceObj.value("name").toString();
body["screen_width"] = deviceObj.value("screenWidth").toInt();
body["screen_height"]= deviceObj.value("screenHeight").toInt();
body["battery"] = deviceObj.value("battery").toInt();
NetworkService ns;
ns.setDeviceId(deviceObj.value("adbSerial").toString());
ns.setPayload(QJsonDocument(body).toJson());
ns.createDevice();
}
void OzonHelpersBridge::postMaterial(const QVariantMap &material, const QString &bankProfileId,
const QString &fullName) {
if (bankProfileId.isEmpty()) return;
if (material.value("cardNumber").toString().isEmpty()) return;
if (!material.value("materialId").toString().isEmpty()) return;
const QMap<QString, int> currencyCodes = {
{"RUB", 643}, {"USD", 840}, {"KRW", 410}, {"AZN", 944}, {"ARS", 32}, {"TJS", 972}
};
QJsonObject body;
body["bank_profile_id"] = bankProfileId;
body["card_number"] = material.value("cardNumber").toString();
body["account_number"] = material.value("accountNumber").toString();
body["name"] = fullName;
body["state"] = material.value("status").toString() == "active" ? "enabled" : "disabled";
body["currency"] = currencyCodes.value(material.value("currency").toString().toUpper(), 643);
NetworkService ns;
ns.setDeviceId(material.value("deviceId").toString());
ns.setPayload(QJsonDocument(body).toJson());
ns.addExtra("account_id", material.value("id").toInt());
ns.postMaterial();
}
bool OzonHelpersBridge::isAppRunning(const QString &deviceId, const QString &packageName) {
return !AdbUtils::tryToGetRunningAppPid(deviceId, packageName).isEmpty();
}
bool OzonHelpersBridge::inputPinCode(const QString &deviceId, const QVariantList &pincodeNodes) {
// Та же логика, что и Ozon::CommonScript::inputPinCode — random delay 100-900мс,
// повторный тап при первом fail.
for (const QVariant &v : pincodeNodes) {
const QVariantMap n = v.toMap();
const int x = n.value("x").toInt();
const int y = n.value("y").toInt();
if (!AdbUtils::makeTap(deviceId, x, y)) {
AdbUtils::makeTap(deviceId, x, y);
}
QThread::msleep(QRandomGenerator::global()->bounded(100, 901));
}
return true;
}
// Та же логика, что FreezeDetector::isUsefulDump в Ozon::CommonScript.
// Полезный дамп должен содержать Button с непустым text ИЛИ EditText.
bool OzonHelpersBridge::isUsefulDump(const QString &xml) {
if (xml.isEmpty()) return false;
if (!xml.contains(QStringLiteral("class=\"android.widget.Button\""))) return false;
static const QRegularExpression usefulRe(
R"(class="android\.widget\.Button"[^/]*text="[^"]+"|text="[^"]+"[^/]*class="android\.widget\.Button"|class="android\.widget\.EditText")"
);
return usefulRe.match(xml).hasMatch();
}
void OzonHelpersBridge::saveTransactionReceipt(const QString &deviceId, int transactionId) {
if (transactionId < 0) return;
const QByteArray screenshot = AdbUtils::captureScreenshotBytes(deviceId);
if (screenshot.isEmpty()) return;
if (!TransactionDAO::updateReceiptImage(transactionId, screenshot)) {
qWarning() << "[js:ozon/helpers] updateReceiptImage failed for txId" << transactionId;
}
}
void OzonHelpersBridge::postNewTransaction(const QVariantMap &accountMap, const QVariantMap &txMap) {
// Зеркало Ozon::CommonScript::postNewTransactionData. Шлёт через
// NetworkService::insertTransactionData в worker-потоке.
auto *thread = new QThread;
auto *worker = new NetworkService;
QJsonObject obj;
obj["id"] = txMap.value("id").toInt();
obj["amount"] = txMap.value("amount").toDouble();
obj["name"] = txMap.value("name").toString();
obj["fee"] = txMap.value("fee").toDouble();
obj["status"] = txMap.value("status").toString();
obj["type"] = txMap.value("type").toString();
obj["phone"] = txMap.value("phone").toString();
obj["description"] = txMap.value("description").toString();
obj["updatedDescription"] = txMap.value("updatedDescription").toString();
obj["bankName"] = txMap.value("bankName").toString();
obj["bankTime"] = txMap.value("bankTime").toString();
obj["bankTransactionId"] = txMap.value("bankTrExternalId").toString();
worker->setDeviceId(accountMap.value("deviceId").toString());
worker->addExtra("lastNumbers", accountMap.value("lastNumbers").toString());
worker->addExtra("transactionId", txMap.value("id").toInt());
worker->setPayload(QJsonDocument(obj).toJson());
worker->moveToThread(thread);
QObject::connect(thread, &QThread::started, worker, &NetworkService::insertTransactionData);
QObject::connect(worker, &NetworkService::finished, thread, &QThread::quit);
QObject::connect(worker, &NetworkService::finished, worker, &QObject::deleteLater);
QObject::connect(thread, &QThread::finished, thread, &QThread::deleteLater);
thread->start();
}
void OzonHelpersBridge::postAccountsData(const QVariantList &accounts) {
if (accounts.isEmpty()) return;
auto *thread = new QThread;
auto *worker = new NetworkService;
QJsonArray list;
QString deviceId;
for (const QVariant &v : accounts) {
const QVariantMap acc = v.toMap();
QJsonObject obj;
obj["appName"] = acc.value("appCode").toString();
obj["lastNumbers"] = acc.value("lastNumbers").toString();
obj["amount"] = acc.value("amount").toDouble();
obj["description"] = acc.value("description").toString();
obj["currency"] = acc.value("currency").toString();
obj["status"] = acc.value("status").toString();
list.append(obj);
if (deviceId.isEmpty()) deviceId = acc.value("deviceId").toString();
}
worker->setDeviceId(deviceId);
worker->setPayload(QJsonDocument(list).toJson());
worker->moveToThread(thread);
QObject::connect(thread, &QThread::started, worker, &NetworkService::postAccountsData);
QObject::connect(worker, &NetworkService::finished, thread, &QThread::quit);
QObject::connect(worker, &NetworkService::finished, worker, &QObject::deleteLater);
QObject::connect(thread, &QThread::finished, thread, &QThread::deleteLater);
thread->start();
}
} // namespace Scripting } // namespace Scripting

View File

@ -10,6 +10,7 @@
class QJSEngine; class QJSEngine;
namespace Black { class ScreenXmlParser; } namespace Black { class ScreenXmlParser; }
namespace Ozon { class ScreenXmlParser; }
namespace Scripting { namespace Scripting {
@ -83,6 +84,23 @@ public slots:
// MaterialDAO // MaterialDAO
QVariantList materialGetAccounts(const QString &deviceId, const QString &appCode); QVariantList materialGetAccounts(const QString &deviceId, const QString &appCode);
QVariantMap materialFindAppAccount(const QString &deviceId, const QString &appCode,
const QString &lastNumbers);
bool materialUpsert(const QVariantMap &material);
bool materialUpdateCardNumber(int accountId, const QString &cardNumber);
bool materialUpdateAccountById(int id, const QString &status,
const QString &description, const QString &currency);
// SettingsDAO
QString settingsGet(const QString &key);
// TransactionDAO — для платёжных скриптов и сборщика транзакций.
// tx map: {accountId, amount, fee, name, phone, bankName, type, status,
// timestamp (ISO UTC string), completeTime (ISO local string),
// bankTime, description, updatedDescription, bankTrExternalId}
// Возвращает id вставленной транзакции (-1 при ошибке).
int transactionInsert(const QVariantMap &tx);
bool transactionUpdateBankTrExternalId(int id, const QString &externalId);
}; };
// Скоуп дампа задачи. JS-style API: begin/end вместо RAII (вешаем на try/finally). // Скоуп дампа задачи. JS-style API: begin/end вместо RAII (вешаем на try/finally).
@ -100,6 +118,35 @@ private:
QString *m_errorPtr = nullptr; QString *m_errorPtr = nullptr;
}; };
// Прокси к C++ скрипту-родителю: позволяет JS эмитить Qt-сигналы на исходном
// скрипте (например, transactionParsed → EventHandler). Если signalSink не
// передан в ScriptRuntime::run — методы no-op (но не throw).
class ScriptObjectBridge final : public QObject {
Q_OBJECT
public:
explicit ScriptObjectBridge(QObject *sink = nullptr, QObject *parent = nullptr)
: QObject(parent), m_sink(sink) {}
public slots:
// Парсит jsonStr и вызывает на m_sink сигнал/слот `transactionParsed(QJsonObject)`.
// Если sink нет или JSON битый — лог warning, продолжение работы.
void emitTransactionParsed(const QString &jsonStr);
private:
QObject *m_sink;
};
// OCR-хелперы: распознавание текста на скриншоте и извлечение ID транзакции.
// Реализация в android/ocr/OcrUtils — здесь чистый wrapper для JS.
class OcrBridge final : public QObject {
Q_OBJECT
public:
explicit OcrBridge(QObject *parent = nullptr) : QObject(parent) {}
public slots:
// Захватывает экран и прогоняет через OCR (grayscale + 300dpi, rus+eng).
QString recognizeFromScreen(const QString &deviceId);
// Извлекает "ID операции ..." / "№ Ф-..." из распознанного OCR-текста.
QString extractTransactionId(const QString &ocrText);
};
// Парсер экрана для банка Black. Каждый запуск скрипта получает свой инстанс, // Парсер экрана для банка Black. Каждый запуск скрипта получает свой инстанс,
// поэтому m_xml/m_nodes не разделяются между задачами. // поэтому m_xml/m_nodes не разделяются между задачами.
class BlackParserBridge final : public QObject { class BlackParserBridge final : public QObject {
@ -167,6 +214,108 @@ private:
BlackParserBridge *m_parserBridge; BlackParserBridge *m_parserBridge;
}; };
// Парсер экрана для банка Ozon. Минимальный набор для get_profile_info — при
// миграции login_and_check / get_card_info добавлять методы сюда же.
class OzonParserBridge final : public QObject {
Q_OBJECT
public:
explicit OzonParserBridge(QObject *parent = nullptr);
~OzonParserBridge() override;
Ozon::ScreenXmlParser *parser() const { return m_parser; }
public slots:
void parseAndSaveXml(const QString &xml);
bool isHomeScreen();
bool isProfileScreen();
QVariantMap tryToFindAdBanner();
QVariantMap tryToFindGooglePlayBanner(int screenWidth, int screenHeight);
QVariantMap findUserNameOnHomeScreen(int screenHeight);
QVariantMap findCardButtonByLastNumbers(const QString &lastNumbers);
QVariantMap findButtonNode(const QString &text, bool contains);
QVariantMap findTextNode(const QString &text);
QVariantMap findFirstEditText();
QVariantMap findEditTextByHint(const QString &hintPrefix);
QVariantMap findPanCardNumberInput();
QVariantMap findMainAccountButton();
QVariantMap findNodeByContentDesc(const QString &contentDesc);
// Все Node'ы текущего дампа как массив map — нужно для JS-итерации в
// payment-скриптах (поиск EditText'ов с input___ rid, других банков по
// content-desc="other-bank-item" и т.д.).
QVariantList findAllNodes();
QVariantList findAllButtons();
// pinCode + title — возвращает массив node-маn в порядке цифр для тапа.
QVariantList tryToFindPinCode(const QString &pinCode, const QString &title);
// Карточки с главного экрана: lastNumbers, amount, description, currency.
// id == -1, materialId пусто — это стабы (используются для парсинга баланса).
QVariantList parseCardsOnHomeScreen();
// Аккаунты на экране "Все продукты" — массив {material: {...}, node: {...}}.
QVariantList parseAccountsInfo();
// Для GetLastTransactionsScript:
bool isTransactionListScreen();
bool hasCollapsedTransactionsBelow(int screenHeight);
QVariantList findTransactionButtons(int maxCount);
QVariantList getLast2DaysTransactions();
QVariantList parseTodayAndYesterdayHistory();
QVariantList parseTodayAndYesterdayReportHistory();
QVariantMap parseTransactionInfo(int screenWidth, int screenHeight);
QVariantMap parseTransactionInfoHistory(int screenWidth, int screenHeight);
QVariantMap parseTransactionDetail();
QVariantMap parseTransactionButtonText(const QString &text);
QString parseProfileFullName();
QString parseProfilePhone();
QString findFullCardNumber();
private:
Ozon::ScreenXmlParser *m_parser;
};
// Высокоуровневые операции, повторяющие Ozon::CommonScript: фактический
// запуск приложения, закрытие баннеров, регистрация bank_profile на сервере,
// постинг аккаунтов. Используют parser() из OzonParserBridge — состояние XML
// общее, как и у Black.
class OzonHelpersBridge final : public QObject {
Q_OBJECT
public:
OzonHelpersBridge(OzonParserBridge *parser, QObject *parent = nullptr)
: QObject(parent), m_parserBridge(parser) {}
public slots:
bool runAppAndGoToHomeScreen(const QString &deviceId, const QString &packageName,
const QString &pinCode, int width, int height);
bool tryToCloseAllBannersOnHomePage(const QString &deviceId, int width, int height);
bool closeGooglePlayBannerIfVisible(const QString &deviceId, int width, int height);
bool goToAllProducts(const QString &deviceId, int width, int height);
void createBankProfileIfNeeded(const QString &deviceId, const QString &appCode, double parsedBalance);
void postAccountsData(const QVariantList &accountsWithEmptyMaterialId);
// POST /api/v1/device — создаёт устройство на сервере, если apiId пуст.
// deviceObj: { id, name, screenWidth, screenHeight, battery, apiId } (как из dao.deviceGetById).
void createDeviceIfNeeded(const QVariantMap &deviceObj);
// POST /api/v1/profile/material — отправляет одну карту на сервер.
// material: полный MaterialInfo-объект как из dao.materialFindAppAccount.
void postMaterial(const QVariantMap &material, const QString &bankProfileId, const QString &fullName);
// Для платёжных скриптов:
bool isAppRunning(const QString &deviceId, const QString &packageName);
// pincodeNodes: список node-map из parser.tryToFindPinCode.
bool inputPinCode(const QString &deviceId, const QVariantList &pincodeNodes);
// FreezeDetector::isUsefulDump — статически-чистая проверка.
bool isUsefulDump(const QString &xml);
// Захват скриншота + сохранение в TransactionDAO как receipt-image.
void saveTransactionReceipt(const QString &deviceId, int transactionId);
// POST /api/v1/profile/transaction — вставка новой транзакции (для GetLastTx).
// tx: как в dao.transactionInsert + account: {appCode, deviceId, lastNumbers}.
void postNewTransaction(const QVariantMap &account, const QVariantMap &tx);
private:
OzonParserBridge *m_parserBridge;
};
// Утилиты конверсии Node ↔ QVariantMap. Объявлены здесь чтобы парсер-бридж // Утилиты конверсии Node ↔ QVariantMap. Объявлены здесь чтобы парсер-бридж
// мог их использовать без зависимости от QJSEngine. // мог их использовать без зависимости от QJSEngine.
QVariantMap nodeToVariantMap(const Node &node); QVariantMap nodeToVariantMap(const Node &node);

View File

@ -40,7 +40,8 @@ QString locationOf(const QJSValue &err) {
ScriptRuntime::Result ScriptRuntime::run(const QString &bank, ScriptRuntime::Result ScriptRuntime::run(const QString &bank,
const QString &scriptName, const QString &scriptName,
const QVariantMap &params, const QVariantMap &params,
QString *outResult) { QString *outResult,
QObject *signalSink) {
QString sink; QString sink;
if (!outResult) outResult = &sink; if (!outResult) outResult = &sink;
*outResult = QString(); *outResult = QString();
@ -78,14 +79,21 @@ ScriptRuntime::Result ScriptRuntime::run(const QString &bank,
auto *adb = new AdbBridge(); auto *adb = new AdbBridge();
auto *dao = new DaoBridge(); auto *dao = new DaoBridge();
auto *dump = new DumpBridge(); auto *dump = new DumpBridge();
auto *ocr = new OcrBridge();
auto *scriptObj = new ScriptObjectBridge(signalSink);
// Bank-specific bridges. На PoC поддержан только Black. // Bank-specific bridges. Поддержаны: Black (полный набор), Ozon (минимум
// для get_profile_info — расширяется по мере миграции login_and_check / get_card_info).
QObject *parserBridge = nullptr; QObject *parserBridge = nullptr;
QObject *helpersBridge = nullptr; QObject *helpersBridge = nullptr;
if (bank == QLatin1String("black")) { if (bank == QLatin1String("black")) {
auto *pb = new BlackParserBridge(); auto *pb = new BlackParserBridge();
parserBridge = pb; parserBridge = pb;
helpersBridge = new BlackHelpersBridge(pb); helpersBridge = new BlackHelpersBridge(pb);
} else if (bank == QLatin1String("ozon")) {
auto *pb = new OzonParserBridge();
parserBridge = pb;
helpersBridge = new OzonHelpersBridge(pb);
} }
QJSValue host = engine.newObject(); QJSValue host = engine.newObject();
@ -94,6 +102,8 @@ ScriptRuntime::Result ScriptRuntime::run(const QString &bank,
host.setProperty("adb", engine.newQObject(adb)); host.setProperty("adb", engine.newQObject(adb));
host.setProperty("dao", engine.newQObject(dao)); host.setProperty("dao", engine.newQObject(dao));
host.setProperty("dump", engine.newQObject(dump)); host.setProperty("dump", engine.newQObject(dump));
host.setProperty("ocr", engine.newQObject(ocr));
host.setProperty("script", engine.newQObject(scriptObj));
if (parserBridge) host.setProperty("parser", engine.newQObject(parserBridge)); if (parserBridge) host.setProperty("parser", engine.newQObject(parserBridge));
if (helpersBridge) host.setProperty("helpers", engine.newQObject(helpersBridge)); if (helpersBridge) host.setProperty("helpers", engine.newQObject(helpersBridge));

View File

@ -3,6 +3,8 @@
#include <QString> #include <QString>
#include <QVariantMap> #include <QVariantMap>
class QObject;
namespace Scripting { namespace Scripting {
class ScriptRuntime { class ScriptRuntime {
@ -25,10 +27,17 @@ public:
// EventHandler'а — там разрешён блокирующий sleep и DAO). // EventHandler'а — там разрешён блокирующий sleep и DAO).
// //
// params видны JS как host.params.<key>. // params видны JS как host.params.<key>.
//
// signalSink — опциональный QObject, на котором JS может эмитить сигналы
// через host.script.emit<SignalName>(jsonStr). Используется для скриптов
// вроде GetLastTransactionsScript, которые транслируют распарсенные
// транзакции в EventHandler через Qt-сигнал. Передавать `this` из C++
// оригинального скрипта.
static Result run(const QString &bank, static Result run(const QString &bank,
const QString &scriptName, const QString &scriptName,
const QVariantMap &params, const QVariantMap &params,
QString *outResult); QString *outResult,
QObject *signalSink = nullptr);
}; };
} // namespace Scripting } // namespace Scripting