Compare commits

...

5 Commits

27 changed files with 6824 additions and 1673 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "wechat-data-analysis-desktop",
"version": "1.3.0",
"version": "1.7.10",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "wechat-data-analysis-desktop",
"version": "1.3.0",
"version": "1.7.10",
"dependencies": {
"electron-updater": "^6.7.3"
},
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "wechat-data-analysis-desktop",
"private": true,
"version": "1.3.0",
"version": "1.7.10",
"main": "src/main.cjs",
"scripts": {
"dev": "node scripts/dev.cjs",
+201 -19
View File
@@ -22,12 +22,12 @@ const fs = require("fs");
const http = require("http");
const net = require("net");
const path = require("path");
const { Worker } = require("worker_threads");
const {
cleanupOutputDirectoryBackup,
getDefaultOutputDirPath,
getEffectiveOutputDirPath,
migrateOutputDirectory,
normalizeDirectoryPath,
rollbackOutputDirectoryChange,
} = require("./output-dir.cjs");
const DEFAULT_BACKEND_HOST = String(process.env.WECHAT_TOOL_HOST || "127.0.0.1").trim() || "127.0.0.1";
@@ -45,6 +45,7 @@ let isQuitting = false;
let desktopSettings = null;
let backendPortChangeInProgress = false;
let outputDirChangeInProgress = false;
let outputDirChangeProgressState = null;
const gotSingleInstanceLock = app.requestSingleInstanceLock();
if (!gotSingleInstanceLock) {
@@ -279,7 +280,9 @@ function resolveOutputDir() {
if (!dataDir) return null;
const envOutputDir = safeNormalizeDirectory(process.env.WECHAT_TOOL_OUTPUT_DIR || "");
const settingsOutputDir = app.isPackaged ? safeNormalizeDirectory(loadDesktopSettings()?.outputDir || "") : "";
// Allow dev-mode desktop runs to persist the chosen output directory too.
// An explicit environment variable still wins so local launch overrides keep working.
const settingsOutputDir = safeNormalizeDirectory(loadDesktopSettings()?.outputDir || "");
let chosen = null;
try {
@@ -705,6 +708,7 @@ function getOutputDirInfo() {
const defaultPath = getDefaultOutputDir() || "";
const currentPath = resolveOutputDir() || defaultPath;
const hasPending = desktopSettings.pendingOutputDir !== null;
const canChange = !!defaultPath && !!currentPath;
const pendingPath =
desktopSettings.pendingOutputDir === null
? ""
@@ -718,8 +722,8 @@ function getOutputDirInfo() {
pendingPath,
hasPending,
lastError: String(desktopSettings.lastOutputDirError || "").trim(),
canChange: !!app.isPackaged,
changeUnavailableReason: app.isPackaged ? "" : "开发模式不支持界面修改 output 目录",
canChange,
changeUnavailableReason: canChange ? "" : "无法定位 output 目录",
};
}
@@ -749,10 +753,6 @@ function setIgnoredUpdateVersion(version) {
}
async function applyOutputDirChange(nextValue) {
if (!app.isPackaged) {
throw new Error("开发模式不支持界面修改 output 目录");
}
const defaultPath = getDefaultOutputDir();
const currentPath = resolveOutputDir();
if (!defaultPath || !currentPath) {
@@ -785,15 +785,41 @@ async function applyOutputDirChange(nextValue) {
const wasBackendRunning = !!backendProc;
let migration = null;
let settingsSwitched = false;
let retainedBackupPath = "";
let backupCleanupWarning = "";
try {
setOutputDirChangeProgressState({
active: true,
stage: "preparing",
message: wasBackendRunning ? "正在暂停后端并准备迁移 output 目录" : "正在准备迁移 output 目录",
percent: 1,
});
if (wasBackendRunning) {
await stopBackendAndWait({ timeoutMs: 10_000 });
}
migration = migrateOutputDirectory({
currentDir: currentPath,
nextDir: nextPath,
migration = await runOutputDirWorker(
"migrate",
{
currentDir: currentPath,
nextDir: nextPath,
},
(progress) => {
setOutputDirChangeProgressState({
active: true,
...progress,
});
}
);
setOutputDirChangeProgressState({
active: true,
stage: "switching",
message: "正在应用新的 output 目录设置",
percent: 99,
currentFile: "",
});
setOutputDirSetting(nextPath);
@@ -803,11 +829,38 @@ async function applyOutputDirChange(nextValue) {
ensureOutputLink();
if (wasBackendRunning) {
setOutputDirChangeProgressState({
active: true,
stage: "restarting",
message: "正在重启后端并应用新的 output 目录",
percent: 99,
});
startBackend();
await waitForBackend({ timeoutMs: 30_000 });
}
retainedBackupPath = migration?.backupDir || "";
if (retainedBackupPath) {
try {
cleanupOutputDirectoryBackup(retainedBackupPath);
retainedBackupPath = "";
} catch (cleanupErr) {
backupCleanupWarning = `;旧 output 目录未能自动删除:${cleanupErr?.message || cleanupErr}`;
logMain(
`[main] failed to clean output dir backup ${retainedBackupPath}: ${cleanupErr?.message || cleanupErr}`
);
}
}
setOutputDirChangeProgressState({
active: true,
stage: "complete",
message: migration?.sourceWasEmpty ? "output 目录已切换" : "output 目录已迁移并切换",
percent: 100,
});
const info = getOutputDirInfo();
const successMessage =
(migration?.sourceWasEmpty ? "output 目录已切换" : "output 目录已迁移并切换") + backupCleanupWarning;
return {
success: true,
changed: true,
@@ -815,16 +868,22 @@ async function applyOutputDirChange(nextValue) {
defaultPath: info.defaultPath,
isDefault: info.isDefault,
pendingPath: info.pendingPath,
backupPath: migration?.backupDir || "",
backupPath: retainedBackupPath,
sourceWasEmpty: !!migration?.sourceWasEmpty,
message: migration?.sourceWasEmpty ? "output 目录已切换" : "output 目录已迁移并切换",
message: successMessage,
};
} catch (err) {
const message = err?.message || String(err);
let rollbackMessage = "";
if (migration?.changed) {
try {
rollbackOutputDirectoryChange({
setOutputDirChangeProgressState({
active: true,
stage: "rolling-back",
message: "迁移失败,正在回滚 output 目录",
percent: 99,
});
await runOutputDirWorker("rollback", {
previousDir: currentPath,
currentDir: nextPath,
backupDir: migration.backupDir,
@@ -969,6 +1028,119 @@ function setWindowProgressBar(value) {
} catch {}
}
function makeIdleOutputDirChangeProgressState() {
return {
active: false,
stage: "idle",
message: "",
percent: 0,
bytesTransferred: 0,
bytesTotal: 0,
itemsTransferred: 0,
itemsTotal: 0,
currentFile: "",
error: "",
};
}
function clampOutputDirProgressNumber(value) {
const n = Number(value);
if (!Number.isFinite(n) || n < 0) return 0;
return n;
}
function normalizeOutputDirChangeProgressState(next = {}) {
const active = next?.active !== false;
const percent = Math.max(0, Math.min(100, Math.round(Number(next?.percent || 0))));
return {
active,
stage: String(next?.stage || (active ? "running" : "idle")),
message: String(next?.message || ""),
percent,
bytesTransferred: clampOutputDirProgressNumber(next?.bytesTransferred),
bytesTotal: clampOutputDirProgressNumber(next?.bytesTotal),
itemsTransferred: clampOutputDirProgressNumber(next?.itemsTransferred),
itemsTotal: clampOutputDirProgressNumber(next?.itemsTotal),
currentFile: String(next?.currentFile || ""),
error: String(next?.error || ""),
};
}
function getOutputDirChangeProgressState() {
if (!outputDirChangeProgressState) {
outputDirChangeProgressState = makeIdleOutputDirChangeProgressState();
}
return outputDirChangeProgressState;
}
function setOutputDirChangeProgressState(next = {}) {
outputDirChangeProgressState = normalizeOutputDirChangeProgressState(next);
sendToRenderer("app:outputDirChangeProgress", outputDirChangeProgressState);
if (!outputDirChangeProgressState.active) {
setWindowProgressBar(-1);
return outputDirChangeProgressState;
}
const ratio =
outputDirChangeProgressState.percent > 0
? Math.max(0.02, Math.min(1, outputDirChangeProgressState.percent / 100))
: 2;
setWindowProgressBar(ratio);
return outputDirChangeProgressState;
}
function clearOutputDirChangeProgressState() {
return setOutputDirChangeProgressState({ active: false });
}
function getOutputDirWorkerScriptPath() {
return path.join(__dirname, "output-dir-worker.cjs");
}
function runOutputDirWorker(action, payload, onProgress) {
return new Promise((resolve, reject) => {
const worker = new Worker(getOutputDirWorkerScriptPath(), {
workerData: {
action: String(action || "migrate"),
payload,
},
});
let settled = false;
const finish = (err, result) => {
if (settled) return;
settled = true;
if (err) reject(err);
else resolve(result);
};
worker.on("message", (message) => {
if (!message || typeof message !== "object") return;
if (message.type === "progress") {
if (typeof onProgress === "function") onProgress(message.progress || {});
return;
}
if (message.type === "result") {
finish(null, message.result);
return;
}
if (message.type === "error") {
finish(new Error(message.error?.message || "output 目录迁移失败"));
}
});
worker.once("error", (err) => {
finish(err);
});
worker.once("exit", (code) => {
if (settled || code === 0) return;
finish(new Error(`output 目录任务异常退出(code=${code}`));
});
});
}
function looksLikeHtml(input) {
if (!input) return false;
const s = String(input);
@@ -1611,14 +1783,21 @@ function startBackend() {
if (backendProc) return backendProc;
startWcdbSidecar();
const resolvedDataPath = resolveDataDir() || getUserDataDir() || repoRoot();
const resolvedOutputPath = resolveOutputDir() || getDefaultOutputDir() || path.join(resolvedDataPath, "output");
const env = {
...process.env,
WECHAT_TOOL_HOST: getBackendBindHost(),
WECHAT_TOOL_PORT: String(getBackendPort()),
WECHAT_TOOL_DATA_DIR: resolvedDataPath,
WECHAT_TOOL_OUTPUT_DIR: resolvedOutputPath,
// Make sure Python prints UTF-8 to stdout/stderr.
PYTHONIOENCODING: process.env.PYTHONIOENCODING || "utf-8",
};
ensureWcdbSidecarEnv(env);
logMain(
`[main] startBackend packaged=${app.isPackaged} port=${env.WECHAT_TOOL_PORT} dataDir=${env.WECHAT_TOOL_DATA_DIR} outputDir=${env.WECHAT_TOOL_OUTPUT_DIR}`
);
// In packaged mode we expect to provide the generated Nuxt output dir via env.
if (app.isPackaged && !env.WECHAT_TOOL_UI_DIR) {
@@ -1626,8 +1805,6 @@ function startBackend() {
}
if (app.isPackaged) {
env.WECHAT_TOOL_DATA_DIR = resolveDataDir() || app.getPath("userData");
env.WECHAT_TOOL_OUTPUT_DIR = resolveOutputDir() || getDefaultOutputDir() || path.join(env.WECHAT_TOOL_DATA_DIR, "output");
try {
fs.mkdirSync(env.WECHAT_TOOL_DATA_DIR, { recursive: true });
fs.mkdirSync(env.WECHAT_TOOL_OUTPUT_DIR, { recursive: true });
@@ -2156,8 +2333,8 @@ function registerWindowIpc() {
pendingPath: "",
hasPending: false,
lastError: err?.message || String(err),
canChange: !!app.isPackaged,
changeUnavailableReason: app.isPackaged ? "" : "开发模式不支持界面修改 output 目录",
canChange: false,
changeUnavailableReason: "无法读取 output 目录信息",
};
}
});
@@ -2166,6 +2343,10 @@ function registerWindowIpc() {
return resolveOutputDir() || "";
});
ipcMain.handle("app:getOutputDirChangeProgress", () => {
return getOutputDirChangeProgressState();
});
ipcMain.handle("app:openOutputDir", async () => {
const outDir = resolveOutputDir();
if (!outDir) throw new Error("无法定位 output 目录");
@@ -2202,6 +2383,7 @@ function registerWindowIpc() {
};
} finally {
outputDirChangeInProgress = false;
clearOutputDirChangeProgressState();
}
});
+74
View File
@@ -0,0 +1,74 @@
const { parentPort, workerData } = require("worker_threads");
const { migrateOutputDirectory, rollbackOutputDirectoryChange } = require("./output-dir.cjs");
function serializeError(err) {
return {
message: err?.message || String(err),
stack: err?.stack ? String(err.stack) : "",
};
}
function createProgressPoster() {
let lastStage = "";
let lastPercent = -1;
let lastSentAt = 0;
return (progress) => {
const stage = String(progress?.stage || "");
const percent = Number(progress?.percent || 0);
const now = Date.now();
const shouldSend =
stage !== lastStage ||
percent >= 100 ||
percent <= 0 ||
percent >= lastPercent + 1 ||
now - lastSentAt >= 120;
if (!shouldSend) return;
lastStage = stage;
lastPercent = percent;
lastSentAt = now;
parentPort?.postMessage({ type: "progress", progress });
};
}
async function main() {
const action = String(workerData?.action || "migrate");
const payload = workerData?.payload && typeof workerData.payload === "object" ? workerData.payload : {};
if (action === "migrate") {
const result = await migrateOutputDirectory({
...payload,
onProgress: createProgressPoster(),
});
parentPort?.postMessage({ type: "result", result });
return;
}
if (action === "rollback") {
parentPort?.postMessage({
type: "progress",
progress: {
stage: "rolling-back",
message: "迁移失败,正在回滚 output 目录",
percent: 99,
bytesTransferred: 0,
bytesTotal: 0,
itemsTransferred: 0,
itemsTotal: 0,
currentFile: "",
},
});
rollbackOutputDirectoryChange(payload);
parentPort?.postMessage({ type: "result", result: { success: true } });
return;
}
throw new Error(`不支持的 output 目录 worker 动作:${action}`);
}
main().catch((err) => {
parentPort?.postMessage({ type: "error", error: serializeError(err) });
process.exitCode = 1;
});
+281 -8
View File
@@ -1,5 +1,6 @@
const fs = require("fs");
const path = require("path");
const { pipeline } = require("stream/promises");
const SENTINEL_NAMES = [
"account_keys.json",
@@ -10,6 +11,17 @@ const SENTINEL_NAMES = [
"logs",
];
const PROGRESS_STAGE_MESSAGES = {
preparing: "正在准备迁移 output 目录",
scanning: "正在扫描 output 目录",
copying: "正在复制 output 数据",
verifying: "正在校验已复制的数据",
switching: "正在切换 output 目录",
"rolling-back": "迁移失败,正在回滚 output 目录",
restarting: "正在重启后端并应用新的 output 目录",
complete: "output 目录迁移完成",
};
function normalizeDirectoryPath(value) {
const text = String(value || "").trim();
if (!text) return "";
@@ -136,7 +148,233 @@ function ensureTargetIsUsable(targetDir) {
}
}
function migrateOutputDirectory({ currentDir, nextDir, now = new Date() }) {
function clampNonNegativeNumber(value) {
const n = Number(value);
if (!Number.isFinite(n) || n < 0) return 0;
return n;
}
function computeProgressPercent(stage, bytesTransferred, bytesTotal, itemsTransferred, itemsTotal) {
if (stage === "preparing") return 1;
if (stage === "scanning") return 2;
if (stage === "verifying") return 96;
if (stage === "switching") return 99;
if (stage === "complete") return 100;
if (stage === "copying") {
const ratio =
bytesTotal > 0
? Math.min(1, bytesTransferred / bytesTotal)
: itemsTotal > 0
? Math.min(1, itemsTransferred / itemsTotal)
: 1;
return Math.max(5, Math.min(94, Math.round(5 + ratio * 89)));
}
return 0;
}
function buildProgressSnapshot({
stage = "preparing",
bytesTransferred = 0,
bytesTotal = 0,
itemsTransferred = 0,
itemsTotal = 0,
currentFile = "",
}) {
const normalizedStage = String(stage || "preparing");
const safeBytesTransferred = clampNonNegativeNumber(bytesTransferred);
const safeBytesTotal = clampNonNegativeNumber(bytesTotal);
const safeItemsTransferred = clampNonNegativeNumber(itemsTransferred);
const safeItemsTotal = clampNonNegativeNumber(itemsTotal);
return {
stage: normalizedStage,
message: PROGRESS_STAGE_MESSAGES[normalizedStage] || "正在迁移 output 目录",
percent: computeProgressPercent(
normalizedStage,
safeBytesTransferred,
safeBytesTotal,
safeItemsTransferred,
safeItemsTotal
),
bytesTransferred: safeBytesTransferred,
bytesTotal: safeBytesTotal,
itemsTransferred: safeItemsTransferred,
itemsTotal: safeItemsTotal,
currentFile: String(currentFile || ""),
};
}
function emitProgress(onProgress, payload) {
if (typeof onProgress !== "function") return;
onProgress(buildProgressSnapshot(payload));
}
function sortDirectoryEntries(entries) {
return entries.sort((a, b) => String(a.name || "").localeCompare(String(b.name || "")));
}
function depthOfRelativePath(relativePath) {
const text = String(relativePath || "").trim();
if (!text) return 0;
return text.split(path.sep).length;
}
function collectCopyManifest(sourceDir) {
const directories = [];
const files = [];
let totalBytes = 0;
const stack = [""];
while (stack.length > 0) {
const relativeDir = stack.pop();
const absoluteDir = relativeDir ? path.join(sourceDir, relativeDir) : sourceDir;
const dirEntries = sortDirectoryEntries(fs.readdirSync(absoluteDir, { withFileTypes: true }));
for (const dirent of dirEntries) {
const relativePath = relativeDir ? path.join(relativeDir, dirent.name) : dirent.name;
const absolutePath = path.join(sourceDir, relativePath);
const stat = fs.lstatSync(absolutePath);
if (dirent.isDirectory()) {
directories.push({
relativePath,
mode: stat.mode,
atime: stat.atime,
mtime: stat.mtime,
});
stack.push(relativePath);
continue;
}
if (dirent.isFile()) {
files.push({
relativePath,
size: stat.size,
mode: stat.mode,
atime: stat.atime,
mtime: stat.mtime,
});
totalBytes += stat.size;
continue;
}
if (dirent.isSymbolicLink()) {
throw new Error(`output 目录包含不支持的符号链接:${relativePath}`);
}
throw new Error(`output 目录包含不支持的文件类型:${relativePath}`);
}
}
directories.sort((a, b) => depthOfRelativePath(a.relativePath) - depthOfRelativePath(b.relativePath));
return {
directories,
files,
totalBytes,
totalItems: directories.length + files.length,
};
}
function applyStatMetadata(targetPath, statLike) {
try {
if (Number.isInteger(statLike?.mode)) {
fs.chmodSync(targetPath, statLike.mode);
}
} catch {}
try {
if (statLike?.atime && statLike?.mtime) {
fs.utimesSync(targetPath, statLike.atime, statLike.mtime);
}
} catch {}
}
async function copyFileWithProgress({ sourcePath, targetPath, mode, onChunk }) {
await fs.promises.mkdir(path.dirname(targetPath), { recursive: true });
const readStream = fs.createReadStream(sourcePath);
readStream.on("data", (chunk) => {
if (typeof onChunk === "function") onChunk(chunk.length);
});
const writeStream = fs.createWriteStream(targetPath, {
flags: "w",
mode: Number.isInteger(mode) ? mode : undefined,
});
await pipeline(readStream, writeStream);
}
async function copyOutputTree({ sourceDir, targetDir, manifest, onProgress }) {
fs.mkdirSync(targetDir, { recursive: true });
let bytesTransferred = 0;
let itemsTransferred = 0;
emitProgress(onProgress, {
stage: "copying",
bytesTransferred,
bytesTotal: manifest.totalBytes,
itemsTransferred,
itemsTotal: manifest.totalItems,
});
for (const dirEntry of manifest.directories) {
const targetPath = path.join(targetDir, dirEntry.relativePath);
fs.mkdirSync(targetPath, { recursive: true });
itemsTransferred += 1;
emitProgress(onProgress, {
stage: "copying",
bytesTransferred,
bytesTotal: manifest.totalBytes,
itemsTransferred,
itemsTotal: manifest.totalItems,
currentFile: dirEntry.relativePath,
});
}
for (const fileEntry of manifest.files) {
const sourcePath = path.join(sourceDir, fileEntry.relativePath);
const targetPath = path.join(targetDir, fileEntry.relativePath);
await copyFileWithProgress({
sourcePath,
targetPath,
mode: fileEntry.mode,
onChunk: (delta) => {
bytesTransferred += clampNonNegativeNumber(delta);
emitProgress(onProgress, {
stage: "copying",
bytesTransferred,
bytesTotal: manifest.totalBytes,
itemsTransferred,
itemsTotal: manifest.totalItems,
currentFile: fileEntry.relativePath,
});
},
});
applyStatMetadata(targetPath, fileEntry);
itemsTransferred += 1;
emitProgress(onProgress, {
stage: "copying",
bytesTransferred,
bytesTotal: manifest.totalBytes,
itemsTransferred,
itemsTotal: manifest.totalItems,
currentFile: fileEntry.relativePath,
});
}
for (let i = manifest.directories.length - 1; i >= 0; i -= 1) {
const dirEntry = manifest.directories[i];
applyStatMetadata(path.join(targetDir, dirEntry.relativePath), dirEntry);
}
}
async function migrateOutputDirectory({ currentDir, nextDir, now = new Date(), onProgress } = {}) {
const currentPath = normalizeDirectoryPath(currentDir);
const targetPath = normalizeDirectoryPath(nextDir);
if (!currentPath || !targetPath) {
@@ -155,15 +393,19 @@ function migrateOutputDirectory({ currentDir, nextDir, now = new Date() }) {
throw new Error("新旧 output 路径不能互相包含");
}
emitProgress(onProgress, { stage: "scanning" });
ensureTargetIsUsable(targetPath);
const sourceExists = pathExists(currentPath);
if (sourceExists && !isDirectory(currentPath)) {
throw new Error("当前 output 路径不是目录");
}
const sourceWasEmpty = !sourceExists || !hasDirectoryContents(currentPath);
if (sourceWasEmpty) {
emitProgress(onProgress, { stage: "switching" });
fs.mkdirSync(targetPath, { recursive: true });
emitProgress(onProgress, { stage: "complete", itemsTransferred: 1, itemsTotal: 1 });
return {
changed: true,
currentDir: currentPath,
@@ -173,18 +415,34 @@ function migrateOutputDirectory({ currentDir, nextDir, now = new Date() }) {
};
}
const manifest = collectCopyManifest(currentPath);
const tempTarget = makeUniqueSiblingPath(targetPath, "migrating", now);
const backupDir = makeUniqueSiblingPath(currentPath, "backup", now);
fs.cpSync(currentPath, tempTarget, {
recursive: true,
force: false,
errorOnExist: true,
preserveTimestamps: true,
});
try {
await copyOutputTree({
sourceDir: currentPath,
targetDir: tempTarget,
manifest,
onProgress,
});
emitProgress(onProgress, {
stage: "verifying",
bytesTransferred: manifest.totalBytes,
bytesTotal: manifest.totalBytes,
itemsTransferred: manifest.totalItems,
itemsTotal: manifest.totalItems,
});
verifyCopiedOutputTree(currentPath, tempTarget);
emitProgress(onProgress, {
stage: "switching",
bytesTransferred: manifest.totalBytes,
bytesTotal: manifest.totalBytes,
itemsTransferred: manifest.totalItems,
itemsTotal: manifest.totalItems,
});
if (pathExists(targetPath)) {
fs.rmSync(targetPath, { recursive: true, force: true });
}
@@ -209,6 +467,13 @@ function migrateOutputDirectory({ currentDir, nextDir, now = new Date() }) {
throw err;
}
emitProgress(onProgress, {
stage: "complete",
bytesTransferred: manifest.totalBytes,
bytesTotal: manifest.totalBytes,
itemsTransferred: manifest.totalItems,
itemsTotal: manifest.totalItems,
});
return {
changed: true,
currentDir: currentPath,
@@ -242,7 +507,15 @@ function rollbackOutputDirectoryChange({ previousDir, currentDir, backupDir, sou
} catch {}
}
function cleanupOutputDirectoryBackup(backupDir) {
const backupPath = normalizeDirectoryPath(backupDir);
if (!backupPath || !pathExists(backupPath)) return false;
fs.rmSync(backupPath, { recursive: true, force: true });
return !pathExists(backupPath);
}
module.exports = {
cleanupOutputDirectoryBackup,
getDefaultOutputDirPath,
getEffectiveOutputDirPath,
hasDirectoryContents,
+6
View File
@@ -84,10 +84,16 @@ contextBridge.exposeInMainWorld("wechatDesktop", {
// Data/output folder helpers
getOutputDirInfo: () => ipcRenderer.invoke("app:getOutputDirInfo"),
getOutputDir: () => ipcRenderer.invoke("app:getOutputDir"),
getOutputDirChangeProgress: () => ipcRenderer.invoke("app:getOutputDirChangeProgress"),
setOutputDir: (dir) => ipcRenderer.invoke("app:setOutputDir", String(dir ?? "")),
openOutputDir: () => ipcRenderer.invoke("app:openOutputDir"),
getAccountInfo: (account) => ipcRenderer.invoke("app:getAccountInfo", String(account || "")),
deleteAccountData: (account) => ipcRenderer.invoke("app:deleteAccountData", String(account || "")),
onOutputDirChangeProgress: (callback) => {
const handler = (_event, progress) => callback(progress);
ipcRenderer.on("app:outputDirChangeProgress", handler);
return () => ipcRenderer.removeListener("app:outputDirChangeProgress", handler);
},
// Auto update
getVersion: () => ipcRenderer.invoke("app:getVersion"),
+35 -10
View File
@@ -5,6 +5,7 @@ const os = require("os");
const path = require("path");
const {
cleanupOutputDirectoryBackup,
getDefaultOutputDirPath,
getEffectiveOutputDirPath,
migrateOutputDirectory,
@@ -55,14 +56,14 @@ test("getEffectiveOutputDirPath prefers env, then settings, then default", () =>
}
});
test("migrateOutputDirectory switches empty source to a new directory", () => {
test("migrateOutputDirectory switches empty source to a new directory", async () => {
const root = makeTempDir();
const currentDir = path.join(root, "current-output");
const nextDir = path.join(root, "custom-output");
try {
fs.mkdirSync(currentDir, { recursive: true });
const result = migrateOutputDirectory({ currentDir, nextDir });
const result = await migrateOutputDirectory({ currentDir, nextDir });
assert.equal(result.changed, true);
assert.equal(result.sourceWasEmpty, true);
assert.equal(result.backupDir, "");
@@ -73,7 +74,7 @@ test("migrateOutputDirectory switches empty source to a new directory", () => {
}
});
test("migrateOutputDirectory blocks non-empty targets", () => {
test("migrateOutputDirectory blocks non-empty targets", async () => {
const root = makeTempDir();
const currentDir = path.join(root, "current-output");
const nextDir = path.join(root, "custom-output");
@@ -84,8 +85,8 @@ test("migrateOutputDirectory blocks non-empty targets", () => {
fs.mkdirSync(nextDir, { recursive: true });
fs.writeFileSync(path.join(nextDir, "existing.txt"), "occupied");
assert.throws(
() => migrateOutputDirectory({ currentDir, nextDir }),
await assert.rejects(
migrateOutputDirectory({ currentDir, nextDir }),
/已有内容/
);
} finally {
@@ -93,15 +94,15 @@ test("migrateOutputDirectory blocks non-empty targets", () => {
}
});
test("migrateOutputDirectory blocks invalid current paths", () => {
test("migrateOutputDirectory blocks invalid current paths", async () => {
const root = makeTempDir();
const currentDir = path.join(root, "current-output");
const nextDir = path.join(root, "custom-output");
try {
fs.writeFileSync(currentDir, "not-a-directory");
assert.throws(
() => migrateOutputDirectory({ currentDir, nextDir }),
await assert.rejects(
migrateOutputDirectory({ currentDir, nextDir }),
/不是目录/
);
} finally {
@@ -109,7 +110,7 @@ test("migrateOutputDirectory blocks invalid current paths", () => {
}
});
test("migrateOutputDirectory copies data and leaves the old directory as a backup", () => {
test("migrateOutputDirectory copies data and leaves the old directory as a backup", async () => {
const root = makeTempDir();
const currentDir = path.join(root, "current-output");
const nextDir = path.join(root, "custom-output");
@@ -120,7 +121,13 @@ test("migrateOutputDirectory copies data and leaves the old directory as a backu
fs.writeFileSync(path.join(currentDir, "databases", "wxid_test", "session.db"), "session");
fs.writeFileSync(path.join(currentDir, "databases", "wxid_test", "contact.db"), "contact");
const result = migrateOutputDirectory({ currentDir, nextDir, now: new Date("2026-03-30T08:00:00Z") });
const progressEvents = [];
const result = await migrateOutputDirectory({
currentDir,
nextDir,
now: new Date("2026-03-30T08:00:00Z"),
onProgress: (progress) => progressEvents.push(progress),
});
assert.equal(result.changed, true);
assert.equal(result.sourceWasEmpty, false);
assert.match(path.basename(result.backupDir), /^current-output\.backup-\d{14}$/);
@@ -129,6 +136,9 @@ test("migrateOutputDirectory copies data and leaves the old directory as a backu
assert.ok(fs.existsSync(path.join(nextDir, "databases", "wxid_test", "session.db")));
assert.ok(fs.existsSync(result.backupDir));
assert.equal(fs.existsSync(currentDir), false);
assert.ok(progressEvents.some((event) => event.stage === "scanning"));
assert.ok(progressEvents.some((event) => event.stage === "copying" && event.percent > 0));
assert.ok(progressEvents.some((event) => event.stage === "complete" && event.percent === 100));
} finally {
cleanupDir(root);
}
@@ -160,3 +170,18 @@ test("rollbackOutputDirectoryChange restores the previous directory", () => {
cleanupDir(root);
}
});
test("cleanupOutputDirectoryBackup removes a completed migration backup directory", () => {
const root = makeTempDir();
const backupDir = path.join(root, "current-output.backup-20260330080100");
try {
fs.mkdirSync(path.join(backupDir, "databases"), { recursive: true });
fs.writeFileSync(path.join(backupDir, "databases", "session.db"), "restored");
assert.equal(cleanupOutputDirectoryBackup(backupDir), true);
assert.equal(fs.existsSync(backupDir), false);
} finally {
cleanupDir(root);
}
});
+104 -1
View File
@@ -214,6 +214,22 @@
<div v-if="desktopOutputDirCanChange" class="text-[11px] text-[#909090]">
修改后会迁移整个 output 目录如果目标目录已有内容会先阻止并提示
</div>
<div v-if="desktopOutputDirProgress" class="rounded-[6px] border border-[#d8efe2] bg-[#f4fbf7] px-2.5 py-2">
<div class="flex items-center justify-between gap-3 text-[11px] text-[#1b6b43]">
<div class="min-w-0 truncate">{{ desktopOutputDirProgressText }}</div>
<div class="shrink-0 tabular-nums">{{ desktopOutputDirProgressPercentText }}</div>
</div>
<div class="mt-1.5 h-2 overflow-hidden rounded-full bg-[#dceee3]">
<div
class="h-full rounded-full bg-[#07b75b] transition-[width] duration-200 ease-out"
:class="desktopOutputDirProgressIndeterminate ? 'animate-pulse' : ''"
:style="{ width: desktopOutputDirProgressBarWidth }"
/>
</div>
<div v-if="desktopOutputDirProgressDetail" class="mt-1 text-[10px] text-[#5d7a68] break-all">
{{ desktopOutputDirProgressDetail }}
</div>
</div>
<div v-if="desktopOutputDirMessage" class="rounded-[6px] border border-[#d8efe2] bg-[#f4fbf7] px-2.5 py-1.5 text-[11px] text-[#1b6b43] whitespace-pre-wrap">
{{ desktopOutputDirMessage }}
</div>
@@ -410,6 +426,8 @@ const desktopOutputDirMessage = ref('')
const desktopOutputDirIsDefault = ref(true)
const desktopOutputDirCanChange = ref(true)
const desktopOutputDirUnavailableReason = ref('')
const desktopOutputDirProgress = ref(null)
let removeDesktopOutputDirProgressListener = null
const desktopOutputDirText = computed(() => {
if (!isDesktopEnv.value) return '仅桌面端可用'
const v = String(desktopOutputDir.value || '').trim()
@@ -424,6 +442,48 @@ const desktopOutputDirPendingText = computed(() => {
const v = String(desktopOutputDirPending.value || '').trim()
return v || ''
})
const desktopOutputDirProgressPercent = computed(() => {
const n = Number(desktopOutputDirProgress.value?.percent || 0)
if (!Number.isFinite(n) || n < 0) return 0
return Math.max(0, Math.min(100, Math.round(n)))
})
const desktopOutputDirProgressPercentText = computed(() => `${desktopOutputDirProgressPercent.value}%`)
const desktopOutputDirProgressText = computed(() => {
const text = String(desktopOutputDirProgress.value?.message || '').trim()
return text || '正在迁移 output 目录'
})
const desktopOutputDirProgressIndeterminate = computed(() => {
const stage = String(desktopOutputDirProgress.value?.stage || '').trim()
return stage === 'preparing' || stage === 'scanning' || stage === 'rolling-back' || stage === 'restarting'
})
const desktopOutputDirProgressBarWidth = computed(() => {
if (!desktopOutputDirProgress.value) return '0%'
if (desktopOutputDirProgressIndeterminate.value) return '28%'
return `${Math.max(6, desktopOutputDirProgressPercent.value)}%`
})
const desktopOutputDirProgressDetail = computed(() => {
const progress = desktopOutputDirProgress.value
if (!progress) return ''
const parts = []
const bytesTotal = Number(progress.bytesTotal || 0)
const bytesTransferred = Number(progress.bytesTransferred || 0)
const itemsTotal = Number(progress.itemsTotal || 0)
const itemsTransferred = Number(progress.itemsTransferred || 0)
if (bytesTotal > 0) {
parts.push(`${formatBytes(bytesTransferred)} / ${formatBytes(bytesTotal)}`)
} else if (itemsTotal > 0) {
parts.push(`${Math.min(itemsTransferred, itemsTotal)} / ${itemsTotal}`)
}
const currentFile = String(progress.currentFile || '').trim()
if (currentFile) {
parts.push(currentFile)
}
return parts.join(' · ')
})
const desktopOutputDirControlsDisabled = computed(() => (
!isDesktopEnv.value || !desktopOutputDirCanChange.value || desktopOutputDirLoading.value || desktopOutputDirApplying.value
))
@@ -442,6 +502,37 @@ const switchTrackClass = (enabled, disabled = false) => {
return enabled ? 'bg-[#07b75b] hover:brightness-95' : 'bg-[#d0d0d0] hover:brightness-95'
}
const formatBytes = (value) => {
const n = Number(value || 0)
if (!Number.isFinite(n) || n <= 0) return '0 B'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
let next = n
let unitIndex = 0
while (next >= 1024 && unitIndex < units.length - 1) {
next /= 1024
unitIndex += 1
}
const digits = next >= 100 || unitIndex === 0 ? 0 : next >= 10 ? 1 : 2
return `${next.toFixed(digits)} ${units[unitIndex]}`
}
const applyDesktopOutputDirProgress = (progress) => {
if (!progress || progress.active === false) {
desktopOutputDirProgress.value = null
return
}
desktopOutputDirProgress.value = { ...progress }
}
const refreshDesktopOutputDirProgress = async () => {
if (!process.client || typeof window === 'undefined') return
if (!window.wechatDesktop?.getOutputDirChangeProgress) return
try {
const progress = await window.wechatDesktop.getOutputDirChangeProgress()
applyDesktopOutputDirProgress(progress)
} catch {}
}
const sectionElements = computed(() => [
{ key: 'desktop', el: desktopSectionRef.value },
{ key: 'startup', el: startupSectionRef.value },
@@ -676,12 +767,13 @@ const applyDesktopOutputDir = async (nextDir) => {
return
}
if (!desktopOutputDirCanChange.value) {
desktopOutputDirError.value = desktopOutputDirUnavailableReason.value || '开发模式不支持界面修改 output 目录'
desktopOutputDirError.value = desktopOutputDirUnavailableReason.value || '当前环境不支持修改 output 目录'
return
}
desktopOutputDirApplying.value = true
desktopOutputDirError.value = ''
desktopOutputDirMessage.value = ''
desktopOutputDirProgress.value = null
try {
const res = await window.wechatDesktop.setOutputDir(String(nextDir ?? '').trim())
if (res?.success === false) {
@@ -856,6 +948,7 @@ watch(() => props.open, async (isOpen) => {
await refreshBackendLogFileInfo()
if (isDesktopEnv.value) {
await refreshDesktopOutputDir()
await refreshDesktopOutputDirProgress()
}
}, { immediate: true })
@@ -864,6 +957,11 @@ onMounted(async () => {
const isElectron = /electron/i.test(String(navigator.userAgent || ''))
isDesktopEnv.value = isElectron && !!window.wechatDesktop
window.addEventListener('keydown', onEscKeydown)
if (window.wechatDesktop?.onOutputDirChangeProgress) {
removeDesktopOutputDirProgressListener = window.wechatDesktop.onOutputDirChangeProgress((progress) => {
applyDesktopOutputDirProgress(progress)
})
}
}
desktopAutoRealtime.value = readLocalBoolSetting(DESKTOP_SETTING_AUTO_REALTIME_KEY, false)
@@ -876,6 +974,7 @@ onMounted(async () => {
await refreshDesktopAutoLaunch()
await refreshDesktopCloseBehavior()
await refreshDesktopOutputDir()
await refreshDesktopOutputDirProgress()
}
await nextTick()
@@ -885,6 +984,10 @@ onMounted(async () => {
onBeforeUnmount(() => {
if (!process.client || typeof window === 'undefined') return
window.removeEventListener('keydown', onEscKeydown)
if (typeof removeDesktopOutputDirProgressListener === 'function') {
removeDesktopOutputDirProgressListener()
removeDesktopOutputDirProgressListener = null
}
})
</script>
+2 -27
View File
@@ -357,30 +357,6 @@ export const useApi = () => {
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)
@@ -477,7 +453,7 @@ export const useApi = () => {
return await request(`/chat/exports/${encodeURIComponent(String(exportId))}`, { method: 'DELETE' })
}
// 朋友圈导出(离线 HTML zip
// 朋友圈导出(离线 ZIP,支持 HTML / JSON / TXT
const createSnsExport = async (data = {}) => {
return await request('/sns/exports', {
method: 'POST',
@@ -485,6 +461,7 @@ export const useApi = () => {
account: data.account || null,
scope: data.scope || 'selected',
usernames: Array.isArray(data.usernames) ? data.usernames : [],
format: data.format || 'html',
use_cache: data.use_cache == null ? true : !!data.use_cache,
output_dir: data.output_dir == null ? null : String(data.output_dir || '').trim(),
file_name: data.file_name || null
@@ -667,8 +644,6 @@ export const useApi = () => {
resolveAppMsg,
listSnsTimeline,
listSnsUsers,
listSnsMediaCandidates,
saveSnsMediaPicks,
openChatMediaFolder,
downloadChatEmoji,
saveMediaKeys,
+457 -423
View File
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "wechat-decrypt-tool"
version = "1.3.0"
version = "1.7.10"
description = "Modern WeChat database decryption tool with React frontend"
readme = "README.md"
requires-python = ">=3.11"
@@ -43,6 +43,9 @@ include = [
"src/wechat_decrypt_tool/native/VoipEngine.dll",
"src/wechat_decrypt_tool/native/wcdb_api.dll",
"src/wechat_decrypt_tool/native/WCDB.dll",
"src/wechat_decrypt_tool/native/weflow_wasm/weflow_wasm_keystream.js",
"src/wechat_decrypt_tool/native/weflow_wasm/wasm_video_decode.js",
"src/wechat_decrypt_tool/native/weflow_wasm/wasm_video_decode.wasm",
]
[tool.uv]
+1 -1
View File
@@ -1,5 +1,5 @@
"""微信数据库解密工具
"""
__version__ = "1.3.0"
__version__ = "1.7.10"
__author__ = "WeChat Decrypt Tool"
-23
View File
@@ -35,7 +35,6 @@ from .routers.sns_export import router as _sns_export_router
from .routers.wechat_detection import router as _wechat_detection_router
from .routers.wrapped import router as _wrapped_router
from .request_logging import log_server_errors_middleware
from .sns_stage_timing import add_sns_stage_timing_headers
from .wcdb_realtime import WCDB_REALTIME, shutdown as _wcdb_shutdown
from .routers.biz import router as _biz_router
from .routers.system import router as _system_router
@@ -56,31 +55,9 @@ app.add_middleware(
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
expose_headers=["X-SNS-Source", "X-SNS-Hit-Type", "X-SNS-X-Enc"],
)
@app.middleware("http")
async def _add_sns_stage_timing_headers(request: Request, call_next):
"""Expose SNS stage metadata to the frontend without extra requests.
`<img>` elements can't read response headers, but browsers can surface `Server-Timing`
via `performance.getEntriesByName(...).serverTiming` when `Timing-Allow-Origin` is set.
"""
response = await call_next(request)
try:
add_sns_stage_timing_headers(
response.headers,
source=str(response.headers.get("X-SNS-Source") or ""),
hit_type=str(response.headers.get("X-SNS-Hit-Type") or ""),
x_enc=str(response.headers.get("X-SNS-X-Enc") or ""),
)
except Exception:
pass
return response
@app.middleware("http")
async def _log_server_errors(request: Request, call_next):
return await log_server_errors_middleware(request_logger, request, call_next)
+3 -3
View File
@@ -10,9 +10,9 @@ This module provides a pure-Python ISAAC-64 implementation so the backend can
still attempt to generate a keystream when the WASM helper is unavailable.
Notes:
- Moments *image* decryption is handled via `wcdb_api.dll` (`wcdb_decrypt_sns_image`)
because "ISAAC-64 full-file XOR" is not reliably reproducible for images across
different versions/samples.
- Production Moments image/video decryption should prefer the vendored
WxIsaac64/WASM path. This pure-Python implementation is only a fallback when
Node/WASM is unavailable.
- This ISAAC-64 implementation may not perfectly match WxIsaac64; treat it as
best-effort.
"""
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,122 @@
// Generate WeChat/WeFlow WxIsaac64 keystream via the vendored WASM module.
//
// Usage:
// node weflow_wasm_keystream.js <key> <size>
//
// Prints a base64-encoded keystream to stdout (no extra logs).
const fs = require('fs')
const path = require('path')
const vm = require('vm')
function usageAndExit() {
process.stderr.write('Usage: node weflow_wasm_keystream.js <key> <size>\\n')
process.exit(2)
}
const key = String(process.argv[2] || '').trim()
const size = Number(process.argv[3] || 0)
if (!key || !Number.isFinite(size) || size <= 0) usageAndExit()
const basePath = __dirname
const wasmPath = path.join(basePath, 'wasm_video_decode.wasm')
const jsPath = path.join(basePath, 'wasm_video_decode.js')
if (!fs.existsSync(wasmPath) || !fs.existsSync(jsPath)) {
process.stderr.write(`Vendored WASM assets not found: ${basePath}\\n`)
process.exit(1)
}
const wasmBinary = fs.readFileSync(wasmPath)
const jsContent = fs.readFileSync(jsPath, 'utf8')
let capturedKeystream = null
let resolveInit
let rejectInit
const initPromise = new Promise((res, rej) => {
resolveInit = res
rejectInit = rej
})
const mockGlobal = {
console: { log: () => {}, error: () => {} },
Buffer,
Uint8Array,
Int8Array,
Uint16Array,
Int16Array,
Uint32Array,
Int32Array,
Float32Array,
Float64Array,
BigInt64Array,
BigUint64Array,
Array,
Object,
Function,
String,
Number,
Boolean,
Error,
Promise,
require,
process,
setTimeout,
clearTimeout,
setInterval,
clearInterval,
}
mockGlobal.Module = {
onRuntimeInitialized: () => resolveInit(),
wasmBinary,
print: () => {},
printErr: () => {},
}
mockGlobal.self = mockGlobal
mockGlobal.self.location = { href: jsPath }
mockGlobal.WorkerGlobalScope = function () {}
mockGlobal.VTS_WASM_URL = `file://${wasmPath}`
mockGlobal.wasm_isaac_generate = (ptr, n) => {
const buf = new Uint8Array(mockGlobal.Module.HEAPU8.buffer, ptr, n)
capturedKeystream = new Uint8Array(buf)
}
try {
const context = vm.createContext(mockGlobal)
new vm.Script(jsContent, { filename: jsPath }).runInContext(context)
} catch (e) {
rejectInit(e)
}
;(async () => {
try {
await initPromise
if (!mockGlobal.Module.WxIsaac64 && mockGlobal.Module.asm && mockGlobal.Module.asm.WxIsaac64) {
mockGlobal.Module.WxIsaac64 = mockGlobal.Module.asm.WxIsaac64
}
if (!mockGlobal.Module.WxIsaac64) {
throw new Error('WxIsaac64 not found in WASM module')
}
const alignedSize = Math.ceil(size / 8) * 8
capturedKeystream = null
const isaac = new mockGlobal.Module.WxIsaac64(key)
isaac.generate(alignedSize)
if (isaac.delete) isaac.delete()
if (!capturedKeystream) throw new Error('Failed to capture keystream')
const out = Buffer.from(capturedKeystream)
out.reverse()
process.stdout.write(out.subarray(0, size).toString('base64'))
} catch (e) {
process.stderr.write(String(e && e.stack ? e.stack : e) + '\\n')
process.exit(1)
}
})()
+3 -729
View File
@@ -1,4 +1,3 @@
from bisect import bisect_left, bisect_right
from functools import lru_cache
from pathlib import Path
import os
@@ -20,7 +19,6 @@ from starlette.background import BackgroundTask
from fastapi import APIRouter, HTTPException
from fastapi.responses import Response, FileResponse # 返回视频文件
from pydantic import BaseModel, Field
from ..chat_helpers import _load_contact_rows, _pick_display_name, _resolve_account_dir
from ..logging_config import get_logger
@@ -44,8 +42,6 @@ logger = get_logger(__name__)
router = APIRouter(route_class=PathFixRoute)
SNS_MEDIA_PICKS_FILE = "_sns_media_picks.json"
_SNS_VIDEO_KEY_RE = re.compile(r'<enc\s+key="(\d+)"', flags=re.IGNORECASE)
_MP_BIZ_RE = re.compile(r"__biz=([A-Za-z0-9_=+-]+)")
_ZSTD_MAGIC = b"\x28\xb5\x2f\xfd"
@@ -860,233 +856,6 @@ def _parse_timeline_xml(xml_text: str, fallback_username: str) -> dict[str, Any]
return out
def _image_size_from_bytes(data: bytes, media_type: str) -> tuple[int, int]:
mt = str(media_type or "").lower()
if mt == "image/png":
# PNG IHDR width/height are stored at byte offsets 16..24
if len(data) >= 24 and data.startswith(b"\x89PNG\r\n\x1a\n"):
try:
w = int.from_bytes(data[16:20], "big")
h = int.from_bytes(data[20:24], "big")
return w, h
except Exception:
return 0, 0
return 0, 0
if mt in {"image/jpeg", "image/jpg"}:
# Minimal JPEG SOF parser.
if len(data) < 4 or (not data.startswith(b"\xFF\xD8")):
return 0, 0
i = 2
while i + 3 < len(data):
if data[i] != 0xFF:
i += 1
continue
# Skip padding 0xFF bytes.
while i < len(data) and data[i] == 0xFF:
i += 1
if i >= len(data):
break
marker = data[i]
i += 1
# Markers without a segment length.
if marker in (0xD8, 0xD9):
continue
if marker == 0xDA: # Start of scan.
break
if i + 1 >= len(data):
break
seg_len = (data[i] << 8) + data[i + 1]
i += 2
if seg_len < 2:
break
# SOF markers which contain width/height.
if marker in {
0xC0,
0xC1,
0xC2,
0xC3,
0xC5,
0xC6,
0xC7,
0xC9,
0xCA,
0xCB,
0xCD,
0xCE,
0xCF,
}:
# segment: [precision(1), height(2), width(2), ...]
if i + 4 < len(data):
try:
h = (data[i + 1] << 8) + data[i + 2]
w = (data[i + 3] << 8) + data[i + 4]
return w, h
except Exception:
return 0, 0
i += seg_len - 2
return 0, 0
return 0, 0
@lru_cache(maxsize=16)
def _sns_img_time_index(wxid_dir_str: str) -> tuple[list[float], list[str]]:
"""Build a (mtime_sorted, path_sorted) index for local Moments cache images.
WeChat stores encrypted SNS cache images under:
`{wxid_dir}/cache/YYYY-MM/Sns/Img/<2hex>/<30hex>`
"""
wxid_dir = Path(str(wxid_dir_str or "").strip())
out: list[tuple[float, str]] = []
cache_root = wxid_dir / "cache"
try:
month_dirs = [p for p in cache_root.iterdir() if p.is_dir()]
except Exception:
month_dirs = []
for mdir in month_dirs:
img_root = mdir / "Sns" / "Img"
try:
if not (img_root.exists() and img_root.is_dir()):
continue
except Exception:
continue
# The Img dir uses a 2-level layout; keep this tight (no global rglob).
try:
for sub in img_root.iterdir():
if not sub.is_dir():
continue
for f in sub.iterdir():
try:
if not f.is_file():
continue
st = f.stat()
out.append((float(st.st_mtime), str(f)))
except Exception:
continue
except Exception:
continue
out.sort(key=lambda x: x[0])
mtimes = [m for m, _p in out]
paths = [_p for _m, _p in out]
return mtimes, paths
def _normalize_hex32(value: Optional[str]) -> str:
"""Return the first 32 hex chars from value, or '' if not present."""
s = str(value or "").strip().lower()
if not s:
return ""
# Keep only hex chars. Some attrs may contain separators or be wrapped.
s = re.sub(r"[^0-9a-f]", "", s)
if len(s) < 32:
return ""
return s[:32]
def _sns_media_picks_path(account_dir: Path) -> Path:
return account_dir / SNS_MEDIA_PICKS_FILE
def _sns_post_id_from_media_key(media_key: str) -> str:
# Frontend stores picks under `${postId}:${idx}`.
s = str(media_key or "").strip()
if not s:
return ""
return s.split(":", 1)[0].strip()
@lru_cache(maxsize=32)
def _load_sns_media_picks_cached(path_str: str, mtime: float) -> dict[str, str]:
p = Path(str(path_str or "").strip())
try:
raw = p.read_text(encoding="utf-8")
except Exception:
return {}
try:
obj = json.loads(raw)
except Exception:
return {}
picks_obj = obj.get("picks") if isinstance(obj, dict) else None
if not isinstance(picks_obj, dict):
return {}
out: dict[str, str] = {}
for k, v in picks_obj.items():
mk = str(k or "").strip()
if not mk:
continue
ck = _normalize_hex32(str(v or ""))
if not ck:
continue
out[mk] = ck
return out
def _load_sns_media_picks(account_dir: Path) -> dict[str, str]:
p = _sns_media_picks_path(account_dir)
try:
st = p.stat()
mtime = float(st.st_mtime)
except Exception:
mtime = 0.0
return _load_sns_media_picks_cached(str(p), mtime)
def _save_sns_media_picks(account_dir: Path, picks: dict[str, str]) -> int:
# Normalize + keep it stable for easier diff/debugging.
out: dict[str, str] = {}
for k, v in (picks or {}).items():
mk = str(k or "").strip()
if not mk:
continue
ck = _normalize_hex32(str(v or ""))
if not ck:
continue
out[mk] = ck
try:
payload = {"updated_at": int(time.time()), "picks": dict(sorted(out.items(), key=lambda x: x[0]))}
_sns_media_picks_path(account_dir).write_text(
json.dumps(payload, ensure_ascii=False, indent=2),
encoding="utf-8",
)
except Exception:
pass
try:
_load_sns_media_picks_cached.cache_clear()
except Exception:
pass
return len(out)
@lru_cache(maxsize=16)
def _sns_img_roots(wxid_dir_str: str) -> tuple[str, ...]:
"""List all month cache roots that contain `Sns/Img`."""
wxid_dir = Path(str(wxid_dir_str or "").strip())
cache_root = wxid_dir / "cache"
try:
month_dirs = [p for p in cache_root.iterdir() if p.is_dir()]
except Exception:
month_dirs = []
roots: list[str] = []
for mdir in month_dirs:
img_root = mdir / "Sns" / "Img"
try:
if img_root.exists() and img_root.is_dir():
roots.append(str(img_root))
except Exception:
continue
# Keep it stable (helps debugging and caching predictability).
roots.sort()
return tuple(roots)
@lru_cache(maxsize=16)
def _sns_video_roots(wxid_dir_str: str) -> tuple[str, ...]:
"""List all month cache roots that contain `Sns/Video`."""
@@ -1139,268 +908,6 @@ def _resolve_sns_cached_video_path(
return None
def _resolve_sns_cached_image_path_by_md5(
*,
wxid_dir: Path,
md5: str,
create_time: int,
) -> Optional[str]:
"""Try to resolve SNS cache image by md5-based cache path layout."""
md5_32 = _normalize_hex32(md5)
if not md5_32:
return None
sub = md5_32[:2]
rest = md5_32[2:]
roots = _sns_img_roots(str(wxid_dir))
if not roots:
return None
best: tuple[float, str] | None = None
for root_str in roots:
try:
p = Path(root_str) / sub / rest
if not (p.exists() and p.is_file()):
continue
# Prefer the cache file closest to the post create_time (if provided),
# otherwise pick the newest one.
st = p.stat()
if create_time > 0:
score = abs(float(st.st_mtime) - float(create_time))
else:
score = -float(st.st_mtime)
if best is None or score < best[0]:
best = (score, str(p))
except Exception:
continue
return best[1] if best else None
def _sns_cache_key_from_path(p: Path) -> str:
"""Return the 32-hex cache key for a SNS cache file path, or ''."""
try:
# cache/.../Sns/Img/<2hex>/<30hex>
key = f"{p.parent.name}{p.name}"
except Exception:
return ""
return _normalize_hex32(key)
def _generate_sns_cache_key(tid: str, media_id: str, media_type: int = 2) -> str:
"""
公式: md5(tid_mediaId_type)
Example: 14852422213384352392_14852422213963625090_2 -> 6d479249ca5a090fab5c42c79bc56b89
"""
if not tid or not media_id:
return ""
raw_key = f"{tid}_{media_id}_{media_type}"
try:
return hashlib.md5(raw_key.encode("utf-8")).hexdigest()
except Exception:
return ""
def _resolve_sns_cached_image_path_by_cache_key(
*,
wxid_dir: Path,
cache_key: str,
create_time: int,
) -> Optional[str]:
"""Resolve SNS cache image by `<2hex>/<30hex>` cache key."""
key32 = _normalize_hex32(cache_key)
if not key32:
return None
sub = key32[:2]
rest = key32[2:]
roots = _sns_img_roots(str(wxid_dir))
if not roots:
return None
best: tuple[float, str] | None = None
for root_str in roots:
try:
p = Path(root_str) / sub / rest
if not (p.exists() and p.is_file()):
continue
st = p.stat()
if create_time > 0:
score = abs(float(st.st_mtime) - float(create_time))
else:
score = -float(st.st_mtime)
if best is None or score < best[0]:
best = (score, str(p))
except Exception:
continue
return best[1] if best else None
@lru_cache(maxsize=4096)
def _resolve_sns_cached_image_path(
*,
account_dir_str: str,
create_time: int,
width: int,
height: int,
idx: int,
total_size: int = 0,
) -> Optional[str]:
"""Best-effort resolve a local cached SNS image for a post+media meta."""
total_size_i = int(total_size or 0)
must_match_size = width > 0 and height > 0
# Without size/total_size, time-only matching is too error-prone and can easily mix images.
if (not must_match_size) and total_size_i <= 0:
return None
account_dir = Path(str(account_dir_str or "").strip())
if not account_dir.exists():
return None
wxid_dir = _resolve_account_wxid_dir(account_dir)
if not wxid_dir:
return None
mtimes, paths = _sns_img_time_index(str(wxid_dir))
if not mtimes:
return None
create_time_i = int(create_time or 0)
if create_time_i > 0:
# We don't know when the image was cached (could be close to create_time, could be hours later).
# Use a generous window but keep it bounded for performance.
window = 72 * 3600 # 72h
lo = create_time_i - window
hi = create_time_i + window
l = bisect_left(mtimes, lo)
r = bisect_right(mtimes, hi)
if l >= r:
# Fallback: search the newest N files if time window has no hits.
l = max(0, len(mtimes) - 800)
r = len(mtimes)
else:
# Missing createTime: only probe the newest cache entries.
l = max(0, len(mtimes) - 800)
r = len(mtimes)
# Rank by time proximity to create_time (or by recency when createTime is missing).
candidates: list[tuple[float, str]] = []
for j in range(l, r):
try:
if create_time_i > 0:
candidates.append((abs(mtimes[j] - float(create_time_i)), paths[j]))
else:
candidates.append((-mtimes[j], paths[j]))
except Exception:
continue
candidates.sort(key=lambda x: x[0])
matched: list[tuple[int, float, str]] = []
# Limit the work per request.
max_probe = 2000 if (r - l) <= 2000 else 2000
for _diff, pstr in candidates[:max_probe]:
try:
p = Path(pstr)
payload, media_type = _read_and_maybe_decrypt_media(p, account_dir)
if not payload or not str(media_type or "").startswith("image/"):
continue
if must_match_size:
w0, h0 = _image_size_from_bytes(payload, str(media_type or ""))
if (w0, h0) != (width, height):
continue
size_diff = abs(len(payload) - total_size_i) if total_size_i > 0 else 0
# When totalSize is available, it tends to be a stronger discriminator than mtime.
matched.append((int(size_diff), float(_diff), pstr))
except Exception:
continue
if not matched:
return None
if must_match_size:
matched.sort(key=lambda x: (x[0], x[1], x[2]))
# If we have totalSize, treat it as a strong discriminator and always take the best match.
if total_size_i > 0:
return matched[0][2]
idx0 = max(0, int(idx or 0))
return matched[idx0][2] if idx0 < len(matched) else None
# No size: only return a best-effort match when totalSize is available.
if total_size_i > 0:
matched.sort(key=lambda x: (x[0], x[1], x[2]))
return matched[0][2]
return None
@lru_cache(maxsize=2048)
def _list_sns_cached_image_candidate_keys(
*,
account_dir_str: str,
create_time: int,
width: int,
height: int,
) -> tuple[str, ...]:
"""List local SNS cache candidates (as 32-hex cache keys) for a media item.
The ordering matches `_resolve_sns_cached_image_path()`'s scan order, so `idx`
is stable within the same (account, create_time, width, height) input.
"""
if create_time <= 0 or width <= 0 or height <= 0:
return tuple()
account_dir = Path(str(account_dir_str or "").strip())
if not account_dir.exists():
return tuple()
wxid_dir = _resolve_account_wxid_dir(account_dir)
if not wxid_dir:
return tuple()
mtimes, paths = _sns_img_time_index(str(wxid_dir))
if not mtimes:
return tuple()
window = 72 * 3600 # 72h
lo = create_time - window
hi = create_time + window
l = bisect_left(mtimes, lo)
r = bisect_right(mtimes, hi)
if l >= r:
l = max(0, len(mtimes) - 800)
r = len(mtimes)
candidates: list[tuple[float, str]] = []
for j in range(l, r):
try:
candidates.append((abs(mtimes[j] - float(create_time)), paths[j]))
except Exception:
continue
candidates.sort(key=lambda x: x[0])
max_probe = 2000 if (r - l) <= 2000 else 2000
out: list[str] = []
seen: set[str] = set()
for _diff, pstr in candidates[:max_probe]:
try:
p = Path(pstr)
payload, media_type = _read_and_maybe_decrypt_media(p, account_dir)
if not payload or not str(media_type or "").startswith("image/"):
continue
w0, h0 = _image_size_from_bytes(payload, str(media_type or ""))
if (w0, h0) != (width, height):
continue
key = _sns_cache_key_from_path(p)
if not key or key in seen:
continue
seen.add(key)
out.append(key)
except Exception:
continue
return tuple(out)
def _get_sns_covers(account_dir: Path, target_wxid: str, limit: int = 20) -> list[dict[str, Any]]:
"""无论多古老,强行揪出用户的朋友圈封面历史 (type=7)。
@@ -2575,47 +2082,6 @@ def list_sns_users(
return {"items": items, "count": len(items), "limit": lim}
class SnsMediaPicksSaveRequest(BaseModel):
account: Optional[str] = Field(None, description="账号目录名(可选,默认使用第一个)")
picks: dict[str, str] = Field(default_factory=dict, description="手动匹配表:`${postId}:${idx}` -> 32hex cacheKey")
@router.post("/api/sns/media_picks", summary="保存朋友圈图片手动匹配结果(本机)")
async def save_sns_media_picks(request: SnsMediaPicksSaveRequest):
account_dir = _resolve_account_dir(request.account)
count = _save_sns_media_picks(account_dir, request.picks or {})
return {"status": "success", "count": int(count)}
@router.get("/api/sns/media_candidates", summary="获取朋友圈图片本地缓存候选")
def list_sns_media_candidates(
account: Optional[str] = None,
create_time: int = 0,
width: int = 0,
height: int = 0,
limit: int = 24,
offset: int = 0,
):
if limit <= 0:
raise HTTPException(status_code=400, detail="Invalid limit.")
if limit > 200:
limit = 200
if offset < 0:
offset = 0
account_dir = _resolve_account_dir(account)
keys = _list_sns_cached_image_candidate_keys(
account_dir_str=str(account_dir),
create_time=int(create_time or 0),
width=int(width or 0),
height=int(height or 0),
)
total = len(keys)
end = min(total, offset + limit)
items = [{"idx": i, "key": keys[i]} for i in range(offset, end)]
return {"count": total, "items": items, "hasMore": end < total, "limit": limit, "offset": offset}
def _is_allowed_sns_media_host(host: str) -> bool:
return _sns_media.is_allowed_sns_media_host(host)
@@ -2902,10 +2368,7 @@ async def _try_fetch_and_decrypt_sns_remote(
token: str,
use_cache: bool,
) -> Optional[Response]:
"""Try remote download+decrypt first (accurate when keys are present).
Returns a Response on success, or None on failure so caller can fall back to local cache matching.
"""
"""Try remote download+decrypt first (accurate when keys are present)."""
res = await _sns_media.try_fetch_and_decrypt_sns_image_remote(
account_dir=account_dir,
url=str(url or ""),
@@ -2918,34 +2381,18 @@ async def _try_fetch_and_decrypt_sns_remote(
resp = Response(content=res.payload, media_type=res.media_type)
resp.headers["Cache-Control"] = "public, max-age=86400" if use_cache else "no-store"
resp.headers["X-SNS-Source"] = str(res.source or "remote")
if res.x_enc:
resp.headers["X-SNS-X-Enc"] = str(res.x_enc)
return resp
@router.get("/api/sns/media", summary="获取朋友圈图片(下载解密优先)")
async def get_sns_media(
account: Optional[str] = None,
create_time: int = 0,
width: int = 0,
height: int = 0,
total_size: int = 0,
idx: int = 0,
avoid_picked: int = 0,
post_id: Optional[str] = None,
media_id: Optional[str] = None,
post_type: int = 1,
media_type: int = 2,
pick: Optional[str] = None,
md5: Optional[str] = None,
token: Optional[str] = None,
key: Optional[str] = None,
use_cache: int = 1,
url: Optional[str] = None,
):
account_dir = _resolve_account_dir(account)
wxid_dir = _resolve_account_wxid_dir(account_dir)
try:
use_cache_flag = bool(int(use_cache or 1))
@@ -2963,179 +2410,7 @@ async def get_sns_media(
if remote_resp is not None:
return remote_resp
# Cache disabled: do not fall back to local cache heuristics.
if not use_cache_flag:
raise HTTPException(status_code=404, detail="SNS media not found (cache disabled).")
if wxid_dir and post_id and media_id:
if int(post_type) == 7:
raw_key = f"{post_id}_{media_id}_4" # 硬编码
md5_str = hashlib.md5(raw_key.encode("utf-8")).hexdigest()
bkg_path = wxid_dir / "business" / "sns" / "bkg" / md5_str[:2] / md5_str
if bkg_path.exists() and bkg_path.is_file():
print(f"===== Hit Bkg Cover ======= {bkg_path}")
return FileResponse(bkg_path, media_type="image/jpeg",
headers={"Cache-Control": "public, max-age=31536000", "X-SNS-Source": "bkg-cover"})
exact_match_path = None
hit_type = ""
# 尝试 1: 使用 post_type 计算 MD5
key_post = _generate_sns_cache_key(post_id, media_id, post_type)
exact_match_path = _resolve_sns_cached_image_path_by_cache_key(
wxid_dir=wxid_dir,
cache_key=key_post,
create_time=0
)
if exact_match_path:
hit_type = "post_type"
# 尝试 2: 如果没找到,并且 media_type 和 post_type 不一样,再试一次
if not exact_match_path and post_type != media_type:
key_media = _generate_sns_cache_key(post_id, media_id, media_type)
exact_match_path = _resolve_sns_cached_image_path_by_cache_key(
wxid_dir=wxid_dir,
cache_key=key_media,
create_time=0
)
if exact_match_path:
hit_type = "media_type"
# 如果通过这两种精确定位找到了文件,直接返回
if exact_match_path:
print(f"=====exact_match_path======={exact_match_path}============= (Hit: {hit_type})")
try:
payload, mtype = _read_and_maybe_decrypt_media(Path(exact_match_path), account_dir)
if payload and str(mtype or "").startswith("image/"):
resp = Response(content=payload, media_type=str(mtype or "image/jpeg"))
resp.headers["Cache-Control"] = "public, max-age=31536000"
resp.headers["X-SNS-Source"] = "deterministic-hash"
# 在 Header 里塞入到底是哪个 type 命中的,方便 F12 调试
resp.headers["X-SNS-Hit-Type"] = hit_type
return resp
except Exception:
pass
print("no exact match path, falling back...")
# 0) User-picked cache key override (stable across candidate ordering).
pick_key = _normalize_hex32(pick)
if pick_key:
wxid_dir = _resolve_account_wxid_dir(account_dir)
if wxid_dir:
local = _resolve_sns_cached_image_path_by_cache_key(
wxid_dir=wxid_dir,
cache_key=pick_key,
create_time=int(create_time or 0),
)
if local:
try:
payload, media_type = _read_and_maybe_decrypt_media(Path(local), account_dir)
if payload and str(media_type or "").startswith("image/"):
resp = Response(content=payload, media_type=str(media_type or "image/jpeg"))
resp.headers["Cache-Control"] = "public, max-age=86400"
resp.headers["X-SNS-Source"] = "manual-pick"
return resp
except Exception:
pass
# Optional: avoid using a cache image that was manually pinned to another post.
# Only applies when frontend enables this setting and the current media has no explicit `pick`.
try:
avoid_flag = bool(int(avoid_picked or 0))
except Exception:
avoid_flag = False
cur_post_id = str(post_id or "").strip()
reserved_other: set[str] = set()
if avoid_flag and (not pick_key) and cur_post_id:
picks_map = _load_sns_media_picks(account_dir)
for mk, ck in (picks_map or {}).items():
pid = _sns_post_id_from_media_key(mk)
if not pid or pid == cur_post_id:
continue
if ck:
reserved_other.add(str(ck))
# 1) Try local decrypted cache first (works for old posts where CDN URLs return placeholders).
local = _resolve_sns_cached_image_path(
account_dir_str=str(account_dir),
create_time=int(create_time or 0),
width=int(width or 0),
height=int(height or 0),
idx=max(0, int(idx or 0)),
total_size=int(total_size or 0),
)
if local and reserved_other:
try:
ck0 = _sns_cache_key_from_path(Path(local))
if ck0 and ck0 in reserved_other:
local = None
except Exception:
pass
if local:
try:
payload, media_type = _read_and_maybe_decrypt_media(Path(local), account_dir)
if payload and str(media_type or "").startswith("image/"):
resp = Response(content=payload, media_type=str(media_type or "image/jpeg"))
resp.headers["Cache-Control"] = "public, max-age=86400"
resp.headers["X-SNS-Source"] = "local-heuristic"
return resp
except Exception:
pass
# 1.5) If enabled, and the default match was skipped (or not found), pick the next candidate
# that is not reserved by a manual pick on another post.
if reserved_other and int(create_time or 0) > 0 and int(width or 0) > 0 and int(height or 0) > 0:
wxid_dir = _resolve_account_wxid_dir(account_dir)
if wxid_dir:
keys = _list_sns_cached_image_candidate_keys(
account_dir_str=str(account_dir),
create_time=int(create_time or 0),
width=int(width or 0),
height=int(height or 0),
)
base_idx = max(0, int(idx or 0))
for ck in keys[base_idx:]:
if not ck or ck in reserved_other:
continue
local2 = _resolve_sns_cached_image_path_by_cache_key(
wxid_dir=wxid_dir,
cache_key=str(ck),
create_time=int(create_time or 0),
)
if not local2:
continue
try:
payload, media_type = _read_and_maybe_decrypt_media(Path(local2), account_dir)
if payload and str(media_type or "").startswith("image/"):
resp = Response(content=payload, media_type=str(media_type or "image/jpeg"))
resp.headers["Cache-Control"] = "public, max-age=86400"
resp.headers["X-SNS-Source"] = "local-heuristic-next"
return resp
except Exception:
continue
# 2) Fallback to the remote URL (may still return a Tencent placeholder image).
u = str(url or "").strip()
if not u:
raise HTTPException(status_code=404, detail="SNS media not found.")
# Delay-import to avoid pulling requests machinery during normal timeline listing.
from .chat_media import proxy_image # pylint: disable=import-outside-toplevel
try:
resp0 = await proxy_image(u)
try:
resp0.headers["X-SNS-Source"] = "proxy"
except Exception:
pass
return resp0
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=502, detail=f"Fetch sns media failed: {e}")
raise HTTPException(status_code=404, detail="SNS media not found.")
@router.get("/api/sns/article_thumb", summary="提取公众号文章封面图")
@@ -3197,8 +2472,7 @@ async def get_sns_video_remote(
if path is None:
raise HTTPException(status_code=404, detail="SNS remote video not found.")
headers = {"X-SNS-Source": "remote-video-cache" if use_cache_flag else "remote-video"}
headers["Cache-Control"] = "public, max-age=86400" if use_cache_flag else "no-store"
headers = {"Cache-Control": "public, max-age=86400" if use_cache_flag else "no-store"}
if use_cache_flag:
return FileResponse(str(path), media_type="video/mp4", headers=headers)
@@ -13,23 +13,26 @@ from ..sns_export_service import SNS_EXPORT_MANAGER
router = APIRouter(route_class=PathFixRoute)
ExportScope = Literal["selected", "all"]
ExportFormat = Literal["html", "json", "txt"]
class SnsExportCreateRequest(BaseModel):
account: Optional[str] = Field(None, description="账号目录名(可选,默认使用第一个)")
scope: ExportScope = Field("selected", description="导出范围:selected=指定联系人;all=全部联系人")
usernames: list[str] = Field(default_factory=list, description="朋友圈 username 列表(scope=selected 时使用)")
format: ExportFormat = Field("html", description="导出格式:html/json/txt")
use_cache: bool = Field(True, description="是否复用导出过程中的本地缓存(默认开启)")
output_dir: Optional[str] = Field(None, description="导出目录绝对路径(可选;不填时使用默认目录)")
file_name: Optional[str] = Field(None, description="导出 zip 文件名(可选,不含/含 .zip 都可)")
@router.post("/api/sns/exports", summary="创建朋友圈导出任务(离线 HTML zip")
@router.post("/api/sns/exports", summary="创建朋友圈导出任务(离线 ZIP,支持 HTML/JSON/TXT")
async def create_sns_export(req: SnsExportCreateRequest):
job = SNS_EXPORT_MANAGER.create_job(
account=req.account,
scope=req.scope,
usernames=req.usernames,
export_format=req.format,
use_cache=bool(req.use_cache),
output_dir=req.output_dir,
file_name=req.file_name,
@@ -111,4 +114,3 @@ async def cancel_sns_export(export_id: str):
if not ok:
raise HTTPException(status_code=404, detail="Export not found.")
return {"status": "success"}
File diff suppressed because it is too large Load Diff
+35 -10
View File
@@ -8,8 +8,8 @@ so it can be reused by:
- Offline export (`sns_export_service.py`)
Important notes (empirical, matches current repo behavior):
- SNS images: prefer `wcdb_api.dll` export `wcdb_decrypt_sns_image` (black-box). Pure ISAAC64
keystream XOR is NOT reliable for images across versions.
- SNS images: match WeFlow's Electron implementation by generating the WxIsaac64
keystream from WASM and XORing the full payload in-memory.
- SNS videos: encrypted only for the first 128KB; decrypt via WeFlow's WxIsaac64 (WASM keystream)
and XOR in-place.
"""
@@ -31,9 +31,11 @@ import httpx
from fastapi import HTTPException
from .logging_config import get_logger
from .wcdb_realtime import decrypt_sns_image as _wcdb_decrypt_sns_image
logger = get_logger(__name__)
_PACKAGE_DIR = Path(__file__).resolve().parent
_NATIVE_DIR = _PACKAGE_DIR / "native"
_WEFLOW_WASM_DIR = _NATIVE_DIR / "weflow_wasm"
def is_allowed_sns_media_host(host: str) -> bool:
@@ -96,11 +98,16 @@ def _detect_mp4_ftyp(head: bytes) -> bool:
@lru_cache(maxsize=1)
def _weflow_wxisaac64_script_path() -> str:
"""Locate the Node helper that wraps WeFlow's wasm_video_decode.* assets."""
repo_root = Path(__file__).resolve().parents[2]
script = repo_root / "tools" / "weflow_wasm_keystream.js"
if script.exists() and script.is_file():
return str(script)
"""Locate the bundled Node helper that wraps the vendored wasm_video_decode.* assets."""
bundled = _WEFLOW_WASM_DIR / "weflow_wasm_keystream.js"
if bundled.exists() and bundled.is_file():
return str(bundled)
# Development fallback: allow the repo-level helper to proxy into the vendored assets.
repo_root = _PACKAGE_DIR.parents[1]
legacy = repo_root / "tools" / "weflow_wasm_keystream.js"
if legacy.exists() and legacy.is_file():
return str(legacy)
return ""
@@ -416,6 +423,24 @@ def detect_image_mime(data: bytes) -> str:
return ""
def weflow_decrypt_sns_image_bytes(payload: bytes, key: str) -> bytes:
"""Decrypt a Moments image with the same full-file XOR flow that WeFlow uses."""
raw = bytes(payload or b"")
key_text = str(key or "").strip()
if not raw or not key_text:
return raw
ks = weflow_wxisaac64_keystream(key_text, len(raw))
if not ks:
return raw
out = bytearray(raw)
n = min(len(out), len(ks))
for i in range(n):
out[i] ^= ks[i]
return bytes(out)
_SNS_REMOTE_CACHE_EXTS = [
".jpg",
".jpeg",
@@ -558,7 +583,7 @@ async def try_fetch_and_decrypt_sns_image_remote(
token: str,
use_cache: bool,
) -> Optional[SnsRemoteImageResult]:
"""Try WeFlow-style: download from CDN -> decrypt via wcdb_decrypt_sns_image -> return bytes.
"""Try WeFlow-style: download from CDN -> WxIsaac64 full-file XOR -> return bytes.
Returns a SnsRemoteImageResult on success, or None on failure so caller can fall back to
local cache matching logic.
@@ -652,7 +677,7 @@ async def try_fetch_and_decrypt_sns_image_remote(
if need_decrypt:
try:
decoded2 = _wcdb_decrypt_sns_image(raw, k)
decoded2 = weflow_decrypt_sns_image_bytes(raw, k)
mt2 = detect_image_mime(decoded2)
if mt2:
decoded = decoded2
@@ -1,63 +0,0 @@
import re
from collections.abc import MutableMapping
def add_sns_stage_timing_headers(
headers: MutableMapping[str, str],
*,
source: str,
hit_type: str = "",
x_enc: str = "",
) -> None:
"""Inject `Server-Timing` + `Timing-Allow-Origin` for SNS media stage inspection.
The frontend can't read `<img>` response headers, but browsers expose `Server-Timing` metrics
via `performance.getEntriesByName(...).serverTiming` when `Timing-Allow-Origin` allows it.
This helper is intentionally side-effect free beyond mutating `headers`.
"""
src = str(source or "").strip()
if not src:
return
ht = str(hit_type or "").strip()
xe = str(x_enc or "").strip()
if "Timing-Allow-Origin" not in headers:
headers["Timing-Allow-Origin"] = "*"
def _esc(v: str) -> str:
return v.replace("\\", "\\\\").replace('"', '\\"')
def _token(v: str) -> str:
raw = str(v or "").strip()
if not raw:
return ""
raw = raw.replace(" ", "_")
safe = re.sub(r"[^0-9A-Za-z_.-]+", "_", raw).strip("_")
if not safe:
return ""
return safe[:64]
parts: list[str] = []
src_tok = _token(src) or "unknown"
parts.append(f'sns_source_{src_tok};dur=0;desc="{_esc(src)}"')
if ht:
ht_tok = _token(ht)
if ht_tok:
parts.append(f'sns_hit_{ht_tok};dur=0;desc="{_esc(ht)}"')
if xe:
xe_tok = _token(xe)
if xe_tok:
parts.append(f'sns_xenc_{xe_tok};dur=0;desc="{_esc(xe)}"')
existing = str(headers.get("Server-Timing") or "").strip()
# Some responses may already have upstream `Server-Timing` metrics. Always append ours so
# the frontend can consistently read `sns_source_*` via ResourceTiming.serverTiming.
if existing and re.search(r"(^|,\\s*)sns_source(_|;)", existing):
return
combined = ", ".join(parts)
headers["Server-Timing"] = f"{existing}, {combined}" if existing else combined
+16 -3
View File
@@ -15,6 +15,20 @@ from wechat_decrypt_tool import sns_media # noqa: E402 pylint: disable=wrong-i
class TestSnsMedia(unittest.TestCase):
def test_weflow_wxisaac64_script_path_uses_bundled_helper(self):
sns_media._weflow_wxisaac64_script_path.cache_clear()
script = sns_media._weflow_wxisaac64_script_path()
self.assertTrue(script)
script_path = Path(script)
normalized = script.replace("\\", "/")
self.assertTrue(script_path.exists())
self.assertEqual(script_path.name, "weflow_wasm_keystream.js")
self.assertIn("/src/wechat_decrypt_tool/native/weflow_wasm/", normalized)
self.assertNotIn("/WeFlow/", normalized)
self.assertTrue((script_path.parent / "wasm_video_decode.js").exists())
self.assertTrue((script_path.parent / "wasm_video_decode.wasm").exists())
def test_fix_sns_cdn_url_image_rewrites_150_and_appends_token(self):
u = "http://mmsns.qpic.cn/sns/abc/150"
out = sns_media.fix_sns_cdn_url(u, token="tkn", is_video=False)
@@ -131,7 +145,7 @@ class TestSnsMedia(unittest.TestCase):
account_dir.mkdir(parents=True, exist_ok=True)
with mock.patch("wechat_decrypt_tool.sns_media._download_sns_remote_bytes", side_effect=fake_download):
with mock.patch("wechat_decrypt_tool.sns_media._wcdb_decrypt_sns_image", return_value=decoded):
with mock.patch("wechat_decrypt_tool.sns_media.weflow_decrypt_sns_image_bytes", return_value=decoded):
res = asyncio.run(
sns_media.try_fetch_and_decrypt_sns_image_remote(
account_dir=account_dir,
@@ -161,7 +175,7 @@ class TestSnsMedia(unittest.TestCase):
account_dir.mkdir(parents=True, exist_ok=True)
with mock.patch("wechat_decrypt_tool.sns_media._download_sns_remote_bytes", side_effect=fake_download):
with mock.patch("wechat_decrypt_tool.sns_media._wcdb_decrypt_sns_image", return_value=decoded_bad):
with mock.patch("wechat_decrypt_tool.sns_media.weflow_decrypt_sns_image_bytes", return_value=decoded_bad):
res = asyncio.run(
sns_media.try_fetch_and_decrypt_sns_image_remote(
account_dir=account_dir,
@@ -177,4 +191,3 @@ class TestSnsMedia(unittest.TestCase):
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,39 @@
import asyncio
import sys
import unittest
from pathlib import Path
from tempfile import TemporaryDirectory
from unittest import mock
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
from wechat_decrypt_tool.routers import sns # noqa: E402 pylint: disable=wrong-import-position
class TestSnsMediaRouteWeFlowDefault(unittest.TestCase):
def test_route_stops_after_remote_miss_by_default(self):
with TemporaryDirectory() as td:
account_dir = Path(td) / "acc"
account_dir.mkdir(parents=True, exist_ok=True)
with mock.patch("wechat_decrypt_tool.routers.sns._resolve_account_dir", return_value=account_dir):
with mock.patch("wechat_decrypt_tool.routers.sns._try_fetch_and_decrypt_sns_remote", return_value=None):
with self.assertRaises(sns.HTTPException) as ctx:
asyncio.run(
sns.get_sns_media(
account="acc",
url="https://mmsns.qpic.cn/sns/test/0",
key="123",
token="tkn",
use_cache=1,
)
)
self.assertEqual(ctx.exception.status_code, 404)
if __name__ == "__main__":
unittest.main()
-40
View File
@@ -1,40 +0,0 @@
import sys
import unittest
from pathlib import Path
from starlette.responses import Response
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
from wechat_decrypt_tool.sns_stage_timing import add_sns_stage_timing_headers # noqa: E402 pylint: disable=wrong-import-position
class TestSnsStageServerTiming(unittest.TestCase):
def test_injects_server_timing_when_missing(self):
resp = Response(content=b"ok")
add_sns_stage_timing_headers(resp.headers, source="proxy")
st = str(resp.headers.get("Server-Timing") or "")
self.assertIn("sns_source_", st)
self.assertIn("proxy", st)
def test_appends_when_upstream_server_timing_exists(self):
resp = Response(content=b"ok")
resp.headers["Server-Timing"] = "edge;dur=1"
add_sns_stage_timing_headers(resp.headers, source="proxy")
st = str(resp.headers.get("Server-Timing") or "")
self.assertIn("edge;dur=1", st)
self.assertIn("sns_source_", st)
def test_does_not_duplicate_existing_sns_source_metric(self):
resp = Response(content=b"ok")
resp.headers["Server-Timing"] = 'sns_source_proxy;dur=0;desc="proxy"'
add_sns_stage_timing_headers(resp.headers, source="proxy")
st = str(resp.headers.get("Server-Timing") or "")
self.assertEqual(st.count("sns_source_"), 1)
if __name__ == "__main__":
unittest.main()
+1 -121
View File
@@ -1,122 +1,2 @@
// Generate WeChat/WeFlow WxIsaac64 keystream via WeFlow's WASM module.
//
// Usage:
// node tools/weflow_wasm_keystream.js <key> <size>
//
// Prints a base64-encoded keystream to stdout (no extra logs).
const fs = require('fs')
const path = require('path')
const vm = require('vm')
function usageAndExit() {
process.stderr.write('Usage: node tools/weflow_wasm_keystream.js <key> <size>\\n')
process.exit(2)
}
const key = String(process.argv[2] || '').trim()
const size = Number(process.argv[3] || 0)
if (!key || !Number.isFinite(size) || size <= 0) usageAndExit()
const basePath = path.join(__dirname, '..', 'WeFlow', 'electron', 'assets', 'wasm')
const wasmPath = path.join(basePath, 'wasm_video_decode.wasm')
const jsPath = path.join(basePath, 'wasm_video_decode.js')
if (!fs.existsSync(wasmPath) || !fs.existsSync(jsPath)) {
process.stderr.write(`WeFlow WASM assets not found: ${basePath}\\n`)
process.exit(1)
}
const wasmBinary = fs.readFileSync(wasmPath)
const jsContent = fs.readFileSync(jsPath, 'utf8')
let capturedKeystream = null
let resolveInit
let rejectInit
const initPromise = new Promise((res, rej) => {
resolveInit = res
rejectInit = rej
})
const mockGlobal = {
console: { log: () => {}, error: () => {} }, // keep stdout clean
Buffer,
Uint8Array,
Int8Array,
Uint16Array,
Int16Array,
Uint32Array,
Int32Array,
Float32Array,
Float64Array,
BigInt64Array,
BigUint64Array,
Array,
Object,
Function,
String,
Number,
Boolean,
Error,
Promise,
require,
process,
setTimeout,
clearTimeout,
setInterval,
clearInterval,
}
mockGlobal.Module = {
onRuntimeInitialized: () => resolveInit(),
wasmBinary,
print: () => {},
printErr: () => {},
}
mockGlobal.self = mockGlobal
mockGlobal.self.location = { href: jsPath }
mockGlobal.WorkerGlobalScope = function () {}
mockGlobal.VTS_WASM_URL = `file://${wasmPath}`
mockGlobal.wasm_isaac_generate = (ptr, n) => {
const buf = new Uint8Array(mockGlobal.Module.HEAPU8.buffer, ptr, n)
capturedKeystream = new Uint8Array(buf) // copy view
}
try {
const context = vm.createContext(mockGlobal)
new vm.Script(jsContent, { filename: jsPath }).runInContext(context)
} catch (e) {
rejectInit(e)
}
;(async () => {
try {
await initPromise
if (!mockGlobal.Module.WxIsaac64 && mockGlobal.Module.asm && mockGlobal.Module.asm.WxIsaac64) {
mockGlobal.Module.WxIsaac64 = mockGlobal.Module.asm.WxIsaac64
}
if (!mockGlobal.Module.WxIsaac64) {
throw new Error('WxIsaac64 not found in WASM module')
}
capturedKeystream = null
const isaac = new mockGlobal.Module.WxIsaac64(key)
isaac.generate(size)
if (isaac.delete) isaac.delete()
if (!capturedKeystream) throw new Error('Failed to capture keystream')
const out = Buffer.from(capturedKeystream)
// Match WeFlow worker logic: reverse the captured Uint8Array.
out.reverse()
process.stdout.write(out.toString('base64'))
} catch (e) {
process.stderr.write(String(e && e.stack ? e.stack : e) + '\\n')
process.exit(1)
}
})()
require(path.join(__dirname, '..', 'src', 'wechat_decrypt_tool', 'native', 'weflow_wasm', 'weflow_wasm_keystream.js'))
Generated
+1 -1
View File
@@ -872,7 +872,7 @@ wheels = [
[[package]]
name = "wechat-decrypt-tool"
version = "1.3.0"
version = "1.7.10"
source = { editable = "." }
dependencies = [
{ name = "aiofiles" },