feat: add page-driven local Mac WeChat connection wizard

This commit is contained in:
luke 2026-10-22 05:56:00 -06:00
parent e2f0cd17ea
commit 9edc59348b
14 changed files with 1007 additions and 10 deletions

3
.gitignore vendored
View File

@ -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

View File

@ -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
View 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)

View File

@ -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
View 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()

View File

@ -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
View 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
View 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
View 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 '本机密钥扫描失败'}))

View File

@ -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 {}

View File

@ -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));

View File

@ -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

View 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()