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.
This commit is contained in:
RRRosmontis
2026-11-11 00:25:30 +08:00
commit 34089d0042
106 changed files with 28028 additions and 0 deletions
+689
View File
@@ -0,0 +1,689 @@
# -*- 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)