feat: add page-driven local Mac WeChat connection wizard
This commit is contained in:
parent
e2f0cd17ea
commit
9edc59348b
3
.gitignore
vendored
3
.gitignore
vendored
@ -7,3 +7,6 @@ dist/
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
mac-wechat-diagnostic*.json
|
mac-wechat-diagnostic*.json
|
||||||
/artifacts/
|
/artifacts/
|
||||||
|
keys.json
|
||||||
|
all_keys.json
|
||||||
|
*_keys.json
|
||||||
|
|||||||
12
README.md
12
README.md
@ -2,13 +2,19 @@
|
|||||||
|
|
||||||
一个本地运行的 Python + SQLite 工具。从群聊和私聊里发现反复提问、风险/事故、重复踩坑、后续复用线索,将其整理为带原文证据的知识需求卡,对应 CREATES 的 Choose → Retrieve → Extract / Validate。
|
一个本地运行的 Python + SQLite 工具。从群聊和私聊里发现反复提问、风险/事故、重复踩坑、后续复用线索,将其整理为带原文证据的知识需求卡,对应 CREATES 的 Choose → Retrieve → Extract / Validate。
|
||||||
|
|
||||||
|
## 页面连接 Mac 微信(v0.2)
|
||||||
|
|
||||||
|
新增“连接 Mac 微信”向导:扫描账号 → 读取本机密钥 → 退出微信后备份、解密、解析并导入。密钥仅存运行内存;权限不足或版本不匹配会显示具体失败原因。首次仍需在 Mac 启动本地服务,之后通过页面操作。
|
||||||
|
|
||||||
|
**接入属于实验性功能,尚未在目标 Intel Mac 微信 4.1.0.18 实机验证,不保证系统允许读取密钥。** 使用流程、权限与格式限制见 [Mac 接入说明](docs/mac-connection.md)。
|
||||||
|
|
||||||
## 交付状态
|
## 交付状态
|
||||||
|
|
||||||
- 已实现:可读文件导入、明文 SQLite 表导入、去重和错误行报告、覆盖统计、时间与会话筛选、本地规则分析、脱敏预览、兼容模型 API、后台任务、原文回溯、人工修正、主题合并/拆分、CSV / Markdown 导出。
|
- 已实现:可读文件导入、明文 SQLite 表导入、去重和错误行报告、覆盖统计、时间与会话筛选、本地规则分析、脱敏预览、兼容模型 API、后台任务、原文回溯、人工修正、主题合并/拆分、CSV / Markdown 导出。
|
||||||
- 已验证:Windows / Python 3.11 的自动化测试;浏览器端样例操作;本机模拟 API 的完整脱敏与证据校验流程。
|
- 已验证:Windows / Python 3.11 的自动化测试;浏览器端样例操作;本机模拟 API 的完整脱敏与证据校验流程。
|
||||||
- **尚未完成:Mac 微信 4.1.0.18 加密数据库的直接读取与解密。** 当前执行环境是 Windows,未连接目标 Mac;未声称或验证该版本的解密兼容性。应用不能仅凭选择微信目录就自动获取全部聊天。使用真实数据前,仍需在目标 Mac 取得可读导出。
|
- 已实现实验性 Mac 原生接入:本机密钥读取、备份、SQLCipher 4 raw-key 解密、WAL 回放与部分微信 4.x 结构解析。**尚未完成:目标 Intel Mac 微信 4.1.0.18 实机验收。** 系统拒绝读取密钥或格式不匹配时会停止,不能承诺任意版本均可一键获取全部聊天。
|
||||||
- 已提供 Mac 只读诊断脚本,帮助确认版本、CPU、数据位置、SQLite / 加密文件头与 WAL 状态;不获取密钥,不修改微信数据或系统保护设置。
|
- 已提供 Mac 只读诊断脚本,帮助确认版本、CPU、数据位置、SQLite / 加密文件头与 WAL 状态;不获取密钥,不修改微信数据或系统保护设置。
|
||||||
- Intel / Apple Silicon 使用相同 Python 分析代码;本版无需 pip 安装第三方依赖。
|
- Intel / Apple Silicon 使用相同 Python 分析代码;基础分析与原生 AES 无需 pip 依赖;压缩微信正文需要在页面安装可选 Zstandard 支持。
|
||||||
|
|
||||||
## 在 Mac 上启动
|
## 在 Mac 上启动
|
||||||
|
|
||||||
@ -90,7 +96,7 @@ CSV 表头使用同名字段,见 `examples/template.csv`。导入界面可填
|
|||||||
- 唯一消息 ID 按会话去重。没有消息 ID 时,用会话、发送人、时间、内容、类型、引用去重;真实同秒同内容消息可能合并,结果会提示。不要混合多个账号但复用同样的会话/消息 ID;如需多账号,请使用不同 `--data-dir`。
|
- 唯一消息 ID 按会话去重。没有消息 ID 时,用会话、发送人、时间、内容、类型、引用去重;真实同秒同内容消息可能合并,结果会提示。不要混合多个账号但复用同样的会话/消息 ID;如需多账号,请使用不同 `--data-dir`。
|
||||||
- 发送人缺失、文本内容为空、时间无效、BLOB 等会拒绝对应行,并返回错误行号,其他有效行仍会导入。
|
- 发送人缺失、文本内容为空、时间无效、BLOB 等会拒绝对应行,并返回错误行号,其他有效行仍会导入。
|
||||||
- 非文字内容保留类型与占位文本,不自动 OCR、转写语音或解析附件。
|
- 非文字内容保留类型与占位文本,不自动 OCR、转写语音或解析附件。
|
||||||
- 不支持未经转换的 HTML、任意 TXT 布局、微信官方备份包、加密数据库。拒绝这些格式,避免把乱码当消息。
|
- 文件导入页不支持未经转换的 HTML、任意 TXT 布局、微信官方备份包或加密数据库;原生加密库请使用“连接 Mac 微信”向导。拒绝未知格式,避免把乱码当消息。
|
||||||
|
|
||||||
TXT 支持:
|
TXT 支持:
|
||||||
|
|
||||||
|
|||||||
36
docs/mac-connection.md
Normal file
36
docs/mac-connection.md
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
# 页面连接 Mac 微信(v0.2,实验性)
|
||||||
|
|
||||||
|
在安装微信的目标 Mac 上更新代码,重新启动 `python3 run.py`,浏览器打开本机地址,进入“连接 Mac 微信”。此后无需手动运行诊断或逐个导出数据库。
|
||||||
|
|
||||||
|
## 页面操作
|
||||||
|
|
||||||
|
1. **扫描本机微信**:发现 `xwechat_files/<账号>/db_storage`,显示消息分片、联系人库与密钥匹配数量。自定义位置点“选择数据文件夹”。旧版 3.x 数据不会自动混入。
|
||||||
|
2. **读取本机密钥**:先登录微信并打开目标聊天,再点击读取。普通权限失败时,可主动点击管理员按钮,使用 macOS 系统密码框授权;工具不保存密码。系统仍可能拒绝访问,授权并不保证成功。已有密钥时可选择本机 JSON 文件。
|
||||||
|
3. **安装文本解压支持**:需要 Zstandard 压缩正文时点击。通过 pip 从 PyPI 安装固定版本 `zstandard==0.23.0` wheel 到 `data/mac_deps`,不全局安装、不发送聊天;需要网络和可用的 pip。
|
||||||
|
4. **备份并导入**:先正常退出微信,保持知微运行,再点击。工具将所需数据库、WAL/SHM 复制到 `data/mac_sources/<任务>/backup`,可读库保存在同任务 `readable`。请预留至少约四倍所选数据库大小的磁盘空间。
|
||||||
|
5. 成功后进入“发现知识需求”,选择时间段、全部会话或指定群聊/私聊。业务消息分片默认不导入,需要时单独勾选。
|
||||||
|
|
||||||
|
浏览器与本地服务须运行在安装微信的同一台 Mac;Windows 页面不能读取另一台 Mac 的微信。不需要向开发者上传数据库、聊天或密钥。
|
||||||
|
|
||||||
|
## 实际边界
|
||||||
|
|
||||||
|
**尚未通过 Intel Mac 微信 4.1.0.18 的实机验收。** 开发环境是 Windows;合成数据库与模拟测试无法证明真实微信版本可用。
|
||||||
|
|
||||||
|
- 密钥扫描通过 macOS Mach 只读接口,验证目标进程路径为 `WeChat.app/Contents/MacOS/WeChat`。最多扫描 45 秒/2 GiB 的可读可写非执行内存,仅筛选 SQLCipher 格式的 64/96 位十六进制候选,不保存内存转储。
|
||||||
|
- 默认普通用户读取;只有用户点击管理员按钮才触发系统授权。微信签名与 macOS 保护仍可能拒绝附加。工具不重签微信、不注入代码、不关闭 SIP,也不保证取得密钥。失败在页面说明,可导入已有本地密钥继续。
|
||||||
|
- 密钥只在进程内存,状态 API 只返回匹配数量,不回传密钥;重启后需重新读取/导入。密钥不进入模型请求、日志、Git 或发布包。
|
||||||
|
- 加密读取仅支持 SQLCipher 4 常见 raw AES-256 key、4096 字节页、80 字节保留区、SHA512 页认证与 little-endian 页编号。其他参数明确失败,不跳过认证。
|
||||||
|
- 逐页验证 HMAC 后解密;WAL 校验头、salt 与滚动校验和,仅回放到最后一次提交,不回放已校验但未提交的事务。截断、损坏或格式不匹配会停止;可读输出还会通过 SQLite `quick_check`。
|
||||||
|
- 解析支持 `Msg_<MD5>` / `Chat_<MD5>`、`Name2Id`、常见联系人字段,和 `create_time/message_content/local_type/real_sender_id` 结构。支持 UTF-8、可选 Zstandard 与部分 XML 引用,不保证覆盖所有版本变体。
|
||||||
|
- 无法关联会话或发送人时整次停止。消息先写独立 staging 库,全部解析成功后才用单次事务并入主库,不保留半次导入。副本保留用于本机排查。
|
||||||
|
- 图片/附件仅保留类型与可读描述,不自动 OCR 或转写。搜索索引、资源库和旧版库不当作消息正文重复导入。
|
||||||
|
- 本地副本和可读库是敏感文件,保存在受限数据目录并被 Git 忽略。任务状态和密钥均在内存;退出 Python 会中断任务,不自动恢复或重试管理员操作。
|
||||||
|
|
||||||
|
## 实现参考
|
||||||
|
|
||||||
|
实现为本项目原创代码,使用本机加密与进程 API,不依赖已下架的微信提取工具。
|
||||||
|
|
||||||
|
- [SQLCipher API:raw key 与密钥验证](https://www.zetetic.net/sqlcipher/sqlcipher-api/)
|
||||||
|
- [SQLCipher 页认证与参数实现](https://github.com/sqlcipher/sqlcipher/blob/master/src/sqlcipher.c)
|
||||||
|
- [SQLite WAL 文件格式](https://www.sqlite.org/fileformat2.html#walformat)
|
||||||
|
- [Apple Mach 内存区域结构](https://github.com/apple-oss-distributions/xnu/blob/main/osfmk/mach/vm_region.h)
|
||||||
@ -5,11 +5,11 @@ from pathlib import Path
|
|||||||
|
|
||||||
ROOT = Path(__file__).resolve().parent.parent
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
FILES = ['run.py', 'README.md', '.gitignore', '.gitattributes', 'start-mac.command', 'start-windows.cmd']
|
FILES = ['run.py', 'README.md', '.gitignore', '.gitattributes', 'start-mac.command', 'start-windows.cmd']
|
||||||
DIRS = ['wechat_tool', 'scripts', 'examples', 'tests']
|
DIRS = ['wechat_tool', 'scripts', 'examples', 'tests', 'docs']
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
out = ROOT / 'dist' / 'zhiwei-wechat-analysis-v0.1.zip'
|
out = ROOT / 'dist' / 'zhiwei-wechat-analysis-v0.2.zip'
|
||||||
out.parent.mkdir(exist_ok=True)
|
out.parent.mkdir(exist_ok=True)
|
||||||
paths = [ROOT / p for p in FILES]
|
paths = [ROOT / p for p in FILES]
|
||||||
for directory in DIRS:
|
for directory in DIRS:
|
||||||
|
|||||||
213
tests/test_mac_connect.py
Normal file
213
tests/test_mac_connect.py
Normal file
@ -0,0 +1,213 @@
|
|||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import struct
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from wechat_tool.cipher import (PAGE, RESERVE, aes_cbc, decrypt_database,
|
||||||
|
hmac_key, matches, wal_checksum)
|
||||||
|
from wechat_tool.mac_connect import MacConnection, parse_keys
|
||||||
|
from wechat_tool.wechat_native import message_rows
|
||||||
|
from wechat_tool import store
|
||||||
|
|
||||||
|
|
||||||
|
def sqlite_fixture(text):
|
||||||
|
db = sqlite3.connect(':memory:')
|
||||||
|
db.execute('PRAGMA page_size=4096')
|
||||||
|
db.execute('CREATE TABLE t (value TEXT)')
|
||||||
|
db.execute('INSERT INTO t VALUES (?)', (text,))
|
||||||
|
data = db.serialize()
|
||||||
|
db.close()
|
||||||
|
return reserved_pages(data)
|
||||||
|
|
||||||
|
|
||||||
|
def reserved_pages(data):
|
||||||
|
pages = []
|
||||||
|
# Repack these small, single-leaf fixture pages with SQLCipher's reserved tail.
|
||||||
|
for index in range(0, len(data), PAGE):
|
||||||
|
page = bytearray(data[index:index+PAGE])
|
||||||
|
offset = 100 if index == 0 else 0
|
||||||
|
assert page[offset] == 13 # no overflow, free blocks or internal nodes
|
||||||
|
count = int.from_bytes(page[offset+3:offset+5], 'big')
|
||||||
|
start = int.from_bytes(page[offset+5:offset+7], 'big') or PAGE
|
||||||
|
cells = bytes(page[start:])
|
||||||
|
page[start-RESERVE:PAGE-RESERVE] = cells
|
||||||
|
page[PAGE-RESERVE:] = bytes(RESERVE)
|
||||||
|
page[offset+5:offset+7] = (start-RESERVE).to_bytes(2, 'big')
|
||||||
|
for i in range(count):
|
||||||
|
pos = offset+8+2*i
|
||||||
|
value = int.from_bytes(page[pos:pos+2], 'big')
|
||||||
|
page[pos:pos+2] = (value-RESERVE).to_bytes(2, 'big')
|
||||||
|
if not index:
|
||||||
|
page[20] = RESERVE
|
||||||
|
pages.append(bytes(page))
|
||||||
|
return pages
|
||||||
|
|
||||||
|
|
||||||
|
def encrypted_page(plain, number, key, salt):
|
||||||
|
start = 16 if number == 1 else 0
|
||||||
|
iv = os.urandom(16)
|
||||||
|
ciphertext = aes_cbc(plain[start:-RESERVE], key, iv, True)
|
||||||
|
mac = hmac.new(hmac_key(key, salt), ciphertext+iv+struct.pack('<I', number), hashlib.sha512).digest()
|
||||||
|
return (salt if number == 1 else b'') + ciphertext + iv + mac
|
||||||
|
|
||||||
|
|
||||||
|
def native_fixture(root, invalid=False):
|
||||||
|
(root/'contact').mkdir(parents=True)
|
||||||
|
(root/'message').mkdir()
|
||||||
|
group = 'test-group@chatroom'
|
||||||
|
db = sqlite3.connect(root/'contact/contact.db')
|
||||||
|
db.execute('CREATE TABLE contact (username TEXT, nick_name TEXT, remark TEXT)')
|
||||||
|
db.executemany('INSERT INTO contact VALUES (?,?,?)', [(group,'测试群',''),('sender-a','张工','张工程师')])
|
||||||
|
db.commit()
|
||||||
|
db.close()
|
||||||
|
table = 'Msg_'+hashlib.md5(group.encode()).hexdigest()
|
||||||
|
db = sqlite3.connect(root/'message/message_0.db')
|
||||||
|
db.execute('CREATE TABLE Name2Id (user_name TEXT)')
|
||||||
|
db.executemany('INSERT INTO Name2Id VALUES (?)', [(group,),('sender-a',)])
|
||||||
|
db.execute('CREATE TABLE '+table+' (local_id INTEGER, server_id INTEGER, create_time INTEGER, message_content, local_type INTEGER, real_sender_id INTEGER)')
|
||||||
|
db.execute('INSERT INTO '+table+' VALUES (1,1001,1790816400,?,1,2)', ('测试比例怎么确定?',))
|
||||||
|
if invalid:
|
||||||
|
db.execute('INSERT INTO '+table+' VALUES (2,1002,1790816401,?,1,999)', ('不能错误归属',))
|
||||||
|
db.commit()
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
class CipherTests(unittest.TestCase):
|
||||||
|
def test_native_aes_nist_vector(self):
|
||||||
|
key = bytes.fromhex('603deb1015ca71be2b73aef0857d77811f352c073b6108d72d9810a30914dff4')
|
||||||
|
iv = bytes.fromhex('000102030405060708090a0b0c0d0e0f')
|
||||||
|
plain = bytes.fromhex('6bc1bee22e409f96e93d7e117393172a')
|
||||||
|
expected = bytes.fromhex('f58c4c04d6e5f1ba779eabfb5f7bfbd6')
|
||||||
|
self.assertEqual(aes_cbc(plain,key,iv,True), expected)
|
||||||
|
self.assertEqual(aes_cbc(expected,key,iv), plain)
|
||||||
|
|
||||||
|
def test_authenticated_decryption_and_corruption(self):
|
||||||
|
key, salt = os.urandom(32), os.urandom(16)
|
||||||
|
pages = sqlite_fixture('正确内容')
|
||||||
|
data = b''.join(encrypted_page(p,i,key,salt) for i,p in enumerate(pages,1))
|
||||||
|
self.assertTrue(matches(data[:PAGE], key))
|
||||||
|
self.assertFalse(matches(data[:PAGE], os.urandom(32)))
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
src, dst = Path(directory)/'source.db', Path(directory)/'readable.db'
|
||||||
|
src.write_bytes(data)
|
||||||
|
decrypt_database(src,dst,key)
|
||||||
|
db=sqlite3.connect(dst)
|
||||||
|
self.assertEqual(db.execute('SELECT value FROM t').fetchone()[0], '正确内容')
|
||||||
|
db.close()
|
||||||
|
self.assertEqual(src.read_bytes(),data)
|
||||||
|
broken=bytearray(data);broken[PAGE+30]^=1;src.write_bytes(broken)
|
||||||
|
other=Path(directory)/'broken.db'
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
decrypt_database(src,other,key)
|
||||||
|
self.assertFalse(other.exists())
|
||||||
|
with self.assertRaises(FileExistsError):
|
||||||
|
src.write_bytes(data)
|
||||||
|
decrypt_database(src,dst,key)
|
||||||
|
self.assertTrue(dst.exists())
|
||||||
|
|
||||||
|
def test_wal_replays_only_commits_and_rejects_bad_checksum(self):
|
||||||
|
key,salt=os.urandom(32),os.urandom(16)
|
||||||
|
before,after,last=sqlite_fixture('before'),sqlite_fixture('committed'),sqlite_fixture('uncommitted')
|
||||||
|
main=b''.join(encrypted_page(p,i,key,salt) for i,p in enumerate(before,1))
|
||||||
|
header=struct.pack('>IIIIII',0x377f0682,3007000,PAGE,0,123,456)
|
||||||
|
checksum=wal_checksum(header)
|
||||||
|
wal=bytearray(header+struct.pack('>II',*checksum))
|
||||||
|
for number,page in list(enumerate(after,1))+[(2,last[1])]:
|
||||||
|
count=len(after) if len(wal)==32+(len(after)-1)*(PAGE+24) else 0
|
||||||
|
payload=encrypted_page(page,number,key,salt)
|
||||||
|
first=struct.pack('>II',number,count)
|
||||||
|
checksum=wal_checksum(first+payload,checksum)
|
||||||
|
wal.extend(first+struct.pack('>II',123,456)+struct.pack('>II',*checksum)+payload)
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
src,dst=Path(directory)/'source.db',Path(directory)/'out.db'
|
||||||
|
src.write_bytes(main);walpath=Path(str(src)+'-wal');walpath.write_bytes(wal)
|
||||||
|
result=decrypt_database(src,dst,key)
|
||||||
|
self.assertEqual(result['wal_frames'],len(after))
|
||||||
|
db=sqlite3.connect(dst)
|
||||||
|
self.assertEqual(db.execute('SELECT value FROM t').fetchone()[0],'committed')
|
||||||
|
db.close()
|
||||||
|
wal[-1]^=1;walpath.write_bytes(wal)
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
decrypt_database(src,Path(directory)/'bad.db',key)
|
||||||
|
|
||||||
|
|
||||||
|
class NativeTests(unittest.TestCase):
|
||||||
|
def test_encrypted_native_pipeline(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root=Path(directory)/'source'/'account'/'db_storage'
|
||||||
|
native_fixture(root)
|
||||||
|
keys, originals = [], {}
|
||||||
|
for path in MacConnection.files(root):
|
||||||
|
key,salt=os.urandom(32),os.urandom(16)
|
||||||
|
keys.append(key)
|
||||||
|
pages=reserved_pages(path.read_bytes())
|
||||||
|
data=b''.join(encrypted_page(p,i,key,salt) for i,p in enumerate(pages,1))
|
||||||
|
path.write_bytes(data)
|
||||||
|
originals[path]=data
|
||||||
|
service=MacConnection(Path(directory)/'workspace');service.job={'status':'running'}
|
||||||
|
with patch('wechat_tool.mac_connect.wechat_pid',return_value=None):
|
||||||
|
result=service.import_account(root,keys,False)
|
||||||
|
self.assertEqual(result['added'],1)
|
||||||
|
for path,data in originals.items():
|
||||||
|
self.assertEqual(path.read_bytes(),data)
|
||||||
|
db=store.connect(service.data)
|
||||||
|
self.assertEqual(db.execute('SELECT sender FROM messages').fetchone()[0],'张工程师')
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
def test_key_json_variants_never_return_raw_keys(self):
|
||||||
|
key='ab'*32
|
||||||
|
self.assertEqual(parse_keys({'a':{'key_hex':key},'b':{'key':"x'"+key+'cd'*16+"'"}}),[bytes.fromhex(key)])
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
parse_keys({'password':'微信密码不是密钥'})
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
service=MacConnection(directory)
|
||||||
|
state=service.load_keys({'key':key})
|
||||||
|
self.assertTrue(state['keys_ready'])
|
||||||
|
self.assertNotIn(key,json.dumps(state))
|
||||||
|
self.assertEqual(list(Path(directory).rglob('*')),[])
|
||||||
|
|
||||||
|
def test_native_mapping_and_atomic_idempotent_import(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root=Path(directory)/'source'/'account'/'db_storage'
|
||||||
|
native_fixture(root)
|
||||||
|
rows=list(message_rows(root,'account'))
|
||||||
|
self.assertEqual(rows[0]['sender'],'张工程师')
|
||||||
|
self.assertEqual(rows[0]['kind'],'group')
|
||||||
|
self.assertEqual(rows[0]['conversation'],'测试群')
|
||||||
|
service=MacConnection(Path(directory)/'workspace')
|
||||||
|
service.job={'status':'running'}
|
||||||
|
with patch('wechat_tool.mac_connect.wechat_pid',return_value=None):
|
||||||
|
one=service.import_account(root,[],False)
|
||||||
|
two=service.import_account(root,[],False)
|
||||||
|
self.assertEqual((one['added'],two['duplicates']),(1,1))
|
||||||
|
db=store.connect(service.data)
|
||||||
|
self.assertEqual(db.execute('SELECT COUNT(*) FROM messages').fetchone()[0],1)
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
def test_unknown_identity_aborts_entire_import(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root=Path(directory)/'source'/'account'/'db_storage'
|
||||||
|
native_fixture(root,True)
|
||||||
|
service=MacConnection(Path(directory)/'workspace');service.job={'status':'running'}
|
||||||
|
with patch('wechat_tool.mac_connect.wechat_pid',return_value=None):
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
service.import_account(root,[],False)
|
||||||
|
db=store.connect(service.data)
|
||||||
|
self.assertEqual(db.execute('SELECT COUNT(*) FROM messages').fetchone()[0],0)
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
def test_running_wechat_and_non_mac_are_actionable(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
service=MacConnection(directory)
|
||||||
|
with patch('wechat_tool.mac_connect.wechat_pid',return_value=123):
|
||||||
|
with self.assertRaisesRegex(ValueError,'退出微信'):
|
||||||
|
service.import_account(Path(directory),[],False)
|
||||||
|
with patch('wechat_tool.mac_connect.platform.system',return_value='Windows'):
|
||||||
|
with self.assertRaisesRegex(ValueError,'目标 Mac'):
|
||||||
|
service.scan()
|
||||||
@ -224,6 +224,22 @@ class HttpTests(unittest.TestCase):
|
|||||||
with self.assertRaises(urllib.error.HTTPError):
|
with self.assertRaises(urllib.error.HTTPError):
|
||||||
self.request('/api/demo', {}, {'Origin': 'https://evil.example'})
|
self.request('/api/demo', {}, {'Origin': 'https://evil.example'})
|
||||||
|
|
||||||
|
def test_mac_routes_hide_keys_and_reject_untrusted_paths(self):
|
||||||
|
state = self.request('/api/mac/status')
|
||||||
|
self.assertEqual(state['accounts'], [])
|
||||||
|
key = 'ab' * 32
|
||||||
|
state = self.request('/api/mac/keys', {'keys': {'key_hex': key}})
|
||||||
|
self.assertTrue(state['keys_ready'])
|
||||||
|
self.assertNotIn(key, json.dumps(state))
|
||||||
|
with self.assertRaises(urllib.error.HTTPError) as error:
|
||||||
|
self.request('/api/mac/start', {'action': 'import', 'account': '../../outside'})
|
||||||
|
self.assertEqual(error.exception.code, 400)
|
||||||
|
with self.assertRaises(urllib.error.HTTPError):
|
||||||
|
self.request('/api/mac/keys', {'keys': {'key_hex': key}}, {'X-Workbench-Token': ''})
|
||||||
|
db = self.app.db()
|
||||||
|
self.assertNotIn(key, '\n'.join(db.iterdump()))
|
||||||
|
db.close()
|
||||||
|
|
||||||
def test_complete_local_review_split_merge_export(self):
|
def test_complete_local_review_split_merge_export(self):
|
||||||
self.request('/api/demo', {})
|
self.request('/api/demo', {})
|
||||||
run = self.request('/api/analyze', {'filters': {'start': '2026-10-01', 'end': '2026-10-31'}, 'cross': True})
|
run = self.request('/api/analyze', {'filters': {'start': '2026-10-01', 'end': '2026-10-31'}, 'cross': True})
|
||||||
|
|||||||
184
wechat_tool/cipher.py
Normal file
184
wechat_tool/cipher.py
Normal file
@ -0,0 +1,184 @@
|
|||||||
|
"""SQLCipher 4 raw-key reader. Native AES; authenticated pages and WAL commits.
|
||||||
|
|
||||||
|
Only 4096-byte pages / 80 reserved bytes / SHA512 / raw AES keys are supported.
|
||||||
|
No source database is opened for writing. Unknown formats fail closed.
|
||||||
|
"""
|
||||||
|
import ctypes as C
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import os
|
||||||
|
import platform
|
||||||
|
import sqlite3
|
||||||
|
import struct
|
||||||
|
from functools import lru_cache
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
PAGE = 4096
|
||||||
|
RESERVE = 80
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=1)
|
||||||
|
def common_crypto():
|
||||||
|
lib = C.CDLL('/usr/lib/system/libcommonCrypto.dylib')
|
||||||
|
fn = lib.CCCrypt
|
||||||
|
fn.argtypes = [C.c_uint, C.c_uint, C.c_uint, C.c_void_p, C.c_size_t,
|
||||||
|
C.c_void_p, C.c_void_p, C.c_size_t, C.c_void_p, C.c_size_t, C.POINTER(C.c_size_t)]
|
||||||
|
return fn
|
||||||
|
|
||||||
|
|
||||||
|
def aes_cbc(data, key, iv, encrypt=False):
|
||||||
|
if len(key) != 32 or len(iv) != 16 or len(data) % 16:
|
||||||
|
raise ValueError('无效的 AES 数据长度')
|
||||||
|
output = C.create_string_buffer(len(data) + 16)
|
||||||
|
if platform.system() == 'Darwin':
|
||||||
|
fn = common_crypto()
|
||||||
|
moved = C.c_size_t()
|
||||||
|
code = fn(0 if encrypt else 1, 0, 0, key, len(key), iv, data, len(data), output, len(output), C.byref(moved))
|
||||||
|
if code or moved.value != len(data):
|
||||||
|
raise ValueError('本机 AES 解密失败')
|
||||||
|
return output.raw[:moved.value]
|
||||||
|
if platform.system() == 'Windows':
|
||||||
|
# Windows native crypto also permits fixture tests without pip packages.
|
||||||
|
lib = C.WinDLL('bcrypt')
|
||||||
|
handle, key_handle = C.c_void_p(), C.c_void_p()
|
||||||
|
def check(result):
|
||||||
|
if result:
|
||||||
|
raise ValueError('本机 AES 操作失败')
|
||||||
|
check(lib.BCryptOpenAlgorithmProvider(C.byref(handle), C.c_wchar_p('AES'), None, 0))
|
||||||
|
try:
|
||||||
|
mode = C.create_unicode_buffer('ChainingModeCBC')
|
||||||
|
check(lib.BCryptSetProperty(handle, C.c_wchar_p('ChainingMode'), mode, C.sizeof(mode), 0))
|
||||||
|
length, written = C.c_ulong(), C.c_ulong()
|
||||||
|
check(lib.BCryptGetProperty(handle, C.c_wchar_p('ObjectLength'), C.byref(length), 4, C.byref(written), 0))
|
||||||
|
obj = C.create_string_buffer(length.value)
|
||||||
|
check(lib.BCryptGenerateSymmetricKey(handle, C.byref(key_handle), obj, length.value, key, len(key), 0))
|
||||||
|
ivbuf = C.create_string_buffer(iv, 16)
|
||||||
|
fn = lib.BCryptEncrypt if encrypt else lib.BCryptDecrypt
|
||||||
|
check(fn(key_handle, data, len(data), None, ivbuf, 16, output, len(output), C.byref(written), 0))
|
||||||
|
return output.raw[:written.value]
|
||||||
|
finally:
|
||||||
|
if key_handle.value:
|
||||||
|
lib.BCryptDestroyKey(key_handle)
|
||||||
|
lib.BCryptCloseAlgorithmProvider(handle, 0)
|
||||||
|
raise ValueError('原生解密目前支持 macOS / Windows')
|
||||||
|
|
||||||
|
|
||||||
|
def hmac_key(key, salt):
|
||||||
|
return hashlib.pbkdf2_hmac('sha512', key, bytes(x ^ 0x3a for x in salt), 2, 32)
|
||||||
|
|
||||||
|
|
||||||
|
def decrypt_page(page, number, key, mac_key):
|
||||||
|
if len(page) != PAGE:
|
||||||
|
raise ValueError('数据库页大小不匹配(仅支持 SQLCipher 4 / 4096)')
|
||||||
|
if not any(page) and number != 1:
|
||||||
|
return page
|
||||||
|
start = 16 if number == 1 else 0
|
||||||
|
payload, iv = page[start:-RESERVE], page[-RESERVE:-64]
|
||||||
|
expected = hmac.new(mac_key, payload + iv + struct.pack('<I', number), hashlib.sha512).digest()
|
||||||
|
if not hmac.compare_digest(expected, page[-64:]):
|
||||||
|
raise ValueError('页认证失败:密钥、格式不匹配或副本损坏;没有跳过错误页')
|
||||||
|
plain = aes_cbc(payload, key, iv)
|
||||||
|
return (b'SQLite format 3\x00' if number == 1 else b'') + plain + bytes(RESERVE)
|
||||||
|
|
||||||
|
|
||||||
|
def matches(first_page, key):
|
||||||
|
try:
|
||||||
|
plain = decrypt_page(first_page, 1, key, hmac_key(key, first_page[:16]))
|
||||||
|
return plain[16:18] == b'\x10\x00' and plain[20] == RESERVE
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def wal_checksum(data, initial=(0, 0), endian='<'):
|
||||||
|
if len(data) % 8:
|
||||||
|
raise ValueError('WAL 校验数据长度错误')
|
||||||
|
a, b = initial
|
||||||
|
for x, y in struct.iter_unpack(endian + 'II', data):
|
||||||
|
a = (a + x + b) & 0xffffffff
|
||||||
|
b = (b + y + a) & 0xffffffff
|
||||||
|
return a, b
|
||||||
|
|
||||||
|
|
||||||
|
def wal_frames(path):
|
||||||
|
"""Validate the complete WAL chain; return only frames through last commit."""
|
||||||
|
if not path.exists() or path.stat().st_size == 0:
|
||||||
|
return [], None
|
||||||
|
frames, commit, db_pages = [], 0, None
|
||||||
|
with path.open('rb') as file:
|
||||||
|
header = file.read(32)
|
||||||
|
if len(header) != 32:
|
||||||
|
raise ValueError('WAL 头不完整,请退出微信后重新备份')
|
||||||
|
magic, version, size = struct.unpack('>III', header[:12])
|
||||||
|
if magic not in (0x377f0682, 0x377f0683) or version != 3007000 or size != PAGE:
|
||||||
|
raise ValueError('不支持的 WAL 格式')
|
||||||
|
endian = '<' if magic == 0x377f0682 else '>'
|
||||||
|
checksum = wal_checksum(header[:24], endian=endian)
|
||||||
|
if checksum != struct.unpack('>II', header[24:32]):
|
||||||
|
raise ValueError('WAL 头校验失败')
|
||||||
|
while True:
|
||||||
|
offset = file.tell()
|
||||||
|
frame_header = file.read(24)
|
||||||
|
if not frame_header:
|
||||||
|
break
|
||||||
|
page = file.read(PAGE)
|
||||||
|
if len(frame_header) != 24 or len(page) != PAGE:
|
||||||
|
raise ValueError('WAL 尾帧不完整,请重新取得稳定副本')
|
||||||
|
if frame_header[8:16] != header[16:24]:
|
||||||
|
raise ValueError('WAL salt 不匹配,副本可能跨越了数据库轮换')
|
||||||
|
checksum = wal_checksum(frame_header[:8] + page, checksum, endian)
|
||||||
|
if checksum != struct.unpack('>II', frame_header[16:24]):
|
||||||
|
raise ValueError('WAL 帧校验失败,请重新备份')
|
||||||
|
number, count = struct.unpack('>II', frame_header[:8])
|
||||||
|
if not number or number > 10_000_000 or count > 10_000_000:
|
||||||
|
raise ValueError('WAL 页编号无效')
|
||||||
|
frames.append((offset + 24, number))
|
||||||
|
if count:
|
||||||
|
commit, db_pages = len(frames), count
|
||||||
|
return frames[:commit], db_pages
|
||||||
|
|
||||||
|
|
||||||
|
def decrypt_database(source, destination, key):
|
||||||
|
source, destination = Path(source), Path(destination)
|
||||||
|
with source.open('rb') as file:
|
||||||
|
first = file.read(PAGE)
|
||||||
|
mac_key = hmac_key(key, first[:16])
|
||||||
|
if not matches(first, key):
|
||||||
|
raise ValueError('密钥或加密参数不匹配')
|
||||||
|
if source.stat().st_size % PAGE:
|
||||||
|
raise ValueError('源数据库大小不完整')
|
||||||
|
frames, count = wal_frames(Path(str(source) + '-wal'))
|
||||||
|
created = False
|
||||||
|
try:
|
||||||
|
with source.open('rb') as file, destination.open('xb') as out:
|
||||||
|
created = True
|
||||||
|
try:
|
||||||
|
destination.chmod(0o600)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
number = 0
|
||||||
|
while page := file.read(PAGE):
|
||||||
|
number += 1
|
||||||
|
out.write(decrypt_page(page, number, key, mac_key))
|
||||||
|
if frames:
|
||||||
|
with Path(str(source) + '-wal').open('rb') as wal:
|
||||||
|
for offset, number in frames:
|
||||||
|
wal.seek(offset)
|
||||||
|
plain = decrypt_page(wal.read(PAGE), number, key, mac_key)
|
||||||
|
out.seek((number - 1) * PAGE)
|
||||||
|
out.write(plain)
|
||||||
|
out.truncate(count * PAGE)
|
||||||
|
# WAL is now replayed into this standalone, private output only.
|
||||||
|
out.seek(18)
|
||||||
|
out.write(b'\x01\x01')
|
||||||
|
db = sqlite3.connect(destination.resolve().as_uri() + '?mode=ro&immutable=1', uri=True)
|
||||||
|
try:
|
||||||
|
result = db.execute('PRAGMA quick_check').fetchall()
|
||||||
|
if result != [('ok',)]:
|
||||||
|
raise ValueError('解密后的数据库完整性检查未通过')
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
except Exception:
|
||||||
|
if created:
|
||||||
|
destination.unlink(missing_ok=True)
|
||||||
|
raise
|
||||||
|
return {'wal_frames': len(frames), 'pages': destination.stat().st_size // PAGE}
|
||||||
269
wechat_tool/mac_connect.py
Normal file
269
wechat_tool/mac_connect.py
Normal file
@ -0,0 +1,269 @@
|
|||||||
|
"""Page-driven local Mac connection, with isolated backups and atomic import."""
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import platform
|
||||||
|
import re
|
||||||
|
import secrets
|
||||||
|
import shlex
|
||||||
|
import shutil
|
||||||
|
import sqlite3
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from . import store
|
||||||
|
from .cipher import PAGE, decrypt_database, matches
|
||||||
|
from .importer import import_rows
|
||||||
|
from .mac_keys import scan_keys, wechat_pid
|
||||||
|
from .wechat_native import message_rows
|
||||||
|
|
||||||
|
|
||||||
|
def parse_keys(value):
|
||||||
|
keys = set()
|
||||||
|
def walk(item):
|
||||||
|
if isinstance(item, dict):
|
||||||
|
for child in item.values():
|
||||||
|
walk(child)
|
||||||
|
elif isinstance(item, list):
|
||||||
|
for child in item:
|
||||||
|
walk(child)
|
||||||
|
elif isinstance(item, str):
|
||||||
|
text = item.strip()
|
||||||
|
if text.lower().startswith("x'") and text.endswith("'"):
|
||||||
|
text = text[2:-1]
|
||||||
|
if re.fullmatch('[a-fA-F0-9]{64}(?:[a-fA-F0-9]{32})?', text):
|
||||||
|
keys.add(text[:64].lower())
|
||||||
|
walk(value)
|
||||||
|
if not keys or len(keys) > 2000:
|
||||||
|
raise ValueError('密钥文件应包含 1~2000 个 64/96 位十六进制 key 字段;只在本机内存处理')
|
||||||
|
return [bytes.fromhex(key) for key in keys]
|
||||||
|
|
||||||
|
|
||||||
|
class MacConnection:
|
||||||
|
def __init__(self, data):
|
||||||
|
self.data = Path(data)
|
||||||
|
self.accounts, self.keys = {}, []
|
||||||
|
self.job = None
|
||||||
|
self.lock = threading.RLock()
|
||||||
|
deps = self.data / 'mac_deps'
|
||||||
|
if deps.is_dir():
|
||||||
|
sys.path.insert(0, str(deps))
|
||||||
|
|
||||||
|
def require_mac(self):
|
||||||
|
if platform.system() != 'Darwin':
|
||||||
|
raise ValueError('请在目标 Mac 上启动本工具。当前服务不在 Mac,无法读取另一台电脑上的微信。')
|
||||||
|
|
||||||
|
def status(self):
|
||||||
|
with self.lock:
|
||||||
|
return {'available': platform.system() == 'Darwin', 'architecture': platform.machine(),
|
||||||
|
'accounts': [self.describe(key, path) for key,path in self.accounts.items()],
|
||||||
|
'keys_ready': bool(self.keys), 'job': dict(self.job) if self.job else None}
|
||||||
|
|
||||||
|
def describe(self, key, path):
|
||||||
|
files = self.files(path)
|
||||||
|
encrypted, ready, total = 0, 0, 0
|
||||||
|
for file in files:
|
||||||
|
if file.is_symlink():
|
||||||
|
raise ValueError('不支持符号链接数据库')
|
||||||
|
with file.open('rb') as handle:
|
||||||
|
first = handle.read(PAGE)
|
||||||
|
total += file.stat().st_size
|
||||||
|
if first.startswith(b'SQLite format 3\x00'):
|
||||||
|
ready += 1
|
||||||
|
else:
|
||||||
|
encrypted += 1
|
||||||
|
ready += any(matches(first, candidate) for candidate in self.keys)
|
||||||
|
return {'id': key, 'name': path.parent.name, 'path': str(path), 'databases': len(files),
|
||||||
|
'bytes': total, 'encrypted': encrypted, 'readable': ready}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def files(root, biz=False):
|
||||||
|
paths = [root/'contact/contact.db']
|
||||||
|
paths += sorted((root/'message').glob('message_[0-9]*.db'))
|
||||||
|
if biz:
|
||||||
|
paths += sorted((root/'message').glob('biz_message_[0-9]*.db'))
|
||||||
|
return [p for p in paths if p.is_file() and (p.name == 'contact.db' or re.fullmatch(r'(?:biz_)?message_\d+\.db', p.name))]
|
||||||
|
|
||||||
|
def scan(self, pick=False):
|
||||||
|
self.require_mac()
|
||||||
|
base = Path.home()/'Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files'
|
||||||
|
roots = [base, Path.home()/'Documents/xwechat_files']
|
||||||
|
if pick:
|
||||||
|
result = subprocess.run(['/usr/bin/osascript', '-e', 'POSIX path of (choose folder with prompt "选择微信 xwechat_files 或账号的 db_storage 目录")'], capture_output=True, text=True, timeout=180)
|
||||||
|
if result.returncode:
|
||||||
|
raise ValueError('目录选择已取消或系统未授予访问权限')
|
||||||
|
roots = [Path(result.stdout.strip())]
|
||||||
|
found = {}
|
||||||
|
for root in roots:
|
||||||
|
candidates = [root] if root.name == 'db_storage' else list(root.glob('*/db_storage'))
|
||||||
|
for path in candidates:
|
||||||
|
if path.is_dir() and not path.is_symlink() and (path/'message').is_dir() and (path/'contact').is_dir():
|
||||||
|
if any(p.is_symlink() for p in path.rglob('*')):
|
||||||
|
raise ValueError('选中的数据目录包含符号链接,请选择实际数据库目录')
|
||||||
|
resolved = path.resolve()
|
||||||
|
found[hashlib.sha256(str(resolved).encode()).hexdigest()[:16]] = resolved
|
||||||
|
with self.lock:
|
||||||
|
if self.job and self.job['status'] == 'running':
|
||||||
|
raise ValueError('正在执行接入任务,请稍后再扫描')
|
||||||
|
self.accounts = found
|
||||||
|
return self.status()
|
||||||
|
|
||||||
|
def load_keys(self, value):
|
||||||
|
keys = parse_keys(value)
|
||||||
|
with self.lock:
|
||||||
|
if self.job and self.job['status'] == 'running':
|
||||||
|
raise ValueError('正在执行接入任务,暂不能替换密钥')
|
||||||
|
self.keys = keys
|
||||||
|
return self.status()
|
||||||
|
|
||||||
|
def start(self, action, body):
|
||||||
|
self.require_mac()
|
||||||
|
with self.lock:
|
||||||
|
if self.job and self.job['status'] == 'running':
|
||||||
|
raise ValueError('已有接入任务正在运行')
|
||||||
|
if action == 'import' and body.get('account') not in self.accounts:
|
||||||
|
raise ValueError('请先扫描并选择账号')
|
||||||
|
if action not in ('capture', 'import', 'dependencies'):
|
||||||
|
raise ValueError('未知接入操作')
|
||||||
|
job_id = secrets.token_hex(12)
|
||||||
|
self.job = {'id': job_id, 'action': action, 'status': 'running', 'stage': '准备中', 'error': ''}
|
||||||
|
path = self.accounts.get(body.get('account'))
|
||||||
|
key_snapshot = list(self.keys)
|
||||||
|
threading.Thread(target=self.work, args=(job_id, action, dict(body), path, key_snapshot), daemon=True).start()
|
||||||
|
return {'id': job_id}
|
||||||
|
|
||||||
|
def stage(self, text):
|
||||||
|
with self.lock:
|
||||||
|
self.job['stage'] = text
|
||||||
|
|
||||||
|
def work(self, job_id, action, body, path, keys):
|
||||||
|
try:
|
||||||
|
if action == 'capture':
|
||||||
|
self.stage('正在读取已登录微信中的候选密钥;最多扫描 45 秒')
|
||||||
|
if body.get('administrator'):
|
||||||
|
# No credentials in argv or files. System owns the password dialog.
|
||||||
|
script_path = Path(__file__).with_name('mac_keys.py')
|
||||||
|
command = shlex.join([sys.executable, '-I', str(script_path)])
|
||||||
|
script = 'do shell script ' + json.dumps(command, ensure_ascii=False) + ' with administrator privileges'
|
||||||
|
result = subprocess.run(['/usr/bin/osascript', '-e', script], capture_output=True, text=True, timeout=180)
|
||||||
|
if result.returncode:
|
||||||
|
raise ValueError('管理员授权已取消或系统拒绝访问;没有修改微信签名或系统设置')
|
||||||
|
payload = json.loads(result.stdout)
|
||||||
|
if payload.get('error'):
|
||||||
|
raise ValueError(payload['error'])
|
||||||
|
values = payload.get('keys', [])
|
||||||
|
else:
|
||||||
|
values = scan_keys()
|
||||||
|
with self.lock:
|
||||||
|
self.keys = parse_keys(values)
|
||||||
|
result = {'message': '候选密钥已保存在运行内存。请查看账号的匹配数量;退出微信后点击备份并导入。'}
|
||||||
|
elif action == 'dependencies':
|
||||||
|
self.stage('从 PyPI 安装压缩文本支持;不会发送聊天或密钥')
|
||||||
|
target = self.data/'mac_deps'
|
||||||
|
done = subprocess.run([sys.executable, '-m', 'pip', 'install', '--disable-pip-version-check', '--no-input', '--only-binary=:all:',
|
||||||
|
'--target', str(target), 'zstandard==0.23.0'], capture_output=True, timeout=180)
|
||||||
|
if done.returncode:
|
||||||
|
raise ValueError('安装未成功,请检查网络和 Python pip 支持。没有发送聊天或密钥。')
|
||||||
|
if str(target) not in sys.path:
|
||||||
|
sys.path.insert(0, str(target))
|
||||||
|
result = {'message': '文本解压支持已就绪'}
|
||||||
|
else:
|
||||||
|
result = self.import_account(path, keys, bool(body.get('include_biz')))
|
||||||
|
with self.lock:
|
||||||
|
self.job.update(status='complete', stage='已完成', result=result)
|
||||||
|
except Exception as exc:
|
||||||
|
# Never serialize subprocess stderr, process memory or raw key data.
|
||||||
|
message = str(exc) if isinstance(exc, ValueError) else '接入失败,请检查文件访问权限、磁盘空间与数据格式。已有记录未被覆盖。'
|
||||||
|
with self.lock:
|
||||||
|
self.job.update(status='failed', error=message, stage='需要处理')
|
||||||
|
|
||||||
|
def import_account(self, source, keys, biz):
|
||||||
|
if wechat_pid():
|
||||||
|
raise ValueError('请先正常退出微信,再点击“备份并导入”。已取得的密钥会保留在本工具运行内存中。')
|
||||||
|
files = self.files(source, biz)
|
||||||
|
if not (source/'contact/contact.db').is_file() or not any(re.fullmatch(r'message_[0-9]+\.db', p.name) for p in files):
|
||||||
|
raise ValueError('联系人或消息库缺失,未开始导入')
|
||||||
|
self.data.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||||
|
total_size = sum(p.stat().st_size + (Path(str(p)+'-wal').stat().st_size if Path(str(p)+'-wal').exists() else 0) for p in files)
|
||||||
|
if shutil.disk_usage(self.data).free < total_size * 4:
|
||||||
|
raise ValueError('磁盘可用空间不足,请预留至少约四倍所选数据库与 WAL 的大小,再开始备份导入')
|
||||||
|
job_root = self.data/'mac_sources'/secrets.token_hex(12)
|
||||||
|
job_root.mkdir(parents=True, mode=0o700)
|
||||||
|
copied, plain = job_root/'backup', job_root/'readable'
|
||||||
|
self.stage('正在创建本地数据库副本,保留 WAL;原文件不修改')
|
||||||
|
snapshots = {}
|
||||||
|
for path in files:
|
||||||
|
for suffix in ('', '-wal', '-shm'):
|
||||||
|
item = Path(str(path)+suffix)
|
||||||
|
if item.exists():
|
||||||
|
if item.is_symlink():
|
||||||
|
raise ValueError('源目录出现符号链接,已停止备份')
|
||||||
|
if suffix != '-shm':
|
||||||
|
st = item.stat()
|
||||||
|
snapshots[item] = (st.st_size, st.st_mtime_ns)
|
||||||
|
target = copied/item.relative_to(source)
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||||
|
shutil.copyfile(item, target)
|
||||||
|
target.chmod(0o600)
|
||||||
|
if wechat_pid() or any(not p.exists() or (p.stat().st_size,p.stat().st_mtime_ns) != st for p,st in snapshots.items()):
|
||||||
|
raise ValueError('备份期间微信数据发生变化,请保持微信退出后重试;没有导入不稳定副本')
|
||||||
|
for index, original in enumerate(files, 1):
|
||||||
|
self.stage(f'解密并检查数据库 {index}/{len(files)}:{original.name}')
|
||||||
|
rel = original.relative_to(source)
|
||||||
|
src, dst = copied/rel, plain/rel
|
||||||
|
dst.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||||
|
with src.open('rb') as handle:
|
||||||
|
first = handle.read(PAGE)
|
||||||
|
if first.startswith(b'SQLite format 3\x00'):
|
||||||
|
inp = sqlite3.connect(src.resolve().as_uri()+'?mode=ro', uri=True)
|
||||||
|
out = sqlite3.connect(dst)
|
||||||
|
try:
|
||||||
|
inp.backup(out)
|
||||||
|
finally:
|
||||||
|
out.close()
|
||||||
|
inp.close()
|
||||||
|
dst.chmod(0o600)
|
||||||
|
else:
|
||||||
|
key = next((candidate for candidate in keys if matches(first, candidate)), None)
|
||||||
|
if not key:
|
||||||
|
raise ValueError('部分库缺少匹配密钥:'+original.name+'。请重新登录并打开相关聊天后读取密钥,再退出微信重试。副本已保留。')
|
||||||
|
decrypt_database(src, dst, key)
|
||||||
|
self.stage('关联联系人、会话和发送人,解析消息;校验完成后统一写入')
|
||||||
|
staging_root = job_root/'staging'
|
||||||
|
staging = store.connect(staging_root)
|
||||||
|
total = 0
|
||||||
|
try:
|
||||||
|
chunk = []
|
||||||
|
for row in message_rows(plain, source.parent.name, biz):
|
||||||
|
chunk.append(row)
|
||||||
|
if len(chunk) >= 1000:
|
||||||
|
result = import_rows(staging, chunk, 'Mac 微信本地接入', {})
|
||||||
|
if result['rejected']:
|
||||||
|
raise ValueError('部分消息无法标准化,已停止整次导入;请检查版本适配')
|
||||||
|
total += len(chunk)
|
||||||
|
self.stage(f'已解析 {total} 条消息,仍在校验(尚未写入主库)')
|
||||||
|
chunk = []
|
||||||
|
if chunk:
|
||||||
|
result = import_rows(staging, chunk, 'Mac 微信本地接入', {})
|
||||||
|
if result['rejected']:
|
||||||
|
raise ValueError('部分消息无法标准化,已停止整次导入')
|
||||||
|
total += len(chunk)
|
||||||
|
if not total:
|
||||||
|
raise ValueError('没有解析出消息;该版本消息表结构需要进一步适配')
|
||||||
|
finally:
|
||||||
|
staging.close()
|
||||||
|
db = store.connect(self.data)
|
||||||
|
try:
|
||||||
|
db.execute('ATTACH DATABASE ? AS incoming', (str(staging_root/'workbench.sqlite3'),))
|
||||||
|
fields = [row[1] for row in db.execute('PRAGMA main.table_info(messages)') if row[1] != 'id']
|
||||||
|
columns = ','.join(fields)
|
||||||
|
before = db.total_changes
|
||||||
|
db.execute('INSERT OR IGNORE INTO main.messages ('+columns+') SELECT '+columns+' FROM incoming.messages')
|
||||||
|
added = db.total_changes - before
|
||||||
|
db.commit()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
return {'message': '导入完成,可进入“发现知识需求”按时间段分析群聊和私聊。',
|
||||||
|
'parsed': total, 'added': added, 'duplicates': total-added, 'backup': str(job_root), 'include_biz': biz}
|
||||||
86
wechat_tool/mac_keys.py
Normal file
86
wechat_tool/mac_keys.py
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
"""Bounded, read-only scan of the local WeChat process. No system changes."""
|
||||||
|
import ctypes as C
|
||||||
|
import json
|
||||||
|
import platform
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
|
||||||
|
|
||||||
|
def wechat_pid():
|
||||||
|
if platform.system() != 'Darwin':
|
||||||
|
return None
|
||||||
|
result = subprocess.run(['/usr/bin/pgrep', '-x', 'WeChat'], capture_output=True, text=True, timeout=5)
|
||||||
|
for value in result.stdout.split():
|
||||||
|
pid = int(value)
|
||||||
|
lib = C.CDLL('/usr/lib/libproc.dylib')
|
||||||
|
lib.proc_pidpath.argtypes = [C.c_int, C.c_void_p, C.c_uint32]
|
||||||
|
buf = C.create_string_buffer(4096)
|
||||||
|
if lib.proc_pidpath(pid, buf, len(buf)) > 0:
|
||||||
|
path = buf.value.decode('utf-8', errors='replace')
|
||||||
|
if path.endswith('/WeChat.app/Contents/MacOS/WeChat'):
|
||||||
|
return pid
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def scan_keys():
|
||||||
|
if platform.system() != 'Darwin':
|
||||||
|
raise ValueError('请在安装微信的 Mac 上运行本地服务')
|
||||||
|
pid = wechat_pid()
|
||||||
|
if not pid:
|
||||||
|
raise ValueError('未找到运行中的微信,请先打开并登录微信')
|
||||||
|
lib = C.CDLL('/usr/lib/libSystem.B.dylib')
|
||||||
|
own = C.c_uint.in_dll(lib, "mach_task_self_").value
|
||||||
|
task = C.c_uint()
|
||||||
|
lib.task_for_pid.argtypes = [C.c_uint, C.c_int, C.POINTER(C.c_uint)]
|
||||||
|
if lib.task_for_pid(own, pid, C.byref(task)):
|
||||||
|
raise ValueError('系统拒绝读取微信进程。管理员授权也可能受微信签名限制;工具不会修改签名或关闭 SIP。可导入本地密钥文件继续。')
|
||||||
|
class Region(C.Structure):
|
||||||
|
_pack_ = 4
|
||||||
|
_fields_ = [('protection', C.c_int), ('max_protection', C.c_int), ('inheritance', C.c_int),
|
||||||
|
('shared', C.c_uint), ('reserved', C.c_uint), ('offset', C.c_uint64),
|
||||||
|
('behavior', C.c_int), ('wired', C.c_ushort)]
|
||||||
|
lib.mach_vm_region.argtypes = [C.c_uint, C.POINTER(C.c_uint64), C.POINTER(C.c_uint64), C.c_int,
|
||||||
|
C.c_void_p, C.POINTER(C.c_uint), C.POINTER(C.c_uint)]
|
||||||
|
lib.mach_vm_read_overwrite.argtypes = [C.c_uint, C.c_uint64, C.c_uint64, C.c_void_p, C.POINTER(C.c_uint64)]
|
||||||
|
lib.mach_port_deallocate.argtypes = [C.c_uint, C.c_uint]
|
||||||
|
address, read, keys = 0, 0, set()
|
||||||
|
deadline = time.monotonic() + 45
|
||||||
|
pattern = re.compile(rb"[xX]'([0-9a-fA-F]{96}|[0-9a-fA-F]{64})'")
|
||||||
|
try:
|
||||||
|
while read < 2 * 1024**3 and time.monotonic() < deadline and len(keys) < 2000:
|
||||||
|
addr, size, region, count, obj = C.c_uint64(address), C.c_uint64(), Region(), C.c_uint(C.sizeof(Region)//4), C.c_uint()
|
||||||
|
code = lib.mach_vm_region(task, C.byref(addr), C.byref(size), 9, C.byref(region), C.byref(count), C.byref(obj))
|
||||||
|
if obj.value:
|
||||||
|
lib.mach_port_deallocate(own, obj)
|
||||||
|
if code or not size.value:
|
||||||
|
break
|
||||||
|
end = addr.value + size.value
|
||||||
|
if end <= address:
|
||||||
|
break
|
||||||
|
if region.protection & 1 and region.protection & 2 and not region.protection & 4:
|
||||||
|
pos, tail = addr.value, b''
|
||||||
|
while pos < end and read < 2 * 1024**3 and time.monotonic() < deadline:
|
||||||
|
length = min(1024**2, end-pos)
|
||||||
|
buf, actual = C.create_string_buffer(length), C.c_uint64()
|
||||||
|
if not lib.mach_vm_read_overwrite(task, pos, length, buf, C.byref(actual)):
|
||||||
|
chunk = tail + buf.raw[:actual.value]
|
||||||
|
keys.update(m[1].decode().lower() for m in pattern.finditer(chunk))
|
||||||
|
tail = chunk[-100:]
|
||||||
|
else:
|
||||||
|
tail = b''
|
||||||
|
read += length
|
||||||
|
pos += length
|
||||||
|
address = end
|
||||||
|
finally:
|
||||||
|
lib.mach_port_deallocate(own, task)
|
||||||
|
if not keys:
|
||||||
|
raise ValueError('扫描完成但未发现可用格式的密钥。请在微信中打开目标聊天后重试,或导入已有密钥文件。该版本可能需要进一步适配。')
|
||||||
|
return sorted(keys)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
try:
|
||||||
|
print(json.dumps({'keys': scan_keys()}))
|
||||||
|
except Exception as exc:
|
||||||
|
print(json.dumps({'error': str(exc) if isinstance(exc, ValueError) else '本机密钥扫描失败'}))
|
||||||
@ -20,6 +20,7 @@ from . import store
|
|||||||
from .analysis import (SYSTEM_PROMPT, STRATEGIES, SIGNALS, Redactor, extract_events,
|
from .analysis import (SYSTEM_PROMPT, STRATEGIES, SIGNALS, Redactor, extract_events,
|
||||||
local_cards, make_card, prepare_plan, validate_model_cards)
|
local_cards, make_card, prepare_plan, validate_model_cards)
|
||||||
from .importer import import_rows, read_rows, offset_zone
|
from .importer import import_rows, read_rows, offset_zone
|
||||||
|
from .mac_connect import MacConnection
|
||||||
|
|
||||||
STATIC = Path(__file__).parent / 'static'
|
STATIC = Path(__file__).parent / 'static'
|
||||||
MAX_UPLOAD = 64 * 1024 * 1024
|
MAX_UPLOAD = 64 * 1024 * 1024
|
||||||
@ -120,6 +121,7 @@ class Workbench(ThreadingHTTPServer):
|
|||||||
self.lock = threading.RLock()
|
self.lock = threading.RLock()
|
||||||
self.plans = {}
|
self.plans = {}
|
||||||
self.api_key = os.environ.get('WECHAT_MODEL_API_KEY', '')
|
self.api_key = os.environ.get('WECHAT_MODEL_API_KEY', '')
|
||||||
|
self.mac = MacConnection(self.data)
|
||||||
db = store.connect(self.data)
|
db = store.connect(self.data)
|
||||||
if not store.setting(db, 'salt'):
|
if not store.setting(db, 'salt'):
|
||||||
store.set_setting(db, 'salt', secrets.token_hex(32))
|
store.set_setting(db, 'salt', secrets.token_hex(32))
|
||||||
@ -210,6 +212,8 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
def get_route(self, db, path, query):
|
def get_route(self, db, path, query):
|
||||||
|
if path == '/api/mac/status':
|
||||||
|
return self.respond(self.server.mac.status())
|
||||||
if path == '/api/overview':
|
if path == '/api/overview':
|
||||||
result = store.overview(db)
|
result = store.overview(db)
|
||||||
result['runs'] = [dict(r) for r in db.execute('SELECT id,created,mode,status,error,coverage FROM runs ORDER BY created DESC')]
|
result['runs'] = [dict(r) for r in db.execute('SELECT id,created,mode,status,error,coverage FROM runs ORDER BY created DESC')]
|
||||||
@ -289,6 +293,14 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
return rid
|
return rid
|
||||||
|
|
||||||
def post_route(self, db, path, body):
|
def post_route(self, db, path, body):
|
||||||
|
if path == '/api/mac/scan':
|
||||||
|
return self.respond(self.server.mac.scan(bool(body.get('choose_folder'))))
|
||||||
|
if path == '/api/mac/keys':
|
||||||
|
if len(json.dumps(body)) > 1024*1024:
|
||||||
|
raise ValueError('密钥文件最多 1 MB')
|
||||||
|
return self.respond(self.server.mac.load_keys(body.get('keys')))
|
||||||
|
if path == '/api/mac/start':
|
||||||
|
return self.respond(self.server.mac.start(body.get('action'), body))
|
||||||
if path == '/api/import':
|
if path == '/api/import':
|
||||||
data = base64.b64decode(body['data'], validate=True)
|
data = base64.b64decode(body['data'], validate=True)
|
||||||
options = body.get('options') or {}
|
options = body.get('options') or {}
|
||||||
|
|||||||
@ -5,7 +5,7 @@ const token = $('meta[name="workbench-token"]').content;
|
|||||||
let overview = null, settings = {}, currentRun = null, currentPlan = null, fileData = null, currentCard = null, poll = null;
|
let overview = null, settings = {}, currentRun = null, currentPlan = null, fileData = null, currentCard = null, poll = null;
|
||||||
const signals = {repeat:'反复提问',risk:'风险 / 事故线索',rework:'重复踩坑线索',future:'后续复用线索'};
|
const signals = {repeat:'反复提问',risk:'风险 / 事故线索',rework:'重复踩坑线索',future:'后续复用线索'};
|
||||||
const fields = {source_id:'消息 ID', conversation:'会话名称', conversation_id:'会话 ID', kind:'会话类型', sender:'发送人名称', sender_id:'发送人 ID', timestamp:'时间', content:'内容', message_type:'消息类型', quote:'引用'};
|
const fields = {source_id:'消息 ID', conversation:'会话名称', conversation_id:'会话 ID', kind:'会话类型', sender:'发送人名称', sender_id:'发送人 ID', timestamp:'时间', content:'内容', message_type:'消息类型', quote:'引用'};
|
||||||
const titles = {overview:'看见对话中的知识需求',import:'导入你的聊天记录',analyze:'发现值得沉淀的知识',settings:'模型接口与隐私设置'};
|
const titles = {connect:'连接 Mac 微信',overview:'看见对话中的知识需求',import:'导入你的聊天记录',analyze:'发现值得沉淀的知识',settings:'模型接口与隐私设置'};
|
||||||
|
|
||||||
function el(tag, text, className) { const n = document.createElement(tag); if(text !== undefined) n.textContent = text; if(className) n.className=className; return n; }
|
function el(tag, text, className) { const n = document.createElement(tag); if(text !== undefined) n.textContent = text; if(className) n.className=className; return n; }
|
||||||
function notify(text, error=false) { const n=$('#notice'); n.textContent=text; n.hidden=false; n.classList.toggle('error',error); }
|
function notify(text, error=false) { const n=$('#notice'); n.textContent=text; n.hidden=false; n.classList.toggle('error',error); }
|
||||||
@ -13,7 +13,7 @@ async function api(path, body) {
|
|||||||
const opts=body===undefined?{}:{method:'POST',headers:{'Content-Type':'application/json','X-Workbench-Token':token},body:JSON.stringify(body)};
|
const opts=body===undefined?{}:{method:'POST',headers:{'Content-Type':'application/json','X-Workbench-Token':token},body:JSON.stringify(body)};
|
||||||
const response=await fetch(path,opts); const data=await response.json(); if(!response.ok)throw Error(data.error||'请求失败'); return data;
|
const response=await fetch(path,opts); const data=await response.json(); if(!response.ok)throw Error(data.error||'请求失败'); return data;
|
||||||
}
|
}
|
||||||
async function busy(button, work) {button.disabled=true;try{await work();}catch(e){notify(e.message,true);}finally{button.disabled=false;}}
|
async function busy(button, work) {button.disabled=true;try{await work();}catch(e){notify(e.message,true);}finally{button.disabled=button.id.startsWith("mac-")&&button.id!=="mac-refresh"&&macRunning;}}
|
||||||
function page(name) {if(!titles[name])name='overview';$$('.page').forEach(n=>n.hidden=n.id!=='page-'+name);$$('nav button').forEach(n=>n.classList.toggle('active',n.dataset.page===name));$('#page-title').textContent=titles[name];location.hash=name;}
|
function page(name) {if(!titles[name])name='overview';$$('.page').forEach(n=>n.hidden=n.id!=='page-'+name);$$('nav button').forEach(n=>n.classList.toggle('active',n.dataset.page===name));$('#page-title').textContent=titles[name];location.hash=name;}
|
||||||
function displayTime(time) {
|
function displayTime(time) {
|
||||||
if(!time)return '—'; const offset=settings.utc_offset||'+08:00'; const sign=offset[0]==='-'?-1:1;
|
if(!time)return '—'; const offset=settings.utc_offset||'+08:00'; const sign=offset[0]==='-'?-1:1;
|
||||||
@ -119,5 +119,37 @@ $('#close-detail').addEventListener('click',()=>$('#detail-dialog').close());
|
|||||||
$('#merge-cards').addEventListener('click',()=>{const ids=$$('.card-select:checked').map(n=>n.value);if(ids.length<2)return notify('请至少勾选两张主题卡片。',true);$('#merge-title').value=currentRun.cards.find(c=>c.id===ids[0]).title;$('#merge-dialog').showModal();});
|
$('#merge-cards').addEventListener('click',()=>{const ids=$$('.card-select:checked').map(n=>n.value);if(ids.length<2)return notify('请至少勾选两张主题卡片。',true);$('#merge-title').value=currentRun.cards.find(c=>c.id===ids[0]).title;$('#merge-dialog').showModal();});
|
||||||
$('#cancel-merge').addEventListener('click',()=>$('#merge-dialog').close());
|
$('#cancel-merge').addEventListener('click',()=>$('#merge-dialog').close());
|
||||||
$('#confirm-merge').addEventListener('click',e=>busy(e.target,async()=>{await api('/api/card',{run_id:currentRun.id,ids:$$('.card-select:checked').map(n=>n.value),action:'merge',title:$('#merge-title').value.trim()});await loadRun(currentRun.id);$('#merge-dialog').close();notify('已合并主题,原摘要保留在备注中,请重新审核。');}));
|
$('#confirm-merge').addEventListener('click',e=>busy(e.target,async()=>{await api('/api/card',{run_id:currentRun.id,ids:$$('.card-select:checked').map(n=>n.value),action:'merge',title:$('#merge-title').value.trim()});await loadRun(currentRun.id);$('#merge-dialog').close();notify('已合并主题,原摘要保留在备注中,请重新审核。');}));
|
||||||
async function init(){await loadSettings();await refresh();page(location.hash.slice(1)||'overview');}
|
let macTimer = null, lastMacJob = null, macRunning = false;
|
||||||
|
async function macStatus(){
|
||||||
|
clearTimeout(macTimer);
|
||||||
|
const state=await api('/api/mac/status');
|
||||||
|
$('#mac-platform').textContent=state.available?`本地服务运行于 Mac · ${state.architecture}。扫描和读取只操作这台 Mac。`:'当前服务运行于非 Mac 环境。请把工具复制到安装微信的 Mac 上启动,再在那台 Mac 的浏览器打开此页面。';
|
||||||
|
const running=state.job?.status==='running';
|
||||||
|
macRunning=running;
|
||||||
|
for(const id of ['mac-scan','mac-folder','mac-capture','mac-capture-admin','mac-dependencies','mac-import'])$('#'+id).disabled=!state.available||running;
|
||||||
|
$('#mac-key-file').disabled=running;
|
||||||
|
const select=$('#mac-account'),previous=select.value;select.replaceChildren();$('#mac-accounts').replaceChildren();
|
||||||
|
for(const account of state.accounts){
|
||||||
|
const option=el('option',account.name);option.value=account.id;option.selected=account.id===previous;select.append(option);
|
||||||
|
const node=el('div',undefined,'mac-account-info');node.append(el('strong',account.name),el('p',`${account.databases} 个核心数据库 · ${(account.bytes/1024**2).toFixed(1)} MiB · 已可读或密钥匹配 ${account.readable}/${account.databases}`),el('p',account.path,'muted'));$('#mac-accounts').append(node);
|
||||||
|
}
|
||||||
|
if(!state.accounts.length){const option=el('option','请先扫描或选择数据文件夹');option.value='';select.append(option);}
|
||||||
|
$('#mac-key-state').textContent=state.keys_ready?'候选密钥已在运行内存。账号卡上的匹配数量决定实际可读范围。重启知微后需重新读取。':'尚未读取或导入密钥;未加密的可读数据库可直接进入第 3 步。';
|
||||||
|
const job=state.job;$('#mac-job-panel').hidden=!job;
|
||||||
|
if(job){$('#mac-job-stage').textContent=job.stage;$('#mac-job-error').hidden=job.status!=='failed';$('#mac-job-error').textContent=job.error||'';$('#mac-job-result').hidden=!job.result;$('#mac-job-result').textContent=job.result?JSON.stringify(job.result,null,2):'';
|
||||||
|
if(job.status==='complete'&&lastMacJob!==job.id){lastMacJob=job.id;await refresh();notify(job.result?.message||'接入操作已完成');}
|
||||||
|
if(running)macTimer=setTimeout(()=>macStatus().catch(e=>notify(e.message,true)),1800);
|
||||||
|
}
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
async function macStart(action,extra={}){await api('/api/mac/start',{action,...extra});await macStatus();$('#mac-job-panel').scrollIntoView({behavior:'smooth',block:'center'});}
|
||||||
|
$('#mac-scan').addEventListener('click',e=>busy(e.target,async()=>{await api('/api/mac/scan',{});await macStatus();}));
|
||||||
|
$('#mac-folder').addEventListener('click',e=>busy(e.target,async()=>{await api('/api/mac/scan',{choose_folder:true});await macStatus();}));
|
||||||
|
$('#mac-refresh').addEventListener('click',e=>busy(e.target,macStatus));
|
||||||
|
$('#mac-capture').addEventListener('click',e=>busy(e.target,()=>macStart('capture')));
|
||||||
|
$('#mac-capture-admin').addEventListener('click',e=>busy(e.target,()=>macStart('capture',{administrator:true})));
|
||||||
|
$('#mac-dependencies').addEventListener('click',e=>busy(e.target,()=>macStart('dependencies')));
|
||||||
|
$('#mac-import').addEventListener('click',e=>busy(e.target,()=>macStart('import',{account:$('#mac-account').value,include_biz:$('#mac-include-biz').checked})));
|
||||||
|
$('#mac-key-file').addEventListener('change',async e=>{try{const file=e.target.files[0];if(!file)return;if(file.size>1024**2)throw Error('密钥文件最多 1 MB');await api('/api/mac/keys',{keys:JSON.parse(await file.text())});await macStatus();notify('密钥文件已在本机内存处理,不会发送给模型。');}catch(error){notify(error.message,true);}finally{e.target.value='';}});
|
||||||
|
async function init(){await loadSettings();await refresh();page(location.hash.slice(1)||'overview');await macStatus();}
|
||||||
init().catch(e=>notify(e.message,true));
|
init().catch(e=>notify(e.message,true));
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="workbench-token" content="__SESSION_TOKEN__"><title>知微 · 微信知识需求分析</title><link rel="stylesheet" href="/style.css"><script src="/app.js" defer></script></head>
|
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="workbench-token" content="__SESSION_TOKEN__"><title>知微 · 微信知识需求分析</title><link rel="stylesheet" href="/style.css"><script src="/app.js" defer></script></head>
|
||||||
<body>
|
<body>
|
||||||
<aside class="sidebar"><a class="brand" href="#overview"><span class="mark">微</span><span>知微<small>从对话到知识</small></span></a><div class="nav-label">工作空间</div><nav><button data-page="overview" class="active">◫ <span>数据概览</span></button><button data-page="import">↥ <span>导入记录</span></button><button data-page="analyze">◎ <span>发现知识需求</span></button><button data-page="settings">⚙ <span>模型与脱敏</span></button></nav><div class="sidebar-bottom"><span class="dot"></span> 本地工作空间<p>原文保存在本机<br>仅主动发送预览过的脱敏文本</p><small>CREATES · C → R → E</small></div></aside>
|
<aside class="sidebar"><a class="brand" href="#overview"><span class="mark">微</span><span>知微<small>从对话到知识</small></span></a><div class="nav-label">工作空间</div><nav><button data-page="overview" class="active">◫ <span>数据概览</span></button><button data-page="connect">⌘ <span>连接 Mac 微信</span></button><button data-page="import">↥ <span>导入记录</span></button><button data-page="analyze">◎ <span>发现知识需求</span></button><button data-page="settings">⚙ <span>模型与脱敏</span></button></nav><div class="sidebar-bottom"><span class="dot"></span> 本地工作空间<p>原文保存在本机<br>仅主动发送预览过的脱敏文本</p><small>CREATES · C → R → E</small></div></aside>
|
||||||
<main>
|
<main>
|
||||||
<header><div><span class="eyebrow">WECHAT KNOWLEDGE WORKBENCH</span><h1 id="page-title">看见对话中的知识需求</h1></div><span class="pill">LOCAL FIRST · 本地优先</span></header>
|
<header><div><span class="eyebrow">WECHAT KNOWLEDGE WORKBENCH</span><h1 id="page-title">看见对话中的知识需求</h1></div><span class="pill">LOCAL FIRST · 本地优先</span></header>
|
||||||
<div id="notice" role="status" hidden></div>
|
<div id="notice" role="status" hidden></div>
|
||||||
@ -12,9 +12,19 @@
|
|||||||
<div class="panel"><div class="panel-head"><div><h2>数据覆盖范围</h2><p>仅展示已导入的记录。最早与最晚日期之间可能存在缺失。</p></div><button data-action="refresh">刷新</button></div><div class="table-scroll"><table><thead><tr><th>会话</th><th>类型</th><th>消息数</th><th>非文字</th><th>最早记录</th><th>最近记录</th></tr></thead><tbody id="conversations"></tbody></table></div><div id="empty" class="empty">还没有导入记录。先导入文件,或体验一组虚构样例。</div></div>
|
<div class="panel"><div class="panel-head"><div><h2>数据覆盖范围</h2><p>仅展示已导入的记录。最早与最晚日期之间可能存在缺失。</p></div><button data-action="refresh">刷新</button></div><div class="table-scroll"><table><thead><tr><th>会话</th><th>类型</th><th>消息数</th><th>非文字</th><th>最早记录</th><th>最近记录</th></tr></thead><tbody id="conversations"></tbody></table></div><div id="empty" class="empty">还没有导入记录。先导入文件,或体验一组虚构样例。</div></div>
|
||||||
<div class="panel"><div class="panel-head"><div><h2>分析历史</h2><p>保留筛选范围、分析方式与人工修正。</p></div></div><div id="history" class="history"></div></div>
|
<div class="panel"><div class="panel-head"><div><h2>分析历史</h2><p>保留筛选范围、分析方式与人工修正。</p></div></div><div id="history" class="history"></div></div>
|
||||||
</section>
|
</section>
|
||||||
|
<section id="page-connect" class="page" hidden>
|
||||||
|
<div class="intro"><h2>在页面里,把微信记录接进来</h2><p>在安装微信的 Mac 上启动知微,按下面三步完成接入。群聊、私聊一起导入,分析时再选时间段。</p></div>
|
||||||
|
<div id="mac-platform" class="callout">正在检查本机环境…</div>
|
||||||
|
<div class="connect-steps"><span>01 找到账号</span><span>02 本机读取密钥</span><span>03 备份并导入</span></div>
|
||||||
|
<div class="panel form-panel"><h3>01 · 找到你的微信账号</h3><p class="muted">自动查找微信 4.x 数据目录。自定义位置可通过系统文件夹选择器指定;旧版目录不会自动混入。</p><div class="actions"><button id="mac-scan" class="primary">扫描本机微信</button><button id="mac-folder">选择数据文件夹</button><button id="mac-refresh">刷新状态</button></div><label>选择账号<select id="mac-account"><option value="">请先扫描</option></select></label><div id="mac-accounts"></div></div>
|
||||||
|
<div class="panel form-panel"><h3>02 · 让本机读取消息</h3><p>先打开并登录微信,在微信中打开你想分析的聊天,然后点击读取。候选密钥仅保存在本工具的运行内存,不会发送给模型、写入日志或仓库。</p><div class="actions"><button id="mac-capture" class="primary">读取本机密钥</button><button id="mac-capture-admin">使用 macOS 管理员授权读取</button><button id="mac-dependencies">安装文本解压支持</button></div><p class="muted">管理员按钮会弹出系统密码框,用于尝试读取微信进程;不会修改微信签名、关闭 SIP 或保存密码。系统保护仍可能拒绝访问,授权不保证成功。文本解压支持从 PyPI 安装到本工具的数据目录,不发送聊天。</p><details><summary>已有本地密钥文件?选择文件继续</summary><label>密钥 JSON(只在本机处理,请勿分享)<input id="mac-key-file" type="file" accept=".json"></label><p class="muted">支持包含 key / key_hex 字段的 JSON;64 位 raw key 或 96 位 key+salt。微信登录密码不能用来代替。</p></details><p id="mac-key-state" class="muted"></p></div>
|
||||||
|
<div class="panel form-panel"><h3>03 · 备份、解密、校验并导入</h3><p>密钥匹配后,请正常退出微信,保持知微运行,再点击下面的按钮。原始数据库不会被修改,完整副本与可读库留在本机。</p><label class="check"><input id="mac-include-biz" type="checkbox">同时导入业务消息分片(可能包括公众号或服务消息)</label><div class="actions"><button id="mac-import" class="primary">备份并导入所选账号</button><button data-go="analyze">去分析记录 →</button></div><p class="muted">请预留至少约四倍所选数据库大小的磁盘空间。未知格式、缺失身份映射或认证失败时会停止,避免静默漏消息。</p></div>
|
||||||
|
<div id="mac-job-panel" class="panel" hidden><h3>接入进度</h3><p id="mac-job-stage" role="status"></p><div id="mac-job-error" class="callout" hidden></div><pre id="mac-job-result" hidden></pre></div>
|
||||||
|
<p class="muted">实验性接入:支持 SQLCipher 4 raw key / 4096 字节页 / SHA512 认证,以及部分微信 4.x 表结构。尚未在你的 Intel Mac 4.1.0.18 实机验收;页面会显示实际结果,不保证任何版本都能一键读取。</p>
|
||||||
|
</section>
|
||||||
<section id="page-import" class="page" hidden>
|
<section id="page-import" class="page" hidden>
|
||||||
<div class="intro"><h2>把记录带进来</h2><p>支持可读 JSON、JSONL、CSV、TSV、TXT,以及明文 SQLite。重复导入自动去重。</p></div>
|
<div class="intro"><h2>把记录带进来</h2><p>支持可读 JSON、JSONL、CSV、TSV、TXT,以及明文 SQLite。重复导入自动去重。</p></div>
|
||||||
<div class="callout">Mac 微信 4.1.0.18 的加密原始库不能直接导入。本版本提供只读环境诊断脚本 <code>scripts/mac_probe.py</code>,生成数据位置与加密状态报告,不读取消息正文。直接解密及该版本兼容性尚未在 Mac 上验证。</div>
|
<div class="callout">Mac 微信原生数据库请使用“连接 Mac 微信”向导;此页导入已经可读的文件或 SQLite 表。原生接入仍需满足本机权限和版本兼容条件。</div>
|
||||||
<div class="panel form-panel"><label class="drop" for="import-file"><span>↥</span><strong>选择本地聊天记录文件</strong><small>文件仅上传到本机服务,建议不超过 45 MB</small><input id="import-file" type="file" accept=".json,.jsonl,.ndjson,.csv,.tsv,.txt,.db,.sqlite,.sqlite3"></label><div id="file-info" class="muted"></div>
|
<div class="panel form-panel"><label class="drop" for="import-file"><span>↥</span><strong>选择本地聊天记录文件</strong><small>文件仅上传到本机服务,建议不超过 45 MB</small><input id="import-file" type="file" accept=".json,.jsonl,.ndjson,.csv,.tsv,.txt,.db,.sqlite,.sqlite3"></label><div id="file-info" class="muted"></div>
|
||||||
<div class="form-grid"><label>默认会话名称<input id="default-conversation" placeholder="文件缺少会话字段时必填,如:测试项目群"></label><label>默认会话类型<select id="default-kind"><option value="">按记录字段 / @chatroom 后缀识别</option><option value="group">群聊</option><option value="private">私聊</option></select></label></div><p class="muted">没有类型字段或 @chatroom 标识时会归为私聊。单群导出建议手动选“群聊”;混合导出请提供 kind 字段。</p>
|
<div class="form-grid"><label>默认会话名称<input id="default-conversation" placeholder="文件缺少会话字段时必填,如:测试项目群"></label><label>默认会话类型<select id="default-kind"><option value="">按记录字段 / @chatroom 后缀识别</option><option value="group">群聊</option><option value="private">私聊</option></select></label></div><p class="muted">没有类型字段或 @chatroom 标识时会归为私聊。单群导出建议手动选“群聊”;混合导出请提供 kind 字段。</p>
|
||||||
<div id="sqlite-options" hidden><label>消息表<select id="sqlite-table"></select></label><p class="muted">只导入所选表;多张分表需分别导入。请使用完整离线快照,直接复制运行中的 .db 可能缺少 WAL 数据。可用 scripts/export_sqlite.py 只读导出完整表。联系人关联、压缩或编码内容需先转换成可读字段。</p></div>
|
<div id="sqlite-options" hidden><label>消息表<select id="sqlite-table"></select></label><p class="muted">只导入所选表;多张分表需分别导入。请使用完整离线快照,直接复制运行中的 .db 可能缺少 WAL 数据。可用 scripts/export_sqlite.py 只读导出完整表。联系人关联、压缩或编码内容需先转换成可读字段。</p></div>
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
128
wechat_tool/wechat_native.py
Normal file
128
wechat_tool/wechat_native.py
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
"""Adapt readable WeChat 4 tables to the workbench's normalized import format."""
|
||||||
|
import hashlib
|
||||||
|
import re
|
||||||
|
import sqlite3
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def quote(name):
|
||||||
|
return '"' + name.replace('"', '""') + '"'
|
||||||
|
|
||||||
|
|
||||||
|
def open_db(path):
|
||||||
|
db = sqlite3.connect(Path(path).resolve().as_uri() + '?mode=ro&immutable=1', uri=True)
|
||||||
|
db.row_factory = sqlite3.Row
|
||||||
|
return db
|
||||||
|
|
||||||
|
|
||||||
|
def tables(db):
|
||||||
|
return [r[0] for r in db.execute("SELECT name FROM sqlite_master WHERE type='table'")]
|
||||||
|
|
||||||
|
|
||||||
|
def decode(value):
|
||||||
|
if value is None:
|
||||||
|
return ''
|
||||||
|
if not isinstance(value, bytes):
|
||||||
|
return str(value)
|
||||||
|
if value.startswith(b'\x28\xb5\x2f\xfd'):
|
||||||
|
try:
|
||||||
|
import zstandard
|
||||||
|
value = zstandard.ZstdDecompressor().decompress(value, max_output_size=16*1024**2)
|
||||||
|
except ImportError:
|
||||||
|
raise ValueError('缺少压缩文本支持,请在连接页面点击“安装文本解压支持”') from None
|
||||||
|
try:
|
||||||
|
return value.decode('utf-8')
|
||||||
|
except UnicodeError:
|
||||||
|
raise ValueError('正文含未识别编码,已拒绝把二进制内容当文本') from None
|
||||||
|
|
||||||
|
|
||||||
|
def contacts(root):
|
||||||
|
names = {}
|
||||||
|
path = root / 'contact/contact.db'
|
||||||
|
if not path.exists():
|
||||||
|
raise ValueError('缺少联系人库,无法可靠关联会话身份')
|
||||||
|
db = open_db(path)
|
||||||
|
try:
|
||||||
|
for table in tables(db):
|
||||||
|
cols = {r[1] for r in db.execute('PRAGMA table_info(' + quote(table) + ')')}
|
||||||
|
user = next((v for v in ('username', 'user_name', 'UserName') if v in cols), None)
|
||||||
|
if not user:
|
||||||
|
continue
|
||||||
|
labels = [v for v in ('remark', 'remark_name', 'nick_name', 'nickname', 'NickName') if v in cols]
|
||||||
|
for row in db.execute('SELECT ' + ','.join(quote(v) for v in [user]+labels) + ' FROM ' + quote(table)):
|
||||||
|
uid = decode(row[0])
|
||||||
|
if uid:
|
||||||
|
names[uid] = next((decode(row[v]) for v in labels if row[v]), uid)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
if not names:
|
||||||
|
raise ValueError('联系人库结构未识别;需要为这个版本适配字段')
|
||||||
|
return names
|
||||||
|
|
||||||
|
|
||||||
|
def message_rows(root, account, include_biz=False):
|
||||||
|
names = contacts(root)
|
||||||
|
chats = {hashlib.md5(uid.encode()).hexdigest(): uid for uid in names}
|
||||||
|
files = sorted((root / 'message').glob('message_[0-9]*.db'))
|
||||||
|
if include_biz:
|
||||||
|
files += sorted((root / 'message').glob('biz_message_[0-9]*.db'))
|
||||||
|
if not files:
|
||||||
|
raise ValueError('没有找到可读消息分片')
|
||||||
|
for path in files:
|
||||||
|
db = open_db(path)
|
||||||
|
try:
|
||||||
|
all_tables = tables(db)
|
||||||
|
ids = {}
|
||||||
|
for table in all_tables:
|
||||||
|
if table.lower() == 'name2id':
|
||||||
|
cols = {r[1] for r in db.execute('PRAGMA table_info(' + quote(table) + ')')}
|
||||||
|
field = next((c for c in ('user_name', 'username') if c in cols), None)
|
||||||
|
if field:
|
||||||
|
for row in db.execute('SELECT rowid,' + quote(field) + ' FROM ' + quote(table)):
|
||||||
|
uid = decode(row[1])
|
||||||
|
ids[row[0]] = uid
|
||||||
|
chats[hashlib.md5(uid.encode()).hexdigest()] = uid
|
||||||
|
for table in all_tables:
|
||||||
|
match = re.fullmatch(r'(?:Msg|Chat)_([a-fA-F0-9]{32})', table)
|
||||||
|
if not match:
|
||||||
|
if table.startswith(('Msg_', 'Chat_')):
|
||||||
|
raise ValueError('消息表名称格式未识别,需要版本适配;没有静默跳过')
|
||||||
|
continue
|
||||||
|
uid = chats.get(match[1].lower())
|
||||||
|
if not uid:
|
||||||
|
raise ValueError('有消息表无法关联会话身份;请补充完整的联系人/Name2Id 数据后重试')
|
||||||
|
cols = {r[1] for r in db.execute('PRAGMA table_info(' + quote(table) + ')')}
|
||||||
|
required = {'create_time', 'message_content', 'local_type', 'real_sender_id'}
|
||||||
|
if not required <= cols:
|
||||||
|
raise ValueError('消息表字段与支持的微信 4.x 格式不同,需要版本适配')
|
||||||
|
for raw in db.execute('SELECT rowid AS _rowid_, * FROM ' + quote(table)):
|
||||||
|
row = dict(raw)
|
||||||
|
typ = int(row['local_type']) & 0xffffffff
|
||||||
|
sender = ids.get(row['real_sender_id'])
|
||||||
|
if not sender:
|
||||||
|
raise ValueError('消息发送人 ID 无法关联,已停止导入,避免错误归属')
|
||||||
|
content = decode(row['message_content'])
|
||||||
|
if not content and row.get('compress_content'):
|
||||||
|
content = decode(row['compress_content'])
|
||||||
|
ref = ''
|
||||||
|
is_text = typ == 1
|
||||||
|
if uid.endswith('@chatroom') and content.startswith(sender + ':\n'):
|
||||||
|
content = content[len(sender)+2:]
|
||||||
|
if content.startswith('<') and typ == 49:
|
||||||
|
try:
|
||||||
|
xml = ET.fromstring(content)
|
||||||
|
title = xml.findtext('.//appmsg/title') or ''
|
||||||
|
ref = xml.findtext('.//refermsg/content') or ''
|
||||||
|
is_text = xml.findtext('.//appmsg/type') == '57'
|
||||||
|
if title:
|
||||||
|
content = title
|
||||||
|
except ET.ParseError:
|
||||||
|
pass
|
||||||
|
source_id = row.get('server_id') or f"{path.name}:{table}:{row.get('local_id',row['_rowid_'])}"
|
||||||
|
yield dict(id=str(source_id), conversation=names.get(uid, uid), conversation_id=account+'::'+uid,
|
||||||
|
kind='group' if uid.endswith('@chatroom') else 'private', sender=names.get(sender, sender),
|
||||||
|
sender_id=sender, timestamp=row['create_time'], content=content or f'[非文字消息 {typ}]',
|
||||||
|
message_type='text' if is_text else str(typ), quote=ref)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
Loading…
x
Reference in New Issue
Block a user