91 lines
4.5 KiB
Python
91 lines
4.5 KiB
Python
#!/usr/bin/env python3
|
|
"""Read-only Mac WeChat storage inventory. Does not decrypt or read messages."""
|
|
import argparse
|
|
import json
|
|
import os
|
|
import platform
|
|
import plistlib
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
|
|
def probe(roots=None, max_files=300):
|
|
home = Path.home()
|
|
default_roots = [
|
|
home / 'Library/Containers/com.tencent.xinWeChat/Data/Documents',
|
|
home / 'Library/Containers/com.tencent.xinWeChat/Data/Library/Application Support/com.tencent.xinWeChat',
|
|
home / 'Documents/xwechat_files',
|
|
]
|
|
report = {'created_utc': datetime.now(timezone.utc).isoformat(), 'platform': platform.system(),
|
|
'architecture': platform.machine(), 'wechat_versions': [], 'roots': [], 'databases': [], 'warnings': [],
|
|
'notes': ['只读取文件名、大小、修改时间和前 16 字节;不读取消息正文、不获取密钥、不修改微信文件。',
|
|
'检测到 SQLite 文件头不代表消息内容已解码或可用。',
|
|
'未覆盖的自定义存储位置可通过 --root 指定。没有找到不代表没有记录。']}
|
|
for app in (Path('/Applications/WeChat.app'), home / 'Applications/WeChat.app'):
|
|
info = app / 'Contents/Info.plist'
|
|
if info.is_file():
|
|
try:
|
|
with info.open('rb') as file:
|
|
data = plistlib.load(file)
|
|
report['wechat_versions'].append({'app': str(app), 'version': data.get('CFBundleShortVersionString'), 'build': data.get('CFBundleVersion')})
|
|
except (OSError, ValueError, plistlib.InvalidFileException):
|
|
report['warnings'].append('无法读取应用版本:' + str(info))
|
|
visited = set()
|
|
def error(exc):
|
|
report['warnings'].append('无法访问目录(可能需要文件访问权限):' + str(exc.filename))
|
|
for root in roots or default_roots:
|
|
root = Path(root).expanduser().absolute()
|
|
report['roots'].append({'path': str(root), 'exists': root.exists()})
|
|
if not root.exists():
|
|
continue
|
|
for directory, dirs, files in os.walk(root, followlinks=False, onerror=error):
|
|
relative = Path(directory).relative_to(root)
|
|
dirs[:] = [d for d in dirs if not (Path(directory)/d).is_symlink() and d not in ('Cache', 'Caches', 'FileStorage', 'video', 'image', 'temp')]
|
|
if len(relative.parts) >= 8:
|
|
dirs[:] = []
|
|
for name in files:
|
|
if not name.lower().endswith(('.db', '.sqlite', '.sqlite3')):
|
|
continue
|
|
path = Path(directory) / name
|
|
if path.is_symlink() or str(path) in visited:
|
|
continue
|
|
visited.add(str(path))
|
|
if len(report['databases']) >= max_files:
|
|
report['warnings'].append(f'达到 {max_files} 个数据库上限,报告不完整。')
|
|
return report
|
|
try:
|
|
stat = path.stat()
|
|
with path.open('rb') as file:
|
|
header = file.read(16)
|
|
report['databases'].append({'path': str(path), 'size': stat.st_size,
|
|
'modified_utc': datetime.fromtimestamp(stat.st_mtime, timezone.utc).isoformat(),
|
|
'format': 'sqlite_header' if header == b'SQLite format 3\x00' else 'encrypted_or_unknown',
|
|
'wal_present': Path(str(path) + '-wal').exists()})
|
|
except OSError:
|
|
report['warnings'].append('无法读取数据库头:' + str(path))
|
|
return report
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument('--root', action='append', help='额外/指定存储根目录,可重复;指定后替代默认目录')
|
|
parser.add_argument('--output', type=Path, default=Path('mac-wechat-diagnostic.json'))
|
|
args = parser.parse_args()
|
|
if platform.system() != 'Darwin' and not args.root:
|
|
parser.error('请在目标 Mac 上运行。测试时可显式指定 --root。')
|
|
report = probe(args.root)
|
|
# Exclusive creation: never overwrite an existing user file or database.
|
|
with args.output.open('x', encoding='utf-8') as file:
|
|
json.dump(report, file, ensure_ascii=False, indent=2)
|
|
try:
|
|
args.output.chmod(0o600)
|
|
except OSError:
|
|
pass
|
|
print('Diagnostic written to:', args.output.resolve())
|
|
print('Database files:', len(report['databases']))
|
|
print('Report contains local paths. Review before sharing.')
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|