mirror of
https://github.com/LifeArchiveProject/WeChatDataAnalysis.git
synced 2026-02-20 06:40:49 +08:00
improvement(chat): 优化导出筛选与目录选择体验
This commit is contained in:
@@ -611,6 +611,25 @@ function registerWindowIpc() {
|
||||
return getCloseBehavior();
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("dialog:chooseDirectory", async (_event, options) => {
|
||||
try {
|
||||
const result = await dialog.showOpenDialog({
|
||||
title: String(options?.title || "选择文件夹"),
|
||||
properties: ["openDirectory", "createDirectory"],
|
||||
});
|
||||
return {
|
||||
canceled: !!result?.canceled,
|
||||
filePaths: Array.isArray(result?.filePaths) ? result.filePaths : [],
|
||||
};
|
||||
} catch (err) {
|
||||
logMain(`[main] dialog:chooseDirectory failed: ${err?.message || err}`);
|
||||
return {
|
||||
canceled: true,
|
||||
filePaths: [],
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
|
||||
@@ -11,4 +11,6 @@ contextBridge.exposeInMainWorld("wechatDesktop", {
|
||||
|
||||
getCloseBehavior: () => ipcRenderer.invoke("app:getCloseBehavior"),
|
||||
setCloseBehavior: (behavior) => ipcRenderer.invoke("app:setCloseBehavior", String(behavior || "")),
|
||||
|
||||
chooseDirectory: (options = {}) => ipcRenderer.invoke("dialog:chooseDirectory", options),
|
||||
});
|
||||
|
||||
@@ -730,35 +730,39 @@
|
||||
}
|
||||
|
||||
.header-btn {
|
||||
@apply flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-lg bg-white border border-gray-200 text-gray-700 transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed;
|
||||
@apply flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-md bg-white border border-gray-200 text-gray-700 transition-all duration-150 disabled:opacity-50 disabled:cursor-not-allowed shadow-sm;
|
||||
}
|
||||
|
||||
.header-btn:hover:not(:disabled) {
|
||||
@apply bg-gray-50 border-gray-300;
|
||||
@apply bg-gray-50 border-gray-300 shadow;
|
||||
}
|
||||
|
||||
.header-btn:active:not(:disabled) {
|
||||
@apply bg-gray-100;
|
||||
@apply bg-gray-100 scale-95;
|
||||
}
|
||||
|
||||
.header-btn svg {
|
||||
@apply w-3.5 h-3.5;
|
||||
}
|
||||
|
||||
.header-btn-icon {
|
||||
@apply w-8 h-8 flex items-center justify-center rounded-lg bg-white border border-gray-200 text-gray-600 transition-all duration-200;
|
||||
@apply w-8 h-8 flex items-center justify-center rounded-lg bg-transparent border border-transparent text-gray-600 transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed;
|
||||
}
|
||||
|
||||
.header-btn-icon:hover {
|
||||
@apply bg-gray-50 border-gray-300 text-gray-800;
|
||||
@apply bg-transparent border-transparent text-gray-800;
|
||||
}
|
||||
|
||||
.header-btn-icon-active {
|
||||
@apply bg-[#03C160]/10 border-[#03C160] text-[#03C160];
|
||||
@apply bg-transparent border-transparent text-[#03C160];
|
||||
}
|
||||
|
||||
.header-btn-icon-active:hover {
|
||||
@apply bg-[#03C160]/15;
|
||||
@apply bg-transparent;
|
||||
}
|
||||
|
||||
.message-filter-select {
|
||||
@apply text-xs px-2 py-1.5 rounded-lg bg-white border border-gray-200 text-gray-700 focus:outline-none focus:ring-2 focus:ring-[#03C160]/20 focus:border-[#03C160] transition-all disabled:opacity-50 disabled:cursor-not-allowed;
|
||||
@apply text-xs px-2 py-1.5 rounded-lg bg-transparent border-0 text-gray-700 focus:outline-none focus:ring-0 transition-all disabled:opacity-50 disabled:cursor-not-allowed;
|
||||
}
|
||||
|
||||
/* 搜索侧边栏样式 */
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -74,6 +74,25 @@ def _safe_name(s: str, max_len: int = 80) -> str:
|
||||
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 _format_ts(ts: int) -> str:
|
||||
if not ts:
|
||||
return ""
|
||||
@@ -99,43 +118,54 @@ def _normalize_render_type_key(value: Any) -> str:
|
||||
return lower
|
||||
|
||||
|
||||
def _render_types_to_local_types(render_types: set[str]) -> Optional[set[int]]:
|
||||
rt = {str(x or "").strip() for x in (render_types or set())}
|
||||
rt = {x for x in rt if x}
|
||||
if not rt:
|
||||
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[int] = set()
|
||||
for k in rt:
|
||||
if k == "text":
|
||||
out.add(1)
|
||||
elif k == "image":
|
||||
out.add(3)
|
||||
elif k == "voice":
|
||||
out.add(34)
|
||||
elif k == "video":
|
||||
out.update({43, 62})
|
||||
elif k == "emoji":
|
||||
out.add(47)
|
||||
elif k == "voip":
|
||||
out.add(50)
|
||||
elif k == "system":
|
||||
out.update({10000, 266287972401})
|
||||
elif k == "quote":
|
||||
out.add(244813135921)
|
||||
out.add(49) # Some quote messages are embedded as appmsg (local_type=49).
|
||||
elif k in {"link", "file", "transfer", "redpacket"}:
|
||||
out.add(49)
|
||||
else:
|
||||
# Unknown type: cannot safely prefilter by local_type.
|
||||
return None
|
||||
out: set[MediaKind] = set()
|
||||
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 _should_estimate_by_local_type(render_types: set[str]) -> bool:
|
||||
# Only estimate counts when every requested type maps 1:1 to local_type.
|
||||
# App messages (local_type=49) are heterogeneous and cannot be counted accurately without parsing.
|
||||
return not bool(render_types & {"link", "file", "transfer", "redpacket", "quote"})
|
||||
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
|
||||
@@ -235,6 +265,7 @@ class ChatExportManager:
|
||||
include_media: bool,
|
||||
media_kinds: list[MediaKind],
|
||||
message_types: list[str],
|
||||
output_dir: Optional[str],
|
||||
allow_process_key_extract: bool,
|
||||
privacy_mode: bool,
|
||||
file_name: Optional[str],
|
||||
@@ -257,6 +288,7 @@ class ChatExportManager:
|
||||
"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),
|
||||
"privacyMode": bool(privacy_mode),
|
||||
"fileName": str(file_name or "").strip(),
|
||||
@@ -313,10 +345,6 @@ class ChatExportManager:
|
||||
if ks in {"image", "emoji", "video", "video_thumb", "voice", "file"}:
|
||||
media_kinds.append(ks) # type: ignore[arg-type]
|
||||
|
||||
if privacy_mode:
|
||||
include_media = False
|
||||
media_kinds = []
|
||||
|
||||
st = int(opts.get("startTime") or 0) or None
|
||||
et = int(opts.get("endTime") or 0) or None
|
||||
|
||||
@@ -328,9 +356,15 @@ class ChatExportManager:
|
||||
if want:
|
||||
want_types = want
|
||||
|
||||
local_types = _render_types_to_local_types(want_types) if want_types else None
|
||||
can_estimate = (want_types is None) or _should_estimate_by_local_type(want_types)
|
||||
estimate_local_types = local_types if (want_types and can_estimate) else None
|
||||
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,
|
||||
@@ -342,8 +376,7 @@ class ChatExportManager:
|
||||
if not target_usernames:
|
||||
raise ValueError("No target conversations to export.")
|
||||
|
||||
exports_root = account_dir.parents[1] / "exports" / account_dir.name
|
||||
exports_root.mkdir(parents=True, exist_ok=True)
|
||||
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()
|
||||
@@ -456,9 +489,6 @@ class ChatExportManager:
|
||||
job.progress.current_conversation_messages_total = 0
|
||||
|
||||
try:
|
||||
if not can_estimate:
|
||||
estimated_total = 0
|
||||
else:
|
||||
estimated_total = _estimate_conversation_message_count(
|
||||
account_dir=account_dir,
|
||||
conv_username=conv_username,
|
||||
@@ -557,6 +587,8 @@ class ChatExportManager:
|
||||
zf.writestr(f"{conv_dir}/meta.json", json.dumps(meta, ensure_ascii=False, indent=2))
|
||||
|
||||
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
|
||||
|
||||
manifest = {
|
||||
@@ -1325,11 +1357,7 @@ def _write_conversation_json(
|
||||
resource_chat_id=resource_chat_id,
|
||||
sender_alias=sender_alias,
|
||||
)
|
||||
if want_types:
|
||||
rt_key = _normalize_render_type_key(msg.get("renderType"))
|
||||
if rt_key not in want_types:
|
||||
if scanned % 500 == 0 and job.cancel_requested:
|
||||
raise _JobCancelled()
|
||||
if not _is_render_type_selected(msg.get("renderType"), want_types):
|
||||
continue
|
||||
|
||||
su = str(msg.get("senderUsername") or "").strip()
|
||||
@@ -1506,11 +1534,7 @@ def _write_conversation_txt(
|
||||
resource_chat_id=resource_chat_id,
|
||||
sender_alias=sender_alias,
|
||||
)
|
||||
if want_types:
|
||||
rt_key = _normalize_render_type_key(msg.get("renderType"))
|
||||
if rt_key not in want_types:
|
||||
if scanned % 500 == 0 and job.cancel_requested:
|
||||
raise _JobCancelled()
|
||||
if not _is_render_type_selected(msg.get("renderType"), want_types):
|
||||
continue
|
||||
|
||||
su = str(msg.get("senderUsername") or "").strip()
|
||||
|
||||
@@ -27,15 +27,16 @@ class ChatExportCreateRequest(BaseModel):
|
||||
end_time: Optional[int] = Field(None, description="结束时间(Unix 秒,含)")
|
||||
include_hidden: bool = Field(False, description="是否包含隐藏会话(scope!=selected 时)")
|
||||
include_official: bool = Field(False, description="是否包含公众号/官方账号会话(scope!=selected 时)")
|
||||
include_media: bool = Field(True, description="是否打包离线媒体(图片/表情/视频/语音/文件)")
|
||||
include_media: bool = Field(True, description="是否允许打包离线媒体(最终仍受 message_types 与 privacy_mode 约束)")
|
||||
media_kinds: list[MediaKind] = Field(
|
||||
default_factory=lambda: ["image", "emoji", "video", "video_thumb", "voice", "file"],
|
||||
description="打包的媒体类型",
|
||||
description="允许打包的媒体类型(最终仍受 message_types 勾选约束)",
|
||||
)
|
||||
message_types: list[MessageType] = Field(
|
||||
default_factory=list,
|
||||
description="导出消息类型(renderType)过滤:为空=导出全部消息;可多选(如仅 voice / 仅 transfer / 仅 redPacket 等)",
|
||||
description="导出消息类型(renderType)过滤:为空=导出全部类型;不为空时,仅导出勾选类型",
|
||||
)
|
||||
output_dir: Optional[str] = Field(None, description="导出目录绝对路径(可选;不填时使用默认目录)")
|
||||
allow_process_key_extract: bool = Field(
|
||||
False,
|
||||
description="预留字段:本项目不从微信进程提取媒体密钥,请使用 wx_key 获取并保存/批量解密",
|
||||
@@ -61,6 +62,7 @@ async def create_chat_export(req: ChatExportCreateRequest):
|
||||
include_media=req.include_media,
|
||||
media_kinds=req.media_kinds,
|
||||
message_types=req.message_types,
|
||||
output_dir=req.output_dir,
|
||||
allow_process_key_extract=req.allow_process_key_extract,
|
||||
privacy_mode=req.privacy_mode,
|
||||
file_name=req.file_name,
|
||||
|
||||
418
tests/test_chat_export_message_types_semantics.py
Normal file
418
tests/test_chat_export_message_types_semantics.py
Normal file
@@ -0,0 +1,418 @@
|
||||
import os
|
||||
import json
|
||||
import hashlib
|
||||
import sqlite3
|
||||
import sys
|
||||
import unittest
|
||||
import zipfile
|
||||
import importlib
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
|
||||
|
||||
class TestChatExportMessageTypesSemantics(unittest.TestCase):
|
||||
def _reload_export_modules(self):
|
||||
import wechat_decrypt_tool.app_paths as app_paths
|
||||
import wechat_decrypt_tool.chat_helpers as chat_helpers
|
||||
import wechat_decrypt_tool.media_helpers as media_helpers
|
||||
import wechat_decrypt_tool.chat_export_service as chat_export_service
|
||||
|
||||
importlib.reload(app_paths)
|
||||
importlib.reload(chat_helpers)
|
||||
importlib.reload(media_helpers)
|
||||
importlib.reload(chat_export_service)
|
||||
return chat_export_service
|
||||
|
||||
def _seed_contact_db(self, path: Path, *, account: str, username: str) -> None:
|
||||
conn = sqlite3.connect(str(path))
|
||||
try:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE contact (
|
||||
username TEXT,
|
||||
remark TEXT,
|
||||
nick_name TEXT,
|
||||
alias TEXT,
|
||||
local_type INTEGER,
|
||||
verify_flag INTEGER,
|
||||
big_head_url TEXT,
|
||||
small_head_url TEXT
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE stranger (
|
||||
username TEXT,
|
||||
remark TEXT,
|
||||
nick_name TEXT,
|
||||
alias TEXT,
|
||||
local_type INTEGER,
|
||||
verify_flag INTEGER,
|
||||
big_head_url TEXT,
|
||||
small_head_url TEXT
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO contact VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(account, "", "我", "", 1, 0, "", ""),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO contact VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(username, "", "测试好友", "", 1, 0, "", ""),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _seed_session_db(self, path: Path, *, username: str) -> None:
|
||||
conn = sqlite3.connect(str(path))
|
||||
try:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE SessionTable (
|
||||
username TEXT,
|
||||
is_hidden INTEGER,
|
||||
sort_timestamp INTEGER
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO SessionTable VALUES (?, ?, ?)",
|
||||
(username, 0, 1735689600),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _seed_message_db(self, path: Path, *, account: str, username: str) -> None:
|
||||
conn = sqlite3.connect(str(path))
|
||||
try:
|
||||
conn.execute("CREATE TABLE Name2Id (rowid INTEGER PRIMARY KEY, user_name TEXT)")
|
||||
conn.execute("INSERT INTO Name2Id(rowid, user_name) VALUES (?, ?)", (1, account))
|
||||
conn.execute("INSERT INTO Name2Id(rowid, user_name) VALUES (?, ?)", (2, username))
|
||||
|
||||
table_name = f"msg_{hashlib.md5(username.encode('utf-8')).hexdigest()}"
|
||||
conn.execute(
|
||||
f"""
|
||||
CREATE TABLE {table_name} (
|
||||
local_id INTEGER,
|
||||
server_id INTEGER,
|
||||
local_type INTEGER,
|
||||
sort_seq INTEGER,
|
||||
real_sender_id INTEGER,
|
||||
create_time INTEGER,
|
||||
message_content TEXT,
|
||||
compress_content BLOB
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
image_xml = '<msg><img md5="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" cdnthumburl="img_file_id_1" /></msg>'
|
||||
video_xml = '<msg><videomsg md5="bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" cdnthumbmd5="cccccccccccccccccccccccccccccccc" cdnvideourl="video_file_id_1" cdnthumburl="video_thumb_id_1" /></msg>'
|
||||
|
||||
rows = [
|
||||
(1, 1001, 3, 1, 2, 1735689601, image_xml, None),
|
||||
(2, 1002, 43, 2, 2, 1735689602, video_xml, None),
|
||||
(3, 1003, 49, 3, 2, 1735689603, '<msg><appmsg><type>2000</type><des>收到转账0.01元</des></appmsg></msg>', None),
|
||||
(4, 1004, 1, 4, 2, 1735689604, '普通文本消息', None),
|
||||
(5, 1005, 10000, 5, 2, 1735689605, '系统提示消息', None),
|
||||
]
|
||||
conn.executemany(
|
||||
f"INSERT INTO {table_name} (local_id, server_id, local_type, sort_seq, real_sender_id, create_time, message_content, compress_content) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
rows,
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _seed_media_files(self, account_dir: Path) -> None:
|
||||
resource_root = account_dir / "resource"
|
||||
(resource_root / "aa").mkdir(parents=True, exist_ok=True)
|
||||
(resource_root / "bb").mkdir(parents=True, exist_ok=True)
|
||||
(resource_root / "cc").mkdir(parents=True, exist_ok=True)
|
||||
|
||||
(resource_root / "aa" / "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.jpg").write_bytes(b"\xff\xd8\xff\xd9")
|
||||
(resource_root / "bb" / "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.mp4").write_bytes(b"video-bytes")
|
||||
(resource_root / "cc" / "cccccccccccccccccccccccccccccccc.jpg").write_bytes(b"\xff\xd8\xff\xd9")
|
||||
|
||||
def _seed_source_info(self, account_dir: Path, wxid_dir: Path) -> None:
|
||||
payload = {
|
||||
"wxid_dir": str(wxid_dir),
|
||||
"db_storage_path": str(wxid_dir / "db_storage"),
|
||||
}
|
||||
(account_dir / "_source.json").write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
|
||||
|
||||
def _seed_wxid_media_files(self, wxid_dir: Path) -> None:
|
||||
(wxid_dir / "msg" / "video").mkdir(parents=True, exist_ok=True)
|
||||
(wxid_dir / "msg" / "attach").mkdir(parents=True, exist_ok=True)
|
||||
(wxid_dir / "cache").mkdir(parents=True, exist_ok=True)
|
||||
(wxid_dir / "db_storage").mkdir(parents=True, exist_ok=True)
|
||||
|
||||
(wxid_dir / "msg" / "video" / "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.mp4").write_bytes(b"video-bytes")
|
||||
(wxid_dir / "msg" / "video" / "cccccccccccccccccccccccccccccccc.jpg").write_bytes(b"\xff\xd8\xff\xd9")
|
||||
|
||||
def _prepare_account(self, root: Path, *, account: str, username: str) -> Path:
|
||||
account_dir = root / "output" / "databases" / account
|
||||
account_dir.mkdir(parents=True, exist_ok=True)
|
||||
wxid_dir = root / "wxid_data" / account
|
||||
|
||||
self._seed_contact_db(account_dir / "contact.db", account=account, username=username)
|
||||
self._seed_session_db(account_dir / "session.db", username=username)
|
||||
self._seed_message_db(account_dir / "message_0.db", account=account, username=username)
|
||||
self._seed_media_files(account_dir)
|
||||
self._seed_wxid_media_files(wxid_dir)
|
||||
self._seed_source_info(account_dir, wxid_dir)
|
||||
return account_dir
|
||||
|
||||
def _create_job(self, manager, *, account: str, username: str, message_types, include_media=True, media_kinds=None, privacy_mode=False):
|
||||
if media_kinds is None:
|
||||
media_kinds = ["image", "emoji", "video", "video_thumb", "voice", "file"]
|
||||
|
||||
job = manager.create_job(
|
||||
account=account,
|
||||
scope="selected",
|
||||
usernames=[username],
|
||||
export_format="json",
|
||||
start_time=None,
|
||||
end_time=None,
|
||||
include_hidden=False,
|
||||
include_official=False,
|
||||
include_media=include_media,
|
||||
media_kinds=media_kinds,
|
||||
message_types=message_types,
|
||||
output_dir=None,
|
||||
allow_process_key_extract=False,
|
||||
privacy_mode=privacy_mode,
|
||||
file_name=None,
|
||||
)
|
||||
|
||||
for _ in range(200):
|
||||
latest = manager.get_job(job.export_id)
|
||||
if latest and latest.status in {"done", "error", "cancelled"}:
|
||||
return latest
|
||||
import time as _time
|
||||
|
||||
_time.sleep(0.05)
|
||||
self.fail("export job did not finish in time")
|
||||
|
||||
def _load_export_payload(self, zip_path: Path):
|
||||
self.assertTrue(zip_path.exists())
|
||||
with zipfile.ZipFile(zip_path, "r") as zf:
|
||||
names = set(zf.namelist())
|
||||
msg_path = next((n for n in names if n.endswith("/messages.json")), "")
|
||||
self.assertTrue(msg_path)
|
||||
import json as _json
|
||||
|
||||
payload = _json.loads(zf.read(msg_path).decode("utf-8"))
|
||||
manifest = _json.loads(zf.read("manifest.json").decode("utf-8"))
|
||||
return payload, manifest, names
|
||||
|
||||
def test_unchecked_image_is_filtered_out(self):
|
||||
with TemporaryDirectory() as td:
|
||||
root = Path(td)
|
||||
account = "wxid_test"
|
||||
username = "wxid_friend"
|
||||
self._prepare_account(root, account=account, username=username)
|
||||
|
||||
prev_data = os.environ.get("WECHAT_TOOL_DATA_DIR")
|
||||
try:
|
||||
os.environ["WECHAT_TOOL_DATA_DIR"] = str(root)
|
||||
svc = self._reload_export_modules()
|
||||
job = self._create_job(
|
||||
svc.CHAT_EXPORT_MANAGER,
|
||||
account=account,
|
||||
username=username,
|
||||
message_types=["text", "transfer"],
|
||||
include_media=True,
|
||||
)
|
||||
self.assertEqual(job.status, "done", msg=job.error)
|
||||
|
||||
payload, _, names = self._load_export_payload(job.zip_path)
|
||||
image_msg = next((m for m in payload.get("messages", []) if int(m.get("type") or 0) == 3), None)
|
||||
self.assertIsNone(image_msg)
|
||||
render_types = {str(m.get("renderType") or "") for m in payload.get("messages", [])}
|
||||
self.assertTrue(render_types.issubset({"text", "transfer"}))
|
||||
self.assertFalse(any(n.startswith("media/images/") for n in names))
|
||||
finally:
|
||||
if prev_data is None:
|
||||
os.environ.pop("WECHAT_TOOL_DATA_DIR", None)
|
||||
else:
|
||||
os.environ["WECHAT_TOOL_DATA_DIR"] = prev_data
|
||||
|
||||
def test_checked_image_exports_media_file(self):
|
||||
with TemporaryDirectory() as td:
|
||||
root = Path(td)
|
||||
account = "wxid_test"
|
||||
username = "wxid_friend"
|
||||
self._prepare_account(root, account=account, username=username)
|
||||
|
||||
prev_data = os.environ.get("WECHAT_TOOL_DATA_DIR")
|
||||
try:
|
||||
os.environ["WECHAT_TOOL_DATA_DIR"] = str(root)
|
||||
svc = self._reload_export_modules()
|
||||
job = self._create_job(
|
||||
svc.CHAT_EXPORT_MANAGER,
|
||||
account=account,
|
||||
username=username,
|
||||
message_types=["image", "text"],
|
||||
include_media=True,
|
||||
)
|
||||
self.assertEqual(job.status, "done", msg=job.error)
|
||||
|
||||
payload, _, names = self._load_export_payload(job.zip_path)
|
||||
image_msg = next((m for m in payload.get("messages", []) if int(m.get("type") or 0) == 3), None)
|
||||
self.assertIsNotNone(image_msg)
|
||||
self.assertEqual(str(image_msg.get("renderType") or ""), "image")
|
||||
self.assertTrue(isinstance(image_msg.get("offlineMedia"), list) and image_msg.get("offlineMedia"))
|
||||
self.assertTrue(any(n.startswith("media/images/") for n in names))
|
||||
finally:
|
||||
if prev_data is None:
|
||||
os.environ.pop("WECHAT_TOOL_DATA_DIR", None)
|
||||
else:
|
||||
os.environ["WECHAT_TOOL_DATA_DIR"] = prev_data
|
||||
|
||||
def test_unchecked_non_media_type_is_filtered_out(self):
|
||||
with TemporaryDirectory() as td:
|
||||
root = Path(td)
|
||||
account = "wxid_test"
|
||||
username = "wxid_friend"
|
||||
self._prepare_account(root, account=account, username=username)
|
||||
|
||||
prev_data = os.environ.get("WECHAT_TOOL_DATA_DIR")
|
||||
try:
|
||||
os.environ["WECHAT_TOOL_DATA_DIR"] = str(root)
|
||||
svc = self._reload_export_modules()
|
||||
job = self._create_job(
|
||||
svc.CHAT_EXPORT_MANAGER,
|
||||
account=account,
|
||||
username=username,
|
||||
message_types=["text"],
|
||||
include_media=True,
|
||||
)
|
||||
self.assertEqual(job.status, "done", msg=job.error)
|
||||
|
||||
payload, manifest, _ = self._load_export_payload(job.zip_path)
|
||||
system_msg = next((m for m in payload.get("messages", []) if int(m.get("type") or 0) == 10000), None)
|
||||
self.assertIsNone(system_msg)
|
||||
self.assertTrue(all(str(m.get("renderType") or "") == "text" for m in payload.get("messages", [])))
|
||||
self.assertEqual(manifest.get("filters", {}).get("messageTypes"), ["text"])
|
||||
finally:
|
||||
if prev_data is None:
|
||||
os.environ.pop("WECHAT_TOOL_DATA_DIR", None)
|
||||
else:
|
||||
os.environ["WECHAT_TOOL_DATA_DIR"] = prev_data
|
||||
|
||||
def test_checked_video_exports_video_and_thumb(self):
|
||||
with TemporaryDirectory() as td:
|
||||
root = Path(td)
|
||||
account = "wxid_test"
|
||||
username = "wxid_friend"
|
||||
self._prepare_account(root, account=account, username=username)
|
||||
|
||||
prev_data = os.environ.get("WECHAT_TOOL_DATA_DIR")
|
||||
try:
|
||||
os.environ["WECHAT_TOOL_DATA_DIR"] = str(root)
|
||||
svc = self._reload_export_modules()
|
||||
job = self._create_job(
|
||||
svc.CHAT_EXPORT_MANAGER,
|
||||
account=account,
|
||||
username=username,
|
||||
message_types=["video", "text"],
|
||||
include_media=True,
|
||||
)
|
||||
self.assertEqual(job.status, "done", msg=job.error)
|
||||
|
||||
payload, _, names = self._load_export_payload(job.zip_path)
|
||||
video_msg = next((m for m in payload.get("messages", []) if int(m.get("type") or 0) == 43), None)
|
||||
self.assertIsNotNone(video_msg)
|
||||
self.assertEqual(str(video_msg.get("renderType") or ""), "video")
|
||||
image_msg = next((m for m in payload.get("messages", []) if int(m.get("type") or 0) == 3), None)
|
||||
self.assertIsNone(image_msg)
|
||||
media_items = video_msg.get("offlineMedia") or []
|
||||
kinds = sorted(str(x.get("kind") or "") for x in media_items)
|
||||
self.assertIn("video", kinds)
|
||||
self.assertIn("video_thumb", kinds)
|
||||
self.assertTrue(any(n.startswith("media/videos/") for n in names))
|
||||
self.assertTrue(any(n.startswith("media/video_thumbs/") for n in names))
|
||||
finally:
|
||||
if prev_data is None:
|
||||
os.environ.pop("WECHAT_TOOL_DATA_DIR", None)
|
||||
else:
|
||||
os.environ["WECHAT_TOOL_DATA_DIR"] = prev_data
|
||||
|
||||
def test_privacy_mode_never_exports_media(self):
|
||||
with TemporaryDirectory() as td:
|
||||
root = Path(td)
|
||||
account = "wxid_test"
|
||||
username = "wxid_friend"
|
||||
self._prepare_account(root, account=account, username=username)
|
||||
|
||||
prev_data = os.environ.get("WECHAT_TOOL_DATA_DIR")
|
||||
try:
|
||||
os.environ["WECHAT_TOOL_DATA_DIR"] = str(root)
|
||||
svc = self._reload_export_modules()
|
||||
job = self._create_job(
|
||||
svc.CHAT_EXPORT_MANAGER,
|
||||
account=account,
|
||||
username=username,
|
||||
message_types=["image", "video", "text"],
|
||||
include_media=True,
|
||||
privacy_mode=True,
|
||||
)
|
||||
self.assertEqual(job.status, "done", msg=job.error)
|
||||
|
||||
payload, manifest, names = self._load_export_payload(job.zip_path)
|
||||
self.assertFalse(any(n.startswith("media/images/") for n in names))
|
||||
self.assertFalse(any(n.startswith("media/videos/") for n in names))
|
||||
self.assertFalse(any(n.startswith("media/video_thumbs/") for n in names))
|
||||
|
||||
for msg in payload.get("messages", []):
|
||||
self.assertFalse(msg.get("offlineMedia"))
|
||||
|
||||
self.assertFalse(bool(manifest.get("options", {}).get("includeMedia")))
|
||||
finally:
|
||||
if prev_data is None:
|
||||
os.environ.pop("WECHAT_TOOL_DATA_DIR", None)
|
||||
else:
|
||||
os.environ["WECHAT_TOOL_DATA_DIR"] = prev_data
|
||||
|
||||
def test_transfer_only_exports_transfer_messages(self):
|
||||
with TemporaryDirectory() as td:
|
||||
root = Path(td)
|
||||
account = "wxid_test"
|
||||
username = "wxid_friend"
|
||||
self._prepare_account(root, account=account, username=username)
|
||||
|
||||
prev_data = os.environ.get("WECHAT_TOOL_DATA_DIR")
|
||||
try:
|
||||
os.environ["WECHAT_TOOL_DATA_DIR"] = str(root)
|
||||
svc = self._reload_export_modules()
|
||||
job = self._create_job(
|
||||
svc.CHAT_EXPORT_MANAGER,
|
||||
account=account,
|
||||
username=username,
|
||||
message_types=["transfer"],
|
||||
include_media=True,
|
||||
)
|
||||
self.assertEqual(job.status, "done", msg=job.error)
|
||||
|
||||
payload, manifest, _ = self._load_export_payload(job.zip_path)
|
||||
messages = list(payload.get("messages", []))
|
||||
self.assertEqual(len(messages), 1)
|
||||
self.assertTrue(all(str(m.get("renderType") or "") == "transfer" for m in messages))
|
||||
self.assertEqual(manifest.get("filters", {}).get("messageTypes"), ["transfer"])
|
||||
finally:
|
||||
if prev_data is None:
|
||||
os.environ.pop("WECHAT_TOOL_DATA_DIR", None)
|
||||
else:
|
||||
os.environ["WECHAT_TOOL_DATA_DIR"] = prev_data
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user