diff --git a/README.md b/README.md
index f7bb3ca2d..17fed7741 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..e14840430 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/option_file_syntax.md b/assets/docs/sources/option_file_syntax.md
index 42d4b8542..dc18945fa 100644
--- a/assets/docs/sources/option_file_syntax.md
+++ b/assets/docs/sources/option_file_syntax.md
@@ -223,29 +223,28 @@ plugins:
rule: '{Atitle}/{Aid}_cover.jpg'
- after_album:
+ after_album: # 钩子(插件被调用时机)
- plugin: zip # 压缩文件插件
kwargs:
- level: photo # 按照章节,一个章节一个压缩文件
- # level 也可以配成 album,表示一个本子对应一个压缩文件,该压缩文件会包含这个本子的所有章节
-
- 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
+ # 压缩文件插件,配在不同钩子下面,效果不一样。可以选择配在 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 和Axxx
+ # 如果配置在 after_album 下, filename_rule 只能写 Axxx,不能写 Pxxx
- # 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 表示压缩文件的命名规则(需显式写出后缀名)
- # 使用此方法指定压缩包存储路径则无需和level对应
+ # 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
new file mode 100644
index 000000000..c83618a9e
--- /dev/null
+++ b/assets/docs/sources/tutorial/13_export_and_feature.md
@@ -0,0 +1,211 @@
+# 教程:下载后转为 PDF / ZIP / 长图
+
+## 1. 需求场景
+
+下载本子后,很多用户有进一步导出的需求:
+- 导出为 **PDF**:方便在电子阅读器上查看
+- 导出为 **ZIP**:方便传输和存档
+- 合并为 **长图**:方便一张图看完整个章节
+
+jmcomic 内置了三个开箱即用的导出 Feature,对应这三种需求:
+
+| Feature | 效果 |
+|---------|------|
+| `Feature.export_pdf` | 下载完自动导出为 PDF |
+| `Feature.export_zip` | 下载完自动打包为 ZIP |
+| `Feature.export_long_img` | 下载完自动拼接为长图 PNG |
+
+
+> 也许你知道,这些功能之前是以插件形式 (JmOptionPlugin) 存在的。
+>
+> 是的,传统方式需要在 option 配置文件中编写插件配置,门槛偏高。
+>
+> 因此,从v2.6.19起,jmcomic 引入了上述的 **Feature** 机制,尽可能简化这些最常用的功能,让小白也能用一行代码搞定导出。
+
+
+## 2. 快速上手
+
+### 2.1 导出 PDF——基本用法示例
+
+```python
+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 文件。如果你没有自定义过option,下载根目录就是你的工作目录(即你运行python脚本或cli的目录)。如果你配置过option,会放在dir_rule.base_dir下面。
+
+```text
+./
+├── [JM123]本子标题.pdf ← 整本合并为 1 个 PDF,注意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])
+download_album('123', option, extra=Feature.export_pdf | Feature.export_zip)
+```
+
+效果同pdf,会在本子下载完以后,额外在对应的下载目录下,生成包含所有本子图片的 PDF 文件和 ZIP 文件:
+
+```text
+./
+├── [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='Atitle', # 用本子标题作为文件名
+ delete_original_file=True, # 合并完 PDF 后删除原图
+))
+```
+
+> 💡 **小白必读:命名规则(filename_rule)的小知识**
+> - `A` 开头的占位符(如 `Atitle`, `Aid`)代表 **Album (本子)**。
+> - `P` 开头的占位符(如 `Ptitle`, `Pid`)代表 **Photo (章节)**。
+> - `download_photo` (下载单章)时,由于程序既知道当前章节,也知道它属于哪个本子,所以 **`Pxxx` 和 `Axxx` 都可以用**。
+> - `download_album` (下载整本)时,由于是按本子合并的,程序没有具体的“当前章节”,此时 **只能用 `Axxx`,不能用 `Pxxx`**,否则会报错。
+
+```python
+# 示例 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:
+
+```text
+./
+├── [JM{Pid}]章节标题.pdf ← 该章节导出为 1 个 PDF
+```
+
+> 💡 **提示**:同一个 Feature,通过 `download_album` 和 `download_photo` 调用时会自动适配不同的导出行为,详见下方 [智能适配规则](#25-智能适配规则)。
+
+### 2.5 智能适配规则
+
+内置的导出 Feature 会根据调用的 API **自动适配**参数:
+
+| 调用方式 | Feature.export_pdf | Feature.export_zip | Feature.export_long_img |
+|-----------------|-------------------|-------------------|----------------------|
+| `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 插件参数大全](../option_file_syntax.md#3-option插件配置项)。
+
+## 3. 传统写法(YAML 插件配置)
+
+如果你更习惯配置文件,仍然可以使用传统的插件配置方式:
+
+```yaml
+# option.yml
+plugins:
+ after_album: # 整本下载完以后
+ - plugin: img2pdf # 合并pdf
+ kwargs:
+ pdf_dir: ./output
+ filename_rule: Atitle
+ - plugin: zip # 合并为压缩文件
+ kwargs:
+ level: album
+ zip_dir: ./output
+```
+
+传统写法的更多细节见 → [Plugin 插件教程](./6_plugin.md)
+
+## 4. Feature 架构设计
+
+### 类层次
+
+```text
+Feature (基类)
+ ├── PluginFeature ← 封装插件调用,参数根据来源自适应
+ └── 你的自定义 Feature ← 继承 Feature,实现任意逻辑
+```
+
+- **Feature 基类**:通用的附加行为抽象,不绑定任何具体实现。默认在所有生命周期钩子中执行。
+- **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')]
+ │
+ └→ dler.download_album(id)
+ │
+ ├→ before_album(album)
+ │
+ ├→ download_by_photo_detail(photo)
+ │ ├→ before_photo(photo)
+ │ ├→ download jmcomic 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_plugin_kwargs(from, when) # 动态生成插件参数
+ └→ option.invoke(pdf, kwargs) # 调用pdf插件,传入参数
+```
+
+> 💡 **关键点**:
+>
+> - **执行时机**:`PluginFeature` 根据注册来源自动推导(`download_album` → `after_album`,`download_photo` → `after_photo`)。自定义 Feature 默认在所有钩子都会执行,你可以覆写 `should_invoke` 来控制。
+> - **参数自适应**:`PluginFeature` 的 `filename_rule` 前缀(A/P)会根据来源动态适配。ZIP 的打包粒度由插件根据上下文自动推导。用户显式传入的参数不会被覆盖。
+
+### 自定义 Feature
+
+Feature 基类完全不绑定插件,你可以实现任意逻辑,欢迎贡献你的feature到本项目中:
+
+```python
+from jmcomic import Feature, download_album
+
+class NotifyFeature(Feature):
+ """下载完成后发送通知"""
+ def invoke(self, option, **kwargs):
+ album = kwargs.get('album')
+ if album:
+ print(f'下载完成通知: {album.name}')
+
+# 使用
+download_album('123', option, extra=NotifyFeature())
+```
+
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/__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..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
)
@@ -49,6 +51,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,13 +63,16 @@ def download_album(jm_album_id,
:param downloader: 下载器类
:param callback: 返回值回调函数,可以拿到 album 和 downloader
:param check_exception: 是否检查异常, 如果为True,会检查downloader是否有下载异常,并上抛PartialDownloadFailedException
+ :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 钩子中自动执行
+ dler.add_features(extra, 'download_album')
album = dler.download_album(jm_album_id)
if callback is not None:
@@ -81,14 +87,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, extra=extra)
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_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 9a1c8c257..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
@@ -551,3 +552,59 @@ 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
+
+ # Windows 需要启用 VT100 ANSI 支持
+ if sys.platform == 'win32':
+ 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)
+ 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..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',
@@ -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[Tuple] = []
def download_album(self, album_id):
album = self.client.get_album_detail(album_id)
@@ -123,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)
@@ -130,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(
@@ -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,45 @@ 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, Feature
+ from .jm_toolkit import ExceptionTool
+
+ if isinstance(features, list):
+ for f in features:
+ self.add_features(f, feature_from)
+ elif isinstance(features, FeatureChain):
+ for f in features.to_list():
+ self._feature_list.append((f, feature_from))
+ 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, **kwargs):
+ """
+ 在指定钩子(when)中触发匹配的 Feature。
+
+ :param when: 当前钩子名,如 'after_album', 'after_photo'
+ :param kwargs: album, photo, downloader 等上下文
+ """
+ for feature, feature_from in self._feature_list:
+ if feature.should_invoke(feature_from, when):
+ 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}]',
+ e)
+
def raise_if_has_exception(self):
if not self.has_download_failures:
return
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_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
new file mode 100644
index 000000000..9021d113e
--- /dev/null
+++ b/src/jmcomic/jm_feature.py
@@ -0,0 +1,173 @@
+"""
+该文件存放的是 Feature 机制
+
+Feature 用于封装复杂、高级的功能特性,例如pdf导出插件,以前用户需要知道插件名称,调用时机,option插件参数等等,使用feature相当于包办了这些。
+
+用法:
+ 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, feature_from: str, when: str) -> bool:
+ """
+ 判断在当前钩子(when)下,根据来源(feature_from),是否应该执行。
+ 默认返回 True(任何钩子都执行)。子类可覆写来限制执行时机。
+
+ :param feature_from: Feature 的注册来源,如 'download_album', 'download_photo'
+ :param when: 当前触发的钩子名称,如 'after_album', 'after_photo'
+ :returns: 是否应该执行
+ """
+ return True
+
+ def invoke(self, option: JmOption, feature_from: str, when: str, **kwargs):
+ """
+ 执行此 Feature。子类需实现该方法。
+
+ :param option: 当前的 JmOption
+ :param feature_from 注册来源,如 'download_album', 'download_photo'
+ :param when: 钩子回调时机,如 'after_album', 'after_photo'
+ :param kwargs: album, photo, downloader 等回调参数
+ """
+ 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 = dict(kwargs)
+
+ def should_invoke(self, feature_from: str, when: 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 = type(self)(self.plugin_key, **new_kwargs)
+ return new_instance
+
+ def invoke(self, option: JmOption, feature_from: str, when: str, **extra):
+ """
+ 执行此 Feature 对应的插件。
+ 根据 feature_from 动态适配 filename_rule 等参数。
+ """
+ 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 动态适配参数
+ plugin_kwargs: dict = self._adapt_plugin_kwargs(option, feature_from, when)
+
+ option.invoke_plugin(
+ pclass=pclass,
+ kwargs=plugin_kwargs,
+ extra=extra,
+ pinfo={'plugin': self.plugin_key, 'kwargs': plugin_kwargs},
+ )
+
+ 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):
+ 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})'
+
+
+# 内置的 PluginFeature
+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 edff61d55..c42456d5b 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 进行繁简体统一
@@ -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)
@@ -328,6 +330,78 @@ 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。
+ """
+
+ 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):
+ 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)
+ 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',
+ '[zip 插件迁移] level 参数已过时,你可以直接删除该参数,不会有任何影响')
+ i += 1
+
def deconstruct(self) -> Dict:
return {
'version': JmModuleConfig.JM_OPTION_VER,
@@ -506,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)
+ return 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)
+ return download_photo(photo_id, self, *args, **kwargs)
# 下面的方法为调用插件提供支持
@@ -547,7 +621,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)
@@ -610,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 8da3e1cbb..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):
@@ -158,7 +158,6 @@ def invoke(self,
cookies = dict(client['cookies'])
self.option.update_cookies(cookies)
- JmModuleConfig.APP_COOKIES = cookies
self.log('登录成功')
@@ -321,7 +320,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 +331,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
@@ -378,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
@@ -401,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]):
# 删除所有原文件
@@ -784,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
@@ -861,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/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:
diff --git a/tests/test_jmcomic/test_jm_feature.py b/tests/test_jmcomic/test_jm_feature.py
new file mode 100644
index 000000000..1a65743c6
--- /dev/null
+++ b/tests/test_jmcomic/test_jm_feature.py
@@ -0,0 +1,200 @@
+from test_jmcomic 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, **kwargs):
+ 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, **kwargs):
+ pass
+
+ base = MyFeature()
+ 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('download_album', 'after_album'))
+ self.assertFalse(pf.should_invoke('download_album', 'after_photo'))
+ # download_photo → 只在 after_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 参数动态适配"""
+ when = 'after_album'
+
+ pdf = Feature.export_pdf
+ 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(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(self.option, 'download_album', when)
+ self.assertEqual(adapted['filename_rule'], '[JM{Aid}]{Atitle}')
+
+ # download_photo 模式
+ when = 'after_photo'
+ 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(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'
+
+ # 记录被执行的次数,便于断言
+ custom_feature_call_count = 0
+
+ class MyCounterFeature(Feature):
+ def invoke(self, option, **kwargs):
+ 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)
+
+ # 测试 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'
+
+ # 直接使用测试环境配置的下载目录
+ 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 注册,动态适配后默认规则均为:[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)
+ 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}")
+
+ 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}")
+
+ 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)