1
0
forked from BRT/arc

Compare commits

...

2 Commits

Author SHA1 Message Date
slava
f8c5a6656f Port Ozon card and payment scripts to JavaScript, mirroring C++ logic for card info fetching and phone-based payments. 2026-05-20 14:52:08 +05:00
slava
cae345cf61 Implement JS-based remote scripting framework for dynamic script updates:
- Add Proof of Concept (PoC) for executing JavaScript scripts fetched remotely.
- Integrate `Black::GetProfileInfoScript` logic into a JS version with fallback to C++ on errors.
- Extend `ScreenXmlParser` with additional JS-to-C++ bridge methods.
- Update `config.ini` for scripting configuration.
- Ensure scripts are securely fetched with SHA-256 validation and use an atomic caching mechanism.
- Add developer tools for debugging JS scripts and enable easier script updates without app re-deployment.
2026-05-13 15:42:10 +07:00
31 changed files with 5076 additions and 0 deletions

View File

@ -58,6 +58,7 @@ find_package(Qt6 REQUIRED COMPONENTS
Widgets Widgets
Sql Sql
Xml Xml
Qml
) )
file(GLOB_RECURSE DB_SOURCES CONFIGURE_DEPENDS file(GLOB_RECURSE DB_SOURCES CONFIGURE_DEPENDS
@ -156,6 +157,7 @@ if (WIN32)
Qt6::Xml Qt6::Xml
Qt6::Sql Qt6::Sql
Qt6::Network Qt6::Network
Qt6::Qml
Tesseract::libtesseract Tesseract::libtesseract
) )
@ -171,6 +173,10 @@ if (WIN32)
file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/config.ini" DESTINATION "${CMAKE_BINARY_DIR}") file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/config.ini" DESTINATION "${CMAKE_BINARY_DIR}")
file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/no_devices.png" DESTINATION "${CMAKE_BINARY_DIR}") file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/no_devices.png" DESTINATION "${CMAKE_BINARY_DIR}")
file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/assets/ADBKeyboard.apk" DESTINATION "${CMAKE_BINARY_DIR}/assets") file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/assets/ADBKeyboard.apk" DESTINATION "${CMAKE_BINARY_DIR}/assets")
# Bundled JS scripts fallback на случай, когда удалённый manifest недоступен
if (EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/scripts")
file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/scripts" DESTINATION "${CMAKE_BINARY_DIR}")
endif ()
# adb копируется в post-build чтобы не падать при configure если adb.exe занят # adb копируется в post-build чтобы не падать при configure если adb.exe занят
file(GLOB _adb_files "${CMAKE_CURRENT_SOURCE_DIR}/tools/adb/windows/*") file(GLOB _adb_files "${CMAKE_CURRENT_SOURCE_DIR}/tools/adb/windows/*")
foreach(_adb_file ${_adb_files}) foreach(_adb_file ${_adb_files})
@ -228,6 +234,14 @@ if (WIN32)
"$<TARGET_FILE_DIR:ARC>/assets/ADBKeyboard.apk" "$<TARGET_FILE_DIR:ARC>/assets/ADBKeyboard.apk"
) )
if (EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/scripts")
add_custom_command(TARGET ARC POST_BUILD
COMMAND ${CMAKE_COMMAND} -E copy_directory
"${CMAKE_CURRENT_SOURCE_DIR}/scripts"
"$<TARGET_FILE_DIR:ARC>/scripts"
)
endif ()
# Copy OpenSSL DLLs required for HTTPS (Qt SSL backend) # Copy OpenSSL DLLs required for HTTPS (Qt SSL backend)
file(GLOB _openssl_dlls "${VCPKG_BIN_DIR}/libssl*.dll" file(GLOB _openssl_dlls "${VCPKG_BIN_DIR}/libssl*.dll"
"${VCPKG_BIN_DIR}/libcrypto*.dll") "${VCPKG_BIN_DIR}/libcrypto*.dll")
@ -276,6 +290,7 @@ elseif (APPLE)
Qt6::Sql Qt6::Sql
Qt6::Xml Qt6::Xml
Qt6::Network Qt6::Network
Qt6::Qml
tesseract tesseract
leptonica leptonica
) )
@ -291,6 +306,9 @@ elseif (APPLE)
file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/config.ini" DESTINATION "${CMAKE_BINARY_DIR}") file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/config.ini" DESTINATION "${CMAKE_BINARY_DIR}")
file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/no_devices.png" DESTINATION "${CMAKE_BINARY_DIR}") file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/no_devices.png" DESTINATION "${CMAKE_BINARY_DIR}")
file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/assets/ADBKeyboard.apk" DESTINATION "${CMAKE_BINARY_DIR}/assets") file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/assets/ADBKeyboard.apk" DESTINATION "${CMAKE_BINARY_DIR}/assets")
if (EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/scripts")
file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/scripts" DESTINATION "${CMAKE_BINARY_DIR}")
endif ()
endif () endif ()
target_include_directories(ARC PRIVATE target_include_directories(ARC PRIVATE

View File

@ -3,6 +3,7 @@
#include <QJsonDocument> #include <QJsonDocument>
#include <QJsonObject> #include <QJsonObject>
#include <QThread> #include <QThread>
#include <QVariantMap>
#include "adb/AdbUtils.h" #include "adb/AdbUtils.h"
#include "dao/MaterialDAO.h" #include "dao/MaterialDAO.h"
@ -10,6 +11,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"
namespace Black { namespace Black {
@ -25,6 +28,26 @@ GetProfileInfoScript::GetProfileInfoScript(
GetProfileInfoScript::~GetProfileInfoScript() = default; GetProfileInfoScript::~GetProfileInfoScript() = default;
void GetProfileInfoScript::doStart() { void GetProfileInfoScript::doStart() {
// PoC удалённых JS-скриптов: пробуем JS-версию, при engine-ошибке делаем
// прозрачный fallback на C++ ниже. См. docs/remote_scripts.md.
if (Scripting::ScriptingConfig::isEnabled()
&& Scripting::ScriptingConfig::isJsEnabledFor("black/get_profile_info")) {
QString jsOut;
const QVariantMap params{
{"deviceId", m_deviceId},
{"appCode", m_appCode},
};
const auto status = Scripting::ScriptRuntime::run(
"black", "get_profile_info", params, &jsOut);
if (status == Scripting::ScriptRuntime::Result::Completed) {
m_error = jsOut;
emit finishedWithResult(m_error);
return;
}
qWarning() << "[Black::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

@ -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

@ -41,3 +41,17 @@ currency=TJS
country=tajikistan country=tajikistan
name=Dushanbe City name=Dushanbe City
phone_code=992 phone_code=992
[scripting]
# Глобальный выключатель JS-скриптов. При false весь scripting-слой выключен.
enabled=false
# Список <bank>/<script>, для которых пробуем JS-версию (через запятую).
# При engine-ошибке runtime прозрачно откатывается на C++.
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-манифеста удалённых обновлений. Пустой → без удалённых обновлений,
# работает только bundled+cache (см. docs/remote_scripts.md).
manifest_url=
# Каталог локального кэша. Если пусто — берётся <AppDataLocation>/scripts/.
cache_dir=
# Каталог bundled-скриптов. Если пусто — рядом с бинарником: ./scripts/
bundled_dir=

View File

@ -11,6 +11,7 @@
| Раздел | Описание | | Раздел | Описание |
|--------|----------| |--------|----------|
| [События и задачи](events.md) | Очередь задач: получение из API, хранение в БД, выполнение скриптов | | [События и задачи](events.md) | Очередь задач: получение из API, хранение в БД, выполнение скриптов |
| [Удалённые JS-скрипты](remote_scripts.md) | Scripting-слой: обновляемые JS-версии банковских скриптов через `QJSEngine` |
### API Backend (`Adb Core Backend v1.0.0`) ### API Backend (`Adb Core Backend v1.0.0`)

480
docs/remote_scripts.md Normal file
View File

@ -0,0 +1,480 @@
# Удалённые JS-скрипты задач
Документ описывает scripting-слой, который позволяет обновлять логику банковских
скриптов **без пересборки и переустановки приложения** — JS-файлы тянутся с сервера
и исполняются внутри `QJSEngine`.
> **Статус.** Фреймворк и удалённое обновление полностью реализованы. На JS
> перенесены: `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++, мигрируются.
---
## 1. Зачем это нужно
Банковские UI меняются часто: переезжают кнопки, появляются новые экраны
подтверждения, шрифты ломают OCR. Раньше любая такая правка требовала:
1. Обновить C++-код скрипта.
2. Пересобрать релиз под Windows/macOS.
3. Раскатить новый exe на все десктопы.
При живом потоке задач каждый раз это «окно простоя» в несколько часов. Удалённые
JS-скрипты позволяют деплоить правку парсинга/последовательности тапов за минуты,
без релизного цикла.
C++-скрипты при этом **не убираются**: при любой ошибке загрузки/исполнения JS
runtime прозрачно откатывается на текущую C++-реализацию. Это страхует от
ситуации «сервер отдал битый JS — все устройства встали».
---
## 2. Архитектура
```
┌──────────────────────────────────────────────────────────────────┐
│ config.ini [scripting] │
│ enabled, use_js_for, manifest_url, cache_dir, bundled_dir │
└──────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────┐
│ main.cpp → startScriptUpdater() │
│ └─ Scripting::ScriptUpdater │
│ └─ GET manifest_url → JSON {scripts:[{bank,script, │
│ version,url,sha256}]} │
│ └─ для каждой записи: GET url → SHA-256 check → │
│ ScriptCache::installFromBytes │
└──────────────────────────────────────────────────────────────────┘
▼ (атомарно через QSaveFile)
<cache_dir>/<bank>/<script>.js + <script>.version
┌──────────────────────────────────────────────────────────────────┐
│ Worker-поток EventHandler'а
│ Black::GetProfileInfoScript::doStart() │
│ ├─ ScriptingConfig::isJsEnabledFor("black/get_profile_info")│
│ ├─ ScriptRuntime::run("black","get_profile_info",params) │
│ │ ┌──────────────────────────────────────────────┐ │
│ │ │ QJSEngine │ │
│ │ │ host = {log, timer, adb, dao, dump, │ │
│ │ │ parser (BlackParserBridge), │ │
│ │ │ helpers (BlackHelpersBridge), │ │
│ │ │ params} │ │
│ │ │ eval(<bank>/<script>.js) run(host)
│ │ └──────────────────────────────────────────────┘ │
│ ├─ Completed → emit finishedWithResult(jsResult) │
│ └─ EngineError → лог + fallback на C++-ветку │
└──────────────────────────────────────────────────────────────────┘
```
**Ключевые свойства:**
- `QJSEngine` пересоздаётся на каждый запуск — состояние от прошлой задачи не
утечёт в следующую.
- JS исполняется в **том же** worker-потоке, что и C++-скрипт, поэтому
`host.timer.sleep()` блокирующий, `host.dao.*` напрямую читают SQLite,
всё остальное работает как раньше.
- `host.timer.sleep()` дробит большой интервал на 100-мс куски и проверяет
`QThread::isInterruptionRequested()` — watchdog EventHandler'а (5 мин)
по-прежнему может остановить зависший JS-поток.
- Резолв скрипта: сначала `cache_dir/<bank>/<script>.js`, потом
`bundled_dir/<bank>/<script>.js` (рядом с exe). Если ничего нет — `Disabled`,
fallback на C++.
---
## 3. Конфигурация (`config.ini`)
```ini
[scripting]
enabled=false
use_js_for=black/get_profile_info
manifest_url=
cache_dir=
bundled_dir=
```
| Ключ | Тип | Default | Назначение |
|---------------|--------|---------|--------------------------------------------------------------------|
| `enabled` | bool | `false` | Глобальный выключатель. При `false` весь слой бездействует. |
| `use_js_for` | csv | пусто | Список `<bank>/<script>`, для которых пробуем JS-версию. |
| `manifest_url`| string | пусто | URL JSON-манифеста. Пусто → удалённого обновления нет. |
| `cache_dir` | path | пусто | Кастомный каталог кэша. Пусто → `<AppDataLocation>/scripts/`. |
| `bundled_dir` | path | пусто | Каталог bundled-скриптов. Пусто → `<applicationDirPath>/scripts/`. |
**Включение JS для PoC-скрипта:**
```ini
[scripting]
enabled=true
use_js_for=black/get_profile_info
manifest_url=https://your-host/api/v1/scripts/manifest
```
При `enabled=false` (текущий default) приложение ведёт себя ровно как до PoC — JS
никогда не запускается, никакой нагрузки.
---
## 4. Формат манифеста
`GET <manifest_url>` должен возвращать `application/json`:
```json
{
"scripts": [
{
"bank": "black",
"script": "get_profile_info",
"version": "1.2.3",
"url": "https://cdn.example.com/scripts/black/get_profile_info-1.2.3.js",
"sha256": "9af15e8c1b1d4a2a3c9f7c5b...64-hex-chars..."
}
]
}
```
Поля:
| Поле | Обязательно | Назначение |
|-----------|-------------|---------------------------------------------------------------------|
| `bank` | да | Идентификатор банка (например `black`, `ozon`). |
| `script` | да | Имя скрипта без расширения (`get_profile_info`). |
| `version` | да | Произвольная строка. Сохраняется рядом со скриптом как `*.version`. |
| `url` | да | Прямая ссылка на `.js`-файл. HTTPS обязателен. |
| `sha256` | да | SHA-256 содержимого `.js` в lowercase hex. |
**Алгоритм апдейтера:**
1. `GET manifest_url`. Сетевые ошибки → лог `[ScriptUpdater] manifest fetch failed`, скрипт остаётся прежним.
2. Для каждой записи: если `ScriptCache::installedVersion(bank, script) == version` — пропускаем.
3. Иначе `GET url`, считаем SHA-256, сравниваем.
4. При совпадении — `QSaveFile` (атомарно: запись в `.tmp``commit`/rename). При несовпадении — лог и пропуск.
5. Старый `.js` остаётся до успешного апдейта. **На устройстве всегда либо валидный новый, либо валидный старый.**
Версии используются как ключ кэша, поэтому **версию обязательно нужно увеличивать** при каждой публикации — иначе обновление пропустится.
---
## 5. JS-API (что доступно из скрипта)
Каждый запуск получает глобальный объект `host`. Полный набор:
### `host.log`
```javascript
host.log.debug(msg)
host.log.info(msg)
host.log.warn(msg)
host.log.error(msg)
```
Уходит в `qDebug/qInfo/qWarning/qCritical` с тегом `[js:<bank>/<script>]`.
### `host.timer`
```javascript
host.timer.sleep(ms) // блокирующий sleep с проверкой interrupt каждые 100мс
host.timer.isInterrupted() // true, если watchdog запросил прерывание потока
```
### `host.adb` — обёртка над `AdbUtils`
```javascript
host.adb.getScreenDumpAsXml(deviceId, idx) → string
host.adb.getRawScreenDumpAsXml(deviceId) → string
host.adb.makeTap(deviceId, x, y) → bool
host.adb.makeSwipe(deviceId, x1, y1, x2, y2) → bool
host.adb.makeSwipeWithDuration(deviceId, x1, y1, x2, y2, ms) → bool
host.adb.tryToStartApplication(deviceId, package) → bool
host.adb.tryToKillApplication(deviceId, package) → bool
host.adb.tryToGetRunningAppPid(deviceId, package) → string
host.adb.takeScreenshot(deviceId, name) → bool
host.adb.inputText(deviceId, text) → bool
host.adb.inputAdbIMEText(deviceId, text) → bool
host.adb.enableAdbIMEKeyboard(deviceId) → bool
host.adb.disableAdbIMEKeyboard(deviceId) → bool
host.adb.hideKeyboard(deviceId) → bool
host.adb.goBack(deviceId) → bool
host.adb.unlockScreen(deviceId, w, h) → void
host.adb.getDeviceTimezone(deviceId) → string
host.adb.captureScreenshotBase64(deviceId) → string // PNG → base64
```
### `host.dao` — чтение/запись в SQLite
```javascript
host.dao.deviceGetById(id) → DeviceObj | {}
host.dao.deviceFindByAdbSerial(serial) → DeviceObj | {}
host.dao.bankProfileGet(deviceId, appCode) → ProfileObj | {}
host.dao.bankProfileUpdateAmount(id, amount) → bool
host.dao.bankProfileUpdateProfileInfo(id, fullName, phone, email) → bool
host.dao.bankProfileUpdateComment(id, comment) → bool
host.dao.materialGetAccounts(deviceId, appCode) → Array<MaterialObj>
```
`DeviceObj`: `{ id, adbSerial, name, screenWidth, screenHeight, apiId }`.
Если устройство не найдено — пустой объект `{}` (без поля `id`).
`ProfileObj`: `{ id, appCode, name, package, pinCode, status, amount, fullName, phone, email, currency, bankProfileId }`.
`MaterialObj`: `{ id, materialId, appCode, deviceId, lastNumbers, amount, currency, description, status }`.
### `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) живёт ровно
один прогон скрипта.
```javascript
host.parser.parseAndSaveXml(xml)
host.parser.isHomeScreen() → bool
host.parser.isSideMenu() → bool
host.parser.isPinCodeScreen() → bool
host.parser.isProfilePage() → bool
// ... все is*Screen() методы
host.parser.findHolaButton() → NodeObj
host.parser.findNodeByContentDesc(text) → NodeObj
host.parser.findNodeByContentDescContains(text) → NodeObj
host.parser.findButtonNode(text, contains) → NodeObj
host.parser.findFirstEditText() → NodeObj
host.parser.findPinCodeEditText() → NodeObj
host.parser.findEditTextByHint(hint) → NodeObj
host.parser.parseUserNameFromHomeScreen() → string
host.parser.parseBalanceFromHomeScreen() → number
host.parser.parseProfileNombre()/Apellido/Email/Phone → string
host.parser.parseCVUNumber() → string
// ... остальные методы Black::ScreenXmlParser
```
`NodeObj`: `{ x, y, x1, y1, x2, y2, width, height, text, contentDesc, resourceId, className, hint, clickable, focusable, password, isEmpty }`.
`x`/`y` — центр элемента (как в C++ `Node::x()` / `Node::y()`).
### `host.helpers` (только для `black`)
Высокоуровневые операции, повторяющие `Black::CommonScript`:
```javascript
host.helpers.runAppAndGoToHomeScreen(deviceId, package, pin, w, h) → bool
host.helpers.postAccountsData(arrayOfMaterialObj) → void
host.helpers.createBankProfileIfNeeded(deviceId, appCode, balance) → void
```
Парсер у `helpers` и `host.parser` **один и тот же** — состояние XML
синхронизировано.
### `host.dump` — скоуп `TaskDumpRecorder`
```javascript
host.dump.beginScope(bank, taskKind, deviceId, metaObj)
host.dump.endScope(errorMessage) // "" = success
```
`metaObj`: `{ materialId, amount, phone, card }` (все поля опциональны).
Если `endScope` не вызвать, деструктор `DumpBridge` закроет скоуп без ошибки.
### `host.params`
То, что передал C++-вызыватель. Для PoC:
```javascript
host.params.deviceId
host.params.appCode
```
---
## 6. Контракт `run(host)`
JS-файл **обязан** определить функцию `run(host)` в глобальной области:
```javascript
function run(host) {
// ...
return ""; // success
// return "human-readable error"; // бизнес-ошибка
// throw new Error("..."); // engine error → fallback на C++
}
```
Возвращаемое значение трактуется так:
| Возврат | Result | Что увидит EventHandler |
|--------------------------------------|---------------|--------------------------------------------------|
| `""`, `null`, `undefined` | `Completed` | `m_error` пустой → success |
| любая непустая строка | `Completed` | `m_error = "<строка>"` → ошибка задачи |
| `throw` | `EngineError` | Лог + fallback на C++ doStart() |
| eval/parse не прошёл, нет `run(host)`| `EngineError` | То же |
**Главное различие:** возврат строки — это «скрипт отработал, но обнаружил
бизнес-проблему» (например, не нашёл нужный экран). Это **не** триггерит
fallback. Throw — это «скрипт сломался» и **триггерит** fallback.
---
## 7. Версионирование, кэш и обновление
- Источник истины: `<cache_dir>/<bank>/<script>.js`. Рядом лежит `<script>.version`
с произвольной строкой версии.
- При старте приложения `ScriptUpdater::start()` сравнивает версии с манифестом
и докачивает только то, что устарело.
- `cache_dir` по умолчанию:
- macOS: `~/Library/Application Support/<orgName>/ARC/scripts/`
- Windows: `%APPDATA%/<orgName>/ARC/scripts/`
- `bundled_dir` — каталог `scripts/` рядом с exe, кладётся CMake'ом из
`<source>/scripts/`. Это **запасной парашют**: если кэш пуст или повреждён —
работает bundled.
- Атомарность установки гарантируется `QSaveFile` (запись в `.tmp`
`commit` = rename).
**Сценарии:**
| Состояние | Что произойдёт |
|----------------------------------------|-------------------------------------------------------------|
| Кэш пуст, bundled есть | Используется bundled |
| Кэш заполнен, обновлений нет | Используется кэш |
| Манифест недоступен | Лог warning, используется то, что было |
| SHA-256 не сошлась | Лог warning, файл **не** перезаписывается |
| Обновление прошло | Новый `.js` + новый `.version`. Следующий запуск увидит его |
---
## 8. Безопасность
- **SHA-256 обязателен** — без него `ScriptUpdater` отклоняет запись манифеста.
- Хеш проверяется ПОСЛЕ скачивания, ПЕРЕД заменой кэша. При несовпадении старый
скрипт остаётся.
- Транспорт — HTTPS (HTTP/CDN тоже сработает, но не используйте в проде).
- **Из коробки нет подписи ключом сервера.** Если manifest_url подменят
(DNS hijack, MITM при кривом TLS), злоумышленник может подсунуть свой JS с
его же sha256. Если такая модель угроз релевантна, нужно добавить Ed25519
подпись манифеста (см. раздел «Дальнейшие шаги»).
- JS работает в `QJSEngine` без доступа к файловой системе/сети напрямую — только
через bridge-объекты. То есть фактически он может только делать то, что уже
умеет делать C++ Black-скрипт. Это **не** sandbox для враждебного кода, но
ограничение поверхности.
---
## 9. Отладка и поведение при ошибке
### Локальная разработка
1. Поставить `enabled=true`, `use_js_for=black/get_profile_info` в `config.ini`.
2. Положить отредактированный `get_profile_info.js` в **`bundled_dir`** (например `<build>/scripts/black/`) — кэш не используется при отсутствии manifest_url.
3. Запустить приложение. В логах `application.log` искать строки `[js:black/get_profile_info]`.
### Falback на C++
Любая из этих ситуаций триггерит fallback:
- В `config.ini` `enabled=false` или скрипта нет в `use_js_for`.
- Файл не найден ни в кэше, ни в bundled.
- `engine.evaluate()` вернул ошибку (синтаксис).
- В JS-коде нет `function run(host)`.
- Из `run(host)` бросилось исключение.
В каждом случае пишется warning вида:
```
[Black::GetProfileInfo] JS path failed ( "<диагностика>" ) — fallback to C++ implementation
```
После этого `Black::GetProfileInfoScript::doStart()` отрабатывает по своей
исторической C++-логике, и пользователь/сервер ничего не замечает.
### Дамп задачи
JS вызывает `host.dump.beginScope(...)`/`endScope(error)` ровно так же, как C++
дёргает `TaskDumpRecorder::Scope` — папка дампа, скриншоты, error-caption работают
без изменений. Если JS не успел вызвать `endScope`, деструктор `DumpBridge` закроет
скоуп с пустой ошибкой.
---
## 10. Как добавить новый скрипт
На PoC поддержан один скрипт. Чтобы добавить ещё один (например `black/pay_by_card`):
1. **C++-сторона.** В `android/black/PayByCardScript.cpp::doStart()` в начало
функции вставить ту же ветку, что уже есть в `GetProfileInfoScript.cpp`:
```cpp
if (Scripting::ScriptingConfig::isEnabled()
&& Scripting::ScriptingConfig::isJsEnabledFor("black/pay_by_card")) {
QString jsOut;
const QVariantMap params{
{"deviceId", m_deviceId},
{"appCode", m_appCode},
{"cardNumber", m_cardNumber},
{"amount", m_amount},
};
const auto status = Scripting::ScriptRuntime::run(
"black", "pay_by_card", params, &jsOut);
if (status == Scripting::ScriptRuntime::Result::Completed) {
m_error = jsOut;
emit finishedWithResult(m_error);
return;
}
qWarning() << "[Black::PayByCard] JS failed (" << jsOut << ") fallback C++";
}
```
2. **Bridges.** Если скрипту нужны новые методы парсера или хелперов — добавить
`Q_INVOKABLE` в `BlackParserBridge` / `BlackHelpersBridge` в
`services/scripting/ScriptBridges.h/.cpp`.
3. **JS.** Положить `scripts/black/pay_by_card.js` с `function run(host)`.
Использовать `host.params.cardNumber`, `host.params.amount`.
4. **Конфиг.** В `config.ini` добавить:
```ini
[scripting]
enabled=true
use_js_for=black/get_profile_info, black/pay_by_card
```
5. **Манифест.** Опубликовать новую запись в `<manifest_url>` с новым `sha256` и
`version`.
Если нужен скрипт для другого банка — добавьте `*ParserBridge` и `*HelpersBridge`
по образцу Black, и в `ScriptRuntime::run` развилку `if (bank == "ozon") ...`.
---
## 11. Известные ограничения
- Парсер/хелперы реализованы для `black` (минимум для get_profile_info) и `ozon`
(полный набор). Для Dushanbe и оставшихся Black-скриптов bridges нужно
добавить аналогично — см. секцию 10.
- Нет hot-reload: обновление подхватится при следующем запуске задачи (новый
`QJSEngine` создаётся на каждый запуск, но файл читается уже из обновлённого
кэша).
- Нет подписи манифеста — целостность только sha256.
- Нет аутентификации при `GET <manifest_url>` — сервер должен сам ограничивать
доступ (IP-allowlist, отдельный токен в URL, basic-auth на ревёрсе и т.п.).
- `host.timer.sleep` блокирующий. Это плюс (читаемый процедурный код), но
любое await/promise-стиль писать **нельзя** — нет event loop внутри `QJSEngine`.
---
## 12. Дальнейшие шаги (после PoC)
1. **Подпись манифеста (Ed25519).** Сервер подписывает JSON приватным ключом,
клиент проверяет публичным ключом, хардкоднутым в бинарник (или
зашифрованным конфигом).
2. **Аутентификация манифеста.** `Bearer access_token` из `SettingsDAO` уже есть
в `NetworkService` — переиспользовать.
3. **Hot-reload по push.** Сервер шлёт нотификацию «обновись», апдейтер тянет
манифест немедленно, а не только при старте.
4. **Telemetry.** Отправлять на `/monitoring/log` событие при каждом успешном/
неуспешном fallback'е — поможет понять, какие скрипты часто падают.
5. **Расширение покрытия.** Перенести остальные `*Script` Black-банка, затем
Ozon и Dushanbe.
6. **Тесты.** Завести golden-tests: для каждого `<bank>/<script>` записанный
набор XML-дампов + ожидаемые ADB-операции (моки), прогоняется на JS-версии.

View File

@ -55,6 +55,7 @@ static LONG WINAPI crashHandler(EXCEPTION_POINTERS *ep) {
#include "dao/SettingsDAO.h" #include "dao/SettingsDAO.h"
#include "EventHandler.h" #include "EventHandler.h"
#include "net/NetworkService.h" #include "net/NetworkService.h"
#include "scripting/ScriptUpdater.h"
#include "black/PayByCardScript.h" #include "black/PayByCardScript.h"
#include "ozon/PayByPhoneScript.h" #include "ozon/PayByPhoneScript.h"
#include "widget/loader/ErrorWindow.h" #include "widget/loader/ErrorWindow.h"
@ -165,6 +166,16 @@ void startBlackPayByCardTest() {
thread->start(); thread->start();
} }
void startScriptUpdater() {
// Опрашивает manifest и подтягивает обновлённые JS-скрипты, если в
// config.ini включена секция [scripting]. При выключенной секции
// start() сразу эмитит finished — никакой нагрузки.
auto *updater = new Scripting::ScriptUpdater;
QObject::connect(updater, &Scripting::ScriptUpdater::finished,
updater, &QObject::deleteLater);
QMetaObject::invokeMethod(updater, "start", Qt::QueuedConnection);
}
void startNetworkService(const QCoreApplication &app) { void startNetworkService(const QCoreApplication &app) {
auto *paymentThread = new QThread; auto *paymentThread = new QThread;
auto *networkService = new NetworkService; auto *networkService = new NetworkService;
@ -360,6 +371,7 @@ int main(int argc, char *argv[]) {
startNetworkService(app); startNetworkService(app);
startDeviceScreener(app); startDeviceScreener(app);
startEventHandler(app); startEventHandler(app);
startScriptUpdater();
// startBlackPayByCardTest(); // startBlackPayByCardTest();
}, Qt::QueuedConnection); }, Qt::QueuedConnection);
}; };
@ -412,6 +424,7 @@ int main(int argc, char *argv[]) {
startNetworkService(app); startNetworkService(app);
startDeviceScreener(app); startDeviceScreener(app);
startEventHandler(app); startEventHandler(app);
startScriptUpdater();
// startBlackPayByCardTest(); // startBlackPayByCardTest();
} else if (result == 0) { } else if (result == 0) {
SettingsDAO::remove("api_key"); SettingsDAO::remove("api_key");

View File

@ -0,0 +1,173 @@
// black/get_profile_info.js
//
// JS-порт Black::GetProfileInfoScript. Логика 1:1 с C++ (см.
// android/black/GetProfileInfoScript.cpp), обращения к ADB/DAO/парсеру
// идут через host.*-мост.
//
// Контракт:
// params.deviceId — серийный или android_id
// params.appCode — "black"
// Возвращает:
// "" / null / undefined → success
// строку → бизнес-ошибка (попадёт в EventInfo.error)
// При выбросе исключения runtime сделает fallback на C++.
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("black", "FETCH_PROFILE_INFO", deviceId, {});
try {
// 2. Читаем текущий экран
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter));
// 3. Если не на домашнем экране — перезапускаем приложение
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));
}
// 4. Тапаем "¡Hola!"
var holaNode = host.parser.findHolaButton();
if (holaNode.isEmpty) {
var err2 = "Cannot find Hola button on home screen: " + deviceId;
host.log.warn(err2);
host.adb.tryToKillApplication(deviceId, app.package);
return err2;
}
// 5. Парсим имя и баланс с домашнего экрана
var fullName = host.parser.parseUserNameFromHomeScreen();
var balance = host.parser.parseBalanceFromHomeScreen();
host.log.debug("fullName from home: " + fullName + " balance: " + balance);
// Сохраняем баланс
host.dao.bankProfileUpdateAmount(app.id, balance);
host.adb.makeTap(deviceId, holaNode.x, holaNode.y);
host.timer.sleep(2000);
// 6. Ждём боковое меню
var sideMenuFound = false;
for (var i = 0; i < 5; ++i) {
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter));
if (host.parser.isSideMenu()) {
sideMenuFound = true;
break;
}
host.timer.sleep(1000);
}
if (!sideMenuFound) {
var err3 = "Side menu not detected: " + deviceId;
host.log.warn(err3);
host.adb.tryToKillApplication(deviceId, app.package);
return err3;
}
// 7. Тапаем "Tu Información"
var tuInfoNode = host.parser.findNodeByContentDesc("Tu Información");
if (tuInfoNode.isEmpty) {
var err4 = "Cannot find Tu Información button: " + deviceId;
host.log.warn(err4);
host.adb.tryToKillApplication(deviceId, app.package);
return err4;
}
host.adb.makeTap(deviceId, tuInfoNode.x, tuInfoNode.y);
host.timer.sleep(2500);
// 8. Ждём страницу профиля
var profileFound = false;
for (var j = 0; j < 5; ++j) {
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter));
if (host.parser.isProfilePage()) {
profileFound = true;
break;
}
host.timer.sleep(1000);
}
if (!profileFound) {
var err5 = "Profile page not detected: " + deviceId;
host.log.warn(err5);
host.adb.tryToKillApplication(deviceId, app.package);
return err5;
}
// 9. Парсим данные профиля
var nombre = host.parser.parseProfileNombre();
var apellido = host.parser.parseProfileApellido();
var email = host.parser.parseProfileEmail();
// 10. Скроллим вниз чтобы увидеть телефон
var x = Math.floor(device.screenWidth / 2);
var fromY = Math.floor(device.screenHeight * 3 / 4);
var toY = Math.floor(device.screenHeight / 4);
host.adb.makeSwipe(deviceId, x, fromY, x, toY);
host.timer.sleep(1500);
host.parser.parseAndSaveXml(host.adb.getScreenDumpAsXml(deviceId, ++counter));
var phone = host.parser.parseProfilePhone();
// Собираем полное имя
var parsedFullName;
if (nombre && apellido) {
parsedFullName = nombre + " " + apellido;
} else if (nombre) {
parsedFullName = nombre;
} else {
parsedFullName = fullName;
}
host.log.debug("nombre: " + nombre + " apellido: " + apellido
+ " email: " + email + " phone: " + phone
+ " fullName: " + parsedFullName);
// 11. Сохраняем профиль
if (!host.dao.bankProfileUpdateProfileInfo(app.id, parsedFullName, phone, email)) {
host.log.warn("Failed to save profile info to DB");
}
// 12. Создаём bank profile на сервере если нет
host.helpers.createBankProfileIfNeeded(deviceId, appCode, -1);
// 13. Отправляем аккаунты без 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);
}
host.dump.endScope("");
return ""; // success
} catch (e) {
// Любая JS-ошибка → engine error для runtime → fallback на C++.
host.dump.endScope(String(e));
throw e;
}
}

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());
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,323 @@
#pragma once
#include <QObject>
#include <QString>
#include <QVariantList>
#include <QVariantMap>
#include "android/xml/Node.h"
class QJSEngine;
namespace Black { class ScreenXmlParser; }
namespace Ozon { class ScreenXmlParser; }
namespace Scripting {
// Лог из JS попадает в qDebug/qWarning с префиксом банка/скрипта.
class LogBridge final : public QObject {
Q_OBJECT
public:
LogBridge(QString tag, QObject *parent = nullptr) : QObject(parent), m_tag(std::move(tag)) {}
public slots:
void debug(const QString &message);
void info(const QString &message);
void warn(const QString &message);
void error(const QString &message);
private:
QString m_tag;
};
// Sleep + проверка interrupt (скрипт крутится в worker-потоке EventHandler'а).
class TimerBridge final : public QObject {
Q_OBJECT
public:
explicit TimerBridge(QObject *parent = nullptr) : QObject(parent) {}
public slots:
void sleep(int ms); // блокирующий, проверяет interrupt
bool isInterrupted() const; // ручная проверка из JS
};
// Прокси над AdbUtils. Все методы статические в C++ — здесь оборачиваем как Q_INVOKABLE,
// чтобы JS видел их как host.adb.<method>.
class AdbBridge final : public QObject {
Q_OBJECT
public:
explicit AdbBridge(QObject *parent = nullptr) : QObject(parent) {}
public slots:
QString getScreenDumpAsXml(const QString &deviceId, int idx);
QString getRawScreenDumpAsXml(const QString &deviceId);
bool makeTap(const QString &deviceId, int x, int y);
bool makeSwipe(const QString &deviceId, int x1, int y1, int x2, int y2);
bool makeSwipeWithDuration(const QString &deviceId, int x1, int y1, int x2, int y2, int durationMs);
bool tryToStartApplication(const QString &deviceId, const QString &packageName);
bool tryToKillApplication(const QString &deviceId, const QString &packageName);
QString tryToGetRunningAppPid(const QString &deviceId, const QString &packageName);
bool takeScreenshot(const QString &deviceId, const QString &name);
bool inputText(const QString &deviceId, const QString &text);
bool inputAdbIMEText(const QString &deviceId, const QString &text);
bool enableAdbIMEKeyboard(const QString &deviceId);
bool disableAdbIMEKeyboard(const QString &deviceId);
bool hideKeyboard(const QString &deviceId);
bool goBack(const QString &deviceId);
void unlockScreen(const QString &deviceId, int screenWidth, int screenHeight);
QString getDeviceTimezone(const QString &deviceId);
QString captureScreenshotBase64(const QString &deviceId);
};
// DAO-прокси: read для устройства/профиля/материалов, write для баланса/профиля.
class DaoBridge final : public QObject {
Q_OBJECT
public:
explicit DaoBridge(QObject *parent = nullptr) : QObject(parent) {}
public slots:
// DeviceDAO
QVariantMap deviceGetById(const QString &id);
QVariantMap deviceFindByAdbSerial(const QString &serial);
// BankProfileDAO
QVariantMap bankProfileGet(const QString &deviceId, const QString &appCode);
bool bankProfileUpdateAmount(int profileId, double amount);
bool bankProfileUpdateProfileInfo(int profileId, const QString &fullName,
const QString &phone, const QString &email);
bool bankProfileUpdateComment(int profileId, const QString &comment);
// MaterialDAO
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).
class DumpBridge final : public QObject {
Q_OBJECT
public:
explicit DumpBridge(QObject *parent = nullptr) : QObject(parent) {}
~DumpBridge() override { endScope(QString()); }
public slots:
void beginScope(const QString &bank, const QString &taskKind, const QString &adbDeviceId,
const QVariantMap &meta);
void endScope(const QString &errorMessage); // пусто = success
private:
void *m_scope = 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. Каждый запуск скрипта получает свой инстанс,
// поэтому m_xml/m_nodes не разделяются между задачами.
class BlackParserBridge final : public QObject {
Q_OBJECT
public:
explicit BlackParserBridge(QObject *parent = nullptr);
~BlackParserBridge() override;
Black::ScreenXmlParser *parser() const { return m_parser; }
public slots:
void parseAndSaveXml(const QString &xml);
bool isHomeScreen();
bool isSideMenu();
bool isPinCodeScreen();
bool isProfilePage();
bool isCVUScreen();
bool isTransactionLoadingScreen();
bool isTransactionListScreen();
bool isPayInputCardNumberScreen();
bool isPayTransferConfirmScreen();
bool isPayInputSumScreen();
bool isPayConfirmScreen();
bool isPaySuccessScreen();
QVariantMap findHolaButton();
QVariantMap findTextNode(const QString &text);
QVariantMap findNodeByContentDesc(const QString &contentDesc);
QVariantMap findNodeByContentDescContains(const QString &contentDesc);
QVariantMap findButtonNode(const QString &text, bool contains);
QVariantMap findFirstEditText();
QVariantMap findPinCodeEditText();
QVariantMap findVerMasAfterTuActividad();
QVariantMap findEditTextByHint(const QString &hint);
QString parseUserNameFromHomeScreen();
double parseBalanceFromHomeScreen();
QString parseProfileNombre();
QString parseProfileApellido();
QString parseProfileEmail();
QString parseProfilePhone();
QString parseCVUNumber();
QString parseRecipientNameFromConfirm();
QString parsePaySuccessDateTime();
QString parsePaySuccessCoelsaId();
private:
Black::ScreenXmlParser *m_parser;
};
// Высокоуровневые операции, повторяющие методы Black::CommonScript:
// runAppAndGoToHomeScreen, postAccountsData, createBankProfileIfNeeded.
// Используют ТОТ ЖЕ парсер, что и BlackParserBridge — состояние (XML) общее.
class BlackHelpersBridge final : public QObject {
Q_OBJECT
public:
BlackHelpersBridge(BlackParserBridge *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);
void postAccountsData(const QVariantList &accountsWithEmptyMaterialId);
void createBankProfileIfNeeded(const QString &deviceId, const QString &appCode, double parsedBalance);
private:
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. Объявлены здесь чтобы парсер-бридж
// мог их использовать без зависимости от QJSEngine.
QVariantMap nodeToVariantMap(const Node &node);
} // namespace Scripting

View File

@ -0,0 +1,88 @@
#include "ScriptCache.h"
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QSaveFile>
#include <QString>
#include "ScriptingConfig.h"
namespace Scripting {
namespace {
QString scriptRelPath(const QString &bank, const QString &scriptName) {
return bank + "/" + scriptName + ".js";
}
QString versionRelPath(const QString &bank, const QString &scriptName) {
return bank + "/" + scriptName + ".version";
}
} // namespace
bool ScriptCache::resolve(const QString &bank, const QString &scriptName,
QString *outPath, QString *outError) {
const QString rel = scriptRelPath(bank, scriptName);
const QString cached = QDir(ScriptingConfig::cacheDir()).filePath(rel);
if (QFileInfo::exists(cached)) {
if (outPath) *outPath = cached;
return true;
}
const QString bundled = QDir(ScriptingConfig::bundledDir()).filePath(rel);
if (QFileInfo::exists(bundled)) {
if (outPath) *outPath = bundled;
return true;
}
if (outError) {
*outError = "not found in cache (" + cached + ") nor bundled (" + bundled + ")";
}
return false;
}
bool ScriptCache::installFromBytes(const QString &bank, const QString &scriptName,
const QString &version, const QByteArray &body,
QString *outError) {
const QString dirPath = QDir(ScriptingConfig::cacheDir()).filePath(bank);
QDir().mkpath(dirPath);
const QString fullPath = QDir(ScriptingConfig::cacheDir()).filePath(scriptRelPath(bank, scriptName));
QSaveFile f(fullPath);
if (!f.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
if (outError) *outError = "cannot open for write: " + fullPath + "" + f.errorString();
return false;
}
if (f.write(body) != body.size()) {
if (outError) *outError = "short write to " + fullPath;
return false;
}
if (!f.commit()) {
if (outError) *outError = "commit failed for " + fullPath + ": " + f.errorString();
return false;
}
// Sidecar с версией. Не критичен — если упал, скрипт всё равно установлен.
const QString verPath = QDir(ScriptingConfig::cacheDir()).filePath(versionRelPath(bank, scriptName));
QFile vf(verPath);
if (vf.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
vf.write(version.toUtf8());
vf.close();
}
return true;
}
QString ScriptCache::installedVersion(const QString &bank, const QString &scriptName) {
const QString verPath = QDir(ScriptingConfig::cacheDir()).filePath(versionRelPath(bank, scriptName));
QFile vf(verPath);
if (!vf.open(QIODevice::ReadOnly)) return {};
const QByteArray b = vf.readAll();
vf.close();
return QString::fromUtf8(b).trimmed();
}
} // namespace Scripting

View File

@ -0,0 +1,29 @@
#pragma once
#include <QByteArray>
#include <QString>
namespace Scripting {
// Локальное хранилище JS-скриптов. Источник истины — cacheDir() из ScriptingConfig.
// Если в кэше скрипта нет — fallback на bundledDir() (рядом с бинарником).
class ScriptCache {
public:
// Резолвит абсолютный путь к файлу для <bank>/<scriptName>.
// 1. <cacheDir>/<bank>/<scriptName>.js
// 2. <bundledDir>/<bank>/<scriptName>.js
// Возвращает true только если файл существует.
static bool resolve(const QString &bank, const QString &scriptName,
QString *outPath, QString *outError);
// Сохраняет скрипт в кэш атомарно (через .tmp + rename).
// body — содержимое .js-файла. version — для диагностики, пишется в *.version.
static bool installFromBytes(const QString &bank, const QString &scriptName,
const QString &version, const QByteArray &body,
QString *outError);
// Текущая установленная версия (содержимое sidecar *.version). Пусто если не установлено.
static QString installedVersion(const QString &bank, const QString &scriptName);
};
} // namespace Scripting

View File

@ -0,0 +1,145 @@
#include "ScriptRuntime.h"
#include <QDebug>
#include <QFile>
#include <QJSEngine>
#include <QJSValue>
#include <QString>
#include <QThread>
#include "ScriptBridges.h"
#include "ScriptCache.h"
#include "ScriptingConfig.h"
namespace Scripting {
namespace {
// Конверсия QJSValue → строка-результат скрипта.
// null/undefined → пустая строка (успех).
// "" → пустая строка (успех).
// строка → текст бизнес-ошибки.
// число/булево/объект → toString() как diagnostic (рассматриваем как ошибку).
QString jsValueToResultString(const QJSValue &v) {
if (v.isUndefined() || v.isNull()) return QString();
if (v.isString()) return v.toString();
return v.toString();
}
QString locationOf(const QJSValue &err) {
const int line = err.property("lineNumber").toInt();
const QString file = err.property("fileName").toString();
if (line > 0 || !file.isEmpty()) {
return QStringLiteral(" at %1:%2").arg(file.isEmpty() ? QStringLiteral("<script>") : file).arg(line);
}
return {};
}
} // namespace
ScriptRuntime::Result ScriptRuntime::run(const QString &bank,
const QString &scriptName,
const QVariantMap &params,
QString *outResult,
QObject *signalSink) {
QString sink;
if (!outResult) outResult = &sink;
*outResult = QString();
const QString key = bank + "/" + scriptName;
if (!ScriptingConfig::isEnabled() || !ScriptingConfig::isJsEnabledFor(key)) {
*outResult = "JS disabled for " + key;
return Result::Disabled;
}
QString sourcePath;
QString loadError;
if (!ScriptCache::resolve(bank, scriptName, &sourcePath, &loadError)) {
*outResult = "Script not available: " + loadError;
return Result::Disabled;
}
QFile f(sourcePath);
if (!f.open(QIODevice::ReadOnly | QIODevice::Text)) {
*outResult = "Cannot read script file: " + sourcePath;
return Result::EngineError;
}
const QString source = QString::fromUtf8(f.readAll());
f.close();
QJSEngine engine;
engine.installExtensions(QJSEngine::ConsoleExtension);
// Bridges живут на стеке здесь, в worker-потоке. Эти объекты не должны
// переживать engine — QJSEngine удаляет их при разрушении (мы их newQObject'нули),
// поэтому передаём nullptr как родителя — engine берёт владение.
auto *log = new LogBridge(key);
auto *timer = new TimerBridge();
auto *adb = new AdbBridge();
auto *dao = new DaoBridge();
auto *dump = new DumpBridge();
auto *ocr = new OcrBridge();
auto *scriptObj = new ScriptObjectBridge(signalSink);
// Bank-specific bridges. Поддержаны: Black (полный набор), Ozon (минимум
// для get_profile_info — расширяется по мере миграции login_and_check / get_card_info).
QObject *parserBridge = nullptr;
QObject *helpersBridge = nullptr;
if (bank == QLatin1String("black")) {
auto *pb = new BlackParserBridge();
parserBridge = pb;
helpersBridge = new BlackHelpersBridge(pb);
} else if (bank == QLatin1String("ozon")) {
auto *pb = new OzonParserBridge();
parserBridge = pb;
helpersBridge = new OzonHelpersBridge(pb);
}
QJSValue host = engine.newObject();
host.setProperty("log", engine.newQObject(log));
host.setProperty("timer", engine.newQObject(timer));
host.setProperty("adb", engine.newQObject(adb));
host.setProperty("dao", engine.newQObject(dao));
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 (helpersBridge) host.setProperty("helpers", engine.newQObject(helpersBridge));
// params: конвертируем QVariantMap → JS object
QJSValue jsParams = engine.toScriptValue(params);
host.setProperty("params", jsParams);
engine.globalObject().setProperty("host", host);
// Оборачиваем исходник так, чтобы он МОГ просто определять run(host) и не
// быть IIFE. Сначала evaluate определений, потом достаём `run` из globalObject.
QJSValue evalResult = engine.evaluate(source, sourcePath);
if (evalResult.isError()) {
*outResult = QStringLiteral("JS parse/eval error: %1%2")
.arg(evalResult.toString(), locationOf(evalResult));
qWarning() << "[Scripting]" << *outResult;
return Result::EngineError;
}
QJSValue runFn = engine.globalObject().property("run");
if (!runFn.isCallable()) {
*outResult = QStringLiteral("Script %1 did not define function run(host)").arg(key);
qWarning() << "[Scripting]" << *outResult;
return Result::EngineError;
}
QJSValue callResult = runFn.call(QJSValueList{} << host);
if (callResult.isError()) {
*outResult = QStringLiteral("JS runtime error: %1%2")
.arg(callResult.toString(), locationOf(callResult));
qWarning() << "[Scripting]" << *outResult;
return Result::EngineError;
}
*outResult = jsValueToResultString(callResult);
return Result::Completed;
}
} // namespace Scripting

View File

@ -0,0 +1,43 @@
#pragma once
#include <QString>
#include <QVariantMap>
class QObject;
namespace Scripting {
class ScriptRuntime {
public:
enum class Result {
// Скрипт выполнился до конца. outResult — пустая строка или бизнес-ошибка
// (то, что вернёт `run()` из JS как строку). EventHandler трактует строку
// как ошибку задачи; пусто — как success.
Completed,
// Не удалось загрузить/распарсить/выполнить скрипт (JS-ошибка, не business).
// В outResult — диагностика. Caller обязан сделать fallback на C++.
EngineError,
// Скрипт не разрешён к запуску (флаг выключен / нет файла / нет SHA).
// outResult — причина. Caller делает fallback.
Disabled
};
// Синхронно запускает скрипт <bank>/<scriptName> в свежем QJSEngine.
// Должен вызываться из того же потока, где будет использоваться (worker-поток
// EventHandler'а — там разрешён блокирующий sleep и DAO).
//
// params видны JS как host.params.<key>.
//
// signalSink — опциональный QObject, на котором JS может эмитить сигналы
// через host.script.emit<SignalName>(jsonStr). Используется для скриптов
// вроде GetLastTransactionsScript, которые транслируют распарсенные
// транзакции в EventHandler через Qt-сигнал. Передавать `this` из C++
// оригинального скрипта.
static Result run(const QString &bank,
const QString &scriptName,
const QVariantMap &params,
QString *outResult,
QObject *signalSink = nullptr);
};
} // namespace Scripting

View File

@ -0,0 +1,159 @@
#include "ScriptUpdater.h"
#include <QCryptographicHash>
#include <QDebug>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QUrl>
#include "ScriptCache.h"
#include "ScriptingConfig.h"
namespace Scripting {
namespace {
constexpr char kReplyKindManifest[] = "manifest";
constexpr char kReplyKindScript[] = "script";
}
ScriptUpdater::ScriptUpdater(QObject *parent)
: QObject(parent), m_net(new QNetworkAccessManager(this)) {}
ScriptUpdater::~ScriptUpdater() = default;
void ScriptUpdater::start() {
if (!ScriptingConfig::isEnabled()) {
qDebug() << "[ScriptUpdater] disabled in config";
emit finished();
return;
}
const QString url = ScriptingConfig::manifestUrl();
if (url.isEmpty()) {
qDebug() << "[ScriptUpdater] manifest_url empty — пропускаю обновление, использую bundled/cache";
emit finished();
return;
}
QNetworkRequest req((QUrl(url)));
req.setRawHeader("Accept", "application/json");
QNetworkReply *reply = m_net->get(req);
reply->setProperty("kind", kReplyKindManifest);
connect(reply, &QNetworkReply::finished, this, &ScriptUpdater::onManifestReply);
}
void ScriptUpdater::onManifestReply() {
auto *reply = qobject_cast<QNetworkReply *>(sender());
if (!reply) { scheduleFinish(); return; }
reply->deleteLater();
if (reply->error() != QNetworkReply::NoError) {
qWarning() << "[ScriptUpdater] manifest fetch failed:" << reply->errorString();
scheduleFinish();
return;
}
const QByteArray body = reply->readAll();
QJsonParseError err{};
const QJsonDocument doc = QJsonDocument::fromJson(body, &err);
if (err.error != QJsonParseError::NoError || !doc.isObject()) {
qWarning() << "[ScriptUpdater] manifest parse error:" << err.errorString();
scheduleFinish();
return;
}
const QJsonArray arr = doc.object().value("scripts").toArray();
for (const QJsonValue &v : arr) {
const QJsonObject o = v.toObject();
ScriptItem item;
item.bank = o.value("bank").toString();
item.script = o.value("script").toString();
item.version = o.value("version").toString();
item.url = o.value("url").toString();
item.sha256 = o.value("sha256").toString().toLower();
if (item.bank.isEmpty() || item.script.isEmpty() || item.url.isEmpty() || item.sha256.isEmpty()) {
qWarning() << "[ScriptUpdater] skipping invalid manifest entry:" << o;
continue;
}
const QString installed = ScriptCache::installedVersion(item.bank, item.script);
if (!installed.isEmpty() && installed == item.version) {
qDebug() << "[ScriptUpdater]" << item.bank << "/" << item.script
<< "version" << item.version << "already installed";
continue;
}
m_queue.append(item);
}
if (m_queue.isEmpty()) {
qDebug() << "[ScriptUpdater] nothing to update";
scheduleFinish();
return;
}
qInfo() << "[ScriptUpdater] queued" << m_queue.size() << "script update(s)";
downloadNext();
}
void ScriptUpdater::downloadNext() {
if (m_queue.isEmpty()) {
if (m_inFlight == 0) scheduleFinish();
return;
}
const ScriptItem item = m_queue.takeFirst();
QNetworkRequest req{QUrl(item.url)};
QNetworkReply *reply = m_net->get(req);
reply->setProperty("kind", kReplyKindScript);
reply->setProperty("bank", item.bank);
reply->setProperty("script", item.script);
reply->setProperty("version", item.version);
reply->setProperty("sha256", item.sha256);
m_inFlight++;
connect(reply, &QNetworkReply::finished, this, &ScriptUpdater::onScriptReply);
}
void ScriptUpdater::onScriptReply() {
auto *reply = qobject_cast<QNetworkReply *>(sender());
if (!reply) { scheduleFinish(); return; }
reply->deleteLater();
m_inFlight--;
const QString bank = reply->property("bank").toString();
const QString script = reply->property("script").toString();
const QString version = reply->property("version").toString();
const QString expectedSha = reply->property("sha256").toString().toLower();
const QString tag = bank + "/" + script;
if (reply->error() != QNetworkReply::NoError) {
qWarning() << "[ScriptUpdater] download failed for" << tag << ":" << reply->errorString();
downloadNext();
return;
}
const QByteArray body = reply->readAll();
const QString actualSha = QString::fromLatin1(
QCryptographicHash::hash(body, QCryptographicHash::Sha256).toHex());
if (actualSha != expectedSha) {
qWarning() << "[ScriptUpdater] SHA-256 mismatch for" << tag
<< "expected" << expectedSha << "actual" << actualSha;
downloadNext();
return;
}
QString installErr;
if (!ScriptCache::installFromBytes(bank, script, version, body, &installErr)) {
qWarning() << "[ScriptUpdater] install failed for" << tag << ":" << installErr;
downloadNext();
return;
}
qInfo() << "[ScriptUpdater] installed" << tag << "version" << version;
downloadNext();
}
void ScriptUpdater::scheduleFinish() {
// Гарантирует, что finished() уйдёт после возврата управления event loop'у.
QMetaObject::invokeMethod(this, [this]() { emit finished(); }, Qt::QueuedConnection);
}
} // namespace Scripting

View File

@ -0,0 +1,59 @@
#pragma once
#include <QObject>
#include <QString>
class QNetworkAccessManager;
class QNetworkReply;
namespace Scripting {
// Опрашивает /scripts/manifest, скачивает обновлённые скрипты,
// сверяет SHA-256 и кладёт их в ScriptCache (атомарно).
//
// Manifest формат (JSON):
// {
// "scripts": [
// { "bank": "black", "script": "get_profile_info",
// "version": "1.0.0", "url": "...", "sha256": "<hex>" },
// ...
// ]
// }
//
// Поведение fallback: если что-то пошло не так (сеть, hash mismatch),
// в кэше остаётся ПРЕДЫДУЩИЙ скрипт; если кэш пуст — runtime возьмёт bundled.
class ScriptUpdater final : public QObject {
Q_OBJECT
public:
explicit ScriptUpdater(QObject *parent = nullptr);
~ScriptUpdater() override;
public slots:
// Запускает разовое обновление. Вызывать после успешной авторизации.
void start();
signals:
void finished();
private slots:
void onManifestReply();
void onScriptReply();
private:
void downloadNext();
void scheduleFinish();
struct ScriptItem {
QString bank;
QString script;
QString version;
QString url;
QString sha256;
};
QNetworkAccessManager *m_net;
QList<ScriptItem> m_queue;
int m_inFlight = 0;
};
} // namespace Scripting

View File

@ -0,0 +1,51 @@
#include "ScriptingConfig.h"
#include <QCoreApplication>
#include <QDir>
#include <QSettings>
#include <QStandardPaths>
#include <QStringList>
namespace Scripting {
bool ScriptingConfig::isEnabled() {
const QSettings s("config.ini", QSettings::IniFormat);
return s.value("scripting/enabled", false).toBool();
}
QSet<QString> ScriptingConfig::parseEnabledScripts() {
const QSettings s("config.ini", QSettings::IniFormat);
const QString raw = s.value("scripting/use_js_for").toString();
QSet<QString> out;
for (QString item : raw.split(',', Qt::SkipEmptyParts)) {
item = item.trimmed();
if (!item.isEmpty()) out.insert(item);
}
return out;
}
bool ScriptingConfig::isJsEnabledFor(const QString &bankSlashScript) {
return parseEnabledScripts().contains(bankSlashScript);
}
QString ScriptingConfig::manifestUrl() {
const QSettings s("config.ini", QSettings::IniFormat);
return s.value("scripting/manifest_url").toString().trimmed();
}
QString ScriptingConfig::cacheDir() {
const QSettings s("config.ini", QSettings::IniFormat);
const QString custom = s.value("scripting/cache_dir").toString().trimmed();
if (!custom.isEmpty()) return custom;
const QString base = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation);
return QDir(base).filePath("scripts");
}
QString ScriptingConfig::bundledDir() {
const QSettings s("config.ini", QSettings::IniFormat);
const QString custom = s.value("scripting/bundled_dir").toString().trimmed();
if (!custom.isEmpty()) return custom;
return QDir(QCoreApplication::applicationDirPath()).filePath("scripts");
}
} // namespace Scripting

View File

@ -0,0 +1,33 @@
#pragma once
#include <QSet>
#include <QString>
namespace Scripting {
// Тонкая обёртка над секцией [scripting] в config.ini.
// Все методы перечитывают конфиг по требованию — изменения без рестарта подхватятся.
class ScriptingConfig {
public:
// Глобальный выключатель: [scripting] enabled = true|false. Default: false.
static bool isEnabled();
// Список скриптов, для которых разрешён JS-вариант.
// [scripting] use_js_for = black/get_profile_info, ozon/get_profile_info
static bool isJsEnabledFor(const QString &bankSlashScript);
// URL манифеста (опционально, по умолчанию пусто = remote-update выключен).
// [scripting] manifest_url = http://.../api/v1/scripts/manifest
static QString manifestUrl();
// Каталог локального кэша. Default: <AppDataLocation>/scripts/
static QString cacheDir();
// Запасной каталог bundled-скриптов (рядом с бинарником). Default: ./scripts/
static QString bundledDir();
private:
static QSet<QString> parseEnabledScripts();
};
} // namespace Scripting