From 9edc59348bc0106831ab2b1afd69ca230074ec58 Mon Sep 17 00:00:00 2001 From: luke Date: Thu, 22 Oct 2026 05:56:00 -0600 Subject: [PATCH] feat: add page-driven local Mac WeChat connection wizard --- .gitignore | 3 + README.md | 12 +- docs/mac-connection.md | 36 +++++ scripts/package_release.py | 4 +- tests/test_mac_connect.py | 213 +++++++++++++++++++++++++++ tests/test_workbench.py | 16 ++ wechat_tool/cipher.py | 184 +++++++++++++++++++++++ wechat_tool/mac_connect.py | 269 ++++++++++++++++++++++++++++++++++ wechat_tool/mac_keys.py | 86 +++++++++++ wechat_tool/server.py | 12 ++ wechat_tool/static/app.js | 38 ++++- wechat_tool/static/index.html | 14 +- wechat_tool/static/style.css | 2 + wechat_tool/wechat_native.py | 128 ++++++++++++++++ 14 files changed, 1007 insertions(+), 10 deletions(-) create mode 100644 docs/mac-connection.md create mode 100644 tests/test_mac_connect.py create mode 100644 wechat_tool/cipher.py create mode 100644 wechat_tool/mac_connect.py create mode 100644 wechat_tool/mac_keys.py create mode 100644 wechat_tool/wechat_native.py diff --git a/.gitignore b/.gitignore index 552cb7b..e2a7b4a 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,6 @@ dist/ .DS_Store mac-wechat-diagnostic*.json /artifacts/ +keys.json +all_keys.json +*_keys.json diff --git a/README.md b/README.md index 7ec8cf9..d729c3f 100644 --- a/README.md +++ b/README.md @@ -2,13 +2,19 @@ 一个本地运行的 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 导出。 - 已验证: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 状态;不获取密钥,不修改微信数据或系统保护设置。 -- Intel / Apple Silicon 使用相同 Python 分析代码;本版无需 pip 安装第三方依赖。 +- Intel / Apple Silicon 使用相同 Python 分析代码;基础分析与原生 AES 无需 pip 依赖;压缩微信正文需要在页面安装可选 Zstandard 支持。 ## 在 Mac 上启动 @@ -90,7 +96,7 @@ CSV 表头使用同名字段,见 `examples/template.csv`。导入界面可填 - 唯一消息 ID 按会话去重。没有消息 ID 时,用会话、发送人、时间、内容、类型、引用去重;真实同秒同内容消息可能合并,结果会提示。不要混合多个账号但复用同样的会话/消息 ID;如需多账号,请使用不同 `--data-dir`。 - 发送人缺失、文本内容为空、时间无效、BLOB 等会拒绝对应行,并返回错误行号,其他有效行仍会导入。 - 非文字内容保留类型与占位文本,不自动 OCR、转写语音或解析附件。 -- 不支持未经转换的 HTML、任意 TXT 布局、微信官方备份包、加密数据库。拒绝这些格式,避免把乱码当消息。 +- 文件导入页不支持未经转换的 HTML、任意 TXT 布局、微信官方备份包或加密数据库;原生加密库请使用“连接 Mac 微信”向导。拒绝未知格式,避免把乱码当消息。 TXT 支持: diff --git a/docs/mac-connection.md b/docs/mac-connection.md new file mode 100644 index 0000000..002f44a --- /dev/null +++ b/docs/mac-connection.md @@ -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_` / `Chat_`、`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) diff --git a/scripts/package_release.py b/scripts/package_release.py index 2154f69..ef021fe 100644 --- a/scripts/package_release.py +++ b/scripts/package_release.py @@ -5,11 +5,11 @@ from pathlib import Path ROOT = Path(__file__).resolve().parent.parent 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(): - out = ROOT / 'dist' / 'zhiwei-wechat-analysis-v0.1.zip' + out = ROOT / 'dist' / 'zhiwei-wechat-analysis-v0.2.zip' out.parent.mkdir(exist_ok=True) paths = [ROOT / p for p in FILES] for directory in DIRS: diff --git a/tests/test_mac_connect.py b/tests/test_mac_connect.py new file mode 100644 index 0000000..534819f --- /dev/null +++ b/tests/test_mac_connect.py @@ -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('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() diff --git a/tests/test_workbench.py b/tests/test_workbench.py index 1c80e28..4eb51a4 100644 --- a/tests/test_workbench.py +++ b/tests/test_workbench.py @@ -224,6 +224,22 @@ class HttpTests(unittest.TestCase): with self.assertRaises(urllib.error.HTTPError): 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): self.request('/api/demo', {}) run = self.request('/api/analyze', {'filters': {'start': '2026-10-01', 'end': '2026-10-31'}, 'cross': True}) diff --git a/wechat_tool/cipher.py b/wechat_tool/cipher.py new file mode 100644 index 0000000..a805d16 --- /dev/null +++ b/wechat_tool/cipher.py @@ -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('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} diff --git a/wechat_tool/mac_connect.py b/wechat_tool/mac_connect.py new file mode 100644 index 0000000..a16b9c7 --- /dev/null +++ b/wechat_tool/mac_connect.py @@ -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} diff --git a/wechat_tool/mac_keys.py b/wechat_tool/mac_keys.py new file mode 100644 index 0000000..19a06e7 --- /dev/null +++ b/wechat_tool/mac_keys.py @@ -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 '本机密钥扫描失败'})) diff --git a/wechat_tool/server.py b/wechat_tool/server.py index ee8e18e..13745f2 100644 --- a/wechat_tool/server.py +++ b/wechat_tool/server.py @@ -20,6 +20,7 @@ from . import store from .analysis import (SYSTEM_PROMPT, STRATEGIES, SIGNALS, Redactor, extract_events, local_cards, make_card, prepare_plan, validate_model_cards) from .importer import import_rows, read_rows, offset_zone +from .mac_connect import MacConnection STATIC = Path(__file__).parent / 'static' MAX_UPLOAD = 64 * 1024 * 1024 @@ -120,6 +121,7 @@ class Workbench(ThreadingHTTPServer): self.lock = threading.RLock() self.plans = {} self.api_key = os.environ.get('WECHAT_MODEL_API_KEY', '') + self.mac = MacConnection(self.data) db = store.connect(self.data) if not store.setting(db, 'salt'): store.set_setting(db, 'salt', secrets.token_hex(32)) @@ -210,6 +212,8 @@ class Handler(BaseHTTPRequestHandler): db.close() def get_route(self, db, path, query): + if path == '/api/mac/status': + return self.respond(self.server.mac.status()) if path == '/api/overview': 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')] @@ -289,6 +293,14 @@ class Handler(BaseHTTPRequestHandler): return rid 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': data = base64.b64decode(body['data'], validate=True) options = body.get('options') or {} diff --git a/wechat_tool/static/app.js b/wechat_tool/static/app.js index e15779f..25e076b 100644 --- a/wechat_tool/static/app.js +++ b/wechat_tool/static/app.js @@ -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; 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 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 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 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 displayTime(time) { 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();}); $('#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('已合并主题,原摘要保留在备注中,请重新审核。');})); -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)); diff --git a/wechat_tool/static/index.html b/wechat_tool/static/index.html index b9d8946..6194285 100644 --- a/wechat_tool/static/index.html +++ b/wechat_tool/static/index.html @@ -2,7 +2,7 @@ 知微 · 微信知识需求分析 - +
WECHAT KNOWLEDGE WORKBENCH

看见对话中的知识需求

LOCAL FIRST · 本地优先
@@ -12,9 +12,19 @@

数据覆盖范围

仅展示已导入的记录。最早与最晚日期之间可能存在缺失。

会话类型消息数非文字最早记录最近记录
还没有导入记录。先导入文件,或体验一组虚构样例。

分析历史

保留筛选范围、分析方式与人工修正。

+