diff --git a/pyproject.toml b/pyproject.toml
index 032555e..f5d9af2 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -9,6 +9,7 @@ description = "WeChat data query CLI for LLMs"
requires-python = ">=3.10"
dependencies = [
"click>=8.1,<9",
+ "pillow>=10,<12",
"pycryptodome>=3.19,<4",
"zstandard>=0.22,<1",
]
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/tests/__init__.py
@@ -0,0 +1 @@
+
diff --git a/tests/test_json_export.py b/tests/test_json_export.py
new file mode 100644
index 0000000..f8c49b3
--- /dev/null
+++ b/tests/test_json_export.py
@@ -0,0 +1,1109 @@
+import json
+import os
+import sqlite3
+import tempfile
+import unittest
+import hashlib
+from types import SimpleNamespace
+from datetime import datetime
+from unittest.mock import patch
+
+from click.testing import CliRunner
+from Crypto.Cipher import AES
+
+import wechat_cli.main as main
+from wechat_cli.core.media_export import (
+ decode_wechat_image_dat,
+ decode_wxgf_image,
+ detect_image_bytes,
+ materialize_record_media,
+ prepare_export_targets,
+ readme_path_for_output,
+ _sticker_cache_dir,
+)
+from wechat_cli.core.messages import collect_chat_export_records
+
+
+CHAT_USERNAME = "room@chatroom"
+
+
+class FakeCache:
+ def __init__(self, mapping):
+ self.mapping = mapping
+
+ def get(self, rel_key):
+ return self.mapping.get(rel_key)
+
+
+class FakeApp:
+ def __init__(self, db_dir, message_db, resource_db=None):
+ self.db_dir = db_dir
+ self.decrypted_dir = os.path.join(os.path.dirname(db_dir), "decrypted")
+ mapping = {"message/message_0.db": message_db}
+ if resource_db:
+ mapping["message/message_resource.db"] = resource_db
+ self.cache = FakeCache(mapping)
+ self.msg_db_keys = ["message/message_0.db"]
+
+ def display_name_fn(self, username, names):
+ if username == "alice":
+ return "Alice"
+ if username == CHAT_USERNAME:
+ return "Project Room"
+ return names.get(username, username)
+
+
+def _table_name(username=CHAT_USERNAME):
+ import hashlib
+ return "Msg_" + hashlib.md5(username.encode()).hexdigest()
+
+
+def _write_sqlite(path, rows, username=CHAT_USERNAME):
+ table = _table_name(username)
+ conn = sqlite3.connect(path)
+ try:
+ conn.execute("CREATE TABLE Name2Id (user_name TEXT)")
+ conn.execute("INSERT INTO Name2Id (rowid, user_name) VALUES (?, ?)", (1, "alice"))
+ conn.execute(
+ f"""
+ CREATE TABLE [{table}] (
+ local_id INTEGER,
+ local_type INTEGER,
+ create_time INTEGER,
+ real_sender_id INTEGER,
+ message_content BLOB,
+ WCDB_CT_message_content INTEGER
+ )
+ """
+ )
+ conn.executemany(
+ f"INSERT INTO [{table}] VALUES (?, ?, ?, ?, ?, ?)",
+ rows,
+ )
+ conn.commit()
+ finally:
+ conn.close()
+ return table
+
+
+def _packed_resource_hash(resource_hash):
+ return b"\x12\x22\x0a\x20" + resource_hash.encode("ascii")
+
+
+def _write_resource_sqlite(path, rows, username=CHAT_USERNAME):
+ conn = sqlite3.connect(path)
+ try:
+ conn.execute("CREATE TABLE ChatName2Id(user_name TEXT PRIMARY KEY, update_time INTEGER)")
+ conn.execute("INSERT INTO ChatName2Id(rowid, user_name, update_time) VALUES (?, ?, ?)", (1, username, 0))
+ conn.execute(
+ """
+ CREATE TABLE MessageResourceInfo(
+ message_id INTEGER PRIMARY KEY AUTOINCREMENT,
+ chat_id INTEGER,
+ sender_id INTEGER,
+ message_local_type INTEGER,
+ message_create_time INTEGER,
+ message_local_id INTEGER,
+ message_svr_id INTEGER,
+ message_origin_source INTEGER,
+ packed_info BLOB
+ )
+ """
+ )
+ conn.execute(
+ """
+ CREATE TABLE MessageResourceDetail(
+ resource_id INTEGER PRIMARY KEY AUTOINCREMENT,
+ message_id INTEGER,
+ type INTEGER,
+ size INTEGER,
+ create_time INTEGER,
+ access_time INTEGER,
+ status INTEGER,
+ data_index TEXT,
+ packed_info BLOB
+ )
+ """
+ )
+ for local_id, local_type, create_time, resource_hash in rows:
+ cur = conn.execute(
+ """
+ INSERT INTO MessageResourceInfo(
+ chat_id, sender_id, message_local_type, message_create_time,
+ message_local_id, message_svr_id, message_origin_source, packed_info
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
+ """,
+ (1, 1, local_type, create_time, local_id, 1000 + local_id, 6, _packed_resource_hash(resource_hash)),
+ )
+ conn.execute(
+ """
+ INSERT INTO MessageResourceDetail(
+ message_id, type, size, create_time, access_time, status, data_index, packed_info
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
+ """,
+ (cur.lastrowid, 131073, 1234, create_time, 0, 1, "0", b""),
+ )
+ conn.commit()
+ finally:
+ conn.close()
+
+
+def _xor(data, key=0x37):
+ return bytes(b ^ key for b in data)
+
+
+def _wechat_v2_dat():
+ return bytes.fromhex("07085632080700040000d64f000001") + (b"\x00" * 128)
+
+
+def _write_v2_media_context(tmp, filename, uin=352745915, wxid="catmoment123"):
+ documents_dir = os.path.join(tmp, "Documents")
+ kvcomm_dir = os.path.join(documents_dir, "app_data", "net", "kvcomm")
+ os.makedirs(kvcomm_dir)
+ with open(os.path.join(kvcomm_dir, f"key_{uin}_4066646122_1_1780465506_1339419274_3600_input.statistic"), "wb") as f:
+ f.write(b"")
+
+ media_dir = os.path.join(
+ documents_dir, "xwechat_files", f"{wxid}_7e99",
+ "msg", "attach", "0" * 32, "2026-06", "Img",
+ )
+ os.makedirs(media_dir)
+ return os.path.join(media_dir, filename)
+
+
+def _wechat_v2_image_dat(payload, uin=352745915, wxid="catmoment123", aes_len=16):
+ aes_key = hashlib.md5((str(uin) + wxid).encode("utf-8")).hexdigest()[:16].encode("ascii")
+ xor_key = uin & 0xff
+ prefix = payload[:aes_len].ljust(aes_len, b"\x00")
+ aes_plain = prefix + (b"\x00" * 16)
+ aes_cipher = AES.new(aes_key, AES.MODE_ECB).encrypt(aes_plain)
+ tail = bytes(b ^ xor_key for b in payload[aes_len:])
+ return (
+ b"\x07\x08V2\x08\x07"
+ + aes_len.to_bytes(4, "little")
+ + len(tail).to_bytes(4, "little")
+ + b"\x01"
+ + aes_cipher
+ + tail
+ )
+
+
+def _wxgf_with_partition(payload):
+ header = b"wxgf" + bytes([19]) + (b"\x00" * 14)
+ return header + len(payload).to_bytes(4, "big") + payload
+
+
+def _png_bytes(width=72, height=80):
+ return (
+ b"\x89PNG\r\n\x1a\n"
+ + (13).to_bytes(4, "big")
+ + b"IHDR"
+ + width.to_bytes(4, "big")
+ + height.to_bytes(4, "big")
+ + b"\x08\x02\x00\x00\x00"
+ + b"\x00\x00\x00\x00"
+ + b"\x00\x00\x00\x00IEND\xaeB`\x82"
+ )
+
+
+def _gif_bytes(width=300, height=304):
+ return (
+ b"GIF89a"
+ + width.to_bytes(2, "little")
+ + height.to_bytes(2, "little")
+ + b"\x80\x00\x00\x00\x00\x00\xff\xff\xff!\xf9\x04\x00\x00\x00\x00\x00,"
+ + b"\x00\x00\x00\x00\x01\x00\x01\x00\x00\x02\x02D\x01\x00;"
+ )
+
+
+def _sticker_xml(sticker_md5, aeskey="5fd76e9a49304191ab82949d45931e89", cdnurl="https://example.test/cdn", encrypturl="https://example.test/encrypt"):
+ return (
+ ''
+ )
+
+
+def _aes_cbc_pkcs7_encrypt(data, aeskey):
+ key = bytes.fromhex(aeskey)
+ pad = 16 - (len(data) % 16)
+ return AES.new(key, AES.MODE_CBC, iv=key).encrypt(data + bytes([pad]) * pad)
+
+
+class JsonExportTests(unittest.TestCase):
+ def setUp(self):
+ self._sticker_cache_tmp = tempfile.TemporaryDirectory()
+ self.sticker_cache_dir = os.path.join(self._sticker_cache_tmp.name, "stickers")
+ self._sticker_cache_patcher = patch(
+ "wechat_cli.core.media_export._sticker_cache_dir",
+ return_value=self.sticker_cache_dir,
+ )
+ self._sticker_cache_patcher.start()
+
+ def tearDown(self):
+ self._sticker_cache_patcher.stop()
+ self._sticker_cache_tmp.cleanup()
+
+ def test_sticker_cache_dir_uses_macos_user_cache(self):
+ with patch.dict(os.environ, {"HOME": "/Users/test"}, clear=True):
+ with patch("wechat_cli.core.media_export.sys.platform", "darwin"):
+ self.assertEqual(
+ _sticker_cache_dir(),
+ "/Users/test/Library/Caches/wechat-cli/stickers",
+ )
+
+ def test_sticker_cache_dir_uses_xdg_cache_on_non_macos(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ with patch.dict(os.environ, {"XDG_CACHE_HOME": tmp}, clear=True):
+ with patch("wechat_cli.core.media_export.sys.platform", "linux"):
+ self.assertEqual(
+ _sticker_cache_dir(),
+ os.path.join(tmp, "wechat-cli", "stickers"),
+ )
+
+ def test_sticker_cache_dir_allows_env_override(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ override = os.path.join(tmp, "custom-stickers")
+ with patch.dict(os.environ, {"WECHAT_CLI_STICKER_CACHE_DIR": override}, clear=True):
+ with patch("wechat_cli.core.media_export.sys.platform", "darwin"):
+ self.assertEqual(_sticker_cache_dir(), override)
+
+ def test_decode_wechat_image_dat_detects_xor_jpeg(self):
+ jpeg = bytes.fromhex("ffd8ffe000104a464946") + b"payload"
+ decoded = decode_wechat_image_dat(_xor(jpeg))
+ self.assertIsNotNone(decoded)
+ data, ext, mime = decoded
+ self.assertEqual(data, jpeg)
+ self.assertEqual(ext, "jpg")
+ self.assertEqual(mime, "image/jpeg")
+
+ def test_decode_wxgf_image_uses_ffmpeg_partition(self):
+ hevc = b"\x00\x00\x00\x01fake-hevc"
+ wxgf = _wxgf_with_partition(hevc)
+ jpeg = bytes.fromhex("ffd8ffe000104a464946") + b"payload"
+ calls = []
+
+ def fake_run(cmd, **kwargs):
+ calls.append((cmd, kwargs))
+ return SimpleNamespace(returncode=0, stdout=jpeg, stderr=b"")
+
+ with patch("wechat_cli.core.media_export._ffmpeg_path", return_value="/usr/bin/ffmpeg"):
+ with patch("wechat_cli.core.media_export.subprocess.run", side_effect=fake_run):
+ decoded = decode_wxgf_image(wxgf)
+
+ self.assertIsNotNone(decoded)
+ decoded_data, ext, mime = decoded
+ self.assertEqual(decoded_data, jpeg)
+ self.assertEqual(ext, "jpg")
+ self.assertEqual(mime, "image/jpeg")
+ self.assertEqual(calls[0][1]["input"], hevc)
+
+ def test_decode_wechat_image_dat_decodes_v2_wxgf_with_ffmpeg(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ src = _write_v2_media_context(tmp, "a" * 32 + ".dat")
+ hevc = b"\x00\x00\x00\x01fake-hevc"
+ wxgf = _wxgf_with_partition(hevc)
+ data = _wechat_v2_image_dat(wxgf)
+ jpeg = bytes.fromhex("ffd8ffe000104a464946") + b"payload"
+
+ with patch("wechat_cli.core.media_export._ffmpeg_path", return_value="/usr/bin/ffmpeg"):
+ with patch(
+ "wechat_cli.core.media_export.subprocess.run",
+ return_value=SimpleNamespace(returncode=0, stdout=jpeg, stderr=b""),
+ ):
+ decoded = decode_wechat_image_dat(data, source_path=src)
+
+ self.assertIsNotNone(decoded)
+ decoded_data, ext, mime = decoded
+ self.assertEqual(decoded_data, jpeg)
+ self.assertEqual(ext, "jpg")
+ self.assertEqual(mime, "image/jpeg")
+
+ def test_decode_wechat_image_dat_rejects_wechat_v2_false_bmp(self):
+ data = _wechat_v2_dat()
+ false_bmp = _xor(data, key=0x45)
+
+ self.assertTrue(false_bmp.startswith(b"BM"))
+ self.assertIsNone(detect_image_bytes(false_bmp))
+ self.assertIsNone(decode_wechat_image_dat(data))
+
+ def test_decode_wechat_image_dat_decodes_v2_with_kvcomm_uin(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ src = _write_v2_media_context(tmp, "a" * 32 + ".dat")
+ jpeg = bytes.fromhex("ffd8ffe000104a46494600010100004800480000") + b"payload\xff\xd9"
+ data = _wechat_v2_image_dat(jpeg)
+ with open(src, "wb") as f:
+ f.write(data)
+
+ decoded = decode_wechat_image_dat(data, source_path=src)
+
+ self.assertIsNotNone(decoded)
+ decoded_data, ext, mime = decoded
+ self.assertEqual(decoded_data, jpeg)
+ self.assertEqual(ext, "jpg")
+ self.assertEqual(mime, "image/jpeg")
+
+ def test_decode_wechat_image_dat_decodes_v2_with_radium_config_uin(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ documents_dir = os.path.join(tmp, "Documents")
+ net_kvcomm = os.path.join(documents_dir, "app_data", "net", "kvcomm")
+ radium_kvcomm = os.path.join(
+ documents_dir, "app_data", "radium", "ilink",
+ "7e99a3705453335bbe64eb651661c573", "kvcomm",
+ )
+ os.makedirs(net_kvcomm)
+ os.makedirs(radium_kvcomm)
+ with open(os.path.join(net_kvcomm, "key_0_4066646122_1_1780474260_299727447_3600_input.statistic"), "wb") as f:
+ f.write(b"")
+ with open(os.path.join(radium_kvcomm, "config.ini"), "w", encoding="utf-8") as f:
+ f.write("last_uin=MzUyNzQ1OTE1\n")
+
+ media_dir = os.path.join(
+ documents_dir, "xwechat_files", "catmoment123_7e99",
+ "msg", "attach", "0" * 32, "2026-06", "Img",
+ )
+ os.makedirs(media_dir)
+ src = os.path.join(media_dir, "a" * 32 + ".dat")
+ png = _png_bytes(72, 80)
+ data = _wechat_v2_image_dat(png)
+ with open(src, "wb") as f:
+ f.write(data)
+
+ decoded = decode_wechat_image_dat(data, source_path=src)
+
+ self.assertIsNotNone(decoded)
+ decoded_data, ext, mime = decoded
+ self.assertEqual(decoded_data, png)
+ self.assertEqual(ext, "png")
+ self.assertEqual(mime, "image/png")
+
+ def test_materialize_media_decodes_dat_and_uses_relative_path(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ src = os.path.join(tmp, "image.dat")
+ png = _png_bytes(72, 80)
+ with open(src, "wb") as f:
+ f.write(_xor(png))
+
+ output_path = os.path.join(tmp, "chat.json")
+ assets_dir = os.path.join(tmp, "chat_assets")
+ records = [{
+ "local_id": 10,
+ "time": "2026-06-03 10:15:00",
+ "_media_sources": [{"kind": "image", "source_path": src, "original_filename": "image.dat"}],
+ }]
+
+ warnings = materialize_record_media(records, assets_dir, output_path)
+
+ self.assertEqual(warnings, [])
+ media = records[0]["media"][0]
+ self.assertEqual(media["status"], "decoded")
+ self.assertEqual(media["mime"], "image/png")
+ self.assertEqual(media["width"], 72)
+ self.assertEqual(media["height"], 80)
+ self.assertTrue(media["path"].startswith("chat_assets/images/"))
+ self.assertTrue(os.path.exists(os.path.join(tmp, media["path"])))
+ self.assertNotIn("_media_sources", records[0])
+
+ def test_materialize_media_copies_undecodable_dat_and_warns(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ src = os.path.join(tmp, "bad.dat")
+ with open(src, "wb") as f:
+ f.write(b"not an encoded image")
+
+ output_path = os.path.join(tmp, "chat.json")
+ assets_dir = os.path.join(tmp, "chat_assets")
+ records = [{
+ "local_id": 11,
+ "time": "2026-06-03 10:16:00",
+ "_media_sources": [{"kind": "image", "source_path": src, "original_filename": "bad.dat"}],
+ }]
+
+ warnings = materialize_record_media(records, assets_dir, output_path)
+
+ media = records[0]["media"][0]
+ self.assertEqual(media["status"], "undecodable")
+ self.assertTrue(media["path"].endswith(".dat"))
+ self.assertEqual(warnings[0]["status"], "undecodable")
+ self.assertTrue(os.path.exists(os.path.join(tmp, media["path"])))
+
+ def test_materialize_media_keeps_undecodable_wechat_v2_dat(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ src = os.path.join(tmp, "v2.dat")
+ data = _wechat_v2_dat()
+ with open(src, "wb") as f:
+ f.write(data)
+
+ output_path = os.path.join(tmp, "chat.json")
+ assets_dir = os.path.join(tmp, "chat_assets")
+ records = [{
+ "local_id": 12,
+ "time": "2026-06-03 10:16:30",
+ "_media_sources": [{"kind": "image", "source_path": src, "original_filename": "v2.dat"}],
+ }]
+
+ warnings = materialize_record_media(records, assets_dir, output_path)
+
+ media = records[0]["media"][0]
+ self.assertEqual(media["status"], "undecodable")
+ self.assertTrue(media["path"].endswith(".dat"))
+ self.assertEqual(media["mime"], "application/octet-stream")
+ self.assertEqual(media["detail"], "WeChat image .dat could not be decoded")
+ with open(os.path.join(tmp, media["path"]), "rb") as f:
+ self.assertEqual(f.read(), data)
+ self.assertEqual(warnings[0]["status"], "undecodable")
+
+ def test_materialize_media_decodes_v2_sibling_when_base_is_wxgf(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ resource_hash = "b" * 32
+ src = _write_v2_media_context(tmp, f"{resource_hash}.dat")
+ high_src = os.path.join(os.path.dirname(src), f"{resource_hash}_h.dat")
+ wxgf_payload = b"wxgf" + (b"\x00" * 64)
+ png = _png_bytes(640, 480)
+ with open(src, "wb") as f:
+ f.write(_wechat_v2_image_dat(wxgf_payload))
+ with open(high_src, "wb") as f:
+ f.write(_wechat_v2_image_dat(png))
+
+ output_path = os.path.join(tmp, "chat.json")
+ assets_dir = os.path.join(tmp, "chat_assets")
+ records = [{
+ "local_id": 13,
+ "time": "2026-06-03 10:16:45",
+ "_media_sources": [{"kind": "image", "source_path": src, "original_filename": f"{resource_hash}.dat"}],
+ }]
+
+ warnings = materialize_record_media(records, assets_dir, output_path)
+
+ self.assertEqual(warnings, [])
+ media = records[0]["media"][0]
+ self.assertEqual(media["status"], "decoded")
+ self.assertEqual(media["mime"], "image/png")
+ self.assertEqual(media["width"], 640)
+ self.assertEqual(media["height"], 480)
+ self.assertTrue(media["path"].endswith(".png"))
+ with open(os.path.join(tmp, media["path"]), "rb") as f:
+ self.assertEqual(f.read(), png)
+
+ def test_materialize_media_records_missing_warning(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ output_path = os.path.join(tmp, "chat.json")
+ assets_dir = os.path.join(tmp, "chat_assets")
+ records = [{
+ "local_id": 12,
+ "time": "2026-06-03 10:17:00",
+ "_media_sources": [{
+ "kind": "video",
+ "source_path": os.path.join(tmp, "missing.mp4"),
+ "original_filename": "missing.mp4",
+ }],
+ }]
+
+ warnings = materialize_record_media(records, assets_dir, output_path)
+
+ media = records[0]["media"][0]
+ self.assertEqual(media["status"], "missing")
+ self.assertEqual(media["original_filename"], "missing.mp4")
+ self.assertEqual(warnings[0]["local_id"], 12)
+ self.assertEqual(warnings[0]["status"], "missing")
+ self.assertFalse(os.path.exists(assets_dir))
+
+ def test_materialize_video_adds_ffprobe_metadata(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ src = os.path.join(tmp, "clip.mp4")
+ with open(src, "wb") as f:
+ f.write(b"fake mp4")
+
+ output_path = os.path.join(tmp, "chat.json")
+ assets_dir = os.path.join(tmp, "chat_assets")
+ records = [{
+ "local_id": 43,
+ "time": "2026-06-03 10:17:30",
+ "_media_sources": [{"kind": "video", "source_path": src, "original_filename": "clip.mp4"}],
+ }]
+
+ with patch(
+ "wechat_cli.core.media_export._probe_ffprobe_metadata",
+ return_value=({"width": 1920, "height": 1080, "duration_ms": 1234}, ""),
+ ):
+ warnings = materialize_record_media(records, assets_dir, output_path)
+
+ self.assertEqual(warnings, [])
+ media = records[0]["media"][0]
+ self.assertEqual(media["status"], "copied")
+ self.assertEqual(media["width"], 1920)
+ self.assertEqual(media["height"], 1080)
+ self.assertEqual(media["duration_ms"], 1234)
+
+ def test_materialize_audio_probe_failure_warns_without_failing(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ src = os.path.join(tmp, "voice.aud")
+ with open(src, "wb") as f:
+ f.write(b"fake audio")
+
+ output_path = os.path.join(tmp, "chat.json")
+ assets_dir = os.path.join(tmp, "chat_assets")
+ records = [{
+ "local_id": 34,
+ "time": "2026-06-03 10:17:45",
+ "_media_sources": [{"kind": "voice", "source_path": src, "original_filename": "voice.aud"}],
+ }]
+
+ with patch(
+ "wechat_cli.core.media_export._probe_ffprobe_metadata",
+ return_value=({}, "ffprobe failed"),
+ ):
+ warnings = materialize_record_media(records, assets_dir, output_path)
+
+ media = records[0]["media"][0]
+ self.assertEqual(media["status"], "copied")
+ self.assertNotIn("duration_ms", media)
+ self.assertEqual(warnings[0]["local_id"], 34)
+ self.assertEqual(warnings[0]["status"], "metadata_unavailable")
+ self.assertIn("ffprobe failed", warnings[0]["detail"])
+
+ def test_materialize_sticker_downloads_cdn_gif(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ gif = _gif_bytes()
+ sticker_md5 = hashlib.md5(gif).hexdigest()
+ output_path = os.path.join(tmp, "chat.json")
+ assets_dir = os.path.join(tmp, "chat_assets")
+ records = [{
+ "local_id": 47,
+ "time": "2026-06-03 10:18:00",
+ "_media_sources": [{
+ "kind": "sticker",
+ "original_filename": sticker_md5,
+ "sticker_md5": sticker_md5,
+ "expected_bytes": str(len(gif)),
+ "width": "300",
+ "height": "304",
+ "cdn_url": "https://example.test/cdn",
+ }],
+ }]
+
+ with patch("wechat_cli.core.media_export._download_url", return_value=(gif, "")):
+ warnings = materialize_record_media(
+ records, assets_dir, output_path, download_stickers=True
+ )
+
+ self.assertEqual(warnings, [])
+ media = records[0]["media"][0]
+ self.assertEqual(media["kind"], "sticker")
+ self.assertEqual(media["status"], "downloaded")
+ self.assertEqual(media["source"], "cdnurl")
+ self.assertEqual(media["mime"], "image/gif")
+ self.assertEqual(media["md5"], sticker_md5)
+ self.assertEqual(media["width"], 300)
+ self.assertEqual(media["height"], 304)
+ self.assertTrue(media["path"].startswith("chat_assets/stickers/"))
+ with open(os.path.join(tmp, media["path"]), "rb") as f:
+ self.assertEqual(f.read(), gif)
+
+ def test_materialize_sticker_writes_persistent_cache_after_download(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ gif = _gif_bytes()
+ sticker_md5 = hashlib.md5(gif).hexdigest()
+ output_path = os.path.join(tmp, "chat.json")
+ assets_dir = os.path.join(tmp, "chat_assets")
+ records = [{
+ "local_id": 47,
+ "time": "2026-06-03 10:18:00",
+ "_media_sources": [{
+ "kind": "sticker",
+ "original_filename": sticker_md5,
+ "sticker_md5": sticker_md5,
+ "cdn_url": "https://example.test/cdn",
+ }],
+ }]
+
+ with patch("wechat_cli.core.media_export._download_url", return_value=(gif, "")):
+ warnings = materialize_record_media(
+ records, assets_dir, output_path, download_stickers=True
+ )
+
+ self.assertEqual(warnings, [])
+ cache_path = os.path.join(self.sticker_cache_dir, f"{sticker_md5}.gif")
+ self.assertTrue(os.path.exists(cache_path))
+ with open(cache_path, "rb") as f:
+ self.assertEqual(f.read(), gif)
+
+ def test_materialize_sticker_uses_persistent_cache_without_network(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ gif = _gif_bytes()
+ sticker_md5 = hashlib.md5(gif).hexdigest()
+ os.makedirs(self.sticker_cache_dir)
+ with open(os.path.join(self.sticker_cache_dir, f"{sticker_md5}.gif"), "wb") as f:
+ f.write(gif)
+
+ output_path = os.path.join(tmp, "chat.json")
+ assets_dir = os.path.join(tmp, "chat_assets")
+ records = [{
+ "local_id": 50,
+ "time": "2026-06-03 10:20:00",
+ "_media_sources": [{
+ "kind": "sticker",
+ "original_filename": sticker_md5,
+ "sticker_md5": sticker_md5,
+ "cdn_url": "https://example.test/cdn",
+ }],
+ }]
+
+ with patch("wechat_cli.core.media_export._download_url") as download:
+ warnings = materialize_record_media(
+ records, assets_dir, output_path, download_stickers=False
+ )
+
+ download.assert_not_called()
+ self.assertEqual(warnings, [])
+ media = records[0]["media"][0]
+ self.assertEqual(media["status"], "copied")
+ self.assertTrue(media["cache_hit"])
+ self.assertEqual(media["mime"], "image/gif")
+ self.assertEqual(media["width"], 300)
+ self.assertEqual(media["height"], 304)
+ self.assertTrue(media["path"].startswith("chat_assets/stickers/"))
+ with open(os.path.join(tmp, media["path"]), "rb") as f:
+ self.assertEqual(f.read(), gif)
+
+ def test_materialize_sticker_reuses_downloaded_asset_by_md5(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ gif = _gif_bytes()
+ sticker_md5 = hashlib.md5(gif).hexdigest()
+ output_path = os.path.join(tmp, "chat.json")
+ assets_dir = os.path.join(tmp, "chat_assets")
+ source = {
+ "kind": "sticker",
+ "original_filename": sticker_md5,
+ "sticker_md5": sticker_md5,
+ "cdn_url": "https://example.test/cdn",
+ }
+ records = [
+ {
+ "local_id": 47,
+ "time": "2026-06-03 10:18:00",
+ "_media_sources": [dict(source)],
+ },
+ {
+ "local_id": 48,
+ "time": "2026-06-03 10:19:00",
+ "_media_sources": [dict(source)],
+ },
+ ]
+ calls = []
+
+ def fake_download(url):
+ calls.append(url)
+ return gif, ""
+
+ with patch("wechat_cli.core.media_export._download_url", side_effect=fake_download):
+ warnings = materialize_record_media(
+ records, assets_dir, output_path, download_stickers=True
+ )
+
+ self.assertEqual(warnings, [])
+ self.assertEqual(calls, ["https://example.test/cdn"])
+ first = records[0]["media"][0]
+ second = records[1]["media"][0]
+ self.assertEqual(first["path"], second["path"])
+ self.assertEqual(first["status"], "downloaded")
+ self.assertEqual(second["status"], "downloaded")
+
+ def test_materialize_sticker_enriches_sparse_earlier_record_by_md5(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ gif = _gif_bytes()
+ sticker_md5 = hashlib.md5(gif).hexdigest()
+ thumb_path = os.path.join(tmp, f"{sticker_md5}.thumb")
+ with open(thumb_path, "wb") as f:
+ f.write(b"wechat-local-thumb-wrapper")
+
+ output_path = os.path.join(tmp, "chat.json")
+ assets_dir = os.path.join(tmp, "chat_assets")
+ records = [
+ {
+ "local_id": 11,
+ "time": "2024-11-29 13:37:31",
+ "_media_sources": [{
+ "kind": "sticker",
+ "source_path": thumb_path,
+ "original_filename": sticker_md5,
+ "sticker_md5": sticker_md5,
+ "expected_bytes": "0",
+ "width": "240",
+ "height": "240",
+ }],
+ },
+ {
+ "local_id": 245,
+ "time": "2024-12-09 17:15:05",
+ "_media_sources": [{
+ "kind": "sticker",
+ "source_path": thumb_path,
+ "original_filename": sticker_md5,
+ "sticker_md5": sticker_md5,
+ "expected_bytes": str(len(gif)),
+ "width": "240",
+ "height": "240",
+ "product_id": "com.tencent.xin.emoticon.person.test",
+ "cdn_url": "https://example.test/cdn",
+ }],
+ },
+ ]
+ calls = []
+
+ def fake_download(url):
+ calls.append(url)
+ return gif, ""
+
+ with patch("wechat_cli.core.media_export._download_url", side_effect=fake_download):
+ warnings = materialize_record_media(
+ records, assets_dir, output_path, download_stickers=True
+ )
+
+ self.assertEqual(warnings, [])
+ self.assertEqual(calls, ["https://example.test/cdn"])
+ first = records[0]["media"][0]
+ second = records[1]["media"][0]
+ self.assertEqual(first["status"], "downloaded")
+ self.assertEqual(first["source"], "cdnurl")
+ self.assertEqual(first["mime"], "image/gif")
+ self.assertEqual(first["expected_bytes"], len(gif))
+ self.assertEqual(first["product_id"], "com.tencent.xin.emoticon.person.test")
+ self.assertEqual(first["path"], second["path"])
+ with open(os.path.join(tmp, first["path"]), "rb") as f:
+ self.assertEqual(f.read(), gif)
+
+ def test_materialize_sticker_decrypts_encrypturl_gif(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ gif = _gif_bytes()
+ aeskey = "5fd76e9a49304191ab82949d45931e89"
+ sticker_md5 = hashlib.md5(gif).hexdigest()
+ encrypted = _aes_cbc_pkcs7_encrypt(gif, aeskey)
+ output_path = os.path.join(tmp, "chat.json")
+ assets_dir = os.path.join(tmp, "chat_assets")
+ records = [{
+ "local_id": 48,
+ "time": "2026-06-03 10:18:30",
+ "_media_sources": [{
+ "kind": "sticker",
+ "original_filename": sticker_md5,
+ "sticker_md5": sticker_md5,
+ "aeskey": aeskey,
+ "encrypt_url": "https://example.test/encrypt",
+ }],
+ }]
+
+ with patch("wechat_cli.core.media_export._download_url", return_value=(encrypted, "")):
+ warnings = materialize_record_media(
+ records, assets_dir, output_path, download_stickers=True
+ )
+
+ self.assertEqual(warnings, [])
+ media = records[0]["media"][0]
+ self.assertEqual(media["status"], "decoded")
+ self.assertEqual(media["source"], "encrypturl")
+ self.assertEqual(media["mime"], "image/gif")
+ with open(os.path.join(tmp, media["path"]), "rb") as f:
+ self.assertEqual(f.read(), gif)
+
+ def test_materialize_sticker_download_disabled_reports_missing(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ output_path = os.path.join(tmp, "chat.json")
+ assets_dir = os.path.join(tmp, "chat_assets")
+ records = [{
+ "local_id": 49,
+ "time": "2026-06-03 10:19:00",
+ "_media_sources": [{
+ "kind": "sticker",
+ "original_filename": "missing-sticker",
+ "cdn_url": "https://example.test/cdn",
+ }],
+ }]
+
+ warnings = materialize_record_media(records, assets_dir, output_path)
+
+ media = records[0]["media"][0]
+ self.assertEqual(media["status"], "missing")
+ self.assertIn("--download-stickers", media["detail"])
+ self.assertEqual(warnings[0]["kind"], "sticker")
+
+ def test_prepare_export_targets_rejects_existing_without_overwrite(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ output_path = os.path.join(tmp, "chat.json")
+ assets_dir = os.path.join(tmp, "chat_assets")
+ readme_path = os.path.join(tmp, "chat_export_readme.md")
+ with open(output_path, "w", encoding="utf-8") as f:
+ f.write("{}")
+ with open(readme_path, "w", encoding="utf-8") as f:
+ f.write("stale")
+
+ with self.assertRaises(FileExistsError):
+ prepare_export_targets(output_path, assets_dir, readme_path=readme_path, overwrite=False)
+
+ prepare_export_targets(output_path, assets_dir, readme_path=readme_path, overwrite=True)
+ self.assertFalse(os.path.exists(output_path))
+ self.assertFalse(os.path.exists(readme_path))
+ self.assertTrue(os.path.isdir(assets_dir))
+
+ def test_collect_chat_export_records_returns_all_messages_by_default_and_missing_media(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ db_dir = os.path.join(tmp, "xwechat", "db_storage")
+ os.makedirs(db_dir)
+ message_db = os.path.join(tmp, "message.db")
+ ts = int(datetime(2026, 6, 3, 10, 0).timestamp())
+ _write_sqlite(message_db, [
+ (1, 1, ts, 1, "alice:\nhello", None),
+ (2, 3, ts + 1, 1, "
", None),
+ (3, 1, ts + 2, 1, "alice:\nbye", None),
+ ])
+
+ app = FakeApp(db_dir, message_db)
+ ctx = {
+ "query": CHAT_USERNAME,
+ "username": CHAT_USERNAME,
+ "display_name": "Project Room",
+ "db_path": message_db,
+ "table_name": _table_name(),
+ "message_tables": [{"db_path": message_db, "table_name": _table_name()}],
+ "is_group": True,
+ }
+
+ records, failures = collect_chat_export_records(
+ ctx, {}, app.display_name_fn, limit=None, db_dir=db_dir
+ )
+
+ self.assertEqual(failures, [])
+ self.assertEqual([r["local_id"] for r in records], [1, 2, 3])
+ self.assertEqual(records[1]["type"], "image")
+ self.assertEqual(records[1]["_media_sources"][0]["kind"], "image")
+ self.assertEqual(records[1]["_media_sources"][0]["source_path"], None)
+
+ def test_collect_chat_export_records_resolves_sticker_cache_and_urls(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ db_dir = os.path.join(tmp, "xwechat", "db_storage")
+ os.makedirs(db_dir)
+ gif = _gif_bytes()
+ sticker_md5 = hashlib.md5(gif).hexdigest()
+ sticker_dir = os.path.join(tmp, "xwechat", "cache", "2026-06", "Emoticon", sticker_md5[:2])
+ os.makedirs(sticker_dir)
+ sticker_path = os.path.join(sticker_dir, sticker_md5)
+ with open(sticker_path, "wb") as f:
+ f.write(b"local-cache-wrapper")
+
+ message_db = os.path.join(tmp, "message.db")
+ ts = int(datetime(2026, 6, 3, 10, 0).timestamp())
+ xml = _sticker_xml(sticker_md5)
+ _write_sqlite(message_db, [(47, 47, ts, 1, f"alice:\n{xml}", None)])
+ app = FakeApp(db_dir, message_db)
+ ctx = {
+ "query": CHAT_USERNAME,
+ "username": CHAT_USERNAME,
+ "display_name": "Project Room",
+ "db_path": message_db,
+ "table_name": _table_name(),
+ "message_tables": [{"db_path": message_db, "table_name": _table_name()}],
+ "is_group": True,
+ }
+
+ records, failures = collect_chat_export_records(
+ ctx, {}, app.display_name_fn, limit=None, db_dir=db_dir
+ )
+
+ self.assertEqual(failures, [])
+ self.assertEqual(records[0]["type"], "sticker")
+ source = records[0]["_media_sources"][0]
+ self.assertEqual(source["kind"], "sticker")
+ self.assertEqual(source["source_path"], sticker_path)
+ self.assertEqual(source["sticker_md5"], sticker_md5)
+ self.assertEqual(source["cdn_url"], "https://example.test/cdn")
+ self.assertEqual(source["encrypt_url"], "https://example.test/encrypt")
+
+ def test_collect_chat_export_records_uses_resource_hash_for_ambiguous_image(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ db_dir = os.path.join(tmp, "xwechat", "db_storage")
+ image_dir = os.path.join(
+ tmp, "xwechat", "msg", "attach",
+ __import__("hashlib").md5(CHAT_USERNAME.encode()).hexdigest(),
+ "2026-06", "Img",
+ )
+ os.makedirs(image_dir)
+ os.makedirs(db_dir)
+ resource_hash = "0123456789abcdef0123456789abcdef"
+ target_path = os.path.join(image_dir, f"{resource_hash}.dat")
+ with open(target_path, "wb") as f:
+ f.write(b"target")
+ with open(os.path.join(image_dir, "other.dat"), "wb") as f:
+ f.write(b"other")
+
+ message_db = os.path.join(tmp, "message.db")
+ resource_db = os.path.join(tmp, "message_resource.db")
+ ts = int(datetime(2026, 6, 3, 10, 0).timestamp())
+ _write_sqlite(message_db, [(2, 3, ts, 1, "
", None)])
+ _write_resource_sqlite(resource_db, [(2, 3, ts, resource_hash)])
+ app = FakeApp(db_dir, message_db, resource_db=resource_db)
+ ctx = {
+ "query": CHAT_USERNAME,
+ "username": CHAT_USERNAME,
+ "display_name": "Project Room",
+ "db_path": message_db,
+ "table_name": _table_name(),
+ "message_tables": [{"db_path": message_db, "table_name": _table_name()}],
+ "is_group": True,
+ }
+
+ records, failures = collect_chat_export_records(
+ ctx, {}, app.display_name_fn, limit=None, db_dir=db_dir, resource_db_path=resource_db
+ )
+
+ self.assertEqual(failures, [])
+ source = records[0]["_media_sources"][0]
+ self.assertEqual(source["kind"], "image")
+ self.assertEqual(source["source_path"], target_path)
+
+ def test_cli_json_export_writes_schema_and_copied_relative_assets(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ db_dir = os.path.join(tmp, "xwechat", "db_storage")
+ image_dir = os.path.join(
+ tmp, "xwechat", "msg", "attach",
+ __import__("hashlib").md5(CHAT_USERNAME.encode()).hexdigest(),
+ "2026-06", "Img",
+ )
+ os.makedirs(image_dir)
+ os.makedirs(db_dir)
+ image_path = os.path.join(image_dir, "photo.dat")
+ png = _png_bytes(72, 80)
+ with open(image_path, "wb") as f:
+ f.write(_xor(png))
+
+ message_db = os.path.join(tmp, "message.db")
+ ts = int(datetime(2026, 6, 3, 10, 0).timestamp())
+ _write_sqlite(message_db, [
+ (1, 1, ts, 1, "alice:\nhello", None),
+ (2, 3, ts + 1, 1, "
", None),
+ ])
+ fake_app = FakeApp(db_dir, message_db)
+ output_path = os.path.join(tmp, "chat.json")
+ readme_path = readme_path_for_output(output_path)
+
+ runner = CliRunner()
+ with patch.object(main, "AppContext", return_value=fake_app):
+ result = runner.invoke(main.cli, [
+ "export", CHAT_USERNAME,
+ "--format", "json",
+ "--output", output_path,
+ ])
+
+ self.assertEqual(result.exit_code, 0, result.output)
+ with open(output_path, encoding="utf-8") as f:
+ payload = json.load(f)
+
+ self.assertEqual(payload["schema_version"], "wechat-cli.chat_export.v1")
+ self.assertEqual(payload["count"], 2)
+ self.assertEqual(payload["chat"]["username"], CHAT_USERNAME)
+ media = payload["messages"][1]["media"][0]
+ self.assertEqual(media["status"], "decoded")
+ self.assertEqual(media["width"], 72)
+ self.assertEqual(media["height"], 80)
+ self.assertTrue(media["path"].startswith("chat_assets/images/"))
+ self.assertTrue(os.path.exists(os.path.join(tmp, media["path"])))
+ self.assertTrue(os.path.exists(readme_path))
+ with open(readme_path, encoding="utf-8") as f:
+ readme = f.read()
+ self.assertIn("# WeChat JSON Export", readme)
+ self.assertIn("schema_version`: `wechat-cli.chat_export.v1`", readme)
+ self.assertIn("Resolve every `media[].path` relative to the directory containing `chat.json`.", readme)
+ self.assertIn("`chat_assets/images/`", readme)
+
+ with patch.object(main, "AppContext", return_value=fake_app):
+ blocked = runner.invoke(main.cli, [
+ "export", CHAT_USERNAME,
+ "--format", "json",
+ "--output", output_path,
+ ])
+ self.assertNotEqual(blocked.exit_code, 0)
+ self.assertIn("--overwrite", blocked.output)
+
+ def test_cli_json_export_uses_resource_db_for_image_hash(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ db_dir = os.path.join(tmp, "xwechat", "db_storage")
+ image_dir = os.path.join(
+ tmp, "xwechat", "msg", "attach",
+ __import__("hashlib").md5(CHAT_USERNAME.encode()).hexdigest(),
+ "2026-06", "Img",
+ )
+ os.makedirs(image_dir)
+ os.makedirs(db_dir)
+ resource_hash = "abcdef0123456789abcdef0123456789"
+ png = _png_bytes(72, 80)
+ with open(os.path.join(image_dir, f"{resource_hash}.dat"), "wb") as f:
+ f.write(_xor(png))
+ with open(os.path.join(image_dir, "unrelated.dat"), "wb") as f:
+ f.write(b"unrelated")
+
+ message_db = os.path.join(tmp, "message.db")
+ resource_db = os.path.join(tmp, "message_resource.db")
+ ts = int(datetime(2026, 6, 3, 10, 0).timestamp())
+ _write_sqlite(message_db, [(2, 3, ts, 1, "
", None)])
+ _write_resource_sqlite(resource_db, [(2, 3, ts, resource_hash)])
+ fake_app = FakeApp(db_dir, message_db, resource_db=resource_db)
+ output_path = os.path.join(tmp, "chat.json")
+
+ runner = CliRunner()
+ with patch.object(main, "AppContext", return_value=fake_app):
+ result = runner.invoke(main.cli, [
+ "export", CHAT_USERNAME,
+ "--format", "json",
+ "--output", output_path,
+ ])
+
+ self.assertEqual(result.exit_code, 0, result.output)
+ with open(output_path, encoding="utf-8") as f:
+ payload = json.load(f)
+
+ media = payload["messages"][0]["media"][0]
+ self.assertEqual(media["status"], "decoded")
+ self.assertTrue(media["path"].startswith("chat_assets/images/"))
+ self.assertTrue(os.path.exists(os.path.join(tmp, media["path"])))
+ self.assertEqual(payload["warnings"], [])
+
+ def test_cli_json_export_requires_output(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ db_dir = os.path.join(tmp, "xwechat", "db_storage")
+ os.makedirs(db_dir)
+ message_db = os.path.join(tmp, "message.db")
+ ts = int(datetime(2026, 6, 3, 10, 0).timestamp())
+ _write_sqlite(message_db, [(1, 1, ts, 1, "alice:\nhello", None)])
+ fake_app = FakeApp(db_dir, message_db)
+
+ runner = CliRunner()
+ with patch.object(main, "AppContext", return_value=fake_app):
+ result = runner.invoke(main.cli, [
+ "export", CHAT_USERNAME,
+ "--format", "json",
+ ])
+
+ self.assertEqual(result.exit_code, 2)
+ self.assertIn("--output", result.output)
+
+ def test_markdown_default_still_limits_to_500_messages(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ db_dir = os.path.join(tmp, "xwechat", "db_storage")
+ os.makedirs(db_dir)
+ message_db = os.path.join(tmp, "message.db")
+ ts = int(datetime(2026, 6, 3, 10, 0).timestamp())
+ rows = [
+ (i, 1, ts + i, 1, f"alice:\nmessage {i}", None)
+ for i in range(1, 502)
+ ]
+ _write_sqlite(message_db, rows)
+ fake_app = FakeApp(db_dir, message_db)
+
+ runner = CliRunner()
+ with patch.object(main, "AppContext", return_value=fake_app):
+ result = runner.invoke(main.cli, ["export", CHAT_USERNAME])
+
+ self.assertEqual(result.exit_code, 0, result.output)
+ self.assertEqual(result.output.count("\n- "), 500)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/uv.lock b/uv.lock
new file mode 100644
index 0000000..d0ba699
--- /dev/null
+++ b/uv.lock
@@ -0,0 +1,270 @@
+version = 1
+revision = 3
+requires-python = ">=3.10"
+
+[[package]]
+name = "click"
+version = "8.4.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/9b/98/518d8e5081007684232226f475082b30087d0f585e8457db087298259f49/click-8.4.1.tar.gz", hash = "sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96", size = 353007, upload-time = "2026-05-22T04:08:37.769Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl", hash = "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2", size = 116639, upload-time = "2026-05-22T04:08:35.26Z" },
+]
+
+[[package]]
+name = "colorama"
+version = "0.4.6"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
+]
+
+[[package]]
+name = "pillow"
+version = "11.3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f3/0d/d0d6dea55cd152ce3d6767bb38a8fc10e33796ba4ba210cbab9354b6d238/pillow-11.3.0.tar.gz", hash = "sha256:3828ee7586cd0b2091b6209e5ad53e20d0649bbe87164a459d0676e035e8f523", size = 47113069, upload-time = "2025-07-01T09:16:30.666Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/4c/5d/45a3553a253ac8763f3561371432a90bdbe6000fbdcf1397ffe502aa206c/pillow-11.3.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:1b9c17fd4ace828b3003dfd1e30bff24863e0eb59b535e8f80194d9cc7ecf860", size = 5316554, upload-time = "2025-07-01T09:13:39.342Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/c8/67c12ab069ef586a25a4a79ced553586748fad100c77c0ce59bb4983ac98/pillow-11.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:65dc69160114cdd0ca0f35cb434633c75e8e7fad4cf855177a05bf38678f73ad", size = 4686548, upload-time = "2025-07-01T09:13:41.835Z" },
+ { url = "https://files.pythonhosted.org/packages/2f/bd/6741ebd56263390b382ae4c5de02979af7f8bd9807346d068700dd6d5cf9/pillow-11.3.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7107195ddc914f656c7fc8e4a5e1c25f32e9236ea3ea860f257b0436011fddd0", size = 5859742, upload-time = "2025-07-03T13:09:47.439Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/0b/c412a9e27e1e6a829e6ab6c2dca52dd563efbedf4c9c6aa453d9a9b77359/pillow-11.3.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc3e831b563b3114baac7ec2ee86819eb03caa1a2cef0b481a5675b59c4fe23b", size = 7633087, upload-time = "2025-07-03T13:09:51.796Z" },
+ { url = "https://files.pythonhosted.org/packages/59/9d/9b7076aaf30f5dd17e5e5589b2d2f5a5d7e30ff67a171eb686e4eecc2adf/pillow-11.3.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f1f182ebd2303acf8c380a54f615ec883322593320a9b00438eb842c1f37ae50", size = 5963350, upload-time = "2025-07-01T09:13:43.865Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/16/1a6bf01fb622fb9cf5c91683823f073f053005c849b1f52ed613afcf8dae/pillow-11.3.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4445fa62e15936a028672fd48c4c11a66d641d2c05726c7ec1f8ba6a572036ae", size = 6631840, upload-time = "2025-07-01T09:13:46.161Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/e6/6ff7077077eb47fde78739e7d570bdcd7c10495666b6afcd23ab56b19a43/pillow-11.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:71f511f6b3b91dd543282477be45a033e4845a40278fa8dcdbfdb07109bf18f9", size = 6074005, upload-time = "2025-07-01T09:13:47.829Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/3a/b13f36832ea6d279a697231658199e0a03cd87ef12048016bdcc84131601/pillow-11.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:040a5b691b0713e1f6cbe222e0f4f74cd233421e105850ae3b3c0ceda520f42e", size = 6708372, upload-time = "2025-07-01T09:13:52.145Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/e4/61b2e1a7528740efbc70b3d581f33937e38e98ef3d50b05007267a55bcb2/pillow-11.3.0-cp310-cp310-win32.whl", hash = "sha256:89bd777bc6624fe4115e9fac3352c79ed60f3bb18651420635f26e643e3dd1f6", size = 6277090, upload-time = "2025-07-01T09:13:53.915Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/d3/60c781c83a785d6afbd6a326ed4d759d141de43aa7365725cbcd65ce5e54/pillow-11.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:19d2ff547c75b8e3ff46f4d9ef969a06c30ab2d4263a9e287733aa8b2429ce8f", size = 6985988, upload-time = "2025-07-01T09:13:55.699Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/28/4f4a0203165eefb3763939c6789ba31013a2e90adffb456610f30f613850/pillow-11.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:819931d25e57b513242859ce1876c58c59dc31587847bf74cfe06b2e0cb22d2f", size = 2422899, upload-time = "2025-07-01T09:13:57.497Z" },
+ { url = "https://files.pythonhosted.org/packages/db/26/77f8ed17ca4ffd60e1dcd220a6ec6d71210ba398cfa33a13a1cd614c5613/pillow-11.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:1cd110edf822773368b396281a2293aeb91c90a2db00d78ea43e7e861631b722", size = 5316531, upload-time = "2025-07-01T09:13:59.203Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/39/ee475903197ce709322a17a866892efb560f57900d9af2e55f86db51b0a5/pillow-11.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9c412fddd1b77a75aa904615ebaa6001f169b26fd467b4be93aded278266b288", size = 4686560, upload-time = "2025-07-01T09:14:01.101Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/90/442068a160fd179938ba55ec8c97050a612426fae5ec0a764e345839f76d/pillow-11.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1aa4de119a0ecac0a34a9c8bde33f34022e2e8f99104e47a3ca392fd60e37d", size = 5870978, upload-time = "2025-07-03T13:09:55.638Z" },
+ { url = "https://files.pythonhosted.org/packages/13/92/dcdd147ab02daf405387f0218dcf792dc6dd5b14d2573d40b4caeef01059/pillow-11.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:91da1d88226663594e3f6b4b8c3c8d85bd504117d043740a8e0ec449087cc494", size = 7641168, upload-time = "2025-07-03T13:10:00.37Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/db/839d6ba7fd38b51af641aa904e2960e7a5644d60ec754c046b7d2aee00e5/pillow-11.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:643f189248837533073c405ec2f0bb250ba54598cf80e8c1e043381a60632f58", size = 5973053, upload-time = "2025-07-01T09:14:04.491Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/2f/d7675ecae6c43e9f12aa8d58b6012683b20b6edfbdac7abcb4e6af7a3784/pillow-11.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:106064daa23a745510dabce1d84f29137a37224831d88eb4ce94bb187b1d7e5f", size = 6640273, upload-time = "2025-07-01T09:14:06.235Z" },
+ { url = "https://files.pythonhosted.org/packages/45/ad/931694675ede172e15b2ff03c8144a0ddaea1d87adb72bb07655eaffb654/pillow-11.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cd8ff254faf15591e724dc7c4ddb6bf4793efcbe13802a4ae3e863cd300b493e", size = 6082043, upload-time = "2025-07-01T09:14:07.978Z" },
+ { url = "https://files.pythonhosted.org/packages/3a/04/ba8f2b11fc80d2dd462d7abec16351b45ec99cbbaea4387648a44190351a/pillow-11.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:932c754c2d51ad2b2271fd01c3d121daaa35e27efae2a616f77bf164bc0b3e94", size = 6715516, upload-time = "2025-07-01T09:14:10.233Z" },
+ { url = "https://files.pythonhosted.org/packages/48/59/8cd06d7f3944cc7d892e8533c56b0acb68399f640786313275faec1e3b6f/pillow-11.3.0-cp311-cp311-win32.whl", hash = "sha256:b4b8f3efc8d530a1544e5962bd6b403d5f7fe8b9e08227c6b255f98ad82b4ba0", size = 6274768, upload-time = "2025-07-01T09:14:11.921Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/cc/29c0f5d64ab8eae20f3232da8f8571660aa0ab4b8f1331da5c2f5f9a938e/pillow-11.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:1a992e86b0dd7aeb1f053cd506508c0999d710a8f07b4c791c63843fc6a807ac", size = 6986055, upload-time = "2025-07-01T09:14:13.623Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/df/90bd886fabd544c25addd63e5ca6932c86f2b701d5da6c7839387a076b4a/pillow-11.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:30807c931ff7c095620fe04448e2c2fc673fcbb1ffe2a7da3fb39613489b1ddd", size = 2423079, upload-time = "2025-07-01T09:14:15.268Z" },
+ { url = "https://files.pythonhosted.org/packages/40/fe/1bc9b3ee13f68487a99ac9529968035cca2f0a51ec36892060edcc51d06a/pillow-11.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fdae223722da47b024b867c1ea0be64e0df702c5e0a60e27daad39bf960dd1e4", size = 5278800, upload-time = "2025-07-01T09:14:17.648Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/32/7e2ac19b5713657384cec55f89065fb306b06af008cfd87e572035b27119/pillow-11.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:921bd305b10e82b4d1f5e802b6850677f965d8394203d182f078873851dada69", size = 4686296, upload-time = "2025-07-01T09:14:19.828Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/1e/b9e12bbe6e4c2220effebc09ea0923a07a6da1e1f1bfbc8d7d29a01ce32b/pillow-11.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb76541cba2f958032d79d143b98a3a6b3ea87f0959bbe256c0b5e416599fd5d", size = 5871726, upload-time = "2025-07-03T13:10:04.448Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/33/e9200d2bd7ba00dc3ddb78df1198a6e80d7669cce6c2bdbeb2530a74ec58/pillow-11.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:67172f2944ebba3d4a7b54f2e95c786a3a50c21b88456329314caaa28cda70f6", size = 7644652, upload-time = "2025-07-03T13:10:10.391Z" },
+ { url = "https://files.pythonhosted.org/packages/41/f1/6f2427a26fc683e00d985bc391bdd76d8dd4e92fac33d841127eb8fb2313/pillow-11.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f07ed9f56a3b9b5f49d3661dc9607484e85c67e27f3e8be2c7d28ca032fec7", size = 5977787, upload-time = "2025-07-01T09:14:21.63Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/c9/06dd4a38974e24f932ff5f98ea3c546ce3f8c995d3f0985f8e5ba48bba19/pillow-11.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:676b2815362456b5b3216b4fd5bd89d362100dc6f4945154ff172e206a22c024", size = 6645236, upload-time = "2025-07-01T09:14:23.321Z" },
+ { url = "https://files.pythonhosted.org/packages/40/e7/848f69fb79843b3d91241bad658e9c14f39a32f71a301bcd1d139416d1be/pillow-11.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3e184b2f26ff146363dd07bde8b711833d7b0202e27d13540bfe2e35a323a809", size = 6086950, upload-time = "2025-07-01T09:14:25.237Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/1a/7cff92e695a2a29ac1958c2a0fe4c0b2393b60aac13b04a4fe2735cad52d/pillow-11.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6be31e3fc9a621e071bc17bb7de63b85cbe0bfae91bb0363c893cbe67247780d", size = 6723358, upload-time = "2025-07-01T09:14:27.053Z" },
+ { url = "https://files.pythonhosted.org/packages/26/7d/73699ad77895f69edff76b0f332acc3d497f22f5d75e5360f78cbcaff248/pillow-11.3.0-cp312-cp312-win32.whl", hash = "sha256:7b161756381f0918e05e7cb8a371fff367e807770f8fe92ecb20d905d0e1c149", size = 6275079, upload-time = "2025-07-01T09:14:30.104Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/ce/e7dfc873bdd9828f3b6e5c2bbb74e47a98ec23cc5c74fc4e54462f0d9204/pillow-11.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a6444696fce635783440b7f7a9fc24b3ad10a9ea3f0ab66c5905be1c19ccf17d", size = 6986324, upload-time = "2025-07-01T09:14:31.899Z" },
+ { url = "https://files.pythonhosted.org/packages/16/8f/b13447d1bf0b1f7467ce7d86f6e6edf66c0ad7cf44cf5c87a37f9bed9936/pillow-11.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:2aceea54f957dd4448264f9bf40875da0415c83eb85f55069d89c0ed436e3542", size = 2423067, upload-time = "2025-07-01T09:14:33.709Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/93/0952f2ed8db3a5a4c7a11f91965d6184ebc8cd7cbb7941a260d5f018cd2d/pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:1c627742b539bba4309df89171356fcb3cc5a9178355b2727d1b74a6cf155fbd", size = 2128328, upload-time = "2025-07-01T09:14:35.276Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/e8/100c3d114b1a0bf4042f27e0f87d2f25e857e838034e98ca98fe7b8c0a9c/pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:30b7c02f3899d10f13d7a48163c8969e4e653f8b43416d23d13d1bbfdc93b9f8", size = 2170652, upload-time = "2025-07-01T09:14:37.203Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/86/3f758a28a6e381758545f7cdb4942e1cb79abd271bea932998fc0db93cb6/pillow-11.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7859a4cc7c9295f5838015d8cc0a9c215b77e43d07a25e460f35cf516df8626f", size = 2227443, upload-time = "2025-07-01T09:14:39.344Z" },
+ { url = "https://files.pythonhosted.org/packages/01/f4/91d5b3ffa718df2f53b0dc109877993e511f4fd055d7e9508682e8aba092/pillow-11.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec1ee50470b0d050984394423d96325b744d55c701a439d2bd66089bff963d3c", size = 5278474, upload-time = "2025-07-01T09:14:41.843Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/0e/37d7d3eca6c879fbd9dba21268427dffda1ab00d4eb05b32923d4fbe3b12/pillow-11.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7db51d222548ccfd274e4572fdbf3e810a5e66b00608862f947b163e613b67dd", size = 4686038, upload-time = "2025-07-01T09:14:44.008Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/b0/3426e5c7f6565e752d81221af9d3676fdbb4f352317ceafd42899aaf5d8a/pillow-11.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2d6fcc902a24ac74495df63faad1884282239265c6839a0a6416d33faedfae7e", size = 5864407, upload-time = "2025-07-03T13:10:15.628Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/c1/c6c423134229f2a221ee53f838d4be9d82bab86f7e2f8e75e47b6bf6cd77/pillow-11.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0f5d8f4a08090c6d6d578351a2b91acf519a54986c055af27e7a93feae6d3f1", size = 7639094, upload-time = "2025-07-03T13:10:21.857Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/c9/09e6746630fe6372c67c648ff9deae52a2bc20897d51fa293571977ceb5d/pillow-11.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c37d8ba9411d6003bba9e518db0db0c58a680ab9fe5179f040b0463644bc9805", size = 5973503, upload-time = "2025-07-01T09:14:45.698Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/1c/a2a29649c0b1983d3ef57ee87a66487fdeb45132df66ab30dd37f7dbe162/pillow-11.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13f87d581e71d9189ab21fe0efb5a23e9f28552d5be6979e84001d3b8505abe8", size = 6642574, upload-time = "2025-07-01T09:14:47.415Z" },
+ { url = "https://files.pythonhosted.org/packages/36/de/d5cc31cc4b055b6c6fd990e3e7f0f8aaf36229a2698501bcb0cdf67c7146/pillow-11.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:023f6d2d11784a465f09fd09a34b150ea4672e85fb3d05931d89f373ab14abb2", size = 6084060, upload-time = "2025-07-01T09:14:49.636Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/ea/502d938cbaeec836ac28a9b730193716f0114c41325db428e6b280513f09/pillow-11.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:45dfc51ac5975b938e9809451c51734124e73b04d0f0ac621649821a63852e7b", size = 6721407, upload-time = "2025-07-01T09:14:51.962Z" },
+ { url = "https://files.pythonhosted.org/packages/45/9c/9c5e2a73f125f6cbc59cc7087c8f2d649a7ae453f83bd0362ff7c9e2aee2/pillow-11.3.0-cp313-cp313-win32.whl", hash = "sha256:a4d336baed65d50d37b88ca5b60c0fa9d81e3a87d4a7930d3880d1624d5b31f3", size = 6273841, upload-time = "2025-07-01T09:14:54.142Z" },
+ { url = "https://files.pythonhosted.org/packages/23/85/397c73524e0cd212067e0c969aa245b01d50183439550d24d9f55781b776/pillow-11.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0bce5c4fd0921f99d2e858dc4d4d64193407e1b99478bc5cacecba2311abde51", size = 6978450, upload-time = "2025-07-01T09:14:56.436Z" },
+ { url = "https://files.pythonhosted.org/packages/17/d2/622f4547f69cd173955194b78e4d19ca4935a1b0f03a302d655c9f6aae65/pillow-11.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:1904e1264881f682f02b7f8167935cce37bc97db457f8e7849dc3a6a52b99580", size = 2423055, upload-time = "2025-07-01T09:14:58.072Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/80/a8a2ac21dda2e82480852978416cfacd439a4b490a501a288ecf4fe2532d/pillow-11.3.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4c834a3921375c48ee6b9624061076bc0a32a60b5532b322cc0ea64e639dd50e", size = 5281110, upload-time = "2025-07-01T09:14:59.79Z" },
+ { url = "https://files.pythonhosted.org/packages/44/d6/b79754ca790f315918732e18f82a8146d33bcd7f4494380457ea89eb883d/pillow-11.3.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5e05688ccef30ea69b9317a9ead994b93975104a677a36a8ed8106be9260aa6d", size = 4689547, upload-time = "2025-07-01T09:15:01.648Z" },
+ { url = "https://files.pythonhosted.org/packages/49/20/716b8717d331150cb00f7fdd78169c01e8e0c219732a78b0e59b6bdb2fd6/pillow-11.3.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1019b04af07fc0163e2810167918cb5add8d74674b6267616021ab558dc98ced", size = 5901554, upload-time = "2025-07-03T13:10:27.018Z" },
+ { url = "https://files.pythonhosted.org/packages/74/cf/a9f3a2514a65bb071075063a96f0a5cf949c2f2fce683c15ccc83b1c1cab/pillow-11.3.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f944255db153ebb2b19c51fe85dd99ef0ce494123f21b9db4877ffdfc5590c7c", size = 7669132, upload-time = "2025-07-03T13:10:33.01Z" },
+ { url = "https://files.pythonhosted.org/packages/98/3c/da78805cbdbee9cb43efe8261dd7cc0b4b93f2ac79b676c03159e9db2187/pillow-11.3.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f85acb69adf2aaee8b7da124efebbdb959a104db34d3a2cb0f3793dbae422a8", size = 6005001, upload-time = "2025-07-01T09:15:03.365Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/fa/ce044b91faecf30e635321351bba32bab5a7e034c60187fe9698191aef4f/pillow-11.3.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05f6ecbeff5005399bb48d198f098a9b4b6bdf27b8487c7f38ca16eeb070cd59", size = 6668814, upload-time = "2025-07-01T09:15:05.655Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/51/90f9291406d09bf93686434f9183aba27b831c10c87746ff49f127ee80cb/pillow-11.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a7bc6e6fd0395bc052f16b1a8670859964dbd7003bd0af2ff08342eb6e442cfe", size = 6113124, upload-time = "2025-07-01T09:15:07.358Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/5a/6fec59b1dfb619234f7636d4157d11fb4e196caeee220232a8d2ec48488d/pillow-11.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:83e1b0161c9d148125083a35c1c5a89db5b7054834fd4387499e06552035236c", size = 6747186, upload-time = "2025-07-01T09:15:09.317Z" },
+ { url = "https://files.pythonhosted.org/packages/49/6b/00187a044f98255225f172de653941e61da37104a9ea60e4f6887717e2b5/pillow-11.3.0-cp313-cp313t-win32.whl", hash = "sha256:2a3117c06b8fb646639dce83694f2f9eac405472713fcb1ae887469c0d4f6788", size = 6277546, upload-time = "2025-07-01T09:15:11.311Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/5c/6caaba7e261c0d75bab23be79f1d06b5ad2a2ae49f028ccec801b0e853d6/pillow-11.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:857844335c95bea93fb39e0fa2726b4d9d758850b34075a7e3ff4f4fa3aa3b31", size = 6985102, upload-time = "2025-07-01T09:15:13.164Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/7e/b623008460c09a0cb38263c93b828c666493caee2eb34ff67f778b87e58c/pillow-11.3.0-cp313-cp313t-win_arm64.whl", hash = "sha256:8797edc41f3e8536ae4b10897ee2f637235c94f27404cac7297f7b607dd0716e", size = 2424803, upload-time = "2025-07-01T09:15:15.695Z" },
+ { url = "https://files.pythonhosted.org/packages/73/f4/04905af42837292ed86cb1b1dabe03dce1edc008ef14c473c5c7e1443c5d/pillow-11.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:d9da3df5f9ea2a89b81bb6087177fb1f4d1c7146d583a3fe5c672c0d94e55e12", size = 5278520, upload-time = "2025-07-01T09:15:17.429Z" },
+ { url = "https://files.pythonhosted.org/packages/41/b0/33d79e377a336247df6348a54e6d2a2b85d644ca202555e3faa0cf811ecc/pillow-11.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0b275ff9b04df7b640c59ec5a3cb113eefd3795a8df80bac69646ef699c6981a", size = 4686116, upload-time = "2025-07-01T09:15:19.423Z" },
+ { url = "https://files.pythonhosted.org/packages/49/2d/ed8bc0ab219ae8768f529597d9509d184fe8a6c4741a6864fea334d25f3f/pillow-11.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0743841cabd3dba6a83f38a92672cccbd69af56e3e91777b0ee7f4dba4385632", size = 5864597, upload-time = "2025-07-03T13:10:38.404Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/3d/b932bb4225c80b58dfadaca9d42d08d0b7064d2d1791b6a237f87f661834/pillow-11.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2465a69cf967b8b49ee1b96d76718cd98c4e925414ead59fdf75cf0fd07df673", size = 7638246, upload-time = "2025-07-03T13:10:44.987Z" },
+ { url = "https://files.pythonhosted.org/packages/09/b5/0487044b7c096f1b48f0d7ad416472c02e0e4bf6919541b111efd3cae690/pillow-11.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41742638139424703b4d01665b807c6468e23e699e8e90cffefe291c5832b027", size = 5973336, upload-time = "2025-07-01T09:15:21.237Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/2d/524f9318f6cbfcc79fbc004801ea6b607ec3f843977652fdee4857a7568b/pillow-11.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:93efb0b4de7e340d99057415c749175e24c8864302369e05914682ba642e5d77", size = 6642699, upload-time = "2025-07-01T09:15:23.186Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/d2/a9a4f280c6aefedce1e8f615baaa5474e0701d86dd6f1dede66726462bbd/pillow-11.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7966e38dcd0fa11ca390aed7c6f20454443581d758242023cf36fcb319b1a874", size = 6083789, upload-time = "2025-07-01T09:15:25.1Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/54/86b0cd9dbb683a9d5e960b66c7379e821a19be4ac5810e2e5a715c09a0c0/pillow-11.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:98a9afa7b9007c67ed84c57c9e0ad86a6000da96eaa638e4f8abe5b65ff83f0a", size = 6720386, upload-time = "2025-07-01T09:15:27.378Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/95/88efcaf384c3588e24259c4203b909cbe3e3c2d887af9e938c2022c9dd48/pillow-11.3.0-cp314-cp314-win32.whl", hash = "sha256:02a723e6bf909e7cea0dac1b0e0310be9d7650cd66222a5f1c571455c0a45214", size = 6370911, upload-time = "2025-07-01T09:15:29.294Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/cc/934e5820850ec5eb107e7b1a72dd278140731c669f396110ebc326f2a503/pillow-11.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:a418486160228f64dd9e9efcd132679b7a02a5f22c982c78b6fc7dab3fefb635", size = 7117383, upload-time = "2025-07-01T09:15:31.128Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/e9/9c0a616a71da2a5d163aa37405e8aced9a906d574b4a214bede134e731bc/pillow-11.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:155658efb5e044669c08896c0c44231c5e9abcaadbc5cd3648df2f7c0b96b9a6", size = 2511385, upload-time = "2025-07-01T09:15:33.328Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/33/c88376898aff369658b225262cd4f2659b13e8178e7534df9e6e1fa289f6/pillow-11.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:59a03cdf019efbfeeed910bf79c7c93255c3d54bc45898ac2a4140071b02b4ae", size = 5281129, upload-time = "2025-07-01T09:15:35.194Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/70/d376247fb36f1844b42910911c83a02d5544ebd2a8bad9efcc0f707ea774/pillow-11.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f8a5827f84d973d8636e9dc5764af4f0cf2318d26744b3d902931701b0d46653", size = 4689580, upload-time = "2025-07-01T09:15:37.114Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/1c/537e930496149fbac69efd2fc4329035bbe2e5475b4165439e3be9cb183b/pillow-11.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ee92f2fd10f4adc4b43d07ec5e779932b4eb3dbfbc34790ada5a6669bc095aa6", size = 5902860, upload-time = "2025-07-03T13:10:50.248Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/57/80f53264954dcefeebcf9dae6e3eb1daea1b488f0be8b8fef12f79a3eb10/pillow-11.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c96d333dcf42d01f47b37e0979b6bd73ec91eae18614864622d9b87bbd5bbf36", size = 7670694, upload-time = "2025-07-03T13:10:56.432Z" },
+ { url = "https://files.pythonhosted.org/packages/70/ff/4727d3b71a8578b4587d9c276e90efad2d6fe0335fd76742a6da08132e8c/pillow-11.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c96f993ab8c98460cd0c001447bff6194403e8b1d7e149ade5f00594918128b", size = 6005888, upload-time = "2025-07-01T09:15:39.436Z" },
+ { url = "https://files.pythonhosted.org/packages/05/ae/716592277934f85d3be51d7256f3636672d7b1abfafdc42cf3f8cbd4b4c8/pillow-11.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:41342b64afeba938edb034d122b2dda5db2139b9a4af999729ba8818e0056477", size = 6670330, upload-time = "2025-07-01T09:15:41.269Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/bb/7fe6cddcc8827b01b1a9766f5fdeb7418680744f9082035bdbabecf1d57f/pillow-11.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:068d9c39a2d1b358eb9f245ce7ab1b5c3246c7c8c7d9ba58cfa5b43146c06e50", size = 6114089, upload-time = "2025-07-01T09:15:43.13Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/f5/06bfaa444c8e80f1a8e4bff98da9c83b37b5be3b1deaa43d27a0db37ef84/pillow-11.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a1bc6ba083b145187f648b667e05a2534ecc4b9f2784c2cbe3089e44868f2b9b", size = 6748206, upload-time = "2025-07-01T09:15:44.937Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/77/bc6f92a3e8e6e46c0ca78abfffec0037845800ea38c73483760362804c41/pillow-11.3.0-cp314-cp314t-win32.whl", hash = "sha256:118ca10c0d60b06d006be10a501fd6bbdfef559251ed31b794668ed569c87e12", size = 6377370, upload-time = "2025-07-01T09:15:46.673Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/82/3a721f7d69dca802befb8af08b7c79ebcab461007ce1c18bd91a5d5896f9/pillow-11.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8924748b688aa210d79883357d102cd64690e56b923a186f35a82cbc10f997db", size = 7121500, upload-time = "2025-07-01T09:15:48.512Z" },
+ { url = "https://files.pythonhosted.org/packages/89/c7/5572fa4a3f45740eaab6ae86fcdf7195b55beac1371ac8c619d880cfe948/pillow-11.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:79ea0d14d3ebad43ec77ad5272e6ff9bba5b679ef73375ea760261207fa8e0aa", size = 2512835, upload-time = "2025-07-01T09:15:50.399Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/8b/209bd6b62ce8367f47e68a218bffac88888fdf2c9fcf1ecadc6c3ec1ebc7/pillow-11.3.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:3cee80663f29e3843b68199b9d6f4f54bd1d4a6b59bdd91bceefc51238bcb967", size = 5270556, upload-time = "2025-07-01T09:16:09.961Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/e6/231a0b76070c2cfd9e260a7a5b504fb72da0a95279410fa7afd99d9751d6/pillow-11.3.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:b5f56c3f344f2ccaf0dd875d3e180f631dc60a51b314295a3e681fe8cf851fbe", size = 4654625, upload-time = "2025-07-01T09:16:11.913Z" },
+ { url = "https://files.pythonhosted.org/packages/13/f4/10cf94fda33cb12765f2397fc285fa6d8eb9c29de7f3185165b702fc7386/pillow-11.3.0-pp310-pypy310_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e67d793d180c9df62f1f40aee3accca4829d3794c95098887edc18af4b8b780c", size = 4874207, upload-time = "2025-07-03T13:11:10.201Z" },
+ { url = "https://files.pythonhosted.org/packages/72/c9/583821097dc691880c92892e8e2d41fe0a5a3d6021f4963371d2f6d57250/pillow-11.3.0-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d000f46e2917c705e9fb93a3606ee4a819d1e3aa7a9b442f6444f07e77cf5e25", size = 6583939, upload-time = "2025-07-03T13:11:15.68Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/8e/5c9d410f9217b12320efc7c413e72693f48468979a013ad17fd690397b9a/pillow-11.3.0-pp310-pypy310_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:527b37216b6ac3a12d7838dc3bd75208ec57c1c6d11ef01902266a5a0c14fc27", size = 4957166, upload-time = "2025-07-01T09:16:13.74Z" },
+ { url = "https://files.pythonhosted.org/packages/62/bb/78347dbe13219991877ffb3a91bf09da8317fbfcd4b5f9140aeae020ad71/pillow-11.3.0-pp310-pypy310_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:be5463ac478b623b9dd3937afd7fb7ab3d79dd290a28e2b6df292dc75063eb8a", size = 5581482, upload-time = "2025-07-01T09:16:16.107Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/28/1000353d5e61498aaeaaf7f1e4b49ddb05f2c6575f9d4f9f914a3538b6e1/pillow-11.3.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:8dc70ca24c110503e16918a658b869019126ecfe03109b754c402daff12b3d9f", size = 6984596, upload-time = "2025-07-01T09:16:18.07Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/e3/6fa84033758276fb31da12e5fb66ad747ae83b93c67af17f8c6ff4cc8f34/pillow-11.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7c8ec7a017ad1bd562f93dbd8505763e688d388cde6e4a010ae1486916e713e6", size = 5270566, upload-time = "2025-07-01T09:16:19.801Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/ee/e8d2e1ab4892970b561e1ba96cbd59c0d28cf66737fc44abb2aec3795a4e/pillow-11.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:9ab6ae226de48019caa8074894544af5b53a117ccb9d3b3dcb2871464c829438", size = 4654618, upload-time = "2025-07-01T09:16:21.818Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/6d/17f80f4e1f0761f02160fc433abd4109fa1548dcfdca46cfdadaf9efa565/pillow-11.3.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fe27fb049cdcca11f11a7bfda64043c37b30e6b91f10cb5bab275806c32f6ab3", size = 4874248, upload-time = "2025-07-03T13:11:20.738Z" },
+ { url = "https://files.pythonhosted.org/packages/de/5f/c22340acd61cef960130585bbe2120e2fd8434c214802f07e8c03596b17e/pillow-11.3.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:465b9e8844e3c3519a983d58b80be3f668e2a7a5db97f2784e7079fbc9f9822c", size = 6583963, upload-time = "2025-07-03T13:11:26.283Z" },
+ { url = "https://files.pythonhosted.org/packages/31/5e/03966aedfbfcbb4d5f8aa042452d3361f325b963ebbadddac05b122e47dd/pillow-11.3.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5418b53c0d59b3824d05e029669efa023bbef0f3e92e75ec8428f3799487f361", size = 4957170, upload-time = "2025-07-01T09:16:23.762Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/2d/e082982aacc927fc2cab48e1e731bdb1643a1406acace8bed0900a61464e/pillow-11.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:504b6f59505f08ae014f724b6207ff6222662aab5cc9542577fb084ed0676ac7", size = 5581505, upload-time = "2025-07-01T09:16:25.593Z" },
+ { url = "https://files.pythonhosted.org/packages/34/e7/ae39f538fd6844e982063c3a5e4598b8ced43b9633baa3a85ef33af8c05c/pillow-11.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c84d689db21a1c397d001aa08241044aa2069e7587b398c8cc63020390b1c1b8", size = 6984598, upload-time = "2025-07-01T09:16:27.732Z" },
+]
+
+[[package]]
+name = "pycryptodome"
+version = "3.23.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/8e/a6/8452177684d5e906854776276ddd34eca30d1b1e15aa1ee9cefc289a33f5/pycryptodome-3.23.0.tar.gz", hash = "sha256:447700a657182d60338bab09fdb27518f8856aecd80ae4c6bdddb67ff5da44ef", size = 4921276, upload-time = "2025-05-17T17:21:45.242Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/04/5d/bdb09489b63cd34a976cc9e2a8d938114f7a53a74d3dd4f125ffa49dce82/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:0011f7f00cdb74879142011f95133274741778abba114ceca229adbf8e62c3e4", size = 2495152, upload-time = "2025-05-17T17:20:20.833Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/ce/7840250ed4cc0039c433cd41715536f926d6e86ce84e904068eb3244b6a6/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:90460fc9e088ce095f9ee8356722d4f10f86e5be06e2354230a9880b9c549aae", size = 1639348, upload-time = "2025-05-17T17:20:23.171Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/f0/991da24c55c1f688d6a3b5a11940567353f74590734ee4a64294834ae472/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4764e64b269fc83b00f682c47443c2e6e85b18273712b98aa43bcb77f8570477", size = 2184033, upload-time = "2025-05-17T17:20:25.424Z" },
+ { url = "https://files.pythonhosted.org/packages/54/16/0e11882deddf00f68b68dd4e8e442ddc30641f31afeb2bc25588124ac8de/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb8f24adb74984aa0e5d07a2368ad95276cf38051fe2dc6605cbcf482e04f2a7", size = 2270142, upload-time = "2025-05-17T17:20:27.808Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/fc/4347fea23a3f95ffb931f383ff28b3f7b1fe868739182cb76718c0da86a1/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d97618c9c6684a97ef7637ba43bdf6663a2e2e77efe0f863cce97a76af396446", size = 2309384, upload-time = "2025-05-17T17:20:30.765Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/d9/c5261780b69ce66d8cfab25d2797bd6e82ba0241804694cd48be41add5eb/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9a53a4fe5cb075075d515797d6ce2f56772ea7e6a1e5e4b96cf78a14bac3d265", size = 2183237, upload-time = "2025-05-17T17:20:33.736Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/6f/3af2ffedd5cfa08c631f89452c6648c4d779e7772dfc388c77c920ca6bbf/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:763d1d74f56f031788e5d307029caef067febf890cd1f8bf61183ae142f1a77b", size = 2343898, upload-time = "2025-05-17T17:20:36.086Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/dc/9060d807039ee5de6e2f260f72f3d70ac213993a804f5e67e0a73a56dd2f/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:954af0e2bd7cea83ce72243b14e4fb518b18f0c1649b576d114973e2073b273d", size = 2269197, upload-time = "2025-05-17T17:20:38.414Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/34/e6c8ca177cb29dcc4967fef73f5de445912f93bd0343c9c33c8e5bf8cde8/pycryptodome-3.23.0-cp313-cp313t-win32.whl", hash = "sha256:257bb3572c63ad8ba40b89f6fc9d63a2a628e9f9708d31ee26560925ebe0210a", size = 1768600, upload-time = "2025-05-17T17:20:40.688Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/1d/89756b8d7ff623ad0160f4539da571d1f594d21ee6d68be130a6eccb39a4/pycryptodome-3.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6501790c5b62a29fcb227bd6b62012181d886a767ce9ed03b303d1f22eb5c625", size = 1799740, upload-time = "2025-05-17T17:20:42.413Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/61/35a64f0feaea9fd07f0d91209e7be91726eb48c0f1bfc6720647194071e4/pycryptodome-3.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9a77627a330ab23ca43b48b130e202582e91cc69619947840ea4d2d1be21eb39", size = 1703685, upload-time = "2025-05-17T17:20:44.388Z" },
+ { url = "https://files.pythonhosted.org/packages/db/6c/a1f71542c969912bb0e106f64f60a56cc1f0fabecf9396f45accbe63fa68/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:187058ab80b3281b1de11c2e6842a357a1f71b42cb1e15bce373f3d238135c27", size = 2495627, upload-time = "2025-05-17T17:20:47.139Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/4e/a066527e079fc5002390c8acdd3aca431e6ea0a50ffd7201551175b47323/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:cfb5cd445280c5b0a4e6187a7ce8de5a07b5f3f897f235caa11f1f435f182843", size = 1640362, upload-time = "2025-05-17T17:20:50.392Z" },
+ { url = "https://files.pythonhosted.org/packages/50/52/adaf4c8c100a8c49d2bd058e5b551f73dfd8cb89eb4911e25a0c469b6b4e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67bd81fcbe34f43ad9422ee8fd4843c8e7198dd88dd3d40e6de42ee65fbe1490", size = 2182625, upload-time = "2025-05-17T17:20:52.866Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/e9/a09476d436d0ff1402ac3867d933c61805ec2326c6ea557aeeac3825604e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8987bd3307a39bc03df5c8e0e3d8be0c4c3518b7f044b0f4c15d1aa78f52575", size = 2268954, upload-time = "2025-05-17T17:20:55.027Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/c5/ffe6474e0c551d54cab931918127c46d70cab8f114e0c2b5a3c071c2f484/pycryptodome-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa0698f65e5b570426fc31b8162ed4603b0c2841cbb9088e2b01641e3065915b", size = 2308534, upload-time = "2025-05-17T17:20:57.279Z" },
+ { url = "https://files.pythonhosted.org/packages/18/28/e199677fc15ecf43010f2463fde4c1a53015d1fe95fb03bca2890836603a/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:53ecbafc2b55353edcebd64bf5da94a2a2cdf5090a6915bcca6eca6cc452585a", size = 2181853, upload-time = "2025-05-17T17:20:59.322Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/ea/4fdb09f2165ce1365c9eaefef36625583371ee514db58dc9b65d3a255c4c/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:156df9667ad9f2ad26255926524e1c136d6664b741547deb0a86a9acf5ea631f", size = 2342465, upload-time = "2025-05-17T17:21:03.83Z" },
+ { url = "https://files.pythonhosted.org/packages/22/82/6edc3fc42fe9284aead511394bac167693fb2b0e0395b28b8bedaa07ef04/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:dea827b4d55ee390dc89b2afe5927d4308a8b538ae91d9c6f7a5090f397af1aa", size = 2267414, upload-time = "2025-05-17T17:21:06.72Z" },
+ { url = "https://files.pythonhosted.org/packages/59/fe/aae679b64363eb78326c7fdc9d06ec3de18bac68be4b612fc1fe8902693c/pycryptodome-3.23.0-cp37-abi3-win32.whl", hash = "sha256:507dbead45474b62b2bbe318eb1c4c8ee641077532067fec9c1aa82c31f84886", size = 1768484, upload-time = "2025-05-17T17:21:08.535Z" },
+ { url = "https://files.pythonhosted.org/packages/54/2f/e97a1b8294db0daaa87012c24a7bb714147c7ade7656973fd6c736b484ff/pycryptodome-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:c75b52aacc6c0c260f204cbdd834f76edc9fb0d8e0da9fbf8352ef58202564e2", size = 1799636, upload-time = "2025-05-17T17:21:10.393Z" },
+ { url = "https://files.pythonhosted.org/packages/18/3d/f9441a0d798bf2b1e645adc3265e55706aead1255ccdad3856dbdcffec14/pycryptodome-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c", size = 1703675, upload-time = "2025-05-17T17:21:13.146Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/12/e33935a0709c07de084d7d58d330ec3f4daf7910a18e77937affdb728452/pycryptodome-3.23.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ddb95b49df036ddd264a0ad246d1be5b672000f12d6961ea2c267083a5e19379", size = 1623886, upload-time = "2025-05-17T17:21:20.614Z" },
+ { url = "https://files.pythonhosted.org/packages/22/0b/aa8f9419f25870889bebf0b26b223c6986652bdf071f000623df11212c90/pycryptodome-3.23.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e95564beb8782abfd9e431c974e14563a794a4944c29d6d3b7b5ea042110b4", size = 1672151, upload-time = "2025-05-17T17:21:22.666Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/5e/63f5cbde2342b7f70a39e591dbe75d9809d6338ce0b07c10406f1a140cdc/pycryptodome-3.23.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14e15c081e912c4b0d75632acd8382dfce45b258667aa3c67caf7a4d4c13f630", size = 1664461, upload-time = "2025-05-17T17:21:25.225Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/92/608fbdad566ebe499297a86aae5f2a5263818ceeecd16733006f1600403c/pycryptodome-3.23.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a7fc76bf273353dc7e5207d172b83f569540fc9a28d63171061c42e361d22353", size = 1702440, upload-time = "2025-05-17T17:21:27.991Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/92/2eadd1341abd2989cce2e2740b4423608ee2014acb8110438244ee97d7ff/pycryptodome-3.23.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:45c69ad715ca1a94f778215a11e66b7ff989d792a4d63b68dc586a1da1392ff5", size = 1803005, upload-time = "2025-05-17T17:21:31.37Z" },
+]
+
+[[package]]
+name = "wechat-cli"
+version = "0.2.4"
+source = { editable = "." }
+dependencies = [
+ { name = "click" },
+ { name = "pillow" },
+ { name = "pycryptodome" },
+ { name = "zstandard" },
+]
+
+[package.metadata]
+requires-dist = [
+ { name = "click", specifier = ">=8.1,<9" },
+ { name = "pillow", specifier = ">=10,<12" },
+ { name = "pycryptodome", specifier = ">=3.19,<4" },
+ { name = "zstandard", specifier = ">=0.22,<1" },
+]
+
+[[package]]
+name = "zstandard"
+version = "0.25.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/fd/aa/3e0508d5a5dd96529cdc5a97011299056e14c6505b678fd58938792794b1/zstandard-0.25.0.tar.gz", hash = "sha256:7713e1179d162cf5c7906da876ec2ccb9c3a9dcbdffef0cc7f70c3667a205f0b", size = 711513, upload-time = "2025-09-14T22:15:54.002Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/56/7a/28efd1d371f1acd037ac64ed1c5e2b41514a6cc937dd6ab6a13ab9f0702f/zstandard-0.25.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e59fdc271772f6686e01e1b3b74537259800f57e24280be3f29c8a0deb1904dd", size = 795256, upload-time = "2025-09-14T22:15:56.415Z" },
+ { url = "https://files.pythonhosted.org/packages/96/34/ef34ef77f1ee38fc8e4f9775217a613b452916e633c4f1d98f31db52c4a5/zstandard-0.25.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4d441506e9b372386a5271c64125f72d5df6d2a8e8a2a45a0ae09b03cb781ef7", size = 640565, upload-time = "2025-09-14T22:15:58.177Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/1b/4fdb2c12eb58f31f28c4d28e8dc36611dd7205df8452e63f52fb6261d13e/zstandard-0.25.0-cp310-cp310-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:ab85470ab54c2cb96e176f40342d9ed41e58ca5733be6a893b730e7af9c40550", size = 5345306, upload-time = "2025-09-14T22:16:00.165Z" },
+ { url = "https://files.pythonhosted.org/packages/73/28/a44bdece01bca027b079f0e00be3b6bd89a4df180071da59a3dd7381665b/zstandard-0.25.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e05ab82ea7753354bb054b92e2f288afb750e6b439ff6ca78af52939ebbc476d", size = 5055561, upload-time = "2025-09-14T22:16:02.22Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/74/68341185a4f32b274e0fc3410d5ad0750497e1acc20bd0f5b5f64ce17785/zstandard-0.25.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:78228d8a6a1c177a96b94f7e2e8d012c55f9c760761980da16ae7546a15a8e9b", size = 5402214, upload-time = "2025-09-14T22:16:04.109Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/67/f92e64e748fd6aaffe01e2b75a083c0c4fd27abe1c8747fee4555fcee7dd/zstandard-0.25.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:2b6bd67528ee8b5c5f10255735abc21aa106931f0dbaf297c7be0c886353c3d0", size = 5449703, upload-time = "2025-09-14T22:16:06.312Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/e5/6d36f92a197c3c17729a2125e29c169f460538a7d939a27eaaa6dcfcba8e/zstandard-0.25.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4b6d83057e713ff235a12e73916b6d356e3084fd3d14ced499d84240f3eecee0", size = 5556583, upload-time = "2025-09-14T22:16:08.457Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/83/41939e60d8d7ebfe2b747be022d0806953799140a702b90ffe214d557638/zstandard-0.25.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:9174f4ed06f790a6869b41cba05b43eeb9a35f8993c4422ab853b705e8112bbd", size = 5045332, upload-time = "2025-09-14T22:16:10.444Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/87/d3ee185e3d1aa0133399893697ae91f221fda79deb61adbe998a7235c43f/zstandard-0.25.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:25f8f3cd45087d089aef5ba3848cd9efe3ad41163d3400862fb42f81a3a46701", size = 5572283, upload-time = "2025-09-14T22:16:12.128Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/1d/58635ae6104df96671076ac7d4ae7816838ce7debd94aecf83e30b7121b0/zstandard-0.25.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3756b3e9da9b83da1796f8809dd57cb024f838b9eeafde28f3cb472012797ac1", size = 4959754, upload-time = "2025-09-14T22:16:14.225Z" },
+ { url = "https://files.pythonhosted.org/packages/75/d6/57e9cb0a9983e9a229dd8fd2e6e96593ef2aa82a3907188436f22b111ccd/zstandard-0.25.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:81dad8d145d8fd981b2962b686b2241d3a1ea07733e76a2f15435dfb7fb60150", size = 5266477, upload-time = "2025-09-14T22:16:16.343Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/a9/ee891e5edf33a6ebce0a028726f0bbd8567effe20fe3d5808c42323e8542/zstandard-0.25.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:a5a419712cf88862a45a23def0ae063686db3d324cec7edbe40509d1a79a0aab", size = 5440914, upload-time = "2025-09-14T22:16:18.453Z" },
+ { url = "https://files.pythonhosted.org/packages/58/08/a8522c28c08031a9521f27abc6f78dbdee7312a7463dd2cfc658b813323b/zstandard-0.25.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:e7360eae90809efd19b886e59a09dad07da4ca9ba096752e61a2e03c8aca188e", size = 5819847, upload-time = "2025-09-14T22:16:20.559Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/11/4c91411805c3f7b6f31c60e78ce347ca48f6f16d552fc659af6ec3b73202/zstandard-0.25.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:75ffc32a569fb049499e63ce68c743155477610532da1eb38e7f24bf7cd29e74", size = 5363131, upload-time = "2025-09-14T22:16:22.206Z" },
+ { url = "https://files.pythonhosted.org/packages/ef/d6/8c4bd38a3b24c4c7676a7a3d8de85d6ee7a983602a734b9f9cdefb04a5d6/zstandard-0.25.0-cp310-cp310-win32.whl", hash = "sha256:106281ae350e494f4ac8a80470e66d1fe27e497052c8d9c3b95dc4cf1ade81aa", size = 436469, upload-time = "2025-09-14T22:16:25.002Z" },
+ { url = "https://files.pythonhosted.org/packages/93/90/96d50ad417a8ace5f841b3228e93d1bb13e6ad356737f42e2dde30d8bd68/zstandard-0.25.0-cp310-cp310-win_amd64.whl", hash = "sha256:ea9d54cc3d8064260114a0bbf3479fc4a98b21dffc89b3459edd506b69262f6e", size = 506100, upload-time = "2025-09-14T22:16:23.569Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/83/c3ca27c363d104980f1c9cee1101cc8ba724ac8c28a033ede6aab89585b1/zstandard-0.25.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:933b65d7680ea337180733cf9e87293cc5500cc0eb3fc8769f4d3c88d724ec5c", size = 795254, upload-time = "2025-09-14T22:16:26.137Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/4d/e66465c5411a7cf4866aeadc7d108081d8ceba9bc7abe6b14aa21c671ec3/zstandard-0.25.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3f79487c687b1fc69f19e487cd949bf3aae653d181dfb5fde3bf6d18894706f", size = 640559, upload-time = "2025-09-14T22:16:27.973Z" },
+ { url = "https://files.pythonhosted.org/packages/12/56/354fe655905f290d3b147b33fe946b0f27e791e4b50a5f004c802cb3eb7b/zstandard-0.25.0-cp311-cp311-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:0bbc9a0c65ce0eea3c34a691e3c4b6889f5f3909ba4822ab385fab9057099431", size = 5348020, upload-time = "2025-09-14T22:16:29.523Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/13/2b7ed68bd85e69a2069bcc72141d378f22cae5a0f3b353a2c8f50ef30c1b/zstandard-0.25.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:01582723b3ccd6939ab7b3a78622c573799d5d8737b534b86d0e06ac18dbde4a", size = 5058126, upload-time = "2025-09-14T22:16:31.811Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/dd/fdaf0674f4b10d92cb120ccff58bbb6626bf8368f00ebfd2a41ba4a0dc99/zstandard-0.25.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5f1ad7bf88535edcf30038f6919abe087f606f62c00a87d7e33e7fc57cb69fcc", size = 5405390, upload-time = "2025-09-14T22:16:33.486Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/67/354d1555575bc2490435f90d67ca4dd65238ff2f119f30f72d5cde09c2ad/zstandard-0.25.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:06acb75eebeedb77b69048031282737717a63e71e4ae3f77cc0c3b9508320df6", size = 5452914, upload-time = "2025-09-14T22:16:35.277Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/1f/e9cfd801a3f9190bf3e759c422bbfd2247db9d7f3d54a56ecde70137791a/zstandard-0.25.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9300d02ea7c6506f00e627e287e0492a5eb0371ec1670ae852fefffa6164b072", size = 5559635, upload-time = "2025-09-14T22:16:37.141Z" },
+ { url = "https://files.pythonhosted.org/packages/21/88/5ba550f797ca953a52d708c8e4f380959e7e3280af029e38fbf47b55916e/zstandard-0.25.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:bfd06b1c5584b657a2892a6014c2f4c20e0db0208c159148fa78c65f7e0b0277", size = 5048277, upload-time = "2025-09-14T22:16:38.807Z" },
+ { url = "https://files.pythonhosted.org/packages/46/c0/ca3e533b4fa03112facbe7fbe7779cb1ebec215688e5df576fe5429172e0/zstandard-0.25.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:f373da2c1757bb7f1acaf09369cdc1d51d84131e50d5fa9863982fd626466313", size = 5574377, upload-time = "2025-09-14T22:16:40.523Z" },
+ { url = "https://files.pythonhosted.org/packages/12/9b/3fb626390113f272abd0799fd677ea33d5fc3ec185e62e6be534493c4b60/zstandard-0.25.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6c0e5a65158a7946e7a7affa6418878ef97ab66636f13353b8502d7ea03c8097", size = 4961493, upload-time = "2025-09-14T22:16:43.3Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/d3/23094a6b6a4b1343b27ae68249daa17ae0651fcfec9ed4de09d14b940285/zstandard-0.25.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c8e167d5adf59476fa3e37bee730890e389410c354771a62e3c076c86f9f7778", size = 5269018, upload-time = "2025-09-14T22:16:45.292Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/a7/bb5a0c1c0f3f4b5e9d5b55198e39de91e04ba7c205cc46fcb0f95f0383c1/zstandard-0.25.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:98750a309eb2f020da61e727de7d7ba3c57c97cf6213f6f6277bb7fb42a8e065", size = 5443672, upload-time = "2025-09-14T22:16:47.076Z" },
+ { url = "https://files.pythonhosted.org/packages/27/22/503347aa08d073993f25109c36c8d9f029c7d5949198050962cb568dfa5e/zstandard-0.25.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:22a086cff1b6ceca18a8dd6096ec631e430e93a8e70a9ca5efa7561a00f826fa", size = 5822753, upload-time = "2025-09-14T22:16:49.316Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/be/94267dc6ee64f0f8ba2b2ae7c7a2df934a816baaa7291db9e1aa77394c3c/zstandard-0.25.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:72d35d7aa0bba323965da807a462b0966c91608ef3a48ba761678cb20ce5d8b7", size = 5366047, upload-time = "2025-09-14T22:16:51.328Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/a3/732893eab0a3a7aecff8b99052fecf9f605cf0fb5fb6d0290e36beee47a4/zstandard-0.25.0-cp311-cp311-win32.whl", hash = "sha256:f5aeea11ded7320a84dcdd62a3d95b5186834224a9e55b92ccae35d21a8b63d4", size = 436484, upload-time = "2025-09-14T22:16:55.005Z" },
+ { url = "https://files.pythonhosted.org/packages/43/a3/c6155f5c1cce691cb80dfd38627046e50af3ee9ddc5d0b45b9b063bfb8c9/zstandard-0.25.0-cp311-cp311-win_amd64.whl", hash = "sha256:daab68faadb847063d0c56f361a289c4f268706b598afbf9ad113cbe5c38b6b2", size = 506183, upload-time = "2025-09-14T22:16:52.753Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/3e/8945ab86a0820cc0e0cdbf38086a92868a9172020fdab8a03ac19662b0e5/zstandard-0.25.0-cp311-cp311-win_arm64.whl", hash = "sha256:22a06c5df3751bb7dc67406f5374734ccee8ed37fc5981bf1ad7041831fa1137", size = 462533, upload-time = "2025-09-14T22:16:53.878Z" },
+ { url = "https://files.pythonhosted.org/packages/82/fc/f26eb6ef91ae723a03e16eddb198abcfce2bc5a42e224d44cc8b6765e57e/zstandard-0.25.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7b3c3a3ab9daa3eed242d6ecceead93aebbb8f5f84318d82cee643e019c4b73b", size = 795738, upload-time = "2025-09-14T22:16:56.237Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/1c/d920d64b22f8dd028a8b90e2d756e431a5d86194caa78e3819c7bf53b4b3/zstandard-0.25.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:913cbd31a400febff93b564a23e17c3ed2d56c064006f54efec210d586171c00", size = 640436, upload-time = "2025-09-14T22:16:57.774Z" },
+ { url = "https://files.pythonhosted.org/packages/53/6c/288c3f0bd9fcfe9ca41e2c2fbfd17b2097f6af57b62a81161941f09afa76/zstandard-0.25.0-cp312-cp312-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:011d388c76b11a0c165374ce660ce2c8efa8e5d87f34996aa80f9c0816698b64", size = 5343019, upload-time = "2025-09-14T22:16:59.302Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/15/efef5a2f204a64bdb5571e6161d49f7ef0fffdbca953a615efbec045f60f/zstandard-0.25.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6dffecc361d079bb48d7caef5d673c88c8988d3d33fb74ab95b7ee6da42652ea", size = 5063012, upload-time = "2025-09-14T22:17:01.156Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/37/a6ce629ffdb43959e92e87ebdaeebb5ac81c944b6a75c9c47e300f85abdf/zstandard-0.25.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7149623bba7fdf7e7f24312953bcf73cae103db8cae49f8154dd1eadc8a29ecb", size = 5394148, upload-time = "2025-09-14T22:17:03.091Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/79/2bf870b3abeb5c070fe2d670a5a8d1057a8270f125ef7676d29ea900f496/zstandard-0.25.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6a573a35693e03cf1d67799fd01b50ff578515a8aeadd4595d2a7fa9f3ec002a", size = 5451652, upload-time = "2025-09-14T22:17:04.979Z" },
+ { url = "https://files.pythonhosted.org/packages/53/60/7be26e610767316c028a2cbedb9a3beabdbe33e2182c373f71a1c0b88f36/zstandard-0.25.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5a56ba0db2d244117ed744dfa8f6f5b366e14148e00de44723413b2f3938a902", size = 5546993, upload-time = "2025-09-14T22:17:06.781Z" },
+ { url = "https://files.pythonhosted.org/packages/85/c7/3483ad9ff0662623f3648479b0380d2de5510abf00990468c286c6b04017/zstandard-0.25.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:10ef2a79ab8e2974e2075fb984e5b9806c64134810fac21576f0668e7ea19f8f", size = 5046806, upload-time = "2025-09-14T22:17:08.415Z" },
+ { url = "https://files.pythonhosted.org/packages/08/b3/206883dd25b8d1591a1caa44b54c2aad84badccf2f1de9e2d60a446f9a25/zstandard-0.25.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:aaf21ba8fb76d102b696781bddaa0954b782536446083ae3fdaa6f16b25a1c4b", size = 5576659, upload-time = "2025-09-14T22:17:10.164Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/31/76c0779101453e6c117b0ff22565865c54f48f8bd807df2b00c2c404b8e0/zstandard-0.25.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1869da9571d5e94a85a5e8d57e4e8807b175c9e4a6294e3b66fa4efb074d90f6", size = 4953933, upload-time = "2025-09-14T22:17:11.857Z" },
+ { url = "https://files.pythonhosted.org/packages/18/e1/97680c664a1bf9a247a280a053d98e251424af51f1b196c6d52f117c9720/zstandard-0.25.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:809c5bcb2c67cd0ed81e9229d227d4ca28f82d0f778fc5fea624a9def3963f91", size = 5268008, upload-time = "2025-09-14T22:17:13.627Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/73/316e4010de585ac798e154e88fd81bb16afc5c5cb1a72eeb16dd37e8024a/zstandard-0.25.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f27662e4f7dbf9f9c12391cb37b4c4c3cb90ffbd3b1fb9284dadbbb8935fa708", size = 5433517, upload-time = "2025-09-14T22:17:16.103Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/60/dd0f8cfa8129c5a0ce3ea6b7f70be5b33d2618013a161e1ff26c2b39787c/zstandard-0.25.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:99c0c846e6e61718715a3c9437ccc625de26593fea60189567f0118dc9db7512", size = 5814292, upload-time = "2025-09-14T22:17:17.827Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/5f/75aafd4b9d11b5407b641b8e41a57864097663699f23e9ad4dbb91dc6bfe/zstandard-0.25.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:474d2596a2dbc241a556e965fb76002c1ce655445e4e3bf38e5477d413165ffa", size = 5360237, upload-time = "2025-09-14T22:17:19.954Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/8d/0309daffea4fcac7981021dbf21cdb2e3427a9e76bafbcdbdf5392ff99a4/zstandard-0.25.0-cp312-cp312-win32.whl", hash = "sha256:23ebc8f17a03133b4426bcc04aabd68f8236eb78c3760f12783385171b0fd8bd", size = 436922, upload-time = "2025-09-14T22:17:24.398Z" },
+ { url = "https://files.pythonhosted.org/packages/79/3b/fa54d9015f945330510cb5d0b0501e8253c127cca7ebe8ba46a965df18c5/zstandard-0.25.0-cp312-cp312-win_amd64.whl", hash = "sha256:ffef5a74088f1e09947aecf91011136665152e0b4b359c42be3373897fb39b01", size = 506276, upload-time = "2025-09-14T22:17:21.429Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/6b/8b51697e5319b1f9ac71087b0af9a40d8a6288ff8025c36486e0c12abcc4/zstandard-0.25.0-cp312-cp312-win_arm64.whl", hash = "sha256:181eb40e0b6a29b3cd2849f825e0fa34397f649170673d385f3598ae17cca2e9", size = 462679, upload-time = "2025-09-14T22:17:23.147Z" },
+ { url = "https://files.pythonhosted.org/packages/35/0b/8df9c4ad06af91d39e94fa96cc010a24ac4ef1378d3efab9223cc8593d40/zstandard-0.25.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec996f12524f88e151c339688c3897194821d7f03081ab35d31d1e12ec975e94", size = 795735, upload-time = "2025-09-14T22:17:26.042Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/06/9ae96a3e5dcfd119377ba33d4c42a7d89da1efabd5cb3e366b156c45ff4d/zstandard-0.25.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a1a4ae2dec3993a32247995bdfe367fc3266da832d82f8438c8570f989753de1", size = 640440, upload-time = "2025-09-14T22:17:27.366Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/14/933d27204c2bd404229c69f445862454dcc101cd69ef8c6068f15aaec12c/zstandard-0.25.0-cp313-cp313-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:e96594a5537722fdfb79951672a2a63aec5ebfb823e7560586f7484819f2a08f", size = 5343070, upload-time = "2025-09-14T22:17:28.896Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/db/ddb11011826ed7db9d0e485d13df79b58586bfdec56e5c84a928a9a78c1c/zstandard-0.25.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bfc4e20784722098822e3eee42b8e576b379ed72cca4a7cb856ae733e62192ea", size = 5063001, upload-time = "2025-09-14T22:17:31.044Z" },
+ { url = "https://files.pythonhosted.org/packages/db/00/87466ea3f99599d02a5238498b87bf84a6348290c19571051839ca943777/zstandard-0.25.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:457ed498fc58cdc12fc48f7950e02740d4f7ae9493dd4ab2168a47c93c31298e", size = 5394120, upload-time = "2025-09-14T22:17:32.711Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/95/fc5531d9c618a679a20ff6c29e2b3ef1d1f4ad66c5e161ae6ff847d102a9/zstandard-0.25.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:fd7a5004eb1980d3cefe26b2685bcb0b17989901a70a1040d1ac86f1d898c551", size = 5451230, upload-time = "2025-09-14T22:17:34.41Z" },
+ { url = "https://files.pythonhosted.org/packages/63/4b/e3678b4e776db00f9f7b2fe58e547e8928ef32727d7a1ff01dea010f3f13/zstandard-0.25.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8e735494da3db08694d26480f1493ad2cf86e99bdd53e8e9771b2752a5c0246a", size = 5547173, upload-time = "2025-09-14T22:17:36.084Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/d5/ba05ed95c6b8ec30bd468dfeab20589f2cf709b5c940483e31d991f2ca58/zstandard-0.25.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3a39c94ad7866160a4a46d772e43311a743c316942037671beb264e395bdd611", size = 5046736, upload-time = "2025-09-14T22:17:37.891Z" },
+ { url = "https://files.pythonhosted.org/packages/50/d5/870aa06b3a76c73eced65c044b92286a3c4e00554005ff51962deef28e28/zstandard-0.25.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:172de1f06947577d3a3005416977cce6168f2261284c02080e7ad0185faeced3", size = 5576368, upload-time = "2025-09-14T22:17:40.206Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/35/398dc2ffc89d304d59bc12f0fdd931b4ce455bddf7038a0a67733a25f550/zstandard-0.25.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3c83b0188c852a47cd13ef3bf9209fb0a77fa5374958b8c53aaa699398c6bd7b", size = 4954022, upload-time = "2025-09-14T22:17:41.879Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/5c/36ba1e5507d56d2213202ec2b05e8541734af5f2ce378c5d1ceaf4d88dc4/zstandard-0.25.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1673b7199bbe763365b81a4f3252b8e80f44c9e323fc42940dc8843bfeaf9851", size = 5267889, upload-time = "2025-09-14T22:17:43.577Z" },
+ { url = "https://files.pythonhosted.org/packages/70/e8/2ec6b6fb7358b2ec0113ae202647ca7c0e9d15b61c005ae5225ad0995df5/zstandard-0.25.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:0be7622c37c183406f3dbf0cba104118eb16a4ea7359eeb5752f0794882fc250", size = 5433952, upload-time = "2025-09-14T22:17:45.271Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/01/b5f4d4dbc59ef193e870495c6f1275f5b2928e01ff5a81fecb22a06e22fb/zstandard-0.25.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:5f5e4c2a23ca271c218ac025bd7d635597048b366d6f31f420aaeb715239fc98", size = 5814054, upload-time = "2025-09-14T22:17:47.08Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/e5/fbd822d5c6f427cf158316d012c5a12f233473c2f9c5fe5ab1ae5d21f3d8/zstandard-0.25.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f187a0bb61b35119d1926aee039524d1f93aaf38a9916b8c4b78ac8514a0aaf", size = 5360113, upload-time = "2025-09-14T22:17:48.893Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/e0/69a553d2047f9a2c7347caa225bb3a63b6d7704ad74610cb7823baa08ed7/zstandard-0.25.0-cp313-cp313-win32.whl", hash = "sha256:7030defa83eef3e51ff26f0b7bfb229f0204b66fe18e04359ce3474ac33cbc09", size = 436936, upload-time = "2025-09-14T22:17:52.658Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/82/b9c06c870f3bd8767c201f1edbdf9e8dc34be5b0fbc5682c4f80fe948475/zstandard-0.25.0-cp313-cp313-win_amd64.whl", hash = "sha256:1f830a0dac88719af0ae43b8b2d6aef487d437036468ef3c2ea59c51f9d55fd5", size = 506232, upload-time = "2025-09-14T22:17:50.402Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/57/60c3c01243bb81d381c9916e2a6d9e149ab8627c0c7d7abb2d73384b3c0c/zstandard-0.25.0-cp313-cp313-win_arm64.whl", hash = "sha256:85304a43f4d513f5464ceb938aa02c1e78c2943b29f44a750b48b25ac999a049", size = 462671, upload-time = "2025-09-14T22:17:51.533Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/5c/f8923b595b55fe49e30612987ad8bf053aef555c14f05bb659dd5dbe3e8a/zstandard-0.25.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e29f0cf06974c899b2c188ef7f783607dbef36da4c242eb6c82dcd8b512855e3", size = 795887, upload-time = "2025-09-14T22:17:54.198Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/09/d0a2a14fc3439c5f874042dca72a79c70a532090b7ba0003be73fee37ae2/zstandard-0.25.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:05df5136bc5a011f33cd25bc9f506e7426c0c9b3f9954f056831ce68f3b6689f", size = 640658, upload-time = "2025-09-14T22:17:55.423Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/7c/8b6b71b1ddd517f68ffb55e10834388d4f793c49c6b83effaaa05785b0b4/zstandard-0.25.0-cp314-cp314-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:f604efd28f239cc21b3adb53eb061e2a205dc164be408e553b41ba2ffe0ca15c", size = 5379849, upload-time = "2025-09-14T22:17:57.372Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/86/a48e56320d0a17189ab7a42645387334fba2200e904ee47fc5a26c1fd8ca/zstandard-0.25.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223415140608d0f0da010499eaa8ccdb9af210a543fac54bce15babbcfc78439", size = 5058095, upload-time = "2025-09-14T22:17:59.498Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/ad/eb659984ee2c0a779f9d06dbfe45e2dc39d99ff40a319895df2d3d9a48e5/zstandard-0.25.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e54296a283f3ab5a26fc9b8b5d4978ea0532f37b231644f367aa588930aa043", size = 5551751, upload-time = "2025-09-14T22:18:01.618Z" },
+ { url = "https://files.pythonhosted.org/packages/61/b3/b637faea43677eb7bd42ab204dfb7053bd5c4582bfe6b1baefa80ac0c47b/zstandard-0.25.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ca54090275939dc8ec5dea2d2afb400e0f83444b2fc24e07df7fdef677110859", size = 6364818, upload-time = "2025-09-14T22:18:03.769Z" },
+ { url = "https://files.pythonhosted.org/packages/31/dc/cc50210e11e465c975462439a492516a73300ab8caa8f5e0902544fd748b/zstandard-0.25.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e09bb6252b6476d8d56100e8147b803befa9a12cea144bbe629dd508800d1ad0", size = 5560402, upload-time = "2025-09-14T22:18:05.954Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/ae/56523ae9c142f0c08efd5e868a6da613ae76614eca1305259c3bf6a0ed43/zstandard-0.25.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a9ec8c642d1ec73287ae3e726792dd86c96f5681eb8df274a757bf62b750eae7", size = 4955108, upload-time = "2025-09-14T22:18:07.68Z" },
+ { url = "https://files.pythonhosted.org/packages/98/cf/c899f2d6df0840d5e384cf4c4121458c72802e8bda19691f3b16619f51e9/zstandard-0.25.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a4089a10e598eae6393756b036e0f419e8c1d60f44a831520f9af41c14216cf2", size = 5269248, upload-time = "2025-09-14T22:18:09.753Z" },
+ { url = "https://files.pythonhosted.org/packages/1b/c0/59e912a531d91e1c192d3085fc0f6fb2852753c301a812d856d857ea03c6/zstandard-0.25.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f67e8f1a324a900e75b5e28ffb152bcac9fbed1cc7b43f99cd90f395c4375344", size = 5430330, upload-time = "2025-09-14T22:18:11.966Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/1d/7e31db1240de2df22a58e2ea9a93fc6e38cc29353e660c0272b6735d6669/zstandard-0.25.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9654dbc012d8b06fc3d19cc825af3f7bf8ae242226df5f83936cb39f5fdc846c", size = 5811123, upload-time = "2025-09-14T22:18:13.907Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/49/fac46df5ad353d50535e118d6983069df68ca5908d4d65b8c466150a4ff1/zstandard-0.25.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4203ce3b31aec23012d3a4cf4a2ed64d12fea5269c49aed5e4c3611b938e4088", size = 5359591, upload-time = "2025-09-14T22:18:16.465Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/38/f249a2050ad1eea0bb364046153942e34abba95dd5520af199aed86fbb49/zstandard-0.25.0-cp314-cp314-win32.whl", hash = "sha256:da469dc041701583e34de852d8634703550348d5822e66a0c827d39b05365b12", size = 444513, upload-time = "2025-09-14T22:18:20.61Z" },
+ { url = "https://files.pythonhosted.org/packages/3a/43/241f9615bcf8ba8903b3f0432da069e857fc4fd1783bd26183db53c4804b/zstandard-0.25.0-cp314-cp314-win_amd64.whl", hash = "sha256:c19bcdd826e95671065f8692b5a4aa95c52dc7a02a4c5a0cac46deb879a017a2", size = 516118, upload-time = "2025-09-14T22:18:17.849Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/ef/da163ce2450ed4febf6467d77ccb4cd52c4c30ab45624bad26ca0a27260c/zstandard-0.25.0-cp314-cp314-win_arm64.whl", hash = "sha256:d7541afd73985c630bafcd6338d2518ae96060075f9463d7dc14cfb33514383d", size = 476940, upload-time = "2025-09-14T22:18:19.088Z" },
+]
diff --git a/wechat_cli/commands/export.py b/wechat_cli/commands/export.py
index c1be606..4d50710 100644
--- a/wechat_cli/commands/export.py
+++ b/wechat_cli/commands/export.py
@@ -1,10 +1,22 @@
-"""export 命令 — 导出聊天记录为 markdown 或 txt"""
+"""export 命令 — 导出聊天记录为 markdown、txt 或 JSON"""
-import click
+import os
from datetime import datetime
+import click
+
from ..core.contacts import get_contact_names
+from ..core.media_export import (
+ asset_dir_for_output,
+ build_export_payload,
+ materialize_record_media,
+ prepare_export_targets,
+ readme_path_for_output,
+ write_export_readme,
+ write_json_export,
+)
from ..core.messages import (
+ collect_chat_export_records,
collect_chat_history,
parse_time_range,
resolve_chat_context,
@@ -15,30 +27,38 @@
@click.command("export")
@click.argument("chat_name")
-@click.option("--format", "fmt", default="markdown", type=click.Choice(["markdown", "txt"]), help="导出格式")
+@click.option("--format", "fmt", default="markdown", type=click.Choice(["markdown", "txt", "json"]), help="导出格式")
@click.option("--output", "output_path", default=None, help="输出文件路径(默认输出到 stdout)")
@click.option("--start-time", default="", help="起始时间 YYYY-MM-DD [HH:MM[:SS]]")
@click.option("--end-time", default="", help="结束时间 YYYY-MM-DD [HH:MM[:SS]]")
-@click.option("--limit", default=500, help="导出消息数量")
+@click.option("--limit", default=None, type=int, help="导出消息数量(JSON 默认全部,markdown/txt 默认 500)")
+@click.option("--overwrite", is_flag=True, help="覆盖已存在的 JSON 输出文件和资产目录(仅 JSON)")
+@click.option("--download-stickers", is_flag=True, help="JSON 导出时从 WeChat CDN 下载/解密本地不可解码的表情")
@click.pass_context
-def export(ctx, chat_name, fmt, output_path, start_time, end_time, limit):
- """导出聊天记录为 markdown 或纯文本
+def export(ctx, chat_name, fmt, output_path, start_time, end_time, limit, overwrite, download_stickers):
+ """导出聊天记录为 markdown、纯文本或结构化 JSON
\b
示例:
wechat-cli export "张三" --format markdown
wechat-cli export "AI交流群" --format txt --output chat.txt
+ wechat-cli export "AI交流群" --format json --output chat.json
wechat-cli export "张三" --start-time "2026-04-01" --limit 1000
"""
app = ctx.obj
try:
- validate_pagination(limit, 0, limit_max=None)
+ if limit is not None:
+ validate_pagination(limit, 0, limit_max=None)
start_ts, end_ts = parse_time_range(start_time, end_time)
except ValueError as e:
click.echo(f"错误: {e}", err=True)
ctx.exit(2)
+ if fmt == "json" and not output_path:
+ click.echo("错误: JSON 导出必须指定 --output,以便生成自包含资产目录", err=True)
+ ctx.exit(2)
+
chat_ctx = resolve_chat_context(chat_name, app.msg_db_keys, app.cache, app.decrypted_dir)
if not chat_ctx:
click.echo(f"找不到聊天对象: {chat_name}", err=True)
@@ -48,9 +68,47 @@ def export(ctx, chat_name, fmt, output_path, start_time, end_time, limit):
ctx.exit(1)
names = get_contact_names(app.cache, app.decrypted_dir)
+
+ if fmt == "json":
+ output_path_abs = os.path.abspath(output_path)
+ assets_dir = asset_dir_for_output(output_path_abs)
+ readme_path = readme_path_for_output(output_path_abs)
+ try:
+ prepare_export_targets(output_path_abs, assets_dir, readme_path=readme_path, overwrite=overwrite)
+ except (FileExistsError, ValueError) as e:
+ click.echo(f"错误: {e}", err=True)
+ ctx.exit(2)
+
+ resource_failures = []
+ try:
+ resource_db_path = app.cache.get(os.path.join("message", "message_resource.db"))
+ except Exception as e:
+ resource_db_path = None
+ resource_failures.append(f"message/message_resource.db: {e}")
+
+ records, failures = collect_chat_export_records(
+ chat_ctx, names, app.display_name_fn,
+ start_ts=start_ts, end_ts=end_ts, limit=limit, db_dir=app.db_dir,
+ resource_db_path=resource_db_path,
+ )
+ failures = resource_failures + failures
+ warnings = materialize_record_media(
+ records, assets_dir, output_path_abs, download_stickers=download_stickers
+ )
+ payload = build_export_payload(
+ chat_ctx, records,
+ start_time=start_time, end_time=end_time, limit=limit,
+ failures=failures, warnings=warnings,
+ )
+ write_json_export(payload, output_path_abs)
+ write_export_readme(payload, output_path_abs, assets_dir, readme_path)
+ click.echo(f"已导出到: {output_path_abs}({len(records)} 条消息,资产目录: {assets_dir},说明: {readme_path})", err=True)
+ return
+
+ text_limit = limit if limit is not None else 500
lines, failures = collect_chat_history(
chat_ctx, names, app.display_name_fn,
- start_ts=start_ts, end_ts=end_ts, limit=limit, offset=0,
+ start_ts=start_ts, end_ts=end_ts, limit=text_limit, offset=0,
)
if not lines:
diff --git a/wechat_cli/core/media_export.py b/wechat_cli/core/media_export.py
new file mode 100644
index 0000000..a427fc7
--- /dev/null
+++ b/wechat_cli/core/media_export.py
@@ -0,0 +1,1454 @@
+"""Media asset helpers for self-contained chat exports."""
+
+import base64
+import hashlib
+import json
+import mimetypes
+import os
+import posixpath
+import re
+import shutil
+import subprocess
+import sys
+import urllib.error
+import urllib.parse
+import urllib.request
+from datetime import datetime
+from pathlib import Path
+
+
+SCHEMA_VERSION = "wechat-cli.chat_export.v1"
+
+_SAFE_NAME_RE = re.compile(r"[^A-Za-z0-9._-]+")
+_IMAGE_SIGNATURES = [
+ ("jpg", "image/jpeg", bytes.fromhex("ffd8ff")),
+ ("png", "image/png", bytes.fromhex("89504e470d0a1a0a")),
+ ("gif", "image/gif", b"GIF8"),
+]
+_BMP_SIGNATURE = ("bmp", "image/bmp", b"BM")
+_WECHAT_V1_DAT_HEADER = b"\x07\x08V1"
+_WECHAT_V2_DAT_HEADER = b"\x07\x08V2"
+_WECHAT_DAT_HEADERS = (_WECHAT_V1_DAT_HEADER, _WECHAT_V2_DAT_HEADER)
+_WECHAT_V1_AES_KEY = b"cfcd208495d565ef"
+_WXGF_SIGNATURE = b"wxgf"
+_KVCOMM_STATISTIC_RE = re.compile(r"^(?:key_(?:reportnow_)?)?(\d+)_.*\.statistic$")
+_HEX32_RE = re.compile(r"^[a-fA-F0-9]{32}$")
+_SUCCESS_MEDIA_STATUSES = {"copied", "decoded", "downloaded"}
+_STICKER_CACHE_EXTENSIONS = ("gif", "png", "jpg", "jpeg", "webp", "bmp")
+_MAX_STICKER_DOWNLOAD_BYTES = 100 * 1024 * 1024
+_STICKER_DOWNLOAD_HEADERS = {
+ "User-Agent": "MicroMessenger Client",
+ "Accept": "*/*",
+ "Cache-Control": "no-cache",
+ "Connection": "Keep-Alive",
+ "Content-Type": "application/octet-stream",
+ "X-Client-Os": "macOS",
+}
+_V2_KEY_CACHE = {}
+
+
+def asset_dir_for_output(output_path):
+ base, _ = os.path.splitext(os.path.abspath(output_path))
+ return f"{base}_assets"
+
+
+def readme_path_for_output(output_path):
+ base, _ = os.path.splitext(os.path.abspath(output_path))
+ return f"{base}_export_readme.md"
+
+
+def prepare_export_targets(output_path, assets_dir, readme_path=None, overwrite=False):
+ output_path = os.path.abspath(output_path)
+ assets_dir = os.path.abspath(assets_dir)
+ readme_path = os.path.abspath(readme_path) if readme_path else None
+ if os.path.isdir(output_path):
+ raise ValueError(f"--output 必须是文件路径,不能是目录: {output_path}")
+
+ existing = []
+ if os.path.exists(output_path):
+ existing.append(output_path)
+ if os.path.exists(assets_dir):
+ existing.append(assets_dir)
+ if readme_path and os.path.exists(readme_path):
+ existing.append(readme_path)
+ if existing and not overwrite:
+ raise FileExistsError("导出目标已存在,请使用 --overwrite: " + ", ".join(existing))
+
+ os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True)
+ if overwrite:
+ if os.path.isfile(output_path):
+ os.unlink(output_path)
+ elif os.path.exists(output_path):
+ raise ValueError(f"无法覆盖非文件输出路径: {output_path}")
+ if os.path.isdir(assets_dir):
+ shutil.rmtree(assets_dir)
+ elif os.path.exists(assets_dir):
+ os.unlink(assets_dir)
+ if readme_path:
+ if os.path.isfile(readme_path):
+ os.unlink(readme_path)
+ elif os.path.exists(readme_path):
+ raise ValueError(f"无法覆盖非文件说明路径: {readme_path}")
+ os.makedirs(assets_dir, exist_ok=True)
+
+
+def build_export_payload(chat_ctx, records, start_time="", end_time="", limit=None, failures=None, warnings=None):
+ return {
+ "schema_version": SCHEMA_VERSION,
+ "exported_at": datetime.now().isoformat(timespec="seconds"),
+ "chat": {
+ "name": chat_ctx["display_name"],
+ "username": chat_ctx["username"],
+ "is_group": bool(chat_ctx["is_group"]),
+ },
+ "range": {
+ "start_time": start_time or None,
+ "end_time": end_time or None,
+ },
+ "limit": limit,
+ "count": len(records),
+ "messages": records,
+ "warnings": warnings or [],
+ "failures": failures or [],
+ }
+
+
+def write_json_export(payload, output_path):
+ with open(output_path, "w", encoding="utf-8") as f:
+ json.dump(payload, f, ensure_ascii=False, indent=2)
+ f.write("\n")
+
+
+def write_export_readme(payload, output_path, assets_dir, readme_path):
+ output_name = os.path.basename(output_path)
+ assets_rel = _to_posix(os.path.relpath(assets_dir, os.path.dirname(os.path.abspath(readme_path)) or "."))
+ schema_version = payload.get("schema_version", SCHEMA_VERSION)
+ chat = payload.get("chat") or {}
+ range_info = payload.get("range") or {}
+ content = f"""# WeChat JSON Export
+
+This folder contains a self-contained WeChat chat export generated by `wechat-cli`.
+
+## Files
+
+- `{output_name}`: structured chat data in JSON.
+- `{assets_rel}/`: media assets referenced by relative paths from the JSON file.
+
+## JSON Shape
+
+- `schema_version`: `{schema_version}`
+- `exported_at`: export timestamp.
+- `chat`: chat metadata with `name`, `username`, and `is_group`.
+- `range`: requested export range with `start_time` and `end_time`.
+- `count`: number of messages in `messages`.
+- `messages[]`: ordered message records.
+- `warnings[]`: non-fatal media/export warnings.
+- `failures[]`: database or query failures encountered during export.
+
+## Message Records
+
+Each `messages[]` entry may include:
+
+- `local_id`: WeChat local message id.
+- `time`: message timestamp.
+- `sender`: display name for the sender.
+- `sender_username`: WeChat username/id for the sender when known.
+- `is_self`: whether the message was sent by the exporting account.
+- `type`: normalized message type such as `text`, `image`, `video`, `voice`, `file`, or `type_`.
+- `text`: message text or a concise placeholder/summary for non-text messages.
+- `media[]`: zero or more external media assets for the message.
+
+## Media Entries
+
+Resolve every `media[].path` relative to the directory containing `{output_name}`.
+
+Common fields:
+
+- `kind`: `image`, `sticker`, `video`, `voice`, `audio`, `file`, or `media`.
+- `status`: `decoded`, `copied`, `downloaded`, `missing`, or `undecodable`.
+- `path`: relative asset path, present when a file was written.
+- `mime`: MIME type for written assets when known.
+- `bytes`: written asset size in bytes when available.
+- `width` / `height`: final exported visual asset dimensions in pixels when available.
+- `duration_ms`: final exported audio/video duration in milliseconds when available.
+- `cache_hit`: `true` when a sticker was reused from the persistent user cache.
+- `original_filename`: source filename when available.
+- `detail`: warning/error detail for non-success statuses.
+
+Status meaning:
+
+- `decoded`: WeChat-specific media was converted to a common format. Consumers can open the file directly.
+- `copied`: the original local file was copied as-is.
+- `downloaded`: a sticker/media asset was fetched from a WeChat CDN URL embedded in the message.
+- `missing`: the media reference was present but no local file was found.
+- `undecodable`: a local file was copied, but it could not be converted to a common format.
+
+## Asset Folders
+
+- `{assets_rel}/images/`: image assets, usually JPG/PNG/GIF/WebP/BMP.
+- `{assets_rel}/stickers/`: sticker/emoticon assets, usually GIF/PNG/WebP.
+- `{assets_rel}/videos/`: video assets.
+- `{assets_rel}/audio/`: voice/audio assets.
+- `{assets_rel}/files/`: file attachments.
+
+## Consumer Notes
+
+- Do not assume every message has media.
+- Do not assume every media entry has `path`; check `status` first.
+- Do not assume every media entry has dimensions or duration; check `width`, `height`, and `duration_ms`.
+- Treat `decoded` and `copied` as successful file outputs.
+- Preserve relative paths when moving the export; keep `{output_name}`, this readme, and `{assets_rel}/` together.
+
+## Export Summary
+
+- Chat: {chat.get("name", "")}
+- Username: {chat.get("username", "")}
+- Group chat: {bool(chat.get("is_group", False))}
+- Start time: {range_info.get("start_time") or "not specified"}
+- End time: {range_info.get("end_time") or "not specified"}
+- Message count: {payload.get("count", 0)}
+"""
+ with open(readme_path, "w", encoding="utf-8") as f:
+ f.write(content)
+
+
+def materialize_record_media(records, assets_dir, output_path, download_stickers=False):
+ warnings = []
+ rel_assets_root = os.path.relpath(assets_dir, os.path.dirname(os.path.abspath(output_path)) or ".")
+ rel_assets_root = _to_posix(rel_assets_root)
+ used_names = set()
+ sticker_cache = {}
+
+ _enrich_sticker_sources(records)
+ for record in records:
+ sources = record.pop("_media_sources", [])
+ media_entries = []
+ for index, source in enumerate(sources):
+ entry = _materialize_source(
+ source, record, index, assets_dir, rel_assets_root, used_names,
+ download_stickers=download_stickers, sticker_cache=sticker_cache,
+ )
+ probe_warning = entry.pop("_probe_warning", "")
+ entry.pop("_asset_path", None)
+ media_entries.append(entry)
+ if entry["status"] not in _SUCCESS_MEDIA_STATUSES:
+ warnings.append({
+ "local_id": record.get("local_id"),
+ "kind": entry.get("kind"),
+ "status": entry.get("status"),
+ "detail": entry.get("detail", ""),
+ })
+ elif probe_warning:
+ warnings.append({
+ "local_id": record.get("local_id"),
+ "kind": entry.get("kind"),
+ "status": "metadata_unavailable",
+ "detail": probe_warning,
+ })
+ record["media"] = media_entries
+ return warnings
+
+
+def _enrich_sticker_sources(records):
+ best_by_md5 = {}
+ for record in records:
+ for source in record.get("_media_sources", []):
+ sticker_md5 = _sticker_md5(source)
+ if source.get("kind") != "sticker" or not sticker_md5:
+ continue
+ score = _sticker_source_enrichment_score(source)
+ if score <= 0:
+ continue
+ current = best_by_md5.get(sticker_md5)
+ if current is None or score > current[0]:
+ best_by_md5[sticker_md5] = (score, source)
+
+ if not best_by_md5:
+ return
+
+ for record in records:
+ for source in record.get("_media_sources", []):
+ sticker_md5 = _sticker_md5(source)
+ if source.get("kind") != "sticker" or not sticker_md5:
+ continue
+ rich_source = best_by_md5.get(sticker_md5)
+ if not rich_source:
+ continue
+ _merge_sticker_enrichment(source, rich_source[1])
+
+
+def _sticker_source_enrichment_score(source):
+ score = 0
+ if _has_sticker_value(source.get("cdn_url")):
+ score += 100
+ if _has_sticker_value(source.get("encrypt_url")) and _has_sticker_value(source.get("aeskey")):
+ score += 90
+ if _has_sticker_value(source.get("expected_bytes")):
+ score += 10
+ if _has_sticker_value(source.get("product_id")):
+ score += 2
+ if _has_sticker_value(source.get("width")):
+ score += 1
+ if _has_sticker_value(source.get("height")):
+ score += 1
+ return score
+
+
+def _merge_sticker_enrichment(target, source):
+ for field_name in (
+ "expected_bytes",
+ "width",
+ "height",
+ "product_id",
+ "aeskey",
+ "cdn_url",
+ "encrypt_url",
+ ):
+ if _has_sticker_value(target.get(field_name)):
+ continue
+ value = source.get(field_name)
+ if _has_sticker_value(value):
+ target[field_name] = value
+
+
+def _has_sticker_value(value):
+ if value in (None, ""):
+ return False
+ if isinstance(value, str):
+ return bool(value.strip()) and value.strip() != "0"
+ return value != 0
+
+
+def _materialize_source(
+ source, record, index, assets_dir, rel_assets_root, used_names,
+ download_stickers=False, sticker_cache=None,
+):
+ kind = source.get("kind", "file")
+ source_path = source.get("source_path")
+ if kind == "sticker":
+ return _materialize_sticker(
+ source, record, index, assets_dir, rel_assets_root, used_names,
+ download_stickers=download_stickers, sticker_cache=sticker_cache,
+ )
+ if not source_path or not os.path.exists(source_path):
+ return _media_status_entry(source, "missing", detail=source.get("detail") or "local media file not found")
+
+ try:
+ if kind == "image":
+ return _materialize_image(source, record, index, assets_dir, rel_assets_root, used_names)
+ return _copy_source(source, record, index, assets_dir, rel_assets_root, used_names, status="copied")
+ except OSError as e:
+ return _media_status_entry(source, "missing", detail=str(e))
+
+
+def _materialize_image(source, record, index, assets_dir, rel_assets_root, used_names):
+ source_path = source["source_path"]
+ original_is_wechat_dat = False
+ candidate_paths = [source_path] + _image_variant_paths(source_path)
+ for candidate_path in candidate_paths:
+ with open(candidate_path, "rb") as f:
+ data = f.read()
+ if candidate_path == source_path:
+ original_is_wechat_dat = _is_wechat_dat(data)
+
+ candidate_source = dict(source)
+ candidate_source["source_path"] = candidate_path
+ if candidate_path != source_path:
+ candidate_source["original_filename"] = os.path.basename(candidate_path)
+
+ detected = detect_image_bytes(data)
+ if detected:
+ ext, mime = detected
+ return _write_asset_bytes(
+ data, candidate_source, record, index, assets_dir, rel_assets_root, used_names,
+ ext=ext, mime=mime, status="copied",
+ )
+
+ decoded = decode_wechat_image_dat(data, source_path=candidate_path)
+ if decoded:
+ decoded_data, ext, mime = decoded
+ return _write_asset_bytes(
+ decoded_data, candidate_source, record, index, assets_dir, rel_assets_root, used_names,
+ ext=ext, mime=mime, status="decoded",
+ )
+
+ detail = "WeChat image .dat could not be decoded" if original_is_wechat_dat else "image .dat could not be decoded"
+ return _copy_source(
+ source, record, index, assets_dir, rel_assets_root, used_names,
+ status="undecodable", detail=detail,
+ )
+
+
+def _materialize_sticker(
+ source, record, index, assets_dir, rel_assets_root, used_names,
+ download_stickers=False, sticker_cache=None,
+):
+ sticker_cache = sticker_cache if sticker_cache is not None else {}
+ cached_entry = _cached_sticker_entry(sticker_cache, source)
+ if cached_entry:
+ return cached_entry
+
+ persistent_entry = _copy_persistent_sticker_cache(
+ source, record, index, assets_dir, rel_assets_root, used_names
+ )
+ if persistent_entry:
+ return _cache_sticker_entry(sticker_cache, source, persistent_entry)
+
+ source_path = source.get("source_path")
+ local_error = ""
+ if source_path and os.path.exists(source_path):
+ local_result = _try_materialize_sticker_bytes(
+ source, record, index, assets_dir, rel_assets_root, used_names
+ )
+ if local_result:
+ _store_persistent_sticker_cache(source, local_result)
+ return _cache_sticker_entry(sticker_cache, source, local_result)
+ local_error = "local sticker cache could not be decoded"
+
+ download_errors = []
+ if download_stickers:
+ downloaded = _download_sticker_asset(
+ source, record, index, assets_dir, rel_assets_root, used_names, download_errors
+ )
+ if downloaded:
+ _store_persistent_sticker_cache(source, downloaded)
+ return _cache_sticker_entry(sticker_cache, source, downloaded)
+
+ if source_path and os.path.exists(source_path):
+ detail = local_error
+ if download_errors:
+ detail += "; " + "; ".join(download_errors)
+ return _copy_source(
+ _with_source_label(source, "local_cache"),
+ record, index, assets_dir, rel_assets_root, used_names,
+ status="undecodable", detail=detail,
+ )
+
+ if not download_stickers and (source.get("cdn_url") or source.get("encrypt_url")):
+ detail = "sticker CDN download disabled; pass --download-stickers to export it"
+ elif download_errors:
+ detail = "; ".join(download_errors)
+ else:
+ detail = source.get("detail") or "local sticker cache not found"
+ return _media_status_entry(source, "missing", detail=detail)
+
+
+def _try_materialize_sticker_bytes(source, record, index, assets_dir, rel_assets_root, used_names):
+ source_path = source.get("source_path")
+ try:
+ with open(source_path, "rb") as f:
+ data = f.read()
+ except OSError:
+ return None
+
+ detected = detect_image_bytes(data)
+ if detected and _sticker_md5_matches(data, source):
+ ext, mime = detected
+ return _write_asset_bytes(
+ data, _with_source_label(source, "local_cache"), record, index,
+ assets_dir, rel_assets_root, used_names, ext=ext, mime=mime, status="copied",
+ )
+
+ decoded = decode_wechat_image_dat(data, source_path=source_path)
+ if decoded and _sticker_md5_matches(decoded[0], source):
+ decoded_data, ext, mime = decoded
+ return _write_asset_bytes(
+ decoded_data, _with_source_label(source, "local_cache"), record, index,
+ assets_dir, rel_assets_root, used_names, ext=ext, mime=mime, status="decoded",
+ )
+
+ decoded = _decode_sticker_aes_cbc(data, source)
+ if decoded:
+ decoded_data, ext, mime = decoded
+ return _write_asset_bytes(
+ decoded_data, _with_source_label(source, "local_cache"), record, index,
+ assets_dir, rel_assets_root, used_names, ext=ext, mime=mime, status="decoded",
+ )
+ return None
+
+
+def _download_sticker_asset(source, record, index, assets_dir, rel_assets_root, used_names, errors):
+ cdn_url = source.get("cdn_url")
+ if cdn_url:
+ data, error = _download_url(cdn_url)
+ if data is None:
+ errors.append(f"cdnurl: {error}")
+ else:
+ detected = detect_image_bytes(data)
+ if detected and _sticker_md5_matches(data, source):
+ ext, mime = detected
+ return _write_asset_bytes(
+ data, _with_source_label(source, "cdnurl"), record, index,
+ assets_dir, rel_assets_root, used_names, ext=ext, mime=mime, status="downloaded",
+ )
+ errors.append("cdnurl: downloaded bytes did not match sticker image metadata")
+
+ encrypt_url = source.get("encrypt_url")
+ if encrypt_url:
+ data, error = _download_url(encrypt_url)
+ if data is None:
+ errors.append(f"encrypturl: {error}")
+ else:
+ decoded = _decode_sticker_aes_cbc(data, source)
+ if decoded:
+ decoded_data, ext, mime = decoded
+ return _write_asset_bytes(
+ decoded_data, _with_source_label(source, "encrypturl"), record, index,
+ assets_dir, rel_assets_root, used_names, ext=ext, mime=mime, status="decoded",
+ )
+ errors.append("encrypturl: encrypted bytes could not be decrypted to a matching image")
+ return None
+
+
+def _copy_persistent_sticker_cache(source, record, index, assets_dir, rel_assets_root, used_names):
+ cache_path = _find_persistent_sticker_cache(source)
+ if not cache_path:
+ return None
+ cache_source = dict(source)
+ cache_source["source_path"] = cache_path
+ cache_source["cache_hit"] = True
+ return _copy_source(
+ cache_source, record, index, assets_dir, rel_assets_root, used_names, status="copied"
+ )
+
+
+def _find_persistent_sticker_cache(source):
+ sticker_md5 = _sticker_md5(source)
+ if not sticker_md5:
+ return None
+ cache_dir = _sticker_cache_dir()
+ for ext in _STICKER_CACHE_EXTENSIONS:
+ path = os.path.join(cache_dir, f"{sticker_md5}.{ext}")
+ if _valid_persistent_sticker_cache_file(path, sticker_md5):
+ return path
+ return None
+
+
+def _store_persistent_sticker_cache(source, entry):
+ sticker_md5 = _sticker_md5(source)
+ asset_path = entry.get("_asset_path")
+ if not sticker_md5 or not asset_path:
+ return
+ try:
+ with open(asset_path, "rb") as f:
+ data = f.read()
+ except OSError:
+ return
+ detected = detect_image_bytes(data)
+ if not detected or hashlib.md5(data).hexdigest().lower() != sticker_md5:
+ return
+
+ ext, _ = detected
+ cache_dir = _sticker_cache_dir()
+ cache_path = os.path.join(cache_dir, f"{sticker_md5}.{ext}")
+ if _valid_persistent_sticker_cache_file(cache_path, sticker_md5):
+ return
+
+ tmp_path = None
+ try:
+ os.makedirs(cache_dir, exist_ok=True)
+ tmp_path = f"{cache_path}.{os.getpid()}.tmp"
+ with open(tmp_path, "wb") as f:
+ f.write(data)
+ os.replace(tmp_path, cache_path)
+ except OSError:
+ try:
+ if tmp_path and os.path.exists(tmp_path):
+ os.unlink(tmp_path)
+ except OSError:
+ pass
+
+
+def _valid_persistent_sticker_cache_file(path, sticker_md5):
+ if not os.path.isfile(path):
+ return False
+ try:
+ with open(path, "rb") as f:
+ data = f.read()
+ except OSError:
+ return False
+ return bool(detect_image_bytes(data)) and hashlib.md5(data).hexdigest().lower() == sticker_md5
+
+
+def _sticker_md5(source):
+ sticker_md5 = (source.get("sticker_md5") or "").strip().lower()
+ if _HEX32_RE.fullmatch(sticker_md5):
+ return sticker_md5
+ return ""
+
+
+def _sticker_cache_dir():
+ configured = os.environ.get("WECHAT_CLI_STICKER_CACHE_DIR")
+ if configured:
+ return os.path.abspath(os.path.expanduser(configured))
+ if sys.platform == "darwin":
+ return os.path.expanduser(os.path.join("~", "Library", "Caches", "wechat-cli", "stickers"))
+ cache_root = os.environ.get("XDG_CACHE_HOME") or os.path.expanduser(os.path.join("~", ".cache"))
+ return os.path.abspath(os.path.join(os.path.expanduser(cache_root), "wechat-cli", "stickers"))
+
+
+def _cached_sticker_entry(sticker_cache, source):
+ for key in _sticker_cache_keys(source):
+ cached = sticker_cache.get(key)
+ if cached:
+ return dict(cached)
+ return None
+
+
+def _cache_sticker_entry(sticker_cache, source, entry):
+ if entry.get("status") not in _SUCCESS_MEDIA_STATUSES or not entry.get("path"):
+ return entry
+ cached = dict(entry)
+ cached.pop("_probe_warning", None)
+ cached.pop("_asset_path", None)
+ for key in _sticker_cache_keys(source):
+ sticker_cache.setdefault(key, cached)
+ return entry
+
+
+def _sticker_cache_keys(source):
+ keys = []
+ sticker_md5 = _sticker_md5(source)
+ if sticker_md5:
+ keys.append(("md5", sticker_md5))
+ for field_name in ("cdn_url", "encrypt_url"):
+ value = (source.get(field_name) or "").strip()
+ if value:
+ keys.append((field_name, value))
+ source_path = source.get("source_path")
+ if source_path:
+ keys.append(("source_path", os.path.abspath(source_path)))
+ return keys
+
+
+def _download_url(url):
+ parsed = urllib.parse.urlparse(url)
+ if parsed.scheme not in {"http", "https"} or not parsed.netloc:
+ return None, "unsupported URL"
+ try:
+ request = urllib.request.Request(url, headers=_STICKER_DOWNLOAD_HEADERS)
+ with urllib.request.urlopen(request, timeout=30) as response:
+ data = response.read(_MAX_STICKER_DOWNLOAD_BYTES + 1)
+ except (OSError, urllib.error.URLError, urllib.error.HTTPError) as e:
+ return None, str(e)
+ if len(data) > _MAX_STICKER_DOWNLOAD_BYTES:
+ return None, "download too large"
+ return data, ""
+
+
+def _decode_sticker_aes_cbc(data, source):
+ aeskey = (source.get("aeskey") or "").strip()
+ if not _HEX32_RE.fullmatch(aeskey) or len(data) % 16:
+ return None
+ try:
+ from Crypto.Cipher import AES
+ key = bytes.fromhex(aeskey)
+ decrypted = AES.new(key, AES.MODE_CBC, iv=key).decrypt(data)
+ except (ImportError, ValueError):
+ return None
+
+ candidates = []
+ unpadded = _pkcs7_unpad(decrypted)
+ if unpadded:
+ candidates.append(unpadded)
+ candidates.append(decrypted)
+ for candidate in candidates:
+ detected = detect_image_bytes(candidate)
+ if detected and _sticker_md5_matches(candidate, source):
+ ext, mime = detected
+ return candidate, ext, mime
+ return None
+
+
+def _pkcs7_unpad(data):
+ if not data:
+ return None
+ pad = data[-1]
+ if pad < 1 or pad > 16 or len(data) < pad:
+ return None
+ if data[-pad:] != bytes([pad]) * pad:
+ return None
+ return data[:-pad]
+
+
+def _sticker_md5_matches(data, source):
+ expected = (source.get("sticker_md5") or "").strip().lower()
+ if not expected:
+ return True
+ return hashlib.md5(data).hexdigest().lower() == expected
+
+
+def _with_source_label(source, label):
+ labeled = dict(source)
+ labeled["source_label"] = label
+ return labeled
+
+
+def _copy_source(source, record, index, assets_dir, rel_assets_root, used_names, status, detail=""):
+ source_path = source["source_path"]
+ ext = _extension_for_source(source_path, source.get("kind", "file"))
+ mime = mimetypes.guess_type(source_path)[0] or _default_mime(source.get("kind", "file"))
+ rel_path, dest_path = _destination_paths(
+ source, record, index, assets_dir, rel_assets_root, used_names, ext
+ )
+ shutil.copy2(source_path, dest_path)
+ return _media_file_entry(source, status, rel_path, dest_path, mime=mime, detail=detail)
+
+
+def _write_asset_bytes(data, source, record, index, assets_dir, rel_assets_root, used_names, ext, mime, status):
+ rel_path, dest_path = _destination_paths(
+ source, record, index, assets_dir, rel_assets_root, used_names, ext
+ )
+ with open(dest_path, "wb") as f:
+ f.write(data)
+ return _media_file_entry(source, status, rel_path, dest_path, mime=mime)
+
+
+def _destination_paths(source, record, index, assets_dir, rel_assets_root, used_names, ext):
+ kind = source.get("kind", "file")
+ subdir = {
+ "image": "images",
+ "sticker": "stickers",
+ "video": "videos",
+ "voice": "audio",
+ "audio": "audio",
+ "file": "files",
+ }.get(kind, "media")
+ safe_base = _safe_asset_basename(source, record, index)
+ filename = f"{safe_base}.{ext.lstrip('.')}" if ext else safe_base
+ filename = _unique_name(used_names, subdir, filename)
+ dest_dir = os.path.join(assets_dir, subdir)
+ os.makedirs(dest_dir, exist_ok=True)
+ rel_path = posixpath.join(rel_assets_root, subdir, filename)
+ return rel_path, os.path.join(dest_dir, filename)
+
+
+def _safe_asset_basename(source, record, index):
+ created = record.get("time") or ""
+ created = created.replace(":", "").replace("-", "").replace(" ", "_")
+ local_id = record.get("local_id", "message")
+ original = source.get("original_filename") or ""
+ original = os.path.splitext(os.path.basename(original))[0]
+ parts = [str(created or "time"), str(local_id), str(index)]
+ if original:
+ parts.append(original[:60])
+ raw = "_".join(parts)
+ safe = _SAFE_NAME_RE.sub("_", raw).strip("._")
+ return safe or f"media_{local_id}_{index}"
+
+
+def _unique_name(used_names, subdir, filename):
+ key = (subdir, filename)
+ if key not in used_names:
+ used_names.add(key)
+ return filename
+ base, ext = os.path.splitext(filename)
+ i = 1
+ while True:
+ candidate = f"{base}_{i}{ext}"
+ key = (subdir, candidate)
+ if key not in used_names:
+ used_names.add(key)
+ return candidate
+ i += 1
+
+
+def _extension_for_source(path, kind):
+ ext = os.path.splitext(path)[1].lstrip(".").lower()
+ if ext:
+ return ext
+ return {
+ "image": "dat",
+ "sticker": "bin",
+ "video": "mp4",
+ "voice": "aud",
+ "audio": "aud",
+ "file": "bin",
+ }.get(kind, "bin")
+
+
+def _default_mime(kind):
+ return {
+ "image": "application/octet-stream",
+ "sticker": "application/octet-stream",
+ "video": "video/mp4",
+ "voice": "application/octet-stream",
+ "audio": "application/octet-stream",
+ "file": "application/octet-stream",
+ }.get(kind, "application/octet-stream")
+
+
+def _media_status_entry(source, status, detail=""):
+ entry = {
+ "kind": source.get("kind", "file"),
+ "status": status,
+ }
+ if source.get("original_filename"):
+ entry["original_filename"] = source["original_filename"]
+ if source.get("source_label"):
+ entry["source"] = source["source_label"]
+ if source.get("cache_hit"):
+ entry["cache_hit"] = True
+ for source_key, entry_key in (
+ ("sticker_md5", "md5"),
+ ("expected_bytes", "expected_bytes"),
+ ("width", "width"),
+ ("height", "height"),
+ ("product_id", "product_id"),
+ ):
+ value = source.get(source_key)
+ if value not in (None, ""):
+ entry[entry_key] = _json_scalar(value)
+ if detail:
+ entry["detail"] = detail
+ return entry
+
+
+def _media_file_entry(source, status, rel_path, dest_path, mime=None, detail=""):
+ entry = _media_status_entry(source, status, detail=detail)
+ entry["path"] = rel_path
+ entry["mime"] = mime or "application/octet-stream"
+ entry["_asset_path"] = dest_path
+ try:
+ entry["bytes"] = os.path.getsize(dest_path)
+ except OSError:
+ pass
+ if status in _SUCCESS_MEDIA_STATUSES:
+ metadata, warning = _probe_media_metadata(dest_path, entry)
+ entry.update(metadata)
+ if warning:
+ entry["_probe_warning"] = warning
+ return entry
+
+
+def _probe_media_metadata(path, entry):
+ kind = entry.get("kind", "")
+ mime = entry.get("mime") or ""
+ if _expects_image_metadata(kind, mime):
+ metadata, detail = _probe_image_metadata(path)
+ missing = []
+ if not (metadata.get("width") or entry.get("width")):
+ missing.append("width")
+ if not (metadata.get("height") or entry.get("height")):
+ missing.append("height")
+ if missing:
+ return metadata, detail or f"image metadata unavailable: {', '.join(missing)}"
+ return metadata, ""
+
+ if _expects_av_metadata(kind, mime):
+ metadata, detail = _probe_ffprobe_metadata(path)
+ missing = []
+ if _expects_video_dimensions(kind, mime):
+ if not metadata.get("width"):
+ missing.append("width")
+ if not metadata.get("height"):
+ missing.append("height")
+ if not metadata.get("duration_ms"):
+ missing.append("duration_ms")
+ if missing:
+ return metadata, detail or f"audio/video metadata unavailable: {', '.join(missing)}"
+ return metadata, ""
+
+ return {}, ""
+
+
+def _expects_image_metadata(kind, mime):
+ return kind in {"image", "sticker"} or mime.startswith("image/")
+
+
+def _expects_av_metadata(kind, mime):
+ return kind in {"video", "voice", "audio"} or mime.startswith("video/") or mime.startswith("audio/")
+
+
+def _expects_video_dimensions(kind, mime):
+ return kind == "video" or mime.startswith("video/")
+
+
+def _probe_image_metadata(path):
+ pillow_error = ""
+ try:
+ from PIL import Image
+ with Image.open(path) as image:
+ width, height = image.size
+ if width and height:
+ return {"width": int(width), "height": int(height)}, ""
+ except ImportError:
+ pass
+ except Exception as e:
+ pillow_error = str(e)
+
+ metadata = _probe_image_metadata_from_header(path)
+ if metadata:
+ return metadata, ""
+ return {}, pillow_error or "image dimensions unavailable"
+
+
+def _probe_image_metadata_from_header(path):
+ try:
+ with open(path, "rb") as f:
+ data = f.read(512 * 1024)
+ except OSError:
+ return {}
+ if len(data) < 10:
+ return {}
+
+ if data.startswith(b"\x89PNG\r\n\x1a\n") and len(data) >= 24:
+ width = int.from_bytes(data[16:20], "big")
+ height = int.from_bytes(data[20:24], "big")
+ return _dimension_metadata(width, height)
+
+ if data.startswith((b"GIF87a", b"GIF89a")) and len(data) >= 10:
+ width = int.from_bytes(data[6:8], "little")
+ height = int.from_bytes(data[8:10], "little")
+ return _dimension_metadata(width, height)
+
+ if _is_valid_bmp(data) and len(data) >= 26:
+ dib_size = int.from_bytes(data[14:18], "little")
+ if dib_size == 12:
+ width = int.from_bytes(data[18:20], "little")
+ height = int.from_bytes(data[20:22], "little")
+ else:
+ width = int.from_bytes(data[18:22], "little", signed=True)
+ height = int.from_bytes(data[22:26], "little", signed=True)
+ return _dimension_metadata(width, abs(height))
+
+ if len(data) >= 12 and data.startswith(b"RIFF") and data[8:12] == b"WEBP":
+ return _probe_webp_dimensions(data)
+
+ if data.startswith(b"\xff\xd8"):
+ return _probe_jpeg_dimensions(data)
+
+ return {}
+
+
+def _probe_webp_dimensions(data):
+ if len(data) < 30:
+ return {}
+ chunk = data[12:16]
+ if chunk == b"VP8X" and len(data) >= 30:
+ width = int.from_bytes(data[24:27], "little") + 1
+ height = int.from_bytes(data[27:30], "little") + 1
+ return _dimension_metadata(width, height)
+ if chunk == b"VP8 " and len(data) >= 30:
+ width = int.from_bytes(data[26:28], "little") & 0x3fff
+ height = int.from_bytes(data[28:30], "little") & 0x3fff
+ return _dimension_metadata(width, height)
+ if chunk == b"VP8L" and len(data) >= 25:
+ bits = int.from_bytes(data[21:25], "little")
+ width = (bits & 0x3fff) + 1
+ height = ((bits >> 14) & 0x3fff) + 1
+ return _dimension_metadata(width, height)
+ return {}
+
+
+def _probe_jpeg_dimensions(data):
+ i = 2
+ sof_markers = {
+ 0xC0, 0xC1, 0xC2, 0xC3, 0xC5, 0xC6, 0xC7,
+ 0xC9, 0xCA, 0xCB, 0xCD, 0xCE, 0xCF,
+ }
+ while i + 9 <= len(data):
+ while i < len(data) and data[i] != 0xFF:
+ i += 1
+ while i < len(data) and data[i] == 0xFF:
+ i += 1
+ if i >= len(data):
+ return {}
+ marker = data[i]
+ i += 1
+ if marker in {0xD8, 0xD9} or 0xD0 <= marker <= 0xD7:
+ continue
+ if i + 2 > len(data):
+ return {}
+ segment_len = int.from_bytes(data[i:i + 2], "big")
+ if segment_len < 2 or i + segment_len > len(data):
+ return {}
+ if marker in sof_markers and segment_len >= 7:
+ height = int.from_bytes(data[i + 3:i + 5], "big")
+ width = int.from_bytes(data[i + 5:i + 7], "big")
+ return _dimension_metadata(width, height)
+ i += segment_len
+ return {}
+
+
+def _dimension_metadata(width, height):
+ if width and height and width > 0 and height > 0:
+ return {"width": int(width), "height": int(height)}
+ return {}
+
+
+def _probe_ffprobe_metadata(path):
+ ffprobe_path = _ffprobe_path()
+ if not ffprobe_path:
+ return {}, "ffprobe not found"
+ try:
+ completed = subprocess.run(
+ [
+ ffprobe_path,
+ "-hide_banner",
+ "-loglevel", "error",
+ "-print_format", "json",
+ "-show_entries", "format=duration:stream=codec_type,width,height,duration",
+ path,
+ ],
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ timeout=30,
+ check=False,
+ )
+ except (OSError, subprocess.SubprocessError) as e:
+ return {}, str(e)
+ if completed.returncode != 0:
+ detail = completed.stderr.decode("utf-8", errors="replace").strip()
+ return {}, detail or "ffprobe failed"
+ try:
+ payload = json.loads(completed.stdout.decode("utf-8", errors="replace") or "{}")
+ except json.JSONDecodeError as e:
+ return {}, f"ffprobe JSON parse failed: {e}"
+
+ metadata = {}
+ streams = payload.get("streams") or []
+ video_stream = next((stream for stream in streams if stream.get("codec_type") == "video"), None)
+ if video_stream:
+ width = _positive_int(video_stream.get("width"))
+ height = _positive_int(video_stream.get("height"))
+ if width:
+ metadata["width"] = width
+ if height:
+ metadata["height"] = height
+
+ duration_ms = _duration_ms((payload.get("format") or {}).get("duration"))
+ if duration_ms is None:
+ for stream in streams:
+ duration_ms = _duration_ms(stream.get("duration"))
+ if duration_ms is not None:
+ break
+ if duration_ms is not None:
+ metadata["duration_ms"] = duration_ms
+ return metadata, "" if metadata else "ffprobe returned no usable metadata"
+
+
+def _duration_ms(value):
+ if value in (None, "", "N/A"):
+ return None
+ try:
+ seconds = float(value)
+ except (TypeError, ValueError):
+ return None
+ if seconds < 0:
+ return None
+ return int(round(seconds * 1000))
+
+
+def _positive_int(value):
+ try:
+ integer = int(value)
+ except (TypeError, ValueError):
+ return None
+ return integer if integer > 0 else None
+
+
+def _ffprobe_path():
+ configured = os.environ.get("FFPROBE_PATH")
+ if configured:
+ return configured if os.path.isfile(configured) or shutil.which(configured) else None
+ ffmpeg_configured = os.environ.get("FFMPEG_PATH")
+ if ffmpeg_configured and os.path.isfile(ffmpeg_configured):
+ candidate = os.path.join(os.path.dirname(ffmpeg_configured), "ffprobe")
+ if os.path.isfile(candidate):
+ return candidate
+ return shutil.which("ffprobe")
+
+
+def detect_image_bytes(data):
+ for ext, mime, signature in _IMAGE_SIGNATURES:
+ if data.startswith(signature):
+ return ext, mime
+ if _is_valid_bmp(data):
+ return _BMP_SIGNATURE[0], _BMP_SIGNATURE[1]
+ if len(data) >= 12 and data.startswith(b"RIFF") and data[8:12] == b"WEBP":
+ return "webp", "image/webp"
+ return None
+
+
+def decode_wechat_image_dat(data, source_path=None):
+ if not data:
+ return None
+ if is_wxgf_image(data):
+ return decode_wxgf_image(data)
+ if _is_wechat_v2_dat(data):
+ return _decode_wechat_v2_image_dat(data, source_path)
+ if _is_wechat_v1_dat(data):
+ decoded = _decode_wechat_segmented_dat(data, _WECHAT_V1_AES_KEY, 0)
+ if decoded:
+ detected = detect_image_bytes(decoded)
+ if detected:
+ ext, mime = detected
+ return decoded, ext, mime
+ return None
+
+ for ext, mime, signature in _IMAGE_SIGNATURES + [_BMP_SIGNATURE]:
+ key = data[0] ^ signature[0]
+ prefix = _xor_prefix(data, key, len(signature))
+ if prefix == signature:
+ decoded = bytes(b ^ key for b in data)
+ detected = detect_image_bytes(decoded)
+ if detected:
+ detected_ext, detected_mime = detected
+ return decoded, detected_ext, detected_mime
+
+ # WEBP uses RIFF....WEBP, so validate both fixed signature positions.
+ webp_header = b"RIFF"
+ key = data[0] ^ webp_header[0]
+ if len(data) >= 12:
+ decoded_prefix = _xor_prefix(data, key, 12)
+ if decoded_prefix.startswith(b"RIFF") and decoded_prefix[8:12] == b"WEBP":
+ decoded = bytes(b ^ key for b in data)
+ return decoded, "webp", "image/webp"
+ return None
+
+
+def _image_variant_paths(path):
+ directory = os.path.dirname(path)
+ filename = os.path.basename(path)
+ stem, ext = os.path.splitext(filename)
+ if ext.lower() != ".dat":
+ return []
+
+ base_stem = stem
+ if stem.endswith("_h") or stem.endswith("_t"):
+ base_stem = stem[:-2]
+ if not _HEX32_RE.fullmatch(base_stem):
+ return []
+
+ candidate_names = [
+ f"{base_stem}_h.dat",
+ f"{base_stem}.dat",
+ f"{base_stem}_t.dat",
+ ]
+ candidates = []
+ seen = {os.path.abspath(path)}
+ for candidate_name in candidate_names:
+ candidate_path = os.path.abspath(os.path.join(directory, candidate_name))
+ if candidate_path in seen:
+ continue
+ seen.add(candidate_path)
+ if os.path.isfile(candidate_path):
+ candidates.append(candidate_path)
+ return candidates
+
+
+def _is_wechat_dat(data):
+ return any(data.startswith(header) for header in _WECHAT_DAT_HEADERS)
+
+
+def _is_wechat_v1_dat(data):
+ return data.startswith(_WECHAT_V1_DAT_HEADER)
+
+
+def _is_wechat_v2_dat(data):
+ return data.startswith(_WECHAT_V2_DAT_HEADER)
+
+
+def _decode_wechat_v2_image_dat(data, source_path):
+ for aes_key, xor_key in _wechat_v2_key_candidates(source_path):
+ decoded = _decode_wechat_segmented_dat(data, aes_key, xor_key)
+ if not decoded:
+ continue
+ detected = detect_image_bytes(decoded)
+ if detected:
+ ext, mime = detected
+ return decoded, ext, mime
+ if is_wxgf_image(decoded):
+ wxgf_decoded = decode_wxgf_image(decoded)
+ if wxgf_decoded:
+ return wxgf_decoded
+ return None
+
+
+def is_wxgf_image(data):
+ return len(data) >= 15 and data.startswith(_WXGF_SIGNATURE)
+
+
+def decode_wxgf_image(data):
+ partition = _largest_wxgf_partition(data)
+ if not partition:
+ return None
+ ffmpeg_path = _ffmpeg_path()
+ if not ffmpeg_path:
+ return None
+
+ start, size = partition
+ try:
+ completed = subprocess.run(
+ [
+ ffmpeg_path,
+ "-hide_banner",
+ "-loglevel", "error",
+ "-f", "hevc",
+ "-i", "pipe:0",
+ "-frames:v", "1",
+ "-c:v", "mjpeg",
+ "-q:v", "4",
+ "-f", "image2",
+ "pipe:1",
+ ],
+ input=data[start:start + size],
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ timeout=30,
+ check=False,
+ )
+ except (OSError, subprocess.SubprocessError):
+ return None
+
+ if completed.returncode != 0 or not completed.stdout:
+ return None
+ detected = detect_image_bytes(completed.stdout)
+ if not detected:
+ return None
+ ext, mime = detected
+ return completed.stdout, ext, mime
+
+
+def _largest_wxgf_partition(data):
+ if not is_wxgf_image(data):
+ return None
+ header_len = data[4]
+ if header_len >= len(data):
+ return None
+
+ best = None
+ for pattern in (b"\x00\x00\x00\x01", b"\x00\x00\x01"):
+ offset = 0
+ while header_len + offset <= len(data):
+ index = data.find(pattern, header_len + offset)
+ if index == -1:
+ break
+ if index >= 4:
+ size = int.from_bytes(data[index - 4:index], "big")
+ if size > 0 and index + size <= len(data):
+ if best is None or size > best[1]:
+ best = (index, size)
+ offset = index - header_len + size
+ continue
+ offset = index - header_len + 1
+ if best:
+ return best
+ return None
+
+
+def _ffmpeg_path():
+ configured = os.environ.get("FFMPEG_PATH")
+ if configured:
+ return configured if os.path.isfile(configured) or shutil.which(configured) else None
+ return shutil.which("ffmpeg")
+
+
+def _decode_wechat_segmented_dat(data, aes_key, xor_key):
+ if len(data) < 15:
+ return None
+ aes_len = int.from_bytes(data[6:10], "little")
+ xor_len = int.from_bytes(data[10:14], "little")
+ body = data[15:]
+ aes_cipher_len = (aes_len // 16) * 16 + 16
+ if aes_cipher_len > len(body) or xor_len > len(body) or len(body) - xor_len < aes_cipher_len:
+ return None
+
+ try:
+ from Crypto.Cipher import AES
+ prefix = AES.new(aes_key, AES.MODE_ECB).decrypt(body[:aes_cipher_len])[:aes_len]
+ except (ImportError, ValueError):
+ return None
+
+ middle_end = len(body) - xor_len
+ middle = body[aes_cipher_len:middle_end]
+ tail = bytes(b ^ xor_key for b in body[middle_end:]) if xor_len else b""
+ return prefix + middle + tail
+
+
+def _wechat_v2_key_candidates(source_path):
+ if not source_path:
+ return []
+ context = _wechat_media_context(source_path)
+ if not context:
+ return []
+ documents_dir, account_dir, wxid = context
+ cache_key = (documents_dir, account_dir)
+ if cache_key in _V2_KEY_CACHE:
+ return _V2_KEY_CACHE[cache_key]
+
+ candidates = []
+ seen_uins = set()
+ for uin in _kvcomm_uins(documents_dir):
+ if uin in seen_uins:
+ continue
+ seen_uins.add(uin)
+ aes_key = hashlib.md5((str(uin) + wxid).encode("utf-8")).hexdigest()[:16].encode("ascii")
+ candidates.append((aes_key, uin & 0xff))
+ _V2_KEY_CACHE[cache_key] = candidates
+ return candidates
+
+
+def _wechat_media_context(source_path):
+ try:
+ path = Path(source_path).resolve()
+ parts = path.parts
+ idx = parts.index("xwechat_files")
+ account_dir = parts[idx + 1]
+ except (ValueError, IndexError, OSError):
+ return None
+
+ wxid = account_dir
+ if "_" in account_dir:
+ prefix, suffix = account_dir.rsplit("_", 1)
+ if len(suffix) == 4 and all(c in "0123456789abcdefABCDEF" for c in suffix):
+ wxid = prefix
+ documents_dir = str(Path(*parts[:idx]))
+ return documents_dir, account_dir, wxid
+
+
+def _kvcomm_uins(documents_dir):
+ roots = _kvcomm_roots(documents_dir)
+ uins = []
+ zero_uins = []
+ for root in roots:
+ if not os.path.isdir(root):
+ continue
+ for uin in _config_last_uins(root):
+ target = zero_uins if uin == 0 else uins
+ if uin not in target:
+ target.append(uin)
+ try:
+ filenames = os.listdir(root)
+ except OSError:
+ continue
+ for filename in filenames:
+ match = _KVCOMM_STATISTIC_RE.match(filename)
+ if not match:
+ continue
+ try:
+ uin = int(match.group(1))
+ except ValueError:
+ continue
+ target = zero_uins if uin == 0 else uins
+ if uin not in target:
+ target.append(uin)
+ return uins or zero_uins
+
+
+def _kvcomm_roots(documents_dir):
+ roots = [
+ os.path.join(documents_dir, "app_data", "radium", "ilink"),
+ os.path.join(documents_dir, "app_data", "net", "kvcomm"),
+ os.path.join(documents_dir, "app_data", "ilink", "kvcomm"),
+ ]
+ expanded = []
+ for root in roots:
+ if root.endswith(os.path.join("radium", "ilink")):
+ if not os.path.isdir(root):
+ continue
+ try:
+ account_dirs = os.listdir(root)
+ except OSError:
+ continue
+ for account_dir in account_dirs:
+ expanded.append(os.path.join(root, account_dir, "kvcomm"))
+ continue
+ expanded.append(root)
+ return expanded
+
+
+def _config_last_uins(root):
+ path = os.path.join(root, "config.ini")
+ try:
+ with open(path, "r", encoding="utf-8", errors="replace") as f:
+ lines = f.readlines()
+ except OSError:
+ return []
+ uins = []
+ for line in lines:
+ key, sep, value = line.partition("=")
+ if not sep or key.strip() != "last_uin":
+ continue
+ uin = _parse_uin_token(value.strip())
+ if uin is not None:
+ uins.append(uin)
+ return uins
+
+
+def _parse_uin_token(value):
+ if not value:
+ return None
+ if value.isdigit():
+ return int(value)
+ padded = value + ("=" * (-len(value) % 4))
+ try:
+ decoded = base64.b64decode(padded, validate=True).decode("ascii", errors="strict").strip()
+ except Exception:
+ return None
+ if decoded.isdigit():
+ return int(decoded)
+ return None
+
+
+def _is_valid_bmp(data):
+ if len(data) < 26 or not data.startswith(b"BM"):
+ return False
+
+ file_size = int.from_bytes(data[2:6], "little")
+ pixel_offset = int.from_bytes(data[10:14], "little")
+ dib_size = int.from_bytes(data[14:18], "little")
+ if dib_size not in {12, 40, 52, 56, 64, 108, 124}:
+ return False
+
+ min_header_size = 14 + dib_size
+ if len(data) < min_header_size:
+ return False
+ if file_size and (file_size < min_header_size or file_size > len(data)):
+ return False
+ if pixel_offset < min_header_size or pixel_offset > len(data):
+ return False
+
+ if dib_size == 12:
+ if len(data) < 26:
+ return False
+ width = int.from_bytes(data[18:20], "little")
+ height = int.from_bytes(data[20:22], "little")
+ planes = int.from_bytes(data[22:24], "little")
+ bit_count = int.from_bytes(data[24:26], "little")
+ else:
+ if len(data) < 30:
+ return False
+ width = int.from_bytes(data[18:22], "little", signed=True)
+ height = int.from_bytes(data[22:26], "little", signed=True)
+ planes = int.from_bytes(data[26:28], "little")
+ bit_count = int.from_bytes(data[28:30], "little")
+
+ return (
+ width > 0
+ and height != 0
+ and abs(height) <= 100000
+ and width <= 100000
+ and planes == 1
+ and bit_count in {1, 4, 8, 16, 24, 32}
+ )
+
+
+def _xor_prefix(data, key, length):
+ return bytes(b ^ key for b in data[:length])
+
+
+def _json_scalar(value):
+ if isinstance(value, str):
+ stripped = value.strip()
+ if stripped.isdigit():
+ try:
+ return int(stripped)
+ except ValueError:
+ return value
+ return value
+
+
+def _to_posix(path):
+ return path.replace(os.sep, "/")
diff --git a/wechat_cli/core/messages.py b/wechat_cli/core/messages.py
index d62ef33..1e7d5cd 100644
--- a/wechat_cli/core/messages.py
+++ b/wechat_cli/core/messages.py
@@ -17,6 +17,7 @@
_XML_PARSE_MAX_LEN = 20000
_QUERY_LIMIT_MAX = 500
_HISTORY_QUERY_BATCH_SIZE = 500
+_RESOURCE_HASH_RE = re.compile(rb'[a-fA-F0-9]{32}')
# 消息类型过滤映射: 名称 -> (base_type,) 或 (base_type, sub_type)
MSG_TYPE_FILTERS = {
@@ -133,7 +134,20 @@ def _collapse_text(text):
return re.sub(r'\s+', ' ', text).strip()
+def _xml_payload(content):
+ if not content:
+ return content
+ text = content.strip()
+ if text.startswith("<"):
+ return text
+ start = text.find("= 0:
+ return text[start:]
+ return text
+
+
def _parse_xml_root(content):
+ content = _xml_payload(content)
if not content or len(content) > _XML_PARSE_MAX_LEN or _XML_UNSAFE_RE.search(content):
return None
try:
@@ -380,6 +394,382 @@ def _resolve_sender_label(real_sender_id, sender_from_content, is_group, chat_us
return ''
+def _resolve_sender_identity(real_sender_id, sender_from_content, is_group, chat_username, names, id_to_username, display_name_fn):
+ sender_username = id_to_username.get(real_sender_id, '')
+ if is_group:
+ if sender_username and sender_username != chat_username:
+ return sender_username, display_name_fn(sender_username, names)
+ if sender_from_content:
+ return sender_from_content, display_name_fn(sender_from_content, names)
+ if sender_username:
+ return sender_username, display_name_fn(sender_username, names)
+ return '', ''
+
+
+def _message_type_key(local_type, content=None):
+ base_type, sub_type = _split_msg_type(local_type)
+ if base_type == 49 and content:
+ root = _parse_xml_root(content)
+ if root is not None:
+ appmsg = root.find('.//appmsg')
+ if appmsg is not None:
+ app_type = _parse_int((appmsg.findtext('type') or '').strip(), _parse_int(sub_type, 0))
+ if app_type == 6:
+ return 'file'
+ if app_type == 5:
+ return 'link'
+ if app_type in (33, 36, 44):
+ return 'mini_program'
+ if app_type == 57:
+ return 'quote'
+ return 'app'
+ return {
+ 1: 'text',
+ 3: 'image',
+ 34: 'voice',
+ 42: 'contact_card',
+ 43: 'video',
+ 47: 'sticker',
+ 48: 'location',
+ 50: 'call',
+ 10000: 'system',
+ 10002: 'recall',
+ }.get(base_type, f'type_{base_type}')
+
+
+def _iter_xml_strings(content):
+ root = _parse_xml_root(content)
+ if root is None:
+ return
+ for elem in root.iter():
+ for value in elem.attrib.values():
+ if value:
+ yield value
+ if elem.text:
+ yield elem.text
+
+
+def _extract_media_tokens(content):
+ tokens = set()
+ if not content:
+ return tokens
+ values = list(_iter_xml_strings(content) or [])
+ values.append(content)
+ for value in values:
+ value = value.strip()
+ if not value:
+ continue
+ base = os.path.basename(value)
+ if base and base != value:
+ tokens.add(base)
+ tokens.add(os.path.splitext(base)[0])
+ for match in re.findall(r'[\w.-]{4,}\.(?:dat|jpg|jpeg|png|gif|webp|mp4|mov|m4v|m4a|amr|aud|silk|wav|pdf|docx?|xlsx?|pptx?|zip|rar|7z|txt)', value, flags=re.IGNORECASE):
+ tokens.add(os.path.basename(match))
+ tokens.add(os.path.splitext(os.path.basename(match))[0])
+ for match in re.findall(r'\b[a-fA-F0-9]{16,64}\b', value):
+ tokens.add(match)
+ return {t for t in tokens if t}
+
+
+def _find_by_title(directory, title):
+ if not title or not os.path.isdir(directory):
+ return None
+ target = os.path.join(directory, title)
+ if os.path.isfile(target):
+ return target
+ for filename in os.listdir(directory):
+ if title in filename or filename in title:
+ path = os.path.join(directory, filename)
+ if os.path.isfile(path):
+ return path
+ return None
+
+
+def _find_media_candidate(search_dirs, tokens=None, exclude_suffixes=()):
+ tokens = tokens or set()
+ files = []
+ for directory in search_dirs:
+ if not directory or not os.path.isdir(directory):
+ continue
+ for filename in os.listdir(directory):
+ if any(filename.endswith(suffix) for suffix in exclude_suffixes):
+ continue
+ path = os.path.join(directory, filename)
+ if os.path.isfile(path):
+ files.append(path)
+ if not files:
+ return None, "local media file not found"
+
+ if tokens:
+ for path in files:
+ name = os.path.basename(path)
+ stem = os.path.splitext(name)[0]
+ if name in tokens or stem in tokens or any(token in name for token in tokens if len(token) >= 8):
+ return path, ""
+
+ if len(files) == 1:
+ return files[0], ""
+ return None, f"ambiguous local media candidates: {len(files)}"
+
+
+def _find_media_by_resource_hash(search_dirs, resource_hash, kind):
+ if not resource_hash:
+ return None
+
+ if kind == "image":
+ exact_names = [
+ f"{resource_hash}.dat", f"{resource_hash}.jpg", f"{resource_hash}.jpeg",
+ f"{resource_hash}.png", f"{resource_hash}.gif", f"{resource_hash}.webp",
+ ]
+ fallback_suffixes = ("_t.dat", "_t.jpg", "_t.jpeg", "_t.png", "_thumb.jpg")
+ elif kind == "video":
+ exact_names = [
+ f"{resource_hash}.mp4", f"{resource_hash}.mov", f"{resource_hash}.m4v",
+ f"{resource_hash}.dat",
+ ]
+ fallback_suffixes = ()
+ else:
+ exact_names = [resource_hash]
+ fallback_suffixes = ()
+
+ for directory in search_dirs:
+ if not directory or not os.path.isdir(directory):
+ continue
+ for filename in exact_names:
+ path = os.path.join(directory, filename)
+ if os.path.isfile(path):
+ return path
+
+ candidates = []
+ for directory in search_dirs:
+ if not directory or not os.path.isdir(directory):
+ continue
+ for filename in os.listdir(directory):
+ if filename.startswith(resource_hash):
+ path = os.path.join(directory, filename)
+ if os.path.isfile(path):
+ candidates.append(path)
+ if not candidates:
+ return None
+
+ def _candidate_rank(path):
+ name = os.path.basename(path)
+ is_fallback = any(name.endswith(suffix) for suffix in fallback_suffixes)
+ return (is_fallback, -os.path.getsize(path))
+
+ candidates.sort(key=_candidate_rank)
+ return candidates[0]
+
+
+def _chat_attach_dirs(db_dir, chat_username, date_prefix, sub_dir_name):
+ wechat_base = os.path.dirname(db_dir)
+ attach_dir = os.path.join(wechat_base, "msg", "attach")
+ if not os.path.isdir(attach_dir):
+ return []
+ dirs = []
+ if chat_username:
+ chat_hash = hashlib.md5(chat_username.encode()).hexdigest()
+ dirs.append(os.path.join(attach_dir, chat_hash, date_prefix, sub_dir_name))
+ return dirs
+
+
+def _build_media_source(kind, source_path=None, original_filename='', detail=''):
+ return {
+ 'kind': kind,
+ 'source_path': source_path,
+ 'original_filename': original_filename or (os.path.basename(source_path) if source_path else ''),
+ 'detail': detail,
+ }
+
+
+def _build_sticker_media_source(info, source_path=None, detail=''):
+ return {
+ 'kind': 'sticker',
+ 'source_path': source_path,
+ 'original_filename': info.get('md5') or (os.path.basename(source_path) if source_path else ''),
+ 'detail': detail,
+ 'sticker_md5': info.get('md5', ''),
+ 'expected_bytes': info.get('len', ''),
+ 'width': info.get('width', ''),
+ 'height': info.get('height', ''),
+ 'product_id': info.get('productid', ''),
+ 'aeskey': info.get('aeskey', ''),
+ 'cdn_url': info.get('cdnurl', ''),
+ 'encrypt_url': info.get('encrypturl', ''),
+ }
+
+
+def _resource_hashes_for_message(resource_index, local_id, base_type, create_time_ts):
+ if not resource_index:
+ return []
+ return resource_index.get((local_id, base_type, create_time_ts), [])
+
+
+def _resolve_export_media_sources(db_dir, local_id, local_type, content, create_time_ts, chat_username, resource_index=None):
+ if not db_dir:
+ return []
+ base_type, _ = _split_msg_type(local_type)
+ wechat_base = os.path.dirname(db_dir)
+ msg_dir = os.path.join(wechat_base, "msg")
+
+ date_prefix = datetime.fromtimestamp(create_time_ts).strftime("%Y-%m") if create_time_ts else ""
+ tokens = _extract_media_tokens(content)
+
+ if base_type == 49 and content:
+ root = _parse_xml_root(content)
+ if root is None:
+ return []
+ appmsg = root.find('.//appmsg')
+ if appmsg is None:
+ return []
+ app_type = _parse_int((appmsg.findtext('type') or '').strip())
+ if app_type != 6:
+ return []
+ title = (appmsg.findtext('title') or '').strip()
+ if not os.path.isdir(msg_dir):
+ return [_build_media_source('file', original_filename=title, detail="WeChat msg storage not found")]
+ file_dir = os.path.join(msg_dir, "file", date_prefix)
+ path = _find_by_title(file_dir, title)
+ detail = "" if path else "local file attachment not found"
+ return [_build_media_source('file', path, title, detail)]
+
+ if base_type == 3:
+ if not os.path.isdir(msg_dir):
+ return [_build_media_source('image', detail="WeChat msg storage not found")]
+ dirs = _chat_attach_dirs(db_dir, chat_username, date_prefix, "Img")
+ resource_hashes = _resource_hashes_for_message(resource_index, local_id, base_type, create_time_ts)
+ for resource_hash in resource_hashes:
+ path = _find_media_by_resource_hash(dirs, resource_hash, "image")
+ if path:
+ return [_build_media_source('image', path)]
+ if resource_hashes:
+ return [_build_media_source('image', detail=f"resource media file not found: {resource_hashes[0]}")]
+ path, detail = _find_media_candidate(dirs, tokens=tokens, exclude_suffixes=("_h.dat",))
+ return [_build_media_source('image', path, detail=detail)]
+
+ if base_type == 43:
+ if not os.path.isdir(msg_dir):
+ return [_build_media_source('video', detail="WeChat msg storage not found")]
+ dirs = _chat_attach_dirs(db_dir, chat_username, date_prefix, "Video")
+ video_dir = os.path.join(msg_dir, "video", date_prefix)
+ if os.path.isdir(video_dir):
+ dirs.append(video_dir)
+ resource_hashes = _resource_hashes_for_message(resource_index, local_id, base_type, create_time_ts)
+ for resource_hash in resource_hashes:
+ path = _find_media_by_resource_hash(dirs, resource_hash, "video")
+ if path:
+ return [_build_media_source('video', path)]
+ path, detail = _find_media_candidate(dirs, tokens=tokens, exclude_suffixes=("_thumb.jpg",))
+ return [_build_media_source('video', path, detail=detail)]
+
+ if base_type == 34:
+ if not os.path.isdir(msg_dir):
+ return [_build_media_source('voice', detail="WeChat msg storage not found")]
+ dirs = _chat_attach_dirs(db_dir, chat_username, date_prefix, "Voice")
+ path, detail = _find_media_candidate(dirs, tokens=tokens)
+ return [_build_media_source('voice', path, detail=detail)]
+
+ if base_type == 47:
+ info = _parse_sticker_info(content)
+ if not info:
+ return [_build_media_source('sticker', detail="sticker XML not found")]
+ path = _find_sticker_cache_path(wechat_base, date_prefix, info.get('md5'))
+ detail = "" if path else "local sticker cache not found"
+ return [_build_sticker_media_source(info, source_path=path, detail=detail)]
+
+ return []
+
+
+def _parse_sticker_info(content):
+ root = _parse_xml_root(content)
+ if root is None:
+ return None
+ emoji = root.find('.//emoji')
+ if emoji is None:
+ return None
+ return {key: (emoji.attrib.get(key) or '').strip() for key in (
+ 'md5', 'len', 'width', 'height', 'productid', 'aeskey', 'cdnurl', 'encrypturl'
+ )}
+
+
+def _find_sticker_cache_path(wechat_base, date_prefix, sticker_md5):
+ if not sticker_md5:
+ return None
+ prefix = sticker_md5[:2]
+ candidates = [
+ os.path.join(wechat_base, "cache", date_prefix, "Emoticon", prefix, sticker_md5),
+ os.path.join(wechat_base, "cache", date_prefix, "Emoticon", sticker_md5),
+ os.path.join(wechat_base, "business", "emoticon", "Persist", prefix, sticker_md5),
+ os.path.join(wechat_base, "business", "emoticon", "Thumb", prefix, f"{sticker_md5}.thumb"),
+ ]
+ for path in candidates:
+ if os.path.isfile(path):
+ return path
+ return None
+
+
+def _extract_resource_hashes(packed_info):
+ if not packed_info:
+ return []
+ if isinstance(packed_info, str):
+ data = packed_info.encode("utf-8", errors="ignore")
+ else:
+ data = bytes(packed_info)
+ seen = set()
+ hashes = []
+ for match in _RESOURCE_HASH_RE.findall(data):
+ value = match.decode("ascii").lower()
+ if value not in seen:
+ seen.add(value)
+ hashes.append(value)
+ return hashes
+
+
+def load_chat_resource_index(resource_db_path, chat_username, start_ts=None, end_ts=None):
+ if not resource_db_path:
+ return {}, []
+
+ index = {}
+ failures = []
+ try:
+ with closing(sqlite3.connect(resource_db_path)) as conn:
+ row = conn.execute(
+ "SELECT rowid FROM ChatName2Id WHERE user_name = ?",
+ (chat_username,)
+ ).fetchone()
+ if not row:
+ return index, failures
+
+ clauses = ["i.chat_id = ?"]
+ params = [row[0]]
+ if start_ts is not None:
+ clauses.append("i.message_create_time >= ?")
+ params.append(start_ts)
+ if end_ts is not None:
+ clauses.append("i.message_create_time <= ?")
+ params.append(end_ts)
+
+ sql = f"""
+ SELECT i.message_local_id, i.message_local_type, i.message_create_time, i.packed_info
+ FROM MessageResourceInfo i
+ WHERE {' AND '.join(clauses)}
+ ORDER BY i.message_create_time, i.message_local_id
+ """
+ for local_id, local_type, create_time, packed_info in conn.execute(sql, params):
+ hashes = _extract_resource_hashes(packed_info)
+ if not hashes:
+ continue
+ base_type, _ = _split_msg_type(local_type)
+ key = (local_id, base_type, create_time)
+ bucket = index.setdefault(key, [])
+ for resource_hash in hashes:
+ if resource_hash not in bucket:
+ bucket.append(resource_hash)
+ except sqlite3.Error as e:
+ failures.append(f"{resource_db_path}: {e}")
+ return index, failures
+
+
# ---- SQL 查询 ----
def _build_message_filters(start_ts=None, end_ts=None, keyword='', msg_type_filter=None):
@@ -528,6 +918,43 @@ def _build_history_line(row, ctx, names, id_to_username, display_name_fn, resolv
return create_time, f'[{time_str}] {text}'
+def _build_export_record(row, ctx, names, id_to_username, display_name_fn, db_dir=None, resource_index=None):
+ local_id, local_type, create_time, real_sender_id, content, ct = row
+ content = decompress_content(content, ct)
+ if content is None:
+ content = '(无法解压)'
+
+ sender_from_content, _ = _parse_message_content(content, local_type, ctx['is_group'])
+ sender_username, sender_name = _resolve_sender_identity(
+ real_sender_id, sender_from_content, ctx['is_group'], ctx['username'], names, id_to_username, display_name_fn
+ )
+ _, text = _format_message_text(
+ local_id, local_type, content, ctx['is_group'], ctx['username'], ctx['display_name'], names, display_name_fn,
+ db_dir=db_dir, create_time_ts=create_time, resolve_media=False,
+ )
+ base_type, sub_type = _split_msg_type(local_type)
+ media_sources = _resolve_export_media_sources(
+ db_dir, local_id, local_type, content, create_time, ctx['username'], resource_index=resource_index
+ )
+ return create_time, {
+ 'local_id': local_id,
+ 'type': _message_type_key(local_type, content),
+ 'type_label': format_msg_type(local_type),
+ 'local_type': local_type,
+ 'base_type': base_type,
+ 'sub_type': sub_type,
+ 'create_time': create_time,
+ 'time': datetime.fromtimestamp(create_time).isoformat(sep=' ', timespec='seconds'),
+ 'sender': {
+ 'username': sender_username or None,
+ 'name': sender_name or None,
+ },
+ 'text': text,
+ 'media': [],
+ '_media_sources': media_sources,
+ }
+
+
def _build_search_entry(row, ctx, names, id_to_username, display_name_fn, resolve_media=False, db_dir=None):
local_id, local_type, create_time, real_sender_id, content, ct = row
content = decompress_content(content, ct)
@@ -585,6 +1012,52 @@ def collect_chat_history(ctx, names, display_name_fn, start_ts=None, end_ts=None
return [line for _, line in paged], failures
+def collect_chat_export_records(ctx, names, display_name_fn, start_ts=None, end_ts=None, limit=None, db_dir=None, resource_db_path=None):
+ collected = []
+ failures = []
+ batch_size = _HISTORY_QUERY_BATCH_SIZE
+ resource_index, resource_failures = load_chat_resource_index(
+ resource_db_path, ctx['username'], start_ts=start_ts, end_ts=end_ts
+ )
+ failures.extend(resource_failures)
+
+ for table_ctx in _iter_table_contexts(ctx):
+ try:
+ with closing(sqlite3.connect(table_ctx['db_path'])) as conn:
+ id_to_username = _load_name2id_maps(conn)
+ fetch_offset = 0
+ table_count = 0
+ while True:
+ page_limit = batch_size if limit is None else min(batch_size, max(limit - table_count, 0))
+ if page_limit <= 0:
+ break
+ rows = _query_messages(
+ conn, table_ctx['table_name'],
+ start_ts=start_ts, end_ts=end_ts,
+ limit=page_limit, offset=fetch_offset,
+ )
+ if not rows:
+ break
+ fetch_offset += len(rows)
+ for row in rows:
+ try:
+ collected.append(_build_export_record(row, table_ctx, names, id_to_username, display_name_fn, db_dir=db_dir, resource_index=resource_index))
+ except Exception as e:
+ failures.append(f"local_id={row[0]}: {e}")
+ table_count += len(rows)
+ if limit is not None and table_count >= limit:
+ break
+ if len(rows) < page_limit:
+ break
+ except Exception as e:
+ failures.append(f"{table_ctx['db_path']}: {e}")
+
+ ordered = sorted(collected, key=lambda item: item[0])
+ if limit is not None:
+ ordered = ordered[-limit:]
+ return [record for _, record in ordered], failures
+
+
# ---- 搜索查询 ----
def _collect_search_entries(conn, contexts, names, keyword, display_name_fn, start_ts=None, end_ts=None, candidate_limit=20, msg_type_filter=None):