Compare commits

..

14 Commits

23 changed files with 4453 additions and 115 deletions
+1
View File
@@ -8,6 +8,7 @@
<p><b>特别致谢</b><a href="https://github.com/ycccccccy/echotrace">echotrace</a>(本项目大量功能参考其实现,提供了重要技术支持)</p>
<img src="https://img.shields.io/github/v/tag/LifeArchiveProject/WeChatDataAnalysis" alt="Version" />
<img src="https://img.shields.io/github/stars/LifeArchiveProject/WeChatDataAnalysis" alt="Stars" />
<img src="https://gh-down-badges.linkof.link/LifeArchiveProject/WeChatDataAnalysis" alt="Downloads" />
<img src="https://img.shields.io/github/forks/LifeArchiveProject/WeChatDataAnalysis" alt="Forks" />
<img src="https://img.shields.io/github/license/LifeArchiveProject/WeChatDataAnalysis" alt="License" />
<img src="https://img.shields.io/badge/Python-3776AB?logo=Python&logoColor=white" alt="Python" />
+75
View File
@@ -78,3 +78,78 @@ Function WDA_InstallDirPageLeave
FunctionEnd
!endif
!ifdef BUILD_UNINSTALLER
!include nsDialogs.nsh
!include LogicLib.nsh
Var WDA_UninstallOptionsPage
Var WDA_UninstallDeleteDataCheckbox
Var /GLOBAL WDA_DeleteUserData
!macro customUnInit
; Default: keep user data (also applies to silent uninstall / update uninstall).
StrCpy $WDA_DeleteUserData "0"
!macroend
!macro customUnWelcomePage
!insertmacro MUI_UNPAGE_WELCOME
; Optional page: allow user to choose whether to delete app data.
UninstPage custom un.WDA_UninstallOptionsCreate un.WDA_UninstallOptionsLeave
!macroend
Function un.WDA_UninstallOptionsCreate
nsDialogs::Create 1018
Pop $WDA_UninstallOptionsPage
${If} $WDA_UninstallOptionsPage == error
Abort
${EndIf}
${NSD_CreateLabel} 0u 0u 100% 24u "卸载选项:"
Pop $0
${NSD_CreateCheckbox} 0u 24u 100% 12u "同时删除用户数据(导出的聊天记录、日志、配置等)"
Pop $WDA_UninstallDeleteDataCheckbox
; Safer default: do not delete.
${NSD_Uncheck} $WDA_UninstallDeleteDataCheckbox
nsDialogs::Show
FunctionEnd
Function un.WDA_UninstallOptionsLeave
${NSD_GetState} $WDA_UninstallDeleteDataCheckbox $0
${If} $0 == ${BST_CHECKED}
StrCpy $WDA_DeleteUserData "1"
${Else}
StrCpy $WDA_DeleteUserData "0"
${EndIf}
FunctionEnd
!macro customUnInstall
; If this is an update uninstall, never delete user data.
${ifNot} ${isUpdated}
${if} $WDA_DeleteUserData == "1"
; Electron always stores user data per-user. If the app was installed for all users,
; switch to current user context to remove the correct AppData directory.
${if} $installMode == "all"
SetShellVarContext current
${endif}
RMDir /r "$APPDATA\${APP_FILENAME}"
!ifdef APP_PRODUCT_FILENAME
RMDir /r "$APPDATA\${APP_PRODUCT_FILENAME}"
!endif
; Electron may use package.json "name" for some storage (cache, indexeddb, etc.).
!ifdef APP_PACKAGE_NAME
RMDir /r "$APPDATA\${APP_PACKAGE_NAME}"
!endif
${if} $installMode == "all"
SetShellVarContext all
${endif}
${endif}
${endif}
!macroend
!endif
Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

