Port Ozon card and payment scripts to JavaScript, mirroring C++ logic for card info fetching and phone-based payments.
This commit is contained in:
parent
cae345cf61
commit
f8c5a6656f
@ -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);
|
||||||
|
|
||||||
|
|||||||
@ -47,7 +47,7 @@ phone_code=992
|
|||||||
enabled=false
|
enabled=false
|
||||||
# Список <bank>/<script>, для которых пробуем JS-версию (через запятую).
|
# Список <bank>/<script>, для которых пробуем JS-версию (через запятую).
|
||||||
# При engine-ошибке runtime прозрачно откатывается на C++.
|
# При engine-ошибке runtime прозрачно откатывается на C++.
|
||||||
use_js_for=black/get_profile_info
|
use_js_for=black/get_profile_info, ozon/get_profile_info, ozon/login_and_check_accounts, ozon/get_card_info, ozon/pay_by_card, ozon/pay_by_phone, ozon/get_last_transactions
|
||||||
# URL JSON-манифеста удалённых обновлений. Пустой → без удалённых обновлений,
|
# URL JSON-манифеста удалённых обновлений. Пустой → без удалённых обновлений,
|
||||||
# работает только bundled+cache (см. docs/remote_scripts.md).
|
# работает только bundled+cache (см. docs/remote_scripts.md).
|
||||||
manifest_url=
|
manifest_url=
|
||||||
|
|||||||
@ -4,9 +4,11 @@
|
|||||||
скриптов **без пересборки и переустановки приложения** — JS-файлы тянутся с сервера
|
скриптов **без пересборки и переустановки приложения** — JS-файлы тянутся с сервера
|
||||||
и исполняются внутри `QJSEngine`.
|
и исполняются внутри `QJSEngine`.
|
||||||
|
|
||||||
> **Статус: PoC.** Полностью реализован фреймворк, удалённое обновление и порт
|
> **Статус.** Фреймворк и удалённое обновление полностью реализованы. На JS
|
||||||
> одного скрипта — `Black::GetProfileInfoScript`. Остальные скрипты остаются на C++
|
> перенесены: `Black::GetProfileInfo` (PoC); **все 7 скриптов Ozon** —
|
||||||
> и обновляются обычным релизом приложения.
|
> `get_profile_info`, `login_and_check_accounts`, `get_card_info`, `pay_by_card`,
|
||||||
|
> `pay_by_phone`, `get_last_transactions`. Остальные банки (Dushanbe, оставшийся
|
||||||
|
> Black) — на C++, мигрируются.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -217,7 +219,24 @@ host.dao.materialGetAccounts(deviceId, appCode) → Array<MaterialObj>
|
|||||||
|
|
||||||
`MaterialObj`: `{ id, materialId, appCode, deviceId, lastNumbers, amount, currency, description, status }`.
|
`MaterialObj`: `{ id, materialId, appCode, deviceId, lastNumbers, amount, currency, description, status }`.
|
||||||
|
|
||||||
### `host.parser` (только для `black`)
|
### `host.script` (опционально)
|
||||||
|
```javascript
|
||||||
|
host.script.emitTransactionParsed(jsonString)
|
||||||
|
```
|
||||||
|
Эмитит Qt-сигнал `transactionParsed(QJsonObject)` на C++ скрипте, который
|
||||||
|
передан в `ScriptRuntime::run(... , this)`. Используется
|
||||||
|
`get_last_transactions.js` для трансляции спарсенных транзакций в EventHandler.
|
||||||
|
Без `signalSink` метод — no-op.
|
||||||
|
|
||||||
|
### `host.ocr`
|
||||||
|
```javascript
|
||||||
|
host.ocr.recognizeFromScreen(deviceId) → string // grayscale + 300dpi, rus+eng
|
||||||
|
host.ocr.extractTransactionId(ocrText) → string // "ID операции..." / "№ Ф-..."
|
||||||
|
```
|
||||||
|
Реализация в `android/ocr/OcrUtils`. Используется `get_last_transactions.js`
|
||||||
|
для извлечения `bank_transaction_id` с экрана "Документы" Ozon.
|
||||||
|
|
||||||
|
### `host.parser` (Black и Ozon)
|
||||||
Обёртка над `Black::ScreenXmlParser`. Состояние (распаршенный XML) живёт ровно
|
Обёртка над `Black::ScreenXmlParser`. Состояние (распаршенный XML) живёт ровно
|
||||||
один прогон скрипта.
|
один прогон скрипта.
|
||||||
|
|
||||||
@ -428,10 +447,11 @@ JS вызывает `host.dump.beginScope(...)`/`endScope(error)` ровно т
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 11. Известные ограничения PoC
|
## 11. Известные ограничения
|
||||||
|
|
||||||
- Парсер/хелперы есть только для банка `black`. Скрипты других банков можно
|
- Парсер/хелперы реализованы для `black` (минимум для get_profile_info) и `ozon`
|
||||||
писать, но `host.parser`/`host.helpers` у них будут `undefined`.
|
(полный набор). Для Dushanbe и оставшихся Black-скриптов bridges нужно
|
||||||
|
добавить аналогично — см. секцию 10.
|
||||||
- Нет hot-reload: обновление подхватится при следующем запуске задачи (новый
|
- Нет hot-reload: обновление подхватится при следующем запуске задачи (новый
|
||||||
`QJSEngine` создаётся на каждый запуск, но файл читается уже из обновлённого
|
`QJSEngine` создаётся на каждый запуск, но файл читается уже из обновлённого
|
||||||
кэша).
|
кэша).
|
||||||
|
|||||||
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());
|
||||||
|
}
|
||||||
@ -5,22 +5,30 @@
|
|||||||
#include <QDebug>
|
#include <QDebug>
|
||||||
#include <QJsonDocument>
|
#include <QJsonDocument>
|
||||||
#include <QJsonObject>
|
#include <QJsonObject>
|
||||||
|
#include <QDateTime>
|
||||||
|
#include <QRandomGenerator>
|
||||||
#include <QSettings>
|
#include <QSettings>
|
||||||
#include <QString>
|
#include <QString>
|
||||||
#include <QThread>
|
#include <QThread>
|
||||||
#include <QRegularExpression>
|
#include <QRegularExpression>
|
||||||
|
#include <QJsonArray>
|
||||||
|
|
||||||
#include "adb/AdbUtils.h"
|
#include "adb/AdbUtils.h"
|
||||||
#include "black/ScreenXmlParser.h"
|
#include "black/ScreenXmlParser.h"
|
||||||
|
#include "ozon/ScreenXmlParser.h"
|
||||||
#include "android/xml/Node.h"
|
#include "android/xml/Node.h"
|
||||||
#include "db/BankProfileInfo.h"
|
#include "db/BankProfileInfo.h"
|
||||||
#include "db/DeviceInfo.h"
|
#include "db/DeviceInfo.h"
|
||||||
#include "db/MaterialInfo.h"
|
#include "db/MaterialInfo.h"
|
||||||
|
#include "db/TransactionInfo.h"
|
||||||
#include "dao/BankProfileDAO.h"
|
#include "dao/BankProfileDAO.h"
|
||||||
#include "dao/DeviceDAO.h"
|
#include "dao/DeviceDAO.h"
|
||||||
#include "dao/MaterialDAO.h"
|
#include "dao/MaterialDAO.h"
|
||||||
|
#include "dao/SettingsDAO.h"
|
||||||
|
#include "dao/TransactionDAO.h"
|
||||||
#include "dump/TaskDumpRecorder.h"
|
#include "dump/TaskDumpRecorder.h"
|
||||||
#include "net/NetworkService.h"
|
#include "net/NetworkService.h"
|
||||||
|
#include "ocr/OcrUtils.h"
|
||||||
|
|
||||||
namespace Scripting {
|
namespace Scripting {
|
||||||
|
|
||||||
@ -171,6 +179,8 @@ static QVariantMap materialToMap(const MaterialInfo &a) {
|
|||||||
m["appCode"] = a.appCode;
|
m["appCode"] = a.appCode;
|
||||||
m["deviceId"] = a.deviceId;
|
m["deviceId"] = a.deviceId;
|
||||||
m["lastNumbers"] = a.lastNumbers;
|
m["lastNumbers"] = a.lastNumbers;
|
||||||
|
m["cardNumber"] = a.cardNumber;
|
||||||
|
m["accountNumber"] = a.accountNumber;
|
||||||
m["amount"] = a.amount;
|
m["amount"] = a.amount;
|
||||||
m["currency"] = a.currency;
|
m["currency"] = a.currency;
|
||||||
m["description"] = a.description;
|
m["description"] = a.description;
|
||||||
@ -178,6 +188,24 @@ static QVariantMap materialToMap(const MaterialInfo &a) {
|
|||||||
return m;
|
return m;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static MaterialInfo materialFromMap(const QVariantMap &m) {
|
||||||
|
MaterialInfo a;
|
||||||
|
a.id = m.value("id", -1).toInt();
|
||||||
|
a.materialId = m.value("materialId").toString();
|
||||||
|
a.appCode = m.value("appCode").toString();
|
||||||
|
a.deviceId = m.value("deviceId").toString();
|
||||||
|
a.lastNumbers = m.value("lastNumbers").toString();
|
||||||
|
a.cardNumber = m.value("cardNumber").toString();
|
||||||
|
a.accountNumber = m.value("accountNumber").toString();
|
||||||
|
a.amount = m.value("amount").toDouble();
|
||||||
|
a.currency = m.value("currency").toString();
|
||||||
|
a.description = m.value("description").toString();
|
||||||
|
const QString status = m.value("status").toString();
|
||||||
|
if (!status.isEmpty()) a.status = materialStatusFromString(status);
|
||||||
|
a.updateTime = QDateTime::currentDateTimeUtc();
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
|
||||||
QVariantMap DaoBridge::deviceGetById(const QString &id) {
|
QVariantMap DaoBridge::deviceGetById(const QString &id) {
|
||||||
return deviceToMap(DeviceDAO::getDeviceById(id));
|
return deviceToMap(DeviceDAO::getDeviceById(id));
|
||||||
}
|
}
|
||||||
@ -204,6 +232,62 @@ QVariantList DaoBridge::materialGetAccounts(const QString &deviceId, const QStri
|
|||||||
}
|
}
|
||||||
return list;
|
return list;
|
||||||
}
|
}
|
||||||
|
QVariantMap DaoBridge::materialFindAppAccount(const QString &deviceId, const QString &appCode,
|
||||||
|
const QString &lastNumbers) {
|
||||||
|
return materialToMap(MaterialDAO::findAppAccount(deviceId, appCode, lastNumbers));
|
||||||
|
}
|
||||||
|
bool DaoBridge::materialUpsert(const QVariantMap &material) {
|
||||||
|
return MaterialDAO::upsertAccount(materialFromMap(material));
|
||||||
|
}
|
||||||
|
bool DaoBridge::materialUpdateCardNumber(int accountId, const QString &cardNumber) {
|
||||||
|
return MaterialDAO::updateCardNumber(accountId, cardNumber);
|
||||||
|
}
|
||||||
|
bool DaoBridge::materialUpdateAccountById(int id, const QString &status,
|
||||||
|
const QString &description, const QString ¤cy) {
|
||||||
|
return MaterialDAO::updateAccountById(id, status, description, currency);
|
||||||
|
}
|
||||||
|
QString DaoBridge::settingsGet(const QString &key) {
|
||||||
|
return SettingsDAO::get(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
TransactionInfo txFromMap(const QVariantMap &m) {
|
||||||
|
TransactionInfo tx;
|
||||||
|
tx.id = m.value("id", -1).toInt();
|
||||||
|
tx.accountId = m.value("accountId", -1).toInt();
|
||||||
|
tx.amount = m.value("amount").toDouble();
|
||||||
|
tx.fee = m.value("fee").toDouble();
|
||||||
|
tx.phone = m.value("phone").toString();
|
||||||
|
tx.name = m.value("name").toString();
|
||||||
|
tx.description = m.value("description").toString();
|
||||||
|
tx.updatedDescription = m.value("updatedDescription").toString();
|
||||||
|
tx.bankName = m.value("bankName").toString();
|
||||||
|
tx.bankTime = m.value("bankTime").toString();
|
||||||
|
tx.bankTrExternalId = m.value("bankTrExternalId").toString();
|
||||||
|
const QString typeS = m.value("type").toString();
|
||||||
|
if (!typeS.isEmpty()) tx.type = transactionTypeFromString(typeS);
|
||||||
|
const QString statusS = m.value("status").toString();
|
||||||
|
if (!statusS.isEmpty()) tx.status = transactionStatusFromString(statusS);
|
||||||
|
|
||||||
|
auto parseDt = [](const QVariant &v) -> QDateTime {
|
||||||
|
if (!v.isValid() || v.isNull()) return {};
|
||||||
|
if (v.canConvert<QDateTime>()) return v.toDateTime();
|
||||||
|
return QDateTime::fromString(v.toString(), Qt::ISODate);
|
||||||
|
};
|
||||||
|
tx.timestamp = parseDt(m.value("timestamp"));
|
||||||
|
tx.completeTime = parseDt(m.value("completeTime"));
|
||||||
|
tx.updateTime = parseDt(m.value("updateTime"));
|
||||||
|
if (!tx.timestamp.isValid()) tx.timestamp = QDateTime::currentDateTimeUtc();
|
||||||
|
return tx;
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
int DaoBridge::transactionInsert(const QVariantMap &tx) {
|
||||||
|
return TransactionDAO::insertTransaction(txFromMap(tx));
|
||||||
|
}
|
||||||
|
bool DaoBridge::transactionUpdateBankTrExternalId(int id, const QString &externalId) {
|
||||||
|
return TransactionDAO::updateBankTrExternalId(id, externalId);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── DumpBridge ─────────────────────────────────────────────────────────────
|
// ─── DumpBridge ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@ -231,6 +315,38 @@ void DumpBridge::endScope(const QString &errorMessage) {
|
|||||||
m_errorPtr = nullptr;
|
m_errorPtr = nullptr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── ScriptObjectBridge ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
void ScriptObjectBridge::emitTransactionParsed(const QString &jsonStr) {
|
||||||
|
if (!m_sink) {
|
||||||
|
qDebug() << "[Scripting] emitTransactionParsed called but no signalSink — dropping";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
QJsonParseError err;
|
||||||
|
const QJsonDocument doc = QJsonDocument::fromJson(jsonStr.toUtf8(), &err);
|
||||||
|
if (err.error != QJsonParseError::NoError || !doc.isObject()) {
|
||||||
|
qWarning() << "[Scripting] emitTransactionParsed: bad JSON" << err.errorString();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// QMetaObject::invokeMethod умеет вызывать Qt-сигналы. Для thread-safety
|
||||||
|
// используем QueuedConnection — JS крутится в worker-потоке, EventHandler
|
||||||
|
// тоже там же, но queued тут не помешает.
|
||||||
|
QMetaObject::invokeMethod(m_sink, "transactionParsed", Qt::DirectConnection,
|
||||||
|
Q_ARG(QJsonObject, doc.object()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── OcrBridge ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
QString OcrBridge::recognizeFromScreen(const QString &deviceId) {
|
||||||
|
const QByteArray png = AdbUtils::captureScreenshotBytes(deviceId);
|
||||||
|
if (png.isEmpty()) return {};
|
||||||
|
return OcrUtils::recognizeText(png);
|
||||||
|
}
|
||||||
|
|
||||||
|
QString OcrBridge::extractTransactionId(const QString &ocrText) {
|
||||||
|
return OcrUtils::extractTransactionId(ocrText);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── BlackParserBridge ──────────────────────────────────────────────────────
|
// ─── BlackParserBridge ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
BlackParserBridge::BlackParserBridge(QObject *parent)
|
BlackParserBridge::BlackParserBridge(QObject *parent)
|
||||||
@ -414,4 +530,566 @@ void BlackHelpersBridge::createBankProfileIfNeeded(const QString &deviceId, cons
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── OzonParserBridge ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
OzonParserBridge::OzonParserBridge(QObject *parent)
|
||||||
|
: QObject(parent), m_parser(new Ozon::ScreenXmlParser(this)) {}
|
||||||
|
|
||||||
|
OzonParserBridge::~OzonParserBridge() = default;
|
||||||
|
|
||||||
|
void OzonParserBridge::parseAndSaveXml(const QString &xml) { m_parser->parseAndSaveXml(xml); }
|
||||||
|
bool OzonParserBridge::isHomeScreen() { return m_parser->isHomeScreen(); }
|
||||||
|
bool OzonParserBridge::isProfileScreen() { return m_parser->isProfileScreen(); }
|
||||||
|
|
||||||
|
QVariantMap OzonParserBridge::tryToFindAdBanner() { return nodeToVariantMap(m_parser->tryToFindAdBanner()); }
|
||||||
|
QVariantMap OzonParserBridge::findUserNameOnHomeScreen(int screenHeight){ return nodeToVariantMap(m_parser->findUserNameOnHomeScreen(screenHeight)); }
|
||||||
|
QVariantMap OzonParserBridge::findCardButtonByLastNumbers(const QString &ln) { return nodeToVariantMap(m_parser->findCardButtonByLastNumbers(ln)); }
|
||||||
|
QVariantMap OzonParserBridge::findButtonNode(const QString &text, bool contains) { return nodeToVariantMap(m_parser->findButtonNode(text, contains)); }
|
||||||
|
QVariantMap OzonParserBridge::findTextNode(const QString &text) { return nodeToVariantMap(m_parser->findTextNode(text)); }
|
||||||
|
|
||||||
|
QVariantMap OzonParserBridge::tryToFindGooglePlayBanner(int w, int h) { return nodeToVariantMap(m_parser->tryToFindGooglePlayBanner(w, h)); }
|
||||||
|
QVariantMap OzonParserBridge::findFirstEditText() { return nodeToVariantMap(m_parser->findFirstEditText()); }
|
||||||
|
QVariantMap OzonParserBridge::findEditTextByHint(const QString &h) { return nodeToVariantMap(m_parser->findEditTextByHint(h)); }
|
||||||
|
QVariantMap OzonParserBridge::findPanCardNumberInput() { return nodeToVariantMap(m_parser->findPanCardNumberInput()); }
|
||||||
|
QVariantMap OzonParserBridge::findMainAccountButton() { return nodeToVariantMap(m_parser->findMainAccountButton()); }
|
||||||
|
QVariantMap OzonParserBridge::findNodeByContentDesc(const QString &cd) { return nodeToVariantMap(m_parser->findNodeByContentDesc(cd)); }
|
||||||
|
|
||||||
|
QVariantList OzonParserBridge::findAllNodes() {
|
||||||
|
QVariantList list;
|
||||||
|
for (const Node &n : m_parser->findAllNodes()) list.append(nodeToVariantMap(n));
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
QVariantList OzonParserBridge::findAllButtons() {
|
||||||
|
QVariantList list;
|
||||||
|
for (const Node &n : m_parser->findAllButtons()) list.append(nodeToVariantMap(n));
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
QVariantList OzonParserBridge::tryToFindPinCode(const QString &pin, const QString &title) {
|
||||||
|
QVariantList list;
|
||||||
|
for (const Node &n : m_parser->tryToFindPinCode(pin, title)) list.append(nodeToVariantMap(n));
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
// Конверсия TransactionInfo → JS-объект. Используется парсер-методами,
|
||||||
|
// возвращающими спарсенные транзакции из XML.
|
||||||
|
QVariantMap txInfoToMap(const TransactionInfo &tx) {
|
||||||
|
QVariantMap m;
|
||||||
|
m["id"] = tx.id;
|
||||||
|
m["accountId"] = tx.accountId;
|
||||||
|
m["amount"] = tx.amount;
|
||||||
|
m["fee"] = tx.fee;
|
||||||
|
m["status"] = transactionStatusToString(tx.status);
|
||||||
|
m["type"] = transactionTypeToString(tx.type);
|
||||||
|
m["phone"] = tx.phone;
|
||||||
|
m["name"] = tx.name;
|
||||||
|
m["description"] = tx.description;
|
||||||
|
m["updatedDescription"] = tx.updatedDescription;
|
||||||
|
m["bankName"] = tx.bankName;
|
||||||
|
m["bankTime"] = tx.bankTime;
|
||||||
|
m["bankTrExternalId"] = tx.bankTrExternalId;
|
||||||
|
if (tx.timestamp.isValid()) m["timestamp"] = tx.timestamp.toString(Qt::ISODateWithMs);
|
||||||
|
if (tx.completeTime.isValid()) m["completeTime"] = tx.completeTime.toString(Qt::ISODateWithMs);
|
||||||
|
if (tx.updateTime.isValid()) m["updateTime"] = tx.updateTime.toString(Qt::ISODateWithMs);
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
bool OzonParserBridge::isTransactionListScreen() { return m_parser->isTransactionListScreen(); }
|
||||||
|
bool OzonParserBridge::hasCollapsedTransactionsBelow(int sh) { return m_parser->hasCollapsedTransactionsBelow(sh); }
|
||||||
|
|
||||||
|
QVariantList OzonParserBridge::findTransactionButtons(int maxCount) {
|
||||||
|
QVariantList list;
|
||||||
|
for (const Node &n : m_parser->findTransactionButtons(maxCount)) list.append(nodeToVariantMap(n));
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
QVariantList OzonParserBridge::getLast2DaysTransactions() {
|
||||||
|
QVariantList list;
|
||||||
|
for (const Node &n : m_parser->getLast2DaysTransactions()) list.append(nodeToVariantMap(n));
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
QVariantList OzonParserBridge::parseTodayAndYesterdayHistory() {
|
||||||
|
QVariantList list;
|
||||||
|
for (const TransactionInfo &t : m_parser->parseTodayAndYesterdayHistory()) list.append(txInfoToMap(t));
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
QVariantList OzonParserBridge::parseTodayAndYesterdayReportHistory() {
|
||||||
|
QVariantList list;
|
||||||
|
for (const TransactionInfo &t : m_parser->parseTodayAndYesterdayReportHistory()) list.append(txInfoToMap(t));
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
QVariantMap OzonParserBridge::parseTransactionInfo(int w, int h) { return txInfoToMap(m_parser->parseTransactionInfo(w, h)); }
|
||||||
|
QVariantMap OzonParserBridge::parseTransactionInfoHistory(int w, int h) { return txInfoToMap(m_parser->parseTransactionInfoHistory(w, h)); }
|
||||||
|
QVariantMap OzonParserBridge::parseTransactionDetail() { return txInfoToMap(m_parser->parseTransactionDetail()); }
|
||||||
|
QVariantMap OzonParserBridge::parseTransactionButtonText(const QString &text) { return txInfoToMap(m_parser->parseTransactionButtonText(text)); }
|
||||||
|
|
||||||
|
QVariantList OzonParserBridge::parseCardsOnHomeScreen() {
|
||||||
|
QVariantList list;
|
||||||
|
for (const MaterialInfo &c : m_parser->parseCardsOnHomeScreen()) {
|
||||||
|
QVariantMap m;
|
||||||
|
m["lastNumbers"] = c.lastNumbers;
|
||||||
|
m["description"] = c.description;
|
||||||
|
m["amount"] = c.amount;
|
||||||
|
m["currency"] = c.currency;
|
||||||
|
list.append(m);
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString OzonParserBridge::parseProfileFullName() { return m_parser->parseProfileFullName(); }
|
||||||
|
QString OzonParserBridge::parseProfilePhone() { return m_parser->parseProfilePhone(); }
|
||||||
|
QString OzonParserBridge::findFullCardNumber() { return m_parser->findFullCardNumber(); }
|
||||||
|
|
||||||
|
QVariantList OzonParserBridge::parseAccountsInfo() {
|
||||||
|
QVariantList list;
|
||||||
|
for (const auto &pair : m_parser->parseAccountsInfo()) {
|
||||||
|
QVariantMap m;
|
||||||
|
m["lastNumbers"] = pair.first.lastNumbers;
|
||||||
|
m["description"] = pair.first.description;
|
||||||
|
m["amount"] = pair.first.amount;
|
||||||
|
m["currency"] = pair.first.currency;
|
||||||
|
QVariantMap entry;
|
||||||
|
entry["material"] = m;
|
||||||
|
entry["node"] = nodeToVariantMap(pair.second);
|
||||||
|
list.append(entry);
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── OzonHelpersBridge ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
// Локальная копия Ozon::CommonScript::inputPinCode — нужна целиком в helper'е,
|
||||||
|
// чтобы не тянуть CommonScript-инстанс ради одной static-функции.
|
||||||
|
bool ozonInputPinCode(const QString &deviceId, const QList<Node> &pincode) {
|
||||||
|
for (const Node &node : pincode) {
|
||||||
|
if (!AdbUtils::makeTap(deviceId, node.x(), node.y())) {
|
||||||
|
AdbUtils::makeTap(deviceId, node.x(), node.y());
|
||||||
|
}
|
||||||
|
QThread::msleep(QRandomGenerator::global()->bounded(100, 901));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool interrupted() {
|
||||||
|
QThread *t = QThread::currentThread();
|
||||||
|
return t && t->isInterruptionRequested();
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
// Полная копия Ozon::CommonScript::runAppAndGoToHomeScreen — fast path + цикл с
|
||||||
|
// обработкой баннеров и пин-кода. Использует ТОТ ЖЕ парсер, что и JS-скрипт.
|
||||||
|
bool OzonHelpersBridge::runAppAndGoToHomeScreen(const QString &deviceId, const QString &packageName,
|
||||||
|
const QString &pinCode, int width, int height) {
|
||||||
|
Ozon::ScreenXmlParser *parser = m_parserBridge->parser();
|
||||||
|
int xmlCounter = 0;
|
||||||
|
|
||||||
|
AdbUtils::unlockScreen(deviceId, width, height);
|
||||||
|
|
||||||
|
// Fast path: если приложение уже запущено
|
||||||
|
if (const QString pid = AdbUtils::tryToGetRunningAppPid(deviceId, packageName); !pid.isEmpty()) {
|
||||||
|
qDebug() << "[js:ozon/helpers] Process already running, pid:" << pid;
|
||||||
|
parser->parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++xmlCounter));
|
||||||
|
if (parser->isHomeScreen()) {
|
||||||
|
qDebug() << "[js:ozon/helpers] Fast path: already on Home screen";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const QList<Node> fastPincode = parser->tryToFindPinCode(pinCode, QStringLiteral("Введите ПИН"));
|
||||||
|
if (fastPincode.size() == pinCode.length() && fastPincode.size() >= 4) {
|
||||||
|
qDebug() << "[js:ozon/helpers] Fast path: PIN screen, entering PIN";
|
||||||
|
if (Node wake = parser->findTextNode(QStringLiteral("Введите ПИН")); !wake.isEmpty()) {
|
||||||
|
AdbUtils::makeTap(deviceId, wake.x(), wake.y());
|
||||||
|
}
|
||||||
|
ozonInputPinCode(deviceId, fastPincode);
|
||||||
|
QThread::msleep(3000);
|
||||||
|
for (int i = 0; i < 10; ++i) {
|
||||||
|
if (interrupted()) return false;
|
||||||
|
parser->parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++xmlCounter));
|
||||||
|
if (parser->isHomeScreen()) {
|
||||||
|
qDebug() << "[js:ozon/helpers] Fast path: Home reached after PIN";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (Node refreshBtn = parser->findNodeByContentDesc(QStringLiteral("Обновить"));
|
||||||
|
refreshBtn.x() > 0 && refreshBtn.y() > 0) {
|
||||||
|
qDebug() << "[js:ozon/helpers] Fast path: page error after PIN, tapping 'Обновить'";
|
||||||
|
AdbUtils::makeTap(deviceId, refreshBtn.x(), refreshBtn.y());
|
||||||
|
QThread::msleep(5000);
|
||||||
|
}
|
||||||
|
QThread::msleep(1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
qDebug() << "[js:ozon/helpers] Fast path failed, restarting app";
|
||||||
|
AdbUtils::tryToKillApplication(deviceId, packageName);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!AdbUtils::tryToStartApplication(deviceId, packageName)) {
|
||||||
|
AdbUtils::takeScreenshot(deviceId, "run_app_error");
|
||||||
|
qWarning() << "[js:ozon/helpers] Process has not started";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
QThread::msleep(4500);
|
||||||
|
int counter = 0;
|
||||||
|
bool pinInjected = false;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
if (interrupted()) return false;
|
||||||
|
if (++counter > 10) {
|
||||||
|
AdbUtils::takeScreenshot(deviceId, "too_many_attempts");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
parser->parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++xmlCounter));
|
||||||
|
|
||||||
|
if (parser->isHomeScreen()) return true;
|
||||||
|
|
||||||
|
if (Node bannerOrDialog = parser->tryToFindBannerOrDialog(width, height);
|
||||||
|
bannerOrDialog.x() != -1 && bannerOrDialog.y() != -1) {
|
||||||
|
AdbUtils::makeTap(deviceId, bannerOrDialog.x(), bannerOrDialog.y());
|
||||||
|
QThread::msleep(1000);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Node refreshBtn = parser->findNodeByContentDesc(QStringLiteral("Обновить"));
|
||||||
|
refreshBtn.x() > 0 && refreshBtn.y() > 0) {
|
||||||
|
qDebug() << "[js:ozon/helpers] Page load error, tapping 'Обновить'";
|
||||||
|
AdbUtils::makeTap(deviceId, refreshBtn.x(), refreshBtn.y());
|
||||||
|
QThread::msleep(5000);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Node securityBanner = parser->tryToFindSecurityBanner(); !securityBanner.isEmpty()) {
|
||||||
|
AdbUtils::makeTap(deviceId, securityBanner.x(), securityBanner.y());
|
||||||
|
QThread::msleep(1000);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Node gpNode = parser->tryToFindGooglePlayBanner(width, height); !gpNode.isEmpty()) {
|
||||||
|
qDebug() << "[js:ozon/helpers] Google Play update banner detected, closing...";
|
||||||
|
AdbUtils::makeTap(deviceId, gpNode.x(), gpNode.y());
|
||||||
|
QThread::msleep(1500);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Node adNode = parser->tryToFindAdBanner(); !adNode.isEmpty()) {
|
||||||
|
qDebug() << "[js:ozon/helpers] Ad bottom sheet detected, closing...";
|
||||||
|
AdbUtils::makeTap(deviceId, adNode.x(), adNode.y());
|
||||||
|
QThread::msleep(1000);
|
||||||
|
parser->parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++xmlCounter));
|
||||||
|
if (Node stillAd = parser->tryToFindAdBanner(); !stillAd.isEmpty()) {
|
||||||
|
qDebug() << "[js:ozon/helpers] Ad still visible, pressing back...";
|
||||||
|
AdbUtils::goBack(deviceId);
|
||||||
|
QThread::msleep(1000);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pinInjected) {
|
||||||
|
const QList<Node> pincode = parser->tryToFindPinCode(pinCode, QStringLiteral("Введите ПИН"));
|
||||||
|
if (pincode.size() == pinCode.length() && pincode.size() >= 4) {
|
||||||
|
if (Node wake = parser->findTextNode(QStringLiteral("Введите ПИН")); !wake.isEmpty()) {
|
||||||
|
AdbUtils::makeTap(deviceId, wake.x(), wake.y());
|
||||||
|
}
|
||||||
|
ozonInputPinCode(deviceId, pincode);
|
||||||
|
pinInjected = true;
|
||||||
|
QThread::msleep(3000);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
QThread::msleep(1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool OzonHelpersBridge::tryToCloseAllBannersOnHomePage(const QString &deviceId, int width, int height) {
|
||||||
|
Ozon::ScreenXmlParser *parser = m_parserBridge->parser();
|
||||||
|
int xmlCounter = 0;
|
||||||
|
bool closedAny = false;
|
||||||
|
|
||||||
|
for (int i = 0; i < 5; ++i) {
|
||||||
|
if (interrupted()) return closedAny;
|
||||||
|
bool closedThis = false;
|
||||||
|
|
||||||
|
if (Node gpNode = parser->tryToFindGooglePlayBanner(width, height); !gpNode.isEmpty()) {
|
||||||
|
qDebug() << "[js:ozon/helpers] GP banner detected, closing";
|
||||||
|
AdbUtils::makeTap(deviceId, gpNode.x(), gpNode.y());
|
||||||
|
QThread::msleep(1500);
|
||||||
|
closedThis = true;
|
||||||
|
} else if (Node adNode = parser->tryToFindAdBanner(); !adNode.isEmpty()) {
|
||||||
|
qDebug() << "[js:ozon/helpers] Ad bottom sheet detected, closing";
|
||||||
|
AdbUtils::makeTap(deviceId, adNode.x(), adNode.y());
|
||||||
|
QThread::msleep(1000);
|
||||||
|
parser->parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++xmlCounter));
|
||||||
|
if (Node stillAd = parser->tryToFindAdBanner(); !stillAd.isEmpty()) {
|
||||||
|
AdbUtils::goBack(deviceId);
|
||||||
|
QThread::msleep(1000);
|
||||||
|
}
|
||||||
|
closedAny = true;
|
||||||
|
parser->parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++xmlCounter));
|
||||||
|
continue;
|
||||||
|
} else if (Node bod = parser->tryToFindBannerOrDialog(width, height);
|
||||||
|
bod.x() != -1 && bod.y() != -1) {
|
||||||
|
AdbUtils::makeTap(deviceId, bod.x(), bod.y());
|
||||||
|
QThread::msleep(500);
|
||||||
|
closedThis = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!closedThis) break;
|
||||||
|
closedAny = true;
|
||||||
|
parser->parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++xmlCounter));
|
||||||
|
}
|
||||||
|
return closedAny;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool OzonHelpersBridge::closeGooglePlayBannerIfVisible(const QString &deviceId, int width, int height) {
|
||||||
|
Ozon::ScreenXmlParser *parser = m_parserBridge->parser();
|
||||||
|
int xmlCounter = 0;
|
||||||
|
|
||||||
|
if (Node denyBtn = parser->tryToFindSystemPermissionDenyButton(); !denyBtn.isEmpty()) {
|
||||||
|
qDebug() << "[js:ozon/helpers] System permission dialog detected, tapping 'ЗАПРЕТИТЬ'";
|
||||||
|
AdbUtils::makeTap(deviceId, denyBtn.x(), denyBtn.y());
|
||||||
|
QThread::msleep(1500);
|
||||||
|
parser->parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++xmlCounter));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Node gpNode = parser->tryToFindGooglePlayBanner(width, height);
|
||||||
|
if (gpNode.isEmpty()) return false;
|
||||||
|
qDebug() << "[js:ozon/helpers] GP banner detected mid-task, closing";
|
||||||
|
AdbUtils::makeTap(deviceId, gpNode.x(), gpNode.y());
|
||||||
|
QThread::msleep(1500);
|
||||||
|
parser->parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++xmlCounter));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void OzonHelpersBridge::createBankProfileIfNeeded(const QString &deviceId, const QString &appCode, double parsedBalance) {
|
||||||
|
const BankProfileInfo app = BankProfileDAO::getApplication(deviceId, appCode);
|
||||||
|
|
||||||
|
if (app.fullName.trimmed().isEmpty() || app.phone.trimmed().isEmpty()) {
|
||||||
|
qWarning() << "[js:ozon/helpers] createBankProfileIfNeeded: skipping — fullName or phone is empty";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QMap<QString, int> currencyCodes = {
|
||||||
|
{"RUB", 643}, {"USD", 840}, {"KRW", 410}, {"AZN", 944}, {"ARS", 32}, {"TJS", 972}
|
||||||
|
};
|
||||||
|
const int currencyCode = currencyCodes.value(app.currency.toUpper(), 643);
|
||||||
|
const QStringList parts = app.fullName.split(' ', Qt::SkipEmptyParts);
|
||||||
|
|
||||||
|
const QSettings settings("config.ini", QSettings::IniFormat);
|
||||||
|
const QString phoneCode = settings.value(appCode + "/phone_code").toString();
|
||||||
|
QString phone = app.phone;
|
||||||
|
phone.remove(QRegularExpression("[^0-9]"));
|
||||||
|
if (!phoneCode.isEmpty() && !phone.startsWith(phoneCode)) {
|
||||||
|
if (phone.startsWith('8') && phoneCode != "8") {
|
||||||
|
phone.replace(0, 1, phoneCode);
|
||||||
|
} else {
|
||||||
|
phone.prepend(phoneCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
phone.prepend('+');
|
||||||
|
|
||||||
|
const DeviceInfo device = DeviceDAO::getDeviceById(deviceId);
|
||||||
|
|
||||||
|
QJsonObject profileBody;
|
||||||
|
profileBody["first_name"] = parts.value(0);
|
||||||
|
profileBody["middle_name"] = parts.size() >= 3 ? parts.value(1) : QString("");
|
||||||
|
profileBody["last_name"] = parts.size() >= 3 ? parts.value(2) : parts.value(1);
|
||||||
|
profileBody["bank_name"] = appCode;
|
||||||
|
profileBody["phone_number"] = phone;
|
||||||
|
const double balance = (parsedBalance >= 0.0) ? parsedBalance : app.amount;
|
||||||
|
profileBody["balance"] = QString::number(qRound64(balance * 100));
|
||||||
|
profileBody["state"] = (app.status == "active") ? "enabled" : "disabled";
|
||||||
|
profileBody["device_id"] = device.apiId;
|
||||||
|
profileBody["currency"] = currencyCode;
|
||||||
|
|
||||||
|
NetworkService ns;
|
||||||
|
ns.setDeviceId(deviceId);
|
||||||
|
ns.setPayload(QJsonDocument(profileBody).toJson());
|
||||||
|
|
||||||
|
if (app.bankProfileId.isEmpty()) {
|
||||||
|
ns.addExtra("app_id", app.id);
|
||||||
|
ns.postBankProfile();
|
||||||
|
} else {
|
||||||
|
ns.addExtra("bank_profile_id", app.bankProfileId);
|
||||||
|
ns.patchBankProfile();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Порт Ozon::CommonScript::goToAllProducts — для Ozon "Финансы" tab.
|
||||||
|
bool OzonHelpersBridge::goToAllProducts(const QString &deviceId, int width, int height) {
|
||||||
|
Ozon::ScreenXmlParser *parser = m_parserBridge->parser();
|
||||||
|
int xmlCounter = 0;
|
||||||
|
|
||||||
|
parser->parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, xmlCounter));
|
||||||
|
if (tryToCloseAllBannersOnHomePage(deviceId, width, height)) {
|
||||||
|
QThread::msleep(500);
|
||||||
|
parser->parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++xmlCounter));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parser->isHomeScreen()) return true;
|
||||||
|
|
||||||
|
for (int i = 0; i < 3; ++i) {
|
||||||
|
if (interrupted()) return false;
|
||||||
|
if (Node financeTab = parser->findNodeByContentDesc(QStringLiteral("Финансы"));
|
||||||
|
!financeTab.isEmpty()) {
|
||||||
|
AdbUtils::makeTap(deviceId, financeTab.x(), financeTab.y());
|
||||||
|
QThread::msleep(1500);
|
||||||
|
parser->parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++xmlCounter));
|
||||||
|
if (parser->isHomeScreen()) return true;
|
||||||
|
}
|
||||||
|
QThread::msleep(1000);
|
||||||
|
parser->parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++xmlCounter));
|
||||||
|
}
|
||||||
|
qDebug() << "[js:ozon/helpers] goToAllProducts: not home screen";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void OzonHelpersBridge::createDeviceIfNeeded(const QVariantMap &deviceObj) {
|
||||||
|
const QString apiId = deviceObj.value("apiId").toString();
|
||||||
|
if (!apiId.isEmpty()) return;
|
||||||
|
|
||||||
|
QJsonObject body;
|
||||||
|
body["desktop_id"] = SettingsDAO::get("desktop_id");
|
||||||
|
body["status"] = "ONLINE";
|
||||||
|
body["name"] = deviceObj.value("name").toString();
|
||||||
|
body["screen_width"] = deviceObj.value("screenWidth").toInt();
|
||||||
|
body["screen_height"]= deviceObj.value("screenHeight").toInt();
|
||||||
|
body["battery"] = deviceObj.value("battery").toInt();
|
||||||
|
|
||||||
|
NetworkService ns;
|
||||||
|
ns.setDeviceId(deviceObj.value("adbSerial").toString());
|
||||||
|
ns.setPayload(QJsonDocument(body).toJson());
|
||||||
|
ns.createDevice();
|
||||||
|
}
|
||||||
|
|
||||||
|
void OzonHelpersBridge::postMaterial(const QVariantMap &material, const QString &bankProfileId,
|
||||||
|
const QString &fullName) {
|
||||||
|
if (bankProfileId.isEmpty()) return;
|
||||||
|
if (material.value("cardNumber").toString().isEmpty()) return;
|
||||||
|
if (!material.value("materialId").toString().isEmpty()) return;
|
||||||
|
|
||||||
|
const QMap<QString, int> currencyCodes = {
|
||||||
|
{"RUB", 643}, {"USD", 840}, {"KRW", 410}, {"AZN", 944}, {"ARS", 32}, {"TJS", 972}
|
||||||
|
};
|
||||||
|
QJsonObject body;
|
||||||
|
body["bank_profile_id"] = bankProfileId;
|
||||||
|
body["card_number"] = material.value("cardNumber").toString();
|
||||||
|
body["account_number"] = material.value("accountNumber").toString();
|
||||||
|
body["name"] = fullName;
|
||||||
|
body["state"] = material.value("status").toString() == "active" ? "enabled" : "disabled";
|
||||||
|
body["currency"] = currencyCodes.value(material.value("currency").toString().toUpper(), 643);
|
||||||
|
|
||||||
|
NetworkService ns;
|
||||||
|
ns.setDeviceId(material.value("deviceId").toString());
|
||||||
|
ns.setPayload(QJsonDocument(body).toJson());
|
||||||
|
ns.addExtra("account_id", material.value("id").toInt());
|
||||||
|
ns.postMaterial();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool OzonHelpersBridge::isAppRunning(const QString &deviceId, const QString &packageName) {
|
||||||
|
return !AdbUtils::tryToGetRunningAppPid(deviceId, packageName).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool OzonHelpersBridge::inputPinCode(const QString &deviceId, const QVariantList &pincodeNodes) {
|
||||||
|
// Та же логика, что и Ozon::CommonScript::inputPinCode — random delay 100-900мс,
|
||||||
|
// повторный тап при первом fail.
|
||||||
|
for (const QVariant &v : pincodeNodes) {
|
||||||
|
const QVariantMap n = v.toMap();
|
||||||
|
const int x = n.value("x").toInt();
|
||||||
|
const int y = n.value("y").toInt();
|
||||||
|
if (!AdbUtils::makeTap(deviceId, x, y)) {
|
||||||
|
AdbUtils::makeTap(deviceId, x, y);
|
||||||
|
}
|
||||||
|
QThread::msleep(QRandomGenerator::global()->bounded(100, 901));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Та же логика, что FreezeDetector::isUsefulDump в Ozon::CommonScript.
|
||||||
|
// Полезный дамп должен содержать Button с непустым text ИЛИ EditText.
|
||||||
|
bool OzonHelpersBridge::isUsefulDump(const QString &xml) {
|
||||||
|
if (xml.isEmpty()) return false;
|
||||||
|
if (!xml.contains(QStringLiteral("class=\"android.widget.Button\""))) return false;
|
||||||
|
static const QRegularExpression usefulRe(
|
||||||
|
R"(class="android\.widget\.Button"[^/]*text="[^"]+"|text="[^"]+"[^/]*class="android\.widget\.Button"|class="android\.widget\.EditText")"
|
||||||
|
);
|
||||||
|
return usefulRe.match(xml).hasMatch();
|
||||||
|
}
|
||||||
|
|
||||||
|
void OzonHelpersBridge::saveTransactionReceipt(const QString &deviceId, int transactionId) {
|
||||||
|
if (transactionId < 0) return;
|
||||||
|
const QByteArray screenshot = AdbUtils::captureScreenshotBytes(deviceId);
|
||||||
|
if (screenshot.isEmpty()) return;
|
||||||
|
if (!TransactionDAO::updateReceiptImage(transactionId, screenshot)) {
|
||||||
|
qWarning() << "[js:ozon/helpers] updateReceiptImage failed for txId" << transactionId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void OzonHelpersBridge::postNewTransaction(const QVariantMap &accountMap, const QVariantMap &txMap) {
|
||||||
|
// Зеркало Ozon::CommonScript::postNewTransactionData. Шлёт через
|
||||||
|
// NetworkService::insertTransactionData в worker-потоке.
|
||||||
|
auto *thread = new QThread;
|
||||||
|
auto *worker = new NetworkService;
|
||||||
|
|
||||||
|
QJsonObject obj;
|
||||||
|
obj["id"] = txMap.value("id").toInt();
|
||||||
|
obj["amount"] = txMap.value("amount").toDouble();
|
||||||
|
obj["name"] = txMap.value("name").toString();
|
||||||
|
obj["fee"] = txMap.value("fee").toDouble();
|
||||||
|
obj["status"] = txMap.value("status").toString();
|
||||||
|
obj["type"] = txMap.value("type").toString();
|
||||||
|
obj["phone"] = txMap.value("phone").toString();
|
||||||
|
obj["description"] = txMap.value("description").toString();
|
||||||
|
obj["updatedDescription"] = txMap.value("updatedDescription").toString();
|
||||||
|
obj["bankName"] = txMap.value("bankName").toString();
|
||||||
|
obj["bankTime"] = txMap.value("bankTime").toString();
|
||||||
|
obj["bankTransactionId"] = txMap.value("bankTrExternalId").toString();
|
||||||
|
|
||||||
|
worker->setDeviceId(accountMap.value("deviceId").toString());
|
||||||
|
worker->addExtra("lastNumbers", accountMap.value("lastNumbers").toString());
|
||||||
|
worker->addExtra("transactionId", txMap.value("id").toInt());
|
||||||
|
worker->setPayload(QJsonDocument(obj).toJson());
|
||||||
|
worker->moveToThread(thread);
|
||||||
|
QObject::connect(thread, &QThread::started, worker, &NetworkService::insertTransactionData);
|
||||||
|
QObject::connect(worker, &NetworkService::finished, thread, &QThread::quit);
|
||||||
|
QObject::connect(worker, &NetworkService::finished, worker, &QObject::deleteLater);
|
||||||
|
QObject::connect(thread, &QThread::finished, thread, &QThread::deleteLater);
|
||||||
|
thread->start();
|
||||||
|
}
|
||||||
|
|
||||||
|
void OzonHelpersBridge::postAccountsData(const QVariantList &accounts) {
|
||||||
|
if (accounts.isEmpty()) return;
|
||||||
|
auto *thread = new QThread;
|
||||||
|
auto *worker = new NetworkService;
|
||||||
|
|
||||||
|
QJsonArray list;
|
||||||
|
QString deviceId;
|
||||||
|
for (const QVariant &v : accounts) {
|
||||||
|
const QVariantMap acc = v.toMap();
|
||||||
|
QJsonObject obj;
|
||||||
|
obj["appName"] = acc.value("appCode").toString();
|
||||||
|
obj["lastNumbers"] = acc.value("lastNumbers").toString();
|
||||||
|
obj["amount"] = acc.value("amount").toDouble();
|
||||||
|
obj["description"] = acc.value("description").toString();
|
||||||
|
obj["currency"] = acc.value("currency").toString();
|
||||||
|
obj["status"] = acc.value("status").toString();
|
||||||
|
list.append(obj);
|
||||||
|
if (deviceId.isEmpty()) deviceId = acc.value("deviceId").toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
worker->setDeviceId(deviceId);
|
||||||
|
worker->setPayload(QJsonDocument(list).toJson());
|
||||||
|
worker->moveToThread(thread);
|
||||||
|
QObject::connect(thread, &QThread::started, worker, &NetworkService::postAccountsData);
|
||||||
|
QObject::connect(worker, &NetworkService::finished, thread, &QThread::quit);
|
||||||
|
QObject::connect(worker, &NetworkService::finished, worker, &QObject::deleteLater);
|
||||||
|
QObject::connect(thread, &QThread::finished, thread, &QThread::deleteLater);
|
||||||
|
thread->start();
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace Scripting
|
} // namespace Scripting
|
||||||
|
|||||||
@ -10,6 +10,7 @@
|
|||||||
class QJSEngine;
|
class QJSEngine;
|
||||||
|
|
||||||
namespace Black { class ScreenXmlParser; }
|
namespace Black { class ScreenXmlParser; }
|
||||||
|
namespace Ozon { class ScreenXmlParser; }
|
||||||
|
|
||||||
namespace Scripting {
|
namespace Scripting {
|
||||||
|
|
||||||
@ -83,6 +84,23 @@ public slots:
|
|||||||
|
|
||||||
// MaterialDAO
|
// MaterialDAO
|
||||||
QVariantList materialGetAccounts(const QString &deviceId, const QString &appCode);
|
QVariantList materialGetAccounts(const QString &deviceId, const QString &appCode);
|
||||||
|
QVariantMap materialFindAppAccount(const QString &deviceId, const QString &appCode,
|
||||||
|
const QString &lastNumbers);
|
||||||
|
bool materialUpsert(const QVariantMap &material);
|
||||||
|
bool materialUpdateCardNumber(int accountId, const QString &cardNumber);
|
||||||
|
bool materialUpdateAccountById(int id, const QString &status,
|
||||||
|
const QString &description, const QString ¤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).
|
// Скоуп дампа задачи. JS-style API: begin/end вместо RAII (вешаем на try/finally).
|
||||||
@ -100,6 +118,35 @@ private:
|
|||||||
QString *m_errorPtr = nullptr;
|
QString *m_errorPtr = nullptr;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Прокси к C++ скрипту-родителю: позволяет JS эмитить Qt-сигналы на исходном
|
||||||
|
// скрипте (например, transactionParsed → EventHandler). Если signalSink не
|
||||||
|
// передан в ScriptRuntime::run — методы no-op (но не throw).
|
||||||
|
class ScriptObjectBridge final : public QObject {
|
||||||
|
Q_OBJECT
|
||||||
|
public:
|
||||||
|
explicit ScriptObjectBridge(QObject *sink = nullptr, QObject *parent = nullptr)
|
||||||
|
: QObject(parent), m_sink(sink) {}
|
||||||
|
public slots:
|
||||||
|
// Парсит jsonStr и вызывает на m_sink сигнал/слот `transactionParsed(QJsonObject)`.
|
||||||
|
// Если sink нет или JSON битый — лог warning, продолжение работы.
|
||||||
|
void emitTransactionParsed(const QString &jsonStr);
|
||||||
|
private:
|
||||||
|
QObject *m_sink;
|
||||||
|
};
|
||||||
|
|
||||||
|
// OCR-хелперы: распознавание текста на скриншоте и извлечение ID транзакции.
|
||||||
|
// Реализация в android/ocr/OcrUtils — здесь чистый wrapper для JS.
|
||||||
|
class OcrBridge final : public QObject {
|
||||||
|
Q_OBJECT
|
||||||
|
public:
|
||||||
|
explicit OcrBridge(QObject *parent = nullptr) : QObject(parent) {}
|
||||||
|
public slots:
|
||||||
|
// Захватывает экран и прогоняет через OCR (grayscale + 300dpi, rus+eng).
|
||||||
|
QString recognizeFromScreen(const QString &deviceId);
|
||||||
|
// Извлекает "ID операции ..." / "№ Ф-..." из распознанного OCR-текста.
|
||||||
|
QString extractTransactionId(const QString &ocrText);
|
||||||
|
};
|
||||||
|
|
||||||
// Парсер экрана для банка Black. Каждый запуск скрипта получает свой инстанс,
|
// Парсер экрана для банка Black. Каждый запуск скрипта получает свой инстанс,
|
||||||
// поэтому m_xml/m_nodes не разделяются между задачами.
|
// поэтому m_xml/m_nodes не разделяются между задачами.
|
||||||
class BlackParserBridge final : public QObject {
|
class BlackParserBridge final : public QObject {
|
||||||
@ -167,6 +214,108 @@ private:
|
|||||||
BlackParserBridge *m_parserBridge;
|
BlackParserBridge *m_parserBridge;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Парсер экрана для банка Ozon. Минимальный набор для get_profile_info — при
|
||||||
|
// миграции login_and_check / get_card_info добавлять методы сюда же.
|
||||||
|
class OzonParserBridge final : public QObject {
|
||||||
|
Q_OBJECT
|
||||||
|
public:
|
||||||
|
explicit OzonParserBridge(QObject *parent = nullptr);
|
||||||
|
~OzonParserBridge() override;
|
||||||
|
|
||||||
|
Ozon::ScreenXmlParser *parser() const { return m_parser; }
|
||||||
|
|
||||||
|
public slots:
|
||||||
|
void parseAndSaveXml(const QString &xml);
|
||||||
|
|
||||||
|
bool isHomeScreen();
|
||||||
|
bool isProfileScreen();
|
||||||
|
|
||||||
|
QVariantMap tryToFindAdBanner();
|
||||||
|
QVariantMap tryToFindGooglePlayBanner(int screenWidth, int screenHeight);
|
||||||
|
QVariantMap findUserNameOnHomeScreen(int screenHeight);
|
||||||
|
QVariantMap findCardButtonByLastNumbers(const QString &lastNumbers);
|
||||||
|
QVariantMap findButtonNode(const QString &text, bool contains);
|
||||||
|
QVariantMap findTextNode(const QString &text);
|
||||||
|
QVariantMap findFirstEditText();
|
||||||
|
QVariantMap findEditTextByHint(const QString &hintPrefix);
|
||||||
|
QVariantMap findPanCardNumberInput();
|
||||||
|
QVariantMap findMainAccountButton();
|
||||||
|
QVariantMap findNodeByContentDesc(const QString &contentDesc);
|
||||||
|
|
||||||
|
// Все Node'ы текущего дампа как массив map — нужно для JS-итерации в
|
||||||
|
// payment-скриптах (поиск EditText'ов с input___ rid, других банков по
|
||||||
|
// content-desc="other-bank-item" и т.д.).
|
||||||
|
QVariantList findAllNodes();
|
||||||
|
QVariantList findAllButtons();
|
||||||
|
|
||||||
|
// pinCode + title — возвращает массив node-маn в порядке цифр для тапа.
|
||||||
|
QVariantList tryToFindPinCode(const QString &pinCode, const QString &title);
|
||||||
|
|
||||||
|
// Карточки с главного экрана: lastNumbers, amount, description, currency.
|
||||||
|
// id == -1, materialId пусто — это стабы (используются для парсинга баланса).
|
||||||
|
QVariantList parseCardsOnHomeScreen();
|
||||||
|
|
||||||
|
// Аккаунты на экране "Все продукты" — массив {material: {...}, node: {...}}.
|
||||||
|
QVariantList parseAccountsInfo();
|
||||||
|
|
||||||
|
// Для GetLastTransactionsScript:
|
||||||
|
bool isTransactionListScreen();
|
||||||
|
bool hasCollapsedTransactionsBelow(int screenHeight);
|
||||||
|
QVariantList findTransactionButtons(int maxCount);
|
||||||
|
QVariantList getLast2DaysTransactions();
|
||||||
|
QVariantList parseTodayAndYesterdayHistory();
|
||||||
|
QVariantList parseTodayAndYesterdayReportHistory();
|
||||||
|
QVariantMap parseTransactionInfo(int screenWidth, int screenHeight);
|
||||||
|
QVariantMap parseTransactionInfoHistory(int screenWidth, int screenHeight);
|
||||||
|
QVariantMap parseTransactionDetail();
|
||||||
|
QVariantMap parseTransactionButtonText(const QString &text);
|
||||||
|
|
||||||
|
QString parseProfileFullName();
|
||||||
|
QString parseProfilePhone();
|
||||||
|
QString findFullCardNumber();
|
||||||
|
|
||||||
|
private:
|
||||||
|
Ozon::ScreenXmlParser *m_parser;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Высокоуровневые операции, повторяющие Ozon::CommonScript: фактический
|
||||||
|
// запуск приложения, закрытие баннеров, регистрация bank_profile на сервере,
|
||||||
|
// постинг аккаунтов. Используют parser() из OzonParserBridge — состояние XML
|
||||||
|
// общее, как и у Black.
|
||||||
|
class OzonHelpersBridge final : public QObject {
|
||||||
|
Q_OBJECT
|
||||||
|
public:
|
||||||
|
OzonHelpersBridge(OzonParserBridge *parser, QObject *parent = nullptr)
|
||||||
|
: QObject(parent), m_parserBridge(parser) {}
|
||||||
|
public slots:
|
||||||
|
bool runAppAndGoToHomeScreen(const QString &deviceId, const QString &packageName,
|
||||||
|
const QString &pinCode, int width, int height);
|
||||||
|
bool tryToCloseAllBannersOnHomePage(const QString &deviceId, int width, int height);
|
||||||
|
bool closeGooglePlayBannerIfVisible(const QString &deviceId, int width, int height);
|
||||||
|
bool goToAllProducts(const QString &deviceId, int width, int height);
|
||||||
|
void createBankProfileIfNeeded(const QString &deviceId, const QString &appCode, double parsedBalance);
|
||||||
|
void postAccountsData(const QVariantList &accountsWithEmptyMaterialId);
|
||||||
|
// POST /api/v1/device — создаёт устройство на сервере, если apiId пуст.
|
||||||
|
// deviceObj: { id, name, screenWidth, screenHeight, battery, apiId } (как из dao.deviceGetById).
|
||||||
|
void createDeviceIfNeeded(const QVariantMap &deviceObj);
|
||||||
|
// POST /api/v1/profile/material — отправляет одну карту на сервер.
|
||||||
|
// material: полный MaterialInfo-объект как из dao.materialFindAppAccount.
|
||||||
|
void postMaterial(const QVariantMap &material, const QString &bankProfileId, const QString &fullName);
|
||||||
|
// Для платёжных скриптов:
|
||||||
|
bool isAppRunning(const QString &deviceId, const QString &packageName);
|
||||||
|
// pincodeNodes: список node-map из parser.tryToFindPinCode.
|
||||||
|
bool inputPinCode(const QString &deviceId, const QVariantList &pincodeNodes);
|
||||||
|
// FreezeDetector::isUsefulDump — статически-чистая проверка.
|
||||||
|
bool isUsefulDump(const QString &xml);
|
||||||
|
// Захват скриншота + сохранение в TransactionDAO как receipt-image.
|
||||||
|
void saveTransactionReceipt(const QString &deviceId, int transactionId);
|
||||||
|
// POST /api/v1/profile/transaction — вставка новой транзакции (для GetLastTx).
|
||||||
|
// tx: как в dao.transactionInsert + account: {appCode, deviceId, lastNumbers}.
|
||||||
|
void postNewTransaction(const QVariantMap &account, const QVariantMap &tx);
|
||||||
|
private:
|
||||||
|
OzonParserBridge *m_parserBridge;
|
||||||
|
};
|
||||||
|
|
||||||
// Утилиты конверсии Node ↔ QVariantMap. Объявлены здесь чтобы парсер-бридж
|
// Утилиты конверсии Node ↔ QVariantMap. Объявлены здесь чтобы парсер-бридж
|
||||||
// мог их использовать без зависимости от QJSEngine.
|
// мог их использовать без зависимости от QJSEngine.
|
||||||
QVariantMap nodeToVariantMap(const Node &node);
|
QVariantMap nodeToVariantMap(const Node &node);
|
||||||
|
|||||||
@ -40,7 +40,8 @@ QString locationOf(const QJSValue &err) {
|
|||||||
ScriptRuntime::Result ScriptRuntime::run(const QString &bank,
|
ScriptRuntime::Result ScriptRuntime::run(const QString &bank,
|
||||||
const QString &scriptName,
|
const QString &scriptName,
|
||||||
const QVariantMap ¶ms,
|
const QVariantMap ¶ms,
|
||||||
QString *outResult) {
|
QString *outResult,
|
||||||
|
QObject *signalSink) {
|
||||||
QString sink;
|
QString sink;
|
||||||
if (!outResult) outResult = &sink;
|
if (!outResult) outResult = &sink;
|
||||||
*outResult = QString();
|
*outResult = QString();
|
||||||
@ -78,14 +79,21 @@ ScriptRuntime::Result ScriptRuntime::run(const QString &bank,
|
|||||||
auto *adb = new AdbBridge();
|
auto *adb = new AdbBridge();
|
||||||
auto *dao = new DaoBridge();
|
auto *dao = new DaoBridge();
|
||||||
auto *dump = new DumpBridge();
|
auto *dump = new DumpBridge();
|
||||||
|
auto *ocr = new OcrBridge();
|
||||||
|
auto *scriptObj = new ScriptObjectBridge(signalSink);
|
||||||
|
|
||||||
// Bank-specific bridges. На PoC поддержан только Black.
|
// Bank-specific bridges. Поддержаны: Black (полный набор), Ozon (минимум
|
||||||
|
// для get_profile_info — расширяется по мере миграции login_and_check / get_card_info).
|
||||||
QObject *parserBridge = nullptr;
|
QObject *parserBridge = nullptr;
|
||||||
QObject *helpersBridge = nullptr;
|
QObject *helpersBridge = nullptr;
|
||||||
if (bank == QLatin1String("black")) {
|
if (bank == QLatin1String("black")) {
|
||||||
auto *pb = new BlackParserBridge();
|
auto *pb = new BlackParserBridge();
|
||||||
parserBridge = pb;
|
parserBridge = pb;
|
||||||
helpersBridge = new BlackHelpersBridge(pb);
|
helpersBridge = new BlackHelpersBridge(pb);
|
||||||
|
} else if (bank == QLatin1String("ozon")) {
|
||||||
|
auto *pb = new OzonParserBridge();
|
||||||
|
parserBridge = pb;
|
||||||
|
helpersBridge = new OzonHelpersBridge(pb);
|
||||||
}
|
}
|
||||||
|
|
||||||
QJSValue host = engine.newObject();
|
QJSValue host = engine.newObject();
|
||||||
@ -94,6 +102,8 @@ ScriptRuntime::Result ScriptRuntime::run(const QString &bank,
|
|||||||
host.setProperty("adb", engine.newQObject(adb));
|
host.setProperty("adb", engine.newQObject(adb));
|
||||||
host.setProperty("dao", engine.newQObject(dao));
|
host.setProperty("dao", engine.newQObject(dao));
|
||||||
host.setProperty("dump", engine.newQObject(dump));
|
host.setProperty("dump", engine.newQObject(dump));
|
||||||
|
host.setProperty("ocr", engine.newQObject(ocr));
|
||||||
|
host.setProperty("script", engine.newQObject(scriptObj));
|
||||||
if (parserBridge) host.setProperty("parser", engine.newQObject(parserBridge));
|
if (parserBridge) host.setProperty("parser", engine.newQObject(parserBridge));
|
||||||
if (helpersBridge) host.setProperty("helpers", engine.newQObject(helpersBridge));
|
if (helpersBridge) host.setProperty("helpers", engine.newQObject(helpersBridge));
|
||||||
|
|
||||||
|
|||||||
@ -3,6 +3,8 @@
|
|||||||
#include <QString>
|
#include <QString>
|
||||||
#include <QVariantMap>
|
#include <QVariantMap>
|
||||||
|
|
||||||
|
class QObject;
|
||||||
|
|
||||||
namespace Scripting {
|
namespace Scripting {
|
||||||
|
|
||||||
class ScriptRuntime {
|
class ScriptRuntime {
|
||||||
@ -25,10 +27,17 @@ public:
|
|||||||
// EventHandler'а — там разрешён блокирующий sleep и DAO).
|
// EventHandler'а — там разрешён блокирующий sleep и DAO).
|
||||||
//
|
//
|
||||||
// params видны JS как host.params.<key>.
|
// params видны JS как host.params.<key>.
|
||||||
|
//
|
||||||
|
// signalSink — опциональный QObject, на котором JS может эмитить сигналы
|
||||||
|
// через host.script.emit<SignalName>(jsonStr). Используется для скриптов
|
||||||
|
// вроде GetLastTransactionsScript, которые транслируют распарсенные
|
||||||
|
// транзакции в EventHandler через Qt-сигнал. Передавать `this` из C++
|
||||||
|
// оригинального скрипта.
|
||||||
static Result run(const QString &bank,
|
static Result run(const QString &bank,
|
||||||
const QString &scriptName,
|
const QString &scriptName,
|
||||||
const QVariantMap ¶ms,
|
const QVariantMap ¶ms,
|
||||||
QString *outResult);
|
QString *outResult,
|
||||||
|
QObject *signalSink = nullptr);
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace Scripting
|
} // namespace Scripting
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user