From d64a2e46f7286387dc7c8823e278fc00e8bcaff9 Mon Sep 17 00:00:00 2001 From: H3CoF6 <1707889225@qq.com> Date: Wed, 8 Apr 2026 06:39:06 +0800 Subject: [PATCH 1/8] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E5=AF=BC?= =?UTF-8?q?=E5=85=A5=E5=B7=B2=E8=A7=A3=E5=AF=86=E7=9B=AE=E5=BD=95=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=EF=BC=8C=E6=94=AF=E6=8C=81=E4=B8=8D=E8=B5=B0=E5=AF=86?= =?UTF-8?q?=E9=92=A5=E6=B5=81=E7=A8=8B=E5=AF=BC=E5=85=A5=E5=B7=B2=E8=A7=A3?= =?UTF-8?q?=E5=AF=86=E7=9A=84=E6=95=B0=E6=8D=AE=E5=BA=93=E5=92=8C=E8=B5=84?= =?UTF-8?q?=E6=BA=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/composables/useApi.js | 8 + frontend/pages/decrypt.vue | 161 ++++++++++++++++-- src/wechat_decrypt_tool/api.py | 2 + .../routers/import_decrypted.py | 135 +++++++++++++++ 4 files changed, 294 insertions(+), 12 deletions(-) create mode 100644 src/wechat_decrypt_tool/routers/import_decrypted.py diff --git a/frontend/composables/useApi.js b/frontend/composables/useApi.js index 13efb89..24bb518 100644 --- a/frontend/composables/useApi.js +++ b/frontend/composables/useApi.js @@ -61,6 +61,14 @@ export const useApi = () => { body: data }) } + + // 导入已解密目录API + const importDecrypted = async (data) => { + return await request('/import_decrypted', { + method: 'POST', + body: data + }) + } // 健康检查API const healthCheck = async () => { diff --git a/frontend/pages/decrypt.vue b/frontend/pages/decrypt.vue index 4e90eed..399ab33 100644 --- a/frontend/pages/decrypt.vue +++ b/frontend/pages/decrypt.vue @@ -16,13 +16,31 @@ -
-

数据库解密

-

输入密钥和路径开始解密

+
+

数据获取

+

选择解密新数据或导入已解密目录

+
+ +
+ +
-
+ +
+ + +
+
+
+ + + +
+

什么是直接导入?

+

如果您已经有了已解密的数据库文件(扁平化目录结构,含 contact.db, session.db 等)以及 resource 资源目录,可以直接导入。此过程不校验密钥,也不进行实时同步。

+
+
+
+ + +
+ + +

+ + + + {{ formErrors.import_path }} +

+

+ + + + 该目录应包含已解密的 .db 文件,若有 resource 文件夹也会一并导入。 +