+303 -2
View File
@@ -2,6 +2,7 @@ const {
app,
BrowserWindow,
Menu,
Tray,
ipcMain,
globalShortcut,
dialog,
@@ -18,19 +19,86 @@ const BACKEND_HEALTH_URL = `http://${BACKEND_HOST}:${BACKEND_PORT}/api/health`;
let backendProc = null;
let backendStdioStream = null;
let resolvedDataDir = null;
let mainWindow = null;
let tray = null;
let isQuitting = false;
let desktopSettings = null;
function nowIso() {
return new Date().toISOString();
}
function getUserDataDir() {
function resolveDataDir() {
if (resolvedDataDir) return resolvedDataDir;
const fromEnv = String(process.env.WECHAT_TOOL_DATA_DIR || "").trim();
const fallback = (() => {
try {
return app.getPath("userData");
} catch {
return null;
}
})();
const chosen = fromEnv || fallback;
if (!chosen) return null;
try {
return app.getPath("userData");
fs.mkdirSync(chosen, { recursive: true });
} catch {}
resolvedDataDir = chosen;
process.env.WECHAT_TOOL_DATA_DIR = chosen;
return chosen;
}
function getUserDataDir() {
// Backwards-compat: we historically used Electron's userData directory for runtime storage.
// Keep this name but resolve to the effective data dir (can be overridden via env).
return resolveDataDir();
}
function getExeDir() {
try {
return path.dirname(process.execPath);
} catch {
return null;
}
}
function ensureOutputLink() {
// Users often expect an `output/` folder near the installed exe. We keep the real data
// in the per-user data dir, and (when possible) create a Windows junction next to the exe.
if (!app.isPackaged) return;
const exeDir = getExeDir();
const dataDir = resolveDataDir();
if (!exeDir || !dataDir) return;
const target = path.join(dataDir, "output");
const linkPath = path.join(exeDir, "output");
// If the target doesn't exist yet, create it so the link points somewhere real.
try {
fs.mkdirSync(target, { recursive: true });
} catch {}
// If something already exists at linkPath, do not overwrite it.
try {
if (fs.existsSync(linkPath)) return;
} catch {
return;
}
try {
fs.symlinkSync(target, linkPath, "junction");
logMain(`[main] created output link: ${linkPath} -> ${target}`);
} catch (err) {
logMain(`[main] failed to create output link: ${err?.message || err}`);
}
}
function getMainLogPath() {
const dir = getUserDataDir();
if (!dir) return null;
@@ -46,6 +114,163 @@ function logMain(line) {
} catch {}
}
function getDesktopSettingsPath() {
const dir = getUserDataDir();
if (!dir) return null;
return path.join(dir, "desktop-settings.json");
}
function loadDesktopSettings() {
if (desktopSettings) return desktopSettings;
const defaults = {
// 'tray' (default): closing the window hides it to the system tray.
// 'exit': closing the window quits the app.
closeBehavior: "tray",
};
const p = getDesktopSettingsPath();
if (!p) {
desktopSettings = { ...defaults };
return desktopSettings;
}
try {
if (!fs.existsSync(p)) {
desktopSettings = { ...defaults };
return desktopSettings;
}
const raw = fs.readFileSync(p, { encoding: "utf8" });
const parsed = JSON.parse(raw || "{}");
desktopSettings = { ...defaults, ...(parsed && typeof parsed === "object" ? parsed : {}) };
} catch (err) {
desktopSettings = { ...defaults };
logMain(`[main] failed to load settings: ${err?.message || err}`);
}
return desktopSettings;
}
function persistDesktopSettings() {
const p = getDesktopSettingsPath();
if (!p) return;
if (!desktopSettings) return;
try {
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.writeFileSync(p, JSON.stringify(desktopSettings, null, 2), { encoding: "utf8" });
} catch (err) {
logMain(`[main] failed to persist settings: ${err?.message || err}`);
}
}
function getCloseBehavior() {
const v = String(loadDesktopSettings()?.closeBehavior || "").trim().toLowerCase();
return v === "exit" ? "exit" : "tray";
}
function setCloseBehavior(next) {
const v = String(next || "").trim().toLowerCase();
loadDesktopSettings();
desktopSettings.closeBehavior = v === "exit" ? "exit" : "tray";
persistDesktopSettings();
return desktopSettings.closeBehavior;
}
function getTrayIconPath() {
// Prefer an icon shipped in `src/` so it works both in dev and packaged (asar) builds.
const shipped = path.join(__dirname, "icon.ico");
try {
if (fs.existsSync(shipped)) return shipped;
} catch {}
// Dev fallback (not available in packaged builds).
const dev = path.resolve(__dirname, "..", "build", "icon.ico");
try {
if (fs.existsSync(dev)) return dev;
} catch {}
return null;
}
function showMainWindow() {
if (!mainWindow) return;
try {
mainWindow.setSkipTaskbar(false);
} catch {}
try {
if (mainWindow.isMinimized()) mainWindow.restore();
} catch {}
try {
mainWindow.show();
} catch {}
try {
mainWindow.focus();
} catch {}
}
function createTray() {
if (tray) return tray;
if (!app.isPackaged) return null;
const iconPath = getTrayIconPath();
if (!iconPath) {
logMain("[main] tray icon not found; disabling tray behavior");
return null;
}
try {
tray = new Tray(iconPath);
} catch (err) {
tray = null;
logMain(`[main] failed to create tray: ${err?.message || err}`);
return null;
}
try {
tray.setToolTip("WeChatDataAnalysis");
} catch {}
try {
tray.setContextMenu(
Menu.buildFromTemplate([
{
label: "显示",
click: () => showMainWindow(),
},
{
label: "退出",
click: () => {
isQuitting = true;
app.quit();
},
},
])
);
} catch {}
try {
tray.on("click", () => showMainWindow());
tray.on("double-click", () => showMainWindow());
} catch {}
return tray;
}
function destroyTray() {
if (!tray) return;
try {
tray.destroy();
} catch {}
tray = null;
}
function ensureTrayForCloseBehavior() {
const behavior = getCloseBehavior();
if (behavior === "tray") createTray();
else destroyTray();
}
function getBackendStdioLogPath(dataDir) {
return path.join(dataDir, "backend-stdio.log");
}
@@ -272,6 +497,26 @@ function createMainWindow() {
},
});
win.on("close", (event) => {
// In packaged builds, we default to "close -> minimize to tray" unless the user opts out.
if (!app.isPackaged) return;
if (isQuitting) return;
if (getCloseBehavior() !== "tray") return;
if (!tray) return;
try {
event.preventDefault();
win.setSkipTaskbar(true);
win.hide();
try {
tray.displayBalloon({
title: "WeChatDataAnalysis",
content: "已最小化到托盘,可从托盘图标再次打开。",
});
} catch {}
} catch {}
});
win.on("closed", () => {
stopBackend();
});
@@ -319,6 +564,53 @@ function registerWindowIpc() {
const win = getWin(event);
return !!win?.isMaximized();
});
ipcMain.handle("app:getAutoLaunch", () => {
try {
const settings = app.getLoginItemSettings();
return !!(settings?.openAtLogin || settings?.executableWillLaunchAtLogin);
} catch (err) {
logMain(`[main] getAutoLaunch failed: ${err?.message || err}`);
return false;
}
});
ipcMain.handle("app:setAutoLaunch", (_event, enabled) => {
const on = !!enabled;
try {
app.setLoginItemSettings({ openAtLogin: on });
} catch (err) {
logMain(`[main] setAutoLaunch(${on}) failed: ${err?.message || err}`);
return false;
}
try {
const settings = app.getLoginItemSettings();
return !!(settings?.openAtLogin || settings?.executableWillLaunchAtLogin);
} catch {
return on;
}
});
ipcMain.handle("app:getCloseBehavior", () => {
try {
return getCloseBehavior();
} catch (err) {
logMain(`[main] getCloseBehavior failed: ${err?.message || err}`);
return "tray";
}
});
ipcMain.handle("app:setCloseBehavior", (_event, behavior) => {
try {
const next = setCloseBehavior(behavior);
ensureTrayForCloseBehavior();
return next;
} catch (err) {
logMain(`[main] setCloseBehavior failed: ${err?.message || err}`);
return getCloseBehavior();
}
});
}
async function main() {
@@ -327,12 +619,19 @@ async function main() {
registerWindowIpc();
registerDebugShortcuts();
// Resolve/create the data dir early so we can log reliably and (optionally) place a link
// next to the installed exe for easier access.
resolveDataDir();
ensureOutputLink();
logMain(`[main] app.isPackaged=${app.isPackaged} argv=${JSON.stringify(process.argv)}`);
startBackend();
await waitForBackend({ timeoutMs: 30_000 });
const win = createMainWindow();
mainWindow = win;
ensureTrayForCloseBehavior();
const startUrl =
process.env.ELECTRON_START_URL ||
@@ -360,6 +659,8 @@ app.on("will-quit", () => {
});
app.on("before-quit", () => {
isQuitting = true;
destroyTray();
stopBackend();
});
+6 -1
View File
@@ -5,5 +5,10 @@ contextBridge.exposeInMainWorld("wechatDesktop", {
toggleMaximize: () => ipcRenderer.invoke("window:toggleMaximize"),
close: () => ipcRenderer.invoke("window:close"),
isMaximized: () => ipcRenderer.invoke("window:isMaximized"),
});
getAutoLaunch: () => ipcRenderer.invoke("app:getAutoLaunch"),
setAutoLaunch: (enabled) => ipcRenderer.invoke("app:setAutoLaunch", !!enabled),
getCloseBehavior: () => ipcRenderer.invoke("app:getCloseBehavior"),
setCloseBehavior: (behavior) => ipcRenderer.invoke("app:setCloseBehavior", String(behavior || "")),
});
+20 -1
View File
@@ -14,12 +14,23 @@
// So we detect desktop onMounted and update reactively.
const isDesktop = ref(false)
const updateDprVar = () => {
const dpr = window.devicePixelRatio || 1
document.documentElement.style.setProperty('--dpr', String(dpr))
}
onMounted(() => {
isDesktop.value = !!window?.wechatDesktop
updateDprVar()
window.addEventListener('resize', updateDprVar)
})
onBeforeUnmount(() => {
window.removeEventListener('resize', updateDprVar)
})
const route = useRoute()
const isChatRoute = computed(() => route.path?.startsWith('/chat'))
const isChatRoute = computed(() => route.path?.startsWith('/chat') || route.path?.startsWith('/sns'))
const rootClass = computed(() => {
const base = 'bg-gradient-to-br from-green-50 via-emerald-50 to-green-100'
@@ -34,6 +45,14 @@ const contentClass = computed(() =>
</script>
<style>
:root {
--dpr: 1;
/* Left sidebar rail (chat/sns): icon size + spacing */
--sidebar-rail-step: 48px;
--sidebar-rail-btn: 32px;
--sidebar-rail-icon: 24px;
}
/* Electron 桌面端使用自绘标题栏(frame: false)。
* 页面里如果继续用 Tailwind 的 h-screen/min-h-screen100vh),会把标题栏高度叠加进去,从而出现外层滚动条。
* 这里把 “screen” 在桌面端视为内容区高度(100%),让标题栏高度自然内嵌在布局里。 */
+43
View File
@@ -179,6 +179,46 @@ export const useApi = () => {
return await request(url)
}
// 朋友圈时间线
const listSnsTimeline = async (params = {}) => {
const query = new URLSearchParams()
if (params && params.account) query.set('account', params.account)
if (params && params.limit != null) query.set('limit', String(params.limit))
if (params && params.offset != null) query.set('offset', String(params.offset))
if (params && params.usernames && Array.isArray(params.usernames) && params.usernames.length > 0) {
query.set('usernames', params.usernames.join(','))
} else if (params && params.usernames && typeof params.usernames === 'string') {
query.set('usernames', params.usernames)
}
if (params && params.keyword) query.set('keyword', params.keyword)
const url = '/sns/timeline' + (query.toString() ? `?${query.toString()}` : '')
return await request(url)
}
// 朋友圈图片本地缓存候选(用于错图时手动选择)
const listSnsMediaCandidates = async (params = {}) => {
const query = new URLSearchParams()
if (params && params.account) query.set('account', params.account)
if (params && params.create_time != null) query.set('create_time', String(params.create_time))
if (params && params.width != null) query.set('width', String(params.width))
if (params && params.height != null) query.set('height', String(params.height))
if (params && params.limit != null) query.set('limit', String(params.limit))
if (params && params.offset != null) query.set('offset', String(params.offset))
const url = '/sns/media_candidates' + (query.toString() ? `?${query.toString()}` : '')
return await request(url)
}
// 保存朋友圈图片手动匹配结果(本机)
const saveSnsMediaPicks = async (data = {}) => {
return await request('/sns/media_picks', {
method: 'POST',
body: {
account: data.account || null,
picks: (data && data.picks && typeof data.picks === 'object' && !Array.isArray(data.picks)) ? data.picks : {}
}
})
}
const openChatMediaFolder = async (params = {}) => {
const query = new URLSearchParams()
if (params && params.account) query.set('account', params.account)
@@ -288,6 +328,9 @@ export const useApi = () => {
buildChatSearchIndex,
listChatSearchSenders,
getChatMessagesAround,
listSnsTimeline,
listSnsMediaCandidates,
saveSnsMediaPicks,
openChatMediaFolder,
downloadChatEmoji,
saveMediaKeys,
File diff suppressed because it is too large Load Diff
+26 -1
View File
@@ -55,6 +55,31 @@
</template>
<script setup>
import { onMounted } from 'vue'
import { useApi } from '~/composables/useApi'
const DESKTOP_SETTING_DEFAULT_TO_CHAT_KEY = 'desktop.settings.defaultToChatWhenData'
onMounted(async () => {
if (!process.client || typeof window === 'undefined') return
if (!window.wechatDesktop) return
let enabled = false
try {
enabled = localStorage.getItem(DESKTOP_SETTING_DEFAULT_TO_CHAT_KEY) === 'true'
} catch {}
if (!enabled) return
try {
const api = useApi()
const resp = await api.listChatAccounts()
const accounts = resp?.accounts || []
if (accounts.length) {
await navigateTo('/chat', { replace: true })
}
} catch {}
})
// 开始检测并跳转到结果页面
const startDetection = async () => {
// 直接跳转到检测结果页面,让该页面处理检测
@@ -100,4 +125,4 @@ const startDetection = async () => {
linear-gradient(90deg, rgba(7, 193, 96, 0.1) 1px, transparent 1px);
background-size: 50px 50px;
}
</style>
</style>
File diff suppressed because it is too large Load Diff
+2
View File
@@ -18,6 +18,7 @@ from .routers.decrypt import router as _decrypt_router
from .routers.health import router as _health_router
from .routers.keys import router as _keys_router
from .routers.media import router as _media_router
from .routers.sns import router as _sns_router
from .routers.wechat_detection import router as _wechat_detection_router
from .wcdb_realtime import WCDB_REALTIME, shutdown as _wcdb_shutdown
@@ -51,6 +52,7 @@ app.include_router(_media_router)
app.include_router(_chat_router)
app.include_router(_chat_export_router)
app.include_router(_chat_media_router)
app.include_router(_sns_router)
class _SPAStaticFiles(StaticFiles):
@@ -894,6 +894,7 @@ def _parse_message_for_export(
content_text = raw_text
title = ""
url = ""
from_name = ""
record_item = ""
image_md5 = ""
image_file_id = ""
@@ -934,6 +935,7 @@ def _parse_message_for_export(
content_text = str(parsed.get("content") or "")
title = str(parsed.get("title") or "")
url = str(parsed.get("url") or "")
from_name = str(parsed.get("from") or "")
record_item = str(parsed.get("recordItem") or "")
quote_title = str(parsed.get("quoteTitle") or "")
quote_content = str(parsed.get("quoteContent") or "")
@@ -1162,6 +1164,7 @@ def _parse_message_for_export(
"content": content_text,
"title": title,
"url": url,
"from": from_name,
"recordItem": record_item,
"thumbUrl": thumb_url,
"imageMd5": image_md5,
+70 -2
View File
@@ -773,7 +773,21 @@ def _parse_app_message(text: str) -> dict[str, Any]:
app_type = 0
title = _extract_xml_tag_text(text, "title")
des = _extract_xml_tag_text(text, "des")
url = _extract_xml_tag_text(text, "url")
url = _normalize_xml_url(_extract_xml_tag_text(text, "url"))
# Some appmsg payloads (notably mp.weixin.qq.com link shares) include a "source" block:
# <sourceusername>gh_xxx</sourceusername>
# <sourcedisplayname>公众号名</sourcedisplayname>
# We'll surface that as `from` so the frontend can render the publisher line like WeChat.
source_display_name = (
_extract_xml_tag_text(text, "sourcedisplayname")
or _extract_xml_tag_text(text, "sourceDisplayName")
or _extract_xml_tag_text(text, "appname")
)
source_username = (
_extract_xml_tag_text(text, "sourceusername")
or _extract_xml_tag_text(text, "sourceUsername")
)
lower = text.lower()
@@ -794,13 +808,15 @@ def _parse_app_message(text: str) -> dict[str, Any]:
}
if app_type in (5, 68) and url:
thumb_url = _extract_xml_tag_text(text, "thumburl")
thumb_url = _normalize_xml_url(_extract_xml_tag_text(text, "thumburl"))
return {
"renderType": "link",
"content": des or title or "[链接]",
"title": title or des or "",
"url": url,
"thumbUrl": thumb_url or "",
"from": str(source_display_name or "").strip(),
"fromUsername": str(source_username or "").strip(),
}
if app_type in (6, 74):
@@ -1322,6 +1338,58 @@ def _load_contact_rows(contact_db_path: Path, usernames: list[str]) -> dict[str,
conn.close()
def _load_usernames_by_display_names(contact_db_path: Path, names: list[str]) -> dict[str, str]:
"""Best-effort mapping from display name -> username using contact.db.
Some appmsg/link payloads only provide `sourcedisplayname` (surfaced as `from`) but not
`sourceusername` (`fromUsername`). We use this mapping to recover `fromUsername` so the
frontend can render the publisher avatar via `/api/chat/avatar`.
"""
uniq = list(dict.fromkeys([str(n or "").strip() for n in names if str(n or "").strip()]))
if not uniq:
return {}
placeholders = ",".join(["?"] * len(uniq))
hits: dict[str, set[str]] = {}
conn = sqlite3.connect(str(contact_db_path))
conn.row_factory = sqlite3.Row
try:
def query_table(table: str) -> None:
for col in ("remark", "nick_name", "alias"):
sql = f"""
SELECT username, {col} AS display_name
FROM {table}
WHERE {col} IN ({placeholders})
"""
try:
rows = conn.execute(sql, uniq).fetchall()
except Exception:
rows = []
for r in rows:
try:
dn = str(r["display_name"] or "").strip()
u = str(r["username"] or "").strip()
except Exception:
continue
if not dn or not u:
continue
hits.setdefault(dn, set()).add(u)
query_table("contact")
query_table("stranger")
# Only return unambiguous mappings (display name -> exactly 1 username).
out: dict[str, str] = {}
for dn, users in hits.items():
if len(users) == 1:
out[dn] = next(iter(users))
return out
finally:
conn.close()
def _make_search_tokens(q: str) -> list[str]:
tokens = [t for t in re.split(r"\s+", str(q or "").strip()) if t]
if len(tokens) > 8:
+15 -8
View File
@@ -3,6 +3,7 @@
"""
import logging
import os
import sys
from datetime import datetime
from pathlib import Path
@@ -58,6 +59,11 @@ class WeChatLogger:
def setup_logging(self, log_level: str = "INFO"):
"""设置日志配置"""
# Allow overriding via env var for easier debugging (e.g. WECHAT_TOOL_LOG_LEVEL=DEBUG)
env_level = str(os.environ.get("WECHAT_TOOL_LOG_LEVEL", "") or "").strip()
if env_level:
log_level = env_level
# 创建日志目录
now = datetime.now()
log_dir = Path("output/logs") / str(now.year) / f"{now.month:02d}" / f"{now.day:02d}"
@@ -88,46 +94,47 @@ class WeChatLogger:
# 文件处理器
file_handler = logging.FileHandler(self.log_file, encoding='utf-8')
file_handler.setFormatter(file_formatter)
file_handler.setLevel(getattr(logging, log_level.upper()))
level = getattr(logging, str(log_level or "INFO").upper(), logging.INFO)
file_handler.setLevel(level)
# 控制台处理器
console_handler = logging.StreamHandler(sys.stdout)
console_handler.setFormatter(console_formatter)
console_handler.setLevel(getattr(logging, log_level.upper()))
console_handler.setLevel(level)
# 配置根日志器
root_logger.setLevel(getattr(logging, log_level.upper()))
root_logger.setLevel(level)
root_logger.addHandler(file_handler)
root_logger.addHandler(console_handler)
# 只为uvicorn日志器添加文件处理器,保持其原有的控制台处理器(带颜色)
uvicorn_logger = logging.getLogger("uvicorn")
uvicorn_logger.addHandler(file_handler)
uvicorn_logger.setLevel(getattr(logging, log_level.upper()))
uvicorn_logger.setLevel(level)
# 只为uvicorn.access日志器添加文件处理器
uvicorn_access_logger = logging.getLogger("uvicorn.access")
uvicorn_access_logger.addHandler(file_handler)
uvicorn_access_logger.setLevel(getattr(logging, log_level.upper()))
uvicorn_access_logger.setLevel(level)
# 只为uvicorn.error日志器添加文件处理器
uvicorn_error_logger = logging.getLogger("uvicorn.error")
uvicorn_error_logger.addHandler(file_handler)
uvicorn_error_logger.setLevel(getattr(logging, log_level.upper()))
uvicorn_error_logger.setLevel(level)
# 配置FastAPI日志器
fastapi_logger = logging.getLogger("fastapi")
fastapi_logger.handlers = []
fastapi_logger.addHandler(file_handler)
fastapi_logger.addHandler(console_handler)
fastapi_logger.setLevel(getattr(logging, log_level.upper()))
fastapi_logger.setLevel(level)
# 记录初始化信息
logger = logging.getLogger(__name__)
logger.info("=" * 60)
logger.info("微信解密工具日志系统初始化完成")
logger.info(f"日志文件: {self.log_file}")
logger.info(f"日志级别: {log_level}")
logger.info(f"日志级别: {logging.getLevelName(level)}")
logger.info("=" * 60)
return self.log_file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large Load Diff
@@ -408,6 +408,110 @@ def _detect_media_type_and_ext(data: bytes) -> tuple[bytes, str, str]:
return payload, media_type, ext
def _is_allowed_proxy_image_host(host: str) -> bool:
"""Allowlist hosts for proxying images to avoid turning this into a general SSRF gadget."""
h = str(host or "").strip().lower()
if not h:
return False
# WeChat public account/article thumbnails and avatars commonly live on these CDNs.
return h.endswith(".qpic.cn") or h.endswith(".qlogo.cn") or h.endswith(".tc.qq.com")
@router.get("/api/chat/media/proxy_image", summary="代理获取远程图片(解决微信公众号图片防盗链)")
async def proxy_image(url: str):
u = html.unescape(str(url or "")).strip()
if not u:
raise HTTPException(status_code=400, detail="Missing url.")
if not _is_safe_http_url(u):
raise HTTPException(status_code=400, detail="Invalid url (only public http/https allowed).")
try:
p = urlparse(u)
except Exception:
raise HTTPException(status_code=400, detail="Invalid url.")
host = (p.hostname or "").strip().lower()
if not _is_allowed_proxy_image_host(host):
raise HTTPException(status_code=400, detail="Unsupported url host for proxy_image.")
def _download_bytes() -> tuple[bytes, str]:
base_headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120 Safari/537.36",
"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
}
# Different Tencent CDNs enforce different anti-hotlink rules.
# Try a couple of safe referers so Moments(qpic) and MP(qpic) both work.
header_variants = [
{"Referer": "https://wx.qq.com/", "Origin": "https://wx.qq.com"},
{"Referer": "https://mp.weixin.qq.com/", "Origin": "https://mp.weixin.qq.com"},
{"Referer": "https://www.baidu.com/", "Origin": "https://www.baidu.com"},
{},
]
last_err: Exception | None = None
for extra in header_variants:
headers = dict(base_headers)
headers.update(extra)
r = requests.get(u, headers=headers, timeout=20, stream=True)
try:
r.raise_for_status()
content_type = str(r.headers.get("Content-Type") or "").strip()
max_bytes = 10 * 1024 * 1024
chunks: list[bytes] = []
total = 0
for ch in r.iter_content(chunk_size=64 * 1024):
if not ch:
continue
chunks.append(ch)
total += len(ch)
if total > max_bytes:
raise HTTPException(status_code=400, detail="Proxy image too large (>10MB).")
return b"".join(chunks), content_type
except HTTPException:
# Hard failure, don't retry with another referer.
raise
except Exception as e:
last_err = e
finally:
try:
r.close()
except Exception:
pass
# All variants failed.
raise last_err or RuntimeError("proxy_image download failed")
try:
data, ct = await asyncio.to_thread(_download_bytes)
except HTTPException:
raise
except Exception as e:
logger.warning(f"proxy_image failed: url={u} err={e}")
raise HTTPException(status_code=502, detail=f"Proxy image failed: {e}")
if not data:
raise HTTPException(status_code=502, detail="Proxy returned empty body.")
payload, media_type, _ext = _detect_media_type_and_ext(data)
# Prefer upstream Content-Type when it looks like an image (sniffing may fail for some formats).
if media_type == "application/octet-stream" and ct:
try:
mt = ct.split(";")[0].strip()
if mt.startswith("image/"):
media_type = mt
except Exception:
pass
if not str(media_type or "").startswith("image/"):
raise HTTPException(status_code=502, detail="Proxy did not return an image.")
resp = Response(content=payload, media_type=media_type)
resp.headers["Cache-Control"] = "public, max-age=86400"
return resp
@router.post("/api/chat/media/emoji/download", summary="下载表情消息资源到本地 resource")
async def download_chat_emoji(req: EmojiDownloadRequest):
md5 = str(req.md5 or "").strip().lower()
File diff suppressed because it is too large Load Diff
+154
View File
@@ -68,6 +68,13 @@ def _load_wcdb_lib() -> ctypes.CDLL:
lib.wcdb_close_account.argtypes = [ctypes.c_int64]
lib.wcdb_close_account.restype = ctypes.c_int
# Optional: wcdb_set_my_wxid(handle, wxid)
try:
lib.wcdb_set_my_wxid.argtypes = [ctypes.c_int64, ctypes.c_char_p]
lib.wcdb_set_my_wxid.restype = ctypes.c_int
except Exception:
pass
lib.wcdb_get_sessions.argtypes = [ctypes.c_int64, ctypes.POINTER(ctypes.c_char_p)]
lib.wcdb_get_sessions.restype = ctypes.c_int
@@ -95,6 +102,37 @@ def _load_wcdb_lib() -> ctypes.CDLL:
lib.wcdb_get_group_members.argtypes = [ctypes.c_int64, ctypes.c_char_p, ctypes.POINTER(ctypes.c_char_p)]
lib.wcdb_get_group_members.restype = ctypes.c_int
# Optional: execute arbitrary SQL on a selected database kind/path.
# Signature: wcdb_exec_query(handle, kind, path, sql, out_json)
try:
lib.wcdb_exec_query.argtypes = [
ctypes.c_int64,
ctypes.c_char_p,
ctypes.c_char_p,
ctypes.c_char_p,
ctypes.POINTER(ctypes.c_char_p),
]
lib.wcdb_exec_query.restype = ctypes.c_int
except Exception:
pass
# Optional (newer DLLs): wcdb_get_sns_timeline(handle, limit, offset, usernames_json, keyword, start_time, end_time, out_json)
try:
lib.wcdb_get_sns_timeline.argtypes = [
ctypes.c_int64,
ctypes.c_int32,
ctypes.c_int32,
ctypes.c_char_p,
ctypes.c_char_p,
ctypes.c_int32,
ctypes.c_int32,
ctypes.POINTER(ctypes.c_char_p),
]
lib.wcdb_get_sns_timeline.restype = ctypes.c_int
except Exception:
# Older wcdb_api.dll may not expose this export.
pass
lib.wcdb_get_logs.argtypes = [ctypes.POINTER(ctypes.c_char_p)]
lib.wcdb_get_logs.restype = ctypes.c_int
@@ -195,6 +233,30 @@ def open_account(session_db_path: Path, key_hex: str) -> int:
return int(out_handle.value)
def set_my_wxid(handle: int, wxid: str) -> bool:
"""Best-effort set the "my wxid" context for some WCDB APIs."""
try:
_ensure_initialized()
except Exception:
return False
lib = _load_wcdb_lib()
fn = getattr(lib, "wcdb_set_my_wxid", None)
if not fn:
return False
w = str(wxid or "").strip()
if not w:
return False
try:
rc = int(fn(ctypes.c_int64(int(handle)), w.encode("utf-8")))
except Exception:
return False
return rc == 0
def close_account(handle: int) -> None:
try:
h = int(handle)
@@ -293,6 +355,93 @@ def get_avatar_urls(handle: int, usernames: list[str]) -> dict[str, str]:
return {}
def exec_query(handle: int, *, kind: str, path: Optional[str], sql: str) -> list[dict[str, Any]]:
"""Execute raw SQL on a specific db kind/path via WCDB.
This is primarily used for SNS/other dbs that are not directly exposed by dedicated APIs.
"""
_ensure_initialized()
lib = _load_wcdb_lib()
fn = getattr(lib, "wcdb_exec_query", None)
if not fn:
raise WCDBRealtimeError("Current wcdb_api.dll does not support exec_query.")
k = str(kind or "").strip()
if not k:
raise WCDBRealtimeError("Missing kind for exec_query.")
s = str(sql or "").strip()
if not s:
return []
p = None if path is None else str(path or "").strip()
out_json = _call_out_json(
fn,
ctypes.c_int64(int(handle)),
k.encode("utf-8"),
None if p is None else p.encode("utf-8"),
s.encode("utf-8"),
)
decoded = _safe_load_json(out_json)
if isinstance(decoded, list):
out: list[dict[str, Any]] = []
for x in decoded:
if isinstance(x, dict):
out.append(x)
return out
return []
def get_sns_timeline(
handle: int,
*,
limit: int = 20,
offset: int = 0,
usernames: Optional[list[str]] = None,
keyword: str | None = None,
start_time: int = 0,
end_time: int = 0,
) -> list[dict[str, Any]]:
"""Read Moments (SnsTimeLine) from the live encrypted db_storage via WCDB.
Requires a newer wcdb_api.dll export: wcdb_get_sns_timeline.
"""
_ensure_initialized()
lib = _load_wcdb_lib()
fn = getattr(lib, "wcdb_get_sns_timeline", None)
if not fn:
raise WCDBRealtimeError("Current wcdb_api.dll does not support sns timeline.")
lim = max(0, int(limit or 0))
off = max(0, int(offset or 0))
users = [str(u or "").strip() for u in (usernames or []) if str(u or "").strip()]
users = list(dict.fromkeys(users))
users_json = json.dumps(users, ensure_ascii=False) if users else ""
kw = str(keyword or "").strip()
payload = _call_out_json(
fn,
ctypes.c_int64(int(handle)),
ctypes.c_int32(lim),
ctypes.c_int32(off),
users_json.encode("utf-8"),
kw.encode("utf-8"),
ctypes.c_int32(int(start_time or 0)),
ctypes.c_int32(int(end_time or 0)),
)
decoded = _safe_load_json(payload)
if isinstance(decoded, list):
out: list[dict[str, Any]] = []
for x in decoded:
if isinstance(x, dict):
out.append(x)
return out
return []
def shutdown() -> None:
global _initialized
lib = _load_wcdb_lib()
@@ -427,6 +576,11 @@ class WCDBRealtimeManager:
session_db_path = _resolve_session_db_path(db_storage_dir)
handle = open_account(session_db_path, key)
# Some WCDB APIs (e.g. exec_query on non-session DBs) may require this context.
try:
set_my_wxid(handle, account)
except Exception:
pass
conn = WCDBRealtimeConnection(
account=account,