459 lines
14 KiB
Markdown
459 lines
14 KiB
Markdown
# 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.kt(React 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 能力。
|