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

459 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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`,实现:
```kotlin
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
```kotlin
@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
```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
<?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`:
```gradle
dependencies {
// Google ML Kit OCR
implementation 'com.google.mlkit:text-recognition-chinese:16.0.0'
}
```
#### 2.2 实现 OcrManager.kt
```kotlin
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 正则实现:
```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`:
```typescript
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`:
```typescript
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 入口:
```typescript
const handleOcrImport = async () => {
const event = await processOcrScreenshot();
if (event) {
setEvents(current => [...current, event]);
setMessage('OCR 识别成功,请确认账单。');
} else {
setMessage('OCR 识别失败,请重试。');
}
};
```
### 阶段四UI 适配2 天)
#### 4.1 添加 OCR 触发按钮
在导入标签页添加"屏幕识别"按钮:
```tsx
{tab === '导入' && (
<>
<Card title="自动记账">
<Button label="屏幕识别OCR" onPress={handleOcrImport} />
<Button label="导入 CSV 文件" onPress={loadDemo} />
</Card>
{/* 现有的事件列表 */}
</>
)}
```
#### 4.2 OCR 结果确认界面
展示识别结果,允许用户修正:
```tsx
<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 无障碍权限引导
首次使用时引导用户开启无障碍权限:
```tsx
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.md`OCR 屏幕识别 + 自动记账第二阶段
实现顺序先完成 plan.md 的核心功能再实现 plan-ocr.md OCR 能力