From e62ba2c7475ce919df28c640c2b1368230d54325 Mon Sep 17 00:00:00 2001 From: Mingda Date: Wed, 3 Jun 2026 14:52:35 +0800 Subject: [PATCH 1/6] Add self-contained JSON media export --- tests/__init__.py | 1 + tests/test_json_export.py | 622 ++++++++++++++++++++++++++++++++ wechat_cli/commands/export.py | 67 +++- wechat_cli/core/media_export.py | 602 +++++++++++++++++++++++++++++++ wechat_cli/core/messages.py | 407 +++++++++++++++++++++ 5 files changed, 1691 insertions(+), 8 deletions(-) create mode 100644 tests/__init__.py create mode 100644 tests/test_json_export.py create mode 100644 wechat_cli/core/media_export.py 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..ea41d0e --- /dev/null +++ b/tests/test_json_export.py @@ -0,0 +1,622 @@ +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, +) +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 + + +class JsonExportTests(unittest.TestCase): + 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_materialize_media_decodes_dat_and_uses_relative_path(self): + with tempfile.TemporaryDirectory() as tmp: + src = os.path.join(tmp, "image.dat") + jpeg = bytes.fromhex("ffd8ffe000104a464946") + b"payload" + with open(src, "wb") as f: + f.write(_xor(jpeg)) + + 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/jpeg") + 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 = bytes.fromhex("89504e470d0a1a0a") + b"payload-after-prefix" + 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.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_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") + with open(output_path, "w", encoding="utf-8") as f: + f.write("{}") + + with self.assertRaises(FileExistsError): + prepare_export_targets(output_path, assets_dir, overwrite=False) + + prepare_export_targets(output_path, assets_dir, overwrite=True) + self.assertFalse(os.path.exists(output_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_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") + jpeg = bytes.fromhex("ffd8ffe000104a464946") + b"payload" + with open(image_path, "wb") as f: + f.write(_xor(jpeg)) + + 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") + + 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.assertTrue(media["path"].startswith("chat_assets/images/")) + self.assertTrue(os.path.exists(os.path.join(tmp, media["path"]))) + + 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" + jpeg = bytes.fromhex("ffd8ffe000104a464946") + b"payload" + with open(os.path.join(image_dir, f"{resource_hash}.dat"), "wb") as f: + f.write(_xor(jpeg)) + 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/wechat_cli/commands/export.py b/wechat_cli/commands/export.py index c1be606..cfe5591 100644 --- a/wechat_cli/commands/export.py +++ b/wechat_cli/commands/export.py @@ -1,10 +1,20 @@ -"""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, + write_json_export, +) from ..core.messages import ( + collect_chat_export_records, collect_chat_history, parse_time_range, resolve_chat_context, @@ -15,30 +25,37 @@ @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.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): + """导出聊天记录为 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 +65,43 @@ 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) + try: + prepare_export_targets(output_path_abs, assets_dir, 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) + 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) + click.echo(f"已导出到: {output_path_abs}({len(records)} 条消息,资产目录: {assets_dir})", 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..12ee83c --- /dev/null +++ b/wechat_cli/core/media_export.py @@ -0,0 +1,602 @@ +"""Media asset helpers for self-contained chat exports.""" + +import hashlib +import json +import mimetypes +import os +import posixpath +import re +import shutil +import subprocess +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}$") +_V2_KEY_CACHE = {} + + +def asset_dir_for_output(output_path): + base, _ = os.path.splitext(os.path.abspath(output_path)) + return f"{base}_assets" + + +def prepare_export_targets(output_path, assets_dir, overwrite=False): + output_path = os.path.abspath(output_path) + assets_dir = os.path.abspath(assets_dir) + 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 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) + 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 materialize_record_media(records, assets_dir, output_path): + 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() + + 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) + media_entries.append(entry) + if entry["status"] != "copied" and entry["status"] != "decoded": + warnings.append({ + "local_id": record.get("local_id"), + "kind": entry.get("kind"), + "status": entry.get("status"), + "detail": entry.get("detail", ""), + }) + record["media"] = media_entries + return warnings + + +def _materialize_source(source, record, index, assets_dir, rel_assets_root, used_names): + kind = source.get("kind", "file") + source_path = source.get("source_path") + 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 _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", + "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", + "video": "mp4", + "voice": "aud", + "audio": "aud", + "file": "bin", + }.get(kind, "bin") + + +def _default_mime(kind): + return { + "image": "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 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" + try: + entry["bytes"] = os.path.getsize(dest_path) + except OSError: + pass + return entry + + +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 = [ + os.path.join(documents_dir, "app_data", "net", "kvcomm"), + os.path.join(documents_dir, "app_data", "ilink", "kvcomm"), + ] + uins = [] + for root in roots: + if not os.path.isdir(root): + continue + try: + filenames = os.listdir(root) + except OSError: + continue + for filename in filenames: + match = _KVCOMM_STATISTIC_RE.match(filename) + if not match: + continue + try: + uins.append(int(match.group(1))) + except ValueError: + continue + return uins + + +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 _to_posix(path): + return path.replace(os.sep, "/") diff --git a/wechat_cli/core/messages.py b/wechat_cli/core/messages.py index d62ef33..cafea56 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 = { @@ -380,6 +381,329 @@ 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 _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)] + + return [] + + +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 +852,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 +946,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): From 7c03e63c3030776cb8db853ccd3bb4130e9808f1 Mon Sep 17 00:00:00 2001 From: Mingda Date: Wed, 3 Jun 2026 15:06:39 +0800 Subject: [PATCH 2/6] add doc in json export so consumer can use it easily --- tests/test_json_export.py | 17 +++++- wechat_cli/commands/export.py | 8 ++- wechat_cli/core/media_export.py | 102 +++++++++++++++++++++++++++++++- 3 files changed, 122 insertions(+), 5 deletions(-) diff --git a/tests/test_json_export.py b/tests/test_json_export.py index ea41d0e..b4a9e25 100644 --- a/tests/test_json_export.py +++ b/tests/test_json_export.py @@ -18,6 +18,7 @@ detect_image_bytes, materialize_record_media, prepare_export_targets, + readme_path_for_output, ) from wechat_cli.core.messages import collect_chat_export_records @@ -397,14 +398,18 @@ 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, overwrite=False) + prepare_export_targets(output_path, assets_dir, readme_path=readme_path, overwrite=False) - prepare_export_targets(output_path, assets_dir, overwrite=True) + 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): @@ -505,6 +510,7 @@ def test_cli_json_export_writes_schema_and_copied_relative_assets(self): ]) 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): @@ -525,6 +531,13 @@ def test_cli_json_export_writes_schema_and_copied_relative_assets(self): 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.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, [ diff --git a/wechat_cli/commands/export.py b/wechat_cli/commands/export.py index cfe5591..392f813 100644 --- a/wechat_cli/commands/export.py +++ b/wechat_cli/commands/export.py @@ -11,6 +11,8 @@ build_export_payload, materialize_record_media, prepare_export_targets, + readme_path_for_output, + write_export_readme, write_json_export, ) from ..core.messages import ( @@ -69,8 +71,9 @@ def export(ctx, chat_name, fmt, output_path, start_time, end_time, limit, overwr 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, overwrite=overwrite) + 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) @@ -95,7 +98,8 @@ def export(ctx, chat_name, fmt, output_path, start_time, end_time, limit, overwr failures=failures, warnings=warnings, ) write_json_export(payload, output_path_abs) - click.echo(f"已导出到: {output_path_abs}({len(records)} 条消息,资产目录: {assets_dir})", err=True) + 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 diff --git a/wechat_cli/core/media_export.py b/wechat_cli/core/media_export.py index 12ee83c..a880ca3 100644 --- a/wechat_cli/core/media_export.py +++ b/wechat_cli/core/media_export.py @@ -36,9 +36,15 @@ def asset_dir_for_output(output_path): return f"{base}_assets" -def prepare_export_targets(output_path, assets_dir, overwrite=False): +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}") @@ -47,6 +53,8 @@ def prepare_export_targets(output_path, assets_dir, overwrite=False): 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)) @@ -60,6 +68,11 @@ def prepare_export_targets(output_path, assets_dir, overwrite=False): 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) @@ -90,6 +103,93 @@ def write_json_export(payload, output_path): 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`, `video`, `voice`, `audio`, `file`, or `media`. +- `status`: `decoded`, `copied`, `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. +- `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. +- `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}/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. +- 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): warnings = [] rel_assets_root = os.path.relpath(assets_dir, os.path.dirname(os.path.abspath(output_path)) or ".") From f49876057e8b828e8d195c6a0fa8f37305675345 Mon Sep 17 00:00:00 2001 From: Mingda Date: Wed, 3 Jun 2026 16:22:33 +0800 Subject: [PATCH 3/6] add sticker in export --- tests/test_json_export.py | 193 ++++++++++++++++++++++ wechat_cli/commands/export.py | 7 +- wechat_cli/core/media_export.py | 273 +++++++++++++++++++++++++++++++- wechat_cli/core/messages.py | 66 ++++++++ 4 files changed, 531 insertions(+), 8 deletions(-) diff --git a/tests/test_json_export.py b/tests/test_json_export.py index b4a9e25..d7da612 100644 --- a/tests/test_json_export.py +++ b/tests/test_json_export.py @@ -192,6 +192,24 @@ def _wxgf_with_partition(payload): return header + len(payload).to_bytes(4, "big") + payload +def _gif_bytes(): + return b"GIF89a" + b"\x01\x00\x01\x00\x80\x00\x00\x00\x00\x00\xff\xff\xff!\xf9\x04\x00\x00\x00\x00\x00,\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 test_decode_wechat_image_dat_detects_xor_jpeg(self): jpeg = bytes.fromhex("ffd8ffe000104a464946") + b"payload" @@ -394,6 +412,141 @@ def test_materialize_media_records_missing_warning(self): self.assertEqual(warnings[0]["status"], "missing") self.assertFalse(os.path.exists(assets_dir)) + 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_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_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") @@ -445,6 +598,46 @@ def test_collect_chat_export_records_returns_all_messages_by_default_and_missing 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") diff --git a/wechat_cli/commands/export.py b/wechat_cli/commands/export.py index 392f813..4d50710 100644 --- a/wechat_cli/commands/export.py +++ b/wechat_cli/commands/export.py @@ -33,8 +33,9 @@ @click.option("--end-time", default="", help="结束时间 YYYY-MM-DD [HH:MM[:SS]]") @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, overwrite): +def export(ctx, chat_name, fmt, output_path, start_time, end_time, limit, overwrite, download_stickers): """导出聊天记录为 markdown、纯文本或结构化 JSON \b @@ -91,7 +92,9 @@ def export(ctx, chat_name, fmt, output_path, start_time, end_time, limit, overwr resource_db_path=resource_db_path, ) failures = resource_failures + failures - warnings = materialize_record_media(records, assets_dir, output_path_abs) + 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, diff --git a/wechat_cli/core/media_export.py b/wechat_cli/core/media_export.py index a880ca3..b699fda 100644 --- a/wechat_cli/core/media_export.py +++ b/wechat_cli/core/media_export.py @@ -8,6 +8,9 @@ import re import shutil import subprocess +import urllib.error +import urllib.parse +import urllib.request from datetime import datetime from pathlib import Path @@ -28,6 +31,16 @@ _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"} +_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 = {} @@ -148,8 +161,8 @@ def write_export_readme(payload, output_path, assets_dir, readme_path): Common fields: -- `kind`: `image`, `video`, `voice`, `audio`, `file`, or `media`. -- `status`: `decoded`, `copied`, `missing`, or `undecodable`. +- `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. @@ -160,12 +173,14 @@ def write_export_readme(payload, output_path, assets_dir, readme_path): - `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. @@ -190,19 +205,23 @@ def write_export_readme(payload, output_path, assets_dir, readme_path): f.write(content) -def materialize_record_media(records, assets_dir, output_path): +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 = {} 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) + entry = _materialize_source( + source, record, index, assets_dir, rel_assets_root, used_names, + download_stickers=download_stickers, sticker_cache=sticker_cache, + ) media_entries.append(entry) - if entry["status"] != "copied" and entry["status"] != "decoded": + if entry["status"] not in _SUCCESS_MEDIA_STATUSES: warnings.append({ "local_id": record.get("local_id"), "kind": entry.get("kind"), @@ -213,9 +232,17 @@ def materialize_record_media(records, assets_dir, output_path): return warnings -def _materialize_source(source, record, index, assets_dir, rel_assets_root, used_names): +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") @@ -265,6 +292,214 @@ def _materialize_image(source, record, index, assets_dir, rel_assets_root, used_ ) +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 + + 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: + 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: + 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 _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) + for key in _sticker_cache_keys(source): + sticker_cache.setdefault(key, cached) + return entry + + +def _sticker_cache_keys(source): + keys = [] + sticker_md5 = (source.get("sticker_md5") or "").strip().lower() + 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")) @@ -289,6 +524,7 @@ def _destination_paths(source, record, index, assets_dir, rel_assets_root, used_ kind = source.get("kind", "file") subdir = { "image": "images", + "sticker": "stickers", "video": "videos", "voice": "audio", "audio": "audio", @@ -339,6 +575,7 @@ def _extension_for_source(path, kind): return ext return { "image": "dat", + "sticker": "bin", "video": "mp4", "voice": "aud", "audio": "aud", @@ -349,6 +586,7 @@ def _extension_for_source(path, kind): def _default_mime(kind): return { "image": "application/octet-stream", + "sticker": "application/octet-stream", "video": "video/mp4", "voice": "application/octet-stream", "audio": "application/octet-stream", @@ -363,6 +601,18 @@ def _media_status_entry(source, status, detail=""): } if source.get("original_filename"): entry["original_filename"] = source["original_filename"] + if source.get("source_label"): + entry["source"] = source["source_label"] + 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 @@ -698,5 +948,16 @@ 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 cafea56..1e7d5cd 100644 --- a/wechat_cli/core/messages.py +++ b/wechat_cli/core/messages.py @@ -134,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: @@ -569,6 +582,23 @@ def _build_media_source(kind, source_path=None, original_filename='', 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 [] @@ -639,9 +669,45 @@ def _resolve_export_media_sources(db_dir, local_id, local_type, content, create_ 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 [] From 0a6c9da793bf8a1ca8998cb1c09199b8bf87eeb6 Mon Sep 17 00:00:00 2001 From: Mingda Date: Wed, 3 Jun 2026 17:24:55 +0800 Subject: [PATCH 4/6] export dimensions and sticker --- pyproject.toml | 1 + tests/test_json_export.py | 133 ++++++++++++- uv.lock | 270 ++++++++++++++++++++++++++ wechat_cli/core/media_export.py | 327 +++++++++++++++++++++++++++++++- 4 files changed, 716 insertions(+), 15 deletions(-) create mode 100644 uv.lock 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/test_json_export.py b/tests/test_json_export.py index d7da612..d8548d7 100644 --- a/tests/test_json_export.py +++ b/tests/test_json_export.py @@ -192,8 +192,27 @@ def _wxgf_with_partition(payload): return header + len(payload).to_bytes(4, "big") + payload -def _gif_bytes(): - return b"GIF89a" + b"\x01\x00\x01\x00\x80\x00\x00\x00\x00\x00\xff\xff\xff!\xf9\x04\x00\x00\x00\x00\x00,\x00\x00\x00\x00\x01\x00\x01\x00\x00\x02\x02D\x01\x00;" +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"): @@ -286,12 +305,46 @@ def test_decode_wechat_image_dat_decodes_v2_with_kvcomm_uin(self): 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") - jpeg = bytes.fromhex("ffd8ffe000104a464946") + b"payload" + png = _png_bytes(72, 80) with open(src, "wb") as f: - f.write(_xor(jpeg)) + f.write(_xor(png)) output_path = os.path.join(tmp, "chat.json") assets_dir = os.path.join(tmp, "chat_assets") @@ -306,7 +359,9 @@ def test_materialize_media_decodes_dat_and_uses_relative_path(self): self.assertEqual(warnings, []) media = records[0]["media"][0] self.assertEqual(media["status"], "decoded") - self.assertEqual(media["mime"], "image/jpeg") + 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]) @@ -365,7 +420,7 @@ def test_materialize_media_decodes_v2_sibling_when_base_is_wxgf(self): 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 = bytes.fromhex("89504e470d0a1a0a") + b"payload-after-prefix" + 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: @@ -385,6 +440,8 @@ def test_materialize_media_decodes_v2_sibling_when_base_is_wxgf(self): 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) @@ -412,6 +469,60 @@ def test_materialize_media_records_missing_warning(self): 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() @@ -691,9 +802,9 @@ def test_cli_json_export_writes_schema_and_copied_relative_assets(self): os.makedirs(image_dir) os.makedirs(db_dir) image_path = os.path.join(image_dir, "photo.dat") - jpeg = bytes.fromhex("ffd8ffe000104a464946") + b"payload" + png = _png_bytes(72, 80) with open(image_path, "wb") as f: - f.write(_xor(jpeg)) + f.write(_xor(png)) message_db = os.path.join(tmp, "message.db") ts = int(datetime(2026, 6, 3, 10, 0).timestamp()) @@ -722,6 +833,8 @@ def test_cli_json_export_writes_schema_and_copied_relative_assets(self): 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)) @@ -752,9 +865,9 @@ def test_cli_json_export_uses_resource_db_for_image_hash(self): os.makedirs(image_dir) os.makedirs(db_dir) resource_hash = "abcdef0123456789abcdef0123456789" - jpeg = bytes.fromhex("ffd8ffe000104a464946") + b"payload" + png = _png_bytes(72, 80) with open(os.path.join(image_dir, f"{resource_hash}.dat"), "wb") as f: - f.write(_xor(jpeg)) + f.write(_xor(png)) with open(os.path.join(image_dir, "unrelated.dat"), "wb") as f: f.write(b"unrelated") 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/core/media_export.py b/wechat_cli/core/media_export.py index b699fda..49d1816 100644 --- a/wechat_cli/core/media_export.py +++ b/wechat_cli/core/media_export.py @@ -1,5 +1,6 @@ """Media asset helpers for self-contained chat exports.""" +import base64 import hashlib import json import mimetypes @@ -166,6 +167,8 @@ def write_export_readme(payload, output_path, assets_dir, readme_path): - `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. - `original_filename`: source filename when available. - `detail`: warning/error detail for non-success statuses. @@ -189,6 +192,7 @@ def write_export_readme(payload, output_path, assets_dir, readme_path): - 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. @@ -220,6 +224,7 @@ def materialize_record_media(records, assets_dir, output_path, download_stickers source, record, index, assets_dir, rel_assets_root, used_names, download_stickers=download_stickers, sticker_cache=sticker_cache, ) + probe_warning = entry.pop("_probe_warning", "") media_entries.append(entry) if entry["status"] not in _SUCCESS_MEDIA_STATUSES: warnings.append({ @@ -228,6 +233,13 @@ def materialize_record_media(records, assets_dir, output_path, download_stickers "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 @@ -417,6 +429,7 @@ 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) for key in _sticker_cache_keys(source): sticker_cache.setdefault(key, cached) return entry @@ -626,9 +639,253 @@ def _media_file_entry(source, status, rel_path, dest_path, mime=None, detail="") 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): @@ -878,14 +1135,16 @@ def _wechat_media_context(source_path): def _kvcomm_uins(documents_dir): - roots = [ - os.path.join(documents_dir, "app_data", "net", "kvcomm"), - os.path.join(documents_dir, "app_data", "ilink", "kvcomm"), - ] + 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: @@ -895,12 +1154,70 @@ def _kvcomm_uins(documents_dir): if not match: continue try: - uins.append(int(match.group(1))) + 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 From 1c1b354cfb4b629728800a09f3c6ed59507d007a Mon Sep 17 00:00:00 2001 From: Mingda Date: Wed, 3 Jun 2026 18:13:18 +0800 Subject: [PATCH 5/6] sticker cache --- tests/test_json_export.py | 104 +++++++++++++++++++++++++++++++ wechat_cli/core/media_export.py | 105 +++++++++++++++++++++++++++++++- 2 files changed, 208 insertions(+), 1 deletion(-) diff --git a/tests/test_json_export.py b/tests/test_json_export.py index d8548d7..70b9971 100644 --- a/tests/test_json_export.py +++ b/tests/test_json_export.py @@ -19,6 +19,7 @@ materialize_record_media, prepare_export_targets, readme_path_for_output, + _sticker_cache_dir, ) from wechat_cli.core.messages import collect_chat_export_records @@ -230,6 +231,43 @@ def _aes_cbc_pkcs7_encrypt(data, aeskey): 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)) @@ -561,6 +599,72 @@ def test_materialize_sticker_downloads_cdn_gif(self): 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() diff --git a/wechat_cli/core/media_export.py b/wechat_cli/core/media_export.py index 49d1816..8bff0fa 100644 --- a/wechat_cli/core/media_export.py +++ b/wechat_cli/core/media_export.py @@ -9,6 +9,7 @@ import re import shutil import subprocess +import sys import urllib.error import urllib.parse import urllib.request @@ -33,6 +34,7 @@ _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", @@ -169,6 +171,7 @@ def write_export_readme(payload, output_path, assets_dir, readme_path): - `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. @@ -225,6 +228,7 @@ def materialize_record_media(records, assets_dir, output_path, download_stickers 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({ @@ -313,6 +317,12 @@ def _materialize_sticker( 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): @@ -320,6 +330,7 @@ def _materialize_sticker( 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" @@ -329,6 +340,7 @@ def _materialize_sticker( 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): @@ -417,6 +429,93 @@ def _download_sticker_asset(source, record, index, assets_dir, rel_assets_root, 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) @@ -430,6 +529,7 @@ def _cache_sticker_entry(sticker_cache, source, entry): 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 @@ -437,7 +537,7 @@ def _cache_sticker_entry(sticker_cache, source, entry): def _sticker_cache_keys(source): keys = [] - sticker_md5 = (source.get("sticker_md5") or "").strip().lower() + sticker_md5 = _sticker_md5(source) if sticker_md5: keys.append(("md5", sticker_md5)) for field_name in ("cdn_url", "encrypt_url"): @@ -616,6 +716,8 @@ def _media_status_entry(source, status, detail=""): 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"), @@ -635,6 +737,7 @@ 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: From ba2acf09e60d7a70f2080600e974969697ab3e4a Mon Sep 17 00:00:00 2001 From: Mingda Date: Wed, 3 Jun 2026 18:41:24 +0800 Subject: [PATCH 6/6] enrich sticker --- tests/test_json_export.py | 64 +++++++++++++++++++++++++++++ wechat_cli/core/media_export.py | 71 +++++++++++++++++++++++++++++++++ 2 files changed, 135 insertions(+) diff --git a/tests/test_json_export.py b/tests/test_json_export.py index 70b9971..f8c49b3 100644 --- a/tests/test_json_export.py +++ b/tests/test_json_export.py @@ -708,6 +708,70 @@ def fake_download(url): 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() diff --git a/wechat_cli/core/media_export.py b/wechat_cli/core/media_export.py index 8bff0fa..a427fc7 100644 --- a/wechat_cli/core/media_export.py +++ b/wechat_cli/core/media_export.py @@ -219,6 +219,7 @@ def materialize_record_media(records, assets_dir, output_path, download_stickers used_names = set() sticker_cache = {} + _enrich_sticker_sources(records) for record in records: sources = record.pop("_media_sources", []) media_entries = [] @@ -248,6 +249,76 @@ def materialize_record_media(records, assets_dir, output_path, download_stickers 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,