Compare commits

..

20 Commits

39 changed files with 15540 additions and 828 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)
+6 -1
View File
@@ -57,7 +57,12 @@ const contentClass = computed(() =>
: 'flex-1 overflow-auto min-h-0'
)
const showSidebar = computed(() => !String(route.path || '').startsWith('/wrapped'))
const showSidebar = computed(() => {
const path = String(route.path || '')
if (path === '/') return false
if (path === '/decrypt' || path === '/detection-result' || path === '/decrypt-result') return false
return !(path === '/wrapped' || path.startsWith('/wrapped/'))
})
</script>
<style>
+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;
@@ -0,0 +1,793 @@
<template>
<div ref="cardRoot" class="h-full w-full">
<WrappedCardShell :card-id="card.id" :title="card.title" :narrative="''" :variant="variant">
<template #narrative>
<div class="mt-1 wrapped-body text-sm sm:text-base text-[#6F6F6F] leading-relaxed">
<p class="whitespace-normal">
<template v-for="(seg, idx) in narrativeSegments" :key="`n-${idx}`">
<img
v-if="seg.type === 'emoji'"
:src="seg.src"
class="inline-block align-[-0.18em] rounded-[3px] wx-inline-emoji"
:style="{ width: `${seg.sizeEm}em`, height: `${seg.sizeEm}em` }"
:alt="seg.alt || 'emoji'"
/>
<img
v-else-if="seg.type === 'sticker'"
:src="seg.src"
class="inline-block align-[-0.16em] rounded-[4px] wx-inline-emoji"
:style="{ width: `${seg.sizeEm}em`, height: `${seg.sizeEm}em` }"
:alt="seg.alt || 'sticker'"
/>
<span
v-else-if="seg.type === 'num'"
class="wrapped-number text-[#07C160] font-semibold"
>
{{ seg.content }}
</span>
<span v-else>{{ seg.content }}</span>
</template>
</p>
</div>
</template>
<div class="w-full -mt-1 sm:-mt-2">
<div class="grid grid-cols-1 lg:grid-cols-12 gap-2">
<div class="lg:col-span-7 space-y-2 sm:space-y-2.5">
<div class="rounded-2xl border border-[#EDEDED] bg-white/65 p-2.5 sm:p-3">
<div class="wrapped-label text-xs text-[#00000066]">高频表情卡堆Vue Bits</div>
<div v-if="stackCardsData.length > 0" class="mt-2">
<div class="relative h-[9.4rem] sm:h-[9.8rem] rounded-xl overflow-visible">
<div class="absolute inset-0 flex items-center justify-center">
<Stack
:randomRotation="true"
:sensitivity="180"
:sendToBackOnClick="false"
:cardDimensions="stackCardDimensions"
:cardsData="stackCardsData"
/>
</div>
</div>
<div class="mt-2 flex flex-col items-center justify-center text-center">
<div class="wrapped-number text-base text-[#07C160] font-semibold leading-tight">
{{ formatInt(Number(stackTopCount || 0)) }}
</div>
<div
class="mt-0.5 inline-flex items-center gap-1.5 rounded-md bg-[#00000008] px-1.5 py-1 max-w-full"
:title="heroStickerOwnerName ? `常发送给 ${heroStickerOwnerName}` : '常发送给:未知'"
>
<span class="w-4 h-4 rounded-md overflow-hidden bg-[#0000000d] flex items-center justify-center flex-shrink-0">
<img
v-if="heroStickerOwnerAvatarUrl && avatarOk.topStickerOwner"
:src="heroStickerOwnerAvatarUrl"
class="w-full h-full object-cover"
alt="avatar"
@error="avatarOk.topStickerOwner = false"
/>
<span v-else class="wrapped-number text-[10px] text-[#00000066]">
{{ avatarFallback(heroStickerOwnerName) }}
</span>
</span>
<span class="wrapped-body text-[11px] text-[#00000080] truncate">
常发送给 <span class="text-[#07C160] font-semibold">{{ heroStickerOwnerName || '未知' }}</span>
</span>
</div>
<div class="mt-1 wrapped-label text-[10px] text-[#00000055]">拖动表情卡片翻一翻</div>
</div>
</div>
<div v-else class="mt-2 wrapped-body text-xs text-[#00000055]">
暂无可展示的高频表情图片
</div>
</div>
<div class="rounded-2xl border border-[#EDEDED] bg-white/65 p-2.5 sm:p-3">
<div class="flex items-center justify-between gap-3">
<div class="wrapped-label text-xs text-[#00000066]">Emoji Top小黄脸 + Unicode</div>
<div class="flex items-center gap-2 flex-shrink-0">
<span class="wrapped-label text-[10px] px-2 py-0.5 rounded-md border bg-[#07C160]/10 text-[#07C160] border-[#07C160]/25">
小黄脸
</span>
<span class="wrapped-label text-[10px] px-2 py-0.5 rounded-md border bg-[#0EA5E9]/10 text-[#0EA5E9] border-[#0EA5E9]/25">
Unicode
</span>
</div>
</div>
<div v-if="emojiBubbleRows.length > 0" class="mt-2">
<div class="grid grid-cols-4 sm:grid-cols-8 gap-2 sm:gap-2.5 place-items-end">
<div
v-for="row in emojiBubbleRows"
:key="row.id"
class="min-w-0 flex flex-col items-center gap-1"
:title="`${row.label} · ${formatInt(row.count)}次`"
>
<div
class="flex items-center justify-center rounded-full shadow-sm"
:class="row.kind === 'wechat' ? 'bg-[#07C160]/14' : 'bg-[#0EA5E9]/12'"
:style="{ width: `${row.size}px`, height: `${row.size}px` }"
>
<img
v-if="row.kind === 'wechat' && row.assetPath && emojiAssetOk[row.key] !== false"
:src="resolveEmojiAsset(row.assetPath)"
class="w-3/4 h-3/4 object-contain"
alt="emoji"
@error="emojiAssetOk[row.key] = false"
/>
<span v-else class="text-xl leading-none">
{{ row.kind === 'unicode' ? row.label : '🙂' }}
</span>
</div>
<div
class="wrapped-number text-[10px] font-semibold leading-none"
:class="row.kind === 'wechat' ? 'text-[#07C160]' : 'text-[#0EA5E9]'"
>
{{ formatInt(row.count) }}
</div>
</div>
</div>
</div>
<div v-else class="mt-2 wrapped-body text-xs text-[#00000066]">
今年没有统计到可识别的 Emoji小黄脸/Unicode
</div>
</div>
</div>
<div class="lg:col-span-5 h-full min-h-[20rem] sm:min-h-[21.5rem] rounded-2xl border border-[#EDEDED] bg-white/65 p-2.5 sm:p-3 relative overflow-hidden">
<div class="relative z-[1] h-full flex flex-col">
<div class="wrapped-label text-xs text-[#00000066]">斗图热力时段</div>
<div class="mt-1 wrapped-body text-sm text-[#000000e6]">
<template v-if="peakHour !== null && peakWeekdayName">
高峰在
<span class="wrapped-number text-[#07C160] font-semibold">{{ peakWeekdayName }} {{ peakHour }}:00</span>
</template>
<template v-else>
今年没有明显的斗图高峰时段
</template>
</div>
<div class="mt-2.5 h-16 sm:h-20 flex items-end gap-[2px]">
<div
v-for="item in hourBars"
:key="`hour-${item.hour}`"
class="flex-1 min-w-0 rounded-sm bg-[#07C160]/20"
:style="{ height: `${item.heightPct}%` }"
:title="`${item.hour}:00 · ${formatInt(item.count)}次`"
/>
</div>
<div class="mt-1.5 flex items-center justify-between wrapped-label text-[10px] text-[#00000055]">
<span>00</span>
<span>06</span>
<span>12</span>
<span>18</span>
<span>23</span>
</div>
<div class="mt-3 rounded-xl border border-[#EDEDED] bg-white/60 p-2.5 flex-1 flex flex-col">
<div class="wrapped-label text-[11px] text-[#00000066]">表情新鲜度</div>
<div class="mt-2 grid grid-cols-1 gap-2.5">
<div class="rounded-lg bg-[#07C160]/10 px-3 py-2.5 flex items-center justify-between gap-3">
<div class="min-w-0">
<div class="wrapped-label text-[11px] text-[#00000066]">年度新解锁</div>
<div class="mt-1.5 wrapped-number text-lg text-[#07C160] font-semibold leading-tight">
{{ formatInt(newStickerCountThisYear) }}
</div>
<div class="wrapped-label text-[11px] text-[#00000055]">
占类型 {{ newStickerSharePct }}%
</div>
</div>
<div v-if="newStickerDecorDisplayItems.length > 0" class="grid grid-cols-3 gap-2 flex-shrink-0">
<div
v-for="item in newStickerDecorDisplayItems"
:key="`new-chip-${item.id}`"
class="w-11 h-11 sm:w-12 sm:h-12 rounded-[10px] overflow-hidden ring-1 ring-[#00000014] bg-white/60"
>
<img
v-if="item.src && stickerDecorOk[item.id] !== false"
:src="item.src"
class="w-full h-full object-cover"
alt=""
@error="stickerDecorOk[item.id] = false"
/>
</div>
</div>
</div>
<div class="rounded-lg bg-[#0EA5E9]/10 px-3 py-2.5 flex items-center justify-between gap-3">
<div class="min-w-0">
<div class="wrapped-label text-[11px] text-[#00000066]">回温表情</div>
<div class="mt-1.5 wrapped-number text-lg text-[#0EA5E9] font-semibold leading-tight">
{{ formatInt(revivedStickerCount) }}
</div>
<div class="wrapped-label text-[11px] text-[#00000055] leading-tight">
间隔{{ formatInt(revivedMinGapDays) }}最长 {{ formatInt(revivedMaxGapDays) }}
</div>
</div>
<div v-if="revivedStickerDecorDisplayItems.length > 0" class="grid grid-cols-3 gap-2 flex-shrink-0">
<div
v-for="item in revivedStickerDecorDisplayItems"
:key="`rev-chip-${item.id}`"
class="w-11 h-11 sm:w-12 sm:h-12 rounded-[10px] overflow-hidden ring-1 ring-[#00000014] bg-white/60"
>
<img
v-if="item.src && stickerDecorOk[item.id] !== false"
:src="item.src"
class="w-full h-full object-cover"
alt=""
@error="stickerDecorOk[item.id] = false"
/>
</div>
</div>
</div>
</div>
<div class="mt-2 wrapped-body text-[11px] text-[#00000066]">
今年共用过 <span class="wrapped-number text-[#07C160] font-semibold">{{ formatInt(uniqueStickerTypeCount) }}</span> 种表情
回温占比 <span class="wrapped-number text-[#0EA5E9] font-semibold">{{ revivedStickerSharePct }}</span>%
</div>
</div>
</div>
</div>
</div>
</div>
</WrappedCardShell>
<Teleport to="body">
<div v-if="cursorFxEnabled && isCardVisible && cursorTrails.length > 0" class="emoji-cursor-layer" aria-hidden="true">
<img
v-for="item in cursorTrails"
:key="item.id"
class="emoji-cursor-item"
:style="{
left: `${item.x}px`,
top: `${item.y}px`,
width: `${item.size}px`,
height: `${item.size}px`,
'--drift-x': `${item.driftX}px`,
'--drift-y': `${item.driftY}px`
}"
:src="item.src"
alt=""
draggable="false"
/>
</div>
</Teleport>
</div>
</template>
<script setup>
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import Stack from '~/components/wrapped/shared/VueBitsStack.vue'
import WechatEmojiTable, { parseTextWithEmoji } from '~/utils/wechat-emojis'
const props = defineProps({
card: { type: Object, required: true },
variant: { type: String, default: 'panel' } // 'panel' | 'slide'
})
const nfInt = new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 0 })
const formatInt = (n) => nfInt.format(Math.round(Number(n) || 0))
const mediaBase = process.client ? 'http://localhost:8000' : ''
const resolveMediaUrl = (value, opts = { backend: false }) => {
const raw = String(value || '').trim()
if (!raw) return ''
if (/^https?:\/\//i.test(raw)) {
try {
const host = new URL(raw).hostname.toLowerCase()
if (host.endsWith('.qpic.cn') || host.endsWith('.qlogo.cn')) {
return `${mediaBase}/api/chat/media/proxy_image?url=${encodeURIComponent(raw)}`
}
} catch {}
return raw
}
if (opts.backend || raw.startsWith('/api/')) {
return `${mediaBase}${raw.startsWith('/') ? '' : '/'}${raw}`
}
return raw.startsWith('/') ? raw : `/${raw}`
}
const resolveEmojiAsset = (value) => {
const raw = String(value || '').trim()
if (!raw) return ''
if (/^https?:\/\//i.test(raw)) return raw
if (raw.startsWith('/wxemoji/')) return raw
if (raw.startsWith('/')) return raw
return `/wxemoji/${raw}`
}
const resolveStickerUrl = (st) => {
const remote = resolveMediaUrl(st?.emojiUrl, { backend: true })
if (remote) return remote
const asset = resolveEmojiAsset(st?.emojiAssetPath)
return asset || ''
}
const splitTextByNumbers = (text) => {
const raw = String(text || '')
if (!raw) return []
const parts = raw.split(/(\d[\d,.]*)/g)
const out = []
for (const p of parts) {
if (!p) continue
if (/^\d[\d,.]*$/.test(p)) out.push({ type: 'num', content: p })
else out.push({ type: 'text', content: p })
}
return out
}
const appendParsedText = (segments, text) => {
const parsed = parseTextWithEmoji(String(text || ''))
for (const seg of parsed) {
if (seg?.type === 'emoji' && seg.emojiSrc) {
segments.push({
type: 'emoji',
src: resolveEmojiAsset(seg.emojiSrc),
alt: seg.content || 'emoji',
sizeEm: 1.12
})
continue
}
const inner = splitTextByNumbers(seg?.content || '')
for (const x of inner) segments.push(x)
}
}
const sentStickerCount = computed(() => Number(props.card?.data?.sentStickerCount || 0))
const stickerActiveDays = computed(() => Number(props.card?.data?.stickerActiveDays || 0))
const peakHour = computed(() => {
const h = props.card?.data?.peakHour
return Number.isFinite(Number(h)) ? Number(h) : null
})
const peakWeekdayName = computed(() => String(props.card?.data?.peakWeekdayName || '').trim())
const stickerPerActiveDayText = computed(() => {
const v = Number(props.card?.data?.stickerPerActiveDay || 0)
return Number.isFinite(v) ? v.toFixed(1) : '0.0'
})
const uniqueStickerTypeCount = computed(() => Number(props.card?.data?.uniqueStickerTypeCount || 0))
const newStickerCountThisYear = computed(() => Number(props.card?.data?.newStickerCountThisYear || 0))
const revivedStickerCount = computed(() => Number(props.card?.data?.revivedStickerCount || 0))
const revivedMinGapDays = computed(() => Number(props.card?.data?.revivedMinGapDays || 60))
const revivedMaxGapDays = computed(() => Number(props.card?.data?.revivedMaxGapDays || 0))
const newStickerSharePct = computed(() => {
const v = Number(props.card?.data?.newStickerShare)
if (Number.isFinite(v) && v >= 0) return Math.max(0, Math.min(100, Math.round(v * 100)))
const total = Math.max(0, Number(uniqueStickerTypeCount.value || 0))
if (total <= 0) return 0
return Math.max(0, Math.min(100, Math.round((Number(newStickerCountThisYear.value || 0) / total) * 100)))
})
const revivedStickerSharePct = computed(() => {
const v = Number(props.card?.data?.revivedStickerShare)
if (Number.isFinite(v) && v >= 0) return Math.max(0, Math.min(100, Math.round(v * 100)))
const total = Math.max(0, Number(uniqueStickerTypeCount.value || 0))
if (total <= 0) return 0
return Math.max(0, Math.min(100, Math.round((Number(revivedStickerCount.value || 0) / total) * 100)))
})
const newStickerSamples = computed(() => {
const arr = props.card?.data?.newStickerSamples
return Array.isArray(arr) ? arr : []
})
const revivedStickerSamples = computed(() => {
const arr = props.card?.data?.revivedStickerSamples
return Array.isArray(arr) ? arr : []
})
const buildDecorStickerItems = (rows, prefix) => {
const out = []
const seen = new Set()
for (let idx = 0; idx < rows.length; idx += 1) {
const st = rows[idx] || {}
const rawId = String(st?.md5 || st?.emojiAssetPath || st?.emojiUrl || `${prefix}-${idx}`).trim()
if (!rawId || seen.has(rawId)) continue
seen.add(rawId)
out.push({
id: `${prefix}-${rawId}`,
src: resolveStickerUrl(st),
count: Math.max(0, Number(st?.count || 0)),
gapDays: Math.max(0, Number(st?.gapDays || 0))
})
if (out.length >= 4) break
}
return out
}
const topStickers = computed(() => {
const arr = props.card?.data?.topStickers
return Array.isArray(arr) ? arr : []
})
const newStickerDecorItems = computed(() => buildDecorStickerItems(newStickerSamples.value, 'new'))
const revivedStickerDecorItems = computed(() => buildDecorStickerItems(revivedStickerSamples.value, 'revived'))
const topStickerDecorItems = computed(() => buildDecorStickerItems(topStickers.value, 'top'))
const toRenderableDecor = (items) => items.filter((x) => String(x?.src || '').trim())
const decorFallbackPool = computed(() => {
const out = []
const seen = new Set()
for (const item of [
...toRenderableDecor(newStickerDecorItems.value),
...toRenderableDecor(revivedStickerDecorItems.value),
...toRenderableDecor(topStickerDecorItems.value)
]) {
const key = String(item?.src || '').trim()
if (!key || seen.has(key)) continue
seen.add(key)
out.push(item)
if (out.length >= 6) break
}
return out
})
const newStickerDecorDisplayItems = computed(() => {
const own = toRenderableDecor(newStickerDecorItems.value).slice(0, 3)
if (own.length > 0) return own
return decorFallbackPool.value.slice(0, 3)
})
const revivedStickerDecorDisplayItems = computed(() => {
const own = toRenderableDecor(revivedStickerDecorItems.value).slice(0, 3)
if (own.length > 0) return own
const fallback = decorFallbackPool.value.slice(3, 6)
return fallback.length > 0 ? fallback : decorFallbackPool.value.slice(0, 3)
})
const heroSticker = computed(() => {
const arr = topStickers.value
return Array.isArray(arr) && arr.length > 0 ? arr[0] : null
})
const heroStickerUrl = computed(() => resolveStickerUrl(heroSticker.value))
const heroStickerOwnerName = computed(() => String(heroSticker.value?.sampleDisplayName || heroSticker.value?.sampleUsername || '').trim())
const heroStickerOwnerAvatarUrl = computed(() => resolveMediaUrl(heroSticker.value?.sampleAvatarUrl, { backend: true }))
const stackCardDimensions = { width: 140, height: 140 }
const stackCardsData = computed(() => {
const seen = new Set()
const out = []
for (const st of topStickers.value) {
const key = String(st?.md5 || st?.emojiAssetPath || st?.emojiUrl || '').trim()
if (!key || seen.has(key)) continue
const src = resolveStickerUrl(st)
if (!src) continue
seen.add(key)
out.push({ id: key, img: src })
if (out.length >= 4) break
}
if (out.length > 0) return out
return allWechatEmojiAssets.value.slice(0, 4).map((img, idx) => ({ id: `wx-${idx}`, img }))
})
const stackTopCount = computed(() => Number(heroSticker.value?.count || 0))
const hourBars = computed(() => {
const raw = Array.isArray(props.card?.data?.stickerHourCounts) ? props.card.data.stickerHourCounts : []
const counts = Array.from({ length: 24 }, (_, i) => Math.max(0, Number(raw[i] || 0)))
const maxV = Math.max(1, ...counts)
return counts.map((count, hour) => ({
hour,
count,
heightPct: Math.max(8, Math.round((count / maxV) * 100))
}))
})
const topTextEmojis = computed(() => {
const arr = props.card?.data?.topTextEmojis
return Array.isArray(arr) ? arr : []
})
const topWechatEmojis = computed(() => {
const arr = props.card?.data?.topWechatEmojis
return Array.isArray(arr) ? arr : []
})
const topUnicodeEmojis = computed(() => {
const arr = props.card?.data?.topUnicodeEmojis
return Array.isArray(arr) ? arr : []
})
const smallWechatEmojiChips = computed(() => {
if (topWechatEmojis.value.length > 0) {
return topWechatEmojis.value.map((x) => ({
key: String(x?.key || ''),
count: Number(x?.count || 0),
assetPath: String(x?.assetPath || '')
}))
}
return topTextEmojis.value.map((x) => ({
key: String(x?.key || ''),
count: Number(x?.count || 0),
assetPath: String(x?.assetPath || '')
}))
})
const emojiAssetOk = reactive({})
watch(
smallWechatEmojiChips,
(arr) => {
for (const k of Object.keys(emojiAssetOk)) delete emojiAssetOk[k]
if (!Array.isArray(arr)) return
for (const em of arr) {
const key = String(em?.key || '').trim()
if (key) emojiAssetOk[key] = true
}
},
{ immediate: true }
)
const emojiChartRows = computed(() => {
const wechat = smallWechatEmojiChips.value
.slice(0, 4)
.map((x, idx) => ({
id: `w-${String(x?.key || idx)}`,
kind: 'wechat',
key: String(x?.key || idx),
label: String(x?.key || '').trim() || '微信表情',
count: Math.max(0, Number(x?.count || 0)),
assetPath: String(x?.assetPath || '').trim()
}))
const unicode = topUnicodeEmojis.value
.slice(0, 4)
.map((x, idx) => ({
id: `u-${String(x?.emoji || idx)}`,
kind: 'unicode',
key: String(x?.emoji || idx),
label: String(x?.emoji || '').trim() || '😀',
count: Math.max(0, Number(x?.count || 0)),
assetPath: ''
}))
const rows = [...wechat, ...unicode].filter((x) => x.count > 0 && x.label)
const maxV = Math.max(1, ...rows.map((x) => x.count))
return rows
.sort((a, b) => b.count - a.count)
.map((x) => ({
...x,
pct: Math.max(8, Math.round((x.count / maxV) * 100))
}))
})
const emojiBubbleRows = computed(() => {
const rows = emojiChartRows.value
if (!rows.length) return []
const maxV = Math.max(1, ...rows.map((x) => x.count))
return rows.map((x) => {
const ratio = Math.max(0, Math.min(1, x.count / maxV))
const size = Math.round(32 + Math.sqrt(ratio) * 36)
return { ...x, size: Math.max(28, Math.min(72, size)) }
})
})
const stickerDecorOk = reactive({})
watch(
() => [...newStickerDecorItems.value, ...revivedStickerDecorItems.value],
(arr) => {
for (const k of Object.keys(stickerDecorOk)) delete stickerDecorOk[k]
if (!Array.isArray(arr)) return
for (const x of arr) {
const key = String(x?.id || '').trim()
if (key) stickerDecorOk[key] = true
}
},
{ immediate: true }
)
const avatarOk = reactive({ topStickerOwner: true })
watch(heroStickerOwnerAvatarUrl, () => { avatarOk.topStickerOwner = true })
const avatarFallback = (name) => {
const s = String(name || '').trim()
return s ? s[0] : '?'
}
const narrativeSegments = computed(() => {
const out = []
if (sentStickerCount.value > 0) {
appendParsedText(
out,
`这一年,你用 ${formatInt(sentStickerCount.value)} 张表情包把聊天变得更有温度;在 ${formatInt(
stickerActiveDays.value
)} 个活跃日里,日均 ${stickerPerActiveDayText.value} 张。`
)
} else {
appendParsedText(out, '这一年你几乎没发过表情包。')
}
if (peakHour.value !== null && peakWeekdayName.value) {
appendParsedText(out, `你最活跃的时刻是 ${peakWeekdayName.value} ${peakHour.value}:00。`)
}
let hasTail = false
if (heroSticker.value) {
appendParsedText(out, '年度 C 位表情是 ')
if (heroStickerUrl.value) {
out.push({ type: 'sticker', src: heroStickerUrl.value, alt: '年度 C 位表情', sizeEm: 1.22 })
} else {
appendParsedText(out, '(图片缺失)')
}
appendParsedText(out, `${formatInt(Number(heroSticker.value?.count || 0))} 次)`)
hasTail = true
}
const topWechat = topWechatEmojis.value[0]
const topText = topTextEmojis.value[0]
if (topWechat) {
appendParsedText(out, `${hasTail ? '' : ''}你最常用的小黄脸是 `)
if (topWechat.assetPath) {
out.push({
type: 'emoji',
src: resolveEmojiAsset(topWechat.assetPath),
alt: topWechat.key || 'emoji',
sizeEm: 1.16
})
} else {
appendParsedText(out, topWechat.key || '')
}
appendParsedText(out, `${formatInt(Number(topWechat.count || 0))} 次)`)
hasTail = true
} else if (topText) {
appendParsedText(out, `${hasTail ? '' : ''}在文字聊天里,你最常打的小黄脸是 `)
if (topText.assetPath) {
out.push({
type: 'emoji',
src: resolveEmojiAsset(topText.assetPath),
alt: topText.key || 'emoji',
sizeEm: 1.16
})
} else {
appendParsedText(out, topText.key || '')
}
appendParsedText(out, `${formatInt(Number(topText.count || 0))} 次)`)
hasTail = true
} else {
appendParsedText(out, `${hasTail ? '' : ''}今年没有命中可识别的小黄脸`)
hasTail = true
}
const topUnicode = topUnicodeEmojis.value[0]
if (topUnicode) {
appendParsedText(
out,
`${hasTail ? '' : ''}普通 Emoji 最常用 ${topUnicode.emoji}${formatInt(Number(topUnicode.count || 0))} 次)。`
)
} else if (hasTail) {
appendParsedText(out, '。')
}
return out
})
const cardRoot = ref(null)
const isCardVisible = ref(false)
const cursorTrails = ref([])
const cursorFxEnabled = ref(true)
const allWechatEmojiAssets = computed(() => {
const values = Object.values(WechatEmojiTable || {})
const uniq = new Set()
for (const x of values) {
const p = resolveEmojiAsset(String(x || '').trim())
if (p) uniq.add(p)
}
return Array.from(uniq)
})
let trailSeq = 0
let lastSpawnAt = 0
let lastSpawnX = -9999
let lastSpawnY = -9999
const TRAIL_LIFETIME_MS = 780
const MIN_SPAWN_INTERVAL_MS = 28
const MIN_SPAWN_DISTANCE = 10
const MAX_TRAIL_COUNT = 32
const checkCardVisible = () => {
if (!process.client) return false
const rect = cardRoot.value?.getBoundingClientRect?.()
if (!rect) return false
const vh = window.innerHeight || 0
const vw = window.innerWidth || 0
return rect.bottom > vh * 0.12 && rect.top < vh * 0.88 && rect.right > 0 && rect.left < vw
}
const spawnCursorTrail = (x, y) => {
const pool = allWechatEmojiAssets.value
if (!pool.length) return
const src = pool[Math.floor(Math.random() * pool.length)]
const item = {
id: ++trailSeq,
x,
y,
src,
size: 18 + Math.floor(Math.random() * 8),
driftX: Math.round((Math.random() - 0.5) * 30),
driftY: 20 + Math.floor(Math.random() * 20)
}
cursorTrails.value = [...cursorTrails.value.slice(-MAX_TRAIL_COUNT), item]
setTimeout(() => {
cursorTrails.value = cursorTrails.value.filter((t) => t.id !== item.id)
}, TRAIL_LIFETIME_MS)
}
const onWindowPointerMove = (e) => {
if (!process.client || !cursorFxEnabled.value) return
if (e?.pointerType === 'touch') return
const visible = checkCardVisible()
isCardVisible.value = visible
if (!visible) return
const x = Number(e.clientX)
const y = Number(e.clientY)
if (!Number.isFinite(x) || !Number.isFinite(y)) return
const now = performance.now()
const dx = x - lastSpawnX
const dy = y - lastSpawnY
const dist = Math.hypot(dx, dy)
if ((now - lastSpawnAt) < MIN_SPAWN_INTERVAL_MS && dist < MIN_SPAWN_DISTANCE) return
lastSpawnAt = now
lastSpawnX = x
lastSpawnY = y
spawnCursorTrail(x, y)
}
const onWindowPointerLeave = () => {
lastSpawnX = -9999
lastSpawnY = -9999
}
onMounted(() => {
if (!process.client) return
try {
const mq = window.matchMedia('(prefers-reduced-motion: reduce)')
cursorFxEnabled.value = !mq.matches
} catch {
cursorFxEnabled.value = true
}
isCardVisible.value = checkCardVisible()
window.addEventListener('pointermove', onWindowPointerMove, { passive: true })
window.addEventListener('pointerleave', onWindowPointerLeave, { passive: true })
})
onBeforeUnmount(() => {
if (process.client) {
window.removeEventListener('pointermove', onWindowPointerMove)
window.removeEventListener('pointerleave', onWindowPointerLeave)
}
cursorTrails.value = []
})
</script>
<style scoped>
.wx-inline-emoji {
object-fit: contain;
}
.emoji-cursor-layer {
position: fixed;
inset: 0;
pointer-events: none;
overflow: hidden;
z-index: 80;
}
.emoji-cursor-item {
position: fixed;
pointer-events: none;
user-select: none;
transform: translate(-50%, -50%);
opacity: 0.94;
animation: emoji-cursor-float 780ms ease-out forwards;
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.18));
}
@keyframes emoji-cursor-float {
0% {
opacity: 0.94;
transform: translate(-50%, -50%) scale(0.88);
}
100% {
opacity: 0;
transform: translate(calc(-50% + var(--drift-x)), calc(-50% - var(--drift-y))) scale(1.16);
}
}
</style>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,294 @@
<template>
<div class="vb-stack relative select-none" :style="stackStyle">
<div
v-for="(card, index) in cards"
:key="String(card.id)"
class="vb-stack-card absolute top-1/2 left-1/2 overflow-hidden rounded-2xl bg-white/80 shadow-sm"
:style="{
width: `${dims.width}px`,
height: `${dims.height}px`,
touchAction: index === cards.length - 1 ? 'none' : 'auto',
pointerEvents: index === cards.length - 1 ? 'auto' : 'none'
}"
:ref="(el) => onCardRef(card.id, el)"
@pointerdown="(e) => onPointerDown(e, card.id, index)"
@pointermove="onPointerMove"
@pointerup="onPointerUp"
@pointercancel="onPointerCancel"
>
<img :src="card.img" class="w-full h-full object-cover" alt="" draggable="false" />
</div>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { gsap } from 'gsap'
type StackCard = {
id: string | number
img: string
}
const props = withDefaults(
defineProps<{
randomRotation?: boolean
sensitivity?: number
sendToBackOnClick?: boolean
cardDimensions?: { width?: number; height?: number }
cardsData?: StackCard[]
}>(),
{
randomRotation: false,
sensitivity: 180,
sendToBackOnClick: true,
cardDimensions: () => ({ width: 200, height: 200 }),
cardsData: () => []
}
)
const dims = computed(() => {
const w = Number(props.cardDimensions?.width)
const h = Number(props.cardDimensions?.height)
return {
width: Number.isFinite(w) && w > 0 ? Math.floor(w) : 200,
height: Number.isFinite(h) && h > 0 ? Math.floor(h) : 200
}
})
const stackStyle = computed(() => ({
width: `${dims.value.width}px`,
height: `${dims.value.height}px`
}))
const cards = ref<StackCard[]>([])
const elMap = new Map<string, HTMLDivElement>()
const rotationMap = ref(new Map<string, number>())
const mounted = ref(false)
const ROT_RANGE_DEG = 7
const STACK_OFFSET_X = 2
const STACK_OFFSET_Y = 4
const STACK_SCALE_STEP = 0.03
const MAX_TILT_DEG = 22
function normalizeCards(data: unknown): StackCard[] {
const raw = Array.isArray(data) ? data : []
const out: StackCard[] = []
for (const item of raw) {
const id = (item as any)?.id
const img = String((item as any)?.img || '').trim()
if (id === null || id === undefined) continue
if (!img) continue
out.push({ id, img })
}
return out
}
function ensureRotations(list: StackCard[]) {
const next = new Map(rotationMap.value)
if (!props.randomRotation) {
for (const c of list) next.set(String(c.id), 0)
rotationMap.value = next
return
}
for (const c of list) {
const key = String(c.id)
if (next.has(key)) continue
next.set(key, Math.round((Math.random() * 2 - 1) * ROT_RANGE_DEG))
}
rotationMap.value = next
}
function onCardRef(id: StackCard['id'], el: Element | null) {
const key = String(id)
if (el && el instanceof HTMLDivElement) elMap.set(key, el)
else elMap.delete(key)
}
function applyLayout(animate: boolean) {
if (!mounted.value) return
const total = cards.value.length
if (total === 0) return
cards.value.forEach((card, idx) => {
const key = String(card.id)
const el = elMap.get(key)
if (!el) return
const orderFromTop = total - 1 - idx
const x = orderFromTop * STACK_OFFSET_X
const y = orderFromTop * STACK_OFFSET_Y
const scale = Math.max(0.88, 1 - orderFromTop * STACK_SCALE_STEP)
const rotation = rotationMap.value.get(key) ?? 0
const tweenVars: gsap.TweenVars = {
x,
y,
rotation,
rotationX: 0,
rotationY: 0,
scale,
xPercent: -50,
yPercent: -50,
zIndex: idx + 1,
transformOrigin: 'center center',
ease: 'power3.out',
duration: animate ? 0.32 : 0
}
gsap.killTweensOf(el)
if (animate) gsap.to(el, tweenVars)
else gsap.set(el, tweenVars)
})
}
watch(
() => props.cardsData,
(val) => {
const nextCards = normalizeCards(val)
cards.value = nextCards
ensureRotations(nextCards)
nextTick(() => applyLayout(false))
},
{ immediate: true }
)
watch(
() => [props.randomRotation, props.cardDimensions?.width, props.cardDimensions?.height] as const,
() => {
ensureRotations(cards.value)
nextTick(() => applyLayout(false))
}
)
onMounted(() => {
mounted.value = true
applyLayout(false)
})
const clamp = (v: number, min: number, max: number) => Math.min(max, Math.max(min, v))
let activePointerId: number | null = null
let activeCardId: string | null = null
let startClientX = 0
let startClientY = 0
let startX = 0
let startY = 0
let startRotationZ = 0
let startScale = 1
let lastDx = 0
let lastDy = 0
function sendToBack(id: string) {
if (cards.value.length < 2) return
const list = cards.value.slice()
const idx = list.findIndex((c) => String(c.id) === id)
if (idx < 0) return
const [card] = list.splice(idx, 1)
if (!card) return
cards.value = [card, ...list]
}
function onPointerDown(e: PointerEvent, id: StackCard['id'], index: number) {
if (activePointerId !== null) return
if (index !== cards.value.length - 1) return
const key = String(id)
const el = elMap.get(key)
if (!el) return
activePointerId = e.pointerId
activeCardId = key
startClientX = e.clientX
startClientY = e.clientY
startX = Number(gsap.getProperty(el, 'x')) || 0
startY = Number(gsap.getProperty(el, 'y')) || 0
startRotationZ = Number(gsap.getProperty(el, 'rotation')) || 0
startScale = Number(gsap.getProperty(el, 'scale')) || 1
lastDx = 0
lastDy = 0
try {
el.setPointerCapture(e.pointerId)
} catch {}
gsap.killTweensOf(el)
gsap.set(el, { zIndex: 999 })
gsap.to(el, { scale: startScale * 1.03, duration: 0.12, ease: 'power2.out' })
}
function onPointerMove(e: PointerEvent) {
if (activePointerId === null || e.pointerId !== activePointerId) return
const key = activeCardId
if (!key) return
const el = elMap.get(key)
if (!el) return
const dx = e.clientX - startClientX
const dy = e.clientY - startClientY
lastDx = dx
lastDy = dy
const w = Math.max(1, dims.value.width)
const h = Math.max(1, dims.value.height)
const nx = dx / (w * 0.55)
const ny = dy / (h * 0.55)
const tiltY = clamp(Math.tanh(nx) * MAX_TILT_DEG, -MAX_TILT_DEG, MAX_TILT_DEG)
const tiltX = clamp(-Math.tanh(ny) * MAX_TILT_DEG, -MAX_TILT_DEG, MAX_TILT_DEG)
gsap.set(el, {
x: startX + dx,
y: startY + dy,
rotation: startRotationZ + dx / 18,
rotationX: tiltX,
rotationY: tiltY
})
}
function finishPointer(id: number, shouldSendBack: boolean) {
const key = activeCardId
activePointerId = null
activeCardId = null
const el = key ? elMap.get(key) : null
if (el) {
try {
el.releasePointerCapture(id)
} catch {}
}
if (shouldSendBack && key) sendToBack(key)
applyLayout(true)
}
function onPointerUp(e: PointerEvent) {
if (activePointerId === null || e.pointerId !== activePointerId) return
const dist = Math.hypot(lastDx, lastDy)
const clickLike = dist < 6
const shouldSendBack = dist > Number(props.sensitivity || 0) || (props.sendToBackOnClick && clickLike)
finishPointer(e.pointerId, shouldSendBack)
}
function onPointerCancel(e: PointerEvent) {
if (activePointerId === null || e.pointerId !== activePointerId) return
finishPointer(e.pointerId, false)
}
</script>
<style scoped>
.vb-stack {
perspective: 1000px;
}
.vb-stack-card {
will-change: transform;
transform-style: preserve-3d;
backface-visibility: hidden;
cursor: grab;
}
.vb-stack-card:active {
cursor: grabbing;
}
</style>
@@ -329,6 +329,10 @@ const PREVIEW_BY_KIND = {
'chat/reply_speed': {
summary: '回复速度',
question: '谁是你愿意秒回的那个人?'
},
'emoji/annual_universe': {
summary: '梗图年鉴',
question: '你这一年最常丢出的表情包是哪张?'
}
}
+46
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()
@@ -294,6 +334,8 @@ export const useApi = () => {
media_kinds: Array.isArray(data.media_kinds) ? data.media_kinds : ['image', 'emoji', 'video', 'video_thumb', 'voice', 'file'],
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
}
@@ -407,6 +449,10 @@ export const useApi = () => {
buildChatSearchIndex,
listChatSearchSenders,
getChatMessagesAround,
getChatMessageDailyCounts,
getChatMessageAnchor,
resolveNestedChatHistory,
resolveAppMsg,
listSnsTimeline,
listSnsMediaCandidates,
saveSnsMediaPicks,
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)
}
+272 -358
View File
@@ -2,25 +2,45 @@
<div class="h-screen flex overflow-hidden" style="background-color: #EDEDED">
<!-- 右侧朋友圈区域 -->
<div class="flex-1 flex flex-col min-h-0" style="background-color: #EDEDED">
<div class="flex-1 overflow-auto min-h-0">
<div class="flex-1 overflow-auto min-h-0 bg-white" @scroll="onScroll">
<div class="max-w-2xl mx-auto px-4 py-4">
<div v-if="error" class="text-sm text-red-500 whitespace-pre-wrap py-2">{{ error }}</div>
<div v-else-if="isLoading && posts.length === 0" class="text-sm text-gray-500 py-2">加载中</div>
<div v-else-if="posts.length === 0" class="text-sm text-gray-500 py-2">暂无朋友圈数据</div>
<div class="relative w-full mb-12 -mt-4 bg-white">
<div class="h-64 w-full bg-[#333333] relative overflow-hidden">
<img
v-if="coverData && coverData.media && coverData.media.length > 0"
:src="getSnsMediaUrl(coverData, coverData.media[0], 0, coverData.media[0].url)"
class="w-full h-full object-cover"
alt="朋友圈封面"
/>
</div>
<div class="absolute right-4 -bottom-6 flex items-end gap-4">
<div class="text-white font-bold text-xl mb-7 drop-shadow-md">
{{ selfInfo.nickname || '获取中...' }}
</div>
<div class="w-[72px] h-[72px] rounded-lg bg-white p-[2px] shadow-sm">
<img
v-if="selfInfo.wxid"
:src="postAvatarUrl(selfInfo.wxid)"
class="w-full h-full rounded-md object-cover bg-gray-100"
:alt="selfInfo.nickname"
referrerpolicy="no-referrer"
/>
<div v-else class="w-full h-full rounded-md bg-gray-300 flex items-center justify-center text-gray-500 text-xs">
...
</div>
</div>
</div>
</div>
<div v-if="error" class="text-sm text-red-500 whitespace-pre-wrap py-4 text-center">{{ error }}</div>
<div v-else-if="isLoading && posts.length === 0" class="flex flex-col items-center justify-center py-16">
<div class="w-8 h-8 border-[3px] border-gray-200 border-t-[#576b95] rounded-full animate-spin"></div>
<div class="mt-4 text-sm text-gray-400">正在前往朋友圈...</div>
</div>
<div v-else-if="posts.length === 0" class="text-sm text-gray-400 py-16 text-center">暂无朋友圈数据</div>
<!-- 图片匹配提示实验功能 -->
<div v-if="!error" class="mb-3 rounded border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900">
<div class="font-medium">图片匹配实验功能</div>
<div class="mt-1 leading-5">
图片可能会出现错配或无法显示点击图片进入预览可在候选匹配中手动选择你的选择会保存在本机并在下次优先使用
</div>
<label class="mt-2 flex items-start gap-2 select-none">
<input v-model="snsAvoidOtherPicked" type="checkbox" class="mt-[2px]" />
<span class="leading-5">
自动匹配时避开已被你手动指定到其他动态的图片降低重复
</span>
</label>
</div>
<div v-for="post in posts" :key="post.id" class="bg-white rounded-sm px-4 py-4 mb-3">
<div class="flex items-start gap-3" @contextmenu.prevent="openPostContextMenu($event, post)">
@@ -47,31 +67,95 @@
</div>
<div
v-if="post.contentDesc"
class="mt-1 text-sm text-gray-900 leading-6 whitespace-pre-wrap break-words"
:class="{ 'privacy-blur': privacyMode }"
v-if="post.contentDesc"
class="mt-1 text-sm text-gray-900 leading-6 whitespace-pre-wrap break-words"
:class="{ 'privacy-blur': privacyMode }"
>
{{ post.contentDesc }}
</div>
<div v-if="post.media && post.media.length > 0" class="mt-2" :class="{ 'privacy-blur': privacyMode }">
<div v-if="post.type === 3" class="mt-2 max-w-[360px]" :class="{ 'privacy-blur': privacyMode }">
<a :href="post.contentUrl" target="_blank" class="block bg-gray-100 p-2 rounded-sm border border-gray-200 no-underline hover:bg-gray-200 transition-colors">
<div class="flex items-center gap-3">
<img
v-if="post.contentUrl && !hasArticleThumbError(post.id)"
:src="getArticleThumbProxyUrl(post.contentUrl)"
class="w-12 h-12 object-cover flex-shrink-0 bg-white"
alt=""
@error="onArticleThumbError(post.id)"
/>
<div v-else class="w-12 h-12 flex items-center justify-center bg-gray-200 text-gray-400 flex-shrink-0 text-xs">
文章
</div>
<div class="flex-1 flex flex-col justify-between overflow-hidden h-12">
<div class="text-[13px] text-gray-900 leading-tight line-clamp-2">{{ post.title }}</div>
</div>
</div>
<div class="text-[11px] text-[#576b95] mt-1 pt-1 border-t border-gray-200/50">
公众号文章分享
</div>
</a>
</div>
<div v-else-if="post.type === 28 && post.finderFeed && Object.keys(post.finderFeed).length > 0" class="mt-2 max-w-[360px]" :class="{ 'privacy-blur': privacyMode }">
<div class="block bg-gray-100 p-2 rounded-sm border border-gray-200 no-underline hover:bg-gray-200 transition-colors">
<!-- 浏览器没有看微信视频号的环境暂时不进行跳转-->
<div class="flex items-start gap-3">
<div class="relative w-14 h-16 flex-shrink-0 bg-black overflow-hidden rounded-sm">
<img
v-if="post.finderFeed.thumbUrl"
:src="getProxyExternalUrl(post.finderFeed.thumbUrl)"
class="w-full h-full object-cover opacity-80"
alt="finder cover"
/>
<div class="absolute inset-0 flex items-center justify-center pointer-events-none">
<svg class="w-5 h-5 text-white/90" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
</div>
</div>
<div class="flex-1 flex flex-col overflow-hidden">
<div class="text-xs text-gray-500 truncate">{{ post.finderFeed.nickname }}</div>
<div class="text-[13px] text-gray-900 leading-tight line-clamp-2 mt-[2px]">{{ post.finderFeed.desc || post.title }}</div>
</div>
</div>
<div class="text-[11px] text-[#576b95] mt-1 pt-1 border-t border-gray-200/50">
视频号 · 动态
</div>
</div>
</div>
<div v-else-if="post.media && post.media.length > 0" class="mt-2" :class="{ 'privacy-blur': privacyMode }">
<div v-if="post.media.length === 1" class="max-w-[360px]">
<div
v-if="!hasMediaError(post.id, 0) && getMediaThumbSrc(post, post.media[0], 0)"
class="inline-block cursor-pointer relative"
@click.stop="onMediaClick(post, post.media[0], 0)"
v-if="!hasMediaError(post.id, 0) && getMediaThumbSrc(post, post.media[0], 0)"
class="inline-block cursor-pointer relative"
@click.stop="onMediaClick(post, post.media[0], 0)"
>
<video
v-if="Number(post.media[0]?.type || 0) === 6"
:src="getSnsVideoUrl(post.id, post.media[0].id)"
:poster="getMediaThumbSrc(post, post.media[0], 0)"
class="rounded-sm max-h-[360px] max-w-full object-cover"
autoplay
loop
muted
playsinline
@loadeddata="onLocalVideoLoaded(post.id, post.media[0].id)"
@error="onLocalVideoError(post.id, post.media[0].id)"
></video>
<img
:src="getMediaThumbSrc(post, post.media[0], 0)"
class="rounded-sm max-h-[360px] object-cover"
alt=""
loading="lazy"
referrerpolicy="no-referrer"
@error="onMediaError(post.id, 0)"
v-else
:src="getMediaThumbSrc(post, post.media[0], 0)"
class="rounded-sm max-h-[360px] object-cover"
alt=""
loading="lazy"
referrerpolicy="no-referrer"
@error="onMediaError(post.id, 0)"
/>
<div
v-if="Number(post.media[0]?.type || 0) === 6"
class="absolute inset-0 flex items-center justify-center pointer-events-none"
v-if="Number(post.media[0]?.type || 0) === 6 && !isLocalVideoLoaded(post.id, post.media[0].id)"
class="absolute inset-0 flex items-center justify-center pointer-events-none"
>
<div class="w-12 h-12 rounded-full bg-black/45 flex items-center justify-center">
<svg class="w-6 h-6 text-white" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
@@ -79,11 +163,11 @@
</div>
</div>
<div
v-else
class="w-[240px] h-[180px] rounded-sm bg-gray-100 border border-gray-200 flex items-center justify-center text-xs text-gray-400"
title="图片加载失败"
@click.stop="onMediaClick(post, post.media[0], 0)"
style="cursor: pointer;"
v-else
class="w-[240px] h-[180px] rounded-sm bg-gray-100 border border-gray-200 flex items-center justify-center text-xs text-gray-400"
title="图片加载失败"
@click.stop="onMediaClick(post, post.media[0], 0)"
style="cursor: pointer;"
>
图片加载失败
</div>
@@ -91,24 +175,39 @@
<div v-else class="grid grid-cols-3 gap-1 max-w-[360px]">
<div
v-for="(m, idx) in post.media.slice(0, 9)"
:key="idx"
class="w-[116px] h-[116px] rounded-[2px] overflow-hidden bg-gray-100 border border-gray-200 flex items-center justify-center cursor-pointer relative"
@click.stop="onMediaClick(post, m, idx)"
v-for="(m, idx) in post.media.slice(0, 9)"
:key="idx"
class="w-[116px] h-[116px] rounded-[2px] overflow-hidden bg-gray-100 border border-gray-200 flex items-center justify-center cursor-pointer relative"
@click.stop="onMediaClick(post, m, idx)"
>
<video
v-if="!hasMediaError(post.id, idx) && Number(m?.type || 0) === 6"
:src="getSnsVideoUrl(post.id, m.id)"
:poster="getMediaThumbSrc(post, m, idx)"
class="w-full h-full object-cover"
autoplay
loop
muted
playsinline
@loadeddata="onLocalVideoLoaded(post.id, m.id)"
@error="onLocalVideoError(post.id, m.id)"
></video>
<img
v-if="!hasMediaError(post.id, idx) && getMediaThumbSrc(post, m, idx)"
:src="getMediaThumbSrc(post, m, idx)"
class="w-full h-full object-cover"
alt=""
loading="lazy"
referrerpolicy="no-referrer"
@error="onMediaError(post.id, idx)"
v-else-if="!hasMediaError(post.id, idx) && getMediaThumbSrc(post, m, idx)"
:src="getMediaThumbSrc(post, m, idx)"
class="w-full h-full object-cover"
alt=""
loading="lazy"
referrerpolicy="no-referrer"
@error="onMediaError(post.id, idx)"
/>
<!-- 不知道微信朋友圈可不可以发多视频先这样写吧-->
<span v-else class="text-[10px] text-gray-400">图片失败</span>
<!-- 视频缩略图的播放提示 -->
<div v-if="Number(m?.type || 0) === 6" class="absolute inset-0 flex items-center justify-center pointer-events-none">
<div
v-if="Number(m?.type || 0) === 6 && !isLocalVideoLoaded(post.id, m.id)"
class="absolute inset-0 flex items-center justify-center pointer-events-none"
>
<div class="w-10 h-10 rounded-full bg-black/45 flex items-center justify-center">
<svg class="w-5 h-5 text-white" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
</div>
@@ -171,16 +270,12 @@
</div>
</div>
<div v-if="hasMore" class="py-2">
<button
type="button"
class="w-full text-sm text-gray-600 py-2 rounded bg-white hover:bg-gray-50 border border-gray-200"
:disabled="isLoading"
@click="loadPosts({ reset: false })"
>
{{ isLoading ? '加载中…' : '加载更多' }}
</button>
</div>
<div v-if="isLoading && posts.length > 0" class="py-4 flex justify-center items-center">
<div class="w-5 h-5 border-2 border-gray-400 border-t-transparent rounded-full animate-spin"></div>
</div>
<div v-if="!hasMore && posts.length > 0" class="py-6 text-center text-xs text-gray-400">
到底了
</div>
</div>
</div>
</div>
@@ -209,68 +304,6 @@
<div class="relative max-w-[92vw] max-h-[92vh] flex flex-col items-center" @click.stop>
<img :src="previewSrc" alt="预览" class="max-w-[90vw] max-h-[70vh] object-contain" />
<!-- 候选匹配面板仅在本地缓存匹配时有意义 -->
<div class="mt-3 w-full max-w-[90vw] rounded bg-black/35 text-white text-xs px-3 py-2">
<div class="flex items-center justify-between gap-2">
<div class="truncate">
候选匹配
<span v-if="previewCandidates.loading">加载中</span>
<span v-else-if="previewCandidates.count > 0"> {{ previewCandidates.count }} </span>
<span v-else>未找到本地候选可能仅能显示占位图</span>
<span v-if="previewEffectiveIdx != null" class="ml-2 text-white/80">当前#{{ Number(previewEffectiveIdx) + 1 }}</span>
<span v-if="previewHasUserOverride" class="ml-2 text-emerald-200">(已保存)</span>
</div>
<div class="flex items-center gap-2 flex-shrink-0">
<button
type="button"
class="px-2 py-1 rounded bg-white/10 hover:bg-white/20 transition-colors"
@click="toggleCandidatePanel"
>
{{ previewCandidatesOpen ? '收起' : '展开' }}
</button>
<button
v-if="previewHasUserOverride"
type="button"
class="px-2 py-1 rounded bg-white/10 hover:bg-white/20 transition-colors"
@click="clearUserOverrideForPreview"
>
恢复自动
</button>
</div>
</div>
<div v-if="previewCandidates.error" class="mt-2 text-red-200 whitespace-pre-wrap">
{{ previewCandidates.error }}
</div>
<div v-if="previewCandidatesOpen && previewCandidates.count > 0" class="mt-2">
<div class="flex gap-2 overflow-x-auto pb-1">
<button
v-for="cand in previewCandidates.items"
:key="cand.idx"
type="button"
class="flex-shrink-0 w-24"
@click="selectCandidateForPreview(cand.idx)"
>
<div class="w-24 h-24 rounded bg-black/20 overflow-hidden border border-white/10">
<img :src="getPreviewCandidateSrc(cand.idx)" class="w-full h-full object-cover" alt="" />
</div>
<div class="mt-1 text-[11px] text-white/80">#{{ Number(cand.idx) + 1 }}</div>
</button>
</div>
<div v-if="previewCandidates.hasMore" class="mt-2">
<button
type="button"
class="px-2 py-1 rounded bg-white/10 hover:bg-white/20 transition-colors"
:disabled="previewCandidates.loadingMore"
@click="loadMorePreviewCandidates"
>
{{ previewCandidates.loadingMore ? '加载中…' : '加载更多候选' }}
</button>
</div>
</div>
</div>
</div>
<button
@@ -295,7 +328,7 @@ useHead({ title: '朋友圈 - 微信数据分析助手' })
const api = useApi()
const chatAccounts = useChatAccountsStore()
const { selectedAccount, accounts: availableAccounts } = storeToRefs(chatAccounts)
const { selectedAccount } = storeToRefs(chatAccounts)
const privacyStore = usePrivacyStore()
const { privacyMode } = storeToRefs(privacyStore)
@@ -305,114 +338,12 @@ const hasMore = ref(true)
const isLoading = ref(false)
const error = ref('')
const coverData = ref(null)
const pageSize = 20
const mediaBase = process.client ? 'http://localhost:8000' : ''
// User overrides for SNS image matching (account-local, stored in localStorage).
const SNS_MEDIA_OVERRIDE_PREFIX = 'sns_media_override:v1:'
const SNS_MEDIA_OVERRIDE_REV_PREFIX = 'sns_media_override_rev:v1:'
const snsMediaOverrides = ref({})
const snsMediaOverrideRev = ref('0')
const snsOverrideStorageKey = (account) => `${SNS_MEDIA_OVERRIDE_PREFIX}${String(account || '').trim()}`
const snsOverrideRevStorageKey = (account) => `${SNS_MEDIA_OVERRIDE_REV_PREFIX}${String(account || '').trim()}`
const snsOverrideMediaKey = (postId, idx) => `${String(postId || '')}:${String(Number(idx) || 0)}`
const loadSnsMediaOverrides = () => {
if (!process.client) return
const acc = String(selectedAccount.value || '').trim()
if (!acc) {
snsMediaOverrides.value = {}
snsMediaOverrideRev.value = '0'
return
}
try {
const raw = localStorage.getItem(snsOverrideStorageKey(acc))
const parsed = raw ? JSON.parse(raw) : {}
snsMediaOverrides.value = parsed && typeof parsed === 'object' ? parsed : {}
} catch {
snsMediaOverrides.value = {}
}
try {
const rev = localStorage.getItem(snsOverrideRevStorageKey(acc))
snsMediaOverrideRev.value = String(rev || '0')
} catch {
snsMediaOverrideRev.value = '0'
}
}
const saveSnsMediaOverrides = () => {
if (!process.client) return
const acc = String(selectedAccount.value || '').trim()
if (!acc) return
try {
localStorage.setItem(snsOverrideStorageKey(acc), JSON.stringify(snsMediaOverrides.value || {}))
} catch {}
try {
localStorage.setItem(snsOverrideRevStorageKey(acc), String(snsMediaOverrideRev.value || '0'))
} catch {}
}
// Settings: avoid auto-using an image that was manually pinned to another SNS post.
const SNS_SNS_SETTINGS_PREFIX = 'sns_settings:v1:'
const snsAvoidOtherPicked = ref(true)
const snsAvoidOtherPickedStorageKey = (account) => `${SNS_SNS_SETTINGS_PREFIX}${String(account || '').trim()}:avoid_other_picked`
const loadSnsSettings = () => {
if (!process.client) return
const acc = String(selectedAccount.value || '').trim()
if (!acc) return
try {
const raw = localStorage.getItem(snsAvoidOtherPickedStorageKey(acc))
if (raw == null || raw === '') return
snsAvoidOtherPicked.value = raw === '1' || raw === 'true'
} catch {}
}
const saveSnsSettings = () => {
if (!process.client) return
const acc = String(selectedAccount.value || '').trim()
if (!acc) return
try {
localStorage.setItem(snsAvoidOtherPickedStorageKey(acc), snsAvoidOtherPicked.value ? '1' : '0')
} catch {}
}
const syncSnsMediaPicksToBackend = async () => {
const acc = String(selectedAccount.value || '').trim()
if (!acc) return
try {
await api.saveSnsMediaPicks({ account: acc, picks: snsMediaOverrides.value || {} })
} catch {}
}
const getSnsMediaOverridePick = (postId, idx) => {
const key = snsOverrideMediaKey(postId, idx)
const v = snsMediaOverrides.value?.[key]
return String(v || '').trim()
}
const setSnsMediaOverridePick = (postId, idx, pick) => {
if (!process.client) return
const key = snsOverrideMediaKey(postId, idx)
const v = String(pick || '').trim()
if (!v) {
if (snsMediaOverrides.value && Object.prototype.hasOwnProperty.call(snsMediaOverrides.value, key)) {
delete snsMediaOverrides.value[key]
}
} else {
snsMediaOverrides.value[key] = v
}
saveSnsMediaOverrides()
// Keep backend in sync so it can apply duplicate-avoidance logic.
// Then bump `pv` so other auto-matched images reload using the updated picks.
void syncSnsMediaPicksToBackend().finally(() => {
snsMediaOverrideRev.value = String(Date.now())
saveSnsMediaOverrides()
})
}
// Track failed images per-post, per-index to render placeholders instead of broken <img>.
const mediaErrors = ref({})
@@ -422,6 +353,34 @@ const onMediaError = (postId, idx) => {
mediaErrors.value[mediaErrorKey(postId, idx)] = true
}
const articleThumbErrors = ref({})
const hasArticleThumbError = (postId) => !!articleThumbErrors.value[postId]
const onArticleThumbError = (postId) => {
articleThumbErrors.value[postId] = true
}
const selfInfo = ref({ wxid: '', nickname: '' })
const loadSelfInfo = async () => {
if (!selectedAccount.value) return
try {
const resp = await $fetch(`${mediaBase}/api/sns/self_info?account=${encodeURIComponent(selectedAccount.value)}`)
if (resp && resp.wxid) {
selfInfo.value = resp
}
} catch (e) {
console.error('获取个人信息失败', e)
}
}
const getArticleThumbProxyUrl = (contentUrl) => {
const u = String(contentUrl || '').trim()
if (!u) return ''
return `${mediaBase}/api/sns/article_thumb?url=${encodeURIComponent(u)}`
}
// Right-click context menu (copy text / JSON) to help debug SNS parsing issues.
const contextMenu = ref({ visible: false, x: 0, y: 0, post: null })
@@ -506,6 +465,15 @@ const onCopyPostJsonClick = async () => {
}
}
const onScroll = (e) => {
const { scrollTop, clientHeight, scrollHeight } = e.target
if (scrollTop + clientHeight >= scrollHeight - 200) {
if (hasMore.value && !isLoading.value) {
loadPosts({ reset: false })
}
}
}
const postAvatarUrl = (username) => {
const acc = String(selectedAccount.value || '').trim()
const u = String(username || '').trim()
@@ -596,7 +564,7 @@ const getSnsMediaUrl = (post, m, idx, rawUrl) => {
const h = String(m?.size?.height || m?.size?.h || '').trim()
const ts = String(m?.size?.totalSize || m?.size?.total_size || m?.size?.total || '').trim()
const sizeIdx = mediaSizeGroupIndex(post, m, idx)
const pick = getSnsMediaOverridePick(post?.id, idx)
// const pick = getSnsMediaOverridePick(post?.id, idx)
let md5 = normalizeHex32(m?.urlAttrs?.md5 || m?.thumbAttrs?.md5 || m?.urlAttrs?.MD5 || m?.thumbAttrs?.MD5)
if (!md5) {
const match = /[?&]md5=([0-9a-fA-F]{16,32})/.exec(raw)
@@ -609,13 +577,19 @@ const getSnsMediaUrl = (post, m, idx, rawUrl) => {
if (h) parts.set('height', h)
if (/^\d+$/.test(ts)) parts.set('total_size', ts)
parts.set('idx', String(Number(sizeIdx) || 0))
if (pick) parts.set('pick', pick)
if (!pick && snsAvoidOtherPicked.value) {
const pid = String(post?.id || '').trim()
if (pid) parts.set('post_id', pid)
parts.set('avoid_picked', '1')
parts.set('pv', String(snsMediaOverrideRev.value || '0'))
}
const pid = String(post?.id || '').trim()
if (pid) parts.set('post_id', pid)
const mid = String(m?.id || '').trim()
if (mid) parts.set('media_id', mid)
const postType = String(post?.type || '1').trim()
if (postType) parts.set('post_type', postType)
const mediaType = String(m?.type || '2').trim()
if (mediaType) parts.set('media_type', mediaType)
if (md5) parts.set('md5', md5)
// Bump this when changing backend matching logic to avoid stale cached wrong images.
parts.set('v', '7')
@@ -636,6 +610,31 @@ const getMediaPreviewSrc = (post, m, idx = 0) => {
return getSnsMediaUrl(post, m, idx, m?.url || m?.thumb)
}
const getSnsVideoUrl = (postId, mediaId) => {
// 本地缓存视频
const acc = String(selectedAccount.value || '').trim()
if (!acc || !postId || !mediaId) return ''
return `${mediaBase}/api/sns/video?account=${encodeURIComponent(acc)}&post_id=${encodeURIComponent(postId)}&media_id=${encodeURIComponent(mediaId)}`
}
const localVideoStatus = ref({})
const videoStatusKey = (postId, mediaId) => `${String(postId)}:${String(mediaId)}`
const onLocalVideoLoaded = (postId, mediaId) => {
localVideoStatus.value[videoStatusKey(postId, mediaId)] = 'loaded'
}
const onLocalVideoError = (postId, mediaId) => {
localVideoStatus.value[videoStatusKey(postId, mediaId)] = 'error'
}
const isLocalVideoLoaded = (postId, mediaId) => {
return localVideoStatus.value[videoStatusKey(postId, mediaId)] === 'loaded'
}
// 图片预览 + 候选匹配选择
const previewCtx = ref(null) // { post, media, idx }
const previewCandidatesOpen = ref(false)
@@ -663,52 +662,6 @@ const previewSrc = computed(() => {
return getMediaPreviewSrc(ctx.post, ctx.media, ctx.idx)
})
const previewHasUserOverride = computed(() => {
const ctx = previewCtx.value
if (!ctx) return false
return !!getSnsMediaOverridePick(ctx.post?.id, ctx.idx)
})
const previewEffectiveIdx = computed(() => {
const ctx = previewCtx.value
if (!ctx) return null
const pick = getSnsMediaOverridePick(ctx.post?.id, ctx.idx)
if (pick) {
const found = (previewCandidates.items || []).find((c) => String(c?.key || '') === pick)
if (found) return Number(found.idx)
return null
}
const baseIdx = mediaSizeGroupIndex(ctx.post, ctx.media, ctx.idx)
if (!snsAvoidOtherPicked.value) return baseIdx
const curPid = String(ctx.post?.id || '').trim()
if (!curPid) return baseIdx
// Mirror backend logic: skip candidates that were manually pinned to other posts.
const reserved = new Set()
try {
for (const [k, v] of Object.entries(snsMediaOverrides.value || {})) {
const pid = String(k || '').split(':', 1)[0].trim()
if (!pid || pid === curPid) continue
const key = String(v || '').trim()
if (key) reserved.add(key)
}
} catch {}
const items = Array.isArray(previewCandidates.items) ? [...previewCandidates.items] : []
items.sort((a, b) => Number(a?.idx || 0) - Number(b?.idx || 0))
for (const c of items) {
const i = Number(c?.idx)
const key = String(c?.key || '').trim()
if (!Number.isFinite(i) || i < baseIdx) continue
if (!key) continue
if (!reserved.has(key)) return i
}
return baseIdx
})
const toggleCandidatePanel = () => {
previewCandidatesOpen.value = !previewCandidatesOpen.value
}
const loadPreviewCandidates = async ({ reset }) => {
const ctx = previewCtx.value
@@ -780,64 +733,26 @@ const closeImagePreview = () => {
document.body.style.overflow = ''
}
const getPreviewCandidateSrc = (candIdx) => {
const ctx = previewCtx.value
const acc = String(selectedAccount.value || '').trim()
if (!ctx || !acc) return ''
const idxNum = Number(candIdx)
const cand = (previewCandidates.items || []).find((c) => Number(c?.idx) === idxNum)
const key = String(cand?.key || '').trim()
if (!key) return ''
const parts = new URLSearchParams()
parts.set('account', acc)
parts.set('pick', key)
const ct = String(ctx.post?.createTime || '').trim()
if (ct) parts.set('create_time', ct)
parts.set('v', '7')
return `${mediaBase}/api/sns/media?${parts.toString()}`
}
const selectCandidateForPreview = (candIdx) => {
const ctx = previewCtx.value
if (!ctx) return
const idxNum = Number(candIdx)
const cand = (previewCandidates.items || []).find((c) => Number(c?.idx) === idxNum)
const key = String(cand?.key || '').trim()
if (!key) return
setSnsMediaOverridePick(ctx.post?.id, ctx.idx, key)
// Allow <img> to retry after user switches candidates.
try {
delete mediaErrors.value[mediaErrorKey(ctx.post?.id, ctx.idx)]
} catch {}
}
const clearUserOverrideForPreview = () => {
const ctx = previewCtx.value
if (!ctx) return
setSnsMediaOverridePick(ctx.post?.id, ctx.idx, '')
try {
delete mediaErrors.value[mediaErrorKey(ctx.post?.id, ctx.idx)]
} catch {}
}
const loadMorePreviewCandidates = async () => {
if (previewCandidates.loading || previewCandidates.loadingMore) return
if (!previewCandidates.hasMore) return
await loadPreviewCandidates({ reset: false })
}
const onMediaClick = (post, m, idx = 0) => {
if (!process.client) return
const mt = Number(m?.type || 0)
// 视频:打开视频链接(新窗口),图片:打开预览
// 视频点击逻辑
if (mt === 6) {
// 1. 如果本地缓存加载成功,永远不请求 CDN!直接在新标签页打开本地的高清完整视频
if (isLocalVideoLoaded(post.id, m.id)) {
const localUrl = getSnsVideoUrl(post.id, m.id)
window.open(localUrl, '_blank', 'noopener,noreferrer')
return
}
// 2. 如果本地没有缓存,按原逻辑 fallback 到 CDN
const u = String(m?.url || '').trim()
if (u) window.open(u, '_blank', 'noopener,noreferrer')
return
}
// Open preview overlay; it also loads local candidates for manual selection.
if (u) window.open(u, '_blank', 'noopener,noreferrer')
return
}
// 图片:打开预览
void openImagePreview(post, m, idx)
}
@@ -880,10 +795,12 @@ const loadPosts = async ({ reset }) => {
offset
})
const items = resp?.timeline || []
if (reset) {
posts.value = items
posts.value = items.filter(p => p.type !== 7)
coverData.value = resp?.cover || null
} else {
posts.value = [...posts.value, ...items]
posts.value = [...posts.value, ...items.filter(p => p.type !== 7)]
}
hasMore.value = !!resp?.hasMore
} catch (e) {
@@ -893,35 +810,23 @@ const loadPosts = async ({ reset }) => {
}
}
watch(
() => selectedAccount.value,
async (v, oldV) => {
if (v && v !== oldV) {
// Account switch: reload overrides and reset preview state.
loadSnsMediaOverrides()
loadSnsSettings()
void syncSnsMediaPicksToBackend()
if (previewCtx.value) closeImagePreview()
await loadPosts({ reset: true })
} else if (!v) {
snsMediaOverrides.value = {}
}
}
)
watch(
() => snsAvoidOtherPicked.value,
() => {
saveSnsSettings()
}
() => selectedAccount.value,
async (v, oldV) => {
if (v && v !== oldV) {
if (previewCtx.value) closeImagePreview()
await loadSelfInfo()
await loadPosts({ reset: true })
}
},
{ immediate: true }
)
onMounted(async () => {
privacyStore.init()
await loadAccounts()
loadSnsMediaOverrides()
loadSnsSettings()
void syncSnsMediaPicksToBackend()
})
const onGlobalClick = () => {
@@ -947,4 +852,13 @@ onUnmounted(() => {
document.removeEventListener('click', onGlobalClick)
document.removeEventListener('keydown', onGlobalKeyDown)
})
const getProxyExternalUrl = (url) => {
// 目前难以计算enc,代理获取封面图(thumbnail
const u = String(url || '').trim()
if (!u) return ''
return `${mediaBase}/api/chat/media/proxy_image?url=${encodeURIComponent(u)}`
}
</script>
+31
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"
@@ -163,6 +184,12 @@
variant="slide"
class="h-full w-full"
/>
<Card04EmojiUniverse
v-else-if="c && (c.kind === 'emoji/annual_universe' || c.id === 4)"
:card="c"
variant="slide"
class="h-full w-full"
/>
<WrappedCardShell
v-else
:card-id="Number(c?.id || (idx + 1))"
@@ -285,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)
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

+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()
File diff suppressed because it is too large Load Diff
+46 -5
View File
@@ -8,7 +8,7 @@ from collections import Counter
from datetime import datetime
from pathlib import Path
from typing import Any, Optional
from urllib.parse import quote, urlparse
from urllib.parse import parse_qs, quote, urlparse
from fastapi import HTTPException
@@ -634,6 +634,32 @@ def _is_mp_weixin_article_url(url: str) -> bool:
return "mp.weixin.qq.com/" in lu
def _is_mp_weixin_feed_article_url(url: str) -> bool:
"""Detect WeChat's PC feed/recommendation mp.weixin.qq.com share URLs.
These links often carry an `exptype` like:
masonry_feed_brief_content_elite_for_pcfeeds_u2i
WeChat desktop tends to render them in a cover-card style (image + bottom title),
so we use this as a hint to choose the 'cover' linkStyle.
"""
u = str(url or "").strip()
if not u:
return False
try:
parsed = urlparse(u)
q = parse_qs(parsed.query or "")
for v in (q.get("exptype") or []):
if "masonry_feed" in str(v or "").lower():
return True
except Exception:
pass
return "exptype=masonry_feed" in u.lower()
def _classify_link_share(*, app_type: int, url: str, source_username: str, desc: str) -> tuple[str, str]:
src = str(source_username or "").strip().lower()
is_official_article = bool(
@@ -647,7 +673,15 @@ def _classify_link_share(*, app_type: int, url: str, source_username: str, desc:
hashtag_count = len(re.findall(r"#[^#\s]+", d))
# 公众号文章中「封面图 + 底栏标题」卡片特征:摘要以 #话题# 风格为主。
link_style = "cover" if (is_official_article and (d.startswith("#") or hashtag_count >= 2)) else "default"
cover_like = bool(
is_official_article
and (
d.startswith("#")
or hashtag_count >= 2
or _is_mp_weixin_feed_article_url(url)
)
)
link_style = "cover" if cover_like else "default"
return link_type, link_style
@@ -948,8 +982,12 @@ def _parse_app_message(text: str) -> dict[str, Any]:
"recordItem": record_item or "",
}
if app_type in (5, 68) and url:
thumb_url = _normalize_xml_url(_extract_xml_tag_text(text, "thumburl"))
if app_type in (4, 5, 68) and url:
# Many appmsg link cards (notably Bilibili shares with <type>4</type>) include a <patMsg> metadata block.
# DO NOT treat "<patmsg" presence as a pat message: it would misclassify normal link cards as "[拍一拍]".
thumb_url = _normalize_xml_url(
_extract_xml_tag_text(text, "thumburl") or _extract_xml_tag_text(text, "cdnthumburl")
)
link_type, link_style = _classify_link_share(
app_type=app_type,
url=url,
@@ -1093,7 +1131,10 @@ def _parse_app_message(text: str) -> dict[str, Any]:
"quoteVoiceLength": quote_voice_length,
}
if app_type == 62 or "<patmsg" in lower or 'type="patmsg"' in lower or "type='patmsg'" in lower:
# Some versions may mark pat messages via sysmsg/appmsg tag attribute: <sysmsg type="patmsg">...</sysmsg>.
# Be strict here: lots of non-pat appmsg payloads still carry a nested <patMsg>...</patMsg> metadata block.
patmsg_attr = bool(re.search(r"<(sysmsg|appmsg)\b[^>]*\btype=['\"]patmsg['\"]", lower))
if app_type == 62 or patmsg_attr:
return {"renderType": "system", "content": "[拍一拍]"}
if app_type == 2000 or (
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
+27 -3
View File
@@ -12,17 +12,31 @@ from ..path_fix import PathFixRoute
router = APIRouter(route_class=PathFixRoute)
ExportFormat = Literal["json", "txt"]
ExportFormat = Literal["json", "txt", "html"]
ExportScope = Literal["selected", "all", "groups", "singles"]
MediaKind = Literal["image", "emoji", "video", "video_thumb", "voice", "file"]
MessageType = Literal["text", "image", "emoji", "video", "voice", "file", "link", "transfer", "redPacket", "system", "quote", "voip"]
MessageType = Literal[
"text",
"image",
"emoji",
"video",
"voice",
"chatHistory",
"file",
"link",
"transfer",
"redPacket",
"system",
"quote",
"voip",
]
class ChatExportCreateRequest(BaseModel):
account: Optional[str] = Field(None, description="账号目录名(可选,默认使用第一个)")
scope: ExportScope = Field("selected", description="导出范围:selected=指定会话;all=全部;groups=仅群聊;singles=仅单聊")
usernames: list[str] = Field(default_factory=list, description="会话 username 列表(scope=selected 时使用)")
format: ExportFormat = Field("json", description="导出格式:json 或 txtzip 内每个会话一个文件)")
format: ExportFormat = Field("json", description="导出格式:json/txt/htmlzip 内每个会话一个文件html 可离线打开 index.html 查看")
start_time: Optional[int] = Field(None, description="起始时间(Unix 秒,含)")
end_time: Optional[int] = Field(None, description="结束时间(Unix 秒,含)")
include_hidden: bool = Field(False, description="是否包含隐藏会话(scope!=selected 时)")
@@ -41,6 +55,14 @@ class ChatExportCreateRequest(BaseModel):
False,
description="预留字段:本项目不从微信进程提取媒体密钥,请使用 wx_key 获取并保存/批量解密",
)
download_remote_media: bool = Field(
False,
description="HTML 导出时允许联网下载链接/引用缩略图等远程媒体(提高离线完整性)",
)
html_page_size: int = Field(
1000,
description="HTML 导出分页大小(每页消息数);<=0 表示禁用分页(单文件,打开大聊天可能很卡)",
)
privacy_mode: bool = Field(
False,
description="隐私模式导出:隐藏会话/用户名/内容,不打包头像与媒体",
@@ -64,6 +86,8 @@ async def create_chat_export(req: ChatExportCreateRequest):
message_types=req.message_types,
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()
+418 -43
View File
@@ -1,15 +1,18 @@
from bisect import bisect_left, bisect_right
from functools import lru_cache
from pathlib import Path
import hashlib
import json
import re
import httpx
import html # 修复&amp;转义的问题!!!
import sqlite3
import time
import xml.etree.ElementTree as ET
from typing import Any, Optional
from fastapi import APIRouter, HTTPException
from fastapi.responses import Response
from fastapi.responses import Response, FileResponse # 返回视频文件
from pydantic import BaseModel, Field
from ..chat_helpers import _load_contact_rows, _pick_display_name, _resolve_account_dir
@@ -92,12 +95,17 @@ def _parse_timeline_xml(xml_text: str, fallback_username: str) -> dict[str, Any]
"media": [],
"likes": [],
"comments": [],
"type": 1, # 默认类型
"title": "",
"contentUrl": "",
"finderFeed": {}
}
xml_str = str(xml_text or "").strip()
if not xml_str:
return out
try:
root = ET.fromstring(xml_str)
except Exception:
@@ -112,54 +120,72 @@ def _parse_timeline_xml(xml_text: str, fallback_username: str) -> dict[str, Any]
if isinstance(v, str) and v.strip():
return v.strip()
return ""
# &amp转义!!
def _clean_url(u: str) -> str:
if not u:
return ""
out["username"] = (
_find_text(".//TimelineObject/username", ".//TimelineObject/user_name", ".//TimelineObject/userName", ".//username")
or fallback_username
)
cleaned = html.unescape(u)
cleaned = cleaned.replace("&amp;", "&")
return cleaned.strip()
out["username"] = _find_text(".//TimelineObject/username", ".//TimelineObject/user_name",
".//username") or fallback_username
out["createTime"] = _safe_int(_find_text(".//TimelineObject/createTime", ".//createTime"))
out["contentDesc"] = _find_text(".//TimelineObject/contentDesc", ".//contentDesc")
out["location"] = _build_location_text(root.find(".//location"))
# --- 提取内容类型 ---
post_type = _safe_int(_find_text(".//ContentObject/type", ".//type"))
out["type"] = post_type
# --- 如果是公众号文章 (Type 3) ---
if post_type == 3:
out["title"] = _find_text(".//ContentObject/title")
out["contentUrl"] = _clean_url(_find_text(".//ContentObject/contentUrl"))
# --- 如果是视频号 (Type 28) ---
if post_type == 28:
out["title"] = _find_text(".//ContentObject/title")
out["contentUrl"] = _clean_url(_find_text(".//ContentObject/contentUrl"))
out["finderFeed"] = {
"nickname": _find_text(".//finderFeed/nickname"),
"desc": _find_text(".//finderFeed/desc"),
"thumbUrl": _clean_url(
_find_text(".//finderFeed/mediaList/media/thumbUrl", ".//finderFeed/mediaList/media/coverUrl")),
"url": _clean_url(_find_text(".//finderFeed/mediaList/media/url"))
}
media: list[dict[str, Any]] = []
try:
for m in root.findall(".//mediaList//media"):
mt = _safe_int(m.findtext("type"))
url_el = m.find("url") if m.find("url") is not None else m.find("urlV")
thumb_el = m.find("thumb") if m.find("thumb") is not None else m.find("thumbV")
# WeChat stores important download/auth hints in attributes (key/enc_idx/token/md5...).
# NOTE: xml.etree.ElementTree.Element is falsy when it has no children.
# So we must check `is None` instead of using `or`, otherwise `<url>` would be treated as missing.
url_el = m.find("url")
if url_el is None:
url_el = m.find("urlV")
thumb_el = m.find("thumb")
if thumb_el is None:
thumb_el = m.find("thumbV")
url = str((url_el.text if url_el is not None else "") or "").strip()
thumb = str((thumb_el.text if thumb_el is not None else "") or "").strip()
url = _clean_url(url_el.text if url_el is not None else "")
thumb = _clean_url(thumb_el.text if thumb_el is not None else "")
url_attrs = dict(url_el.attrib) if url_el is not None and url_el.attrib else {}
thumb_attrs = dict(thumb_el.attrib) if thumb_el is not None and thumb_el.attrib else {}
media_id = str(m.findtext("id") or "").strip()
size_el = m.find("size")
size = dict(size_el.attrib) if size_el is not None and size_el.attrib else {}
if not url and not thumb:
continue
media.append(
{
"type": mt,
"id": media_id,
"url": url,
"thumb": thumb,
"urlAttrs": url_attrs,
"thumbAttrs": thumb_attrs,
"size": size,
}
)
media.append({
"type": mt,
"id": media_id,
"url": url,
"thumb": thumb,
"urlAttrs": url_attrs,
"thumbAttrs": thumb_attrs,
"size": size,
})
except Exception:
media = []
pass
out["media"] = media
likes: list[str] = []
@@ -423,6 +449,58 @@ def _sns_img_roots(wxid_dir_str: str) -> tuple[str, ...]:
roots.sort()
return tuple(roots)
@lru_cache(maxsize=16)
def _sns_video_roots(wxid_dir_str: str) -> tuple[str, ...]:
"""List all month cache roots that contain `Sns/Video`."""
wxid_dir = Path(str(wxid_dir_str or "").strip())
cache_root = wxid_dir / "cache"
try:
month_dirs = [p for p in cache_root.iterdir() if p.is_dir()]
except Exception:
month_dirs = []
roots: list[str] = []
for mdir in month_dirs:
video_root = mdir / "Sns" / "Video"
try:
if video_root.exists() and video_root.is_dir():
roots.append(str(video_root))
except Exception:
continue
roots.sort()
return tuple(roots)
def _resolve_sns_cached_video_path(
wxid_dir: Path,
post_id: str,
media_id: str
) -> Optional[str]:
"""基于逆向出的固定盐值 3,解析朋友圈视频的本地缓存路径"""
if not post_id or not media_id:
return None
raw_key = f"{post_id}_{media_id}_3" # 暂时硬编码,大概率是对的
try:
key32 = hashlib.md5(raw_key.encode("utf-8")).hexdigest()
except Exception:
return None
sub = key32[:2]
rest = key32[2:]
roots = _sns_video_roots(str(wxid_dir))
for root_str in roots:
try:
base_path = Path(root_str) / sub / rest
for ext in [".mp4", ".tmp"]:
p = base_path.with_suffix(ext)
if p.exists() and p.is_file():
return str(p)
except Exception:
continue
return None
def _resolve_sns_cached_image_path_by_md5(
*,
@@ -471,6 +549,21 @@ def _sns_cache_key_from_path(p: Path) -> str:
return _normalize_hex32(key)
def _generate_sns_cache_key(tid: str, media_id: str, media_type: int = 2) -> str:
"""
公式: md5(tid_mediaId_type)
Example: 14852422213384352392_14852422213963625090_2 -> 6d479249ca5a090fab5c42c79bc56b89
"""
if not tid or not media_id:
return ""
raw_key = f"{tid}_{media_id}_{media_type}"
try:
return hashlib.md5(raw_key.encode("utf-8")).hexdigest()
except Exception:
return ""
def _resolve_sns_cached_image_path_by_cache_key(
*,
wxid_dir: Path,
@@ -670,6 +763,133 @@ def _list_sns_cached_image_candidate_keys(
return tuple(out)
def _get_sns_cover(account_dir: Path, target_wxid: str) -> Optional[dict[str, Any]]:
"""无论多古老,强行揪出用户最近的一次朋友圈封面 (type=7)"""
cover_sql = f"SELECT tid, content FROM SnsTimeLine WHERE user_name = '{target_wxid}' AND content LIKE '%<type>7</type>%' ORDER BY tid DESC LIMIT 1"
cover_xml = None
cover_tid = None
try:
if WCDB_REALTIME.is_connected(account_dir.name):
conn = WCDB_REALTIME.ensure_connected(account_dir)
with conn.lock:
sns_db_path = conn.db_storage_dir / "sns" / "sns.db"
if not sns_db_path.exists():
sns_db_path = conn.db_storage_dir / "sns.db"
# 利用 exec_query 强行查
rows = _wcdb_exec_query(conn.handle, kind="media", path=str(sns_db_path), sql=cover_sql)
if rows:
cover_xml = rows[0].get("content")
cover_tid = rows[0].get("tid")
except Exception as e:
logger.warning(f"[sns] WCDB cover fetch failed: {e}")
# 2. 如果没查到,降级从本地解密的 sns.db 查
if not cover_xml:
sns_db_path = account_dir / "sns.db"
if sns_db_path.exists():
try:
# 只读模式防止锁死
conn_sq = sqlite3.connect(f"file:{sns_db_path}?mode=ro", uri=True)
conn_sq.row_factory = sqlite3.Row
row = conn_sq.execute(cover_sql).fetchone()
if row:
cover_xml = str(row["content"] or "")
cover_tid = row["tid"]
conn_sq.close()
except Exception as e:
logger.warning(f"[sns] SQLite cover fetch failed: {e}")
if cover_xml:
parsed = _parse_timeline_xml(cover_xml, target_wxid)
return {
"id": str(cover_tid or ""),
"media": parsed.get("media", []),
"type": 7
}
return None
@router.get("/api/sns/self_info", summary="获取个人信息(wxid和nickname")
def api_sns_self_info(account: Optional[str] = None):
account_dir = _resolve_account_dir(account)
wxid = account_dir.name
logger.info(f"[self_info] 开始获取账号信息, 预设 wxid: {wxid}")
nickname = wxid
source = "wxid_dir"
try:
status = WCDB_REALTIME.get_status(account_dir)
if status.get("dll_present") and status.get("key_present"):
rt_conn = WCDB_REALTIME.ensure_connected(account_dir)
with rt_conn.lock:
names_map = _wcdb_get_display_names(rt_conn.handle, [wxid])
if names_map and names_map.get(wxid):
nickname = names_map[wxid]
source = "wcdb_realtime"
logger.info(f"[self_info] 从 WCDB 实时连接获取成功: {nickname}")
return {"wxid": wxid, "nickname": nickname, "source": source}
except Exception as e:
logger.debug(f"[self_info] WCDB 路径跳过或失败: {e}")
contact_db_path = account_dir / "contact.db"
if contact_db_path.exists():
conn = None
try:
db_uri = f"file:{contact_db_path}?mode=ro"
conn = sqlite3.connect(db_uri, uri=True, timeout=5)
conn.row_factory = sqlite3.Row
cursor = conn.execute("PRAGMA table_info(contact)")
cols = {row["name"].lower() for row in cursor.fetchall()}
logger.debug(f"[self_info] contact 表现有字段: {cols}")
target_nick_col = "nick_name" if "nick_name" in cols else ("nickname" if "nickname" in cols else None)
if target_nick_col:
sql = f"SELECT remark, {target_nick_col} as nickname_val, alias FROM contact WHERE username = ? LIMIT 1"
row = conn.execute(sql, (wxid,)).fetchone()
if row:
raw_remark = str(row["remark"] or "").strip() if "remark" in row.keys() else ""
raw_nick = str(row["nickname_val"] or "").strip()
raw_alias = str(row["alias"] or "").strip() if "alias" in row.keys() else ""
if raw_remark:
nickname = raw_remark
source = "contact_db_remark"
elif raw_nick:
nickname = raw_nick
source = "contact_db_nickname"
elif raw_alias:
nickname = raw_alias
source = "contact_db_alias"
logger.info(f"[self_info] 从数据库提取成功: {nickname} (src: {source})")
else:
logger.warning("[self_info] contact 表中找不到任何昵称相关字段")
except sqlite3.OperationalError as e:
logger.error(f"[self_info] 数据库繁忙或锁定: {e}")
except Exception as e:
logger.exception(f"[self_info] 查询异常: {e}")
finally:
if conn: conn.close()
else:
logger.warning(f"[self_info] 找不到 contact.db: {contact_db_path}")
return {
"wxid": wxid,
"nickname": nickname,
"source": source
}
@router.get("/api/sns/timeline", summary="获取朋友圈时间线")
def list_sns_timeline(
@@ -692,6 +912,11 @@ def list_sns_timeline(
users = _parse_csv_list(usernames)
kw = str(keyword or "").strip()
cover_data = None
if offset == 0:
target_wxid = users[0] if users else account_dir.name
cover_data = _get_sns_cover(account_dir, target_wxid)
# Prefer real-time WCDB access (reads the latest encrypted db_storage/sns/sns.db).
# Fallback to the decrypted sqlite copy in output/{account}/sns.db.
try:
@@ -773,6 +998,11 @@ def list_sns_timeline(
# Enrich with parsed XML when available.
location = str(r.get("location") or "")
post_type = 1
title = ""
content_url = ""
finder_feed = {}
try:
tid_u = int(r.get("id") or 0)
tid_s = (tid_u & 0xFFFFFFFFFFFFFFFF)
@@ -783,6 +1013,16 @@ def list_sns_timeline(
parsed = _parse_timeline_xml(xml, uname)
if parsed.get("location"):
location = str(parsed.get("location") or "")
post_type = parsed.get("type", 1)
if post_type == 7: # 朋友圈封面
continue
title = parsed.get("title", "")
content_url = parsed.get("contentUrl", "")
finder_feed = parsed.get("finderFeed", {})
pmedia = parsed.get("media") or []
if isinstance(pmedia, list) and isinstance(media, list) and pmedia:
# Merge by index (best-effort).
@@ -819,10 +1059,21 @@ def list_sns_timeline(
"media": media,
"likes": likes,
"comments": comments,
"type": post_type,
"title": title,
"contentUrl": content_url,
"finderFeed": finder_feed,
}
)
return {"timeline": timeline, "hasMore": has_more, "limit": limit, "offset": offset, "source": "wcdb"}
return {
"timeline": timeline,
"hasMore": has_more,
"limit": limit,
"offset": offset,
"source": "wcdb",
"cover": cover_data,
}
except WCDBRealtimeError as e:
logger.info("[sns] wcdb realtime unavailable: %s", e)
except Exception as e:
@@ -895,10 +1146,20 @@ def list_sns_timeline(
"media": parsed.get("media") or [],
"likes": parsed.get("likes") or [],
"comments": parsed.get("comments") or [],
"type": parsed.get("type", 1),
"title": parsed.get("title", ""),
"contentUrl": parsed.get("contentUrl", ""),
"finderFeed": parsed.get("finderFeed", {}),
}
)
return {"timeline": timeline, "hasMore": has_more, "limit": limit, "offset": offset}
return {
"timeline": timeline,
"hasMore": has_more,
"limit": limit,
"offset": offset,
"cover": cover_data,
}
class SnsMediaPicksSaveRequest(BaseModel):
@@ -944,19 +1205,76 @@ def list_sns_media_candidates(
@router.get("/api/sns/media", summary="获取朋友圈图片(本地缓存优先)")
async def get_sns_media(
account: Optional[str] = None,
create_time: int = 0,
width: int = 0,
height: int = 0,
total_size: int = 0,
idx: int = 0,
avoid_picked: int = 0,
post_id: Optional[str] = None,
pick: Optional[str] = None,
md5: Optional[str] = None,
url: Optional[str] = None,
account: Optional[str] = None,
create_time: int = 0,
width: int = 0,
height: int = 0,
total_size: int = 0,
idx: int = 0,
avoid_picked: int = 0,
post_id: Optional[str] = None,
media_id: Optional[str] = None,
post_type: int = 1,
media_type: int = 2,
pick: Optional[str] = None,
md5: Optional[str] = None,
url: Optional[str] = None,
):
account_dir = _resolve_account_dir(account)
wxid_dir = _resolve_account_wxid_dir(account_dir)
if wxid_dir and post_id and media_id:
if int(post_type) == 7:
raw_key = f"{post_id}_{media_id}_4" # 硬编码
md5_str = hashlib.md5(raw_key.encode("utf-8")).hexdigest()
bkg_path = wxid_dir / "business" / "sns" / "bkg" / md5_str[:2] / md5_str
if bkg_path.exists() and bkg_path.is_file():
print(f"===== Hit Bkg Cover ======= {bkg_path}")
return FileResponse(bkg_path, media_type="image/jpeg",
headers={"Cache-Control": "public, max-age=31536000"})
exact_match_path = None
hit_type = ""
# 尝试 1: 使用 post_type 计算 MD5
key_post = _generate_sns_cache_key(post_id, media_id, post_type)
exact_match_path = _resolve_sns_cached_image_path_by_cache_key(
wxid_dir=wxid_dir,
cache_key=key_post,
create_time=0
)
if exact_match_path:
hit_type = "post_type"
# 尝试 2: 如果没找到,并且 media_type 和 post_type 不一样,再试一次
if not exact_match_path and post_type != media_type:
key_media = _generate_sns_cache_key(post_id, media_id, media_type)
exact_match_path = _resolve_sns_cached_image_path_by_cache_key(
wxid_dir=wxid_dir,
cache_key=key_media,
create_time=0
)
if exact_match_path:
hit_type = "media_type"
# 如果通过这两种精确定位找到了文件,直接返回
if exact_match_path:
print(f"=====exact_match_path======={exact_match_path}============= (Hit: {hit_type})")
try:
payload, mtype = _read_and_maybe_decrypt_media(Path(exact_match_path), account_dir)
if payload and str(mtype or "").startswith("image/"):
resp = Response(content=payload, media_type=str(mtype or "image/jpeg"))
resp.headers["Cache-Control"] = "public, max-age=31536000"
resp.headers["X-SNS-Source"] = "deterministic-hash"
# 在 Header 里塞入到底是哪个 type 命中的,方便 F12 调试
resp.headers["X-SNS-Hit-Type"] = hit_type
return resp
except Exception:
pass
print("no exact match path, falling back...")
# 0) User-picked cache key override (stable across candidate ordering).
pick_key = _normalize_hex32(pick)
@@ -1066,3 +1384,60 @@ async def get_sns_media(
raise
except Exception as e:
raise HTTPException(status_code=502, detail=f"Fetch sns media failed: {e}")
@router.get("/api/sns/article_thumb", summary="提取公众号文章封面图")
async def proxy_article_thumb(url: str):
u = str(url or "").strip()
if not u.startswith("http"):
raise HTTPException(status_code=400, detail="Invalid URL")
try:
async with httpx.AsyncClient(timeout=10.0) as client:
headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"}
resp = await client.get(u, headers=headers)
resp.raise_for_status()
html_text = resp.text
match = re.search(r'["\'](https?://[^"\']*?mmbiz_[a-zA-Z]+[^"\']*?)["\']', html_text)
if not match:
raise HTTPException(status_code=404, detail="未在 HTML 中找到图片 URL")
img_url = match.group(1)
img_url = html.unescape(img_url).replace("&amp;", "&")
img_resp = await client.get(img_url, headers=headers)
img_resp.raise_for_status()
return Response(
content=img_resp.content,
media_type=img_resp.headers.get("Content-Type", "image/jpeg")
)
except Exception as e:
logger.warning(f"[sns] 提取公众号封面失败 url={u[:50]}... : {e}")
raise HTTPException(status_code=404, detail="无法获取文章封面")
@router.get("/api/sns/video", summary="获取朋友圈本地缓存视频")
async def get_sns_video(
account: Optional[str] = None,
post_id: Optional[str] = None,
media_id: Optional[str] = None,
):
if not post_id or not media_id:
raise HTTPException(status_code=400, detail="Missing post_id or media_id")
account_dir = _resolve_account_dir(account)
wxid_dir = _resolve_account_wxid_dir(account_dir)
if not wxid_dir:
raise HTTPException(status_code=404, detail="WXID dir not found")
video_path = _resolve_sns_cached_video_path(wxid_dir, post_id, media_id)
if not video_path:
raise HTTPException(status_code=404, detail="Local video cache not found")
return FileResponse(video_path, media_type="video/mp4")
File diff suppressed because it is too large Load Diff
+15 -3
View File
@@ -16,15 +16,16 @@ from .cards.card_00_global_overview import build_card_00_global_overview
from .cards.card_01_cyber_schedule import WeekdayHourHeatmap, build_card_01_cyber_schedule, compute_weekday_hour_heatmap
from .cards.card_02_message_chars import build_card_02_message_chars
from .cards.card_03_reply_speed import build_card_03_reply_speed
from .cards.card_04_emoji_universe import build_card_04_emoji_universe
logger = get_logger(__name__)
# We use this number to version the cache filename so adding more cards won't accidentally serve
# an older partial cache.
_IMPLEMENTED_UPTO_ID = 3
_IMPLEMENTED_UPTO_ID = 4
# Bump this when we change card payloads/ordering while keeping the same implemented_upto.
_CACHE_VERSION = 9
_CACHE_VERSION = 15
# "Manifest" is used by the frontend to render the deck quickly, then lazily fetch each card.
@@ -58,6 +59,13 @@ _WRAPPED_CARD_MANIFEST: tuple[dict[str, Any], ...] = (
"category": "B",
"kind": "chat/reply_speed",
},
{
"id": 4,
"title": "这一年,你的表情包里藏了多少心情?",
"scope": "global",
"category": "B",
"kind": "emoji/annual_universe",
},
)
_WRAPPED_CARD_ID_SET = {int(c["id"]) for c in _WRAPPED_CARD_MANIFEST}
@@ -274,7 +282,7 @@ def build_wrapped_annual_response(
) -> dict[str, Any]:
"""Build annual wrapped response for the given account/year.
For now we implement cards up to id=3 (plus a meta overview card id=0).
For now we implement cards up to id=4 (plus a meta overview card id=0).
"""
account_dir = _resolve_account_dir(account)
@@ -317,6 +325,8 @@ def build_wrapped_annual_response(
cards.append(build_card_02_message_chars(account_dir=account_dir, year=y))
# Page 5: reply speed / best chat buddy.
cards.append(build_card_03_reply_speed(account_dir=account_dir, year=y))
# Page 6: annual emoji universe / meme almanac.
cards.append(build_card_04_emoji_universe(account_dir=account_dir, year=y))
obj: dict[str, Any] = {
"account": account_dir.name,
@@ -508,6 +518,8 @@ def build_wrapped_annual_card(
card = build_card_02_message_chars(account_dir=account_dir, year=y)
elif cid == 3:
card = build_card_03_reply_speed(account_dir=account_dir, year=y)
elif cid == 4:
card = build_card_04_emoji_universe(account_dir=account_dir, year=y)
else:
# Should be unreachable due to _WRAPPED_CARD_ID_SET check.
raise ValueError(f"Unknown Wrapped card id: {cid}")
@@ -0,0 +1,50 @@
import sys
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
from wechat_decrypt_tool.chat_helpers import _parse_app_message
class TestChatAppMessageType4PatMsgRegression(unittest.TestCase):
def test_type4_link_with_patmsg_metadata_is_not_misclassified_as_pat(self):
raw_text = (
"<msg>"
'<appmsg appid="wxcb8d4298c6a09bcb" sdkver="0">'
"<title>【中配】抽象可能让你的代码变差 - CodeAesthetic</title>"
"<des>UP主:黑纹白斑马</des>"
"<type>4</type>"
"<url>https://b23.tv/au68guF</url>"
"<appname>哔哩哔哩</appname>"
"<appattach><cdnthumburl>3057020100044b30</cdnthumburl></appattach>"
"<patMsg><chatUser /></patMsg>"
"</appmsg>"
"</msg>"
)
parsed = _parse_app_message(raw_text)
self.assertEqual(parsed.get("renderType"), "link")
self.assertEqual(parsed.get("url"), "https://b23.tv/au68guF")
self.assertEqual(parsed.get("title"), "【中配】抽象可能让你的代码变差 - CodeAesthetic")
self.assertEqual(parsed.get("from"), "哔哩哔哩")
self.assertNotEqual(parsed.get("content"), "[拍一拍]")
def test_type62_is_still_pat(self):
raw_text = '<msg><appmsg><title>"A" 拍了拍 "B"</title><type>62</type></appmsg></msg>'
parsed = _parse_app_message(raw_text)
self.assertEqual(parsed.get("renderType"), "system")
self.assertEqual(parsed.get("content"), "[拍一拍]")
def test_sysmsg_type_patmsg_attr_is_still_pat(self):
raw_text = '<sysmsg type="patmsg"><foo>bar</foo></sysmsg>'
parsed = _parse_app_message(raw_text)
self.assertEqual(parsed.get("renderType"), "system")
self.assertEqual(parsed.get("content"), "[拍一拍]")
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,218 @@
import os
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 TestChatExportChatHistoryModal(unittest.TestCase):
_MD5 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
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, "", "", "", 1, 0, "", ""),
)
conn.execute(
"INSERT INTO contact VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
(username, "", "测试好友", "", 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) -> 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
)
"""
)
record_item = (
"<recorditem>"
"<datalist>"
"<dataitem>"
"<datatype>2</datatype>"
f"<fullmd5>{self._MD5}</fullmd5>"
"</dataitem>"
"</datalist>"
"</recorditem>"
)
chat_history_xml = (
"<msg><appmsg>"
"<type>19</type>"
"<title>聊天记录</title>"
"<des>记录预览</des>"
f"<recorditem><![CDATA[{record_item}]]></recorditem>"
"</appmsg></msg>"
)
conn.execute(
f"INSERT INTO {table_name} (local_id, server_id, local_type, sort_seq, real_sender_id, create_time, message_content, compress_content) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
(1, 1001, 49, 1, 2, 1735689601, chat_history_xml, None),
)
conn.commit()
finally:
conn.close()
def _seed_media_files(self, account_dir: Path) -> None:
resource_root = account_dir / "resource"
(resource_root / "aa").mkdir(parents=True, exist_ok=True)
(resource_root / "aa" / f"{self._MD5}.jpg").write_bytes(b"\xff\xd8\xff\xd9")
def _prepare_account(self, root: Path, *, account: str, username: str) -> 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)
self._seed_media_files(account_dir)
return account_dir
def _create_job(self, manager, *, account: str, username: str):
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=True,
media_kinds=["image"],
message_types=["chatHistory", "image"],
output_dir=None,
allow_process_key_extract=False,
download_remote_media=False,
privacy_mode=False,
file_name=None,
)
for _ in range(200):
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_chat_history_modal_has_media_index_and_record_item(self):
with TemporaryDirectory() as td:
root = Path(td)
account = "wxid_test"
username = "wxid_friend"
self._prepare_account(root, account=account, username=username)
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)
self.assertEqual(job.status, "done", msg=job.error)
with zipfile.ZipFile(job.zip_path, "r") as zf:
names = set(zf.namelist())
self.assertIn(f"media/images/{self._MD5}.jpg", names)
html_path = next((n for n in names if n.endswith("/messages.html")), "")
self.assertTrue(html_path)
html_text = zf.read(html_path).decode("utf-8")
self.assertIn('id="chatHistoryModal"', html_text)
self.assertIn('data-wce-chat-history="1"', html_text)
self.assertIn('data-record-item-b64="', html_text)
self.assertIn('id="wceMediaIndex"', html_text)
self.assertIn(self._MD5, html_text)
finally:
if prev_data is None:
os.environ.pop("WECHAT_TOOL_DATA_DIR", None)
else:
os.environ["WECHAT_TOOL_DATA_DIR"] = prev_data
+353
View File
@@ -0,0 +1,353 @@
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 TestChatExportHtmlFormat(unittest.TestCase):
_FILE_MD5 = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
_VOICE_SERVER_ID = 2001
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, "", "", "", 1, 0, "", ""),
)
conn.execute(
"INSERT INTO contact VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
(username, "", "测试好友", "", 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) -> 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
)
"""
)
image_xml = '<msg><img md5="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" cdnthumburl="img_file_id_1" /></msg>'
voice_xml = '<msg><voicemsg voicelength="3000" /></msg>'
file_md5 = self._FILE_MD5
file_xml = (
"<msg><appmsg>"
"<type>6</type>"
"<title>demo.pdf</title>"
"<totallen>2048</totallen>"
f"<md5>{file_md5}</md5>"
"</appmsg></msg>"
)
link_xml = (
"<msg><appmsg>"
"<type>5</type>"
"<title>示例链接</title>"
"<des>这是描述</des>"
"<url>https://example.com/</url>"
"<thumburl>https://example.com/thumb.jpg</thumburl>"
"<sourceusername>gh_test</sourceusername>"
"<sourcedisplayname>测试公众号</sourcedisplayname>"
"</appmsg></msg>"
)
chat_history_xml = (
"<msg><appmsg>"
"<type>19</type>"
"<title>聊天记录</title>"
"<des>记录预览</des>"
"<recorditem><desc>张三: hi\n李四: ok</desc></recorditem>"
"</appmsg></msg>"
)
transfer_xml = (
"<msg><appmsg>"
"<type>2000</type>"
"<title>微信转账</title>"
"<wcpayinfo>"
"<pay_memo>转账备注</pay_memo>"
"<feedesc>¥1.23</feedesc>"
"<paysubtype>3</paysubtype>"
"<transferid>transfer_123</transferid>"
"</wcpayinfo>"
"</appmsg></msg>"
)
red_packet_xml = (
"<msg><appmsg>"
"<type>2001</type>"
"<title>红包</title>"
"<wcpayinfo>"
"<sendertitle>恭喜发财,大吉大利</sendertitle>"
"<senderdes>微信红包</senderdes>"
"</wcpayinfo>"
"</appmsg></msg>"
)
voip_xml = (
"<msg><VoIPBubbleMsg>"
"<room_type>1</room_type>"
"<msg>语音通话</msg>"
"</VoIPBubbleMsg></msg>"
)
quote_voice_xml = (
"<msg><appmsg>"
"<type>57</type>"
"<title>回复语音</title>"
"<refermsg>"
"<type>34</type>"
f"<svrid>{self._VOICE_SERVER_ID}</svrid>"
"<fromusr>wxid_friend</fromusr>"
"<displayname>测试好友</displayname>"
"<content>wxid_friend:3000:1:</content>"
"</refermsg>"
"</appmsg></msg>"
)
rows = [
(1, 1001, 3, 1, 2, 1735689601, image_xml, None),
(2, 1002, 1, 2, 2, 1735689602, "普通文本消息[微笑]", None),
(3, 1003, 49, 3, 1, 1735689603, transfer_xml, None),
(4, 1004, 49, 4, 2, 1735689604, red_packet_xml, None),
(5, 1005, 49, 5, 1, 1735689605, file_xml, None),
(6, 1006, 49, 6, 2, 1735689606, link_xml, None),
(7, 1007, 49, 7, 2, 1735689607, chat_history_xml, None),
(8, 1008, 50, 8, 2, 1735689608, voip_xml, None),
(9, self._VOICE_SERVER_ID, 34, 9, 1, 1735689609, voice_xml, None),
(10, 1010, 49, 10, 1, 1735689610, quote_voice_xml, 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 _seed_media_files(self, account_dir: Path) -> None:
resource_root = account_dir / "resource"
(resource_root / "aa").mkdir(parents=True, exist_ok=True)
(resource_root / "aa" / "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.jpg").write_bytes(b"\xff\xd8\xff\xd9")
(resource_root / "bb").mkdir(parents=True, exist_ok=True)
(resource_root / "bb" / f"{self._FILE_MD5}.dat").write_bytes(b"dummy")
conn = sqlite3.connect(str(account_dir / "media_0.db"))
try:
conn.execute(
"""
CREATE TABLE VoiceInfo (
svr_id INTEGER,
create_time INTEGER,
voice_data BLOB
)
"""
)
conn.execute(
"INSERT INTO VoiceInfo VALUES (?, ?, ?)",
(self._VOICE_SERVER_ID, 1735689609, b"SILK_VOICE_DATA"),
)
conn.commit()
finally:
conn.close()
def _prepare_account(self, root: Path, *, account: str, username: str) -> 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)
self._seed_media_files(account_dir)
return account_dir
def _create_job(self, manager, *, account: str, username: str):
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=True,
media_kinds=["image", "emoji", "video", "video_thumb", "voice", "file"],
message_types=[],
output_dir=None,
allow_process_key_extract=False,
download_remote_media=False,
privacy_mode=False,
file_name=None,
)
for _ in range(200):
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_contains_index_and_conversation_page(self):
with TemporaryDirectory() as td:
root = Path(td)
account = "wxid_test"
username = "wxid_friend"
self._prepare_account(root, account=account, username=username)
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)
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())
self.assertIn("index.html", names)
self.assertIn("assets/wechat-chat-export.css", names)
self.assertIn("assets/wechat-chat-export.js", names)
manifest = json.loads(zf.read("manifest.json").decode("utf-8"))
self.assertEqual(manifest.get("format"), "html")
html_path = next((n for n in names if n.endswith("/messages.html")), "")
self.assertTrue(html_path)
html_text = zf.read(html_path).decode("utf-8")
self.assertIn('data-wce-rail-avatar="1"', html_text)
self.assertIn('data-wce-session-list="1"', html_text)
self.assertIn('id="sessionSearchInput"', html_text)
self.assertIn('data-wce-time-divider="1"', html_text)
self.assertIn('id="messageTypeFilter"', html_text)
self.assertIn('value="chatHistory"', html_text)
self.assertIn('id="chatHistoryModal"', html_text)
self.assertIn('data-wce-chat-history="1"', html_text)
self.assertIn('data-record-item-b64="', html_text)
self.assertIn('id="wceMediaIndex"', html_text)
self.assertIn('data-wce-quote-voice-btn="1"', html_text)
self.assertNotIn('title="刷新消息"', html_text)
self.assertNotIn('title="导出聊天记录"', html_text)
self.assertNotIn("搜索聊天记录", html_text)
self.assertNotIn("朋友圈", html_text)
self.assertNotIn("年度总结", html_text)
self.assertNotIn("设置", html_text)
self.assertNotIn("隐私模式", html_text)
self.assertTrue(any(n.startswith("media/images/") for n in names))
self.assertIn("../../media/images/", html_text)
self.assertIn("wechat-transfer-card", html_text)
self.assertIn("wechat-redpacket-card", html_text)
self.assertIn("wechat-chat-history-card", html_text)
self.assertIn("wechat-voip-bubble", html_text)
self.assertIn("wechat-link-card", html_text)
self.assertIn("wechat-file-card", html_text)
self.assertIn("wechat-voice-wrapper", html_text)
css_text = zf.read("assets/wechat-chat-export.css").decode("utf-8", errors="ignore")
self.assertIn("wechat-transfer-card", css_text)
self.assertNotIn("wechat-transfer-card[data-v-", css_text)
js_text = zf.read("assets/wechat-chat-export.js").decode("utf-8", errors="ignore")
self.assertIn("wechat-voice-bubble", js_text)
self.assertIn("voice-playing", js_text)
self.assertIn("data-wce-quote-voice-btn", js_text)
self.assertIn("assets/images/wechat/wechat-trans-icon1.png", names)
self.assertIn("assets/images/wechat/zip.png", names)
self.assertIn("assets/images/wechat/WeChat-Icon-Logo.wine.svg", names)
self.assertTrue(any(n.startswith("fonts/") and n.endswith(".woff2") for n in names))
self.assertIn("wxemoji/Expression_1@2x.png", names)
self.assertIn("../../wxemoji/Expression_1@2x.png", html_text)
finally:
if prev_data is None:
os.environ.pop("WECHAT_TOOL_DATA_DIR", None)
else:
os.environ["WECHAT_TOOL_DATA_DIR"] = prev_data
+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
@@ -0,0 +1,199 @@
import os
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 TestChatExportImageMd5CandidateFallback(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, "", "", "", 1, 0, "", ""),
)
conn.execute(
"INSERT INTO contact VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
(username, "", "测试好友", "", 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) -> 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
)
"""
)
good_md5 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
bad_md5 = "ffffffffffffffffffffffffffffffff"
image_xml = f'<msg><img md5="{bad_md5}" hdmd5="{good_md5}" cdnthumburl="img_file_id_1" /></msg>'
conn.execute(
f"INSERT INTO {table_name} (local_id, server_id, local_type, sort_seq, real_sender_id, create_time, message_content, compress_content) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
(1, 1001, 3, 1, 2, 1735689601, image_xml, None),
)
conn.commit()
finally:
conn.close()
def _seed_decrypted_resource(self, account_dir: Path) -> None:
resource_root = account_dir / "resource"
(resource_root / "aa").mkdir(parents=True, exist_ok=True)
(resource_root / "aa" / "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.jpg").write_bytes(b"\xff\xd8\xff\xd9")
def _prepare_account(self, root: Path, *, account: str, username: str) -> 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)
self._seed_decrypted_resource(account_dir)
return account_dir
def _create_job(self, manager, *, account: str, username: str):
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=True,
media_kinds=["image"],
message_types=[],
output_dir=None,
allow_process_key_extract=False,
download_remote_media=False,
privacy_mode=False,
file_name=None,
)
for _ in range(200):
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_falls_back_to_secondary_md5_candidate(self):
with TemporaryDirectory() as td:
root = Path(td)
account = "wxid_test"
username = "wxid_friend"
self._prepare_account(root, account=account, username=username)
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)
self.assertEqual(job.status, "done", msg=job.error)
with zipfile.ZipFile(job.zip_path, "r") as zf:
names = set(zf.namelist())
self.assertIn("media/images/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.jpg", names)
self.assertFalse(any("ffffffffffffffffffffffffffffffff" in n for n in names if n.startswith("media/images/")))
html_path = next((n for n in names if n.endswith("/messages.html")), "")
self.assertTrue(html_path)
html_text = zf.read(html_path).decode("utf-8", errors="ignore")
self.assertIn("../../media/images/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.jpg", html_text)
finally:
if prev_data is None:
os.environ.pop("WECHAT_TOOL_DATA_DIR", None)
else:
os.environ["WECHAT_TOOL_DATA_DIR"] = prev_data
@@ -0,0 +1,235 @@
import os
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 TestChatExportImageMd5PrefersMessageResource(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_source_info(self, account_dir: Path) -> None:
wxid_dir = account_dir / "_wxid_dummy"
db_storage_dir = account_dir / "_db_storage_dummy"
wxid_dir.mkdir(parents=True, exist_ok=True)
db_storage_dir.mkdir(parents=True, exist_ok=True)
(account_dir / "_source.json").write_text(
'{"wxid_dir": "' + str(wxid_dir).replace("\\", "\\\\") + '", "db_storage_path": "' + str(db_storage_dir).replace("\\", "\\\\") + '"}',
encoding="utf-8",
)
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, "", "", "", 1, 0, "", ""),
)
conn.execute(
"INSERT INTO contact VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
(username, "", "测试好友", "", 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, bad_md5: str) -> 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
)
"""
)
image_xml = f'<msg><img md5="{bad_md5}" /></msg>'
conn.execute(
f"INSERT INTO {table_name} (local_id, server_id, local_type, sort_seq, real_sender_id, create_time, message_content, compress_content) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
(1, 1001, 3, 1, 2, 1735689601, image_xml, None),
)
conn.commit()
finally:
conn.close()
def _seed_message_resource_db(self, path: Path, *, good_md5: str) -> None:
conn = sqlite3.connect(str(path))
try:
conn.execute(
"""
CREATE TABLE MessageResourceInfo (
message_id INTEGER,
message_svr_id INTEGER,
message_local_type INTEGER,
chat_id INTEGER,
message_local_id INTEGER,
message_create_time INTEGER,
packed_info BLOB
)
"""
)
# packed_info may contain multiple tokens; include a realistic *.dat reference so the extractor prefers it.
packed_info = f"{good_md5}_t.dat".encode("ascii")
conn.execute(
"INSERT INTO MessageResourceInfo VALUES (?, ?, ?, ?, ?, ?, ?)",
(1, 1001, 3, 0, 1, 1735689601, packed_info),
)
conn.commit()
finally:
conn.close()
def _seed_decrypted_resource(self, account_dir: Path, *, good_md5: str) -> None:
resource_root = account_dir / "resource"
(resource_root / good_md5[:2]).mkdir(parents=True, exist_ok=True)
# Minimal JPEG payload (valid SOI/EOI).
(resource_root / good_md5[:2] / f"{good_md5}.jpg").write_bytes(b"\xff\xd8\xff\xd9")
def _prepare_account(self, root: Path, *, account: str, username: str, bad_md5: str, good_md5: str) -> Path:
account_dir = root / "output" / "databases" / account
account_dir.mkdir(parents=True, exist_ok=True)
self._seed_source_info(account_dir)
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, bad_md5=bad_md5)
self._seed_message_resource_db(account_dir / "message_resource.db", good_md5=good_md5)
self._seed_decrypted_resource(account_dir, good_md5=good_md5)
return account_dir
def _create_job(self, manager, *, account: str, username: str):
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=True,
media_kinds=["image"],
message_types=["image"],
output_dir=None,
allow_process_key_extract=False,
download_remote_media=False,
privacy_mode=False,
file_name=None,
)
for _ in range(200):
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_prefers_message_resource_md5_over_xml_md5(self):
with TemporaryDirectory() as td:
root = Path(td)
account = "wxid_test"
username = "wxid_friend"
bad_md5 = "ffffffffffffffffffffffffffffffff"
good_md5 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
self._prepare_account(root, account=account, username=username, bad_md5=bad_md5, good_md5=good_md5)
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)
self.assertEqual(job.status, "done", msg=job.error)
with zipfile.ZipFile(job.zip_path, "r") as zf:
names = set(zf.namelist())
self.assertIn(f"media/images/{good_md5}.jpg", names)
html_path = next((n for n in names if n.endswith("/messages.html")), "")
self.assertTrue(html_path)
html_text = zf.read(html_path).decode("utf-8", errors="ignore")
self.assertIn(f"../../media/images/{good_md5}.jpg", html_text)
finally:
if prev_data is None:
os.environ.pop("WECHAT_TOOL_DATA_DIR", None)
else:
os.environ["WECHAT_TOOL_DATA_DIR"] = prev_data
@@ -198,6 +198,7 @@ class TestChatExportMessageTypesSemantics(unittest.TestCase):
message_types=message_types,
output_dir=None,
allow_process_key_extract=False,
download_remote_media=False,
privacy_mode=privacy_mode,
file_name=None,
)
@@ -0,0 +1,304 @@
import os
import hashlib
import sqlite3
import sys
import unittest
import zipfile
import importlib
from pathlib import Path
from tempfile import TemporaryDirectory
from unittest import mock
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
class _FakeResponse:
def __init__(self, body: bytes, *, content_type: str) -> None:
self.status_code = 200
self.headers = {
"Content-Type": str(content_type or "").strip(),
"Content-Length": str(len(body)),
}
self._body = body
def iter_content(self, chunk_size=65536):
data = self._body or b""
for i in range(0, len(data), int(chunk_size or 65536)):
yield data[i : i + int(chunk_size or 65536)]
def close(self):
return None
class TestChatExportRemoteThumbOption(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, "", "", "", 1, 0, "", ""),
)
conn.execute(
"INSERT INTO contact VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
(username, "", "测试好友", "", 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) -> tuple[str, str]:
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
)
"""
)
link_thumb = "https://1.1.1.1/thumb.png"
quote_thumb = "https://1.1.1.1/quote.png"
link_xml = (
"<msg><appmsg>"
"<type>5</type>"
"<title>示例链接</title>"
"<des>这是描述</des>"
"<url>https://example.com/</url>"
f"<thumburl>{link_thumb}</thumburl>"
"</appmsg></msg>"
)
quote_xml = (
"<msg><appmsg>"
"<type>57</type>"
"<title>回复</title>"
"<refermsg>"
"<type>49</type>"
"<svrid>8888</svrid>"
"<fromusr>wxid_other</fromusr>"
"<displayname>对方</displayname>"
"<content>"
"<msg><appmsg><type>5</type><title>被引用链接</title><url>https://example.com/</url>"
f"<thumburl>{quote_thumb}</thumburl>"
"</appmsg></msg>"
"</content>"
"</refermsg>"
"</appmsg></msg>"
)
rows = [
(1, 1001, 49, 1, 2, 1735689601, link_xml, None),
(2, 1002, 49, 2, 2, 1735689602, quote_xml, 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()
return link_thumb, quote_thumb
finally:
conn.close()
def _prepare_account(self, root: Path, *, account: str, username: str) -> tuple[Path, str, str]:
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)
link_thumb, quote_thumb = self._seed_message_db(account_dir / "message_0.db", account=account, username=username)
return account_dir, link_thumb, quote_thumb
def _create_job(self, manager, *, account: str, username: str, download_remote_media: bool):
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=True,
media_kinds=["image", "emoji", "video", "video_thumb", "voice", "file"],
message_types=["link", "quote", "image"],
output_dir=None,
allow_process_key_extract=False,
download_remote_media=download_remote_media,
privacy_mode=False,
file_name=None,
)
for _ in range(200):
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_remote_thumb_disabled_does_not_download(self):
with TemporaryDirectory() as td:
root = Path(td)
account = "wxid_test"
username = "wxid_friend"
_, link_thumb, quote_thumb = self._prepare_account(root, account=account, username=username)
prev_data = os.environ.get("WECHAT_TOOL_DATA_DIR")
try:
os.environ["WECHAT_TOOL_DATA_DIR"] = str(root)
svc = self._reload_export_modules()
with mock.patch.object(
svc.requests,
"get",
side_effect=AssertionError("requests.get should not be called when download_remote_media=False"),
) as m_get:
job = self._create_job(
svc.CHAT_EXPORT_MANAGER,
account=account,
username=username,
download_remote_media=False,
)
self.assertEqual(job.status, "done", msg=job.error)
self.assertEqual(m_get.call_count, 0)
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)
html_text = zf.read(html_path).decode("utf-8")
self.assertIn(f'src="{link_thumb}"', html_text)
self.assertIn(f'src="{quote_thumb}"', html_text)
self.assertFalse(any(n.startswith("media/remote/") for n in names))
finally:
if prev_data is None:
os.environ.pop("WECHAT_TOOL_DATA_DIR", None)
else:
os.environ["WECHAT_TOOL_DATA_DIR"] = prev_data
def test_remote_thumb_enabled_downloads_and_rewrites(self):
with TemporaryDirectory() as td:
root = Path(td)
account = "wxid_test"
username = "wxid_friend"
_, link_thumb, quote_thumb = self._prepare_account(root, account=account, username=username)
prev_data = os.environ.get("WECHAT_TOOL_DATA_DIR")
try:
os.environ["WECHAT_TOOL_DATA_DIR"] = str(root)
svc = self._reload_export_modules()
fake_png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x02\x00\x00\x00\x90wS\xde"
def _fake_get(url, **_kwargs):
return _FakeResponse(fake_png, content_type="image/png")
with mock.patch.object(svc.requests, "get", side_effect=_fake_get) as m_get:
job = self._create_job(
svc.CHAT_EXPORT_MANAGER,
account=account,
username=username,
download_remote_media=True,
)
self.assertEqual(job.status, "done", msg=job.error)
self.assertGreaterEqual(m_get.call_count, 1)
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)
html_text = zf.read(html_path).decode("utf-8")
h1 = hashlib.sha256(link_thumb.encode("utf-8", errors="ignore")).hexdigest()
arc1 = f"media/remote/{h1[:32]}.png"
self.assertIn(arc1, names)
self.assertIn(f"../../{arc1}", html_text)
self.assertNotIn(f'src="{link_thumb}"', html_text)
h2 = hashlib.sha256(quote_thumb.encode("utf-8", errors="ignore")).hexdigest()
arc2 = f"media/remote/{h2[:32]}.png"
self.assertIn(arc2, names)
self.assertIn(f"../../{arc2}", html_text)
self.assertNotIn(f'src="{quote_thumb}"', html_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")
@@ -0,0 +1,58 @@
import sys
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
from wechat_decrypt_tool.chat_helpers import _parse_app_message
class TestChatOfficialArticleCoverStyle(unittest.TestCase):
def test_mp_weixin_feed_url_is_cover_style(self):
raw_text = (
"<msg>"
"<appmsg>"
"<title>时尚穿搭:「这样的jk你喜欢吗」</title>"
"<des>这样的jk你喜欢吗?</des>"
"<type>5</type>"
"<url>"
"http://mp.weixin.qq.com/s?__biz=MzkxOTY4MjIxOA==&amp;mid=2247508015&amp;idx=1&amp;sn=931dce677c6e70b4365792b14e7e8ff0"
"&amp;exptype=masonry_feed_brief_content_elite_for_pcfeeds_u2i&amp;ranksessionid=1770868256_1&amp;req_id=1770867949535989#rd"
"</url>"
"<thumburl>https://mmbiz.qpic.cn/sz_mmbiz_jpg/foo/640?wx_fmt=jpeg&amp;wxfrom=401</thumburl>"
"<sourcedisplayname>甜图社</sourcedisplayname>"
"<sourceusername>gh_abc123</sourceusername>"
"</appmsg>"
"</msg>"
)
parsed = _parse_app_message(raw_text)
self.assertEqual(parsed.get("renderType"), "link")
self.assertEqual(parsed.get("linkType"), "official_article")
self.assertEqual(parsed.get("linkStyle"), "cover")
def test_mp_weixin_non_feed_url_keeps_default_style(self):
raw_text = (
"<msg>"
"<appmsg>"
"<title>普通分享</title>"
"<des>这样的jk你喜欢吗?</des>"
"<type>5</type>"
"<url>http://mp.weixin.qq.com/s?__biz=foo&amp;mid=1&amp;idx=1&amp;sn=bar#rd</url>"
"<sourcedisplayname>甜图社</sourcedisplayname>"
"<sourceusername>gh_abc123</sourceusername>"
"</appmsg>"
"</msg>"
)
parsed = _parse_app_message(raw_text)
self.assertEqual(parsed.get("renderType"), "link")
self.assertEqual(parsed.get("linkType"), "official_article")
self.assertEqual(parsed.get("linkStyle"), "default")
if __name__ == "__main__":
unittest.main()
+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"), "通过群聊添加")
+62 -1
View File
@@ -62,7 +62,68 @@ class TestTransferPostprocess(unittest.TestCase):
self.assertEqual(merged[0].get("transferStatus"), "已被接收")
def test_pending_transfer_marked_expired_by_system_message(self):
merged = [
{
"id": "message_0:Msg_x:100",
"renderType": "transfer",
"paySubType": "1",
"transferId": "t-expired-1",
"amount": "¥500.00",
"createTime": 1770742598,
"isSent": True,
"transferStatus": "转账",
},
{
"id": "message_0:Msg_x:101",
"renderType": "system",
"type": 10000,
"createTime": 1770829000,
"content": "收款方24小时内未接收你的转账,已过期",
},
]
chat_router._postprocess_transfer_messages(merged)
self.assertEqual(merged[0].get("paySubType"), "10")
self.assertEqual(merged[0].get("transferStatus"), "已过期")
def test_expired_matching_wins_over_amount_time_received_fallback(self):
merged = [
{
"id": "message_0:Msg_x:200",
"renderType": "transfer",
"paySubType": "1",
"transferId": "t-expired-2",
"amount": "¥500.00",
"createTime": 1770742598,
"isSent": True,
"transferStatus": "",
},
{
"id": "message_0:Msg_x:201",
"renderType": "transfer",
"paySubType": "3",
"transferId": "t-other",
"amount": "¥500.00",
"createTime": 1770828800,
"isSent": False,
"transferStatus": "已收款",
},
{
"id": "message_0:Msg_x:202",
"renderType": "system",
"type": 10000,
"createTime": 1770829000,
"content": "收款方24小时内未接收你的转账,已过期",
},
]
chat_router._postprocess_transfer_messages(merged)
self.assertEqual(merged[0].get("paySubType"), "10")
self.assertEqual(merged[0].get("transferStatus"), "已过期")
if __name__ == "__main__":
unittest.main()
+773
View File
@@ -0,0 +1,773 @@
import hashlib
import sqlite3
import sys
import unittest
from datetime import datetime
from pathlib import Path
from tempfile import TemporaryDirectory
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
class TestWrappedEmojiUniverse(unittest.TestCase):
def _ts(self, y: int, m: int, d: int, h: int = 0, mi: int = 0, s: int = 0) -> int:
return int(datetime(y, m, d, h, mi, s).timestamp())
def _seed_contact_db(self, path: Path, *, account: str, usernames: list[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, "", "", "", 1, 0, "", ""),
)
for idx, username in enumerate(usernames):
conn.execute(
"INSERT INTO contact VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
(username, "", f"好友{idx + 1}", "", 1, 0, "", ""),
)
conn.commit()
finally:
conn.close()
def _seed_session_db(self, path: Path, *, usernames: list[str]) -> None:
conn = sqlite3.connect(str(path))
try:
conn.execute(
"""
CREATE TABLE SessionTable (
username TEXT,
is_hidden INTEGER,
sort_timestamp INTEGER
)
"""
)
for username in usernames:
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,
rows: list[dict[str, object]],
) -> None:
table_name = f"msg_{hashlib.md5(username.encode('utf-8')).hexdigest()}"
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))
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,
packed_info_data BLOB
)
"""
)
for row in rows:
conn.execute(
f"""
INSERT INTO {table_name}
(local_id, server_id, local_type, sort_seq, real_sender_id, create_time, message_content, compress_content, packed_info_data)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
int(row.get("local_id", 0)),
int(row.get("server_id", 0)),
int(row.get("local_type", 0)),
int(row.get("sort_seq", row.get("local_id", 0))),
int(row.get("real_sender_id", 1)),
int(row.get("create_time", 0)),
str(row.get("message_content", "")),
row.get("compress_content"),
row.get("packed_info_data"),
),
)
conn.commit()
finally:
conn.close()
def _seed_index_db(self, path: Path, *, rows: list[dict[str, object]]) -> None:
conn = sqlite3.connect(str(path))
try:
conn.execute(
"""
CREATE TABLE message_fts (
text TEXT,
username TEXT,
render_type TEXT,
create_time INTEGER,
sort_seq INTEGER,
local_id INTEGER,
server_id INTEGER,
local_type INTEGER,
db_stem TEXT,
table_name TEXT,
sender_username TEXT,
is_hidden INTEGER,
is_official INTEGER
)
"""
)
for row in rows:
conn.execute(
"""
INSERT INTO message_fts (
text, username, render_type, create_time, sort_seq, local_id, server_id, local_type,
db_stem, table_name, sender_username, is_hidden, is_official
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
str(row.get("text", "")),
str(row.get("username", "")),
str(row.get("render_type", "")),
int(row.get("create_time", 0)),
int(row.get("sort_seq", 0)),
int(row.get("local_id", 0)),
int(row.get("server_id", 0)),
int(row.get("local_type", 0)),
str(row.get("db_stem", "message_0")),
str(row.get("table_name", "")),
str(row.get("sender_username", "")),
int(row.get("is_hidden", 0)),
int(row.get("is_official", 0)),
),
)
conn.commit()
finally:
conn.close()
def _seed_resource_db(
self,
path: Path,
*,
username: str,
md5: str,
server_id: int,
local_id: int,
create_time: int,
) -> None:
conn = sqlite3.connect(str(path))
try:
conn.execute("CREATE TABLE ChatName2Id (user_name TEXT)")
conn.execute("INSERT INTO ChatName2Id (rowid, user_name) VALUES (?, ?)", (7, username))
conn.execute(
"""
CREATE TABLE MessageResourceInfo (
message_id INTEGER PRIMARY KEY AUTOINCREMENT,
message_svr_id INTEGER,
chat_id INTEGER,
message_local_type INTEGER,
packed_info BLOB,
message_local_id INTEGER,
message_create_time INTEGER
)
"""
)
packed = f"/tmp/{md5}.dat".encode("utf-8")
conn.execute(
"""
INSERT INTO MessageResourceInfo
(message_svr_id, chat_id, message_local_type, packed_info, message_local_id, message_create_time)
VALUES (?, ?, ?, ?, ?, ?)
""",
(int(server_id), 7, 47, packed, int(local_id), int(create_time)),
)
conn.commit()
finally:
conn.close()
def test_only_sticker_messages_outputs_core_stats(self):
from wechat_decrypt_tool.wrapped.cards.card_04_emoji_universe import compute_emoji_universe_stats
with TemporaryDirectory() as td:
root = Path(td)
account = "wxid_me"
friend = "wxid_friend_a"
account_dir = root / account
account_dir.mkdir(parents=True, exist_ok=True)
self._seed_contact_db(account_dir / "contact.db", account=account, usernames=[friend])
self._seed_session_db(account_dir / "session.db", usernames=[friend])
md5_a = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
md5_b = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
rows = [
{
"local_id": 1,
"server_id": 1001,
"local_type": 47,
"create_time": self._ts(2025, 1, 1, 10, 5, 0),
"message_content": f'<msg><emoji md5="{md5_a}" cdnurl="http://cdn/a.gif"/></msg>',
},
{
"local_id": 2,
"server_id": 1002,
"local_type": 47,
"create_time": self._ts(2025, 1, 1, 10, 30, 0),
"message_content": f'<msg><emoji md5="{md5_a}" cdnurl="http://cdn/a2.gif"/></msg>',
},
{
"local_id": 3,
"server_id": 1003,
"local_type": 47,
"create_time": self._ts(2025, 1, 2, 22, 10, 0),
"message_content": f'<msg><emoji md5="{md5_b}" cdnurl="http://cdn/b.gif"/></msg>',
},
]
self._seed_message_db(account_dir / "message_0.db", account=account, username=friend, rows=rows)
table_name = f"msg_{hashlib.md5(friend.encode('utf-8')).hexdigest()}"
fts_rows = []
for row in rows:
fts_rows.append(
{
"text": "[表情]",
"username": friend,
"render_type": "emoji",
"create_time": row["create_time"],
"sort_seq": row["local_id"],
"local_id": row["local_id"],
"server_id": row["server_id"],
"local_type": 47,
"db_stem": "message_0",
"table_name": table_name,
"sender_username": account,
}
)
self._seed_index_db(account_dir / "chat_search_index.db", rows=fts_rows)
data = compute_emoji_universe_stats(account_dir=account_dir, year=2025)
self.assertTrue(data["settings"]["usedIndex"])
self.assertEqual(data["sentStickerCount"], 3)
self.assertEqual(data["peakHour"], 10)
self.assertIsNotNone(data["peakWeekday"])
self.assertEqual(data["topBattlePartner"]["username"], friend)
self.assertEqual(data["topBattlePartner"]["stickerCount"], 3)
self.assertEqual(data["topBattlePartner"]["maskedName"], data["topBattlePartner"]["displayName"])
self.assertEqual(data["topStickers"][0]["md5"], md5_a)
self.assertEqual(data["topStickers"][0]["count"], 2)
self.assertTrue(str(data["topStickers"][0].get("sampleDisplayName") or "").strip())
self.assertTrue(str(data["topStickers"][0].get("sampleAvatarUrl") or "").startswith("/api/chat/avatar"))
def test_fallback_to_resource_md5_when_xml_missing(self):
from wechat_decrypt_tool.wrapped.cards.card_04_emoji_universe import compute_emoji_universe_stats
with TemporaryDirectory() as td:
root = Path(td)
account = "wxid_me"
friend = "wxid_friend_b"
account_dir = root / account
account_dir.mkdir(parents=True, exist_ok=True)
self._seed_contact_db(account_dir / "contact.db", account=account, usernames=[friend])
self._seed_session_db(account_dir / "session.db", usernames=[friend])
ts = self._ts(2025, 3, 8, 21, 0, 0)
rows = [
{
"local_id": 11,
"server_id": 220011,
"local_type": 47,
"create_time": ts,
"message_content": '<msg><emoji cdnurl="http://cdn/no_md5.gif"/></msg>',
}
]
self._seed_message_db(account_dir / "message_0.db", account=account, username=friend, rows=rows)
md5_fallback = "cccccccccccccccccccccccccccccccc"
self._seed_resource_db(
account_dir / "message_resource.db",
username=friend,
md5=md5_fallback,
server_id=220011,
local_id=11,
create_time=ts,
)
data = compute_emoji_universe_stats(account_dir=account_dir, year=2025)
self.assertFalse(data["settings"]["usedIndex"])
self.assertEqual(data["sentStickerCount"], 1)
self.assertEqual(data["topStickers"][0]["md5"], md5_fallback)
self.assertEqual(data["topStickers"][0]["count"], 1)
def test_text_emoji_mapping_from_wechat_emojis_ts(self):
from wechat_decrypt_tool.wrapped.cards.card_04_emoji_universe import compute_emoji_universe_stats
with TemporaryDirectory() as td:
root = Path(td)
account = "wxid_me"
friend = "wxid_friend_c"
account_dir = root / account
account_dir.mkdir(parents=True, exist_ok=True)
self._seed_contact_db(account_dir / "contact.db", account=account, usernames=[friend])
self._seed_session_db(account_dir / "session.db", usernames=[friend])
table_name = f"msg_{hashlib.md5(friend.encode('utf-8')).hexdigest()}"
fts_rows = [
{
"text": "早上好[微笑][微笑]🙂🙂",
"username": friend,
"render_type": "text",
"create_time": self._ts(2025, 4, 1, 9, 0, 0),
"local_id": 1,
"server_id": 901,
"local_type": 1,
"db_stem": "message_0",
"table_name": table_name,
"sender_username": account,
},
{
"text": "晚上见[微笑][发呆]😂",
"username": friend,
"render_type": "text",
"create_time": self._ts(2025, 4, 1, 22, 0, 0),
"local_id": 2,
"server_id": 902,
"local_type": 1,
"db_stem": "message_0",
"table_name": table_name,
"sender_username": account,
},
]
self._seed_index_db(account_dir / "chat_search_index.db", rows=fts_rows)
data = compute_emoji_universe_stats(account_dir=account_dir, year=2025)
self.assertTrue(data["settings"]["usedIndex"])
self.assertGreaterEqual(len(data["topTextEmojis"]), 1)
self.assertEqual(data["topTextEmojis"][0]["key"], "[微笑]")
self.assertEqual(data["topTextEmojis"][0]["count"], 3)
self.assertTrue(data["topTextEmojis"][0]["assetPath"].endswith("Expression_1@2x.png"))
self.assertGreaterEqual(len(data["topUnicodeEmojis"]), 1)
self.assertEqual(data["topUnicodeEmojis"][0]["emoji"], "🙂")
self.assertEqual(data["topUnicodeEmojis"][0]["count"], 2)
def test_wechat_builtin_emoji_from_packed_info_data(self):
from wechat_decrypt_tool.wrapped.cards.card_04_emoji_universe import compute_emoji_universe_stats
with TemporaryDirectory() as td:
root = Path(td)
account = "wxid_me"
friend = "wxid_friend_e"
account_dir = root / account
account_dir.mkdir(parents=True, exist_ok=True)
self._seed_contact_db(account_dir / "contact.db", account=account, usernames=[friend])
self._seed_session_db(account_dir / "session.db", usernames=[friend])
# packed_info_data protobuf varints:
# 08 04 => field#1=4
# 10 33 => field#2=51 (Expression_51@2x)
rows = [
{
"local_id": 1,
"server_id": 501,
"local_type": 47,
"create_time": self._ts(2025, 7, 1, 10, 0, 0),
"message_content": "binary_emoji_payload_a",
"packed_info_data": bytes.fromhex("08041033"),
},
{
"local_id": 2,
"server_id": 502,
"local_type": 47,
"create_time": self._ts(2025, 7, 1, 10, 1, 0),
"message_content": "binary_emoji_payload_b",
"packed_info_data": bytes.fromhex("08041033"),
},
{
"local_id": 3,
"server_id": 503,
"local_type": 47,
"create_time": self._ts(2025, 7, 1, 11, 0, 0),
"message_content": "binary_emoji_payload_c",
"packed_info_data": bytes.fromhex("0804104a"),
},
]
self._seed_message_db(account_dir / "message_0.db", account=account, username=friend, rows=rows)
data = compute_emoji_universe_stats(account_dir=account_dir, year=2025)
self.assertFalse(data["settings"]["usedIndex"])
self.assertEqual(data["sentStickerCount"], 3)
self.assertGreaterEqual(len(data["topWechatEmojis"]), 1)
self.assertEqual(data["topWechatEmojis"][0]["id"], 51)
self.assertEqual(data["topWechatEmojis"][0]["count"], 2)
self.assertTrue(data["topWechatEmojis"][0]["assetPath"].endswith("Expression_51@2x.png"))
self.assertGreaterEqual(len(data["topStickers"]), 1)
self.assertEqual(data["topStickers"][0]["emojiId"], 51)
self.assertEqual(data["topStickers"][0]["count"], 2)
self.assertTrue(str(data["topStickers"][0].get("emojiAssetPath") or "").endswith("Expression_51@2x.png"))
def test_index_counts_only_sent_messages(self):
from wechat_decrypt_tool.wrapped.cards.card_04_emoji_universe import compute_emoji_universe_stats
with TemporaryDirectory() as td:
root = Path(td)
account = "wxid_me"
friend = "wxid_friend_sent_only"
account_dir = root / account
account_dir.mkdir(parents=True, exist_ok=True)
self._seed_contact_db(account_dir / "contact.db", account=account, usernames=[friend])
self._seed_session_db(account_dir / "session.db", usernames=[friend])
rows = [
{
"text": "[ 微 笑 ]",
"username": friend,
"render_type": "text",
"create_time": self._ts(2025, 6, 2, 9, 0, 0),
"local_id": 101,
"server_id": 4001,
"local_type": 1,
"table_name": "msg_dummy",
"sender_username": account,
},
{
"text": "[ 发 呆 ]",
"username": friend,
"render_type": "text",
"create_time": self._ts(2025, 6, 2, 9, 1, 0),
"local_id": 102,
"server_id": 4002,
"local_type": 1,
"table_name": "msg_dummy",
"sender_username": friend,
},
{
"text": "[表情]",
"username": friend,
"render_type": "emoji",
"create_time": self._ts(2025, 6, 2, 9, 2, 0),
"local_id": 201,
"server_id": 5001,
"local_type": 47,
"table_name": "msg_dummy",
"sender_username": account,
},
{
"text": "[表情]",
"username": friend,
"render_type": "emoji",
"create_time": self._ts(2025, 6, 2, 9, 3, 0),
"local_id": 202,
"server_id": 5002,
"local_type": 47,
"table_name": "msg_dummy",
"sender_username": friend,
},
]
self._seed_index_db(account_dir / "chat_search_index.db", rows=rows)
data = compute_emoji_universe_stats(account_dir=account_dir, year=2025)
self.assertTrue(data["settings"]["usedIndex"])
self.assertEqual(data["sentStickerCount"], 1)
keys = {x.get("key") for x in data.get("topTextEmojis") or []}
self.assertIn("[微笑]", keys)
self.assertNotIn("[发呆]", keys)
def test_raw_db_counts_only_sent_messages(self):
from wechat_decrypt_tool.wrapped.cards.card_04_emoji_universe import compute_emoji_universe_stats
with TemporaryDirectory() as td:
root = Path(td)
account = "wxid_me"
friend = "wxid_friend_raw_dir"
account_dir = root / account
account_dir.mkdir(parents=True, exist_ok=True)
self._seed_contact_db(account_dir / "contact.db", account=account, usernames=[friend])
self._seed_session_db(account_dir / "session.db", usernames=[friend])
rows = [
{
"local_id": 1,
"server_id": 1001,
"local_type": 1,
"real_sender_id": 1,
"create_time": self._ts(2025, 7, 1, 8, 0, 0),
"message_content": "/::B",
},
{
"local_id": 2,
"server_id": 1002,
"local_type": 1,
"real_sender_id": 2,
"create_time": self._ts(2025, 7, 1, 8, 1, 0),
"message_content": "/::B",
},
{
"local_id": 3,
"server_id": 1101,
"local_type": 47,
"real_sender_id": 1,
"create_time": self._ts(2025, 7, 1, 9, 0, 0),
"message_content": "binary_emoji_payload_a",
"packed_info_data": bytes.fromhex("08031033"),
},
{
"local_id": 4,
"server_id": 1102,
"local_type": 47,
"real_sender_id": 2,
"create_time": self._ts(2025, 7, 1, 9, 1, 0),
"message_content": "binary_emoji_payload_b",
"packed_info_data": bytes.fromhex("08031033"),
},
]
self._seed_message_db(account_dir / "message_0.db", account=account, username=friend, rows=rows)
data = compute_emoji_universe_stats(account_dir=account_dir, year=2025)
self.assertFalse(data["settings"]["usedIndex"])
self.assertEqual(data["sentStickerCount"], 1)
self.assertEqual(data["topWechatEmojis"][0]["id"], 51)
self.assertEqual(data["topWechatEmojis"][0]["count"], 1)
self.assertGreaterEqual(len(data["topTextEmojis"]), 1)
self.assertEqual(data["topTextEmojis"][0]["key"], "[色]")
self.assertEqual(data["topTextEmojis"][0]["count"], 1)
self.assertTrue(data["topTextEmojis"][0]["assetPath"].endswith("Expression_3@2x.png"))
def test_new_and_revived_sticker_metrics(self):
from wechat_decrypt_tool.wrapped.cards.card_04_emoji_universe import compute_emoji_universe_stats
with TemporaryDirectory() as td:
root = Path(td)
account = "wxid_me"
friend = "wxid_friend_new_revived"
account_dir = root / account
account_dir.mkdir(parents=True, exist_ok=True)
self._seed_contact_db(account_dir / "contact.db", account=account, usernames=[friend])
self._seed_session_db(account_dir / "session.db", usernames=[friend])
md5_revived = "dddddddddddddddddddddddddddddddd"
md5_recent = "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"
md5_new = "ffffffffffffffffffffffffffffffff"
rows = [
{
"local_id": 1,
"server_id": 5001,
"local_type": 47,
"create_time": self._ts(2024, 1, 1, 9, 0, 0),
"message_content": f'<msg><emoji md5="{md5_revived}" /></msg>',
},
{
"local_id": 2,
"server_id": 5002,
"local_type": 47,
"create_time": self._ts(2024, 12, 28, 10, 0, 0),
"message_content": f'<msg><emoji md5="{md5_recent}" /></msg>',
},
{
"local_id": 3,
"server_id": 5003,
"local_type": 47,
"create_time": self._ts(2025, 1, 5, 11, 0, 0),
"message_content": f'<msg><emoji md5="{md5_recent}" /></msg>',
},
{
"local_id": 4,
"server_id": 5004,
"local_type": 47,
"create_time": self._ts(2025, 3, 15, 12, 0, 0),
"message_content": f'<msg><emoji md5="{md5_revived}" /></msg>',
},
{
"local_id": 5,
"server_id": 5005,
"local_type": 47,
"create_time": self._ts(2025, 5, 10, 13, 0, 0),
"message_content": f'<msg><emoji md5="{md5_new}" /></msg>',
},
]
self._seed_message_db(account_dir / "message_0.db", account=account, username=friend, rows=rows)
data = compute_emoji_universe_stats(account_dir=account_dir, year=2025)
self.assertEqual(data["sentStickerCount"], 3)
self.assertEqual(data["uniqueStickerTypeCount"], 3)
self.assertEqual(data["newStickerCountThisYear"], 1)
self.assertEqual(data["revivedStickerCount"], 1)
self.assertEqual(data["revivedMinGapDays"], 60)
self.assertGreaterEqual(int(data.get("revivedMaxGapDays") or 0), 400)
new_samples = list(data.get("newStickerSamples") or [])
revived_samples = list(data.get("revivedStickerSamples") or [])
self.assertTrue(any(str(x.get("md5") or "") == md5_new for x in new_samples))
self.assertTrue(any(str(x.get("md5") or "") == md5_revived for x in revived_samples))
revived_item = next((x for x in revived_samples if str(x.get("md5") or "") == md5_revived), {})
self.assertGreaterEqual(int(revived_item.get("gapDays") or 0), 400)
def test_empty_year_returns_safe_empty_state(self):
from wechat_decrypt_tool.wrapped.cards.card_04_emoji_universe import build_card_04_emoji_universe
with TemporaryDirectory() as td:
root = Path(td)
account = "wxid_me"
account_dir = root / account
account_dir.mkdir(parents=True, exist_ok=True)
self._seed_contact_db(account_dir / "contact.db", account=account, usernames=[])
self._seed_session_db(account_dir / "session.db", usernames=[])
card = build_card_04_emoji_universe(account_dir=account_dir, year=2025)
self.assertEqual(card["id"], 4)
self.assertEqual(card["status"], "ok")
self.assertEqual(card["data"]["sentStickerCount"], 0)
self.assertIn("几乎没用表情表达", card["narrative"])
self.assertIsInstance(card["data"]["lines"], list)
self.assertGreaterEqual(len(card["data"]["lines"]), 1)
self.assertEqual(card["data"].get("topUnicodeEmojis"), [])
def test_tie_break_is_stable_by_key(self):
from wechat_decrypt_tool.wrapped.cards.card_04_emoji_universe import compute_emoji_universe_stats
with TemporaryDirectory() as td:
root = Path(td)
account = "wxid_me"
friend = "wxid_friend_d"
account_dir = root / account
account_dir.mkdir(parents=True, exist_ok=True)
self._seed_contact_db(account_dir / "contact.db", account=account, usernames=[friend])
self._seed_session_db(account_dir / "session.db", usernames=[friend])
md5_a = "11111111111111111111111111111111"
md5_b = "22222222222222222222222222222222"
rows = [
{
"local_id": 1,
"server_id": 301,
"local_type": 47,
"create_time": self._ts(2025, 6, 1, 8, 0, 0),
"message_content": f'<msg><emoji md5="{md5_a}" /></msg>',
},
{
"local_id": 2,
"server_id": 302,
"local_type": 47,
"create_time": self._ts(2025, 6, 1, 8, 1, 0),
"message_content": f'<msg><emoji md5="{md5_b}" /></msg>',
},
{
"local_id": 3,
"server_id": 303,
"local_type": 47,
"create_time": self._ts(2025, 6, 1, 8, 2, 0),
"message_content": f'<msg><emoji md5="{md5_a}" /></msg>',
},
{
"local_id": 4,
"server_id": 304,
"local_type": 47,
"create_time": self._ts(2025, 6, 1, 8, 3, 0),
"message_content": f'<msg><emoji md5="{md5_b}" /></msg>',
},
]
self._seed_message_db(account_dir / "message_0.db", account=account, username=friend, rows=rows)
table_name = f"msg_{hashlib.md5(friend.encode('utf-8')).hexdigest()}"
fts_rows = []
for row in rows:
fts_rows.append(
{
"text": "[表情]",
"username": friend,
"render_type": "emoji",
"create_time": row["create_time"],
"local_id": row["local_id"],
"server_id": row["server_id"],
"local_type": 47,
"db_stem": "message_0",
"table_name": table_name,
"sender_username": account,
}
)
fts_rows.extend(
[
{
# `chat_search_index` stores text as char-tokens: "[微笑][发呆]" -> "[ 微 笑 ] [ 发 呆 ]"
"text": "[ 微 笑 ] [ 发 呆 ]",
"username": friend,
"render_type": "text",
"create_time": self._ts(2025, 6, 2, 9, 0, 0),
"local_id": 101,
"server_id": 4001,
"local_type": 1,
"db_stem": "message_0",
"table_name": table_name,
"sender_username": account,
},
{
"text": "[ 发 呆 ] [ 微 笑 ]",
"username": friend,
"render_type": "text",
"create_time": self._ts(2025, 6, 2, 9, 1, 0),
"local_id": 102,
"server_id": 4002,
"local_type": 1,
"db_stem": "message_0",
"table_name": table_name,
"sender_username": account,
},
]
)
self._seed_index_db(account_dir / "chat_search_index.db", rows=fts_rows)
data = compute_emoji_universe_stats(account_dir=account_dir, year=2025)
self.assertEqual(data["topStickers"][0]["md5"], md5_a)
expected_emoji_key = sorted(["[微笑]", "[发呆]"])[0]
self.assertEqual(data["topTextEmojis"][0]["key"], expected_emoji_key)
if __name__ == "__main__":
unittest.main()
+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()