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}'