Include utility modules, shared channels, prompts, read-only skills, wardrobe memory and regression tests. Externalize legacy credentials and exclude private runtime state.
690 lines
29 KiB
Python
690 lines
29 KiB
Python
# -*- 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)
|