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:
@@ -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)
|
||||
Reference in New Issue
Block a user