+
+ + +
+
+ +
+
+
@@ -434,7 +518,7 @@ import { ref, reactive, computed, onMounted, onBeforeUnmount } from 'vue' import { useApi } from '~/composables/useApi' -const { decryptDatabase, saveMediaKeys, getSavedKeys, getKeys, getImageKey, getWxStatus } = useApi() +const { decryptDatabase, importDecrypted, saveMediaKeys, getSavedKeys, getKeys, getImageKey, getWxStatus } = useApi() const loading = ref(false) const error = ref('') @@ -443,12 +527,8 @@ const currentStep = ref(0) const mediaAccount = ref('') const isGettingDbKey = ref(false) -// 步骤定义 -const steps = [ - { title: '数据库解密' }, - { title: '填写图片密钥' }, - { title: '图片解密' } -] +// 解密模式切换 +const decryptMode = ref('standard') // 'standard' or 'import' // 表单数据 const formData = reactive({ @@ -456,10 +536,16 @@ const formData = reactive({ db_storage_path: '' }) +// 导入数据 +const importData = reactive({ + path: '' +}) + // 表单错误 const formErrors = reactive({ key: '', - db_storage_path: '' + db_storage_path: '', + import_path: '' }) // 图片密钥相关 @@ -698,6 +784,57 @@ const resetDbDecryptProgress = () => { dbDecryptProgress.message = '' } +const handleImport = async () => { + if (!importData.path) { + formErrors.import_path = '请输入已解密目录路径' + return + } + + loading.value = true + error.value = '' + warning.value = '' + formErrors.import_path = '' + + try { + const res = await importDecrypted({ + import_path: importData.path + }) + + if (res.status === 'success') { + mediaAccount.value = res.account + // 模拟一个成功的结果 + decryptResult.value = { + status: 'completed', + success_count: res.imported_files.length, + total_databases: res.imported_files.length, + account_results: { + [res.account]: { + success: res.imported_files.length + } + } + } + + if (process.client && typeof window !== 'undefined') { + sessionStorage.setItem('decryptResult', JSON.stringify(decryptResult.value)) + } + + // 如果有 resource 目录,则提示用户可以跳过图片解密 + if (res.has_resource) { + warning.value = '检测到已包含 resource 资源目录,您可以直接跳转到聊天记录。' + } + + currentStep.value = 1 + await prefillKeysForAccount(mediaAccount.value) + } else { + error.value = res.message || '导入失败' + } + } catch (err) { + error.value = err.message || '导入过程中发生错误' + } finally { + loading.value = false + } +} + // 处理解密 const handleDecrypt = async () => { if (!validateForm()) { diff --git a/src/wechat_decrypt_tool/api.py b/src/wechat_decrypt_tool/api.py index bc2df4d..154d55d 100644 --- a/src/wechat_decrypt_tool/api.py +++ b/src/wechat_decrypt_tool/api.py @@ -25,6 +25,7 @@ from .routers.chat_contacts import router as _chat_contacts_router from .routers.chat_export import router as _chat_export_router from .routers.chat_media import router as _chat_media_router from .routers.decrypt import router as _decrypt_router +from .routers.import_decrypted import router as _import_decrypted_router from .routers.health import router as _health_router from .routers.admin import router as _admin_router from .routers.keys import router as _keys_router @@ -87,6 +88,7 @@ async def _log_server_errors(request: Request, call_next): app.include_router(_health_router) app.include_router(_admin_router) app.include_router(_wechat_detection_router) +app.include_router(_import_decrypted_router) app.include_router(_decrypt_router) app.include_router(_keys_router) app.include_router(_media_router) diff --git a/src/wechat_decrypt_tool/routers/import_decrypted.py b/src/wechat_decrypt_tool/routers/import_decrypted.py new file mode 100644 index 0000000..71143bf --- /dev/null +++ b/src/wechat_decrypt_tool/routers/import_decrypted.py @@ -0,0 +1,135 @@ +from __future__ import annotations + +import os +import shutil +import json +from pathlib import Path +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel, Field + +from ..app_paths import get_output_databases_dir +from ..logging_config import get_logger +from ..path_fix import PathFixRoute +from ..session_last_message import build_session_last_message_table + +logger = get_logger(__name__) + +router = APIRouter(route_class=PathFixRoute) + +class ImportRequest(BaseModel): + import_path: str = Field(..., description="已解密的数据库和资源所在目录的绝对路径") + +def _is_valid_sqlite(path: Path) -> bool: + SQLITE_HEADER = b"SQLite format 3\x00" + try: + if not path.exists() or not path.is_file(): + return False + with path.open("rb") as f: + return f.read(len(SQLITE_HEADER)) == SQLITE_HEADER + except Exception: + return False + +@router.post("/api/import_decrypted", summary="导入已解密的数据库和资源目录") +async def import_decrypted_directory(request: ImportRequest): + """ + 导入已解密的微信数据库和资源目录。 + 该功能不需要密钥,直接将现有的已解密文件链接或复制到输出目录。 + """ + import_path = Path(request.import_path.strip()) + if not import_path.exists() or not import_path.is_dir(): + raise HTTPException(status_code=400, detail="导入路径不存在或不是目录") + + # 1. 尝试识别账号名 + # 优先从路径名识别 (例如 .../wxid_xxxx) + from ..wechat_decrypt import _derive_account_name_from_path + account_name = _derive_account_name_from_path(import_path) + + # 2. 验证关键数据库文件 + # 必须包含 contact.db 和 session.db 才能在列表中正常显示 + required_dbs = ["contact.db", "session.db"] + for db_name in required_dbs: + if not _is_valid_sqlite(import_path / db_name): + # 兼容性检查:如果不在根目录,可能在 db_storage 子目录? + # 但用户说“和现在完全保存的目录一致”,所以应该在根目录。 + raise HTTPException(status_code=400, detail=f"导入目录中未找到有效的 {db_name},请确保是已解密的扁平化目录") + + # 3. 准备输出目录 + output_base = get_output_databases_dir() + account_output_dir = output_base / account_name + account_output_dir.mkdir(parents=True, exist_ok=True) + + logger.info(f"正在从 {import_path} 导入账号 {account_name} ...") + + # 4. 导入 .db 文件 + imported_files = [] + for item in import_path.iterdir(): + if item.is_file() and item.suffix == ".db": + target = account_output_dir / item.name + try: + # 优先尝试硬链接以节省空间 + if target.exists(): + target.unlink() + os.link(item, target) + imported_files.append(item.name) + except Exception as e: + logger.warning(f"硬链接失败,尝试复制: {item.name}, error: {e}") + try: + shutil.copy2(item, target) + imported_files.append(item.name) + except Exception as e2: + logger.error(f"复制失败: {item.name}, error: {e2}") + + # 5. 导入 resource 目录 + resource_src = import_path / "resource" + if resource_src.exists() and resource_src.is_dir(): + resource_dst = account_output_dir / "resource" + try: + if resource_dst.exists(): + if resource_dst.is_symlink() or resource_dst.is_file(): + resource_dst.unlink() + else: + shutil.rmtree(resource_dst) + + # 对目录尝试符号链接(Windows 下可能需要权限) + try: + os.symlink(resource_src, resource_dst, target_is_directory=True) + logger.info("已创建 resource 目录的符号链接") + except Exception: + # 符号链接失败则尝试硬链接或复制(对于资源目录,复制比较慢,建议用户手动移动) + logger.warning("符号链接失败,尝试复制 resource 目录(这可能需要较长时间)") + shutil.copytree(resource_src, resource_dst, dirs_exist_ok=True) + except Exception as e: + logger.error(f"导入 resource 目录失败: {e}") + + # 6. 保存来源信息 + try: + (account_output_dir / "_source.json").write_text( + json.dumps( + {"db_storage_path": str(import_path), "import_mode": "manual_import", "imported_at": __import__('datetime').datetime.now().isoformat()}, + ensure_ascii=False, + indent=2, + ), + encoding="utf-8", + ) + except Exception: + pass + + # 7. 构建缓存 + logger.info(f"正在为 {account_name} 构建会话缓存...") + try: + build_session_last_message_table( + account_output_dir, + rebuild=True, + include_hidden=True, + include_official=True, + ) + except Exception as e: + logger.error(f"构建会话缓存失败: {e}") + + return { + "status": "success", + "account": account_name, + "imported_files": imported_files, + "has_resource": resource_src.exists(), + "message": f"成功导入账号 {account_name},共 {len(imported_files)} 个数据库" + } From ad6031651b988b10d663c2c3f5b18b2e946ccd53 Mon Sep 17 00:00:00 2001 From: H3CoF6 <1707889225@qq.com> Date: Wed, 8 Apr 2026 07:00:53 +0800 Subject: [PATCH 2/8] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E5=AF=BC?= =?UTF-8?q?=E5=85=A5=E6=B5=81=E7=A8=8B=EF=BC=8C=E5=B0=86=E5=AF=BC=E5=85=A5?= =?UTF-8?q?=E6=8C=89=E9=92=AE=E7=A7=BB=E8=87=B3=E9=A6=96=E9=A1=B5=E5=B9=B6?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E7=9B=AE=E5=BD=95=E9=80=89=E6=8B=A9=E5=92=8C?= =?UTF-8?q?=E8=B4=A6=E5=8F=B7=E9=A2=84=E8=A7=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/composables/useApi.js | 10 ++ frontend/pages/decrypt.vue | 161 ++---------------- frontend/pages/index.vue | 155 +++++++++++++++-- .../routers/import_decrypted.py | 109 ++++++++---- 4 files changed, 238 insertions(+), 197 deletions(-) diff --git a/frontend/composables/useApi.js b/frontend/composables/useApi.js index 24bb518..c4ac4b6 100644 --- a/frontend/composables/useApi.js +++ b/frontend/composables/useApi.js @@ -62,6 +62,14 @@ export const useApi = () => { }) } + // 导入预览API + const importDecryptedPreview = async (data) => { + return await request('/import_decrypted/preview', { + method: 'POST', + body: data + }) + } + // 导入已解密目录API const importDecrypted = async (data) => { return await request('/import_decrypted', { @@ -613,6 +621,8 @@ export const useApi = () => { detectWechat, detectCurrentAccount, decryptDatabase, + importDecryptedPreview, + importDecrypted, healthCheck, listChatAccounts, getChatAccountInfo, diff --git a/frontend/pages/decrypt.vue b/frontend/pages/decrypt.vue index 399ab33..4e90eed 100644 --- a/frontend/pages/decrypt.vue +++ b/frontend/pages/decrypt.vue @@ -16,31 +16,13 @@ -
-

