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 / 部署与运维文档同步更新
This commit is contained in:
RRRosmontis
2026-09-23 10:58:48 +08:00
parent 9ba194ae97
commit 9ecbbbc6d8
5 changed files with 367 additions and 15 deletions
+280
View File
@@ -0,0 +1,280 @@
# -*- 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)
+10
View File
@@ -4,6 +4,7 @@ from aiohttp import web, ClientSession, ClientTimeout
PIXIV_BACKEND = "http://127.0.0.1:9007"
BILIBILI_BACKEND = "http://127.0.0.1:9006"
DUFE_BACKEND = "http://127.0.0.1:9005"
JM_BACKEND = "http://127.0.0.1:9004"
async def handle_pixiv(request):
@@ -30,6 +31,14 @@ async def handle_dufe_sign_in(request):
return await proxy_forward(request, target)
async def handle_jm(request):
qs = request.query_string
target = f"{JM_BACKEND}/jm/get_comic"
if qs:
target += "?" + qs
return await proxy(request, target)
async def proxy(request, target):
timeout = ClientTimeout(total=300)
async with ClientSession(timeout=timeout) as session:
@@ -92,6 +101,7 @@ app.router.add_get("/pixiv/get_image", handle_pixiv)
app.router.add_get("/bilibili/get_video", handle_bilibili)
app.router.add_get("/dufe/ginkgostu_sign_in", handle_dufe_sign_in)
app.router.add_post("/dufe/ginkgostu_sign_in", handle_dufe_sign_in)
app.router.add_get("/jm/get_comic", handle_jm)
if __name__ == "__main__":
web.run_app(app, host="127.0.0.1", port=9008)