Files
shirobana-bot/bot-utility/jm.py
T
RRRosmontis 34089d0042 Initial import of Shirobana bot backend
Include utility modules, shared channels, prompts, read-only skills, wardrobe memory and regression tests. Externalize legacy credentials and exclude private runtime state.
2026-11-11 00:25:30 +08:00

690 lines
29 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
# jm.py — JM 下载模块
# 流程: 源站下载 -> 本地打包 ZIP -> 复制到本机 OSS (/mnt/oss/jm/books/JM) -> 删除本地临时文件 -> QQ 回复分享页链接
# 约定: 任何情况下不在 QQ 内直发文件/图片; 失败时回复源站原url或 JMID
import asyncio
import json
import os
from pathlib import Path
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
import re
import shutil
import tempfile
import threading
import time
import unicodedata
from pathlib import Path
# ---------- 配置 ----------
JM_DIR = os.environ.get("JM_DIR", os.path.join(BASE_DIR, "jm"))
# 留存: 直接写本机 ossfs2 挂载的 OSS, 不再上传远程文件服务器。
# /mnt/oss/jm/books/JM 与 pan 上的 /mnt/A/jm/books/JM 是同一位置, 分享页 JMGeneralDownload 即指向它。
OSS_MOUNT = os.environ.get("JM_OSS_MOUNT", "/mnt/oss")
OSS_JM_DIR = os.environ.get("JM_OSS_DIR", os.path.join(OSS_MOUNT, "jm", "books", "JM"))
SHARE_PAGE = "https://pan.rrrosmontis.icu/share/JMGeneralDownload"
PROXY_CONFIG_PATH = os.environ.get("BOT_PROXY_CONFIG", os.path.join(BASE_DIR, "proxies.json"))
PROXY_COOLDOWN = 600 # 节点失败后冷却秒数
# 下载模式 (获取 ZIP 的方式):
# "openapi" = 走 openapi jm 服务 (hk 直连源站打包后回 ZIP), 代理绕行加速 + 全失败直连兜底
# "local" = 本地 jmcomic + 代理下载打包 (旧模式)
# 说明: 元数据解析/搜索仍用本地 jmcomic (单次轻量 API 调用); openapi 模式只是把
# 大头 (逐张下载+打包) offload 给 hk。上传 cf / 分享链接流程两种模式一致。
JM_FETCH_MODE = "openapi" # 2026-09-23 启用 openapi 模式
OPENAPI_JM_URL = "https://openapi.rrrosmontis.icu/jm/get_comic"
OPENAPI_TIMEOUT = (30, 1800) # (连接, 读取) 秒; 30 章 ZIP 经代理 ~200KB/s 需留足余量
MAX_CHAPTERS = 30 # 单段最多章节
SEARCH_LIMIT = 10 # 搜索最多返回条数
HTTP_TIMEOUT = 30
HELP_TEXT = (
"JM 下载\n"
"━━━━━━━━━━━━\n"
"• .jm <JMID> [起始-结束]\n"
"• .jm zip <JMID> [起始-结束]\n"
"• .jm ss <关键词> [页码] 搜索\n"
"示例: .jm 350234\n"
" .jm 350234 1-30\n"
" .jm ss 关键词 2\n"
"• 总章节 ≤30 可不填范围自动全量\n"
"• 每段最多 30 章\n"
"━━━━━━━━━━━━\n"
"下载完成将发送文件分享地址"
)
_active_users = set() # 同一用户防并发
_album_locks = {} # album_id -> asyncio.Lock: 同一 JMID 防并发 (共享 ZIP 文件名/工作目录)
_album_locks_guard = threading.Lock()
_proxy_lock = threading.Lock()
def _get_album_lock(album_id):
"""取指定 JMID 的 asyncio.Lock (去重/下载/打包/上传/清理 全段持锁, 同 JMID 串行, 不同 JMID 并行)"""
key = str(album_id)
with _album_locks_guard:
lock = _album_locks.get(key)
if lock is None:
lock = asyncio.Lock()
_album_locks[key] = lock
return lock
# ---------- 代理列表 (从 proxies.json 导入, 轮换 + 回退) ----------
def _load_proxy_list():
"""读取代理列表配置, 失败返回空列表"""
try:
with open(PROXY_CONFIG_PATH, encoding="utf-8") as f:
data = json.load(f)
return [p for p in data.get("proxies", []) if p.get("localPort")]
except Exception as e:
print(f"[jm] 代理列表加载失败: {e}")
return []
PROXY_LIST = _load_proxy_list()
_proxy_idx = 0 # 轮换指针
_proxy_unavailable = {} # localPort -> 失败时间戳
def _next_proxy():
"""轮换选取下一个可用代理, 全部冷却中则清空重来"""
global _proxy_idx
with _proxy_lock:
if not PROXY_LIST:
return None
now = time.time()
for port in list(_proxy_unavailable):
if now - _proxy_unavailable[port] >= PROXY_COOLDOWN:
del _proxy_unavailable[port]
for _ in range(len(PROXY_LIST)):
p = PROXY_LIST[_proxy_idx % len(PROXY_LIST)]
_proxy_idx = (_proxy_idx + 1) % len(PROXY_LIST)
if p["localPort"] not in _proxy_unavailable:
return p
# 全部冷却中: 保留冷却记录, 降级使用最早失败的节点
return min(PROXY_LIST, key=lambda p: _proxy_unavailable.get(p["localPort"], 0))
def _safe_text(s):
"""确保文本可被 Windows GBK 控制台输出 (剔除 emoji 等无法编码字符)"""
try:
s.encode("gbk")
return s
except (UnicodeEncodeError, AttributeError):
return s.encode("gbk", "replace").decode("gbk", "replace")
def _mark_proxy_down(proxy):
"""标记代理不可用, 进入冷却"""
if not proxy:
return
with _proxy_lock:
_proxy_unavailable[proxy["localPort"]] = time.time()
print(f"[jm] 代理不可用, 冷却 {PROXY_COOLDOWN}s: {_safe_text(proxy.get('name'))} "
f"(127.0.0.1:{proxy['localPort']})")
def _jm_proxies(proxy=None):
"""jmcomic postman 使用的代理配置 (本地 xray http 入站)"""
p = proxy or _next_proxy()
if not p:
return {}
return {
"http": f"http://127.0.0.1:{p['localPort']}",
"https": f"http://127.0.0.1:{p['localPort']}",
}
# ---------- 工具 ----------
def _sanitize_filename(name):
"""清理文件名非法字符, 保留中英文/数字/常见符号"""
cleaned = unicodedata.normalize("NFKC", name or "")
result = []
for ch in cleaned:
if ch.isalnum() or ch in ("_", "-", ".", "(", ")", "[", "]", "(", ")", "【", "】"):
result.append(ch)
elif ch.isspace():
result.append("_")
else:
result.append("-")
s = "".join(result)
while "__" in s:
s = s.replace("__", "_")
while "--" in s:
s = s.replace("--", "-")
return s[:80].strip("_-") or "unnamed"
def _source_url(album_id):
"""源站原url (域名运行时取自 jmcomic 库配置, 不硬编码)"""
try:
from jmcomic import JmModuleConfig
domains = JmModuleConfig.get_html_domain_all() or []
if domains:
return f"https://{domains[0]}/album/{album_id}"
except Exception:
pass
return f"JMID {album_id}"
def _clear_local_cache():
"""清空本地临时目录, 返回提示文本"""
d = Path(JM_DIR)
if not d.exists():
return "本地临时目录不存在, 无需清理"
total = 0
count = 0
for p in d.rglob("*"):
if p.is_file():
count += 1
try:
total += p.stat().st_size
except OSError:
pass
shutil.rmtree(d, ignore_errors=True)
d.mkdir(parents=True, exist_ok=True)
return f"已清理本地临时文件: {count} 个, 释放 {total / 1024 / 1024:.1f} MB"
def _cleanup_album(album_id, zip_path=None):
"""删除某次的临时图片目录和 ZIP (上传完毕后调用)"""
try:
shutil.rmtree(Path(JM_DIR) / str(album_id), ignore_errors=True)
except Exception:
pass
if zip_path:
try:
zp = Path(zip_path)
if zp.exists():
os.remove(zp)
except Exception:
pass
# ---------- 下载 + 打包 ----------
class _ParamError(RuntimeError):
"""参数类错误, 不应触发代理轮换"""
def _is_missing_album_error(e):
"""判定 jmcomic 的「本子/章节不存在」类异常 (确定性内容错误)。
优先按类型匹配 (jmcomic.jm_exception.MissingAlbumPhotoException), 版本差异取不到类时按类名兜底"""
try:
from jmcomic.jm_exception import MissingAlbumPhotoException
if isinstance(e, MissingAlbumPhotoException):
return True
except ImportError:
pass
return type(e).__name__ == "MissingAlbumPhotoException"
def _resolve_album_plan(album_id, range_start, range_end, proxy=None):
"""获取元数据并确定章节范围与 ZIP 文件名。
返回 (title, episodes, range_start, range_end, zip_name),失败抛异常"""
from jmcomic import JmModuleConfig
option = JmModuleConfig.option_class().default()
try:
option.client.postman.meta_data.proxies = _jm_proxies(proxy)
except Exception:
pass
client = option.build_jm_client()
try:
album = client.get_album_detail(int(album_id))
except Exception as e:
# jmcomic 对不存在/已下架 JMID 抛 MissingAlbumPhotoException, 属确定性内容错误,
# 转 _ParamError 避免误伤代理池 (冷却重试无意义); 其余异常(网络/解析)照旧冒泡走代理轮换
if _is_missing_album_error(e):
raise _ParamError(f"JMID {album_id} 不存在或已下架")
raise
title = getattr(album, "title", "") or str(album_id)
episodes = getattr(album, "episode_list", []) or []
total = len(episodes)
if total == 0:
raise _ParamError("未找到任何章节")
if range_start is None:
if total > MAX_CHAPTERS:
raise _ParamError(
f"共 {total} 章, 超过单段上限 {MAX_CHAPTERS} 章, 请分段下载 (如 1-{MAX_CHAPTERS})"
)
range_start, range_end = 1, total
else:
if range_start < 1 or range_end < range_start:
raise _ParamError("章节范围不合法")
if range_end - range_start + 1 > MAX_CHAPTERS:
raise _ParamError(f"章节跨度超过 {MAX_CHAPTERS} 章, 请缩小范围")
if range_start > total:
raise _ParamError(f"起始章节超出总章节数 {total}")
range_end = min(range_end, total)
zip_name = f"{_sanitize_filename(title)}_{album_id}_ch{range_start}-{range_end}.zip"
return title, episodes, range_start, range_end, zip_name
def _check_existing(zip_name):
"""检查 OSS 留存目录是否已有同名 ZIP, 返回 bool (OSS 未挂载或异常视为不存在)"""
try:
if not os.path.ismount(OSS_MOUNT):
return False
return os.path.isfile(os.path.join(OSS_JM_DIR, zip_name))
except Exception:
return False
def _download_and_pack(album_id, range_start, range_end, proxy=None, plan=None):
"""下载并打包 ZIP。成功返回 (title, zip_path, image_count, ok_ch, fail_ch),失败抛异常"""
import zipfile
from PIL import Image
from jmcomic import JmModuleConfig
# A previous proxy attempt may have left partial chapters or a truncated ZIP.
# The caller holds the per-album lock, so discard that attempt before reuse.
_cleanup_album(album_id, Path(JM_DIR) / plan[4] if plan is not None else None)
work_dir = Path(JM_DIR) / str(album_id)
work_dir.mkdir(parents=True, exist_ok=True)
if plan is None:
plan = _resolve_album_plan(album_id, range_start, range_end, proxy)
title, episodes, range_start, range_end, zip_name = plan
# 逐章下载
ok_count = 0
fail_count = 0
for idx in range(range_start - 1, range_end):
photo_id = episodes[idx][0]
try:
dl_option = JmModuleConfig.option_class().construct({
"dir_rule": {"rule": "Bd_Pid", "base_dir": str(work_dir)},
"download": {"image": {"suffix": ".webp"}},
"client": {"postman": {"meta_data": {"proxies": _jm_proxies(proxy)}}},
})
dler = JmModuleConfig.downloader_class()(dl_option)
dler.download_photo(int(photo_id))
ok_count += 1
print(f"[jm] 章节下载完成: {album_id}/{photo_id}")
except Exception as e:
fail_count += 1
print(f"[jm] 章节下载失败: {album_id}/{photo_id}: {e}")
# 收集图片 (按章节区间顺序逐章收集, 不依赖路径字典序: photo_id 位数跨越时 sorted() 会乱序;
# 同时避免把上次失败残留的、超出本次范围的章节图片打进来)
images = []
for idx in range(range_start - 1, range_end):
ch_dir = work_dir / str(episodes[idx][0])
for ext in ("*.webp", "*.jpg", "*.png"):
images.extend(sorted(ch_dir.glob(ext)))
if not images:
raise RuntimeError("未下载到任何图片")
# 打包 ZIP (webp 统一转 jpg, 无密码)
zip_path = Path(JM_DIR) / zip_name
tmp_dir = Path(tempfile.mkdtemp(prefix="jmpack_", dir=JM_DIR))
try:
packed = []
for i, p in enumerate(images, 1):
out = tmp_dir / f"{i:04d}.jpg"
try:
img = Image.open(str(p))
if img.mode != "RGB":
img = img.convert("RGB")
img.save(str(out), "JPEG", quality=95)
except Exception:
out = tmp_dir / f"{i:04d}{p.suffix.lower()}"
shutil.copyfile(str(p), str(out))
packed.append(out)
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
for j in packed:
zf.write(j, j.name)
finally:
shutil.rmtree(tmp_dir, ignore_errors=True)
print(f"[jm] 打包完成: {zip_name} ({len(images)} 张)")
return title, zip_path, len(images), ok_count, fail_count
def _download_from_openapi(album_id, plan, proxy=None):
"""从 openapi jm 服务下载已打包的 ZIP (hk 直连源站, 无需本地代理出墙)。
plan: _resolve_album_plan 的结果, 用于章节范围与本地文件名 (与 cf 去重一致)。
成功返回 (zip_path, image_count),失败抛异常; openapi 400 (参数类) 抛 _ParamError 不轮换代理。"""
import requests
title, episodes, range_start, range_end, zip_name = plan
url = f"{OPENAPI_JM_URL}?id={album_id}&range={range_start}-{range_end}"
kwargs = {"timeout": OPENAPI_TIMEOUT, "stream": True}
if proxy:
kwargs["proxies"] = _jm_proxies(proxy)
r = requests.get(url, **kwargs)
try:
if r.status_code != 200:
msg = f"HTTP {r.status_code}"
try:
j = r.json()
msg = j.get("detail") or j.get("error") or msg
except Exception:
pass
if r.status_code == 400:
raise _ParamError(msg)
raise RuntimeError(f"openapi jm {r.status_code}: {msg}")
# 本地文件名沿用 plan 的 zip_name, 保证上传 cf 的名字与去重检查一致
zip_path = Path(JM_DIR) / zip_name
with open(zip_path, "wb") as f:
for chunk in r.iter_content(chunk_size=256 * 1024):
if chunk:
f.write(chunk)
import zipfile
try:
with zipfile.ZipFile(zip_path) as z:
img_count = len(z.namelist())
except zipfile.BadZipFile:
try:
os.remove(zip_path)
except OSError:
pass
raise RuntimeError("openapi 返回的 ZIP 损坏")
print(f"[jm] openapi 下载完成: {zip_name} ({img_count} 张)", flush=True)
return zip_path, img_count
finally:
r.close()
# ---------- 搜索 ----------
def _search_jm(keyword, page=1, limit=SEARCH_LIMIT, proxy=None):
"""站内搜索, 返回 ([(album_id, title), ...], total, page_count),失败抛异常"""
from jmcomic import JmModuleConfig
option = JmModuleConfig.option_class().default()
try:
option.client.postman.meta_data.proxies = _jm_proxies(proxy)
except Exception:
pass
client = option.build_jm_client()
sp = client.search_site(search_query=keyword, page=page)
results = []
for aid, title in sp.iter_id_title():
results.append((aid, title))
if len(results) >= limit:
break
return results, int(sp.total), sp.page_count
# ---------- 留存到本机 OSS ----------
def _retain_to_oss(zip_path):
"""把 ZIP 复制到本机 OSS 留存目录, 返回 (ok, msg)
OSS 由 ossfs2 挂在 OSS_MOUNT 上; 未挂载时拒绝写入, 避免把大文件写到根分区。
"""
src = Path(zip_path)
if not os.path.ismount(OSS_MOUNT):
return False, "OSS 未挂载: %s" % OSS_MOUNT
try:
os.makedirs(OSS_JM_DIR, exist_ok=True)
dst = os.path.join(OSS_JM_DIR, src.name)
shutil.copyfile(str(src), dst)
# ossfs2 复制后 stat 可能短暂滞后, 最多重试 5 秒
expect = src.stat().st_size
for _ in range(5):
try:
if os.path.getsize(dst) == expect:
return True, "ok"
except OSError:
pass
time.sleep(1)
return False, "复制后大小校验不一致"
except Exception as e:
return False, str(e)
# ---------- 指令入口 ----------
async def handle_jm(websocket, message_type, target_id, user_id, raw_text):
from channel import send_message
uid = str(user_id)
body = re.sub(r"^\s*\.jm\b", "", raw_text, flags=re.IGNORECASE).strip()
parts = body.split()
# 无参数 / help
if not parts or parts[0].lower() == "help":
await send_message(websocket, message_type, target_id, HELP_TEXT)
return
# del-cache (仅管理员)
if parts[0].lower() in ("del-cache", "delcache", "clearcache", "clear"):
if not websocket.is_admin(user_id):
await send_message(websocket, message_type, target_id, "该指令仅管理员可用")
return
await send_message(websocket, message_type, target_id,
await asyncio.to_thread(_clear_local_cache))
return
# 站内搜索
if parts[0].lower() in ("ss", "search"):
args = parts[1:]
page_num = 1
if args and args[-1].isdigit():
page_num = max(1, int(args[-1]))
args = args[:-1]
keyword = " ".join(args).strip()
if not keyword:
await send_message(websocket, message_type, target_id,
"用法: .jm ss <关键词> [页码]")
return
if uid in _active_users:
await send_message(websocket, message_type, target_id,
"你有一个任务在处理中, 请稍候...")
return
_active_users.add(uid)
try:
await send_message(websocket, message_type, target_id,
f"正在搜索「{keyword}」第{page_num}页...")
last_err = None
node = None
for _ in range(len(PROXY_LIST)):
node = _next_proxy()
if node is None:
break
try:
results, total, page_count = await asyncio.to_thread(
_search_jm, keyword, page_num, SEARCH_LIMIT, node)
break
except Exception as e:
print(f"[jm] 搜索失败 (代理 {_safe_text(node.get('name'))}): {e}")
_mark_proxy_down(node)
last_err = e
node = None
if node is None:
await send_message(websocket, message_type, target_id,
f"搜索失败: 所有代理均不可用 ({str(last_err)[:80]})")
return
if not results:
if total > 0 and page_num > 1:
await send_message(websocket, message_type, target_id,
f"「{keyword}」第{page_num}页没有结果 (共{total}条/{page_count}页)")
else:
await send_message(websocket, message_type, target_id,
f"未找到「{keyword}」相关结果")
return
lines = [f"「{keyword}」第{page_num}页/共{page_count}页 (总{total}条):"]
for i, (aid, title) in enumerate(results, 1):
lines.append(f"{i}. {title}\n JMID: {aid}")
lines.append("━━━━━━━━━━━━")
if page_num < page_count:
lines.append(f"翻页: .jm ss {keyword} {page_num + 1}")
lines.append("使用 .jm <JMID> 下载")
await send_message(websocket, message_type, target_id, "\n".join(lines))
finally:
_active_users.discard(uid)
return
# 输出模式 (目前仅 zip)
if parts[0].lower() == "zip":
parts = parts[1:]
elif parts[0].lower() in ("pic", "pdf", "all"):
await send_message(websocket, message_type, target_id,
"目前仅支持 zip 输出\n用法: .jm zip <JMID> [起始-结束]")
return
if not parts or not parts[0].isdigit():
await send_message(websocket, message_type, target_id,
"格式: .jm <JMID> [起始-结束]\n示例: .jm 350234 或 .jm 350234 1-30")
return
album_id = parts[0]
range_start = range_end = None
if len(parts) >= 2:
m = re.fullmatch(r"(\d{1,4})-(\d{1,4})", parts[1])
if not m:
await send_message(websocket, message_type, target_id,
"章节范围格式: 起始-结束 (如 1-30)")
return
range_start, range_end = int(m.group(1)), int(m.group(2))
if uid in _active_users:
await send_message(websocket, message_type, target_id,
"你有一个任务在处理中, 请稍候...")
return
_active_users.add(uid)
try:
await send_message(websocket, message_type, target_id,
f"开始处理 JMID {album_id}, 完成后发送下载链接...")
# 1) 解析元数据, 确定章节范围与 ZIP 文件名 (轮换代理)
last_err = None
node = None
plan = None
for _ in range(len(PROXY_LIST)):
node = _next_proxy()
if node is None:
break
try:
plan = await asyncio.to_thread(
_resolve_album_plan, album_id, range_start, range_end, node)
break
except _ParamError as e:
await send_message(websocket, message_type, target_id,
f"JMID {album_id} 参数错误: {e}")
return
except Exception as e:
print(f"[jm] 获取信息失败 (代理 {_safe_text(node.get('name'))}): {e}")
_mark_proxy_down(node)
last_err = e
node = None
if node is None or plan is None:
src_url = await asyncio.to_thread(_source_url, album_id)
await send_message(websocket, message_type, target_id,
f"JMID {album_id} 获取信息失败: 所有代理均不可用 ({str(last_err)[:80]})\n"
f"可尝试: {OPENAPI_JM_URL}?id={album_id}\n"
f"或自行访问原url: {src_url}")
return
# 同一 JMID 串行: 并发请求同一本时, 后到任务持锁后先复查 OSS 去重 (前者已保存则直接复用),
# 避免同名 ZIP 并发互写 / local 模式共享工作目录混图 / 互相清理临时文件
async with _get_album_lock(album_id):
# 2) 去重: 目标目录已有同名 ZIP 则跳过
zip_name = plan[4]
if await asyncio.to_thread(_check_existing, zip_name):
await send_message(websocket, message_type, target_id,
f"文件已存在, 跳过下载: {zip_name}\n"
f"下载地址: {SHARE_PAGE}")
return
# 3) 获取 ZIP (openapi 模式: openapi jm 服务已打包直接回 ZIP, 代理绕行加速;
# local 模式: 本地 jmcomic + 代理下载打包。两者失败均轮换代理重试)
ok = False
zip_path = img_count = ok_c = fail_c = None
title = plan[0]
for _ in range(len(PROXY_LIST)):
try:
if JM_FETCH_MODE == "openapi":
zip_path, img_count = await asyncio.to_thread(
_download_from_openapi, album_id, plan, node)
ok_c = plan[3] - plan[2] + 1 # 章节数 (openapi 单章失败不会部分返回)
fail_c = 0
else:
title, zip_path, img_count, ok_c, fail_c = await asyncio.to_thread(
_download_and_pack, album_id, range_start, range_end, node, plan)
ok = True
break
except _ParamError as e:
# 参数类错误是确定性结果, 不触发代理轮换
await send_message(websocket, message_type, target_id,
f"JMID {album_id} 参数错误: {e}")
return
except Exception as e:
print(f"[jm] 下载失败 (模式 {JM_FETCH_MODE}, 节点 "
f"{_safe_text(node.get('name')) if node else '直连'}): {e}")
_mark_proxy_down(node)
last_err = e
node = _next_proxy()
if node is None:
break
if not ok and JM_FETCH_MODE == "openapi":
# 全部代理不可用: openapi 模式最后尝试直连兜底 (bj->hk 直连 ~8KB/s, 慢但可用)
try:
print(f"[jm] 代理全部失败, openapi 模式尝试直连兜底: {last_err}")
zip_path, img_count = await asyncio.to_thread(
_download_from_openapi, album_id, plan, None)
ok_c = plan[3] - plan[2] + 1
fail_c = 0
ok = True
except _ParamError as e:
await send_message(websocket, message_type, target_id,
f"JMID {album_id} 参数错误: {e}")
return
except Exception as e:
last_err = e
if not ok:
src_url = await asyncio.to_thread(_source_url, album_id)
# 对称清理: 失败也删除残留临时文件 (local 模式残留章节目录, openapi 模式半截 ZIP)
await asyncio.to_thread(_cleanup_album, album_id,
zip_path or Path(JM_DIR) / plan[4])
await send_message(websocket, message_type, target_id,
f"JMID {album_id} 下载失败: 所有代理均不可用 ({str(last_err)[:80]})\n"
f"可尝试: {OPENAPI_JM_URL}?id={album_id}\n"
f"或自行访问原url: {src_url}")
return
try:
up_ok, up_msg = await asyncio.to_thread(_retain_to_oss, zip_path)
except Exception as e:
up_ok, up_msg = False, str(e)
is_tg = getattr(websocket, "name", "") == "tg"
if not up_ok:
# TG 分支不回复错误, 但必须留日志 (旧逻辑只在 QQ 分支打印, 上传失败会静默)
print(f"[jm] 保存失败 {album_id}: {up_msg}")
try:
summary = (f"下载完成: {title}\n"
f"文件: {zip_path.name} ({img_count} 张, 成功 {ok_c} 章 / 失败 {fail_c} 章)")
if is_tg:
# TG 侧直接发送压缩包文件(不发 share 链接);
# 文件必须在 _cleanup_album 删除前发送,tg 通道失败时会提示分享页兜底
await send_message(websocket, message_type, target_id, [
{"type": "text", "data": {"text": summary}},
{"type": "file", "data": {"file": str(zip_path),
"manual": f"{OPENAPI_JM_URL}?id={album_id}"}},
])
elif not up_ok:
src_url = await asyncio.to_thread(_source_url, album_id)
await send_message(websocket, message_type, target_id,
f"JMID {album_id} 保存到 OSS 失败\n"
f"可尝试: {OPENAPI_JM_URL}?id={album_id}\n"
f"或自行访问原url: {src_url}")
else:
await send_message(websocket, message_type, target_id,
summary + f"\n下载地址: {SHARE_PAGE}")
finally:
# 无论上传/发送成败都删除本地临时文件
await asyncio.to_thread(_cleanup_album, album_id, zip_path)
finally:
_active_users.discard(uid)