数据获取

-

选择解密新数据或导入已解密目录

-
- -
- - +
+

数据库解密

+

输入密钥和路径开始解密

- -
+
- - -
-
-
- - - -
-

什么是直接导入?

-

如果您已经有了已解密的数据库文件(扁平化目录结构,含 contact.db, session.db 等)以及 resource 资源目录,可以直接导入。此过程不校验密钥,也不进行实时同步。

-
-
-
- - -
- - -

- - - - {{ formErrors.import_path }} -

-

- - - - 该目录应包含已解密的 .db 文件,若有 resource 文件夹也会一并导入。 -

-
- - -
-
- -
-
-
@@ -518,7 +434,7 @@ import { ref, reactive, computed, onMounted, onBeforeUnmount } from 'vue' import { useApi } from '~/composables/useApi' -const { decryptDatabase, importDecrypted, saveMediaKeys, getSavedKeys, getKeys, getImageKey, getWxStatus } = useApi() +const { decryptDatabase, saveMediaKeys, getSavedKeys, getKeys, getImageKey, getWxStatus } = useApi() const loading = ref(false) const error = ref('') @@ -527,8 +443,12 @@ const currentStep = ref(0) const mediaAccount = ref('') const isGettingDbKey = ref(false) -// 解密模式切换 -const decryptMode = ref('standard') // 'standard' or 'import' +// 步骤定义 +const steps = [ + { title: '数据库解密' }, + { title: '填写图片密钥' }, + { title: '图片解密' } +] // 表单数据 const formData = reactive({ @@ -536,16 +456,10 @@ const formData = reactive({ db_storage_path: '' }) -// 导入数据 -const importData = reactive({ - path: '' -}) - // 表单错误 const formErrors = reactive({ key: '', - db_storage_path: '', - import_path: '' + db_storage_path: '' }) // 图片密钥相关 @@ -784,57 +698,6 @@ const resetDbDecryptProgress = () => { dbDecryptProgress.message = '' } -const handleImport = async () => { - if (!importData.path) { - formErrors.import_path = '请输入已解密目录路径' - return - } - - loading.value = true - error.value = '' - warning.value = '' - formErrors.import_path = '' - - try { - const res = await importDecrypted({ - import_path: importData.path - }) - - if (res.status === 'success') { - mediaAccount.value = res.account - // 模拟一个成功的结果 - decryptResult.value = { - status: 'completed', - success_count: res.imported_files.length, - total_databases: res.imported_files.length, - account_results: { - [res.account]: { - success: res.imported_files.length - } - } - } - - if (process.client && typeof window !== 'undefined') { - sessionStorage.setItem('decryptResult', JSON.stringify(decryptResult.value)) - } - - // 如果有 resource 目录,则提示用户可以跳过图片解密 - if (res.has_resource) { - warning.value = '检测到已包含 resource 资源目录,您可以直接跳转到聊天记录。' - } - - currentStep.value = 1 - await prefillKeysForAccount(mediaAccount.value) - } else { - error.value = res.message || '导入失败' - } - } catch (err) { - error.value = err.message || '导入过程中发生错误' - } finally { - loading.value = false - } -} - // 处理解密 const handleDecrypt = async () => { if (!validateForm()) { diff --git a/frontend/pages/index.vue b/frontend/pages/index.vue index 95995d7..6465479 100644 --- a/frontend/pages/index.vue +++ b/frontend/pages/index.vue @@ -41,6 +41,14 @@ 直接解密 + + @@ -49,27 +57,86 @@ 聊天预览 - - - - - - - - - 年度总结 - + + + +
+
+
+

+ + + + 确认导入账号 +

+ +
+
+ 头像 +
+
+
{{ importPreview.nick }}
+
{{ importPreview.username }}
+
+ +
+
+ 包含数据库 + +
+
+ 包含资源文件 + + {{ importPreview.has_resource ? '是' : '否' }} + +
+
+
+ +
+ + + + {{ importError }} +
+ +
+ + +
+
+
+
+
+ + diff --git a/frontend/pages/index.vue b/frontend/pages/index.vue index 6465479..c788bf0 100644 --- a/frontend/pages/index.vue +++ b/frontend/pages/index.vue @@ -42,13 +42,13 @@ 直接解密 - + @@ -57,85 +57,28 @@ 聊天预览 + + + + + + + + + 年度总结 + - - - -
-
-
-

- - - - 确认导入账号 -

- -
-
- 头像 -
-
-
{{ importPreview.nick }}
-
{{ importPreview.username }}
-
- -
-
- 包含数据库 - -
-
- 包含资源文件 - - {{ importPreview.has_resource ? '是' : '否' }} - -
-
-
- -
- - - - {{ importError }} -
- -
- - -
-
-
-
-
- - + \ No newline at end of file From 73f69f6f14ede2398dc14b240a67425ae654327d Mon Sep 17 00:00:00 2001 From: H3CoF6 <1707889225@qq.com> Date: Thu, 9 Apr 2026 01:13:48 +0800 Subject: [PATCH 6/8] feat: parse wxgf(wxam) for import data --- frontend/pages/import.vue | 100 +++++-- .../routers/import_decrypted.py | 261 ++++++++++++------ 2 files changed, 259 insertions(+), 102 deletions(-) diff --git a/frontend/pages/import.vue b/frontend/pages/import.vue index e0221ef..aeb23a8 100644 --- a/frontend/pages/import.vue +++ b/frontend/pages/import.vue @@ -32,7 +32,7 @@ -
+
@@ -42,8 +42,30 @@

