mirror of
https://github.com/joeseesun/wechat-radar.git
synced 2026-09-11 16:08:30 +09:00
Initial open source release
This commit is contained in:
+148
@@ -0,0 +1,148 @@
|
||||
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import * as fsp from 'node:fs/promises';
|
||||
|
||||
// Node 22+ ships fs.promises.glob but TS types lag behind
|
||||
const glob = (fsp as unknown as {
|
||||
glob: (pattern: string, opts: { cwd: string }) => AsyncIterable<string>;
|
||||
}).glob;
|
||||
|
||||
const WX_CACHE_ROOT = join(
|
||||
/*turbopackIgnore: true*/ homedir(),
|
||||
'Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files',
|
||||
);
|
||||
|
||||
let _userDirCache: string | null = null;
|
||||
|
||||
/** 找到当前微信用户目录(取最近修改的) */
|
||||
function findUserDir(): string | null {
|
||||
if (_userDirCache && existsSync(/*turbopackIgnore: true*/ _userDirCache)) return _userDirCache;
|
||||
if (!existsSync(/*turbopackIgnore: true*/ WX_CACHE_ROOT)) return null;
|
||||
const entries = readdirSync(/*turbopackIgnore: true*/ WX_CACHE_ROOT, { withFileTypes: true })
|
||||
.filter((e) => e.isDirectory() && e.name !== 'all_users' && e.name !== 'Backup')
|
||||
.map((e) => {
|
||||
const p = join(/*turbopackIgnore: true*/ WX_CACHE_ROOT, e.name);
|
||||
return { p, mtime: statSync(/*turbopackIgnore: true*/ p).mtimeMs };
|
||||
})
|
||||
.sort((a, b) => b.mtime - a.mtime);
|
||||
if (entries.length === 0) return null;
|
||||
_userDirCache = entries[0].p;
|
||||
return _userDirCache;
|
||||
}
|
||||
|
||||
/** 列出按月分的子目录,按时间倒序(最近月份优先) */
|
||||
function listMonthDirs(userDir: string): string[] {
|
||||
const cacheDir = join(/*turbopackIgnore: true*/ userDir, 'cache');
|
||||
if (!existsSync(/*turbopackIgnore: true*/ cacheDir)) return [];
|
||||
return readdirSync(/*turbopackIgnore: true*/ cacheDir)
|
||||
.filter((d) => /^\d{4}-\d{2}$/.test(d))
|
||||
.sort((a, b) => b.localeCompare(a));
|
||||
}
|
||||
|
||||
export interface ResolvedImage {
|
||||
path: string;
|
||||
type: 'hd' | 'mid' | 'thumb';
|
||||
format: 'png' | 'jpeg' | 'gif' | 'bmp' | 'bin';
|
||||
}
|
||||
|
||||
/** 检测文件 magic bytes */
|
||||
function detectFormat(path: string): ResolvedImage['format'] {
|
||||
try {
|
||||
const fd = readFileSync(/*turbopackIgnore: true*/ path, { flag: 'r' });
|
||||
const h = fd.subarray(0, 4);
|
||||
if (h[0] === 0xff && h[1] === 0xd8) return 'jpeg';
|
||||
if (h[0] === 0x89 && h[1] === 0x50 && h[2] === 0x4e && h[3] === 0x47) return 'png';
|
||||
if (h[0] === 0x47 && h[1] === 0x49 && h[2] === 0x46) return 'gif';
|
||||
if (h[0] === 0x42 && h[1] === 0x4d) return 'bmp';
|
||||
} catch {}
|
||||
return 'bin';
|
||||
}
|
||||
|
||||
// 月份 → { localId → ResolvedImage } 索引(懒加载)
|
||||
const monthIndexCache = new Map<string, Map<number, ResolvedImage>>();
|
||||
const monthIndexLoading = new Map<string, Promise<void>>();
|
||||
|
||||
async function buildMonthIndex(userDir: string, month: string): Promise<void> {
|
||||
if (monthIndexCache.has(month)) return;
|
||||
const existing = monthIndexLoading.get(month);
|
||||
if (existing) return existing;
|
||||
|
||||
const p = (async () => {
|
||||
const monthRoot = join(/*turbopackIgnore: true*/ userDir, 'cache', month, 'Message');
|
||||
if (!existsSync(/*turbopackIgnore: true*/ monthRoot)) {
|
||||
monthIndexCache.set(month, new Map());
|
||||
return;
|
||||
}
|
||||
const idx = new Map<number, ResolvedImage>();
|
||||
const priority: Record<ResolvedImage['type'], number> = { hd: 3, mid: 2, thumb: 1 };
|
||||
|
||||
const consider = (path: string, type: ResolvedImage['type']) => {
|
||||
const m = /\/(\d+)_/.exec(path);
|
||||
if (!m) return;
|
||||
const id = Number(m[1]);
|
||||
const cur = idx.get(id);
|
||||
if (!cur || priority[type] > priority[cur.type]) {
|
||||
// 推迟 detectFormat 到实际请求时
|
||||
idx.set(id, { path, type, format: 'bin' });
|
||||
}
|
||||
};
|
||||
|
||||
for await (const p of glob('*/ImageTemp/*hd_temp_convert', { cwd: monthRoot })) {
|
||||
consider(join(/*turbopackIgnore: true*/ monthRoot, String(p)), 'hd');
|
||||
}
|
||||
for await (const p of glob('*/ImageTemp/*mid_temp_convert', { cwd: monthRoot })) {
|
||||
consider(join(/*turbopackIgnore: true*/ monthRoot, String(p)), 'mid');
|
||||
}
|
||||
for await (const p of glob('*/Thumb/*thumb.jpg', { cwd: monthRoot })) {
|
||||
consider(join(/*turbopackIgnore: true*/ monthRoot, String(p)), 'thumb');
|
||||
}
|
||||
monthIndexCache.set(month, idx);
|
||||
})();
|
||||
|
||||
monthIndexLoading.set(month, p);
|
||||
await p;
|
||||
monthIndexLoading.delete(month);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 local_id 在 wx 缓存找图。优先 hint 月份,否则按月扫到旧。
|
||||
* 用懒加载的内存索引加速:每月扫一次后命中 ~1ms。
|
||||
*/
|
||||
export async function resolveWxImage(
|
||||
localId: number,
|
||||
hintMonth?: string,
|
||||
): Promise<ResolvedImage | null> {
|
||||
const userDir = findUserDir();
|
||||
if (!userDir) return null;
|
||||
|
||||
const months = listMonthDirs(userDir);
|
||||
if (months.length === 0) return null;
|
||||
|
||||
const ordered = hintMonth && months.includes(hintMonth)
|
||||
? [hintMonth, ...months.filter((m) => m !== hintMonth)]
|
||||
: months;
|
||||
|
||||
for (const m of ordered) {
|
||||
await buildMonthIndex(userDir, m);
|
||||
const idx = monthIndexCache.get(m);
|
||||
if (!idx) continue;
|
||||
const hit = idx.get(localId);
|
||||
if (hit) {
|
||||
return { ...hit, format: detectFormat(hit.path) };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const MIME: Record<ResolvedImage['format'], string> = {
|
||||
png: 'image/png',
|
||||
jpeg: 'image/jpeg',
|
||||
gif: 'image/gif',
|
||||
bmp: 'image/bmp',
|
||||
bin: 'application/octet-stream',
|
||||
};
|
||||
|
||||
export function mimeFor(format: ResolvedImage['format']): string {
|
||||
return MIME[format];
|
||||
}
|
||||
Reference in New Issue
Block a user