├── .gitignore ├── .python-version ├── README.md ├── __init__.py ├── config.py ├── converter.py ├── docs └── screenshot │ ├── jm1.png │ ├── jms1.png │ ├── jms2.png │ ├── jmt1.png │ └── jmt2.png ├── downloader.py ├── model.py ├── option.example.yml ├── pyproject.toml ├── requirements.txt ├── templates ├── album_meta.html └── search_result.html └── utils.py /.gitignore: -------------------------------------------------------------------------------- 1 | option.yml 2 | __pycache__ -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.11 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # JMHelper - 漫画下载与转换 NoneBot2 插件 2 | 3 | 适用于 NoneBot2 的 JM(禁漫天堂) 插件,支持分页搜索、下载漫画并上传群文件、查看漫画元信息、漫画文件缓存及定时清理等功能。 4 | 5 | ## 效果展示 6 | 7 | ### 漫画元信息查询 8 | 9 |  10 |  11 | 12 | ## 漫画搜索 13 | 14 |  15 |  16 | 17 | ### 漫画下载 18 | 19 |  20 | 21 | ## 安装方法 22 | 23 | ```bash 24 | git clone https://github.com/X-Zero-L/jmhelper.git 25 | ``` 26 | 27 | 或者使用 git 子模块 28 | 29 | ```bash 30 | git submodule add https://github.com/X-Zero-L/jmhelper.git path/to/plugins/jmhelper 31 | ``` 32 | 33 | - 如果你使用 uv, 请使用以下命令 34 | 35 | ```bash 36 | uv add path/to/plugins/jmhelper 37 | ``` 38 | 或者 39 | ```bash 40 | uv add -r path/to/plugins/jmhelper/requirements.txt 41 | ``` 42 | 43 | - 如果你不知道 uv 是什么,请查看 [uv](https://github.com/astral-sh/uv) 44 | - 如果你不想使用 uv,那么你可以使用以下命令 45 | 46 | ```bash 47 | pip install -r requirements.txt 48 | ``` 49 | 50 | ## 配置说明 51 | 52 | 1. 复制插件目录中的`option.example.yml`为`option.yml`: 53 | 54 | ```bash 55 | cp option.example.yml option.yml 56 | ``` 57 | 58 | 2. 编辑`option.yml`文件,修改以下关键配置: 59 | 60 | - `client.postman.meta_data.proxies`: 设置代理(必须,不然可能无法访问) 61 | - `dir_rule.base_dir`: 设置下载和 PDF 输出的根目录, 注意和`pdf_dir`保持一致 62 | 63 | 主要配置项解释: 64 | 65 | ```yaml 66 | client: 67 | impl: html # 客户端实现,html(网页端)或api(APP端) 68 | domain: # 可用域名列表 69 | - 18comic.vip 70 | - 18comic.org 71 | postman: 72 | meta_data: 73 | proxies: 127.0.0.1:7890 # 代理设置,根据你的环境修改 74 | 75 | download: 76 | cache: true # 是否启用缓存 77 | image: 78 | decode: true # 是否解码图片 79 | suffix: .jpg # 图片格式 80 | 81 | dir_rule: 82 | base_dir: /your/path/to/download # 下载根目录,必须修改 83 | rule: Bd_Ptitle # 目录结构规则 84 | 85 | # 插件的配置示例 86 | plugins: 87 | after_photo: 88 | # 把章节的所有图片合并为一个pdf的插件 89 | # 使用前需要安装依赖库: [pip install img2pdf] 90 | - plugin: img2pdf 91 | kwargs: 92 | pdf_dir: /your/path/to/download # pdf存放文件夹,和dir_rule.base_dir保持一致 93 | filename_rule: Pid # pdf命名规则,P代表photo, id代表使用photo.id也就是章节id 94 | 95 | after_album: 96 | # img2pdf也支持合并整个本子,把上方的after_photo改为after_album即可。 97 | # https://github.com/hect0x7/JMComic-Crawler-Python/discussions/258 98 | # 配置到after_album时,需要修改filename_rule参数,不能写Pxx只能写Axx示例如下 99 | - plugin: img2pdf 100 | kwargs: 101 | pdf_dir: /your/path/to/download # pdf存放文件夹,和dir_rule.base_dir保持一致 102 | filename_rule: Aid # pdf命名规则,A代表album, id代表使用album.id也就是本子id 103 | ``` 104 | 105 | - 配置项详情请看 jmcomic 项目的[文档](https://jmcomic.readthedocs.io/zh-cn/latest/) 106 | - 如果你使用 docker 部署 nonebot/llonebot/napcat 等,建议这里的下载目录都对等挂载,不然你只能自己修改插件代码了 🤭 107 | - 本插件每天凌晨 3 点会清理今天之前的下载文件,所以请保证您输入的下载目录仅用于 jm 资源的下载,如果您不希望清理文件,请在环境变量中设置`JM_CLEAN=False` 108 | - 由于渲染图片时会用到漫画对应的封面图,如果你无法正常访问对应地址,或速度较慢,请在环境变量设置`htmlrender_proxy_host`,具体看[nonebot-plugin-htmlrender](https://github.com/kexue-z/nonebot-plugin-htmlrender) 109 | 110 | ## 使用方法 111 | 112 | 插件注册了以下指令: 113 | 114 | - `/jm [漫画ID]` - 下载并转换指定 ID 的漫画,上传 PDF 文件 115 | - `/jmt [漫画ID]` - 查看指定 ID 的漫画元信息,如标题、作者、标签等 116 | - `/jms [关键词] [页码]` - 搜索关键词,返回搜索结果,默认第一页 117 | - `/jmh` - 查看帮助信息 118 | 119 | 示例: 120 | 121 | ``` 122 | /jm 123456 123 | /jmt 123456 124 | /jms genshin 125 | /jmh 126 | ``` 127 | 128 | ## 注意事项 129 | 130 | 1. 请确保配置了正确的代理设置,否则可能无法访问资源 131 | 2. 确保机器人有上传群文件的权限 132 | 3. 部分敏感内容需要在`option.yml`中配置 cookies 才能下载 133 | 4. 请合理使用,避免频繁请求造成服务器压力 134 | 5. 虽然本插件使用了`nonebot_plugin_alconna`,但是文件上传接口使用的是 onebot11 的,所以不支持其他协议 😀 135 | 6. 如果你需要日志记录,请看[logfire](https://logfire.pydantic.dev/docs/) 136 | 137 | ## 目录结构 138 | 139 | ``` 140 | jmhelper/ 141 | ├── __init__.py # 插件主入口 142 | ├── config.py # 配置定义 143 | ├── converter.py # PDF转换器 144 | ├── downloader.py # 下载器 145 | ├── utils.py # 工具函数 146 | └── option.example.yml # 配置示例 147 | ``` 148 | -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import Optional, Tuple 3 | 4 | import logfire 5 | from nonebot import get_plugin_config, require 6 | from nonebot.plugin import PluginMetadata 7 | 8 | logfire.configure(send_to_logfire="if-token-present", scrubbing=False) 9 | 10 | require("nonebot_plugin_alconna") 11 | require("nonebot_plugin_htmlrender") 12 | require("nonebot_plugin_apscheduler") 13 | from nonebot_plugin_alconna import Alconna, Args, on_alconna, Match, UniMessage 14 | from nonebot.adapters import Bot, Event 15 | from nonebot_plugin_apscheduler import scheduler 16 | import jmcomic 17 | import yaml 18 | from functools import partial 19 | import asyncio 20 | import concurrent.futures 21 | import os 22 | 23 | from .config import Config 24 | from .downloader import download_album, get_album_detail, search_albums 25 | from .converter import convert_to_pdf 26 | from .utils import merge_forward 27 | 28 | _help_str = """ 29 | JM助手帮助 30 | /jm [漫画ID] - 下载并转换漫画资源为PDF格式 31 | /jmt [漫画ID] - 获取漫画详情 32 | /jms [关键字] [页数] - 搜索漫画, 默认第一页 33 | /jmh - JM助手帮助 34 | """.strip() 35 | 36 | __plugin_meta__ = PluginMetadata( 37 | name="JM助手", 38 | description="下载并转换漫画资源为PDF格式", 39 | usage=_help_str, 40 | config=Config, 41 | ) 42 | 43 | config = get_plugin_config(Config) 44 | 45 | download_command = on_alconna( 46 | Alconna( 47 | "/jm", 48 | Args["jmid", str], 49 | ), 50 | use_cmd_start=True, 51 | priority=5, 52 | block=True, 53 | aliases={"下载漫画", "下载本子", "/JM", "/Jm", "/jM"}, 54 | ) 55 | 56 | meta_command = on_alconna( 57 | Alconna( 58 | "/jm_meta", 59 | Args["jmid", str], 60 | ), 61 | use_cmd_start=True, 62 | priority=5, 63 | block=True, 64 | aliases={"获取漫画详情", "/JM_META", "/Jm_Meta", "/jM_meta", "/jmt"}, 65 | ) 66 | 67 | search_command = on_alconna( 68 | Alconna( 69 | "/jm_search", 70 | Args["keyword", str], 71 | Args["page?", int], 72 | ), 73 | use_cmd_start=True, 74 | priority=5, 75 | block=True, 76 | aliases={"搜索漫画", "/JM_SEARCH", "/Jm_Search", "/jM_search", "/jms"}, 77 | ) 78 | 79 | help_command = on_alconna( 80 | Alconna( 81 | "/jm_help", 82 | ), 83 | use_cmd_start=True, 84 | priority=5, 85 | block=True, 86 | aliases={"JM助手帮助", "/JM_HELP", "/Jm_Help", "/jM_help", "/jmh"}, 87 | ) 88 | 89 | thread_pool_executor = concurrent.futures.ThreadPoolExecutor() 90 | 91 | PLUGIN_DIR = Path(__file__).parent 92 | OPTION_FILE = PLUGIN_DIR / "option.yml" 93 | option = jmcomic.create_option_by_file(str(OPTION_FILE)) 94 | 95 | with open(OPTION_FILE, "r", encoding="utf-8") as f: 96 | option_dict = yaml.safe_load(f) 97 | BASE_DIR = option_dict["dir_rule"]["base_dir"] 98 | 99 | 100 | @scheduler.scheduled_job("cron", hour=3) 101 | async def clean_expired_files(): 102 | if not config.jm_clear: 103 | logfire.info("清理过期文件功能已关闭") 104 | return 105 | logfire.info("开始清理过期文件") 106 | for root, _, files in os.walk(BASE_DIR): 107 | for file in files: 108 | file_path = os.path.join(root, file) 109 | file_stat = os.stat(file_path) 110 | if file_stat.st_ctime < os.stat(BASE_DIR).st_ctime: 111 | os.remove(file_path) 112 | logfire.info(f"删除过期文件: {file_path}") 113 | logfire.info("清理过期文件完成") 114 | 115 | 116 | async def process_download(jmid: str) -> Tuple[Optional[str], Optional[str]]: 117 | """ 118 | 处理下载和转换任务 119 | 120 | Args: 121 | jmid: 漫画ID 122 | 123 | Returns: 124 | Tuple[Optional[str], Optional[str]]: (PDF文件路径, 漫画名称)或错误情况下的(None, None) 125 | """ 126 | 127 | album_pdf = os.path.join(BASE_DIR, f"{jmid}.pdf") 128 | if os.path.exists(album_pdf): 129 | logfire.info(f"PDF已存在: {album_pdf}") 130 | return album_pdf, jmid 131 | 132 | album, _ = await asyncio.get_event_loop().run_in_executor( 133 | thread_pool_executor, partial(download_album, jmid, option) 134 | ) 135 | 136 | album_name = album.name 137 | # 使用自带的插件,不再手动转换 138 | """ 139 | album_dir = os.path.join(BASE_DIR, album_name) 140 | pdf_path = await asyncio.get_event_loop().run_in_executor( 141 | thread_pool_executor, 142 | partial(convert_to_pdf, album_dir, BASE_DIR, f"{jmid}"), 143 | ) 144 | """ 145 | album_id = album.id 146 | album_pdf = os.path.join(BASE_DIR, f"{album_id}.pdf") 147 | # 检查文件大小,判断是否转换成功 148 | if os.path.exists(album_pdf): 149 | file_size = os.path.getsize(album_pdf) 150 | if file_size < 1024 * 1024: 151 | logfire.error(f"PDF文件过小: {file_size}") 152 | os.remove(album_pdf) 153 | raise Exception("下载失败,请重试") 154 | else: 155 | logfire.error(f"PDF文件不存在: {album_pdf}") 156 | raise Exception("下载失败,请重试") 157 | 158 | return album_pdf, album_name 159 | 160 | 161 | @download_command.handle() 162 | async def handle_download(bot: Bot, event: Event, jmid: Match[str]): 163 | jmid_value = jmid.result 164 | await download_command.send(f"正在处理 {jmid_value},请稍等...") 165 | 166 | try: 167 | album_pdf, album_name = await process_download(jmid_value) 168 | 169 | if album_pdf and album_name: 170 | await bot.upload_group_file( 171 | group_id=event.group_id, file=album_pdf, name=f"{album_name}.pdf" 172 | ) 173 | else: 174 | await download_command.send(f"处理 {jmid_value} 失败,请检查日志") 175 | 176 | except Exception as e: 177 | error_message = f"处理 {jmid_value} 时出错: {str(e)}" 178 | logfire.error(error_message, _exc_info=True) 179 | await download_command.send(error_message) 180 | 181 | 182 | @meta_command.handle() 183 | async def handle_meta(bot: Bot, event: Event, jmid: Match[str]): 184 | jmid_value = jmid.result 185 | await meta_command.send(f"正在获取 {jmid_value} 详情,请稍等...") 186 | 187 | try: 188 | album_info = await asyncio.get_event_loop().run_in_executor( 189 | thread_pool_executor, partial(get_album_detail, jmid_value, option) 190 | ) 191 | 192 | if album_info: 193 | await meta_command.send( 194 | await merge_forward( 195 | [await album_info.meta_img], 196 | uid=event.user_id, 197 | name=f"{jmid_value}详情", 198 | ) 199 | ) 200 | else: 201 | await meta_command.send(f"获取 {jmid_value} 详情失败,请检查日志") 202 | 203 | except Exception as e: 204 | error_message = f"获取 {jmid_value} 详情时出错: {str(e)}" 205 | logfire.error(error_message, _exc_info=True) 206 | await meta_command.send(error_message) 207 | 208 | 209 | @search_command.handle() 210 | async def handle_search(bot: Bot, event: Event, keyword: Match[str], page: Match[int]): 211 | keyword_value = keyword.result 212 | await search_command.send(f"正在搜索 {keyword_value},请稍等...") 213 | page_value = page.result if page.available else 1 214 | try: 215 | search_result = await asyncio.get_event_loop().run_in_executor( 216 | thread_pool_executor, 217 | partial(search_albums, keyword_value, option, page_value), 218 | ) 219 | 220 | if search_result: 221 | await search_command.send( 222 | await merge_forward( 223 | [await search_result.meta_img], 224 | uid=event.user_id, 225 | name=f"{keyword_value}搜索结果", 226 | ) 227 | ) 228 | else: 229 | await search_command.send(f"搜索 {keyword_value} 失败,请检查日志") 230 | 231 | except Exception as e: 232 | error_message = f"搜索 {keyword_value} 时出错: {str(e)}" 233 | logfire.error(error_message, _exc_info=True) 234 | await search_command.send(error_message) 235 | 236 | 237 | @help_command.handle() 238 | async def handle_help(): 239 | await help_command.finish(_help_str) 240 | -------------------------------------------------------------------------------- /config.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | 3 | 4 | class Config(BaseModel): 5 | """Plugin Config Here""" 6 | jm_clear: bool = True 7 | -------------------------------------------------------------------------------- /converter.py: -------------------------------------------------------------------------------- 1 | import re 2 | import time 3 | from pathlib import Path 4 | from typing import List, Optional 5 | import logfire 6 | from PIL import Image 7 | from reportlab.pdfgen import canvas 8 | from reportlab.lib.pagesizes import letter 9 | from PyPDF2 import PdfWriter 10 | import os 11 | import tempfile 12 | 13 | 14 | def sort_image_files(image_files: List[Path]) -> List[Path]: 15 | """ 16 | 按数字顺序排序图片文件 17 | 18 | Args: 19 | image_files: 图片文件路径列表 20 | 21 | Returns: 22 | List[Path]: 排序后的图片文件路径列表 23 | """ 24 | 25 | def extract_number(filename: Path) -> int: 26 | try: 27 | file_name = filename.name 28 | match = re.search(r"(\d+)\.jpg$", file_name, re.IGNORECASE) 29 | return int(match[1]) if match else 0 30 | except Exception as e: 31 | logfire.warning(f"提取文件名数字失败 {filename}: {str(e)}") 32 | return 0 33 | 34 | return sorted(image_files, key=extract_number) 35 | 36 | 37 | def convert_to_pdf( 38 | input_folder: str, output_folder: str, pdf_name: str 39 | ) -> Optional[str]: 40 | """ 41 | 将文件夹内的JPG图片转换为PDF,使用流式处理避免内存溢出 42 | 43 | Args: 44 | input_folder: 输入文件夹路径 45 | output_folder: 输出文件夹路径 46 | pdf_name: PDF文件名(不含扩展名) 47 | 48 | Returns: 49 | Optional[str]: 成功时返回PDF文件路径,失败时返回None 50 | """ 51 | start_time = time.time() 52 | 53 | try: 54 | input_path = Path(input_folder) 55 | pdf_path = Path(output_folder) 56 | 57 | # 确保输出目录存在 58 | pdf_path.mkdir(parents=True, exist_ok=True) 59 | 60 | # 搜集所有JPG图片 61 | image_files = list(input_path.glob("**/*.jpg")) 62 | image_files.extend(list(input_path.glob("**/*.JPG"))) 63 | 64 | if not image_files: 65 | logfire.warning(f"在 {input_folder} 中没有找到jpg图片") 66 | return None 67 | 68 | # 排序图片 69 | image_files = sort_image_files(image_files) 70 | logfire.info(f"找到 {len(image_files)} 张图片") 71 | 72 | # 创建最终PDF文件路径 73 | pdf_file = pdf_path / f"{pdf_name}.pdf" 74 | 75 | # 创建PDF合并器 76 | pdf_writer = PdfWriter() 77 | 78 | # 创建临时目录 79 | with tempfile.TemporaryDirectory() as temp_dir: 80 | # 逐个处理图片 81 | for i, img_path in enumerate(image_files): 82 | try: 83 | # 获取图片尺寸 84 | with Image.open(img_path) as img: 85 | width, height = img.size 86 | 87 | # 创建临时PDF 88 | temp_pdf = os.path.join(temp_dir, f"temp_{i}.pdf") 89 | 90 | # 使用reportlab将单个图片转换为PDF 91 | c = canvas.Canvas(temp_pdf, pagesize=(width, height)) 92 | c.drawImage(str(img_path), 0, 0, width, height) 93 | c.save() 94 | 95 | # 将临时PDF添加到合并器 96 | pdf_writer.append(temp_pdf) 97 | 98 | if (i + 1) % 10 == 0: 99 | logfire.info(f"已处理 {i + 1}/{len(image_files)} 张图片") 100 | 101 | except Exception as e: 102 | logfire.error(f"处理图片 {img_path} 时出错: {str(e)}", _exc_info=True) 103 | 104 | # 写入最终PDF文件 105 | with open(str(pdf_file), "wb") as f: 106 | pdf_writer.write(f) 107 | 108 | end_time = time.time() 109 | run_time = end_time - start_time 110 | logfire.info(f"PDF生成完成: {pdf_file},处理时间: {run_time:.2f}秒") 111 | 112 | return str(pdf_file) 113 | 114 | except Exception as e: 115 | logfire.error(f"PDF转换失败: {str(e)}", _exc_info=True) 116 | raise Exception(f"PDF转换失败: {str(e)}") from e -------------------------------------------------------------------------------- /docs/screenshot/jm1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/X-Zero-L/jmhelper/e5dd07d1fd83590233df56ae1b964e88aeb8397c/docs/screenshot/jm1.png -------------------------------------------------------------------------------- /docs/screenshot/jms1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/X-Zero-L/jmhelper/e5dd07d1fd83590233df56ae1b964e88aeb8397c/docs/screenshot/jms1.png -------------------------------------------------------------------------------- /docs/screenshot/jms2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/X-Zero-L/jmhelper/e5dd07d1fd83590233df56ae1b964e88aeb8397c/docs/screenshot/jms2.png -------------------------------------------------------------------------------- /docs/screenshot/jmt1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/X-Zero-L/jmhelper/e5dd07d1fd83590233df56ae1b964e88aeb8397c/docs/screenshot/jmt1.png -------------------------------------------------------------------------------- /docs/screenshot/jmt2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/X-Zero-L/jmhelper/e5dd07d1fd83590233df56ae1b964e88aeb8397c/docs/screenshot/jmt2.png -------------------------------------------------------------------------------- /downloader.py: -------------------------------------------------------------------------------- 1 | import jmcomic 2 | from jmcomic import JmOption 3 | import logfire 4 | from typing import Optional, Tuple, Any 5 | from .model import AlbumInfo, SearchResult 6 | 7 | 8 | def download_album(jmid: str, option: JmOption) -> Tuple[Any, Any]: 9 | """ 10 | 下载漫画 11 | 12 | Args: 13 | jmid: 漫画ID 14 | option: jmcomic选项 15 | 16 | Returns: 17 | Tuple[Any, Any]: (album, downloader)对象 18 | """ 19 | try: 20 | logfire.info(f"开始下载漫画 {jmid}") 21 | album, dler = jmcomic.download_album(jmid, option) 22 | logfire.info(f"漫画 {jmid} 下载完成,名称: {album.name}") 23 | return album, dler 24 | except Exception as e: 25 | logfire.error(f"下载漫画 {jmid} 失败: {str(e)}", _exc_info=True) 26 | raise Exception(f"下载失败: {str(e)}") from e 27 | 28 | 29 | def get_album_detail(jmid: str, option: JmOption) -> Optional[AlbumInfo]: 30 | """ 31 | 获取漫画详情 32 | 33 | Args: 34 | jmid: 漫画ID 35 | option: jmcomic选项 36 | 37 | Returns: 38 | Optional[AlbumInfo]: 漫画详情对象,Pydantic模型 39 | """ 40 | try: 41 | logfire.info(f"开始获取漫画 {jmid} 详情") 42 | client = option.build_jm_client() 43 | album = client.get_album_detail(jmid) 44 | album_info = AlbumInfo.from_jm_album(album) 45 | 46 | logfire.info( 47 | f"漫画 {jmid} 详情获取成功", 48 | album_id=album_info.album_id, 49 | name=album_info.name, 50 | authors=album_info.authors, 51 | tags=album_info.tags, 52 | ) 53 | return album_info 54 | except Exception as e: 55 | logfire.error(f"获取漫画 {jmid} 详情失败: {str(e)}", _exc_info=True) 56 | return None 57 | 58 | 59 | def search_albums( 60 | keyword: str, 61 | option: JmOption, 62 | page: int = 1, 63 | limit: int | None = None, 64 | retry: int = 3, 65 | ) -> SearchResult: 66 | """ 67 | 搜索漫画 68 | 69 | Args: 70 | keyword: 搜索关键词 71 | option: jmcomic选项 72 | page: 页码 73 | limit: 每页结果数量 74 | 75 | Returns: 76 | SearchResult: 搜索结果,Pydantic模型 77 | """ 78 | try: 79 | logfire.info(f"搜索漫画: 关键词={keyword}, 页码={page}, 数量={limit}") 80 | client = option.build_jm_client() 81 | search_page = client.search_site( 82 | keyword, 83 | page=page, 84 | ) 85 | 86 | albums_info = [] 87 | 88 | # 如果是单个漫画的结果 89 | if search_page.is_single_album: 90 | album_detail = search_page.single_album 91 | albums_info.append(AlbumInfo.from_jm_album(album_detail)) 92 | # 正常搜索结果 93 | else: 94 | albums_info = [ 95 | AlbumInfo(album_id=album_id, name=album_data) 96 | for album_id, album_data in search_page 97 | ] 98 | 99 | search_result = SearchResult( 100 | query=keyword, 101 | total=len(albums_info), 102 | albums=albums_info, 103 | page=page, 104 | limit=limit, 105 | keyword=keyword, 106 | ) 107 | 108 | if not albums_info and retry > 0: 109 | logfire.warning(f"搜索结果为空,重试: {retry}") 110 | return search_albums(keyword, option, page, limit, retry - 1) 111 | 112 | logfire.info( 113 | f"搜索完成: 关键词={keyword}, 找到{search_result.total}个结果", 114 | search_result=search_result, 115 | ) 116 | return search_result 117 | except Exception as e: 118 | logfire.error(f"搜索漫画失败: {str(e)}", exc_info=True) 119 | return SearchResult(keyword=keyword, page=page, limit=limit) 120 | -------------------------------------------------------------------------------- /model.py: -------------------------------------------------------------------------------- 1 | import os 2 | from pydantic import BaseModel, Field 3 | from typing import Tuple, Any, List, Optional, Union 4 | from jmcomic import JmAlbumDetail, JmModuleConfig 5 | from nonebot_plugin_alconna import Image as AlconnaImage 6 | from nonebot_plugin_htmlrender import template_to_pic 7 | from pathlib import Path 8 | import logfire 9 | 10 | TEMPLATE_DIR = Path(os.path.dirname(os.path.abspath(__file__))) / "templates" 11 | 12 | 13 | class AlbumInfo(BaseModel): 14 | """漫画信息模型""" 15 | 16 | cover: str = Field("", description="封面图片链接") 17 | album_id: str = Field(..., description="漫画ID") 18 | name: str = Field(..., description="漫画名称") 19 | author: str = Field("", description="主要作者") 20 | authors: List[str] = Field(default_factory=list, description="所有作者") 21 | page_count: int = Field(0, description="总页数") 22 | pub_date: str = Field("", description="发布日期") 23 | update_date: str = Field("", description="更新日期") 24 | likes: str = Field("0", description="点赞数") 25 | views: str = Field("0", description="浏览数") 26 | comment_count: int = Field(0, description="评论数") 27 | tags: List[str] = Field(default_factory=list, description="标签") 28 | episode_count: int = Field(0, description="章节数") 29 | works: List[str] = Field(default_factory=list, description="相关作品") 30 | 31 | @classmethod 32 | def from_jm_album(cls, album: JmAlbumDetail) -> "AlbumInfo": 33 | """从JmAlbumDetail对象创建AlbumInfo模型""" 34 | return cls( 35 | cover=f"https://{JmModuleConfig.DOMAIN_IMAGE_LIST[0]}/media/albums/{album.album_id}.jpg", 36 | album_id=album.album_id, 37 | name=album.name, 38 | author=album.author, 39 | authors=album.authors, 40 | page_count=album.page_count, 41 | pub_date=album.pub_date, 42 | update_date=album.update_date, 43 | likes=album.likes, 44 | views=album.views, 45 | comment_count=album.comment_count, 46 | tags=album.tags, 47 | episode_count=len(album.episode_list), 48 | works=album.works or [], 49 | ) 50 | 51 | @property 52 | def meta(self) -> str: 53 | authors_str = "、".join(self.authors) if self.authors else "未知" 54 | display_tags = self.tags[:8] 55 | tags_str = "、".join(display_tags) if display_tags else "无标签" 56 | if len(self.tags) > 8: 57 | tags_str += f"...等{len(self.tags)}个标签" 58 | info_lines = [ 59 | f"📚 {self.name} [{self.album_id}]", 60 | f"👤 作者: {authors_str}", 61 | f"📅 发布: {self.pub_date}", 62 | f"🔄 更新: {self.update_date}", 63 | f"📊 统计: {self.views}浏览 | {self.likes}喜欢 | {self.comment_count}评论", 64 | f"📖 页数: {self.page_count}页 (共{self.episode_count}章)", 65 | f"🏷️ 标签: {tags_str}", 66 | ] 67 | 68 | if self.works: 69 | works_str = "、".join(self.works) if self.works else "无关联作品" 70 | 71 | info_lines.append(f"🔗 系列: {works_str}") 72 | 73 | info_lines.append(f"\n💾 发送 /jm {self.album_id} 下载此漫画") 74 | 75 | return "\n".join(info_lines) 76 | 77 | @property 78 | async def meta_img(self) -> Union[AlconnaImage, str]: 79 | """ 80 | 生成漫画信息的图片版本 81 | 82 | Returns: 83 | Union[AlconnaImage, str]: 成功时返回AlconnaImage对象,失败时返回文本版本 84 | """ 85 | try: 86 | pic_bytes = await template_to_pic( 87 | template_path=str(TEMPLATE_DIR), 88 | template_name="album_meta.html", 89 | templates={"album": self.model_dump()}, 90 | pages={ 91 | "viewport": {"width": 1100, "height": 500}, 92 | }, 93 | ) 94 | 95 | return AlconnaImage(raw=pic_bytes) 96 | except Exception as e: 97 | logfire.error(f"生成漫画信息图片失败: {str(e)}", _exc_info=True) 98 | return self.meta 99 | 100 | @property 101 | def brief_meta(self) -> str: 102 | authors_str = "、".join(self.authors[:2]) if self.authors else "未知" 103 | if len(self.authors) > 2: 104 | authors_str += f"...等{len(self.authors)}位作者" 105 | 106 | tags_preview = "、".join(self.tags[:3]) if self.tags else "无标签" 107 | if len(self.tags) > 3: 108 | tags_preview += "..." 109 | 110 | return f"📚 {self.name} [{self.album_id}]\n👤 {authors_str} | 📖 {self.page_count}页 | 🏷️ {tags_preview}" 111 | 112 | # id:name 113 | @property 114 | def id_name(self) -> str: 115 | return f"{self.album_id}:{self.name}" 116 | 117 | 118 | class SearchResult(BaseModel): 119 | """搜索结果模型""" 120 | 121 | query: str = Field("", description="搜索关键词") 122 | total: int = Field(0, description="总结果数") 123 | albums: List[AlbumInfo] = Field( 124 | default_factory=list, description="搜索到的漫画列表" 125 | ) 126 | page: int = Field(1, description="当前页码") 127 | limit: int | None = Field(None, description="每页结果数") 128 | keyword: str = Field("", description="搜索关键词") 129 | 130 | def format_search_results(self) -> str: 131 | detail_lines = [] 132 | 133 | detail_lines.extend( 134 | f"{i}. {album.id_name}" for i, album in enumerate(self.albums, start=1) 135 | ) 136 | footer = f"\n💡 发送 /jm [ID] 下载指定漫画" 137 | footer += f"\n🔍 发送 /jms {self.query} {self.page+1} 搜索下一页" 138 | 139 | return "\n\n".join(detail_lines) + footer 140 | 141 | @property 142 | def str(self) -> str: 143 | return self.format_search_results() 144 | 145 | @property 146 | async def meta_img(self) -> Union[AlconnaImage, str]: 147 | """ 148 | 生成搜索结果的图片版本 149 | 150 | Returns: 151 | Union[AlconnaImage, str]: 成功时返回AlconnaImage对象,失败时返回文本版本 152 | """ 153 | try: 154 | [ 155 | setattr( 156 | album, 157 | "cover", 158 | f"https://{JmModuleConfig.DOMAIN_IMAGE_LIST[0]}/media/albums/{album.album_id}.jpg", 159 | ) 160 | for album in self.albums 161 | ] 162 | pic_bytes = await template_to_pic( 163 | template_path=str(TEMPLATE_DIR), 164 | template_name="search_result.html", 165 | templates={"search": self.model_dump()}, 166 | pages={"viewport": {"width": 1280, "height": 1}}, 167 | ) 168 | 169 | return AlconnaImage(raw=pic_bytes) 170 | except Exception as e: 171 | logfire.error(f"生成搜索结果图片失败: {str(e)}", _exc_info=True) 172 | return self.format_search_results() 173 | -------------------------------------------------------------------------------- /option.example.yml: -------------------------------------------------------------------------------- 1 | # 开启jmcomic的日志输出,默认为true 2 | # 对日志有需求的可进一步参考文档 → https://jmcomic.readthedocs.io/en/latest/tutorial/11_log_custom/ 3 | log: true 4 | 5 | # 配置客户端相关 6 | client: 7 | # impl: 客户端实现类,不配置默认会使用JmModuleConfig.DEFAULT_CLIENT_IMPL 8 | # 可配置: 9 | # html - 表示网页端 10 | # api - 表示APP端 11 | # APP端不限ip兼容性好,网页端限制ip地区但效率高 12 | impl: html 13 | 14 | # domain: 域名配置,默认是 [],表示运行时自动获取域名。 15 | # 可配置特定域名,如下: 16 | # 程序会先用第一个域名,如果第一个域名重试n次失败,则换下一个域名重试,以此类推。 17 | domain: 18 | - 18comic.vip 19 | - 18comic.org 20 | 21 | # retry_times: 请求失败重试次数,默认为5 22 | retry_times: 5 23 | 24 | # postman: 请求配置 25 | postman: 26 | meta_data: 27 | # proxies: 代理配置,默认是 system,表示使用系统代理。 28 | # 以下的写法都可以: 29 | # proxies: null # 不使用代理 30 | # proxies: clash 31 | # proxies: v2ray 32 | # proxies: 127.0.0.1:7890 33 | # proxies: 34 | # http: 127.0.0.1:7890 35 | # https: 127.0.0.1:7890 36 | proxies: 127.0.0.1:7890 37 | 38 | # cookies: 帐号配置,默认是 null,表示未登录状态访问JM。 39 | # 禁漫的大部分本子,下载是不需要登录的;少部分敏感题材需要登录才能看。 40 | # 如果你希望以登录状态下载本子,最简单的方式是配置一下浏览器的cookies, 41 | # 不用全部cookies,只要那个叫 AVS 就行。 42 | # 特别注意!!!(https://github.com/hect0x7/JMComic-Crawler-Python/issues/104) 43 | # cookies是区分域名的: 44 | # 假如你要访问的是 `18comic.vip`,那么你配置的cookies也要来自于 `18comic.vip`,不能配置来自于 `jm-comic.club` 的cookies。 45 | # 如果你发现配置了cookies还是没有效果,大概率就是你配置的cookies和代码访问的域名不一致。 46 | #cookies: 47 | # AVS: qkwehjjasdowqeq # 这个值是乱打的,不能用 48 | 49 | # 下载配置 50 | download: 51 | cache: true # 如果要下载的文件在磁盘上已存在,不用再下一遍了吧?默认为true 52 | image: 53 | decode: true # JM的原图是混淆过的,要不要还原?默认为true 54 | suffix: .jpg # 把图片都转为.jpg格式,默认为null,表示不转换。 55 | threading: 56 | # image: 同时下载的图片数,默认是30张图 57 | # 数值大,下得快,配置要求高,对禁漫压力大 58 | # 数值小,下得慢,配置要求低,对禁漫压力小 59 | # PS: 禁漫网页一次最多请求50张图 60 | image: 30 61 | # photo: 同时下载的章节数,不配置默认是cpu的线程数。例如8核16线程的cpu → 16. 62 | # photo: 16 63 | 64 | # 文件夹规则配置,决定图片文件存放在你的电脑上的哪个文件夹 65 | dir_rule: 66 | # base_dir: 根目录。 67 | # 此配置也支持引用环境变量,例如 68 | # base_dir: ${JM_DIR}/下载文件夹/ 69 | base_dir: /your/path/to/download 70 | 71 | # rule: 规则dsl。 72 | # 本项只建议了解编程的朋友定制,实现在这个类: jmcomic.jm_option.DirRule 73 | # 写法: 74 | # 1. 以'Bd'开头,表示根目录 75 | # 2. 文件夹每增加一层,使用 '_' 或者 '/' 区隔 76 | # 3. 用Pxxx或者Ayyy指代文件夹名,意思是 JmPhotoDetail.xxx / JmAlbumDetail的.yyy。xxx和yyy可以写什么需要看源码。 77 | # 78 | # 下面演示如果要使用禁漫网站的默认下载方式,该怎么写: 79 | # 规则: 根目录 / 本子id / 章节序号 / 图片文件 80 | # rule: 'Bd / Aid / Pindex' 81 | # rule: 'Bd_Aid_Pindex' 82 | 83 | # 默认规则是: 根目录 / 章节标题 / 图片文件 84 | rule: Bd_Ptitle 85 | 86 | # 插件的配置示例 87 | plugins: 88 | after_photo: 89 | # 把章节的所有图片合并为一个pdf的插件 90 | # 使用前需要安装依赖库: [pip install img2pdf] 91 | - plugin: img2pdf 92 | kwargs: 93 | pdf_dir: /your/path/to/download # pdf存放文件夹 94 | filename_rule: Pid # pdf命名规则,P代表photo, id代表使用photo.id也就是章节id 95 | 96 | after_album: 97 | # img2pdf也支持合并整个本子,把上方的after_photo改为after_album即可。 98 | # https://github.com/hect0x7/JMComic-Crawler-Python/discussions/258 99 | # 配置到after_album时,需要修改filename_rule参数,不能写Pxx只能写Axx示例如下 100 | - plugin: img2pdf 101 | kwargs: 102 | pdf_dir: /your/path/to/download # pdf存放文件夹 103 | filename_rule: Aid # pdf命名规则,A代表album, id代表使用album.id也就是本子id -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "jmhelper" 3 | version = "0.1.0" 4 | description = "Add your description here" 5 | readme = "README.md" 6 | requires-python = ">=3.9, <4.0" 7 | dependencies = [ 8 | "img2pdf>=0.6.0", 9 | "jmcomic>=2.5.32", 10 | "logfire>=3.6.2", 11 | "nonebot-plugin-alconna>=0.55.1", 12 | "nonebot-plugin-apscheduler>=0.5.0", 13 | "nonebot-plugin-htmlrender>=0.6.0", 14 | "pillow>=11.1.0", 15 | "pypdf2>=3.0.1", 16 | "pyyaml>=6.0.2", 17 | "reportlab>=4.3.1", 18 | ] 19 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | jmcomic>=2.5.32 2 | logfire>=3.6.2 3 | nonebot-plugin-alconna>=0.55.1 4 | nonebot-plugin-htmlrender>=0.6.0 5 | pillow>=11.1.0 6 | pyyaml>=6.0.2 7 | pydantic 8 | pypdf2>=3.0.1 9 | reportlab>=4.3.1 -------------------------------------------------------------------------------- /templates/album_meta.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |
5 | 6 | 7 |