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;
}
/* 时间侧边栏(按日期定位) */
.time-sidebar {
@apply w-[420px] h-full flex flex-col bg-white border-l border-gray-200 flex-shrink-0;
}
.time-sidebar-header {
@apply flex items-center justify-between px-4 py-3 border-b border-gray-200 bg-gray-50;
}
.time-sidebar-title {
@apply flex items-center gap-2 text-sm font-medium text-gray-800;
}
.time-sidebar-close {
@apply p-1.5 text-gray-500 hover:text-gray-700 hover:bg-gray-200 rounded-md transition-colors;
}
.time-sidebar-body {
@apply flex-1 overflow-y-auto min-h-0;
}
.time-sidebar-status {
@apply px-4 py-2 text-xs text-gray-600 border-b border-gray-100;
}
.time-sidebar-status-error {
@apply text-red-600;
}
.calendar-header {
@apply flex items-center justify-between px-4 py-3;
}
.calendar-nav-btn {
@apply p-1.5 text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed;
}
.calendar-month-label {
@apply text-sm font-medium text-gray-800;
}
.calendar-month-label-selects {
@apply flex items-center gap-2;
}
.calendar-ym-select {
@apply text-xs px-2 py-1 rounded-md border border-gray-200 bg-white text-gray-800 hover:border-gray-300 focus:outline-none focus:ring-2 focus:ring-[#03C160]/20 disabled:opacity-60 disabled:cursor-not-allowed;
}
.calendar-weekdays {
@apply grid grid-cols-7 gap-1 px-4 pt-1;
}
.calendar-weekday {
@apply text-[11px] text-gray-400 text-center py-1;
}
.calendar-grid {
@apply grid grid-cols-7 gap-1 px-4 pb-4;
}
.calendar-day {
@apply h-9 rounded-md flex items-center justify-center text-xs font-medium transition-colors border border-gray-200 bg-white disabled:cursor-not-allowed;
}
.calendar-day-outside {
@apply bg-transparent border-transparent;
}
.calendar-day-empty {
@apply bg-gray-100 text-gray-400 border-gray-100;
}
.calendar-day-selected {
/* Keep background as-is (heatmap), but emphasize with a ring/outline. */
box-shadow: 0 0 0 2px rgba(3, 193, 96, 0.85);
border-color: rgba(3, 193, 96, 0.95) !important;
}
.calendar-day-l1 {
background: rgba(3, 193, 96, 0.12);
border-color: rgba(3, 193, 96, 0.18);
color: #065f46;
}
.calendar-day-l2 {
background: rgba(3, 193, 96, 0.24);
border-color: rgba(3, 193, 96, 0.28);
color: #065f46;
}
.calendar-day-l3 {
background: rgba(3, 193, 96, 0.38);
border-color: rgba(3, 193, 96, 0.40);
color: #064e3b;
}
.calendar-day-l4 {
background: rgba(3, 193, 96, 0.55);
border-color: rgba(3, 193, 96, 0.55);
color: #053d2e;
}
.calendar-day-l1:hover,
.calendar-day-l2:hover,
.calendar-day-l3:hover,
.calendar-day-l4:hover {
filter: brightness(0.98);
}
.calendar-day-number {
@apply select-none;
}
.time-sidebar-actions {
@apply px-4 pb-4;
}
.time-sidebar-action-btn {
@apply w-full text-xs px-3 py-2 rounded-md bg-[#03C160] text-white hover:bg-[#02a650] transition-colors disabled:opacity-60 disabled:cursor-not-allowed;
}
/* 整合搜索框样式 */
.search-input-combined {
@apply flex items-center bg-white border-2 border-gray-200 rounded-lg overflow-hidden transition-all duration-200;

View File

@@ -180,6 +180,46 @@ export const useApi = () => {
return await request(url)
}
// 聊天记录日历热力图:某月每日消息数
const getChatMessageDailyCounts = async (params = {}) => {
const query = new URLSearchParams()
if (params && params.account) query.set('account', params.account)
if (params && params.username) query.set('username', params.username)
if (params && params.year != null) query.set('year', String(params.year))
if (params && params.month != null) query.set('month', String(params.month))
const url = '/chat/messages/daily_counts' + (query.toString() ? `?${query.toString()}` : '')
return await request(url)
}
// 聊天记录定位锚点:某日第一条 / 会话最早一条
const getChatMessageAnchor = async (params = {}) => {
const query = new URLSearchParams()
if (params && params.account) query.set('account', params.account)
if (params && params.username) query.set('username', params.username)
if (params && params.kind) query.set('kind', String(params.kind))
if (params && params.date) query.set('date', String(params.date))
const url = '/chat/messages/anchor' + (query.toString() ? `?${query.toString()}` : '')
return await request(url)
}
// 解析嵌套合并转发聊天记录(通过 server_id
const resolveNestedChatHistory = async (params = {}) => {
const query = new URLSearchParams()
if (params && params.account) query.set('account', params.account)
if (params && params.server_id != null) query.set('server_id', String(params.server_id))
const url = '/chat/chat_history/resolve' + (query.toString() ? `?${query.toString()}` : '')
return await request(url)
}
// 解析卡片/小程序等 App 消息(通过 server_id
const resolveAppMsg = async (params = {}) => {
const query = new URLSearchParams()
if (params && params.account) query.set('account', params.account)
if (params && params.server_id != null) query.set('server_id', String(params.server_id))
const url = '/chat/appmsg/resolve' + (query.toString() ? `?${query.toString()}` : '')
return await request(url)
}
// 朋友圈时间线
const listSnsTimeline = async (params = {}) => {
const query = new URLSearchParams()
@@ -295,6 +335,7 @@ export const useApi = () => {
output_dir: data.output_dir == null ? null : String(data.output_dir || '').trim(),
allow_process_key_extract: !!data.allow_process_key_extract,
download_remote_media: !!data.download_remote_media,
html_page_size: data.html_page_size != null ? Number(data.html_page_size) : 1000,
privacy_mode: !!data.privacy_mode,
file_name: data.file_name || null
}
@@ -408,6 +449,10 @@ export const useApi = () => {
buildChatSearchIndex,
listChatSearchSenders,
getChatMessagesAround,
getChatMessageDailyCounts,
getChatMessageAnchor,
resolveNestedChatHistory,
resolveAppMsg,
listSnsTimeline,
listSnsMediaCandidates,
saveSnsMediaPicks,

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -5,6 +5,7 @@ import asyncio
import json
import time
import threading
from datetime import datetime, timedelta
from os import scandir
from pathlib import Path
from typing import Any, Optional
@@ -452,6 +453,33 @@ def _resolve_decrypted_message_table(account_dir: Path, username: str) -> Option
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]:
"""Pick a target decrypted sqlite db to place a new Msg_<md5> table.
@@ -3126,6 +3154,27 @@ def _postprocess_full_messages(
base_url
+ 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":
if str(m.get("serverId") or ""):
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
@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="获取会话消息列表")
def list_chat_messages(
request: Request,
@@ -5055,6 +5280,23 @@ def list_chat_messages(
base_url
+ 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":
if str(m.get("serverId") or ""):
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.")
if not anchor_id:
raise HTTPException(status_code=400, detail="Missing anchor_id.")
if before < 0:
before = 0
if after < 0:
@@ -5967,7 +6210,7 @@ async def get_chat_messages_around(
parts = str(anchor_id).split(":", 2)
if len(parts) != 3:
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:
anchor_local_id = int(anchor_local_id_str)
except Exception:
@@ -5980,14 +6223,15 @@ async def get_chat_messages_around(
message_resource_db_path = account_dir / "message_resource.db"
base_url = str(request.base_url).rstrip("/")
target_db: Optional[Path] = None
anchor_db_path: Optional[Path] = None
for p in db_paths:
if p.stem == anchor_db_stem:
target_db = p
anchor_db_path = p
break
if target_db is None:
if anchor_db_path is None:
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_chat_id: Optional[int] = None
try:
@@ -6004,21 +6248,106 @@ async def get_chat_messages_around(
resource_conn = None
resource_chat_id = None
conn = sqlite3.connect(str(target_db))
conn.row_factory = sqlite3.Row
# Resolve anchor message tuple from its DB.
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:
table_name = str(anchor_table_name).strip()
if not table_name:
conn_a = sqlite3.connect(str(anchor_db_path))
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.")
# Normalize table name casing if needed
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]}
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:
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_rowid = None
try:
@@ -6041,53 +6370,37 @@ async def get_chat_messages_around(
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, "
+ 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
try:
anchor_row = conn.execute(sql_anchor_with_join, (anchor_local_id,)).fetchone()
except Exception:
anchor_row = conn.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
# Stable cross-db ordering: (create_time, sort_seq, db_stem, local_id)
stem = db_path.stem
if stem < anchor_db_stem:
tie_before = "1"
tie_before_params: tuple[Any, ...] = ()
tie_after = "0"
tie_after_params: tuple[Any, ...] = ()
elif stem > anchor_db_stem:
tie_before = "0"
tie_before_params = ()
tie_after = "1"
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 ("
"m.create_time < ? "
"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 ("
"m.create_time > ? "
"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 ?"
)
params_before = (anchor_ct, anchor_ct, anchor_ss, anchor_ct, anchor_ss, anchor_local_id, int(before) + 1)
params_after = (anchor_ct, anchor_ct, anchor_ss, anchor_ct, anchor_ss, anchor_local_id, int(after) + 1)
# Always fetch anchor row from anchor DB, but don't include anchor itself in before/after queries.
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:
before_rows = conn.execute(sql_before_with_join, params_before).fetchall()
except Exception:
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:
after_rows = conn.execute(sql_after_with_join, params_after).fetchall()
except Exception:
after_rows = conn.execute(sql_after_no_join, params_after).fetchall()
# Dedup rows by message id within this DB.
seen_ids: set[str] = set()
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)
mid = f"{target_db.stem}:{table_name}:{lid}"
mid = f"{db_path.stem}:{table_name}:{lid}"
if mid in seen_ids:
continue
seen_ids.add(mid)
combined.append(rr)
merged: list[dict[str, Any]] = []
sender_usernames: list[str] = []
quote_usernames: list[str] = []
pat_usernames: set[str] = set()
is_group = bool(username.endswith("@chatroom"))
if not combined:
continue
_append_full_messages_from_rows(
merged=merged,
sender_usernames=sender_usernames,
quote_usernames=quote_usernames,
pat_usernames=pat_usernames,
sender_usernames=sender_usernames_all,
quote_usernames=quote_usernames_all,
pat_usernames=pat_usernames_all,
rows=combined,
db_path=target_db,
db_path=db_path,
table_name=table_name,
username=username,
account_dir=account_dir,
@@ -6183,21 +6522,104 @@ async def get_chat_messages_around(
resource_conn=resource_conn,
resource_chat_id=resource_chat_id,
)
return_messages = merged
except HTTPException:
raise
except Exception:
# Skip broken DBs / missing tables gracefully.
continue
finally:
if conn is not None:
try:
conn.close()
except Exception:
pass
if resource_conn is not None:
try:
resource_conn.close()
except Exception:
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(
merged=return_messages,
sender_usernames=sender_usernames,
quote_usernames=quote_usernames,
pat_usernames=pat_usernames,
sender_usernames=sender_usernames_win,
quote_usernames=quote_usernames_win,
pat_usernames=pat_usernames_win,
account_dir=account_dir,
username=username,
base_url=base_url,
@@ -6205,24 +6627,235 @@ async def get_chat_messages_around(
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 {
"status": "success",
"account": account_dir.name,
"username": username,
"anchorId": anchor_id,
"anchorId": anchor_id_canon,
"anchorIndex": anchor_index,
"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,
description="HTML 导出时允许联网下载链接/引用缩略图等远程媒体(提高离线完整性)",
)
html_page_size: int = Field(
1000,
description="HTML 导出分页大小(每页消息数);<=0 表示禁用分页(单文件,打开大聊天可能很卡)",
)
privacy_mode: bool = Field(
False,
description="隐私模式导出:隐藏会话/用户名/内容,不打包头像与媒体",
@@ -83,6 +87,7 @@ async def create_chat_export(req: ChatExportCreateRequest):
output_dir=req.output_dir,
allow_process_key_extract=req.allow_process_key_extract,
download_remote_media=req.download_remote_media,
html_page_size=req.html_page_size,
privacy_mode=req.privacy_mode,
file_name=req.file_name,
)

View File

@@ -1019,6 +1019,171 @@ async def proxy_image(url: str):
return resp
def _origin_favicon_url(page_url: str) -> str:
"""Best-effort favicon URL for a given page URL (origin + /favicon.ico)."""
u = str(page_url or "").strip()
if not u:
return ""
try:
p = urlparse(u)
except Exception:
return ""
if not p.scheme or not p.netloc:
return ""
return f"{p.scheme}://{p.netloc}/favicon.ico"
def _resolve_final_url_for_favicon(page_url: str) -> str:
"""Resolve final URL for redirects (used for favicon host inference)."""
u = str(page_url or "").strip()
if not u:
return ""
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120 Safari/537.36",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
}
# Prefer HEAD (no body). Some hosts reject HEAD; fall back to GET+stream.
try:
r = requests.head(u, headers=headers, timeout=10, allow_redirects=True)
try:
final = str(getattr(r, "url", "") or "").strip()
return final or u
finally:
try:
r.close()
except Exception:
pass
except Exception:
pass
try:
r = requests.get(u, headers=headers, timeout=10, allow_redirects=True, stream=True)
try:
final = str(getattr(r, "url", "") or "").strip()
return final or u
finally:
try:
r.close()
except Exception:
pass
except Exception:
return u
@router.get("/api/chat/media/favicon", summary="获取网站 favicon用于链接卡片来源头像")
async def get_favicon(url: str):
page_url = html.unescape(str(url or "")).strip()
if not page_url:
raise HTTPException(status_code=400, detail="Missing url.")
if not _is_safe_http_url(page_url):
raise HTTPException(status_code=400, detail="Invalid url (only public http/https allowed).")
# Resolve redirects first (e.g. b23.tv -> www.bilibili.com), so cached favicons are hit early.
final_url = _resolve_final_url_for_favicon(page_url)
candidates: list[str] = []
for u in (final_url, page_url):
fav = _origin_favicon_url(u)
if fav and fav not in candidates:
candidates.append(fav)
proxy_account = "_favicon"
max_bytes = 512 * 1024 # favicons should be small; protect against huge downloads.
for cand in candidates:
if not _is_safe_http_url(cand):
continue
source_url = normalize_avatar_source_url(cand)
cache_entry = get_avatar_cache_url_entry(proxy_account, source_url) if is_avatar_cache_enabled() else None
cache_file = avatar_cache_entry_file_exists(proxy_account, cache_entry)
if cache_entry and cache_file and avatar_cache_entry_is_fresh(cache_entry):
logger.info(f"[avatar_cache_hit] kind=favicon account={proxy_account} url={source_url}")
touch_avatar_cache_entry(proxy_account, cache_key_for_avatar_url(source_url))
headers = build_avatar_cache_response_headers(cache_entry)
return FileResponse(
str(cache_file),
media_type=str(cache_entry.get("media_type") or "application/octet-stream"),
headers=headers,
)
# Download favicon bytes (best-effort)
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120 Safari/537.36",
"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
}
r = None
try:
r = requests.get(source_url, headers=headers, timeout=20, stream=True, allow_redirects=True)
if int(getattr(r, "status_code", 0) or 0) != 200:
continue
ct = str((getattr(r, "headers", {}) or {}).get("Content-Type") or "").strip()
try:
cl = int((getattr(r, "headers", {}) or {}).get("content-length") or 0)
except Exception:
cl = 0
if cl and cl > max_bytes:
raise HTTPException(status_code=413, detail="Remote favicon too large.")
chunks: list[bytes] = []
total = 0
for chunk in r.iter_content(chunk_size=64 * 1024):
if not chunk:
continue
chunks.append(chunk)
total += len(chunk)
if total > max_bytes:
raise HTTPException(status_code=413, detail="Remote favicon too large.")
data = b"".join(chunks)
except HTTPException:
raise
except Exception:
continue
finally:
if r is not None:
try:
r.close()
except Exception:
pass
if not data:
continue
payload, media_type, _ext = _detect_media_type_and_ext(data)
if media_type == "application/octet-stream" and ct:
try:
mt = ct.split(";")[0].strip()
if mt.startswith("image/"):
media_type = mt
except Exception:
pass
if not str(media_type or "").startswith("image/"):
continue
if is_avatar_cache_enabled():
entry, out_path = write_avatar_cache_payload(
proxy_account,
source_kind="url",
source_url=source_url,
payload=payload,
media_type=media_type,
ttl_seconds=AVATAR_CACHE_TTL_SECONDS,
)
if entry and out_path:
logger.info(f"[avatar_cache_download] kind=favicon account={proxy_account} url={source_url}")
headers = build_avatar_cache_response_headers(entry)
return FileResponse(str(out_path), media_type=media_type, headers=headers)
resp = Response(content=payload, media_type=media_type)
resp.headers["Cache-Control"] = f"public, max-age={AVATAR_CACHE_TTL_SECONDS}"
return resp
raise HTTPException(status_code=404, detail="favicon not found.")
@router.post("/api/chat/media/emoji/download", summary="下载表情消息资源到本地 resource")
async def download_chat_emoji(req: EmojiDownloadRequest):
md5 = str(req.md5 or "").strip().lower()

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