支持原生目录选择器

+ +
+
+
+ + + + +
+ {{ importProgress }}% +
+
+ +

{{ importMessage }}

+

正在为您处理数据,请稍候...

+ +
+
+
+
+
+ -
+
头像 @@ -72,10 +94,6 @@ @@ -83,13 +101,13 @@
-
+
-

目录校验失败

+

导入失败

{{ importError }}

@@ -118,14 +136,25 @@ diff --git a/src/wechat_decrypt_tool/routers/import_decrypted.py b/src/wechat_decrypt_tool/routers/import_decrypted.py index 4853983..fec4884 100644 --- a/src/wechat_decrypt_tool/routers/import_decrypted.py +++ b/src/wechat_decrypt_tool/routers/import_decrypted.py @@ -3,14 +3,18 @@ from __future__ import annotations import os import shutil import json +import asyncio from pathlib import Path -from fastapi import APIRouter, HTTPException +from typing import Optional +from fastapi import APIRouter, HTTPException, Query +from fastapi.responses import StreamingResponse from pydantic import BaseModel, Field from ..app_paths import get_output_databases_dir from ..logging_config import get_logger from ..path_fix import PathFixRoute from ..session_last_message import build_session_last_message_table +from ..media_helpers import _wxgf_to_image_bytes logger = get_logger(__name__) @@ -78,99 +82,190 @@ async def preview_import(request: ImportRequest): return _validate_import_structure(import_path) -@router.post("/api/import_decrypted", summary="执行导入已解密的数据库和资源目录") -async def import_decrypted_directory(request: ImportRequest): - import_path = Path(request.import_path.strip()) - if not import_path.exists() or not import_path.is_dir(): - raise HTTPException(status_code=400, detail="导入路径不存在或不是目录") - - # 1. 验证并获取账号信息 - info = _validate_import_structure(import_path) - account_name = info["username"] +@router.get("/api/import_decrypted", summary="执行导入已解密的数据库和资源目录 (SSE)") +async def import_decrypted_directory( + import_path: str = Query(..., description="已解密的数据库和资源所在目录的绝对路径") +): + import_path_obj = Path(import_path.strip()) - # 2. 准备输出目录 - output_base = get_output_databases_dir() - account_output_dir = output_base / account_name - account_output_dir.mkdir(parents=True, exist_ok=True) + def _sse(data: dict): + return f"data: {json.dumps(data, ensure_ascii=False)}\n\n" - logger.info(f"正在从 {import_path} 导入账号 {account_name} ...") + async def generate_progress(): + try: + if not import_path_obj.exists() or not import_path_obj.is_dir(): + yield _sse({"type": "error", "message": "导入路径不存在或不是目录"}) + return - # 3. 导入 databases 目录下的 .db 文件 - db_src_dir = import_path / "databases" - imported_files = [] - for item in db_src_dir.iterdir(): - if item.is_file() and item.suffix == ".db": - target = account_output_dir / item.name + yield _sse({"type": "progress", "percent": 5, "message": "正在验证目录结构..."}) + # 1. 验证并获取账号信息 try: - if target.exists(): - target.unlink() - os.link(item, target) - imported_files.append(item.name) - except Exception: + info = await asyncio.to_thread(_validate_import_structure, import_path_obj) + except HTTPException as e: + yield _sse({"type": "error", "message": e.detail}) + return + except Exception as e: + yield _sse({"type": "error", "message": f"验证失败: {e}"}) + return + + account_name = info["username"] + yield _sse({"type": "progress", "percent": 10, "message": f"验证成功: {account_name}"}) + + # 2. 准备输出目录 + output_base = get_output_databases_dir() + account_output_dir = output_base / account_name + await asyncio.to_thread(account_output_dir.mkdir, parents=True, exist_ok=True) + + yield _sse({"type": "progress", "percent": 15, "message": "正在准备目标目录..."}) + + # 3. 导入 databases 目录下的 .db 文件 + db_src_dir = import_path_obj / "databases" + db_files = [f for f in db_src_dir.iterdir() if f.is_file() and f.suffix == ".db"] + imported_files = [] + + for i, item in enumerate(db_files): + target = account_output_dir / item.name + def _do_import_db(src, dst): + if dst.exists(): + dst.unlink() + try: + os.link(src, dst) + except Exception: + shutil.copy2(src, dst) + try: - shutil.copy2(item, target) + await asyncio.to_thread(_do_import_db, item, target) imported_files.append(item.name) except Exception as e: logger.error(f"导入数据库失败: {item.name}, error: {e}") + + percent = 15 + int((i + 1) / (len(db_files) or 1) * 15) + yield _sse({"type": "progress", "percent": percent, "message": f"正在导入数据库: {item.name}"}) - # 4. 导入 resource 目录 - resource_src = import_path / "resource" - if resource_src.exists() and resource_src.is_dir(): - resource_dst = account_output_dir / "resource" - try: - if resource_dst.exists(): - if resource_dst.is_symlink() or resource_dst.is_file(): - resource_dst.unlink() - else: - shutil.rmtree(resource_dst) - + # 4. 导入 resource 目录 + resource_src = import_path_obj / "resource" + if resource_src.exists() and resource_src.is_dir(): + yield _sse({"type": "progress", "percent": 30, "message": "正在导入资源文件 (这可能需要一些时间)..."}) + resource_dst = account_output_dir / "resource" + + def _do_import_resource(src, dst): + if dst.exists(): + if dst.is_symlink() or dst.is_file(): + dst.unlink() + else: + shutil.rmtree(dst) + try: + os.symlink(src, dst, target_is_directory=True) + except Exception: + shutil.copytree(src, dst, dirs_exist_ok=True) + + try: + await asyncio.to_thread(_do_import_resource, resource_src, resource_dst) + except Exception as e: + logger.error(f"导入 resource 目录失败: {e}") + + # 5. 转换 .wxgf 资源 (新增加的流程) + yield _sse({"type": "progress", "percent": 50, "message": "正在搜索并转换 .wxgf 图片..."}) + + if resource_dst.exists(): + # 搜索 wxgf 文件 + def _find_wxgf(root_dir): + found = [] + for root, _, files in os.walk(root_dir): + for f in files: + if f.lower().endswith(".wxgf"): + found.append(Path(root) / f) + return found + + wxgf_files = await asyncio.to_thread(_find_wxgf, resource_dst) + + if wxgf_files: + total_wxgf = len(wxgf_files) + converted_count = 0 + for i, wxgf_path in enumerate(wxgf_files): + def _convert_one(p): + jpg_p = p.with_suffix(".wxgf.jpg") + if not jpg_p.exists(): + data = p.read_bytes() + if data.startswith(b"wxgf"): + converted = _wxgf_to_image_bytes(data) + if converted: + jpg_p.write_bytes(converted) + return True + else: + return True # 已经存在视为成功 + return False + + try: + success = await asyncio.to_thread(_convert_one, wxgf_path) + if success: + converted_count += 1 + except Exception as e: + logger.error(f"转换 wxgf 失败: {wxgf_path}, {e}") + + if i % max(1, total_wxgf // 20) == 0 or i == total_wxgf - 1: + progress_val = 50 + int((i + 1) / total_wxgf * 30) + yield _sse({"type": "progress", "percent": progress_val, "message": f"转换 wxgf 图片: {i+1}/{total_wxgf}"}) + + logger.info(f"账号 {account_name} 转换完成: {converted_count}/{total_wxgf} 个 .wxgf 文件") + + # 6. 复制 account.json + yield _sse({"type": "progress", "percent": 85, "message": "正在更新账号配置..."}) try: - os.symlink(resource_src, resource_dst, target_is_directory=True) + await asyncio.to_thread(shutil.copy2, import_path_obj / "account.json", account_output_dir / "account.json") except Exception: - shutil.copytree(resource_src, resource_dst, dirs_exist_ok=True) + pass + + # 7. 保存来源信息 + def _save_source_info(dst, path, info): + (dst / "_source.json").write_text( + json.dumps( + { + "db_storage_path": str(path), + "import_mode": "manual_import", + "imported_at": __import__('datetime').datetime.now().isoformat(), + "original_info": info + }, + ensure_ascii=False, + indent=2, + ), + encoding="utf-8", + ) + + try: + await asyncio.to_thread(_save_source_info, account_output_dir, import_path_obj, info) + except Exception: + pass + + # 8. 构建缓存 + yield _sse({"type": "progress", "percent": 90, "message": "正在构建会话缓存 (这可能需要较长时间)..."}) + try: + await asyncio.to_thread( + build_session_last_message_table, + account_output_dir, + rebuild=True, + include_hidden=True, + include_official=True, + ) + except Exception as e: + logger.error(f"构建会话缓存失败: {e}") + + yield _sse({ + "type": "complete", + "status": "success", + "account": account_name, + "nick": info["nick"], + "message": f"成功导入账号 {info['nick']} ({account_name})" + }) + except Exception as e: - logger.error(f"导入 resource 目录失败: {e}") + logger.error(f"导入过程中发生异常: {e}", exc_info=True) + yield _sse({"type": "error", "message": f"导入失败: {str(e)}"}) - # 5. 复制 account.json - try: - shutil.copy2(import_path / "account.json", account_output_dir / "account.json") - except Exception: - pass - - # 6. 保存来源信息 - try: - (account_output_dir / "_source.json").write_text( - json.dumps( - { - "db_storage_path": str(import_path), - "import_mode": "manual_import", - "imported_at": __import__('datetime').datetime.now().isoformat(), - "original_info": info - }, - ensure_ascii=False, - indent=2, - ), - encoding="utf-8", - ) - except Exception: - pass - - # 7. 构建缓存 - logger.info(f"正在为 {account_name} 构建会话缓存...") - try: - build_session_last_message_table( - account_output_dir, - rebuild=True, - include_hidden=True, - include_official=True, - ) - except Exception as e: - logger.error(f"构建会话缓存失败: {e}") - - return { - "status": "success", - "account": account_name, - "nick": info["nick"], - "imported_files": imported_files, - "message": f"成功导入账号 {info['nick']} ({account_name})" + headers = { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no" } + return StreamingResponse(generate_progress(), headers=headers) From 4b47c1e69c7f888a7bd9f5df0076a4917ea1391a Mon Sep 17 00:00:00 2001 From: H3CoF6 <1707889225@qq.com> Date: Thu, 9 Apr 2026 01:30:35 +0800 Subject: [PATCH 7/8] feat: parse last login account info --- frontend/pages/detection-result.vue | 95 +++++--- .../routers/wechat_detection.py | 14 ++ src/wechat_decrypt_tool/wechat_detection.py | 223 +++++++++++++----- 3 files changed, 233 insertions(+), 99 deletions(-) diff --git a/frontend/pages/detection-result.vue b/frontend/pages/detection-result.vue index 661c258..5b677fa 100644 --- a/frontend/pages/detection-result.vue +++ b/frontend/pages/detection-result.vue @@ -132,40 +132,58 @@
-
+ :class="['p-5 transition-all duration-200 relative overflow-hidden', isCurrentAccount(account.account_name) ? 'bg-[#07C160]/5 border border-[#07C160]/20' : 'hover:bg-[#F9F9F9]']"> + +
+ + + 最近登录账户 + +
+ +
-
- {{ account.account_name?.charAt(0)?.toUpperCase() || 'U' }} -
-
-
-

