Files
WeChatDataAnalysis/src/wechat_decrypt_tool/chat_export_service.py
T
2977094657 a01dba4a93 fix(chat): 修复群聊置顶系统消息显示名称与导出文案
- 解析 ChatRoomTopMsg 文本/XML 系统消息,识别置顶与取消置顶操作
- 优先使用备注名/联系人名替换 wxid,避免实时消息、历史消息、搜索结果显示原始账号
- 导出 JSON/TXT/HTML 时复用同一套系统消息名称解析逻辑
- 补充系统消息解析与实时消息展示测试,覆盖多种消息载荷格式
2026-03-22 15:34:05 +08:00

6236 lines
254 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
import functools
import base64
import hashlib
import heapq
import html
import ipaddress
import json
import os
import re
import sqlite3
import socket
import tempfile
import threading
import time
import uuid
import zipfile
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from typing import Any, Callable, Iterable, Literal, Optional
from urllib.parse import urljoin, urlparse
import requests
from .chat_helpers import (
_decode_message_content,
_decode_sqlite_text,
_extract_sender_from_group_xml,
_extract_xml_attr,
_extract_xml_tag_or_attr,
_extract_xml_tag_text,
_format_session_time,
_infer_message_brief_by_local_type,
_infer_transfer_status_text,
_iter_message_db_paths,
_list_decrypted_accounts,
_load_contact_rows,
_load_latest_message_previews,
_lookup_resource_md5,
_parse_app_message,
_parse_location_message,
_parse_system_message_content,
_parse_pat_message,
_pick_display_name,
_quote_ident,
_resolve_account_dir,
_resolve_msg_table_name,
_resource_lookup_chat_id,
_should_keep_session,
_split_group_sender_prefix,
)
from .logging_config import get_logger
from .media_helpers import (
_convert_silk_to_browser_audio,
_detect_image_media_type,
_fallback_search_media_by_file_id,
_read_and_maybe_decrypt_media,
_resolve_account_db_storage_dir,
_resolve_account_wxid_dir,
_resolve_media_path_for_kind,
_try_find_decrypted_resource,
)
logger = get_logger(__name__)
ExportFormat = Literal["json", "txt", "html"]
ExportScope = Literal["selected", "all", "groups", "singles"]
ExportStatus = Literal["queued", "running", "done", "error", "cancelled"]
MediaKind = Literal["image", "emoji", "video", "video_thumb", "voice", "file"]
def _now_iso() -> str:
return datetime.now().isoformat(timespec="seconds")
_INVALID_PATH_CHARS = re.compile(r'[<>:"/\\|?*\x00-\x1f]')
def _safe_name(s: str, max_len: int = 80) -> str:
t = str(s or "").strip()
if not t:
return ""
t = _INVALID_PATH_CHARS.sub("_", t)
t = re.sub(r"\s+", " ", t).strip()
if len(t) > max_len:
t = t[:max_len].rstrip()
return t
def _resolve_export_output_dir(account_dir: Path, output_dir_raw: Any) -> Path:
text = str(output_dir_raw or "").strip()
if not text:
default_dir = account_dir.parents[1] / "exports" / account_dir.name
default_dir.mkdir(parents=True, exist_ok=True)
return default_dir
out_dir = Path(text).expanduser()
if not out_dir.is_absolute():
raise ValueError("output_dir must be an absolute path.")
try:
out_dir.mkdir(parents=True, exist_ok=True)
except Exception as e:
raise ValueError(f"Failed to prepare output_dir: {e}") from e
return out_dir.resolve()
def _resolve_ui_public_dir() -> Optional[Path]:
"""Best-effort resolve Nuxt generated public directory for exporting UI CSS.
Priority:
1) `WECHAT_TOOL_UI_DIR` env
2) repo default `frontend/.output/public`
"""
ui_dir_env = os.environ.get("WECHAT_TOOL_UI_DIR", "").strip()
candidates: list[Path] = []
if ui_dir_env:
candidates.append(Path(ui_dir_env))
# Repo defaults: generated Nuxt output or checked-in desktop UI assets.
repo_root = Path(__file__).resolve().parents[2]
candidates.append(repo_root / "frontend" / ".output" / "public")
candidates.append(repo_root / "desktop" / "resources" / "ui")
for p in candidates:
try:
nuxt_dir = p / "_nuxt"
if nuxt_dir.is_dir() and any(nuxt_dir.glob("entry.*.css")):
return p
except Exception:
continue
return None
def _load_ui_entry_css(ui_public_dir: Path) -> str:
"""Load Nuxt `entry.*.css` content (choose largest file if multiple)."""
nuxt_dir = Path(ui_public_dir) / "_nuxt"
try:
css_files = list(nuxt_dir.glob("entry.*.css"))
except Exception:
css_files = []
if not css_files:
return ""
def sort_key(p: Path) -> int:
try:
return int(p.stat().st_size)
except Exception:
return 0
css_files.sort(key=sort_key, reverse=True)
best = css_files[0]
try:
return best.read_text(encoding="utf-8")
except Exception:
try:
return best.read_text(encoding="utf-8", errors="ignore")
except Exception:
return ""
_VUE_SCOPED_ATTR_RE = re.compile(r"\[data-v-[0-9a-f]{8}\]", flags=re.IGNORECASE)
_CHAT_HISTORY_MD5_TAG_RE = re.compile(
r"(?i)<(?:fullmd5|thumbfullmd5|md5|emoticonmd5|emojimd5|cdnthumbmd5)>([0-9a-f]{32})<"
)
_CHAT_HISTORY_URL_TAG_RE = re.compile(r"(?i)<(?:sourceheadurl|cdnurlstring|encrypturlstring|externurl)>(https?://[^<\s]+)<")
_CHAT_HISTORY_SERVER_ID_TAG_RE = re.compile(r"(?i)<fromnewmsgid>\s*(\d+)\s*<")
def _strip_vue_scoped_attrs(css: str) -> str:
"""Strip Vue SFC scoped attribute selectors like `[data-v-xxxxxxxx]`."""
if not css:
return ""
try:
return _VUE_SCOPED_ATTR_RE.sub("", css)
except Exception:
return css
def _load_ui_css_bundle(*, ui_public_dir: Optional[Path], report: dict[str, Any]) -> str:
"""Load Nuxt CSS bundle for offline HTML export.
Includes:
- `_nuxt/entry.*.css` (base + tailwind utilities)
- Chat page chunks `_nuxt/*_username_*.css` (scoped selectors stripped)
- `_HTML_EXPORT_CSS_PATCH` appended last
Falls back to `_HTML_EXPORT_CSS_FALLBACK` when entry css is missing.
Note: We only bundle chat-related chunks because stripping Vue SFC scoped selectors (`[data-v-...]`) can
otherwise leak scoped utility overrides (e.g. `.text-sm[data-v-...]`) into global rules in the export.
"""
if ui_public_dir is None:
try:
report["errors"].append("WARN: Nuxt UI dir not found; export HTML will use fallback styles.")
except Exception:
pass
return _HTML_EXPORT_CSS_FALLBACK + "\n\n" + _HTML_EXPORT_CSS_PATCH
entry_css = _load_ui_entry_css(ui_public_dir)
if not entry_css:
try:
report["errors"].append("WARN: Nuxt UI CSS not found; export HTML will use fallback styles.")
except Exception:
pass
return _HTML_EXPORT_CSS_FALLBACK + "\n\n" + _HTML_EXPORT_CSS_PATCH
entry_css = _strip_vue_scoped_attrs(entry_css)
nuxt_dir = Path(ui_public_dir) / "_nuxt"
chat_css_paths: list[Path] = []
try:
chat_css_paths = [p for p in nuxt_dir.glob("*_username_*.css") if p.is_file()]
except Exception:
chat_css_paths = []
chat_css_paths.sort(key=lambda p: p.name)
if not chat_css_paths:
try:
report["errors"].append(
"WARN: Nuxt chat CSS chunk not found (*_username_*.css); some message styles may be missing."
)
except Exception:
pass
extra_chunks: list[str] = []
for p in chat_css_paths:
try:
extra_chunks.append(_strip_vue_scoped_attrs(p.read_text(encoding="utf-8")))
except Exception:
try:
extra_chunks.append(_strip_vue_scoped_attrs(p.read_text(encoding="utf-8", errors="ignore")))
except Exception:
continue
parts = [entry_css]
if extra_chunks:
parts.append("\n\n".join(extra_chunks))
parts.append(_HTML_EXPORT_CSS_PATCH)
return "\n\n".join(parts)
_TS_WECHAT_EMOJI_ENTRY_RE = re.compile(r'^\s*"(?P<key>[^"]+)"\s*:\s*"(?P<value>[^"]+)"\s*,?\s*$')
@functools.lru_cache(maxsize=1)
def _load_wechat_emoji_table() -> dict[str, str]:
repo_root = Path(__file__).resolve().parents[2]
path = repo_root / "frontend" / "utils" / "wechat-emojis.ts"
try:
text = path.read_text(encoding="utf-8")
except Exception:
return {}
table: dict[str, str] = {}
for line in text.splitlines():
stripped = line.strip()
if not stripped or stripped.startswith("//"):
continue
match = _TS_WECHAT_EMOJI_ENTRY_RE.match(line)
if match:
key = str(match.group("key") or "")
value = str(match.group("value") or "")
if key and value:
table[key] = value
return table
@functools.lru_cache(maxsize=1)
def _load_wechat_emoji_regex() -> Optional[re.Pattern[str]]:
table = _load_wechat_emoji_table()
if not table:
return None
keys = sorted(table.keys(), key=len, reverse=True)
escaped = [re.escape(k) for k in keys if k]
if not escaped:
return None
try:
return re.compile(f"({'|'.join(escaped)})")
except Exception:
return None
def _zip_write_tree(
*,
zf: zipfile.ZipFile,
src_dir: Path,
dest_prefix: str,
written: set[str],
) -> int:
"""Recursively add a directory tree to the zip under `dest_prefix`.
Skips any file whose `arcname` already exists in `written`.
Returns number of files written.
"""
try:
if not src_dir.exists() or (not src_dir.is_dir()):
return 0
except Exception:
return 0
prefix = str(dest_prefix or "").strip().strip("/").replace("\\", "/")
count = 0
try:
for p in src_dir.rglob("*"):
try:
if not p.is_file():
continue
except Exception:
continue
try:
rel = p.relative_to(src_dir).as_posix()
except Exception:
rel = p.name
arc = f"{prefix}/{rel}" if prefix else rel
arc = arc.lstrip("/").replace("\\", "/")
if not arc or arc in written:
continue
try:
zf.write(str(p), arcname=arc)
except Exception:
continue
written.add(arc)
count += 1
except Exception:
return count
return count
_REMOTE_IMAGE_MAX_BYTES = 5 * 1024 * 1024
_REMOTE_IMAGE_TIMEOUT = (5, 10)
_REMOTE_IMAGE_ALLOWED_CT: dict[str, str] = {
"image/jpeg": "jpg",
"image/png": "png",
"image/gif": "gif",
"image/webp": "webp",
}
def _is_public_ip(ip_text: str) -> bool:
try:
ip = ipaddress.ip_address(str(ip_text or "").strip())
except Exception:
return False
return bool(getattr(ip, "is_global", False))
def _is_safe_remote_host(hostname: str, port: Optional[int]) -> bool:
host = str(hostname or "").strip().lower().rstrip(".")
if not host:
return False
if host == "localhost" or host.endswith(".localhost"):
return False
try:
if _is_public_ip(host):
return True
if re.fullmatch(r"[0-9a-f:]+", host) and ":" in host and (not _is_public_ip(host)):
return False
except Exception:
pass
try:
infos = socket.getaddrinfo(host, int(port or 443), type=socket.SOCK_STREAM)
except Exception:
return False
for info in infos:
try:
sockaddr = info[4]
ip_text = str(sockaddr[0] or "")
except Exception:
ip_text = ""
if not _is_public_ip(ip_text):
return False
return True
def _download_remote_image_to_zip(
*,
zf: zipfile.ZipFile,
url: str,
remote_written: dict[str, str],
report: dict[str, Any],
) -> str:
raw = str(url or "").strip()
if not raw:
return ""
cached = remote_written.get(raw)
if cached is not None:
return cached
current = raw
last_error = ""
for _ in range(4): # 0..3 redirects
parsed = urlparse(current)
if parsed.scheme not in {"http", "https"}:
last_error = f"unsupported scheme: {parsed.scheme}"
break
host = parsed.hostname or ""
if not host:
last_error = "missing hostname"
break
if not _is_safe_remote_host(host, parsed.port):
last_error = f"blocked host: {host}"
break
resp = None
try:
resp = requests.get(
current,
stream=True,
timeout=_REMOTE_IMAGE_TIMEOUT,
allow_redirects=False,
headers={
"User-Agent": "wechat-chat-export/1.0",
"Accept": "image/*",
},
)
if int(resp.status_code) in {301, 302, 303, 307, 308}:
loc = str(resp.headers.get("Location") or "").strip()
if not loc:
last_error = f"redirect without Location ({resp.status_code})"
break
current = urljoin(current, loc)
continue
if int(resp.status_code) != 200:
last_error = f"http {resp.status_code}"
break
ct = str(resp.headers.get("Content-Type") or "").split(";", 1)[0].strip().lower()
ext = _REMOTE_IMAGE_ALLOWED_CT.get(ct, "")
cl = str(resp.headers.get("Content-Length") or "").strip()
if cl:
try:
if int(cl) > _REMOTE_IMAGE_MAX_BYTES:
last_error = f"remote image too large: {cl} bytes"
break
except Exception:
pass
buf = bytearray()
too_large = False
for chunk in resp.iter_content(chunk_size=65536):
if not chunk:
continue
buf.extend(chunk)
if len(buf) > _REMOTE_IMAGE_MAX_BYTES:
too_large = True
break
if too_large:
last_error = f"remote image too large: >{_REMOTE_IMAGE_MAX_BYTES} bytes"
break
if not ext:
# Some WeChat CDN endpoints return `application/octet-stream` even for images.
# Detect by magic bytes to improve offline exports for merged-forward emojis/avatars.
try:
mt2 = _detect_image_media_type(bytes(buf[:32]))
except Exception:
mt2 = ""
ext = _REMOTE_IMAGE_ALLOWED_CT.get(str(mt2 or "").strip().lower(), "")
if not ext:
last_error = f"unsupported content-type: {ct or 'unknown'}"
break
h = hashlib.sha256(raw.encode("utf-8", errors="ignore")).hexdigest()
arc = f"media/remote/{h[:32]}.{ext}"
zf.writestr(arc, bytes(buf))
remote_written[raw] = arc
return arc
except Exception as e:
last_error = f"request failed: {e}"
break
finally:
try:
if resp is not None:
resp.close()
except Exception:
pass
try:
clipped = raw if len(raw) <= 260 else (raw[:257] + "...")
report["errors"].append(f"WARN: Remote image download skipped/failed: {clipped} ({last_error})")
except Exception:
pass
remote_written[raw] = ""
return ""
_HTML_EXPORT_CSS_FALLBACK = """
/* Fallback styles for chat export HTML (Nuxt build CSS not found). */
html, body { height: 100%; }
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei",
"Helvetica Neue", Helvetica, Arial, sans-serif;
background: #EDEDED;
color: #111827;
}
a { color: inherit; }
"""
_HTML_EXPORT_CSS_PATCH = """
/* Offline HTML viewer patch */
:root {
/* Keep aligned with frontend defaults (see `frontend/app.vue`). */
--dpr: 1;
--message-radius: 4px;
--sidebar-rail-step: 48px;
--sidebar-rail-btn: 32px;
--sidebar-rail-icon: 24px;
}
html, body { height: 100%; }
body { background: #EDEDED; }
/* Layout helpers (used by exported HTML). */
.wce-root { height: 100vh; display: flex; overflow: hidden; background: #EDEDED; }
.wce-rail { width: 60px; min-width: 60px; max-width: 60px; background: #e8e7e7; border-right: 1px solid #e5e7eb; display: flex; flex-direction: column; }
.wce-session-panel { width: calc(var(--session-list-width, 295px) / var(--dpr)); min-width: calc(var(--session-list-width, 295px) / var(--dpr)); max-width: calc(var(--session-list-width, 295px) / var(--dpr)); background: #F7F7F7; border-right: 1px solid #e5e7eb; display: flex; flex-direction: column; min-height: 0; }
.wce-chat-area { flex: 1; display: flex; flex-direction: column; min-height: 0; background: #EDEDED; }
.wce-chat-main { flex: 1; display: flex; min-height: 0; }
.wce-chat-col { flex: 1; display: flex; flex-direction: column; min-height: 0; min-width: 0; position: relative; }
.wce-chat-header { height: calc(56px / var(--dpr)); padding: 0 calc(20px / var(--dpr)); display: flex; align-items: center; border-bottom: 1px solid #e5e7eb; background: #EDEDED; }
.wce-chat-title { font-size: 1rem; font-weight: 500; color: #111827; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.wce-filter-select { font-size: 0.75rem; padding: calc(6px / var(--dpr)) calc(8px / var(--dpr)); border: 0; border-radius: calc(8px / var(--dpr)); 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: calc(12px / var(--dpr)); padding: calc(6px / var(--dpr)) 0 calc(12px / var(--dpr)); }
.wce-pager-btn { font-size: 0.75rem; padding: calc(6px / var(--dpr)) calc(10px / var(--dpr)); border-radius: calc(8px / var(--dpr)); 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: 0.75rem; 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; }
.wce-session-avatar { width: 45px; height: 45px; border-radius: 6px; overflow: hidden; background: #d1d5db; flex-shrink: 0; }
.wce-session-avatar img { width: 100%; height: 100%; object-fit: cover; display: block; }
.wce-session-meta { min-width: 0; flex: 1; }
.wce-session-name { font-size: 0.875rem; font-weight: 600; color: #111827; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.wce-session-sub { font-size: 0.75rem; color: #6b7280; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: calc(2px / var(--dpr)); }
/* Message rows (right column). */
.wce-msg-row { display: flex; align-items: flex-start; margin-bottom: 24px; }
.wce-msg-row-sent { justify-content: flex-end; }
.wce-msg-row-received { justify-content: flex-start; }
.wce-msg { display: flex; align-items: flex-start; max-width: 640px; }
.wce-msg-sent { flex-direction: row-reverse; }
.wce-avatar { width: calc(42px / var(--dpr)); height: calc(42px / var(--dpr)); border-radius: 6px; overflow: hidden; background: #d1d5db; flex-shrink: 0; }
.wce-avatar img { width: 100%; height: 100%; object-fit: cover; display: block; }
.wce-avatar-sent { margin-left: 12px; }
.wce-avatar-received { margin-right: 12px; }
.wce-sender-name { font-size: 0.75rem; color: #6b7280; margin-bottom: calc(4px / var(--dpr)); max-width: calc(320px / var(--dpr)); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* Bubble basics (tailwind classes may override when Nuxt CSS is present). */
.wce-bubble { padding: calc(8px / var(--dpr)) calc(12px / var(--dpr)); border-radius: var(--message-radius); font-size: 0.875rem; line-height: 1.6; white-space: pre-wrap; word-break: break-word; max-width: calc(320px / var(--dpr)); position: relative; }
.wce-bubble-sent { background: #95EC69; color: #000; }
.wce-bubble-received { background: #fff; color: #1f2937; }
/* WeChat-like bubble tail (fallback). */
.bubble-tail-l, .bubble-tail-r { position: relative; }
.bubble-tail-l::after {
content: '';
position: absolute;
left: -4px;
top: 12px;
width: 12px;
height: 12px;
background: #FFFFFF;
transform: rotate(45deg);
border-radius: 2px;
}
.bubble-tail-r::after {
content: '';
position: absolute;
right: -4px;
top: 12px;
width: 12px;
height: 12px;
background: #95EC69;
transform: rotate(45deg);
border-radius: 2px;
}
/* System messages. */
.wce-system { display: flex; justify-content: center; margin: 16px 0; }
.wce-system > div { font-size: 0.75rem; color: #9e9e9e; padding: calc(4px / var(--dpr)) 0; }
/* Media blocks. */
.wce-media-img { max-width: 240px; max-height: 240px; border-radius: var(--message-radius); display: block; object-fit: cover; }
.wce-emoji-img { width: 96px; height: 96px; object-fit: contain; display: block; }
.wce-video-wrap { position: relative; display: inline-block; border-radius: var(--message-radius); overflow: hidden; background: rgba(0,0,0,0.05); }
.wce-video-thumb { display: block; width: 220px; max-width: 260px; height: auto; max-height: 260px; object-fit: cover; }
.wce-video-play { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; }
.wce-video-play > div { width: 48px; height: 48px; border-radius: 9999px; background: rgba(0,0,0,0.45); display: flex; align-items: center; justify-content: center; }
.wce-file { border: 1px solid #e5e7eb; border-radius: 10px; padding: 10px 12px; background: #fff; max-width: 320px; }
.wce-file-name { font-size: 0.8125rem; color: #111827; word-break: break-all; }
.wce-file-meta { font-size: 0.75rem; color: #6b7280; margin-top: calc(4px / var(--dpr)); }
.wce-file-actions { margin-top: 8px; }
.wce-file-actions a { font-size: 0.75rem; color: #07c160; text-decoration: none; }
.wce-file-actions a:hover { text-decoration: underline; }
.wce-audio { width: 260px; max-width: 92vw; }
.wce-audio-actions { margin-top: 6px; }
.wce-audio-actions a { font-size: 0.75rem; color: #07c160; text-decoration: none; }
.wce-audio-actions a:hover { text-decoration: underline; }
/* Voice message fallback styles (keep close to `frontend/pages/chat/[[username]].vue`). */
.wechat-voice-wrapper { display: flex; width: 100%; position: relative; }
.wechat-voice-bubble {
border-radius: var(--message-radius);
position: relative;
transition: opacity 0.15s ease;
min-width: 80px;
max-width: 200px;
cursor: pointer;
}
.wechat-voice-bubble:hover { opacity: 0.85; }
.wechat-voice-bubble:active { opacity: 0.7; }
.wechat-voice-sent { background: #95EC69; }
.wechat-voice-sent::after {
content: '';
position: absolute;
top: 50%;
right: -4px;
transform: translateY(-50%) rotate(45deg);
width: 10px;
height: 10px;
background: #95EC69;
border-radius: 2px;
}
.wechat-voice-received { background: #fff; }
.wechat-voice-received::before {
content: '';
position: absolute;
top: 50%;
left: -4px;
transform: translateY(-50%) rotate(45deg);
width: 10px;
height: 10px;
background: #fff;
border-radius: 2px;
}
.wechat-voice-content { display: flex; align-items: center; padding: 8px 12px; gap: 8px; }
.wechat-voice-icon { width: 18px; height: 18px; flex-shrink: 0; color: #1a1a1a; }
.wechat-quote-voice-icon { width: 14px; height: 14px; color: inherit; }
.voice-icon-sent { transform: scaleX(-1); }
.wechat-voice-icon.voice-playing .voice-wave-2 { animation: voice-wave-2 1s infinite; }
.wechat-voice-icon.voice-playing .voice-wave-3 { animation: voice-wave-3 1s infinite; }
@keyframes voice-wave-2 {
0%, 33% { opacity: 0; }
34%, 100% { opacity: 1; }
}
@keyframes voice-wave-3 {
0%, 66% { opacity: 0; }
67%, 100% { opacity: 1; }
}
.wechat-voice-duration { font-size: 14px; color: #1a1a1a; }
.wechat-voice-unread {
position: absolute;
top: 50%;
right: -20px;
transform: translateY(-50%);
width: 8px;
height: 8px;
border-radius: 50%;
background: #e75e58;
}
/* Index page helpers. */
.wce-index { min-height: 100vh; background: #EDEDED; }
.wce-index-container { max-width: 880px; margin: 0 auto; padding: 24px; }
.wce-index-card { background: #fff; border: 1px solid #e5e7eb; border-radius: 12px; overflow: hidden; }
.wce-index-item { display: flex; align-items: center; gap: 12px; padding: 12px 14px; border-bottom: 1px solid #f3f4f6; text-decoration: none; color: inherit; }
.wce-index-item:last-child { border-bottom: 0; }
.wce-index-item:hover { background: #f9fafb; }
.wce-index-title { font-size: 1.125rem; font-weight: 700; color: #111827; margin: 0 0 calc(6px / var(--dpr)) 0; }
.wce-index-sub { font-size: 0.75rem; color: #6b7280; margin: 0 0 calc(16px / var(--dpr)) 0; }
"""
_HTML_EXPORT_JS = r"""
(() => {
const updateDprVar = () => {
try {
document.documentElement.style.setProperty('--dpr', '1')
} catch {}
}
const hideJsMissingBanner = () => {
try {
const el = document.getElementById('wceJsMissing')
if (el) el.style.display = 'none'
} catch {}
}
const initSessionSearch = () => {
const input = document.getElementById('sessionSearchInput')
if (!input) return
const clearBtn = document.getElementById('sessionSearchClear')
const items = Array.from(document.querySelectorAll('[data-wce-session-item=\"1\"]'))
const apply = () => {
const q = String(input.value || '').trim().toLowerCase()
try { if (clearBtn) clearBtn.style.display = q ? '' : 'none' } catch {}
items.forEach((el) => {
if (!el) return
const isActive = String(el.getAttribute('aria-current') || '') === 'page'
const name = String(el.getAttribute('data-wce-session-name') || '').toLowerCase()
const username = String(el.getAttribute('data-wce-session-username') || '').toLowerCase()
const show = !q || isActive || name.includes(q) || username.includes(q)
try { el.style.display = show ? '' : 'none' } catch {}
})
}
input.addEventListener('input', apply)
if (clearBtn) {
clearBtn.addEventListener('click', () => {
try { input.value = '' } catch {}
try { input.focus() } catch {}
apply()
})
}
apply()
}
const initVoicePlayback = () => {
let activeAudio = null
let activeIcon = null
const stopAudio = (audio, icon) => {
if (!audio) return
try { audio.pause() } catch {}
try { audio.currentTime = 0 } catch {}
try { if (icon) icon.classList.remove('voice-playing') } catch {}
}
const bindAudioEnd = (audio) => {
if (!audio) return
try {
if (audio.dataset && audio.dataset.wceVoiceBound === '1') return
if (audio.dataset) audio.dataset.wceVoiceBound = '1'
} catch {}
try {
audio.addEventListener('ended', () => {
try {
const wrapper = audio.closest('.wechat-voice-wrapper') || audio.parentElement
const icon = wrapper ? wrapper.querySelector('.wechat-voice-icon') : null
if (icon) icon.classList.remove('voice-playing')
} catch {}
if (activeAudio === audio) {
activeAudio = null
activeIcon = null
}
})
} catch {}
}
document.addEventListener('click', (ev) => {
const target = ev && ev.target
const quoteBtn = target && target.closest ? target.closest('[data-wce-quote-voice-btn=\"1\"]') : null
if (quoteBtn) {
if (quoteBtn.hasAttribute && quoteBtn.hasAttribute('disabled')) return
const wrapper = quoteBtn.closest ? (quoteBtn.closest('[data-wce-quote-voice-wrapper=\"1\"]') || quoteBtn.parentElement) : quoteBtn.parentElement
if (!wrapper) return
const audio = wrapper.querySelector ? (wrapper.querySelector('audio[data-wce-quote-voice-audio=\"1\"]') || wrapper.querySelector('audio')) : null
if (!audio) return
bindAudioEnd(audio)
const icon = (quoteBtn.querySelector && quoteBtn.querySelector('.wechat-voice-icon')) || (wrapper.querySelector && wrapper.querySelector('.wechat-voice-icon'))
if (activeAudio && activeAudio !== audio) stopAudio(activeAudio, activeIcon)
const isPlaying = !audio.paused && !audio.ended
if (activeAudio === audio && isPlaying) {
stopAudio(audio, icon)
activeAudio = null
activeIcon = null
return
}
activeAudio = audio
activeIcon = icon
try { if (icon) icon.classList.add('voice-playing') } catch {}
try {
const p = audio.play()
if (p && typeof p.catch === 'function') {
p.catch(() => {
stopAudio(audio, icon)
if (activeAudio === audio) {
activeAudio = null
activeIcon = null
}
})
}
} catch {
stopAudio(audio, icon)
if (activeAudio === audio) {
activeAudio = null
activeIcon = null
}
}
return
}
const bubble = target && target.closest ? target.closest('.wechat-voice-bubble') : null
if (!bubble) return
const wrapper = bubble.closest('.wechat-voice-wrapper') || bubble.parentElement
if (!wrapper) return
const audio = wrapper.querySelector('audio')
if (!audio) return
bindAudioEnd(audio)
const icon = bubble.querySelector('.wechat-voice-icon') || wrapper.querySelector('.wechat-voice-icon')
if (activeAudio && activeAudio !== audio) stopAudio(activeAudio, activeIcon)
const isPlaying = !audio.paused && !audio.ended
if (activeAudio === audio && isPlaying) {
stopAudio(audio, icon)
activeAudio = null
activeIcon = null
return
}
activeAudio = audio
activeIcon = icon
try { if (icon) icon.classList.add('voice-playing') } catch {}
try {
const p = audio.play()
if (p && typeof p.catch === 'function') {
p.catch(() => {
stopAudio(audio, icon)
if (activeAudio === audio) {
activeAudio = null
activeIcon = null
}
})
}
} catch {
stopAudio(audio, icon)
if (activeAudio === audio) {
activeAudio = null
activeIcon = null
}
}
})
}
const applyMessageTypeFilter = () => {
const select = document.getElementById('messageTypeFilter')
if (!select) return
const selected = String(select.value || 'all')
const nodes = document.querySelectorAll('[data-render-type]')
nodes.forEach((el) => {
const rt = String(el.getAttribute('data-render-type') || 'text')
const show = selected === 'all' ? true : rt === selected
el.style.display = show ? '' : 'none'
})
}
const scrollToBottom = () => {
const container = document.getElementById('messageContainer')
if (!container) return
container.scrollTop = container.scrollHeight
}
const updateSessionMessageCount = () => {
const el = document.getElementById('sessionMessageCount')
const container = document.getElementById('messageContainer')
if (!el || !container) return
const items = container.querySelectorAll('[data-render-type]')
el.textContent = String(items.length)
}
const safeJsonParse = (text) => {
try { return JSON.parse(String(text || '')) } catch { return null }
}
const readMediaIndex = () => {
const el = document.getElementById('wceMediaIndex')
const obj = safeJsonParse(el ? el.textContent : '')
if (!obj || typeof obj !== 'object') return {}
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) {
const s = String(v || '').trim()
if (isMaybeMd5(s)) return s.toLowerCase()
}
return ''
}
const normalizeChatHistoryUrl = (value) => String(value || '').trim().replace(/\s+/g, '')
const decodeBase64Utf8 = (b64) => {
try {
const bin = atob(String(b64 || ''))
const bytes = new Uint8Array(bin.length)
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i)
if (typeof TextDecoder !== 'undefined') {
return new TextDecoder('utf-8', { fatal: false }).decode(bytes)
}
let out = ''
for (let i = 0; i < bytes.length; i++) out += String.fromCharCode(bytes[i])
return out
} catch {
return ''
}
}
const resolveMd5Any = (index, md5) => {
const key = String(md5 || '').trim().toLowerCase()
if (!key) return ''
const maps = [
index && index.images,
index && index.emojis,
index && index.videos,
index && index.videoThumbs,
]
for (const m of maps) {
try {
if (m && m[key]) return String(m[key] || '')
} catch {}
}
return ''
}
const resolveServerMd5 = (index, serverId) => {
const key = String(serverId || '').trim()
if (!key) return ''
try {
const v = index && index.serverMd5 && index.serverMd5[key]
return isMaybeMd5(v) ? String(v || '').trim().toLowerCase() : ''
} catch {}
return ''
}
const resolveRemoteAny = (index, ...urls) => {
for (const u0 of urls) {
const u = normalizeChatHistoryUrl(u0)
if (!u) continue
try {
const local = index && index.remote && index.remote[u]
if (local) return String(local || '')
} catch {}
const ul = String(u || '').trim().toLowerCase()
if (ul.startsWith('http://') || ul.startsWith('https://')) return u
}
return ''
}
const parseChatHistoryRecord = (recordItemXml) => {
const xml = String(recordItemXml || '').trim()
if (!xml) return { info: null, items: [] }
const normalized = xml
.replace(/&#x20;/g, ' ')
.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F]/g, '')
.replace(/&(?!amp;|lt;|gt;|quot;|apos;|#\d+;|#x[\da-fA-F]+;)/g, '&amp;')
let doc
try {
doc = new DOMParser().parseFromString(normalized, 'text/xml')
} catch {
return { info: null, items: [] }
}
const parserErrors = doc.getElementsByTagName('parsererror')
if (parserErrors && parserErrors.length) return { info: null, items: [] }
const getText = (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]
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 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')
const fullmd5 = getText(node, 'fullmd5')
const thumbfullmd5 = getText(node, 'thumbfullmd5')
const md5 = getText(node, 'md5') || getText(node, 'emoticonmd5') || getText(node, 'emojimd5') || getText(node, 'emojiMd5')
const cdnthumbmd5 = getText(node, 'cdnthumbmd5')
const cdnurlstring = normalizeChatHistoryUrl(getText(node, 'cdnurlstring'))
const encrypturlstring = normalizeChatHistoryUrl(getText(node, 'encrypturlstring'))
const externurl = normalizeChatHistoryUrl(getText(node, 'externurl'))
const aeskey = getText(node, 'aeskey')
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')
|| getAnyXml(node, 'recordxml')
|| getDirectChildXml(node, 'recordxml')
|| getText(node, 'recordxml')
)
let content = datatitle || datadesc
if (!content) {
if (datatype === '4') content = '[视频]'
else if (datatype === '2' || datatype === '3') content = '[图片]'
else if (datatype === '47' || datatype === '37') content = '[表情]'
else if (datatype) content = `[消息 ${datatype}]`
else content = '[消息]'
}
const fmt = String(datafmt || '').trim().toLowerCase().replace(/^\./, '')
const imageFormats = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'heic', 'heif'])
let renderType = 'text'
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'
} else if (
datatype === '2'
|| datatype === '3'
|| imageFormats.has(fmt)
|| (datatype !== '1' && isMaybeMd5(fullmd5))
) {
renderType = 'image'
} else if (isMaybeMd5(md5) && /表情/.test(String(content || ''))) {
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,
sourcename,
sourcetime,
sourceheadurl,
datafmt,
duration,
fullmd5,
thumbfullmd5,
md5,
cdnthumbmd5,
cdnurlstring,
encrypturlstring,
externurl,
aeskey,
fromnewmsgid,
srcMsgLocalid,
srcMsgCreateTime,
renderType,
title: outTitle,
recordItem,
url: outUrl,
content
}
})
return {
info: { isChatRoom, title, desc },
items: parsed
}
}
const initChatHistoryModal = () => {
const modal = document.getElementById('chatHistoryModal')
const titleEl = document.getElementById('chatHistoryModalTitle')
const closeBtn = document.getElementById('chatHistoryModalClose')
const emptyEl = document.getElementById('chatHistoryModalEmpty')
const listEl = document.getElementById('chatHistoryModalList')
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 { 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) => {
const row = document.createElement('div')
row.className = 'px-4 py-3 flex gap-3 border-b border-gray-100'
const avatarWrap = document.createElement('div')
avatarWrap.className = 'w-9 h-9 rounded-md overflow-hidden bg-gray-200 flex-shrink-0'
const name0 = String(rec?.sourcename || '').trim() || '?'
const avatarUrlRaw = normalizeChatHistoryUrl(rec?.sourceheadurl)
const avatarLocal = (mediaIndex && mediaIndex.remote && mediaIndex.remote[avatarUrlRaw]) ? String(mediaIndex.remote[avatarUrlRaw] || '') : ''
const avatarUrlLower = String(avatarUrlRaw || '').trim().toLowerCase()
const avatarUrl = avatarLocal || ((avatarUrlLower.startsWith('http://') || avatarUrlLower.startsWith('https://')) ? avatarUrlRaw : '')
if (avatarUrl) {
const img = document.createElement('img')
img.src = avatarUrl
img.alt = '头像'
img.className = 'w-full h-full object-cover'
try { img.referrerPolicy = 'no-referrer' } catch {}
img.onerror = () => {
try { avatarWrap.textContent = '' } catch {}
const fb = document.createElement('div')
fb.className = 'w-full h-full flex items-center justify-center text-xs font-bold text-gray-600'
fb.textContent = String(name0.charAt(0) || '?')
avatarWrap.appendChild(fb)
}
avatarWrap.appendChild(img)
} else {
const fb = document.createElement('div')
fb.className = 'w-full h-full flex items-center justify-center text-xs font-bold text-gray-600'
fb.textContent = String(name0.charAt(0) || '?')
avatarWrap.appendChild(fb)
}
const main = document.createElement('div')
main.className = 'min-w-0 flex-1'
const header = document.createElement('div')
header.className = 'flex items-start gap-2'
const headerLeft = document.createElement('div')
headerLeft.className = 'min-w-0 flex-1'
const senderName = String(rec?.sourcename || '').trim()
if (info && info.isChatRoom && senderName) {
const sn = document.createElement('div')
sn.className = 'text-xs text-gray-500 leading-none truncate mb-1'
sn.textContent = senderName
headerLeft.appendChild(sn)
}
const headerRight = document.createElement('div')
headerRight.className = 'text-xs text-gray-400 flex-shrink-0 leading-none'
const timeText = String(rec?.sourcetime || '').trim()
headerRight.textContent = timeText
header.appendChild(headerLeft)
if (timeText) header.appendChild(headerRight)
const body = document.createElement('div')
body.className = 'mt-1'
const rt = String(rec?.renderType || 'text')
const content = String(rec?.content || '').trim()
const serverId = String(rec?.fromnewmsgid || '').trim()
const serverMd5 = resolveServerMd5(mediaIndex, serverId)
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?.cdnthumbmd5, rec?.md5, rec?.id)
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, rec?.id)
const thumbMd5 = pickFirstMd5(rec?.thumbfullmd5, rec?.cdnthumbmd5) || videoMd5
let videoUrl = resolveMd5Any(mediaIndex, videoMd5)
if (!videoUrl && serverMd5) videoUrl = resolveMd5Any(mediaIndex, serverMd5)
if (!videoUrl) videoUrl = resolveRemoteAny(mediaIndex, rec?.externurl, rec?.cdnurlstring, rec?.encrypturlstring)
let thumbUrl = resolveMd5Any(mediaIndex, thumbMd5)
if (!thumbUrl && serverMd5) thumbUrl = resolveMd5Any(mediaIndex, serverMd5)
if (!thumbUrl) thumbUrl = resolveRemoteAny(mediaIndex, rec?.externurl, rec?.cdnurlstring, rec?.encrypturlstring)
const wrap = document.createElement('div')
wrap.className = 'msg-radius overflow-hidden relative bg-black/5 inline-block'
if (thumbUrl) {
const img = document.createElement('img')
img.src = thumbUrl
img.alt = '视频'
img.className = 'block w-[220px] max-w-[260px] h-auto max-h-[260px] object-cover'
wrap.appendChild(img)
} else {
const t = document.createElement('div')
t.className = 'px-3 py-2 text-sm text-gray-700'
t.textContent = content || '[视频]'
wrap.appendChild(t)
}
if (thumbUrl) {
const overlay = document.createElement(videoUrl ? 'a' : 'div')
if (videoUrl) {
overlay.href = videoUrl
overlay.target = '_blank'
overlay.rel = 'noreferrer noopener'
}
overlay.className = 'absolute inset-0 flex items-center justify-center'
const btn = document.createElement('div')
btn.className = 'w-12 h-12 rounded-full bg-black/45 flex items-center justify-center'
btn.innerHTML = '<svg class="w-6 h-6 text-white" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>'
overlay.appendChild(btn)
wrap.appendChild(overlay)
}
body.appendChild(wrap)
} else if (rt === 'image') {
const imageMd5 = pickFirstMd5(rec?.fullmd5, rec?.thumbfullmd5, rec?.cdnthumbmd5, rec?.md5, rec?.id)
let imgUrl = resolveMd5Any(mediaIndex, imageMd5)
if (!imgUrl && serverMd5) imgUrl = resolveMd5Any(mediaIndex, serverMd5)
if (!imgUrl) imgUrl = resolveRemoteAny(mediaIndex, rec?.externurl, rec?.cdnurlstring, rec?.encrypturlstring)
if (imgUrl) {
const outer = document.createElement('div')
outer.className = 'msg-radius overflow-hidden cursor-pointer inline-block'
const a = document.createElement('a')
a.href = imgUrl
a.target = '_blank'
a.rel = 'noreferrer noopener'
const img = document.createElement('img')
img.src = imgUrl
img.alt = '图片'
img.className = 'max-w-[240px] max-h-[240px] object-cover'
a.appendChild(img)
outer.appendChild(a)
body.appendChild(outer)
} else {
const t = document.createElement('div')
t.className = 'px-3 py-2 text-sm text-gray-700 whitespace-pre-wrap break-words'
t.textContent = content || '[图片]'
body.appendChild(t)
}
} else if (rt === 'emoji') {
const emojiMd5 = pickFirstMd5(rec?.md5, rec?.fullmd5, rec?.thumbfullmd5, rec?.cdnthumbmd5, rec?.id)
let emojiUrl = resolveMd5Any(mediaIndex, emojiMd5)
if (!emojiUrl && serverMd5) emojiUrl = resolveMd5Any(mediaIndex, serverMd5)
if (!emojiUrl) emojiUrl = resolveRemoteAny(mediaIndex, rec?.externurl, rec?.cdnurlstring, rec?.encrypturlstring)
if (emojiUrl) {
const img = document.createElement('img')
img.src = emojiUrl
img.alt = '表情'
img.className = 'w-24 h-24 object-contain'
body.appendChild(img)
} else {
const t = document.createElement('div')
t.className = 'px-3 py-2 text-sm text-gray-700 whitespace-pre-wrap break-words'
t.textContent = content || '[表情]'
body.appendChild(t)
}
} else {
const t = document.createElement('div')
t.className = 'px-3 py-2 text-sm text-gray-700 whitespace-pre-wrap break-words'
t.textContent = content || ''
body.appendChild(t)
}
main.appendChild(header)
main.appendChild(body)
row.appendChild(avatarWrap)
row.appendChild(main)
return row
}
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 {}
if (!records.length) {
try { emptyEl.style.display = '' } catch {}
} else {
try { emptyEl.style.display = 'none' } catch {}
for (const rec of records) {
try {
listEl.appendChild(renderRecordRow(rec, info))
} catch {}
}
}
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 {}
try { document.body.style.overflow = 'hidden' } catch {}
}
closeBtn.addEventListener('click', (ev) => {
try { ev.preventDefault() } catch {}
close()
})
modal.addEventListener('click', (ev) => {
const t = ev && ev.target
if (t === modal) close()
})
document.addEventListener('keydown', (ev) => {
const key = String(ev?.key || '')
if (key === 'Escape' && !modal.classList.contains('hidden')) close()
if ((key === 'Enter' || key === ' ') && modal.classList.contains('hidden')) {
const target = ev && ev.target
const card = target && target.closest ? target.closest('[data-wce-chat-history=\"1\"]') : null
if (!card) return
try { ev.preventDefault() } catch {}
openFromCard(card)
}
}, true)
document.addEventListener('click', (ev) => {
const target = ev && ev.target
const card = target && target.closest ? target.closest('[data-wce-chat-history=\"1\"]') : null
if (!card) return
try { ev.preventDefault() } catch {}
openFromCard(card)
}, true)
}
const initChatHistoryFloatingWindows = () => {
const mediaIndex = readMediaIndex()
let zIndex = 1000
let cascade = 0
let idSeed = 0
const clampNumber = (value, min, max) => {
const n = Number(value)
if (!Number.isFinite(n)) return min
return Math.min(max, Math.max(min, n))
}
const getViewport = () => {
const w = Math.max(320, window.innerWidth || 0)
const h = Math.max(240, window.innerHeight || 0)
return { w, h }
}
const getPoint = (ev) => {
try {
return (ev && ev.touches && ev.touches[0]) ? ev.touches[0] : ev
} catch {
return ev
}
}
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, onOpenNested) => {
const row = document.createElement('div')
row.className = 'px-4 py-3 flex gap-3 border-b border-gray-100 bg-[#f7f7f7]'
const avatarWrap = document.createElement('div')
avatarWrap.className = 'w-9 h-9 rounded-md overflow-hidden bg-gray-200 flex-shrink-0'
const name0 = String(rec?.sourcename || '').trim() || '?'
const avatarUrlRaw = normalizeChatHistoryUrl(rec?.sourceheadurl)
const avatarLocal = (mediaIndex && mediaIndex.remote && mediaIndex.remote[avatarUrlRaw]) ? String(mediaIndex.remote[avatarUrlRaw] || '') : ''
const avatarUrlLower = String(avatarUrlRaw || '').trim().toLowerCase()
const avatarUrl = avatarLocal || ((avatarUrlLower.startsWith('http://') || avatarUrlLower.startsWith('https://')) ? avatarUrlRaw : '')
if (avatarUrl) {
const img = document.createElement('img')
img.src = avatarUrl
img.alt = '头像'
img.className = 'w-full h-full object-cover'
try { img.referrerPolicy = 'no-referrer' } catch {}
img.onerror = () => {
try { avatarWrap.textContent = '' } catch {}
const fb = document.createElement('div')
fb.className = 'w-full h-full flex items-center justify-center text-xs font-bold text-gray-600'
fb.textContent = String(name0.charAt(0) || '?')
avatarWrap.appendChild(fb)
}
avatarWrap.appendChild(img)
} else {
const fb = document.createElement('div')
fb.className = 'w-full h-full flex items-center justify-center text-xs font-bold text-gray-600'
fb.textContent = String(name0.charAt(0) || '?')
avatarWrap.appendChild(fb)
}
const main = document.createElement('div')
main.className = 'min-w-0 flex-1'
const header = document.createElement('div')
header.className = 'flex items-start gap-2'
const headerLeft = document.createElement('div')
headerLeft.className = 'min-w-0 flex-1'
const senderName = String(rec?.sourcename || '').trim()
if (info && info.isChatRoom && senderName) {
const sn = document.createElement('div')
sn.className = 'text-xs text-gray-500 leading-none truncate mb-1'
sn.textContent = senderName
headerLeft.appendChild(sn)
}
const headerRight = document.createElement('div')
headerRight.className = 'text-xs text-gray-400 flex-shrink-0 leading-none'
const timeText = String(rec?.sourcetime || '').trim()
headerRight.textContent = timeText
header.appendChild(headerLeft)
if (timeText) header.appendChild(headerRight)
const body = document.createElement('div')
body.className = 'mt-1'
const rt = String(rec?.renderType || 'text')
const content = String(rec?.content || '').trim()
const serverId = String(rec?.fromnewmsgid || '').trim()
const serverMd5 = resolveServerMd5(mediaIndex, serverId)
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 {}
if (typeof onOpenNested === 'function') onOpenNested(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?.cdnthumbmd5, rec?.md5, rec?.id)
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 fromAvatar = document.createElement('div')
fromAvatar.className = 'wechat-link-from-avatar'
const fromUrlRaw = normalizeChatHistoryUrl(rec?.sourceheadurl)
const fromLocal = (mediaIndex && mediaIndex.remote && mediaIndex.remote[fromUrlRaw]) ? String(mediaIndex.remote[fromUrlRaw] || '') : ''
const fromLower = String(fromUrlRaw || '').trim().toLowerCase()
const fromUrl = fromLocal || ((fromLower.startsWith('http://') || fromLower.startsWith('https://')) ? fromUrlRaw : '')
const fromText = String(rec?.sourcename || '').trim()
if (fromUrl) {
const img = document.createElement('img')
img.src = fromUrl
img.alt = ''
img.className = 'wechat-link-from-avatar-img'
try { img.referrerPolicy = 'no-referrer' } catch {}
img.onerror = () => {
try { fromAvatar.textContent = '' } catch {}
const span = document.createElement('span')
span.textContent = String(fromText ? fromText.charAt(0) : '\u200B')
fromAvatar.appendChild(span)
}
fromAvatar.appendChild(img)
} else {
const span = document.createElement('span')
span.textContent = String(fromText ? fromText.charAt(0) : '\u200B')
fromAvatar.appendChild(span)
}
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, rec?.id)
const thumbMd5 = pickFirstMd5(rec?.thumbfullmd5, rec?.cdnthumbmd5) || videoMd5
let videoUrl = resolveMd5Any(mediaIndex, videoMd5)
if (!videoUrl && serverMd5) videoUrl = resolveMd5Any(mediaIndex, serverMd5)
if (!videoUrl) videoUrl = resolveRemoteAny(mediaIndex, rec?.externurl, rec?.cdnurlstring, rec?.encrypturlstring)
let thumbUrl = resolveMd5Any(mediaIndex, thumbMd5)
if (!thumbUrl && serverMd5) thumbUrl = resolveMd5Any(mediaIndex, serverMd5)
if (!thumbUrl) thumbUrl = resolveRemoteAny(mediaIndex, rec?.externurl, rec?.cdnurlstring, rec?.encrypturlstring)
const wrap = document.createElement('div')
wrap.className = 'msg-radius overflow-hidden relative bg-black/5 inline-block'
if (thumbUrl) {
const img = document.createElement('img')
img.src = thumbUrl
img.alt = '视频'
img.className = 'block w-[220px] max-w-[260px] h-auto max-h-[260px] object-cover'
wrap.appendChild(img)
} else {
const t = document.createElement('div')
t.className = 'px-3 py-2 text-sm text-gray-700'
t.textContent = content || '[视频]'
wrap.appendChild(t)
}
if (thumbUrl) {
const overlay = document.createElement(videoUrl ? 'a' : 'div')
if (videoUrl) {
overlay.href = videoUrl
overlay.target = '_blank'
overlay.rel = 'noreferrer noopener'
}
overlay.className = 'absolute inset-0 flex items-center justify-center'
const btn = document.createElement('div')
btn.className = 'w-12 h-12 rounded-full bg-black/45 flex items-center justify-center'
btn.innerHTML = '<svg class=\"w-6 h-6 text-white\" fill=\"currentColor\" viewBox=\"0 0 24 24\"><path d=\"M8 5v14l11-7z\"/></svg>'
overlay.appendChild(btn)
wrap.appendChild(overlay)
}
body.appendChild(wrap)
} else if (rt === 'image') {
const imageMd5 = pickFirstMd5(rec?.fullmd5, rec?.thumbfullmd5, rec?.cdnthumbmd5, rec?.md5, rec?.id)
let imgUrl = resolveMd5Any(mediaIndex, imageMd5)
if (!imgUrl && serverMd5) imgUrl = resolveMd5Any(mediaIndex, serverMd5)
if (!imgUrl) imgUrl = resolveRemoteAny(mediaIndex, rec?.externurl, rec?.cdnurlstring, rec?.encrypturlstring)
if (imgUrl) {
const outer = document.createElement('div')
outer.className = 'msg-radius overflow-hidden cursor-pointer inline-block'
const a = document.createElement('a')
a.href = imgUrl
a.target = '_blank'
a.rel = 'noreferrer noopener'
const img = document.createElement('img')
img.src = imgUrl
img.alt = '图片'
img.className = 'max-w-[240px] max-h-[240px] object-cover'
a.appendChild(img)
outer.appendChild(a)
body.appendChild(outer)
} else {
const t = document.createElement('div')
t.className = 'px-3 py-2 text-sm text-gray-700 whitespace-pre-wrap break-words'
t.textContent = content || '[图片]'
body.appendChild(t)
}
} else if (rt === 'emoji') {
const emojiMd5 = pickFirstMd5(rec?.md5, rec?.fullmd5, rec?.thumbfullmd5, rec?.cdnthumbmd5, rec?.id)
let emojiUrl = resolveMd5Any(mediaIndex, emojiMd5)
if (!emojiUrl && serverMd5) emojiUrl = resolveMd5Any(mediaIndex, serverMd5)
if (!emojiUrl) emojiUrl = resolveRemoteAny(mediaIndex, rec?.externurl, rec?.cdnurlstring, rec?.encrypturlstring)
if (emojiUrl) {
const img = document.createElement('img')
img.src = emojiUrl
img.alt = '表情'
img.className = 'w-24 h-24 object-contain'
body.appendChild(img)
} else {
const t = document.createElement('div')
t.className = 'px-3 py-2 text-sm text-gray-700 whitespace-pre-wrap break-words'
t.textContent = content || '[表情]'
body.appendChild(t)
}
} else {
const t = document.createElement('div')
t.className = 'px-3 py-2 text-sm text-gray-700 whitespace-pre-wrap break-words'
t.textContent = content || ''
body.appendChild(t)
}
main.appendChild(header)
main.appendChild(body)
row.appendChild(avatarWrap)
row.appendChild(main)
return row
}
const focusWindow = (wrap) => {
zIndex += 1
try { wrap.style.zIndex = String(zIndex) } catch {}
}
const openChatHistoryWindow = (payload, opts) => {
const state = buildChatHistoryState(payload || {})
const info = state.info || { isChatRoom: false }
const records = Array.isArray(state.records) ? state.records : []
const vp = getViewport()
const width = Math.min(560, Math.max(320, Math.floor(vp.w * 0.92)))
const height = Math.min(560, Math.max(240, Math.floor(vp.h * 0.8)))
let x = Math.max(8, Math.floor((vp.w - width) / 2))
let y = Math.max(8, Math.floor((vp.h - height) / 2))
const spawnFrom = opts && opts.spawnFrom
if (spawnFrom) {
x = Number(spawnFrom.x || x) + 24
y = Number(spawnFrom.y || y) + 24
} else {
x += cascade
y += cascade
cascade = (cascade + 24) % 120
}
x = clampNumber(x, 8, Math.max(8, vp.w - width - 8))
y = clampNumber(y, 8, Math.max(8, vp.h - height - 8))
const win = { id: String(++idSeed), x, y, width, height }
const wrap = document.createElement('div')
wrap.className = 'fixed'
wrap.style.left = `${win.x}px`
wrap.style.top = `${win.y}px`
wrap.style.zIndex = String(++zIndex)
const box = document.createElement('div')
box.className = 'bg-[#f7f7f7] rounded-xl shadow-xl overflow-hidden border border-gray-200 flex flex-col'
box.style.width = `${win.width}px`
box.style.height = `${win.height}px`
wrap.appendChild(box)
const header = document.createElement('div')
header.className = 'px-3 py-2 bg-[#f7f7f7] border-b border-gray-200 flex items-center justify-between select-none cursor-move'
box.appendChild(header)
const titleEl = document.createElement('div')
titleEl.className = 'text-sm text-[#161616] truncate min-w-0'
titleEl.textContent = String(state.title || '聊天记录')
header.appendChild(titleEl)
const closeBtn = document.createElement('button')
closeBtn.type = 'button'
closeBtn.className = 'p-2 rounded hover:bg-black/5 flex-shrink-0'
try { closeBtn.setAttribute('aria-label', '关闭') } catch {}
try { closeBtn.setAttribute('title', '关闭') } catch {}
closeBtn.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=\"M6 18L18 6M6 6l12 12\"/></svg>'
header.appendChild(closeBtn)
const body = document.createElement('div')
body.className = 'flex-1 overflow-auto bg-[#f7f7f7]'
box.appendChild(body)
if (!records.length) {
const empty = document.createElement('div')
empty.className = 'text-sm text-gray-500 text-center py-10'
empty.textContent = '没有可显示的聊天记录'
body.appendChild(empty)
} else {
const onOpenNested = (rec) => {
const xml = String(rec?.recordItem || '').trim()
if (!xml) return
openChatHistoryWindow({
title: String(rec?.title || '聊天记录'),
recordItem: xml,
content: String(rec?.content || ''),
}, { spawnFrom: win })
}
for (const rec of records) {
try {
body.appendChild(renderRecordRow(rec, info, onOpenNested))
} catch {}
}
}
const updatePos = () => {
try { wrap.style.left = `${win.x}px` } catch {}
try { wrap.style.top = `${win.y}px` } catch {}
}
closeBtn.addEventListener('click', (ev) => {
try { ev.preventDefault() } catch {}
try { ev.stopPropagation() } catch {}
try { wrap.remove() } catch {
try { if (wrap.parentElement) wrap.parentElement.removeChild(wrap) } catch {}
}
})
const startDrag = (ev) => {
const t = ev && ev.target
if (t && t.closest && t.closest('button')) return
focusWindow(wrap)
const p0 = getPoint(ev)
const ox = Number(p0?.clientX || 0) - win.x
const oy = Number(p0?.clientY || 0) - win.y
const onMove = (e2) => {
const p = getPoint(e2)
if (!p) return
try { if (e2 && typeof e2.preventDefault === 'function') e2.preventDefault() } catch {}
const vp2 = getViewport()
const nx = Number(p.clientX || 0) - ox
const ny = Number(p.clientY || 0) - oy
win.x = clampNumber(nx, 8, Math.max(8, vp2.w - win.width - 8))
win.y = clampNumber(ny, 8, Math.max(8, vp2.h - win.height - 8))
updatePos()
}
const stop = () => {
try { document.removeEventListener('mousemove', onMove) } catch {}
try { document.removeEventListener('touchmove', onMove) } catch {}
}
try { document.addEventListener('mousemove', onMove) } catch {}
try { document.addEventListener('mouseup', () => stop(), { once: true }) } catch {}
try { document.addEventListener('touchmove', onMove, { passive: false }) } catch {}
try { document.addEventListener('touchend', () => stop(), { once: true }) } catch {}
try { ev.preventDefault() } catch {}
}
header.addEventListener('mousedown', startDrag)
header.addEventListener('touchstart', startDrag, { passive: false })
wrap.addEventListener('mousedown', () => focusWindow(wrap))
wrap.addEventListener('touchstart', () => focusWindow(wrap), { passive: true })
try { document.body.appendChild(wrap) } catch {}
return win
}
document.addEventListener('keydown', (ev) => {
const key = String(ev?.key || '')
if (key !== 'Enter' && key !== ' ') return
const target = ev && ev.target
const card = target && target.closest ? target.closest('[data-wce-chat-history=\"1\"]') : null
if (!card) return
try { ev.preventDefault() } catch {}
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)
openChatHistoryWindow({ title, recordItem: xml, fallbackLines: lines })
}, true)
document.addEventListener('click', (ev) => {
const target = ev && ev.target
const card = target && target.closest ? target.closest('[data-wce-chat-history=\"1\"]') : null
if (!card) return
try { ev.preventDefault() } catch {}
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)
openChatHistoryWindow({ title, recordItem: xml, fallbackLines: lines })
}, true)
}
document.addEventListener('DOMContentLoaded', () => {
hideJsMissingBanner()
updateDprVar()
try {
window.addEventListener('resize', updateDprVar)
} catch {}
initSessionSearch()
initVoicePlayback()
initChatHistoryFloatingWindows()
initPagedMessageLoading()
const select = document.getElementById('messageTypeFilter')
if (select) {
select.addEventListener('change', applyMessageTypeFilter)
applyMessageTypeFilter()
}
updateSessionMessageCount()
scrollToBottom()
try {
window.addEventListener('load', () => {
updateSessionMessageCount()
scrollToBottom()
setTimeout(scrollToBottom, 60)
})
} catch {}
})
// Best-effort: defer scripts execute after the DOM is parsed, so we can hide the banner immediately.
hideJsMissingBanner()
})()
"""
def _format_ts(ts: int) -> str:
if not ts:
return ""
try:
return datetime.fromtimestamp(int(ts)).strftime("%Y-%m-%d %H:%M:%S")
except Exception:
return str(ts)
def _is_md5(s: str) -> bool:
return bool(re.fullmatch(r"(?i)[0-9a-f]{32}", str(s or "").strip()))
def _normalize_render_type_key(value: Any) -> str:
v = str(value or "").strip()
if not v:
return ""
if v == "redPacket":
return "redpacket"
lower = v.lower()
if lower in {"redpacket", "red_packet", "red-packet", "redenvelope", "red_envelope"}:
return "redpacket"
return lower
def _is_render_type_selected(render_type: Any, selected_render_types: Optional[set[str]]) -> bool:
if selected_render_types is None:
return True
rt = _normalize_render_type_key(render_type) or "text"
return rt in selected_render_types
def _media_kinds_from_selected_types(selected_render_types: Optional[set[str]]) -> Optional[set[MediaKind]]:
if selected_render_types is None:
return None
out: set[MediaKind] = set()
# Merged-forward chat history items can contain arbitrary media types; enable packing those
# even when users only select `chatHistory` in the renderType filter.
if "chathistory" in selected_render_types:
out.update({"image", "emoji", "video", "video_thumb", "voice", "file"})
if "image" in selected_render_types:
out.add("image")
if "emoji" in selected_render_types:
out.add("emoji")
if "video" in selected_render_types:
out.add("video")
out.add("video_thumb")
if "voice" in selected_render_types:
out.add("voice")
if "file" in selected_render_types:
out.add("file")
return out
def _resolve_effective_media_kinds(
*,
include_media: bool,
media_kinds: list[MediaKind],
selected_render_types: Optional[set[str]],
privacy_mode: bool,
) -> tuple[bool, list[MediaKind]]:
if privacy_mode or (not include_media):
return False, []
kinds = [k for k in media_kinds if k in {"image", "emoji", "video", "video_thumb", "voice", "file"}]
if not kinds:
return False, []
selected_media_kinds = _media_kinds_from_selected_types(selected_render_types)
if selected_media_kinds is not None:
kinds = [k for k in kinds if k in selected_media_kinds]
kinds = list(dict.fromkeys(kinds))
if not kinds:
return False, []
return True, kinds
@dataclass
class ExportProgress:
conversations_total: int = 0
conversations_done: int = 0
current_conversation_index: int = 0 # 1-based
current_conversation_username: str = ""
current_conversation_name: str = ""
current_conversation_messages_total: int = 0
current_conversation_messages_exported: int = 0
messages_exported: int = 0
media_copied: int = 0
media_missing: int = 0
@dataclass
class ExportJob:
export_id: str
account: str
status: ExportStatus = "queued"
created_at: float = field(default_factory=time.time)
started_at: Optional[float] = None
finished_at: Optional[float] = None
error: str = ""
zip_path: Optional[Path] = None
options: dict[str, Any] = field(default_factory=dict)
progress: ExportProgress = field(default_factory=ExportProgress)
cancel_requested: bool = False
def to_public_dict(self) -> dict[str, Any]:
return {
"exportId": self.export_id,
"account": self.account,
"status": self.status,
"createdAt": int(self.created_at),
"startedAt": int(self.started_at) if self.started_at else None,
"finishedAt": int(self.finished_at) if self.finished_at else None,
"error": self.error or "",
"zipPath": str(self.zip_path) if self.zip_path else "",
"zipReady": bool(self.zip_path and self.zip_path.exists()),
"options": self.options,
"progress": {
"conversationsTotal": self.progress.conversations_total,
"conversationsDone": self.progress.conversations_done,
"currentConversationIndex": self.progress.current_conversation_index,
"currentConversationUsername": self.progress.current_conversation_username,
"currentConversationName": self.progress.current_conversation_name,
"currentConversationMessagesTotal": self.progress.current_conversation_messages_total,
"currentConversationMessagesExported": self.progress.current_conversation_messages_exported,
"messagesExported": self.progress.messages_exported,
"mediaCopied": self.progress.media_copied,
"mediaMissing": self.progress.media_missing,
},
}
class _JobCancelled(Exception):
pass
class ChatExportManager:
def __init__(self) -> None:
self._lock = threading.Lock()
self._jobs: dict[str, ExportJob] = {}
def list_jobs(self) -> list[ExportJob]:
with self._lock:
return list(self._jobs.values())
def get_job(self, export_id: str) -> Optional[ExportJob]:
with self._lock:
return self._jobs.get(export_id)
def cancel_job(self, export_id: str) -> bool:
with self._lock:
job = self._jobs.get(export_id)
if not job:
return False
job.cancel_requested = True
if job.status in {"queued"}:
job.status = "cancelled"
job.finished_at = time.time()
return True
def create_job(
self,
*,
account: Optional[str],
scope: ExportScope,
usernames: list[str],
export_format: ExportFormat,
start_time: Optional[int],
end_time: Optional[int],
include_hidden: bool,
include_official: bool,
include_media: bool,
media_kinds: list[MediaKind],
message_types: list[str],
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:
account_dir = _resolve_account_dir(account)
export_id = uuid.uuid4().hex[:12]
job = ExportJob(
export_id=export_id,
account=account_dir.name,
status="queued",
options={
"scope": scope,
"usernames": usernames,
"format": export_format,
"startTime": int(start_time) if start_time else None,
"endTime": int(end_time) if end_time else None,
"includeHidden": bool(include_hidden),
"includeOfficial": bool(include_official),
"includeMedia": bool(include_media),
"mediaKinds": media_kinds,
"messageTypes": list(dict.fromkeys([str(t or "").strip() for t in (message_types or []) if str(t or "").strip()])),
"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(),
},
)
with self._lock:
self._jobs[export_id] = job
t = threading.Thread(
target=self._run_job_safe,
args=(job, account_dir),
name=f"chat-export-{export_id}",
daemon=True,
)
t.start()
return job
def _run_job_safe(self, job: ExportJob, account_dir: Path) -> None:
try:
self._run_job(job, account_dir)
except Exception as e:
logger.exception(f"export job failed: {job.export_id}: {e}")
with self._lock:
job.status = "error"
job.error = str(e)
job.finished_at = time.time()
def _should_cancel(self, job: ExportJob) -> bool:
with self._lock:
return bool(job.cancel_requested)
def _run_job(self, job: ExportJob, account_dir: Path) -> None:
with self._lock:
if job.status == "cancelled":
return
job.status = "running"
job.started_at = time.time()
job.error = ""
opts = dict(job.options or {})
scope: ExportScope = str(opts.get("scope") or "selected") # type: ignore[assignment]
export_format_raw = str(opts.get("format") or "json").strip() or "json"
if export_format_raw not in {"json", "txt", "html"}:
raise ValueError(f"Unsupported export format: {export_format_raw}")
export_format: ExportFormat = export_format_raw # type: ignore[assignment]
include_hidden = bool(opts.get("includeHidden"))
include_official = bool(opts.get("includeOfficial"))
include_media = bool(opts.get("includeMedia"))
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] = []
for k in media_kinds_raw:
ks = str(k or "").strip()
if ks in {"image", "emoji", "video", "video_thumb", "voice", "file"}:
media_kinds.append(ks) # type: ignore[arg-type]
st = int(opts.get("startTime") or 0) or None
et = int(opts.get("endTime") or 0) or None
message_types_raw = opts.get("messageTypes") or []
want_types: Optional[set[str]] = None
if message_types_raw:
parts = [_normalize_render_type_key(x) for x in message_types_raw]
want = {p for p in parts if p}
if want:
want_types = want
include_media, media_kinds = _resolve_effective_media_kinds(
include_media=include_media,
media_kinds=media_kinds,
selected_render_types=want_types,
privacy_mode=privacy_mode,
)
local_types = None
estimate_local_types = None
target_usernames = _resolve_export_targets(
account_dir=account_dir,
scope=scope,
usernames=list(opts.get("usernames") or []),
include_hidden=include_hidden,
include_official=include_official,
)
if not target_usernames:
raise ValueError("No target conversations to export.")
exports_root = _resolve_export_output_dir(account_dir, opts.get("outputDir"))
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
base_name = str(opts.get("fileName") or "").strip()
if not base_name:
if privacy_mode:
base_name = f"wechat_chat_export_privacy_{ts}_{job.export_id}.zip"
else:
base_name = f"wechat_chat_export_{account_dir.name}_{ts}_{job.export_id}.zip"
else:
base_name = _safe_name(base_name, max_len=120) or f"wechat_chat_export_{account_dir.name}_{ts}_{job.export_id}.zip"
if not base_name.lower().endswith(".zip"):
base_name += ".zip"
final_zip = (exports_root / base_name).resolve()
tmp_zip = (exports_root / f".{base_name}.{job.export_id}.part").resolve()
contact_db_path = account_dir / "contact.db"
message_resource_db_path = account_dir / "message_resource.db"
media_db_path = account_dir / "media_0.db"
head_image_db_path = account_dir / "head_image.db"
resource_conn: Optional[sqlite3.Connection] = None
try:
if message_resource_db_path.exists():
resource_conn = sqlite3.connect(str(message_resource_db_path))
resource_conn.row_factory = sqlite3.Row
except Exception:
try:
if resource_conn is not None:
resource_conn.close()
except Exception:
pass
resource_conn = None
head_image_conn: Optional[sqlite3.Connection] = None
if not privacy_mode:
try:
if head_image_db_path.exists():
head_image_conn = sqlite3.connect(str(head_image_db_path))
except Exception:
try:
if head_image_conn is not None:
head_image_conn.close()
except Exception:
pass
head_image_conn = None
contact_cache: dict[str, str] = {}
contact_row_cache: dict[str, sqlite3.Row] = {}
def resolve_display_name(u: str) -> str:
if not u:
return ""
if u in contact_cache:
return contact_cache[u]
rows = _load_contact_rows(contact_db_path, [u])
row = rows.get(u)
if row is not None:
contact_row_cache[u] = row
name = _pick_display_name(row, u)
contact_cache[u] = name
return name
conv_rows = _load_contact_rows(contact_db_path, target_usernames)
for k, v in conv_rows.items():
contact_row_cache[k] = v
contact_cache[k] = _pick_display_name(v, k)
media_written: dict[str, str] = {}
avatar_written: dict[str, str] = {}
report: dict[str, Any] = {
"schemaVersion": 1,
"exportId": job.export_id,
"account": account_dir.name,
"createdAt": _now_iso(),
"missingMedia": [],
"errors": [],
}
with self._lock:
job.progress.conversations_total = len(target_usernames)
job.progress.conversations_done = 0
job.progress.messages_exported = 0
job.progress.media_copied = 0
job.progress.media_missing = 0
try:
if tmp_zip.exists():
try:
tmp_zip.unlink()
except Exception:
pass
with zipfile.ZipFile(tmp_zip, mode="w", compression=zipfile.ZIP_DEFLATED, compresslevel=6) as zf:
html_index_items: list[dict[str, Any]] = []
self_avatar_path = ""
session_items: list[dict[str, Any]] = []
remote_written: dict[str, str] = {}
remote_download_enabled = bool(download_remote_media) and (export_format == "html") and include_media and (not privacy_mode)
if export_format == "html":
ui_public_dir = _resolve_ui_public_dir()
css_payload = _load_ui_css_bundle(ui_public_dir=ui_public_dir, report=report)
zf.writestr("assets/wechat-chat-export.css", css_payload)
zf.writestr("assets/wechat-chat-export.js", _HTML_EXPORT_JS)
# Bundle UI static assets so the HTML works offline.
repo_root = Path(__file__).resolve().parents[2]
static_written: set[str] = {
"assets/wechat-chat-export.css",
"assets/wechat-chat-export.js",
}
if ui_public_dir is not None:
_zip_write_tree(
zf=zf,
src_dir=Path(ui_public_dir) / "fonts",
dest_prefix="fonts",
written=static_written,
)
_zip_write_tree(
zf=zf,
src_dir=Path(ui_public_dir) / "wxemoji",
dest_prefix="wxemoji",
written=static_written,
)
_zip_write_tree(
zf=zf,
src_dir=Path(ui_public_dir) / "assets" / "images" / "wechat",
dest_prefix="assets/images/wechat",
written=static_written,
)
_zip_write_tree(
zf=zf,
src_dir=repo_root / "frontend" / "public" / "assets" / "images" / "wechat",
dest_prefix="assets/images/wechat",
written=static_written,
)
_zip_write_tree(
zf=zf,
src_dir=repo_root / "frontend" / "assets" / "images" / "wechat",
dest_prefix="assets/images/wechat",
written=static_written,
)
preview_by_username: dict[str, str] = {}
last_ts_by_username: dict[str, int] = {}
if not privacy_mode:
self_avatar_path = _materialize_avatar(
zf=zf,
head_image_conn=head_image_conn,
username=account_dir.name,
avatar_written=avatar_written,
)
try:
preview_by_username = _load_latest_message_previews(account_dir, target_usernames)
except Exception:
preview_by_username = {}
session_db_path = Path(account_dir) / "session.db"
if session_db_path.exists():
sconn = sqlite3.connect(str(session_db_path))
sconn.row_factory = sqlite3.Row
try:
uniq = list(dict.fromkeys([u for u in target_usernames if u]))
chunk_size = 900
for i in range(0, len(uniq), chunk_size):
chunk = uniq[i : i + chunk_size]
placeholders = ",".join(["?"] * len(chunk))
try:
rows = sconn.execute(
f"SELECT username, sort_timestamp, last_timestamp FROM SessionTable WHERE username IN ({placeholders})",
chunk,
).fetchall()
for r in rows:
u = str(r["username"] or "").strip()
if not u:
continue
ts = int(r["sort_timestamp"] or 0)
if ts <= 0:
ts = int(r["last_timestamp"] or 0)
last_ts_by_username[u] = int(ts or 0)
except sqlite3.OperationalError:
rows = sconn.execute(
f"SELECT username, last_timestamp FROM SessionTable WHERE username IN ({placeholders})",
chunk,
).fetchall()
for r in rows:
u = str(r["username"] or "").strip()
if not u:
continue
last_ts_by_username[u] = int(r["last_timestamp"] or 0)
except Exception:
last_ts_by_username = {}
finally:
sconn.close()
for idx, conv_username in enumerate(target_usernames, start=1):
conv_row = contact_row_cache.get(conv_username)
conv_name = _pick_display_name(conv_row, conv_username)
conv_is_group = bool(conv_username.endswith("@chatroom"))
conv_dir = f"conversations/{_conversation_dir_name(idx, conv_name, conv_username, conv_is_group, privacy_mode)}"
conv_avatar_path = ""
if not privacy_mode:
conv_avatar_path = _materialize_avatar(
zf=zf,
head_image_conn=head_image_conn,
username=conv_username,
avatar_written=avatar_written,
)
session_items.append(
{
"username": "" if privacy_mode else conv_username,
"displayName": (f"会话 {idx:04d}" if privacy_mode else conv_name),
"isGroup": bool(conv_is_group),
"convDir": conv_dir,
"avatarPath": "" if privacy_mode else conv_avatar_path,
"lastTimeText": ("" if privacy_mode else _format_session_time(last_ts_by_username.get(conv_username))),
"previewText": ("" if privacy_mode else str(preview_by_username.get(conv_username) or "")),
}
)
for idx, conv_username in enumerate(target_usernames, start=1):
if self._should_cancel(job):
raise _JobCancelled()
conv_row = contact_row_cache.get(conv_username)
conv_name = _pick_display_name(conv_row, conv_username)
conv_is_group = bool(conv_username.endswith("@chatroom"))
conv_dir = f"conversations/{_conversation_dir_name(idx, conv_name, conv_username, conv_is_group, privacy_mode)}"
with self._lock:
job.progress.current_conversation_index = idx
job.progress.current_conversation_username = conv_username
job.progress.current_conversation_name = conv_name
job.progress.current_conversation_messages_exported = 0
job.progress.current_conversation_messages_total = 0
try:
estimated_total = _estimate_conversation_message_count(
account_dir=account_dir,
conv_username=conv_username,
start_time=st,
end_time=et,
local_types=estimate_local_types,
)
except Exception:
estimated_total = 0
with self._lock:
job.progress.current_conversation_messages_total = int(estimated_total)
chat_id = None
try:
if resource_conn is not None:
chat_id = _resource_lookup_chat_id(resource_conn, conv_username)
except Exception:
chat_id = None
conv_avatar_path = ""
if not privacy_mode:
conv_avatar_path = _materialize_avatar(
zf=zf,
head_image_conn=head_image_conn,
username=conv_username,
avatar_written=avatar_written,
)
if export_format == "txt":
exported_count = _write_conversation_txt(
zf=zf,
conv_dir=conv_dir,
account_dir=account_dir,
conv_username=conv_username,
conv_name=conv_name,
conv_avatar_path=conv_avatar_path,
conv_is_group=conv_is_group,
start_time=st,
end_time=et,
want_types=want_types,
local_types=local_types,
resource_conn=resource_conn,
resource_chat_id=chat_id,
head_image_conn=head_image_conn,
resolve_display_name=resolve_display_name,
privacy_mode=privacy_mode,
include_media=include_media,
media_kinds=media_kinds,
media_written=media_written,
avatar_written=avatar_written,
report=report,
allow_process_key_extract=allow_process_key_extract,
media_db_path=media_db_path,
job=job,
lock=self._lock,
)
elif export_format == "html":
exported_count = _write_conversation_html(
zf=zf,
conv_dir=conv_dir,
account_dir=account_dir,
conv_username=conv_username,
conv_name=conv_name,
conv_avatar_path=conv_avatar_path,
conv_is_group=conv_is_group,
self_avatar_path=self_avatar_path,
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,
local_types=local_types,
resource_conn=resource_conn,
resource_chat_id=chat_id,
head_image_conn=head_image_conn,
resolve_display_name=resolve_display_name,
privacy_mode=privacy_mode,
include_media=include_media,
media_kinds=media_kinds,
media_written=media_written,
avatar_written=avatar_written,
report=report,
allow_process_key_extract=allow_process_key_extract,
media_db_path=media_db_path,
job=job,
lock=self._lock,
)
else:
exported_count = _write_conversation_json(
zf=zf,
conv_dir=conv_dir,
account_dir=account_dir,
conv_username=conv_username,
conv_name=conv_name,
conv_avatar_path=conv_avatar_path,
conv_is_group=conv_is_group,
start_time=st,
end_time=et,
want_types=want_types,
local_types=local_types,
resource_conn=resource_conn,
resource_chat_id=chat_id,
head_image_conn=head_image_conn,
resolve_display_name=resolve_display_name,
privacy_mode=privacy_mode,
include_media=include_media,
media_kinds=media_kinds,
media_written=media_written,
avatar_written=avatar_written,
report=report,
allow_process_key_extract=allow_process_key_extract,
media_db_path=media_db_path,
job=job,
lock=self._lock,
)
meta = {
"schemaVersion": 1,
"username": "" if privacy_mode else conv_username,
"displayName": "已隐藏" if privacy_mode else conv_name,
"avatarPath": "" if privacy_mode else (conv_avatar_path or ""),
"isGroup": bool(conv_is_group),
"exportedAt": _now_iso(),
"messageCount": int(exported_count),
}
zf.writestr(f"{conv_dir}/meta.json", json.dumps(meta, ensure_ascii=False, indent=2))
if export_format == "html":
html_index_items.append({"convDir": conv_dir, "meta": meta})
with self._lock:
job.progress.current_conversation_messages_exported = int(exported_count)
job.progress.current_conversation_messages_total = int(exported_count)
job.progress.conversations_done += 1
if export_format == "html":
def esc_text(v: Any) -> str:
return html.escape(str(v or ""), quote=False)
def esc_attr(v: Any) -> str:
return html.escape(str(v or ""), quote=True)
parts: list[str] = []
parts.append("<!doctype html>\n")
parts.append('<html lang="zh-CN">\n')
parts.append("<head>\n")
parts.append(' <meta charset="utf-8" />\n')
parts.append(' <meta name="viewport" content="width=device-width, initial-scale=1" />\n')
parts.append(" <title>聊天记录导出</title>\n")
parts.append(' <link rel="stylesheet" href="assets/wechat-chat-export.css" />\n')
parts.append(' <script defer src="assets/wechat-chat-export.js"></script>\n')
parts.append("</head>\n")
parts.append("<body>\n")
parts.append(
' <div id="wceJsMissing" style="position:fixed;top:0;left:0;right:0;z-index:9999;background:#FEF3C7;color:#92400E;border-bottom:1px solid #F59E0B;padding:8px 12px;font-size:12px;line-height:1.4">'
"提示:此页面需要 JavaScript 才能使用“合并聊天记录”等交互功能。若该提示一直存在,请确认已完整解压导出目录,并检查 wechat-chat-export.js 是否能加载(位于 assets/)。</div>\n"
)
parts.append('<div class="wce-index">\n')
parts.append(' <div class="wce-index-container">\n')
parts.append(' <h1 class="wce-index-title">聊天记录导出(HTML</h1>\n')
parts.append(
f' <p class="wce-index-sub">账号: {esc_text("hidden" if privacy_mode else account_dir.name)} · 会话数: {len(html_index_items)} · 导出时间: {esc_text(_now_iso())}</p>\n'
)
parts.append(' <div class="wce-index-card">\n')
for item in html_index_items:
conv_dir0 = str(item.get("convDir") or "").strip()
meta0 = item.get("meta") or {}
display_name = str(meta0.get("displayName") or "会话").strip() or "会话"
avatar_path = str(meta0.get("avatarPath") or "").strip()
try:
msg_count = int(meta0.get("messageCount") or 0)
except Exception:
msg_count = 0
href = f"{conv_dir0}/messages.html" if conv_dir0 else ""
parts.append(f' <a class="wce-index-item" href="{esc_attr(href)}">\n')
parts.append(' <div class="wce-session-avatar" aria-hidden="true">')
if avatar_path:
parts.append(
f'<img src="{esc_attr(avatar_path)}" alt="avatar" referrerpolicy="no-referrer" />'
)
else:
parts.append(
f'<div style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;color:#fff;font-size:12px;font-weight:700;background-color:#4B5563">{esc_text(display_name[:1] or "?")}</div>'
)
parts.append("</div>\n")
parts.append(' <div class="wce-session-meta">\n')
parts.append(f' <div class="wce-session-name">{esc_text(display_name)}</div>\n')
parts.append(f' <div class="wce-session-sub">共 {msg_count} 条消息</div>\n')
parts.append(" </div>\n")
parts.append(" </a>\n")
parts.append(" </div>\n")
parts.append(' <p class="wce-index-sub" style="margin-top:16px">提示:解压后直接打开本文件;媒体文件位于 media/ 目录。</p>\n')
parts.append(" </div>\n")
parts.append("</div>\n")
parts.append("</body>\n")
parts.append("</html>\n")
zf.writestr("index.html", "".join(parts))
manifest = {
"schemaVersion": 1,
"exportedAt": _now_iso(),
"exportId": job.export_id,
"account": "hidden" if privacy_mode else account_dir.name,
"format": export_format,
"scope": scope,
"filters": {
"startTime": st,
"endTime": et,
"messageTypes": sorted(want_types) if want_types else None,
"includeHidden": include_hidden,
"includeOfficial": include_official,
},
"options": {
"includeMedia": include_media,
"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": {
"conversations": len(target_usernames),
"messagesExported": job.progress.messages_exported,
"mediaCopied": job.progress.media_copied,
"mediaMissing": job.progress.media_missing,
},
"accountsAvailable": _list_decrypted_accounts(),
}
zf.writestr("manifest.json", json.dumps(manifest, ensure_ascii=False, indent=2))
zf.writestr("report.json", json.dumps(report, ensure_ascii=False, indent=2))
if final_zip.exists():
final_zip = (exports_root / f"{final_zip.stem}_{job.export_id}{final_zip.suffix}").resolve()
tmp_zip.replace(final_zip)
with self._lock:
job.status = "done"
job.zip_path = final_zip
job.finished_at = time.time()
except _JobCancelled:
try:
if tmp_zip.exists():
tmp_zip.unlink()
except Exception:
pass
with self._lock:
job.status = "cancelled"
job.finished_at = time.time()
finally:
try:
if resource_conn is not None:
resource_conn.close()
except Exception:
pass
try:
if head_image_conn is not None:
head_image_conn.close()
except Exception:
pass
def _resolve_export_targets(
*,
account_dir: Path,
scope: ExportScope,
usernames: list[str],
include_hidden: bool,
include_official: bool,
) -> list[str]:
if scope == "selected":
uniq = list(dict.fromkeys([str(u or "").strip() for u in usernames if str(u or "").strip()]))
return uniq
session_db_path = account_dir / "session.db"
conn = sqlite3.connect(str(session_db_path))
conn.row_factory = sqlite3.Row
try:
rows = conn.execute(
"""
SELECT username, is_hidden
FROM SessionTable
ORDER BY sort_timestamp DESC
""",
).fetchall()
finally:
conn.close()
out: list[str] = []
for r in rows:
u = str(r["username"] or "").strip()
if not u:
continue
if not include_hidden and int(r["is_hidden"] or 0) == 1:
continue
if not _should_keep_session(u, include_official=include_official):
continue
if scope == "groups" and (not u.endswith("@chatroom")):
continue
if scope == "singles" and u.endswith("@chatroom"):
continue
out.append(u)
return out
def _conversation_dir_name(
idx: int,
display_name: str,
username: str,
is_group: bool,
privacy_mode: bool,
) -> str:
h = uuid.uuid5(uuid.NAMESPACE_DNS, username).hex[:8] if username else uuid.uuid4().hex[:8]
if privacy_mode:
kind = "group" if is_group else "single"
return f"{idx:04d}_{kind}_{h}"
base = _safe_name(display_name, max_len=40) or "conversation"
user_part = _safe_name(username, max_len=50) or "unknown"
return f"{idx:04d}_{base}_{user_part}_{h}"
def _estimate_conversation_message_count(
*,
account_dir: Path,
conv_username: str,
start_time: Optional[int],
end_time: Optional[int],
local_types: Optional[set[int]] = None,
) -> int:
total = 0
for db_path in _iter_message_db_paths(account_dir):
conn = sqlite3.connect(str(db_path))
try:
table = _resolve_msg_table_name(conn, conv_username)
if not table:
continue
quoted = _quote_ident(table)
where = []
params: list[Any] = []
if local_types:
lt = sorted({int(x) for x in local_types if int(x) != 0})
if lt:
placeholders = ",".join(["?"] * len(lt))
where.append(f"local_type IN ({placeholders})")
params.extend(lt)
if start_time is not None:
where.append("create_time >= ?")
params.append(int(start_time))
if end_time is not None:
where.append("create_time <= ?")
params.append(int(end_time))
where_sql = (" WHERE " + " AND ".join(where)) if where else ""
row = conn.execute(f"SELECT COUNT(1) FROM {quoted}{where_sql}", params).fetchone()
if row and row[0] is not None:
total += int(row[0])
finally:
conn.close()
return total
@dataclass
class _Row:
db_stem: str
table_name: str
local_id: int
server_id: int
local_type: int
sort_seq: int
create_time: int
raw_text: str
sender_username: str
is_sent: bool
def _iter_rows_for_conversation(
*,
account_dir: Path,
conv_username: str,
start_time: Optional[int],
end_time: Optional[int],
local_types: Optional[set[int]] = None,
) -> Iterable[_Row]:
db_paths = _iter_message_db_paths(account_dir)
if not db_paths:
return []
account_wxid = account_dir.name
def iter_db(db_path: Path) -> Iterable[_Row]:
conn = sqlite3.connect(str(db_path))
conn.row_factory = sqlite3.Row
try:
table_name = _resolve_msg_table_name(conn, conv_username)
if not table_name:
return
# Force sqlite3 to return TEXT as raw bytes for this query, so we can zstd-decompress
# compress_content reliably (and avoid losing binary payloads).
conn.text_factory = bytes
my_rowid = None
try:
r = conn.execute(
"SELECT rowid FROM Name2Id WHERE user_name = ? LIMIT 1",
(account_wxid,),
).fetchone()
if r is not None:
my_rowid = int(r[0])
except Exception:
my_rowid = None
quoted = _quote_ident(table_name)
where = []
params: list[Any] = []
if local_types:
lt = sorted({int(x) for x in local_types if int(x) != 0})
if lt:
placeholders = ",".join(["?"] * len(lt))
where.append(f"m.local_type IN ({placeholders})")
params.extend(lt)
if start_time is not None:
where.append("m.create_time >= ?")
params.append(int(start_time))
if end_time is not None:
where.append("m.create_time <= ?")
params.append(int(end_time))
where_sql = (" WHERE " + " AND ".join(where)) if where else ""
sql_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, n.user_name AS sender_username "
f"FROM {quoted} m "
"LEFT JOIN Name2Id n ON m.real_sender_id = n.rowid "
f"{where_sql} "
"ORDER BY m.create_time ASC, m.sort_seq ASC, m.local_id ASC "
)
sql_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, '' AS sender_username "
f"FROM {quoted} m "
f"{where_sql} "
"ORDER BY m.create_time ASC, m.sort_seq ASC, m.local_id ASC "
)
try:
cur = conn.execute(sql_with_join, params)
except Exception:
cur = conn.execute(sql_no_join, params)
batch = 400
while True:
rows = cur.fetchmany(batch)
if not rows:
break
for r in rows:
local_id = int(r["local_id"] or 0)
server_id = int(r["server_id"] or 0)
local_type = int(r["local_type"] or 0)
sort_seq = int(r["sort_seq"] or 0) if r["sort_seq"] is not None else 0
create_time = int(r["create_time"] or 0)
sender_username = _decode_sqlite_text(r["sender_username"]).strip()
is_sent = False
if my_rowid is not None:
try:
is_sent = int(r["real_sender_id"] or 0) == int(my_rowid)
except Exception:
is_sent = False
raw_text = _decode_message_content(r["compress_content"], r["message_content"]).strip()
is_group = bool(conv_username.endswith("@chatroom"))
if is_sent:
sender_username = account_wxid
elif (not is_group) and (not sender_username):
sender_username = conv_username
yield _Row(
db_stem=db_path.stem,
table_name=table_name,
local_id=local_id,
server_id=server_id,
local_type=local_type,
sort_seq=sort_seq,
create_time=create_time,
raw_text=raw_text,
sender_username=sender_username,
is_sent=bool(is_sent),
)
finally:
try:
conn.close()
except Exception:
pass
streams = [iter_db(p) for p in db_paths]
def sort_key(r: _Row) -> tuple[int, int, int]:
return (int(r.create_time or 0), int(r.sort_seq or 0), int(r.local_id or 0))
return heapq.merge(*streams, key=sort_key)
def _parse_message_for_export(
*,
row: _Row,
conv_username: str,
is_group: bool,
resource_conn: Optional[sqlite3.Connection],
resource_chat_id: Optional[int],
sender_alias: str = "",
resolve_display_name: Optional[Callable[[str], str]] = None,
) -> dict[str, Any]:
raw_text = row.raw_text or ""
sender_username = str(row.sender_username or "").strip()
if is_group and raw_text and (not raw_text.startswith("<")) and (not raw_text.startswith('"<')):
sender_prefix, raw_text = _split_group_sender_prefix(raw_text, sender_username, sender_alias)
if sender_prefix and (not sender_username):
sender_username = sender_prefix
if is_group and raw_text and (raw_text.startswith("<") or raw_text.startswith('"<')):
xml_sender = _extract_sender_from_group_xml(raw_text)
if xml_sender:
sender_username = xml_sender
local_type = int(row.local_type or 0)
is_sent = bool(row.is_sent)
render_type = "text"
content_text = raw_text
title = ""
url = ""
from_name = ""
from_username = ""
link_type = ""
link_style = ""
record_item = ""
image_md5 = ""
image_md5_candidates: list[str] = []
image_file_id = ""
image_file_id_candidates: list[str] = []
emoji_md5 = ""
emoji_url = ""
thumb_url = ""
image_url = ""
video_md5 = ""
video_thumb_md5 = ""
video_file_id = ""
video_thumb_file_id = ""
video_url = ""
video_thumb_url = ""
voice_length = ""
quote_username = ""
quote_server_id = ""
quote_type = ""
quote_thumb_url = ""
quote_voice_length = ""
quote_title = ""
quote_content = ""
amount = ""
cover_url = ""
file_size = ""
pay_sub_type = ""
transfer_status = ""
file_md5 = ""
transfer_id = ""
voip_type = ""
location_lat: Optional[float] = None
location_lng: Optional[float] = None
location_poiname = ""
location_label = ""
if local_type == 10000:
render_type = "system"
system_display_name_resolver = None
if resolve_display_name is not None:
def system_display_name_resolver(username: str, fallback_display_name: str) -> str:
resolved = str(resolve_display_name(username) or "").strip()
if resolved and resolved != username:
return resolved
fallback = str(fallback_display_name or "").strip()
return fallback or resolved or username
content_text = _parse_system_message_content(
raw_text,
resolve_display_name=system_display_name_resolver,
)
elif local_type == 49:
parsed = _parse_app_message(raw_text)
render_type = str(parsed.get("renderType") or "text")
content_text = str(parsed.get("content") or "")
title = str(parsed.get("title") or "")
url = str(parsed.get("url") or "")
from_name = str(parsed.get("from") or "")
from_username = str(parsed.get("fromUsername") or "")
link_type = str(parsed.get("linkType") or "")
link_style = str(parsed.get("linkStyle") or "")
record_item = str(parsed.get("recordItem") or "")
quote_username = str(parsed.get("quoteUsername") or "")
quote_server_id = str(parsed.get("quoteServerId") or "")
quote_type = str(parsed.get("quoteType") or "")
quote_thumb_url = str(parsed.get("quoteThumbUrl") or "")
quote_voice_length = str(parsed.get("quoteVoiceLength") or "")
quote_title = str(parsed.get("quoteTitle") or "")
quote_content = str(parsed.get("quoteContent") or "")
amount = str(parsed.get("amount") or "")
cover_url = str(parsed.get("coverUrl") or "")
thumb_url = str(parsed.get("thumbUrl") or "")
file_size = str(parsed.get("size") or "")
pay_sub_type = str(parsed.get("paySubType") or "")
file_md5 = str(parsed.get("fileMd5") or "")
transfer_id = str(parsed.get("transferId") or "")
if render_type == "transfer":
if not transfer_id:
transfer_id = _extract_xml_tag_or_attr(raw_text, "transferid") or ""
transfer_status = _infer_transfer_status_text(
is_sent=is_sent,
paysubtype=pay_sub_type,
receivestatus=str(parsed.get("receiveStatus") or ""),
sendertitle=str(parsed.get("senderTitle") or ""),
receivertitle=str(parsed.get("receiverTitle") or ""),
senderdes=str(parsed.get("senderDes") or ""),
receiverdes=str(parsed.get("receiverDes") or ""),
)
if not content_text:
content_text = transfer_status or "转账"
elif local_type == 266287972401:
render_type = "system"
template = _extract_xml_tag_text(raw_text, "template")
content_text = "[拍一拍]" if template else "[拍一拍]"
elif local_type == 244813135921:
render_type = "quote"
parsed = _parse_app_message(raw_text)
content_text = str(parsed.get("content") or "[引用消息]")
quote_username = str(parsed.get("quoteUsername") or "")
quote_server_id = str(parsed.get("quoteServerId") or "")
quote_type = str(parsed.get("quoteType") or "")
quote_thumb_url = str(parsed.get("quoteThumbUrl") or "")
quote_voice_length = str(parsed.get("quoteVoiceLength") or "")
quote_title = str(parsed.get("quoteTitle") or "")
quote_content = str(parsed.get("quoteContent") or "")
elif local_type == 48:
parsed = _parse_location_message(raw_text)
render_type = str(parsed.get("renderType") or "location")
content_text = str(parsed.get("content") or "[Location]")
location_lat = parsed.get("locationLat")
location_lng = parsed.get("locationLng")
location_poiname = str(parsed.get("locationPoiname") or "")
location_label = str(parsed.get("locationLabel") or "")
elif local_type == 3:
render_type = "image"
def add_md5(v: Any) -> None:
s = str(v or "").strip().lower()
if _is_md5(s) and s not in image_md5_candidates:
image_md5_candidates.append(s)
for k in [
"md5",
"hdmd5",
"hevc_md5",
"hevc_mid_md5",
"cdnbigimgmd5",
"cdnmidimgmd5",
"cdnthumbmd5",
"cdnthumd5",
"imgmd5",
"filemd5",
]:
add_md5(_extract_xml_attr(raw_text, k))
add_md5(_extract_xml_tag_text(raw_text, k))
# Prefer message_resource.db md5 for local files: XML md5 frequently differs from the on-disk *.dat basename
# (especially for *_t.dat thumbnails), causing offline media materialization to miss.
if resource_conn is not None:
try:
md5_hit = _lookup_resource_md5(
resource_conn,
resource_chat_id,
message_local_type=local_type,
server_id=int(row.server_id or 0),
local_id=int(row.local_id or 0),
create_time=int(row.create_time or 0),
)
except Exception:
md5_hit = ""
md5_hit = str(md5_hit or "").strip().lower()
if _is_md5(md5_hit):
try:
image_md5_candidates.remove(md5_hit)
except ValueError:
pass
image_md5_candidates.insert(0, md5_hit)
image_md5 = image_md5_candidates[0] if image_md5_candidates else ""
url_or_id_candidates: list[str] = []
def add_url_or_id(v: Any) -> None:
s = str(v or "").strip()
if s:
try:
s = html.unescape(s).strip()
except Exception:
pass
if s and s not in url_or_id_candidates:
url_or_id_candidates.append(s)
for k in ["cdnthumburl", "cdnthumurl", "cdnmidimgurl", "cdnbigimgurl"]:
add_url_or_id(_extract_xml_attr(raw_text, k))
add_url_or_id(_extract_xml_tag_text(raw_text, k))
for v in url_or_id_candidates:
low = str(v or "").strip().lower()
if low.startswith(("http://", "https://")):
if not image_url:
image_url = str(v).strip()
continue
if str(v).startswith("//"):
if not image_url:
image_url = "https:" + str(v).strip()
continue
if v and v not in image_file_id_candidates:
image_file_id_candidates.append(v)
image_file_id = image_file_id_candidates[0] if image_file_id_candidates else ""
content_text = "[图片]"
elif local_type == 34:
render_type = "voice"
duration = _extract_xml_attr(raw_text, "voicelength")
voice_length = duration
content_text = f"[语音 {duration}秒]" if duration else "[语音]"
elif local_type == 43 or local_type == 62:
render_type = "video"
video_md5 = _extract_xml_attr(raw_text, "md5")
video_thumb_md5 = _extract_xml_attr(raw_text, "cdnthumbmd5")
video_thumb_url_or_id = _extract_xml_attr(raw_text, "cdnthumburl") or _extract_xml_tag_text(
raw_text, "cdnthumburl"
)
video_url_or_id = _extract_xml_attr(raw_text, "cdnvideourl") or _extract_xml_tag_text(
raw_text, "cdnvideourl"
)
video_thumb_url = (
video_thumb_url_or_id
if str(video_thumb_url_or_id or "").strip().lower().startswith(("http://", "https://"))
else ""
)
video_url = (
video_url_or_id if str(video_url_or_id or "").strip().lower().startswith(("http://", "https://")) else ""
)
video_thumb_file_id = "" if video_thumb_url else (str(video_thumb_url_or_id or "").strip() or "")
video_file_id = "" if video_url else (str(video_url_or_id or "").strip() or "")
if (not video_thumb_md5) and resource_conn is not None:
video_thumb_md5 = _lookup_resource_md5(
resource_conn,
resource_chat_id,
message_local_type=local_type,
server_id=int(row.server_id or 0),
local_id=int(row.local_id or 0),
create_time=int(row.create_time or 0),
)
content_text = "[视频]"
elif local_type == 47:
render_type = "emoji"
emoji_md5 = _extract_xml_attr(raw_text, "md5")
if not emoji_md5:
emoji_md5 = _extract_xml_tag_text(raw_text, "md5")
emoji_url = _extract_xml_attr(raw_text, "cdnurl")
if not emoji_url:
emoji_url = _extract_xml_tag_text(raw_text, "cdn_url")
if (not emoji_md5) and resource_conn is not None:
emoji_md5 = _lookup_resource_md5(
resource_conn,
resource_chat_id,
message_local_type=local_type,
server_id=int(row.server_id or 0),
local_id=int(row.local_id or 0),
create_time=int(row.create_time or 0),
)
content_text = "[表情]"
elif local_type == 50:
render_type = "voip"
try:
import re as _re
block = raw_text
m_voip = _re.search(
r"(<VoIPBubbleMsg[^>]*>.*?</VoIPBubbleMsg>)",
raw_text,
flags=_re.IGNORECASE | _re.DOTALL,
)
if m_voip:
block = m_voip.group(1) or raw_text
room_type = str(_extract_xml_tag_text(block, "room_type") or "").strip()
if room_type == "0":
voip_type = "video"
elif room_type == "1":
voip_type = "audio"
voip_msg = str(_extract_xml_tag_text(block, "msg") or "").strip()
content_text = voip_msg or "通话"
except Exception:
content_text = "通话"
elif local_type != 1:
if not content_text:
content_text = _infer_message_brief_by_local_type(local_type)
else:
if content_text.startswith("<") or content_text.startswith('"<'):
parsed_special = False
if "<appmsg" in content_text.lower():
parsed = _parse_app_message(content_text)
rt = str(parsed.get("renderType") or "")
if rt and rt != "text":
parsed_special = True
render_type = rt
content_text = str(parsed.get("content") or content_text)
title = str(parsed.get("title") or title)
url = str(parsed.get("url") or url)
from_name = str(parsed.get("from") or from_name)
from_username = str(parsed.get("fromUsername") or from_username)
link_type = str(parsed.get("linkType") or link_type)
link_style = str(parsed.get("linkStyle") or link_style)
record_item = str(parsed.get("recordItem") or record_item)
quote_username = str(parsed.get("quoteUsername") or quote_username)
quote_server_id = str(parsed.get("quoteServerId") or quote_server_id)
quote_type = str(parsed.get("quoteType") or quote_type)
quote_thumb_url = str(parsed.get("quoteThumbUrl") or quote_thumb_url)
quote_voice_length = str(parsed.get("quoteVoiceLength") or quote_voice_length)
quote_title = str(parsed.get("quoteTitle") or quote_title)
quote_content = str(parsed.get("quoteContent") or quote_content)
amount = str(parsed.get("amount") or amount)
cover_url = str(parsed.get("coverUrl") or cover_url)
thumb_url = str(parsed.get("thumbUrl") or thumb_url)
file_size = str(parsed.get("size") or file_size)
pay_sub_type = str(parsed.get("paySubType") or pay_sub_type)
file_md5 = str(parsed.get("fileMd5") or file_md5)
transfer_id = str(parsed.get("transferId") or transfer_id)
if render_type == "transfer":
if not transfer_id:
transfer_id = _extract_xml_tag_or_attr(content_text, "transferid") or ""
transfer_status = _infer_transfer_status_text(
is_sent=is_sent,
paysubtype=pay_sub_type,
receivestatus=str(parsed.get("receiveStatus") or ""),
sendertitle=str(parsed.get("senderTitle") or ""),
receivertitle=str(parsed.get("receiverTitle") or ""),
senderdes=str(parsed.get("senderDes") or ""),
receiverdes=str(parsed.get("receiverDes") or ""),
)
if not content_text:
content_text = transfer_status or "转账"
if not parsed_special:
t = _extract_xml_tag_text(content_text, "title")
d = _extract_xml_tag_text(content_text, "des")
content_text = t or d or _infer_message_brief_by_local_type(local_type)
if not content_text:
content_text = _infer_message_brief_by_local_type(local_type)
if local_type == 266287972401:
try:
if raw_text:
content_text = _parse_pat_message(raw_text, {})
except Exception:
pass
return {
"id": f"{row.db_stem}:{row.table_name}:{row.local_id}",
"localId": row.local_id,
"serverId": row.server_id,
"createTime": row.create_time,
"createTimeText": _format_ts(row.create_time),
"sortSeq": row.sort_seq,
"type": local_type,
"renderType": render_type,
"isSent": bool(is_sent),
"senderUsername": sender_username,
"conversationUsername": conv_username,
"isGroup": bool(is_group),
"content": content_text,
"title": title,
"url": url,
"from": from_name,
"fromUsername": from_username,
"linkType": link_type,
"linkStyle": link_style,
"recordItem": record_item,
"thumbUrl": thumb_url,
"imageMd5": image_md5,
"imageFileId": image_file_id,
"imageMd5Candidates": image_md5_candidates,
"imageFileIdCandidates": image_file_id_candidates,
"imageUrl": image_url,
"emojiMd5": emoji_md5,
"emojiUrl": emoji_url,
"videoMd5": video_md5,
"videoThumbMd5": video_thumb_md5,
"videoFileId": video_file_id,
"videoThumbFileId": video_thumb_file_id,
"videoUrl": video_url,
"videoThumbUrl": video_thumb_url,
"voiceLength": voice_length,
"quoteUsername": quote_username,
"quoteServerId": quote_server_id,
"quoteType": quote_type,
"quoteThumbUrl": quote_thumb_url,
"quoteVoiceLength": quote_voice_length,
"quoteTitle": quote_title,
"quoteContent": quote_content,
"amount": amount,
"coverUrl": cover_url,
"fileSize": file_size,
"fileMd5": file_md5,
"paySubType": pay_sub_type,
"transferStatus": transfer_status,
"transferId": transfer_id,
"voipType": voip_type,
"locationLat": location_lat,
"locationLng": location_lng,
"locationPoiname": location_poiname,
"locationLabel": location_label,
}
def _write_conversation_json(
*,
zf: zipfile.ZipFile,
conv_dir: str,
account_dir: Path,
conv_username: str,
conv_name: str,
conv_avatar_path: str,
conv_is_group: bool,
start_time: Optional[int],
end_time: Optional[int],
want_types: Optional[set[str]],
local_types: Optional[set[int]],
resource_conn: Optional[sqlite3.Connection],
resource_chat_id: Optional[int],
head_image_conn: Optional[sqlite3.Connection],
resolve_display_name: Any,
privacy_mode: bool,
include_media: bool,
media_kinds: list[MediaKind],
media_written: dict[str, str],
avatar_written: dict[str, str],
report: dict[str, Any],
allow_process_key_extract: bool,
media_db_path: Path,
job: ExportJob,
lock: threading.Lock,
) -> int:
arcname = f"{conv_dir}/messages.json"
exported = 0
contact_conn: Optional[sqlite3.Connection] = None
alias_cache: dict[str, str] = {}
if conv_is_group:
try:
contact_db_path = account_dir / "contact.db"
if contact_db_path.exists():
contact_conn = sqlite3.connect(str(contact_db_path))
except Exception:
contact_conn = None
def lookup_alias(username: str) -> str:
u = str(username or "").strip()
if not u or contact_conn is None:
return ""
if u in alias_cache:
return alias_cache[u]
alias = ""
try:
r = contact_conn.execute("SELECT alias FROM contact WHERE username = ? LIMIT 1", (u,)).fetchone()
if r is not None and r[0] is not None:
alias = str(r[0] or "").strip()
if not alias:
r = contact_conn.execute("SELECT alias FROM stranger WHERE username = ? LIMIT 1", (u,)).fetchone()
if r is not None and r[0] is not None:
alias = str(r[0] or "").strip()
except Exception:
alias = ""
alias_cache[u] = alias
return alias
# NOTE: Do not keep an entry handle opened while also writing other entries (avatars/media).
# zipfile forbids interleaving writes; stream to a temp file then add it to zip at the end.
with tempfile.TemporaryDirectory(prefix="wechat_chat_export_") as tmp_dir:
tmp_path = Path(tmp_dir) / "messages.json"
with open(tmp_path, "w", encoding="utf-8", newline="\n") as tw:
tw.write("{\n")
tw.write(" \"schemaVersion\": 1,\n")
tw.write(f" \"exportedAt\": {json.dumps(_now_iso(), ensure_ascii=False)},\n")
tw.write(f" \"account\": {json.dumps('hidden' if privacy_mode else account_dir.name, ensure_ascii=False)},\n")
tw.write(
" \"conversation\": "
+ json.dumps(
{
"username": "" if privacy_mode else conv_username,
"displayName": "已隐藏" if privacy_mode else conv_name,
"avatarPath": "" if privacy_mode else (conv_avatar_path or ""),
"isGroup": bool(conv_is_group),
},
ensure_ascii=False,
)
+ ",\n"
)
tw.write(
" \"filters\": "
+ json.dumps(
{
"startTime": int(start_time) if start_time else None,
"endTime": int(end_time) if end_time else None,
"messageTypes": sorted(want_types) if want_types else None,
},
ensure_ascii=False,
)
+ ",\n"
)
tw.write(" \"messages\": [\n")
sender_alias_map: dict[str, int] = {}
first = True
scanned = 0
for row in _iter_rows_for_conversation(
account_dir=account_dir,
conv_username=conv_username,
start_time=start_time,
end_time=end_time,
local_types=local_types,
):
scanned += 1
sender_alias = ""
if conv_is_group and row.raw_text and (not row.raw_text.startswith("<")) and (not row.raw_text.startswith('"<')):
sep = row.raw_text.find(":\n")
if sep > 0:
prefix = row.raw_text[:sep].strip()
su = str(row.sender_username or "").strip()
if prefix and su and prefix != su:
strong_hint = prefix.startswith("wxid_") or prefix.endswith("@chatroom") or "@" in prefix
if not strong_hint:
body_probe = row.raw_text[sep + 2 :].lstrip("\n").lstrip()
body_is_xml = body_probe.startswith("<") or body_probe.startswith('"<')
if not body_is_xml:
sender_alias = lookup_alias(su)
msg = _parse_message_for_export(
row=row,
conv_username=conv_username,
is_group=conv_is_group,
resource_conn=resource_conn,
resource_chat_id=resource_chat_id,
sender_alias=sender_alias,
resolve_display_name=resolve_display_name,
)
if not _is_render_type_selected(msg.get("renderType"), want_types):
continue
su = str(msg.get("senderUsername") or "").strip()
if privacy_mode:
_privacy_scrub_message(msg, conv_is_group=conv_is_group, sender_alias_map=sender_alias_map)
else:
msg["senderDisplayName"] = resolve_display_name(su) if su else ""
msg["senderAvatarPath"] = (
_materialize_avatar(
zf=zf,
head_image_conn=head_image_conn,
username=su,
avatar_written=avatar_written,
)
if (su and head_image_conn is not None)
else ""
)
if include_media:
_attach_offline_media(
zf=zf,
account_dir=account_dir,
conv_username=conv_username,
msg=msg,
media_written=media_written,
report=report,
media_kinds=media_kinds,
allow_process_key_extract=allow_process_key_extract,
media_db_path=media_db_path,
lock=lock,
job=job,
)
if not first:
tw.write(",\n")
tw.write(" " + json.dumps(msg, ensure_ascii=False))
first = False
exported += 1
with lock:
job.progress.messages_exported += 1
job.progress.current_conversation_messages_exported = exported
if scanned % 500 == 0 and job.cancel_requested:
raise _JobCancelled()
tw.write("\n ]\n")
tw.write("}\n")
tw.flush()
zf.write(str(tmp_path), arcname)
if contact_conn is not None:
try:
contact_conn.close()
except Exception:
pass
return exported
def _write_conversation_txt(
*,
zf: zipfile.ZipFile,
conv_dir: str,
account_dir: Path,
conv_username: str,
conv_name: str,
conv_avatar_path: str,
conv_is_group: bool,
start_time: Optional[int],
end_time: Optional[int],
want_types: Optional[set[str]],
local_types: Optional[set[int]],
resource_conn: Optional[sqlite3.Connection],
resource_chat_id: Optional[int],
head_image_conn: Optional[sqlite3.Connection],
resolve_display_name: Any,
privacy_mode: bool,
include_media: bool,
media_kinds: list[MediaKind],
media_written: dict[str, str],
avatar_written: dict[str, str],
report: dict[str, Any],
allow_process_key_extract: bool,
media_db_path: Path,
job: ExportJob,
lock: threading.Lock,
) -> int:
arcname = f"{conv_dir}/messages.txt"
exported = 0
contact_conn: Optional[sqlite3.Connection] = None
alias_cache: dict[str, str] = {}
if conv_is_group:
try:
contact_db_path = account_dir / "contact.db"
if contact_db_path.exists():
contact_conn = sqlite3.connect(str(contact_db_path))
except Exception:
contact_conn = None
def lookup_alias(username: str) -> str:
u = str(username or "").strip()
if not u or contact_conn is None:
return ""
if u in alias_cache:
return alias_cache[u]
alias = ""
try:
r = contact_conn.execute("SELECT alias FROM contact WHERE username = ? LIMIT 1", (u,)).fetchone()
if r is not None and r[0] is not None:
alias = str(r[0] or "").strip()
if not alias:
r = contact_conn.execute("SELECT alias FROM stranger WHERE username = ? LIMIT 1", (u,)).fetchone()
if r is not None and r[0] is not None:
alias = str(r[0] or "").strip()
except Exception:
alias = ""
alias_cache[u] = alias
return alias
# Same as JSON: write to temp file first to avoid zip interleaving writes.
with tempfile.TemporaryDirectory(prefix="wechat_chat_export_") as tmp_dir:
tmp_path = Path(tmp_dir) / "messages.txt"
with open(tmp_path, "w", encoding="utf-8", newline="\n") as tw:
if privacy_mode:
tw.write("会话: 已隐藏\n")
tw.write("账号: hidden\n")
else:
tw.write(f"会话: {conv_name} ({conv_username})\n")
tw.write(f"账号: {account_dir.name}\n")
if conv_avatar_path:
tw.write(f"会话头像: {conv_avatar_path}\n")
if start_time or end_time:
st = _format_ts(int(start_time)) if start_time else "不限"
et = _format_ts(int(end_time)) if end_time else "不限"
tw.write(f"时间范围: {st} ~ {et}\n")
if want_types:
tw.write(f"消息类型: {', '.join(sorted(want_types))}\n")
tw.write(f"导出时间: {_now_iso()}\n")
tw.write("\n")
sender_alias_map: dict[str, int] = {}
scanned = 0
prev_ts = 0
for row in _iter_rows_for_conversation(
account_dir=account_dir,
conv_username=conv_username,
start_time=start_time,
end_time=end_time,
local_types=local_types,
):
scanned += 1
sender_alias = ""
if conv_is_group and row.raw_text and (not row.raw_text.startswith("<")) and (not row.raw_text.startswith('"<')):
sep = row.raw_text.find(":\n")
if sep > 0:
prefix = row.raw_text[:sep].strip()
su = str(row.sender_username or "").strip()
if prefix and su and prefix != su:
strong_hint = prefix.startswith("wxid_") or prefix.endswith("@chatroom") or "@" in prefix
if not strong_hint:
body_probe = row.raw_text[sep + 2 :].lstrip("\n").lstrip()
body_is_xml = body_probe.startswith("<") or body_probe.startswith('"<')
if not body_is_xml:
sender_alias = lookup_alias(su)
msg = _parse_message_for_export(
row=row,
conv_username=conv_username,
is_group=conv_is_group,
resource_conn=resource_conn,
resource_chat_id=resource_chat_id,
sender_alias=sender_alias,
resolve_display_name=resolve_display_name,
)
if not _is_render_type_selected(msg.get("renderType"), want_types):
continue
su = str(msg.get("senderUsername") or "").strip()
if privacy_mode:
_privacy_scrub_message(msg, conv_is_group=conv_is_group, sender_alias_map=sender_alias_map)
else:
msg["senderDisplayName"] = resolve_display_name(su) if su else ""
msg["senderAvatarPath"] = (
_materialize_avatar(
zf=zf,
head_image_conn=head_image_conn,
username=su,
avatar_written=avatar_written,
)
if (su and head_image_conn is not None)
else ""
)
if include_media:
_attach_offline_media(
zf=zf,
account_dir=account_dir,
conv_username=conv_username,
msg=msg,
media_written=media_written,
report=report,
media_kinds=media_kinds,
allow_process_key_extract=allow_process_key_extract,
media_db_path=media_db_path,
lock=lock,
job=job,
)
tw.write(_format_message_line_txt(msg=msg) + "\n")
exported += 1
with lock:
job.progress.messages_exported += 1
job.progress.current_conversation_messages_exported = exported
if scanned % 500 == 0 and job.cancel_requested:
raise _JobCancelled()
tw.flush()
zf.write(str(tmp_path), arcname)
if contact_conn is not None:
try:
contact_conn.close()
except Exception:
pass
return exported
def _write_conversation_html(
*,
zf: zipfile.ZipFile,
conv_dir: str,
account_dir: Path,
conv_username: str,
conv_name: str,
conv_avatar_path: str,
conv_is_group: bool,
self_avatar_path: str,
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]],
local_types: Optional[set[int]],
resource_conn: Optional[sqlite3.Connection],
resource_chat_id: Optional[int],
head_image_conn: Optional[sqlite3.Connection],
resolve_display_name: Any,
privacy_mode: bool,
include_media: bool,
media_kinds: list[MediaKind],
media_written: dict[str, str],
avatar_written: dict[str, str],
report: dict[str, Any],
allow_process_key_extract: bool,
media_db_path: Path,
job: ExportJob,
lock: threading.Lock,
) -> int:
arcname = f"{conv_dir}/messages.html"
exported = 0
rel_root = "../../"
css_href = rel_root + "assets/wechat-chat-export.css"
js_src = rel_root + "assets/wechat-chat-export.js"
def esc_text(v: Any) -> str:
return html.escape(str(v or ""), quote=False)
def esc_attr(v: Any) -> str:
return html.escape(str(v or ""), quote=True)
def is_http_url(u: str) -> bool:
s = str(u or "").strip().lower()
return s.startswith("http://") or s.startswith("https://")
def rel_path(p: Any) -> str:
s = str(p or "").strip().lstrip("/").replace("\\", "/")
if not s:
return ""
return rel_root + s
def offline_path(msg: dict[str, Any], kind: str) -> str:
media = msg.get("offlineMedia") or []
if not isinstance(media, list):
return ""
for item in media:
try:
k = str(item.get("kind") or "").strip()
except Exception:
k = ""
if k != kind:
continue
try:
p = str(item.get("path") or "").strip()
except Exception:
p = ""
if p:
return rel_path(p)
return ""
def maybe_download_remote_image(url: str) -> str:
if not download_remote_media:
return ""
u = str(url or "").strip()
if u:
try:
u = html.unescape(u).strip()
except Exception:
pass
try:
u = re.sub(r"\s+", "", u)
except Exception:
pass
if not is_http_url(u):
return ""
arc = _download_remote_image_to_zip(
zf=zf,
url=u,
remote_written=remote_written,
report=report,
)
if not arc:
return ""
local = rel_path(arc)
try:
page_media_index.setdefault("remote", {})[u] = local
except Exception:
pass
return local
emoji_table = _load_wechat_emoji_table()
emoji_regex = _load_wechat_emoji_regex()
def render_text_with_emojis(v: Any) -> str:
text = str(v or "")
if not text:
return ""
if not emoji_table or emoji_regex is None:
return esc_text(text)
parts: list[str] = []
last = 0
for match in emoji_regex.finditer(text):
start = match.start()
end = match.end()
if start > last:
parts.append(esc_text(text[last:start]))
key = match.group(0)
value = str(emoji_table.get(key) or "")
if value:
src = rel_path(f"wxemoji/{value}")
parts.append(
f'<img class="inline-block w-[1.25em] h-[1.25em] align-text-bottom mx-px" src="{esc_attr(src)}" alt="" />'
)
else:
parts.append(esc_text(key))
last = end
if last < len(text):
parts.append(esc_text(text[last:]))
return "".join(parts)
def build_avatar_html(*, src: str, fallback_text: str, extra_class: str) -> str:
safe_fallback = esc_text((fallback_text or "?")[:1] or "?")
if src:
return (
f'<div class="wce-avatar {extra_class} w-[calc(42px/var(--dpr))] h-[calc(42px/var(--dpr))] rounded-md overflow-hidden bg-gray-300 flex-shrink-0">'
f'<img src="{esc_attr(src)}" alt="avatar" class="w-full h-full object-cover" referrerpolicy="no-referrer" />'
f"</div>"
)
return (
f'<div class="wce-avatar {extra_class} w-[calc(42px/var(--dpr))] h-[calc(42px/var(--dpr))] rounded-md overflow-hidden bg-gray-300 flex-shrink-0">'
f'<div style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;color:#fff;font-size:12px;font-weight:700;background-color:#4B5563">{safe_fallback}</div>'
f"</div>"
)
def wechat_icon(name: str) -> str:
return rel_path(f"assets/images/wechat/{name}")
def format_file_size(size: Any) -> str:
if not size:
return ""
s = str(size).strip()
try:
num = float(s)
except Exception:
return s
if num < 0:
return s
def fmt_num(n: float) -> str:
if float(n).is_integer():
return str(int(n))
txt = f"{n:.2f}"
return txt.rstrip("0").rstrip(".")
if num < 1024:
return f"{fmt_num(num)} B"
if num < 1024 * 1024:
return f"{(num / 1024):.2f} KB"
return f"{(num / 1024 / 1024):.2f} MB"
def format_transfer_amount(amount: Any) -> str:
s = str(amount if amount is not None else "").strip()
if not s:
return ""
return re.sub(r"[¥¥]", "", s).strip()
def get_red_packet_text(message: dict[str, Any]) -> str:
text = str(message.get("content") if message is not None else "").strip()
if (not text) or text == "[Red Packet]":
return "恭喜发财,大吉大利"
return text
def is_transfer_returned(message: dict[str, Any]) -> bool:
pay_sub_type = str(message.get("paySubType") or "").strip()
if pay_sub_type in {"4", "9"}:
return True
st = str(message.get("transferStatus") or "").strip()
c = str(message.get("content") or "").strip()
text = f"{st} {c}".strip()
if not text:
return False
return ("退回" in text) or ("退还" in text)
def is_transfer_overdue(message: dict[str, Any]) -> bool:
pay_sub_type = str(message.get("paySubType") or "").strip()
if pay_sub_type == "10":
return True
st = str(message.get("transferStatus") or "").strip()
c = str(message.get("content") or "").strip()
text = f"{st} {c}".strip()
if not text:
return False
return "过期" in text
def is_transfer_received(message: dict[str, Any]) -> bool:
pay_sub_type = str(message.get("paySubType") or "").strip()
if pay_sub_type == "3":
return True
st = str(message.get("transferStatus") or "").strip()
if not st:
return False
return ("已收款" in st) or ("已被接收" in st)
def get_transfer_title(message: dict[str, Any], *, is_sent: bool) -> str:
pay_sub_type = str(message.get("paySubType") or "").strip()
transfer_status = str(message.get("transferStatus") or "").strip()
if transfer_status:
return transfer_status
if pay_sub_type == "1":
return "转账"
if pay_sub_type == "3":
return "已被接收" if is_sent else "已收款"
if pay_sub_type == "8":
return "发起转账"
if pay_sub_type == "4":
return "已退还"
if pay_sub_type == "9":
return "已被退还"
if pay_sub_type == "10":
return "已过期"
content = str(message.get("content") or "").strip()
if content and content not in {"转账", "[转账]"}:
return content
return "转账"
def get_voice_duration_in_seconds(duration_ms: Any) -> int:
try:
ms = int(str(duration_ms or "0").strip() or "0")
except Exception:
ms = 0
return int(round(ms / 1000.0))
def get_voice_width(duration_ms: Any) -> str:
seconds = get_voice_duration_in_seconds(duration_ms)
min_width = 80
max_width = 200
width = min(max_width, min_width + seconds * 4)
return f"{width}px"
def get_chat_history_preview_lines(message: dict[str, Any]) -> list[str]:
raw = str(message.get("content") or "").strip()
if not raw:
return []
lines = [ln.strip() for ln in raw.splitlines()]
lines = [ln for ln in lines if ln]
return lines[:4]
def get_file_icon_url(file_name: str) -> str:
ext = ""
try:
ext = (str(file_name or "").rsplit(".", 1)[-1] or "").lower().strip()
except Exception:
ext = ""
if ext == "pdf":
return wechat_icon("pdf.png")
if ext in {"zip", "rar", "7z", "tar", "gz"}:
return wechat_icon("zip.png")
if ext in {"doc", "docx"}:
return wechat_icon("word.png")
if ext in {"xls", "xlsx", "csv"}:
return wechat_icon("excel.png")
return wechat_icon("zip.png")
def get_link_from_text(message: dict[str, Any], *, url: str) -> str:
raw = str(message.get("from") or "").strip()
if raw:
return raw
try:
from urllib.parse import urlparse
host = urlparse(str(url or "")).hostname
return str(host or "").strip()
except Exception:
return ""
def first_glyph(text: str) -> str:
t = str(text or "").strip()
if not t:
return ""
try:
return next(iter(t)) or ""
except Exception:
return t[:1]
page_media_index: dict[str, Any] = {
"images": {},
"emojis": {},
"videos": {},
"videoThumbs": {},
"serverMd5": {},
"remote": {},
}
chat_history_md5_done: set[str] = set()
def _remember_offline_media(message: dict[str, Any]) -> None:
media = message.get("offlineMedia") or []
if not isinstance(media, list):
return
for item in media:
try:
kind = str(item.get("kind") or "").strip()
except Exception:
kind = ""
try:
md5 = str(item.get("md5") or "").strip().lower()
except Exception:
md5 = ""
try:
path0 = str(item.get("path") or "").strip()
except Exception:
path0 = ""
if (not md5) or (not path0):
continue
url0 = rel_path(path0)
if kind == "image":
page_media_index["images"][md5] = url0
elif kind == "emoji":
page_media_index["emojis"][md5] = url0
elif kind == "video":
page_media_index["videos"][md5] = url0
elif kind == "video_thumb":
page_media_index["videoThumbs"][md5] = url0
def _ensure_chat_history_md5(md5: str) -> str:
m = str(md5 or "").strip().lower()
if (not m) or (not _is_md5(m)):
return ""
if m in chat_history_md5_done:
for k in ("images", "emojis", "videos", "videoThumbs"):
try:
hit = str((page_media_index.get(k) or {}).get(m) or "").strip()
except Exception:
hit = ""
if hit:
return hit
return ""
chat_history_md5_done.add(m)
arc = ""
is_new = False
for try_kind in ("image", "emoji", "video_thumb", "video"):
arc, is_new = _materialize_media(
zf=zf,
account_dir=account_dir,
conv_username=conv_username,
kind=try_kind, # type: ignore[arg-type]
md5=m,
file_id="",
media_written=media_written,
suggested_name="",
)
if arc:
break
if not arc:
return ""
url0 = rel_path(arc)
try:
page_media_index["images"].setdefault(m, url0)
page_media_index["emojis"].setdefault(m, url0)
page_media_index["videoThumbs"].setdefault(m, url0)
if arc.lower().endswith(".mp4"):
page_media_index["videos"][m] = url0
except Exception:
pass
if is_new:
with lock:
job.progress.media_copied += 1
return url0
chat_title = "已隐藏" if privacy_mode else (conv_name or conv_username or "会话")
page_title = chat_title
options = [
("all", "全部"),
("text", "文本"),
("image", "图片"),
("emoji", "表情"),
("video", "视频"),
("voice", "语音"),
("chatHistory", "聊天记录"),
("transfer", "转账"),
("redPacket", "红包"),
("file", "文件"),
("link", "链接"),
("quote", "引用"),
("system", "系统"),
("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"
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")
tw.write(' <meta charset="utf-8" />\n')
tw.write(' <meta name="viewport" content="width=device-width, initial-scale=1" />\n')
tw.write(f" <title>{esc_text(page_title)}</title>\n")
tw.write(f' <link rel="stylesheet" href="{esc_attr(css_href)}" />\n')
tw.write(f' <script defer src="{esc_attr(js_src)}"></script>\n')
tw.write("</head>\n")
tw.write("<body>\n")
tw.write(
' <div id="wceJsMissing" style="position:fixed;top:0;left:0;right:0;z-index:9999;background:#FEF3C7;color:#92400E;border-bottom:1px solid #F59E0B;padding:8px 12px;font-size:12px;line-height:1.4">'
"提示:此页面需要 JavaScript 才能使用“合并聊天记录”等交互功能。若该提示一直存在,请确认已完整解压导出目录,并检查 wechat-chat-export.js 是否能加载(位于 assets/)。</div>\n"
)
# Root
tw.write('<div class="wce-root h-screen flex overflow-hidden" style="background-color:#EDEDED">\n')
# Left rail (avatar + chat icon)
tw.write(
'<div class="wce-rail border-r border-gray-200 flex flex-col" style="background-color:#e8e7e7;width:60px;min-width:60px;max-width:60px">\n'
)
self_avatar_src = "" if privacy_mode else rel_path(self_avatar_path)
tw.write(' <div class="w-full h-[60px] flex items-center justify-center">\n')
tw.write(' <div data-wce-rail-avatar="1" class="w-[40px] h-[40px] rounded-md overflow-hidden bg-gray-300 flex-shrink-0">\n')
if self_avatar_src:
tw.write(
f' <img src="{esc_attr(self_avatar_src)}" alt="avatar" class="w-full h-full object-cover" referrerpolicy="no-referrer" />\n'
)
else:
tw.write(
' <div class="w-full h-full flex items-center justify-center text-white text-xs font-bold" style="background-color:#4B5563">我</div>\n'
)
tw.write(" </div>\n")
tw.write(" </div>\n")
tw.write(
f' <a href="{esc_attr(rel_root + "index.html")}" class="w-full h-[var(--sidebar-rail-step)] flex items-center justify-center group" aria-label="会话列表" title="会话列表">\n'
)
tw.write(
' <div class="w-[var(--sidebar-rail-btn)] h-[var(--sidebar-rail-btn)] rounded-md bg-transparent group-hover:bg-[#E1E1E1] flex items-center justify-center transition-colors">\n'
)
tw.write(' <div class="w-[var(--sidebar-rail-icon)] h-[var(--sidebar-rail-icon)] text-[#07b75b]">\n')
tw.write(' <svg class="w-full h-full" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">\n')
tw.write(
' <path d="M12 19.8C17.52 19.8 22 15.99 22 11.3C22 6.6 17.52 2.8 12 2.8C6.48 2.8 2 6.6 2 11.3C2 13.29 2.8 15.12 4.15 16.57C4.6 17.05 4.82 17.29 4.92 17.44C5.14 17.79 5.21 17.99 5.23 18.4C5.24 18.59 5.22 18.81 5.16 19.26C5.1 19.75 5.07 19.99 5.13 20.16C5.23 20.49 5.53 20.71 5.87 20.72C6.04 20.72 6.27 20.63 6.72 20.43L8.07 19.86C8.43 19.71 8.61 19.63 8.77 19.59C8.95 19.55 9.04 19.54 9.22 19.54C9.39 19.53 9.64 19.57 10.14 19.65C10.74 19.75 11.37 19.8 12 19.8Z" />\n'
)
tw.write(" </svg>\n")
tw.write(" </div>\n")
tw.write(" </div>\n")
tw.write(" </a>\n")
tw.write("</div>\n")
# Middle session list (all exported conversations)
tw.write(
'<div class="wce-session-panel session-list-panel border-r border-gray-200 flex flex-col min-h-0 shrink-0 relative" style="background-color:#F7F7F7;--session-list-width:295px">\n'
)
tw.write(' <div class="p-3 border-b border-gray-200" style="background-color:#F7F7F7">\n')
tw.write(
' <div class="flex items-center gap-2">\n'
)
tw.write(' <div class="contact-search-wrapper flex-1">\n')
tw.write(' <svg class="contact-search-icon" fill="none" stroke="currentColor" viewBox="0 0 16 16">\n')
tw.write(
' <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M7.33333 12.6667C10.2789 12.6667 12.6667 10.2789 12.6667 7.33333C12.6667 4.38781 10.2789 2 7.33333 2C4.38781 2 2 4.38781 2 7.33333C2 10.2789 4.38781 12.6667 7.33333 12.6667Z" />\n'
)
tw.write(
' <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M14 14L11.1 11.1" />\n'
)
tw.write(" </svg>\n")
search_input_cls = "contact-search-input"
if privacy_mode:
search_input_cls += " privacy-blur"
tw.write(
f' <input id="sessionSearchInput" type="text" placeholder="搜索联系人" class="{esc_attr(search_input_cls)}" autocomplete="off" />\n'
)
tw.write(
' <button type="button" id="sessionSearchClear" class="contact-search-clear" style="display:none" aria-label="清空搜索">\n'
)
tw.write(' <svg class="w-3.5 h-3.5" 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(" </svg>\n")
tw.write(" </button>\n")
tw.write(" </div>\n")
tw.write(" </div>\n")
tw.write(" </div>\n")
tw.write(' <div class="flex-1 overflow-y-auto min-h-0" data-wce-session-list="1">\n')
conv_dir_norm = str(conv_dir or "").strip().strip("/").replace("\\", "/")
for item in session_items:
item_conv_dir = str(item.get("convDir") or "").strip().strip("/").replace("\\", "/")
if not item_conv_dir:
continue
href = f"{rel_root}{item_conv_dir}/messages.html"
item_display_name = str(item.get("displayName") or "").strip() or "会话"
item_avatar_path = str(item.get("avatarPath") or "").strip()
item_avatar_src = rel_path(item_avatar_path) if item_avatar_path else ""
item_last_time = str(item.get("lastTimeText") or "").strip()
item_preview = str(item.get("previewText") or "").strip()
is_active = False
try:
is_active = (str(item.get("username") or "").strip() == conv_username) or (item_conv_dir == conv_dir_norm)
except Exception:
is_active = item_conv_dir == conv_dir_norm
safe_char = (item_display_name[:1] or "?").strip() or "?"
classes = (
"px-3 cursor-pointer transition-colors duration-150 border-b border-gray-100 "
"h-[calc(80px/var(--dpr))] flex items-center"
)
if is_active:
classes += " bg-[#DEDEDE]"
else:
classes += " hover:bg-[#F5F5F5]"
item_username = str(item.get("username") or "").strip()
tw.write(
f' <a href="{esc_attr(href)}" class="{esc_attr(classes)}" data-wce-session-item="1" '
f'data-wce-session-name="{esc_attr(item_display_name)}" data-wce-session-username="{esc_attr(item_username)}"'
)
if is_active:
tw.write(' aria-current="page"')
tw.write(">\n")
tw.write(' <div class="relative">\n')
tw.write(
' <div class="w-[calc(45px/var(--dpr))] h-[calc(45px/var(--dpr))] rounded-md overflow-hidden bg-gray-300">\n'
)
if item_avatar_src and (not privacy_mode):
tw.write(
f' <img src="{esc_attr(item_avatar_src)}" alt="{esc_attr(item_display_name)}" class="w-full h-full object-cover" referrerpolicy="no-referrer" />\n'
)
else:
tw.write(
f' <div class="w-full h-full flex items-center justify-center text-white text-xs font-bold" style="background-color:#4B5563">{esc_text(safe_char)}</div>\n'
)
tw.write(" </div>\n")
tw.write(" </div>\n")
tw.write(' <div class="flex-1 min-w-0 ml-3">\n')
tw.write(' <div class="flex items-center justify-between">\n')
tw.write(
f' <h3 class="text-sm font-medium text-gray-900 truncate">{esc_text(item_display_name)}</h3>\n'
)
tw.write(' <div class="flex items-center flex-shrink-0 ml-2">\n')
tw.write(f' <span class="text-xs text-gray-500">{esc_text(item_last_time)}</span>\n')
tw.write(" </div>\n")
tw.write(" </div>\n")
tw.write(
f' <p class="text-xs text-gray-500 truncate mt-0.5 leading-tight">{render_text_with_emojis(item_preview)}</p>\n'
)
tw.write(" </div>\n")
tw.write(" </a>\n")
tw.write(" </div>\n")
tw.write("</div>\n")
# Right chat area
tw.write('<div class="wce-chat-area flex-1 flex flex-col min-h-0" style="background-color:#EDEDED">\n')
tw.write(' <div class="wce-chat-main flex-1 flex min-h-0">\n')
tw.write(' <div class="wce-chat-col flex-1 flex flex-col min-h-0 min-w-0">\n')
tw.write(' <div class="flex-1 flex flex-col min-h-0 relative">\n')
tw.write(' <div class="chat-header">\n')
tw.write(' <div class="flex items-center gap-3 min-w-0">\n')
tw.write(f' <h2 class="text-base font-medium text-gray-900">{esc_text(chat_title)}</h2>\n')
tw.write(" </div>\n")
tw.write(' <div class="ml-auto flex items-center gap-2">\n')
tw.write(f' <select id="messageTypeFilter" class="message-filter-select" title="筛选消息类型">\n')
for value, label in options:
tw.write(f' <option value="{esc_attr(value)}">{esc_text(label)}</option>\n')
tw.write(" </select>\n")
tw.write(" </div>\n")
tw.write(" </div>\n")
tw.write(' <div id="messageContainer" class="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
scanned = 0
for row in _iter_rows_for_conversation(
account_dir=account_dir,
conv_username=conv_username,
start_time=start_time,
end_time=end_time,
local_types=local_types,
):
scanned += 1
msg = _parse_message_for_export(
row=row,
conv_username=conv_username,
is_group=conv_is_group,
resource_conn=resource_conn,
resource_chat_id=resource_chat_id,
sender_alias="",
resolve_display_name=resolve_display_name,
)
if not _is_render_type_selected(msg.get("renderType"), want_types):
continue
sender_username = str(msg.get("senderUsername") or "").strip()
if privacy_mode:
_privacy_scrub_message(msg, conv_is_group=conv_is_group, sender_alias_map=sender_alias_map)
else:
msg["senderDisplayName"] = resolve_display_name(sender_username) if sender_username else ""
msg["senderAvatarPath"] = (
_materialize_avatar(
zf=zf,
head_image_conn=head_image_conn,
username=sender_username,
avatar_written=avatar_written,
)
if (sender_username and head_image_conn is not None)
else ""
)
if include_media:
_attach_offline_media(
zf=zf,
account_dir=account_dir,
conv_username=conv_username,
msg=msg,
media_written=media_written,
report=report,
media_kinds=media_kinds,
allow_process_key_extract=allow_process_key_extract,
media_db_path=media_db_path,
lock=lock,
job=job,
)
_remember_offline_media(msg)
rt = str(msg.get("renderType") or "text").strip() or "text"
create_time_text = str(msg.get("createTimeText") or "").strip()
try:
ts = int(msg.get("createTime") or 0)
except Exception:
ts = 0
show_divider = False
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:
tw.write(' <div class="flex justify-center mb-4" data-wce-time-divider="1">\n')
tw.write(f' <div class="px-3 py-1 text-xs text-[#9e9e9e]">{esc_text(divider_text)}</div>\n')
tw.write(" </div>\n")
# Wrapper (for filter)
tw.write(f' <div class="mb-6" data-render-type="{esc_attr(rt)}" title="{esc_attr(create_time_text)}">\n')
if rt == "system":
tw.write(' <div class="wce-system flex justify-center">\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")
_mark_exported()
if ts:
prev_ts = ts
continue
is_sent = bool(msg.get("isSent"))
row_cls = "wce-msg-row wce-msg-row-sent flex items-center justify-end" if is_sent else "wce-msg-row wce-msg-row-received flex items-center justify-start"
msg_cls = "wce-msg wce-msg-sent flex items-start max-w-md flex-row-reverse" if is_sent else "wce-msg flex items-start max-w-md"
avatar_extra = "wce-avatar-sent ml-3" if is_sent else "wce-avatar-received mr-3"
tw.write(f' <div class="{esc_attr(row_cls)}">\n')
tw.write(f' <div class="{esc_attr(msg_cls)}">\n')
avatar_src = rel_path(str(msg.get("senderAvatarPath") or "").strip())
display_name = str(msg.get("senderDisplayName") or "").strip()
fallback_char = (display_name or sender_username or "?")[:1]
tw.write(" " + build_avatar_html(src=avatar_src, fallback_text=fallback_char, extra_class=avatar_extra) + "\n")
align_cls = "items-end" if is_sent else "items-start"
tw.write(f' <div class="flex flex-col relative group {esc_attr(align_cls)}" style="min-width:0">\n')
if conv_is_group and (not is_sent) and display_name:
tw.write(f' <div class="text-[11px] text-gray-500 mb-1 text-left">{esc_text(display_name)}</div>\n')
pos_cls = "right-0" if is_sent else "left-0"
tw.write(
' <div class="absolute -top-6 z-10 rounded bg-black/70 text-white text-[10px] px-2 py-1 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none whitespace-nowrap '
+ pos_cls
+ f'">{esc_text(create_time_text)}</div>\n'
)
# Message body
bubble_dir_cls = "bg-[#95EC69] text-black bubble-tail-r" if is_sent else "bg-white text-gray-800 bubble-tail-l"
bubble_base_cls = "px-3 py-2 text-sm max-w-sm relative msg-bubble whitespace-pre-wrap break-words leading-relaxed"
bubble_unknown_cls = (
"px-3 py-2 text-xs max-w-sm relative msg-bubble whitespace-pre-wrap break-words leading-relaxed text-gray-700"
)
if rt == "image":
src = offline_path(msg, "image")
if not src:
url = str(msg.get("imageUrl") or "").strip()
src = url if is_http_url(url) else ""
if src:
tw.write(' <div class="max-w-sm">\n')
tw.write(' <div class="msg-radius overflow-hidden cursor-pointer">\n')
tw.write(f' <a href="{esc_attr(src)}" target="_blank" rel="noreferrer noopener">\n')
tw.write(f' <img src="{esc_attr(src)}" alt="图片" class="max-w-[240px] max-h-[240px] object-cover hover:opacity-90 transition-opacity" loading="lazy" decoding="async" />\n')
tw.write(" </a>\n")
tw.write(" </div>\n")
tw.write(" </div>\n")
else:
tw.write(f' <div class="{esc_attr(bubble_base_cls + " " + bubble_dir_cls)}">{render_text_with_emojis(msg.get("content") or "")}</div>\n')
elif rt == "emoji":
src = offline_path(msg, "emoji")
if not src:
url = str(msg.get("emojiUrl") or "").strip()
src = url if is_http_url(url) else ""
if src:
emoji_dir = " flex-row-reverse" if is_sent else ""
tw.write(f' <div class="max-w-sm flex items-center{emoji_dir}">\n')
tw.write(f' <img src="{esc_attr(src)}" alt="表情" class="w-24 h-24 object-contain" loading="lazy" decoding="async" />\n')
tw.write(" </div>\n")
else:
tw.write(f' <div class="{esc_attr(bubble_base_cls + " " + bubble_dir_cls)}">{render_text_with_emojis(msg.get("content") or "")}</div>\n')
elif rt == "video":
thumb = offline_path(msg, "video_thumb")
if not thumb:
url = str(msg.get("videoThumbUrl") or "").strip()
thumb = url if is_http_url(url) else ""
video = offline_path(msg, "video")
if not video:
url = str(msg.get("videoUrl") or "").strip()
video = url if is_http_url(url) else ""
if thumb:
tw.write(' <div class="max-w-sm">\n')
tw.write(' <div class="msg-radius overflow-hidden relative bg-black/5">\n')
tw.write(f' <img src="{esc_attr(thumb)}" alt="视频" class="block w-[220px] max-w-[260px] h-auto max-h-[260px] object-cover" loading="lazy" decoding="async" />\n')
if video:
tw.write(f' <a href="{esc_attr(video)}" target="_blank" rel="noreferrer noopener" class="absolute inset-0 flex items-center justify-center">\n')
tw.write(' <div class="w-12 h-12 rounded-full bg-black/45 flex items-center justify-center">\n')
tw.write(' <svg class="w-6 h-6 text-white" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>\n')
tw.write(" </div>\n")
tw.write(" </a>\n")
else:
tw.write(' <div class="absolute inset-0 flex items-center justify-center">\n')
tw.write(' <div class="w-12 h-12 rounded-full bg-black/45 flex items-center justify-center">\n')
tw.write(' <svg class="w-6 h-6 text-white" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>\n')
tw.write(" </div>\n")
tw.write(" </div>\n")
tw.write(" </div>\n")
tw.write(" </div>\n")
else:
tw.write(f' <div class="{esc_attr(bubble_base_cls + " " + bubble_dir_cls)}">{render_text_with_emojis(msg.get("content") or "")}</div>\n')
elif rt == "voice":
voice = offline_path(msg, "voice")
duration_ms = msg.get("voiceLength")
width = get_voice_width(duration_ms)
seconds = get_voice_duration_in_seconds(duration_ms)
voice_dir_cls = "wechat-voice-sent" if is_sent else "wechat-voice-received"
content_dir_cls = " flex-row-reverse" if is_sent else ""
icon_dir_cls = "voice-icon-sent" if is_sent else "voice-icon-received"
voice_id = str(msg.get("id") or "").strip()
tw.write(' <div class="wechat-voice-wrapper">\n')
tw.write(
f' <div class="wechat-voice-bubble msg-radius {esc_attr(voice_dir_cls)}" style="width: {esc_attr(width)}" data-voice-id="{esc_attr(voice_id)}">\n'
)
tw.write(f' <div class="wechat-voice-content{esc_attr(content_dir_cls)}">\n')
tw.write(
f' <svg class="wechat-voice-icon {esc_attr(icon_dir_cls)}" viewBox="0 0 32 32" fill="currentColor">\n'
)
tw.write(
' <path d="M10.24 11.616l-4.224 4.192 4.224 4.192c1.088-1.056 1.76-2.56 1.76-4.192s-0.672-3.136-1.76-4.192z"></path>\n'
)
tw.write(
' <path class="voice-wave-2" d="M15.199 6.721l-1.791 1.76c1.856 1.888 3.008 4.48 3.008 7.328s-1.152 5.44-3.008 7.328l1.791 1.76c2.336-2.304 3.809-5.536 3.809-9.088s-1.473-6.784-3.809-9.088z"></path>\n'
)
tw.write(
' <path class="voice-wave-3" d="M20.129 1.793l-1.762 1.76c3.104 3.168 5.025 7.488 5.025 12.256s-1.921 9.088-5.025 12.256l1.762 1.76c3.648-3.616 5.887-8.544 5.887-14.016s-2.239-10.432-5.887-14.016z"></path>\n'
)
tw.write(" </svg>\n")
tw.write(f' <span class="wechat-voice-duration">{esc_text(seconds)}"</span>\n')
tw.write(" </div>\n")
tw.write(" </div>\n")
if voice:
tw.write(f' <audio src="{esc_attr(voice)}" preload="none" class="hidden"></audio>\n')
tw.write(" </div>\n")
elif rt == "file":
fsrc = offline_path(msg, "file")
title = str(msg.get("title") or msg.get("content") or "文件").strip()
size = str(msg.get("fileSize") or "").strip()
size_text = format_file_size(size)
sent_side_cls = " wechat-special-sent-side" if is_sent else ""
cls = f"wechat-redpacket-card wechat-special-card wechat-file-card msg-radius{sent_side_cls}"
tag = "a" if fsrc else "div"
attrs = f' href="{esc_attr(fsrc)}" download' if fsrc else ""
tw.write(f' <{tag}{attrs} class="{esc_attr(cls)}">\n')
tw.write(' <div class="wechat-redpacket-content">\n')
tw.write(' <div class="wechat-redpacket-info wechat-file-info">\n')
tw.write(f' <span class="wechat-file-name">{esc_text(title or "文件")}</span>\n')
if size_text:
tw.write(f' <span class="wechat-file-size">{esc_text(size_text)}</span>\n')
tw.write(" </div>\n")
tw.write(f' <img src="{esc_attr(get_file_icon_url(title))}" alt="" class="wechat-file-icon" />\n')
tw.write(" </div>\n")
tw.write(' <div class="wechat-redpacket-bottom wechat-file-bottom">\n')
tw.write(f' <img src="{esc_attr(wechat_icon("WeChat-Icon-Logo.wine.svg"))}" alt="" class="wechat-file-logo" />\n')
tw.write(" <span>微信电脑版</span>\n")
tw.write(" </div>\n")
tw.write(f" </{tag}>\n")
elif rt == "link":
url = str(msg.get("url") or "").strip()
safe_url = url if is_http_url(url) else ""
if safe_url:
heading = str(msg.get("title") or msg.get("content") or safe_url).strip()
abstract = str(msg.get("content") or "").strip()
preview = str(msg.get("thumbUrl") or "").strip()
preview_url = ""
if is_http_url(preview):
local = maybe_download_remote_image(preview)
preview_url = local or preview
variant = str(msg.get("linkStyle") or "").strip().lower()
from_text = get_link_from_text(msg, url=safe_url)
from_avatar_text = first_glyph(from_text) or "\u200B"
from_text = from_text or "\u200B"
sent_side_cls = " wechat-special-sent-side" if is_sent else ""
if variant == "cover":
cls = f"wechat-link-card-cover wechat-special-card msg-radius{sent_side_cls}"
tw.write(
f' <a href="{esc_attr(safe_url)}" target="_blank" rel="noreferrer" class="{esc_attr(cls)}" '
'style="width:137px;min-width:137px;max-width:137px;display:flex;flex-direction:column;box-sizing:border-box;flex:0 0 auto;background:#fff;border:none;box-shadow:none;text-decoration:none;outline:none">\n'
)
if preview_url:
tw.write(' <div class="wechat-link-cover-image-wrap">\n')
tw.write(
f' <img src="{esc_attr(preview_url)}" alt="{esc_attr(heading or "链接封面")}" class="wechat-link-cover-image" referrerpolicy="no-referrer" />\n'
)
tw.write(' <div class="wechat-link-cover-from">\n')
tw.write(
f' <div class="wechat-link-cover-from-avatar" aria-hidden="true">{esc_text(from_avatar_text)}</div>\n'
)
tw.write(f' <div class="wechat-link-cover-from-name">{esc_text(from_text)}</div>\n')
tw.write(" </div>\n")
tw.write(" </div>\n")
else:
tw.write(' <div class="wechat-link-cover-from">\n')
tw.write(
f' <div class="wechat-link-cover-from-avatar" aria-hidden="true">{esc_text(from_avatar_text)}</div>\n'
)
tw.write(f' <div class="wechat-link-cover-from-name">{esc_text(from_text)}</div>\n')
tw.write(" </div>\n")
tw.write(f' <div class="wechat-link-cover-title">{esc_text(heading or safe_url)}</div>\n')
tw.write(" </a>\n")
else:
cls = f"wechat-link-card wechat-special-card msg-radius{sent_side_cls}"
tw.write(
f' <a href="{esc_attr(safe_url)}" target="_blank" rel="noreferrer" class="{esc_attr(cls)}" '
'style="width:210px;min-width:210px;max-width:210px;display:flex;flex-direction:column;box-sizing:border-box;flex:0 0 auto;background:#fff;border:none;box-shadow:none;text-decoration:none;outline:none">\n'
)
tw.write(' <div class="wechat-link-content">\n')
tw.write(' <div class="wechat-link-info">\n')
tw.write(f' <div class="wechat-link-title">{esc_text(heading or safe_url)}</div>\n')
if abstract:
tw.write(f' <div class="wechat-link-desc">{esc_text(abstract)}</div>\n')
tw.write(" </div>\n")
if preview_url:
tw.write(' <div class="wechat-link-thumb">\n')
tw.write(
f' <img src="{esc_attr(preview_url)}" alt="{esc_attr(heading or "链接预览")}" class="wechat-link-thumb-img" referrerpolicy="no-referrer" />\n'
)
tw.write(" </div>\n")
tw.write(" </div>\n")
tw.write(' <div class="wechat-link-from">\n')
tw.write(
f' <div class="wechat-link-from-avatar" aria-hidden="true">{esc_text(from_avatar_text)}</div>\n'
)
tw.write(f' <div class="wechat-link-from-name">{esc_text(from_text)}</div>\n')
tw.write(" </div>\n")
tw.write(" </a>\n")
else:
tw.write(f' <div class="{esc_attr(bubble_base_cls + " " + bubble_dir_cls)}">{render_text_with_emojis(msg.get("content") or "")}</div>\n')
elif rt == "voip":
voip_dir_cls = "wechat-voip-sent" if is_sent else "wechat-voip-received"
content_dir_cls = " flex-row-reverse" if is_sent else ""
voip_type = str(msg.get("voipType") or "").strip().lower()
icon = "wechat-video-light.png" if voip_type == "video" else "wechat-audio-light.png"
tw.write(f' <div class="wechat-voip-bubble msg-radius {esc_attr(voip_dir_cls)}">\n')
tw.write(f' <div class="wechat-voip-content{esc_attr(content_dir_cls)}">\n')
tw.write(f' <img src="{esc_attr(wechat_icon(icon))}" class="wechat-voip-icon" alt="" />\n')
tw.write(f' <span class="wechat-voip-text">{esc_text(msg.get("content") or "通话")}</span>\n')
tw.write(" </div>\n")
tw.write(" </div>\n")
elif rt == "quote":
tw.write(
f' <div class="{esc_attr(bubble_base_cls + " " + bubble_dir_cls)}">{render_text_with_emojis(msg.get("content") or "")}</div>\n'
)
qt = str(msg.get("quoteTitle") or "").strip()
qc = str(msg.get("quoteContent") or "").strip()
qthumb = str(msg.get("quoteThumbUrl") or "").strip()
qtype = str(msg.get("quoteType") or "").strip()
qsid_raw = str(msg.get("quoteServerId") or "").strip()
qsid = int(qsid_raw) if qsid_raw.isdigit() else 0
def is_quoted_voice() -> bool:
if qtype == "34":
return True
return (qc == "[语音]") and bool(qsid_raw)
def is_quoted_image() -> bool:
if qtype == "3":
return True
return (qc == "[图片]") and bool(qsid_raw)
def is_quoted_link() -> bool:
if qtype == "49":
return True
return bool(re.match(r"^\[链接\]\s*", qc))
def get_quoted_link_text() -> str:
if not qc:
return ""
return re.sub(r"^\[链接\]\s*", "", qc).strip() or qc
quoted_voice = is_quoted_voice()
quoted_image = is_quoted_image()
quoted_link = is_quoted_link()
quote_voice_url = ""
if include_media and ("voice" in media_kinds) and quoted_voice and qsid:
try:
arc, is_new = _materialize_voice(
zf=zf,
media_db_path=media_db_path,
server_id=int(qsid),
media_written=media_written,
)
except Exception:
arc, is_new = "", False
if arc:
quote_voice_url = rel_path(arc)
if is_new:
with lock:
job.progress.media_copied += 1
quote_image_url = ""
if include_media and ("image" in media_kinds) and quoted_image and qsid and resource_conn is not None:
md5_hit = ""
try:
md5_hit = _lookup_resource_md5(
resource_conn,
resource_chat_id,
message_local_type=3,
server_id=int(qsid),
local_id=0,
create_time=0,
)
except Exception:
md5_hit = ""
if md5_hit:
try:
arc, is_new = _materialize_media(
zf=zf,
account_dir=account_dir,
conv_username=conv_username,
kind="image",
md5=str(md5_hit or "").strip().lower(),
file_id="",
media_written=media_written,
suggested_name="",
)
except Exception:
arc, is_new = "", False
if arc:
quote_image_url = rel_path(arc)
if is_new:
with lock:
job.progress.media_copied += 1
qthumb_url = ""
if is_http_url(qthumb):
qthumb_local = maybe_download_remote_image(qthumb) if download_remote_media else ""
qthumb_url = qthumb_local or qthumb
if qt or qc:
tw.write(
' <div class="mt-[5px] px-2 text-xs text-neutral-600 rounded max-w-[404px] max-h-[65px] overflow-hidden flex items-start bg-[#e1e1e1]">\n'
)
tw.write(' <div class="py-2 min-w-0 flex-1">\n')
if quoted_voice:
seconds = get_voice_duration_in_seconds(msg.get("quoteVoiceLength"))
disabled = not bool(quote_voice_url)
btn_cls = "flex items-center gap-1 min-w-0 hover:opacity-80"
if disabled:
btn_cls += " opacity-60 cursor-not-allowed"
dis_attr = " disabled" if disabled else ""
tw.write(' <div class="flex items-center gap-1 min-w-0" data-wce-quote-voice-wrapper="1">\n')
if qt:
tw.write(f' <span class="truncate flex-shrink-0">{esc_text(qt)}:</span>\n')
tw.write(
f' <button type="button" data-wce-quote-voice-btn="1" class="{esc_attr(btn_cls)}"{dis_attr}>\n'
)
tw.write(
' <svg class="wechat-voice-icon wechat-quote-voice-icon" viewBox="0 0 32 32" fill="currentColor">\n'
)
tw.write(
' <path d="M10.24 11.616l-4.224 4.192 4.224 4.192c1.088-1.056 1.76-2.56 1.76-4.192s-0.672-3.136-1.76-4.192z"></path>\n'
)
tw.write(
' <path class="voice-wave-2" d="M15.199 6.721l-1.791 1.76c1.856 1.888 3.008 4.48 3.008 7.328s-1.152 5.44-3.008 7.328l1.791 1.76c2.336-2.304 3.809-5.536 3.809-9.088s-1.473-6.784-3.809-9.088z"></path>\n'
)
tw.write(
' <path class="voice-wave-3" d="M20.129 1.793l-1.762 1.76c3.104 3.168 5.025 7.488 5.025 12.256s-1.921 9.088-5.025 12.256l1.762 1.76c3.648-3.616 5.887-8.544 5.887-14.016s-2.239-10.432-5.887-14.016z"></path>\n'
)
tw.write(" </svg>\n")
if seconds > 0:
tw.write(f' <span class="flex-shrink-0">{esc_text(seconds)}"</span>\n')
else:
tw.write(' <span class="flex-shrink-0">语音</span>\n')
tw.write(" </button>\n")
if quote_voice_url:
tw.write(
f' <audio src="{esc_attr(quote_voice_url)}" preload="none" class="hidden" data-wce-quote-voice-audio="1"></audio>\n'
)
tw.write(" </div>\n")
else:
tw.write(' <div class="min-w-0 flex items-start">\n')
if quoted_link:
link_text = get_quoted_link_text()
tw.write(' <div class="line-clamp-2 min-w-0 flex-1">\n')
if qt:
tw.write(f' <span>{esc_text(qt)}:</span>\n')
if link_text:
ml = ' class="ml-1"' if qt else ""
tw.write(f' <span{ml}>🔗 {esc_text(link_text)}</span>\n')
tw.write(" </div>\n")
else:
hide_qc = quoted_image and qt and bool(quote_image_url)
tw.write(' <div class="line-clamp-2 min-w-0 flex-1">\n')
if qt:
tw.write(f' <span>{esc_text(qt)}:</span>\n')
if qc and (not hide_qc):
ml = ' class="ml-1"' if qt else ""
tw.write(f' <span{ml}>{esc_text(qc)}</span>\n')
tw.write(" </div>\n")
tw.write(" </div>\n")
tw.write(" </div>\n")
if quoted_link and qthumb_url:
tw.write(
f' <a href="{esc_attr(qthumb_url)}" target="_blank" rel="noreferrer noopener" class="ml-2 my-2 flex-shrink-0 max-w-[98px] max-h-[49px] overflow-hidden flex items-center justify-center cursor-pointer">\n'
)
tw.write(
f' <img src="{esc_attr(qthumb_url)}" alt="引用链接缩略图" class="max-h-[49px] w-auto max-w-[98px] object-contain" loading="lazy" decoding="async" referrerpolicy="no-referrer" onerror="this.style.display=\'none\'" />\n'
)
tw.write(" </a>\n")
if (not quoted_link) and quoted_image and quote_image_url:
tw.write(
f' <a href="{esc_attr(quote_image_url)}" target="_blank" rel="noreferrer noopener" class="ml-2 my-2 flex-shrink-0 max-w-[98px] max-h-[49px] overflow-hidden flex items-center justify-center cursor-pointer">\n'
)
tw.write(
f' <img src="{esc_attr(quote_image_url)}" alt="引用图片" class="max-h-[49px] w-auto max-w-[98px] object-contain" loading="lazy" decoding="async" referrerpolicy="no-referrer" onerror="this.style.display=\'none\'" />\n'
)
tw.write(" </a>\n")
tw.write(" </div>\n")
elif rt == "chatHistory":
title = str(msg.get("title") or "").strip() or "聊天记录"
record_item = str(msg.get("recordItem") or "").strip()
record_item_b64 = ""
if record_item:
try:
record_item_b64 = base64.b64encode(record_item.encode("utf-8", errors="replace")).decode("ascii")
except Exception:
record_item_b64 = ""
if record_item and include_media and (not privacy_mode):
try:
for m in _CHAT_HISTORY_MD5_TAG_RE.findall(record_item):
_ensure_chat_history_md5(m)
except Exception:
pass
if resource_conn is not None:
try:
server_map = page_media_index.get("serverMd5")
if not isinstance(server_map, dict):
server_map = {}
page_media_index["serverMd5"] = server_map
for sid_raw in _CHAT_HISTORY_SERVER_ID_TAG_RE.findall(record_item):
sid_text = str(sid_raw or "").strip()
if not sid_text or sid_text in server_map:
continue
if (len(sid_text) > 24) or (not sid_text.isdigit()):
continue
sid = int(sid_text)
if sid <= 0:
continue
md5_hit = ""
try:
md5_hit = _lookup_resource_md5(
resource_conn,
None, # do NOT filter by chat_id: merged-forward records come from other chats
0, # do NOT filter by local_type
int(sid),
0,
0,
)
except Exception:
md5_hit = ""
md5_hit = str(md5_hit or "").strip().lower()
if not _is_md5(md5_hit):
continue
if _ensure_chat_history_md5(md5_hit):
server_map[sid_text] = md5_hit
except Exception:
pass
if download_remote_media:
try:
for u in _CHAT_HISTORY_URL_TAG_RE.findall(record_item):
maybe_download_remote_image(u)
except Exception:
pass
lines = get_chat_history_preview_lines(msg)
sent_side_cls = " wechat-special-sent-side" if is_sent else ""
cls = f"wechat-chat-history-card wechat-special-card msg-radius{sent_side_cls} cursor-pointer"
tw.write(
f' <div class="{esc_attr(cls)}" data-wce-chat-history="1" role="button" tabindex="0" '
f'data-title="{esc_attr(title)}" data-record-item-b64="{esc_attr(record_item_b64)}">\n'
)
tw.write(' <div class="wechat-chat-history-body">\n')
tw.write(f' <div class="wechat-chat-history-title">{esc_text(title)}</div>\n')
if lines:
tw.write(' <div class="wechat-chat-history-preview">\n')
for line in lines:
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>\n")
elif rt == "transfer":
received = is_transfer_received(msg)
returned = is_transfer_returned(msg)
overdue = is_transfer_overdue(msg)
side_cls = "wechat-transfer-sent-side" if is_sent else "wechat-transfer-received-side"
cls_parts = ["wechat-transfer-card", "msg-radius", side_cls]
if received:
cls_parts.append("wechat-transfer-received")
if returned:
cls_parts.append("wechat-transfer-returned")
if overdue:
cls_parts.append("wechat-transfer-overdue")
cls = " ".join(cls_parts)
if returned:
icon = "wechat-returned.png"
elif overdue:
icon = "overdue.png"
elif received:
icon = "wechat-trans-icon2.png"
else:
icon = "wechat-trans-icon1.png"
amount = format_transfer_amount(msg.get("amount"))
status = get_transfer_title(msg, is_sent=is_sent)
tw.write(f' <div class="{esc_attr(cls)}">\n')
tw.write(' <div class="wechat-transfer-content">\n')
tw.write(f' <img src="{esc_attr(wechat_icon(icon))}" class="wechat-transfer-icon" alt="" />\n')
tw.write(' <div class="wechat-transfer-info">\n')
if amount:
tw.write(f' <span class="wechat-transfer-amount"{esc_text(amount)}</span>\n')
tw.write(f' <span class="wechat-transfer-status">{esc_text(status)}</span>\n')
tw.write(" </div>\n")
tw.write(" </div>\n")
tw.write(' <div class="wechat-transfer-bottom"><span>微信转账</span></div>\n')
tw.write(" </div>\n")
elif rt == "redPacket":
received = False
cls_parts = ["wechat-redpacket-card", "wechat-special-card", "msg-radius"]
if received:
cls_parts.append("wechat-redpacket-received")
if is_sent:
cls_parts.append("wechat-special-sent-side")
icon = "wechat-trans-icon4.png" if received else "wechat-trans-icon3.png"
tw.write(f' <div class="{esc_attr(" ".join(cls_parts))}">\n')
tw.write(' <div class="wechat-redpacket-content">\n')
tw.write(f' <img src="{esc_attr(wechat_icon(icon))}" class="wechat-redpacket-icon" alt="" />\n')
tw.write(' <div class="wechat-redpacket-info">\n')
tw.write(f' <span class="wechat-redpacket-text">{esc_text(get_red_packet_text(msg))}</span>\n')
if received:
tw.write(' <span class="wechat-redpacket-status">已领取</span>\n')
tw.write(" </div>\n")
tw.write(" </div>\n")
tw.write(' <div class="wechat-redpacket-bottom"><span>微信红包</span></div>\n')
tw.write(" </div>\n")
elif rt == "text":
tw.write(f' <div class="{esc_attr(bubble_base_cls + " " + bubble_dir_cls)}">{render_text_with_emojis(msg.get("content") or "")}</div>\n')
else:
content = str(msg.get("content") or "").strip()
if not content:
content = f"[{str(msg.get('type') or 'unknown')}] 消息"
tw.write(f' <div class="{esc_attr(bubble_unknown_cls + " " + bubble_dir_cls)}">{render_text_with_emojis(content)}</div>\n')
tw.write(" </div>\n")
tw.write(" </div>\n")
tw.write(" </div>\n")
tw.write(" </div>\n")
_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")
tw.write("</div>\n")
try:
media_index_payload = json.dumps(page_media_index, ensure_ascii=False)
except Exception:
media_index_payload = "{}"
media_index_payload = media_index_payload.replace("</", "<\\/")
tw.write(f'<script type="application/json" id="wceMediaIndex">{media_index_payload}</script>\n')
tw.write("</body>\n")
tw.write("</html>\n")
tw.flush()
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
def _format_message_line_txt(*, msg: dict[str, Any]) -> str:
ts = int(msg.get("createTime") or 0)
time_text = _format_ts(ts)
sender_username = str(msg.get("senderUsername") or "").strip()
sender_display = str(msg.get("senderDisplayName") or "").strip()
if sender_display and sender_username:
sender = f"{sender_display}({sender_username})"
else:
sender = sender_display or sender_username or "未知"
avatar_path = str(msg.get("senderAvatarPath") or "").strip()
if avatar_path:
sender = f"{sender} [avatar={avatar_path}]"
rt = str(msg.get("renderType") or "text")
content = str(msg.get("content") or "").strip()
extra = ""
if rt == "link":
title = str(msg.get("title") or "").strip()
url = str(msg.get("url") or "").strip()
extra = f" {title} {url}".strip()
elif rt == "transfer":
amt = str(msg.get("amount") or "").strip()
st = str(msg.get("transferStatus") or "").strip()
extra = f" 金额={amt} 状态={st}".strip()
elif rt == "file":
title = str(msg.get("title") or "").strip()
sz = str(msg.get("fileSize") or "").strip()
extra = f" {title} size={sz}".strip()
media = msg.get("offlineMedia") or []
media_desc = ""
if isinstance(media, list) and media:
paths: list[str] = []
for m in media:
try:
p = str(m.get("path") or "").strip()
except Exception:
p = ""
if p:
paths.append(p)
if paths:
media_desc = " " + " ".join(paths)
if rt == "system":
return f"[{time_text}] [系统] {content}".rstrip()
return f"[{time_text}] {sender}: {content}{extra}{media_desc}".rstrip()
def _privacy_scrub_message(
msg: dict[str, Any],
*,
conv_is_group: bool,
sender_alias_map: dict[str, int],
) -> None:
sender_username = str(msg.get("senderUsername") or "").strip()
is_sent = bool(msg.get("isSent"))
if is_sent:
alias = ""
pseudo_username = "me"
else:
if not conv_is_group:
alias = "对方"
pseudo_username = "other"
else:
idx = sender_alias_map.get(sender_username)
if idx is None:
idx = len(sender_alias_map) + 1
sender_alias_map[sender_username] = idx
alias = f"成员#{idx}"
pseudo_username = f"member_{idx}"
rt = str(msg.get("renderType") or "text").strip() or "text"
content_map = {
"text": "[文本]",
"system": "[系统消息]",
"image": "[图片]",
"emoji": "[表情]",
"video": "[视频]",
"voice": "[语音]",
"link": "[链接]",
"file": "[文件]",
"transfer": "[转账]",
"redPacket": "[红包]",
"quote": "[引用消息]",
"voip": "[通话]",
}
msg["content"] = content_map.get(rt, f"[{rt}]")
msg["senderDisplayName"] = alias
msg["senderUsername"] = pseudo_username
msg["senderAvatarPath"] = ""
msg["conversationUsername"] = ""
# Remove potentially sensitive payload fields.
for k in (
"title",
"url",
"from",
"fromUsername",
"linkType",
"linkStyle",
"thumbUrl",
"recordItem",
"imageMd5",
"imageFileId",
"imageMd5Candidates",
"imageFileIdCandidates",
"imageUrl",
"emojiMd5",
"emojiUrl",
"videoMd5",
"videoThumbMd5",
"videoFileId",
"videoThumbFileId",
"videoUrl",
"videoThumbUrl",
"voiceLength",
"quoteUsername",
"quoteServerId",
"quoteType",
"quoteThumbUrl",
"quoteVoiceLength",
"quoteTitle",
"quoteContent",
"amount",
"coverUrl",
"fileSize",
"fileMd5",
"paySubType",
"transferStatus",
"transferId",
"voipType",
):
if k in msg:
msg[k] = ""
msg.pop("offlineMedia", None)
def _attach_offline_media(
*,
zf: zipfile.ZipFile,
account_dir: Path,
conv_username: str,
msg: dict[str, Any],
media_written: dict[str, str],
report: dict[str, Any],
media_kinds: list[MediaKind],
allow_process_key_extract: bool,
media_db_path: Path,
lock: threading.Lock,
job: ExportJob,
) -> None:
# allow_process_key_extract is reserved; this project does not extract keys from process (use wx_key instead).
_ = allow_process_key_extract
rt = str(msg.get("renderType") or "")
def record_missing(kind: str, ident: str) -> None:
with lock:
job.progress.media_missing += 1
try:
report["missingMedia"].append(
{
"kind": kind,
"id": ident,
"conversation": conv_username,
"messageId": msg.get("id"),
}
)
except Exception:
pass
offline: list[dict[str, Any]] = []
if rt == "image" and "image" in media_kinds:
primary_md5 = str(msg.get("imageMd5") or "").strip().lower()
primary_file_id = str(msg.get("imageFileId") or "").strip()
md5_candidates_raw = msg.get("imageMd5Candidates") or []
file_id_candidates_raw = msg.get("imageFileIdCandidates") or []
md5_candidates = md5_candidates_raw if isinstance(md5_candidates_raw, list) else []
file_id_candidates = file_id_candidates_raw if isinstance(file_id_candidates_raw, list) else []
md5s: list[str] = []
file_ids: list[str] = []
def add_md5(v: Any) -> None:
s = str(v or "").strip().lower()
if _is_md5(s) and s not in md5s:
md5s.append(s)
def add_file_id(v: Any) -> None:
s = str(v or "").strip()
if s and s not in file_ids:
file_ids.append(s)
add_md5(primary_md5)
for v in md5_candidates:
add_md5(v)
add_file_id(primary_file_id)
for v in file_id_candidates:
add_file_id(v)
arc = ""
is_new = False
used_md5 = ""
used_file_id = ""
# Prefer md5-based resolution first (more reliable), then fall back to file_id search.
for md5 in md5s:
arc, is_new = _materialize_media(
zf=zf,
account_dir=account_dir,
conv_username=conv_username,
kind="image",
md5=md5,
file_id="",
media_written=media_written,
suggested_name="",
)
if arc:
used_md5 = md5
break
if not arc:
for file_id in file_ids:
arc, is_new = _materialize_media(
zf=zf,
account_dir=account_dir,
conv_username=conv_username,
kind="image",
md5="",
file_id=file_id,
media_written=media_written,
suggested_name="",
)
if arc:
used_file_id = file_id
break
if arc:
# Keep primary fields in sync with what actually resolved.
try:
if used_md5:
msg["imageMd5"] = used_md5
if used_file_id:
msg["imageFileId"] = used_file_id
except Exception:
pass
offline.append({"kind": "image", "path": arc, "md5": used_md5 or primary_md5, "fileId": used_file_id or primary_file_id})
if is_new:
with lock:
job.progress.media_copied += 1
else:
record_missing("image", primary_md5 or primary_file_id)
if rt == "emoji" and "emoji" in media_kinds:
md5 = str(msg.get("emojiMd5") or "").strip().lower()
arc, is_new = _materialize_media(
zf=zf,
account_dir=account_dir,
conv_username=conv_username,
kind="emoji",
md5=md5 if _is_md5(md5) else "",
file_id="",
media_written=media_written,
suggested_name="",
)
if arc:
offline.append({"kind": "emoji", "path": arc, "md5": md5})
if is_new:
with lock:
job.progress.media_copied += 1
else:
record_missing("emoji", md5)
if rt == "video":
if "video_thumb" in media_kinds:
md5 = str(msg.get("videoThumbMd5") or "").strip().lower()
file_id = str(msg.get("videoThumbFileId") or "").strip()
arc, is_new = _materialize_media(
zf=zf,
account_dir=account_dir,
conv_username=conv_username,
kind="video_thumb",
md5=md5 if _is_md5(md5) else "",
file_id=file_id,
media_written=media_written,
suggested_name="",
)
if arc:
offline.append({"kind": "video_thumb", "path": arc, "md5": md5, "fileId": file_id})
if is_new:
with lock:
job.progress.media_copied += 1
else:
record_missing("video_thumb", md5 or file_id)
if "video" in media_kinds:
md5 = str(msg.get("videoMd5") or "").strip().lower()
file_id = str(msg.get("videoFileId") or "").strip()
arc, is_new = _materialize_media(
zf=zf,
account_dir=account_dir,
conv_username=conv_username,
kind="video",
md5=md5 if _is_md5(md5) else "",
file_id=file_id,
media_written=media_written,
suggested_name="",
)
if arc:
offline.append({"kind": "video", "path": arc, "md5": md5, "fileId": file_id})
if is_new:
with lock:
job.progress.media_copied += 1
else:
record_missing("video", md5 or file_id)
if rt == "voice" and "voice" in media_kinds:
server_id = int(msg.get("serverId") or 0)
if server_id > 0:
arc, is_new = _materialize_voice(
zf=zf,
media_db_path=media_db_path,
server_id=server_id,
media_written=media_written,
)
if arc:
offline.append({"kind": "voice", "path": arc, "serverId": server_id})
if is_new:
with lock:
job.progress.media_copied += 1
else:
record_missing("voice", str(server_id))
if rt == "file" and "file" in media_kinds:
md5 = str(msg.get("fileMd5") or "").strip().lower()
arc, is_new = _materialize_media(
zf=zf,
account_dir=account_dir,
conv_username=conv_username,
kind="file",
md5=md5 if _is_md5(md5) else "",
file_id="",
media_written=media_written,
suggested_name=str(msg.get("title") or "").strip(),
)
if arc:
offline.append({"kind": "file", "path": arc, "md5": md5, "title": str(msg.get("title") or "").strip()})
if is_new:
with lock:
job.progress.media_copied += 1
else:
record_missing("file", md5)
if offline:
msg["offlineMedia"] = offline
def _materialize_avatar(
*,
zf: zipfile.ZipFile,
head_image_conn: Optional[sqlite3.Connection],
username: str,
avatar_written: dict[str, str],
) -> str:
u = str(username or "").strip()
if not u or head_image_conn is None:
return ""
key = f"avatar:{u}"
if key in avatar_written:
return avatar_written[key]
try:
row = head_image_conn.execute(
"SELECT image_buffer FROM head_image WHERE username = ? ORDER BY update_time DESC LIMIT 1",
(u,),
).fetchone()
except Exception:
row = None
if not row or row[0] is None:
avatar_written[key] = ""
return ""
data = bytes(row[0]) if isinstance(row[0], (memoryview, bytearray)) else row[0]
if not isinstance(data, (bytes, bytearray)):
data = bytes(data)
if not data:
avatar_written[key] = ""
return ""
mt = _detect_image_media_type(data[:32])
ext = "dat"
if mt == "image/png":
ext = "png"
elif mt == "image/jpeg":
ext = "jpg"
elif mt == "image/gif":
ext = "gif"
elif mt == "image/webp":
ext = "webp"
safe = _safe_name(u, max_len=50) or "avatar"
h = uuid.uuid5(uuid.NAMESPACE_DNS, u).hex[:8]
arc = f"media/avatars/{safe}_{h}.{ext}"
if len(arc) > 220:
arc = f"media/avatars/avatar_{h}.{ext}"
try:
zf.writestr(arc, data)
except Exception:
avatar_written[key] = ""
return ""
avatar_written[key] = arc
return arc
def _materialize_voice(
*,
zf: zipfile.ZipFile,
media_db_path: Path,
server_id: int,
media_written: dict[str, str],
) -> tuple[str, bool]:
key = f"voice:{int(server_id)}"
existing = media_written.get(key)
if existing:
return existing, False
if not media_db_path.exists():
return "", False
conn = sqlite3.connect(str(media_db_path))
try:
row = conn.execute(
"SELECT voice_data FROM VoiceInfo WHERE svr_id = ? ORDER BY create_time DESC LIMIT 1",
(int(server_id),),
).fetchone()
except Exception:
row = None
finally:
conn.close()
if not row or row[0] is None:
return "", False
data = bytes(row[0]) if isinstance(row[0], (memoryview, bytearray)) else row[0]
if not isinstance(data, (bytes, bytearray)):
data = bytes(data)
payload, ext, _media_type = _convert_silk_to_browser_audio(data, preferred_format="mp3")
if not payload:
return "", False
arc = f"media/voices/voice_{int(server_id)}.{ext}"
zf.writestr(arc, payload)
media_written[key] = arc
return arc, True
def _materialize_media(
*,
zf: zipfile.ZipFile,
account_dir: Path,
conv_username: str,
kind: MediaKind,
md5: str,
file_id: str,
media_written: dict[str, str],
suggested_name: str,
) -> tuple[str, bool]:
ident = md5 or file_id
if not ident:
return "", False
key = f"{kind}:{ident}"
existing = media_written.get(key)
if existing:
return existing, False
src: Optional[Path] = None
if md5 and _is_md5(md5):
try:
src = _try_find_decrypted_resource(account_dir, md5)
except Exception:
src = None
if src is None:
try:
src = _resolve_media_path_for_kind(account_dir, kind=kind, md5=md5, username=conv_username)
except Exception:
src = None
if src is None and file_id:
try:
wxid_dir = _resolve_account_wxid_dir(account_dir)
db_storage_dir = _resolve_account_db_storage_dir(account_dir)
for r in [wxid_dir, db_storage_dir]:
if not r:
continue
hit = _fallback_search_media_by_file_id(
str(r),
str(file_id),
kind=str(kind),
username=str(conv_username or ""),
)
if hit:
src = Path(hit)
break
except Exception:
src = None
if not src:
return "", False
try:
if not src.exists() or (not src.is_file()):
return "", False
except Exception:
return "", False
try:
with open(src, "rb") as f:
head = f.read(64)
except Exception:
head = b""
head_mt = _detect_image_media_type(head[:32])
looks_like_mp4 = len(head) >= 8 and head[4:8] == b"ftyp"
ext = src.suffix.lstrip(".").lower()
if not ext:
if head_mt.startswith("image/"):
ext = head_mt.split("/", 1)[-1]
elif looks_like_mp4:
ext = "mp4"
else:
ext = "dat"
if ext == "jpeg":
ext = "jpg"
folder = "misc"
if kind == "image":
folder = "images"
elif kind == "emoji":
folder = "emojis"
elif kind == "video":
folder = "videos"
elif kind == "video_thumb":
folder = "video_thumbs"
elif kind == "file":
folder = "files"
nice = _safe_name(suggested_name, max_len=60)
if nice and kind == "file":
arc_name = f"{nice}_{ident}.{ext}" if ext else f"{nice}_{ident}"
else:
arc_name = f"{ident}.{ext}" if ext else ident
if len(arc_name) > 160:
arc_name = arc_name[:160]
arc = f"media/{folder}/{arc_name}"
should_stream_copy = False
if kind == "file":
should_stream_copy = True
elif kind in {"image", "emoji", "video_thumb"}:
should_stream_copy = (
(ext == "jpg" and head_mt == "image/jpeg")
or (ext == "png" and head_mt == "image/png")
or (ext == "gif" and head_mt == "image/gif")
or (ext == "webp" and head_mt == "image/webp")
)
elif kind == "video":
should_stream_copy = ext == "mp4" and looks_like_mp4
if should_stream_copy or (kind not in {"image", "emoji", "video", "video_thumb"}):
try:
zf.write(src, arcname=arc)
except Exception:
return "", False
else:
try:
data, mt = _read_and_maybe_decrypt_media(src, account_dir=account_dir)
except Exception:
try:
zf.write(src, arcname=arc)
except Exception:
return "", False
media_written[key] = arc
return arc, True
mt = str(mt or "").strip()
if mt == "image/png":
ext2 = "png"
elif mt == "image/jpeg":
ext2 = "jpg"
elif mt == "image/gif":
ext2 = "gif"
elif mt == "image/webp":
ext2 = "webp"
elif mt == "video/mp4":
ext2 = "mp4"
else:
ext2 = "dat" if mt == "application/octet-stream" else (ext or "dat")
if ext2 != ext:
if nice and kind == "file":
arc_name = f"{nice}_{ident}.{ext2}" if ext2 else f"{nice}_{ident}"
else:
arc_name = f"{ident}.{ext2}" if ext2 else ident
if len(arc_name) > 160:
arc_name = arc_name[:160]
arc = f"media/{folder}/{arc_name}"
try:
zf.writestr(arc, data)
except Exception:
return "", False
media_written[key] = arc
return arc, True
CHAT_EXPORT_MANAGER = ChatExportManager()