Compare commits
2 Commits
main
...
remote-scr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f8c5a6656f | ||
|
|
cae345cf61 |
@ -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
|
||||||
|
|||||||
@ -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);
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
@ -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
|
||||||
@ -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);
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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);
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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);
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
||||||
|
|
||||||
|
|||||||
14
config.ini
14
config.ini
@ -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=
|
||||||
|
|||||||
@ -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
480
docs/remote_scripts.md
Normal 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-версии.
|
||||||
13
main.cpp
13
main.cpp
@ -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");
|
||||||
|
|||||||
173
scripts/black/get_profile_info.js
Normal file
173
scripts/black/get_profile_info.js
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
358
scripts/ozon/get_card_info.js
Normal file
358
scripts/ozon/get_card_info.js
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
665
scripts/ozon/get_last_transactions.js
Normal file
665
scripts/ozon/get_last_transactions.js
Normal 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
|
||||||
|
}
|
||||||
166
scripts/ozon/get_profile_info.js
Normal file
166
scripts/ozon/get_profile_info.js
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
118
scripts/ozon/login_and_check_accounts.js
Normal file
118
scripts/ozon/login_and_check_accounts.js
Normal 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
359
scripts/ozon/pay_by_card.js
Normal 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());
|
||||||
|
}
|
||||||
430
scripts/ozon/pay_by_phone.js
Normal file
430
scripts/ozon/pay_by_phone.js
Normal 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());
|
||||||
|
}
|
||||||
1095
services/scripting/ScriptBridges.cpp
Normal file
1095
services/scripting/ScriptBridges.cpp
Normal file
File diff suppressed because it is too large
Load Diff
323
services/scripting/ScriptBridges.h
Normal file
323
services/scripting/ScriptBridges.h
Normal 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 ¤cy);
|
||||||
|
|
||||||
|
// 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
|
||||||
88
services/scripting/ScriptCache.cpp
Normal file
88
services/scripting/ScriptCache.cpp
Normal 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
|
||||||
29
services/scripting/ScriptCache.h
Normal file
29
services/scripting/ScriptCache.h
Normal 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
|
||||||
145
services/scripting/ScriptRuntime.cpp
Normal file
145
services/scripting/ScriptRuntime.cpp
Normal 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 ¶ms,
|
||||||
|
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
|
||||||
43
services/scripting/ScriptRuntime.h
Normal file
43
services/scripting/ScriptRuntime.h
Normal 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 ¶ms,
|
||||||
|
QString *outResult,
|
||||||
|
QObject *signalSink = nullptr);
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace Scripting
|
||||||
159
services/scripting/ScriptUpdater.cpp
Normal file
159
services/scripting/ScriptUpdater.cpp
Normal 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
|
||||||
59
services/scripting/ScriptUpdater.h
Normal file
59
services/scripting/ScriptUpdater.h
Normal 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
|
||||||
51
services/scripting/ScriptingConfig.cpp
Normal file
51
services/scripting/ScriptingConfig.cpp
Normal 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
|
||||||
33
services/scripting/ScriptingConfig.h
Normal file
33
services/scripting/ScriptingConfig.h
Normal 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
|
||||||
Loading…
Reference in New Issue
Block a user