From b5d98583a769be741afdf9445f02c5bc5060ec8e Mon Sep 17 00:00:00 2001
From: hect0x7 <93357912+hect0x7@users.noreply.github.com>
Date: Wed, 6 May 2026 02:12:57 +0800
Subject: [PATCH 01/13] =?UTF-8?q?v2.6.19:=20=E6=96=B0=E5=A2=9E=20Feature?=
=?UTF-8?q?=20=E6=9C=BA=E5=88=B6=EF=BC=8C=E6=94=AF=E6=8C=81=E4=B8=8B?=
=?UTF-8?q?=E8=BD=BD=E6=97=B6=E9=80=9A=E8=BF=87=20extra=20=E5=8F=82?=
=?UTF-8?q?=E6=95=B0=E9=99=84=E5=8A=A0=E5=AF=BC=E5=87=BA=20PDF/ZIP/?=
=?UTF-8?q?=E9=95=BF=E5=9B=BE=E7=AD=89=E8=A1=8C=E4=B8=BA;=20Feature=20?=
=?UTF-8?q?=E5=8F=82=E6=95=B0=E6=A0=B9=E6=8D=AE=20download=5Falbum/downloa?=
=?UTF-8?q?d=5Fphoto=20=E6=9D=A5=E6=BA=90=E8=87=AA=E9=80=82=E5=BA=94;=20?=
=?UTF-8?q?=E6=96=B0=E5=A2=9E=20Feature=20=E6=95=99=E7=A8=8B=E6=96=87?=
=?UTF-8?q?=E6=A1=A3?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
README.md | 1 +
assets/docs/mkdocs.yml | 1 +
assets/docs/sources/index.md | 2 +
.../sources/tutorial/13_export_and_feature.md | 198 ++++++++++++++++++
src/jmcomic/__init__.py | 3 +-
src/jmcomic/api.py | 9 +-
src/jmcomic/jm_config.py | 52 +++++
src/jmcomic/jm_downloader.py | 38 ++++
src/jmcomic/jm_feature.py | 194 +++++++++++++++++
src/jmcomic/jm_option.py | 6 +-
tests/test_jmcomic/test_jm_feature.py | 144 +++++++++++++
11 files changed, 644 insertions(+), 4 deletions(-)
create mode 100644 assets/docs/sources/tutorial/13_export_and_feature.md
create mode 100644 src/jmcomic/jm_feature.py
create mode 100644 tests/test_jmcomic/test_jm_feature.py
diff --git a/README.md b/README.md
index f7bb3ca2d..fb41de233 100644
--- a/README.md
+++ b/README.md
@@ -30,6 +30,7 @@
> **🧭 快速指路**
> - [教程:使用 GitHub Actions 下载禁漫本子](./assets/docs/sources/tutorial/1_github_actions.md)
> - [教程:导出并下载你的禁漫收藏夹数据](./assets/docs/sources/tutorial/10_export_favorites.md)
+> - [教程:下载后转为 PDF / ZIP](./assets/docs/sources/tutorial/13_export_and_feature.md)
> - [塔台广播:欢迎各位机长加入并贡献代码](./.github/CONTRIBUTING.md)
>
> **友情提示:珍爱JM,为了减轻JM的服务器压力,请不要一次性爬取太多本子,西门🙏🙏🙏**.
diff --git a/assets/docs/mkdocs.yml b/assets/docs/mkdocs.yml
index 27c716af5..10c7675b6 100644
--- a/assets/docs/mkdocs.yml
+++ b/assets/docs/mkdocs.yml
@@ -58,6 +58,7 @@ nav:
- tutorial/10_export_favorites.md
- tutorial/11_log_custom.md
- tutorial/12_domain_strategy.md
+ - tutorial/13_export_and_feature.md
plugins:
- search
diff --git a/assets/docs/sources/index.md b/assets/docs/sources/index.md
index 16783b1d1..38cb62dbd 100644
--- a/assets/docs/sources/index.md
+++ b/assets/docs/sources/index.md
@@ -17,6 +17,7 @@
- [快速上手(GitHub README)](https://github.com/hect0x7/JMComic-Crawler-Python/tree/master?tab=readme-ov-file#%E5%BF%AB%E9%80%9F%E4%B8%8A%E6%89%8B)
- [常用类和方法演示](tutorial/0_common_usage.md)
+- [下载同时转 PDF/ZIP/长图](tutorial/13_export_and_feature.md)
- [option配置以及插件写法](./option_file_syntax.md)
## 特殊用法教程
@@ -30,6 +31,7 @@
- [下载过滤器机制](tutorial/5_filter.md)
- [插件机制](tutorial/6_plugin.md)
+- [Feature机制](tutorial/13_export_and_feature.md)
## 自定义
diff --git a/assets/docs/sources/tutorial/13_export_and_feature.md b/assets/docs/sources/tutorial/13_export_and_feature.md
new file mode 100644
index 000000000..cad829b66
--- /dev/null
+++ b/assets/docs/sources/tutorial/13_export_and_feature.md
@@ -0,0 +1,198 @@
+# Feature 机制——下载附加行为
+
+## 1. 需求场景
+
+下载本子后,很多用户有进一步导出的需求:
+- 导出为 **PDF**:方便在电子阅读器上查看
+- 导出为 **ZIP**:方便传输和存档
+- 合并为 **长图**:方便一张图看完整个章节
+
+jmcomic 一直通过内置插件(`img2pdf`、`zip`、`long_img`)支持这些功能,但传统方式需要在 YAML 配置文件中编写插件配置,门槛偏高。
+
+从最新版本起,jmcomic 引入了 **Feature(特性)** 机制——一套通用的**下载附加行为系统**,让你用一行代码搞定导出。Feature 不仅能调用插件,还能封装任意自定义逻辑(通知、清理等),并且会根据调用方式自动选择最合理的配置。
+
+内置了三个开箱即用的导出 Feature:
+
+| Feature | 效果 |
+|---------|------|
+| `Feature.export_pdf` | 下载完自动导出为 PDF |
+| `Feature.export_zip` | 下载完自动打包为 ZIP |
+| `Feature.export_long_img` | 下载完自动拼接为长图 PNG |
+
+## 2. 快速上手
+
+### 2.1 导出 PDF——基本用法示例
+
+```python
+from jmcomic import download_album, Feature
+
+# 只需要加一个 extra 参数,就能在下载完成后自动导出 PDF
+download_album('123', option, extra=Feature.export_pdf)
+```
+
+效果:在**当前工作目录**下生成以本子标题命名的 PDF 文件:
+
+```
+./
+├── [本子标题].pdf ← 整本合并为 1 个 PDF
+```
+
+### 2.2 需要多种导出格式(PDF、ZIP)——直接组合 Feature
+
+用 `+` 号组合,同时导出多种格式:
+
+```python
+# 下载完后同时导出 PDF 和 ZIP
+download_album('123', option, extra=Feature.export_pdf + Feature.export_zip)
+
+# 也支持列表语法
+download_album('123', option, extra=[Feature.export_pdf, Feature.export_zip])
+```
+
+### 2.3 自定义参数
+
+像调用函数一样传入自定义参数,可以改变输出目录、命名规则等:
+
+```python
+# 示例 1:指定输出目录和命名规则
+download_album('123', option, extra=Feature.export_pdf(
+ pdf_dir='D:/my_pdfs', # PDF 保存到 D:/my_pdfs 文件夹
+ filename_rule='Ptitle', # 用章节标题作为文件名
+ delete_original_file=True, # 合并完 PDF 后删除原图
+))
+
+# 示例 2:全都要——ZIP 存盘 + 长图阅读
+combo = (
+ Feature.export_zip(zip_dir='D:/zips')
+ + Feature.export_long_img(img_dir='D:/long_imgs')
+)
+download_album('123', option, extra=combo)
+```
+
+### 2.4 download_photo 也支持
+
+```python
+from jmcomic import download_photo, Feature
+
+# 对单个章节导出
+download_photo('456', option, extra=Feature.export_pdf)
+```
+
+效果:在当前工作目录下生成以章节标题命名的 PDF:
+
+```
+./
+├── [章节标题].pdf ← 该章节导出为 1 个 PDF
+```
+
+> 💡 **提示**:同一个 Feature,通过 `download_album` 和 `download_photo` 调用时会自动适配不同的导出行为,详见下方 [智能适配规则](#智能适配规则)。
+
+### 2.5 智能适配规则
+
+内置的导出 Feature 会根据调用的 API **自动适配**参数(命名规则、打包级别等):
+
+| 调用方式 | Feature.export_pdf | Feature.export_zip | Feature.export_long_img |
+|---------|-------------------|-------------------|----------------------|
+| `download_album` | 整本合并为 1 个 PDF
`[本子标题].pdf` | 整本打包为 1 个 ZIP
`[本子标题].zip` | 所有章节合并为 1 张长图
`[本子ID].png` |
+| `download_photo` | 该章节导出为 PDF
`[章节标题].pdf` | 该章节打包为 ZIP
`[章节标题].zip` | 该章节拼接为长图
`[章节ID].png` |
+
+当你显式传入参数时(如 `filename_rule='Ptitle'`),**你的配置优先**,不会被自适应覆盖。
+
+> 💡 **提示**:更多可选参数(如加密密码 `encrypt`、后缀名 `suffix` 等),参考 [Plugin 插件参数大全](./6_plugin.md#参数)。
+
+## 3. 传统写法(YAML 插件配置)
+
+如果你更习惯配置文件,仍然可以使用传统的插件配置方式:
+
+```yaml
+# option.yml
+plugins:
+ after_album:
+ - plugin: img2pdf
+ kwargs:
+ pdf_dir: ./output
+ filename_rule: Atitle
+ - plugin: zip
+ kwargs:
+ level: album
+ zip_dir: ./output
+```
+
+传统写法的更多细节见 → [Plugin 插件教程](./6_plugin.md)
+
+## 4. Feature 架构设计
+
+### 类层次
+
+```
+Feature (基类)
+ ├── PluginFeature ← 封装插件调用,参数根据来源自适应
+ └── 你的自定义 Feature ← 继承 Feature,实现任意逻辑
+```
+
+- **Feature 基类**:通用的附加行为抽象,不绑定任何具体实现。默认在所有生命周期钩子中执行。
+- **PluginFeature**:Feature 的子类,专门封装 jmcomic 插件。除了调用插件之外,还会根据调用来源动态适配 `filename_rule`、`level` 等参数。
+
+### 执行流程
+
+Feature **自然嵌入到 downloader 的生命周期钩子**中自动触发:
+
+```
+api.download_album(extra=Feature.export_pdf)
+ │
+ ├→ dler.add_features(pdf, 'download_album') # 注册: [(pdf, 'download_album')]
+ │
+ └→ dler.download_album(id)
+ │
+ ├→ before_album(album)
+ │
+ ├→ download_by_photo_detail(photo)
+ │ ├→ before_photo(photo)
+ │ ├→ download images ...
+ │ └→ after_photo(photo)
+ │ └→ _invoke_features_for('after_photo')
+ │ └→ pdf.should_invoke('after_photo', 'download_album') → False ✗ 跳过
+ │
+ └→ after_album(album)
+ └→ _invoke_features_for('after_album')
+ └→ pdf.should_invoke('after_album', 'download_album') → True ✓ 执行!
+ └→ _adapt_kwargs('download_album')
+ # Atitle 不变, Ptitle→Atitle, Pid→Aid, level→album
+```
+
+> 💡 **关键点**:
+>
+> - **执行时机**:`PluginFeature` 根据注册来源自动推导(`download_album` → `after_album`,`download_photo` → `after_photo`)。自定义 Feature 默认在所有钩子都会执行,你可以覆写 `should_invoke` 来控制。
+> - **参数自适应**:`PluginFeature` 的 `filename_rule` 前缀(A/P)和 `level`(album/photo)会根据来源动态适配。用户显式传入的参数不会被覆盖。
+
+### 自定义 Feature
+
+Feature 基类完全不绑定插件,你可以实现任意逻辑:
+
+```python
+from jmcomic import Feature, download_album
+
+class NotifyFeature(Feature):
+ """下载完成后发送通知"""
+ def invoke(self, option, **context):
+ album = context.get('album')
+ if album:
+ print(f'下载完成通知: {album.name}')
+
+# 使用
+download_album('123', option, extra=NotifyFeature())
+```
+
+### 自定义 PluginFeature
+
+如果你注册了自定义插件,也可以创建对应的 PluginFeature:
+
+```python
+from jmcomic import PluginFeature, Feature
+
+# 假设你注册了一个 plugin_key 为 'my_export' 的插件
+Feature.my_export = PluginFeature('my_export', output_dir='./my_output')
+
+# 使用
+download_album('123', option, extra=Feature.my_export)
+```
diff --git a/src/jmcomic/__init__.py b/src/jmcomic/__init__.py
index 9698ba5fc..9811d780b 100644
--- a/src/jmcomic/__init__.py
+++ b/src/jmcomic/__init__.py
@@ -2,10 +2,11 @@
# 被依赖方 <--- 使用方
# config <--- entity <--- toolkit <--- client <--- option <--- downloader
-__version__ = '2.6.18'
+__version__ = '2.6.19'
from .api import *
from .jm_plugin import *
+from .jm_feature import *
# 下面进行注册组件(客户端、插件)
gb = dict(filter(lambda pair: isinstance(pair[1], type), globals().items()))
diff --git a/src/jmcomic/api.py b/src/jmcomic/api.py
index 47141450b..512e143e8 100644
--- a/src/jmcomic/api.py
+++ b/src/jmcomic/api.py
@@ -49,6 +49,7 @@ def download_album(jm_album_id,
downloader=None,
callback=None,
check_exception=True,
+ extra=None,
) -> Union[__DOWNLOAD_API_RET, Set[__DOWNLOAD_API_RET]]:
"""
下载一个本子(album),包含其所有的章节(photo)
@@ -60,6 +61,7 @@ def download_album(jm_album_id,
:param downloader: 下载器类
:param callback: 返回值回调函数,可以拿到 album 和 downloader
:param check_exception: 是否检查异常, 如果为True,会检查downloader是否有下载异常,并上抛PartialDownloadFailedException
+ :param extra: 下载特性(Feature),下载完成后自动执行对应插件。支持单个 Feature、FeatureChain、或列表
:return: 对于的本子实体类,下载器(如果是上述的批量情况,返回值为download_batch的返回值)
"""
@@ -67,6 +69,8 @@ def download_album(jm_album_id,
return download_batch(download_album, jm_album_id, option, downloader)
with new_downloader(option, downloader) as dler:
+ # 注册 Feature 及来源,由 downloader 在 after_album 钩子中自动执行
+ dler.add_features(extra, 'download_album')
album = dler.download_album(jm_album_id)
if callback is not None:
@@ -81,14 +85,17 @@ def download_photo(jm_photo_id,
downloader=None,
callback=None,
check_exception=True,
+ extra=None,
):
"""
下载一个章节(photo),参数同 download_album
"""
if not isinstance(jm_photo_id, (str, int)):
- return download_batch(download_photo, jm_photo_id, option)
+ return download_batch(download_photo, jm_photo_id, option, downloader)
with new_downloader(option, downloader) as dler:
+ # 注册 Feature 及来源,由 downloader 在 after_photo 钩子中自动执行
+ dler.add_features(extra, 'download_photo')
photo = dler.download_photo(jm_photo_id)
if callback is not None:
diff --git a/src/jmcomic/jm_config.py b/src/jmcomic/jm_config.py
index 9a1c8c257..62f7a5803 100644
--- a/src/jmcomic/jm_config.py
+++ b/src/jmcomic/jm_config.py
@@ -551,3 +551,55 @@ def register_exception_listener(cls, etype, listener):
jm_log = JmModuleConfig.jm_log
disable_jm_log = JmModuleConfig.disable_jm_log
+
+
+class PrettyFormatter(logging.Formatter):
+ """带 ANSI 颜色的日志格式化器,按 topic 前缀分配颜色"""
+
+ TOPIC_COLORS = {
+ 'album': '\033[1;36m', # 青色加粗 — 本子级别
+ 'photo': '\033[36m', # 青色 — 章节级别
+ 'image': '\033[2;37m', # 暗灰 — 图片级别(弱化)
+ 'plugin': '\033[35m', # 紫色 — 插件
+ 'req': '\033[33m', # 黄色 — 网络请求
+ 'api': '\033[34m', # 蓝色 — API
+ }
+ ERROR_COLOR = '\033[1;31m' # 红色加粗
+ WARN_COLOR = '\033[33m' # 黄色
+ RESET = '\033[0m'
+
+ def __init__(self):
+ super().__init__(fmt='[%(asctime)s] %(message)s', datefmt='%H:%M:%S')
+
+ def format(self, record):
+ topic = getattr(record, 'topic', '')
+ if record.levelno >= logging.ERROR:
+ color = self.ERROR_COLOR
+ elif record.levelno >= logging.WARNING:
+ color = self.WARN_COLOR
+ else:
+ # 按 topic 前缀匹配颜色
+ color = next(
+ (c for prefix, c in self.TOPIC_COLORS.items()
+ if topic.startswith(prefix)),
+ ''
+ )
+ formatted = super().format(record)
+ return f'{color}{formatted}{self.RESET}' if color else formatted
+
+
+def enable_pretty_log():
+ """开启带颜色的美化日志"""
+ import sys
+ import os as _os
+
+ # Windows 需要启用 VT100 ANSI 支持
+ if sys.platform == 'win32':
+ _os.system('')
+
+ jm_logger.handlers.clear()
+ handler = logging.StreamHandler(sys.stdout)
+ handler.setFormatter(PrettyFormatter())
+ jm_logger.addHandler(handler)
+ jm_logger.setLevel(logging.INFO)
+
diff --git a/src/jmcomic/jm_downloader.py b/src/jmcomic/jm_downloader.py
index 085c52baf..99cb832ef 100644
--- a/src/jmcomic/jm_downloader.py
+++ b/src/jmcomic/jm_downloader.py
@@ -81,6 +81,8 @@ def __init__(self, option: JmOption) -> None:
# 下载失败的记录list
self.download_failed_image: List[Tuple[JmImageDetail, BaseException]] = []
self.download_failed_photo: List[Tuple[JmPhotoDetail, BaseException]] = []
+ # Feature 特性列表: [(feature, feature_from), ...]
+ self._feature_list: list = []
def download_album(self, album_id):
album = self.client.get_album_detail(album_id)
@@ -230,6 +232,8 @@ def after_album(self, album: JmAlbumDetail):
album=album,
downloader=self,
)
+ # 触发匹配 after_album 的 Feature
+ self._invoke_features_for('after_album', album=album, downloader=self)
def before_photo(self, photo: JmPhotoDetail):
super().before_photo(photo)
@@ -248,6 +252,8 @@ def after_photo(self, photo: JmPhotoDetail):
photo=photo,
downloader=self,
)
+ # 触发匹配 after_photo 的 Feature
+ self._invoke_features_for('after_photo', photo=photo, downloader=self)
def before_image(self, image: JmImageDetail, img_save_path):
super().before_image(image, img_save_path)
@@ -269,6 +275,38 @@ def after_image(self, image: JmImageDetail, img_save_path):
downloader=self,
)
+ def add_features(self, features, feature_from: str):
+ """
+ 注册 Feature 及其来源。
+
+ :param features: Feature / FeatureChain / list / None
+ :param feature_from: 来源标记,如 'download_album' 或 'download_photo'
+ """
+ if features is None:
+ return
+
+ from .jm_feature import FeatureChain
+
+ if isinstance(features, list):
+ for f in features:
+ self.add_features(f, feature_from)
+ elif isinstance(features, FeatureChain):
+ for f in features._features:
+ self._feature_list.append((f, feature_from))
+ else:
+ self._feature_list.append((features, feature_from))
+
+ def _invoke_features_for(self, when: str, **context):
+ """
+ 在指定钩子(when)中触发匹配的 Feature。
+
+ :param when: 当前钩子名,如 'after_album', 'after_photo'
+ :param context: album, photo, downloader 等上下文
+ """
+ for feature, feature_from in self._feature_list:
+ if feature.should_invoke(when, feature_from):
+ feature.invoke(self.option, feature_from=feature_from, **context)
+
def raise_if_has_exception(self):
if not self.has_download_failures:
return
diff --git a/src/jmcomic/jm_feature.py b/src/jmcomic/jm_feature.py
new file mode 100644
index 000000000..ffd3f7709
--- /dev/null
+++ b/src/jmcomic/jm_feature.py
@@ -0,0 +1,194 @@
+"""
+该文件存放的是 Feature(下载特性)机制
+
+Feature 用于在 download_album / download_photo 时附加额外行为,
+例如下载完成后自动导出为 PDF、ZIP 等格式。
+
+用法:
+ from jmcomic import download_album, Feature
+
+ # 最简单
+ download_album(id, option, extra=Feature.export_pdf)
+
+ # 带参数
+ download_album(id, option, extra=Feature.export_pdf(pdf_dir='./output'))
+
+ # 多个 Feature(列表 / 运算符均可)
+ download_album(id, option, extra=[Feature.export_pdf, Feature.export_zip])
+ download_album(id, option, extra=Feature.export_pdf + Feature.export_zip)
+"""
+
+from .jm_plugin import *
+
+
+class Feature:
+ """
+ 下载特性。传入 download_album / download_photo 的 extra 参数,
+ 下载完成后自动执行。
+
+ Feature 记录在 downloader 上,由 downloader 在 after_album / after_photo
+ 钩子中根据 feature_from 自动判断是否执行。
+ """
+
+ # 类型声明(保证 IDE 自动补全)
+ export_pdf: 'PluginFeature'
+ export_zip: 'PluginFeature'
+ export_long_img: 'PluginFeature'
+
+ def should_invoke(self, when: str, feature_from: str) -> bool:
+ """
+ 判断在当前钩子(when)下,根据来源(feature_from),是否应该执行。
+ 默认返回 True(任何钩子都执行)。子类可覆写来限制执行时机。
+
+ :param when: 当前触发的钩子名称,如 'after_album', 'after_photo'
+ :param feature_from: Feature 的注册来源,如 'download_album', 'download_photo'
+ :returns: 是否应该执行
+ """
+ return True
+
+ def invoke(self, option, **context):
+ """
+ 执行此 Feature。子类需实现该方法。
+
+ :param option: 当前的 JmOption
+ :param context: album, photo, downloader, feature_from 等上下文
+ """
+ raise NotImplementedError
+
+ # ---- 组合运算符,统一返回 FeatureChain ----
+
+ def __add__(self, other):
+ return FeatureChain._combine(self, other)
+
+ def __or__(self, other):
+ return FeatureChain._combine(self, other)
+
+ def __and__(self, other):
+ return FeatureChain._combine(self, other)
+
+ def _to_list(self):
+ return [self]
+
+
+class PluginFeature(Feature):
+ """
+ 插件特性。封装 jmcomic 的插件,在 invoke 时调用相应的插件类。
+ 参数根据 feature_from 动态适配,无需写死。
+ """
+
+ def __init__(self, plugin_key, **kwargs):
+ self.plugin_key = plugin_key
+ self.kwargs = kwargs
+ # 用户通过 __call__ 显式传入的参数名,这些参数不会被 _adapt_kwargs 动态适配
+ self._user_keys: set = set()
+
+ def should_invoke(self, when: str, feature_from: str) -> bool:
+ """
+ 根据注册来源推导执行时机:
+ download_album → after_album, download_photo → after_photo
+ """
+ if feature_from == 'download_album':
+ return when == 'after_album'
+ elif feature_from == 'download_photo':
+ return when == 'after_photo'
+ return False
+
+ def __call__(self, **kwargs):
+ """带自定义参数,返回新实例(继承默认参数)"""
+ new_kwargs = self.kwargs.copy()
+ new_kwargs.update(kwargs)
+ new_instance = PluginFeature(self.plugin_key, **new_kwargs)
+ # 记录用户显式传入的参数名,这些参数不被动态适配
+ new_instance._user_keys = set(kwargs.keys())
+ return new_instance
+
+ def invoke(self, option, feature_from=None, **context):
+ """
+ 执行此 Feature 对应的插件。
+ 根据 feature_from 动态适配 filename_rule 和 level 等参数。
+ """
+ pclass = JmModuleConfig.REGISTRY_PLUGIN.get(self.plugin_key)
+ if pclass is None:
+ ExceptionTool.raises(f'PluginFeature 引用了未注册的插件: {self.plugin_key}')
+
+ # 根据 feature_from 动态适配参数
+ adapted = self._adapt_kwargs(feature_from)
+ merged_kwargs = {**adapted, **context}
+
+ option.invoke_plugin(
+ pclass=pclass,
+ kwargs=merged_kwargs,
+ extra={},
+ pinfo={'plugin': self.plugin_key, 'kwargs': adapted},
+ )
+
+ def _adapt_kwargs(self, feature_from):
+ """
+ 根据 feature_from 动态适配参数:
+ - filename_rule 前缀:download_album → A前缀,download_photo → P前缀
+ - level:download_album → 'album',download_photo → 'photo'
+
+ 注意:用户通过 __call__ 显式传入的参数(记录在 _user_keys 中)不会被适配。
+ """
+ kwargs = self.kwargs.copy()
+
+ if feature_from == 'download_album':
+ # album 模式:P前缀规则 → A前缀规则, level → album
+ if 'filename_rule' not in self._user_keys and 'filename_rule' in kwargs:
+ rule = kwargs['filename_rule']
+ if rule and rule[0] == 'P':
+ kwargs['filename_rule'] = 'A' + rule[1:]
+ if 'level' not in self._user_keys and 'level' in kwargs and kwargs['level'] == 'photo':
+ kwargs['level'] = 'album'
+
+ elif feature_from == 'download_photo':
+ # photo 模式:A前缀规则 → P前缀规则, level → photo
+ if 'filename_rule' not in self._user_keys and 'filename_rule' in kwargs:
+ rule = kwargs['filename_rule']
+ if rule and rule[0] == 'A':
+ kwargs['filename_rule'] = 'P' + rule[1:]
+ if 'level' not in self._user_keys and 'level' in kwargs and kwargs['level'] == 'album':
+ kwargs['level'] = 'photo'
+
+ return kwargs
+
+ def __repr__(self):
+ if self.kwargs:
+ args = ', '.join(f'{k}={v!r}' for k, v in self.kwargs.items())
+ return f'PluginFeature({self.plugin_key!r}, {args})'
+ return f'PluginFeature({self.plugin_key!r})'
+
+
+class FeatureChain:
+ """多个 Feature 的组合"""
+
+ def __init__(self, features):
+ self._features = features
+
+ @classmethod
+ def _combine(cls, left, right):
+ return cls(left._to_list() + right._to_list())
+
+ def __add__(self, other):
+ return FeatureChain._combine(self, other)
+
+ def __or__(self, other):
+ return FeatureChain._combine(self, other)
+
+ def __and__(self, other):
+ return FeatureChain._combine(self, other)
+
+ def _to_list(self):
+ return list(self._features)
+
+ def __repr__(self):
+ return f'FeatureChain({self._features})'
+
+
+# 预定义特性(用插件类的 plugin_key 引用,附带默认参数)
+# filename_rule 和 level 会根据 feature_from 在 invoke 时动态适配:
+# download_album → A前缀 + level=album
+# download_photo → P前缀 + level=photo
+Feature.export_pdf = PluginFeature(Img2pdfPlugin.plugin_key, pdf_dir='./', filename_rule='Atitle')
+Feature.export_zip = PluginFeature(ZipPlugin.plugin_key, level='photo', zip_dir='./', filename_rule='Ptitle')
+Feature.export_long_img = PluginFeature(LongImgPlugin.plugin_key, img_dir='./', filename_rule='Pid')
diff --git a/src/jmcomic/jm_option.py b/src/jmcomic/jm_option.py
index edff61d55..7b74f57f8 100644
--- a/src/jmcomic/jm_option.py
+++ b/src/jmcomic/jm_option.py
@@ -249,7 +249,7 @@ def decide_image_suffix(self, image: JmImageDetail) -> str:
# 非动图,以配置为先
return self.download.image.suffix or image.img_file_suffix
- def decide_image_save_dir(self, photo, ensure_exists=True) -> str:
+ def decide_image_save_dir(self, photo: JmPhotoDetail, ensure_exists=True) -> str:
# 使用 self.dir_rule 决定 save_dir
save_dir = self.dir_rule.decide_image_save_dir(
photo.from_album,
@@ -300,6 +300,8 @@ def construct(cls, origdic: Dict, cover_default=True) -> 'JmOption':
log = dic.pop('log', True)
if log is False:
disable_jm_log()
+ elif log == 'pretty':
+ enable_pretty_log()
# version
version = dic.pop('version', None)
@@ -547,7 +549,7 @@ def call_all_plugin(self, group: str, safe=None, **extra):
def invoke_plugin(self, pclass, kwargs: Optional[Dict], extra: dict, pinfo: dict):
# 检查插件的参数类型
- kwargs = self.fix_kwargs(kwargs)
+ kwargs: dict = self.fix_kwargs(kwargs)
# 把插件的配置数据kwargs和附加数据extra合并,extra会覆盖kwargs
if len(extra) != 0:
kwargs.update(extra)
diff --git a/tests/test_jmcomic/test_jm_feature.py b/tests/test_jmcomic/test_jm_feature.py
new file mode 100644
index 000000000..b9bc2ec78
--- /dev/null
+++ b/tests/test_jmcomic/test_jm_feature.py
@@ -0,0 +1,144 @@
+from . import *
+
+
+class Test_Feature(JmTestConfigurable):
+
+ def test_feature_combine(self):
+ # 1. + 运算
+ f1 = Feature.export_pdf + Feature.export_zip
+ self.assertIsInstance(f1, FeatureChain)
+ self.assertEqual(len(f1._features), 2)
+
+ # 2. | 运算
+ f2 = Feature.export_pdf | Feature.export_zip
+ self.assertIsInstance(f2, FeatureChain)
+
+ # 3. & 运算
+ f3 = Feature.export_pdf & Feature.export_zip
+ self.assertIsInstance(f3, FeatureChain)
+
+ # 4. 连续组合
+ f4 = Feature.export_pdf + Feature.export_zip + Feature.export_long_img
+ self.assertIsInstance(f4, FeatureChain)
+ self.assertEqual(len(f4._features), 3)
+
+ def test_plugin_feature_call(self):
+ f = Feature.export_pdf(pdf_dir='./test', filename_rule='test')
+ self.assertIsInstance(f, PluginFeature)
+ self.assertEqual(f.plugin_key, 'img2pdf')
+ self.assertEqual(f.kwargs['pdf_dir'], './test')
+ self.assertEqual(f.kwargs['filename_rule'], 'test')
+
+ def test_custom_feature(self):
+ class MyCustomFeature(Feature):
+ def invoke(self, option, **context):
+ pass
+
+ my_feature = MyCustomFeature()
+ combo = my_feature + Feature.export_pdf
+ self.assertIsInstance(combo, FeatureChain)
+ self.assertEqual(len(combo._features), 2)
+ self.assertIsInstance(combo._features[0], MyCustomFeature)
+
+ def test_should_invoke(self):
+ """测试 should_invoke 判断逻辑"""
+ # Feature 基类默认在所有钩子中都执行
+ class MyFeature(Feature):
+ def invoke(self, option, **context):
+ pass
+
+ base = MyFeature()
+ self.assertTrue(base.should_invoke('after_album', 'download_album'))
+ self.assertTrue(base.should_invoke('after_photo', 'download_album'))
+
+ # PluginFeature 根据来源推导执行时机
+ pf = Feature.export_pdf
+ # download_album → 只在 after_album 执行
+ self.assertTrue(pf.should_invoke('after_album', 'download_album'))
+ self.assertFalse(pf.should_invoke('after_photo', 'download_album'))
+ # download_photo → 只在 after_photo 执行
+ self.assertTrue(pf.should_invoke('after_photo', 'download_photo'))
+ self.assertFalse(pf.should_invoke('after_album', 'download_photo'))
+
+ def test_adapt_kwargs(self):
+ """测试 PluginFeature 参数动态适配"""
+ # download_album 模式:P前缀 → A前缀, level → album
+ pdf = Feature.export_pdf
+ adapted = pdf._adapt_kwargs('download_album')
+ self.assertEqual(adapted['filename_rule'], 'Atitle') # A开头不变
+
+ zip_f = Feature.export_zip
+ adapted = zip_f._adapt_kwargs('download_album')
+ self.assertEqual(adapted['filename_rule'], 'Atitle') # Ptitle → Atitle
+ self.assertEqual(adapted['level'], 'album') # photo → album
+
+ long_img = Feature.export_long_img
+ adapted = long_img._adapt_kwargs('download_album')
+ self.assertEqual(adapted['filename_rule'], 'Aid') # Pid → Aid
+
+ # download_photo 模式:A前缀 → P前缀, level → photo
+ adapted = pdf._adapt_kwargs('download_photo')
+ self.assertEqual(adapted['filename_rule'], 'Ptitle') # Atitle → Ptitle
+
+ # 用户显式传入的参数不被动态适配
+ custom = Feature.export_zip(filename_rule='Ptitle', level='photo')
+ adapted = custom._adapt_kwargs('download_album')
+ self.assertEqual(adapted['filename_rule'], 'Ptitle') # 用户显式指定,不适配
+ self.assertEqual(adapted['level'], 'photo') # 用户显式指定,不适配
+
+ def test_download_use_feature(self):
+ album_id = '438516'
+
+ # 记录被执行的次数,便于断言
+ custom_feature_call_count = 0
+
+ class MyCounterFeature(Feature):
+ def invoke(self, option, **context):
+ nonlocal custom_feature_call_count
+ custom_feature_call_count += 1
+
+ counter_feature = MyCounterFeature()
+
+ # 测试 download_album:
+ # 自定义 Feature 基类 should_invoke 默认 True,
+ # 438516 有 1 个章节,所以 after_photo(1次) + after_album(1次) = 2次
+ jmcomic.download_album(album_id, self.option, extra=counter_feature)
+ self.assertEqual(custom_feature_call_count, 2)
+
+ # 测试 download_photo: after_photo 触发 1 次
+ photo_id = '438516'
+ jmcomic.download_photo(photo_id, self.option, extra=counter_feature)
+ self.assertEqual(custom_feature_call_count, 3)
+
+ def test_export_features(self):
+ album_id = '438516'
+
+ # 直接使用测试环境配置的下载目录
+ export_dir = self.option.dir_rule.base_dir
+
+ # 定义导出路径指向测试目录
+ f_pdf = Feature.export_pdf(pdf_dir=export_dir)
+ f_zip = Feature.export_zip(zip_dir=export_dir)
+ f_long_img = Feature.export_long_img(img_dir=export_dir)
+
+ # 组合下载并导出
+ combo = f_pdf + f_zip + f_long_img
+ album, dler = jmcomic.download_album(album_id, self.option, extra=combo)
+
+ # 验证文件是否精确生成
+ # 通过 download_album 注册,动态适配后全部为 album 级别:
+ # PDF: Atitle(不变) → [album标题].pdf
+ # ZIP: Ptitle→Atitle, level→album → [album标题].zip
+ # PNG: Pid→Aid → [album_id].png
+ pdf_name = DirRule.apply_rule_to_filename(album, None, 'Atitle') + '.pdf'
+ zip_name = DirRule.apply_rule_to_filename(album, None, 'Atitle') + '.zip'
+ png_name = DirRule.apply_rule_to_filename(album, None, 'Aid') + '.png'
+
+ import os
+ pdf_path = os.path.join(export_dir, pdf_name)
+ zip_path = os.path.join(export_dir, zip_name)
+ png_path = os.path.join(export_dir, png_name)
+
+ self.assertTrue(os.path.exists(pdf_path), f"未生成精确匹配的 PDF 文件: {pdf_path}")
+ self.assertTrue(os.path.exists(zip_path), f"未生成精确匹配的 ZIP 文件: {zip_path}")
+ self.assertTrue(os.path.exists(png_path), f"未生成精确匹配的 PNG 长图: {png_path}")
From db09b8bc833abddedaefb3dabf77d22d34d9f70a Mon Sep 17 00:00:00 2001
From: hect0x7 <93357912+hect0x7@users.noreply.github.com>
Date: Wed, 6 May 2026 12:56:26 +0800
Subject: [PATCH 02/13] =?UTF-8?q?fix:=20=E8=A7=A3=E5=86=B3=20PR#533=20?=
=?UTF-8?q?=E7=9A=84=20review=20=E9=97=AE=E9=A2=98?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
修复内容:
1. [文档修正] 修正 13_export_and_feature.md 中的失效锚点链接
2. [文档完善] 补全 README.md 中的教程支持范围描述(增加长图)
3. [核心修复] 修复批量下载时没有透传 extra 参数导致 Feature 无法执行的严重 Bug
4. [性能优化] 改用 ctypes 启用 Windows ANSI 控制台支持,移除低效且不安全的 os.system 进程调用
5. [错误捕获] 提前在 jm_downloader.py 的 add_features 方法中对 extra 进行类型校验
6. [认知升级] 更新代码中的 docstring,强化 Feature 作为“基于生命周期和调用来源的动态自适应机制”的定位
未修改内容及原因:
1. jm_feature.py 链式调用丢失 _user_keys 问题
- 经与开发者确认,暂时先不修改此逻辑。
---
README.md | 2 +-
assets/docs/sources/tutorial/13_export_and_feature.md | 2 +-
src/jmcomic/api.py | 8 +++++---
src/jmcomic/jm_config.py | 7 ++++++-
src/jmcomic/jm_downloader.py | 7 +++++--
src/jmcomic/jm_feature.py | 5 +++--
6 files changed, 21 insertions(+), 10 deletions(-)
diff --git a/README.md b/README.md
index fb41de233..17fed7741 100644
--- a/README.md
+++ b/README.md
@@ -30,7 +30,7 @@
> **🧭 快速指路**
> - [教程:使用 GitHub Actions 下载禁漫本子](./assets/docs/sources/tutorial/1_github_actions.md)
> - [教程:导出并下载你的禁漫收藏夹数据](./assets/docs/sources/tutorial/10_export_favorites.md)
-> - [教程:下载后转为 PDF / ZIP](./assets/docs/sources/tutorial/13_export_and_feature.md)
+> - [教程:下载后转为 PDF / ZIP / 长图](./assets/docs/sources/tutorial/13_export_and_feature.md)
> - [塔台广播:欢迎各位机长加入并贡献代码](./.github/CONTRIBUTING.md)
>
> **友情提示:珍爱JM,为了减轻JM的服务器压力,请不要一次性爬取太多本子,西门🙏🙏🙏**.
diff --git a/assets/docs/sources/tutorial/13_export_and_feature.md b/assets/docs/sources/tutorial/13_export_and_feature.md
index cad829b66..91bb91ce3 100644
--- a/assets/docs/sources/tutorial/13_export_and_feature.md
+++ b/assets/docs/sources/tutorial/13_export_and_feature.md
@@ -85,7 +85,7 @@ download_photo('456', option, extra=Feature.export_pdf)
├── [章节标题].pdf ← 该章节导出为 1 个 PDF
```
-> 💡 **提示**:同一个 Feature,通过 `download_album` 和 `download_photo` 调用时会自动适配不同的导出行为,详见下方 [智能适配规则](#智能适配规则)。
+> 💡 **提示**:同一个 Feature,通过 `download_album` 和 `download_photo` 调用时会自动适配不同的导出行为,详见下方 [智能适配规则](#25-智能适配规则)。
### 2.5 智能适配规则
diff --git a/src/jmcomic/api.py b/src/jmcomic/api.py
index 512e143e8..24d722d17 100644
--- a/src/jmcomic/api.py
+++ b/src/jmcomic/api.py
@@ -7,6 +7,7 @@ def download_batch(download_api,
jm_id_iter: Union[Iterable, Generator],
option=None,
downloader=None,
+ **kwargs,
) -> Set[__DOWNLOAD_API_RET]:
"""
批量下载 album / photo
@@ -37,6 +38,7 @@ def callback(*ret):
option,
downloader,
callback=callback,
+ **kwargs,
),
wait_finish=True
)
@@ -61,12 +63,12 @@ def download_album(jm_album_id,
:param downloader: 下载器类
:param callback: 返回值回调函数,可以拿到 album 和 downloader
:param check_exception: 是否检查异常, 如果为True,会检查downloader是否有下载异常,并上抛PartialDownloadFailedException
- :param extra: 下载特性(Feature),下载完成后自动执行对应插件。支持单个 Feature、FeatureChain、或列表
+ :param extra: 下载特性(Feature),下载时动态挂载的附加行为上下文。会自动根据上下文(如 album/photo 来源)自适应参数行为。支持单个 Feature、FeatureChain、或列表
:return: 对于的本子实体类,下载器(如果是上述的批量情况,返回值为download_batch的返回值)
"""
if not isinstance(jm_album_id, (str, int)):
- return download_batch(download_album, jm_album_id, option, downloader)
+ return download_batch(download_album, jm_album_id, option, downloader, extra=extra)
with new_downloader(option, downloader) as dler:
# 注册 Feature 及来源,由 downloader 在 after_album 钩子中自动执行
@@ -91,7 +93,7 @@ def download_photo(jm_photo_id,
下载一个章节(photo),参数同 download_album
"""
if not isinstance(jm_photo_id, (str, int)):
- return download_batch(download_photo, jm_photo_id, option, downloader)
+ return download_batch(download_photo, jm_photo_id, option, downloader, extra=extra)
with new_downloader(option, downloader) as dler:
# 注册 Feature 及来源,由 downloader 在 after_photo 钩子中自动执行
diff --git a/src/jmcomic/jm_config.py b/src/jmcomic/jm_config.py
index 62f7a5803..4ab2a8634 100644
--- a/src/jmcomic/jm_config.py
+++ b/src/jmcomic/jm_config.py
@@ -595,7 +595,12 @@ def enable_pretty_log():
# Windows 需要启用 VT100 ANSI 支持
if sys.platform == 'win32':
- _os.system('')
+ import ctypes
+ kernel32 = ctypes.windll.kernel32
+ handle = kernel32.GetStdHandle(-11) # STD_OUTPUT_HANDLE
+ mode = ctypes.c_uint32()
+ if kernel32.GetConsoleMode(handle, ctypes.byref(mode)):
+ kernel32.SetConsoleMode(handle, mode.value | 0x0004) # ENABLE_VIRTUAL_TERMINAL_PROCESSING
jm_logger.handlers.clear()
handler = logging.StreamHandler(sys.stdout)
diff --git a/src/jmcomic/jm_downloader.py b/src/jmcomic/jm_downloader.py
index 99cb832ef..2d6af3cdf 100644
--- a/src/jmcomic/jm_downloader.py
+++ b/src/jmcomic/jm_downloader.py
@@ -285,7 +285,8 @@ def add_features(self, features, feature_from: str):
if features is None:
return
- from .jm_feature import FeatureChain
+ from .jm_feature import FeatureChain, Feature
+ from .jm_toolkit import ExceptionTool
if isinstance(features, list):
for f in features:
@@ -293,8 +294,10 @@ def add_features(self, features, feature_from: str):
elif isinstance(features, FeatureChain):
for f in features._features:
self._feature_list.append((f, feature_from))
- else:
+ elif isinstance(features, Feature):
self._feature_list.append((features, feature_from))
+ else:
+ ExceptionTool.raises(f'不支持的 extra 类型: {type(features)},请传入 Feature / FeatureChain / list / None')
def _invoke_features_for(self, when: str, **context):
"""
diff --git a/src/jmcomic/jm_feature.py b/src/jmcomic/jm_feature.py
index ffd3f7709..150b9a961 100644
--- a/src/jmcomic/jm_feature.py
+++ b/src/jmcomic/jm_feature.py
@@ -1,8 +1,9 @@
"""
该文件存放的是 Feature(下载特性)机制
-Feature 用于在 download_album / download_photo 时附加额外行为,
-例如下载完成后自动导出为 PDF、ZIP 等格式。
+Feature 用于在下载生命周期中挂载上下文相关的动态附加行为,
+例如下载完成后自适应导出为 PDF、ZIP 或长图等。
+它不仅是插件的封装,更能根据调用来源(整本/单章)智能调整执行策略。
用法:
from jmcomic import download_album, Feature
From a88d83d2127b1314cddf0ede339e081f803a9688 Mon Sep 17 00:00:00 2001
From: hect0x7 <93357912+hect0x7@users.noreply.github.com>
Date: Wed, 6 May 2026 13:20:58 +0800
Subject: [PATCH 03/13] =?UTF-8?q?refactor:=20=E5=BA=9F=E5=BC=83=20zip=20?=
=?UTF-8?q?=E6=8F=92=E4=BB=B6=E7=9A=84=20level=20=E5=8F=82=E6=95=B0?=
=?UTF-8?q?=EF=BC=8C=E6=89=93=E5=8C=85=E7=B2=92=E5=BA=A6=E7=94=B1=E6=89=80?=
=?UTF-8?q?=E5=9C=A8=E9=92=A9=E5=AD=90=E8=87=AA=E5=8A=A8=E6=8E=A8=E5=AF=BC?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- ZipPlugin: level 默认值改为 None,根据上下文自动推导(有 album 则合并,只有 photo 则单章)
- Feature: export_zip 不再传递 level,_adapt_kwargs 移除 level 适配逻辑
- JmOption: compatible_with_old_versions 中新增 _migrate_zip_level,自动将旧 yaml 中的 level 配置等价迁移到正确的钩子位置
- 文档: 更新 option_file_syntax.md 和 13_export_and_feature.md 中 level 相关说明
---
assets/docs/sources/option_file_syntax.md | 17 ++++---
.../sources/tutorial/13_export_and_feature.md | 2 +-
src/jmcomic/jm_feature.py | 18 +++-----
src/jmcomic/jm_option.py | 46 +++++++++++++++++++
src/jmcomic/jm_plugin.py | 5 +-
5 files changed, 68 insertions(+), 20 deletions(-)
diff --git a/assets/docs/sources/option_file_syntax.md b/assets/docs/sources/option_file_syntax.md
index 42d4b8542..7250df5dd 100644
--- a/assets/docs/sources/option_file_syntax.md
+++ b/assets/docs/sources/option_file_syntax.md
@@ -226,14 +226,19 @@ plugins:
after_album:
- plugin: zip # 压缩文件插件
kwargs:
- level: photo # 按照章节,一个章节一个压缩文件
- # level 也可以配成 album,表示一个本子对应一个压缩文件,该压缩文件会包含这个本子的所有章节
+ # ⚠ level 参数已在 v2.6.19 废弃,打包粒度由插件所在的钩子自动推导:
+ # 配置在 after_album 下 → 整本合并为一个压缩文件
+ # 配置在 after_photo 下 → 每个章节各一个压缩文件
+ # 旧配置会自动等价迁移,无需手动修改配置文件。
+ # 迁移示例:
+ # 旧:after_album + level: photo → 等价于:after_photo(不写 level)
+ # 旧:after_album + level: album → 等价于:after_album(不写 level)
filename_rule: Ptitle # 压缩文件的命名规则
# 请注意⚠ [https://github.com/hect0x7/JMComic-Crawler-Python/issues/223#issuecomment-2045227527]
- # filename_rule和level有对应关系
- # 如果level=[photo], filename_rule只能写Pxxx
- # 如果level=[album], filename_rule只能写Axxx
+ # filename_rule和所在钩子有对应关系
+ # 如果配置在 after_photo 下, filename_rule只能写Pxxx
+ # 如果配置在 after_album 下, filename_rule只能写Axxx
zip_dir: D:/jmcomic/zip/ # 压缩文件存放的文件夹
@@ -245,7 +250,7 @@ plugins:
# dir_rule: # 新配置项,可取代旧的zip_dir和filename_rule
# base_dir: D:/jmcomic-zip
# rule: 'Bd / {Atitle} / [{Pid}]-{Ptitle}.zip' # 设置压缩文件夹规则,中间Atitle表示创建一层文件夹,名称是本子标题。[{Pid}]-{Ptitle}.zip 表示压缩文件的命名规则(需显式写出后缀名)
- # 使用此方法指定压缩包存储路径则无需和level对应
+ # 使用此方法指定压缩包存储路径则无需和所在钩子对应
delete_original_file: true # 压缩成功后,删除所有原文件和文件夹
diff --git a/assets/docs/sources/tutorial/13_export_and_feature.md b/assets/docs/sources/tutorial/13_export_and_feature.md
index 91bb91ce3..4f4235ee1 100644
--- a/assets/docs/sources/tutorial/13_export_and_feature.md
+++ b/assets/docs/sources/tutorial/13_export_and_feature.md
@@ -89,7 +89,7 @@ download_photo('456', option, extra=Feature.export_pdf)
### 2.5 智能适配规则
-内置的导出 Feature 会根据调用的 API **自动适配**参数(命名规则、打包级别等):
+内置的导出 Feature 会根据调用的 API **自动适配**参数(命名规则、打包粒度等):
| 调用方式 | Feature.export_pdf | Feature.export_zip | Feature.export_long_img |
|---------|-------------------|-------------------|----------------------|
diff --git a/src/jmcomic/jm_feature.py b/src/jmcomic/jm_feature.py
index 150b9a961..3efd878f8 100644
--- a/src/jmcomic/jm_feature.py
+++ b/src/jmcomic/jm_feature.py
@@ -106,7 +106,7 @@ def __call__(self, **kwargs):
def invoke(self, option, feature_from=None, **context):
"""
执行此 Feature 对应的插件。
- 根据 feature_from 动态适配 filename_rule 和 level 等参数。
+ 根据 feature_from 动态适配 filename_rule 等参数。
"""
pclass = JmModuleConfig.REGISTRY_PLUGIN.get(self.plugin_key)
if pclass is None:
@@ -127,29 +127,24 @@ def _adapt_kwargs(self, feature_from):
"""
根据 feature_from 动态适配参数:
- filename_rule 前缀:download_album → A前缀,download_photo → P前缀
- - level:download_album → 'album',download_photo → 'photo'
注意:用户通过 __call__ 显式传入的参数(记录在 _user_keys 中)不会被适配。
"""
kwargs = self.kwargs.copy()
if feature_from == 'download_album':
- # album 模式:P前缀规则 → A前缀规则, level → album
+ # album 模式:P前缀规则 → A前缀规则
if 'filename_rule' not in self._user_keys and 'filename_rule' in kwargs:
rule = kwargs['filename_rule']
if rule and rule[0] == 'P':
kwargs['filename_rule'] = 'A' + rule[1:]
- if 'level' not in self._user_keys and 'level' in kwargs and kwargs['level'] == 'photo':
- kwargs['level'] = 'album'
elif feature_from == 'download_photo':
- # photo 模式:A前缀规则 → P前缀规则, level → photo
+ # photo 模式:A前缀规则 → P前缀规则
if 'filename_rule' not in self._user_keys and 'filename_rule' in kwargs:
rule = kwargs['filename_rule']
if rule and rule[0] == 'A':
kwargs['filename_rule'] = 'P' + rule[1:]
- if 'level' not in self._user_keys and 'level' in kwargs and kwargs['level'] == 'album':
- kwargs['level'] = 'photo'
return kwargs
@@ -187,9 +182,8 @@ def __repr__(self):
# 预定义特性(用插件类的 plugin_key 引用,附带默认参数)
-# filename_rule 和 level 会根据 feature_from 在 invoke 时动态适配:
-# download_album → A前缀 + level=album
-# download_photo → P前缀 + level=photo
+# filename_rule 会根据 feature_from 在 invoke 时动态适配 A/P 前缀
+# zip 的打包粒度由插件根据上下文(album/photo)自动推导,无需 level 参数
Feature.export_pdf = PluginFeature(Img2pdfPlugin.plugin_key, pdf_dir='./', filename_rule='Atitle')
-Feature.export_zip = PluginFeature(ZipPlugin.plugin_key, level='photo', zip_dir='./', filename_rule='Ptitle')
+Feature.export_zip = PluginFeature(ZipPlugin.plugin_key, zip_dir='./', filename_rule='Ptitle')
Feature.export_long_img = PluginFeature(LongImgPlugin.plugin_key, img_dir='./', filename_rule='Pid')
diff --git a/src/jmcomic/jm_option.py b/src/jmcomic/jm_option.py
index 7b74f57f8..9daa620fe 100644
--- a/src/jmcomic/jm_option.py
+++ b/src/jmcomic/jm_option.py
@@ -330,6 +330,52 @@ def compatible_with_old_versions(cls, dic):
if 'plugin' in dic:
dic['plugins'] = dic.pop('plugin')
+ # 3: zip 插件 level 参数迁移
+ # level 已废弃,打包粒度由所在钩子上下文自动推导
+ plugins = dic.get('plugins', {})
+ if isinstance(plugins, dict):
+ cls._migrate_zip_level(plugins)
+
+ @classmethod
+ def _migrate_zip_level(cls, plugins: dict):
+ """
+ zip 插件 level 参数迁移。
+
+ level 已废弃,打包粒度由所在钩子的上下文自动推导。
+ 迁移规则:level='album' → 确保在 after_album;其他 → 确保在 after_photo。
+ """
+ for group in ['after_album', 'after_photo']:
+ plugin_list = plugins.get(group)
+ if not isinstance(plugin_list, list):
+ continue
+ i = 0
+ while i < len(plugin_list):
+ pinfo = plugin_list[i]
+ if pinfo.get('plugin') != 'zip':
+ i += 1
+ continue
+ kwargs = pinfo.get('kwargs') or {}
+ if 'level' not in kwargs:
+ # 旧版本默认值是 'photo'
+ level = 'photo'
+ else:
+ level = kwargs.pop('level')
+
+ if group == 'after_album' and level != 'album':
+ # after_album + level=photo → 等价迁移到 after_photo
+ plugins.setdefault('after_photo', []).append(pinfo)
+ plugin_list.pop(i)
+ jm_log('option.migrate',
+ f'[zip 插件迁移] level 参数已废弃,打包粒度由所在钩子自动推导。'
+ f'已自动将 after_album 下的 zip(level={level!r}) 等价迁移到 after_photo。'
+ f'等价写法:将 zip 插件从 after_album 移至 after_photo 并删除 level 配置项。')
+ else:
+ if level != 'photo':
+ jm_log('option.migrate',
+ f'[zip 插件迁移] level 参数已废弃,已自动移除。'
+ f'打包粒度由所在钩子自动推导({group} → {level})。')
+ i += 1
+
def deconstruct(self) -> Dict:
return {
'version': JmModuleConfig.JM_OPTION_VER,
diff --git a/src/jmcomic/jm_plugin.py b/src/jmcomic/jm_plugin.py
index 8da3e1cbb..129b8b9be 100644
--- a/src/jmcomic/jm_plugin.py
+++ b/src/jmcomic/jm_plugin.py
@@ -321,7 +321,7 @@ def invoke(self,
album: JmAlbumDetail = None,
photo: JmPhotoDetail = None,
delete_original_file=False,
- level='photo',
+ level=None,
filename_rule='Ptitle',
suffix='zip',
zip_dir='./',
@@ -332,6 +332,9 @@ def invoke(self,
from .jm_downloader import JmDownloader
downloader: JmDownloader
self.downloader = downloader
+ # level 自动推导:有 album 则合并打包,只有 photo 则单章打包
+ if level is None:
+ level = 'album' if album is not None else 'photo'
self.level = level
self.delete_original_file = delete_original_file
From 26a830d70544ab47f1c48f4a2d68c8da5562e978 Mon Sep 17 00:00:00 2001
From: hect0x7 <93357912+hect0x7@users.noreply.github.com>
Date: Wed, 6 May 2026 17:29:58 +0800
Subject: [PATCH 04/13] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E5=A4=9A?=
=?UTF-8?q?=E8=B4=A6=E5=8F=B7=E7=99=BB=E5=BD=95=E6=97=B6=20Session=20?=
=?UTF-8?q?=E6=B1=A1=E6=9F=93=E9=97=AE=E9=A2=98?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/jmcomic/jm_plugin.py | 1 -
1 file changed, 1 deletion(-)
diff --git a/src/jmcomic/jm_plugin.py b/src/jmcomic/jm_plugin.py
index 129b8b9be..6b50d2318 100644
--- a/src/jmcomic/jm_plugin.py
+++ b/src/jmcomic/jm_plugin.py
@@ -158,7 +158,6 @@ def invoke(self,
cookies = dict(client['cookies'])
self.option.update_cookies(cookies)
- JmModuleConfig.APP_COOKIES = cookies
self.log('登录成功')
From 09233da5e3e5be71d417de8cf31dbfa8d6400832 Mon Sep 17 00:00:00 2001
From: hect0x7 <93357912+hect0x7@users.noreply.github.com>
Date: Wed, 6 May 2026 21:48:54 +0800
Subject: [PATCH 05/13] fix pr review
---
assets/docs/sources/option_file_syntax.md | 28 +++----
.../sources/tutorial/13_export_and_feature.md | 75 ++++++++++---------
src/jmcomic/jm_downloader.py | 8 +-
src/jmcomic/jm_feature.py | 71 +++++++-----------
src/jmcomic/jm_option.py | 13 ++--
tests/test_jmcomic/test_jm_feature.py | 36 +++++----
6 files changed, 107 insertions(+), 124 deletions(-)
diff --git a/assets/docs/sources/option_file_syntax.md b/assets/docs/sources/option_file_syntax.md
index 7250df5dd..c5d0c3a5d 100644
--- a/assets/docs/sources/option_file_syntax.md
+++ b/assets/docs/sources/option_file_syntax.md
@@ -223,34 +223,28 @@ plugins:
rule: '{Atitle}/{Aid}_cover.jpg'
- after_album:
+ after_album: # 钩子(插件被调用时机)
- plugin: zip # 压缩文件插件
kwargs:
- # ⚠ level 参数已在 v2.6.19 废弃,打包粒度由插件所在的钩子自动推导:
- # 配置在 after_album 下 → 整本合并为一个压缩文件
- # 配置在 after_photo 下 → 每个章节各一个压缩文件
- # 旧配置会自动等价迁移,无需手动修改配置文件。
- # 迁移示例:
- # 旧:after_album + level: photo → 等价于:after_photo(不写 level)
- # 旧:after_album + level: album → 等价于:after_album(不写 level)
-
- filename_rule: Ptitle # 压缩文件的命名规则
- # 请注意⚠ [https://github.com/hect0x7/JMComic-Crawler-Python/issues/223#issuecomment-2045227527]
- # filename_rule和所在钩子有对应关系
- # 如果配置在 after_photo 下, filename_rule只能写Pxxx
- # 如果配置在 after_album 下, filename_rule只能写Axxx
+ # 压缩文件插件,配在不同钩子下面,效果不一样。可以选择配在 after_album 或者 after_photo 下
+ # 配置在 after_album 下 → 整个本子合并为一个压缩文件
+ # 配置在 after_photo 下 → 每个章节各一个压缩文件
+ # (旧的 level 配置已废弃,如果你配置过level,比如level=photo, 请直接改用after_photo)
zip_dir: D:/jmcomic/zip/ # 压缩文件存放的文件夹
-
suffix: zip #压缩包后缀名,默认值为zip,可以指定为zip或者7z
+ filename_rule: Atitle # 压缩文件的命名规则
+ # 请注意⚠ [https://github.com/hect0x7/JMComic-Crawler-Python/issues/223#issuecomment-2045227527]
+ # filename_rule和所在钩子有对应关系
+ # 如果配置在 after_photo 下, filename_rule只能写 Pxxx
+ # 如果配置在 after_album 下, filename_rule只能写 Axxx
- # v2.6.0 以后,zip插件也支持dir_rule配置项,可以替代旧版本的zip_dir和filename_rule
+ # zip插件也支持dir_rule配置项,可以替代旧版本的zip_dir和filename_rule
# 请注意⚠ 使用此配置项会使filename_rule,zip_dir,suffix三个配置项无效,与这三个配置项同时存在时仅会使用dir_rule
# 示例如下:
# dir_rule: # 新配置项,可取代旧的zip_dir和filename_rule
# base_dir: D:/jmcomic-zip
# rule: 'Bd / {Atitle} / [{Pid}]-{Ptitle}.zip' # 设置压缩文件夹规则,中间Atitle表示创建一层文件夹,名称是本子标题。[{Pid}]-{Ptitle}.zip 表示压缩文件的命名规则(需显式写出后缀名)
- # 使用此方法指定压缩包存储路径则无需和所在钩子对应
delete_original_file: true # 压缩成功后,删除所有原文件和文件夹
diff --git a/assets/docs/sources/tutorial/13_export_and_feature.md b/assets/docs/sources/tutorial/13_export_and_feature.md
index 4f4235ee1..d40ad5943 100644
--- a/assets/docs/sources/tutorial/13_export_and_feature.md
+++ b/assets/docs/sources/tutorial/13_export_and_feature.md
@@ -7,11 +7,7 @@
- 导出为 **ZIP**:方便传输和存档
- 合并为 **长图**:方便一张图看完整个章节
-jmcomic 一直通过内置插件(`img2pdf`、`zip`、`long_img`)支持这些功能,但传统方式需要在 YAML 配置文件中编写插件配置,门槛偏高。
-
-从最新版本起,jmcomic 引入了 **Feature(特性)** 机制——一套通用的**下载附加行为系统**,让你用一行代码搞定导出。Feature 不仅能调用插件,还能封装任意自定义逻辑(通知、清理等),并且会根据调用方式自动选择最合理的配置。
-
-内置了三个开箱即用的导出 Feature:
+jmcomic 内置了三个开箱即用的导出 Feature,对应这三种需求:
| Feature | 效果 |
|---------|------|
@@ -19,6 +15,14 @@ jmcomic 一直通过内置插件(`img2pdf`、`zip`、`long_img`)支持这些
| `Feature.export_zip` | 下载完自动打包为 ZIP |
| `Feature.export_long_img` | 下载完自动拼接为长图 PNG |
+
+> 也许你知道,这些功能之前是以插件形式 (JmOptionPlugin) 存在的。
+>
+> 是的,传统方式需要在 option 配置文件中编写插件配置,门槛偏高。
+>
+> 因此,从v2.6.19起,jmcomic 引入了上述的 **Feature** 机制,尽可能简化这些最常用的功能,让小白也能用一行代码搞定导出。
+
+
## 2. 快速上手
### 2.1 导出 PDF——基本用法示例
@@ -27,17 +31,20 @@ jmcomic 一直通过内置插件(`img2pdf`、`zip`、`long_img`)支持这些
from jmcomic import download_album, Feature
# 只需要加一个 extra 参数,就能在下载完成后自动导出 PDF
+download_album('123', extra=Feature.export_pdf)
+
+# 如果要传 option 参数,就是如下写法,三个参数
download_album('123', option, extra=Feature.export_pdf)
```
-效果:在**当前工作目录**下生成以本子标题命名的 PDF 文件:
+**效果**:在本子下载完以后,额外在**当前工作目录**下生成包含所有本子图片的 PDF 文件:
```
./
-├── [本子标题].pdf ← 整本合并为 1 个 PDF
+├── [JM123]本子标题.pdf ← 整本合并为 1 个 PDF,注意pdf文件名的格式,默认包含本子禁漫车号+本子标题
```
-### 2.2 需要多种导出格式(PDF、ZIP)——直接组合 Feature
+### 2.2 需要多种导出格式(PDF、ZIP等)——直接组合 Feature
用 `+` 号组合,同时导出多种格式:
@@ -45,17 +52,28 @@ download_album('123', option, extra=Feature.export_pdf)
# 下载完后同时导出 PDF 和 ZIP
download_album('123', option, extra=Feature.export_pdf + Feature.export_zip)
-# 也支持列表语法
+# 也支持列表语法,|语法
download_album('123', option, extra=[Feature.export_pdf, Feature.export_zip])
+download_album('123', option, extra=Feature.export_pdf | Feature.export_zip)
```
+效果同pdf,会在本子下载完以后,额外在当前工作目录下,生成包含所有本子图片的 PDF 文件和 ZIP 文件:
+
+```
+./
+├── [JM123]本子标题.pdf ← 整本合并为 1 个 PDF
+├── [JM123]本子标题.zip ← 整本合并为 1 个 zip 压缩包
+```
+
+
### 2.3 自定义参数
-像调用函数一样传入自定义参数,可以改变输出目录、命名规则等:
+如果你了解插件配置,可以同样使用Feature传递插件的自定义参数,例如改变输出目录、命名规则等:
```python
# 示例 1:指定输出目录和命名规则
download_album('123', option, extra=Feature.export_pdf(
+ # 下面是自定义参数
pdf_dir='D:/my_pdfs', # PDF 保存到 D:/my_pdfs 文件夹
filename_rule='Ptitle', # 用章节标题作为文件名
delete_original_file=True, # 合并完 PDF 后删除原图
@@ -89,10 +107,10 @@ download_photo('456', option, extra=Feature.export_pdf)
### 2.5 智能适配规则
-内置的导出 Feature 会根据调用的 API **自动适配**参数(命名规则、打包粒度等):
+内置的导出 Feature 会根据调用的 API **自动适配**参数:
-| 调用方式 | Feature.export_pdf | Feature.export_zip | Feature.export_long_img |
-|---------|-------------------|-------------------|----------------------|
+| 调用方式 | Feature.export_pdf | Feature.export_zip | Feature.export_long_img |
+|-----------------|-------------------|-------------------|----------------------|
| `download_album` | 整本合并为 1 个 PDF
`[本子标题].pdf` | 整本打包为 1 个 ZIP
`[本子标题].zip` | 所有章节合并为 1 张长图
`[本子ID].png` |
| `download_photo` | 该章节导出为 PDF
`[章节标题].pdf` | 该章节打包为 ZIP
`[章节标题].zip` | 该章节拼接为长图
`[章节ID].png` |
@@ -107,12 +125,12 @@ download_photo('456', option, extra=Feature.export_pdf)
```yaml
# option.yml
plugins:
- after_album:
- - plugin: img2pdf
+ after_album: # 整本下载完以后
+ - plugin: img2pdf # 合并pdf
kwargs:
pdf_dir: ./output
filename_rule: Atitle
- - plugin: zip
+ - plugin: zip # 合并为压缩文件
kwargs:
level: album
zip_dir: ./output
@@ -148,7 +166,7 @@ api.download_album(extra=Feature.export_pdf)
│
├→ download_by_photo_detail(photo)
│ ├→ before_photo(photo)
- │ ├→ download images ...
+ │ ├→ download jmcomic images ... # 下载禁漫图片
│ └→ after_photo(photo)
│ └→ _invoke_features_for('after_photo')
│ └→ pdf.should_invoke('after_photo', 'download_album') → False ✗ 跳过
@@ -156,8 +174,8 @@ api.download_album(extra=Feature.export_pdf)
└→ after_album(album)
└→ _invoke_features_for('after_album')
└→ pdf.should_invoke('after_album', 'download_album') → True ✓ 执行!
- └→ _adapt_kwargs('download_album')
- # Atitle 不变, Ptitle→Atitle, Pid→Aid, level→album
+ └→ _adapt_plugin_kwargs(from, when) # 动态生成插件参数
+ └→ option.invoke(pdf, kwargs) # 调用pdf插件,传入参数
```
> 💡 **关键点**:
@@ -167,15 +185,15 @@ api.download_album(extra=Feature.export_pdf)
### 自定义 Feature
-Feature 基类完全不绑定插件,你可以实现任意逻辑:
+Feature 基类完全不绑定插件,你可以实现任意逻辑,欢迎贡献你的feature到本项目中:
```python
from jmcomic import Feature, download_album
class NotifyFeature(Feature):
"""下载完成后发送通知"""
- def invoke(self, option, **context):
- album = context.get('album')
+ def invoke(self, option, **kwargs):
+ album = kwargs.get('album')
if album:
print(f'下载完成通知: {album.name}')
@@ -183,16 +201,3 @@ class NotifyFeature(Feature):
download_album('123', option, extra=NotifyFeature())
```
-### 自定义 PluginFeature
-
-如果你注册了自定义插件,也可以创建对应的 PluginFeature:
-
-```python
-from jmcomic import PluginFeature, Feature
-
-# 假设你注册了一个 plugin_key 为 'my_export' 的插件
-Feature.my_export = PluginFeature('my_export', output_dir='./my_output')
-
-# 使用
-download_album('123', option, extra=Feature.my_export)
-```
diff --git a/src/jmcomic/jm_downloader.py b/src/jmcomic/jm_downloader.py
index 2d6af3cdf..54e2d28c6 100644
--- a/src/jmcomic/jm_downloader.py
+++ b/src/jmcomic/jm_downloader.py
@@ -299,16 +299,16 @@ def add_features(self, features, feature_from: str):
else:
ExceptionTool.raises(f'不支持的 extra 类型: {type(features)},请传入 Feature / FeatureChain / list / None')
- def _invoke_features_for(self, when: str, **context):
+ def _invoke_features_for(self, when: str, **kwargs):
"""
在指定钩子(when)中触发匹配的 Feature。
:param when: 当前钩子名,如 'after_album', 'after_photo'
- :param context: album, photo, downloader 等上下文
+ :param kwargs: album, photo, downloader 等上下文
"""
for feature, feature_from in self._feature_list:
- if feature.should_invoke(when, feature_from):
- feature.invoke(self.option, feature_from=feature_from, **context)
+ if feature.should_invoke(feature_from, when):
+ feature.invoke(self.option, feature_from=feature_from, when=when, **kwargs)
def raise_if_has_exception(self):
if not self.has_download_failures:
diff --git a/src/jmcomic/jm_feature.py b/src/jmcomic/jm_feature.py
index 3efd878f8..94f3ca718 100644
--- a/src/jmcomic/jm_feature.py
+++ b/src/jmcomic/jm_feature.py
@@ -1,9 +1,7 @@
"""
-该文件存放的是 Feature(下载特性)机制
+该文件存放的是 Feature 机制
-Feature 用于在下载生命周期中挂载上下文相关的动态附加行为,
-例如下载完成后自适应导出为 PDF、ZIP 或长图等。
-它不仅是插件的封装,更能根据调用来源(整本/单章)智能调整执行策略。
+Feature 用于封装复杂、高级的功能特性,例如pdf导出插件,以前用户需要知道插件名称,调用时机,option插件参数等等,使用feature相当于包办了这些。
用法:
from jmcomic import download_album, Feature
@@ -11,13 +9,14 @@
# 最简单
download_album(id, option, extra=Feature.export_pdf)
- # 带参数
+ # 带自定义参数
download_album(id, option, extra=Feature.export_pdf(pdf_dir='./output'))
# 多个 Feature(列表 / 运算符均可)
download_album(id, option, extra=[Feature.export_pdf, Feature.export_zip])
download_album(id, option, extra=Feature.export_pdf + Feature.export_zip)
"""
+from typing import LiteralString
from .jm_plugin import *
@@ -36,23 +35,25 @@ class Feature:
export_zip: 'PluginFeature'
export_long_img: 'PluginFeature'
- def should_invoke(self, when: str, feature_from: str) -> bool:
+ def should_invoke(self, feature_from: str, when: str) -> bool:
"""
判断在当前钩子(when)下,根据来源(feature_from),是否应该执行。
默认返回 True(任何钩子都执行)。子类可覆写来限制执行时机。
- :param when: 当前触发的钩子名称,如 'after_album', 'after_photo'
:param feature_from: Feature 的注册来源,如 'download_album', 'download_photo'
+ :param when: 当前触发的钩子名称,如 'after_album', 'after_photo'
:returns: 是否应该执行
"""
return True
- def invoke(self, option, **context):
+ def invoke(self, option: JmOption, feature_from: str, when: str, **kwargs):
"""
执行此 Feature。子类需实现该方法。
:param option: 当前的 JmOption
- :param context: album, photo, downloader, feature_from 等上下文
+ :param feature_from 注册来源,如 'download_album', 'download_photo'
+ :param when: 钩子回调时机,如 'after_album', 'after_photo'
+ :param kwargs: album, photo, downloader 等回调参数
"""
raise NotImplementedError
@@ -83,9 +84,9 @@ def __init__(self, plugin_key, **kwargs):
# 用户通过 __call__ 显式传入的参数名,这些参数不会被 _adapt_kwargs 动态适配
self._user_keys: set = set()
- def should_invoke(self, when: str, feature_from: str) -> bool:
+ def should_invoke(self, feature_from: str, when: str) -> bool:
"""
- 根据注册来源推导执行时机:
+ 默认根据注册来源推导执行时机:
download_album → after_album, download_photo → after_photo
"""
if feature_from == 'download_album':
@@ -100,52 +101,34 @@ def __call__(self, **kwargs):
new_kwargs.update(kwargs)
new_instance = PluginFeature(self.plugin_key, **new_kwargs)
# 记录用户显式传入的参数名,这些参数不被动态适配
- new_instance._user_keys = set(kwargs.keys())
+ new_instance._user_0keys = set(kwargs.keys())
return new_instance
- def invoke(self, option, feature_from=None, **context):
+ def invoke(self, option: JmOption, feature_from: str, when: str, **extra):
"""
执行此 Feature 对应的插件。
根据 feature_from 动态适配 filename_rule 等参数。
"""
- pclass = JmModuleConfig.REGISTRY_PLUGIN.get(self.plugin_key)
- if pclass is None:
- ExceptionTool.raises(f'PluginFeature 引用了未注册的插件: {self.plugin_key}')
+ pclass: type = JmModuleConfig.REGISTRY_PLUGIN.get(self.plugin_key)
+ ExceptionTool.require_true(pclass is not None, f'PluginFeature 引用了未注册的插件: {self.plugin_key}, from {feature_from}, when {when}')
# 根据 feature_from 动态适配参数
- adapted = self._adapt_kwargs(feature_from)
- merged_kwargs = {**adapted, **context}
+ plugin_kwargs: dict = self._adapt_plugin_kwargs(feature_from, when)
option.invoke_plugin(
pclass=pclass,
- kwargs=merged_kwargs,
- extra={},
- pinfo={'plugin': self.plugin_key, 'kwargs': adapted},
+ kwargs=plugin_kwargs,
+ extra=extra,
+ pinfo={'plugin': self.plugin_key, 'kwargs': plugin_kwargs},
)
- def _adapt_kwargs(self, feature_from):
+ def _adapt_plugin_kwargs(self, feature_from: str, when: str) -> dict:
"""
- 根据 feature_from 动态适配参数:
- - filename_rule 前缀:download_album → A前缀,download_photo → P前缀
-
- 注意:用户通过 __call__ 显式传入的参数(记录在 _user_keys 中)不会被适配。
+ 根据feature_from和when动态确定以下插件参数:
+ filename_rule
"""
kwargs = self.kwargs.copy()
-
- if feature_from == 'download_album':
- # album 模式:P前缀规则 → A前缀规则
- if 'filename_rule' not in self._user_keys and 'filename_rule' in kwargs:
- rule = kwargs['filename_rule']
- if rule and rule[0] == 'P':
- kwargs['filename_rule'] = 'A' + rule[1:]
-
- elif feature_from == 'download_photo':
- # photo 模式:A前缀规则 → P前缀规则
- if 'filename_rule' not in self._user_keys and 'filename_rule' in kwargs:
- rule = kwargs['filename_rule']
- if rule and rule[0] == 'A':
- kwargs['filename_rule'] = 'P' + rule[1:]
-
+ kwargs.setdefault('filename_rule', '[JM{Aid}]{Atitle}' if feature_from == 'download_album' else '[JM{Pid}]{Ptitle}')
return kwargs
def __repr__(self):
@@ -184,6 +167,6 @@ def __repr__(self):
# 预定义特性(用插件类的 plugin_key 引用,附带默认参数)
# filename_rule 会根据 feature_from 在 invoke 时动态适配 A/P 前缀
# zip 的打包粒度由插件根据上下文(album/photo)自动推导,无需 level 参数
-Feature.export_pdf = PluginFeature(Img2pdfPlugin.plugin_key, pdf_dir='./', filename_rule='Atitle')
-Feature.export_zip = PluginFeature(ZipPlugin.plugin_key, zip_dir='./', filename_rule='Ptitle')
-Feature.export_long_img = PluginFeature(LongImgPlugin.plugin_key, img_dir='./', filename_rule='Pid')
+Feature.export_pdf = PluginFeature(Img2pdfPlugin.plugin_key, pdf_dir='./')
+Feature.export_zip = PluginFeature(ZipPlugin.plugin_key, zip_dir='./')
+Feature.export_long_img = PluginFeature(LongImgPlugin.plugin_key, img_dir='./')
diff --git a/src/jmcomic/jm_option.py b/src/jmcomic/jm_option.py
index 9daa620fe..1401dfcf5 100644
--- a/src/jmcomic/jm_option.py
+++ b/src/jmcomic/jm_option.py
@@ -366,14 +366,17 @@ def _migrate_zip_level(cls, plugins: dict):
plugins.setdefault('after_photo', []).append(pinfo)
plugin_list.pop(i)
jm_log('option.migrate',
- f'[zip 插件迁移] level 参数已废弃,打包粒度由所在钩子自动推导。'
- f'已自动将 after_album 下的 zip(level={level!r}) 等价迁移到 after_photo。'
- f'等价写法:将 zip 插件从 after_album 移至 after_photo 并删除 level 配置项。')
+ f'[zip 插件迁移] level 参数已过时,建议删除level参数。'
+ f'你的当前配置为,在本子下载完毕后按章节压缩,建议改为如下的等价新写法:\n'
+ f'plugins:\n'
+ f' after_photo:\n'
+ f' - plugin: {pinfo["plugin"]}\n'
+ f' kwargs: {pinfo["kwargs"]}\n'
+ )
else:
if level != 'photo':
jm_log('option.migrate',
- f'[zip 插件迁移] level 参数已废弃,已自动移除。'
- f'打包粒度由所在钩子自动推导({group} → {level})。')
+ f'[zip 插件迁移] level 参数已过时,你可以直接删除该参数,不会有任何影响')
i += 1
def deconstruct(self) -> Dict:
diff --git a/tests/test_jmcomic/test_jm_feature.py b/tests/test_jmcomic/test_jm_feature.py
index b9bc2ec78..9a5ca851c 100644
--- a/tests/test_jmcomic/test_jm_feature.py
+++ b/tests/test_jmcomic/test_jm_feature.py
@@ -31,7 +31,7 @@ def test_plugin_feature_call(self):
def test_custom_feature(self):
class MyCustomFeature(Feature):
- def invoke(self, option, **context):
+ def invoke(self, option, **kwargs):
pass
my_feature = MyCustomFeature()
@@ -44,47 +44,45 @@ def test_should_invoke(self):
"""测试 should_invoke 判断逻辑"""
# Feature 基类默认在所有钩子中都执行
class MyFeature(Feature):
- def invoke(self, option, **context):
+ def invoke(self, option, **kwargs):
pass
base = MyFeature()
- self.assertTrue(base.should_invoke('after_album', 'download_album'))
- self.assertTrue(base.should_invoke('after_photo', 'download_album'))
+ self.assertTrue(base.should_invoke('download_album', 'after_album'))
+ self.assertTrue(base.should_invoke('download_album', 'after_photo'))
# PluginFeature 根据来源推导执行时机
pf = Feature.export_pdf
# download_album → 只在 after_album 执行
- self.assertTrue(pf.should_invoke('after_album', 'download_album'))
- self.assertFalse(pf.should_invoke('after_photo', 'download_album'))
+ self.assertTrue(pf.should_invoke('download_album', 'after_album'))
+ self.assertFalse(pf.should_invoke('download_album', 'after_photo'))
# download_photo → 只在 after_photo 执行
- self.assertTrue(pf.should_invoke('after_photo', 'download_photo'))
- self.assertFalse(pf.should_invoke('after_album', 'download_photo'))
+ self.assertTrue(pf.should_invoke('download_photo', 'after_photo'))
+ self.assertFalse(pf.should_invoke('download_photo', 'after_album'))
def test_adapt_kwargs(self):
"""测试 PluginFeature 参数动态适配"""
# download_album 模式:P前缀 → A前缀, level → album
pdf = Feature.export_pdf
- adapted = pdf._adapt_kwargs('download_album')
+ adapted = pdf._adapt_plugin_kwargs('download_album', when)
self.assertEqual(adapted['filename_rule'], 'Atitle') # A开头不变
zip_f = Feature.export_zip
- adapted = zip_f._adapt_kwargs('download_album')
+ adapted = zip_f._adapt_plugin_kwargs('download_album', when)
self.assertEqual(adapted['filename_rule'], 'Atitle') # Ptitle → Atitle
- self.assertEqual(adapted['level'], 'album') # photo → album
long_img = Feature.export_long_img
- adapted = long_img._adapt_kwargs('download_album')
+ adapted = long_img._adapt_plugin_kwargs('download_album', when)
self.assertEqual(adapted['filename_rule'], 'Aid') # Pid → Aid
# download_photo 模式:A前缀 → P前缀, level → photo
- adapted = pdf._adapt_kwargs('download_photo')
+ adapted = pdf._adapt_plugin_kwargs('download_photo', when)
self.assertEqual(adapted['filename_rule'], 'Ptitle') # Atitle → Ptitle
# 用户显式传入的参数不被动态适配
- custom = Feature.export_zip(filename_rule='Ptitle', level='photo')
- adapted = custom._adapt_kwargs('download_album')
+ custom = Feature.export_zip(filename_rule='Ptitle')
+ adapted = custom._adapt_plugin_kwargs('download_album', when)
self.assertEqual(adapted['filename_rule'], 'Ptitle') # 用户显式指定,不适配
- self.assertEqual(adapted['level'], 'photo') # 用户显式指定,不适配
def test_download_use_feature(self):
album_id = '438516'
@@ -93,7 +91,7 @@ def test_download_use_feature(self):
custom_feature_call_count = 0
class MyCounterFeature(Feature):
- def invoke(self, option, **context):
+ def invoke(self, option, **kwargs):
nonlocal custom_feature_call_count
custom_feature_call_count += 1
@@ -126,9 +124,9 @@ def test_export_features(self):
album, dler = jmcomic.download_album(album_id, self.option, extra=combo)
# 验证文件是否精确生成
- # 通过 download_album 注册,动态适配后全部为 album 级别:
+ # 通过 download_album 注册,动态适配后:
# PDF: Atitle(不变) → [album标题].pdf
- # ZIP: Ptitle→Atitle, level→album → [album标题].zip
+ # ZIP: Ptitle→Atitle → [album标题].zip
# PNG: Pid→Aid → [album_id].png
pdf_name = DirRule.apply_rule_to_filename(album, None, 'Atitle') + '.pdf'
zip_name = DirRule.apply_rule_to_filename(album, None, 'Atitle') + '.zip'
From ba8782704cb177d3831ad7666d68dcb2a35cef3b Mon Sep 17 00:00:00 2001
From: hect0x7 <93357912+hect0x7@users.noreply.github.com>
Date: Wed, 6 May 2026 22:58:21 +0800
Subject: [PATCH 06/13] =?UTF-8?q?fix:=20=20PR=20Review=20=E4=BF=AE?=
=?UTF-8?q?=E5=A4=8D?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../sources/tutorial/13_export_and_feature.md | 2 +-
src/jmcomic/jm_feature.py | 6 ---
src/jmcomic/jm_option.py | 41 +++++++++++++++----
tests/test_jmcomic/test_jm_feature.py | 18 ++++----
4 files changed, 43 insertions(+), 24 deletions(-)
diff --git a/assets/docs/sources/tutorial/13_export_and_feature.md b/assets/docs/sources/tutorial/13_export_and_feature.md
index d40ad5943..617f0f73e 100644
--- a/assets/docs/sources/tutorial/13_export_and_feature.md
+++ b/assets/docs/sources/tutorial/13_export_and_feature.md
@@ -54,7 +54,7 @@ download_album('123', option, extra=Feature.export_pdf + Feature.export_zip)
# 也支持列表语法,|语法
download_album('123', option, extra=[Feature.export_pdf, Feature.export_zip])
-download_album('123', option, extra=Feature.export_pdf | Feature.export_zip)
+download_album('123', option, extra=Feature.export_pdf | Feature.export_zip)
```
效果同pdf,会在本子下载完以后,额外在当前工作目录下,生成包含所有本子图片的 PDF 文件和 ZIP 文件:
diff --git a/src/jmcomic/jm_feature.py b/src/jmcomic/jm_feature.py
index 94f3ca718..9be5a1d39 100644
--- a/src/jmcomic/jm_feature.py
+++ b/src/jmcomic/jm_feature.py
@@ -16,8 +16,6 @@
download_album(id, option, extra=[Feature.export_pdf, Feature.export_zip])
download_album(id, option, extra=Feature.export_pdf + Feature.export_zip)
"""
-from typing import LiteralString
-
from .jm_plugin import *
@@ -81,8 +79,6 @@ class PluginFeature(Feature):
def __init__(self, plugin_key, **kwargs):
self.plugin_key = plugin_key
self.kwargs = kwargs
- # 用户通过 __call__ 显式传入的参数名,这些参数不会被 _adapt_kwargs 动态适配
- self._user_keys: set = set()
def should_invoke(self, feature_from: str, when: str) -> bool:
"""
@@ -100,8 +96,6 @@ def __call__(self, **kwargs):
new_kwargs = self.kwargs.copy()
new_kwargs.update(kwargs)
new_instance = PluginFeature(self.plugin_key, **new_kwargs)
- # 记录用户显式传入的参数名,这些参数不被动态适配
- new_instance._user_0keys = set(kwargs.keys())
return new_instance
def invoke(self, option: JmOption, feature_from: str, when: str, **extra):
diff --git a/src/jmcomic/jm_option.py b/src/jmcomic/jm_option.py
index 1401dfcf5..08098456e 100644
--- a/src/jmcomic/jm_option.py
+++ b/src/jmcomic/jm_option.py
@@ -344,6 +344,29 @@ def _migrate_zip_level(cls, plugins: dict):
level 已废弃,打包粒度由所在钩子的上下文自动推导。
迁移规则:level='album' → 确保在 after_album;其他 → 确保在 after_photo。
"""
+
+ def log_advice(reason, plugins):
+ import yaml
+ # 意图聚焦:建议配置中只展示相关的 zip 插件,剔除其他无关插件的干扰
+ advice_plugins = {}
+ for g, plist in plugins.items():
+ zips = [p for p in plist if p.get('plugin') == 'zip']
+ if zips:
+ advice_plugins[g] = zips
+
+ if not advice_plugins:
+ return
+
+ plugins_yml = yaml.dump({'plugins': advice_plugins}, default_flow_style=False, indent=2, sort_keys=False).strip()
+
+ jm_log('option.migrate',
+ f'[zip 插件迁移] level 参数已过时,建议直接删除。'
+ f'{reason},建议参考如下的等价新写法:\n'
+ f'```yml\n'
+ f'{plugins_yml}\n'
+ f'```'
+ )
+
for group in ['after_album', 'after_photo']:
plugin_list = plugins.get(group)
if not isinstance(plugin_list, list):
@@ -365,18 +388,18 @@ def _migrate_zip_level(cls, plugins: dict):
# after_album + level=photo → 等价迁移到 after_photo
plugins.setdefault('after_photo', []).append(pinfo)
plugin_list.pop(i)
- jm_log('option.migrate',
- f'[zip 插件迁移] level 参数已过时,建议删除level参数。'
- f'你的当前配置为,在本子下载完毕后按章节压缩,建议改为如下的等价新写法:\n'
- f'plugins:\n'
- f' after_photo:\n'
- f' - plugin: {pinfo["plugin"]}\n'
- f' kwargs: {pinfo["kwargs"]}\n'
- )
+ log_advice('你的当前配置为:在本子下载完毕后按章节压缩', plugins)
+
+ elif group == 'after_photo' and level == 'album':
+ # after_photo + level=album → 等价迁移到 after_album
+ plugins.setdefault('after_album', []).append(pinfo)
+ plugin_list.pop(i)
+ log_advice('你的当前配置为:在单章节下载完毕后对全本进行压缩', plugins)
+
else:
if level != 'photo':
jm_log('option.migrate',
- f'[zip 插件迁移] level 参数已过时,你可以直接删除该参数,不会有任何影响')
+ '[zip 插件迁移] level 参数已过时,你可以直接删除该参数,不会有任何影响')
i += 1
def deconstruct(self) -> Dict:
diff --git a/tests/test_jmcomic/test_jm_feature.py b/tests/test_jmcomic/test_jm_feature.py
index 9a5ca851c..f54671180 100644
--- a/tests/test_jmcomic/test_jm_feature.py
+++ b/tests/test_jmcomic/test_jm_feature.py
@@ -62,27 +62,29 @@ def invoke(self, option, **kwargs):
def test_adapt_kwargs(self):
"""测试 PluginFeature 参数动态适配"""
- # download_album 模式:P前缀 → A前缀, level → album
+ when = 'after_album'
+
pdf = Feature.export_pdf
adapted = pdf._adapt_plugin_kwargs('download_album', when)
- self.assertEqual(adapted['filename_rule'], 'Atitle') # A开头不变
+ self.assertEqual(adapted['filename_rule'], '[JM{Aid}]{Atitle}')
zip_f = Feature.export_zip
adapted = zip_f._adapt_plugin_kwargs('download_album', when)
- self.assertEqual(adapted['filename_rule'], 'Atitle') # Ptitle → Atitle
+ self.assertEqual(adapted['filename_rule'], '[JM{Aid}]{Atitle}')
long_img = Feature.export_long_img
adapted = long_img._adapt_plugin_kwargs('download_album', when)
- self.assertEqual(adapted['filename_rule'], 'Aid') # Pid → Aid
+ self.assertEqual(adapted['filename_rule'], '[JM{Aid}]{Atitle}')
- # download_photo 模式:A前缀 → P前缀, level → photo
+ # download_photo 模式
+ when = 'after_photo'
adapted = pdf._adapt_plugin_kwargs('download_photo', when)
- self.assertEqual(adapted['filename_rule'], 'Ptitle') # Atitle → Ptitle
+ self.assertEqual(adapted['filename_rule'], '[JM{Pid}]{Ptitle}')
- # 用户显式传入的参数不被动态适配
+ # 用户显式传入的参数不被动态适配 (通过 kwargs 机制自带)
custom = Feature.export_zip(filename_rule='Ptitle')
adapted = custom._adapt_plugin_kwargs('download_album', when)
- self.assertEqual(adapted['filename_rule'], 'Ptitle') # 用户显式指定,不适配
+ self.assertEqual(adapted['filename_rule'], 'Ptitle') # 用户显式指定,不被 setdefault 覆盖
def test_download_use_feature(self):
album_id = '438516'
From 4cec8920f301eb8f949052b107be2cb0f5f51add Mon Sep 17 00:00:00 2001
From: hect0x7 <93357912+hect0x7@users.noreply.github.com>
Date: Wed, 6 May 2026 23:21:58 +0800
Subject: [PATCH 07/13] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20test=5Fjm=5Ff?=
=?UTF-8?q?eature=20=E6=B5=8B=E8=AF=95=E5=A4=B1=E8=B4=A5=EF=BC=8C=E5=90=8C?=
=?UTF-8?q?=E6=AD=A5=E6=9B=B4=E6=96=B0=E5=91=BD=E5=90=8D=E8=A7=84=E5=88=99?=
=?UTF-8?q?=E5=B9=B6=E6=96=B0=E5=A2=9E=20photo=20=E7=BA=A7=E6=B5=8B?=
=?UTF-8?q?=E8=AF=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
tests/test_jmcomic/test_jm_feature.py | 28 ++++++++++++++++++++-------
1 file changed, 21 insertions(+), 7 deletions(-)
diff --git a/tests/test_jmcomic/test_jm_feature.py b/tests/test_jmcomic/test_jm_feature.py
index f54671180..d201ffac0 100644
--- a/tests/test_jmcomic/test_jm_feature.py
+++ b/tests/test_jmcomic/test_jm_feature.py
@@ -126,13 +126,11 @@ def test_export_features(self):
album, dler = jmcomic.download_album(album_id, self.option, extra=combo)
# 验证文件是否精确生成
- # 通过 download_album 注册,动态适配后:
- # PDF: Atitle(不变) → [album标题].pdf
- # ZIP: Ptitle→Atitle → [album标题].zip
- # PNG: Pid→Aid → [album_id].png
- pdf_name = DirRule.apply_rule_to_filename(album, None, 'Atitle') + '.pdf'
- zip_name = DirRule.apply_rule_to_filename(album, None, 'Atitle') + '.zip'
- png_name = DirRule.apply_rule_to_filename(album, None, 'Aid') + '.png'
+ # 通过 download_album 注册,动态适配后默认规则均为:[JM{Aid}]{Atitle}
+ rule = '[JM{Aid}]{Atitle}'
+ pdf_name = DirRule.apply_rule_to_filename(album, None, rule) + '.pdf'
+ zip_name = DirRule.apply_rule_to_filename(album, None, rule) + '.zip'
+ png_name = DirRule.apply_rule_to_filename(album, None, rule) + '.png'
import os
pdf_path = os.path.join(export_dir, pdf_name)
@@ -142,3 +140,19 @@ def test_export_features(self):
self.assertTrue(os.path.exists(pdf_path), f"未生成精确匹配的 PDF 文件: {pdf_path}")
self.assertTrue(os.path.exists(zip_path), f"未生成精确匹配的 ZIP 文件: {zip_path}")
self.assertTrue(os.path.exists(png_path), f"未生成精确匹配的 PNG 长图: {png_path}")
+
+ def test_export_features_photo(self):
+ photo_id = '438516'
+ export_dir = self.option.dir_rule.base_dir
+
+ # 测试单个章节的 PDF 导出
+ f_pdf = Feature.export_pdf(pdf_dir=export_dir)
+ photo, dler = jmcomic.download_photo(photo_id, self.option, extra=f_pdf)
+
+ # 验证文件是否按照 [JM{Pid}]{Ptitle} 规则生成
+ rule = '[JM{Pid}]{Ptitle}'
+ pdf_name = DirRule.apply_rule_to_filename(None, photo, rule) + '.pdf'
+
+ import os
+ pdf_path = os.path.join(export_dir, pdf_name)
+ self.assertTrue(os.path.exists(pdf_path), f"未生成精确匹配的 PDF 文件 (章节级): {pdf_path}")
From 3f8246dc3500ab2343e0cd61b2b422f45ae0dbc6 Mon Sep 17 00:00:00 2001
From: hect0x7 <93357912+hect0x7@users.noreply.github.com>
Date: Thu, 7 May 2026 00:43:04 +0800
Subject: [PATCH 08/13] =?UTF-8?q?feat:=20=E5=AE=8C=E5=96=84=20Feature=20?=
=?UTF-8?q?=E6=9C=BA=E5=88=B6=E5=8F=8A=E6=8F=92=E4=BB=B6=E5=81=A5=E5=A3=AE?=
=?UTF-8?q?=E6=80=A7=E4=BC=98=E5=8C=96?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
1. [核心] 在 JmDownloader 中实现 Feature 异常隔离与详细日志记录,确保单个 Feature 失败不影响下载流程。
2. [优化] 优化 PluginFeature:使用 type(self) 确保子类化安全,使用 dict(kwargs) 避免可变参数污染。
3. [测试] 补全 download_batch 对 extra 参数的传播测试,并新增 Album 模式下使用 Photo 规则的负面测试。
4. [文档] 修正教程文档中的 filename_rule 示例及相关错误,精简代码注释。
5. [其他] 开启 workflow 下载配置的美观打印日志。
---
.../sources/tutorial/13_export_and_feature.md | 7 ++++++-
assets/option/option_workflow_download.yml | 2 ++
src/jmcomic/jm_downloader.py | 5 ++++-
src/jmcomic/jm_feature.py | 8 +++-----
tests/test_jmcomic/test_jm_feature.py | 20 +++++++++++++++++++
5 files changed, 35 insertions(+), 7 deletions(-)
diff --git a/assets/docs/sources/tutorial/13_export_and_feature.md b/assets/docs/sources/tutorial/13_export_and_feature.md
index 617f0f73e..61869a90e 100644
--- a/assets/docs/sources/tutorial/13_export_and_feature.md
+++ b/assets/docs/sources/tutorial/13_export_and_feature.md
@@ -75,10 +75,15 @@ download_album('123', option, extra=Feature.export_pdf | Feature.export_zip)
download_album('123', option, extra=Feature.export_pdf(
# 下面是自定义参数
pdf_dir='D:/my_pdfs', # PDF 保存到 D:/my_pdfs 文件夹
- filename_rule='Ptitle', # 用章节标题作为文件名
+ filename_rule='Atitle', # 用本子标题作为文件名
delete_original_file=True, # 合并完 PDF 后删除原图
))
+> 💡 **小白必读:命名规则(filename_rule)的小知识**
+> - `A` 开头的占位符(如 `Atitle`, `Aid`)代表 **Album (本子)**,适用于 `download_album`。
+> - `P` 开头的占位符(如 `Ptitle`, `Pid`)代表 **Photo (章节)**,适用于 `download_photo`。
+> - 如果在下载整本(Album)时强行使用章节级(Photo)的规则,程序会因为不知道该用哪一章的标题而报错。
+
# 示例 2:全都要——ZIP 存盘 + 长图阅读
combo = (
Feature.export_zip(zip_dir='D:/zips')
diff --git a/assets/option/option_workflow_download.yml b/assets/option/option_workflow_download.yml
index 4a201b5a7..968d32fee 100644
--- a/assets/option/option_workflow_download.yml
+++ b/assets/option/option_workflow_download.yml
@@ -1,4 +1,6 @@
# GitHub Actions 下载脚本配置
+log: pretty
+
dir_rule:
base_dir: ${JM_DOWNLOAD_DIR}
rule: Bd_Aauthor_Atitle_Pindex
diff --git a/src/jmcomic/jm_downloader.py b/src/jmcomic/jm_downloader.py
index 54e2d28c6..d47d66c5c 100644
--- a/src/jmcomic/jm_downloader.py
+++ b/src/jmcomic/jm_downloader.py
@@ -308,7 +308,10 @@ def _invoke_features_for(self, when: str, **kwargs):
"""
for feature, feature_from in self._feature_list:
if feature.should_invoke(feature_from, when):
- feature.invoke(self.option, feature_from=feature_from, when=when, **kwargs)
+ try:
+ feature.invoke(self.option, feature_from=feature_from, when=when, **kwargs)
+ except BaseException as e:
+ jm_log('downloader.feature.exception', f'Feature执行失败: [{feature}], 来源: [{feature_from}], 异常: [{e}]')
def raise_if_has_exception(self):
if not self.has_download_failures:
diff --git a/src/jmcomic/jm_feature.py b/src/jmcomic/jm_feature.py
index 9be5a1d39..724551093 100644
--- a/src/jmcomic/jm_feature.py
+++ b/src/jmcomic/jm_feature.py
@@ -78,7 +78,7 @@ class PluginFeature(Feature):
def __init__(self, plugin_key, **kwargs):
self.plugin_key = plugin_key
- self.kwargs = kwargs
+ self.kwargs = dict(kwargs)
def should_invoke(self, feature_from: str, when: str) -> bool:
"""
@@ -95,7 +95,7 @@ def __call__(self, **kwargs):
"""带自定义参数,返回新实例(继承默认参数)"""
new_kwargs = self.kwargs.copy()
new_kwargs.update(kwargs)
- new_instance = PluginFeature(self.plugin_key, **new_kwargs)
+ new_instance = type(self)(self.plugin_key, **new_kwargs)
return new_instance
def invoke(self, option: JmOption, feature_from: str, when: str, **extra):
@@ -158,9 +158,7 @@ def __repr__(self):
return f'FeatureChain({self._features})'
-# 预定义特性(用插件类的 plugin_key 引用,附带默认参数)
-# filename_rule 会根据 feature_from 在 invoke 时动态适配 A/P 前缀
-# zip 的打包粒度由插件根据上下文(album/photo)自动推导,无需 level 参数
+# 内置的 PluginFeature
Feature.export_pdf = PluginFeature(Img2pdfPlugin.plugin_key, pdf_dir='./')
Feature.export_zip = PluginFeature(ZipPlugin.plugin_key, zip_dir='./')
Feature.export_long_img = PluginFeature(LongImgPlugin.plugin_key, img_dir='./')
diff --git a/tests/test_jmcomic/test_jm_feature.py b/tests/test_jmcomic/test_jm_feature.py
index d201ffac0..b8d970b50 100644
--- a/tests/test_jmcomic/test_jm_feature.py
+++ b/tests/test_jmcomic/test_jm_feature.py
@@ -110,6 +110,11 @@ def invoke(self, option, **kwargs):
jmcomic.download_photo(photo_id, self.option, extra=counter_feature)
self.assertEqual(custom_feature_call_count, 3)
+ # 测试 download_batch (Iterable 批量输入): 确保 extra 参数不被丢弃
+ jmcomic.download_batch(jmcomic.download_album, [album_id], self.option, extra=counter_feature)
+ # 上面增加了 1 个 album (包含 1 个 photo),因此 invoke 追加 2 次,总计 5 次
+ self.assertEqual(custom_feature_call_count, 5)
+
def test_export_features(self):
album_id = '438516'
@@ -156,3 +161,18 @@ def test_export_features_photo(self):
import os
pdf_path = os.path.join(export_dir, pdf_name)
self.assertTrue(os.path.exists(pdf_path), f"未生成精确匹配的 PDF 文件 (章节级): {pdf_path}")
+
+ def test_export_album_use_photo_rule(self):
+ """
+ 负面测试:在 Album 模式下强行使用 Photo 级规则(Ptitle),预期报错。
+ 本子=album,本子的章节=photo。下载本子时,photo对象为None。
+ """
+ album_id = '438516'
+ # 强行使用 Ptitle
+ f = Feature.export_pdf(filename_rule='Ptitle')
+
+ # 验证底层 invoke 会抛出 AttributeError
+ # 因为在 download_album 的 after_album 阶段,photo 为 None
+ with self.assertRaises(AttributeError):
+ album = self.client.get_album_detail(album_id)
+ f.invoke(self.option, feature_from='download_album', when='after_album', album=album, photo=None)
From 6b47ecb450822d654e73b9686166ea04de7f93a9 Mon Sep 17 00:00:00 2001
From: hect0x7 <93357912+hect0x7@users.noreply.github.com>
Date: Thu, 7 May 2026 20:36:26 +0800
Subject: [PATCH 09/13] =?UTF-8?q?docs:=20=E4=BF=AE=E5=A4=8D=E6=96=87?=
=?UTF-8?q?=E6=A1=A3=E6=B8=B2=E6=9F=93=E9=94=99=E8=AF=AF=E5=B9=B6=E5=90=8C?=
=?UTF-8?q?=E6=AD=A5=20Feature=20=E6=9C=BA=E5=88=B6=E6=8F=8F=E8=BF=B0?=
=?UTF-8?q?=EF=BC=9B=E4=BC=98=E5=8C=96=E5=BC=82=E5=B8=B8=E5=A4=84=E7=90=86?=
=?UTF-8?q?=E8=AF=AD=E4=B9=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../sources/tutorial/13_export_and_feature.md | 16 +++++++++-------
src/jmcomic/jm_downloader.py | 2 +-
2 files changed, 10 insertions(+), 8 deletions(-)
diff --git a/assets/docs/sources/tutorial/13_export_and_feature.md b/assets/docs/sources/tutorial/13_export_and_feature.md
index 61869a90e..454fe681c 100644
--- a/assets/docs/sources/tutorial/13_export_and_feature.md
+++ b/assets/docs/sources/tutorial/13_export_and_feature.md
@@ -39,7 +39,7 @@ download_album('123', option, extra=Feature.export_pdf)
**效果**:在本子下载完以后,额外在**当前工作目录**下生成包含所有本子图片的 PDF 文件:
-```
+```text
./
├── [JM123]本子标题.pdf ← 整本合并为 1 个 PDF,注意pdf文件名的格式,默认包含本子禁漫车号+本子标题
```
@@ -59,7 +59,7 @@ download_album('123', option, extra=Feature.export_pdf | Feature.export_zip)
效果同pdf,会在本子下载完以后,额外在当前工作目录下,生成包含所有本子图片的 PDF 文件和 ZIP 文件:
-```
+```text
./
├── [JM123]本子标题.pdf ← 整本合并为 1 个 PDF
├── [JM123]本子标题.zip ← 整本合并为 1 个 zip 压缩包
@@ -78,12 +78,14 @@ download_album('123', option, extra=Feature.export_pdf(
filename_rule='Atitle', # 用本子标题作为文件名
delete_original_file=True, # 合并完 PDF 后删除原图
))
+```
> 💡 **小白必读:命名规则(filename_rule)的小知识**
> - `A` 开头的占位符(如 `Atitle`, `Aid`)代表 **Album (本子)**,适用于 `download_album`。
> - `P` 开头的占位符(如 `Ptitle`, `Pid`)代表 **Photo (章节)**,适用于 `download_photo`。
> - 如果在下载整本(Album)时强行使用章节级(Photo)的规则,程序会因为不知道该用哪一章的标题而报错。
+```python
# 示例 2:全都要——ZIP 存盘 + 长图阅读
combo = (
Feature.export_zip(zip_dir='D:/zips')
@@ -103,7 +105,7 @@ download_photo('456', option, extra=Feature.export_pdf)
效果:在当前工作目录下生成以章节标题命名的 PDF:
-```
+```text
./
├── [章节标题].pdf ← 该章节导出为 1 个 PDF
```
@@ -147,20 +149,20 @@ plugins:
### 类层次
-```
+```text
Feature (基类)
├── PluginFeature ← 封装插件调用,参数根据来源自适应
└── 你的自定义 Feature ← 继承 Feature,实现任意逻辑
```
- **Feature 基类**:通用的附加行为抽象,不绑定任何具体实现。默认在所有生命周期钩子中执行。
-- **PluginFeature**:Feature 的子类,专门封装 jmcomic 插件。除了调用插件之外,还会根据调用来源动态适配 `filename_rule`、`level` 等参数。
+- **PluginFeature**:Feature 的子类,专门封装 jmcomic 插件。除了调用插件之外,还会根据调用来源动态适配 `filename_rule` 参数;ZIP 的打包粒度则由插件在运行时根据上下文自动推导。
### 执行流程
Feature **自然嵌入到 downloader 的生命周期钩子**中自动触发:
-```
+```text
api.download_album(extra=Feature.export_pdf)
│
├→ dler.add_features(pdf, 'download_album') # 注册: [(pdf, 'download_album')]
@@ -186,7 +188,7 @@ api.download_album(extra=Feature.export_pdf)
> 💡 **关键点**:
>
> - **执行时机**:`PluginFeature` 根据注册来源自动推导(`download_album` → `after_album`,`download_photo` → `after_photo`)。自定义 Feature 默认在所有钩子都会执行,你可以覆写 `should_invoke` 来控制。
-> - **参数自适应**:`PluginFeature` 的 `filename_rule` 前缀(A/P)和 `level`(album/photo)会根据来源动态适配。用户显式传入的参数不会被覆盖。
+> - **参数自适应**:`PluginFeature` 的 `filename_rule` 前缀(A/P)会根据来源动态适配。ZIP 的打包粒度由插件根据上下文自动推导。用户显式传入的参数不会被覆盖。
### 自定义 Feature
diff --git a/src/jmcomic/jm_downloader.py b/src/jmcomic/jm_downloader.py
index d47d66c5c..7d3980a91 100644
--- a/src/jmcomic/jm_downloader.py
+++ b/src/jmcomic/jm_downloader.py
@@ -310,7 +310,7 @@ def _invoke_features_for(self, when: str, **kwargs):
if feature.should_invoke(feature_from, when):
try:
feature.invoke(self.option, feature_from=feature_from, when=when, **kwargs)
- except BaseException as e:
+ except Exception as e:
jm_log('downloader.feature.exception', f'Feature执行失败: [{feature}], 来源: [{feature_from}], 异常: [{e}]')
def raise_if_has_exception(self):
From 530e74cb6caca1c96bddecb0fd5d728d89a05fb2 Mon Sep 17 00:00:00 2001
From: hect0x7 <93357912+hect0x7@users.noreply.github.com>
Date: Thu, 7 May 2026 21:07:59 +0800
Subject: [PATCH 10/13] =?UTF-8?q?refactor(feature):=20=E5=93=8D=E5=BA=94?=
=?UTF-8?q?=20CodeReview=20=E6=84=8F=E8=A7=81=EF=BC=8C=E4=BC=98=E5=8C=96?=
=?UTF-8?q?=20FeatureChain=20=E5=B0=81=E8=A3=85=E4=B8=8E=E5=8A=A8=E6=80=81?=
=?UTF-8?q?=E5=8F=82=E6=95=B0=E9=80=82=E9=85=8D=EF=BC=8C=E8=A1=A5=E5=85=85?=
=?UTF-8?q?=E6=96=B0=E6=89=8B=E6=96=87=E6=A1=A3?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../sources/tutorial/13_export_and_feature.md | 4 ++++
src/jmcomic/jm_client_impl.py | 5 +----
src/jmcomic/jm_config.py | 1 -
src/jmcomic/jm_downloader.py | 4 ++--
src/jmcomic/jm_exception.py | 2 +-
src/jmcomic/jm_feature.py | 22 +++++++++----------
src/jmcomic/jm_toolkit.py | 2 +-
7 files changed, 20 insertions(+), 20 deletions(-)
diff --git a/assets/docs/sources/tutorial/13_export_and_feature.md b/assets/docs/sources/tutorial/13_export_and_feature.md
index 454fe681c..9a5886340 100644
--- a/assets/docs/sources/tutorial/13_export_and_feature.md
+++ b/assets/docs/sources/tutorial/13_export_and_feature.md
@@ -44,6 +44,10 @@ download_album('123', option, extra=Feature.export_pdf)
├── [JM123]本子标题.pdf ← 整本合并为 1 个 PDF,注意pdf文件名的格式,默认包含本子禁漫车号+本子标题
```
+> 💡 **小白提示:当前工作目录在哪?**
+>
+> 默认情况下,文件会直接出现在你**运行 Python 脚本的那个文件夹里**。如果你不知道具体是哪,可以看后面的【自定义参数】章节,手动指定你想保存到的文件夹。
+
### 2.2 需要多种导出格式(PDF、ZIP等)——直接组合 Feature
用 `+` 号组合,同时导出多种格式:
diff --git a/src/jmcomic/jm_client_impl.py b/src/jmcomic/jm_client_impl.py
index bf0b5a500..76b61f9dd 100644
--- a/src/jmcomic/jm_client_impl.py
+++ b/src/jmcomic/jm_client_impl.py
@@ -280,9 +280,6 @@ def get_photo_detail(self,
photo = self.fetch_detail_entity(photo_id, 'photo')
# 一并获取该章节的所处本子
- # todo: 可优化,获取章节所在本子,其实不需要等待章节获取完毕后。
- # 可以直接调用 self.get_album_detail(photo_id),会重定向返回本子的HTML
- # (had polished by FutureClientProxy)
if fetch_album is True:
photo.from_album = self.get_album_detail(photo.album_id)
@@ -1205,4 +1202,4 @@ def get_photo_detail(self, photo_id, fetch_album=True, fetch_scramble_id=True) -
if scramble_id != '':
photo.scramble_id = scramble_id
- return photo
\ No newline at end of file
+ return photo
diff --git a/src/jmcomic/jm_config.py b/src/jmcomic/jm_config.py
index 4ab2a8634..39bd3aa80 100644
--- a/src/jmcomic/jm_config.py
+++ b/src/jmcomic/jm_config.py
@@ -591,7 +591,6 @@ def format(self, record):
def enable_pretty_log():
"""开启带颜色的美化日志"""
import sys
- import os as _os
# Windows 需要启用 VT100 ANSI 支持
if sys.platform == 'win32':
diff --git a/src/jmcomic/jm_downloader.py b/src/jmcomic/jm_downloader.py
index 7d3980a91..fd9942577 100644
--- a/src/jmcomic/jm_downloader.py
+++ b/src/jmcomic/jm_downloader.py
@@ -82,7 +82,7 @@ def __init__(self, option: JmOption) -> None:
self.download_failed_image: List[Tuple[JmImageDetail, BaseException]] = []
self.download_failed_photo: List[Tuple[JmPhotoDetail, BaseException]] = []
# Feature 特性列表: [(feature, feature_from), ...]
- self._feature_list: list = []
+ self._feature_list: List[Tuple] = []
def download_album(self, album_id):
album = self.client.get_album_detail(album_id)
@@ -292,7 +292,7 @@ def add_features(self, features, feature_from: str):
for f in features:
self.add_features(f, feature_from)
elif isinstance(features, FeatureChain):
- for f in features._features:
+ for f in features.to_list():
self._feature_list.append((f, feature_from))
elif isinstance(features, Feature):
self._feature_list.append((features, feature_from))
diff --git a/src/jmcomic/jm_exception.py b/src/jmcomic/jm_exception.py
index 987ec0fc5..34bd3e20a 100644
--- a/src/jmcomic/jm_exception.py
+++ b/src/jmcomic/jm_exception.py
@@ -183,7 +183,7 @@ def new(msg, context=None, _etype=None):
@classmethod
def notify_all_listeners(cls, e):
- registry: Dict[Type, Callable[Type]] = JmModuleConfig.REGISTRY_EXCEPTION_LISTENER
+ registry: Dict[Type, Callable] = JmModuleConfig.REGISTRY_EXCEPTION_LISTENER
if not registry:
return None
diff --git a/src/jmcomic/jm_feature.py b/src/jmcomic/jm_feature.py
index 724551093..3ed537567 100644
--- a/src/jmcomic/jm_feature.py
+++ b/src/jmcomic/jm_feature.py
@@ -58,15 +58,15 @@ def invoke(self, option: JmOption, feature_from: str, when: str, **kwargs):
# ---- 组合运算符,统一返回 FeatureChain ----
def __add__(self, other):
- return FeatureChain._combine(self, other)
+ return FeatureChain.combine(self, other)
def __or__(self, other):
- return FeatureChain._combine(self, other)
+ return FeatureChain.combine(self, other)
def __and__(self, other):
- return FeatureChain._combine(self, other)
+ return FeatureChain.combine(self, other)
- def _to_list(self):
+ def to_list(self):
return [self]
@@ -122,7 +122,7 @@ def _adapt_plugin_kwargs(self, feature_from: str, when: str) -> dict:
filename_rule
"""
kwargs = self.kwargs.copy()
- kwargs.setdefault('filename_rule', '[JM{Aid}]{Atitle}' if feature_from == 'download_album' else '[JM{Pid}]{Ptitle}')
+ kwargs.setdefault('filename_rule', '[JM{Aid}]{Atitle}' if when == 'after_album' else '[JM{Pid}]{Ptitle}')
return kwargs
def __repr__(self):
@@ -139,19 +139,19 @@ def __init__(self, features):
self._features = features
@classmethod
- def _combine(cls, left, right):
- return cls(left._to_list() + right._to_list())
+ def combine(cls, left, right):
+ return cls(left.to_list() + right.to_list())
def __add__(self, other):
- return FeatureChain._combine(self, other)
+ return FeatureChain.combine(self, other)
def __or__(self, other):
- return FeatureChain._combine(self, other)
+ return FeatureChain.combine(self, other)
def __and__(self, other):
- return FeatureChain._combine(self, other)
+ return FeatureChain.combine(self, other)
- def _to_list(self):
+ def to_list(self):
return list(self._features)
def __repr__(self):
diff --git a/src/jmcomic/jm_toolkit.py b/src/jmcomic/jm_toolkit.py
index 72434f372..850bd1eec 100644
--- a/src/jmcomic/jm_toolkit.py
+++ b/src/jmcomic/jm_toolkit.py
@@ -350,7 +350,7 @@ def to_zh(cls, s, target=None):
try:
import zhconv
return zhconv.convert(s, target)
- except ImportError as e:
+ except ImportError:
jm_log('zhconv.error', '繁简转换失败,未安装zhconv,请先使用命令安装: [pip install zhconv]')
return s
except Exception as e:
From ab58ce2f12faf3f78c0a5c8707ddfa3a6d73a57f Mon Sep 17 00:00:00 2001
From: hect0x7 <93357912+hect0x7@users.noreply.github.com>
Date: Thu, 7 May 2026 23:27:57 +0800
Subject: [PATCH 11/13] =?UTF-8?q?v2.0.21:=20=E6=9B=B4=E6=96=B0=E7=A6=81?=
=?UTF-8?q?=E6=BC=ABAPI=E5=9F=9F=E5=90=8D=E5=92=8C=E7=89=88=E6=9C=AC?=
=?UTF-8?q?=E5=8F=B7;=20=E5=B0=86=E5=90=88=E5=B9=B6PDF/ZIP/=E9=95=BF?=
=?UTF-8?q?=E5=9B=BE=E7=9A=84=E5=8A=9F=E8=83=BD=E7=AE=80=E5=8C=96=E4=B8=BA?=
=?UTF-8?q?=20Feature=EF=BC=8C=E6=96=B9=E4=BE=BF=E5=B0=8F=E7=99=BD?=
=?UTF-8?q?=E4=BD=BF=E7=94=A8;=20=E5=A2=9E=E5=8A=A0=E7=BE=8E=E8=A7=82?=
=?UTF-8?q?=E6=97=A5=E5=BF=97;=20bugfix=E5=92=8C=E4=BC=98=E5=8C=96?=
=?UTF-8?q?=E6=96=87=E6=A1=A3?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
assets/docs/sources/index.md | 2 +-
assets/docs/sources/option_file_syntax.md | 8 ++---
.../sources/tutorial/13_export_and_feature.md | 25 ++++++--------
src/jmcomic/jm_config.py | 11 +++---
src/jmcomic/jm_downloader.py | 21 ++++++------
src/jmcomic/jm_entity.py | 11 ++++--
src/jmcomic/jm_feature.py | 19 ++++++++---
src/jmcomic/jm_option.py | 18 +++++-----
src/jmcomic/jm_plugin.py | 27 +++++++++++----
tests/test_jmcomic/test_jm_feature.py | 34 +++++++++++++++----
10 files changed, 114 insertions(+), 62 deletions(-)
diff --git a/assets/docs/sources/index.md b/assets/docs/sources/index.md
index 38cb62dbd..e14840430 100644
--- a/assets/docs/sources/index.md
+++ b/assets/docs/sources/index.md
@@ -17,7 +17,7 @@
- [快速上手(GitHub README)](https://github.com/hect0x7/JMComic-Crawler-Python/tree/master?tab=readme-ov-file#%E5%BF%AB%E9%80%9F%E4%B8%8A%E6%89%8B)
- [常用类和方法演示](tutorial/0_common_usage.md)
-- [下载同时转 PDF/ZIP/长图](tutorial/13_export_and_feature.md)
+- [下载后转为 PDF / ZIP / 长图](tutorial/13_export_and_feature.md)
- [option配置以及插件写法](./option_file_syntax.md)
## 特殊用法教程
diff --git a/assets/docs/sources/option_file_syntax.md b/assets/docs/sources/option_file_syntax.md
index c5d0c3a5d..dc18945fa 100644
--- a/assets/docs/sources/option_file_syntax.md
+++ b/assets/docs/sources/option_file_syntax.md
@@ -236,15 +236,15 @@ plugins:
filename_rule: Atitle # 压缩文件的命名规则
# 请注意⚠ [https://github.com/hect0x7/JMComic-Crawler-Python/issues/223#issuecomment-2045227527]
# filename_rule和所在钩子有对应关系
- # 如果配置在 after_photo 下, filename_rule只能写 Pxxx
- # 如果配置在 after_album 下, filename_rule只能写 Axxx
+ # 如果配置在 after_photo 下, filename_rule 可以写 Pxxx 和Axxx
+ # 如果配置在 after_album 下, filename_rule 只能写 Axxx,不能写 Pxxx
# zip插件也支持dir_rule配置项,可以替代旧版本的zip_dir和filename_rule
# 请注意⚠ 使用此配置项会使filename_rule,zip_dir,suffix三个配置项无效,与这三个配置项同时存在时仅会使用dir_rule
# 示例如下:
# dir_rule: # 新配置项,可取代旧的zip_dir和filename_rule
- # base_dir: D:/jmcomic-zip
- # rule: 'Bd / {Atitle} / [{Pid}]-{Ptitle}.zip' # 设置压缩文件夹规则,中间Atitle表示创建一层文件夹,名称是本子标题。[{Pid}]-{Ptitle}.zip 表示压缩文件的命名规则(需显式写出后缀名)
+ # base_dir: D:/jmcomic-download/
+ # rule: 'Bd / zip / JM{Aid}-{Atitle}.zip' # 设置压缩文件夹规则,Bd指代base_dir,中间zip表示在{base_dir}下创建一个名为zip的文件夹,JM{Aid}-{Atitle}.zip 表示压缩文件的命名规则(需显式写出后缀名)
delete_original_file: true # 压缩成功后,删除所有原文件和文件夹
diff --git a/assets/docs/sources/tutorial/13_export_and_feature.md b/assets/docs/sources/tutorial/13_export_and_feature.md
index 9a5886340..e2fd54ea4 100644
--- a/assets/docs/sources/tutorial/13_export_and_feature.md
+++ b/assets/docs/sources/tutorial/13_export_and_feature.md
@@ -1,4 +1,4 @@
-# Feature 机制——下载附加行为
+# 教程:下载后转为 PDF / ZIP / 长图
## 1. 需求场景
@@ -37,17 +37,13 @@ download_album('123', extra=Feature.export_pdf)
download_album('123', option, extra=Feature.export_pdf)
```
-**效果**:在本子下载完以后,额外在**当前工作目录**下生成包含所有本子图片的 PDF 文件:
+**效果**:在本子下载完以后,默认在**下载根目录**下生成包含所有本子图片的 PDF 文件。如果你没有自定义过option,下载根目录就是你的工作目录(即你运行python脚本或cli的目录)。如果你配置过option,会放在dir_rule.base_dir下面。
```text
./
├── [JM123]本子标题.pdf ← 整本合并为 1 个 PDF,注意pdf文件名的格式,默认包含本子禁漫车号+本子标题
```
-> 💡 **小白提示:当前工作目录在哪?**
->
-> 默认情况下,文件会直接出现在你**运行 Python 脚本的那个文件夹里**。如果你不知道具体是哪,可以看后面的【自定义参数】章节,手动指定你想保存到的文件夹。
-
### 2.2 需要多种导出格式(PDF、ZIP等)——直接组合 Feature
用 `+` 号组合,同时导出多种格式:
@@ -61,7 +57,7 @@ download_album('123', option, extra=[Feature.export_pdf, Feature.export_zip])
download_album('123', option, extra=Feature.export_pdf | Feature.export_zip)
```
-效果同pdf,会在本子下载完以后,额外在当前工作目录下,生成包含所有本子图片的 PDF 文件和 ZIP 文件:
+效果同pdf,会在本子下载完以后,额外在对应的下载目录下,生成包含所有本子图片的 PDF 文件和 ZIP 文件:
```text
./
@@ -85,9 +81,10 @@ download_album('123', option, extra=Feature.export_pdf(
```
> 💡 **小白必读:命名规则(filename_rule)的小知识**
-> - `A` 开头的占位符(如 `Atitle`, `Aid`)代表 **Album (本子)**,适用于 `download_album`。
-> - `P` 开头的占位符(如 `Ptitle`, `Pid`)代表 **Photo (章节)**,适用于 `download_photo`。
-> - 如果在下载整本(Album)时强行使用章节级(Photo)的规则,程序会因为不知道该用哪一章的标题而报错。
+> - `A` 开头的占位符(如 `Atitle`, `Aid`)代表 **Album (本子)**。
+> - `P` 开头的占位符(如 `Ptitle`, `Pid`)代表 **Photo (章节)**。
+> - `download_photo` (下载单章)时,由于程序既知道当前章节,也知道它属于哪个本子,所以 **`Pxxx` 和 `Axxx` 都可以用**。
+> - `download_album` (下载整本)时,由于是按本子合并的,程序没有具体的“当前章节”,此时 **只能用 `Axxx`,不能用 `Pxxx`**,否则会报错。
```python
# 示例 2:全都要——ZIP 存盘 + 长图阅读
@@ -107,7 +104,7 @@ from jmcomic import download_photo, Feature
download_photo('456', option, extra=Feature.export_pdf)
```
-效果:在当前工作目录下生成以章节标题命名的 PDF:
+效果:在对应的下载目录下生成以章节标题命名的 PDF:
```text
./
@@ -122,12 +119,12 @@ download_photo('456', option, extra=Feature.export_pdf)
| 调用方式 | Feature.export_pdf | Feature.export_zip | Feature.export_long_img |
|-----------------|-------------------|-------------------|----------------------|
-| `download_album` | 整本合并为 1 个 PDF
`[本子标题].pdf` | 整本打包为 1 个 ZIP
`[本子标题].zip` | 所有章节合并为 1 张长图
`[本子ID].png` |
-| `download_photo` | 该章节导出为 PDF
`[章节标题].pdf` | 该章节打包为 ZIP
`[章节标题].zip` | 该章节拼接为长图
`[章节ID].png` |
+| `download_album` | 整本合并为 1 个 PDF
`[JM本子号]本子标题.pdf` | 整本打包为 1 个 ZIP
`[JM本子号]本子标题.zip` | 所有章节合并为 1 张长图
`[JM本子号]本子标题.png` |
+| `download_photo` | 该章节导出为 PDF
`[JM章节号]章节标题.pdf` | 该章节打包为 ZIP
`[JM章节号]章节标题.zip` | 该章节拼接为长图
`[JM章节号]章节标题.png` |
当你显式传入参数时(如 `filename_rule='Ptitle'`),**你的配置优先**,不会被自适应覆盖。
-> 💡 **提示**:更多可选参数(如加密密码 `encrypt`、后缀名 `suffix` 等),参考 [Plugin 插件参数大全](./6_plugin.md#参数)。
+> 💡 **提示**:更多可选参数(如加密密码 `encrypt`、后缀名 `suffix` 等),参考 [Plugin 插件参数大全](../option_file_syntax.md#3-option插件配置项)。
## 3. 传统写法(YAML 插件配置)
diff --git a/src/jmcomic/jm_config.py b/src/jmcomic/jm_config.py
index 39bd3aa80..cbe2565de 100644
--- a/src/jmcomic/jm_config.py
+++ b/src/jmcomic/jm_config.py
@@ -102,7 +102,7 @@ class JmMagicConstants:
APP_TOKEN_SECRET_2 = '18comicAPPContent'
APP_DATA_SECRET = '185Hcomic3PAPP7R'
API_DOMAIN_SERVER_SECRET = 'diosfjckwpqpdfjkvnqQjsik'
- APP_VERSION = '2.0.19'
+ APP_VERSION = '2.0.21'
# 模块级别共用配置
@@ -153,10 +153,11 @@ class JmModuleConfig:
# 移动端API域名
DOMAIN_API_LIST = shuffled('''
- www.cdnaspa.vip
- www.cdnaspa.club
- www.cdnplaystation6.vip
- www.cdnplaystation6.cc
+ www.cdnhjk.net
+ www.cdngwc.cc
+ www.cdngwc.net
+ www.cdngwc.club
+ www.cdnhjk.cc
''')
DOMAIN_API_UPDATED_LIST = None
diff --git a/src/jmcomic/jm_downloader.py b/src/jmcomic/jm_downloader.py
index fd9942577..5bf2ee4da 100644
--- a/src/jmcomic/jm_downloader.py
+++ b/src/jmcomic/jm_downloader.py
@@ -13,12 +13,12 @@ def wrapper(self, *args, **kwargs):
detail: JmBaseEntity = args[0]
if detail.is_image():
detail: JmImageDetail
- jm_log('image.failed', f'图片下载失败: [{detail.download_url}], 异常: [{e}]')
+ jm_log('image.failed', f'图片下载失败: [{detail.download_url}], 异常: [{e}]', e)
self.download_failed_image.append((detail, e))
elif detail.is_photo():
detail: JmPhotoDetail
- jm_log('photo.failed', f'章节下载失败: [{detail.id}], 异常: [{e}]')
+ jm_log('photo.failed', f'章节下载失败: [{detail.id}], 异常: [{e}]', e)
self.download_failed_photo.append((detail, e))
raise e
@@ -54,14 +54,14 @@ def after_photo(self, photo: JmPhotoDetail):
f'章节下载完成: [{photo.id}] ({photo.album_id}[{photo.index}/{len(photo.from_album)}])')
def before_image(self, image: JmImageDetail, img_save_path):
- if image.exists:
+ if image.exists and image.cache:
jm_log('image.before',
f'图片已存在: {image.tag} ← [{img_save_path}]'
)
- else:
- jm_log('image.before',
- f'图片准备下载: {image.tag}, [{image.img_url}] → [{img_save_path}]'
- )
+ return
+ jm_log('image.before',
+ f'图片准备下载: {image.tag}, [{image.img_url}] → [{img_save_path}]'
+ )
def after_image(self, image: JmImageDetail, img_save_path):
jm_log('image.after',
@@ -125,6 +125,7 @@ def download_by_image_detail(self, image: JmImageDetail):
image.save_path = img_save_path
image.exists = file_exists(img_save_path)
+ image.cache = self.option.decide_download_cache(image)
self.before_image(image, img_save_path)
@@ -132,11 +133,10 @@ def download_by_image_detail(self, image: JmImageDetail):
return
# let option decide use_cache and decode_image
- use_cache = self.option.decide_download_cache(image)
decode_image = self.option.decide_download_image_decode(image)
# skip download
- if use_cache is True and image.exists:
+ if image.cache and image.exists:
return
self.client.download_by_image_detail(
@@ -311,7 +311,8 @@ def _invoke_features_for(self, when: str, **kwargs):
try:
feature.invoke(self.option, feature_from=feature_from, when=when, **kwargs)
except Exception as e:
- jm_log('downloader.feature.exception', f'Feature执行失败: [{feature}], 来源: [{feature_from}], 异常: [{e}]')
+ jm_log('downloader.feature.exception', f'Feature执行失败: [{feature}], 来源: [{feature_from}], 异常: [{e}]',
+ e)
def raise_if_has_exception(self):
if not self.has_download_failures:
diff --git a/src/jmcomic/jm_entity.py b/src/jmcomic/jm_entity.py
index 8742a65a0..059bca2d0 100644
--- a/src/jmcomic/jm_entity.py
+++ b/src/jmcomic/jm_entity.py
@@ -11,6 +11,7 @@ def __init__(self):
self.save_path: str = ''
self.exists: bool = False
self.skip = False
+ self.cache = True
class JmBaseEntity:
@@ -125,17 +126,23 @@ def idoname(self):
return f'[{self.id}] {self.oname}'
def __str__(self):
- return f'''{self.__class__.__name__}({self.__alias__()}-{self.id}: "{self.title}")'''
+ return f'''{self.__class__.__name__}({self.alias_en()}-{self.id}: "{self.title}")'''
__repr__ = __str__
@classmethod
- def __alias__(cls):
+ def alias_en(cls):
# "JmAlbumDetail" -> "album" (本子)
# "JmPhotoDetail" -> "photo" (章节)
cls_name = cls.__name__
return cls_name[cls_name.index("m") + 1: cls_name.rfind("Detail")].lower()
+ @classmethod
+ def alias_cn(cls) -> str:
+ # "JmAlbumDetail" -> "album" (本子)
+ # "JmPhotoDetail" -> "photo" (章节)
+ return "本子" if issubclass(cls, JmAlbumDetail) else "章节"
+
@classmethod
def get_dirname(cls, detail: 'DetailEntity', ref: str) -> str:
"""
diff --git a/src/jmcomic/jm_feature.py b/src/jmcomic/jm_feature.py
index 3ed537567..9021d113e 100644
--- a/src/jmcomic/jm_feature.py
+++ b/src/jmcomic/jm_feature.py
@@ -107,7 +107,7 @@ def invoke(self, option: JmOption, feature_from: str, when: str, **extra):
ExceptionTool.require_true(pclass is not None, f'PluginFeature 引用了未注册的插件: {self.plugin_key}, from {feature_from}, when {when}')
# 根据 feature_from 动态适配参数
- plugin_kwargs: dict = self._adapt_plugin_kwargs(feature_from, when)
+ plugin_kwargs: dict = self._adapt_plugin_kwargs(option, feature_from, when)
option.invoke_plugin(
pclass=pclass,
@@ -116,13 +116,22 @@ def invoke(self, option: JmOption, feature_from: str, when: str, **extra):
pinfo={'plugin': self.plugin_key, 'kwargs': plugin_kwargs},
)
- def _adapt_plugin_kwargs(self, feature_from: str, when: str) -> dict:
+ def _adapt_plugin_kwargs(self, option: JmOption, feature_from: str, when: str) -> dict:
"""
根据feature_from和when动态确定以下插件参数:
filename_rule
"""
kwargs = self.kwargs.copy()
kwargs.setdefault('filename_rule', '[JM{Aid}]{Atitle}' if when == 'after_album' else '[JM{Pid}]{Ptitle}')
+
+ # 动态适配导出目录:当且仅当用户未自定义目录时,根据插件类型自动将 dir 导向 option.dir_rule.base_dir
+ if self.plugin_key == 'zip':
+ kwargs.setdefault('zip_dir', option.dir_rule.base_dir)
+ elif self.plugin_key == 'img2pdf':
+ kwargs.setdefault('pdf_dir', option.dir_rule.base_dir)
+ elif self.plugin_key == 'long_img':
+ kwargs.setdefault('img_dir', option.dir_rule.base_dir)
+
return kwargs
def __repr__(self):
@@ -159,6 +168,6 @@ def __repr__(self):
# 内置的 PluginFeature
-Feature.export_pdf = PluginFeature(Img2pdfPlugin.plugin_key, pdf_dir='./')
-Feature.export_zip = PluginFeature(ZipPlugin.plugin_key, zip_dir='./')
-Feature.export_long_img = PluginFeature(LongImgPlugin.plugin_key, img_dir='./')
+Feature.export_pdf = PluginFeature(Img2pdfPlugin.plugin_key)
+Feature.export_zip = PluginFeature(ZipPlugin.plugin_key)
+Feature.export_long_img = PluginFeature(LongImgPlugin.plugin_key)
diff --git a/src/jmcomic/jm_option.py b/src/jmcomic/jm_option.py
index 08098456e..13807748f 100644
--- a/src/jmcomic/jm_option.py
+++ b/src/jmcomic/jm_option.py
@@ -91,7 +91,7 @@ def apply_rule_to_path(self, album, photo, only_album_rules=False) -> str:
path = parser(album, photo, rule)
except BaseException as e:
# noinspection PyUnboundLocalVariable
- jm_log('dir_rule', f'路径规则"{rule}"的解析出错: {e}, album={album}, photo={photo}')
+ jm_log('dir_rule', f'路径规则"{rule}"的解析出错: {e}, album={album}, photo={photo}', e)
raise e
if parser != self.parse_bd_rule:
# 根据配置 normalize_zh 进行繁简体统一
@@ -580,19 +580,19 @@ def merge_default_dict(cls, user_dict, default_dict=None):
def download_album(self,
album_id,
- downloader=None,
- callback=None,
+ *args,
+ **kwargs,
):
from .api import download_album
- download_album(album_id, self, downloader, callback)
+ download_album(album_id, self, *args, **kwargs)
def download_photo(self,
photo_id,
- downloader=None,
- callback=None
+ *args,
+ **kwargs,
):
from .api import download_photo
- download_photo(photo_id, self, downloader, callback)
+ download_photo(photo_id, self, *args, **kwargs)
# 下面的方法为调用插件提供支持
@@ -684,13 +684,13 @@ def handle_plugin_valid_exception(self, e, pinfo: dict, kwargs: dict, _plugin, _
# noinspection PyMethodMayBeStatic,PyUnusedLocal
def handle_plugin_unexpected_error(self, e, pinfo: dict, kwargs: dict, _plugin, pclass):
msg = str(e)
- jm_log('plugin.error', f'插件 [{pclass.plugin_key}],运行遇到未捕获异常,异常信息: [{msg}]')
+ jm_log('plugin.error', f'插件 [{pclass.plugin_key}],运行遇到未捕获异常,异常信息: [{msg}]', e)
raise e
# noinspection PyMethodMayBeStatic,PyUnusedLocal
def handle_plugin_jmcomic_exception(self, e, pinfo: dict, kwargs: dict, _plugin, pclass):
msg = str(e)
- jm_log('plugin.exception', f'插件 [{pclass.plugin_key}] 调用失败,异常信息: [{msg}]')
+ jm_log('plugin.exception', f'插件 [{pclass.plugin_key}] 调用失败,异常信息: [{msg}]', e)
raise e
# noinspection PyMethodMayBeStatic
diff --git a/src/jmcomic/jm_plugin.py b/src/jmcomic/jm_plugin.py
index 6b50d2318..0373e762a 100644
--- a/src/jmcomic/jm_plugin.py
+++ b/src/jmcomic/jm_plugin.py
@@ -36,7 +36,7 @@ def build(cls, option: JmOption) -> 'JmOptionPlugin':
return cls(option)
def log(self, msg, topic=None):
- if self.log_enable:
+ if not self.log_enable:
return
jm_log(
@@ -136,7 +136,7 @@ def decide_filepath(self,
filepath = os.path.join(base_dir, DirRule.apply_rule_to_filename(album, photo, filename_rule) + fix_suffix(suffix))
mkdir_if_not_exists(base_dir)
- return filepath
+ return fix_filepath(filepath)
class JmLoginPlugin(JmOptionPlugin):
@@ -380,7 +380,9 @@ def zip_photo(self, photo, image_list: list, zip_path: str, path_to_delete, encr
relpath = os.path.relpath(abspath, photo_dir)
f.write(abspath, relpath)
- self.log(f'压缩章节[{photo.photo_id}]成功 → {zip_path}', 'finish')
+ # 打印结果
+ self.log(f'{photo.alias_cn()}压缩成功!'
+ f'[{photo}] → [{zip_path}]', 'finish')
path_to_delete.append(self.unified_path(photo_dir))
@staticmethod
@@ -403,7 +405,9 @@ def zip_album(self, album, photo_dict: dict, zip_path, path_to_delete, encrypt_d
abspath = os.path.join(photo_dir, file)
relpath = os.path.relpath(abspath, album_dir)
f.write(abspath, relpath)
- self.log(f'压缩本子[{album.album_id}]成功 → {zip_path}', 'finish')
+ # 打印结果
+ self.log(f'{album.alias_cn()}压缩成功!'
+ f'[{album}] → [{zip_path}]', 'finish')
def after_zip(self, path_to_delete: List[str]):
# 删除所有原文件
@@ -786,7 +790,13 @@ def invoke(self,
if not result:
return
img_path_ls, img_dir_ls = result
- self.log(f'Convert Successfully: JM{album or photo} → {pdf_filepath}')
+
+ # noinspection PyTypeChecker
+ detail: DetailEntity = album or photo
+
+ # 打印结果
+ self.log(f'{detail.alias_cn()}合并PDF成功!'
+ f'[{detail}] → [{pdf_filepath}]', 'finish')
# 执行删除
img_path_ls += img_dir_ls
@@ -863,7 +873,12 @@ def invoke(self,
img_path_ls = self.write_img_2_long_img(long_img_path, album, photo)
if not img_path_ls:
return
- self.log(f'Convert Successfully: JM{album or photo} → {long_img_path}')
+ # noinspection PyTypeChecker
+ detail: DetailEntity = album or photo
+
+ # 打印结果
+ self.log(f'{detail.alias_cn()}合并长图成功!'
+ f'[{detail}] → [{long_img_path}]', 'finish')
# 执行删除
self.execute_deletion(img_path_ls)
diff --git a/tests/test_jmcomic/test_jm_feature.py b/tests/test_jmcomic/test_jm_feature.py
index b8d970b50..ed8224d02 100644
--- a/tests/test_jmcomic/test_jm_feature.py
+++ b/tests/test_jmcomic/test_jm_feature.py
@@ -1,4 +1,4 @@
-from . import *
+from test_jmcomic import *
class Test_Feature(JmTestConfigurable):
@@ -65,27 +65,49 @@ def test_adapt_kwargs(self):
when = 'after_album'
pdf = Feature.export_pdf
- adapted = pdf._adapt_plugin_kwargs('download_album', when)
+ adapted = pdf._adapt_plugin_kwargs(self.option, 'download_album', when)
self.assertEqual(adapted['filename_rule'], '[JM{Aid}]{Atitle}')
zip_f = Feature.export_zip
- adapted = zip_f._adapt_plugin_kwargs('download_album', when)
+ adapted = zip_f._adapt_plugin_kwargs(self.option, 'download_album', when)
self.assertEqual(adapted['filename_rule'], '[JM{Aid}]{Atitle}')
long_img = Feature.export_long_img
- adapted = long_img._adapt_plugin_kwargs('download_album', when)
+ adapted = long_img._adapt_plugin_kwargs(self.option, 'download_album', when)
self.assertEqual(adapted['filename_rule'], '[JM{Aid}]{Atitle}')
# download_photo 模式
when = 'after_photo'
- adapted = pdf._adapt_plugin_kwargs('download_photo', when)
+ adapted = pdf._adapt_plugin_kwargs(self.option, 'download_photo', when)
self.assertEqual(adapted['filename_rule'], '[JM{Pid}]{Ptitle}')
# 用户显式传入的参数不被动态适配 (通过 kwargs 机制自带)
custom = Feature.export_zip(filename_rule='Ptitle')
- adapted = custom._adapt_plugin_kwargs('download_album', when)
+ adapted = custom._adapt_plugin_kwargs(self.option, 'download_album', when)
self.assertEqual(adapted['filename_rule'], 'Ptitle') # 用户显式指定,不被 setdefault 覆盖
+ def test_dynamic_base_dir(self):
+ """测试不指定导出目录时,自动适配为 option.dir_rule.base_dir"""
+ self.option.dir_rule.base_dir = './custom_base'
+ when = 'after_album'
+
+ # 1. PDF
+ adapted = Feature.export_pdf._adapt_plugin_kwargs(self.option, 'download_album', when)
+ self.assertEqual(adapted['pdf_dir'], './custom_base')
+
+ # 2. ZIP
+ adapted = Feature.export_zip._adapt_plugin_kwargs(self.option, 'download_album', when)
+ self.assertEqual(adapted['zip_dir'], './custom_base')
+
+ # 3. LongImg
+ adapted = Feature.export_long_img._adapt_plugin_kwargs(self.option, 'download_album', when)
+ self.assertEqual(adapted['img_dir'], './custom_base')
+
+ # 4. 如果显式指定了,则不应被覆盖
+ custom_pdf = Feature.export_pdf(pdf_dir='./explicit_dir')
+ adapted = custom_pdf._adapt_plugin_kwargs(self.option, 'download_album', when)
+ self.assertEqual(adapted['pdf_dir'], './explicit_dir')
+
def test_download_use_feature(self):
album_id = '438516'
From f6771b58f52b8765b1640ad6ab0bac4e9616e7dc Mon Sep 17 00:00:00 2001
From: hect0x7 <93357912+hect0x7@users.noreply.github.com>
Date: Fri, 8 May 2026 00:04:16 +0800
Subject: [PATCH 12/13] =?UTF-8?q?fix:=20=E8=A1=A5=E5=85=A8=20JmOption=20?=
=?UTF-8?q?=E9=9D=A2=E5=90=91=E5=AF=B9=E8=B1=A1=E5=B0=81=E8=A3=85=E6=96=B9?=
=?UTF-8?q?=E6=B3=95=E7=9A=84=E8=BF=94=E5=9B=9E=E5=80=BC?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/jmcomic/jm_option.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/jmcomic/jm_option.py b/src/jmcomic/jm_option.py
index 13807748f..c42456d5b 100644
--- a/src/jmcomic/jm_option.py
+++ b/src/jmcomic/jm_option.py
@@ -584,7 +584,7 @@ def download_album(self,
**kwargs,
):
from .api import download_album
- download_album(album_id, self, *args, **kwargs)
+ return download_album(album_id, self, *args, **kwargs)
def download_photo(self,
photo_id,
@@ -592,7 +592,7 @@ def download_photo(self,
**kwargs,
):
from .api import download_photo
- download_photo(photo_id, self, *args, **kwargs)
+ return download_photo(photo_id, self, *args, **kwargs)
# 下面的方法为调用插件提供支持
From d5217a520f3feddfce213e0d001908b83e7af548 Mon Sep 17 00:00:00 2001
From: hect0x7 <93357912+hect0x7@users.noreply.github.com>
Date: Fri, 8 May 2026 00:41:35 +0800
Subject: [PATCH 13/13] =?UTF-8?q?docs:=20=E4=BF=AE=E6=AD=A3=E5=AF=BC?=
=?UTF-8?q?=E5=87=BA=E7=B3=BB=E7=BB=9F=E6=96=87=E6=A1=A3=E7=A4=BA=E4=BE=8B?=
=?UTF-8?q?=E5=91=BD=E5=90=8D=20;=20test:=20=E4=BF=AE=E5=A4=8D=E6=B5=8B?=
=?UTF-8?q?=E8=AF=95=E7=94=A8=E4=BE=8B=E4=B8=AD=E7=9A=84=20unused=20variab?=
=?UTF-8?q?le=20=E8=AD=A6=E5=91=8A?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
assets/docs/sources/tutorial/13_export_and_feature.md | 2 +-
tests/test_jmcomic/test_jm_feature.py | 4 ++--
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/assets/docs/sources/tutorial/13_export_and_feature.md b/assets/docs/sources/tutorial/13_export_and_feature.md
index e2fd54ea4..c83618a9e 100644
--- a/assets/docs/sources/tutorial/13_export_and_feature.md
+++ b/assets/docs/sources/tutorial/13_export_and_feature.md
@@ -108,7 +108,7 @@ download_photo('456', option, extra=Feature.export_pdf)
```text
./
-├── [章节标题].pdf ← 该章节导出为 1 个 PDF
+├── [JM{Pid}]章节标题.pdf ← 该章节导出为 1 个 PDF
```
> 💡 **提示**:同一个 Feature,通过 `download_album` 和 `download_photo` 调用时会自动适配不同的导出行为,详见下方 [智能适配规则](#25-智能适配规则)。
diff --git a/tests/test_jmcomic/test_jm_feature.py b/tests/test_jmcomic/test_jm_feature.py
index ed8224d02..1a65743c6 100644
--- a/tests/test_jmcomic/test_jm_feature.py
+++ b/tests/test_jmcomic/test_jm_feature.py
@@ -150,7 +150,7 @@ def test_export_features(self):
# 组合下载并导出
combo = f_pdf + f_zip + f_long_img
- album, dler = jmcomic.download_album(album_id, self.option, extra=combo)
+ album, _dler = jmcomic.download_album(album_id, self.option, extra=combo)
# 验证文件是否精确生成
# 通过 download_album 注册,动态适配后默认规则均为:[JM{Aid}]{Atitle}
@@ -174,7 +174,7 @@ def test_export_features_photo(self):
# 测试单个章节的 PDF 导出
f_pdf = Feature.export_pdf(pdf_dir=export_dir)
- photo, dler = jmcomic.download_photo(photo_id, self.option, extra=f_pdf)
+ photo, _dler = jmcomic.download_photo(photo_id, self.option, extra=f_pdf)
# 验证文件是否按照 [JM{Pid}]{Ptitle} 规则生成
rule = '[JM{Pid}]{Ptitle}'