feat(chat): 聊天页支持日历定位/卡片解析/HTML导出分页

- 新增 /api/chat/messages/daily_counts 与 /api/chat/messages/anchor,用于月度热力图与按日/首条定位\n- messages/around 支持跨 message 分片定位,定位更稳定\n- 新增 /api/chat/chat_history/resolve 与 /api/chat/appmsg/resolve,合并转发/链接卡片可按 server_id 补全\n- 新增 /api/chat/media/favicon,并补齐 link 本地缩略图处理\n- HTML 导出支持分页加载(html_page_size),避免大聊天单文件卡顿\n- tests: 覆盖 heatmap/anchor、favicon 缓存、HTML 分页导出
This commit is contained in:
2977094657
2026-02-15 14:32:47 +08:00
parent 31d98abddf
commit bd44601611
10 changed files with 4375 additions and 302 deletions

View File

@@ -786,6 +786,128 @@
@apply px-3 py-3 border-b border-gray-100; @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 { .search-input-combined {
@apply flex items-center bg-white border-2 border-gray-200 rounded-lg overflow-hidden transition-all duration-200; @apply flex items-center bg-white border-2 border-gray-200 rounded-lg overflow-hidden transition-all duration-200;

View File

@@ -180,6 +180,46 @@ export const useApi = () => {
return await request(url) 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 listSnsTimeline = async (params = {}) => {
const query = new URLSearchParams() const query = new URLSearchParams()
@@ -295,6 +335,7 @@ export const useApi = () => {
output_dir: data.output_dir == null ? null : String(data.output_dir || '').trim(), output_dir: data.output_dir == null ? null : String(data.output_dir || '').trim(),
allow_process_key_extract: !!data.allow_process_key_extract, allow_process_key_extract: !!data.allow_process_key_extract,
download_remote_media: !!data.download_remote_media, 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, privacy_mode: !!data.privacy_mode,
file_name: data.file_name || null file_name: data.file_name || null
} }
@@ -408,6 +449,10 @@ export const useApi = () => {
buildChatSearchIndex, buildChatSearchIndex,
listChatSearchSenders, listChatSearchSenders,
getChatMessagesAround, getChatMessagesAround,
getChatMessageDailyCounts,
getChatMessageAnchor,
resolveNestedChatHistory,
resolveAppMsg,
listSnsTimeline, listSnsTimeline,
listSnsMediaCandidates, listSnsMediaCandidates,
saveSnsMediaPicks, saveSnsMediaPicks,

File diff suppressed because it is too large Load Diff

View File

@@ -541,6 +541,11 @@ body { background: #EDEDED; }
.wce-chat-title { font-size: 16px; font-weight: 500; color: #111827; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .wce-chat-title { font-size: 16px; font-weight: 500; color: #111827; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.wce-filter-select { font-size: 12px; padding: 6px 8px; border: 0; border-radius: 8px; background: transparent; color: #374151; } .wce-filter-select { font-size: 12px; padding: 6px 8px; border: 0; border-radius: 8px; background: transparent; color: #374151; }
.wce-message-container { flex: 1; overflow: auto; padding: 16px; min-height: 0; } .wce-message-container { flex: 1; overflow: auto; padding: 16px; min-height: 0; }
.wce-pager { display: flex; align-items: center; justify-content: center; gap: 12px; padding: 6px 0 12px; }
.wce-pager-btn { font-size: 12px; padding: 6px 10px; border-radius: 8px; border: 1px solid #e5e7eb; background: #fff; color: #374151; cursor: pointer; }
.wce-pager-btn:hover { background: #f9fafb; }
.wce-pager-btn:disabled { opacity: 0.6; cursor: not-allowed; }
.wce-pager-status { font-size: 12px; color: #6b7280; }
/* Single session item (middle column). */ /* Single session item (middle column). */
.wce-session-item { display: flex; align-items: center; gap: 12px; padding: 0 12px; height: 80px; border-bottom: 1px solid #f3f4f6; background: #DEDEDE; text-decoration: none; color: inherit; } .wce-session-item { display: flex; align-items: center; gap: 12px; padding: 0 12px; height: 80px; border-bottom: 1px solid #f3f4f6; background: #DEDEDE; text-decoration: none; color: inherit; }
@@ -838,6 +843,140 @@ _HTML_EXPORT_JS = r"""
return obj return obj
} }
const readPageMeta = () => {
const el = document.getElementById('wcePageMeta')
const obj = safeJsonParse(el ? el.textContent : '')
if (!obj || typeof obj !== 'object') return null
return obj
}
const initPagedMessageLoading = () => {
const meta = readPageMeta()
if (!meta) return
const totalPages = Number(meta.totalPages || 0)
if (!Number.isFinite(totalPages) || totalPages <= 1) return
const initialPage = Number(meta.initialPage || totalPages || 1)
const padWidth = Number(meta.padWidth || 0) || 0
const prefix = String(meta.pageFilePrefix || 'pages/page-')
const suffix = String(meta.pageFileSuffix || '.js')
const container = document.getElementById('messageContainer')
const list = document.getElementById('wceMessageList') || container
const pager = document.getElementById('wcePager')
const btn = document.getElementById('wceLoadPrevBtn')
const status = document.getElementById('wceLoadPrevStatus')
if (!container || !list || !pager || !btn) return
try { pager.style.display = '' } catch {}
const loaded = new Set()
loaded.add(initialPage)
let nextPage = initialPage - 1
let loading = false
const setStatus = (text) => {
try { if (status) status.textContent = String(text || '') } catch {}
}
const updateUi = (overrideText) => {
if (overrideText != null) {
setStatus(overrideText)
try { btn.disabled = false } catch {}
return
}
if (nextPage < 1) {
setStatus('已到底')
try { btn.disabled = true } catch {}
return
}
if (loading) {
setStatus('加载中...')
try { btn.disabled = true } catch {}
return
}
setStatus('点击加载更早消息')
try { btn.disabled = false } catch {}
}
const pageSrc = (n) => {
const num = padWidth > 0 ? String(n).padStart(padWidth, '0') : String(n)
return prefix + num + suffix
}
window.__WCE_PAGE_QUEUE__ = window.__WCE_PAGE_QUEUE__ || []
window.__WCE_PAGE_LOADED__ = (pageNo, html) => {
const n = Number(pageNo)
if (!Number.isFinite(n) || n < 1) return
if (loaded.has(n)) return
loaded.add(n)
try {
const prevH = container.scrollHeight
const prevTop = container.scrollTop
list.insertAdjacentHTML('afterbegin', String(html || ''))
const newH = container.scrollHeight
container.scrollTop = prevTop + (newH - prevH)
} catch {
try { list.insertAdjacentHTML('afterbegin', String(html || '')) } catch {}
}
loading = false
nextPage = n - 1
try { applyMessageTypeFilter() } catch {}
try { updateSessionMessageCount() } catch {}
updateUi()
}
// Flush any queued pages (should be rare, but keeps behavior robust).
try {
const q = window.__WCE_PAGE_QUEUE__
if (Array.isArray(q) && q.length) {
const items = q.slice(0)
q.length = 0
items.forEach((it) => {
try {
if (it && it.length >= 2) window.__WCE_PAGE_LOADED__(it[0], it[1])
} catch {}
})
}
} catch {}
const requestLoad = () => {
if (loading) return
if (nextPage < 1) return
const n = nextPage
loading = true
updateUi()
const s = document.createElement('script')
s.async = true
s.src = pageSrc(n)
s.onerror = () => {
loading = false
updateUi('加载失败,可重试')
}
try { document.body.appendChild(s) } catch {
loading = false
updateUi('加载失败,可重试')
}
}
btn.addEventListener('click', () => requestLoad())
let lastScrollAt = 0
container.addEventListener('scroll', () => {
const now = Date.now()
if (now - lastScrollAt < 200) return
lastScrollAt = now
if (container.scrollTop < 120) requestLoad()
})
updateUi()
}
const isMaybeMd5 = (value) => /^[0-9a-f]{32}$/i.test(String(value || '').trim()) const isMaybeMd5 = (value) => /^[0-9a-f]{32}$/i.test(String(value || '').trim())
const pickFirstMd5 = (...values) => { const pickFirstMd5 = (...values) => {
for (const v of values) { for (const v of values) {
@@ -926,28 +1065,90 @@ _HTML_EXPORT_JS = r"""
const getText = (node, tag) => { const getText = (node, tag) => {
try { try {
const el = node.getElementsByTagName(tag)?.[0] if (!node) return ''
const els = Array.from(node.getElementsByTagName(tag) || [])
const direct = els.find((el) => el && el.parentNode === node)
const el = direct || els[0]
return String(el?.textContent || '').trim() return String(el?.textContent || '').trim()
} catch { } catch {
return '' return ''
} }
} }
const getDirectChildXml = (node, tag) => {
try {
if (!node) return ''
const children = Array.from(node.children || [])
const el = children.find((c) => String(c?.tagName || '').toLowerCase() === String(tag || '').toLowerCase())
if (!el) return ''
const raw = String(el.textContent || '').trim()
if (raw && raw.startsWith('<') && raw.endsWith('>')) return raw
if (typeof XMLSerializer !== 'undefined') {
return new XMLSerializer().serializeToString(el)
}
} catch {}
return ''
}
const getAnyXml = (node, tag) => {
try {
if (!node) return ''
const els = Array.from(node.getElementsByTagName(tag) || [])
const direct = els.find((el) => el && el.parentNode === node)
const el = direct || els[0]
if (!el) return ''
const raw = String(el.textContent || '').trim()
if (raw && raw.startsWith('<') && raw.endsWith('>')) return raw
if (typeof XMLSerializer !== 'undefined') return new XMLSerializer().serializeToString(el)
} catch {}
return ''
}
const sameTag = (el, tag) => String(el?.tagName || '').toLowerCase() === String(tag || '').toLowerCase()
const closestAncestorByTag = (node, tag) => {
const lower = String(tag || '').toLowerCase()
let cur = node
while (cur) {
if (cur.nodeType === 1 && String(cur.tagName || '').toLowerCase() === lower) return cur
cur = cur.parentNode
}
return null
}
const root = doc?.documentElement const root = doc?.documentElement
const isChatRoom = String(getText(root, 'isChatRoom') || '').trim() === '1' const isChatRoom = String(getText(root, 'isChatRoom') || '').trim() === '1'
const title = getText(root, 'title') const title = getText(root, 'title')
const desc = getText(root, 'desc') || getText(root, 'info') const desc = getText(root, 'desc') || getText(root, 'info')
const items = Array.from(doc.getElementsByTagName('dataitem') || []) const datalist = (() => {
const parsed = items.map((node, idx) => { try {
const datatype = String(node.getAttribute('datatype') || '').trim() const all = Array.from(doc.getElementsByTagName('datalist') || [])
const dataid = String(node.getAttribute('dataid') || '').trim() || String(idx) const top = root ? all.find((el) => closestAncestorByTag(el, 'recorditem') === root) : null
return top || all[0] || null
} catch {
return null
}
})()
const itemNodes = (() => {
if (datalist) return Array.from(datalist.children || []).filter((el) => sameTag(el, 'dataitem'))
return Array.from(root?.children || []).filter((el) => sameTag(el, 'dataitem'))
})()
const parsed = itemNodes.map((node, idx) => {
const datatype = String(node.getAttribute('datatype') || getText(node, 'datatype') || '').trim()
const dataid = String(node.getAttribute('dataid') || getText(node, 'dataid') || '').trim() || String(idx)
const sourcename = getText(node, 'sourcename') const sourcename = getText(node, 'sourcename')
const sourcetime = getText(node, 'sourcetime') const sourcetime = getText(node, 'sourcetime')
const sourceheadurl = normalizeChatHistoryUrl(getText(node, 'sourceheadurl')) const sourceheadurl = normalizeChatHistoryUrl(getText(node, 'sourceheadurl'))
const datatitle = getText(node, 'datatitle') const datatitle = getText(node, 'datatitle')
const datadesc = getText(node, 'datadesc') const datadesc = getText(node, 'datadesc')
const link = normalizeChatHistoryUrl(getText(node, 'link') || getText(node, 'dataurl') || getText(node, 'url'))
const datafmt = getText(node, 'datafmt') const datafmt = getText(node, 'datafmt')
const duration = getText(node, 'duration') const duration = getText(node, 'duration')
@@ -961,6 +1162,7 @@ _HTML_EXPORT_JS = r"""
const fromnewmsgid = getText(node, 'fromnewmsgid') const fromnewmsgid = getText(node, 'fromnewmsgid')
const srcMsgLocalid = getText(node, 'srcMsgLocalid') const srcMsgLocalid = getText(node, 'srcMsgLocalid')
const srcMsgCreateTime = getText(node, 'srcMsgCreateTime') const srcMsgCreateTime = getText(node, 'srcMsgCreateTime')
const nestedRecordItem = getAnyXml(node, 'recorditem') || getDirectChildXml(node, 'recorditem') || getText(node, 'recorditem')
let content = datatitle || datadesc let content = datatitle || datadesc
if (!content) { if (!content) {
@@ -975,7 +1177,11 @@ _HTML_EXPORT_JS = r"""
const imageFormats = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'heic', 'heif']) const imageFormats = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'heic', 'heif'])
let renderType = 'text' let renderType = 'text'
if (datatype === '4' || String(duration || '').trim() || fmt === 'mp4') { if (datatype === '17') {
renderType = 'chatHistory'
} else if (datatype === '5' || link) {
renderType = 'link'
} else if (datatype === '4' || String(duration || '').trim() || fmt === 'mp4') {
renderType = 'video' renderType = 'video'
} else if (datatype === '47' || datatype === '37') { } else if (datatype === '47' || datatype === '37') {
renderType = 'emoji' renderType = 'emoji'
@@ -990,6 +1196,23 @@ _HTML_EXPORT_JS = r"""
renderType = 'emoji' renderType = 'emoji'
} }
let outTitle = ''
let outUrl = ''
let recordItem = ''
if (renderType === 'chatHistory') {
outTitle = datatitle || content || '聊天记录'
content = datadesc || ''
recordItem = nestedRecordItem
} else if (renderType === 'link') {
outTitle = datatitle || content || ''
outUrl = link || externurl || ''
// datadesc can be an invisible filler; only keep as description when meaningful.
const cleanDesc = String(datadesc || '').replace(/[\\u3164\\u2800]/g, '').trim()
const cleanTitle = String(outTitle || '').replace(/[\\u3164\\u2800]/g, '').trim()
if (!cleanDesc || (cleanTitle && cleanDesc === cleanTitle)) content = ''
else content = String(datadesc || '').trim()
}
return { return {
id: dataid, id: dataid,
datatype, datatype,
@@ -1009,6 +1232,9 @@ _HTML_EXPORT_JS = r"""
srcMsgLocalid, srcMsgLocalid,
srcMsgCreateTime, srcMsgCreateTime,
renderType, renderType,
title: outTitle,
recordItem,
url: outUrl,
content content
} }
}) })
@@ -1028,15 +1254,64 @@ _HTML_EXPORT_JS = r"""
if (!modal || !titleEl || !closeBtn || !emptyEl || !listEl) return if (!modal || !titleEl || !closeBtn || !emptyEl || !listEl) return
const mediaIndex = readMediaIndex() const mediaIndex = readMediaIndex()
let historyStack = []
let currentState = null
let backBtn = null
const updateBackVisibility = () => {
if (!backBtn) return
const show = Array.isArray(historyStack) && historyStack.length > 0
try { backBtn.classList.toggle('hidden', !show) } catch {}
}
// Add a back button next to the title (created at runtime to avoid changing the HTML template).
try {
const header = titleEl.parentElement
if (header) {
const wrap = document.createElement('div')
wrap.className = 'flex items-center gap-2 min-w-0'
backBtn = document.createElement('button')
backBtn.type = 'button'
backBtn.className = 'p-2 rounded hover:bg-black/5 flex-shrink-0 hidden'
try { backBtn.setAttribute('aria-label', '返回') } catch {}
try { backBtn.setAttribute('title', '返回') } catch {}
backBtn.innerHTML = '<svg class="w-5 h-5 text-gray-700" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" /></svg>'
header.insertBefore(wrap, titleEl)
wrap.appendChild(backBtn)
wrap.appendChild(titleEl)
}
} catch {}
const close = () => { const close = () => {
try { modal.classList.add('hidden') } catch {} try { modal.classList.add('hidden') } catch {}
try { modal.style.display = 'none' } catch {} try { modal.style.display = 'none' } catch {}
try { modal.setAttribute('aria-hidden', 'true') } catch {} try { modal.setAttribute('aria-hidden', 'true') } catch {}
try { document.body.style.overflow = '' } catch {} try { document.body.style.overflow = '' } catch {}
try { titleEl.textContent = '合并消息' } catch {} try { titleEl.textContent = '聊天记录' } catch {}
try { listEl.textContent = '' } catch {} try { listEl.textContent = '' } catch {}
try { emptyEl.style.display = '' } catch {} try { emptyEl.style.display = '' } catch {}
historyStack = []
currentState = null
updateBackVisibility()
}
const buildChatHistoryState = (payload) => {
const title = String(payload?.title || '聊天记录').trim() || '聊天记录'
const xml = String(payload?.recordItem || '').trim()
const parsed = parseChatHistoryRecord(xml)
const info = (parsed && parsed.info) ? parsed.info : { isChatRoom: false }
let records = (parsed && Array.isArray(parsed.items)) ? parsed.items : []
if (!records.length) {
const lines = Array.isArray(payload?.fallbackLines)
? payload.fallbackLines
: String(payload?.content || '').trim().split(/\r?\n/).map((x) => String(x || '').trim()).filter(Boolean)
records = lines.map((line, idx) => ({ id: String(idx), renderType: 'text', content: line, sourcename: '', sourcetime: '' }))
}
return { title, info, records }
} }
const renderRecordRow = (rec, info) => { const renderRecordRow = (rec, info) => {
@@ -1102,7 +1377,123 @@ _HTML_EXPORT_JS = r"""
const serverId = String(rec?.fromnewmsgid || '').trim() const serverId = String(rec?.fromnewmsgid || '').trim()
const serverMd5 = resolveServerMd5(mediaIndex, serverId) const serverMd5 = resolveServerMd5(mediaIndex, serverId)
if (rt === 'video') { if (rt === 'chatHistory') {
const card = document.createElement('div')
card.className = 'wechat-chat-history-card wechat-special-card msg-radius'
const chBody = document.createElement('div')
chBody.className = 'wechat-chat-history-body'
const chTitle = document.createElement('div')
chTitle.className = 'wechat-chat-history-title'
chTitle.textContent = String(rec?.title || '聊天记录')
chBody.appendChild(chTitle)
const raw = String(rec?.content || '').trim()
const lines = raw ? raw.split(/\r?\n/).map((x) => String(x || '').trim()).filter(Boolean).slice(0, 4) : []
if (lines.length) {
const preview = document.createElement('div')
preview.className = 'wechat-chat-history-preview'
for (const line of lines) {
const el = document.createElement('div')
el.className = 'wechat-chat-history-line'
el.textContent = line
preview.appendChild(el)
}
chBody.appendChild(preview)
}
card.appendChild(chBody)
const bottom = document.createElement('div')
bottom.className = 'wechat-chat-history-bottom'
const label = document.createElement('span')
label.textContent = '聊天记录'
bottom.appendChild(label)
card.appendChild(bottom)
const nestedXml = String(rec?.recordItem || '').trim()
if (nestedXml) {
card.classList.add('cursor-pointer')
card.addEventListener('click', (ev) => {
try { ev.preventDefault() } catch {}
try { ev.stopPropagation() } catch {}
openNestedChatHistory(rec)
})
}
body.appendChild(card)
} else if (rt === 'link') {
const href = normalizeChatHistoryUrl(rec?.url) || normalizeChatHistoryUrl(rec?.externurl)
const heading = String(rec?.title || '').trim() || content || href || '链接'
const desc = String(rec?.content || '').trim()
const thumbMd5 = pickFirstMd5(rec?.fullmd5, rec?.thumbfullmd5, rec?.md5)
let previewUrl = resolveMd5Any(mediaIndex, thumbMd5)
if (!previewUrl && serverMd5) previewUrl = resolveMd5Any(mediaIndex, serverMd5)
if (!previewUrl) previewUrl = resolveRemoteAny(mediaIndex, rec?.externurl, rec?.cdnurlstring, rec?.encrypturlstring)
const card = document.createElement(href ? 'a' : 'div')
card.className = 'wechat-link-card wechat-special-card msg-radius cursor-pointer'
if (href) {
card.href = href
card.target = '_blank'
card.rel = 'noreferrer noopener'
}
try { card.style.textDecoration = 'none' } catch {}
try { card.style.outline = 'none' } catch {}
const linkContent = document.createElement('div')
linkContent.className = 'wechat-link-content'
const linkInfo = document.createElement('div')
linkInfo.className = 'wechat-link-info'
const titleEl = document.createElement('div')
titleEl.className = 'wechat-link-title'
titleEl.textContent = heading
linkInfo.appendChild(titleEl)
if (desc) {
const descEl = document.createElement('div')
descEl.className = 'wechat-link-desc'
descEl.textContent = desc
linkInfo.appendChild(descEl)
}
linkContent.appendChild(linkInfo)
if (previewUrl) {
const thumb = document.createElement('div')
thumb.className = 'wechat-link-thumb'
const img = document.createElement('img')
img.src = previewUrl
img.alt = heading || '链接预览'
img.className = 'wechat-link-thumb-img'
try { img.referrerPolicy = 'no-referrer' } catch {}
thumb.appendChild(img)
linkContent.appendChild(thumb)
}
card.appendChild(linkContent)
const fromRow = document.createElement('div')
fromRow.className = 'wechat-link-from'
const fromText = (() => {
const f0 = String(rec?.from || '').trim()
if (f0) return f0
try { return href ? (new URL(href).hostname || '') : '' } catch { return '' }
})()
const fromAvatarText = fromText ? (Array.from(fromText)[0] || '') : ''
const fromAvatar = document.createElement('div')
fromAvatar.className = 'wechat-link-from-avatar'
fromAvatar.textContent = fromAvatarText || '\u200B'
const fromName = document.createElement('div')
fromName.className = 'wechat-link-from-name'
fromName.textContent = fromText || '\u200B'
fromRow.appendChild(fromAvatar)
fromRow.appendChild(fromName)
card.appendChild(fromRow)
body.appendChild(card)
} else if (rt === 'video') {
const videoMd5 = pickFirstMd5(rec?.fullmd5, rec?.md5) const videoMd5 = pickFirstMd5(rec?.fullmd5, rec?.md5)
const thumbMd5 = pickFirstMd5(rec?.thumbfullmd5) || videoMd5 const thumbMd5 = pickFirstMd5(rec?.thumbfullmd5) || videoMd5
let videoUrl = resolveMd5Any(mediaIndex, videoMd5) let videoUrl = resolveMd5Any(mediaIndex, videoMd5)
@@ -1202,20 +1593,11 @@ _HTML_EXPORT_JS = r"""
return row return row
} }
const openFromCard = (card) => { const applyChatHistoryState = (state) => {
const title = String(card?.getAttribute('data-title') || '合并消息').trim() || '合并消息' currentState = state
const b64 = String(card?.getAttribute('data-record-item-b64') || '').trim() const title = String(state?.title || '聊天记录').trim() || '聊天记录'
const xml = decodeBase64Utf8(b64) const info = state?.info || { isChatRoom: false }
const parsed = parseChatHistoryRecord(xml) const records = Array.isArray(state?.records) ? state.records : []
const info = (parsed && parsed.info) ? parsed.info : { isChatRoom: false }
let records = (parsed && Array.isArray(parsed.items)) ? parsed.items : []
if (!records.length) {
const lines = Array.from(card.querySelectorAll('.wechat-chat-history-line') || [])
.map((el) => String(el?.textContent || '').trim())
.filter(Boolean)
records = lines.map((line, idx) => ({ id: String(idx), renderType: 'text', content: line, sourcename: '', sourcetime: '' }))
}
try { titleEl.textContent = title } catch {} try { titleEl.textContent = title } catch {}
try { listEl.textContent = '' } catch {} try { listEl.textContent = '' } catch {}
@@ -1231,6 +1613,45 @@ _HTML_EXPORT_JS = r"""
} }
} }
updateBackVisibility()
}
const openNestedChatHistory = (rec) => {
const xml = String(rec?.recordItem || '').trim()
if (!xml) return
if (currentState) {
historyStack = [...historyStack, currentState]
}
const state = buildChatHistoryState({
title: String(rec?.title || '聊天记录'),
recordItem: xml,
content: String(rec?.content || ''),
})
applyChatHistoryState(state)
}
if (backBtn) {
backBtn.addEventListener('click', (ev) => {
try { ev.preventDefault() } catch {}
if (!Array.isArray(historyStack) || !historyStack.length) return
const prev = historyStack[historyStack.length - 1]
historyStack = historyStack.slice(0, -1)
applyChatHistoryState(prev)
})
}
const openFromCard = (card) => {
const title = String(card?.getAttribute('data-title') || '聊天记录').trim() || '聊天记录'
const b64 = String(card?.getAttribute('data-record-item-b64') || '').trim()
const xml = decodeBase64Utf8(b64)
const lines = Array.from(card.querySelectorAll('.wechat-chat-history-line') || [])
.map((el) => String(el?.textContent || '').trim())
.filter(Boolean)
historyStack = []
const state = buildChatHistoryState({ title, recordItem: xml, fallbackLines: lines })
applyChatHistoryState(state)
try { modal.classList.remove('hidden') } catch {} try { modal.classList.remove('hidden') } catch {}
try { modal.style.display = 'flex' } catch {} try { modal.style.display = 'flex' } catch {}
try { modal.setAttribute('aria-hidden', 'false') } catch {} try { modal.setAttribute('aria-hidden', 'false') } catch {}
@@ -1269,6 +1690,7 @@ _HTML_EXPORT_JS = r"""
initSessionSearch() initSessionSearch()
initVoicePlayback() initVoicePlayback()
initChatHistoryModal() initChatHistoryModal()
initPagedMessageLoading()
const select = document.getElementById('messageTypeFilter') const select = document.getElementById('messageTypeFilter')
if (select) { if (select) {
@@ -1469,6 +1891,7 @@ class ChatExportManager:
output_dir: Optional[str], output_dir: Optional[str],
allow_process_key_extract: bool, allow_process_key_extract: bool,
download_remote_media: bool, download_remote_media: bool,
html_page_size: int = 1000,
privacy_mode: bool, privacy_mode: bool,
file_name: Optional[str], file_name: Optional[str],
) -> ExportJob: ) -> ExportJob:
@@ -1493,6 +1916,7 @@ class ChatExportManager:
"outputDir": str(output_dir or "").strip(), "outputDir": str(output_dir or "").strip(),
"allowProcessKeyExtract": bool(allow_process_key_extract), "allowProcessKeyExtract": bool(allow_process_key_extract),
"downloadRemoteMedia": bool(download_remote_media), "downloadRemoteMedia": bool(download_remote_media),
"htmlPageSize": int(html_page_size) if int(html_page_size or 0) > 0 else int(html_page_size or 0),
"privacyMode": bool(privacy_mode), "privacyMode": bool(privacy_mode),
"fileName": str(file_name or "").strip(), "fileName": str(file_name or "").strip(),
}, },
@@ -1544,6 +1968,12 @@ class ChatExportManager:
allow_process_key_extract = bool(opts.get("allowProcessKeyExtract")) allow_process_key_extract = bool(opts.get("allowProcessKeyExtract"))
download_remote_media = bool(opts.get("downloadRemoteMedia")) download_remote_media = bool(opts.get("downloadRemoteMedia"))
privacy_mode = bool(opts.get("privacyMode")) privacy_mode = bool(opts.get("privacyMode"))
try:
html_page_size = int(opts.get("htmlPageSize") or 1000)
except Exception:
html_page_size = 1000
if html_page_size < 0:
html_page_size = 0
media_kinds_raw = opts.get("mediaKinds") or [] media_kinds_raw = opts.get("mediaKinds") or []
media_kinds: list[MediaKind] = [] media_kinds: list[MediaKind] = []
@@ -1898,6 +2328,7 @@ class ChatExportManager:
session_items=session_items, session_items=session_items,
download_remote_media=remote_download_enabled, download_remote_media=remote_download_enabled,
remote_written=remote_written, remote_written=remote_written,
html_page_size=html_page_size,
start_time=st, start_time=st,
end_time=et, end_time=et,
want_types=want_types, want_types=want_types,
@@ -2045,6 +2476,7 @@ class ChatExportManager:
"mediaKinds": media_kinds, "mediaKinds": media_kinds,
"allowProcessKeyExtract": allow_process_key_extract, "allowProcessKeyExtract": allow_process_key_extract,
"downloadRemoteMedia": bool(download_remote_media), "downloadRemoteMedia": bool(download_remote_media),
"htmlPageSize": int(html_page_size) if export_format == "html" else None,
"privacyMode": privacy_mode, "privacyMode": privacy_mode,
}, },
"stats": { "stats": {
@@ -3110,6 +3542,7 @@ def _write_conversation_html(
session_items: list[dict[str, Any]], session_items: list[dict[str, Any]],
download_remote_media: bool, download_remote_media: bool,
remote_written: dict[str, str], remote_written: dict[str, str],
html_page_size: int = 1000,
start_time: Optional[int], start_time: Optional[int],
end_time: Optional[int], end_time: Optional[int],
want_types: Optional[set[str]], want_types: Optional[set[str]],
@@ -3499,7 +3932,7 @@ def _write_conversation_html(
("emoji", "表情"), ("emoji", "表情"),
("video", "视频"), ("video", "视频"),
("voice", "语音"), ("voice", "语音"),
("chatHistory", "合并消息"), ("chatHistory", "聊天记录"),
("transfer", "转账"), ("transfer", "转账"),
("redPacket", "红包"), ("redPacket", "红包"),
("file", "文件"), ("file", "文件"),
@@ -3509,10 +3942,46 @@ def _write_conversation_html(
("voip", "通话"), ("voip", "通话"),
] ]
page_size = 0
try:
page_size = int(html_page_size or 0)
except Exception:
page_size = 0
if page_size < 0:
page_size = 0
# NOTE: write to a temp file first to avoid zip interleaving writes. # NOTE: write to a temp file first to avoid zip interleaving writes.
with tempfile.TemporaryDirectory(prefix="wechat_chat_export_") as tmp_dir: with tempfile.TemporaryDirectory(prefix="wechat_chat_export_") as tmp_dir:
tmp_path = Path(tmp_dir) / "messages.html" tmp_path = Path(tmp_dir) / "messages.html"
with open(tmp_path, "w", encoding="utf-8", newline="\n") as tw: pages_frag_dir = Path(tmp_dir) / "pages_fragments"
page_frag_paths: list[Path] = []
paged_old_page_paths: list[Path] = []
paged_total_pages = 1
paged_pad_width = 4
with open(tmp_path, "w", encoding="utf-8", newline="\n") as hw:
class _WriteProxy:
def __init__(self, default_target):
self._default = default_target
self._target = default_target
def set_target(self, target) -> None:
self._target = target or self._default
def write(self, s: str) -> Any:
return self._target.write(s)
def flush(self) -> None:
try:
if self._target is not self._default:
self._target.flush()
except Exception:
pass
try:
self._default.flush()
except Exception:
pass
tw = _WriteProxy(hw)
tw.write("<!doctype html>\n") tw.write("<!doctype html>\n")
tw.write('<html lang="zh-CN">\n') tw.write('<html lang="zh-CN">\n')
tw.write("<head>\n") tw.write("<head>\n")
@@ -3688,6 +4157,55 @@ def _write_conversation_html(
tw.write(" </div>\n") tw.write(" </div>\n")
tw.write(' <div id="messageContainer" class="wce-message-container flex-1 overflow-y-auto p-4 min-h-0">\n') tw.write(' <div id="messageContainer" class="wce-message-container flex-1 overflow-y-auto p-4 min-h-0">\n')
tw.write(' <div id="wcePager" class="wce-pager" style="display:none">\n')
tw.write(' <button id="wceLoadPrevBtn" type="button" class="wce-pager-btn">加载更早消息</button>\n')
tw.write(' <span id="wceLoadPrevStatus" class="wce-pager-status"></span>\n')
tw.write(" </div>\n")
tw.write(' <div id="wceMessageList">\n')
page_fp = None
page_fp_path: Optional[Path] = None
page_no = 1
page_msg_count = 0
def _open_page_fp() -> Any:
nonlocal page_fp, page_fp_path
pages_frag_dir.mkdir(parents=True, exist_ok=True)
page_fp_path = pages_frag_dir / f"page_{page_no}.htmlfrag"
page_fp = open(page_fp_path, "w", encoding="utf-8", newline="\n")
return page_fp
def _close_page_fp() -> None:
nonlocal page_fp, page_fp_path
if page_fp is None:
page_fp_path = None
return
try:
page_fp.flush()
except Exception:
pass
try:
page_fp.close()
except Exception:
pass
if page_fp_path is not None:
page_frag_paths.append(page_fp_path)
page_fp = None
page_fp_path = None
tw.set_target(hw)
def _mark_exported() -> None:
nonlocal exported, page_no, page_msg_count
exported += 1
with lock:
job.progress.messages_exported += 1
job.progress.current_conversation_messages_exported = exported
if page_size > 0:
page_msg_count += 1
if page_msg_count >= page_size:
_close_page_fp()
page_no += 1
page_msg_count = 0
sender_alias_map: dict[str, int] = {} sender_alias_map: dict[str, int] = {}
prev_ts = 0 prev_ts = 0
@@ -3755,6 +4273,11 @@ def _write_conversation_html(
if ts and ((prev_ts == 0) or (abs(ts - prev_ts) >= 300)): if ts and ((prev_ts == 0) or (abs(ts - prev_ts) >= 300)):
show_divider = True show_divider = True
if page_size > 0:
if page_fp is None:
_open_page_fp()
tw.set_target(page_fp)
if show_divider: if show_divider:
divider_text = _format_session_time(ts) divider_text = _format_session_time(ts)
if divider_text: if divider_text:
@@ -3770,10 +4293,7 @@ def _write_conversation_html(
tw.write(f' <div class="px-3 py-1 text-xs text-[#9e9e9e]">{esc_text(msg.get("content") or "")}</div>\n') tw.write(f' <div class="px-3 py-1 text-xs text-[#9e9e9e]">{esc_text(msg.get("content") or "")}</div>\n')
tw.write(" </div>\n") tw.write(" </div>\n")
tw.write(" </div>\n") tw.write(" </div>\n")
exported += 1 _mark_exported()
with lock:
job.progress.messages_exported += 1
job.progress.current_conversation_messages_exported = exported
if ts: if ts:
prev_ts = ts prev_ts = ts
continue continue
@@ -4186,7 +4706,7 @@ def _write_conversation_html(
tw.write(" </div>\n") tw.write(" </div>\n")
elif rt == "chatHistory": elif rt == "chatHistory":
title = str(msg.get("title") or "").strip() or "合并消息" title = str(msg.get("title") or "").strip() or "聊天记录"
record_item = str(msg.get("recordItem") or "").strip() record_item = str(msg.get("recordItem") or "").strip()
record_item_b64 = "" record_item_b64 = ""
if record_item: if record_item:
@@ -4260,7 +4780,7 @@ def _write_conversation_html(
tw.write(f' <div class="wechat-chat-history-line">{esc_text(line)}</div>\n') tw.write(f' <div class="wechat-chat-history-line">{esc_text(line)}</div>\n')
tw.write(" </div>\n") tw.write(" </div>\n")
tw.write(" </div>\n") tw.write(" </div>\n")
tw.write(' <div class="wechat-chat-history-bottom"><span>合并消息</span></div>\n') tw.write(' <div class="wechat-chat-history-bottom"><span>聊天记录</span></div>\n')
tw.write(" </div>\n") tw.write(" </div>\n")
elif rt == "transfer": elif rt == "transfer":
received = is_transfer_received(msg) received = is_transfer_received(msg)
@@ -4328,18 +4848,56 @@ def _write_conversation_html(
tw.write(" </div>\n") tw.write(" </div>\n")
tw.write(" </div>\n") tw.write(" </div>\n")
exported += 1 _mark_exported()
with lock:
job.progress.messages_exported += 1
job.progress.current_conversation_messages_exported = exported
if ts: if ts:
prev_ts = ts prev_ts = ts
if scanned % 500 == 0 and job.cancel_requested: if scanned % 500 == 0 and job.cancel_requested:
raise _JobCancelled() raise _JobCancelled()
if page_size > 0:
_close_page_fp()
paged_total_pages = max(1, len(page_frag_paths))
paged_pad_width = max(4, len(str(paged_total_pages)))
if page_frag_paths:
paged_old_page_paths = list(page_frag_paths[:-1])
tw.set_target(hw)
try:
tw.write(page_frag_paths[-1].read_text(encoding="utf-8"))
except Exception:
try:
tw.write(page_frag_paths[-1].read_text(encoding="utf-8", errors="ignore"))
except Exception:
pass
else:
paged_old_page_paths = []
tw.set_target(hw)
# Close message list + container
tw.set_target(hw)
tw.write(" </div>\n") tw.write(" </div>\n")
tw.write(" </div>\n") tw.write(" </div>\n")
if page_size > 0 and paged_total_pages > 1:
page_meta = {
"schemaVersion": 1,
"pageSize": int(page_size),
"totalPages": int(paged_total_pages),
"initialPage": int(paged_total_pages),
"totalMessages": int(exported),
"padWidth": int(paged_pad_width),
"pageFilePrefix": "pages/page-",
"pageFileSuffix": ".js",
"inlinedPages": [int(paged_total_pages)],
}
try:
page_meta_payload = json.dumps(page_meta, ensure_ascii=False)
except Exception:
page_meta_payload = "{}"
page_meta_payload = page_meta_payload.replace("</", "<\\/")
tw.write(f'<script type="application/json" id="wcePageMeta">{page_meta_payload}</script>\n')
tw.write(" </div>\n")
tw.write(" </div>\n") tw.write(" </div>\n")
tw.write(" </div>\n") tw.write(" </div>\n")
tw.write("</div>\n") tw.write("</div>\n")
@@ -4357,7 +4915,7 @@ def _write_conversation_html(
) )
tw.write(' <div class="w-[92vw] max-w-[560px] max-h-[80vh] bg-white rounded-xl shadow-xl overflow-hidden flex flex-col" role="dialog" aria-modal="true">\n') tw.write(' <div class="w-[92vw] max-w-[560px] max-h-[80vh] bg-white rounded-xl shadow-xl overflow-hidden flex flex-col" role="dialog" aria-modal="true">\n')
tw.write(' <div class="px-4 py-3 bg-neutral-100 border-b border-gray-200 flex items-center justify-between">\n') tw.write(' <div class="px-4 py-3 bg-neutral-100 border-b border-gray-200 flex items-center justify-between">\n')
tw.write(' <div id="chatHistoryModalTitle" class="text-sm text-[#161616] truncate">合并消息</div>\n') tw.write(' <div id="chatHistoryModalTitle" class="text-sm text-[#161616] truncate">聊天记录</div>\n')
tw.write(' <button type="button" id="chatHistoryModalClose" class="p-2 rounded hover:bg-black/5" aria-label="关闭" title="关闭">\n') tw.write(' <button type="button" id="chatHistoryModalClose" class="p-2 rounded hover:bg-black/5" aria-label="关闭" title="关闭">\n')
tw.write(' <svg class="w-5 h-5 text-gray-700" fill="none" stroke="currentColor" viewBox="0 0 24 24">\n') tw.write(' <svg class="w-5 h-5 text-gray-700" fill="none" stroke="currentColor" viewBox="0 0 24 24">\n')
tw.write(' <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>\n') tw.write(' <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>\n')
@@ -4365,7 +4923,7 @@ def _write_conversation_html(
tw.write(" </button>\n") tw.write(" </button>\n")
tw.write(" </div>\n") tw.write(" </div>\n")
tw.write(' <div class="flex-1 overflow-auto bg-white">\n') tw.write(' <div class="flex-1 overflow-auto bg-white">\n')
tw.write(' <div id="chatHistoryModalEmpty" class="text-sm text-gray-500 text-center py-10">没有可显示的合并消息</div>\n') tw.write(' <div id="chatHistoryModalEmpty" class="text-sm text-gray-500 text-center py-10">没有可显示的聊天记录</div>\n')
tw.write(' <div id="chatHistoryModalList"></div>\n') tw.write(' <div id="chatHistoryModalList"></div>\n')
tw.write(" </div>\n") tw.write(" </div>\n")
tw.write(" </div>\n") tw.write(" </div>\n")
@@ -4377,6 +4935,39 @@ def _write_conversation_html(
zf.write(str(tmp_path), arcname) zf.write(str(tmp_path), arcname)
if page_size > 0 and paged_old_page_paths:
for page_no, frag_path in enumerate(paged_old_page_paths, start=1):
try:
frag_text = frag_path.read_text(encoding="utf-8")
except Exception:
try:
frag_text = frag_path.read_text(encoding="utf-8", errors="ignore")
except Exception:
frag_text = ""
try:
frag_json = json.dumps(frag_text, ensure_ascii=False)
except Exception:
frag_json = json.dumps("", ensure_ascii=False)
num = str(page_no).zfill(int(paged_pad_width or 4))
arc_js = f"{conv_dir}/pages/page-{num}.js"
js_payload = (
"(() => {\n"
f" const pageNo = {int(page_no)};\n"
f" const html = {frag_json};\n"
" try {\n"
" const fn = window.__WCE_PAGE_LOADED__;\n"
" if (typeof fn === 'function') fn(pageNo, html);\n"
" else {\n"
" const q = (window.__WCE_PAGE_QUEUE__ = window.__WCE_PAGE_QUEUE__ || []);\n"
" q.push([pageNo, html]);\n"
" }\n"
" } catch {}\n"
"})();\n"
)
zf.writestr(arc_js, js_payload)
return exported return exported

View File

@@ -5,6 +5,7 @@ import asyncio
import json import json
import time import time
import threading import threading
from datetime import datetime, timedelta
from os import scandir from os import scandir
from pathlib import Path from pathlib import Path
from typing import Any, Optional from typing import Any, Optional
@@ -452,6 +453,33 @@ def _resolve_decrypted_message_table(account_dir: Path, username: str) -> Option
return None return None
def _local_month_range_epoch_seconds(*, year: int, month: int) -> tuple[int, int]:
"""Return [start, end) range as epoch seconds for local time month boundaries.
Notes:
- Uses local midnight boundaries (not +86400 * days) to stay DST-safe.
- Returned timestamps are integers (seconds).
"""
start = datetime(int(year), int(month), 1)
if int(month) == 12:
end = datetime(int(year) + 1, 1, 1)
else:
end = datetime(int(year), int(month) + 1, 1)
return int(start.timestamp()), int(end.timestamp())
def _local_day_range_epoch_seconds(*, date_str: str) -> tuple[int, int, str]:
"""Return [start, end) range as epoch seconds for local date boundaries.
Returns the normalized `YYYY-MM-DD` date string as the 3rd element.
"""
d0 = datetime.strptime(str(date_str or "").strip(), "%Y-%m-%d")
d1 = d0 + timedelta(days=1)
return int(d0.timestamp()), int(d1.timestamp()), d0.strftime("%Y-%m-%d")
def _pick_message_db_for_new_table(account_dir: Path, username: str) -> Optional[Path]: def _pick_message_db_for_new_table(account_dir: Path, username: str) -> Optional[Path]:
"""Pick a target decrypted sqlite db to place a new Msg_<md5> table. """Pick a target decrypted sqlite db to place a new Msg_<md5> table.
@@ -3126,6 +3154,27 @@ def _postprocess_full_messages(
base_url base_url
+ f"/api/chat/media/video?account={quote(account_dir.name)}&file_id={quote(video_file_id)}&username={quote(username)}" + f"/api/chat/media/video?account={quote(account_dir.name)}&file_id={quote(video_file_id)}&username={quote(username)}"
) )
elif rt == "link":
# Some appmsg link cards (notably Bilibili shares) carry a non-HTTP `<thumburl>` payload
# (often an ASN.1-ish hex blob). The actual preview image is typically saved as:
# msg/attach/{md5(conv_username)}/.../Img/{local_id}_{create_time}_t.dat
# Expose it via the existing image endpoint using file_id.
thumb_url = str(m.get("thumbUrl") or "").strip()
if thumb_url and (not thumb_url.lower().startswith(("http://", "https://"))):
try:
lid = int(m.get("localId") or 0)
except Exception:
lid = 0
try:
ct = int(m.get("createTime") or 0)
except Exception:
ct = 0
if lid > 0 and ct > 0:
file_id = f"{lid}_{ct}"
m["thumbUrl"] = (
base_url
+ f"/api/chat/media/image?account={quote(account_dir.name)}&file_id={quote(file_id)}&username={quote(username)}"
)
elif rt == "voice": elif rt == "voice":
if str(m.get("serverId") or ""): if str(m.get("serverId") or ""):
sid = int(m.get("serverId") or 0) sid = int(m.get("serverId") or 0)
@@ -4090,6 +4139,182 @@ def _collect_chat_messages(
return merged, has_more_any, sender_usernames, quote_usernames, pat_usernames return merged, has_more_any, sender_usernames, quote_usernames, pat_usernames
@router.get("/api/chat/messages/daily_counts", summary="获取某月每日消息数(热力图)")
def get_chat_message_daily_counts(
username: str,
year: int,
month: int,
account: Optional[str] = None,
):
username = str(username or "").strip()
if not username:
raise HTTPException(status_code=400, detail="Missing username.")
try:
y = int(year)
m = int(month)
except Exception:
raise HTTPException(status_code=400, detail="Invalid year or month.")
if m < 1 or m > 12:
raise HTTPException(status_code=400, detail="Invalid month.")
try:
start_ts, end_ts = _local_month_range_epoch_seconds(year=y, month=m)
except Exception:
raise HTTPException(status_code=400, detail="Invalid year or month.")
account_dir = _resolve_account_dir(account)
db_paths = _iter_message_db_paths(account_dir)
counts: dict[str, int] = {}
for db_path in db_paths:
conn = sqlite3.connect(str(db_path))
try:
try:
table_name = _resolve_msg_table_name(conn, username)
if not table_name:
continue
quoted_table = _quote_ident(table_name)
rows = conn.execute(
"SELECT strftime('%Y-%m-%d', CAST(create_time AS INTEGER), 'unixepoch', 'localtime') AS day, "
"COUNT(*) AS c "
f"FROM {quoted_table} "
"WHERE CAST(create_time AS INTEGER) >= ? AND CAST(create_time AS INTEGER) < ? "
"GROUP BY day",
(int(start_ts), int(end_ts)),
).fetchall()
for day, c in rows:
k = str(day or "").strip()
if not k:
continue
try:
vv = int(c or 0)
except Exception:
vv = 0
if vv <= 0:
continue
counts[k] = int(counts.get(k, 0)) + vv
except Exception:
continue
finally:
conn.close()
total = int(sum(int(v) for v in counts.values())) if counts else 0
max_count = int(max(counts.values())) if counts else 0
return {
"status": "success",
"account": account_dir.name,
"username": username,
"year": int(y),
"month": int(m),
"counts": counts,
"total": total,
"max": max_count,
}
@router.get("/api/chat/messages/anchor", summary="获取定位锚点(某日第一条/会话顶部)")
def get_chat_message_anchor(
username: str,
kind: str,
account: Optional[str] = None,
date: Optional[str] = None,
):
username = str(username or "").strip()
if not username:
raise HTTPException(status_code=400, detail="Missing username.")
kind_norm = str(kind or "").strip().lower()
if kind_norm not in {"day", "first"}:
raise HTTPException(status_code=400, detail="Invalid kind.")
date_norm: Optional[str] = None
start_ts: Optional[int] = None
end_ts: Optional[int] = None
if kind_norm == "day":
if not date:
raise HTTPException(status_code=400, detail="Missing date.")
try:
start_ts, end_ts, date_norm = _local_day_range_epoch_seconds(date_str=str(date))
except Exception:
raise HTTPException(status_code=400, detail="Invalid date.")
account_dir = _resolve_account_dir(account)
db_paths = _iter_message_db_paths(account_dir)
best_key: Optional[tuple[int, int, int]] = None
best_anchor_id = ""
best_create_time = 0
for db_path in db_paths:
conn = sqlite3.connect(str(db_path))
try:
try:
table_name = _resolve_msg_table_name(conn, username)
if not table_name:
continue
quoted_table = _quote_ident(table_name)
if kind_norm == "first":
row = conn.execute(
"SELECT local_id, CAST(create_time AS INTEGER) AS create_time, "
"COALESCE(CAST(sort_seq AS INTEGER), 0) AS sort_seq "
f"FROM {quoted_table} "
"ORDER BY CAST(create_time AS INTEGER) ASC, COALESCE(CAST(sort_seq AS INTEGER), 0) ASC, local_id ASC "
"LIMIT 1"
).fetchone()
else:
row = conn.execute(
"SELECT local_id, CAST(create_time AS INTEGER) AS create_time, "
"COALESCE(CAST(sort_seq AS INTEGER), 0) AS sort_seq "
f"FROM {quoted_table} "
"WHERE CAST(create_time AS INTEGER) >= ? AND CAST(create_time AS INTEGER) < ? "
"ORDER BY CAST(create_time AS INTEGER) ASC, COALESCE(CAST(sort_seq AS INTEGER), 0) ASC, local_id ASC "
"LIMIT 1",
(int(start_ts or 0), int(end_ts or 0)),
).fetchone()
if not row:
continue
try:
local_id = int(row[0] or 0)
create_time = int(row[1] or 0)
sort_seq = int(row[2] or 0)
except Exception:
continue
if local_id <= 0:
continue
key = (int(create_time), int(sort_seq), int(local_id))
if (best_key is None) or (key < best_key):
best_key = key
best_create_time = int(create_time)
best_anchor_id = f"{db_path.stem}:{table_name}:{local_id}"
except Exception:
continue
finally:
conn.close()
if not best_anchor_id:
return {
"status": "empty",
"anchorId": "",
}
resp: dict[str, Any] = {
"status": "success",
"account": account_dir.name,
"username": username,
"kind": kind_norm,
"anchorId": best_anchor_id,
"createTime": int(best_create_time),
}
if date_norm is not None:
resp["date"] = date_norm
return resp
@router.get("/api/chat/messages", summary="获取会话消息列表") @router.get("/api/chat/messages", summary="获取会话消息列表")
def list_chat_messages( def list_chat_messages(
request: Request, request: Request,
@@ -5055,6 +5280,23 @@ def list_chat_messages(
base_url base_url
+ f"/api/chat/media/video?account={quote(account_dir.name)}&file_id={quote(video_file_id)}&username={quote(username)}" + f"/api/chat/media/video?account={quote(account_dir.name)}&file_id={quote(video_file_id)}&username={quote(username)}"
) )
elif rt == "link":
thumb_url = str(m.get("thumbUrl") or "").strip()
if thumb_url and (not thumb_url.lower().startswith(("http://", "https://"))):
try:
lid = int(m.get("localId") or 0)
except Exception:
lid = 0
try:
ct = int(m.get("createTime") or 0)
except Exception:
ct = 0
if lid > 0 and ct > 0:
file_id = f"{lid}_{ct}"
m["thumbUrl"] = (
base_url
+ f"/api/chat/media/image?account={quote(account_dir.name)}&file_id={quote(file_id)}&username={quote(username)}"
)
elif rt == "voice": elif rt == "voice":
if str(m.get("serverId") or ""): if str(m.get("serverId") or ""):
sid = int(m.get("serverId") or 0) sid = int(m.get("serverId") or 0)
@@ -5955,6 +6197,7 @@ async def get_chat_messages_around(
raise HTTPException(status_code=400, detail="Missing username.") raise HTTPException(status_code=400, detail="Missing username.")
if not anchor_id: if not anchor_id:
raise HTTPException(status_code=400, detail="Missing anchor_id.") raise HTTPException(status_code=400, detail="Missing anchor_id.")
if before < 0: if before < 0:
before = 0 before = 0
if after < 0: if after < 0:
@@ -5967,7 +6210,7 @@ async def get_chat_messages_around(
parts = str(anchor_id).split(":", 2) parts = str(anchor_id).split(":", 2)
if len(parts) != 3: if len(parts) != 3:
raise HTTPException(status_code=400, detail="Invalid anchor_id.") raise HTTPException(status_code=400, detail="Invalid anchor_id.")
anchor_db_stem, anchor_table_name, anchor_local_id_str = parts anchor_db_stem, anchor_table_name_in, anchor_local_id_str = parts
try: try:
anchor_local_id = int(anchor_local_id_str) anchor_local_id = int(anchor_local_id_str)
except Exception: except Exception:
@@ -5980,14 +6223,15 @@ async def get_chat_messages_around(
message_resource_db_path = account_dir / "message_resource.db" message_resource_db_path = account_dir / "message_resource.db"
base_url = str(request.base_url).rstrip("/") base_url = str(request.base_url).rstrip("/")
target_db: Optional[Path] = None anchor_db_path: Optional[Path] = None
for p in db_paths: for p in db_paths:
if p.stem == anchor_db_stem: if p.stem == anchor_db_stem:
target_db = p anchor_db_path = p
break break
if target_db is None: if anchor_db_path is None:
raise HTTPException(status_code=404, detail="Anchor database not found.") raise HTTPException(status_code=404, detail="Anchor database not found.")
# Open resource DB once (optional), and reuse for all message DBs.
resource_conn: Optional[sqlite3.Connection] = None resource_conn: Optional[sqlite3.Connection] = None
resource_chat_id: Optional[int] = None resource_chat_id: Optional[int] = None
try: try:
@@ -6004,21 +6248,106 @@ async def get_chat_messages_around(
resource_conn = None resource_conn = None
resource_chat_id = None resource_chat_id = None
conn = sqlite3.connect(str(target_db)) # Resolve anchor message tuple from its DB.
conn.row_factory = sqlite3.Row anchor_ct = 0
anchor_ss = 0
anchor_table_name = str(anchor_table_name_in or "").strip()
anchor_row: Optional[sqlite3.Row] = None
anchor_packed_select = "NULL AS packed_info_data, "
try: try:
table_name = str(anchor_table_name).strip() conn_a = sqlite3.connect(str(anchor_db_path))
if not table_name: conn_a.row_factory = sqlite3.Row
try:
if not anchor_table_name:
try:
anchor_table_name = _resolve_msg_table_name(conn_a, username) or ""
except Exception:
anchor_table_name = ""
if not anchor_table_name:
raise HTTPException(status_code=404, detail="Anchor table not found.") raise HTTPException(status_code=404, detail="Anchor table not found.")
# Normalize table name casing if needed # Normalize table name casing if needed
try: try:
trows = conn.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall() trows = conn_a.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall()
lower_to_actual = {str(x[0]).lower(): str(x[0]) for x in trows if x and x[0]} lower_to_actual = {str(x[0]).lower(): str(x[0]) for x in trows if x and x[0]}
table_name = lower_to_actual.get(table_name.lower(), table_name) anchor_table_name = lower_to_actual.get(anchor_table_name.lower(), anchor_table_name)
except Exception: except Exception:
pass pass
quoted_table_a = _quote_ident(anchor_table_name)
has_packed_info_data = False
try:
cols = conn_a.execute(f"PRAGMA table_info({quoted_table_a})").fetchall()
has_packed_info_data = any(str(c[1] or "").strip().lower() == "packed_info_data" for c in cols)
except Exception:
has_packed_info_data = False
anchor_packed_select = (
"m.packed_info_data AS packed_info_data, " if has_packed_info_data else "NULL AS packed_info_data, "
)
sql_anchor_with_join = (
"SELECT "
"m.local_id, m.server_id, m.local_type, m.sort_seq, m.real_sender_id, m.create_time, "
"m.message_content, m.compress_content, "
+ anchor_packed_select
+ "n.user_name AS sender_username "
f"FROM {quoted_table_a} m "
"LEFT JOIN Name2Id n ON m.real_sender_id = n.rowid "
"WHERE m.local_id = ? "
"LIMIT 1"
)
sql_anchor_no_join = (
"SELECT "
"m.local_id, m.server_id, m.local_type, m.sort_seq, m.real_sender_id, m.create_time, "
"m.message_content, m.compress_content, "
+ anchor_packed_select
+ "'' AS sender_username "
f"FROM {quoted_table_a} m "
"WHERE m.local_id = ? "
"LIMIT 1"
)
conn_a.text_factory = bytes
try:
anchor_row = conn_a.execute(sql_anchor_with_join, (anchor_local_id,)).fetchone()
except Exception:
anchor_row = conn_a.execute(sql_anchor_no_join, (anchor_local_id,)).fetchone()
if anchor_row is None:
raise HTTPException(status_code=404, detail="Anchor message not found.")
anchor_ct = int(anchor_row["create_time"] or 0)
anchor_ss = int(anchor_row["sort_seq"] or 0) if anchor_row["sort_seq"] is not None else 0
finally:
conn_a.close()
finally:
pass
anchor_id_canon = f"{anchor_db_stem}:{anchor_table_name}:{anchor_local_id}"
merged: list[dict[str, Any]] = []
sender_usernames_all: list[str] = []
quote_usernames_all: list[str] = []
pat_usernames_all: set[str] = set()
is_group = bool(username.endswith("@chatroom"))
for db_path in db_paths:
conn: Optional[sqlite3.Connection] = None
try:
conn = sqlite3.connect(str(db_path))
conn.row_factory = sqlite3.Row
table_name = ""
if db_path.stem == anchor_db_stem:
table_name = anchor_table_name
else:
try:
table_name = _resolve_msg_table_name(conn, username) or ""
except Exception:
table_name = ""
if not table_name:
continue
my_wxid = account_dir.name my_wxid = account_dir.name
my_rowid = None my_rowid = None
try: try:
@@ -6041,53 +6370,37 @@ async def get_chat_messages_around(
packed_select = ( packed_select = (
"m.packed_info_data AS packed_info_data, " if has_packed_info_data else "NULL AS packed_info_data, " "m.packed_info_data AS packed_info_data, " if has_packed_info_data else "NULL AS packed_info_data, "
) )
sql_anchor_with_join = (
"SELECT "
"m.local_id, m.server_id, m.local_type, m.sort_seq, m.real_sender_id, m.create_time, "
"m.message_content, m.compress_content, "
+ packed_select
+ "n.user_name AS sender_username "
f"FROM {quoted_table} m "
"LEFT JOIN Name2Id n ON m.real_sender_id = n.rowid "
"WHERE m.local_id = ? "
"LIMIT 1"
)
sql_anchor_no_join = (
"SELECT "
"m.local_id, m.server_id, m.local_type, m.sort_seq, m.real_sender_id, m.create_time, "
"m.message_content, m.compress_content, "
+ packed_select
+ "'' AS sender_username "
f"FROM {quoted_table} m "
"WHERE m.local_id = ? "
"LIMIT 1"
)
conn.text_factory = bytes # Stable cross-db ordering: (create_time, sort_seq, db_stem, local_id)
stem = db_path.stem
try: if stem < anchor_db_stem:
anchor_row = conn.execute(sql_anchor_with_join, (anchor_local_id,)).fetchone() tie_before = "1"
except Exception: tie_before_params: tuple[Any, ...] = ()
anchor_row = conn.execute(sql_anchor_no_join, (anchor_local_id,)).fetchone() tie_after = "0"
tie_after_params: tuple[Any, ...] = ()
if anchor_row is None: elif stem > anchor_db_stem:
raise HTTPException(status_code=404, detail="Anchor message not found.") tie_before = "0"
tie_before_params = ()
anchor_ct = int(anchor_row["create_time"] or 0) tie_after = "1"
anchor_ss = int(anchor_row["sort_seq"] or 0) if anchor_row["sort_seq"] is not None else 0 tie_after_params = ()
else:
tie_before = "m.local_id < ?"
tie_before_params = (int(anchor_local_id),)
tie_after = "m.local_id > ?"
tie_after_params = (int(anchor_local_id),)
where_before = ( where_before = (
"WHERE (" "WHERE ("
"m.create_time < ? " "m.create_time < ? "
"OR (m.create_time = ? AND COALESCE(m.sort_seq, 0) < ?) " "OR (m.create_time = ? AND COALESCE(m.sort_seq, 0) < ?) "
"OR (m.create_time = ? AND COALESCE(m.sort_seq, 0) = ? AND m.local_id <= ?)" f"OR (m.create_time = ? AND COALESCE(m.sort_seq, 0) = ? AND {tie_before})"
")" ")"
) )
where_after = ( where_after = (
"WHERE (" "WHERE ("
"m.create_time > ? " "m.create_time > ? "
"OR (m.create_time = ? AND COALESCE(m.sort_seq, 0) > ?) " "OR (m.create_time = ? AND COALESCE(m.sort_seq, 0) > ?) "
"OR (m.create_time = ? AND COALESCE(m.sort_seq, 0) = ? AND m.local_id >= ?)" f"OR (m.create_time = ? AND COALESCE(m.sort_seq, 0) = ? AND {tie_after})"
")" ")"
) )
@@ -6139,42 +6452,68 @@ async def get_chat_messages_around(
"LIMIT ?" "LIMIT ?"
) )
params_before = (anchor_ct, anchor_ct, anchor_ss, anchor_ct, anchor_ss, anchor_local_id, int(before) + 1) # Always fetch anchor row from anchor DB, but don't include anchor itself in before/after queries.
params_after = (anchor_ct, anchor_ct, anchor_ss, anchor_ct, anchor_ss, anchor_local_id, int(after) + 1) anchor_rows: list[sqlite3.Row] = []
if db_path.stem == anchor_db_stem:
if anchor_row is None:
raise HTTPException(status_code=404, detail="Anchor message not found.")
anchor_rows = [anchor_row]
conn.text_factory = bytes
before_rows: list[sqlite3.Row] = []
if int(before) > 0:
params_before = (
int(anchor_ct),
int(anchor_ct),
int(anchor_ss),
int(anchor_ct),
int(anchor_ss),
*tie_before_params,
int(before) + 1,
)
try: try:
before_rows = conn.execute(sql_before_with_join, params_before).fetchall() before_rows = conn.execute(sql_before_with_join, params_before).fetchall()
except Exception: except Exception:
before_rows = conn.execute(sql_before_no_join, params_before).fetchall() before_rows = conn.execute(sql_before_no_join, params_before).fetchall()
after_rows: list[sqlite3.Row] = []
if int(after) > 0:
params_after = (
int(anchor_ct),
int(anchor_ct),
int(anchor_ss),
int(anchor_ct),
int(anchor_ss),
*tie_after_params,
int(after) + 1,
)
try: try:
after_rows = conn.execute(sql_after_with_join, params_after).fetchall() after_rows = conn.execute(sql_after_with_join, params_after).fetchall()
except Exception: except Exception:
after_rows = conn.execute(sql_after_no_join, params_after).fetchall() after_rows = conn.execute(sql_after_no_join, params_after).fetchall()
# Dedup rows by message id within this DB.
seen_ids: set[str] = set() seen_ids: set[str] = set()
combined: list[sqlite3.Row] = [] combined: list[sqlite3.Row] = []
for rr in list(before_rows) + list(after_rows): for rr in list(before_rows) + list(anchor_rows) + list(after_rows):
lid = int(rr["local_id"] or 0) lid = int(rr["local_id"] or 0)
mid = f"{target_db.stem}:{table_name}:{lid}" mid = f"{db_path.stem}:{table_name}:{lid}"
if mid in seen_ids: if mid in seen_ids:
continue continue
seen_ids.add(mid) seen_ids.add(mid)
combined.append(rr) combined.append(rr)
merged: list[dict[str, Any]] = [] if not combined:
sender_usernames: list[str] = [] continue
quote_usernames: list[str] = []
pat_usernames: set[str] = set()
is_group = bool(username.endswith("@chatroom"))
_append_full_messages_from_rows( _append_full_messages_from_rows(
merged=merged, merged=merged,
sender_usernames=sender_usernames, sender_usernames=sender_usernames_all,
quote_usernames=quote_usernames, quote_usernames=quote_usernames_all,
pat_usernames=pat_usernames, pat_usernames=pat_usernames_all,
rows=combined, rows=combined,
db_path=target_db, db_path=db_path,
table_name=table_name, table_name=table_name,
username=username, username=username,
account_dir=account_dir, account_dir=account_dir,
@@ -6183,21 +6522,104 @@ async def get_chat_messages_around(
resource_conn=resource_conn, resource_conn=resource_conn,
resource_chat_id=resource_chat_id, resource_chat_id=resource_chat_id,
) )
except HTTPException:
return_messages = merged raise
except Exception:
# Skip broken DBs / missing tables gracefully.
continue
finally: finally:
if conn is not None:
try:
conn.close() conn.close()
except Exception:
pass
if resource_conn is not None: if resource_conn is not None:
try: try:
resource_conn.close() resource_conn.close()
except Exception: except Exception:
pass pass
# Global dedupe + sort.
if merged:
seen_ids2: set[str] = set()
deduped: list[dict[str, Any]] = []
for m in merged:
mid = str(m.get("id") or "").strip()
if mid and mid in seen_ids2:
continue
if mid:
seen_ids2.add(mid)
deduped.append(m)
merged = deduped
def sort_key_global(m: dict[str, Any]) -> tuple[int, int, str, int]:
cts = int(m.get("createTime") or 0)
sseq = int(m.get("sortSeq") or 0)
lid = int(m.get("localId") or 0)
mid = str(m.get("id") or "")
stem2 = ""
try:
stem2 = mid.split(":", 1)[0] if ":" in mid else ""
except Exception:
stem2 = ""
return (cts, sseq, stem2, lid)
merged.sort(key=sort_key_global, reverse=False)
anchor_index_all = -1
for i, m in enumerate(merged):
if str(m.get("id") or "") == str(anchor_id_canon):
anchor_index_all = i
break
if anchor_index_all < 0:
# Fallback: ignore table casing differences when matching anchor.
for i, m in enumerate(merged):
mid = str(m.get("id") or "")
p2 = mid.split(":", 2)
if len(p2) != 3:
continue
if p2[0] != anchor_db_stem:
continue
try:
if int(p2[2] or 0) == int(anchor_local_id):
anchor_index_all = i
break
except Exception:
continue
if anchor_index_all < 0:
# Should not happen because we always include the anchor row, but keep defensive.
anchor_index_all = 0
start = max(0, int(anchor_index_all) - int(before))
end = min(len(merged), int(anchor_index_all) + int(after) + 1)
return_messages = merged[start:end]
anchor_index = int(anchor_index_all) - start if 0 <= anchor_index_all < len(merged) else -1
# Postprocess only the returned window to keep it fast.
sender_usernames_win = [str(m.get("senderUsername") or "").strip() for m in return_messages if str(m.get("senderUsername") or "").strip()]
quote_usernames_win = [str(m.get("quoteUsername") or "").strip() for m in return_messages if str(m.get("quoteUsername") or "").strip()]
pat_usernames_win: set[str] = set()
try:
for m in return_messages:
if int(m.get("type") or 0) != 266287972401:
continue
raw = str(m.get("_rawText") or "")
if not raw:
continue
template = _extract_xml_tag_text(raw, "template")
if not template:
continue
pat_usernames_win.update({mm.group(1) for mm in re.finditer(r"\$\{([^}]+)\}", template) if mm.group(1)})
except Exception:
pat_usernames_win = set()
_postprocess_full_messages( _postprocess_full_messages(
merged=return_messages, merged=return_messages,
sender_usernames=sender_usernames, sender_usernames=sender_usernames_win,
quote_usernames=quote_usernames, quote_usernames=quote_usernames_win,
pat_usernames=pat_usernames, pat_usernames=pat_usernames_win,
account_dir=account_dir, account_dir=account_dir,
username=username, username=username,
base_url=base_url, base_url=base_url,
@@ -6205,24 +6627,235 @@ async def get_chat_messages_around(
head_image_db_path=head_image_db_path, head_image_db_path=head_image_db_path,
) )
def sort_key(m: dict[str, Any]) -> tuple[int, int, int]:
sseq = int(m.get("sortSeq") or 0)
cts = int(m.get("createTime") or 0)
lid = int(m.get("localId") or 0)
return (cts, sseq, lid)
return_messages.sort(key=sort_key, reverse=False)
anchor_index = -1
for i, m in enumerate(return_messages):
if str(m.get("id") or "") == str(anchor_id):
anchor_index = i
break
return { return {
"status": "success", "status": "success",
"account": account_dir.name, "account": account_dir.name,
"username": username, "username": username,
"anchorId": anchor_id, "anchorId": anchor_id_canon,
"anchorIndex": anchor_index, "anchorIndex": anchor_index,
"messages": return_messages, "messages": return_messages,
} }
@router.get("/api/chat/chat_history/resolve", summary="解析嵌套合并转发聊天记录(通过 server_id")
async def resolve_nested_chat_history(
request: Request,
server_id: int,
account: Optional[str] = None,
):
"""Resolve a nested merged-forward chat history item (datatype=17) to its full recordItem XML.
Some nested records inside a merged-forward recordItem only carry pointers like `fromnewmsgid` (server_id),
while the full recordItem exists in the original app message (local_type=49, appmsg type=19) stored elsewhere.
WeChat can open it by looking up the original message; we do the same here.
"""
if not server_id:
raise HTTPException(status_code=400, detail="Missing server_id.")
account_dir = _resolve_account_dir(account)
db_paths = _iter_message_db_paths(account_dir)
base_url = str(request.base_url).rstrip("/")
found_appmsg = False
for db_path in db_paths:
conn: Optional[sqlite3.Connection] = None
try:
conn = sqlite3.connect(str(db_path))
conn.row_factory = sqlite3.Row
conn.text_factory = bytes
try:
table_rows = conn.execute(
# Some DBs use `Msg_...` (capital M). Use LOWER() to keep matching even if
# `PRAGMA case_sensitive_like=ON` is set.
"SELECT name FROM sqlite_master WHERE type='table' AND lower(name) LIKE 'msg_%'"
).fetchall()
except Exception:
table_rows = []
# With `conn.text_factory = bytes`, sqlite_master.name comes back as bytes.
# Decode it to the real table name, otherwise we'd end up querying a non-existent
# table like "b'Msg_...'" and never find the message.
table_names = [_decode_sqlite_text(r[0]).strip() for r in table_rows if r and r[0]]
for table_name in table_names:
quoted = _quote_ident(table_name)
try:
row = conn.execute(
f"""
SELECT local_id, server_id, local_type, create_time, message_content, compress_content
FROM {quoted}
-- WeChat v4 can pack appmsg subtype into the high 32 bits of local_type:
-- local_type = base_type + (app_subtype << 32)
-- so a chatHistory appmsg can be 49 + (19<<32), not exactly 49.
WHERE server_id = ? AND (local_type & 4294967295) = 49
LIMIT 1
""",
(int(server_id),),
).fetchone()
except Exception:
row = None
if row is None:
continue
found_appmsg = True
raw_text = _decode_message_content(row["compress_content"], row["message_content"]).strip()
if not raw_text:
continue
# If the stored payload is a zstd frame but we couldn't decode it into XML, it's
# almost always because the optional `zstandard` dependency isn't installed.
try:
blob = row["message_content"]
if isinstance(blob, memoryview):
blob = blob.tobytes()
if isinstance(blob, (bytes, bytearray)) and bytes(blob).startswith(b"\x28\xb5\x2f\xfd"):
lower = raw_text.lower()
if "<appmsg" not in lower and "<msg" not in lower:
raise HTTPException(
status_code=500,
detail="Failed to decode zstd-compressed message_content. Please install `zstandard` and restart the backend.",
)
except HTTPException:
raise
except Exception:
pass
parsed = _parse_app_message(raw_text)
if not isinstance(parsed, dict):
continue
if str(parsed.get("renderType") or "") != "chatHistory":
# Found an app message, but not a merged-forward chat history.
continue
record_item = str(parsed.get("recordItem") or "").strip()
if not record_item:
continue
return {
"status": "success",
"serverId": int(server_id),
"title": str(parsed.get("title") or "").strip(),
"content": str(parsed.get("content") or "").strip(),
"recordItem": record_item,
"baseUrl": base_url,
}
finally:
if conn is not None:
try:
conn.close()
except Exception:
pass
if found_appmsg:
raise HTTPException(status_code=404, detail="Target message is not a chat history.")
raise HTTPException(status_code=404, detail="Message not found for server_id.")
@router.get("/api/chat/appmsg/resolve", summary="解析卡片/小程序等 App 消息(通过 server_id")
async def resolve_app_message(
request: Request,
server_id: int,
account: Optional[str] = None,
):
"""Resolve an app message (base local_type=49) by server_id.
This is mainly used by merged-forward recordItem dataitems that only contain pointers like
`fromnewmsgid` (server_id). WeChat can open the original card by looking up the appmsg in
message DBs; we do the same and return the parsed appmsg fields.
"""
if not server_id:
raise HTTPException(status_code=400, detail="Missing server_id.")
account_dir = _resolve_account_dir(account)
db_paths = _iter_message_db_paths(account_dir)
base_url = str(request.base_url).rstrip("/")
found_appmsg = False
for db_path in db_paths:
conn: Optional[sqlite3.Connection] = None
try:
conn = sqlite3.connect(str(db_path))
conn.row_factory = sqlite3.Row
conn.text_factory = bytes
try:
table_rows = conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND lower(name) LIKE 'msg_%'"
).fetchall()
except Exception:
table_rows = []
table_names = [_decode_sqlite_text(r[0]).strip() for r in table_rows if r and r[0]]
for table_name in table_names:
quoted = _quote_ident(table_name)
try:
row = conn.execute(
f"""
SELECT local_id, server_id, local_type, create_time, message_content, compress_content
FROM {quoted}
WHERE server_id = ? AND (local_type & 4294967295) = 49
LIMIT 1
""",
(int(server_id),),
).fetchone()
except Exception:
row = None
if row is None:
continue
found_appmsg = True
raw_text = _decode_message_content(row["compress_content"], row["message_content"]).strip()
if not raw_text:
continue
# Same zstd guard as chat_history/resolve.
try:
blob = row["message_content"]
if isinstance(blob, memoryview):
blob = blob.tobytes()
if isinstance(blob, (bytes, bytearray)) and bytes(blob).startswith(b"\x28\xb5\x2f\xfd"):
lower = raw_text.lower()
if "<appmsg" not in lower and "<msg" not in lower:
raise HTTPException(
status_code=500,
detail="Failed to decode zstd-compressed message_content. Please install `zstandard` and restart the backend.",
)
except HTTPException:
raise
except Exception:
pass
parsed = _parse_app_message(raw_text)
if not isinstance(parsed, dict):
continue
# Return a stable, explicit shape for the frontend.
return {
"status": "success",
"serverId": int(server_id),
"renderType": str(parsed.get("renderType") or "text"),
"title": str(parsed.get("title") or "").strip(),
"content": str(parsed.get("content") or "").strip(),
"url": str(parsed.get("url") or "").strip(),
"thumbUrl": str(parsed.get("thumbUrl") or "").strip(),
"coverUrl": str(parsed.get("coverUrl") or "").strip(),
"from": str(parsed.get("from") or "").strip(),
"fromUsername": str(parsed.get("fromUsername") or "").strip(),
"linkType": str(parsed.get("linkType") or "").strip(),
"linkStyle": str(parsed.get("linkStyle") or "").strip(),
"size": str(parsed.get("size") or "").strip(),
"baseUrl": base_url,
}
finally:
if conn is not None:
try:
conn.close()
except Exception:
pass
if found_appmsg:
raise HTTPException(status_code=404, detail="App message decode failed.")
raise HTTPException(status_code=404, detail="Message not found for server_id.")

View File

@@ -59,6 +59,10 @@ class ChatExportCreateRequest(BaseModel):
False, False,
description="HTML 导出时允许联网下载链接/引用缩略图等远程媒体(提高离线完整性)", description="HTML 导出时允许联网下载链接/引用缩略图等远程媒体(提高离线完整性)",
) )
html_page_size: int = Field(
1000,
description="HTML 导出分页大小(每页消息数);<=0 表示禁用分页(单文件,打开大聊天可能很卡)",
)
privacy_mode: bool = Field( privacy_mode: bool = Field(
False, False,
description="隐私模式导出:隐藏会话/用户名/内容,不打包头像与媒体", description="隐私模式导出:隐藏会话/用户名/内容,不打包头像与媒体",
@@ -83,6 +87,7 @@ async def create_chat_export(req: ChatExportCreateRequest):
output_dir=req.output_dir, output_dir=req.output_dir,
allow_process_key_extract=req.allow_process_key_extract, allow_process_key_extract=req.allow_process_key_extract,
download_remote_media=req.download_remote_media, download_remote_media=req.download_remote_media,
html_page_size=req.html_page_size,
privacy_mode=req.privacy_mode, privacy_mode=req.privacy_mode,
file_name=req.file_name, file_name=req.file_name,
) )

View File

@@ -1019,6 +1019,171 @@ async def proxy_image(url: str):
return resp 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") @router.post("/api/chat/media/emoji/download", summary="下载表情消息资源到本地 resource")
async def download_chat_emoji(req: EmojiDownloadRequest): async def download_chat_emoji(req: EmojiDownloadRequest):
md5 = str(req.md5 or "").strip().lower() md5 = str(req.md5 or "").strip().lower()

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

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()

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")