DriftLedger/plan-ocr.md
2026-07-13 13:34:27 +08:00

14 KiB
Raw Blame History

OCR 自动记账集成方案

摘要

借鉴 AutoAccounting 项目的 OCR 模式,为 beancount-mobile 增加屏幕识别自动记账能力。用户在支付宝/微信/银行 App 付款后,通过无障碍服务截屏 → OCR 识别 → 解析账单 → 生成 Beancount 复式分录 → 用户确认入账。

核心原则:不修改任何应用、不需要 Root、不需要 Shizuku仅依赖 Android 无障碍权限。

技术方案

架构概览

用户在支付 App 完成付款
    ↓
Android 无障碍服务检测到页面变化
    ↓
AccessibilityService.takeScreenshot() 截屏API 30+
    ↓
Google ML Kit OCR 识别文字
    ↓
正则 + 规则引擎解析为 ImportedEvent
    ↓
复用现有 classify() → TransactionDraft
    ↓
用户确认 → commitMobileTransaction() → mobile.bean

依赖 AutoAccounting 的部分

AutoAccounting 组件 用途 beancount-mobile 替代方案
OcrTools.kt 无障碍截屏 + 前台应用检测 原生模块重写,逻辑一致
OcrProcessor.kt PP-OCRv5 文字识别 Google ML Kit免费无需 AAR
JsExecutor.kt QuickJS 规则引擎 复用现有importStatement + 规则
BillService.kt 账单分析流程 复用现有classify() 流程
PageSignatureManager.kt 页面特征匹配 可选,首版不做
FlipDetector.kt 翻转触发 改为悬浮按钮触发

不依赖的部分

  • Shizuku SDK无障碍模式不需要
  • Xposed/LSPatch 框架
  • Ktor 嵌入式服务器
  • Room 数据库
  • MMKV 配置存储
  • TapBack 双击背部模块

实现计划

阶段一原生模块搭建3 天)

1.1 创建 Android 原生模块目录结构

android/app/src/main/java/com/beancount/mobile/
├── ocr/
│   ├── OcrModule.kt          # React Native 原生模块
│   ├── OcrAccessibilityService.kt  # 无障碍服务
│   └── OcrManager.kt         # OCR 处理管理器

1.2 实现 OcrAccessibilityService.kt

参考 AutoAccounting 的 OcrTools.kt,实现:

class OcrAccessibilityService : AccessibilityService() {
    // 1. 监听窗口变化事件
    override fun onAccessibilityEvent(event: AccessibilityEvent?) {
        // 检测前台应用变化
        // 触发截屏回调
    }

    // 2. 截屏功能API 30+
    fun takeScreenshot(callback: (Bitmap?) -> Unit) {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
            takeScreenshot(
                Display.DEFAULT_DISPLAY,
                mainExecutor,
                object : TakeScreenshotCallback {
                    override fun onSuccess(result: ScreenshotResult) {
                        val bitmap = Bitmap.wrapHardwareBuffer(
                            result.hardwareBuffer, result.colorSpace
                        )
                        callback(bitmap)
                        result.hardwareBuffer.close()
                    }
                    override fun onFailure(errorCode: Int) {
                        callback(null)
                    }
                }
            )
        }
    }

    // 3. 获取前台应用包名
    fun getTopPackage(): String? {
        // 通过 rootInActiveWindow 获取
    }
}

1.3 实现 OcrModule.ktReact Native Bridge

@ReactModule(name = "OcrModule")
class OcrModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) {
  
    @ReactMethod
    fun startOcrService(promise: Promise) {
        // 启动无障碍服务
    }

    @ReactMethod
    fun stopOcrService(promise: Promise) {
        // 停止无障碍服务
    }

    @ReactMethod
    fun takeScreenshot(promise: Promise) {
        // 调用无障碍服务截屏
        // 返回 base64 编码的图片
    }

    @ReactMethod
    fun getTopApp(promise: Promise) {
        // 返回前台应用包名
    }

    @ReactMethod
    fun isServiceEnabled(promise: Promise) {
        // 检查无障碍服务是否已启用
    }
}

1.4 配置 AndroidManifest.xml

<service
    android:name=".ocr.OcrAccessibilityService"
    android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE"
    android:exported="false">
    <intent-filter>
        <action android:name="android.accessibilityservice.AccessibilityService" />
    </intent-filter>
    <meta-data
        android:name="android.accessibilityservice"
        android:resource="@xml/accessibility_service_config" />
</service>

