diff --git a/frontend/composables/useApi.js b/frontend/composables/useApi.js index 64ac39f..7b0adfa 100644 --- a/frontend/composables/useApi.js +++ b/frontend/composables/useApi.js @@ -58,11 +58,51 @@ export const useApi = () => { const healthCheck = async () => { return await request('/health') } + + const listChatAccounts = async () => { + return await request('/chat/accounts') + } + + const listChatSessions = async (params = {}) => { + const query = new URLSearchParams() + if (params && params.account) query.set('account', params.account) + if (params && params.limit != null) query.set('limit', String(params.limit)) + if (params && params.include_hidden != null) query.set('include_hidden', String(!!params.include_hidden)) + if (params && params.include_official != null) query.set('include_official', String(!!params.include_official)) + const url = '/chat/sessions' + (query.toString() ? `?${query.toString()}` : '') + return await request(url) + } + + const listChatMessages = async (params = {}) => { + const query = new URLSearchParams() + if (params && params.account) query.set('account', params.account) + if (params && params.username) query.set('username', params.username) + if (params && params.limit != null) query.set('limit', String(params.limit)) + if (params && params.offset != null) query.set('offset', String(params.offset)) + if (params && params.order) query.set('order', params.order) + const url = '/chat/messages' + (query.toString() ? `?${query.toString()}` : '') + return await request(url) + } + + const openChatMediaFolder = async (params = {}) => { + const query = new URLSearchParams() + if (params && params.account) query.set('account', params.account) + if (params && params.username) query.set('username', params.username) + if (params && params.kind) query.set('kind', params.kind) + if (params && params.md5) query.set('md5', params.md5) + if (params && params.server_id != null) query.set('server_id', String(params.server_id)) + const url = '/chat/media/open_folder' + (query.toString() ? `?${query.toString()}` : '') + return await request(url, { method: 'POST' }) + } return { detectWechat, detectCurrentAccount, decryptDatabase, - healthCheck + healthCheck, + listChatAccounts, + listChatSessions, + listChatMessages, + openChatMediaFolder } } \ No newline at end of file diff --git a/frontend/pages/chat.vue b/frontend/pages/chat.vue new file mode 100644 index 0000000..ecf894e --- /dev/null +++ b/frontend/pages/chat.vue @@ -0,0 +1,1145 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {{ acc }} + + + + + + + + 加载中... + + + {{ contactsError }} + + + 暂无会话 + + + + + + + + + + + {{ contact.name.charAt(0) }} + + + + + + + {{ contact.name }} + + + {{ contact.unreadCount > 99 ? '99+' : contact.unreadCount }} + + {{ contact.lastMessageTime }} + + + {{ contact.lastMessage }} + + + + + + + + + + + + + + + + + + {{ selectedContact ? selectedContact.name : '' }} + + + + + 刷新 + + + + + + + + + {{ isLoadingMessages ? '加载中...' : '继续上滑加载更多' }} + + + + + 加载中... + + + {{ messagesError }} + + + 暂无聊天记录 + + + + + + {{ message.timeDivider }} + + + + + + {{ message.content }} + + + + + + + + + + + + {{ message.sender.charAt(0) }} + + + + + + + {{ message.senderDisplayName }} + + + {{ message.fullTime }} + + + + + + {{ message.title || message.content }} + {{ formatFileSize(message.fileSize) }} + + + + + + + + + + + + + {{ message.content }} + + + + + + + + {{ message.content }} + + + + + + + + + + + + + + + + + + + + + + {{ message.voiceDuration || '' }} + + + + + + + + + + {{ message.content }} + + + + {{ message.content }} + + {{ message.quoteTitle }} + {{ message.quoteContent }} + + + + + + + + + + + + {{ getTransferTitle(message) }} + {{ message.amount }} + + + + + + + + + + + + + + + + + {{ message.content || '红包' }} + + + + + + + + + + + + + + + + {{ message.content || '红包' }} + {{ message.title }} + + + + + + {{ message.content }} + + + + + {{ message.content || ('[' + (message.type || 'unknown') + '] 消息组件已移除') }} + + + + + + + + + + + + + + + + + 微信聊天记录查看器 + + 请选择一个联系人查看聊天记录 + + + + + + + + + + + + + + + + + + 打开文件夹 + + + + + + + + \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index c07a13d..bfd0d7f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,10 @@ dependencies = [ "pycryptodome>=3.23.0", "requests>=2.32.4", "loguru>=0.7.0", + "zstandard>=0.23.0", + "pymem>=1.14.0", + "yara-python>=4.5.4", + "pilk>=0.2.4", ] [project.scripts] diff --git a/src/wechat_decrypt_tool/api.py b/src/wechat_decrypt_tool/api.py index 50db2c2..840a36f 100644 --- a/src/wechat_decrypt_tool/api.py +++ b/src/wechat_decrypt_tool/api.py @@ -1,14 +1,40 @@ """微信解密工具的FastAPI Web服务器""" +import hashlib import time import re import json import os -from typing import Optional, Callable +import subprocess +import html +import ctypes +import base64 +import mimetypes +import sqlite3 +import struct +import threading +from collections import Counter +from concurrent.futures import ThreadPoolExecutor +from datetime import datetime +from functools import lru_cache +from pathlib import Path +from typing import Optional, Callable, Any +from urllib.parse import quote + +try: + import zstandard as zstd # type: ignore +except Exception: + zstd = None + +try: + import psutil # type: ignore +except Exception: + psutil = None from fastapi import FastAPI, HTTPException, Request from fastapi.routing import APIRoute from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import Response, FileResponse from pydantic import BaseModel, Field from .logging_config import setup_logging, get_logger @@ -18,6 +44,1540 @@ from .wechat_decrypt import decrypt_wechat_databases setup_logging() logger = get_logger(__name__) +# 仓库根目录(用于定位 output/databases) +_REPO_ROOT = Path(__file__).resolve().parents[2] +_OUTPUT_DATABASES_DIR = _REPO_ROOT / "output" / "databases" + + +def _list_decrypted_accounts() -> list[str]: + """列出已解密输出的账号目录名(仅保留包含 session.db + contact.db 的账号)""" + if not _OUTPUT_DATABASES_DIR.exists(): + return [] + + accounts: list[str] = [] + for p in _OUTPUT_DATABASES_DIR.iterdir(): + if not p.is_dir(): + continue + if (p / "session.db").exists() and (p / "contact.db").exists(): + accounts.append(p.name) + + accounts.sort() + return accounts + + +def _resolve_account_dir(account: Optional[str]) -> Path: + """解析账号目录,并进行路径安全校验(防止路径穿越)""" + accounts = _list_decrypted_accounts() + if not accounts: + raise HTTPException( + status_code=404, + detail="No decrypted databases found. Please decrypt first.", + ) + + selected = account or accounts[0] + base = _OUTPUT_DATABASES_DIR.resolve() + candidate = (_OUTPUT_DATABASES_DIR / selected).resolve() + + if candidate != base and base not in candidate.parents: + raise HTTPException(status_code=400, detail="Invalid account path.") + + if not candidate.exists() or not candidate.is_dir(): + raise HTTPException(status_code=404, detail="Account not found.") + + if not (candidate / "session.db").exists(): + raise HTTPException(status_code=404, detail="session.db not found for this account.") + if not (candidate / "contact.db").exists(): + raise HTTPException(status_code=404, detail="contact.db not found for this account.") + + return candidate + + +def _should_keep_session(username: str, include_official: bool) -> bool: + """会话过滤:默认排除公众号/系统会话(参考 echotrace 的过滤策略)""" + if not username: + return False + + if not include_official and username.startswith("gh_"): + return False + + if username.startswith(("weixin", "qqmail", "fmessage", "medianote", "floatbottle", "newsapp")): + return False + + if "@kefu.openim" in username: + return False + if "@openim" in username: + return False + if "service_" in username: + return False + + if username in { + "brandsessionholder", + "brandservicesessionholder", + "notifymessage", + "opencustomerservicemsg", + "notification_messages", + "userexperience_alarm", + }: + return False + + return username.endswith("@chatroom") or username.startswith("wxid_") or ("@" not in username) + + +def _format_session_time(ts: Optional[int]) -> str: + """格式化会话时间:今天显示 HH:MM,否则显示 MM/DD""" + if not ts: + return "" + try: + dt = datetime.fromtimestamp(int(ts)) + now = datetime.now() + if dt.date() == now.date(): + return dt.strftime("%H:%M") + return dt.strftime("%m/%d") + except Exception: + return "" + + +def _infer_last_message_brief(msg_type: Optional[int], sub_type: Optional[int]) -> str: + """当 summary/draft 为空时,用类型生成占位摘要(英文文案)""" + t = int(msg_type or 0) + s = int(sub_type or 0) + + if t == 1: + return "[Text]" + if t == 3: + return "[Image]" + if t == 34: + return "[Voice]" + if t == 42: + return "[Contact Card]" + if t == 43: + return "[Video]" + if t == 47: + return "[Emoji]" + if t == 48: + return "[Location]" + if t == 49: + if s == 5: + return "[Link]" + if s == 6: + return "[File]" + if s in (33, 36): + return "[Mini Program]" + if s == 57: + return "[Quote]" + if s in (63, 88): + return "[Live]" + if s == 87: + return "[Announcement]" + if s == 2000: + return "[Transfer]" + if s == 2003: + return "[Red Packet]" + if s == 19: + return "[Chat History]" + return "[App Message]" + if t == 10000: + return "[System]" + return "[Message]" + + +def _infer_message_brief_by_local_type(local_type: Optional[int]) -> str: + t = int(local_type or 0) + if t == 1: + return "" + if t == 3: + return "[Image]" + if t == 34: + return "[Voice]" + if t == 43: + return "[Video]" + if t == 47: + return "[Emoji]" + if t == 48: + return "[Location]" + if t == 50: + return "[VoIP]" + if t == 10000: + return "[System]" + if t == 244813135921: + return "[Quote]" + if t == 17179869233: + return "[Link]" + if t == 21474836529: + return "[Article]" + if t == 154618822705: + return "[Mini Program]" + if t == 12884901937: + return "[Music]" + if t == 8594229559345: + return "[Red Packet]" + if t == 81604378673: + return "[Chat History]" + if t == 266287972401: + return "[Pat]" + if t == 8589934592049: + return "[Transfer]" + if t == 270582939697: + return "[Live]" + if t == 25769803825: + return "[File]" + return "[Message]" + + +def _quote_ident(ident: str) -> str: + return '"' + ident.replace('"', '""') + '"' + + +def _resolve_msg_table_name(conn: sqlite3.Connection, username: str) -> Optional[str]: + if not username: + return None + md5_hex = hashlib.md5(username.encode("utf-8")).hexdigest() + expected = f"msg_{md5_hex}".lower() + + expected_chat = f"chat_{md5_hex}".lower() + + rows = conn.execute( + "SELECT name FROM sqlite_master WHERE type='table'" + ).fetchall() + names = [r[0] for r in rows if r and r[0]] + + for name in names: + if str(name).lower() == expected: + return str(name) + + for name in names: + if str(name).lower() == expected_chat: + return str(name) + + for name in names: + ln = str(name).lower() + if ln.startswith("msg_") and md5_hex in ln: + return str(name) + if ln.startswith("chat_") and md5_hex in ln: + return str(name) + + for name in names: + if md5_hex in str(name).lower(): + return str(name) + + partial = md5_hex[:24] + for name in names: + if partial in str(name).lower(): + return str(name) + + return None + + +def _detect_image_media_type(data: bytes) -> str: + if not data: + return "application/octet-stream" + + if data.startswith(b"\x89PNG\r\n\x1a\n"): + return "image/png" + if data.startswith(b"\xff\xd8\xff"): + return "image/jpeg" + if data.startswith(b"GIF87a") or data.startswith(b"GIF89a"): + return "image/gif" + if data.startswith(b"RIFF") and data[8:12] == b"WEBP": + return "image/webp" + return "application/octet-stream" + + +def _load_account_source_info(account_dir: Path) -> dict[str, Any]: + p = account_dir / "_source.json" + if not p.exists(): + return {} + try: + return json.loads(p.read_text(encoding="utf-8")) + except Exception: + return {} + + +def _guess_wxid_dir_from_common_paths(account_name: str) -> Optional[Path]: + try: + home = Path.home() + except Exception: + return None + + roots = [ + home / "Documents" / "xwechat_files", + home / "Documents" / "WeChat Files", + ] + + # Exact match first + for root in roots: + c = root / account_name + try: + if c.exists() and c.is_dir(): + return c + except Exception: + continue + + # Then try prefix match: wxid_xxx_yyyy + for root in roots: + try: + if not root.exists() or not root.is_dir(): + continue + for p in root.iterdir(): + if not p.is_dir(): + continue + if p.name.startswith(account_name + "_"): + return p + except Exception: + continue + return None + + +def _resolve_account_wxid_dir(account_dir: Path) -> Optional[Path]: + info = _load_account_source_info(account_dir) + wxid_dir = str(info.get("wxid_dir") or "").strip() + if wxid_dir: + try: + p = Path(wxid_dir) + if p.exists() and p.is_dir(): + return p + except Exception: + pass + return _guess_wxid_dir_from_common_paths(account_dir.name) + + +def _resolve_account_db_storage_dir(account_dir: Path) -> Optional[Path]: + info = _load_account_source_info(account_dir) + db_storage_path = str(info.get("db_storage_path") or "").strip() + if db_storage_path: + try: + p = Path(db_storage_path) + if p.exists() and p.is_dir(): + return p + except Exception: + pass + + wxid_dir = _resolve_account_wxid_dir(account_dir) + if wxid_dir: + c = wxid_dir / "db_storage" + try: + if c.exists() and c.is_dir(): + return c + except Exception: + pass + return None + + +def _resolve_hardlink_table_name(conn: sqlite3.Connection, prefix: str) -> Optional[str]: + rows = conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE ? ORDER BY name DESC", + (f"{prefix}%",), + ).fetchall() + if not rows: + return None + return str(rows[0][0]) if rows[0] and rows[0][0] else None + + +def _resolve_hardlink_dir2id_table_name(conn: sqlite3.Connection) -> Optional[str]: + rows = conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'dir2id%' ORDER BY name DESC" + ).fetchall() + if not rows: + return None + return str(rows[0][0]) if rows[0] and rows[0][0] else None + + +def _resolve_media_path_from_hardlink( + hardlink_db_path: Path, + wxid_dir: Path, + md5: str, + kind: str, + username: Optional[str], + extra_roots: Optional[list[Path]] = None, +) -> Optional[Path]: + if not hardlink_db_path.exists(): + return None + + kind_key = str(kind or "").lower().strip() + if kind_key == "image" or kind_key == "emoji": + prefix = "image_hardlink_info" + elif kind_key == "video" or kind_key == "video_thumb": + prefix = "video_hardlink_info" + elif kind_key == "file": + prefix = "file_hardlink_info" + else: + return None + + conn = sqlite3.connect(str(hardlink_db_path)) + conn.row_factory = sqlite3.Row + try: + table_name = _resolve_hardlink_table_name(conn, prefix) + if not table_name: + return None + + quoted = _quote_ident(table_name) + row = conn.execute( + f"SELECT dir1, dir2, file_name FROM {quoted} WHERE md5 = ? ORDER BY modify_time DESC LIMIT 1", + (md5,), + ).fetchone() + if not row: + return None + + dir1 = str(row["dir1"] or "").strip() + dir2 = str(row["dir2"] or "").strip() + file_name = str(row["file_name"] or "").strip() + if not dir1 or not dir2 or not file_name: + return None + + dir_name = dir2 + dir2id_table = _resolve_hardlink_dir2id_table_name(conn) + + # WeChat 4.x: dir2id table only has 'username' column, use rowid to lookup + if dir2id_table: + try: + # First try WeChat 4.x schema: lookup by rowid + drow = conn.execute( + f"SELECT username FROM {_quote_ident(dir2id_table)} WHERE rowid = ? LIMIT 1", + (int(dir2),), + ).fetchone() + if drow and drow[0]: + dir_name = str(drow[0]) + except Exception: + # Fallback to old schema with dir_id and username columns + if username: + try: + drow = conn.execute( + f"SELECT dir_name FROM {_quote_ident(dir2id_table)} WHERE dir_id = ? AND username = ? LIMIT 1", + (dir2, username), + ).fetchone() + if drow and drow[0]: + dir_name = str(drow[0]) + except Exception: + pass + + roots: list[Path] = [] + for r in [wxid_dir] + (extra_roots or []): + if not r: + continue + try: + rr = r.resolve() + except Exception: + rr = r + if rr not in roots: + roots.append(rr) + + # Try multiple path patterns for different WeChat versions + file_stem = Path(file_name).stem + file_variants = [file_name, f"{file_stem}_h.dat", f"{file_stem}_t.dat"] + + for root in roots: + # Pattern 1: Old structure - {root}/{dir1}/{dir_name}/{file} + for fv in file_variants: + p = (root / dir1 / dir_name / fv).resolve() + try: + if p.exists() and p.is_file(): + return p + except Exception: + continue + + # Pattern 2: WeChat 4.x - {root}/msg/attach/{chat_hash}/{dir_name}/Img/{file} + # chat_hash is MD5 of the username/chat_id + if username: + import hashlib + chat_hash = hashlib.md5(username.encode()).hexdigest() + for fv in file_variants: + p = (root / "msg" / "attach" / chat_hash / dir_name / "Img" / fv).resolve() + try: + if p.exists() and p.is_file(): + return p + except Exception: + continue + + return None + finally: + conn.close() + + +@lru_cache(maxsize=4096) +def _fallback_search_media_by_md5(weixin_root_str: str, md5: str) -> Optional[str]: + if not weixin_root_str or not md5: + return None + try: + root = Path(weixin_root_str) + except Exception: + return None + + search_dirs = [ + root / "msg" / "attach", + root / "msg" / "file", + root / "msg" / "video", + root / "cache", + ] + # 优先顺序: _h.dat (高清) > _t.dat (缩略图) > 普通 .dat > 其他格式 + # 因为基础 .dat 可能是 wxgf 容器格式,而 _h.dat/_t.dat 是真正的图片 + patterns = [ + f"{md5}_h.dat", # 高清图优先 + f"{md5}_t.dat", # 缩略图次之 + f"{md5}.dat", # 基础 dat + f"{md5}*.dat", # 其他 dat 变体 + f"{md5}*.jpg", + f"{md5}*.jpeg", + f"{md5}*.png", + f"{md5}*.gif", + f"{md5}*.webp", + f"{md5}*.mp4", + ] + + for d in search_dirs: + try: + if not d.exists() or not d.is_dir(): + continue + except Exception: + continue + for pat in patterns: + try: + for p in d.rglob(pat): + try: + if p.is_file(): + return str(p) + except Exception: + continue + except Exception: + continue + return None + + +def _guess_media_type_by_path(path: Path, fallback: str = "application/octet-stream") -> str: + try: + mt = mimetypes.guess_type(str(path.name))[0] + if mt: + return mt + except Exception: + pass + return fallback + + +def _try_xor_decrypt_by_magic(data: bytes) -> tuple[Optional[bytes], Optional[str]]: + if not data: + return None, None + + # (offset, magic, media_type) + candidates: list[tuple[int, bytes, str]] = [ + (0, b"\x89PNG\r\n\x1a\n", "image/png"), + (0, b"\xff\xd8\xff", "image/jpeg"), + (0, b"GIF87a", "image/gif"), + (0, b"GIF89a", "image/gif"), + (0, b"RIFF", "application/octet-stream"), + (4, b"ftyp", "video/mp4"), + ] + + for offset, magic, mt in candidates: + if len(data) < offset + len(magic): + continue + key = data[offset] ^ magic[0] + ok = True + for i in range(len(magic)): + if (data[offset + i] ^ key) != magic[i]: + ok = False + break + if not ok: + continue + + decoded = bytes(b ^ key for b in data) + + if offset == 0 and magic == b"RIFF": + if len(decoded) >= 12 and decoded[8:12] == b"WEBP": + return decoded, "image/webp" + continue + + if mt == "application/octet-stream": + mt2 = _detect_image_media_type(decoded[:32]) + if mt2 != "application/octet-stream": + return decoded, mt2 + continue + + return decoded, mt + + return None, None + + +def _detect_wechat_dat_version(data: bytes) -> int: + if not data or len(data) < 6: + return -1 + sig = data[:6] + if sig == b"\x07\x08V1\x08\x07": + return 1 + if sig == b"\x07\x08V2\x08\x07": + return 2 + return 0 + + +@lru_cache(maxsize=16) +def _get_wechat_template_most_common_last2(weixin_root_str: str) -> Optional[bytes]: + try: + root = Path(weixin_root_str) + if not root.exists() or not root.is_dir(): + return None + except Exception: + return None + + try: + template_files = list(root.rglob("*_t.dat")) + except Exception: + template_files = [] + + if not template_files: + return None + + template_files.sort(key=_extract_yyyymm_for_sort, reverse=True) + last_bytes_list: list[bytes] = [] + for file in template_files[:16]: + try: + with open(file, "rb") as f: + f.seek(-2, 2) + b2 = f.read(2) + if b2 and len(b2) == 2: + last_bytes_list.append(b2) + except Exception: + continue + + if not last_bytes_list: + return None + return Counter(last_bytes_list).most_common(1)[0][0] + + +def _extract_yyyymm_for_sort(p: Path) -> str: + m = re.search(r"(\d{4}-\d{2})", str(p)) + return m.group(1) if m else "0000-00" + + +@lru_cache(maxsize=16) +def _find_wechat_xor_key(weixin_root_str: str) -> Optional[int]: + try: + root = Path(weixin_root_str) + if not root.exists() or not root.is_dir(): + return None + except Exception: + return None + + most_common = _get_wechat_template_most_common_last2(weixin_root_str) + if not most_common or len(most_common) != 2: + return None + x, y = most_common[0], most_common[1] + xor_key = x ^ 0xFF + if xor_key != (y ^ 0xD9): + return None + return xor_key + + +def _get_wechat_v2_ciphertext(weixin_root: Path, most_common_last2: bytes) -> Optional[bytes]: + try: + template_files = list(weixin_root.rglob("*_t.dat")) + except Exception: + return None + if not template_files: + return None + + template_files.sort(key=_extract_yyyymm_for_sort, reverse=True) + sig = b"\x07\x08V2\x08\x07" + for file in template_files: + try: + with open(file, "rb") as f: + if f.read(6) != sig: + continue + f.seek(-2, 2) + if f.read(2) != most_common_last2: + continue + f.seek(0xF) + ct = f.read(16) + if ct and len(ct) == 16: + return ct + except Exception: + continue + return None + + +def _verify_wechat_aes_key(ciphertext: bytes, key16: bytes) -> bool: + try: + from Crypto.Cipher import AES + + cipher = AES.new(key16[:16], AES.MODE_ECB) + plain = cipher.decrypt(ciphertext) + if plain.startswith(b"\xff\xd8\xff"): + return True + if plain.startswith(b"\x89PNG\r\n\x1a\n"): + return True + return False + except Exception: + return False + + +class _MEMORY_BASIC_INFORMATION(ctypes.Structure): + _fields_ = [ + ("BaseAddress", ctypes.c_void_p), + ("AllocationBase", ctypes.c_void_p), + ("AllocationProtect", ctypes.c_ulong), + ("RegionSize", ctypes.c_size_t), + ("State", ctypes.c_ulong), + ("Protect", ctypes.c_ulong), + ("Type", ctypes.c_ulong), + ] + + +def _find_weixin_pid() -> Optional[int]: + if psutil is None: + return None + for p in psutil.process_iter(["name"]): + try: + name = (p.info.get("name") or "").lower() + if name in {"weixin.exe", "wechat.exe"}: + return int(p.pid) + except Exception: + continue + return None + + +def _extract_wechat_aes_key_from_process(ciphertext: bytes) -> Optional[bytes]: + pid = _find_weixin_pid() + if not pid: + return None + + PROCESS_VM_READ = 0x0010 + PROCESS_QUERY_INFORMATION = 0x0400 + MEM_COMMIT = 0x1000 + MEM_PRIVATE = 0x20000 + + kernel32 = ctypes.windll.kernel32 + + OpenProcess = kernel32.OpenProcess + OpenProcess.argtypes = [ctypes.c_ulong, ctypes.c_bool, ctypes.c_ulong] + OpenProcess.restype = ctypes.c_void_p + + ReadProcessMemory = kernel32.ReadProcessMemory + ReadProcessMemory.argtypes = [ + ctypes.c_void_p, + ctypes.c_void_p, + ctypes.c_void_p, + ctypes.c_size_t, + ctypes.POINTER(ctypes.c_size_t), + ] + ReadProcessMemory.restype = ctypes.c_bool + + VirtualQueryEx = kernel32.VirtualQueryEx + VirtualQueryEx.argtypes = [ctypes.c_void_p, ctypes.c_void_p, ctypes.c_void_p, ctypes.c_size_t] + VirtualQueryEx.restype = ctypes.c_size_t + + CloseHandle = kernel32.CloseHandle + CloseHandle.argtypes = [ctypes.c_void_p] + CloseHandle.restype = ctypes.c_bool + + handle = OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, False, pid) + if not handle: + return None + + stop = threading.Event() + result: list[Optional[bytes]] = [None] + pattern = re.compile(rb"[^a-z0-9]([a-z0-9]{32})[^a-z0-9]", flags=re.IGNORECASE) + + def read_mem(addr: int, size: int) -> Optional[bytes]: + buf = ctypes.create_string_buffer(size) + read = ctypes.c_size_t(0) + ok = ReadProcessMemory(handle, ctypes.c_void_p(addr), buf, size, ctypes.byref(read)) + if not ok or read.value <= 0: + return None + return buf.raw[: read.value] + + def scan_region(base: int, region_size: int) -> Optional[bytes]: + chunk = 4 * 1024 * 1024 + offset = 0 + tail = b"" + while offset < region_size and not stop.is_set(): + to_read = min(chunk, region_size - offset) + b = read_mem(base + offset, int(to_read)) + if not b: + return None + data = tail + b + for m in pattern.finditer(data): + cand32 = m.group(1) + cand16 = cand32[:16] + if _verify_wechat_aes_key(ciphertext, cand16): + return cand16 + tail = data[-64:] if len(data) > 64 else data + offset += to_read + return None + + regions: list[tuple[int, int]] = [] + mbi = _MEMORY_BASIC_INFORMATION() + addr = 0 + try: + while VirtualQueryEx(handle, ctypes.c_void_p(addr), ctypes.byref(mbi), ctypes.sizeof(mbi)): + try: + if int(mbi.State) == MEM_COMMIT and int(mbi.Type) == MEM_PRIVATE: + base = int(mbi.BaseAddress) + size = int(mbi.RegionSize) + if size > 0: + regions.append((base, size)) + addr = int(mbi.BaseAddress) + int(mbi.RegionSize) + except Exception: + addr += 0x1000 + if addr <= 0: + break + + with ThreadPoolExecutor(max_workers=min(32, max(1, len(regions)))) as ex: + for found in ex.map(lambda r: scan_region(r[0], r[1]), regions): + if found: + result[0] = found + stop.set() + break + finally: + CloseHandle(handle) + + return result[0] + + +def _save_media_keys(account_dir: Path, xor_key: int, aes_key16: bytes) -> None: + try: + payload = { + "xor": int(xor_key), + "aes": aes_key16.decode("ascii", errors="ignore"), + } + (account_dir / "_media_keys.json").write_text( + json.dumps(payload, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + except Exception: + pass + + +def _decrypt_wechat_dat_v3(data: bytes, xor_key: int) -> bytes: + return bytes(b ^ xor_key for b in data) + + +def _decrypt_wechat_dat_v4(data: bytes, xor_key: int, aes_key: bytes) -> bytes: + from Crypto.Cipher import AES + from Crypto.Util import Padding + + header, rest = data[:0xF], data[0xF:] + signature, aes_size, xor_size = struct.unpack("<6sLLx", header) + aes_size += AES.block_size - aes_size % AES.block_size + + aes_data = rest[:aes_size] + raw_data = rest[aes_size:] + + cipher = AES.new(aes_key[:16], AES.MODE_ECB) + decrypted_data = Padding.unpad(cipher.decrypt(aes_data), AES.block_size) + + if xor_size > 0: + raw_data = rest[aes_size:-xor_size] + xor_data = rest[-xor_size:] + xored_data = bytes(b ^ xor_key for b in xor_data) + else: + xored_data = b"" + + return decrypted_data + raw_data + xored_data + + +def _load_media_keys(account_dir: Path) -> dict[str, Any]: + p = account_dir / "_media_keys.json" + if not p.exists(): + return {} + try: + return json.loads(p.read_text(encoding="utf-8")) + except Exception: + return {} + + +def _read_and_maybe_decrypt_media(path: Path, account_dir: Optional[Path] = None, weixin_root: Optional[Path] = None) -> tuple[bytes, str]: + # Fast path: already a normal image + with open(path, "rb") as f: + head = f.read(64) + + mt = _detect_image_media_type(head) + if mt != "application/octet-stream": + return path.read_bytes(), mt + + data = path.read_bytes() + + dec, mt2 = _try_xor_decrypt_by_magic(data) + if dec is not None and mt2: + return dec, mt2 + + # Try WeChat .dat v1/v2 decrypt. + version = _detect_wechat_dat_version(data) + if version in (0, 1, 2): + root = weixin_root + if root is None and account_dir is not None: + root = _resolve_account_wxid_dir(account_dir) + if root is None and account_dir is not None: + ds = _resolve_account_db_storage_dir(account_dir) + root = ds.parent if ds else None + + xor_key = _find_wechat_xor_key(str(root)) if root else None + try: + if version == 0 and xor_key is not None: + out = _decrypt_wechat_dat_v3(data, xor_key) + mt0 = _detect_image_media_type(out[:32]) + if mt0 != "application/octet-stream": + return out, mt0 + elif version == 1 and xor_key is not None: + out = _decrypt_wechat_dat_v4(data, xor_key, b"cfcd208495d565ef") + mt1 = _detect_image_media_type(out[:32]) + if mt1 != "application/octet-stream": + return out, mt1 + elif version == 2 and xor_key is not None and account_dir is not None and root is not None: + keys = _load_media_keys(account_dir) + aes_str = str(keys.get("aes") or "").strip() + aes_key16 = aes_str.encode("ascii", errors="ignore")[:16] if aes_str else b"" + + if not aes_key16: + most_common = _get_wechat_template_most_common_last2(str(root)) + if most_common: + ct = _get_wechat_v2_ciphertext(Path(root), most_common) + else: + ct = None + + if ct: + aes_key16 = _extract_wechat_aes_key_from_process(ct) or b"" + if aes_key16: + _save_media_keys(account_dir, xor_key, aes_key16) + + if aes_key16: + out = _decrypt_wechat_dat_v4(data, xor_key, aes_key16) + mt2b = _detect_image_media_type(out[:32]) + if mt2b != "application/octet-stream": + return out, mt2b + except Exception: + pass + + # Fallback: return as-is. + mt3 = _guess_media_type_by_path(path, fallback="application/octet-stream") + return data, mt3 + + +def _query_head_image_usernames(head_image_db_path: Path, usernames: list[str]) -> set[str]: + uniq = list(dict.fromkeys([u for u in usernames if u])) + if not uniq: + return set() + if not head_image_db_path.exists(): + return set() + + conn = sqlite3.connect(str(head_image_db_path)) + try: + placeholders = ",".join(["?"] * len(uniq)) + rows = conn.execute( + f"SELECT username FROM head_image WHERE username IN ({placeholders})", + uniq, + ).fetchall() + return {str(r[0]) for r in rows if r and r[0]} + finally: + conn.close() + + +def _build_avatar_url(account_dir_name: str, username: str) -> str: + return f"/api/chat/avatar?account={quote(account_dir_name)}&username={quote(username)}" + + +def _decode_sqlite_text(value: Any) -> str: + if value is None: + return "" + if isinstance(value, bytes): + try: + return value.decode("utf-8", errors="ignore") + except Exception: + return "" + if isinstance(value, memoryview): + try: + return bytes(value).decode("utf-8", errors="ignore") + except Exception: + return "" + return str(value) + + +def _is_mostly_printable_text(s: str) -> bool: + if not s: + return False + sample = s[:600] + if not sample: + return False + printable = sum(1 for ch in sample if ch.isprintable() or ch in {"\n", "\r", "\t"}) + return (printable / len(sample)) >= 0.85 + + +def _looks_like_xml(s: str) -> bool: + if not s: + return False + t = s.lstrip() + if t.startswith('"') and t.endswith('"'): + t = t.strip('"').lstrip() + return t.startswith("<") + + +def _decode_message_content(compress_value: Any, message_value: Any) -> str: + msg_text = _decode_sqlite_text(message_value) + + # Try zstd decompression on message_value if it's binary zstd data (e.g., emoji messages) + if isinstance(message_value, (bytes, bytearray, memoryview)): + raw = bytes(message_value) if isinstance(message_value, memoryview) else message_value + if raw.startswith(b'\x28\xb5\x2f\xfd') and zstd is not None: + try: + out = zstd.decompress(raw) + s = out.decode("utf-8", errors="ignore") + s = html.unescape(s.strip()) + if _looks_like_xml(s) or _is_mostly_printable_text(s): + msg_text = s + except Exception: + pass + + if compress_value is None: + return msg_text + + def try_decode_text_blob(text: str) -> Optional[str]: + t = (text or "").strip() + if not t: + return None + + # hex + if len(t) >= 16 and len(t) % 2 == 0 and re.fullmatch(r"[0-9a-fA-F]+", t): + try: + raw = bytes.fromhex(t) + if zstd is not None: + try: + out = zstd.decompress(raw) + s2 = out.decode("utf-8", errors="ignore") + s2 = html.unescape(s2.strip()) + if _looks_like_xml(s2) or _is_mostly_printable_text(s2): + return s2 + except Exception: + pass + s2 = raw.decode("utf-8", errors="ignore") + s2 = html.unescape(s2.strip()) + if _looks_like_xml(s2) or _is_mostly_printable_text(s2): + return s2 + except Exception: + return None + + # base64 + if len(t) >= 24 and len(t) % 4 == 0 and re.fullmatch(r"[A-Za-z0-9+/=]+", t): + try: + raw = base64.b64decode(t) + if zstd is not None: + try: + out = zstd.decompress(raw) + s2 = out.decode("utf-8", errors="ignore") + s2 = html.unescape(s2.strip()) + if _looks_like_xml(s2) or _is_mostly_printable_text(s2): + return s2 + except Exception: + pass + s2 = raw.decode("utf-8", errors="ignore") + s2 = html.unescape(s2.strip()) + if _looks_like_xml(s2) or _is_mostly_printable_text(s2): + return s2 + except Exception: + return None + + return None + + # Some DBs store compress_content already as TEXT/XML. + if isinstance(compress_value, str): + s = html.unescape(compress_value.strip()) + s2 = try_decode_text_blob(s) + if s2: + return s2 + if _looks_like_xml(s) or _is_mostly_printable_text(s): + return s + return msg_text + + data: Optional[bytes] = None + if isinstance(compress_value, memoryview): + data = bytes(compress_value) + elif isinstance(compress_value, (bytes, bytearray)): + data = bytes(compress_value) + + if not data: + return msg_text + + # Try zstd first. + if zstd is not None: + try: + out = zstd.decompress(data) + s = out.decode("utf-8", errors="ignore") + s = html.unescape(s.strip()) + if _looks_like_xml(s) or _is_mostly_printable_text(s): + return s + except Exception: + pass + + # Fallback to plain utf-8 decode. + try: + s = data.decode("utf-8", errors="ignore") + s = html.unescape(s.strip()) + s2 = try_decode_text_blob(s) + if s2: + return s2 + if _looks_like_xml(s) or _is_mostly_printable_text(s): + return s + except Exception: + pass + + return msg_text + + +_MD5_HEX_RE = re.compile(rb"(?i)[0-9a-f]{32}") + + +def _extract_md5_from_blob(blob: Any) -> str: + if blob is None: + return "" + if isinstance(blob, memoryview): + data = bytes(blob) + elif isinstance(blob, (bytes, bytearray)): + data = bytes(blob) + else: + try: + data = bytes(blob) + except Exception: + return "" + + if not data: + return "" + m = _MD5_HEX_RE.findall(data) + if not m: + return "" + best = Counter([x.lower() for x in m]).most_common(1)[0][0] + try: + return best.decode("ascii", errors="ignore") + except Exception: + return "" + + +def _resource_lookup_chat_id(resource_conn: sqlite3.Connection, username: str) -> Optional[int]: + if not username: + return None + try: + row = resource_conn.execute( + "SELECT rowid FROM ChatName2Id WHERE user_name = ? LIMIT 1", + (username,), + ).fetchone() + if row and row[0] is not None: + return int(row[0]) + except Exception: + return None + return None + + +def _lookup_resource_md5( + resource_conn: sqlite3.Connection, + chat_id: Optional[int], + message_local_type: int, + server_id: int, + local_id: int, + create_time: int, +) -> str: + if server_id <= 0 and local_id <= 0: + return "" + + where_chat = "" + params_prefix: list[Any] = [] + if chat_id is not None and int(chat_id) > 0: + where_chat = " AND chat_id = ?" + params_prefix.append(int(chat_id)) + + where_type = "" + if int(message_local_type) > 0: + where_type = " AND message_local_type = ?" + params_prefix.append(int(message_local_type)) + + try: + if server_id > 0: + row = resource_conn.execute( + "SELECT packed_info FROM MessageResourceInfo WHERE message_svr_id = ?" + where_chat + where_type + " ORDER BY message_id DESC LIMIT 1", + [int(server_id)] + params_prefix, + ).fetchone() + if row and row[0] is not None: + md5 = _extract_md5_from_blob(row[0]) + if md5: + return md5 + except Exception: + pass + + try: + if local_id > 0 and create_time > 0: + row = resource_conn.execute( + "SELECT packed_info FROM MessageResourceInfo WHERE message_local_id = ? AND message_create_time = ?" + where_chat + where_type + " ORDER BY message_id DESC LIMIT 1", + [int(local_id), int(create_time)] + params_prefix, + ).fetchone() + if row and row[0] is not None: + return _extract_md5_from_blob(row[0]) + except Exception: + pass + + return "" + + +def _strip_cdata(s: str) -> str: + if not s: + return "" + out = s.replace("", "") + return out.strip() + + +def _extract_xml_tag_text(xml_text: str, tag: str) -> str: + if not xml_text or not tag: + return "" + m = re.search(rf"<{re.escape(tag)}>(.*?){re.escape(tag)}>", xml_text, flags=re.IGNORECASE | re.DOTALL) + if not m: + return "" + return _strip_cdata(m.group(1) or "") + + +def _extract_xml_attr(xml_text: str, attr: str) -> str: + if not xml_text or not attr: + return "" + m = re.search(rf"{re.escape(attr)}\s*=\s*['\"]([^'\"]+)['\"]", xml_text, flags=re.IGNORECASE) + return (m.group(1) or "").strip() if m else "" + + +def _extract_xml_tag_or_attr(xml_text: str, name: str) -> str: + v = _extract_xml_tag_text(xml_text, name) + if v: + return v + return _extract_xml_attr(xml_text, name) + + +def _extract_refermsg_block(xml_text: str) -> str: + if not xml_text: + return "" + m = re.search(r"(]*>.*?)", xml_text, flags=re.IGNORECASE | re.DOTALL) + return (m.group(1) or "").strip() if m else "" + + +def _infer_transfer_status_text( + is_sent: bool, + paysubtype: str, + receivestatus: str, + sendertitle: str, + receivertitle: str, + senderdes: str, + receiverdes: str, +) -> str: + t = str(paysubtype or "").strip() + rs = str(receivestatus or "").strip() + + # Final states first + if rs == "1": + return "已收款" + if rs == "2": + return "已退回" + if rs == "3": + return "已过期" + + if t == "4": + return "已退回" + if t == "9": + return "已被退回" + if t == "10": + return "已过期" + + # Non-final states (match oh-my-wechat component) + if t == "8": + return "发起转账" + if t == "3": + return "接收转账" + if t == "1": + return "转账" + + # Fallback to titles/descriptions + title = sendertitle if is_sent else receivertitle + if title: + return title + des = senderdes if is_sent else receiverdes + if des: + return des + return "转账" + + +def _split_group_sender_prefix(text: str) -> tuple[str, str]: + if not text: + return "", text + sep = text.find(":\n") + if sep <= 0: + return "", text + prefix = text[:sep].strip() + body = text[sep + 2 :].lstrip("\n") + if not prefix or len(prefix) > 128: + return "", text + if re.search(r"\s", prefix): + return "", text + if prefix.startswith("wxid_") or prefix.endswith("@chatroom") or "@" in prefix: + return prefix, body + return "", text + + +def _extract_sender_from_group_xml(xml_text: str) -> str: + if not xml_text: + return "" + + v = _extract_xml_tag_text(xml_text, "fromusername") + if v: + return v + v = _extract_xml_attr(xml_text, "fromusername") + if v: + return v + return "" + + +def _parse_pat_message(text: str, contact_rows: dict[str, sqlite3.Row]) -> str: + template = _extract_xml_tag_text(text, "template") + if not template: + return "[拍一拍]" + wxids = list({m.group(1) for m in re.finditer(r"\$\{([^}]+)\}", template) if m.group(1)}) + rendered = template + for wxid in wxids: + row = contact_rows.get(wxid) + name = _pick_display_name(row, wxid) + rendered = rendered.replace(f"${{{wxid}}}", name) + return rendered.strip() or "[拍一拍]" + + +def _parse_quote_message(text: str) -> str: + title = _extract_xml_tag_text(text, "title") + if title: + return title + refer = _extract_xml_tag_text(text, "content") + if refer: + return refer + return "[引用消息]" + + +def _parse_app_message(text: str) -> dict[str, Any]: + app_type_raw = _extract_xml_tag_text(text, "type") + try: + app_type = int(str(app_type_raw or "0").strip() or "0") + except Exception: + app_type = 0 + title = _extract_xml_tag_text(text, "title") + des = _extract_xml_tag_text(text, "des") + url = _extract_xml_tag_text(text, "url") + + if "" in text.lower(): + return { + "renderType": "system", + "content": "[拍一拍]", + } + + if app_type in (5, 68) and url: + thumb_url = _extract_xml_tag_text(text, "thumburl") + return { + "renderType": "link", + "content": des or title or "[链接]", + "title": title or des or "", + "url": url, + "thumbUrl": thumb_url or "", + } + + if app_type in (6, 74): + file_name = title or "" + total_len = _extract_xml_tag_text(text, "totallen") + file_md5 = ( + _extract_xml_tag_or_attr(text, "md5") + or _extract_xml_tag_or_attr(text, "filemd5") + or _extract_xml_tag_or_attr(text, "file_md5") + ) + return { + "renderType": "file", + "content": f"[文件] {file_name}".strip(), + "title": file_name, + "size": total_len or "", + "fileMd5": file_md5 or "", + } + + if app_type == 57 or " inside by stripping the refermsg block first. + try: + text_wo_refer = re.sub( + r"(]*>.*?)", + "", + text, + flags=re.IGNORECASE | re.DOTALL, + ) + except Exception: + text_wo_refer = text + + reply_text = _extract_xml_tag_text(text_wo_refer, "title") or _extract_xml_tag_text( + text, "title" + ) + refer_displayname = _extract_xml_tag_or_attr(refer_block, "displayname") + refer_content = _extract_xml_tag_text(refer_block, "content") + refer_type = _extract_xml_tag_or_attr(refer_block, "type") + + # Some DBs embed the reply text as the first line of refer_content (causing duplication in UI). + # Try to strip it if it looks like a prefix. + rt = (reply_text or "").strip() + rc = (refer_content or "").strip() + if rt and rc: + if rc == rt: + refer_content = "" + else: + lines = [ln.strip() for ln in rc.splitlines()] + if lines and lines[0] == rt: + refer_content = "\n".join(rc.splitlines()[1:]).lstrip() + elif rc.startswith(rt): + rest = rc[len(rt) :].lstrip() + refer_content = rest + + # Make quote preview friendlier based on refer_type. + t = str(refer_type or "").strip() + if t == "3": + refer_content = "[图片]" + elif t == "47": + refer_content = "[表情]" + elif t == "43" or t == "62": + refer_content = "[视频]" + elif t == "34": + refer_content = "[语音]" + elif t == "49" and refer_content: + refer_content = f"[链接] {refer_content}".strip() + return { + "renderType": "quote", + "content": reply_text or "[引用消息]", + "quoteTitle": refer_displayname or "", + "quoteContent": refer_content or "", + } + + if app_type == 2000 or " list[Path]: + if not account_dir.exists(): + return [] + + candidates: list[Path] = [] + for p in account_dir.glob("*.db"): + n = p.name + ln = n.lower() + if ln in {"session.db", "contact.db", "head_image.db"}: + continue + if ln == "message_resource.db": + continue + + if re.match(r"^message(_\d+)?\.db$", ln): + candidates.append(p) + continue + if re.match(r"^biz_message(_\d+)?\.db$", ln): + candidates.append(p) + continue + if "message" in ln and ln.endswith(".db"): + candidates.append(p) + continue + candidates.sort(key=lambda x: x.name) + return candidates + + +def _pick_display_name(contact_row: Optional[sqlite3.Row], fallback_username: str) -> str: + """显示名优先级:remark > nick_name > alias > username""" + if contact_row is None: + return fallback_username + + for key in ("remark", "nick_name", "alias"): + try: + v = contact_row[key] + except Exception: + v = None + if isinstance(v, str) and v.strip(): + return v.strip() + + return fallback_username + + +def _pick_avatar_url(contact_row: Optional[sqlite3.Row]) -> Optional[str]: + """头像URL优先级:big_head_url > small_head_url""" + if contact_row is None: + return None + + for key in ("big_head_url", "small_head_url"): + try: + v = contact_row[key] + except Exception: + v = None + if isinstance(v, str) and v.strip(): + return v.strip() + + return None + + +def _load_contact_rows(contact_db_path: Path, usernames: list[str]) -> dict[str, sqlite3.Row]: + """批量加载联系人行数据:先查 contact,再查 stranger 补缺""" + uniq = list(dict.fromkeys([u for u in usernames if u])) + if not uniq: + return {} + + result: dict[str, sqlite3.Row] = {} + + conn = sqlite3.connect(str(contact_db_path)) + conn.row_factory = sqlite3.Row + try: + def query_table(table: str, targets: list[str]) -> None: + if not targets: + return + placeholders = ",".join(["?"] * len(targets)) + sql = f""" + SELECT username, remark, nick_name, alias, big_head_url, small_head_url + FROM {table} + WHERE username IN ({placeholders}) + """ + rows = conn.execute(sql, targets).fetchall() + for r in rows: + result[r["username"]] = r + + query_table("contact", uniq) + missing = [u for u in uniq if u not in result] + query_table("stranger", missing) + return result + finally: + conn.close() + class PathFixRequest(Request): """自定义Request类,自动修复JSON中的路径问题并检测相对路径""" @@ -233,6 +1793,384 @@ app.add_middleware( ) +@app.get("/api/chat/avatar", summary="获取联系人头像") +async def get_chat_avatar(username: str, account: Optional[str] = None): + if not username: + raise HTTPException(status_code=400, detail="Missing username.") + account_dir = _resolve_account_dir(account) + head_image_db_path = account_dir / "head_image.db" + if not head_image_db_path.exists(): + raise HTTPException(status_code=404, detail="head_image.db not found.") + + conn = sqlite3.connect(str(head_image_db_path)) + try: + row = conn.execute( + "SELECT image_buffer FROM head_image WHERE username = ? ORDER BY update_time DESC LIMIT 1", + (username,), + ).fetchone() + finally: + conn.close() + + if not row or row[0] is None: + raise HTTPException(status_code=404, detail="Avatar not found.") + + data = bytes(row[0]) if isinstance(row[0], (memoryview, bytearray)) else row[0] + if not isinstance(data, (bytes, bytearray)): + data = bytes(data) + media_type = _detect_image_media_type(data) + return Response(content=data, media_type=media_type) + + +@app.get("/api/chat/media/image", summary="获取图片消息资源") +async def get_chat_image(md5: str, account: Optional[str] = None, username: Optional[str] = None): + if not md5: + raise HTTPException(status_code=400, detail="Missing md5.") + account_dir = _resolve_account_dir(account) + wxid_dir = _resolve_account_wxid_dir(account_dir) + hardlink_db_path = account_dir / "hardlink.db" + extra_roots: list[Path] = [] + db_storage_dir = _resolve_account_db_storage_dir(account_dir) + if db_storage_dir: + extra_roots.append(db_storage_dir) + + roots: list[Path] = [] + if wxid_dir: + roots.append(wxid_dir) + roots.append(wxid_dir / "msg" / "attach") + roots.append(wxid_dir / "msg" / "file") + roots.append(wxid_dir / "msg" / "video") + roots.append(wxid_dir / "cache") + if db_storage_dir: + roots.append(db_storage_dir) + if not roots: + raise HTTPException(status_code=404, detail="wxid_dir/db_storage_path not found. Please decrypt with db_storage_path to enable media lookup.") + p = _resolve_media_path_from_hardlink( + hardlink_db_path, + roots[0], + md5=str(md5), + kind="image", + username=username, + extra_roots=roots[1:], + ) + if (not p) and wxid_dir: + hit = _fallback_search_media_by_md5(str(wxid_dir), str(md5)) + if hit: + p = Path(hit) + if not p: + raise HTTPException(status_code=404, detail="Image not found.") + + data, media_type = _read_and_maybe_decrypt_media(p, account_dir=account_dir, weixin_root=wxid_dir) + return Response(content=data, media_type=media_type) + + +@app.get("/api/chat/media/emoji", summary="获取表情消息资源") +async def get_chat_emoji(md5: str, account: Optional[str] = None, username: Optional[str] = None): + if not md5: + raise HTTPException(status_code=400, detail="Missing md5.") + account_dir = _resolve_account_dir(account) + wxid_dir = _resolve_account_wxid_dir(account_dir) + hardlink_db_path = account_dir / "hardlink.db" + extra_roots: list[Path] = [] + db_storage_dir = _resolve_account_db_storage_dir(account_dir) + if db_storage_dir: + extra_roots.append(db_storage_dir) + + roots: list[Path] = [] + if wxid_dir: + roots.append(wxid_dir) + if db_storage_dir: + roots.append(db_storage_dir) + if not roots: + raise HTTPException(status_code=404, detail="wxid_dir/db_storage_path not found. Please decrypt with db_storage_path to enable media lookup.") + p = _resolve_media_path_from_hardlink( + hardlink_db_path, + roots[0], + md5=str(md5), + kind="emoji", + username=username, + extra_roots=roots[1:], + ) + if (not p) and wxid_dir: + hit = _fallback_search_media_by_md5(str(wxid_dir), str(md5)) + if hit: + p = Path(hit) + if not p: + raise HTTPException(status_code=404, detail="Emoji not found.") + + data, media_type = _read_and_maybe_decrypt_media(p, account_dir=account_dir, weixin_root=wxid_dir) + return Response(content=data, media_type=media_type) + + +@app.get("/api/chat/media/video_thumb", summary="获取视频缩略图资源") +async def get_chat_video_thumb(md5: str, account: Optional[str] = None, username: Optional[str] = None): + if not md5: + raise HTTPException(status_code=400, detail="Missing md5.") + account_dir = _resolve_account_dir(account) + wxid_dir = _resolve_account_wxid_dir(account_dir) + hardlink_db_path = account_dir / "hardlink.db" + extra_roots: list[Path] = [] + db_storage_dir = _resolve_account_db_storage_dir(account_dir) + if db_storage_dir: + extra_roots.append(db_storage_dir) + + roots: list[Path] = [] + if wxid_dir: + roots.append(wxid_dir) + if db_storage_dir: + roots.append(db_storage_dir) + if not roots: + raise HTTPException(status_code=404, detail="wxid_dir/db_storage_path not found. Please decrypt with db_storage_path to enable media lookup.") + p = _resolve_media_path_from_hardlink( + hardlink_db_path, + roots[0], + md5=str(md5), + kind="video_thumb", + username=username, + extra_roots=roots[1:], + ) + if (not p) and wxid_dir: + hit = _fallback_search_media_by_md5(str(wxid_dir), str(md5)) + if hit: + p = Path(hit) + if not p: + raise HTTPException(status_code=404, detail="Video thumbnail not found.") + + data, media_type = _read_and_maybe_decrypt_media(p, account_dir=account_dir, weixin_root=wxid_dir) + return Response(content=data, media_type=media_type) + + +@app.get("/api/chat/media/video", summary="获取视频资源") +async def get_chat_video(md5: str, account: Optional[str] = None, username: Optional[str] = None): + if not md5: + raise HTTPException(status_code=400, detail="Missing md5.") + account_dir = _resolve_account_dir(account) + wxid_dir = _resolve_account_wxid_dir(account_dir) + hardlink_db_path = account_dir / "hardlink.db" + extra_roots: list[Path] = [] + db_storage_dir = _resolve_account_db_storage_dir(account_dir) + if db_storage_dir: + extra_roots.append(db_storage_dir) + + roots: list[Path] = [] + if wxid_dir: + roots.append(wxid_dir) + if db_storage_dir: + roots.append(db_storage_dir) + if not roots: + raise HTTPException(status_code=404, detail="wxid_dir/db_storage_path not found. Please decrypt with db_storage_path to enable media lookup.") + p = _resolve_media_path_from_hardlink( + hardlink_db_path, + roots[0], + md5=str(md5), + kind="video", + username=username, + extra_roots=roots[1:], + ) + if (not p) and wxid_dir: + hit = _fallback_search_media_by_md5(str(wxid_dir), str(md5)) + if hit: + p = Path(hit) + if not p: + raise HTTPException(status_code=404, detail="Video not found.") + media_type = _guess_media_type_by_path(p, fallback="video/mp4") + return FileResponse(str(p), media_type=media_type) + + +def _convert_silk_to_wav(silk_data: bytes) -> bytes: + """Convert SILK audio data to WAV format for browser playback.""" + import tempfile + + try: + import pilk + except ImportError: + # If pilk not installed, return original data + return silk_data + + try: + # pilk.silk_to_wav works with file paths, so use temp files + with tempfile.NamedTemporaryFile(suffix=".silk", delete=False) as silk_file: + silk_file.write(silk_data) + silk_path = silk_file.name + + wav_path = silk_path.replace(".silk", ".wav") + + try: + pilk.silk_to_wav(silk_path, wav_path, rate=24000) + with open(wav_path, "rb") as wav_file: + wav_data = wav_file.read() + return wav_data + finally: + # Clean up temp files + import os + try: + os.unlink(silk_path) + except Exception: + pass + try: + os.unlink(wav_path) + except Exception: + pass + except Exception as e: + logger.warning(f"SILK to WAV conversion failed: {e}") + return silk_data + + +@app.get("/api/chat/media/voice", summary="获取语音消息资源") +async def get_chat_voice(server_id: int, account: Optional[str] = None): + if not server_id: + raise HTTPException(status_code=400, detail="Missing server_id.") + account_dir = _resolve_account_dir(account) + media_db_path = account_dir / "media_0.db" + if not media_db_path.exists(): + raise HTTPException(status_code=404, detail="media_0.db not found.") + + conn = sqlite3.connect(str(media_db_path)) + conn.row_factory = sqlite3.Row + try: + row = conn.execute( + "SELECT voice_data FROM VoiceInfo WHERE svr_id = ? ORDER BY create_time DESC LIMIT 1", + (int(server_id),), + ).fetchone() + except Exception: + row = None + finally: + conn.close() + + if not row or row[0] is None: + raise HTTPException(status_code=404, detail="Voice not found.") + + data = bytes(row[0]) if isinstance(row[0], (memoryview, bytearray)) else row[0] + if not isinstance(data, (bytes, bytearray)): + data = bytes(data) + + # Try to convert SILK to WAV for browser playback + wav_data = _convert_silk_to_wav(data) + if wav_data != data: + return Response( + content=wav_data, + media_type="audio/wav", + ) + + # Fallback to raw SILK if conversion fails + return Response( + content=data, + media_type="audio/silk", + headers={"Content-Disposition": f"attachment; filename=voice_{int(server_id)}.silk"}, + ) + + +def _resolve_media_path_for_kind( + account_dir: Path, + kind: str, + md5: str, + username: Optional[str], +) -> Optional[Path]: + if not md5: + return None + wxid_dir = _resolve_account_wxid_dir(account_dir) + hardlink_db_path = account_dir / "hardlink.db" + db_storage_dir = _resolve_account_db_storage_dir(account_dir) + + roots: list[Path] = [] + if wxid_dir: + roots.append(wxid_dir) + roots.append(wxid_dir / "msg" / "attach") + roots.append(wxid_dir / "msg" / "file") + roots.append(wxid_dir / "msg" / "video") + roots.append(wxid_dir / "cache") + if db_storage_dir: + roots.append(db_storage_dir) + if not roots: + return None + + p = _resolve_media_path_from_hardlink( + hardlink_db_path, + roots[0], + md5=str(md5), + kind=str(kind), + username=username, + extra_roots=roots[1:], + ) + if (not p) and wxid_dir: + hit = _fallback_search_media_by_md5(str(wxid_dir), str(md5)) + if hit: + p = Path(hit) + return p + + +@app.post("/api/chat/media/open_folder", summary="在资源管理器中打开媒体文件所在位置") +async def open_chat_media_folder( + kind: str, + md5: Optional[str] = None, + server_id: Optional[int] = None, + account: Optional[str] = None, + username: Optional[str] = None, +): + account_dir = _resolve_account_dir(account) + + kind_key = str(kind or "").strip().lower() + if kind_key not in {"image", "emoji", "video", "video_thumb", "file", "voice"}: + raise HTTPException(status_code=400, detail="Unsupported kind.") + + p: Optional[Path] = None + if kind_key == "voice": + if not server_id: + raise HTTPException(status_code=400, detail="Missing server_id.") + + media_db_path = account_dir / "media_0.db" + if not media_db_path.exists(): + raise HTTPException(status_code=404, detail="media_0.db not found.") + + conn = sqlite3.connect(str(media_db_path)) + conn.row_factory = sqlite3.Row + try: + row = conn.execute( + "SELECT voice_data FROM VoiceInfo WHERE svr_id = ? ORDER BY create_time DESC LIMIT 1", + (int(server_id),), + ).fetchone() + except Exception: + row = None + finally: + conn.close() + + if not row or row[0] is None: + raise HTTPException(status_code=404, detail="Voice not found.") + + data = bytes(row[0]) if isinstance(row[0], (memoryview, bytearray)) else row[0] + if not isinstance(data, (bytes, bytearray)): + data = bytes(data) + + export_dir = account_dir / "_exports" + export_dir.mkdir(parents=True, exist_ok=True) + p = export_dir / f"voice_{int(server_id)}.silk" + try: + p.write_bytes(data) + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to export voice: {e}") + else: + if not md5: + raise HTTPException(status_code=400, detail="Missing md5.") + p = _resolve_media_path_for_kind(account_dir, kind=kind_key, md5=str(md5), username=username) + + if not p: + raise HTTPException(status_code=404, detail="File not found.") + + try: + target = str(p.resolve()) + except Exception: + target = str(p) + + if os.name != "nt": + raise HTTPException(status_code=400, detail="open_folder is only supported on Windows.") + + try: + subprocess.Popen(["explorer", "/select,", target]) + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to open explorer: {e}") + + return {"status": "success", "path": target} + + @app.middleware("http") async def log_requests(request: Request, call_next): """记录所有HTTP请求的中间件""" @@ -385,7 +2323,572 @@ async def decrypt_databases(request: DecryptRequest): raise HTTPException(status_code=500, detail=str(e)) +@app.get("/api/chat/accounts", summary="列出已解密账号") +async def list_chat_accounts(): + """列出 output/databases 下可用于聊天预览的账号目录""" + accounts = _list_decrypted_accounts() + if not accounts: + return { + "status": "error", + "accounts": [], + "default_account": None, + "message": "No decrypted databases found. Please decrypt first.", + } + return { + "status": "success", + "accounts": accounts, + "default_account": accounts[0], + } + + +@app.get("/api/chat/sessions", summary="获取会话列表(聊天左侧列表)") +async def list_chat_sessions( + request: Request, + account: Optional[str] = None, + limit: int = 400, + include_hidden: bool = False, + include_official: bool = False, +): + """从 session.db + contact.db 读取会话列表,用于前端聊天界面动态渲染联系人""" + if limit <= 0: + raise HTTPException(status_code=400, detail="Invalid limit.") + if limit > 2000: + limit = 2000 + + account_dir = _resolve_account_dir(account) + session_db_path = account_dir / "session.db" + contact_db_path = account_dir / "contact.db" + head_image_db_path = account_dir / "head_image.db" + base_url = str(request.base_url).rstrip("/") + + sconn = sqlite3.connect(str(session_db_path)) + sconn.row_factory = sqlite3.Row + try: + rows = sconn.execute( + """ + SELECT + username, + unread_count, + is_hidden, + summary, + draft, + last_timestamp, + sort_timestamp, + last_msg_type, + last_msg_sub_type + FROM SessionTable + ORDER BY sort_timestamp DESC + LIMIT ? + """, + (int(limit),), + ).fetchall() + finally: + sconn.close() + + filtered: list[sqlite3.Row] = [] + usernames: list[str] = [] + for r in rows: + username = r["username"] or "" + if not username: + continue + if not include_hidden and int(r["is_hidden"] or 0) == 1: + continue + if not _should_keep_session(username, include_official=include_official): + continue + filtered.append(r) + usernames.append(username) + + contact_rows = _load_contact_rows(contact_db_path, usernames) + local_avatar_usernames = _query_head_image_usernames(head_image_db_path, usernames) + + sessions: list[dict[str, Any]] = [] + for r in filtered: + username = r["username"] + c_row = contact_rows.get(username) + + display_name = _pick_display_name(c_row, username) + avatar_url = _pick_avatar_url(c_row) + if not avatar_url and username in local_avatar_usernames: + avatar_url = base_url + _build_avatar_url(account_dir.name, username) + + summary = (r["summary"] or "").strip() if isinstance(r["summary"], str) else (r["summary"] or "") + draft = (r["draft"] or "").strip() if isinstance(r["draft"], str) else (r["draft"] or "") + + if draft: + last_message = f"[Draft] {draft}" + elif summary: + last_message = summary + else: + last_message = _infer_last_message_brief(r["last_msg_type"], r["last_msg_sub_type"]) + + last_time = _format_session_time(r["sort_timestamp"] or r["last_timestamp"]) + + sessions.append( + { + "id": username, + "username": username, + "name": display_name, + "avatar": avatar_url, + "lastMessage": last_message, + "lastMessageTime": last_time, + "unreadCount": int(r["unread_count"] or 0), + "isGroup": bool(username.endswith("@chatroom")), + } + ) + + return { + "status": "success", + "account": account_dir.name, + "total": len(sessions), + "sessions": sessions, + } + + +@app.get("/api/chat/messages", summary="获取会话消息列表") +async def list_chat_messages( + request: Request, + username: str, + account: Optional[str] = None, + limit: int = 50, + offset: int = 0, + order: str = "asc", +): + if not username: + raise HTTPException(status_code=400, detail="Missing username.") + if limit <= 0: + raise HTTPException(status_code=400, detail="Invalid limit.") + if limit > 500: + limit = 500 + if offset < 0: + offset = 0 + + account_dir = _resolve_account_dir(account) + db_paths = _iter_message_db_paths(account_dir) + contact_db_path = account_dir / "contact.db" + head_image_db_path = account_dir / "head_image.db" + message_resource_db_path = account_dir / "message_resource.db" + base_url = str(request.base_url).rstrip("/") + if not db_paths: + return { + "status": "error", + "account": account_dir.name, + "username": username, + "total": 0, + "messages": [], + "message": "No message databases found for this account.", + } + + resource_conn: Optional[sqlite3.Connection] = None + resource_chat_id: Optional[int] = None + try: + if message_resource_db_path.exists(): + resource_conn = sqlite3.connect(str(message_resource_db_path)) + resource_conn.row_factory = sqlite3.Row + resource_chat_id = _resource_lookup_chat_id(resource_conn, username) + except Exception: + if resource_conn is not None: + try: + resource_conn.close() + except Exception: + pass + resource_conn = None + resource_chat_id = None + + want_asc = str(order or "").lower() != "desc" + take = int(limit) + int(offset) + take_probe = take + 1 + merged: list[dict[str, Any]] = [] + sender_usernames: list[str] = [] + pat_usernames: set[str] = set() + is_group = bool(username.endswith("@chatroom")) + has_more_any = False + + for db_path in db_paths: + conn = sqlite3.connect(str(db_path)) + conn.row_factory = sqlite3.Row + try: + table_name = _resolve_msg_table_name(conn, username) + if not table_name: + continue + + my_wxid = account_dir.name + my_rowid = None + try: + r = conn.execute( + "SELECT rowid FROM Name2Id WHERE user_name = ? LIMIT 1", + (my_wxid,), + ).fetchone() + if r is not None: + my_rowid = int(r[0]) + except Exception: + my_rowid = None + + quoted_table = _quote_ident(table_name) + sql_with_join = ( + "SELECT " + "m.local_id, m.server_id, m.local_type, m.sort_seq, m.real_sender_id, m.create_time, " + "m.message_content, m.compress_content, n.user_name AS sender_username " + f"FROM {quoted_table} m " + "LEFT JOIN Name2Id n ON m.real_sender_id = n.rowid " + "ORDER BY m.create_time DESC, m.sort_seq DESC, m.local_id DESC " + "LIMIT ?" + ) + sql_no_join = ( + "SELECT " + "m.local_id, m.server_id, m.local_type, m.sort_seq, m.real_sender_id, m.create_time, " + "m.message_content, m.compress_content, '' AS sender_username " + f"FROM {quoted_table} m " + "ORDER BY m.create_time DESC, m.sort_seq DESC, m.local_id DESC " + "LIMIT ?" + ) + + # Force sqlite3 to return TEXT as raw bytes for this query, so we can zstd-decompress + # compress_content reliably. + conn.text_factory = bytes + + try: + rows = conn.execute(sql_with_join, (take_probe,)).fetchall() + except Exception: + rows = conn.execute(sql_no_join, (take_probe,)).fetchall() + if len(rows) > take: + has_more_any = True + rows = rows[:take] + + for r in rows: + local_id = int(r["local_id"] or 0) + create_time = int(r["create_time"] or 0) + sort_seq = int(r["sort_seq"] or 0) if r["sort_seq"] is not None else 0 + local_type = int(r["local_type"] or 0) + sender_username = _decode_sqlite_text(r["sender_username"]).strip() + + is_sent = False + if my_rowid is not None: + try: + is_sent = int(r["real_sender_id"] or 0) == int(my_rowid) + except Exception: + is_sent = False + + raw_text = _decode_message_content(r["compress_content"], r["message_content"]) + raw_text = raw_text.strip() + + sender_prefix = "" + if is_group and not raw_text.startswith("<") and not raw_text.startswith('"<'): + sender_prefix, raw_text = _split_group_sender_prefix(raw_text) + + if is_group and sender_prefix: + sender_username = sender_prefix + + if is_group and (raw_text.startswith("<") or raw_text.startswith('"<')): + xml_sender = _extract_sender_from_group_xml(raw_text) + if xml_sender: + sender_username = xml_sender + + if is_sent: + sender_username = account_dir.name + elif (not is_group) and (not sender_username): + sender_username = username + + if sender_username: + sender_usernames.append(sender_username) + + render_type = "text" + content_text = raw_text + title = "" + url = "" + image_md5 = "" + emoji_md5 = "" + emoji_url = "" + thumb_url = "" + image_url = "" + video_md5 = "" + video_thumb_md5 = "" + video_url = "" + video_thumb_url = "" + voice_length = "" + quote_title = "" + quote_content = "" + amount = "" + cover_url = "" + file_size = "" + pay_sub_type = "" + transfer_status = "" + file_md5 = "" + + if local_type == 10000: + render_type = "system" + if "revokemsg" in raw_text: + content_text = "撤回了一条消息" + else: + content_text = re.sub(r"?[_a-zA-Z0-9]+[^>]*>", "", raw_text) + content_text = re.sub(r"\s+", " ", content_text).strip() or "[系统消息]" + elif local_type == 49: + parsed = _parse_app_message(raw_text) + render_type = str(parsed.get("renderType") or "text") + content_text = str(parsed.get("content") or "") + title = str(parsed.get("title") or "") + url = str(parsed.get("url") or "") + quote_title = str(parsed.get("quoteTitle") or "") + quote_content = str(parsed.get("quoteContent") or "") + amount = str(parsed.get("amount") or "") + cover_url = str(parsed.get("coverUrl") or "") + thumb_url = str(parsed.get("thumbUrl") or "") + file_size = str(parsed.get("size") or "") + pay_sub_type = str(parsed.get("paySubType") or "") + file_md5 = str(parsed.get("fileMd5") or "") + + if render_type == "transfer": + transfer_status = _infer_transfer_status_text( + is_sent=is_sent, + paysubtype=pay_sub_type, + receivestatus=str(parsed.get("receiveStatus") or ""), + sendertitle=str(parsed.get("senderTitle") or ""), + receivertitle=str(parsed.get("receiverTitle") or ""), + senderdes=str(parsed.get("senderDes") or ""), + receiverdes=str(parsed.get("receiverDes") or ""), + ) + if not content_text: + content_text = transfer_status or "转账" + elif local_type == 266287972401: + render_type = "system" + template = _extract_xml_tag_text(raw_text, "template") + if template: + pat_usernames.update({m.group(1) for m in re.finditer(r"\$\{([^}]+)\}", template) if m.group(1)}) + content_text = "[拍一拍]" + else: + content_text = "[拍一拍]" + elif local_type == 244813135921: + render_type = "quote" + parsed = _parse_app_message(raw_text) + content_text = str(parsed.get("content") or "[引用消息]") + quote_title = str(parsed.get("quoteTitle") or "") + quote_content = str(parsed.get("quoteContent") or "") + elif local_type == 3: + render_type = "image" + image_md5 = _extract_xml_attr(raw_text, "md5") + # Extract CDN URL and validate it looks like a proper URL + _cdn_url = ( + _extract_xml_attr(raw_text, "cdnthumburl") + or _extract_xml_attr(raw_text, "cdnmidimgurl") + or _extract_xml_attr(raw_text, "cdnbigimgurl") + ) + image_url = _cdn_url if _cdn_url.startswith(("http://", "https://")) else "" + if (not image_md5) and resource_conn is not None: + image_md5 = _lookup_resource_md5( + resource_conn, + resource_chat_id, + message_local_type=local_type, + server_id=int(r["server_id"] or 0), + local_id=local_id, + create_time=create_time, + ) + content_text = "[图片]" + elif local_type == 34: + render_type = "voice" + duration = _extract_xml_attr(raw_text, "voicelength") + voice_length = duration + content_text = f"[语音 {duration}秒]" if duration else "[语音]" + elif local_type == 43 or local_type == 62: + render_type = "video" + video_md5 = _extract_xml_attr(raw_text, "md5") + video_thumb_md5 = _extract_xml_attr(raw_text, "cdnthumbmd5") + video_thumb_url = _extract_xml_attr(raw_text, "cdnthumburl") + video_url = _extract_xml_attr(raw_text, "cdnvideourl") + if (not video_thumb_md5) and resource_conn is not None: + video_thumb_md5 = _lookup_resource_md5( + resource_conn, + resource_chat_id, + message_local_type=local_type, + server_id=int(r["server_id"] or 0), + local_id=local_id, + create_time=create_time, + ) + content_text = "[视频]" + elif local_type == 47: + render_type = "emoji" + emoji_md5 = _extract_xml_attr(raw_text, "md5") + if not emoji_md5: + emoji_md5 = _extract_xml_tag_text(raw_text, "md5") + emoji_url = _extract_xml_attr(raw_text, "cdnurl") + if not emoji_url: + emoji_url = _extract_xml_tag_text(raw_text, "cdn_url") + if (not emoji_md5) and resource_conn is not None: + emoji_md5 = _lookup_resource_md5( + resource_conn, + resource_chat_id, + message_local_type=local_type, + server_id=int(r["server_id"] or 0), + local_id=local_id, + create_time=create_time, + ) + content_text = "[表情]" + elif local_type != 1: + if not content_text: + content_text = _infer_message_brief_by_local_type(local_type) + else: + if content_text.startswith("<") or content_text.startswith('"<'): + if " tuple[int, int, int]: + sseq = int(m.get("sortSeq") or 0) + cts = int(m.get("createTime") or 0) + lid = int(m.get("localId") or 0) + primary = sseq or cts + return (primary, cts, lid) + + merged.sort(key=sort_key, reverse=True) + has_more_global = bool(has_more_any or (len(merged) > (int(offset) + int(limit)))) + page = merged[int(offset) : int(offset) + int(limit)] + if want_asc: + page = list(reversed(page)) + + return { + "status": "success", + "account": account_dir.name, + "username": username, + "total": int(offset) + len(page) + (1 if has_more_global else 0), + "hasMore": bool(has_more_global), + "messages": page, + } @app.get("/api/health", summary="健康检查端点") diff --git a/uv.lock b/uv.lock index 9bed627..fee73dd 100644 --- a/uv.lock +++ b/uv.lock @@ -281,6 +281,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/78/f9/690a8600b93c332de3ab4a344a4ac34f00c8f104917061f779db6a918ed6/pathlib-1.0.1-py3-none-any.whl", hash = "sha256:f35f95ab8b0f59e6d354090350b44a80a80635d22efdedfa84c7ad1cf0a74147", size = 14363, upload-time = "2022-05-04T13:37:20.585Z" }, ] +[[package]] +name = "pilk" +version = "0.2.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/bb/938dd697b6bc2d851ffec4ffe82ed20078e58bd0ef049e84f4d038d6f991/pilk-0.2.4.tar.gz", hash = "sha256:d4a1bcf93dc6ef5e95e0cfd728ed4ef4d49f9c0476d70816fecbe456cc762e7f", size = 226451, upload-time = "2023-05-26T03:32:34.133Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/06/80cac61bc7f791bcaae552ba90fb868f7af7503ef1c74e423cc20fca1a53/pilk-0.2.4-cp311-cp311-win_amd64.whl", hash = "sha256:a6692096607e8d77d348aeec00df633788c85b527aa83cbd803ddf508936db7f", size = 127024, upload-time = "2023-05-26T03:32:24.524Z" }, +] + [[package]] name = "psutil" version = "7.0.0" @@ -415,6 +427,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/32/56/8a7ca5d2cd2cda1d245d34b1c9a942920a718082ae8e54e5f3e5a58b7add/pydantic_core-2.33.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:329467cecfb529c925cf2bbd4d60d2c509bc2fb52a20c1045bf09bb70971a9c1", size = 2066757, upload-time = "2025-04-23T18:33:30.645Z" }, ] +[[package]] +name = "pymem" +version = "1.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/fd/1906f383fd9697c0da599580c58f5cd5af48edb55429f6ef994c447fb94e/pymem-1.14.0.tar.gz", hash = "sha256:29f6c32bcad0032888afabadf97d1e4c7757f88873de4d79f7f4c1df9b9e7ef1", size = 24890, upload-time = "2024-10-27T18:59:50.369Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/a5/23907a4b55d67cd4c0e4b9a37e32f5f1ffaf7e286dcf172a2a1bb9e12444/pymem-1.14.0-py3-none-any.whl", hash = "sha256:2b9cc64b49d0685f73d616ab1f638611f87e8d649869e7a556f050f677c42a7e", size = 29833, upload-time = "2024-10-27T18:59:48.911Z" }, +] + [[package]] name = "python-dotenv" version = "1.1.0" @@ -736,13 +757,17 @@ dependencies = [ { name = "fastapi" }, { name = "loguru" }, { name = "pathlib" }, + { name = "pilk" }, { name = "psutil" }, { name = "pycryptodome" }, + { name = "pymem" }, { name = "python-multipart" }, { name = "pywin32" }, { name = "requests" }, { name = "typing-extensions" }, { name = "uvicorn", extra = ["standard"] }, + { name = "yara-python" }, + { name = "zstandard" }, ] [package.metadata] @@ -752,13 +777,17 @@ requires-dist = [ { name = "fastapi", specifier = ">=0.104.0" }, { name = "loguru", specifier = ">=0.7.0" }, { name = "pathlib", specifier = ">=1.0.1" }, + { name = "pilk", specifier = ">=0.2.4" }, { name = "psutil", specifier = ">=7.0.0" }, { name = "pycryptodome", specifier = ">=3.23.0" }, + { name = "pymem", specifier = ">=1.14.0" }, { name = "python-multipart", specifier = ">=0.0.6" }, { name = "pywin32", specifier = ">=310" }, { name = "requests", specifier = ">=2.32.4" }, { name = "typing-extensions", specifier = ">=4.8.0" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.24.0" }, + { name = "yara-python", specifier = ">=4.5.4" }, + { name = "zstandard", specifier = ">=0.23.0" }, ] [[package]] @@ -769,3 +798,121 @@ sdist = { url = "https://files.pythonhosted.org/packages/b3/8f/705086c9d734d3b66 wheels = [ { url = "https://files.pythonhosted.org/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390", size = 4083, upload-time = "2024-12-07T15:28:26.465Z" }, ] + +[[package]] +name = "yara-python" +version = "4.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/51/38/347d1fcde4edabd338d5872ca5759ccfb95ff1cf5207dafded981fd08c4f/yara_python-4.5.4.tar.gz", hash = "sha256:4c682170f3d5cb3a73aa1bd0dc9ab1c0957437b937b7a83ff6d7ffd366415b9c", size = 551142, upload-time = "2025-05-27T14:15:49.035Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/17/f0bc4a643d8c3afb485c34b70fe1d161f3fe0459361d2eb3561d23cc16e1/yara_python-4.5.4-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:e3e2a5575d61adc2b4ff2007737590783a43d16386b061ac12e6e70a82e5d1de", size = 2471737, upload-time = "2025-05-27T14:14:23.876Z" }, + { url = "https://files.pythonhosted.org/packages/3f/11/39c74fc2732b89d4a5a6ad272b2b60ec84b3aae53b120a9eccc742eec802/yara_python-4.5.4-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:f79a27dbdafb79fc2dc03c7c3ba66751551e3e0b350ab69cc499870b78a6cb95", size = 208862, upload-time = "2025-05-27T14:14:25.941Z" }, + { url = "https://files.pythonhosted.org/packages/05/ef/ff38abffe3c5126da0b4f31471bc6df2e38f4f532a65941a1a8092cddb4f/yara_python-4.5.4-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:9d9acf6f8135bcee03f47b1096ad69f4a2788abe37dd070aab6e9dd816742ecc", size = 2478391, upload-time = "2025-05-27T14:14:27.802Z" }, + { url = "https://files.pythonhosted.org/packages/69/97/638f0f6920250dd4cc202f05d2b931c4aeb8ea916ae185cd15b9dacf9ae4/yara_python-4.5.4-cp311-cp311-macosx_15_0_x86_64.whl", hash = "sha256:0e762e6c5b47ddf30b0128ba723da46fcc2aa7959a252748497492cb452d1c84", size = 209304, upload-time = "2025-05-27T14:14:29.663Z" }, + { url = "https://files.pythonhosted.org/packages/2a/08/e9396374b8d4348f71db28dabbcbde21ceb0e68c604a4de82ab4f1c286e9/yara_python-4.5.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:adcfac4b225e76ab6dcbeaf10101f0de2731fdbee51610dbc77b96e667e85a3a", size = 2242098, upload-time = "2025-05-27T14:14:31.001Z" }, + { url = "https://files.pythonhosted.org/packages/58/fa/b159db2afedef12d5de32b6eb7c087a78c29dc51fc5111475bbd96759744/yara_python-4.5.4-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a82c87038f0da2d90051bfd6449cf9a4b977a15ee8372f3512ce0a413ef822fd", size = 2324289, upload-time = "2025-05-27T14:14:32.852Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1e/8846d6c37e3cc5328ac90d888ac60599ed62f1ffcb7d68054ad60954df4a/yara_python-4.5.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1a1721b61ee4e625a143e8e5bf32fa6774797c06724c45067f3e8919a8e5f8f3", size = 2329563, upload-time = "2025-05-27T14:14:34.758Z" }, + { url = "https://files.pythonhosted.org/packages/35/db/d6de595384e357366a8e7832876e5d9be56629e29e12d2a9325cc652e855/yara_python-4.5.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:57d80c7591bbc6d9e73934e0fa4cbbb35e3e733b2706c5fd6756edf495f42678", size = 2947661, upload-time = "2025-05-27T14:14:37.419Z" }, + { url = "https://files.pythonhosted.org/packages/b9/26/35d67095b0b000315545c59b2826166371d005f9815ca6c7c79a87e6c4cc/yara_python-4.5.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:3872b5f5575d6f5077f86e2b8bcdfe8688f859a50854334a4085399331167abc", size = 2417932, upload-time = "2025-05-27T14:14:40.402Z" }, + { url = "https://files.pythonhosted.org/packages/8f/28/760d114cea3f160e3f862d747208a09150974a668a49c0cee23a943006c4/yara_python-4.5.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:fd84af5b6da3429236b61f3ad8760fdc739d0e1d6a08b8f3d90cd375e71594df", size = 2638058, upload-time = "2025-05-27T14:14:42.141Z" }, + { url = "https://files.pythonhosted.org/packages/74/9d/e208dd3ffc38a163d18476d2f758c24de8ece67c9d59c654a6b5b400fd96/yara_python-4.5.4-cp311-cp311-win32.whl", hash = "sha256:491c9de854e4a47dfbef7b3a38686c574459779915be19dcf4421b65847a57ce", size = 1447300, upload-time = "2025-05-27T14:14:44.091Z" }, + { url = "https://files.pythonhosted.org/packages/85/ad/23ed18900f6024d5c1de567768c12a53c5759ac90d08624c87c816cd7249/yara_python-4.5.4-cp311-cp311-win_amd64.whl", hash = "sha256:2a1bf52cb7b9178cc1ee2acd1697a0c8468af0c76aa1beffe22534bd4f62698b", size = 1825566, upload-time = "2025-05-27T14:14:45.879Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cc/deaf10b6b31ee81842176affce79d57c3b6df50e894cf6cdbb1f0eb12af2/yara_python-4.5.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:ade234700c492bce0efda96c1cdcd763425016e40df4a8d30c4c4e6897be5ace", size = 2471771, upload-time = "2025-05-27T14:14:47.381Z" }, + { url = "https://files.pythonhosted.org/packages/b1/47/9227c56450be00db6a3a50ccf88ba201945ae14a34b80b3aae4607954159/yara_python-4.5.4-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:e1dedd149be61992781f085b592d169d1d813f9b5ffc7c8c2b74e429b443414c", size = 208991, upload-time = "2025-05-27T14:14:48.832Z" }, + { url = "https://files.pythonhosted.org/packages/5b/0d/1f2e054f7ddf9fd4c873fccf63a08f2647b205398e11ea75cf44c161e702/yara_python-4.5.4-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:92b233aae320ee9e59728ee23f9faf4a423ae407d4768b47c8f0e472a34dbae2", size = 2478413, upload-time = "2025-05-27T14:14:50.205Z" }, + { url = "https://files.pythonhosted.org/packages/10/ab/96e2d06c909ba07941d6d303f23624e46751a54d6fd358069275f982168c/yara_python-4.5.4-cp312-cp312-macosx_15_0_x86_64.whl", hash = "sha256:1f238f10d26e4701559f73a69b22e1e192a6fa20abdd76f57a7054566780aa89", size = 209401, upload-time = "2025-05-27T14:14:52.017Z" }, + { url = "https://files.pythonhosted.org/packages/df/7d/e51ecb0db87094976904a52eb521e3faf9c18f7c889b9d5cf996ae3bb680/yara_python-4.5.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d29d0e137e0d77dd110186369276e88381f784bdc45b5932a2fb3463e2a1b1c7", size = 2245326, upload-time = "2025-05-27T14:14:53.338Z" }, + { url = "https://files.pythonhosted.org/packages/4c/5e/fe93d8609b8470148ebbae111f209c6f208bb834cee64046fce0532fcc70/yara_python-4.5.4-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e7d0039d734705b123494acad7a00b67df171dd5b1c16ff7b18ff07578efd4cd", size = 2327041, upload-time = "2025-05-27T14:14:54.915Z" }, + { url = "https://files.pythonhosted.org/packages/52/06/104c4daa22e34a7edb49051798126c37f6280d4f1ea7e8888b043314e72d/yara_python-4.5.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e5eae935b05a9f8dc71df55a79c38f52abd93f8840310fe4e0d75fbd78284f24", size = 2332343, upload-time = "2025-05-27T14:14:56.424Z" }, + { url = "https://files.pythonhosted.org/packages/cd/38/4b788b8fe15faca08e4a52c0b3dc8787953115ce1811e7bf9439914b6a5b/yara_python-4.5.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:30fc7959394532c6e3f48faf59337f5da124f1630668258276b6cfa54e555a6e", size = 2949346, upload-time = "2025-05-27T14:14:57.924Z" }, + { url = "https://files.pythonhosted.org/packages/53/3a/c1d97172aa9672f381df78b4d3a9f60378f35431ff48f4a6c45037057e07/yara_python-4.5.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e6d8c2acaf33931338fdb78aba8a68462b0151d833b2eeda712db87713ac2abf", size = 2421057, upload-time = "2025-05-27T14:15:00.118Z" }, + { url = "https://files.pythonhosted.org/packages/ba/cc/c6366d6d047f73594badd5444f6a32501e8b20ab1a7124837d305c08b42b/yara_python-4.5.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a3c7bc8cd0db5fb87ab579c755de83723030522f3c0cd5b3374044055a8ce6c6", size = 2639871, upload-time = "2025-05-27T14:15:02.63Z" }, + { url = "https://files.pythonhosted.org/packages/e6/5c/edacbd11db432ac04a37c9e3a7c569986256d9659d1859c6f828268dfeed/yara_python-4.5.4-cp312-cp312-win32.whl", hash = "sha256:d12e57101683e9270738a1bccf676747f93e86b5bc529e7a7fb7adf94f20bd77", size = 1447403, upload-time = "2025-05-27T14:15:04.193Z" }, + { url = "https://files.pythonhosted.org/packages/ad/e1/f6a72c155f3241360da890c218911d09bf63329eca9cfa1af64b1498339b/yara_python-4.5.4-cp312-cp312-win_amd64.whl", hash = "sha256:bf14a8af06b2b980a889bdc3f9e8ccd6e703d2b3fa1c98da5fd3a1c3b551eb47", size = 1825743, upload-time = "2025-05-27T14:15:05.678Z" }, + { url = "https://files.pythonhosted.org/packages/74/7f/9bf4864fee85f86302d78d373c93793419c080222b5e18badadebb959263/yara_python-4.5.4-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:fe8ad189843c729eae74be3b8447a4753fac2cebe705e5e2a7280badfcc7e3b4", size = 2471811, upload-time = "2025-05-27T14:15:07.55Z" }, + { url = "https://files.pythonhosted.org/packages/44/58/dca36144c44b0613dbc39c70cc32ee0fc9db1ba9e5fa15f55657183f4229/yara_python-4.5.4-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:94e290d5035be23059d0475bff3eac8228acd51145bf0cabe355b1ddabab742b", size = 209044, upload-time = "2025-05-27T14:15:09.142Z" }, + { url = "https://files.pythonhosted.org/packages/8b/4c/997be6898cdda211cb601ae2af40a2dbd89a48ba9889168ddd3993636e97/yara_python-4.5.4-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:4537f8499d166d22a54739f440fb306f65b0438be2c6c4ecb2352ecb5adb5f1c", size = 2478416, upload-time = "2025-05-27T14:15:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/b6/29/91d5911d6decdd8a96bb891cacd8922569480ff1d4b47e7947b5dfc7f1d6/yara_python-4.5.4-cp313-cp313-macosx_15_0_x86_64.whl", hash = "sha256:ab5133a16e466db6fe9c1a08d1b171013507896175010fb85fc1b92da32e558c", size = 209441, upload-time = "2025-05-27T14:15:12.173Z" }, + { url = "https://files.pythonhosted.org/packages/87/9b/e21f534f33062f2e5f6dceec3cb4918f4923264b1609652adc0c83fe2bde/yara_python-4.5.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93f5f5aba88e2ed2aaebfbb697433a0c8020c6a6c6a711e900a29e9b512d5c3a", size = 2245135, upload-time = "2025-05-27T14:15:13.569Z" }, + { url = "https://files.pythonhosted.org/packages/70/8e/3618d2473f1e97f3f91d13af5b1ed26381c74444f6cdebb849eb855b21ca/yara_python-4.5.4-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:473c52b53c39d5daedc1912bd8a82a1c88702a3e393688879d77f9ff5f396543", size = 2326864, upload-time = "2025-05-27T14:15:15.321Z" }, + { url = "https://files.pythonhosted.org/packages/99/ee/21477d4c83e7f267ff7d61a518eb1b2d3eb7877031c5c07bd1dc0a54eb95/yara_python-4.5.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9d9a58b7dc87411a2443d2e0382a111bd892aef9f6db2a1ebb4a9215eef0db71", size = 2331976, upload-time = "2025-05-27T14:15:17.138Z" }, + { url = "https://files.pythonhosted.org/packages/9f/5d/2680831aa43d181a0cc023ba89132ff6f03a0532f220cde27bccd60d54e2/yara_python-4.5.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0b9de86fbe8a646c0644df9e1396d6941dc6ed0f89be2807e6c52ab39161fd9f", size = 2949066, upload-time = "2025-05-27T14:15:18.822Z" }, + { url = "https://files.pythonhosted.org/packages/9e/76/e89ec354731b1872462fa9cfdfc6d4751c272b3f43fa55523a8f0fcdd48a/yara_python-4.5.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:973f0bc24470ac86b6009baf2800ad3eadfa4ab653b6546ba5c65e9239850f47", size = 2420758, upload-time = "2025-05-27T14:15:20.505Z" }, + { url = "https://files.pythonhosted.org/packages/0c/28/9499649cb2cb42592c13ca6a15163e0cbf132c2974394de171aa5f8b49e4/yara_python-4.5.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cb0f0e7183165426b09e2b1235e70909e540ac18e2c6be96070dfe17d7db4d78", size = 2639628, upload-time = "2025-05-27T14:15:22.142Z" }, + { url = "https://files.pythonhosted.org/packages/ac/37/e8f2b9a2287070f39fa6a5afbcf1ed6762f60ab3b1fb08018732838ccc25/yara_python-4.5.4-cp313-cp313-win32.whl", hash = "sha256:7707b144c8fcdb30c069ea57b94799cd7601f694ba01b696bbd1832721f37fd0", size = 1447395, upload-time = "2025-05-27T14:15:25.237Z" }, + { url = "https://files.pythonhosted.org/packages/cc/a0/40b0291c8b24d13daf0e26538c9f3a0d843c38c6446dd17f36335bdd5b5f/yara_python-4.5.4-cp313-cp313-win_amd64.whl", hash = "sha256:5f1288448991d63c1f6351c9f6d112916b0177ceefaa27d1419427a6ff09f829", size = 1825779, upload-time = "2025-05-27T14:15:26.802Z" }, +] + +[[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/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" }, +]
{{ contact.lastMessage }}
{{ message.amount }}
{{ message.title }}
+ 请选择一个联系人查看聊天记录 +