{{ account.account_name || '未知账户' }}

- - 当前登录 - + + + +
+
+ + +
+ +
+ + + + + {{ account.database_count }} 个库文件 + - - - - 路径已确认 - + + + + 路径已确认 +
+
-

📂 {{ account.data_dir }} @@ -254,20 +271,26 @@ const handlePickDirectory = async () => { // 计算属性:将当前登录账号排在第一位 const sortedAccounts = computed(() => { if (!detectionResult.value?.data?.accounts) return [] - const accounts = [...detectionResult.value.data.accounts] - const currentAccountName = detectionResult.value.data?.current_account?.current_account - - if (!currentAccountName) return accounts - - // 将当前登录账号移到第一位 + + const current = detectionResult.value.data?.current_account + const currentTargetName = current?.matched_folder || current?.current_account + + if (!currentTargetName) return accounts + + // 置顶最近登录账号 return accounts.sort((a, b) => { - if (a.account_name === currentAccountName) return -1 - if (b.account_name === currentAccountName) return 1 + if (a.account_name === currentTargetName) return -1 + if (b.account_name === currentTargetName) return 1 return 0 }) }) + +const currentAccountInfo = computed(() => { + return detectionResult.value?.data?.current_account || null +}) + // 开始检测 const startDetection = async () => { loading.value = true @@ -357,7 +380,9 @@ const isCurrentAccount = (accountName) => { if (!detectionResult.value?.data?.current_account) { return false } - return detectionResult.value.data.current_account.current_account === accountName + const current = detectionResult.value.data.current_account + // 支持严格匹配或通过后缀兼容的匹配 + return accountName === current.matched_folder || accountName === current.current_account } // 页面加载时自动检测 diff --git a/src/wechat_decrypt_tool/routers/wechat_detection.py b/src/wechat_decrypt_tool/routers/wechat_detection.py index b2fe1c5..f64fc37 100644 --- a/src/wechat_decrypt_tool/routers/wechat_detection.py +++ b/src/wechat_decrypt_tool/routers/wechat_detection.py @@ -21,7 +21,21 @@ async def detect_wechat_detailed(data_root_path: Optional[str] = None): # 检测当前登录账号 current_account_info = detect_current_logged_in_account(data_root_path) + + # 【新增特性】目录匹配校验:处理目录名 wxid_xxxx_yyyy 与真实 wxid_xxxx 的适配 + if current_account_info and current_account_info.get("current_account"): + base_wxid = current_account_info["current_account"] + current_account_info["matched_folder"] = base_wxid # 默认兜底 + + # 遍历寻找以该 wxid 开头的用户文件夹(支持后缀匹配) + for acc in info.get("accounts", []): + acc_name = acc["account_name"] + if acc_name == base_wxid or acc_name.startswith(f"{base_wxid}_"): + current_account_info["matched_folder"] = acc_name + break + info['current_account'] = current_account_info + # logger.info(current_account_info) # 添加一些统计信息 stats = { diff --git a/src/wechat_decrypt_tool/wechat_detection.py b/src/wechat_decrypt_tool/wechat_detection.py index ced8056..d00dc1d 100644 --- a/src/wechat_decrypt_tool/wechat_detection.py +++ b/src/wechat_decrypt_tool/wechat_detection.py @@ -14,7 +14,6 @@ from ctypes import wintypes from datetime import datetime - def get_wx_db(msg_dir: str = None, db_types: Union[List[str], str] = None, wxids: Union[List[str], str] = None) -> List[dict]: @@ -49,7 +48,7 @@ def get_wx_db(msg_dir: str = None, wxid_dirs[os.path.basename(sub_dir)] = os.path.join(msg_dir, sub_dir) else: wxid_dirs[os.path.basename(msg_dir)] = msg_dir - + for wxid, wxid_dir in wxid_dirs.items(): if wxids and wxid not in wxids: # 如果指定wxid,则过滤掉其他wxid continue @@ -70,6 +69,7 @@ def get_wx_db(msg_dir: str = None, result.append({"wxid": wxid, "db_type": db_type, "db_path": db_path, "wxid_dir": wxid_dir}) return result + # Windows API 常量和结构 PROCESS_QUERY_INFORMATION = 0x0400 PROCESS_VM_READ = 0x0010 @@ -87,6 +87,7 @@ CreateToolhelp32Snapshot = kernel32.CreateToolhelp32Snapshot Process32FirstW = kernel32.Process32FirstW Process32NextW = kernel32.Process32NextW + class PROCESSENTRY32W(ctypes.Structure): _fields_ = [ ('dwSize', wintypes.DWORD), @@ -105,6 +106,98 @@ class PROCESSENTRY32W(ctypes.Structure): # 删除了WeChatDecryptor类,解密功能已移至独立的wechat_decrypt.py脚本 +def parse_global_config(base_path: str) -> dict: + """ + 解析 all_users/config/global_config 获取最近登录用户信息 + 基于 AES-128-CFB 解密,并解析 MMKV 的 Varint 格式 + """ + try: + import os + config_path = os.path.join(base_path, 'all_users', 'config', 'global_config') + if not os.path.exists(config_path): + return None + + with open(config_path, 'rb') as f: + full_data = f.read() + + if len(full_data) <= 4: + return None + + encrypted_data = full_data[4:] + + # 核心修复 1:强制截断取前 16 字节,等同于 Rust 中的 b"xwechat_crypt_ke" + key = b'xwechat_crypt_key'[:16] + iv = b'\0' * 16 + + # 尝试主流的两种密码库 + try: + from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes + from cryptography.hazmat.backends import default_backend + cipher = Cipher(algorithms.AES(key), modes.CFB(iv), backend=default_backend()) + decryptor = cipher.decryptor() + decrypted = decryptor.update(encrypted_data) + decryptor.finalize() + except ImportError: + from Crypto.Cipher import AES + # PyCryptodome 中 CFB 模式默认 segment_size 是 8,需要指定为 128 + cipher = AES.new(key, AES.MODE_CFB, iv=iv, segment_size=128) + decrypted = cipher.decrypt(encrypted_data) + + # MMKV Varint 长度解码 + def decode_varint(data, offset): + result = 0 + shift = 0 + while offset < len(data): + byte = data[offset] + offset += 1 + result |= (byte & 0x7f) << shift + if not (byte & 0x80): + break + shift += 7 + return result, offset + + def extract_mmkv_string(data: bytes, key_str: str) -> str: + key_bytes = key_str.encode('utf-8') + idx = data.find(key_bytes) + if idx == -1: return None + + offset = idx + len(key_bytes) + try: + value_len, offset = decode_varint(data, offset) + if value_len <= 0 or offset >= len(data): + return None + + str_len, offset = decode_varint(data, offset) + + if str_len > 0 and offset + str_len <= len(data): + return data[offset:offset + str_len].decode('utf-8', errors='ignore') + except Exception: + pass + return None + + + wxid = extract_mmkv_string(decrypted, 'mmkv_key_user_name') + nickname = extract_mmkv_string(decrypted, 'mmkv_key_nick_name') + avatar_url = extract_mmkv_string(decrypted, 'mmkv_key_head_img_url') + + # 核心修复 2:参考 Rust 逻辑,头像链接往往以 "/0" 结尾(微信头像的尺寸标识) + if not avatar_url and b'http' in decrypted: + http_idx = decrypted.find(b'http') + slash_zero_idx = decrypted.find(b'/0', http_idx) + if slash_zero_idx != -1: + # 包含 "/0" 这两个字符本身,所以是 +2 + avatar_url = decrypted[http_idx:slash_zero_idx + 2].decode('utf-8', errors='ignore') + + if wxid or nickname: + return { + "wxid": wxid, + "nickname": nickname, + "avatar": avatar_url + } + return None + except Exception as e: + print(f"[DEBUG] 解析 global_config 失败: {e}") + return None + def find_wechat_databases() -> List[str]: """在新的xwechat_files目录中查找微信数据库文件 @@ -119,13 +212,13 @@ def find_wechat_databases() -> List[str]: # 检查新的微信4.0+目录结构 wechat_dirs = [ documents_dir / "xwechat_files", # 新版微信4.0+ - documents_dir / "WeChat Files" # 旧版微信 + documents_dir / "WeChat Files" # 旧版微信 ] - + for wechat_dir in wechat_dirs: if not wechat_dir.exists(): continue - + # 查找用户目录(wxid_*模式) for user_dir in wechat_dir.iterdir(): if not user_dir.is_dir(): @@ -149,7 +242,7 @@ def find_wechat_databases() -> List[str]: for db_file in multi_dir.glob("*.db"): if db_file.is_file(): db_files.append(str(db_file)) - + return db_files @@ -158,7 +251,7 @@ def get_process_exe_path(process_id): h_process = OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, False, process_id) if not h_process: return None - + exe_path = ctypes.create_unicode_buffer(MAX_PATH) if GetModuleFileNameExW(h_process, None, exe_path, MAX_PATH) > 0: CloseHandle(h_process) @@ -167,35 +260,37 @@ def get_process_exe_path(process_id): CloseHandle(h_process) return None + def get_process_list(): """获取系统进程列表""" h_process_snap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) if h_process_snap == ctypes.wintypes.HANDLE(-1).value: return [] - + pe32 = PROCESSENTRY32W() pe32.dwSize = ctypes.sizeof(PROCESSENTRY32W) process_list = [] - + if not Process32FirstW(h_process_snap, ctypes.byref(pe32)): CloseHandle(h_process_snap) return [] - + while True: process_list.append((pe32.th32ProcessID, pe32.szExeFile)) if not Process32NextW(h_process_snap, ctypes.byref(pe32)): break - + CloseHandle(h_process_snap) return process_list + def auto_detect_wechat_data_dirs(): """ 自动检测微信数据目录 - 多策略组合检测 :return: 检测到的微信数据目录列表 """ detected_dirs = [] - + # 策略1:注册表检测已移除 # 策略2和策略3:注册表相关检测已移除 @@ -211,24 +306,24 @@ def auto_detect_wechat_data_dirs(): for drive in drives: if not os.path.exists(drive): continue - + try: # 扫描驱动器根目录和常见目录 scan_paths = [ drive + os.sep, os.path.join(drive + os.sep, "Users"), ] - + for scan_path in scan_paths: if not os.path.exists(scan_path): continue - + try: for item in os.listdir(scan_path): item_path = os.path.join(scan_path, item) if not os.path.isdir(item_path): continue - + # 检查是否匹配微信目录模式 for pattern in common_wechat_patterns: if pattern.lower() in item.lower(): @@ -242,7 +337,7 @@ def auto_detect_wechat_data_dirs(): continue except (PermissionError, OSError): continue - + # 策略2:进程内存分析(简化版) try: process_list = get_process_list() @@ -263,7 +358,7 @@ def auto_detect_wechat_data_dirs(): current = parent else: break - + for parent_dir in parent_dirs: for pattern in common_wechat_patterns: potential_dir = os.path.join(parent_dir, pattern) @@ -275,7 +370,7 @@ def auto_detect_wechat_data_dirs(): pass except: pass - + return detected_dirs @@ -300,6 +395,7 @@ def has_wxid_directories(directory): except: return False + def get_wx_dir_by_reg(wxid="all"): """ 通过多种方法获取微信目录 - 改进的自动检测 @@ -327,6 +423,7 @@ def get_wx_dir_by_reg(wxid="all"): return wx_dir if os.path.exists(wx_dir) else None + def detect_wechat_accounts_from_backup(backup_base_path: str = None) -> List[Dict[str, Any]]: """ 从指定的备份路径检测微信账号 @@ -393,8 +490,8 @@ def detect_wechat_accounts_from_backup(backup_base_path: str = None) -> List[Dic for data_item in os.listdir(backup_base_path): data_item_path = os.path.join(backup_base_path, data_item) if (os.path.isdir(data_item_path) and - data_item.startswith(f"{account_name}_") and - data_item != "Backup"): + data_item.startswith(f"{account_name}_") and + data_item != "Backup"): data_dir = data_item_path break except (PermissionError, OSError): @@ -525,9 +622,9 @@ def detect_wechat_accounts_from_login(login_base_path: str = None) -> List[Dict[ for data_item in os.listdir(base_path): data_item_path = os.path.join(base_path, data_item) if ( - os.path.isdir(data_item_path) - and data_item.startswith(f"{account_name}_") - and data_item not in ["Backup", "all_users"] + os.path.isdir(data_item_path) + and data_item.startswith(f"{account_name}_") + and data_item not in ["Backup", "all_users"] ): data_dir = data_item_path break @@ -551,6 +648,7 @@ def detect_wechat_accounts_from_login(login_base_path: str = None) -> List[Dict[ return accounts + def collect_account_databases(data_dir: str, account_name: str) -> List[Dict[str, Any]]: """ 收集指定账号数据目录下的所有数据库文件 @@ -801,40 +899,37 @@ def detect_wechat_installation(data_root_path: str | None = None) -> Dict[str, A def detect_current_logged_in_account(base_path: str = None) -> Dict[str, Any]: """ - 通过key_info.db文件时间检测当前登录的微信账号 - - Args: - base_path: 微信数据根目录,如果为None则自动检测 - - Returns: - 当前登录账号信息 + 通过 global_config 解析 或 key_info.db 时间检测当前登录的微信账号 """ - current_account = None - latest_time = None - - # 添加调试信息 - print(f"[DEBUG] 开始检测当前登录账号,提供的base_path: {base_path}") - - # 如果没有指定路径,尝试自动检测 + # print(f"[DEBUG] 开始检测当前登录账号,提供的base_path: {base_path}") + if base_path is None: detected_dirs = auto_detect_wechat_data_dirs() - print(f"[DEBUG] 自动检测到的目录: {detected_dirs}") if not detected_dirs: - return { - "current_account": None, - "latest_time": None, - "message": "未检测到微信数据目录" - } + return {"current_account": None, "message": "未检测到微信数据目录"} base_path = detected_dirs[0] - - print(f"[DEBUG] 使用的base_path: {base_path}") - - # 查找登录信息目录 - 尝试多个可能的路径 + + # 1. 新特性:优先尝试从 global_config 解析完整用户信息 + parsed_config = parse_global_config(base_path) + if parsed_config and parsed_config.get('wxid'): + print(f"[DEBUG] 从 global_config 成功解析出账号: {parsed_config['wxid']}") + return { + "current_account": parsed_config["wxid"], # 不带校验位的 wxid + "nickname": parsed_config.get("nickname"), + "avatar": parsed_config.get("avatar"), + "latest_time": None, + "message": f"通过 global_config 检测到最近登录账号: {parsed_config['wxid']}" + } + + # 2. 降级回退机制:原先基于 key_info.db 的时间探测逻辑 + latest_time = None + current_account = None + possible_login_paths = [ - os.path.join(base_path, "all_users", "login"), # 标准路径 - os.path.join(base_path, "login"), # 备选路径1 + os.path.join(base_path, "all_users", "login"), + os.path.join(base_path, "login"), ] - + # 也尝试在子目录中查找 try: for item in os.listdir(base_path): @@ -842,11 +937,11 @@ def detect_current_logged_in_account(base_path: str = None) -> Dict[str, Any]: if os.path.isdir(item_path): possible_login_paths.extend([ os.path.join(item_path, "all_users", "login"), # 子目录中的标准路径 - os.path.join(item_path, "login"), # 子目录中的备选路径 + os.path.join(item_path, "login"), # 子目录中的备选路径 ]) except (PermissionError, OSError): pass - + login_dir = None for path in possible_login_paths: print(f"[DEBUG] 检查路径: {path}") @@ -854,49 +949,49 @@ def detect_current_logged_in_account(base_path: str = None) -> Dict[str, Any]: login_dir = path print(f"[DEBUG] 找到登录目录: {login_dir}") break - + if not login_dir: return { "current_account": None, "latest_time": None, "message": f"未找到登录信息目录,尝试的路径: {possible_login_paths}" } - + try: # 遍历登录目录下的所有账号文件夹 items = os.listdir(login_dir) print(f"[DEBUG] 登录目录内容: {items}") - + for item in items: item_path = os.path.join(login_dir, item) print(f"[DEBUG] 检查项目: {item}, 路径: {item_path}, 是否为目录: {os.path.isdir(item_path)}") - + if not os.path.isdir(item_path): continue - + # 检查key_info.db文件 key_info_path = os.path.join(item_path, "key_info.db") print(f"[DEBUG] 检查key_info.db文件: {key_info_path}, 是否存在: {os.path.exists(key_info_path)}") - + if not os.path.exists(key_info_path): continue - + # 获取文件修改时间 try: file_time = os.path.getmtime(key_info_path) file_datetime = datetime.fromtimestamp(file_time) print(f"[DEBUG] 找到key_info.db文件: {key_info_path}, 修改时间: {file_datetime}") - + # 更新最新登录的账号 if latest_time is None or file_time > latest_time: latest_time = file_time current_account = item print(f"[DEBUG] 更新最新登录账号: {current_account}, 时间: {file_datetime}") - + except OSError as e: print(f"[DEBUG] 无法获取文件时间: {key_info_path}, 错误: {e}") continue - + except (PermissionError, OSError) as e: print(f"[DEBUG] 无法访问登录目录: {login_dir}, 错误: {e}") return { @@ -904,7 +999,7 @@ def detect_current_logged_in_account(base_path: str = None) -> Dict[str, Any]: "latest_time": None, "message": f"无法访问登录目录: {e}" } - + if current_account: print(f"[DEBUG] 最终结果: 当前登录账号 {current_account}, 时间 {latest_time}") return { From 37666b33720f57795ea9d83898dd3a1545e65a64 Mon Sep 17 00:00:00 2001 From: H3CoF6 <1707889225@qq.com> Date: Thu, 9 Apr 2026 01:36:06 +0800 Subject: [PATCH 8/8] fix: fix api path error --- frontend/pages/import.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/pages/import.vue b/frontend/pages/import.vue index aeb23a8..18eb5fe 100644 --- a/frontend/pages/import.vue +++ b/frontend/pages/import.vue @@ -222,7 +222,7 @@ const confirmImport = async () => { importProgress.value = 0 importMessage.value = '启动导入程序...' - const url = new URL(`${apiBase.replace(/\/$/, '')}/api/import_decrypted`, window.location.origin) + const url = new URL(`${apiBase.replace(/\/$/, '')}/import_decrypted`, window.location.origin) url.searchParams.set('import_path', selectedImportPath.value) if (eventSource) eventSource.close()