- 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 / 部署与运维文档同步更新
281 lines
9.8 KiB
Python
281 lines
9.8 KiB
Python
# -*- 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)
|