Files
RRRosmontis 9ecbbbc6d8 feat(jm): 新增 JM 漫画下载打包服务(9004,移植自 qqbot jm 模块)
- source/jm/jm.py:aiohttp + jmcomic,GET /jm/get_comic?id=xxx[&range=起-止]
  下载至 cache/jm 隐藏暂存目录 → webp 转 jpg 打包 ZIP → HTTP 直返 → 600s 延时删除
  单次上限 30 章;per-album 锁防并发;cache_sweep 兜底清理;/jm/cache_info 运维接口
- source/main.py:聚合层注册 /jm/get_comic(新增 JM_BACKEND,复用 proxy())
- systemd/jm.service:独立系统服务(Restart=always,日志 /var/log/jm.log)
- README / 部署与运维文档同步更新
2026-09-23 10:58:48 +08:00

281 lines
9.8 KiB
Python
Raw Permalink 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 漫画下载 API 模块
# 流程: 源站下载 -> 本地临时目录打包 ZIP -> HTTP 返回 -> 延时删除
# 移植自 qqbot 的 jm 模块(bj C:/call/bot/utility/jm.py),
# 去掉 QQ 机器人 / 代理轮换 / 上传 cf 逻辑,改为与 pixiv/bilibili 一致的 HTTP 直返模式。
# hk 服务器可直连源站,无需代理。
import asyncio
import shutil
import tempfile
import time
import unicodedata
import zipfile
from pathlib import Path
from aiohttp import web
# ---------------- 本地临时缓存(与 pixiv/bilibili 同级:source/cache/jm) ----------------
CACHE_DIR = (Path(__file__).resolve().parent / "../cache/jm").resolve()
CACHE_DIR.mkdir(parents=True, exist_ok=True)
MAX_CHAPTERS = 30 # 单次最多章节(与 qqbot 一致,同时避免超过聚合层 300s 超时)
ZIP_TTL_SECONDS = 600 # ZIP 返回后保留 10 分钟再删除
PORT = 9004
_image_exts = (".webp", ".jpg", ".png")
# 同一本子防并发(album_id -> asyncio.Lock)
_locks = {}
_locks_guard = asyncio.Lock()
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 _zip_files():
return [f for f in CACHE_DIR.glob("*.zip") if f.is_file()]
def _cache_sweep():
"""兜底清理:进程重启后残留的过期 ZIP(mtime 超过 TTL 即删)"""
now = time.time()
for f in _zip_files():
try:
if now - f.stat().st_mtime > ZIP_TTL_SECONDS:
f.unlink()
print(f"[jm] sweep expired zip: {f.name}", flush=True)
except OSError:
pass
async def _delete_later(path, delay):
"""返回响应后延时删除 ZIP"""
await asyncio.sleep(delay)
try:
Path(path).unlink(missing_ok=True)
print(f"[jm] ttl delete: {Path(path).name}", flush=True)
except OSError:
pass
class _ParamError(RuntimeError):
"""参数类错误,返回 400"""
def _resolve_album_plan(album_id, range_start, range_end):
"""获取元数据并确定章节范围与 ZIP 文件名。
返回 (title, episodes, range_start, range_end, zip_name),失败抛异常"""
from jmcomic import JmModuleConfig
option = JmModuleConfig.option_class().default()
client = option.build_jm_client()
album = client.get_album_detail(int(album_id))
title = getattr(album, "title", "") or str(album_id)
episodes = getattr(album, "episode_list", []) or []
total = len(episodes)
if total == 0:
raise RuntimeError("未找到任何章节")
if range_start is None:
if total > MAX_CHAPTERS:
raise _ParamError(
f"共 {total} 章, 超过单次上限 {MAX_CHAPTERS} 章, 请用 range 参数分段下载 (如 range=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 _download_and_pack(album_id, plan):
"""下载并打包 ZIP。成功返回 (zip_path, image_count, ok_ch, fail_ch),失败抛异常"""
from jmcomic import JmModuleConfig
from PIL import Image
title, episodes, range_start, range_end, zip_name = plan
work_dir = Path(tempfile.mkdtemp(prefix=f".dl-{album_id}-", dir=CACHE_DIR))
try:
# 逐章下载(hk 直连源站,无需代理)
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"}},
})
dler = JmModuleConfig.downloader_class()(dl_option)
dler.download_photo(int(photo_id))
ok_count += 1
print(f"[jm] 章节下载完成: {album_id}/{photo_id}", flush=True)
except Exception as e:
fail_count += 1
print(f"[jm] 章节下载失败: {album_id}/{photo_id}: {e}", flush=True)
# 收集图片
images = []
for ext in _image_exts:
images.extend(sorted(work_dir.rglob(f"*{ext}")))
if not images:
raise RuntimeError("未下载到任何图片")
# 打包 ZIP (webp 统一转 jpg, 无密码)
zip_path = CACHE_DIR / zip_name
tmp_dir = Path(tempfile.mkdtemp(prefix=".pack-", dir=CACHE_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)} 张, 成功 {ok_count} 章 / 失败 {fail_count} 章)", flush=True)
return zip_path, len(images), ok_count, fail_count
finally:
# 无论成功/失败都回收图片临时目录
shutil.rmtree(work_dir, ignore_errors=True)
def _file_response(zip_path):
return web.FileResponse(
path=zip_path,
headers={
"Content-Type": "application/zip",
"Content-Disposition": f'attachment; filename="{zip_path.name}"',
},
)
async def handle_jm(request):
album_id = request.query.get("id", "").strip()
if not album_id or not album_id.isdigit():
return web.json_response({"error": "missing or invalid id parameter"}, status=400)
range_start = range_end = None
range_str = request.query.get("range", "").strip()
if range_str:
import re
m = re.fullmatch(r"(\d{1,4})-(\d{1,4})", range_str)
if not m:
return web.json_response(
{"error": "invalid range format, expected: start-end (e.g. 1-30)"}, status=400
)
range_start, range_end = int(m.group(1)), int(m.group(2))
_cache_sweep()
# 同本子串行,防止并发重复下载
async with _locks_guard:
lock = _locks.setdefault(album_id, asyncio.Lock())
if lock.locked():
return web.json_response(
{"error": "same album is being processed, please retry later"}, status=409
)
async with lock:
try:
plan = await asyncio.to_thread(
_resolve_album_plan, album_id, range_start, range_end
)
except _ParamError as e:
return web.json_response({"error": str(e)}, status=400)
except Exception as e:
return web.json_response(
{"error": "failed to fetch album info", "detail": str(e)[:200]}, status=502
)
zip_name = plan[4]
existing = CACHE_DIR / zip_name
if existing.exists():
# TTL 窗口内重复请求:直接复用并重新计时
try:
existing.touch()
except OSError:
pass
print(f"[jm] reuse zip: {zip_name}", flush=True)
asyncio.create_task(_delete_later(existing, ZIP_TTL_SECONDS))
return _file_response(existing)
try:
zip_path, img_count, ok_c, fail_c = await asyncio.to_thread(
_download_and_pack, album_id, plan
)
except Exception as e:
return web.json_response(
{"error": "download or pack failed", "detail": str(e)[:200]}, status=502
)
asyncio.create_task(_delete_later(zip_path, ZIP_TTL_SECONDS))
return _file_response(zip_path)
async def handle_cache_info(request):
files = []
total = 0
for f in _zip_files():
try:
st = f.stat()
except OSError:
continue
files.append({"name": f.name, "size": st.st_size, "mtime": int(st.st_mtime)})
total += st.st_size
return web.json_response(
{
"cache_dir": str(CACHE_DIR),
"zip_ttl_seconds": ZIP_TTL_SECONDS,
"max_chapters": MAX_CHAPTERS,
"count": len(files),
"total_bytes": total,
"free_bytes": shutil.disk_usage(CACHE_DIR).free,
"files": sorted(files, key=lambda x: -x["size"]),
}
)
app = web.Application()
app.router.add_get("/jm/get_comic", handle_jm)
app.router.add_get("/jm/cache_info", handle_cache_info)
if __name__ == "__main__":
print(f"Cache directory: {CACHE_DIR}")
web.run_app(app, host="127.0.0.1", port=PORT)