1.5 创建无障碍服务配置

android/app/src/main/res/xml/accessibility_service_config.xml:

<?xml version="1.0" encoding="utf-8"?>
<accessibility-service xmlns:android="http://schemas.android.com/apk/res/android"
    android:description="@string/ocr_service_description"
    android:accessibilityEventTypes="typeWindowStateChanged"
    android:accessibilityFeedbackType="feedbackGeneric"
    android:notificationTimeout="100"
    android:canTakeScreenshot="true"
    android:canRetrieveWindowContent="false" />

阶段二OCR 引擎集成2 天)

2.1 添加 ML Kit 依赖

android/app/build.gradle:

dependencies {
    // Google ML Kit OCR
    implementation 'com.google.mlkit:text-recognition-chinese:16.0.0'
}

2.2 实现 OcrManager.kt

class OcrManager(private val context: Context) {
    private val recognizer = TextRecognition.getClient(
        ChineseTextRecognizerOptions.Builder().build()
    )

    suspend fun recognizeText(bitmap: Bitmap): String {
        val image = InputImage.fromBitmap(bitmap, 0)
        val result = recognizer.process(image).await()
        return result.text
    }
}

2.3 金额/商户正则解析

参考 AutoAccounting 的 JS 规则,用 Kotlin 正则实现:

object BillParser {
    // 金额匹配¥100.00 / 100.00元 / -50.50
    private val amountPattern = Regex("""[¥¥]?\s*(-?\d+\.?\d*)\s*元?""")
  
    // 时间匹配2026-07-10 14:30:00 / 07-10 14:30
    private val timePattern = Regex("""(\d{4}[-/]\d{2}[-/]\d{2}\s+\d{2}:\d{2}(?::\d{2})?)""")
  
    // 商户匹配:支付成功至 XXX / 商户名称XXX
    private val merchantPattern = Regex("""(?:商户|商家|收款方)[:]\s*(.+?)(?:\s|$)""")
  
    fun parse(ocrText: String, appPackage: String): ImportedEvent? {
        val amount = amountPattern.find(ocrText)?.groupValues?.get(1) ?: return null
        val time = timePattern.find(ocrText)?.groupValues?.get(1) ?: return null
        val merchant = merchantPattern.find(ocrText)?.groupValues?.get(1) ?: "未知商户"
      
        // 根据 appPackage 判断渠道
        val channel = when {
            appPackage.contains("alipay") -> "Alipay"
            appPackage.contains("wechat") -> "WeChat"
            else -> "Bank"
        }
      
        return ImportedEvent(
            id = "ocr-${System.currentTimeMillis()}",
            occurredAt = time.replace("/", "-").take(10),
            amount = amount,
            currency = "CNY",
            direction = if (amount.startsWith("-")) "expense" else "income",
            channel = channel,
            counterparty = merchant,
            memo = "OCR 自动识别",
            raw = mapOf("ocrText" to ocrText)
        )
    }
}

阶段三JS 层集成2 天)

3.1 创建 OCR Bridge 模块

src/ocr/OcrBridge.ts:

import { NativeModules, Platform } from 'react-native';

const { OcrModule } = NativeModules;

export interface OcrResult {
  text: string;
  imagePath: string;
}

export class OcrBridge {
  static async isAvailable(): Promise<boolean> {
    if (Platform.OS !== 'android') return false;
    return await OcrModule?.isServiceEnabled() ?? false;
  }

  static async startService(): Promise<void> {
    await OcrModule?.startOcrService();
  }

  static async takeScreenshot(): Promise<string | null> {
    return await OcrModule?.takeScreenshot();
  }

  static async getTopApp(): Promise<string | null> {
    return await OcrModule?.getTopApp();
  }
}

3.2 OCR 识别流程

src/ocr/OcrProcessor.ts:

import { OcrBridge } from './OcrBridge';
import { importStatement, type ImportedEvent } from '../domain';

export async function processOcrScreenshot(): Promise<ImportedEvent | null> {
  // 1. 截屏
  const base64Image = await OcrBridge.takeScreenshot();
  if (!base64Image) return null;

  // 2. 获取前台应用
  const topApp = await OcrBridge.getTopApp();
  if (!topApp) return null;

  // 3. OCR 识别(通过原生模块)
  const ocrText = await OcrModule.recognizeText(base64Image);
  if (!ocrText) return null;

  // 4. 解析为 ImportedEvent
  const event = parseOcrText(ocrText, topApp);
  return event;
}

