diff --git a/src/wechat_decrypt_tool/media_helpers.py b/src/wechat_decrypt_tool/media_helpers.py index 845f609..47a9e3c 100644 --- a/src/wechat_decrypt_tool/media_helpers.py +++ b/src/wechat_decrypt_tool/media_helpers.py @@ -2,6 +2,7 @@ import ctypes import datetime import glob import hashlib +import ipaddress import json import mimetypes import os @@ -11,6 +12,7 @@ import struct from functools import lru_cache from pathlib import Path from typing import Any, Optional +from urllib.parse import urlparse from fastapi import HTTPException @@ -74,15 +76,391 @@ def _detect_image_media_type(data: bytes) -> str: if data.startswith(b"\x89PNG\r\n\x1a\n"): return "image/png" - if data.startswith(b"\xff\xd8\xff"): - return "image/jpeg" + if data.startswith(b"\xff\xd8\xff") and len(data) >= 4: + marker = data[3] + # Most JPEG marker types are in 0xC0..0xFE (APP, SOF, DQT, DHT, SOS, COM, etc.). + # This avoids false positives where random bytes start with 0xFFD8FF. + if marker not in (0x00, 0xFF) and marker >= 0xC0: + return "image/jpeg" if data.startswith(b"GIF87a") or data.startswith(b"GIF89a"): return "image/gif" - if data.startswith(b"RIFF") and data[8:12] == b"WEBP": + if data.startswith(b"RIFF") and len(data) >= 12 and data[8:12] == b"WEBP": return "image/webp" return "application/octet-stream" +def _is_probably_valid_image(data: bytes, media_type: str) -> bool: + """Heuristic validation to reduce false positives when guessing XOR keys. + + We keep it lightweight (no full parsing), only checking common trailers. + """ + if not data: + return False + + mt = str(media_type or "").strip().lower() + if not mt.startswith("image/"): + return False + + if mt == "image/jpeg": + if _detect_image_media_type(data[:32]) != "image/jpeg": + return False + trimmed = data.rstrip(b"\x00") + if len(trimmed) < 4 or not trimmed.startswith(b"\xff\xd8\xff"): + return False + if trimmed.endswith(b"\xff\xd9"): + return True + tail = trimmed[-4096:] if len(trimmed) > 4096 else trimmed + i = tail.rfind(b"\xff\xd9") + return i >= 0 and i >= len(tail) - 64 - 2 + + if mt == "image/png": + if not data.startswith(b"\x89PNG\r\n\x1a\n"): + return False + trailer = b"\x00\x00\x00\x00IEND\xaeB`\x82" + trimmed = data.rstrip(b"\x00") + if trimmed.endswith(trailer): + return True + tail = trimmed[-256:] if len(trimmed) > 256 else trimmed + i = tail.rfind(trailer) + return i >= 0 and i >= len(tail) - 64 - len(trailer) + + if mt == "image/gif": + if not (data.startswith(b"GIF87a") or data.startswith(b"GIF89a")): + return False + trimmed = data.rstrip(b"\x00") + if trimmed.endswith(b"\x3B"): + return True + tail = trimmed[-256:] if len(trimmed) > 256 else trimmed + i = tail.rfind(b"\x3B") + return i >= 0 and i >= len(tail) - 16 - 1 + + if mt == "image/webp": + if len(data) < 12: + return False + return bool(data.startswith(b"RIFF") and data[8:12] == b"WEBP") + + # Unknown image types: fall back to header-only check. + return _detect_image_media_type(data[:32]) != "application/octet-stream" + + +def _normalize_variant_basename(name: str) -> str: + """Normalize a media filename stem by stripping common variant suffixes. + + Mirrors echotrace's idea of normalizing `.t/.h/.b/.c` and `_t/_h/_b/_c`. + """ + v = str(name or "").strip() + if not v: + return "" + lower = v.lower() + for suf in ("_b", "_h", "_c", "_t", ".b", ".h", ".c", ".t"): + if lower.endswith(suf) and len(lower) > len(suf): + return lower[: -len(suf)] + return lower + + +def _variant_rank(name: str) -> int: + """Ordering used when trying multiple candidate resources. + + Prefer: big > high > original > cache > thumb. + """ + n = str(name or "").lower() + if n.endswith(("_b", ".b")): + return 0 + if n.endswith(("_h", ".h")): + return 1 + if n.endswith(("_c", ".c")): + return 3 + if n.endswith(("_t", ".t")): + return 4 + return 2 + + +def _iter_media_source_candidates(source: Path, *, limit: int = 30) -> list[Path]: + """Yield sibling variant files around a resolved source path. + + This is a lightweight approximation of echotrace's \"search many .dat variants then try them\". + """ + if not source: + return [] + + try: + if not source.exists(): + return [] + except Exception: + return [] + + try: + if source.is_dir(): + return [] + except Exception: + return [] + + out: list[Path] = [] + try: + out.append(source.resolve()) + except Exception: + out.append(source) + + parent = source.parent + stem = str(source.stem or "") + base = _normalize_variant_basename(stem) + if not base: + return out + + preferred_names = [ + f"{base}_b.dat", + f"{base}_h.dat", + f"{base}.dat", + f"{base}_c.dat", + f"{base}_t.dat", + f"{base}.b.dat", + f"{base}.h.dat", + f"{base}.c.dat", + f"{base}.t.dat", + f"{base}.gif", + f"{base}.webp", + f"{base}.png", + f"{base}.jpg", + f"{base}.jpeg", + ] + + for name in preferred_names: + p = parent / name + try: + if p.exists() and p.is_file(): + out.append(p.resolve()) + except Exception: + continue + + # Add any other local .dat siblings with the same normalized base (limit to avoid explosion). + try: + for p in parent.glob(f"{base}*.dat"): + try: + if p.exists() and p.is_file(): + out.append(p.resolve()) + except Exception: + continue + if len(out) >= int(limit): + break + except Exception: + pass + + # De-dup while keeping order. + seen: set[str] = set() + uniq: list[Path] = [] + for p in out: + try: + k = str(p.resolve()) + except Exception: + k = str(p) + if k in seen: + continue + seen.add(k) + uniq.append(p) + return uniq + + +def _order_media_candidates(paths: list[Path]) -> list[Path]: + """Sort candidate files similar to echotrace's variant preference + size heuristic.""" + def _stat(p: Path) -> tuple[int, float]: + try: + st = p.stat() + return int(st.st_size), float(st.st_mtime) + except Exception: + return 0, 0.0 + + def key(p: Path) -> tuple[int, int, int, float, str]: + name = str(p.stem or "").lower() + rank = _variant_rank(name) + ext = str(p.suffix or "").lower() + # Prefer already-decoded formats (non-.dat) within the same variant rank. + ext_penalty = 1 if ext == ".dat" else 0 + size, mtime = _stat(p) + return (rank, ext_penalty, -size, -mtime, str(p)) + + try: + return sorted(list(paths or []), key=key) + except Exception: + return list(paths or []) + + +def _is_safe_http_url(url: str) -> bool: + u = str(url or "").strip() + if not u: + return False + try: + p = urlparse(u) + except Exception: + return False + if p.scheme not in ("http", "https"): + return False + host = (p.hostname or "").strip() + if not host: + return False + if host in {"localhost"}: + return False + try: + ip = ipaddress.ip_address(host) + if ip.is_private or ip.is_loopback or ip.is_link_local: + return False + except Exception: + pass + return True + + +def _download_http_bytes(url: str, *, timeout: int = 20, max_bytes: int = 30 * 1024 * 1024) -> bytes: + if not _is_safe_http_url(url): + raise HTTPException(status_code=400, detail="Unsafe URL.") + + try: + import requests + except Exception as e: + raise HTTPException(status_code=500, detail=f"requests not available: {e}") + + try: + with requests.get(url, stream=True, timeout=timeout) as r: + r.raise_for_status() + try: + cl = int(r.headers.get("content-length") or 0) + if cl and cl > int(max_bytes): + raise HTTPException(status_code=413, detail="Remote file too large.") + except HTTPException: + raise + except Exception: + pass + + chunks: list[bytes] = [] + total = 0 + for chunk in r.iter_content(chunk_size=256 * 1024): + if not chunk: + continue + chunks.append(chunk) + total += len(chunk) + if total > int(max_bytes): + raise HTTPException(status_code=413, detail="Remote file too large.") + return b"".join(chunks) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=502, detail=f"Download failed: {e}") + + +def _decrypt_emoticon_aes_cbc(data: bytes, aes_key_hex: str) -> Optional[bytes]: + """Decrypt WeChat emoticon payload from kNonStoreEmoticonTable.encrypt_url. + + Observed scheme (WeChat 4.x): + - key = bytes.fromhex(aes_key_hex) (16 bytes) + - iv = key + - cipher = AES-128-CBC + - padding = PKCS7 + """ + if not data: + return None + if len(data) % 16 != 0: + return None + + khex = str(aes_key_hex or "").strip().lower() + if not re.fullmatch(r"[0-9a-f]{32}", khex): + return None + + try: + key = bytes.fromhex(khex) + if len(key) != 16: + return None + except Exception: + return None + + try: + from Crypto.Cipher import AES + from Crypto.Util import Padding + + pt_padded = AES.new(key, AES.MODE_CBC, iv=key).decrypt(data) + pt = Padding.unpad(pt_padded, AES.block_size) + return pt + except Exception: + return None + + +@lru_cache(maxsize=2048) +def _lookup_emoticon_info(account_dir_str: str, md5: str) -> dict[str, str]: + account_dir = Path(account_dir_str) + md5s = str(md5 or "").strip().lower() + if not md5s: + return {} + + db_path = account_dir / "emoticon.db" + if not db_path.exists(): + return {} + + conn = sqlite3.connect(str(db_path)) + conn.row_factory = sqlite3.Row + try: + row = conn.execute( + "SELECT md5, aes_key, cdn_url, encrypt_url, extern_url, thumb_url, tp_url " + "FROM kNonStoreEmoticonTable WHERE lower(md5) = lower(?) LIMIT 1", + (md5s,), + ).fetchone() + if not row: + return {} + return {k: str(row[k] or "") for k in row.keys()} + except Exception: + return {} + finally: + try: + conn.close() + except Exception: + pass + + +def _try_fetch_emoticon_from_remote(account_dir: Path, md5: str) -> tuple[Optional[bytes], Optional[str]]: + info = _lookup_emoticon_info(str(account_dir), str(md5 or "").lower()) + if not info: + return None, None + + aes_key_hex = str(info.get("aes_key") or "").strip() + urls: list[str] = [] + # Prefer plain CDN URL first; fall back to encrypt_url (needs AES-CBC decrypt). + for k in ("cdn_url", "extern_url", "thumb_url", "tp_url", "encrypt_url"): + u = str(info.get(k) or "").strip() + if u and _is_safe_http_url(u): + urls.append(u) + + for url in urls: + try: + payload = _download_http_bytes(url) + except Exception: + continue + + candidates: list[bytes] = [payload] + dec = _decrypt_emoticon_aes_cbc(payload, aes_key_hex) + if dec is not None: + candidates.insert(0, dec) + + for data in candidates: + if not data: + continue + try: + data2, mt = _try_strip_media_prefix(data) + except Exception: + data2, mt = data, "application/octet-stream" + + if mt == "application/octet-stream": + mt = _detect_image_media_type(data2[:32]) + if mt == "application/octet-stream": + try: + if len(data2) >= 8 and data2[4:8] == b"ftyp": + mt = "video/mp4" + except Exception: + pass + + if mt.startswith("image/") and (not _is_probably_valid_image(data2, mt)): + continue + if mt != "application/octet-stream": + return data2, mt + + return None, None + + class _WxAMConfig(ctypes.Structure): _fields_ = [ ("mode", ctypes.c_int), @@ -191,7 +569,7 @@ def _try_strip_media_prefix(data: bytes) -> tuple[bytes, str]: if j >= 0 and j <= 128 * 1024: sliced = data[j:] mt2 = _detect_image_media_type(sliced[:32]) - if mt2 != "application/octet-stream": + if mt2 != "application/octet-stream" and _is_probably_valid_image(sliced, mt2): return sliced, mt2 try: @@ -363,7 +741,7 @@ def _resolve_media_path_from_hardlink( quoted = _quote_ident(table_name) try: row = conn.execute( - f"SELECT dir1, dir2, file_name, modify_time FROM {quoted} WHERE md5 = ? ORDER BY modify_time DESC LIMIT 1", + f"SELECT dir1, dir2, file_name, modify_time FROM {quoted} WHERE md5 = ? ORDER BY modify_time DESC, dir1 DESC, rowid DESC LIMIT 1", (md5,), ).fetchone() except Exception: @@ -917,9 +1295,10 @@ def _try_xor_decrypt_by_magic(data: bytes) -> tuple[Optional[bytes], Optional[st # (offset, magic, media_type) candidates: list[tuple[int, bytes, str]] = [ (0, b"\x89PNG\r\n\x1a\n", "image/png"), - (0, b"\xff\xd8\xff", "image/jpeg"), (0, b"GIF87a", "image/gif"), (0, b"GIF89a", "image/gif"), + (0, b"RIFF", "application/octet-stream"), + (4, b"ftyp", "video/mp4"), (0, b"wxgf", "application/octet-stream"), (1, b"wxgf", "application/octet-stream"), (2, b"wxgf", "application/octet-stream"), @@ -936,8 +1315,8 @@ def _try_xor_decrypt_by_magic(data: bytes) -> tuple[Optional[bytes], Optional[st (13, b"wxgf", "application/octet-stream"), (14, b"wxgf", "application/octet-stream"), (15, b"wxgf", "application/octet-stream"), - (0, b"RIFF", "application/octet-stream"), - (4, b"ftyp", "video/mp4"), + # JPEG magic is short (3 bytes), keep it last to reduce false positives. + (0, b"\xff\xd8\xff", "image/jpeg"), ] for offset, magic, mt in candidates: @@ -968,16 +1347,24 @@ def _try_xor_decrypt_by_magic(data: bytes) -> tuple[Optional[bytes], Optional[st if offset == 0 and magic == b"RIFF": if len(decoded) >= 12 and decoded[8:12] == b"WEBP": - return decoded, "image/webp" + if _is_probably_valid_image(decoded, "image/webp"): + return decoded, "image/webp" continue - if mt == "application/octet-stream": - mt2 = _detect_image_media_type(decoded[:32]) - if mt2 != "application/octet-stream": - return decoded, mt2 + if mt == "video/mp4": + try: + if len(decoded) >= 8 and decoded[4:8] == b"ftyp": + return decoded, "video/mp4" + except Exception: + pass continue - return decoded, mt + mt2 = _detect_image_media_type(decoded[:32]) + if mt2 != mt: + continue + if not _is_probably_valid_image(decoded, mt2): + continue + return decoded, mt2 preview_len = 8192 try: @@ -1005,6 +1392,8 @@ def _try_xor_decrypt_by_magic(data: bytes) -> tuple[Optional[bytes], Optional[st decoded = bytes(b ^ key for b in data) dec2, mt2 = _try_strip_media_prefix(decoded) if mt2 != "application/octet-stream": + if mt2.startswith("image/") and (not _is_probably_valid_image(dec2, mt2)): + continue return dec2, mt2 except Exception: continue @@ -1193,13 +1582,15 @@ def _detect_image_extension(data: bytes) -> str: """根据图片数据检测文件扩展名""" if not data: return "dat" - if data.startswith(b"\x89PNG\r\n\x1a\n"): + head = data[:32] if len(data) > 32 else data + mt = _detect_image_media_type(head) + if mt == "image/png": return "png" - if data.startswith(b"\xff\xd8\xff"): + if mt == "image/jpeg": return "jpg" - if data.startswith(b"GIF87a") or data.startswith(b"GIF89a"): + if mt == "image/gif": return "gif" - if data.startswith(b"RIFF") and len(data) >= 12 and data[8:12] == b"WEBP": + if mt == "image/webp": return "webp" return "dat" @@ -1262,18 +1653,19 @@ def _read_and_maybe_decrypt_media( try: data_pref = path.read_bytes() + # Only accept prefix stripping when it looks like a real image/video, + # otherwise encrypted/random bytes may trigger false positives. stripped, mtp = _try_strip_media_prefix(data_pref) if mtp != "application/octet-stream": - return stripped, mtp + if mtp.startswith("image/") and (not _is_probably_valid_image(stripped, mtp)): + pass + else: + return stripped, mtp except Exception: pass data = path.read_bytes() - dec, mt2 = _try_xor_decrypt_by_magic(data) - if dec is not None and mt2: - return dec, mt2 - # Try WeChat .dat v1/v2 decrypt. version = _detect_wechat_dat_version(data) if version in (0, 1, 2): @@ -1356,8 +1748,23 @@ def _read_and_maybe_decrypt_media( except Exception: pass + # Fallback: try guessing XOR key by magic (only after key-based decrypt attempts). + # For V4 signature files, XOR guessing is not applicable and may be expensive. + if version in (0, -1): + dec, mt2 = _try_xor_decrypt_by_magic(data) + if dec is not None and mt2: + return dec, mt2 + # Fallback: return as-is. mt3 = _guess_media_type_by_path(path, fallback="application/octet-stream") + if mt3.startswith("image/") and (not _is_probably_valid_image(data, mt3)): + mt3 = "application/octet-stream" + if mt3 == "video/mp4": + try: + if not (len(data) >= 8 and data[4:8] == b"ftyp"): + mt3 = "application/octet-stream" + except Exception: + mt3 = "application/octet-stream" return data, mt3 diff --git a/src/wechat_decrypt_tool/routers/chat_media.py b/src/wechat_decrypt_tool/routers/chat_media.py index edd29b3..c753156 100644 --- a/src/wechat_decrypt_tool/routers/chat_media.py +++ b/src/wechat_decrypt_tool/routers/chat_media.py @@ -18,6 +18,9 @@ from ..media_helpers import ( _convert_silk_to_wav, _detect_image_extension, _detect_image_media_type, + _is_probably_valid_image, + _iter_media_source_candidates, + _order_media_candidates, _ensure_decrypted_resource_for_md5, _fallback_search_media_by_file_id, _fallback_search_media_by_md5, @@ -31,6 +34,7 @@ from ..media_helpers import ( _resolve_account_wxid_dir, _resolve_media_path_for_kind, _resolve_media_path_from_hardlink, + _try_fetch_emoticon_from_remote, _try_find_decrypted_resource, _try_strip_media_prefix, ) @@ -255,11 +259,14 @@ async def get_chat_image( if decrypted_path: data = decrypted_path.read_bytes() media_type = _detect_image_media_type(data[:32]) - if media_type == "application/octet-stream": - guessed = mimetypes.guess_type(str(decrypted_path))[0] - if guessed: - media_type = guessed - return Response(content=data, media_type=media_type) + if media_type != "application/octet-stream" and _is_probably_valid_image(data, media_type): + return Response(content=data, media_type=media_type) + # Corrupted cached file (e.g. wrong ext / partial data): remove and regenerate from source. + try: + if decrypted_path.suffix.lower() in {".jpg", ".jpeg", ".png", ".gif", ".webp"}: + decrypted_path.unlink() + except Exception: + pass # 回退:从微信数据目录实时定位并解密 wxid_dir = _resolve_account_wxid_dir(account_dir) @@ -283,6 +290,7 @@ async def get_chat_image( ) p: Optional[Path] = None + candidates: list[Path] = [] if md5: p = _resolve_media_path_from_hardlink( @@ -297,12 +305,25 @@ async def get_chat_image( hit = _fallback_search_media_by_md5(str(wxid_dir), str(md5), kind="image") if hit: p = Path(hit) + # Also add scan-based candidates to improve the chance of finding a usable variant. + if wxid_dir: + try: + hit2 = _fallback_search_media_by_md5(str(wxid_dir), str(md5), kind="image") + if hit2: + candidates.extend(_iter_media_source_candidates(Path(hit2))) + except Exception: + pass elif file_id: # 一些版本图片消息无 MD5,仅提供 cdnthumburl 等“文件标识” for r in [wxid_dir, db_storage_dir]: if not r: continue - hit = _fallback_search_media_by_file_id(str(r), str(file_id), kind="image", username=str(username or "")) + hit = _fallback_search_media_by_file_id( + str(r), + str(file_id), + kind="image", + username=str(username or ""), + ) if hit: p = Path(hit) break @@ -310,9 +331,29 @@ async def get_chat_image( if not p: raise HTTPException(status_code=404, detail="Image not found.") - logger.info(f"chat_image: md5={md5} file_id={file_id} resolved_source={p}") + candidates.extend(_iter_media_source_candidates(p)) + candidates = _order_media_candidates(candidates) - data, media_type = _read_and_maybe_decrypt_media(p, account_dir=account_dir, weixin_root=wxid_dir) + logger.info(f"chat_image: md5={md5} file_id={file_id} candidates={len(candidates)} first={p}") + + data = b"" + media_type = "application/octet-stream" + chosen: Optional[Path] = None + for src_path in candidates: + try: + data, media_type = _read_and_maybe_decrypt_media(src_path, account_dir=account_dir, weixin_root=wxid_dir) + except Exception: + continue + + if media_type.startswith("image/") and (not _is_probably_valid_image(data, media_type)): + continue + + if media_type != "application/octet-stream": + chosen = src_path + break + + if not chosen: + raise HTTPException(status_code=422, detail="Image found but failed to decode/decrypt.") # 仅在 md5 有效时缓存到 resource 目录;file_id 可能非常长,避免写入超长文件名 if md5 and media_type.startswith("image/"): @@ -326,7 +367,9 @@ async def get_chat_image( except Exception: pass - logger.info(f"chat_image: md5={md5} file_id={file_id} media_type={media_type} bytes={len(data)}") + logger.info( + f"chat_image: md5={md5} file_id={file_id} chosen={chosen} media_type={media_type} bytes={len(data)}" + ) return Response(content=data, media_type=media_type) @@ -341,18 +384,31 @@ async def get_chat_emoji(md5: str, account: Optional[str] = None, username: Opti if decrypted_path: data = decrypted_path.read_bytes() media_type = _detect_image_media_type(data[:32]) - if media_type == "application/octet-stream": - guessed = mimetypes.guess_type(str(decrypted_path))[0] - if guessed: - media_type = guessed - return Response(content=data, media_type=media_type) + if media_type != "application/octet-stream" and _is_probably_valid_image(data, media_type): + return Response(content=data, media_type=media_type) + try: + if decrypted_path.suffix.lower() in {".jpg", ".jpeg", ".png", ".gif", ".webp"}: + decrypted_path.unlink() + except Exception: + pass wxid_dir = _resolve_account_wxid_dir(account_dir) p = _resolve_media_path_for_kind(account_dir, kind="emoji", md5=str(md5), username=username) - if not p: + + data = b"" + media_type = "application/octet-stream" + if p: + data, media_type = _read_and_maybe_decrypt_media(p, account_dir=account_dir, weixin_root=wxid_dir) + + if media_type == "application/octet-stream": + # Some emojis are stored encrypted (see emoticon.db); try remote fetch as fallback. + data2, mt2 = _try_fetch_emoticon_from_remote(account_dir, str(md5).lower()) + if data2 is not None and mt2: + data, media_type = data2, mt2 + + if (not p) and media_type == "application/octet-stream": raise HTTPException(status_code=404, detail="Emoji not found.") - data, media_type = _read_and_maybe_decrypt_media(p, account_dir=account_dir, weixin_root=wxid_dir) if media_type.startswith("image/"): try: out_md5 = str(md5).lower() diff --git a/src/wechat_decrypt_tool/routers/media.py b/src/wechat_decrypt_tool/routers/media.py index d2f3bdd..1359dce 100644 --- a/src/wechat_decrypt_tool/routers/media.py +++ b/src/wechat_decrypt_tool/routers/media.py @@ -11,6 +11,7 @@ from ..media_helpers import ( _collect_all_dat_files, _decrypt_and_save_resource, _detect_image_media_type, + _is_probably_valid_image, _get_resource_dir, _load_media_keys, _resolve_account_dir, @@ -315,14 +316,26 @@ async def decrypt_all_media_stream( # 检查是否已解密 existing = _try_find_decrypted_resource(account_dir, md5) if existing: - skip_count += 1 - # 每100个跳过的文件发送一次进度,减少消息量 - if skip_count % 100 == 0 or current == total_files: - yield ( - f"data: {json.dumps({'type': 'progress', 'current': current, 'total': total_files, 'success_count': success_count, 'skip_count': skip_count, 'fail_count': fail_count, 'current_file': file_name, 'status': 'skip', 'message': '已存在'})}\n\n" - ) - await asyncio.sleep(0) - continue + try: + cached = existing.read_bytes() + cached_mt = _detect_image_media_type(cached[:32]) + if cached_mt != "application/octet-stream" and _is_probably_valid_image(cached, cached_mt): + skip_count += 1 + # 每100个跳过的文件发送一次进度,减少消息量 + if skip_count % 100 == 0 or current == total_files: + yield ( + f"data: {json.dumps({'type': 'progress', 'current': current, 'total': total_files, 'success_count': success_count, 'skip_count': skip_count, 'fail_count': fail_count, 'current_file': file_name, 'status': 'skip', 'message': '已存在'})}\n\n" + ) + await asyncio.sleep(0) + continue + # Cache exists but looks corrupted: remove and regenerate. + existing.unlink(missing_ok=True) + except Exception: + # If we can't read/validate it, try regenerating. + try: + existing.unlink(missing_ok=True) + except Exception: + pass # 解密并保存 success, msg = _decrypt_and_save_resource(