asdasd
This commit is contained in:
665
custom/apps/mining-checker/src/Domain/OcrService.php
Normal file
665
custom/apps/mining-checker/src/Domain/OcrService.php
Normal file
@@ -0,0 +1,665 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\MiningChecker\Domain;
|
||||
|
||||
use Modules\MiningChecker\Infrastructure\ModuleConfig;
|
||||
use Modules\MiningChecker\Support\ApiException;
|
||||
|
||||
final class OcrService
|
||||
{
|
||||
private ModuleConfig $config;
|
||||
|
||||
public function __construct(ModuleConfig $config)
|
||||
{
|
||||
$this->config = $config;
|
||||
}
|
||||
|
||||
public function preview(array $file, array $input): array
|
||||
{
|
||||
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
|
||||
throw new ApiException('Screenshot-Upload fehlt oder ist fehlerhaft.', 422);
|
||||
}
|
||||
|
||||
$mime = mime_content_type($file['tmp_name']) ?: '';
|
||||
if (!in_array($mime, ['image/png', 'image/jpeg', 'image/webp'], true)) {
|
||||
throw new ApiException('Nur PNG, JPEG und WEBP werden akzeptiert.', 422, ['mime' => $mime]);
|
||||
}
|
||||
|
||||
$projectKey = (string) ($input['project_key'] ?? $this->config->defaultProjectKey());
|
||||
$uploadDir = $this->resolveUploadDir($projectKey);
|
||||
|
||||
$extension = pathinfo((string) ($file['name'] ?? 'upload.png'), PATHINFO_EXTENSION) ?: 'png';
|
||||
$filename = date('Ymd-His') . '-' . bin2hex(random_bytes(4)) . '.' . strtolower($extension);
|
||||
$targetFile = $uploadDir . '/' . $filename;
|
||||
if (!move_uploaded_file($file['tmp_name'], $targetFile)) {
|
||||
throw new ApiException('Bild konnte nicht gespeichert werden.', 500);
|
||||
}
|
||||
|
||||
$rawText = trim((string) ($input['ocr_hint_text'] ?? ''));
|
||||
$flags = [];
|
||||
|
||||
if ($rawText === '') {
|
||||
['text' => $rawText, 'flags' => $providerFlags] = $this->extractRawText($targetFile);
|
||||
$flags = array_merge($flags, $providerFlags);
|
||||
} else {
|
||||
$flags[] = 'ocr_hint_text_used';
|
||||
}
|
||||
|
||||
$parsed = $this->parseText(
|
||||
$rawText,
|
||||
(string) ($input['date_context'] ?? date('Y-m-d')),
|
||||
strtoupper(trim((string) ($input['wallet_currency_hint'] ?? '')))
|
||||
);
|
||||
$parsed['image_path'] = $targetFile;
|
||||
$parsed['raw_text'] = $rawText;
|
||||
$parsed['flags'] = array_values(array_unique(array_merge($flags, $parsed['flags'])));
|
||||
|
||||
return $parsed;
|
||||
}
|
||||
|
||||
private function resolveUploadDir(string $projectKey): string
|
||||
{
|
||||
$safeProjectKey = preg_replace('~[^a-zA-Z0-9_-]~', '-', $projectKey) ?: 'default';
|
||||
$candidates = [
|
||||
rtrim($this->config->uploadsDir(), '/') . '/' . $safeProjectKey,
|
||||
rtrim(sys_get_temp_dir(), '/') . '/mining-checker/uploads/' . $safeProjectKey,
|
||||
];
|
||||
|
||||
foreach ($candidates as $candidate) {
|
||||
if ($this->ensureWritableDirectory($candidate)) {
|
||||
return $candidate;
|
||||
}
|
||||
}
|
||||
|
||||
throw new ApiException('Upload-Verzeichnis konnte nicht erstellt werden.', 500, [
|
||||
'candidates' => $candidates,
|
||||
]);
|
||||
}
|
||||
|
||||
private function ensureWritableDirectory(string $directory): bool
|
||||
{
|
||||
if (is_dir($directory)) {
|
||||
return is_writable($directory);
|
||||
}
|
||||
|
||||
return @mkdir($directory, 0775, true) || is_dir($directory);
|
||||
}
|
||||
|
||||
private function extractRawText(string $imagePath): array
|
||||
{
|
||||
$ocrConfig = $this->config->ocr();
|
||||
$providers = $ocrConfig['providers'] ?? ['tesseract'];
|
||||
$flags = [];
|
||||
|
||||
if (!is_array($providers) || $providers === []) {
|
||||
$providers = ['tesseract'];
|
||||
}
|
||||
|
||||
foreach ($providers as $provider) {
|
||||
$providerName = strtolower(trim((string) $provider));
|
||||
if ($providerName === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($providerName === 'ocrspace') {
|
||||
$result = $this->runOcrSpace((array) ($ocrConfig['ocrspace'] ?? []), $imagePath);
|
||||
} elseif ($providerName === 'tesseract') {
|
||||
$result = $this->runTesseract((array) ($ocrConfig['tesseract'] ?? []), $imagePath);
|
||||
} else {
|
||||
$flags[] = 'ocr_provider_unsupported:' . $providerName;
|
||||
continue;
|
||||
}
|
||||
|
||||
$flags = array_merge($flags, $result['flags']);
|
||||
if (($result['text'] ?? '') !== '') {
|
||||
return [
|
||||
'text' => (string) $result['text'],
|
||||
'flags' => array_values(array_unique(array_merge(
|
||||
$flags,
|
||||
['ocr_provider:' . $providerName]
|
||||
))),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'text' => '',
|
||||
'flags' => array_values(array_unique(array_merge($flags, ['ocr_engine_missing']))),
|
||||
];
|
||||
}
|
||||
|
||||
private function runOcrSpace(array $providerConfig, string $imagePath): array
|
||||
{
|
||||
if (!function_exists('curl_init') || !class_exists(\CURLFile::class)) {
|
||||
return [
|
||||
'text' => '',
|
||||
'flags' => ['ocr_provider_missing:ocrspace', 'ocr_transport_missing:curl'],
|
||||
];
|
||||
}
|
||||
|
||||
$url = trim((string) ($providerConfig['url'] ?? ''));
|
||||
if ($url === '') {
|
||||
return [
|
||||
'text' => '',
|
||||
'flags' => ['ocr_provider_missing:ocrspace', 'ocrspace_url_missing'],
|
||||
];
|
||||
}
|
||||
|
||||
$apiKey = trim((string) ($providerConfig['api_key'] ?? ''));
|
||||
if ($apiKey === '') {
|
||||
return [
|
||||
'text' => '',
|
||||
'flags' => ['ocr_provider_missing:ocrspace', 'ocrspace_api_key_missing'],
|
||||
];
|
||||
}
|
||||
|
||||
$postFields = [
|
||||
'file' => new \CURLFile($imagePath),
|
||||
'language' => (string) ($providerConfig['language'] ?? 'eng'),
|
||||
'OCREngine' => (string) ((int) ($providerConfig['engine'] ?? 2)),
|
||||
'scale' => (string) ($providerConfig['scale'] ?? 'true'),
|
||||
'detectOrientation' => (string) ($providerConfig['detect_orientation'] ?? 'true'),
|
||||
'isTable' => (string) ($providerConfig['is_table'] ?? 'false'),
|
||||
'isOverlayRequired' => 'false',
|
||||
];
|
||||
|
||||
$ch = curl_init();
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_URL => $url,
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => $postFields,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_TIMEOUT => max(5, (int) ($providerConfig['timeout'] ?? 25)),
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Accept: application/json',
|
||||
'apikey: ' . $apiKey,
|
||||
],
|
||||
]);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$curlError = curl_error($ch);
|
||||
$httpStatus = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($response === false || $curlError !== '') {
|
||||
return [
|
||||
'text' => '',
|
||||
'flags' => ['ocr_provider_empty:ocrspace', 'ocrspace_request_failed'],
|
||||
];
|
||||
}
|
||||
|
||||
$payload = json_decode((string) $response, true);
|
||||
if (!is_array($payload)) {
|
||||
return [
|
||||
'text' => '',
|
||||
'flags' => ['ocr_provider_empty:ocrspace', 'ocrspace_invalid_response'],
|
||||
];
|
||||
}
|
||||
|
||||
$flags = [];
|
||||
$rawText = '';
|
||||
$parsedResults = $payload['ParsedResults'] ?? null;
|
||||
if (is_array($parsedResults)) {
|
||||
$texts = [];
|
||||
foreach ($parsedResults as $result) {
|
||||
if (!is_array($result)) {
|
||||
continue;
|
||||
}
|
||||
$fileExitCode = (string) ($result['FileParseExitCode'] ?? '');
|
||||
if ($fileExitCode !== '') {
|
||||
$flags[] = 'ocrspace_file_exit_code:' . $fileExitCode;
|
||||
}
|
||||
$parsedText = trim((string) ($result['ParsedText'] ?? ''));
|
||||
if ($parsedText !== '') {
|
||||
$texts[] = $parsedText;
|
||||
}
|
||||
$resultError = trim((string) ($result['ErrorMessage'] ?? ''));
|
||||
if ($resultError !== '') {
|
||||
$flags[] = 'ocrspace_result_error';
|
||||
}
|
||||
}
|
||||
$rawText = trim(implode("\n", $texts));
|
||||
}
|
||||
|
||||
$ocrExitCode = (string) ($payload['OCRExitCode'] ?? '');
|
||||
$isErroredOnProcessing = !empty($payload['IsErroredOnProcessing']);
|
||||
$errorMessage = trim((string) ($payload['ErrorMessage'] ?? ''));
|
||||
$errorDetails = trim((string) ($payload['ErrorDetails'] ?? ''));
|
||||
|
||||
if ($httpStatus >= 400) {
|
||||
$flags[] = 'ocrspace_http_error';
|
||||
}
|
||||
|
||||
if ($ocrExitCode !== '') {
|
||||
$flags[] = 'ocrspace_exit_code:' . $ocrExitCode;
|
||||
}
|
||||
|
||||
$flags[] = 'ocrspace_engine:' . (string) ((int) ($providerConfig['engine'] ?? 2));
|
||||
|
||||
if ($isErroredOnProcessing) {
|
||||
$flags[] = 'ocrspace_processing_error';
|
||||
}
|
||||
|
||||
if ($errorMessage !== '' || $errorDetails !== '') {
|
||||
$flags[] = 'ocrspace_error';
|
||||
}
|
||||
|
||||
return [
|
||||
'text' => $rawText,
|
||||
'flags' => $rawText === '' ? array_values(array_unique(array_merge($flags, ['ocr_provider_empty:ocrspace']))) : array_values(array_unique($flags)),
|
||||
];
|
||||
}
|
||||
|
||||
private function runTesseract(array $providerConfig, string $imagePath): array
|
||||
{
|
||||
$binary = (string) ($providerConfig['binary'] ?? 'tesseract');
|
||||
if (!$this->binaryExists($binary)) {
|
||||
return [
|
||||
'text' => '',
|
||||
'flags' => ['ocr_provider_missing:tesseract'],
|
||||
];
|
||||
}
|
||||
|
||||
$language = (string) ($providerConfig['language'] ?? 'eng');
|
||||
$tmpBase = tempnam(sys_get_temp_dir(), 'mc-ocr-');
|
||||
if ($tmpBase === false) {
|
||||
return [
|
||||
'text' => '',
|
||||
'flags' => ['ocr_tempfile_failed:tesseract'],
|
||||
];
|
||||
}
|
||||
|
||||
@unlink($tmpBase);
|
||||
$command = sprintf(
|
||||
'%s %s %s -l %s 2>/dev/null',
|
||||
escapeshellcmd($binary),
|
||||
escapeshellarg($imagePath),
|
||||
escapeshellarg($tmpBase),
|
||||
escapeshellarg($language)
|
||||
);
|
||||
shell_exec($command);
|
||||
|
||||
$txtFile = $tmpBase . '.txt';
|
||||
$text = is_file($txtFile) ? (string) file_get_contents($txtFile) : '';
|
||||
@unlink($txtFile);
|
||||
return [
|
||||
'text' => trim($text),
|
||||
'flags' => trim($text) === '' ? ['ocr_provider_empty:tesseract'] : [],
|
||||
];
|
||||
}
|
||||
|
||||
private function binaryExists(string $binary): bool
|
||||
{
|
||||
return $binary !== '' && trim((string) shell_exec('command -v ' . escapeshellarg($binary) . ' 2>/dev/null')) !== '';
|
||||
}
|
||||
|
||||
private function parseText(string $rawText, string $dateContext, string $walletCurrencyHint = ''): array
|
||||
{
|
||||
$measurement = $this->parseMeasurementText($rawText, $dateContext);
|
||||
$wallet = $this->parseWalletText($rawText, $dateContext, $walletCurrencyHint);
|
||||
|
||||
$isWallet = ($wallet['score'] ?? 0) > ($measurement['score'] ?? 0)
|
||||
&& (
|
||||
($wallet['suggested_wallet']['wallet_balance'] ?? null) !== null
|
||||
|| ($wallet['suggested_wallet']['total_value_amount'] ?? null) !== null
|
||||
);
|
||||
|
||||
return [
|
||||
'kind' => $isWallet ? 'wallet' : 'measurement',
|
||||
'suggested' => $measurement['suggested'],
|
||||
'suggested_wallet' => $wallet['suggested_wallet'],
|
||||
'confidence' => round((float) ($isWallet ? ($wallet['confidence'] ?? 0.0) : ($measurement['confidence'] ?? 0.0)), 4),
|
||||
'flags' => $isWallet ? $wallet['flags'] : $measurement['flags'],
|
||||
];
|
||||
}
|
||||
|
||||
private function parseMeasurementText(string $rawText, string $dateContext): array
|
||||
{
|
||||
$flags = [];
|
||||
$suggestedTime = null;
|
||||
$coinsTotal = null;
|
||||
$price = null;
|
||||
$currency = null;
|
||||
$normalizedText = preg_replace('/[[:space:]]+/u', ' ', trim($rawText)) ?: '';
|
||||
$lines = array_values(array_filter(array_map(
|
||||
static fn (string $line): string => trim($line),
|
||||
preg_split('/\R/u', $rawText) ?: []
|
||||
), static fn (string $line): bool => $line !== ''));
|
||||
|
||||
if ($normalizedText === '') {
|
||||
$flags[] = 'ocr_raw_text_empty';
|
||||
}
|
||||
|
||||
if (preg_match('/\b([01]?\d|2[0-3]):([0-5]\d)\b/', $normalizedText, $timeMatch)) {
|
||||
$suggestedTime = sprintf('%s %02d:%02d:00', $dateContext, (int) $timeMatch[1], (int) $timeMatch[2]);
|
||||
}
|
||||
|
||||
preg_match_all('/\b\d+(?:[.,]\d+)?\b/', $normalizedText, $numberMatches);
|
||||
$decimalCandidates = [];
|
||||
foreach ($numberMatches[0] ?? [] as $candidate) {
|
||||
$normalized = (float) str_replace(',', '.', $candidate);
|
||||
if ($normalized <= 0) {
|
||||
continue;
|
||||
}
|
||||
$decimalCandidates[] = [
|
||||
'raw' => $candidate,
|
||||
'value' => $normalized,
|
||||
'precision' => str_contains($candidate, ',') || str_contains($candidate, '.')
|
||||
? strlen((string) preg_replace('/^\d+[.,]/', '', $candidate))
|
||||
: 0,
|
||||
];
|
||||
}
|
||||
|
||||
if (preg_match('/DOGE\s*\/\s*(USD|EUR|USDT|USDC|BTC|ETH|LTC)/i', $normalizedText, $pairMatch)) {
|
||||
$currency = strtoupper((string) $pairMatch[1]);
|
||||
} elseif (preg_match('/\b(EUR|USD|USDT|USDC|BTC|ETH|LTC)\b/i', $normalizedText, $currencyMatch)) {
|
||||
$currency = strtoupper((string) $currencyMatch[1]);
|
||||
} elseif (str_contains($normalizedText, '$')) {
|
||||
$currency = 'USD';
|
||||
} else {
|
||||
$flags[] = 'currency_missing';
|
||||
}
|
||||
|
||||
if (preg_match('/DOGE\s*\/\s*(?:USD|EUR|USDT|USDC|BTC|ETH|LTC)[^\d]{0,20}(\d+[.,]\d{3,8})/i', $normalizedText, $priceMatch)) {
|
||||
$price = round((float) str_replace(',', '.', $priceMatch[1]), 8);
|
||||
}
|
||||
|
||||
if ($coinsTotal === null) {
|
||||
foreach ($lines as $line) {
|
||||
if (!preg_match('/MINING[- ]?GUTHABEN|MINING[- ]?BALANCE|GUTHABEN|BALANCE/i', $line)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (preg_match('/(\d+[.,]\d{4,8})/', $line, $lineCoinsMatch)) {
|
||||
$coinsTotal = round((float) str_replace(',', '.', $lineCoinsMatch[1]), 6);
|
||||
$flags[] = 'coins_from_balance_line';
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($coinsTotal === null && preg_match('/(\d+[.,]\d{4,8})\s*(?:DOGE)?\s*(?:MINING[- ]?GUTHABEN|MINING[- ]?BALANCE|GUTHABEN|BALANCE)/i', $normalizedText, $coinsMatch)) {
|
||||
$coinsTotal = round((float) str_replace(',', '.', $coinsMatch[1]), 6);
|
||||
$flags[] = 'coins_from_balance_context';
|
||||
}
|
||||
|
||||
if ($coinsTotal === null) {
|
||||
$coinsCandidates = array_values(array_filter($decimalCandidates, static function (array $item) use ($price): bool {
|
||||
if ($item['precision'] < 4) {
|
||||
return false;
|
||||
}
|
||||
if ($item['value'] <= 0 || $item['value'] >= 1000000) {
|
||||
return false;
|
||||
}
|
||||
if ($price !== null && abs($item['value'] - $price) < 0.0000005) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}));
|
||||
|
||||
if ($coinsCandidates !== []) {
|
||||
usort($coinsCandidates, static function (array $a, array $b): int {
|
||||
return [$b['precision'], $b['value']] <=> [$a['precision'], $a['value']];
|
||||
});
|
||||
$coinsTotal = round((float) $coinsCandidates[0]['value'], 6);
|
||||
if (count($coinsCandidates) > 1) {
|
||||
$flags[] = 'coins_ambiguous';
|
||||
}
|
||||
} else {
|
||||
$flags[] = 'coins_missing';
|
||||
}
|
||||
}
|
||||
|
||||
$priceCandidates = array_values(array_filter(
|
||||
$decimalCandidates,
|
||||
static fn (array $item): bool => $item['value'] > 0 && $item['value'] < 1
|
||||
));
|
||||
|
||||
if ($price === null && $priceCandidates !== []) {
|
||||
usort($priceCandidates, static function (array $a, array $b): int {
|
||||
return [$b['precision'], $a['value']] <=> [$a['precision'], $b['value']];
|
||||
});
|
||||
$price = round((float) $priceCandidates[0]['value'], 8);
|
||||
if (count($priceCandidates) > 1 && count(array_filter($priceCandidates, static fn (array $item): bool => $item['precision'] >= 4)) > 1) {
|
||||
$flags[] = 'price_ambiguous';
|
||||
}
|
||||
}
|
||||
|
||||
if ($price === null && $coinsTotal !== null && preg_match('/~\s*(\d+[.,]\d+)\s*\$/', $normalizedText, $fiatMatch)) {
|
||||
$fiatValue = (float) str_replace(',', '.', $fiatMatch[1]);
|
||||
if ($fiatValue > 0) {
|
||||
$price = round($fiatValue / $coinsTotal, 8);
|
||||
$flags[] = 'price_derived_from_balance_value';
|
||||
$currency = $currency ?? 'USD';
|
||||
}
|
||||
}
|
||||
|
||||
$measurementIndicators = 0;
|
||||
$normalizedLower = strtolower($normalizedText);
|
||||
foreach (['mining-guthaben', 'mining guthaben', 'mining-balance', 'mining balance', 'doge /', 'bonus', 'verlauf'] as $indicator) {
|
||||
if (str_contains($normalizedLower, $indicator)) {
|
||||
$measurementIndicators++;
|
||||
}
|
||||
}
|
||||
|
||||
$matchedFields = 0;
|
||||
foreach ([$coinsTotal, $price, $currency] as $field) {
|
||||
if ($field !== null) {
|
||||
$matchedFields++;
|
||||
}
|
||||
}
|
||||
|
||||
$score = $matchedFields + min(3, $measurementIndicators);
|
||||
$confidence = max(0.05, min(0.99, ($matchedFields / 3) + (min(3, $measurementIndicators) * 0.08) - (count($flags) * 0.04)));
|
||||
|
||||
return [
|
||||
'suggested' => [
|
||||
'measured_at' => $suggestedTime,
|
||||
'coins_total' => $coinsTotal,
|
||||
'price_per_coin' => $price,
|
||||
'price_currency' => $currency,
|
||||
'note' => null,
|
||||
'source' => 'image_ocr',
|
||||
],
|
||||
'confidence' => round($confidence, 4),
|
||||
'flags' => $flags,
|
||||
'score' => $score,
|
||||
];
|
||||
}
|
||||
|
||||
private function parseWalletText(string $rawText, string $dateContext, string $walletCurrencyHint = ''): array
|
||||
{
|
||||
$flags = [];
|
||||
$suggestedTime = null;
|
||||
$totalValueAmount = null;
|
||||
$totalValueCurrency = null;
|
||||
$walletBalance = null;
|
||||
$walletCurrency = $walletCurrencyHint !== '' ? $walletCurrencyHint : 'DOGE';
|
||||
$balances = [];
|
||||
|
||||
$normalizedText = preg_replace('/[[:space:]]+/u', ' ', trim($rawText)) ?: '';
|
||||
$lines = array_values(array_filter(array_map(
|
||||
static fn (string $line): string => trim($line),
|
||||
preg_split('/\R/u', $rawText) ?: []
|
||||
), static fn (string $line): bool => $line !== ''));
|
||||
|
||||
if (preg_match('/\b([01]?\d|2[0-3]):([0-5]\d)\b/', $normalizedText, $timeMatch)) {
|
||||
$suggestedTime = sprintf('%s %02d:%02d:00', $dateContext, (int) $timeMatch[1], (int) $timeMatch[2]);
|
||||
}
|
||||
|
||||
if (
|
||||
preg_match('/GESAMTSALDO[^\d]{0,24}(\d+(?:[.,]\d+)?)\s*(USD|EUR|USDT|USDC|BTC|ETH|DOGE|CTC|HSH)/i', $normalizedText, $totalMatch)
|
||||
|| preg_match('/(\d+(?:[.,]\d+)?)\s*(USD|EUR)\b.*GESAMTSALDO/i', $normalizedText, $totalMatch)
|
||||
) {
|
||||
$totalValueAmount = round((float) str_replace(',', '.', $totalMatch[1]), 8);
|
||||
$totalValueCurrency = strtoupper((string) $totalMatch[2]);
|
||||
} else {
|
||||
$flags[] = 'wallet_total_missing';
|
||||
}
|
||||
|
||||
$assetRows = [];
|
||||
foreach ($lines as $lineIndex => $line) {
|
||||
if (!preg_match('/(\d+(?:[.,]\d+)?)\s*([A-Z]{2,10})\b/u', $line, $match)) {
|
||||
continue;
|
||||
}
|
||||
$amount = round((float) str_replace(',', '.', $match[1]), 10);
|
||||
$currency = strtoupper((string) $match[2]);
|
||||
if ($amount <= 0 || $currency === '' || in_array($currency, ['USD', 'EUR'], true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$assetRows[] = [
|
||||
'index' => $lineIndex,
|
||||
'currency' => $currency,
|
||||
'balance' => $amount,
|
||||
];
|
||||
}
|
||||
|
||||
foreach ($assetRows as $assetIndex => $assetRow) {
|
||||
$currency = (string) $assetRow['currency'];
|
||||
$balanceAmount = (float) $assetRow['balance'];
|
||||
$startIndex = (int) $assetRow['index'];
|
||||
$endIndex = isset($assetRows[$assetIndex + 1]['index'])
|
||||
? (int) $assetRows[$assetIndex + 1]['index']
|
||||
: count($lines);
|
||||
|
||||
$usdCandidates = [];
|
||||
for ($i = $startIndex; $i < $endIndex; $i++) {
|
||||
if (!isset($lines[$i])) {
|
||||
continue;
|
||||
}
|
||||
if (preg_match_all('/(\d+(?:[.,]\d+)?)\s*USD\b/u', $lines[$i], $usdMatches, PREG_SET_ORDER)) {
|
||||
foreach ($usdMatches as $usdMatch) {
|
||||
$usdCandidates[] = round((float) str_replace(',', '.', $usdMatch[1]), 8);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$priceAmount = $this->pickWalletUnitPrice(
|
||||
$balanceAmount,
|
||||
$usdCandidates,
|
||||
$totalValueCurrency === 'USD' ? $totalValueAmount : null
|
||||
);
|
||||
$balances[$currency] = [
|
||||
'balance' => $balanceAmount,
|
||||
'price_amount' => $priceAmount,
|
||||
'price_currency' => $priceAmount !== null ? 'USD' : null,
|
||||
];
|
||||
}
|
||||
|
||||
if ($walletCurrencyHint !== '' && array_key_exists($walletCurrencyHint, $balances)) {
|
||||
$walletCurrency = $walletCurrencyHint;
|
||||
$walletBalance = is_array($balances[$walletCurrencyHint])
|
||||
? (float) ($balances[$walletCurrencyHint]['balance'] ?? 0.0)
|
||||
: (float) $balances[$walletCurrencyHint];
|
||||
} elseif ($balances !== []) {
|
||||
foreach (['DOGE', 'BTC', 'ETH', 'CTC', 'HSH', 'LTC', 'USDT', 'USDC'] as $preferredCurrency) {
|
||||
if (array_key_exists($preferredCurrency, $balances)) {
|
||||
$walletCurrency = $preferredCurrency;
|
||||
$walletBalance = is_array($balances[$preferredCurrency])
|
||||
? (float) ($balances[$preferredCurrency]['balance'] ?? 0.0)
|
||||
: (float) $balances[$preferredCurrency];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($walletBalance === null) {
|
||||
$firstCurrency = array_key_first($balances);
|
||||
if (is_string($firstCurrency)) {
|
||||
$walletCurrency = $firstCurrency;
|
||||
$walletBalance = is_array($balances[$firstCurrency])
|
||||
? (float) ($balances[$firstCurrency]['balance'] ?? 0.0)
|
||||
: (float) $balances[$firstCurrency];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$flags[] = 'wallet_balance_missing';
|
||||
}
|
||||
|
||||
$walletIndicators = 0;
|
||||
$normalizedLower = strtolower($normalizedText);
|
||||
foreach (['wallets', 'gesamtsaldo', 'alle münzen', 'alle munzen', 'letzte transaktion'] as $indicator) {
|
||||
if (str_contains($normalizedLower, $indicator)) {
|
||||
$walletIndicators++;
|
||||
}
|
||||
}
|
||||
|
||||
$matchedFields = 0;
|
||||
foreach ([$totalValueAmount, $walletBalance, $walletCurrency] as $field) {
|
||||
if ($field !== null && $field !== '') {
|
||||
$matchedFields++;
|
||||
}
|
||||
}
|
||||
$score = $matchedFields + ($walletIndicators * 2);
|
||||
$confidence = max(0.05, min(0.99, ($matchedFields / 3) + (min(3, $walletIndicators) * 0.12) - (count($flags) * 0.03)));
|
||||
|
||||
ksort($balances);
|
||||
|
||||
return [
|
||||
'suggested_wallet' => [
|
||||
'measured_at' => $suggestedTime,
|
||||
'total_value_amount' => $totalValueAmount,
|
||||
'total_value_currency' => $totalValueCurrency,
|
||||
'wallet_balance' => $walletBalance,
|
||||
'wallet_currency' => $walletCurrency,
|
||||
'balances_json' => $balances,
|
||||
'note' => null,
|
||||
'source' => 'image_ocr',
|
||||
],
|
||||
'confidence' => round($confidence, 4),
|
||||
'flags' => array_values(array_unique($flags)),
|
||||
'score' => $score,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<float|int> $candidates
|
||||
*/
|
||||
private function pickWalletUnitPrice(float $balance, array $candidates, ?float $walletTotal = null): ?float
|
||||
{
|
||||
$candidates = array_values(array_filter(array_map(
|
||||
static fn (mixed $value): float => round((float) $value, 8),
|
||||
$candidates
|
||||
), static fn (float $value): bool => $value > 0));
|
||||
|
||||
if ($balance <= 0 || $candidates === []) {
|
||||
return null;
|
||||
}
|
||||
if (count($candidates) === 1) {
|
||||
$candidate = $candidates[0];
|
||||
if ($walletTotal !== null && $walletTotal > 0 && ($balance * $candidate) > ($walletTotal * 1.5)) {
|
||||
return round($candidate / $balance, 8);
|
||||
}
|
||||
return $candidate;
|
||||
}
|
||||
|
||||
$bestPrice = null;
|
||||
$bestError = null;
|
||||
$candidateCount = count($candidates);
|
||||
|
||||
for ($i = 0; $i < $candidateCount; $i++) {
|
||||
$priceCandidate = $candidates[$i];
|
||||
for ($j = 0; $j < $candidateCount; $j++) {
|
||||
if ($i === $j) {
|
||||
continue;
|
||||
}
|
||||
$totalCandidate = $candidates[$j];
|
||||
$estimatedTotal = $balance * $priceCandidate;
|
||||
$denominator = max(abs($totalCandidate), 0.00000001);
|
||||
$error = abs($estimatedTotal - $totalCandidate) / $denominator;
|
||||
if ($bestError === null || $error < $bestError) {
|
||||
$bestError = $error;
|
||||
$bestPrice = $priceCandidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($bestPrice !== null && $bestError !== null && $bestError <= 0.2) {
|
||||
return round($bestPrice, 8);
|
||||
}
|
||||
|
||||
return round($candidates[count($candidates) - 1], 8);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user