Commit d19239ac34d4720f45d31ac89673a2d5a3bdb3f4
1 parent
07f3d222
feat(lifelog/photolog): 人脉里程碑2/4/5 — 微信聊天 + 朋友圈 + AI 印象
档案页扩成多源聚合(里程碑 2/4/5;3 短信因本机无数据暂缓)。 - data:WechatMessage / Moment 两表 + persons.impression 字段 + 迁移 v6→v7→v9; 对应 DAO observeByTalker / observeByUser / updateImpression - WeChatEnrollWorker 增 Pass1.5/1.6:导入 messages.json(聊天) + moments.json(朋友圈) (clear+重灌幂等,按 wxid 关联 Person.contactRef) - PersonProfileScreen:加「微信聊天」气泡 + 「朋友圈」列表两段 - M5 AI 印象:PhotologViewModel.enrichImpression 汇总 TA 朋友圈 → 本地 4B(8080) 生成一句话画像 + 标签(爱好/性格),存 persons.impression,档案页展示 + 按钮触发 - server/services/wechat/extract-wechat-chat-moments.py:Mac 侧提取脚本 (EnMicroMsg message 表解密 + SnsMicroMsg 明文 SnsInfo protobuf 文本近似提取) 实测:真机导入聊天 40 条 + 朋友圈 78 条,按 wxid 关联到联系人(火星人 10 条朋友圈)。 M3 短信:研究清楚(mmssms.db 明文 + contacts2 手机号源),但本机 SMS=0、通讯录=0 (纯微信用户),无数据暂缓,机制在有数据设备上可直接复用。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Showing
8 changed files
with
296 additions
and
3 deletions
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PersonProfileScreen.kt
| ... | ... | @@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.height |
| 12 | 12 | import androidx.compose.foundation.layout.padding |
| 13 | 13 | import androidx.compose.foundation.layout.size |
| 14 | 14 | import androidx.compose.foundation.layout.width |
| 15 | +import androidx.compose.foundation.layout.widthIn | |
| 15 | 16 | import androidx.compose.foundation.lazy.LazyColumn |
| 16 | 17 | import androidx.compose.foundation.lazy.LazyRow |
| 17 | 18 | import androidx.compose.foundation.lazy.items |
| ... | ... | @@ -47,8 +48,10 @@ import androidx.compose.ui.text.style.TextOverflow |
| 47 | 48 | import androidx.compose.ui.unit.dp |
| 48 | 49 | import coil.compose.AsyncImage |
| 49 | 50 | import com.lemon.lifelog.audio.SegmentPlayer |
| 51 | +import com.lemon.lifelog.data.Moment | |
| 50 | 52 | import com.lemon.lifelog.data.Person |
| 51 | 53 | import com.lemon.lifelog.data.SpeakerProfile |
| 54 | +import com.lemon.lifelog.data.WechatMessage | |
| 52 | 55 | import java.io.File |
| 53 | 56 | import java.text.SimpleDateFormat |
| 54 | 57 | import java.util.Date |
| ... | ... | @@ -72,6 +75,15 @@ internal fun PersonProfileScreen( |
| 72 | 75 | if (sid == null) kotlinx.coroutines.flow.flowOf(emptyList()) |
| 73 | 76 | else vm.repo.segments.observeBySpeaker(sid) |
| 74 | 77 | }.collectAsState(initial = emptyList()) |
| 78 | + val wxid = person?.contactRef?.removePrefix("wechat:")?.takeIf { person?.contactRef?.startsWith("wechat:") == true } | |
| 79 | + val messages by remember(wxid) { | |
| 80 | + if (wxid == null) kotlinx.coroutines.flow.flowOf(emptyList()) | |
| 81 | + else vm.repo.wechatMessages.observeByTalker(wxid) | |
| 82 | + }.collectAsState(initial = emptyList()) | |
| 83 | + val moments by remember(wxid) { | |
| 84 | + if (wxid == null) kotlinx.coroutines.flow.flowOf(emptyList()) | |
| 85 | + else vm.repo.moments.observeByUser(wxid) | |
| 86 | + }.collectAsState(initial = emptyList()) | |
| 75 | 87 | |
| 76 | 88 | var bindVoice by remember { mutableStateOf(false) } |
| 77 | 89 | val player = remember { SegmentPlayer() } |
| ... | ... | @@ -116,6 +128,17 @@ internal fun PersonProfileScreen( |
| 116 | 128 | Text("声纹:${sName ?: "未绑定"}", style = MaterialTheme.typography.bodyMedium) |
| 117 | 129 | OutlinedButton(onClick = { bindVoice = true }) { Text(if (p.voiceSpeakerId == null) "绑定" else "改绑") } |
| 118 | 130 | } |
| 131 | + // AI 印象 | |
| 132 | + Spacer(Modifier.height(6.dp)) | |
| 133 | + p.impression?.let { | |
| 134 | + Surface(color = MaterialTheme.colorScheme.secondaryContainer, shape = RoundedCornerShape(10.dp), modifier = Modifier.fillMaxWidth()) { | |
| 135 | + Text("🧠 $it", style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(10.dp)) | |
| 136 | + } | |
| 137 | + Spacer(Modifier.height(6.dp)) | |
| 138 | + } | |
| 139 | + OutlinedButton(onClick = { vm.enrichImpression(personId) }) { | |
| 140 | + Text(if (p.impression == null) "生成 AI 印象(本地 4B,看朋友圈)" else "重新生成印象") | |
| 141 | + } | |
| 119 | 142 | } |
| 120 | 143 | HorizontalDivider() |
| 121 | 144 | } |
| ... | ... | @@ -169,6 +192,24 @@ internal fun PersonProfileScreen( |
| 169 | 192 | } |
| 170 | 193 | } |
| 171 | 194 | } |
| 195 | + // ----- 微信聊天 ----- | |
| 196 | + item { HorizontalDivider(); SectionHeader("微信聊天", messages.size) } | |
| 197 | + if (wxid == null) { | |
| 198 | + item { Text("非微信联系人。", style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)) } | |
| 199 | + } else if (messages.isEmpty()) { | |
| 200 | + item { Text("这台设备上没有跟 TA 的聊天记录。", style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)) } | |
| 201 | + } else { | |
| 202 | + items(messages, key = { it.id }) { m -> ChatBubble(m) } | |
| 203 | + } | |
| 204 | + // ----- 朋友圈 ----- | |
| 205 | + item { HorizontalDivider(); SectionHeader("朋友圈", moments.size) } | |
| 206 | + if (wxid == null) { | |
| 207 | + item { Text("非微信联系人。", style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)) } | |
| 208 | + } else if (moments.isEmpty()) { | |
| 209 | + item { Text("没有 TA 的朋友圈记录。", style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)) } | |
| 210 | + } else { | |
| 211 | + items(moments, key = { it.id }) { mo -> MomentRow(mo) } | |
| 212 | + } | |
| 172 | 213 | item { Spacer(Modifier.height(24.dp)) } |
| 173 | 214 | } |
| 174 | 215 | } |
| ... | ... | @@ -185,6 +226,30 @@ internal fun PersonProfileScreen( |
| 185 | 226 | } |
| 186 | 227 | |
| 187 | 228 | @Composable |
| 229 | +private fun MomentRow(m: Moment) { | |
| 230 | + Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp)) { | |
| 231 | + Text(PROFILE_FMT.format(Date(m.createTime)), style = MaterialTheme.typography.labelSmall) | |
| 232 | + Text(m.text, style = MaterialTheme.typography.bodyMedium) | |
| 233 | + } | |
| 234 | +} | |
| 235 | + | |
| 236 | +@Composable | |
| 237 | +private fun ChatBubble(m: WechatMessage) { | |
| 238 | + Row( | |
| 239 | + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 3.dp), | |
| 240 | + horizontalArrangement = if (m.isSend) Arrangement.End else Arrangement.Start, | |
| 241 | + ) { | |
| 242 | + Surface( | |
| 243 | + color = if (m.isSend) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant, | |
| 244 | + shape = RoundedCornerShape(12.dp), | |
| 245 | + modifier = Modifier.widthIn(max = 280.dp), | |
| 246 | + ) { | |
| 247 | + Text(m.content, modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), style = MaterialTheme.typography.bodyMedium) | |
| 248 | + } | |
| 249 | + } | |
| 250 | +} | |
| 251 | + | |
| 252 | +@Composable | |
| 188 | 253 | private fun InfoRow(label: String, value: String?) { |
| 189 | 254 | if (value.isNullOrBlank()) return |
| 190 | 255 | Row(modifier = Modifier.fillMaxWidth().padding(vertical = 3.dp), horizontalArrangement = Arrangement.spacedBy(10.dp)) { | ... | ... |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologViewModel.kt
| ... | ... | @@ -11,6 +11,8 @@ import com.lemon.lifelog.data.Repository |
| 11 | 11 | import androidx.work.ExistingWorkPolicy |
| 12 | 12 | import androidx.work.OneTimeWorkRequestBuilder |
| 13 | 13 | import androidx.work.WorkManager |
| 14 | +import com.lemon.lifelog.chat.ChatClient | |
| 15 | +import com.lemon.lifelog.chat.ChatConfig | |
| 14 | 16 | import com.lemon.lifelog.vision.FaceMatcher |
| 15 | 17 | import com.lemon.lifelog.vision.ImageUtil |
| 16 | 18 | import com.lemon.lifelog.vision.PhotoScheduler |
| ... | ... | @@ -79,6 +81,30 @@ class PhotologViewModel(app: Application) : AndroidViewModel(app) { |
| 79 | 81 | viewModelScope.launch { repo.persons.linkVoice(personId, speakerId) } |
| 80 | 82 | } |
| 81 | 83 | |
| 84 | + /** AI 印象:汇总 TA 的朋友圈 + 聊天,本地 4B LLM 生成画像 + 标签(爱好/性格)。 */ | |
| 85 | + fun enrichImpression(personId: Long) { | |
| 86 | + viewModelScope.launch { | |
| 87 | + val p = withContext(Dispatchers.IO) { repo.persons.get(personId) } ?: return@launch | |
| 88 | + val wxid = p.contactRef?.removePrefix("wechat:")?.takeIf { p.contactRef.startsWith("wechat:") } | |
| 89 | + val moments = if (wxid != null) withContext(Dispatchers.IO) { repo.moments.byUser(wxid, 40).map { it.text } } else emptyList() | |
| 90 | + if (moments.isEmpty()) { | |
| 91 | + repo.persons.updateImpression(personId, "(朋友圈/聊天资料不足,无法生成画像)") | |
| 92 | + return@launch | |
| 93 | + } | |
| 94 | + repo.persons.updateImpression(personId, "生成中…") | |
| 95 | + try { | |
| 96 | + val prompt = "下面是「${p.name}」的微信朋友圈片段。请用中文输出对 TA 的**一句话画像** + " + | |
| 97 | + "**3–6 个标签**(爱好/性格/职业方向/星座倾向等,只基于片段、不编造)。格式:先一句话,再「标签:A、B、C」。\n\n" + | |
| 98 | + moments.joinToString("\n") { "- ${it.take(80)}" } | |
| 99 | + val target = ChatConfig.targetById(getApplication(), ChatConfig.ID_4B) | |
| 100 | + val full = ChatClient().stream(target, listOf("user" to prompt)) {} | |
| 101 | + repo.persons.updateImpression(personId, full.trim().ifBlank { "(生成为空)" }) | |
| 102 | + } catch (t: Throwable) { | |
| 103 | + repo.persons.updateImpression(personId, "⚠ 生成失败:${t.message}") | |
| 104 | + } | |
| 105 | + } | |
| 106 | + } | |
| 107 | + | |
| 82 | 108 | /** 设备上是否已备好微信 enroll 数据(Mac 推过来的头像+清单)。 */ |
| 83 | 109 | fun wechatEnrollReady(): Boolean = |
| 84 | 110 | File(getApplication<Application>().filesDir, "wx_enroll/enroll.json").exists() | ... | ... |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/AppDatabase.kt
| ... | ... | @@ -19,8 +19,10 @@ import androidx.sqlite.db.SupportSQLiteDatabase |
| 19 | 19 | Person::class, |
| 20 | 20 | Photo::class, |
| 21 | 21 | Face::class, |
| 22 | + WechatMessage::class, | |
| 23 | + Moment::class, | |
| 22 | 24 | ], |
| 23 | - version = 6, | |
| 25 | + version = 9, | |
| 24 | 26 | exportSchema = false, |
| 25 | 27 | ) |
| 26 | 28 | abstract class AppDatabase : RoomDatabase() { |
| ... | ... | @@ -32,6 +34,8 @@ abstract class AppDatabase : RoomDatabase() { |
| 32 | 34 | abstract fun personDao(): PersonDao |
| 33 | 35 | abstract fun photoDao(): PhotoDao |
| 34 | 36 | abstract fun faceDao(): FaceDao |
| 37 | + abstract fun wechatMessageDao(): WechatMessageDao | |
| 38 | + abstract fun momentDao(): MomentDao | |
| 35 | 39 | |
| 36 | 40 | companion object { |
| 37 | 41 | @Volatile |
| ... | ... | @@ -80,6 +84,25 @@ abstract class AppDatabase : RoomDatabase() { |
| 80 | 84 | } |
| 81 | 85 | } |
| 82 | 86 | |
| 87 | + /** v0.7 人脉里程碑2:微信聊天消息表。 */ | |
| 88 | + private val MIGRATION_6_7 = object : Migration(6, 7) { | |
| 89 | + override fun migrate(db: SupportSQLiteDatabase) { | |
| 90 | + db.execSQL( | |
| 91 | + "CREATE TABLE IF NOT EXISTS `wechat_messages` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `talker` TEXT NOT NULL, `content` TEXT NOT NULL, `createTime` INTEGER NOT NULL, `isSend` INTEGER NOT NULL)", | |
| 92 | + ) | |
| 93 | + } | |
| 94 | + } | |
| 95 | + | |
| 96 | + /** v0.7 人脉里程碑4/5:朋友圈表 + persons.impression(AI 印象)。 */ | |
| 97 | + private val MIGRATION_7_9 = object : Migration(7, 9) { | |
| 98 | + override fun migrate(db: SupportSQLiteDatabase) { | |
| 99 | + db.execSQL( | |
| 100 | + "CREATE TABLE IF NOT EXISTS `moments` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userName` TEXT NOT NULL, `text` TEXT NOT NULL, `createTime` INTEGER NOT NULL)", | |
| 101 | + ) | |
| 102 | + db.execSQL("ALTER TABLE `persons` ADD COLUMN `impression` TEXT") | |
| 103 | + } | |
| 104 | + } | |
| 105 | + | |
| 83 | 106 | fun get(ctx: Context): AppDatabase = |
| 84 | 107 | instance ?: synchronized(this) { |
| 85 | 108 | instance ?: Room.databaseBuilder( |
| ... | ... | @@ -87,7 +110,7 @@ abstract class AppDatabase : RoomDatabase() { |
| 87 | 110 | AppDatabase::class.java, |
| 88 | 111 | "lifelog.db", |
| 89 | 112 | ) |
| 90 | - .addMigrations(MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6) | |
| 113 | + .addMigrations(MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_9) | |
| 91 | 114 | // 不再 fallbackToDestructiveMigration:以后改 schema 必须写 Migration, |
| 92 | 115 | // 否则启动直接抛 IllegalStateException(fail-loud),绝不静默清掉真实录音。 |
| 93 | 116 | .build() | ... | ... |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt
| ... | ... | @@ -266,6 +266,9 @@ interface PersonDao { |
| 266 | 266 | @Query("UPDATE persons SET faceEmbedding = :emb WHERE id = :id") |
| 267 | 267 | suspend fun updateFaceEmbedding(id: Long, emb: ByteArray) |
| 268 | 268 | |
| 269 | + @Query("UPDATE persons SET impression = :text WHERE id = :id") | |
| 270 | + suspend fun updateImpression(id: Long, text: String) | |
| 271 | + | |
| 269 | 272 | @Query("UPDATE persons SET name = :name WHERE id = :id") |
| 270 | 273 | suspend fun updateName(id: Long, name: String) |
| 271 | 274 | |
| ... | ... | @@ -358,6 +361,40 @@ interface FaceDao { |
| 358 | 361 | } |
| 359 | 362 | |
| 360 | 363 | @Dao |
| 364 | +interface MomentDao { | |
| 365 | + @Insert | |
| 366 | + suspend fun insertAll(m: List<Moment>) | |
| 367 | + | |
| 368 | + @Query("DELETE FROM moments") | |
| 369 | + suspend fun clear() | |
| 370 | + | |
| 371 | + @Query("SELECT COUNT(*) FROM moments") | |
| 372 | + suspend fun count(): Int | |
| 373 | + | |
| 374 | + @Query("SELECT * FROM moments WHERE userName = :u ORDER BY createTime DESC") | |
| 375 | + fun observeByUser(u: String): Flow<List<Moment>> | |
| 376 | + | |
| 377 | + @Query("SELECT * FROM moments WHERE userName = :u ORDER BY createTime DESC LIMIT :n") | |
| 378 | + suspend fun byUser(u: String, n: Int): List<Moment> | |
| 379 | +} | |
| 380 | + | |
| 381 | +@Dao | |
| 382 | +interface WechatMessageDao { | |
| 383 | + @Insert | |
| 384 | + suspend fun insertAll(msgs: List<WechatMessage>) | |
| 385 | + | |
| 386 | + @Query("DELETE FROM wechat_messages") | |
| 387 | + suspend fun clear() | |
| 388 | + | |
| 389 | + @Query("SELECT COUNT(*) FROM wechat_messages") | |
| 390 | + suspend fun count(): Int | |
| 391 | + | |
| 392 | + /** 某联系人(wxid)的聊天,时间正序。 */ | |
| 393 | + @Query("SELECT * FROM wechat_messages WHERE talker = :talker ORDER BY createTime ASC") | |
| 394 | + fun observeByTalker(talker: String): Flow<List<WechatMessage>> | |
| 395 | +} | |
| 396 | + | |
| 397 | +@Dao | |
| 361 | 398 | interface ChatDao { |
| 362 | 399 | @Insert |
| 363 | 400 | suspend fun insertConversation(c: ChatConversation): Long | ... | ... |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Entities.kt
| ... | ... | @@ -103,7 +103,9 @@ data class Person( |
| 103 | 103 | val alias: String? = null, // 微信号 |
| 104 | 104 | val pinyin: String? = null, // 全拼(排序) |
| 105 | 105 | val pyInitial: String? = null, // 首字母(A–Z 索引) |
| 106 | - val phone: String? = null, // 手机号(未来短信关联,先占位) | |
| 106 | + val phone: String? = null, // 手机号(短信关联) | |
| 107 | + /** AI 印象:本地 LLM 综合 TA 的朋友圈/聊天生成的一句话画像 + 标签(爱好/性格)。 */ | |
| 108 | + val impression: String? = null, | |
| 107 | 109 | val createdAt: Long = System.currentTimeMillis(), |
| 108 | 110 | ) |
| 109 | 111 | |
| ... | ... | @@ -124,6 +126,25 @@ data class Photo( |
| 124 | 126 | val processedAt: Long? = null, |
| 125 | 127 | ) |
| 126 | 128 | |
| 129 | +/** 朋友圈(从 SnsMicroMsg SnsInfo 表导入,userName=发布者 wxid,text=正文)。 */ | |
| 130 | +@Entity(tableName = "moments") | |
| 131 | +data class Moment( | |
| 132 | + @PrimaryKey(autoGenerate = true) val id: Long = 0, | |
| 133 | + val userName: String, | |
| 134 | + val text: String, | |
| 135 | + val createTime: Long, | |
| 136 | +) | |
| 137 | + | |
| 138 | +/** 微信聊天消息(从 EnMicroMsg message 表导入,talker=对方 wxid,关联 Person.contactRef)。 */ | |
| 139 | +@Entity(tableName = "wechat_messages") | |
| 140 | +data class WechatMessage( | |
| 141 | + @PrimaryKey(autoGenerate = true) val id: Long = 0, | |
| 142 | + val talker: String, // 对方 wxid | |
| 143 | + val content: String, | |
| 144 | + val createTime: Long, // 毫秒 | |
| 145 | + val isSend: Boolean, // true = 我发的 | |
| 146 | +) | |
| 147 | + | |
| 127 | 148 | /** 照片里检测到的一张脸:bbox + ArcFace embedding + 归属 Person。结构同 SpeakerProfile 的脸版。 */ |
| 128 | 149 | @Entity(tableName = "faces") |
| 129 | 150 | data class Face( | ... | ... |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Repository.kt
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/vision/WeChatEnrollWorker.kt
| ... | ... | @@ -4,8 +4,10 @@ import android.content.Context |
| 4 | 4 | import android.util.Log |
| 5 | 5 | import androidx.work.CoroutineWorker |
| 6 | 6 | import androidx.work.WorkerParameters |
| 7 | +import com.lemon.lifelog.data.Moment | |
| 7 | 8 | import com.lemon.lifelog.data.Person |
| 8 | 9 | import com.lemon.lifelog.data.Repository |
| 10 | +import com.lemon.lifelog.data.WechatMessage | |
| 9 | 11 | import org.json.JSONArray |
| 10 | 12 | import java.io.File |
| 11 | 13 | import java.util.concurrent.atomic.AtomicBoolean |
| ... | ... | @@ -62,6 +64,49 @@ class WeChatEnrollWorker(ctx: Context, params: WorkerParameters) : CoroutineWork |
| 62 | 64 | } |
| 63 | 65 | Log.i(TAG, "资料 upsert:新建 $created(共 ${arr.length()})") |
| 64 | 66 | |
| 67 | + // ---- Pass 1.5: 微信聊天导入(clear + 重灌,幂等)---- | |
| 68 | + val msgFile = File(dir, "messages.json") | |
| 69 | + if (msgFile.exists()) { | |
| 70 | + try { | |
| 71 | + val m = JSONArray(msgFile.readText()) | |
| 72 | + val msgs = ArrayList<WechatMessage>(m.length()) | |
| 73 | + for (i in 0 until m.length()) { | |
| 74 | + val o = m.getJSONObject(i) | |
| 75 | + msgs.add( | |
| 76 | + WechatMessage( | |
| 77 | + talker = o.optString("talker"), | |
| 78 | + content = o.optString("content"), | |
| 79 | + createTime = o.optLong("createTime"), | |
| 80 | + isSend = o.optInt("isSend") == 1, | |
| 81 | + ), | |
| 82 | + ) | |
| 83 | + } | |
| 84 | + repo.wechatMessages.clear() | |
| 85 | + repo.wechatMessages.insertAll(msgs) | |
| 86 | + Log.i(TAG, "微信聊天导入 ${msgs.size} 条") | |
| 87 | + } catch (t: Throwable) { | |
| 88 | + Log.w(TAG, "msg import failed: ${t.message}") | |
| 89 | + } | |
| 90 | + } | |
| 91 | + | |
| 92 | + // ---- Pass 1.6: 朋友圈导入 ---- | |
| 93 | + val snsFile = File(dir, "moments.json") | |
| 94 | + if (snsFile.exists()) { | |
| 95 | + try { | |
| 96 | + val a = JSONArray(snsFile.readText()) | |
| 97 | + val ms = ArrayList<Moment>(a.length()) | |
| 98 | + for (i in 0 until a.length()) { | |
| 99 | + val o = a.getJSONObject(i) | |
| 100 | + ms.add(Moment(userName = o.optString("userName"), text = o.optString("text"), createTime = o.optLong("createTime"))) | |
| 101 | + } | |
| 102 | + repo.moments.clear() | |
| 103 | + repo.moments.insertAll(ms) | |
| 104 | + Log.i(TAG, "朋友圈导入 ${ms.size} 条") | |
| 105 | + } catch (t: Throwable) { | |
| 106 | + Log.w(TAG, "moments import failed: ${t.message}") | |
| 107 | + } | |
| 108 | + } | |
| 109 | + | |
| 65 | 110 | // ---- Pass 2: 人脸锚点(仅 hasFace 且未建锚点)---- |
| 66 | 111 | val engine = FaceEngine.load(applicationContext) |
| 67 | 112 | var anchored = 0 | ... | ... |
server/services/wechat/extract-wechat-chat-moments.py
0 → 100644
| 1 | +#!/usr/bin/env python3 | |
| 2 | +""" | |
| 3 | +微信聊天 + 朋友圈提取 —— 给 lifelog/photolog 人脉档案页用(里程碑 2/4)。 | |
| 4 | + | |
| 5 | +- 聊天:EnMicroMsg.db(SQLCipher,key e03ce81 + compat=1 + page1024)的 message 表, | |
| 6 | + type=1 文本、talker 非 @chatroom → messages.json [{talker,isSend,createTime,content}]。 | |
| 7 | + 注意 sqlcipher .mode json 输出开头有 PRAGMA 的 "ok",要 raw[raw.index('['):] 跳过, | |
| 8 | + 否则 app 端 JSONArray 解析失败、消息导不进去(实测踩过)。 | |
| 9 | +- 朋友圈:SnsMicroMsg.db **是明文 SQLite**(不加密!)的 SnsInfo 表,content 是 protobuf, | |
| 10 | + 正文用「抽含中文最多的可读段」近似提取 → moments.json [{userName,createTime,text}]。 | |
| 11 | + | |
| 12 | +产物 push 到设备 filesDir/wx_enroll/{messages.json,moments.json},app 内「从微信导入联系人」 | |
| 13 | +的 WeChatEnrollWorker 会 clear+重灌进 wechat_messages / moments 表,按 wxid 关联 Person。 | |
| 14 | + | |
| 15 | +用法:python3 extract-wechat-chat-moments.py /tmp/EnMicroMsg.db /tmp/SnsMicroMsg.db e03ce81 ./out | |
| 16 | +""" | |
| 17 | +import sys, os, re, json, subprocess | |
| 18 | + | |
| 19 | + | |
| 20 | +def sqlcipher_json(db, key, sql): | |
| 21 | + script = f'PRAGMA key="{key}";\nPRAGMA cipher_compatibility=1;\nPRAGMA cipher_page_size=1024;\n.mode json\n{sql}\n' | |
| 22 | + out = subprocess.run(['sqlcipher', db], input=script, capture_output=True, text=True).stdout | |
| 23 | + return json.loads(out[out.index('['):]) # 跳过 PRAGMA 的 "ok" | |
| 24 | + | |
| 25 | + | |
| 26 | +def extract_messages(en_db, key): | |
| 27 | + return sqlcipher_json(en_db, key, """ | |
| 28 | + SELECT talker, isSend, createTime, content FROM message | |
| 29 | + WHERE type=1 AND talker NOT LIKE '%@chatroom' AND content IS NOT NULL AND content != '' | |
| 30 | + ORDER BY createTime ASC;""") | |
| 31 | + | |
| 32 | + | |
| 33 | +def moment_text(blob): | |
| 34 | + if not blob: | |
| 35 | + return '' | |
| 36 | + s = blob.decode('utf-8', 'ignore') | |
| 37 | + segs = re.findall(r'[一-鿿A-Za-z0-9,。!?、,.!?#@::…~()()【】\-—\s]{2,}', s) | |
| 38 | + | |
| 39 | + def score(x): | |
| 40 | + x = x.strip() | |
| 41 | + if x.startswith(('http', 'wxid', 'mmsns', 'mp.weixin')): | |
| 42 | + return -1 | |
| 43 | + if re.fullmatch(r'[A-Za-z0-9]+', x): | |
| 44 | + return -1 | |
| 45 | + return len(re.findall(r'[一-鿿]', x)) | |
| 46 | + best = max(segs, key=score, default='') | |
| 47 | + return best.strip() if score(best) > 0 else '' | |
| 48 | + | |
| 49 | + | |
| 50 | +def extract_moments(sns_db): | |
| 51 | + import sqlite3 # SnsMicroMsg 是明文 | |
| 52 | + c = sqlite3.connect(sns_db) | |
| 53 | + out = [] | |
| 54 | + for u, t, content in c.execute("SELECT userName, createTime, content FROM SnsInfo WHERE content IS NOT NULL"): | |
| 55 | + txt = moment_text(content) | |
| 56 | + if txt: | |
| 57 | + ct = int(t) | |
| 58 | + ct = ct * 1000 if ct < 1e12 else ct # 秒→毫秒 | |
| 59 | + out.append({'userName': u, 'createTime': ct, 'text': txt}) | |
| 60 | + return out | |
| 61 | + | |
| 62 | + | |
| 63 | +def main(): | |
| 64 | + en_db, sns_db, key, out_dir = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4] | |
| 65 | + os.makedirs(out_dir, exist_ok=True) | |
| 66 | + msgs = extract_messages(en_db, key) | |
| 67 | + json.dump(msgs, open(os.path.join(out_dir, 'messages.json'), 'w'), ensure_ascii=False) | |
| 68 | + moments = extract_moments(sns_db) | |
| 69 | + json.dump(moments, open(os.path.join(out_dir, 'moments.json'), 'w'), ensure_ascii=False) | |
| 70 | + print(f'聊天 {len(msgs)} 条,朋友圈 {len(moments)} 条') | |
| 71 | + | |
| 72 | + | |
| 73 | +if __name__ == '__main__': | |
| 74 | + main() | ... | ... |