3.3 集成到现有导入流程

修改 App.tsx 的导入标签页,添加 OCR 入口:

const handleOcrImport = async () => {
  const event = await processOcrScreenshot();
  if (event) {
    setEvents(current => [...current, event]);
    setMessage('OCR 识别成功,请确认账单。');
  } else {
    setMessage('OCR 识别失败,请重试。');
  }
};

阶段四UI 适配2 天)

4.1 添加 OCR 触发按钮

在导入标签页添加"屏幕识别"按钮:

{tab === '导入' && (
  <>
    <Card title="自动记账">
      <Button label="屏幕识别OCR" onPress={handleOcrImport} />
      <Button label="导入 CSV 文件" onPress={loadDemo} />
    </Card>
    {/* 现有的事件列表 */}
  </>
)}

4.2 OCR 结果确认界面

展示识别结果,允许用户修正:

<Card title={`OCR 识别 · ${event.amount} ${event.currency}`}>
  <Text>{event.counterparty} · {event.memo}</Text>
  <Text style={styles.muted}>来源:{event.channel} · {event.occurredAt}</Text>
  {/* 编辑按钮 */}
  <Button label="确认入账" onPress={() => confirm(event)} />
</Card>

4.3 无障碍权限引导

首次使用时引导用户开启无障碍权限:

const enableOcr = async () => {
  const available = await OcrBridge.isAvailable();
  if (!available) {
    // 打开系统无障碍设置
    await OcrBridge.startService();
  }
};

阶段五测试与优化2 天)

5.1 测试用例

测试场景 预期结果
支付宝付款成功页 OCR 正确识别金额、商户、时间
微信支付凭证页 OCR 正确识别金额、商户
银行卡扣款通知页 OCR 正确识别金额、来源
识别失败(非支付页面) 返回 null提示重试
重复识别同一笔交易 去重,提示已存在

5.2 性能优化

  • 截屏后立即回收 Bitmap避免内存泄漏
  • OCR 识别在后台线程执行
  • 缓存最近识别结果,避免重复处理

5.3 兼容性处理

  • Android 11 以下不支持 takeScreenshot(),降级为提示用户手动截图
  • 不同支付 App 的页面布局差异,通过正则适配

文件清单

新增文件

android/app/src/main/java/com/beancount/mobile/ocr/
├── OcrModule.kt                    # React Native 原生模块
├── OcrAccessibilityService.kt      # 无障碍服务
└── OcrManager.kt                   # OCR 处理管理器

android/app/src/main/res/xml/
└── accessibility_service_config.xml # 无障碍服务配置

src/ocr/
├── OcrBridge.ts                    # JS 层 Bridge
└── OcrProcessor.ts                 # OCR 处理逻辑

修改文件

android/app/build.gradle            # 添加 ML Kit 依赖
android/app/src/main/AndroidManifest.xml  # 注册无障碍服务
App.tsx                             # 添加 OCR 入口
package.json                        # 无变化(纯原生模块)

依赖清单

依赖 版本 用途 必需
Google ML Kit Text Recognition 16.0.0 中文 OCR
React Native 0.81.0 框架
Expo ~54.0.0 开发框架

不需要的依赖

  • Shizuku SDK
  • Xposed/LSPatch
  • PP-OCR AAR
  • QuickJS

风险与限制

风险 影响 缓解措施
Android < 11 不支持截屏 API 低版本设备无法使用 降级为手动截图导入
支付 App 页面更新 OCR 正则失效 正则设计为宽松匹配
无障碍权限被系统回收 服务停止 前台通知保活
OCR 识别准确率 可能识别错误 用户确认环节兜底

预估工期

阶段 工作量 产出
阶段一:原生模块 3 天 无障碍服务 + 截屏
阶段二OCR 引擎 2 天 ML Kit 集成
阶段三JS 集成 2 天 Bridge + 解析
阶段四UI 适配 2 天 按钮 + 确认界面
阶段五:测试优化 2 天 测试用例 + 兼容性
总计 11 天

与 plan.md 的关系

本方案是 plan.md 的扩展,补充了 OCR 自动记账能力。plan.md 中明确"首版不做 OCR",本方案作为第二阶段实现。

两份文档的关系:

  • plan.md:核心架构 + CSV 导入 + 手工记账(首版)
  • plan-ocr.mdOCR 屏幕识别 + 自动记账(第二阶段)

实现顺序:先完成 plan.md 的核心功能,再实现 plan-ocr.md 的 OCR 能力。