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'] ?? ''))), strtoupper(trim((string) ($input['mining_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 = '', string $miningCurrencyHint = ''): array { $measurement = $this->parseMeasurementText($rawText, $dateContext, $miningCurrencyHint); $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, string $miningCurrencyHint = ''): 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, ]; } $assetPattern = $miningCurrencyHint !== '' ? preg_quote($miningCurrencyHint, '/') : '(?:DOGE|BTC|ETH|LTC|XRP|ADA|SOL|USDT|USDC|TRX|XMR|DOT|ARB|BNB|AVAX|MATIC)'; if (preg_match('/' . $assetPattern . '\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('/' . $assetPattern . '\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*(?:' . $assetPattern . ')?\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 $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); } }