Implemented `PayByCardScript` to enable automated card payments on the Ozon platform. Includes ad banner detection/handling, OCR-based transaction ID extraction, fee calculation, and database updates for transactions. Added XML asset `pay_by_card_success.xml` for payment result handling.
376 lines
16 KiB
C++
376 lines
16 KiB
C++
#include "PayByCardScript.h"
|
||
|
||
#include <QProcess>
|
||
#include <QRegularExpression>
|
||
#include <QThread>
|
||
#include <QTimeZone>
|
||
#include <utility>
|
||
|
||
#include <tesseract/baseapi.h>
|
||
#include <leptonica/allheaders.h>
|
||
|
||
#include "adb/AdbUtils.h"
|
||
#include "dao/AccountDAO.h"
|
||
#include "dao/ApplicationDAO.h"
|
||
#include "dao/DeviceDAO.h"
|
||
#include "dao/TransactionDAO.h"
|
||
#include "time/DateUtils.h"
|
||
|
||
static QString recognizeTextFromImage(const QByteArray &pngData) {
|
||
auto *api = new tesseract::TessBaseAPI();
|
||
if (api->Init(nullptr, "rus+eng")) {
|
||
qWarning() << "[OCR] 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() << "[OCR] Failed to read image";
|
||
api->End();
|
||
delete api;
|
||
return {};
|
||
}
|
||
|
||
api->SetImage(pix);
|
||
char *text = api->GetUTF8Text();
|
||
QString result = QString::fromUtf8(text).trimmed();
|
||
delete[] text;
|
||
pixDestroy(&pix);
|
||
api->End();
|
||
delete api;
|
||
|
||
return result;
|
||
}
|
||
|
||
static QString extractTransactionId(const QString &ocrText) {
|
||
// "ID операции A60130542076330K0000040011670301" или "ID операции = Аб60650..."
|
||
QRegularExpression reId(R"(ID\s+операции\s*=?\s*([A-Za-zА-Яа-я0-9]+))");
|
||
if (auto m = reId.match(ocrText); m.hasMatch()) return m.captured(1);
|
||
|
||
// "№ Ф-2026-25235663"
|
||
QRegularExpression reRef(R"(№\s*(Ф-[\d-]+))");
|
||
if (auto m = reRef.match(ocrText); m.hasMatch()) return m.captured(1);
|
||
|
||
return {};
|
||
}
|
||
|
||
namespace Ozon {
|
||
|
||
PayByCardScript::PayByCardScript(
|
||
AccountInfo account,
|
||
QString cardNumber,
|
||
const double amount,
|
||
QObject *parent
|
||
) : CommonScript(parent),
|
||
m_account(std::move(account)), m_cardNumber(std::move(cardNumber)),
|
||
m_amount(amount) {
|
||
}
|
||
|
||
PayByCardScript::~PayByCardScript() = default;
|
||
|
||
void PayByCardScript::doStart() {
|
||
const DeviceInfo device = DeviceDAO::getDeviceById(m_account.deviceId);
|
||
const ApplicationInfo app = ApplicationDAO::getApplication(m_account.deviceId, m_account.appCode);
|
||
|
||
if (device.id.isEmpty() || app.id == -1) {
|
||
m_error = "Device or app not found: " + m_account.deviceId;
|
||
qCritical() << m_error;
|
||
emit finishedWithResult(m_error);
|
||
return;
|
||
}
|
||
|
||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(device.id, ++m_counter));
|
||
|
||
// Закрываем рекламный bottom sheet
|
||
if (Node adNode = xmlScreenParser.tryToFindAdBanner(); !adNode.isEmpty()) {
|
||
qDebug() << "[PayByCard] Ad banner detected, closing...";
|
||
AdbUtils::makeTap(device.id, adNode.x(), adNode.y());
|
||
QThread::msleep(1000);
|
||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(device.id, ++m_counter));
|
||
}
|
||
|
||
// Если не на домашнем экране — перезапускаем
|
||
if (!xmlScreenParser.isHomeScreen()) {
|
||
qDebug() << "[PayByCard] Not on home screen, restarting app...";
|
||
if (!runAppAndGoToHomeScreen(device.id, app.package, app.pinCode,
|
||
device.screenWidth, device.screenHeight)) {
|
||
m_error = "Cannot reach home screen: " + device.id;
|
||
qWarning() << m_error;
|
||
AdbUtils::tryToKillApplication(device.id, app.package);
|
||
emit finishedWithResult(m_error);
|
||
return;
|
||
}
|
||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(device.id, ++m_counter));
|
||
}
|
||
|
||
// Закрываем баннеры
|
||
tryToCloseAllBannersOnHomePage(device.id, device.screenWidth, device.screenHeight);
|
||
QThread::msleep(500);
|
||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(device.id, ++m_counter));
|
||
|
||
// Закрываем баннер Google Play "Доступно обновление"
|
||
if (Node gpNode = xmlScreenParser.tryToFindGooglePlayBanner(); !gpNode.isEmpty()) {
|
||
qDebug() << "[PayByCard] Google Play update banner detected, closing...";
|
||
AdbUtils::makeTap(device.id, gpNode.x(), gpNode.y());
|
||
QThread::msleep(1500);
|
||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(device.id, ++m_counter));
|
||
}
|
||
|
||
// Закрываем баннер "Заказать карту" — тапаем сверху экрана
|
||
if (Node orderCardNode = xmlScreenParser.findTextNode("Заказать карту"); !orderCardNode.isEmpty()) {
|
||
qDebug() << "[PayByCard] 'Заказать карту' banner detected, closing...";
|
||
AdbUtils::makeTap(device.id, device.screenWidth / 2, 100);
|
||
QThread::msleep(1000);
|
||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(device.id, ++m_counter));
|
||
}
|
||
|
||
makePayment(device.id, app.pinCode, device.screenWidth, device.screenHeight);
|
||
|
||
emit finishedWithResult(m_error);
|
||
}
|
||
|
||
void PayByCardScript::makePayment(
|
||
const QString &deviceId,
|
||
const QString &pinCode,
|
||
const int width,
|
||
const int height
|
||
) {
|
||
// 1. Нажимаем "Перевести" на домашнем экране
|
||
Node transferBtn = xmlScreenParser.findButtonNode("Перевести", false);
|
||
if (transferBtn.isEmpty()) {
|
||
m_error = "Кнопка 'Перевести' не найдена на домашнем экране";
|
||
qWarning() << "[PayByCard]" << m_error;
|
||
return;
|
||
}
|
||
|
||
qDebug() << "[PayByCard] Tapping 'Перевести'...";
|
||
AdbUtils::makeTap(deviceId, transferBtn.x(), transferBtn.y());
|
||
QThread::msleep(2000);
|
||
|
||
// 2. Ждём экран "Платежи и переводы"
|
||
bool paymentsPageLoaded = false;
|
||
for (int attempt = 0; attempt < 10; ++attempt) {
|
||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
|
||
Node title = xmlScreenParser.findTextNode("Платежи и переводы");
|
||
if (!title.isEmpty()) {
|
||
paymentsPageLoaded = true;
|
||
break;
|
||
}
|
||
qDebug() << "[PayByCard] Waiting for 'Платежи и переводы', attempt" << attempt + 1 << "/ 10";
|
||
QThread::msleep(1500);
|
||
}
|
||
|
||
if (!paymentsPageLoaded) {
|
||
m_error = "Экран 'Платежи и переводы' не загрузился";
|
||
qWarning() << "[PayByCard]" << m_error;
|
||
return;
|
||
}
|
||
|
||
// 3. Нажимаем "По номеру карты" в секции "ДРУГИЕ СПОСОБЫ"
|
||
Node cardBtn = xmlScreenParser.findButtonNode("По номеру карты", false);
|
||
if (cardBtn.isEmpty()) {
|
||
m_error = "Кнопка 'По номеру карты' не найдена на экране платежей";
|
||
qWarning() << "[PayByCard]" << m_error;
|
||
return;
|
||
}
|
||
|
||
qDebug() << "[PayByCard] Tapping 'По номеру карты'...";
|
||
AdbUtils::makeTap(deviceId, cardBtn.x(), cardBtn.y());
|
||
QThread::msleep(2000);
|
||
|
||
// 4. Ждём экран "Перевод по номеру карты"
|
||
bool cardScreenLoaded = false;
|
||
for (int attempt = 0; attempt < 10; ++attempt) {
|
||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
|
||
Node title = xmlScreenParser.findTextNode("Перевод по номеру карты");
|
||
if (!title.isEmpty()) {
|
||
cardScreenLoaded = true;
|
||
break;
|
||
}
|
||
qDebug() << "[PayByCard] Waiting for 'Перевод по номеру карты', attempt" << attempt + 1 << "/ 10";
|
||
QThread::msleep(1500);
|
||
}
|
||
|
||
if (!cardScreenLoaded) {
|
||
m_error = "Экран 'Перевод по номеру карты' не загрузился";
|
||
qWarning() << "[PayByCard]" << m_error;
|
||
return;
|
||
}
|
||
|
||
qDebug() << "[PayByCard] Card payment screen loaded successfully";
|
||
|
||
// 5. Вводим номер карты в поле "Номер карты получателя"
|
||
Node cardInput = xmlScreenParser.findEditTextByHint("Номер карты получателя");
|
||
if (cardInput.isEmpty()) {
|
||
m_error = "Поле 'Номер карты получателя' не найдено";
|
||
qWarning() << "[PayByCard]" << m_error;
|
||
return;
|
||
}
|
||
|
||
AdbUtils::makeTap(deviceId, cardInput.x(), cardInput.y());
|
||
QThread::msleep(500);
|
||
AdbUtils::inputText(deviceId, m_cardNumber);
|
||
QThread::msleep(2000);
|
||
|
||
// 6. Ждём распознавания карты и вводим сумму
|
||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
|
||
|
||
Node sumInput = xmlScreenParser.findEditTextByHint("Сумма от");
|
||
if (sumInput.isEmpty()) {
|
||
m_error = "Поле ввода суммы не найдено";
|
||
qWarning() << "[PayByCard]" << m_error;
|
||
return;
|
||
}
|
||
|
||
AdbUtils::makeTap(deviceId, sumInput.x(), sumInput.y());
|
||
QThread::msleep(500);
|
||
AdbUtils::inputText(deviceId, QString::number(static_cast<int>(m_amount)));
|
||
QThread::msleep(1000);
|
||
|
||
qDebug() << "[PayByCard] Card number and amount entered";
|
||
|
||
// 7. Ждём расчёта комиссии (текст после "Комиссия" перестаёт быть "Рассчитаем...")
|
||
double fee = 0;
|
||
bool feeCalculated = false;
|
||
for (int attempt = 0; attempt < 15; ++attempt) {
|
||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
|
||
|
||
bool foundKomissiya = false;
|
||
for (const Node &node : xmlScreenParser.findAllNodes()) {
|
||
if (node.className == "android.widget.TextView" && node.text.trimmed() == "Комиссия") {
|
||
foundKomissiya = true;
|
||
continue;
|
||
}
|
||
if (foundKomissiya && node.className == "android.widget.TextView" && !node.text.isEmpty()) {
|
||
const QString feeText = node.text.trimmed();
|
||
if (feeText.startsWith("Рассчитаем")) {
|
||
foundKomissiya = false;
|
||
break; // ещё не рассчитана
|
||
}
|
||
// Парсим "45 ₽" → 45.0
|
||
QString digits = feeText;
|
||
digits.remove(QRegularExpression("[^0-9,\\.]"));
|
||
digits.replace(',', '.');
|
||
fee = digits.toDouble();
|
||
feeCalculated = true;
|
||
qDebug() << "[PayByCard] Fee parsed:" << fee << "from text:" << feeText;
|
||
break;
|
||
}
|
||
}
|
||
if (feeCalculated) break;
|
||
qDebug() << "[PayByCard] Waiting for fee calculation, attempt" << attempt + 1 << "/ 15";
|
||
QThread::msleep(1500);
|
||
}
|
||
|
||
if (!feeCalculated) {
|
||
qWarning() << "[PayByCard] Fee not calculated after timeout, proceeding with fee=0";
|
||
}
|
||
|
||
// 8. Нажимаем "Перевести" (или "Перевести X ₽")
|
||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
|
||
Node transferBtn2 = xmlScreenParser.findButtonNode("Перевести", true);
|
||
if (transferBtn2.isEmpty()) {
|
||
m_error = "Кнопка 'Перевести' не найдена на экране ввода суммы";
|
||
qWarning() << "[PayByCard]" << m_error;
|
||
return;
|
||
}
|
||
|
||
qDebug() << "[PayByCard] Tapping transfer button:" << transferBtn2.text;
|
||
AdbUtils::makeTap(deviceId, transferBtn2.x(), transferBtn2.y());
|
||
QThread::msleep(3000);
|
||
|
||
// 9. Ждём результат: "Успешно" или "Не удалось"
|
||
for (int attempt = 0; attempt < 15; ++attempt) {
|
||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
|
||
|
||
if (!xmlScreenParser.findTextNode("Успешно").isEmpty()) {
|
||
qDebug() << "[PayByCard] Payment successful!";
|
||
m_error.clear();
|
||
|
||
// Сохраняем транзакцию в БД
|
||
TransactionInfo tx;
|
||
tx.accountId = m_account.id;
|
||
tx.amount = -m_amount;
|
||
tx.fee = fee;
|
||
tx.phone = m_cardNumber;
|
||
tx.bankName = "ozon";
|
||
tx.type = TransactionType::Card;
|
||
tx.status = TransactionStatus::Complete;
|
||
tx.timestamp = DateUtils::getUtcNow();
|
||
tx.completeTime = QDateTime::currentDateTime().toTimeZone(QTimeZone("Europe/Moscow"));
|
||
tx.bankTime = tx.completeTime.toString("dd.MM.yyyy, HH:mm");
|
||
const int txId = TransactionDAO::insertTransaction(tx);
|
||
qDebug() << "[PayByCard] Saved transaction to DB, id:" << txId << "fee:" << fee;
|
||
|
||
// Ищем кнопку "Документы" и достаём ID операции
|
||
Node docBtn = xmlScreenParser.findButtonNode("Документы", false);
|
||
if (!docBtn.isEmpty() && txId != -1) {
|
||
qDebug() << "[PayByCard] Tapping 'Документы'...";
|
||
AdbUtils::makeTap(deviceId, docBtn.x(), docBtn.y());
|
||
QThread::msleep(2000);
|
||
|
||
// Ждём появления кнопки "Поделиться"
|
||
bool docLoaded = false;
|
||
for (int da = 0; da < 20; ++da) {
|
||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
|
||
Node shareBtn = xmlScreenParser.findNodeByContentDesc("Поделиться");
|
||
if (!shareBtn.isEmpty()) {
|
||
docLoaded = true;
|
||
break;
|
||
}
|
||
qDebug() << "[PayByCard] Waiting for 'Поделиться', attempt" << da + 1 << "/ 20";
|
||
QThread::msleep(1500);
|
||
}
|
||
|
||
if (docLoaded) {
|
||
QThread::msleep(500);
|
||
const QByteArray screenshot = AdbUtils::captureScreenshotBytes(deviceId);
|
||
if (!screenshot.isEmpty()) {
|
||
TransactionDAO::updateReceiptImage(txId, screenshot);
|
||
qDebug() << "[PayByCard] Receipt saved to DB, txId:" << txId;
|
||
|
||
const QString ocrText = recognizeTextFromImage(screenshot);
|
||
if (!ocrText.isEmpty()) {
|
||
qDebug() << "[PayByCard] OCR result:\n" << ocrText;
|
||
const QString externalId = extractTransactionId(ocrText);
|
||
if (!externalId.isEmpty()) {
|
||
TransactionDAO::updateBankTrExternalId(txId, externalId);
|
||
qDebug() << "[PayByCard] Extracted transaction ID:" << externalId;
|
||
} else {
|
||
qWarning() << "[PayByCard] Could not extract transaction ID from OCR text";
|
||
}
|
||
} else {
|
||
qWarning() << "[PayByCard] OCR returned empty text";
|
||
}
|
||
} else {
|
||
qWarning() << "[PayByCard] Screenshot is empty";
|
||
}
|
||
} else {
|
||
qWarning() << "[PayByCard] 'Поделиться' not found, skipping receipt";
|
||
}
|
||
|
||
AdbUtils::goBack(deviceId);
|
||
QThread::msleep(1000);
|
||
}
|
||
|
||
return;
|
||
}
|
||
|
||
if (!xmlScreenParser.findTextNode("Не удалось").isEmpty()) {
|
||
m_error = "Перевод не удался (экран 'Не удалось')";
|
||
qWarning() << "[PayByCard]" << m_error;
|
||
return;
|
||
}
|
||
|
||
qDebug() << "[PayByCard] Waiting for payment result, attempt" << attempt + 1 << "/ 15";
|
||
QThread::msleep(2000);
|
||
}
|
||
|
||
m_error = "Не дождались результата перевода";
|
||
qWarning() << "[PayByCard]" << m_error;
|
||
}
|
||
|
||
} // namespace Ozon
|