Compare commits

...

21 Commits

36 changed files with 11802 additions and 851 deletions
+1 -10
View File
@@ -5,14 +5,13 @@
<div align="center">
<h1>WeChatDataAnalysis - 微信数据库解密与分析工具</h1>
<p>一个专门用于微信4.x版本数据库解密的工具(支持聊天记录实时更新)</p>
<p><b>特别致谢</b><a href="https://github.com/ycccccccy/echotrace">echotrace</a>、<a href="https://github.com/hicccc77/WeFlow">WeFlow</a>(本项目大量功能参考其实现,提供了重要技术支持</p>
<p><b>特别致谢</b><a href="https://github.com/H3CoF6">H3CoF6</a>(密钥与朋友圈等核心内容的技术支持)、<a href="https://github.com/ycccccccy/echotrace">echotrace</a>、<a href="https://github.com/hicccc77/WeFlow">WeFlow</a>(本项目大量功能参考其实现)</p>
<img src="https://img.shields.io/github/v/tag/LifeArchiveProject/WeChatDataAnalysis" alt="Version" />
<img src="https://img.shields.io/github/stars/LifeArchiveProject/WeChatDataAnalysis" alt="Stars" />
<img src="https://gh-down-badges.linkof.link/LifeArchiveProject/WeChatDataAnalysis" alt="Downloads" />
<img src="https://img.shields.io/github/forks/LifeArchiveProject/WeChatDataAnalysis" alt="Forks" />
<a href="https://qm.qq.com/q/VQEQ7PcGkk"><img src="https://img.shields.io/badge/QQ%20Group-WeChatDataAnalysis-12B7F5?logo=tencentqq&logoColor=white" alt="QQ Group" /></a>
<img src="https://img.shields.io/badge/Python-3776AB?logo=Python&logoColor=white" alt="Python" />
<img src="https://img.shields.io/badge/FastAPI-009688?logo=FastAPI&logoColor=white" alt="FastAPI" />
<img src="https://img.shields.io/badge/Vue.js-4FC08D?logo=Vue.js&logoColor=white" alt="Vue.js" />
<img src="https://img.shields.io/badge/SQLite-003B57?logo=SQLite&logoColor=white" alt="SQLite" />
</div>
@@ -189,8 +188,6 @@ npm run dist
本项目的开发过程中参考了以下优秀的开源项目和资源:
### 主要参考项目
1. **[echotrace](https://github.com/ycccccccy/echotrace)** - 微信数据解析/取证工具
- 本项目大量功能参考并复用其实现思路,提供了重要技术支持
@@ -215,12 +212,6 @@ npm run dist
7. **[wx-dat](https://github.com/waaaaashi/wx-dat)** - 微信图片密钥获取工具
- 实现真正的无头获取图片密钥,不再依赖扫描微信内存与点击朋友圈大图
8. **PR #24 贡献者 [H3CoF6](https://github.com/H3CoF6)** - 微信密钥获取能力增强
- 无第三方工具依赖实现微信密钥获取能力
- 实现数据库密钥获取:实现形式参考 [wx_key](https://github.com/ycccccccy/wx_key) 项目,完成 Python 预编译 wheel 封装,详情见 [py_wx_key](https://github.com/H3CoF6/py_wx_key)
- 特征码不在 C++ 内硬编码,而由 Python 模块传入,减少 wheel 更新次数
- 实现真正的无头获取图片密钥,不再依赖扫描微信内存(以及点击朋友圈大图),感谢项目 [wx-dat](https://github.com/waaaaashi/wx-dat)
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=LifeArchiveProject/WeChatDataAnalysis&type=Date)](https://www.star-history.com/#LifeArchiveProject/WeChatDataAnalysis&Date)
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "wechat-data-analysis-desktop",
"version": "0.2.1",
"version": "1.3.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "wechat-data-analysis-desktop",
"version": "0.2.1",
"version": "1.3.0",
"devDependencies": {
"concurrently": "^9.2.1",
"cross-env": "^10.1.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "wechat-data-analysis-desktop",
"private": true,
"version": "0.2.1",
"version": "1.3.0",
"main": "src/main.cjs",
"scripts": {
"dev": "concurrently -k -s first \"cd ..\\\\frontend && npm run dev\" \"cross-env ELECTRON_START_URL=http://localhost:3000 electron .\"",
+122
View File
@@ -786,6 +786,128 @@
@apply px-3 py-3 border-b border-gray-100;
}
/* 时间侧边栏(按日期定位) */
.time-sidebar {
@apply w-[420px] h-full flex flex-col bg-white border-l border-gray-200 flex-shrink-0;
}
.time-sidebar-header {
@apply flex items-center justify-between px-4 py-3 border-b border-gray-200 bg-gray-50;
}
.time-sidebar-title {
@apply flex items-center gap-2 text-sm font-medium text-gray-800;
}
.time-sidebar-close {
@apply p-1.5 text-gray-500 hover:text-gray-700 hover:bg-gray-200 rounded-md transition-colors;
}
.time-sidebar-body {
@apply flex-1 overflow-y-auto min-h-0;
}
.time-sidebar-status {
@apply px-4 py-2 text-xs text-gray-600 border-b border-gray-100;
}
.time-sidebar-status-error {
@apply text-red-600;
}
.calendar-header {
@apply flex items-center justify-between px-4 py-3;
}
.calendar-nav-btn {
@apply p-1.5 text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed;
}
.calendar-month-label {
@apply text-sm font-medium text-gray-800;
}
.calendar-month-label-selects {
@apply flex items-center gap-2;
}
.calendar-ym-select {
@apply text-xs px-2 py-1 rounded-md border border-gray-200 bg-white text-gray-800 hover:border-gray-300 focus:outline-none focus:ring-2 focus:ring-[#03C160]/20 disabled:opacity-60 disabled:cursor-not-allowed;
}
.calendar-weekdays {
@apply grid grid-cols-7 gap-1 px-4 pt-1;
}
.calendar-weekday {
@apply text-[11px] text-gray-400 text-center py-1;
}
.calendar-grid {
@apply grid grid-cols-7 gap-1 px-4 pb-4;
}
.calendar-day {
@apply h-9 rounded-md flex items-center justify-center text-xs font-medium transition-colors border border-gray-200 bg-white disabled:cursor-not-allowed;
}
.calendar-day-outside {
@apply bg-transparent border-transparent;
}
.calendar-day-empty {
@apply bg-gray-100 text-gray-400 border-gray-100;
}
.calendar-day-selected {
/* Keep background as-is (heatmap), but emphasize with a ring/outline. */
box-shadow: 0 0 0 2px rgba(3, 193, 96, 0.85);
border-color: rgba(3, 193, 96, 0.95) !important;
}
.calendar-day-l1 {
background: rgba(3, 193, 96, 0.12);
border-color: rgba(3, 193, 96, 0.18);
color: #065f46;
}
.calendar-day-l2 {
background: rgba(3, 193, 96, 0.24);
border-color: rgba(3, 193, 96, 0.28);
color: #065f46;
}
.calendar-day-l3 {
background: rgba(3, 193, 96, 0.38);
border-color: rgba(3, 193, 96, 0.40);
color: #064e3b;
}
.calendar-day-l4 {
background: rgba(3, 193, 96, 0.55);
border-color: rgba(3, 193, 96, 0.55);
color: #053d2e;
}
.calendar-day-l1:hover,
.calendar-day-l2:hover,
.calendar-day-l3:hover,
.calendar-day-l4:hover {
filter: brightness(0.98);
}
.calendar-day-number {
@apply select-none;
}
.time-sidebar-actions {
@apply px-4 pb-4;
}
.time-sidebar-action-btn {
@apply w-full text-xs px-3 py-2 rounded-md bg-[#03C160] text-white hover:bg-[#02a650] transition-colors disabled:opacity-60 disabled:cursor-not-allowed;
}
/* 整合搜索框样式 */
.search-input-combined {
@apply flex items-center bg-white border-2 border-gray-200 rounded-lg overflow-hidden transition-all duration-200;
+29
View File
@@ -0,0 +1,29 @@
<template>
<svg
:width="size"
:height="size"
viewBox="0 0 24 24"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<!-- Keep the SVG identical to WeFlow/src/components/LivePhotoIcon.tsx for visual consistency -->
<g stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" stroke-linecap="round" stroke-linejoin="round">
<g stroke="currentColor" stroke-width="2">
<circle fill="currentColor" stroke="none" cx="12" cy="12" r="2.5" />
<circle cx="12" cy="12" r="5.5" />
<circle cx="12" cy="12" r="9" stroke-dasharray="1 3.7" />
</g>
</g>
</svg>
</template>
<script setup>
defineProps({
size: {
type: [Number, String],
default: 24
}
})
</script>
+84
View File
@@ -180,6 +180,46 @@ export const useApi = () => {
return await request(url)
}
// 聊天记录日历热力图:某月每日消息数
const getChatMessageDailyCounts = async (params = {}) => {
const query = new URLSearchParams()
if (params && params.account) query.set('account', params.account)
if (params && params.username) query.set('username', params.username)
if (params && params.year != null) query.set('year', String(params.year))
if (params && params.month != null) query.set('month', String(params.month))
const url = '/chat/messages/daily_counts' + (query.toString() ? `?${query.toString()}` : '')
return await request(url)
}
// 聊天记录定位锚点:某日第一条 / 会话最早一条
const getChatMessageAnchor = async (params = {}) => {
const query = new URLSearchParams()
if (params && params.account) query.set('account', params.account)
if (params && params.username) query.set('username', params.username)
if (params && params.kind) query.set('kind', String(params.kind))
if (params && params.date) query.set('date', String(params.date))
const url = '/chat/messages/anchor' + (query.toString() ? `?${query.toString()}` : '')
return await request(url)
}
// 解析嵌套合并转发聊天记录(通过 server_id)
const resolveNestedChatHistory = async (params = {}) => {
const query = new URLSearchParams()
if (params && params.account) query.set('account', params.account)
if (params && params.server_id != null) query.set('server_id', String(params.server_id))
const url = '/chat/chat_history/resolve' + (query.toString() ? `?${query.toString()}` : '')
return await request(url)
}
// 解析卡片/小程序等 App 消息(通过 server_id
const resolveAppMsg = async (params = {}) => {
const query = new URLSearchParams()
if (params && params.account) query.set('account', params.account)
if (params && params.server_id != null) query.set('server_id', String(params.server_id))
const url = '/chat/appmsg/resolve' + (query.toString() ? `?${query.toString()}` : '')
return await request(url)
}
// 朋友圈时间线
const listSnsTimeline = async (params = {}) => {
const query = new URLSearchParams()
@@ -196,6 +236,16 @@ export const useApi = () => {
return await request(url)
}
// 朋友圈联系人列表(按发圈数统计)
const listSnsUsers = async (params = {}) => {
const query = new URLSearchParams()
if (params && params.account) query.set('account', params.account)
if (params && params.keyword) query.set('keyword', String(params.keyword))
if (params && params.limit != null) query.set('limit', String(params.limit))
const url = '/sns/users' + (query.toString() ? `?${query.toString()}` : '')
return await request(url)
}
// 朋友圈图片本地缓存候选(用于错图时手动选择)
const listSnsMediaCandidates = async (params = {}) => {
const query = new URLSearchParams()
@@ -295,6 +345,7 @@ export const useApi = () => {
output_dir: data.output_dir == null ? null : String(data.output_dir || '').trim(),
allow_process_key_extract: !!data.allow_process_key_extract,
download_remote_media: !!data.download_remote_media,
html_page_size: data.html_page_size != null ? Number(data.html_page_size) : 1000,
privacy_mode: !!data.privacy_mode,
file_name: data.file_name || null
}
@@ -315,6 +366,31 @@ export const useApi = () => {
return await request(`/chat/exports/${encodeURIComponent(String(exportId))}`, { method: 'DELETE' })
}
// 朋友圈导出(离线 HTML zip
const createSnsExport = async (data = {}) => {
return await request('/sns/exports', {
method: 'POST',
body: {
account: data.account || null,
scope: data.scope || 'selected',
usernames: Array.isArray(data.usernames) ? data.usernames : [],
use_cache: data.use_cache == null ? true : !!data.use_cache,
output_dir: data.output_dir == null ? null : String(data.output_dir || '').trim(),
file_name: data.file_name || null
}
})
}
const getSnsExport = async (exportId) => {
if (!exportId) throw new Error('Missing exportId')
return await request(`/sns/exports/${encodeURIComponent(String(exportId))}`)
}
const cancelSnsExport = async (exportId) => {
if (!exportId) throw new Error('Missing exportId')
return await request(`/sns/exports/${encodeURIComponent(String(exportId))}`, { method: 'DELETE' })
}
// 联系人
const listChatContacts = async (params = {}) => {
const query = new URLSearchParams()
@@ -408,7 +484,12 @@ export const useApi = () => {
buildChatSearchIndex,
listChatSearchSenders,
getChatMessagesAround,
getChatMessageDailyCounts,
getChatMessageAnchor,
resolveNestedChatHistory,
resolveAppMsg,
listSnsTimeline,
listSnsUsers,
listSnsMediaCandidates,
saveSnsMediaPicks,
openChatMediaFolder,
@@ -420,6 +501,9 @@ export const useApi = () => {
getChatExport,
listChatExports,
cancelChatExport,
createSnsExport,
getSnsExport,
cancelSnsExport,
listChatContacts,
exportChatContacts,
getWrappedAnnual,
File diff suppressed because it is too large Load Diff
+74 -21
View File
@@ -46,29 +46,34 @@
<div v-else-if="error" class="p-4 text-sm text-red-500 whitespace-pre-wrap">{{ error }}</div>
<div v-else-if="contacts.length === 0" class="p-4 text-sm text-gray-500">暂无联系人</div>
<div v-else>
<div
v-for="contact in contacts"
:key="contact.username"
class="px-3 py-2 border-b border-gray-100 flex items-center gap-3"
>
<div class="w-10 h-10 rounded-md overflow-hidden bg-gray-300 shrink-0" :class="{ 'privacy-blur': privacyMode }">
<img v-if="contact.avatar" :src="contact.avatar" :alt="contact.displayName" class="w-full h-full object-cover" referrerpolicy="no-referrer" />
<div v-else class="w-full h-full flex items-center justify-center text-white text-xs font-bold" style="background-color:#4B5563">{{ contact.displayName?.charAt(0) || '?' }}</div>
<div v-for="group in groupedContacts" :key="group.key">
<div class="px-3 py-1 text-xs font-semibold text-gray-500 bg-gray-50 border-b border-gray-100">
{{ group.key }}
</div>
<div class="min-w-0 flex-1" :class="{ 'privacy-blur': privacyMode }">
<div class="text-sm text-gray-900 truncate">{{ contact.displayName }}</div>
<div class="text-xs text-gray-500 truncate">{{ contact.username }}</div>
<div class="text-[11px] text-gray-500 truncate" v-if="contact.type !== 'group' && (contact.region || contact.source)">
<span v-if="contact.region">地区{{ contact.region }}</span>
<span v-if="contact.region && contact.source"> · </span>
<span
v-if="contact.source"
:title="contact.sourceScene != null ? `来源场景码:${contact.sourceScene}` : ''"
>来源{{ contact.source }}</span>
<div
v-for="contact in group.items"
:key="contact.username"
class="px-3 py-2 border-b border-gray-100 flex items-center gap-3"
>
<div class="w-10 h-10 rounded-md overflow-hidden bg-gray-300 shrink-0" :class="{ 'privacy-blur': privacyMode }">
<img v-if="contact.avatar" :src="contact.avatar" :alt="contact.displayName" class="w-full h-full object-cover" referrerpolicy="no-referrer" />
<div v-else class="w-full h-full flex items-center justify-center text-white text-xs font-bold" style="background-color:#4B5563">{{ contact.displayName?.charAt(0) || '?' }}</div>
</div>
<div class="min-w-0 flex-1" :class="{ 'privacy-blur': privacyMode }">
<div class="text-sm text-gray-900 truncate">{{ contact.displayName }}</div>
<div class="text-xs text-gray-500 truncate">{{ contact.username }}</div>
<div class="text-[11px] text-gray-500 truncate" v-if="contact.type !== 'group' && (contact.region || contact.source)">
<span v-if="contact.region">地区{{ contact.region }}</span>
<span v-if="contact.region && contact.source"> · </span>
<span
v-if="contact.source"
:title="contact.sourceScene != null ? `来源场景码:${contact.sourceScene}` : ''"
>来源{{ contact.source }}</span>
</div>
</div>
<div class="text-xs px-2 py-0.5 rounded" :class="typeBadgeClass(contact.type)">
{{ typeLabel(contact.type) }}
</div>
</div>
<div class="text-xs px-2 py-0.5 rounded" :class="typeBadgeClass(contact.type)">
{{ typeLabel(contact.type) }}
</div>
</div>
</div>
@@ -184,6 +189,54 @@ const typeBadgeClass = (type) => {
return 'bg-gray-100 text-gray-600'
}
const normalizeContactGroupKey = (value) => {
const key = String(value || '').trim().toUpperCase()
if (key.length === 1 && key >= 'A' && key <= 'Z') return key
return '#'
}
const buildContactSortKey = (contact) => {
const pinyinKey = String(contact?.pinyinKey || '').trim().toLowerCase()
if (pinyinKey) return pinyinKey
const nameKey = String(contact?.displayName || '').trim().toLowerCase()
if (nameKey) return nameKey
return String(contact?.username || '').trim().toLowerCase()
}
const groupedContacts = computed(() => {
const list = Array.isArray(contacts.value) ? contacts.value : []
const rows = list.map((contact) => {
return {
contact,
groupKey: normalizeContactGroupKey(contact?.pinyinInitial),
sortKey: buildContactSortKey(contact),
usernameKey: String(contact?.username || '').trim().toLowerCase(),
}
})
rows.sort((a, b) => {
if (a.groupKey !== b.groupKey) {
if (a.groupKey === '#') return 1
if (b.groupKey === '#') return -1
return a.groupKey.localeCompare(b.groupKey)
}
const cmpKey = a.sortKey.localeCompare(b.sortKey)
if (cmpKey !== 0) return cmpKey
return a.usernameKey.localeCompare(b.usernameKey)
})
const groups = []
for (const row of rows) {
const last = groups[groups.length - 1]
if (!last || last.key !== row.groupKey) {
groups.push({ key: row.groupKey, items: [row.contact] })
} else {
last.items.push(row.contact)
}
}
return groups
})
const isDesktopExportRuntime = () => {
return !!(process.client && window?.wechatDesktop?.chooseDirectory)
}
+29 -1
View File
@@ -89,6 +89,26 @@
</div>
</div>
</div>
<div class="rounded-lg border border-gray-200">
<div class="px-4 py-3 border-b border-gray-200 bg-gray-50">
<div class="text-sm font-medium text-gray-900">朋友圈</div>
</div>
<div class="px-4 py-3 space-y-4">
<div class="flex items-center justify-between gap-4">
<div class="min-w-0">
<div class="text-sm font-medium text-gray-900">朋友圈图片使用缓存</div>
<div class="text-xs text-gray-500">开启下载解密失败时回退本地缓存默认开启关闭每次都走下载+解密</div>
</div>
<input
type="checkbox"
class="h-4 w-4"
:checked="snsUseCache"
@change="onSnsUseCacheToggle"
/>
</div>
</div>
</div>
</div>
</div>
</div>
@@ -98,7 +118,7 @@
</template>
<script setup>
import { DESKTOP_SETTING_AUTO_REALTIME_KEY, DESKTOP_SETTING_DEFAULT_TO_CHAT_KEY, readLocalBoolSetting, writeLocalBoolSetting } from '~/utils/desktop-settings'
import { DESKTOP_SETTING_AUTO_REALTIME_KEY, DESKTOP_SETTING_DEFAULT_TO_CHAT_KEY, SNS_SETTING_USE_CACHE_KEY, readLocalBoolSetting, writeLocalBoolSetting } from '~/utils/desktop-settings'
useHead({ title: '设置 - 微信数据分析助手' })
@@ -106,6 +126,7 @@ const isDesktopEnv = ref(false)
const desktopAutoRealtime = ref(false)
const desktopDefaultToChatWhenData = ref(false)
const snsUseCache = ref(true)
const desktopAutoLaunch = ref(false)
const desktopAutoLaunchLoading = ref(false)
@@ -198,6 +219,12 @@ const onDesktopDefaultToChatToggle = (ev) => {
writeLocalBoolSetting(DESKTOP_SETTING_DEFAULT_TO_CHAT_KEY, checked)
}
const onSnsUseCacheToggle = (ev) => {
const checked = !!ev?.target?.checked
snsUseCache.value = checked
writeLocalBoolSetting(SNS_SETTING_USE_CACHE_KEY, checked)
}
onMounted(async () => {
if (process.client && typeof window !== 'undefined') {
isDesktopEnv.value = !!window.wechatDesktop
@@ -205,6 +232,7 @@ onMounted(async () => {
desktopAutoRealtime.value = readLocalBoolSetting(DESKTOP_SETTING_AUTO_REALTIME_KEY, false)
desktopDefaultToChatWhenData.value = readLocalBoolSetting(DESKTOP_SETTING_DEFAULT_TO_CHAT_KEY, false)
snsUseCache.value = readLocalBoolSetting(SNS_SETTING_USE_CACHE_KEY, true)
if (isDesktopEnv.value) {
await refreshDesktopAutoLaunch()
+1169 -351
View File
File diff suppressed because it is too large Load Diff
+25
View File
@@ -13,6 +13,27 @@
<!-- 左上角刷新 + 复古模式开关 -->
<div class="absolute top-6 left-6 z-20 select-none">
<div class="flex items-center gap-3">
<button
type="button"
class="pointer-events-auto inline-flex items-center justify-center w-9 h-9 rounded-full bg-transparent text-[#07C160] hover:bg-[#07C160]/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-[#07C160]/30 transition"
aria-label="返回上一级"
title="返回上一级"
@click="goBack"
>
<svg
class="w-4 h-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
</button>
<button
type="button"
class="pointer-events-auto inline-flex items-center justify-center w-9 h-9 rounded-full bg-transparent text-[#07C160] hover:bg-[#07C160]/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-[#07C160]/30 disabled:opacity-60 disabled:cursor-not-allowed transition"
@@ -291,6 +312,10 @@ const goTo = (i) => {
activeIndex.value = clampIndex(i)
}
const goBack = async () => {
await router.push('/chat')
}
const next = () => goTo(activeIndex.value + 1)
const prev = () => goTo(activeIndex.value - 1)
+2
View File
@@ -1,5 +1,7 @@
export const DESKTOP_SETTING_AUTO_REALTIME_KEY = 'desktop.settings.autoRealtime'
export const DESKTOP_SETTING_DEFAULT_TO_CHAT_KEY = 'desktop.settings.defaultToChatWhenData'
// 朋友圈图片:是否允许使用缓存(默认开启)。关闭后会尽量每次都走下载+解密流程。
export const SNS_SETTING_USE_CACHE_KEY = 'sns.settings.useCache'
export const readLocalBoolSetting = (key, fallback = false) => {
if (!process.client) return !!fallback
+140 -40
View File
@@ -6,6 +6,7 @@
import sqlite3
import json
import argparse
from pathlib import Path
from typing import Dict, List, Any
from collections import defaultdict
@@ -127,6 +128,82 @@ class ConfigTemplateGenerator:
try:
cursor = conn.cursor()
def parse_columns_from_create_sql(create_sql: str) -> list[tuple[str, str]]:
"""
从建表 SQL 中尽力解析列名(用于 FTS5/缺失 tokenizer 扩展导致 PRAGMA 失败的情况)。
返回 (name, type);类型缺失时默认 TEXT。
"""
out: list[tuple[str, str]] = []
if not create_sql:
return out
try:
start = create_sql.find("(")
end = create_sql.rfind(")")
if start == -1 or end == -1 or end <= start:
return out
inner = create_sql[start + 1:end]
parts: list[str] = []
buf = ""
depth = 0
for ch in inner:
if ch == "(":
depth += 1
elif ch == ")":
depth -= 1
if ch == "," and depth == 0:
parts.append(buf.strip())
buf = ""
else:
buf += ch
if buf.strip():
parts.append(buf.strip())
for part in parts:
token = part.strip()
if not token:
continue
low = token.lower()
# 跳过约束/外键等
if low.startswith(("constraint", "primary", "unique", "foreign", "check")):
continue
# fts5 选项(tokenize/prefix/content/content_rowid 等)
if "=" in token:
key = token.split("=", 1)[0].strip().lower()
if key in ("tokenize", "prefix", "content", "content_rowid", "compress", "uncompress"):
continue
tokens = token.split()
if not tokens:
continue
name = tokens[0].strip("`\"[]")
typ = tokens[1].upper() if len(tokens) > 1 and "=" not in tokens[1] else "TEXT"
out.append((name, typ))
except Exception:
return out
return out
def get_table_columns(table_name: str) -> list[tuple[str, str]]:
# 先尝试 PRAGMA
try:
cursor.execute(f"PRAGMA table_info({table_name})")
columns = cursor.fetchall()
if columns:
return [(col[1], col[2]) for col in columns]
except Exception:
pass
# 兜底:从 sqlite_master.sql 解析
try:
cursor.execute(
"SELECT sql FROM sqlite_master WHERE type='table' AND name=?",
(table_name,),
)
row = cursor.fetchone()
create_sql = row[0] if row and len(row) > 0 else ""
return parse_columns_from_create_sql(create_sql or "")
except Exception:
return []
# 获取所有表名
cursor.execute("SELECT name FROM sqlite_master WHERE type='table'")
@@ -152,13 +229,10 @@ class ConfigTemplateGenerator:
table_key = f"{prefix}_*" # 使用模式名
# 获取代表表的字段信息
cursor.execute(f"PRAGMA table_info({representative_table})")
columns = cursor.fetchall()
columns = get_table_columns(representative_table)
fields = {}
for col in columns:
field_name = col[1]
field_type = col[2]
for field_name, field_type in columns:
fields[field_name] = {
"type": field_type,
"meaning": "", # 留空供用户填写
@@ -188,13 +262,10 @@ class ConfigTemplateGenerator:
try:
# 获取表字段信息
cursor.execute(f"PRAGMA table_info({table_name})")
columns = cursor.fetchall()
columns = get_table_columns(table_name)
fields = {}
for col in columns:
field_name = col[1]
field_type = col[2]
for field_name, field_type in columns:
fields[field_name] = {
"type": field_type,
"meaning": "", # 留空供用户填写
@@ -219,16 +290,23 @@ class ConfigTemplateGenerator:
finally:
conn.close()
def generate_template(self, output_file: str = "wechat_db_config_template.json"):
def generate_template(
self,
output_file: str = "wechat_db_config_template.json",
*,
include_excluded: bool = False,
include_message_shards: bool = False,
exclude_db_stems: set[str] | None = None,
):
"""生成配置模板"""
print("开始生成微信数据库配置模板...")
# 定义要排除的数据库模式和描述
excluded_patterns = {
r'biz_message_\d+\.db$': '企业微信聊天记录数据库',
r'bizchat\.db$': '企业微信联系人数据库',
r'contact_fts\.db$': '搜索联系人数据库',
r'favorite_fts\.db$': '搜索收藏数据库'
excluded_patterns = {} if include_excluded else {
r'biz_message_\d+\.db$': '公众号/企业微信聊天记录数据库(通常不参与个人聊天分析)',
r'bizchat\.db$': '企业微信联系人/会话数据库(通常不参与个人聊天分析)',
r'contact_fts\.db$': '联系人搜索索引数据库(FTS',
r'favorite_fts\.db$': '收藏搜索索引数据库(FTS'
}
# 查找所有数据库文件
@@ -263,29 +341,38 @@ class ConfigTemplateGenerator:
for excluded_file, description in excluded_files:
print(f" - {excluded_file.name} ({description})")
# 显式排除指定 stem(不含 .db)
if exclude_db_stems:
before = len(db_files)
db_files = [p for p in db_files if p.stem not in exclude_db_stems]
after = len(db_files)
if before != after:
print(f"\n按 --exclude-db-stem 排除 {before - after} 个数据库: {sorted(exclude_db_stems)}")
print(f"\n实际处理 {len(db_files)} 个数据库文件")
# 过滤message数据库,只保留倒数第二个(与主脚本逻辑一致)
message_numbered_dbs = []
message_other_dbs = []
for db in db_files:
if re.match(r'message_\d+$', db.stem): # message_{数字}.db
message_numbered_dbs.append(db)
elif db.stem.startswith('message_'): # message_fts.db, message_resource.db等
message_other_dbs.append(db)
if len(message_numbered_dbs) > 1:
# 按数字编号排序(提取数字进行排序)
message_numbered_dbs.sort(key=lambda x: int(re.search(r'message_(\d+)', x.stem).group(1)))
# 选择倒数第二个(按编号排序)
selected_message_db = message_numbered_dbs[-2] # 倒数第二个
print(f"检测到 {len(message_numbered_dbs)} 个message_{{数字}}.db数据库")
print(f"选择倒数第二个: {selected_message_db.name}")
# 从db_files中移除其他message_{数字}.db数据库,但保留message_fts.db等
db_files = [db for db in db_files if not re.match(r'message_\d+$', db.stem)]
db_files.append(selected_message_db)
if not include_message_shards:
message_numbered_dbs = []
message_other_dbs = []
for db in db_files:
if re.match(r'message_\d+$', db.stem): # message_{数字}.db
message_numbered_dbs.append(db)
elif db.stem.startswith('message_'): # message_fts.db, message_resource.db等
message_other_dbs.append(db)
if len(message_numbered_dbs) > 1:
# 按数字编号排序(提取数字进行排序)
message_numbered_dbs.sort(key=lambda x: int(re.search(r'message_(\d+)', x.stem).group(1)))
# 选择倒数第二个(按编号排序)
selected_message_db = message_numbered_dbs[-2] # 倒数第二个
print(f"检测到 {len(message_numbered_dbs)} 个message_{{数字}}.db数据库")
print(f"选择倒数第二个: {selected_message_db.name}")
# 从db_files中移除其他message_{数字}.db数据库,但保留message_fts.db等
db_files = [db for db in db_files if not re.match(r'message_\d+$', db.stem)]
db_files.append(selected_message_db)
print(f"实际分析 {len(db_files)} 个数据库文件")
@@ -370,11 +457,24 @@ class ConfigTemplateGenerator:
def main():
"""主函数"""
parser = argparse.ArgumentParser(description="微信数据库字段配置模板生成器")
parser.add_argument("--databases-path", default="output/databases", help="解密后的数据库根目录(按账号分目录)")
parser.add_argument("--output", default="wechat_db_config_template.json", help="输出 JSON 模板路径")
parser.add_argument("--include-excluded", action="store_true", help="包含默认会被排除的数据库(如 bizchat/contact_fts/favorite_fts 等)")
parser.add_argument("--include-message-shards", action="store_true", help="包含所有 message_{n}.db(否则仅保留倒数第二个作代表)")
parser.add_argument("--exclude-db-stem", action="append", default=[], help="按 stem(不含 .db)排除数据库,可重复,例如: --exclude-db-stem digital_twin")
args = parser.parse_args()
print("微信数据库配置模板生成器")
print("=" * 50)
generator = ConfigTemplateGenerator()
generator.generate_template()
generator = ConfigTemplateGenerator(databases_path=args.databases_path)
generator.generate_template(
output_file=args.output,
include_excluded=bool(args.include_excluded),
include_message_shards=bool(args.include_message_shards),
exclude_db_stems=set(args.exclude_db_stem or []),
)
if __name__ == "__main__":
main()
main()
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "wechat-decrypt-tool"
version = "0.2.1"
version = "1.3.0"
description = "Modern WeChat database decryption tool with React frontend"
readme = "README.md"
requires-python = ">=3.11"
+2 -2
View File
@@ -1,5 +1,5 @@
"""微信数据库解密工具
"""
__version__ = "0.1.0"
__author__ = "WeChat Decrypt Tool"
__version__ = "1.3.0"
__author__ = "WeChat Decrypt Tool"
+4 -1
View File
@@ -9,6 +9,7 @@ from starlette.exceptions import HTTPException as StarletteHTTPException
from starlette.responses import FileResponse
from starlette.staticfiles import StaticFiles
from . import __version__ as APP_VERSION
from .logging_config import setup_logging, get_logger
from .path_fix import PathFixRoute
from .chat_realtime_autosync import CHAT_REALTIME_AUTOSYNC
@@ -21,6 +22,7 @@ from .routers.health import router as _health_router
from .routers.keys import router as _keys_router
from .routers.media import router as _media_router
from .routers.sns import router as _sns_router
from .routers.sns_export import router as _sns_export_router
from .routers.wechat_detection import router as _wechat_detection_router
from .routers.wrapped import router as _wrapped_router
from .wcdb_realtime import WCDB_REALTIME, shutdown as _wcdb_shutdown
@@ -32,7 +34,7 @@ logger = get_logger(__name__)
app = FastAPI(
title="微信数据库解密工具",
description="现代化的微信数据库解密工具,支持微信信息检测和数据库解密功能",
version="0.1.0",
version=APP_VERSION,
)
# 设置自定义路由类
@@ -57,6 +59,7 @@ app.include_router(_chat_contacts_router)
app.include_router(_chat_export_router)
app.include_router(_chat_media_router)
app.include_router(_sns_router)
app.include_router(_sns_export_router)
app.include_router(_wrapped_router)
+627 -36
View File
@@ -541,6 +541,11 @@ body { background: #EDEDED; }
.wce-chat-title { font-size: 16px; font-weight: 500; color: #111827; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.wce-filter-select { font-size: 12px; padding: 6px 8px; border: 0; border-radius: 8px; background: transparent; color: #374151; }
.wce-message-container { flex: 1; overflow: auto; padding: 16px; min-height: 0; }
.wce-pager { display: flex; align-items: center; justify-content: center; gap: 12px; padding: 6px 0 12px; }
.wce-pager-btn { font-size: 12px; padding: 6px 10px; border-radius: 8px; border: 1px solid #e5e7eb; background: #fff; color: #374151; cursor: pointer; }
.wce-pager-btn:hover { background: #f9fafb; }
.wce-pager-btn:disabled { opacity: 0.6; cursor: not-allowed; }
.wce-pager-status { font-size: 12px; color: #6b7280; }
/* Single session item (middle column). */
.wce-session-item { display: flex; align-items: center; gap: 12px; padding: 0 12px; height: 80px; border-bottom: 1px solid #f3f4f6; background: #DEDEDE; text-decoration: none; color: inherit; }
@@ -838,6 +843,140 @@ _HTML_EXPORT_JS = r"""
return obj
}
const readPageMeta = () => {
const el = document.getElementById('wcePageMeta')
const obj = safeJsonParse(el ? el.textContent : '')
if (!obj || typeof obj !== 'object') return null
return obj
}
const initPagedMessageLoading = () => {
const meta = readPageMeta()
if (!meta) return
const totalPages = Number(meta.totalPages || 0)
if (!Number.isFinite(totalPages) || totalPages <= 1) return
const initialPage = Number(meta.initialPage || totalPages || 1)
const padWidth = Number(meta.padWidth || 0) || 0
const prefix = String(meta.pageFilePrefix || 'pages/page-')
const suffix = String(meta.pageFileSuffix || '.js')
const container = document.getElementById('messageContainer')
const list = document.getElementById('wceMessageList') || container
const pager = document.getElementById('wcePager')
const btn = document.getElementById('wceLoadPrevBtn')
const status = document.getElementById('wceLoadPrevStatus')
if (!container || !list || !pager || !btn) return
try { pager.style.display = '' } catch {}
const loaded = new Set()
loaded.add(initialPage)
let nextPage = initialPage - 1
let loading = false
const setStatus = (text) => {
try { if (status) status.textContent = String(text || '') } catch {}
}
const updateUi = (overrideText) => {
if (overrideText != null) {
setStatus(overrideText)
try { btn.disabled = false } catch {}
return
}
if (nextPage < 1) {
setStatus('已到底')
try { btn.disabled = true } catch {}
return
}
if (loading) {
setStatus('加载中...')
try { btn.disabled = true } catch {}
return
}
setStatus('点击加载更早消息')
try { btn.disabled = false } catch {}
}
const pageSrc = (n) => {
const num = padWidth > 0 ? String(n).padStart(padWidth, '0') : String(n)
return prefix + num + suffix
}
window.__WCE_PAGE_QUEUE__ = window.__WCE_PAGE_QUEUE__ || []
window.__WCE_PAGE_LOADED__ = (pageNo, html) => {
const n = Number(pageNo)
if (!Number.isFinite(n) || n < 1) return
if (loaded.has(n)) return
loaded.add(n)
try {
const prevH = container.scrollHeight
const prevTop = container.scrollTop
list.insertAdjacentHTML('afterbegin', String(html || ''))
const newH = container.scrollHeight
container.scrollTop = prevTop + (newH - prevH)
} catch {
try { list.insertAdjacentHTML('afterbegin', String(html || '')) } catch {}
}
loading = false
nextPage = n - 1
try { applyMessageTypeFilter() } catch {}
try { updateSessionMessageCount() } catch {}
updateUi()
}
// Flush any queued pages (should be rare, but keeps behavior robust).
try {
const q = window.__WCE_PAGE_QUEUE__
if (Array.isArray(q) && q.length) {
const items = q.slice(0)
q.length = 0
items.forEach((it) => {
try {
if (it && it.length >= 2) window.__WCE_PAGE_LOADED__(it[0], it[1])
} catch {}
})
}
} catch {}
const requestLoad = () => {
if (loading) return
if (nextPage < 1) return
const n = nextPage
loading = true
updateUi()
const s = document.createElement('script')
s.async = true
s.src = pageSrc(n)
s.onerror = () => {
loading = false
updateUi('加载失败,可重试')
}
try { document.body.appendChild(s) } catch {
loading = false
updateUi('加载失败,可重试')
}
}
btn.addEventListener('click', () => requestLoad())
let lastScrollAt = 0
container.addEventListener('scroll', () => {
const now = Date.now()
if (now - lastScrollAt < 200) return
lastScrollAt = now
if (container.scrollTop < 120) requestLoad()
})
updateUi()
}
const isMaybeMd5 = (value) => /^[0-9a-f]{32}$/i.test(String(value || '').trim())
const pickFirstMd5 = (...values) => {
for (const v of values) {
@@ -926,28 +1065,90 @@ _HTML_EXPORT_JS = r"""
const getText = (node, tag) => {
try {
const el = node.getElementsByTagName(tag)?.[0]
if (!node) return ''
const els = Array.from(node.getElementsByTagName(tag) || [])
const direct = els.find((el) => el && el.parentNode === node)
const el = direct || els[0]
return String(el?.textContent || '').trim()
} catch {
return ''
}
}
const getDirectChildXml = (node, tag) => {
try {
if (!node) return ''
const children = Array.from(node.children || [])
const el = children.find((c) => String(c?.tagName || '').toLowerCase() === String(tag || '').toLowerCase())
if (!el) return ''
const raw = String(el.textContent || '').trim()
if (raw && raw.startsWith('<') && raw.endsWith('>')) return raw
if (typeof XMLSerializer !== 'undefined') {
return new XMLSerializer().serializeToString(el)
}
} catch {}
return ''
}
const getAnyXml = (node, tag) => {
try {
if (!node) return ''
const els = Array.from(node.getElementsByTagName(tag) || [])
const direct = els.find((el) => el && el.parentNode === node)
const el = direct || els[0]
if (!el) return ''
const raw = String(el.textContent || '').trim()
if (raw && raw.startsWith('<') && raw.endsWith('>')) return raw
if (typeof XMLSerializer !== 'undefined') return new XMLSerializer().serializeToString(el)
} catch {}
return ''
}
const sameTag = (el, tag) => String(el?.tagName || '').toLowerCase() === String(tag || '').toLowerCase()
const closestAncestorByTag = (node, tag) => {
const lower = String(tag || '').toLowerCase()
let cur = node
while (cur) {
if (cur.nodeType === 1 && String(cur.tagName || '').toLowerCase() === lower) return cur
cur = cur.parentNode
}
return null
}
const root = doc?.documentElement
const isChatRoom = String(getText(root, 'isChatRoom') || '').trim() === '1'
const title = getText(root, 'title')
const desc = getText(root, 'desc') || getText(root, 'info')
const items = Array.from(doc.getElementsByTagName('dataitem') || [])
const parsed = items.map((node, idx) => {
const datatype = String(node.getAttribute('datatype') || '').trim()
const dataid = String(node.getAttribute('dataid') || '').trim() || String(idx)
const datalist = (() => {
try {
const all = Array.from(doc.getElementsByTagName('datalist') || [])
const top = root ? all.find((el) => closestAncestorByTag(el, 'recorditem') === root) : null
return top || all[0] || null
} catch {
return null
}
})()
const itemNodes = (() => {
if (datalist) return Array.from(datalist.children || []).filter((el) => sameTag(el, 'dataitem'))
return Array.from(root?.children || []).filter((el) => sameTag(el, 'dataitem'))
})()
const parsed = itemNodes.map((node, idx) => {
const datatype = String(node.getAttribute('datatype') || getText(node, 'datatype') || '').trim()
const dataid = String(node.getAttribute('dataid') || getText(node, 'dataid') || '').trim() || String(idx)
const sourcename = getText(node, 'sourcename')
const sourcetime = getText(node, 'sourcetime')
const sourceheadurl = normalizeChatHistoryUrl(getText(node, 'sourceheadurl'))
const datatitle = getText(node, 'datatitle')
const datadesc = getText(node, 'datadesc')
const link = normalizeChatHistoryUrl(getText(node, 'link') || getText(node, 'dataurl') || getText(node, 'url'))
const datafmt = getText(node, 'datafmt')
const duration = getText(node, 'duration')
@@ -961,6 +1162,7 @@ _HTML_EXPORT_JS = r"""
const fromnewmsgid = getText(node, 'fromnewmsgid')
const srcMsgLocalid = getText(node, 'srcMsgLocalid')
const srcMsgCreateTime = getText(node, 'srcMsgCreateTime')
const nestedRecordItem = getAnyXml(node, 'recorditem') || getDirectChildXml(node, 'recorditem') || getText(node, 'recorditem')
let content = datatitle || datadesc
if (!content) {
@@ -975,7 +1177,11 @@ _HTML_EXPORT_JS = r"""
const imageFormats = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'heic', 'heif'])
let renderType = 'text'
if (datatype === '4' || String(duration || '').trim() || fmt === 'mp4') {
if (datatype === '17') {
renderType = 'chatHistory'
} else if (datatype === '5' || link) {
renderType = 'link'
} else if (datatype === '4' || String(duration || '').trim() || fmt === 'mp4') {
renderType = 'video'
} else if (datatype === '47' || datatype === '37') {
renderType = 'emoji'
@@ -990,6 +1196,23 @@ _HTML_EXPORT_JS = r"""
renderType = 'emoji'
}
let outTitle = ''
let outUrl = ''
let recordItem = ''
if (renderType === 'chatHistory') {
outTitle = datatitle || content || '聊天记录'
content = datadesc || ''
recordItem = nestedRecordItem
} else if (renderType === 'link') {
outTitle = datatitle || content || ''
outUrl = link || externurl || ''
// datadesc can be an invisible filler; only keep as description when meaningful.
const cleanDesc = String(datadesc || '').replace(/[\\u3164\\u2800]/g, '').trim()
const cleanTitle = String(outTitle || '').replace(/[\\u3164\\u2800]/g, '').trim()
if (!cleanDesc || (cleanTitle && cleanDesc === cleanTitle)) content = ''
else content = String(datadesc || '').trim()
}
return {
id: dataid,
datatype,
@@ -1009,6 +1232,9 @@ _HTML_EXPORT_JS = r"""
srcMsgLocalid,
srcMsgCreateTime,
renderType,
title: outTitle,
recordItem,
url: outUrl,
content
}
})
@@ -1028,15 +1254,64 @@ _HTML_EXPORT_JS = r"""
if (!modal || !titleEl || !closeBtn || !emptyEl || !listEl) return
const mediaIndex = readMediaIndex()
let historyStack = []
let currentState = null
let backBtn = null
const updateBackVisibility = () => {
if (!backBtn) return
const show = Array.isArray(historyStack) && historyStack.length > 0
try { backBtn.classList.toggle('hidden', !show) } catch {}
}
// Add a back button next to the title (created at runtime to avoid changing the HTML template).
try {
const header = titleEl.parentElement
if (header) {
const wrap = document.createElement('div')
wrap.className = 'flex items-center gap-2 min-w-0'
backBtn = document.createElement('button')
backBtn.type = 'button'
backBtn.className = 'p-2 rounded hover:bg-black/5 flex-shrink-0 hidden'
try { backBtn.setAttribute('aria-label', '返回') } catch {}
try { backBtn.setAttribute('title', '返回') } catch {}
backBtn.innerHTML = '<svg class="w-5 h-5 text-gray-700" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" /></svg>'
header.insertBefore(wrap, titleEl)
wrap.appendChild(backBtn)
wrap.appendChild(titleEl)
}
} catch {}
const close = () => {
try { modal.classList.add('hidden') } catch {}
try { modal.style.display = 'none' } catch {}
try { modal.setAttribute('aria-hidden', 'true') } catch {}
try { document.body.style.overflow = '' } catch {}
try { titleEl.textContent = '合并消息' } catch {}
try { titleEl.textContent = '聊天记录' } catch {}
try { listEl.textContent = '' } catch {}
try { emptyEl.style.display = '' } catch {}
historyStack = []
currentState = null
updateBackVisibility()
}
const buildChatHistoryState = (payload) => {
const title = String(payload?.title || '聊天记录').trim() || '聊天记录'
const xml = String(payload?.recordItem || '').trim()
const parsed = parseChatHistoryRecord(xml)
const info = (parsed && parsed.info) ? parsed.info : { isChatRoom: false }
let records = (parsed && Array.isArray(parsed.items)) ? parsed.items : []
if (!records.length) {
const lines = Array.isArray(payload?.fallbackLines)
? payload.fallbackLines
: String(payload?.content || '').trim().split(/\r?\n/).map((x) => String(x || '').trim()).filter(Boolean)
records = lines.map((line, idx) => ({ id: String(idx), renderType: 'text', content: line, sourcename: '', sourcetime: '' }))
}
return { title, info, records }
}
const renderRecordRow = (rec, info) => {
@@ -1102,7 +1377,123 @@ _HTML_EXPORT_JS = r"""
const serverId = String(rec?.fromnewmsgid || '').trim()
const serverMd5 = resolveServerMd5(mediaIndex, serverId)
if (rt === 'video') {
if (rt === 'chatHistory') {
const card = document.createElement('div')
card.className = 'wechat-chat-history-card wechat-special-card msg-radius'
const chBody = document.createElement('div')
chBody.className = 'wechat-chat-history-body'
const chTitle = document.createElement('div')
chTitle.className = 'wechat-chat-history-title'
chTitle.textContent = String(rec?.title || '聊天记录')
chBody.appendChild(chTitle)
const raw = String(rec?.content || '').trim()
const lines = raw ? raw.split(/\r?\n/).map((x) => String(x || '').trim()).filter(Boolean).slice(0, 4) : []
if (lines.length) {
const preview = document.createElement('div')
preview.className = 'wechat-chat-history-preview'
for (const line of lines) {
const el = document.createElement('div')
el.className = 'wechat-chat-history-line'
el.textContent = line
preview.appendChild(el)
}
chBody.appendChild(preview)
}
card.appendChild(chBody)
const bottom = document.createElement('div')
bottom.className = 'wechat-chat-history-bottom'
const label = document.createElement('span')
label.textContent = '聊天记录'
bottom.appendChild(label)
card.appendChild(bottom)
const nestedXml = String(rec?.recordItem || '').trim()
if (nestedXml) {
card.classList.add('cursor-pointer')
card.addEventListener('click', (ev) => {
try { ev.preventDefault() } catch {}
try { ev.stopPropagation() } catch {}
openNestedChatHistory(rec)
})
}
body.appendChild(card)
} else if (rt === 'link') {
const href = normalizeChatHistoryUrl(rec?.url) || normalizeChatHistoryUrl(rec?.externurl)
const heading = String(rec?.title || '').trim() || content || href || '链接'
const desc = String(rec?.content || '').trim()
const thumbMd5 = pickFirstMd5(rec?.fullmd5, rec?.thumbfullmd5, rec?.md5)
let previewUrl = resolveMd5Any(mediaIndex, thumbMd5)
if (!previewUrl && serverMd5) previewUrl = resolveMd5Any(mediaIndex, serverMd5)
if (!previewUrl) previewUrl = resolveRemoteAny(mediaIndex, rec?.externurl, rec?.cdnurlstring, rec?.encrypturlstring)
const card = document.createElement(href ? 'a' : 'div')
card.className = 'wechat-link-card wechat-special-card msg-radius cursor-pointer'
if (href) {
card.href = href
card.target = '_blank'
card.rel = 'noreferrer noopener'
}
try { card.style.textDecoration = 'none' } catch {}
try { card.style.outline = 'none' } catch {}
const linkContent = document.createElement('div')
linkContent.className = 'wechat-link-content'
const linkInfo = document.createElement('div')
linkInfo.className = 'wechat-link-info'
const titleEl = document.createElement('div')
titleEl.className = 'wechat-link-title'
titleEl.textContent = heading
linkInfo.appendChild(titleEl)
if (desc) {
const descEl = document.createElement('div')
descEl.className = 'wechat-link-desc'
descEl.textContent = desc
linkInfo.appendChild(descEl)
}
linkContent.appendChild(linkInfo)
if (previewUrl) {
const thumb = document.createElement('div')
thumb.className = 'wechat-link-thumb'
const img = document.createElement('img')
img.src = previewUrl
img.alt = heading || '链接预览'
img.className = 'wechat-link-thumb-img'
try { img.referrerPolicy = 'no-referrer' } catch {}
thumb.appendChild(img)
linkContent.appendChild(thumb)
}
card.appendChild(linkContent)
const fromRow = document.createElement('div')
fromRow.className = 'wechat-link-from'
const fromText = (() => {
const f0 = String(rec?.from || '').trim()
if (f0) return f0
try { return href ? (new URL(href).hostname || '') : '' } catch { return '' }
})()
const fromAvatarText = fromText ? (Array.from(fromText)[0] || '') : ''
const fromAvatar = document.createElement('div')
fromAvatar.className = 'wechat-link-from-avatar'
fromAvatar.textContent = fromAvatarText || '\u200B'
const fromName = document.createElement('div')
fromName.className = 'wechat-link-from-name'
fromName.textContent = fromText || '\u200B'
fromRow.appendChild(fromAvatar)
fromRow.appendChild(fromName)
card.appendChild(fromRow)
body.appendChild(card)
} else if (rt === 'video') {
const videoMd5 = pickFirstMd5(rec?.fullmd5, rec?.md5)
const thumbMd5 = pickFirstMd5(rec?.thumbfullmd5) || videoMd5
let videoUrl = resolveMd5Any(mediaIndex, videoMd5)
@@ -1202,20 +1593,11 @@ _HTML_EXPORT_JS = r"""
return row
}
const openFromCard = (card) => {
const title = String(card?.getAttribute('data-title') || '合并消息').trim() || '合并消息'
const b64 = String(card?.getAttribute('data-record-item-b64') || '').trim()
const xml = decodeBase64Utf8(b64)
const parsed = parseChatHistoryRecord(xml)
const info = (parsed && parsed.info) ? parsed.info : { isChatRoom: false }
let records = (parsed && Array.isArray(parsed.items)) ? parsed.items : []
if (!records.length) {
const lines = Array.from(card.querySelectorAll('.wechat-chat-history-line') || [])
.map((el) => String(el?.textContent || '').trim())
.filter(Boolean)
records = lines.map((line, idx) => ({ id: String(idx), renderType: 'text', content: line, sourcename: '', sourcetime: '' }))
}
const applyChatHistoryState = (state) => {
currentState = state
const title = String(state?.title || '聊天记录').trim() || '聊天记录'
const info = state?.info || { isChatRoom: false }
const records = Array.isArray(state?.records) ? state.records : []
try { titleEl.textContent = title } catch {}
try { listEl.textContent = '' } catch {}
@@ -1231,6 +1613,45 @@ _HTML_EXPORT_JS = r"""
}
}
updateBackVisibility()
}
const openNestedChatHistory = (rec) => {
const xml = String(rec?.recordItem || '').trim()
if (!xml) return
if (currentState) {
historyStack = [...historyStack, currentState]
}
const state = buildChatHistoryState({
title: String(rec?.title || '聊天记录'),
recordItem: xml,
content: String(rec?.content || ''),
})
applyChatHistoryState(state)
}
if (backBtn) {
backBtn.addEventListener('click', (ev) => {
try { ev.preventDefault() } catch {}
if (!Array.isArray(historyStack) || !historyStack.length) return
const prev = historyStack[historyStack.length - 1]
historyStack = historyStack.slice(0, -1)
applyChatHistoryState(prev)
})
}
const openFromCard = (card) => {
const title = String(card?.getAttribute('data-title') || '聊天记录').trim() || '聊天记录'
const b64 = String(card?.getAttribute('data-record-item-b64') || '').trim()
const xml = decodeBase64Utf8(b64)
const lines = Array.from(card.querySelectorAll('.wechat-chat-history-line') || [])
.map((el) => String(el?.textContent || '').trim())
.filter(Boolean)
historyStack = []
const state = buildChatHistoryState({ title, recordItem: xml, fallbackLines: lines })
applyChatHistoryState(state)
try { modal.classList.remove('hidden') } catch {}
try { modal.style.display = 'flex' } catch {}
try { modal.setAttribute('aria-hidden', 'false') } catch {}
@@ -1269,6 +1690,7 @@ _HTML_EXPORT_JS = r"""
initSessionSearch()
initVoicePlayback()
initChatHistoryModal()
initPagedMessageLoading()
const select = document.getElementById('messageTypeFilter')
if (select) {
@@ -1469,6 +1891,7 @@ class ChatExportManager:
output_dir: Optional[str],
allow_process_key_extract: bool,
download_remote_media: bool,
html_page_size: int = 1000,
privacy_mode: bool,
file_name: Optional[str],
) -> ExportJob:
@@ -1493,6 +1916,7 @@ class ChatExportManager:
"outputDir": str(output_dir or "").strip(),
"allowProcessKeyExtract": bool(allow_process_key_extract),
"downloadRemoteMedia": bool(download_remote_media),
"htmlPageSize": int(html_page_size) if int(html_page_size or 0) > 0 else int(html_page_size or 0),
"privacyMode": bool(privacy_mode),
"fileName": str(file_name or "").strip(),
},
@@ -1544,6 +1968,12 @@ class ChatExportManager:
allow_process_key_extract = bool(opts.get("allowProcessKeyExtract"))
download_remote_media = bool(opts.get("downloadRemoteMedia"))
privacy_mode = bool(opts.get("privacyMode"))
try:
html_page_size = int(opts.get("htmlPageSize") or 1000)
except Exception:
html_page_size = 1000
if html_page_size < 0:
html_page_size = 0
media_kinds_raw = opts.get("mediaKinds") or []
media_kinds: list[MediaKind] = []
@@ -1898,6 +2328,7 @@ class ChatExportManager:
session_items=session_items,
download_remote_media=remote_download_enabled,
remote_written=remote_written,
html_page_size=html_page_size,
start_time=st,
end_time=et,
want_types=want_types,
@@ -2045,6 +2476,7 @@ class ChatExportManager:
"mediaKinds": media_kinds,
"allowProcessKeyExtract": allow_process_key_extract,
"downloadRemoteMedia": bool(download_remote_media),
"htmlPageSize": int(html_page_size) if export_format == "html" else None,
"privacyMode": privacy_mode,
},
"stats": {
@@ -3110,6 +3542,7 @@ def _write_conversation_html(
session_items: list[dict[str, Any]],
download_remote_media: bool,
remote_written: dict[str, str],
html_page_size: int = 1000,
start_time: Optional[int],
end_time: Optional[int],
want_types: Optional[set[str]],
@@ -3499,7 +3932,7 @@ def _write_conversation_html(
("emoji", "表情"),
("video", "视频"),
("voice", "语音"),
("chatHistory", "合并消息"),
("chatHistory", "聊天记录"),
("transfer", "转账"),
("redPacket", "红包"),
("file", "文件"),
@@ -3509,10 +3942,46 @@ def _write_conversation_html(
("voip", "通话"),
]
page_size = 0
try:
page_size = int(html_page_size or 0)
except Exception:
page_size = 0
if page_size < 0:
page_size = 0
# NOTE: write to a temp file first to avoid zip interleaving writes.
with tempfile.TemporaryDirectory(prefix="wechat_chat_export_") as tmp_dir:
tmp_path = Path(tmp_dir) / "messages.html"
with open(tmp_path, "w", encoding="utf-8", newline="\n") as tw:
pages_frag_dir = Path(tmp_dir) / "pages_fragments"
page_frag_paths: list[Path] = []
paged_old_page_paths: list[Path] = []
paged_total_pages = 1
paged_pad_width = 4
with open(tmp_path, "w", encoding="utf-8", newline="\n") as hw:
class _WriteProxy:
def __init__(self, default_target):
self._default = default_target
self._target = default_target
def set_target(self, target) -> None:
self._target = target or self._default
def write(self, s: str) -> Any:
return self._target.write(s)
def flush(self) -> None:
try:
if self._target is not self._default:
self._target.flush()
except Exception:
pass
try:
self._default.flush()
except Exception:
pass
tw = _WriteProxy(hw)
tw.write("<!doctype html>\n")
tw.write('<html lang="zh-CN">\n')
tw.write("<head>\n")
@@ -3688,6 +4157,55 @@ def _write_conversation_html(
tw.write(" </div>\n")
tw.write(' <div id="messageContainer" class="wce-message-container flex-1 overflow-y-auto p-4 min-h-0">\n')
tw.write(' <div id="wcePager" class="wce-pager" style="display:none">\n')
tw.write(' <button id="wceLoadPrevBtn" type="button" class="wce-pager-btn">加载更早消息</button>\n')
tw.write(' <span id="wceLoadPrevStatus" class="wce-pager-status"></span>\n')
tw.write(" </div>\n")
tw.write(' <div id="wceMessageList">\n')
page_fp = None
page_fp_path: Optional[Path] = None
page_no = 1
page_msg_count = 0
def _open_page_fp() -> Any:
nonlocal page_fp, page_fp_path
pages_frag_dir.mkdir(parents=True, exist_ok=True)
page_fp_path = pages_frag_dir / f"page_{page_no}.htmlfrag"
page_fp = open(page_fp_path, "w", encoding="utf-8", newline="\n")
return page_fp
def _close_page_fp() -> None:
nonlocal page_fp, page_fp_path
if page_fp is None:
page_fp_path = None
return
try:
page_fp.flush()
except Exception:
pass
try:
page_fp.close()
except Exception:
pass
if page_fp_path is not None:
page_frag_paths.append(page_fp_path)
page_fp = None
page_fp_path = None
tw.set_target(hw)
def _mark_exported() -> None:
nonlocal exported, page_no, page_msg_count
exported += 1
with lock:
job.progress.messages_exported += 1
job.progress.current_conversation_messages_exported = exported
if page_size > 0:
page_msg_count += 1
if page_msg_count >= page_size:
_close_page_fp()
page_no += 1
page_msg_count = 0
sender_alias_map: dict[str, int] = {}
prev_ts = 0
@@ -3755,6 +4273,11 @@ def _write_conversation_html(
if ts and ((prev_ts == 0) or (abs(ts - prev_ts) >= 300)):
show_divider = True
if page_size > 0:
if page_fp is None:
_open_page_fp()
tw.set_target(page_fp)
if show_divider:
divider_text = _format_session_time(ts)
if divider_text:
@@ -3770,10 +4293,7 @@ def _write_conversation_html(
tw.write(f' <div class="px-3 py-1 text-xs text-[#9e9e9e]">{esc_text(msg.get("content") or "")}</div>\n')
tw.write(" </div>\n")
tw.write(" </div>\n")
exported += 1
with lock:
job.progress.messages_exported += 1
job.progress.current_conversation_messages_exported = exported
_mark_exported()
if ts:
prev_ts = ts
continue
@@ -4186,7 +4706,7 @@ def _write_conversation_html(
tw.write(" </div>\n")
elif rt == "chatHistory":
title = str(msg.get("title") or "").strip() or "合并消息"
title = str(msg.get("title") or "").strip() or "聊天记录"
record_item = str(msg.get("recordItem") or "").strip()
record_item_b64 = ""
if record_item:
@@ -4260,7 +4780,7 @@ def _write_conversation_html(
tw.write(f' <div class="wechat-chat-history-line">{esc_text(line)}</div>\n')
tw.write(" </div>\n")
tw.write(" </div>\n")
tw.write(' <div class="wechat-chat-history-bottom"><span>合并消息</span></div>\n')
tw.write(' <div class="wechat-chat-history-bottom"><span>聊天记录</span></div>\n')
tw.write(" </div>\n")
elif rt == "transfer":
received = is_transfer_received(msg)
@@ -4328,17 +4848,55 @@ def _write_conversation_html(
tw.write(" </div>\n")
tw.write(" </div>\n")
exported += 1
with lock:
job.progress.messages_exported += 1
job.progress.current_conversation_messages_exported = exported
_mark_exported()
if ts:
prev_ts = ts
if scanned % 500 == 0 and job.cancel_requested:
raise _JobCancelled()
if page_size > 0:
_close_page_fp()
paged_total_pages = max(1, len(page_frag_paths))
paged_pad_width = max(4, len(str(paged_total_pages)))
if page_frag_paths:
paged_old_page_paths = list(page_frag_paths[:-1])
tw.set_target(hw)
try:
tw.write(page_frag_paths[-1].read_text(encoding="utf-8"))
except Exception:
try:
tw.write(page_frag_paths[-1].read_text(encoding="utf-8", errors="ignore"))
except Exception:
pass
else:
paged_old_page_paths = []
tw.set_target(hw)
# Close message list + container
tw.set_target(hw)
tw.write(" </div>\n")
tw.write(" </div>\n")
if page_size > 0 and paged_total_pages > 1:
page_meta = {
"schemaVersion": 1,
"pageSize": int(page_size),
"totalPages": int(paged_total_pages),
"initialPage": int(paged_total_pages),
"totalMessages": int(exported),
"padWidth": int(paged_pad_width),
"pageFilePrefix": "pages/page-",
"pageFileSuffix": ".js",
"inlinedPages": [int(paged_total_pages)],
}
try:
page_meta_payload = json.dumps(page_meta, ensure_ascii=False)
except Exception:
page_meta_payload = "{}"
page_meta_payload = page_meta_payload.replace("</", "<\\/")
tw.write(f'<script type="application/json" id="wcePageMeta">{page_meta_payload}</script>\n')
tw.write(" </div>\n")
tw.write(" </div>\n")
tw.write(" </div>\n")
@@ -4357,7 +4915,7 @@ def _write_conversation_html(
)
tw.write(' <div class="w-[92vw] max-w-[560px] max-h-[80vh] bg-white rounded-xl shadow-xl overflow-hidden flex flex-col" role="dialog" aria-modal="true">\n')
tw.write(' <div class="px-4 py-3 bg-neutral-100 border-b border-gray-200 flex items-center justify-between">\n')
tw.write(' <div id="chatHistoryModalTitle" class="text-sm text-[#161616] truncate">合并消息</div>\n')
tw.write(' <div id="chatHistoryModalTitle" class="text-sm text-[#161616] truncate">聊天记录</div>\n')
tw.write(' <button type="button" id="chatHistoryModalClose" class="p-2 rounded hover:bg-black/5" aria-label="关闭" title="关闭">\n')
tw.write(' <svg class="w-5 h-5 text-gray-700" fill="none" stroke="currentColor" viewBox="0 0 24 24">\n')
tw.write(' <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>\n')
@@ -4365,7 +4923,7 @@ def _write_conversation_html(
tw.write(" </button>\n")
tw.write(" </div>\n")
tw.write(' <div class="flex-1 overflow-auto bg-white">\n')
tw.write(' <div id="chatHistoryModalEmpty" class="text-sm text-gray-500 text-center py-10">没有可显示的合并消息</div>\n')
tw.write(' <div id="chatHistoryModalEmpty" class="text-sm text-gray-500 text-center py-10">没有可显示的聊天记录</div>\n')
tw.write(' <div id="chatHistoryModalList"></div>\n')
tw.write(" </div>\n")
tw.write(" </div>\n")
@@ -4377,6 +4935,39 @@ def _write_conversation_html(
zf.write(str(tmp_path), arcname)
if page_size > 0 and paged_old_page_paths:
for page_no, frag_path in enumerate(paged_old_page_paths, start=1):
try:
frag_text = frag_path.read_text(encoding="utf-8")
except Exception:
try:
frag_text = frag_path.read_text(encoding="utf-8", errors="ignore")
except Exception:
frag_text = ""
try:
frag_json = json.dumps(frag_text, ensure_ascii=False)
except Exception:
frag_json = json.dumps("", ensure_ascii=False)
num = str(page_no).zfill(int(paged_pad_width or 4))
arc_js = f"{conv_dir}/pages/page-{num}.js"
js_payload = (
"(() => {\n"
f" const pageNo = {int(page_no)};\n"
f" const html = {frag_json};\n"
" try {\n"
" const fn = window.__WCE_PAGE_LOADED__;\n"
" if (typeof fn === 'function') fn(pageNo, html);\n"
" else {\n"
" const q = (window.__WCE_PAGE_QUEUE__ = window.__WCE_PAGE_QUEUE__ || []);\n"
" q.push([pageNo, html]);\n"
" }\n"
" } catch {}\n"
"})();\n"
)
zf.writestr(arc_js, js_payload)
return exported
+169
View File
@@ -0,0 +1,169 @@
from __future__ import annotations
"""ISAAC-64 PRNG (WeFlow compatible).
WeChat SNS live photo/video decryption uses a keystream generated by ISAAC-64 and
XORs the first 128KB of the mp4 file. WeFlow's implementation reverses the
generated byte array, so we mirror that behavior for compatibility.
"""
from typing import Any
_MASK_64 = 0xFFFFFFFFFFFFFFFF
def _u64(v: int) -> int:
return int(v) & _MASK_64
class Isaac64:
def __init__(self, seed: Any):
seed_text = str(seed).strip()
if not seed_text:
seed_val = 0
else:
try:
# WeFlow seeds with BigInt(seed), where seed is usually a decimal string.
seed_val = int(seed_text, 0)
except Exception:
seed_val = 0
self.mm = [_u64(0) for _ in range(256)]
self.aa = _u64(0)
self.bb = _u64(0)
self.cc = _u64(0)
self.randrsl = [_u64(0) for _ in range(256)]
self.randrsl[0] = _u64(seed_val)
self.randcnt = 0
self._init(True)
def _init(self, flag: bool) -> None:
a = b = c = d = e = f = g = h = _u64(0x9E3779B97F4A7C15)
def mix() -> tuple[int, int, int, int, int, int, int, int]:
nonlocal a, b, c, d, e, f, g, h
a = _u64(a - e)
f = _u64(f ^ (h >> 9))
h = _u64(h + a)
b = _u64(b - f)
g = _u64(g ^ _u64(a << 9))
a = _u64(a + b)
c = _u64(c - g)
h = _u64(h ^ (b >> 23))
b = _u64(b + c)
d = _u64(d - h)
a = _u64(a ^ _u64(c << 15))
c = _u64(c + d)
e = _u64(e - a)
b = _u64(b ^ (d >> 14))
d = _u64(d + e)
f = _u64(f - b)
c = _u64(c ^ _u64(e << 20))
e = _u64(e + f)
g = _u64(g - c)
d = _u64(d ^ (f >> 17))
f = _u64(f + g)
h = _u64(h - d)
e = _u64(e ^ _u64(g << 14))
g = _u64(g + h)
return a, b, c, d, e, f, g, h
for _ in range(4):
mix()
for i in range(0, 256, 8):
if flag:
a = _u64(a + self.randrsl[i])
b = _u64(b + self.randrsl[i + 1])
c = _u64(c + self.randrsl[i + 2])
d = _u64(d + self.randrsl[i + 3])
e = _u64(e + self.randrsl[i + 4])
f = _u64(f + self.randrsl[i + 5])
g = _u64(g + self.randrsl[i + 6])
h = _u64(h + self.randrsl[i + 7])
mix()
self.mm[i] = a
self.mm[i + 1] = b
self.mm[i + 2] = c
self.mm[i + 3] = d
self.mm[i + 4] = e
self.mm[i + 5] = f
self.mm[i + 6] = g
self.mm[i + 7] = h
if flag:
for i in range(0, 256, 8):
a = _u64(a + self.mm[i])
b = _u64(b + self.mm[i + 1])
c = _u64(c + self.mm[i + 2])
d = _u64(d + self.mm[i + 3])
e = _u64(e + self.mm[i + 4])
f = _u64(f + self.mm[i + 5])
g = _u64(g + self.mm[i + 6])
h = _u64(h + self.mm[i + 7])
mix()
self.mm[i] = a
self.mm[i + 1] = b
self.mm[i + 2] = c
self.mm[i + 3] = d
self.mm[i + 4] = e
self.mm[i + 5] = f
self.mm[i + 6] = g
self.mm[i + 7] = h
self._isaac64()
self.randcnt = 256
def _isaac64(self) -> None:
self.cc = _u64(self.cc + 1)
self.bb = _u64(self.bb + self.cc)
for i in range(256):
x = self.mm[i]
if (i & 3) == 0:
# aa ^= ~(aa << 21)
self.aa = _u64(self.aa ^ (_u64(self.aa << 21) ^ _MASK_64))
elif (i & 3) == 1:
self.aa = _u64(self.aa ^ (self.aa >> 5))
elif (i & 3) == 2:
self.aa = _u64(self.aa ^ _u64(self.aa << 12))
else:
self.aa = _u64(self.aa ^ (self.aa >> 33))
self.aa = _u64(self.mm[(i + 128) & 255] + self.aa)
y = _u64(self.mm[(x >> 3) & 255] + self.aa + self.bb)
self.mm[i] = y
self.bb = _u64(self.mm[(y >> 11) & 255] + x)
self.randrsl[i] = self.bb
def get_next(self) -> int:
if self.randcnt == 0:
self._isaac64()
self.randcnt = 256
idx = 256 - self.randcnt
self.randcnt -= 1
return _u64(self.randrsl[idx])
def generate_keystream(self, size: int) -> bytes:
"""Generate a keystream of `size` bytes (must be multiple of 8)."""
if size <= 0:
return b""
if size % 8 != 0:
raise ValueError("ISAAC64 keystream size must be multiple of 8 bytes.")
out = bytearray()
count = size // 8
for _ in range(count):
out.extend(int(self.get_next()).to_bytes(8, "little", signed=False))
# WeFlow reverses the entire byte array (Uint8Array.reverse()).
out.reverse()
return bytes(out)
Binary file not shown.
File diff suppressed because it is too large Load Diff
@@ -3,10 +3,12 @@ import json
import re
import sqlite3
from datetime import datetime, timezone
from functools import lru_cache
from pathlib import Path
from typing import Any, Literal, Optional
from fastapi import APIRouter, HTTPException, Request
from pypinyin import Style, lazy_pinyin
from pydantic import BaseModel, Field
from ..chat_helpers import (
@@ -96,6 +98,76 @@ def _to_optional_int(v: Any) -> Optional[int]:
return None
_PINYIN_CLEAN_RE = re.compile(r"[^a-z0-9]+")
_PINYIN_ALPHA_RE = re.compile(r"[A-Za-z]")
# 多音字姓氏:pypinyin 对单字默认读音不一定是姓氏读音(例如:曾= ceng / zeng)。
# 这里在“姓名首字”场景优先采用常见姓氏读音,用于联系人列表的分组/排序。
_SURNAME_PINYIN_OVERRIDES: dict[str, str] = {
"": "zeng",
"": "ou",
"": "qiu",
"": "xie",
"": "shan",
"": "zha",
"": "yue",
"": "piao",
"": "ge",
"": "miao",
}
@lru_cache(maxsize=4096)
def _build_contact_pinyin_key(name: str) -> str:
text = _normalize_text(name)
if not text:
return ""
# Keep non-CJK segments so English names can be sorted/grouped as expected.
first = text[0]
override = _SURNAME_PINYIN_OVERRIDES.get(first)
if override:
rest = text[1:]
parts = [override]
if rest:
parts.extend(lazy_pinyin(rest, style=Style.NORMAL, errors="default"))
else:
parts = lazy_pinyin(text, style=Style.NORMAL, errors="default")
out: list[str] = []
for part in parts:
cleaned = _PINYIN_CLEAN_RE.sub("", _normalize_text(part).lower())
if cleaned:
out.append(cleaned)
return "".join(out)
@lru_cache(maxsize=4096)
def _build_contact_pinyin_initial(name: str) -> str:
text = _normalize_text(name).lstrip()
if not text:
return "#"
first = text[0]
if "A" <= first <= "Z":
return first
if "a" <= first <= "z":
return first.upper()
override = _SURNAME_PINYIN_OVERRIDES.get(first)
if override:
return override[0].upper()
# For CJK, try to convert the first character to pinyin initial.
parts = lazy_pinyin(first, style=Style.NORMAL, errors="ignore")
if parts:
m = _PINYIN_ALPHA_RE.search(parts[0])
if m:
return m.group(0).upper()
# Emoji / digits / symbols, etc.
return "#"
def _decode_varint(raw: bytes, offset: int) -> tuple[Optional[int], int]:
value = 0
shift = 0
@@ -125,6 +197,7 @@ def _decode_proto_text(raw: bytes) -> str:
def _parse_contact_extra_buffer(extra_buffer: Any) -> dict[str, Any]:
out = {
"gender": 0,
"signature": "",
"country": "",
"province": "",
@@ -160,6 +233,9 @@ def _parse_contact_extra_buffer(extra_buffer: Any) -> dict[str, Any]:
if val is None:
break
idx = idx_next
if field_no == 2:
# 性别: 1=男, 2=女, 0=未知
out["gender"] = int(val)
if field_no == 8:
out["source_scene"] = int(val)
continue
@@ -327,6 +403,8 @@ def _load_contact_rows_map(contact_db_path: Path) -> dict[str, dict[str, Any]]:
"verify_flag": _to_int(row["verify_flag"] if "verify_flag" in row.keys() else 0),
"big_head_url": _normalize_text(row["big_head_url"] if "big_head_url" in row.keys() else ""),
"small_head_url": _normalize_text(row["small_head_url"] if "small_head_url" in row.keys() else ""),
"gender": _to_int(extra_info.get("gender")),
"signature": _normalize_text(extra_info.get("signature")),
"country": _normalize_text(extra_info.get("country")),
"province": _normalize_text(extra_info.get("province")),
"city": _normalize_text(extra_info.get("city")),
@@ -481,6 +559,8 @@ def _collect_contacts_for_account(
province = _normalize_text(row.get("province"))
city = _normalize_text(row.get("city"))
source_scene = _to_optional_int(row.get("source_scene"))
gender = _to_int(row.get("gender"))
signature = _normalize_text(row.get("signature"))
item = {
"username": username,
@@ -488,6 +568,8 @@ def _collect_contacts_for_account(
"remark": _normalize_text(row.get("remark")),
"nickname": _normalize_text(row.get("nick_name")),
"alias": _normalize_text(row.get("alias")),
"gender": gender,
"signature": signature,
"type": contact_type,
"country": country,
"province": province,
@@ -520,6 +602,8 @@ def _collect_contacts_for_account(
"remark": "",
"nickname": "",
"alias": "",
"gender": 0,
"signature": "",
"type": "group",
"country": "",
"province": "",
@@ -545,6 +629,9 @@ def _collect_contacts_for_account(
)
for item in contacts:
item.pop("_sortTs", None)
name_for_pinyin = _normalize_text(item.get("displayName")) or _normalize_text(item.get("username"))
item["pinyinKey"] = _build_contact_pinyin_key(name_for_pinyin)
item["pinyinInitial"] = _build_contact_pinyin_initial(name_for_pinyin)
return contacts
@@ -59,6 +59,10 @@ class ChatExportCreateRequest(BaseModel):
False,
description="HTML 导出时允许联网下载链接/引用缩略图等远程媒体(提高离线完整性)",
)
html_page_size: int = Field(
1000,
description="HTML 导出分页大小(每页消息数);<=0 表示禁用分页(单文件,打开大聊天可能很卡)",
)
privacy_mode: bool = Field(
False,
description="隐私模式导出:隐藏会话/用户名/内容,不打包头像与媒体",
@@ -83,6 +87,7 @@ async def create_chat_export(req: ChatExportCreateRequest):
output_dir=req.output_dir,
allow_process_key_extract=req.allow_process_key_extract,
download_remote_media=req.download_remote_media,
html_page_size=req.html_page_size,
privacy_mode=req.privacy_mode,
file_name=req.file_name,
)
@@ -1019,6 +1019,171 @@ async def proxy_image(url: str):
return resp
def _origin_favicon_url(page_url: str) -> str:
"""Best-effort favicon URL for a given page URL (origin + /favicon.ico)."""
u = str(page_url or "").strip()
if not u:
return ""
try:
p = urlparse(u)
except Exception:
return ""
if not p.scheme or not p.netloc:
return ""
return f"{p.scheme}://{p.netloc}/favicon.ico"
def _resolve_final_url_for_favicon(page_url: str) -> str:
"""Resolve final URL for redirects (used for favicon host inference)."""
u = str(page_url or "").strip()
if not u:
return ""
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120 Safari/537.36",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
}
# Prefer HEAD (no body). Some hosts reject HEAD; fall back to GET+stream.
try:
r = requests.head(u, headers=headers, timeout=10, allow_redirects=True)
try:
final = str(getattr(r, "url", "") or "").strip()
return final or u
finally:
try:
r.close()
except Exception:
pass
except Exception:
pass
try:
r = requests.get(u, headers=headers, timeout=10, allow_redirects=True, stream=True)
try:
final = str(getattr(r, "url", "") or "").strip()
return final or u
finally:
try:
r.close()
except Exception:
pass
except Exception:
return u
@router.get("/api/chat/media/favicon", summary="获取网站 favicon(用于链接卡片来源头像)")
async def get_favicon(url: str):
page_url = html.unescape(str(url or "")).strip()
if not page_url:
raise HTTPException(status_code=400, detail="Missing url.")
if not _is_safe_http_url(page_url):
raise HTTPException(status_code=400, detail="Invalid url (only public http/https allowed).")
# Resolve redirects first (e.g. b23.tv -> www.bilibili.com), so cached favicons are hit early.
final_url = _resolve_final_url_for_favicon(page_url)
candidates: list[str] = []
for u in (final_url, page_url):
fav = _origin_favicon_url(u)
if fav and fav not in candidates:
candidates.append(fav)
proxy_account = "_favicon"
max_bytes = 512 * 1024 # favicons should be small; protect against huge downloads.
for cand in candidates:
if not _is_safe_http_url(cand):
continue
source_url = normalize_avatar_source_url(cand)
cache_entry = get_avatar_cache_url_entry(proxy_account, source_url) if is_avatar_cache_enabled() else None
cache_file = avatar_cache_entry_file_exists(proxy_account, cache_entry)
if cache_entry and cache_file and avatar_cache_entry_is_fresh(cache_entry):
logger.info(f"[avatar_cache_hit] kind=favicon account={proxy_account} url={source_url}")
touch_avatar_cache_entry(proxy_account, cache_key_for_avatar_url(source_url))
headers = build_avatar_cache_response_headers(cache_entry)
return FileResponse(
str(cache_file),
media_type=str(cache_entry.get("media_type") or "application/octet-stream"),
headers=headers,
)
# Download favicon bytes (best-effort)
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120 Safari/537.36",
"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
}
r = None
try:
r = requests.get(source_url, headers=headers, timeout=20, stream=True, allow_redirects=True)
if int(getattr(r, "status_code", 0) or 0) != 200:
continue
ct = str((getattr(r, "headers", {}) or {}).get("Content-Type") or "").strip()
try:
cl = int((getattr(r, "headers", {}) or {}).get("content-length") or 0)
except Exception:
cl = 0
if cl and cl > max_bytes:
raise HTTPException(status_code=413, detail="Remote favicon too large.")
chunks: list[bytes] = []
total = 0
for chunk in r.iter_content(chunk_size=64 * 1024):
if not chunk:
continue
chunks.append(chunk)
total += len(chunk)
if total > max_bytes:
raise HTTPException(status_code=413, detail="Remote favicon too large.")
data = b"".join(chunks)
except HTTPException:
raise
except Exception:
continue
finally:
if r is not None:
try:
r.close()
except Exception:
pass
if not data:
continue
payload, media_type, _ext = _detect_media_type_and_ext(data)
if media_type == "application/octet-stream" and ct:
try:
mt = ct.split(";")[0].strip()
if mt.startswith("image/"):
media_type = mt
except Exception:
pass
if not str(media_type or "").startswith("image/"):
continue
if is_avatar_cache_enabled():
entry, out_path = write_avatar_cache_payload(
proxy_account,
source_kind="url",
source_url=source_url,
payload=payload,
media_type=media_type,
ttl_seconds=AVATAR_CACHE_TTL_SECONDS,
)
if entry and out_path:
logger.info(f"[avatar_cache_download] kind=favicon account={proxy_account} url={source_url}")
headers = build_avatar_cache_response_headers(entry)
return FileResponse(str(out_path), media_type=media_type, headers=headers)
resp = Response(content=payload, media_type=media_type)
resp.headers["Cache-Control"] = f"public, max-age={AVATAR_CACHE_TTL_SECONDS}"
return resp
raise HTTPException(status_code=404, detail="favicon not found.")
@router.post("/api/chat/media/emoji/download", summary="下载表情消息资源到本地 resource")
async def download_chat_emoji(req: EmojiDownloadRequest):
md5 = str(req.md5 or "").strip().lower()
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,114 @@
import asyncio
import json
import time
from typing import Literal, Optional
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import FileResponse, StreamingResponse
from pydantic import BaseModel, Field
from ..path_fix import PathFixRoute
from ..sns_export_service import SNS_EXPORT_MANAGER
router = APIRouter(route_class=PathFixRoute)
ExportScope = Literal["selected", "all"]
class SnsExportCreateRequest(BaseModel):
account: Optional[str] = Field(None, description="账号目录名(可选,默认使用第一个)")
scope: ExportScope = Field("selected", description="导出范围:selected=指定联系人;all=全部联系人")
usernames: list[str] = Field(default_factory=list, description="朋友圈 username 列表(scope=selected 时使用)")
use_cache: bool = Field(True, description="是否复用导出过程中的本地缓存(默认开启)")
output_dir: Optional[str] = Field(None, description="导出目录绝对路径(可选;不填时使用默认目录)")
file_name: Optional[str] = Field(None, description="导出 zip 文件名(可选,不含/含 .zip 都可)")
@router.post("/api/sns/exports", summary="创建朋友圈导出任务(离线 HTML zip)")
async def create_sns_export(req: SnsExportCreateRequest):
job = SNS_EXPORT_MANAGER.create_job(
account=req.account,
scope=req.scope,
usernames=req.usernames,
use_cache=bool(req.use_cache),
output_dir=req.output_dir,
file_name=req.file_name,
)
return {"status": "success", "job": job.to_public_dict()}
@router.get("/api/sns/exports", summary="列出导出任务(内存)")
async def list_sns_exports():
jobs = [j.to_public_dict() for j in SNS_EXPORT_MANAGER.list_jobs()]
jobs.sort(key=lambda x: int(x.get("createdAt") or 0), reverse=True)
return {"status": "success", "jobs": jobs}
@router.get("/api/sns/exports/{export_id}", summary="获取导出任务状态")
async def get_sns_export(export_id: str):
job = SNS_EXPORT_MANAGER.get_job(str(export_id or "").strip())
if not job:
raise HTTPException(status_code=404, detail="Export not found.")
return {"status": "success", "job": job.to_public_dict()}
@router.get("/api/sns/exports/{export_id}/download", summary="下载导出 zip")
async def download_sns_export(export_id: str):
job = SNS_EXPORT_MANAGER.get_job(str(export_id or "").strip())
if not job:
raise HTTPException(status_code=404, detail="Export not found.")
if not job.zip_path or (not job.zip_path.exists()):
raise HTTPException(status_code=409, detail="Export not ready.")
return FileResponse(
str(job.zip_path),
media_type="application/zip",
filename=job.zip_path.name,
)
@router.get("/api/sns/exports/{export_id}/events", summary="导出任务进度 SSE")
async def stream_sns_export_events(export_id: str, request: Request):
export_id = str(export_id or "").strip()
job0 = SNS_EXPORT_MANAGER.get_job(export_id)
if not job0:
raise HTTPException(status_code=404, detail="Export not found.")
async def gen():
last_payload = ""
last_heartbeat = 0.0
while True:
if await request.is_disconnected():
break
job = SNS_EXPORT_MANAGER.get_job(export_id)
if not job:
yield "event: error\ndata: " + json.dumps({"error": "Export not found."}, ensure_ascii=False) + "\n\n"
break
payload = json.dumps(job.to_public_dict(), ensure_ascii=False)
if payload != last_payload:
last_payload = payload
yield f"data: {payload}\n\n"
now = time.time()
if now - last_heartbeat > 15:
last_heartbeat = now
yield ": ping\n\n"
if job.status in {"done", "error", "cancelled"}:
break
await asyncio.sleep(0.6)
headers = {"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}
return StreamingResponse(gen(), media_type="text/event-stream", headers=headers)
@router.delete("/api/sns/exports/{export_id}", summary="取消导出任务")
async def cancel_sns_export(export_id: str):
ok = SNS_EXPORT_MANAGER.cancel_job(str(export_id or "").strip())
if not ok:
raise HTTPException(status_code=404, detail="Export not found.")
return {"status": "success"}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,274 @@
"""SNS (Moments) realtime -> decrypted sqlite incremental sync.
Why:
- We can read the latest Moments via WCDB realtime, but the decrypted snapshot (`output/databases/{account}/sns.db`)
can lag behind or miss data (e.g. you viewed it when it was visible, then it became "only last 3 days").
- For export/offline browsing, we want to keep a local append-only cache of Moments that were visible at some point.
This module runs a lightweight background poller that watches db_storage/sns*.db mtime changes and triggers a cheap
incremental sync of the latest N Moments into the decrypted snapshot.
"""
from __future__ import annotations
import os
import threading
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Optional
from fastapi import HTTPException
from .chat_helpers import _list_decrypted_accounts, _resolve_account_dir
from .logging_config import get_logger
from .wcdb_realtime import WCDB_REALTIME
logger = get_logger(__name__)
def _env_bool(name: str, default: bool) -> bool:
raw = str(os.environ.get(name, "") or "").strip().lower()
if not raw:
return default
return raw not in {"0", "false", "no", "off"}
def _env_int(name: str, default: int, *, min_v: int, max_v: int) -> int:
raw = str(os.environ.get(name, "") or "").strip()
try:
v = int(raw)
except Exception:
v = int(default)
if v < min_v:
v = min_v
if v > max_v:
v = max_v
return v
def _mtime_ns(path: Path) -> int:
try:
st = path.stat()
m_ns = int(getattr(st, "st_mtime_ns", 0) or 0)
if m_ns <= 0:
m_ns = int(float(getattr(st, "st_mtime", 0.0) or 0.0) * 1_000_000_000)
return int(m_ns)
except Exception:
return 0
def _scan_sns_db_mtime_ns(db_storage_dir: Path) -> int:
"""Best-effort "latest mtime" signal for sns.db buckets."""
base = Path(db_storage_dir)
candidates: list[Path] = [
base / "sns" / "sns.db",
base / "sns" / "sns.db-wal",
base / "sns" / "sns.db-shm",
base / "sns.db",
base / "sns.db-wal",
base / "sns.db-shm",
]
max_ns = 0
for p in candidates:
v = _mtime_ns(p)
if v > max_ns:
max_ns = v
return int(max_ns)
@dataclass
class _AccountState:
last_mtime_ns: int = 0
due_at: float = 0.0
last_sync_end_at: float = 0.0
thread: Optional[threading.Thread] = None
class SnsRealtimeAutoSyncService:
def __init__(self) -> None:
self._enabled = _env_bool("WECHAT_TOOL_SNS_AUTOSYNC", True)
self._interval_ms = _env_int("WECHAT_TOOL_SNS_AUTOSYNC_INTERVAL_MS", 2000, min_v=500, max_v=60_000)
self._debounce_ms = _env_int("WECHAT_TOOL_SNS_AUTOSYNC_DEBOUNCE_MS", 800, min_v=0, max_v=60_000)
self._min_sync_interval_ms = _env_int(
"WECHAT_TOOL_SNS_AUTOSYNC_MIN_SYNC_INTERVAL_MS", 5000, min_v=0, max_v=300_000
)
self._workers = _env_int("WECHAT_TOOL_SNS_AUTOSYNC_WORKERS", 1, min_v=1, max_v=4)
self._max_scan = _env_int("WECHAT_TOOL_SNS_AUTOSYNC_MAX_SCAN", 200, min_v=20, max_v=2000)
self._mu = threading.Lock()
self._states: dict[str, _AccountState] = {}
self._stop = threading.Event()
self._thread: Optional[threading.Thread] = None
def start(self) -> None:
if not self._enabled:
logger.info("[sns-autosync] disabled by env WECHAT_TOOL_SNS_AUTOSYNC=0")
return
with self._mu:
if self._thread is not None and self._thread.is_alive():
return
self._stop.clear()
th = threading.Thread(target=self._run, name="sns-realtime-autosync", daemon=True)
self._thread = th
th.start()
logger.info(
"[sns-autosync] started interval_ms=%s debounce_ms=%s min_sync_interval_ms=%s max_scan=%s workers=%s",
int(self._interval_ms),
int(self._debounce_ms),
int(self._min_sync_interval_ms),
int(self._max_scan),
int(self._workers),
)
def stop(self) -> None:
self._stop.set()
with self._mu:
self._thread = None
def _run(self) -> None:
while not self._stop.is_set():
tick_t0 = time.perf_counter()
try:
self._tick()
except Exception:
logger.exception("[sns-autosync] tick failed")
elapsed_ms = (time.perf_counter() - tick_t0) * 1000.0
sleep_ms = max(200.0, float(self._interval_ms) - elapsed_ms)
self._stop.wait(timeout=sleep_ms / 1000.0)
def _tick(self) -> None:
accounts = _list_decrypted_accounts()
now = time.time()
if not accounts:
return
for acc in accounts:
if self._stop.is_set():
break
try:
account_dir = _resolve_account_dir(acc)
except HTTPException:
continue
except Exception:
continue
info = WCDB_REALTIME.get_status(account_dir)
available = bool(info.get("dll_present") and info.get("key_present") and info.get("db_storage_dir"))
if not available:
continue
db_storage_dir = Path(str(info.get("db_storage_dir") or "").strip())
if not db_storage_dir.exists() or not db_storage_dir.is_dir():
continue
mtime_ns = _scan_sns_db_mtime_ns(db_storage_dir)
with self._mu:
st = self._states.setdefault(acc, _AccountState())
if mtime_ns and mtime_ns != st.last_mtime_ns:
st.last_mtime_ns = int(mtime_ns)
st.due_at = now + (float(self._debounce_ms) / 1000.0)
# Schedule daemon threads.
to_start: list[threading.Thread] = []
with self._mu:
keep = set(accounts)
for acc in list(self._states.keys()):
if acc not in keep:
self._states.pop(acc, None)
running = 0
for st in self._states.values():
th = st.thread
if th is not None and th.is_alive():
running += 1
elif th is not None and (not th.is_alive()):
st.thread = None
for acc, st in self._states.items():
if running >= int(self._workers):
break
if st.due_at <= 0 or st.due_at > now:
continue
if st.thread is not None and st.thread.is_alive():
continue
since = now - float(st.last_sync_end_at or 0.0)
min_interval = float(self._min_sync_interval_ms) / 1000.0
if min_interval > 0 and since < min_interval:
st.due_at = now + (min_interval - since)
continue
st.due_at = 0.0
th = threading.Thread(
target=self._sync_account_runner,
args=(acc,),
name=f"sns-autosync-{acc}",
daemon=True,
)
st.thread = th
to_start.append(th)
running += 1
for th in to_start:
if self._stop.is_set():
break
try:
th.start()
except Exception:
with self._mu:
for acc, st in self._states.items():
if st.thread is th:
st.thread = None
break
def _sync_account_runner(self, account: str) -> None:
account = str(account or "").strip()
try:
if self._stop.is_set() or (not account):
return
res = self._sync_account(account)
upserted = int((res or {}).get("upserted") or 0)
logger.info("[sns-autosync] sync done account=%s upserted=%s", account, upserted)
except Exception:
logger.exception("[sns-autosync] sync failed account=%s", account)
finally:
with self._mu:
st = self._states.get(account)
if st is not None:
st.thread = None
st.last_sync_end_at = time.time()
def _sync_account(self, account: str) -> dict[str, Any]:
account = str(account or "").strip()
if not account:
return {"status": "skipped", "reason": "missing account"}
try:
account_dir = _resolve_account_dir(account)
except Exception as e:
return {"status": "skipped", "reason": f"resolve account failed: {e}"}
info = WCDB_REALTIME.get_status(account_dir)
available = bool(info.get("dll_present") and info.get("key_present") and info.get("db_storage_dir"))
if not available:
return {"status": "skipped", "reason": "realtime not available"}
# Import lazily to avoid startup import ordering issues.
from .routers.sns import sync_sns_realtime_timeline_latest
try:
return sync_sns_realtime_timeline_latest(
account=account,
max_scan=int(self._max_scan),
force=0,
)
except HTTPException as e:
return {"status": "error", "error": str(e.detail or "")}
except Exception as e:
return {"status": "error", "error": str(e)}
SNS_REALTIME_AUTOSYNC = SnsRealtimeAutoSyncService()
+129 -6
View File
@@ -1,6 +1,8 @@
import ctypes
import binascii
import json
import os
import re
import sys
import threading
import time
@@ -20,7 +22,51 @@ class WCDBRealtimeError(RuntimeError):
_NATIVE_DIR = Path(__file__).resolve().parent / "native"
_WCDB_API_DLL = _NATIVE_DIR / "wcdb_api.dll"
_DEFAULT_WCDB_API_DLL = _NATIVE_DIR / "wcdb_api.dll"
_WCDB_API_DLL_SELECTED: Optional[Path] = None
def _candidate_wcdb_api_dll_paths() -> list[Path]:
"""Return possible locations for wcdb_api.dll (prefer WeFlow's newer build when present)."""
cands: list[Path] = []
env = str(os.environ.get("WECHAT_TOOL_WCDB_API_DLL_PATH", "") or "").strip()
if env:
cands.append(Path(env))
# Repo checkout convenience: reuse bundled WeFlow / echotrace DLLs when available.
try:
repo_root = Path(__file__).resolve().parents[2]
except Exception:
repo_root = Path.cwd()
for p in [
repo_root / "WeFlow" / "resources" / "wcdb_api.dll",
repo_root / "echotrace" / "assets" / "dll" / "wcdb_api.dll",
_DEFAULT_WCDB_API_DLL,
]:
if p not in cands:
cands.append(p)
return cands
def _resolve_wcdb_api_dll_path() -> Path:
global _WCDB_API_DLL_SELECTED
if _WCDB_API_DLL_SELECTED is not None:
return _WCDB_API_DLL_SELECTED
for p in _candidate_wcdb_api_dll_paths():
try:
if p.exists() and p.is_file():
_WCDB_API_DLL_SELECTED = p
return p
except Exception:
continue
# Fall back to the default path even if it doesn't exist; caller will raise a clear error.
_WCDB_API_DLL_SELECTED = _DEFAULT_WCDB_API_DLL
return _WCDB_API_DLL_SELECTED
_lib_lock = threading.Lock()
_lib: Optional[ctypes.CDLL] = None
@@ -40,16 +86,18 @@ def _load_wcdb_lib() -> ctypes.CDLL:
if not _is_windows():
raise WCDBRealtimeError("WCDB realtime mode is only supported on Windows.")
if not _WCDB_API_DLL.exists():
raise WCDBRealtimeError(f"Missing wcdb_api.dll at: {_WCDB_API_DLL}")
wcdb_api_dll = _resolve_wcdb_api_dll_path()
if not wcdb_api_dll.exists():
raise WCDBRealtimeError(f"Missing wcdb_api.dll at: {wcdb_api_dll}")
# Ensure dependent DLLs (e.g. WCDB.dll) can be found.
try:
os.add_dll_directory(str(_NATIVE_DIR))
os.add_dll_directory(str(wcdb_api_dll.parent))
except Exception:
pass
lib = ctypes.CDLL(str(_WCDB_API_DLL))
lib = ctypes.CDLL(str(wcdb_api_dll))
logger.info("[wcdb] using wcdb_api.dll: %s", wcdb_api_dll)
# Signatures
lib.wcdb_init.argtypes = []
@@ -144,6 +192,19 @@ def _load_wcdb_lib() -> ctypes.CDLL:
# Older wcdb_api.dll may not expose this export.
pass
# Optional (newer DLLs): wcdb_decrypt_sns_image(encrypted_data, len, key, out_hex)
# WeFlow uses this to decrypt Moments CDN images.
try:
lib.wcdb_decrypt_sns_image.argtypes = [
ctypes.c_void_p,
ctypes.c_int32,
ctypes.c_char_p,
ctypes.POINTER(ctypes.c_void_p),
]
lib.wcdb_decrypt_sns_image.restype = ctypes.c_int32
except Exception:
pass
lib.wcdb_get_logs.argtypes = [ctypes.POINTER(ctypes.c_char_p)]
lib.wcdb_get_logs.restype = ctypes.c_int
@@ -488,6 +549,63 @@ def get_sns_timeline(
return []
def decrypt_sns_image(encrypted_data: bytes, key: str) -> bytes:
"""Decrypt Moments CDN image bytes using WCDB DLL (WeFlow compatible).
Notes:
- Requires a newer wcdb_api.dll export: wcdb_decrypt_sns_image.
- On failure, returns the original encrypted_data (best-effort behavior like WeFlow).
"""
_ensure_initialized()
lib = _load_wcdb_lib()
fn = getattr(lib, "wcdb_decrypt_sns_image", None)
if not fn:
raise WCDBRealtimeError("Current wcdb_api.dll does not support sns image decryption.")
raw = bytes(encrypted_data or b"")
if not raw:
return b""
k = str(key or "").strip()
if not k:
return raw
out_ptr = ctypes.c_void_p()
buf = ctypes.create_string_buffer(raw, len(raw))
rc = 0
try:
rc = int(
fn(
ctypes.cast(buf, ctypes.c_void_p),
ctypes.c_int32(len(raw)),
k.encode("utf-8"),
ctypes.byref(out_ptr),
)
)
if rc != 0 or not out_ptr.value:
return raw
hex_bytes = ctypes.cast(out_ptr, ctypes.c_char_p).value or b""
if not hex_bytes:
return raw
# Defensive: keep only hex chars (some builds may include whitespace).
hex_clean = re.sub(rb"[^0-9a-fA-F]", b"", hex_bytes)
if not hex_clean:
return raw
try:
return binascii.unhexlify(hex_clean)
except Exception:
return raw
finally:
try:
if out_ptr.value:
lib.wcdb_free_string(ctypes.cast(out_ptr, ctypes.c_char_p))
except Exception:
pass
def shutdown() -> None:
global _initialized
lib = _load_wcdb_lib()
@@ -573,11 +691,16 @@ class WCDBRealtimeManager:
except Exception as e:
err = str(e)
dll_ok = _WCDB_API_DLL.exists()
dll_path = _resolve_wcdb_api_dll_path()
try:
dll_ok = bool(dll_path.exists())
except Exception:
dll_ok = False
connected = self.is_connected(account)
return {
"account": account,
"dll_present": bool(dll_ok),
"wcdb_api_dll": str(dll_path),
"key_present": bool(key_ok),
"db_storage_dir": str(db_storage_dir) if db_storage_dir else "",
"session_db_path": str(session_db_path) if session_db_path else "",
+221
View File
@@ -0,0 +1,221 @@
import os
import json
import hashlib
import sqlite3
import sys
import unittest
import zipfile
import importlib
from pathlib import Path
from tempfile import TemporaryDirectory
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
class TestChatExportHtmlPaging(unittest.TestCase):
def _reload_export_modules(self):
import wechat_decrypt_tool.app_paths as app_paths
import wechat_decrypt_tool.chat_helpers as chat_helpers
import wechat_decrypt_tool.media_helpers as media_helpers
import wechat_decrypt_tool.chat_export_service as chat_export_service
importlib.reload(app_paths)
importlib.reload(chat_helpers)
importlib.reload(media_helpers)
importlib.reload(chat_export_service)
return chat_export_service
def _seed_contact_db(self, path: Path, *, account: str, username: str) -> None:
conn = sqlite3.connect(str(path))
try:
conn.execute(
"""
CREATE TABLE contact (
username TEXT,
remark TEXT,
nick_name TEXT,
alias TEXT,
local_type INTEGER,
verify_flag INTEGER,
big_head_url TEXT,
small_head_url TEXT
)
"""
)
conn.execute(
"""
CREATE TABLE stranger (
username TEXT,
remark TEXT,
nick_name TEXT,
alias TEXT,
local_type INTEGER,
verify_flag INTEGER,
big_head_url TEXT,
small_head_url TEXT
)
"""
)
conn.execute(
"INSERT INTO contact VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
(account, "", "Me", "", 1, 0, "", ""),
)
conn.execute(
"INSERT INTO contact VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
(username, "", "Friend", "", 1, 0, "", ""),
)
conn.commit()
finally:
conn.close()
def _seed_session_db(self, path: Path, *, username: str) -> None:
conn = sqlite3.connect(str(path))
try:
conn.execute(
"""
CREATE TABLE SessionTable (
username TEXT,
is_hidden INTEGER,
sort_timestamp INTEGER
)
"""
)
conn.execute(
"INSERT INTO SessionTable VALUES (?, ?, ?)",
(username, 0, 1735689600),
)
conn.commit()
finally:
conn.close()
def _seed_message_db(self, path: Path, *, account: str, username: str, total: int) -> None:
conn = sqlite3.connect(str(path))
try:
conn.execute("CREATE TABLE Name2Id (rowid INTEGER PRIMARY KEY, user_name TEXT)")
conn.execute("INSERT INTO Name2Id(rowid, user_name) VALUES (?, ?)", (1, account))
conn.execute("INSERT INTO Name2Id(rowid, user_name) VALUES (?, ?)", (2, username))
table_name = f"msg_{hashlib.md5(username.encode('utf-8')).hexdigest()}"
conn.execute(
f"""
CREATE TABLE {table_name} (
local_id INTEGER,
server_id INTEGER,
local_type INTEGER,
sort_seq INTEGER,
real_sender_id INTEGER,
create_time INTEGER,
message_content TEXT,
compress_content BLOB
)
"""
)
# Generate lots of plain text messages with unique markers.
rows = []
base_ts = 1735689600
for i in range(1, total + 1):
marker = f"MSG{i:04d}"
real_sender_id = 1 if (i % 2 == 0) else 2
rows.append((i, 100000 + i, 1, i, real_sender_id, base_ts + i, marker, None))
conn.executemany(
f"INSERT INTO {table_name} (local_id, server_id, local_type, sort_seq, real_sender_id, create_time, message_content, compress_content) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
rows,
)
conn.commit()
finally:
conn.close()
def _prepare_account(self, root: Path, *, account: str, username: str, total: int) -> Path:
account_dir = root / "output" / "databases" / account
account_dir.mkdir(parents=True, exist_ok=True)
self._seed_contact_db(account_dir / "contact.db", account=account, username=username)
self._seed_session_db(account_dir / "session.db", username=username)
self._seed_message_db(account_dir / "message_0.db", account=account, username=username, total=total)
return account_dir
def _create_job(self, manager, *, account: str, username: str, html_page_size: int):
job = manager.create_job(
account=account,
scope="selected",
usernames=[username],
export_format="html",
start_time=None,
end_time=None,
include_hidden=False,
include_official=False,
include_media=False,
media_kinds=[],
message_types=[],
output_dir=None,
allow_process_key_extract=False,
download_remote_media=False,
html_page_size=html_page_size,
privacy_mode=False,
file_name=None,
)
# Export is async (thread). Allow enough time for a few thousand messages + zip writes.
for _ in range(600):
latest = manager.get_job(job.export_id)
if latest and latest.status in {"done", "error", "cancelled"}:
return latest
import time as _time
_time.sleep(0.05)
self.fail("export job did not finish in time")
def test_html_export_paging_inlines_latest_page_only(self):
with TemporaryDirectory() as td:
root = Path(td)
account = "wxid_test"
username = "wxid_friend"
total_messages = 2300
page_size = 1000
self._prepare_account(root, account=account, username=username, total=total_messages)
prev_data = os.environ.get("WECHAT_TOOL_DATA_DIR")
try:
os.environ["WECHAT_TOOL_DATA_DIR"] = str(root)
svc = self._reload_export_modules()
job = self._create_job(
svc.CHAT_EXPORT_MANAGER,
account=account,
username=username,
html_page_size=page_size,
)
self.assertEqual(job.status, "done", msg=job.error)
self.assertTrue(job.zip_path and job.zip_path.exists())
with zipfile.ZipFile(job.zip_path, "r") as zf:
names = set(zf.namelist())
html_path = next((n for n in names if n.endswith("/messages.html")), "")
self.assertTrue(html_path, msg="missing messages.html")
html_text = zf.read(html_path).decode("utf-8", errors="ignore")
# Paging UI + meta should exist for multi-page exports.
self.assertIn('id="wcePageMeta"', html_text)
self.assertIn('id="wcePager"', html_text)
self.assertIn('id="wceMessageList"', html_text)
self.assertIn('id="wceLoadPrevBtn"', html_text)
# Latest page is inlined; earliest page should not be present in messages.html.
self.assertIn("MSG2300", html_text)
self.assertNotIn("MSG0001", html_text)
conv_dir = html_path.rsplit("/", 1)[0]
page1_js = f"{conv_dir}/pages/page-0001.js"
self.assertIn(page1_js, names)
page1_text = zf.read(page1_js).decode("utf-8", errors="ignore")
self.assertIn("MSG0001", page1_text)
finally:
if prev_data is None:
os.environ.pop("WECHAT_TOOL_DATA_DIR", None)
else:
os.environ["WECHAT_TOOL_DATA_DIR"] = prev_data
+133
View File
@@ -0,0 +1,133 @@
import os
import sqlite3
import sys
import unittest
import importlib
from pathlib import Path
from tempfile import TemporaryDirectory
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
class _FakeResponse:
def __init__(self, *, status_code: int = 200, headers: dict | None = None, url: str = "", body: bytes = b""):
self.status_code = int(status_code)
self.headers = dict(headers or {})
self.url = str(url or "")
self._body = bytes(body or b"")
def iter_content(self, chunk_size: int = 64 * 1024):
yield self._body
def close(self) -> None:
return None
class TestChatMediaFavicon(unittest.TestCase):
def test_chat_media_favicon_caches(self):
from fastapi import FastAPI
from fastapi.testclient import TestClient
# 1x1 PNG (same as other avatar cache tests)
png = bytes.fromhex(
"89504E470D0A1A0A"
"0000000D49484452000000010000000108060000001F15C489"
"0000000D49444154789C6360606060000000050001A5F64540"
"0000000049454E44AE426082"
)
with TemporaryDirectory() as td:
root = Path(td)
prev_data = None
prev_cache = None
try:
prev_data = os.environ.get("WECHAT_TOOL_DATA_DIR")
prev_cache = os.environ.get("WECHAT_TOOL_AVATAR_CACHE_ENABLED")
os.environ["WECHAT_TOOL_DATA_DIR"] = str(root)
os.environ["WECHAT_TOOL_AVATAR_CACHE_ENABLED"] = "1"
import wechat_decrypt_tool.app_paths as app_paths
import wechat_decrypt_tool.avatar_cache as avatar_cache
import wechat_decrypt_tool.routers.chat_media as chat_media
importlib.reload(app_paths)
importlib.reload(avatar_cache)
importlib.reload(chat_media)
def fake_head(url, **_kwargs):
# Pretend short-link resolves to bilibili.
return _FakeResponse(
status_code=200,
headers={},
url="https://www.bilibili.com/video/BV1Au4tzNEq2",
body=b"",
)
def fake_get(url, **_kwargs):
u = str(url or "")
if "www.bilibili.com/favicon.ico" in u:
return _FakeResponse(
status_code=200,
headers={"Content-Type": "image/png", "content-length": str(len(png))},
url=u,
body=png,
)
return _FakeResponse(
status_code=404,
headers={"Content-Type": "text/html"},
url=u,
body=b"",
)
app = FastAPI()
app.include_router(chat_media.router)
client = TestClient(app)
with patch("wechat_decrypt_tool.routers.chat_media.requests.head", side_effect=fake_head) as mock_head, patch(
"wechat_decrypt_tool.routers.chat_media.requests.get", side_effect=fake_get
) as mock_get:
resp = client.get("/api/chat/media/favicon", params={"url": "https://b23.tv/au68guF"})
self.assertEqual(resp.status_code, 200)
self.assertTrue(resp.headers.get("content-type", "").startswith("image/"))
self.assertEqual(resp.content, png)
# Second call should hit disk cache (no extra favicon download).
resp2 = client.get("/api/chat/media/favicon", params={"url": "https://b23.tv/au68guF"})
self.assertEqual(resp2.status_code, 200)
self.assertEqual(resp2.content, png)
self.assertGreaterEqual(mock_head.call_count, 1)
self.assertEqual(mock_get.call_count, 1)
cache_db = root / "output" / "avatar_cache" / "favicon" / "avatar_cache.db"
self.assertTrue(cache_db.exists())
conn = sqlite3.connect(str(cache_db))
try:
row = conn.execute(
"SELECT source_kind, source_url, media_type FROM avatar_cache_entries WHERE source_kind = 'url' LIMIT 1"
).fetchone()
self.assertIsNotNone(row)
self.assertEqual(str(row[0] or ""), "url")
self.assertIn("favicon.ico", str(row[1] or ""))
self.assertTrue(str(row[2] or "").startswith("image/"))
finally:
conn.close()
finally:
if prev_data is None:
os.environ.pop("WECHAT_TOOL_DATA_DIR", None)
else:
os.environ["WECHAT_TOOL_DATA_DIR"] = prev_data
if prev_cache is None:
os.environ.pop("WECHAT_TOOL_AVATAR_CACHE_ENABLED", None)
else:
os.environ["WECHAT_TOOL_AVATAR_CACHE_ENABLED"] = prev_cache
if __name__ == "__main__":
unittest.main()
+292
View File
@@ -0,0 +1,292 @@
import hashlib
import sqlite3
import sys
import unittest
from datetime import datetime
from pathlib import Path
from tempfile import TemporaryDirectory
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
from wechat_decrypt_tool.routers import chat as chat_router
def _msg_table_name(username: str) -> str:
md5_hex = hashlib.md5(username.encode("utf-8")).hexdigest()
return f"Msg_{md5_hex}"
def _seed_message_db(path: Path, *, username: str, rows: list[tuple[int, int]]) -> None:
"""rows: [(create_time, sort_seq), ...]"""
table = _msg_table_name(username)
conn = sqlite3.connect(str(path))
try:
conn.execute(
f"""
CREATE TABLE "{table}"(
local_id INTEGER PRIMARY KEY AUTOINCREMENT,
create_time INTEGER,
sort_seq INTEGER
)
"""
)
for create_time, sort_seq in rows:
conn.execute(
f'INSERT INTO "{table}"(create_time, sort_seq) VALUES (?, ?)',
(int(create_time), int(sort_seq)),
)
conn.commit()
finally:
conn.close()
def _seed_message_db_full(path: Path, *, username: str, rows: list[tuple[int, int, str]]) -> None:
"""rows: [(create_time, sort_seq, text), ...] - minimal schema for /api/chat/messages/around."""
table = _msg_table_name(username)
conn = sqlite3.connect(str(path))
try:
conn.execute(
f"""
CREATE TABLE "{table}"(
local_id INTEGER PRIMARY KEY AUTOINCREMENT,
server_id INTEGER,
local_type INTEGER,
sort_seq INTEGER,
real_sender_id INTEGER,
create_time INTEGER,
message_content TEXT,
compress_content BLOB
)
"""
)
for create_time, sort_seq, text in rows:
conn.execute(
f'INSERT INTO "{table}"(server_id, local_type, sort_seq, real_sender_id, create_time, message_content, compress_content) '
"VALUES (?, ?, ?, ?, ?, ?, ?)",
(0, 1, int(sort_seq), 0, int(create_time), str(text), None),
)
conn.commit()
finally:
conn.close()
def _seed_contact_db_minimal(path: Path) -> None:
conn = sqlite3.connect(str(path))
try:
conn.execute(
"""
CREATE TABLE contact (
username TEXT,
remark TEXT,
nick_name TEXT,
alias TEXT,
big_head_url TEXT,
small_head_url TEXT
)
"""
)
conn.execute(
"""
CREATE TABLE stranger (
username TEXT,
remark TEXT,
nick_name TEXT,
alias TEXT,
big_head_url TEXT,
small_head_url TEXT
)
"""
)
conn.commit()
finally:
conn.close()
class TestChatMessageCalendarHeatmap(unittest.TestCase):
def test_daily_counts_aggregates_per_day_and_respects_month_range(self):
with TemporaryDirectory() as td:
account_dir = Path(td) / "acc"
account_dir.mkdir(parents=True, exist_ok=True)
username = "wxid_test_user"
ts_jan31_23 = int(datetime(2026, 1, 31, 23, 0, 0).timestamp())
ts_feb01_10 = int(datetime(2026, 2, 1, 10, 0, 0).timestamp())
ts_feb14_12 = int(datetime(2026, 2, 14, 12, 0, 0).timestamp())
_seed_message_db(
account_dir / "message.db",
username=username,
rows=[
(ts_jan31_23, 0),
(ts_feb01_10, 5),
(ts_feb01_10, 2),
(ts_feb14_12, 0),
],
)
with patch.object(chat_router, "_resolve_account_dir", return_value=account_dir):
resp = chat_router.get_chat_message_daily_counts(
username=username,
year=2026,
month=2,
account="acc",
)
self.assertEqual(resp.get("status"), "success")
self.assertEqual(resp.get("username"), username)
self.assertEqual(resp.get("year"), 2026)
self.assertEqual(resp.get("month"), 2)
counts = resp.get("counts") or {}
self.assertEqual(counts.get("2026-02-01"), 2)
self.assertEqual(counts.get("2026-02-14"), 1)
self.assertIsNone(counts.get("2026-01-31"))
self.assertEqual(resp.get("total"), 3)
self.assertEqual(resp.get("max"), 2)
def test_anchor_day_picks_earliest_by_create_time_then_sort_seq_then_local_id(self):
with TemporaryDirectory() as td:
account_dir = Path(td) / "acc"
account_dir.mkdir(parents=True, exist_ok=True)
username = "wxid_test_user"
ts_jan31_23 = int(datetime(2026, 1, 31, 23, 0, 0).timestamp())
ts_feb01_10 = int(datetime(2026, 2, 1, 10, 0, 0).timestamp())
_seed_message_db(
account_dir / "message.db",
username=username,
rows=[
(ts_jan31_23, 0), # local_id = 1
(ts_feb01_10, 5), # local_id = 2
(ts_feb01_10, 2), # local_id = 3 <- expected (sort_seq smaller)
],
)
with patch.object(chat_router, "_resolve_account_dir", return_value=account_dir):
resp = chat_router.get_chat_message_anchor(
username=username,
kind="day",
account="acc",
date="2026-02-01",
)
self.assertEqual(resp.get("status"), "success")
self.assertEqual(resp.get("kind"), "day")
self.assertEqual(resp.get("date"), "2026-02-01")
anchor_id = str(resp.get("anchorId") or "")
self.assertTrue(anchor_id.startswith("message:"), anchor_id)
self.assertTrue(anchor_id.endswith(":3"), anchor_id)
def test_anchor_first_picks_global_earliest(self):
with TemporaryDirectory() as td:
account_dir = Path(td) / "acc"
account_dir.mkdir(parents=True, exist_ok=True)
username = "wxid_test_user"
ts_jan31_23 = int(datetime(2026, 1, 31, 23, 0, 0).timestamp())
ts_feb01_10 = int(datetime(2026, 2, 1, 10, 0, 0).timestamp())
_seed_message_db(
account_dir / "message.db",
username=username,
rows=[
(ts_feb01_10, 2), # local_id = 1
(ts_jan31_23, 0), # local_id = 2, but earlier create_time -> should win even if local_id bigger
],
)
with patch.object(chat_router, "_resolve_account_dir", return_value=account_dir):
resp = chat_router.get_chat_message_anchor(
username=username,
kind="first",
account="acc",
)
self.assertEqual(resp.get("status"), "success")
self.assertEqual(resp.get("kind"), "first")
anchor_id = str(resp.get("anchorId") or "")
self.assertTrue(anchor_id.startswith("message:"), anchor_id)
self.assertTrue(anchor_id.endswith(":2"), anchor_id)
def test_anchor_day_empty_returns_empty_status(self):
with TemporaryDirectory() as td:
account_dir = Path(td) / "acc"
account_dir.mkdir(parents=True, exist_ok=True)
username = "wxid_test_user"
ts_feb01_10 = int(datetime(2026, 2, 1, 10, 0, 0).timestamp())
_seed_message_db(account_dir / "message.db", username=username, rows=[(ts_feb01_10, 0)])
with patch.object(chat_router, "_resolve_account_dir", return_value=account_dir):
resp = chat_router.get_chat_message_anchor(
username=username,
kind="day",
account="acc",
date="2026-02-02",
)
self.assertEqual(resp.get("status"), "empty")
self.assertEqual(resp.get("anchorId"), "")
def test_around_can_span_multiple_message_dbs_for_pagination(self):
from fastapi import FastAPI
from fastapi.testclient import TestClient
with TemporaryDirectory() as td:
account_dir = Path(td) / "acc"
account_dir.mkdir(parents=True, exist_ok=True)
username = "wxid_test_user"
table = _msg_table_name(username)
# Anchor in message.db, next message in message_1.db
_seed_message_db_full(
account_dir / "message.db",
username=username,
rows=[(1000, 0, "A")], # local_id=1
)
_seed_message_db_full(
account_dir / "message_1.db",
username=username,
rows=[(2000, 0, "B")], # local_id=1
)
_seed_contact_db_minimal(account_dir / "contact.db")
app = FastAPI()
app.include_router(chat_router.router)
client = TestClient(app)
with patch.object(chat_router, "_resolve_account_dir", return_value=account_dir):
resp = client.get(
"/api/chat/messages/around",
params={
"account": "acc",
"username": username,
"anchor_id": f"message:{table}:1",
"before": 0,
"after": 10,
},
)
self.assertEqual(resp.status_code, 200, resp.text)
data = resp.json()
self.assertEqual(data.get("status"), "success")
self.assertEqual(data.get("username"), username)
self.assertEqual(data.get("anchorId"), f"message:{table}:1")
self.assertEqual(data.get("anchorIndex"), 0)
msgs = data.get("messages") or []
self.assertEqual(len(msgs), 2)
self.assertEqual(msgs[0].get("id"), f"message:{table}:1")
self.assertEqual(msgs[1].get("id"), f"message_1:{table}:1")
+16 -1
View File
@@ -39,9 +39,20 @@ class TestContactsExport(unittest.TestCase):
return cls._encode_varint(tag) + cls._encode_varint(int(value))
@classmethod
def _build_extra_buffer(cls, *, country: str, province: str, city: str, source_scene: int) -> bytes:
def _build_extra_buffer(
cls,
*,
country: str,
province: str,
city: str,
source_scene: int,
gender: int = 0,
signature: str = "",
) -> bytes:
return b"".join(
[
cls._encode_field_varint(2, gender),
cls._encode_field_len(4, signature.encode("utf-8")),
cls._encode_field_len(5, country.encode("utf-8")),
cls._encode_field_len(6, province.encode("utf-8")),
cls._encode_field_len(7, city.encode("utf-8")),
@@ -88,6 +99,8 @@ class TestContactsExport(unittest.TestCase):
province="Sichuan",
city="Chengdu",
source_scene=14,
gender=1,
signature="自助者天助!!!",
)
conn.execute(
@@ -320,6 +333,8 @@ class TestContactsExport(unittest.TestCase):
self.assertEqual(friend_contact.get("province"), "Sichuan")
self.assertEqual(friend_contact.get("city"), "Chengdu")
self.assertEqual(friend_contact.get("region"), "中国大陆·Sichuan·Chengdu")
self.assertEqual(friend_contact.get("gender"), 1)
self.assertEqual(friend_contact.get("signature"), "自助者天助!!!")
self.assertEqual(friend_contact.get("sourceScene"), 14)
self.assertEqual(friend_contact.get("source"), "通过群聊添加")
+530
View File
@@ -0,0 +1,530 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
导出微信数据库字段配置为一份 Markdown 文档单文件
- 输入wechat_db_config.json tools/generate_wechat_db_config.py 生成
- 输出Markdown包含数据库 /表组 字段与含义
说明
- 本脚本只基于配置文件中的结构与字段含义生成文档不会读取真实数据内容
- 会对类似 Msg_<md5> 这类用户相关的哈希表名做脱敏显示
- 会将同结构但表名仅数字不同的重复表自动折叠为一个表组常见于 FTS 分片/内部表
用法示例
python tools/export_database_schema_markdown.py \
--config wechat_db_config.json \
--output docs/wechat_database_schema.md
"""
from __future__ import annotations
import argparse
import json
import re
from datetime import datetime
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[1]
_HASH_TABLE_RE = re.compile(r"^([A-Za-z0-9]+)_([0-9a-fA-F]{16,})$")
def _md_escape_cell(v: Any) -> str:
"""Escape Markdown table cell content."""
if v is None:
return "-"
s = str(v)
# Keep it one-line for tables.
s = s.replace("\r", " ").replace("\n", " ").strip()
# Escape pipe
s = s.replace("|", r"\|")
return s if s else "-"
def _mask_hash_table_name(name: str) -> str:
"""
Mask user-specific hash suffix table names:
Msg_00140f... -> Msg_<hash>
"""
m = _HASH_TABLE_RE.match(name)
if not m:
return name
return f"{m.group(1)}_<hash>"
def _db_sort_key(db_name: str) -> tuple[int, int, str]:
"""
Roughly sort DBs by importance for readers.
"""
# Core
if db_name == "contact":
return (10, 0, db_name)
if db_name == "session":
return (20, 0, db_name)
m = re.match(r"^message_(\d+)$", db_name)
if m:
return (30, int(m.group(1)), db_name)
if re.match(r"^biz_message_(\d+)$", db_name):
n = int(re.match(r"^biz_message_(\d+)$", db_name).group(1)) # type: ignore[union-attr]
return (31, n, db_name)
if db_name == "message_resource":
return (40, 0, db_name)
if db_name == "media_0":
return (41, 0, db_name)
if db_name == "hardlink":
return (42, 0, db_name)
if db_name == "head_image":
return (43, 0, db_name)
# Social / content
if db_name == "sns":
return (50, 0, db_name)
if db_name == "favorite":
return (60, 0, db_name)
if db_name == "emoticon":
return (70, 0, db_name)
# System / misc
if db_name in {"general", "unspportmsg"}:
return (80, 0, db_name)
# Search / index
if db_name in {"chat_search_index", "message_fts"} or db_name.endswith("_fts"):
return (90, 0, db_name)
# Others
return (100, 0, db_name)
def _render_message_type_map(message_types: dict[str, Any]) -> str:
# In Windows WeChat v4, `local_type` is commonly a 64-bit integer:
# raw = (sub_type << 32) | type
# Some configs may still store explicit (type, sub_type) pairs; handle both.
items: list[tuple[int, int, int, str]] = []
for k, v in message_types.items():
if k in {"_instructions", "examples"}:
continue
if not isinstance(k, str) or "," not in k:
continue
a, b = k.split(",", 1)
try:
a_i = int(a)
b_i = int(b)
except Exception:
continue
desc = str(v)
if b_i != 0:
msg_type = a_i
msg_sub = b_i
raw = (msg_sub << 32) | (msg_type & 0xFFFFFFFF)
else:
raw = a_i
msg_type = raw & 0xFFFFFFFF
msg_sub = (raw >> 32) & 0xFFFFFFFF
items.append((raw, msg_type, msg_sub, desc))
if not items:
return ""
# Sort by decoded (type, sub_type), then raw value.
items.sort(key=lambda x: (x[1], x[2], x[0]))
out = "## 消息类型(local_type)速查\n\n"
out += "说明:Windows 微信 v4 的 `local_type` 常见为 64 位整型:`raw = (sub_type<<32) | type`。\n\n"
out += "| local_type(raw) | type(low32) | sub_type(high32) | 含义 |\n|---:|---:|---:|---|\n"
for raw, t, st, desc in items:
out += f"| {raw} | {t} | {st} | {_md_escape_cell(desc)} |\n"
return out + "\n"
def _table_schema_signature(table: dict[str, Any]) -> tuple[str, str, tuple[tuple[str, str, str, str], ...]]:
"""
Build a stable signature for a table schema in config.
Used to fold tables which are structurally identical but only differ in name
(e.g. message_fts_v4_aux_0..3).
"""
t_type = str(table.get("type", "table"))
desc = str(table.get("description", ""))
fields = table.get("fields") or {}
items: list[tuple[str, str, str, str]] = []
if isinstance(fields, dict):
for field_name, fm in fields.items():
if not isinstance(fm, dict):
fm = {}
items.append(
(
str(field_name),
str(fm.get("type", "")),
str(fm.get("meaning", "")),
str(fm.get("notes", "")),
)
)
items.sort(key=lambda x: x[0])
return (t_type, desc, tuple(items))
def _name_family_key(name: str) -> str:
"""Normalize a table name into a family key by replacing digit runs with {n}."""
return re.sub(r"\d+", "{n}", name)
def _make_group_pattern(table_names: list[str]) -> str:
"""
Make a readable pattern for a group of similar table names:
- Only varying numeric segments become `{n}`
- Constant numeric segments are kept as-is
Example:
message_fts_v4_0/message_fts_v4_1 -> message_fts_v4_{n}
ImgFts0V0/ImgFts1V0 -> ImgFts{n}V0
"""
if not table_names:
return ""
tokenized = [re.split(r"(\d+)", n) for n in table_names]
base = tokenized[0]
# Ensure token structures match; otherwise fall back to a simple normalization.
for t in tokenized[1:]:
if len(t) != len(base):
return _name_family_key(table_names[0])
for i in range(0, len(base), 2):
if t[i] != base[i]:
return _name_family_key(table_names[0])
out_parts: list[str] = []
for i, part in enumerate(base):
if i % 2 == 0:
out_parts.append(part)
continue
nums = {t[i] for t in tokenized if i < len(t)}
out_parts.append(part if len(nums) == 1 else "{n}")
return "".join(out_parts)
def _fold_same_schema_tables_for_display(
tables: dict[str, Any],
) -> list[tuple[str, dict[str, Any]]]:
"""
Fold duplicated tables that share the same schema/signature but only differ in name.
This is common in FTS shards, e.g.:
message_fts_v4_aux_0..3
message_fts_v4_0..3 and their internal *_content/*_data/*_idx tables
ImgFts0V0..3 and their internal tables
Returns a list of (display_name, table_dict) items sorted by the original table name order.
"""
if not tables:
return []
# (family_key, schema_sig) -> [table_name, ...]
groups: dict[tuple[str, tuple[str, str, tuple[tuple[str, str, str, str], ...]]], list[str]] = {}
for table_name, table in tables.items():
if not isinstance(table, dict):
continue
if str(table.get("type", "table")) == "similar_group":
continue
family = _name_family_key(str(table_name))
sig = _table_schema_signature(table)
groups.setdefault((family, sig), []).append(str(table_name))
consumed: set[str] = set()
items: list[tuple[str, str, dict[str, Any]]] = [] # (sort_key, display_name, table)
used_display_names: set[str] = set()
# Create auto "similar_group" entries for groups > 1.
for (_, _), names in sorted(groups.items(), key=lambda x: x[0][0]):
if len(names) <= 1:
continue
names_sorted = sorted(names)
rep = names_sorted[0]
rep_table = tables.get(rep)
if not isinstance(rep_table, dict):
continue
pattern = _make_group_pattern(names_sorted)
if not pattern:
pattern = _name_family_key(rep)
display_name = pattern
if display_name in used_display_names:
# Rare: same name pattern but different schema signatures. Disambiguate.
n = 2
while f"{pattern} (var{n})" in used_display_names:
n += 1
display_name = f"{pattern} (var{n})"
group_entry = dict(rep_table)
group_entry.update(
{
"type": "similar_group",
"pattern": pattern,
"table_count": len(names_sorted),
"representative_table": rep,
"table_names": names_sorted,
}
)
items.append((rep, display_name, group_entry))
used_display_names.add(display_name)
consumed.update(names_sorted)
# Keep non-grouped tables (and existing similar_group) as-is.
for table_name, table in tables.items():
if not isinstance(table, dict):
continue
if str(table_name) in consumed:
continue
items.append((str(table_name), str(table_name), table))
items.sort(key=lambda x: (x[0], x[1]))
return [(display_name, table) for _, display_name, table in items]
def export_markdown(config_path: Path, output_path: Path) -> None:
cfg = json.loads(config_path.read_text(encoding="utf-8"))
meta = cfg.get("_metadata") or {}
databases: dict[str, Any] = cfg.get("databases") or {}
# message_{n}.db are typically shards with identical schema. Keep only the last shard for detailed sections.
message_shards: list[tuple[int, str]] = []
for name in databases.keys():
m = re.match(r"^message_(\d+)$", str(name))
if not m:
continue
try:
message_shards.append((int(m.group(1)), str(name)))
except Exception:
continue
message_shards.sort(key=lambda x: x[0])
rep_message_db: str | None = message_shards[-1][1] if message_shards else None
all_message_db_names = [n for _, n in message_shards]
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
gen_time = meta.get("generated_time") or now
lines: list[str] = []
lines.append("# Windows 微信数据库结构文档(自动生成)")
lines.append("")
lines.append(f"> 生成时间:{_md_escape_cell(gen_time)}")
lines.append(f"> 本次导出:{now}")
lines.append(f"> 配置来源:`{config_path.as_posix()}`(由 `tools/generate_wechat_db_config.py` 生成)")
lines.append("")
lines.append("参考资料:")
lines.append("- `万字长文带你了解Windows微信.md`(目录结构/部分表结构与含义)")
lines.append("- 本项目前端页面与后端解析逻辑(字段命名与用途)")
lines.append("")
lines.append("注意:")
lines.append("- 本文档尽量覆盖“库/表/字段”,字段含义部分来自启发式与公开资料,可能存在不准确之处。")
lines.append("- 为避免泄露个人数据,类似 `Msg_<md5>` 的哈希表名会脱敏显示。")
lines.append("- 部分 FTS 虚表可能依赖微信自定义 tokenizer(如 `MMFtsTokenizer`),普通 sqlite 环境下查询会报错;本文档字段来自建表 SQL/模板解析。")
lines.append("")
# Overview
lines.append("## 数据库总览")
lines.append("")
lines.append("| 数据库 | 描述 | 表数量 |")
lines.append("|---|---|---:|")
for db_name in sorted(databases.keys(), key=_db_sort_key):
db = databases.get(db_name) or {}
if not isinstance(db, dict):
continue
desc = db.get("description", "")
tables = db.get("tables") or {}
lines.append(
f"| `{db_name}.db` | {_md_escape_cell(desc)} | {len(tables) if isinstance(tables, dict) else 0} |"
)
lines.append("")
lines.append("## 本项目(前端)功能与数据库大致对应")
lines.append("")
lines.append("- 联系人/群聊:`contact.db`contact/chat_room/chatroom_member/label 等)")
lines.append("- 会话列表/未读:`session.db`(通常为 SessionTable/ChatInfo 等)")
lines.append("- 聊天记录:`message_*.db``Msg_*` 表组 + `Name2Id` 映射等)")
lines.append("- 消息资源/媒体:`message_resource.db` / `hardlink.db` / `media_0.db` / `head_image.db`")
lines.append("- 朋友圈:`sns.db`")
lines.append("- 收藏:`favorite.db`")
lines.append("- 表情包:`emoticon.db`")
lines.append("- 搜索:`chat_search_index.db` / `message_fts.db` / `*_fts.db`(不同版本/实现可能不同)")
lines.append("")
# Per DB
for db_name in sorted(databases.keys(), key=_db_sort_key):
# Skip duplicated details for message shards; only keep the last shard as representative.
if rep_message_db and re.match(r"^message_\d+$", str(db_name)) and str(db_name) != rep_message_db:
continue
db = databases.get(db_name) or {}
if not isinstance(db, dict):
continue
desc = db.get("description", "")
tables = db.get("tables") or {}
if not isinstance(tables, dict):
tables = {}
display_table_items = _fold_same_schema_tables_for_display(tables)
display_table_count = len(display_table_items)
lines.append(f"## {db_name}.db")
lines.append("")
lines.append(f"- 描述:{_md_escape_cell(desc)}")
if display_table_count != len(tables):
lines.append(f"- 表数量:{len(tables)}(同结构表折叠后展示 {display_table_count}")
else:
lines.append(f"- 表数量:{len(tables)}")
lines.append("")
# Extra note for message shards
if re.match(r"^message_\d+$", db_name):
if rep_message_db and db_name == rep_message_db and len(all_message_db_names) > 1:
others = [n for n in all_message_db_names if n != rep_message_db]
# Keep it short; avoid blowing up the doc with too many names if there are lots of shards.
if len(others) <= 10:
lines.append(f"本节仅展示最后一个分片 `{rep_message_db}.db` 的结构;其它分片结构通常一致:{', '.join([f'`{n}.db`' for n in others])}")
else:
lines.append(
f"本节仅展示最后一个分片 `{rep_message_db}.db` 的结构;其它分片({len(others)} 个)结构通常一致。"
)
lines.append("说明:")
lines.append("- `Msg_*` 表组通常对应“每个联系人/会话一个表”,常见命名为 `Msg_{md5(wxid)}`。")
lines.append("- 可通过对 wxid 做 md5 计算定位具体会话表;或结合 `Name2Id`/`name2id` 映射表进行解析。")
lines.append("")
lines.append("示例(Python):")
lines.append("")
lines.append("```python")
lines.append("import hashlib")
lines.append("")
lines.append("wxid = \"wxid_xxx\"")
lines.append("md5_hex = hashlib.md5(wxid.encode(\"utf-8\")).hexdigest()")
lines.append("table = f\"Msg_{md5_hex}\"")
lines.append("print(table)")
lines.append("```")
lines.append("")
# Tables
for table_name, table in display_table_items:
if not isinstance(table, dict):
continue
t_type = table.get("type", "table")
t_desc = table.get("description", "")
# Table header
display_table_name = _mask_hash_table_name(table_name)
lines.append(f"### {display_table_name}")
lines.append("")
if t_desc:
lines.append(f"- 描述:{_md_escape_cell(t_desc)}")
if t_type == "similar_group":
pat = table.get("pattern") or display_table_name
rep = table.get("representative_table")
table_count = table.get("table_count")
lines.append(f"- 类型:相似表组(pattern: `{_md_escape_cell(pat)}`")
if table_count is not None:
lines.append(f"- 表数量:{_md_escape_cell(table_count)}")
if rep:
rep_s = str(rep)
rep_masked = _mask_hash_table_name(rep_s)
rep_note = "(已脱敏)" if rep_masked != rep_s else ""
lines.append(f"- 代表表:`{_md_escape_cell(rep_masked)}`{rep_note}")
members = table.get("table_names") or table.get("tables")
if isinstance(members, list) and members:
member_names = [str(x) for x in members]
member_names = [_mask_hash_table_name(n) for n in member_names]
if len(member_names) <= 20:
show = member_names
suffix = ""
else:
show = member_names[:10] + ["..."] + member_names[-5:]
suffix = f"(共 {len(member_names)} 个)"
parts = [f"`{_md_escape_cell(n)}`" if n != "..." else "..." for n in show]
lines.append(f"- 包含表:{', '.join(parts)}{suffix}")
lines.append("")
fields = table.get("fields") or {}
if not isinstance(fields, dict) or not fields:
lines.append("_无字段信息_\n")
continue
lines.append("| 字段 | 类型 | 含义 | 备注 |")
lines.append("|---|---|---|---|")
for field_name in sorted(fields.keys()):
fm = fields.get(field_name) or {}
if not isinstance(fm, dict):
fm = {}
f_type = fm.get("type", "")
meaning = fm.get("meaning", "")
notes = fm.get("notes", "")
lines.append(
f"| `{_md_escape_cell(field_name)}` | `{_md_escape_cell(f_type)}` | {_md_escape_cell(meaning)} | {_md_escape_cell(notes)} |"
)
lines.append("")
# Appendices
message_types = cfg.get("message_types") or {}
if isinstance(message_types, dict) and message_types:
mt = _render_message_type_map(message_types)
if mt:
lines.append(mt)
friend_types = cfg.get("friend_types") or {}
if isinstance(friend_types, dict) and friend_types:
# friend_types in config usually uses string keys
items: list[tuple[int, str]] = []
for k, v in friend_types.items():
if k in {"_instructions", "examples"}:
continue
try:
items.append((int(str(k)), str(v)))
except Exception:
continue
items.sort(key=lambda x: x[0])
if items:
lines.append("## 联系人类型(friend_type)速查\n")
lines.append("| 值 | 含义 |\n|---:|---|\n")
for code, desc in items:
lines.append(f"| {code} | {_md_escape_cell(desc)} |")
lines.append("")
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
def main() -> int:
parser = argparse.ArgumentParser(description="导出微信数据库字段配置为 Markdown 文档(单文件)")
parser.add_argument(
"--config",
default=str(ROOT / "wechat_db_config.json"),
help="wechat_db_config.json 路径(由 tools/generate_wechat_db_config.py 生成)",
)
parser.add_argument(
"--output",
default=str(ROOT / "docs" / "wechat_database_schema.md"),
help="Markdown 输出路径",
)
args = parser.parse_args()
cfg = Path(args.config)
if not cfg.exists():
raise FileNotFoundError(f"未找到配置文件: {cfg},请先运行 tools/generate_wechat_db_config.py")
out = Path(args.output)
export_markdown(cfg, out)
print(f"[OK] 写出 Markdown: {out}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+569 -11
View File
@@ -14,6 +14,7 @@ import json
import re
from pathlib import Path
from datetime import datetime
import sys
ROOT = Path(__file__).resolve().parents[1]
TEMPLATE_PATH = ROOT / "wechat_db_config_template.json"
@@ -21,6 +22,10 @@ OUTPUT_MAIN = ROOT / "wechat_db_config.json"
OUTPUT_DIR = ROOT / "output" / "configs"
OUTPUT_COPY = OUTPUT_DIR / "wechat_db_config.generated.json"
# 允许从 tools/ 目录运行时仍能 import 根目录模块
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
# 尝试导入分析器以复用其启发式
AnalyzerCls = None
try:
@@ -33,19 +38,24 @@ except Exception:
def build_db_descriptions() -> dict[str, str]:
return {
"message": "聊天记录核心数据库",
"message_3": "聊天消息分表数据库(示例或分片)",
# message_{n}.db 会在 fill_config 里按正则单独处理(分片/分表)
"message_fts": "聊天消息全文索引数据库(FTS",
"message_resource": "消息资源索引数据库(图片/文件/视频等)",
"contact": "联系人数据库(好友/群/公众号基础信息)",
"session": "会话数据库(会话列表与未读统计)",
"sns": "朋友圈数据库(动态与互动)",
"favorite": "收藏数据库",
"favorite_fts": "收藏全文索引数据库(FTS",
"emoticon": "表情包数据库",
"head_image": "头像数据数据库",
"hardlink": "硬链接索引数据库(资源去重/快速定位)",
"media_0": "媒体数据数据库(含语音SILK等)",
"unspportmsg": "不支持消息数据库(客户端不支持的消息类型)",
"general": "通用/系统数据库(新消息通知/支付等)",
"contact_fts": "联系人全文索引数据库(FTS",
"chat_search_index": "(本项目生成)聊天记录全文检索索引库(FTS5,用于搜索)",
"bizchat": "公众号/企业微信相关数据库(会话/联系人等)",
"digital_twin": "(本项目生成)数字分身数据库(派生数据,非微信原始库)",
}
@@ -172,6 +182,12 @@ KNOWN_FIELD_MEANINGS = {
"c4": "FTS列c4(内部结构)",
"c5": "FTS列c5(内部结构)",
"c6": "FTS列c6(内部结构)",
"c7": "FTS列c7(内部结构)",
"c8": "FTS列c8(内部结构)",
"c9": "FTS列c9(内部结构)",
"c10": "FTS列c10(内部结构)",
"c11": "FTS列c11(内部结构)",
"c12": "FTS列c12(内部结构)",
"sz": "FTS文档大小信息",
"_rowid_": "SQLite内部行ID",
@@ -199,12 +215,483 @@ KNOWN_FIELD_MEANINGS = {
"last_sender_display_name": "最后一条消息发送者显示名",
"last_msg_ext_type": "最后一条消息扩展类型",
# 常见“Key-Value”配置表(多库复用)
"key": "键(Key-Value配置表)",
"valueint64": "整数值(int64",
"valuedouble": "浮点值(double",
"valuestdstr": "字符串值(std::string",
"valueblob": "二进制值(blob",
"k": "配置键(k",
"v": "配置值(v",
# 常见保留字段
"reserved0": "保留字段(reserved0",
"reserved1": "保留字段(reserved1",
"reserved2": "保留字段(reserved2",
"reserved3": "保留字段(reserved3",
# 版本/位标志
"version": "版本号(记录/结构版本,具体含义依表而定)",
"bit_flag": "位标志/开关(bit flags",
# 本项目索引/缓存库常见字段
"render_type": "渲染类型(本项目定义:text/image/system/...",
"db_stem": "来源数据库分片名(如 message_0)",
"table_name": "来源表名(如 Msg_xxx",
"sender_username": "发送者username(解码后)",
"preview": "会话预览文本(用于会话列表展示)",
"built_at": "构建时间(Unix时间戳,秒)",
"tablename": "表名(tableName",
"value": "值(value",
"brand_user_name": "品牌/公众号usernamebrand_user_name",
# 常见业务字段(命名自解释)
"ticket": "票据/验证ticketticket",
"delete_table_name": "删除记录关联的消息表名(delete_table_name",
"res_path": "资源路径(res_path",
"biz_username": "公众号usernamebiz_username",
"search_key": "搜索键/索引字段(search_key",
"click_type": "点击/热词类型(click_type",
"a_group_remark": "群备注(FTS检索字段:a_group_remark",
"op_code": "操作码(op_code",
"query": "查询关键词(query",
"score": "评分/权重(score",
"keyword": "关键词(keyword",
"pay_load_": "payload/扩展数据(pay_load_",
"bill_no": "账单号(bill_no",
"session_title": "会话标题(session_title",
"unread_stat": "未读统计字段(unread_stat",
"ui_type": "UI类型/发布类型(ui_type",
"error_type": "错误类型(error_type",
"tips_content": "提示内容(tips_content",
"record_content": "记录内容(record_content",
"business_type": "业务类型(business_type",
"access_content_key": "访问内容keyaccess_content_key",
"access_content_type": "访问内容类型(access_content_type",
"range_type": "范围类型(range_type",
"message_local_type": "消息类型(message_local_type",
"message_origin_source": "消息来源标识(message_origin_source",
# 朋友圈(sns)常见拆分字段
"tid_heigh_bit": "tid 高位拆分字段(heigh_bit,字段名原样保留)",
"tid_low_bit": "tid 低位拆分字段(low_bit",
"break_flag": "断点/分页标志(0/1;用于分页/增量拉取水位)",
# WCDB 压缩控制
"WCDB_CT_message_content": "WCDB压缩标记(message_content列)",
"WCDB_CT_source": "WCDB压缩标记(source列)",
}
# 表级字段含义覆盖(优先级高于 KNOWN_FIELD_MEANINGS
# key: table_name.lower() ; value: { field_name.lower(): meaning }
KNOWN_FIELD_MEANINGS_BY_TABLE: dict[str, dict[str, str]] = {
# contact.db
"contact": {
"id": "序号(通常与 name2id.rowid 对应)",
"username": "联系人的 wxid / 群聊 username(可唯一确定联系人)",
"local_type": "联系人类型:1=通讯录好友/公众号/已添加群聊;2=未添加到通讯录的群聊;3=群中的陌生人;5=企业微信好友;6=群聊中的陌生企业微信好友",
"alias": "微信号(微信里显示的微信号)",
"flag": "联系人标志位(需转二进制;常见:第7位星标,第12位置顶,第17位屏蔽朋友圈,第24位仅聊天)",
"head_img_md5": "头像md5(可通过 head_image.db 查询对应头像)",
"verify_flag": "认证标志(公众号/企业等;非0常表示公众号)",
"description": "描述字段(样本为空;用途待确认)",
"extra_buffer": "好友扩展信息(protobuf;包含性别/地区/签名等,本项目解析 gender/signature/country/province/city/source_scene",
"chat_room_notify": "群消息通知相关设置(样本为0/1;疑似免打扰/通知开关,待确认)",
"is_in_chat_room": "群聊状态标记(样本为1/2;具体含义待确认)",
"chat_room_type": "群聊类型/标志(样本为0/2;具体含义待确认)",
},
"stranger": {
"id": "序号(通常与 name2id.rowid 对应)",
"username": "联系人的 wxid / 群聊 username",
"local_type": "联系人类型:1=通讯录好友/公众号/已添加群聊;2=未添加到通讯录的群聊;3=群中的陌生人;5=企业微信好友;6=群聊中的陌生企业微信好友",
"alias": "微信号(微信里显示的微信号)",
"flag": "联系人标志位(需转二进制;常见:第7位星标,第12位置顶,第17位屏蔽朋友圈,第24位仅聊天)",
"head_img_md5": "头像md5(可通过 head_image.db 查询对应头像)",
"verify_flag": "认证标志(公众号/企业等;非0常表示公众号)",
"description": "描述字段(样本为空;用途待确认)",
"extra_buffer": "好友扩展信息(protobuf;包含性别/地区/签名等,本项目解析 gender/signature/country/province/city/source_scene",
"chat_room_notify": "群消息通知相关设置(样本为0/1;疑似免打扰/通知开关,待确认)",
"is_in_chat_room": "群聊状态标记(样本为1/2;具体含义待确认)",
"chat_room_type": "群聊类型/标志(样本为0/2;具体含义待确认)",
},
"biz_info": {
"id": "序号(与 name2id.rowid 对应,可唯一确定一个公众号)",
"username": "公众号username(原始 wxid/gh_xxx",
"type": "公众号类型:1=公众号,0=订阅号(资料来源:万字长文)",
"accept_type": "接收类型(accept_type;含义待确认,样本常为0)",
"child_type": "子类型(child_type;含义待确认,样本常为0)",
"version": "版本号(含义待确认,样本常为0",
"external_info": "公众号详细信息(常见 JSON;含底部菜单/交互配置等)",
"brand_info": "公众号品牌/菜单信息(常见 JSON:urls 等)",
"brand_list": "品牌列表/关联列表(格式待确认,可能为 JSON)",
"brand_flag": "品牌/能力标志位(含义待确认)",
"belong": "归属字段(含义待确认)",
"home_url": "主页链接(含义待确认)",
},
"chat_room": {
"id": "序号(与 name2id.rowid 对应)",
"username": "群聊的usernamexxx@chatroom",
"owner": "群主username",
"ext_buffer": "群成员username与群昵称(protobufChatRoomData.members 等)",
},
"chat_room_info_detail": {
"room_id_": "序号(与 name2id.rowid 对应)",
"username_": "群聊的usernamexxx@chatroom",
"announcement_": "群公告(文本)",
"announcement_editor_": "群公告编辑者username",
"announcement_publish_time_": "群公告发布时间(时间戳)",
"chat_room_status_": "群状态/标志位(bitmask;样本常见 0x80000 等,具体位含义待确认)",
"xml_announcement_": "群公告(XML,可解析更多信息:图片/文件等)",
"ext_buffer_": "扩展信息(protobuf-like;样本长度较小,具体结构待确认)",
},
"chatroom_member": {
"room_id": "群聊ID(对应 name2id.rowid",
"member_id": "群成员ID(对应 name2id.rowid",
},
"contact_label": {
"label_id_": "标签ID",
"label_name_": "标签名称",
"sort_order_": "排序",
},
# message_*.db / biz_message_*.db
"msg_*": {
"local_id": "自增id(本地)",
"server_id": "服务端id(每条消息唯一)",
"local_type": "消息类型(local_type;低32位=type,高32位=sub_type;可用 (local_type & 0xFFFFFFFF) 与 (local_type >> 32) 拆分)",
"sort_seq": "排序字段(单会话内消息排序;样本≈create_time*1000",
"real_sender_id": "发送者id(可通过 Name2Id.rowid 映射到 username",
"create_time": "秒级时间戳",
"server_seq": "服务端接收顺序idserver_seq",
"message_content": "消息内容:local_type=1 时为文本,其它类型多为 Zstandard 压缩后的XML/二进制",
"compress_content": "压缩后的内容(多见 Zstandard)",
"packed_info_data": "protobuf扩展信息(图片文件名/语音转文字/合并转发文件夹名等)",
},
"name2id": {
"is_session": "是否会话名标记(1=会话/聊天对象;0=其它映射,如群成员ID)",
},
# session.db
"sessiontable": {
"type": "会话类型(样本为0;枚举待确认)",
"status": "会话状态(样本为0;枚举待确认)",
"unread_first_pat_msg_local_id": "未读拍一拍消息的本地ID(样本为0;含义待确认)",
"unread_first_pat_msg_sort_seq": "未读拍一拍消息的排序序号(样本为0;含义待确认)",
},
"session_last_message": {
"username": "会话username",
"sort_seq": "最后一条消息sort_seq",
"local_id": "最后一条消息local_id",
"create_time": "最后一条消息create_time(秒级时间戳)",
"local_type": "最后一条消息local_type",
"sender_username": "最后一条消息发送者username",
"preview": "最后一条消息预览文本(用于会话列表)",
"db_stem": "来源消息库分片名(如 message_0)",
"table_name": "来源消息表名(如 Msg_xxx",
"built_at": "构建时间(Unix时间戳,秒)",
},
# 本项目 chat_search_index.db
"message_fts": {
"text": "可检索文本(索引内容)",
"render_type": "渲染类型(text/system/image/voice/video/emoji/...,本项目定义)",
"db_stem": "来源消息库分片名(如 message_0)",
"table_name": "来源消息表名(如 Msg_xxx",
"sender_username": "发送者username(解码后)",
},
# emoticon.db
"knonstoreemoticontable": {
"type": "表情类型(样本均为3;枚举含义待确认)",
"caption": "表情说明/标题(caption",
"product_id": "表情包/产品IDproduct_id",
"aes_key": "AES密钥(用于CDN下载解密)",
"auth_key": "鉴权keyCDN下载)",
"extern_md5": "外部资源md5extern_md5",
},
"kstoreemoticonpackagetable": {
"package_id_": "表情包IDpackage_id",
"package_name_": "表情包名称",
"payment_status_": "支付状态(payment_status",
"download_status_": "下载状态(download_status",
"install_time_": "安装时间(时间戳)",
"remove_time_": "移除时间(时间戳)",
"sort_order_": "排序",
"introduction_": "简介(introduction",
"full_description_": "完整描述(full_description",
"copyright_": "版权信息",
"author_": "作者信息",
"store_icon_url_": "商店图标URL",
"panel_url_": "面板/详情页URL",
},
"kstoreemoticonfilestable": {
"package_id_": "表情包IDpackage_id",
"md5_": "表情md5",
"type_": "表情类型(type",
"sort_order_": "排序",
"emoticon_size_": "表情文件大小(字节)",
"emoticon_offset_": "表情文件偏移(用于包内定位)",
"thumb_size_": "缩略图大小(字节)",
"thumb_offset_": "缩略图偏移(用于包内定位)",
},
# favorite.db
"fav_db_item": {
"version": "版本号(收藏条目结构/内容版本;样本为87)",
"fromusr": "来源用户username(收藏来源)",
"realchatname": "来源群聊username(若收藏来源于群聊)",
"upload_error_code": "上传错误码",
"trans_res_error_code": "资源转换错误码(trans_res_error_code",
},
# general.db
"ilink_voip": {
"wx_chatroom_": "群聊usernamexxx@chatroom",
"millsecond_": "毫秒时间戳/时间标记(字段名推断)",
"group_id_": "ILink group_id(字段名推断)",
"room_id_": "房间ID(字段名推断)",
"room_key_": "房间key(字段名推断)",
"route_id_": "路由ID(字段名推断)",
"voice_status_": "通话状态(字段名推断)",
"talker_create_user_": "发起者username(字段名推断)",
"not_friend_user_list_": "非好友成员列表(字段名推断)",
"members_": "成员列表(字段名推断)",
"is_ilink_": "是否ilink通话(字段名推断)",
"ever_quit_chatroom_": "是否曾退出群聊(字段名推断)",
},
"fmessagetable": {
"user_name_": "用户名(好友验证/陌生人会话用户名)",
"type_": "消息类型(好友验证/系统消息;样本为37)",
"timestamp_": "时间戳",
"encrypt_user_name_": "加密用户名",
"content_": "内容(验证消息/系统提示等)",
"is_sender_": "是否发送方(is_sender",
"ticket_": "票据/验证ticket",
"scene_": "来源场景码(scene",
"fmessage_detail_buf_": "详细信息(protobuf-like;包含验证文案/来源等信息)",
},
"handoff_remind_v0": {
"item_id": "条目IDitem_id",
"head_icon": "图标(URL/资源标识)",
"title": "标题",
"desc_type": "描述类型(desc_type",
"create_time": "创建时间(时间戳)",
"start_time": "开始时间(时间戳)",
"expire_time": "过期时间(时间戳)",
"biz_type": "业务类型(biz_type",
"version": "版本号(version",
"url": "跳转URL",
"extra_info": "扩展信息(extra_info",
},
"transfertable": {
"transfer_id": "转账IDtransfer_id",
"transcation_id": "交易IDtransaction_id,原字段拼写保留)",
"message_server_id": "关联消息server_id",
"second_message_server_id": "关联第二条转账消息server_id(可在 message_*.db::Msg_* 表的 server_id 对应到)",
"session_name": "会话username",
"pay_sub_type": "支付子类型(pay_sub_type",
"pay_receiver": "收款方username",
"pay_payer": "付款方username",
"begin_transfer_time": "转账开始时间(时间戳)",
"last_modified_time": "最后修改时间(时间戳)",
"invalid_time": "失效时间(时间戳)",
"last_update_time": "最后更新时间(时间戳)",
"delay_confirm_flag": "延迟确认标志(delay_confirm_flag",
"bubble_clicked_flag": "气泡点击标志(bubble_clicked_flag",
},
# bizchat.db
"chat_group": {
"brand_user_name": "品牌/公众号usernamebrand_user_name",
"bit_flag": "位标志/开关(bit_flag",
"chat_name": "群组名称(chat_name",
"user_list": "成员列表(常见为 ; 分隔的 user_id/username 列表;待确认)",
"reserved0": "保留字段(reserved0",
"reserved1": "保留字段(reserved1",
"reserved2": "保留字段(reserved2",
"reserved3": "保留字段(reserved3",
},
"user_info": {
"brand_user_name": "品牌/公众号usernamebrand_user_name",
"bit_flag": "位标志/开关(bit_flag",
"reserved0": "保留字段(reserved0",
"reserved1": "保留字段(reserved1",
"reserved2": "保留字段(reserved2",
"reserved3": "保留字段(reserved3",
},
# sns.db
"snsmessage_tmp3": {
"from_username": "来源用户username(评论/点赞发起者)",
"from_nickname": "来源用户昵称(评论/点赞发起者)",
"to_username": "目标用户username(被回复/被@的人)",
"to_nickname": "目标用户昵称(被回复/被@的人)",
"comment_flag": "评论标志位(样本为0;具体 bit 含义待确认)",
},
"snsadtimeline": {
"ad_content": "广告内容(ad_content,格式待确认)",
"remind_source_info": "提醒来源信息(remind_source_info,格式待确认)",
"remind_self_info": "提醒自身信息(remind_self_info,格式待确认)",
"extra_data": "扩展数据(extra_data,格式待确认)",
},
# unspportmsg.db
"unsupportmessage": {
"from_user": "发送者username",
"to_user": "接收者username",
"msg_source": "消息来源附加信息(msg_source)",
},
# contact.db
"openim_wording": {
"wording": "文案/提示语(wording",
"pinyin": "拼音(pinyin",
},
# message_*.db / biz_message_*.db (WCDB)
"wcdb_builtin_compression_record": {
"tablename": "表名(tableName",
"columns": "被WCDB压缩的列列表(columns",
},
# general.db
"revokemessage": {
"to_user_name": "会话username(撤回消息所在会话)",
"message_type": "消息类型(local_type",
"at_user_list": "@用户列表(字段名推断)",
},
"wcfinderlivestatus": {
"finder_username": "视频号作者usernamefinder_username",
"charge_flag": "是否付费/收费标志(charge_flag",
},
"new_tips": {
"disable": "禁用标志(disable",
"new_tips_content": "提示内容(new_tips_content",
},
"redenvelopetable": {
"sender_user_name": "红包发送者username",
"hb_type": "红包类型(hb_type",
},
"wacontact": {
"external_info": "外部信息(JSON;常见包含 BindWxaInfo/RegisterSource/WxaAppDynamic 等)",
"contact_pack_data": "联系人打包数据(protobuf-like;常含昵称/品牌名等)",
"wx_app_opt": "小程序/应用选项(wx_app_opt;位标志/开关;样本为0)",
},
# emoticon.db
"kstoreemoticoncaptionstable": {
"package_id_": "表情包IDpackage_id",
"md5_": "表情md5",
"language_": "语言(language",
"caption_": "文案/标题(caption",
},
}
KNOWN_TABLE_DESCRIPTIONS: dict[str, str] = {
# contact.db
"biz_info": "公众号信息表(公众号类型/菜单/品牌信息等)",
"chat_room": "群聊基础信息表(群主/成员列表等扩展在 ext_buffer",
"chat_room_info_detail": "群聊详细信息表(群公告/群状态等)",
"chatroom_member": "群聊成员映射表(room_id ↔ member_id",
"contact": "联系人核心表(好友/群/公众号等基础信息)",
"contact_label": "联系人标签表(标签ID与名称)",
"name2id": "用户名(wxid/群id@chatroom 等)到内部数值ID映射表",
"encrypt_name2id": "加密用户名到内部数值ID映射表",
"stranger": "陌生人/临时会话信息表",
"ticket_info": "票据/会话票据信息表(用途待进一步确认)",
"stranger_ticket_info": "陌生人票据信息表(用途待进一步确认)",
"oplog": "操作/同步日志表(增量同步相关)",
"openim_appid": "OpenIM 应用ID表(企业微信/互通相关)",
"openim_acct_type": "OpenIM 账号类型表",
"openim_wording": "OpenIM 文案/提示语表",
# session.db
"sessiontable": "会话列表表(会话展示/未读/置顶/隐藏等)",
"sessiondeletetable": "会话删除记录表",
"sessionunreadlisttable_1": "未读会话列表表(分表)",
"sessionunreadstattable_1": "未读统计表(分表)",
"sessionnocontactinfotable": "会话表(无联系人信息的会话)",
"session_last_message": "会话最后一条消息缓存/索引表(版本/实现差异)",
# message_*.db / biz_message_*.db
"timestamp": "时间戳/增量同步辅助表",
"deleteinfo": "删除消息记录表(删除/撤回相关)",
"deleteresinfo": "删除资源记录表(资源删除相关)",
"sendinfo": "发送相关信息表(发送状态/队列等)",
"historysysmsginfo": "历史系统消息表",
"historyaddmsginfo": "历史新增消息表",
# message_resource.db
"chatname2id": "会话名 → 会话ID 映射表(资源库维度)",
"sendername2id": "发送者名 → 发送者ID 映射表(资源库维度)",
"messageresourceinfo": "消息资源索引表(按消息/会话定位资源)",
"messageresourcedetail": "消息资源明细表(md5/路径/大小等)",
"ftsrange": "FTS 范围信息表(搜索/索引辅助)",
"ftsdeleteinfo": "FTS 删除记录表(索引维护)",
# media_0.db
"voiceinfo": "语音数据表(voice_data 等)",
# hardlink.db
"db_info": "WCDB Key-Value 元信息表(FTS构建状态/版本/扫描时间等)",
"dir2id": "目录 → ID 映射表(硬链接索引)",
"image_hardlink_info_v4": "图片硬链接索引表(v4",
"file_hardlink_info_v4": "文件硬链接索引表(v4",
"video_hardlink_info_v4": "视频硬链接索引表(v4",
"file_checkpoint_v4": "文件索引检查点(增量)",
"video_checkpoint_v4": "视频索引检查点(增量)",
"talker_checkpoint_v4": "会话索引检查点(增量)",
# *_fts.db / message_fts.db
"table_info": "WCDB Key-Value 元信息表(索引范围/水位/时间戳等)",
# head_image.db
"head_image": "头像缓存表(头像 md5/二进制缩略图等)",
# favorite.db
"buff": "WCDB Key-Value 缓冲/配置表(收藏等模块的缓存)",
"fav_db_item": "收藏条目表",
"fav_tag_db_item": "收藏标签表",
"fav_bind_tag_db_item": "收藏条目与标签绑定表",
# emoticon.db
"kcustomemoticonordertable": "自定义表情排序表(md5 列表)",
"kexpressrecentuseeemoticontable": "最近使用表情记录(Key-Value",
"knonstoreemoticontable": "非商店表情表(用户收藏/外部表情资源;含CDN下载信息)",
"kstoreemoticonpackagetable": "商店表情包信息表(package 元数据)",
"kstoreemoticoncaptionstable": "商店表情文案表(多语言 caption)",
# unspportmsg.db
"unsupportmessage": "不支持消息表(PC端无法直接展示的消息类型)",
# bizchat.db
"chat_group": "BizChat 群组表(企业微信/公众号群组信息)",
"user_info": "BizChat 用户表(企业微信/公众号用户信息)",
"my_user_info": "BizChat 当前账号映射表(brand_user_name ↔ user_id",
# general.db
"forwardrecent": "最近转发会话记录表(username/时间)",
"transfertable": "转账记录表(转账ID/关联消息/状态等)",
"redenvelopetable": "红包记录表(关联消息/状态等)",
"ilink_voip": "iLink/群通话相关表(房间ID/成员/状态等)",
"fmessagetable": "好友验证/陌生人消息表(FMessage)",
"handoff_remind_v0": "跨设备接力/提醒项表(handoff_remind_v0",
"biz_pay_status": "公众号文章付费状态表(url_id/is_paid 等)",
"biz_subscribe_status": "公众号订阅模板状态表(template_id/is_subscribe",
"new_tips": "新提示/新功能提示表",
"reddot": "小红点提示表",
"reddot_record": "小红点记录表",
"wcfinderlivestatus": "视频号直播状态表",
"teenager_apply_access_agree_info": "青少年模式访问同意记录表",
# chat_search_index.db(本项目生成)
"meta": "索引元数据表(schema_version/构建时间等)",
"message_fts": "全文索引表(fts5,用于搜索)",
}
def simple_heuristic(field_name: str, table_name: str) -> str:
"""简易兜底启发式,避免完全空白"""
f = field_name.lower()
@@ -243,10 +730,17 @@ def simple_heuristic(field_name: str, table_name: str) -> str:
def compute_field_meaning(analyzer, table_name: str, field_name: str) -> str:
# 优先精确已知映射
lt = table_name.lower()
lf = field_name.lower()
# 1) 表级覆盖优先
tmap = KNOWN_FIELD_MEANINGS_BY_TABLE.get(lt)
if tmap and lf in tmap:
return tmap[lf]
# 2) 全局精确映射
if field_name in KNOWN_FIELD_MEANINGS:
return KNOWN_FIELD_MEANINGS[field_name]
lf = field_name.lower()
if lf in KNOWN_FIELD_MEANINGS:
return KNOWN_FIELD_MEANINGS[lf]
@@ -266,13 +760,44 @@ def compute_field_meaning(analyzer, table_name: str, field_name: str) -> str:
def guess_table_desc(analyzer, table_name: str) -> str:
# 简易猜测(优先命中已知表名)
tl = table_name.lower()
# 已知表名(大小写不敏感)
if tl in KNOWN_TABLE_DESCRIPTIONS:
return KNOWN_TABLE_DESCRIPTIONS[tl]
# SQLite / WCDB 内置
if tl == "sqlite_sequence":
return "SQLite 自增序列表"
if tl.startswith("wcdb"):
return "WCDB 内置表(压缩/元数据等)"
# FTS 内部表(多为 *_data/_idx/_config/_content/_docsize/_aux
if "fts" in tl:
if tl.endswith("_data"):
return "全文检索(FTS)内部数据表"
if tl.endswith("_idx"):
return "全文检索(FTS)内部索引表"
if tl.endswith("_config"):
return "全文检索(FTS)内部配置表"
if tl.endswith("_content"):
return "全文检索(FTS)内部内容表"
if tl.endswith("_docsize"):
return "全文检索(FTS)内部文档长度表"
if tl.endswith("_aux") or "_aux_" in tl:
return "全文检索(FTS)辅助表"
return "全文检索(FTS)表/索引表"
# 借助分析器的启发式(如果可用,且不是“未知功能表”)
if analyzer is not None:
try:
return analyzer.guess_table_function(table_name)
guessed = analyzer.guess_table_function(table_name)
if isinstance(guessed, str) and guessed.strip() and guessed.strip() != "未知功能表":
return guessed.strip()
except Exception:
pass
# 简易猜测
tl = table_name.lower()
if tl == "msg" or tl.startswith("msg_"):
return "某会话的消息表(聊天消息数据)"
if "name2id" in tl:
@@ -281,10 +806,18 @@ def guess_table_desc(analyzer, table_name: str) -> str:
return "联系人/群聊信息表"
if "session" in tl:
return "会话信息/未读统计表"
if "fts" in tl:
return "全文检索(FTS)内部表"
if "resource" in tl:
return "消息资源/附件索引表"
if "voice" in tl:
return "语音相关数据表"
if "image" in tl or "img" in tl:
return "图片相关数据表"
if "video" in tl:
return "视频相关数据表"
if "file" in tl:
return "文件相关数据表"
if "sns" in tl:
return "朋友圈相关数据表"
return "未知功能表"
@@ -301,13 +834,38 @@ def fill_config(template: dict) -> dict:
# 数据库描述补齐
db_desc_map = build_db_descriptions()
def guess_db_desc(db_name: str) -> str:
# 1) 精确映射优先
if db_name in db_desc_map:
return db_desc_map[db_name]
# 2) 常见分片/变体:message_{n}.db
m = re.match(r"^message_(\d+)$", db_name)
if m:
return f"聊天记录数据库分片(message_{m.group(1)}.db"
# 3) 公众号/企业微信消息库:biz_message_{n}.db(结构通常同 message_{n}.db
m = re.match(r"^biz_message_(\d+)$", db_name)
if m:
return f"公众号消息记录数据库(biz_message_{m.group(1)}.db,结构通常同 message_{m.group(1)}.db"
# 4) FTS/索引类库:*_fts.db
if db_name.endswith("_fts"):
return "全文索引数据库(FTS"
# 5) 退化到 base 前缀
base = db_name.split("_", 1)[0]
if base in db_desc_map:
return db_desc_map[base]
return "未知用途数据库"
databases = template.get("databases", {})
for db_name, db in databases.items():
if isinstance(db, dict):
# 数据库级描述
if not db.get("description"):
# 用已知映射或尝试推断
db["description"] = db_desc_map.get(db_name, db.get("description", "")) or "未知用途数据库"
db["description"] = guess_db_desc(db_name)
# 遍历表
tables = db.get("tables", {})
@@ -378,4 +936,4 @@ def main():
if __name__ == "__main__":
main()
main()
+122
View File
@@ -0,0 +1,122 @@
// Generate WeChat/WeFlow WxIsaac64 keystream via WeFlow's WASM module.
//
// Usage:
// node tools/weflow_wasm_keystream.js <key> <size>
//
// Prints a base64-encoded keystream to stdout (no extra logs).
const fs = require('fs')
const path = require('path')
const vm = require('vm')
function usageAndExit() {
process.stderr.write('Usage: node tools/weflow_wasm_keystream.js <key> <size>\\n')
process.exit(2)
}
const key = String(process.argv[2] || '').trim()
const size = Number(process.argv[3] || 0)
if (!key || !Number.isFinite(size) || size <= 0) usageAndExit()
const basePath = path.join(__dirname, '..', 'WeFlow', 'electron', 'assets', 'wasm')
const wasmPath = path.join(basePath, 'wasm_video_decode.wasm')
const jsPath = path.join(basePath, 'wasm_video_decode.js')
if (!fs.existsSync(wasmPath) || !fs.existsSync(jsPath)) {
process.stderr.write(`WeFlow WASM assets not found: ${basePath}\\n`)
process.exit(1)
}
const wasmBinary = fs.readFileSync(wasmPath)
const jsContent = fs.readFileSync(jsPath, 'utf8')
let capturedKeystream = null
let resolveInit
let rejectInit
const initPromise = new Promise((res, rej) => {
resolveInit = res
rejectInit = rej
})
const mockGlobal = {
console: { log: () => {}, error: () => {} }, // keep stdout clean
Buffer,
Uint8Array,
Int8Array,
Uint16Array,
Int16Array,
Uint32Array,
Int32Array,
Float32Array,
Float64Array,
BigInt64Array,
BigUint64Array,
Array,
Object,
Function,
String,
Number,
Boolean,
Error,
Promise,
require,
process,
setTimeout,
clearTimeout,
setInterval,
clearInterval,
}
mockGlobal.Module = {
onRuntimeInitialized: () => resolveInit(),
wasmBinary,
print: () => {},
printErr: () => {},
}
mockGlobal.self = mockGlobal
mockGlobal.self.location = { href: jsPath }
mockGlobal.WorkerGlobalScope = function () {}
mockGlobal.VTS_WASM_URL = `file://${wasmPath}`
mockGlobal.wasm_isaac_generate = (ptr, n) => {
const buf = new Uint8Array(mockGlobal.Module.HEAPU8.buffer, ptr, n)
capturedKeystream = new Uint8Array(buf) // copy view
}
try {
const context = vm.createContext(mockGlobal)
new vm.Script(jsContent, { filename: jsPath }).runInContext(context)
} catch (e) {
rejectInit(e)
}
;(async () => {
try {
await initPromise
if (!mockGlobal.Module.WxIsaac64 && mockGlobal.Module.asm && mockGlobal.Module.asm.WxIsaac64) {
mockGlobal.Module.WxIsaac64 = mockGlobal.Module.asm.WxIsaac64
}
if (!mockGlobal.Module.WxIsaac64) {
throw new Error('WxIsaac64 not found in WASM module')
}
capturedKeystream = null
const isaac = new mockGlobal.Module.WxIsaac64(key)
isaac.generate(size)
if (isaac.delete) isaac.delete()
if (!capturedKeystream) throw new Error('Failed to capture keystream')
const out = Buffer.from(capturedKeystream)
// Match WeFlow worker logic: reverse the captured Uint8Array.
out.reverse()
process.stdout.write(out.toString('base64'))
} catch (e) {
process.stderr.write(String(e && e.stack ? e.stack : e) + '\\n')
process.exit(1)
}
})()
Generated
+1 -1
View File
@@ -866,7 +866,7 @@ wheels = [
[[package]]
name = "wechat-decrypt-tool"
version = "0.2.1"
version = "1.3.0"
source = { editable = "." }
dependencies = [
{ name = "aiofiles" },