diff --git a/README.md b/README.md index f7bb3ca2d..17fed7741 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ > **🧭 快速指路** > - [教程:使用 GitHub Actions 下载禁漫本子](./assets/docs/sources/tutorial/1_github_actions.md) > - [教程:导出并下载你的禁漫收藏夹数据](./assets/docs/sources/tutorial/10_export_favorites.md) +> - [教程:下载后转为 PDF / ZIP / 长图](./assets/docs/sources/tutorial/13_export_and_feature.md) > - [塔台广播:欢迎各位机长加入并贡献代码](./.github/CONTRIBUTING.md) > > **友情提示:珍爱JM,为了减轻JM的服务器压力,请不要一次性爬取太多本子,西门🙏🙏🙏**. diff --git a/assets/docs/mkdocs.yml b/assets/docs/mkdocs.yml index 27c716af5..10c7675b6 100644 --- a/assets/docs/mkdocs.yml +++ b/assets/docs/mkdocs.yml @@ -58,6 +58,7 @@ nav: - tutorial/10_export_favorites.md - tutorial/11_log_custom.md - tutorial/12_domain_strategy.md + - tutorial/13_export_and_feature.md plugins: - search diff --git a/assets/docs/sources/index.md b/assets/docs/sources/index.md index 16783b1d1..e14840430 100644 --- a/assets/docs/sources/index.md +++ b/assets/docs/sources/index.md @@ -17,6 +17,7 @@ - [快速上手(GitHub README)](https://github.com/hect0x7/JMComic-Crawler-Python/tree/master?tab=readme-ov-file#%E5%BF%AB%E9%80%9F%E4%B8%8A%E6%89%8B) - [常用类和方法演示](tutorial/0_common_usage.md) +- [下载后转为 PDF / ZIP / 长图](tutorial/13_export_and_feature.md) - [option配置以及插件写法](./option_file_syntax.md) ## 特殊用法教程 @@ -30,6 +31,7 @@ - [下载过滤器机制](tutorial/5_filter.md) - [插件机制](tutorial/6_plugin.md) +- [Feature机制](tutorial/13_export_and_feature.md) ## 自定义 diff --git a/assets/docs/sources/option_file_syntax.md b/assets/docs/sources/option_file_syntax.md index 42d4b8542..dc18945fa 100644 --- a/assets/docs/sources/option_file_syntax.md +++ b/assets/docs/sources/option_file_syntax.md @@ -223,29 +223,28 @@ plugins: rule: '{Atitle}/{Aid}_cover.jpg' - after_album: + after_album: # 钩子(插件被调用时机) - plugin: zip # 压缩文件插件 kwargs: - level: photo # 按照章节,一个章节一个压缩文件 - # level 也可以配成 album,表示一个本子对应一个压缩文件,该压缩文件会包含这个本子的所有章节 - - filename_rule: Ptitle # 压缩文件的命名规则 - # 请注意⚠ [https://github.com/hect0x7/JMComic-Crawler-Python/issues/223#issuecomment-2045227527] - # filename_rule和level有对应关系 - # 如果level=[photo], filename_rule只能写Pxxx - # 如果level=[album], filename_rule只能写Axxx + # 压缩文件插件,配在不同钩子下面,效果不一样。可以选择配在 after_album 或者 after_photo 下 + # 配置在 after_album 下 → 整个本子合并为一个压缩文件 + # 配置在 after_photo 下 → 每个章节各一个压缩文件 + # (旧的 level 配置已废弃,如果你配置过level,比如level=photo, 请直接改用after_photo) zip_dir: D:/jmcomic/zip/ # 压缩文件存放的文件夹 - suffix: zip #压缩包后缀名,默认值为zip,可以指定为zip或者7z + filename_rule: Atitle # 压缩文件的命名规则 + # 请注意⚠ [https://github.com/hect0x7/JMComic-Crawler-Python/issues/223#issuecomment-2045227527] + # filename_rule和所在钩子有对应关系 + # 如果配置在 after_photo 下, filename_rule 可以写 Pxxx 和Axxx + # 如果配置在 after_album 下, filename_rule 只能写 Axxx,不能写 Pxxx - # v2.6.0 以后,zip插件也支持dir_rule配置项,可以替代旧版本的zip_dir和filename_rule + # zip插件也支持dir_rule配置项,可以替代旧版本的zip_dir和filename_rule # 请注意⚠ 使用此配置项会使filename_rule,zip_dir,suffix三个配置项无效,与这三个配置项同时存在时仅会使用dir_rule # 示例如下: # dir_rule: # 新配置项,可取代旧的zip_dir和filename_rule - # base_dir: D:/jmcomic-zip - # rule: 'Bd / {Atitle} / [{Pid}]-{Ptitle}.zip' # 设置压缩文件夹规则,中间Atitle表示创建一层文件夹,名称是本子标题。[{Pid}]-{Ptitle}.zip 表示压缩文件的命名规则(需显式写出后缀名) - # 使用此方法指定压缩包存储路径则无需和level对应 + # base_dir: D:/jmcomic-download/ + # rule: 'Bd / zip / JM{Aid}-{Atitle}.zip' # 设置压缩文件夹规则,Bd指代base_dir,中间zip表示在{base_dir}下创建一个名为zip的文件夹,JM{Aid}-{Atitle}.zip 表示压缩文件的命名规则(需显式写出后缀名) delete_original_file: true # 压缩成功后,删除所有原文件和文件夹 diff --git a/assets/docs/sources/tutorial/13_export_and_feature.md b/assets/docs/sources/tutorial/13_export_and_feature.md new file mode 100644 index 000000000..c83618a9e --- /dev/null +++ b/assets/docs/sources/tutorial/13_export_and_feature.md @@ -0,0 +1,211 @@ +# 教程:下载后转为 PDF / ZIP / 长图 + +## 1. 需求场景 + +下载本子后,很多用户有进一步导出的需求: +- 导出为 **PDF**:方便在电子阅读器上查看 +- 导出为 **ZIP**:方便传输和存档 +- 合并为 **长图**:方便一张图看完整个章节 + +jmcomic 内置了三个开箱即用的导出 Feature,对应这三种需求: + +| Feature | 效果 | +|---------|------| +| `Feature.export_pdf` | 下载完自动导出为 PDF | +| `Feature.export_zip` | 下载完自动打包为 ZIP | +| `Feature.export_long_img` | 下载完自动拼接为长图 PNG | + + +> 也许你知道,这些功能之前是以插件形式 (JmOptionPlugin) 存在的。 +> +> 是的,传统方式需要在 option 配置文件中编写插件配置,门槛偏高。 +> +> 因此,从v2.6.19起,jmcomic 引入了上述的 **Feature** 机制,尽可能简化这些最常用的功能,让小白也能用一行代码搞定导出。 + + +## 2. 快速上手 + +### 2.1 导出 PDF——基本用法示例 + +```python +from jmcomic import download_album, Feature + +# 只需要加一个 extra 参数,就能在下载完成后自动导出 PDF +download_album('123', extra=Feature.export_pdf) + +# 如果要传 option 参数,就是如下写法,三个参数 +download_album('123', option, extra=Feature.export_pdf) +``` + +**效果**:在本子下载完以后,默认在**下载根目录**下生成包含所有本子图片的 PDF 文件。如果你没有自定义过option,下载根目录就是你的工作目录(即你运行python脚本或cli的目录)。如果你配置过option,会放在dir_rule.base_dir下面。 + +```text +./ +├── [JM123]本子标题.pdf ← 整本合并为 1 个 PDF,注意pdf文件名的格式,默认包含本子禁漫车号+本子标题 +``` + +### 2.2 需要多种导出格式(PDF、ZIP等)——直接组合 Feature + +用 `+` 号组合,同时导出多种格式: + +```python +# 下载完后同时导出 PDF 和 ZIP +download_album('123', option, extra=Feature.export_pdf + Feature.export_zip) + +# 也支持列表语法,|语法 +download_album('123', option, extra=[Feature.export_pdf, Feature.export_zip]) +download_album('123', option, extra=Feature.export_pdf | Feature.export_zip) +``` + +效果同pdf,会在本子下载完以后,额外在对应的下载目录下,生成包含所有本子图片的 PDF 文件和 ZIP 文件: + +```text +./ +├── [JM123]本子标题.pdf ← 整本合并为 1 个 PDF +├── [JM123]本子标题.zip ← 整本合并为 1 个 zip 压缩包 +``` + + +### 2.3 自定义参数 + +如果你了解插件配置,可以同样使用Feature传递插件的自定义参数,例如改变输出目录、命名规则等: + +```python +# 示例 1:指定输出目录和命名规则 +download_album('123', option, extra=Feature.export_pdf( + # 下面是自定义参数 + pdf_dir='D:/my_pdfs', # PDF 保存到 D:/my_pdfs 文件夹 + filename_rule='Atitle', # 用本子标题作为文件名 + delete_original_file=True, # 合并完 PDF 后删除原图 +)) +``` + +> 💡 **小白必读:命名规则(filename_rule)的小知识** +> - `A` 开头的占位符(如 `Atitle`, `Aid`)代表 **Album (本子)**。 +> - `P` 开头的占位符(如 `Ptitle`, `Pid`)代表 **Photo (章节)**。 +> - `download_photo` (下载单章)时,由于程序既知道当前章节,也知道它属于哪个本子,所以 **`Pxxx` 和 `Axxx` 都可以用**。 +> - `download_album` (下载整本)时,由于是按本子合并的,程序没有具体的“当前章节”,此时 **只能用 `Axxx`,不能用 `Pxxx`**,否则会报错。 + +```python +# 示例 2:全都要——ZIP 存盘 + 长图阅读 +combo = ( + Feature.export_zip(zip_dir='D:/zips') + + Feature.export_long_img(img_dir='D:/long_imgs') +) +download_album('123', option, extra=combo) +``` + +### 2.4 download_photo 也支持 + +```python +from jmcomic import download_photo, Feature + +# 对单个章节导出 +download_photo('456', option, extra=Feature.export_pdf) +``` + +效果:在对应的下载目录下生成以章节标题命名的 PDF: + +```text +./ +├── [JM{Pid}]章节标题.pdf ← 该章节导出为 1 个 PDF +``` + +> 💡 **提示**:同一个 Feature,通过 `download_album` 和 `download_photo` 调用时会自动适配不同的导出行为,详见下方 [智能适配规则](#25-智能适配规则)。 + +### 2.5 智能适配规则 + +内置的导出 Feature 会根据调用的 API **自动适配**参数: + +| 调用方式 | Feature.export_pdf | Feature.export_zip | Feature.export_long_img | +|-----------------|-------------------|-------------------|----------------------| +| `download_album` | 整本合并为 1 个 PDF
`[JM本子号]本子标题.pdf` | 整本打包为 1 个 ZIP
`[JM本子号]本子标题.zip` | 所有章节合并为 1 张长图
`[JM本子号]本子标题.png` | +| `download_photo` | 该章节导出为 PDF
`[JM章节号]章节标题.pdf` | 该章节打包为 ZIP
`[JM章节号]章节标题.zip` | 该章节拼接为长图
`[JM章节号]章节标题.png` | + +当你显式传入参数时(如 `filename_rule='Ptitle'`),**你的配置优先**,不会被自适应覆盖。 + +> 💡 **提示**:更多可选参数(如加密密码 `encrypt`、后缀名 `suffix` 等),参考 [Plugin 插件参数大全](../option_file_syntax.md#3-option插件配置项)。 + +## 3. 传统写法(YAML 插件配置) + +如果你更习惯配置文件,仍然可以使用传统的插件配置方式: + +```yaml +# option.yml +plugins: + after_album: # 整本下载完以后 + - plugin: img2pdf # 合并pdf + kwargs: + pdf_dir: ./output + filename_rule: Atitle + - plugin: zip # 合并为压缩文件 + kwargs: + level: album + zip_dir: ./output +``` + +传统写法的更多细节见 → [Plugin 插件教程](./6_plugin.md) + +## 4. Feature 架构设计 + +### 类层次 + +```text +Feature (基类) + ├── PluginFeature ← 封装插件调用,参数根据来源自适应 + └── 你的自定义 Feature ← 继承 Feature,实现任意逻辑 +``` + +- **Feature 基类**:通用的附加行为抽象,不绑定任何具体实现。默认在所有生命周期钩子中执行。 +- **PluginFeature**:Feature 的子类,专门封装 jmcomic 插件。除了调用插件之外,还会根据调用来源动态适配 `filename_rule` 参数;ZIP 的打包粒度则由插件在运行时根据上下文自动推导。 + +### 执行流程 + +Feature **自然嵌入到 downloader 的生命周期钩子**中自动触发: + +```text +api.download_album(extra=Feature.export_pdf) + │ + ├→ dler.add_features(pdf, 'download_album') # 注册: [(pdf, 'download_album')] + │ + └→ dler.download_album(id) + │ + ├→ before_album(album) + │ + ├→ download_by_photo_detail(photo) + │ ├→ before_photo(photo) + │ ├→ download jmcomic images ... # 下载禁漫图片 + │ └→ after_photo(photo) + │ └→ _invoke_features_for('after_photo') + │ └→ pdf.should_invoke('after_photo', 'download_album') → False ✗ 跳过 + │ + └→ after_album(album) + └→ _invoke_features_for('after_album') + └→ pdf.should_invoke('after_album', 'download_album') → True ✓ 执行! + └→ _adapt_plugin_kwargs(from, when) # 动态生成插件参数 + └→ option.invoke(pdf, kwargs) # 调用pdf插件,传入参数 +``` + +> 💡 **关键点**: +> +> - **执行时机**:`PluginFeature` 根据注册来源自动推导(`download_album` → `after_album`,`download_photo` → `after_photo`)。自定义 Feature 默认在所有钩子都会执行,你可以覆写 `should_invoke` 来控制。 +> - **参数自适应**:`PluginFeature` 的 `filename_rule` 前缀(A/P)会根据来源动态适配。ZIP 的打包粒度由插件根据上下文自动推导。用户显式传入的参数不会被覆盖。 + +### 自定义 Feature + +Feature 基类完全不绑定插件,你可以实现任意逻辑,欢迎贡献你的feature到本项目中: + +```python +from jmcomic import Feature, download_album + +class NotifyFeature(Feature): + """下载完成后发送通知""" + def invoke(self, option, **kwargs): + album = kwargs.get('album') + if album: + print(f'下载完成通知: {album.name}') + +# 使用 +download_album('123', option, extra=NotifyFeature()) +``` + diff --git a/assets/option/option_workflow_download.yml b/assets/option/option_workflow_download.yml index 4a201b5a7..968d32fee 100644 --- a/assets/option/option_workflow_download.yml +++ b/assets/option/option_workflow_download.yml @@ -1,4 +1,6 @@ # GitHub Actions 下载脚本配置 +log: pretty + dir_rule: base_dir: ${JM_DOWNLOAD_DIR} rule: Bd_Aauthor_Atitle_Pindex diff --git a/src/jmcomic/__init__.py b/src/jmcomic/__init__.py index 9698ba5fc..9811d780b 100644 --- a/src/jmcomic/__init__.py +++ b/src/jmcomic/__init__.py @@ -2,10 +2,11 @@ # 被依赖方 <--- 使用方 # config <--- entity <--- toolkit <--- client <--- option <--- downloader -__version__ = '2.6.18' +__version__ = '2.6.19' from .api import * from .jm_plugin import * +from .jm_feature import * # 下面进行注册组件(客户端、插件) gb = dict(filter(lambda pair: isinstance(pair[1], type), globals().items())) diff --git a/src/jmcomic/api.py b/src/jmcomic/api.py index 47141450b..24d722d17 100644 --- a/src/jmcomic/api.py +++ b/src/jmcomic/api.py @@ -7,6 +7,7 @@ def download_batch(download_api, jm_id_iter: Union[Iterable, Generator], option=None, downloader=None, + **kwargs, ) -> Set[__DOWNLOAD_API_RET]: """ 批量下载 album / photo @@ -37,6 +38,7 @@ def callback(*ret): option, downloader, callback=callback, + **kwargs, ), wait_finish=True ) @@ -49,6 +51,7 @@ def download_album(jm_album_id, downloader=None, callback=None, check_exception=True, + extra=None, ) -> Union[__DOWNLOAD_API_RET, Set[__DOWNLOAD_API_RET]]: """ 下载一个本子(album),包含其所有的章节(photo) @@ -60,13 +63,16 @@ def download_album(jm_album_id, :param downloader: 下载器类 :param callback: 返回值回调函数,可以拿到 album 和 downloader :param check_exception: 是否检查异常, 如果为True,会检查downloader是否有下载异常,并上抛PartialDownloadFailedException + :param extra: 下载特性(Feature),下载时动态挂载的附加行为上下文。会自动根据上下文(如 album/photo 来源)自适应参数行为。支持单个 Feature、FeatureChain、或列表 :return: 对于的本子实体类,下载器(如果是上述的批量情况,返回值为download_batch的返回值) """ if not isinstance(jm_album_id, (str, int)): - return download_batch(download_album, jm_album_id, option, downloader) + return download_batch(download_album, jm_album_id, option, downloader, extra=extra) with new_downloader(option, downloader) as dler: + # 注册 Feature 及来源,由 downloader 在 after_album 钩子中自动执行 + dler.add_features(extra, 'download_album') album = dler.download_album(jm_album_id) if callback is not None: @@ -81,14 +87,17 @@ def download_photo(jm_photo_id, downloader=None, callback=None, check_exception=True, + extra=None, ): """ 下载一个章节(photo),参数同 download_album """ if not isinstance(jm_photo_id, (str, int)): - return download_batch(download_photo, jm_photo_id, option) + return download_batch(download_photo, jm_photo_id, option, downloader, extra=extra) with new_downloader(option, downloader) as dler: + # 注册 Feature 及来源,由 downloader 在 after_photo 钩子中自动执行 + dler.add_features(extra, 'download_photo') photo = dler.download_photo(jm_photo_id) if callback is not None: diff --git a/src/jmcomic/jm_client_impl.py b/src/jmcomic/jm_client_impl.py index bf0b5a500..76b61f9dd 100644 --- a/src/jmcomic/jm_client_impl.py +++ b/src/jmcomic/jm_client_impl.py @@ -280,9 +280,6 @@ def get_photo_detail(self, photo = self.fetch_detail_entity(photo_id, 'photo') # 一并获取该章节的所处本子 - # todo: 可优化,获取章节所在本子,其实不需要等待章节获取完毕后。 - # 可以直接调用 self.get_album_detail(photo_id),会重定向返回本子的HTML - # (had polished by FutureClientProxy) if fetch_album is True: photo.from_album = self.get_album_detail(photo.album_id) @@ -1205,4 +1202,4 @@ def get_photo_detail(self, photo_id, fetch_album=True, fetch_scramble_id=True) - if scramble_id != '': photo.scramble_id = scramble_id - return photo \ No newline at end of file + return photo diff --git a/src/jmcomic/jm_config.py b/src/jmcomic/jm_config.py index 9a1c8c257..cbe2565de 100644 --- a/src/jmcomic/jm_config.py +++ b/src/jmcomic/jm_config.py @@ -102,7 +102,7 @@ class JmMagicConstants: APP_TOKEN_SECRET_2 = '18comicAPPContent' APP_DATA_SECRET = '185Hcomic3PAPP7R' API_DOMAIN_SERVER_SECRET = 'diosfjckwpqpdfjkvnqQjsik' - APP_VERSION = '2.0.19' + APP_VERSION = '2.0.21' # 模块级别共用配置 @@ -153,10 +153,11 @@ class JmModuleConfig: # 移动端API域名 DOMAIN_API_LIST = shuffled(''' - www.cdnaspa.vip - www.cdnaspa.club - www.cdnplaystation6.vip - www.cdnplaystation6.cc + www.cdnhjk.net + www.cdngwc.cc + www.cdngwc.net + www.cdngwc.club + www.cdnhjk.cc ''') DOMAIN_API_UPDATED_LIST = None @@ -551,3 +552,59 @@ def register_exception_listener(cls, etype, listener): jm_log = JmModuleConfig.jm_log disable_jm_log = JmModuleConfig.disable_jm_log + + +class PrettyFormatter(logging.Formatter): + """带 ANSI 颜色的日志格式化器,按 topic 前缀分配颜色""" + + TOPIC_COLORS = { + 'album': '\033[1;36m', # 青色加粗 — 本子级别 + 'photo': '\033[36m', # 青色 — 章节级别 + 'image': '\033[2;37m', # 暗灰 — 图片级别(弱化) + 'plugin': '\033[35m', # 紫色 — 插件 + 'req': '\033[33m', # 黄色 — 网络请求 + 'api': '\033[34m', # 蓝色 — API + } + ERROR_COLOR = '\033[1;31m' # 红色加粗 + WARN_COLOR = '\033[33m' # 黄色 + RESET = '\033[0m' + + def __init__(self): + super().__init__(fmt='[%(asctime)s] %(message)s', datefmt='%H:%M:%S') + + def format(self, record): + topic = getattr(record, 'topic', '') + if record.levelno >= logging.ERROR: + color = self.ERROR_COLOR + elif record.levelno >= logging.WARNING: + color = self.WARN_COLOR + else: + # 按 topic 前缀匹配颜色 + color = next( + (c for prefix, c in self.TOPIC_COLORS.items() + if topic.startswith(prefix)), + '' + ) + formatted = super().format(record) + return f'{color}{formatted}{self.RESET}' if color else formatted + + +def enable_pretty_log(): + """开启带颜色的美化日志""" + import sys + + # Windows 需要启用 VT100 ANSI 支持 + if sys.platform == 'win32': + import ctypes + kernel32 = ctypes.windll.kernel32 + handle = kernel32.GetStdHandle(-11) # STD_OUTPUT_HANDLE + mode = ctypes.c_uint32() + if kernel32.GetConsoleMode(handle, ctypes.byref(mode)): + kernel32.SetConsoleMode(handle, mode.value | 0x0004) # ENABLE_VIRTUAL_TERMINAL_PROCESSING + + jm_logger.handlers.clear() + handler = logging.StreamHandler(sys.stdout) + handler.setFormatter(PrettyFormatter()) + jm_logger.addHandler(handler) + jm_logger.setLevel(logging.INFO) + diff --git a/src/jmcomic/jm_downloader.py b/src/jmcomic/jm_downloader.py index 085c52baf..5bf2ee4da 100644 --- a/src/jmcomic/jm_downloader.py +++ b/src/jmcomic/jm_downloader.py @@ -13,12 +13,12 @@ def wrapper(self, *args, **kwargs): detail: JmBaseEntity = args[0] if detail.is_image(): detail: JmImageDetail - jm_log('image.failed', f'图片下载失败: [{detail.download_url}], 异常: [{e}]') + jm_log('image.failed', f'图片下载失败: [{detail.download_url}], 异常: [{e}]', e) self.download_failed_image.append((detail, e)) elif detail.is_photo(): detail: JmPhotoDetail - jm_log('photo.failed', f'章节下载失败: [{detail.id}], 异常: [{e}]') + jm_log('photo.failed', f'章节下载失败: [{detail.id}], 异常: [{e}]', e) self.download_failed_photo.append((detail, e)) raise e @@ -54,14 +54,14 @@ def after_photo(self, photo: JmPhotoDetail): f'章节下载完成: [{photo.id}] ({photo.album_id}[{photo.index}/{len(photo.from_album)}])') def before_image(self, image: JmImageDetail, img_save_path): - if image.exists: + if image.exists and image.cache: jm_log('image.before', f'图片已存在: {image.tag} ← [{img_save_path}]' ) - else: - jm_log('image.before', - f'图片准备下载: {image.tag}, [{image.img_url}] → [{img_save_path}]' - ) + return + jm_log('image.before', + f'图片准备下载: {image.tag}, [{image.img_url}] → [{img_save_path}]' + ) def after_image(self, image: JmImageDetail, img_save_path): jm_log('image.after', @@ -81,6 +81,8 @@ def __init__(self, option: JmOption) -> None: # 下载失败的记录list self.download_failed_image: List[Tuple[JmImageDetail, BaseException]] = [] self.download_failed_photo: List[Tuple[JmPhotoDetail, BaseException]] = [] + # Feature 特性列表: [(feature, feature_from), ...] + self._feature_list: List[Tuple] = [] def download_album(self, album_id): album = self.client.get_album_detail(album_id) @@ -123,6 +125,7 @@ def download_by_image_detail(self, image: JmImageDetail): image.save_path = img_save_path image.exists = file_exists(img_save_path) + image.cache = self.option.decide_download_cache(image) self.before_image(image, img_save_path) @@ -130,11 +133,10 @@ def download_by_image_detail(self, image: JmImageDetail): return # let option decide use_cache and decode_image - use_cache = self.option.decide_download_cache(image) decode_image = self.option.decide_download_image_decode(image) # skip download - if use_cache is True and image.exists: + if image.cache and image.exists: return self.client.download_by_image_detail( @@ -230,6 +232,8 @@ def after_album(self, album: JmAlbumDetail): album=album, downloader=self, ) + # 触发匹配 after_album 的 Feature + self._invoke_features_for('after_album', album=album, downloader=self) def before_photo(self, photo: JmPhotoDetail): super().before_photo(photo) @@ -248,6 +252,8 @@ def after_photo(self, photo: JmPhotoDetail): photo=photo, downloader=self, ) + # 触发匹配 after_photo 的 Feature + self._invoke_features_for('after_photo', photo=photo, downloader=self) def before_image(self, image: JmImageDetail, img_save_path): super().before_image(image, img_save_path) @@ -269,6 +275,45 @@ def after_image(self, image: JmImageDetail, img_save_path): downloader=self, ) + def add_features(self, features, feature_from: str): + """ + 注册 Feature 及其来源。 + + :param features: Feature / FeatureChain / list / None + :param feature_from: 来源标记,如 'download_album' 或 'download_photo' + """ + if features is None: + return + + from .jm_feature import FeatureChain, Feature + from .jm_toolkit import ExceptionTool + + if isinstance(features, list): + for f in features: + self.add_features(f, feature_from) + elif isinstance(features, FeatureChain): + for f in features.to_list(): + self._feature_list.append((f, feature_from)) + elif isinstance(features, Feature): + self._feature_list.append((features, feature_from)) + else: + ExceptionTool.raises(f'不支持的 extra 类型: {type(features)},请传入 Feature / FeatureChain / list / None') + + def _invoke_features_for(self, when: str, **kwargs): + """ + 在指定钩子(when)中触发匹配的 Feature。 + + :param when: 当前钩子名,如 'after_album', 'after_photo' + :param kwargs: album, photo, downloader 等上下文 + """ + for feature, feature_from in self._feature_list: + if feature.should_invoke(feature_from, when): + try: + feature.invoke(self.option, feature_from=feature_from, when=when, **kwargs) + except Exception as e: + jm_log('downloader.feature.exception', f'Feature执行失败: [{feature}], 来源: [{feature_from}], 异常: [{e}]', + e) + def raise_if_has_exception(self): if not self.has_download_failures: return diff --git a/src/jmcomic/jm_entity.py b/src/jmcomic/jm_entity.py index 8742a65a0..059bca2d0 100644 --- a/src/jmcomic/jm_entity.py +++ b/src/jmcomic/jm_entity.py @@ -11,6 +11,7 @@ def __init__(self): self.save_path: str = '' self.exists: bool = False self.skip = False + self.cache = True class JmBaseEntity: @@ -125,17 +126,23 @@ def idoname(self): return f'[{self.id}] {self.oname}' def __str__(self): - return f'''{self.__class__.__name__}({self.__alias__()}-{self.id}: "{self.title}")''' + return f'''{self.__class__.__name__}({self.alias_en()}-{self.id}: "{self.title}")''' __repr__ = __str__ @classmethod - def __alias__(cls): + def alias_en(cls): # "JmAlbumDetail" -> "album" (本子) # "JmPhotoDetail" -> "photo" (章节) cls_name = cls.__name__ return cls_name[cls_name.index("m") + 1: cls_name.rfind("Detail")].lower() + @classmethod + def alias_cn(cls) -> str: + # "JmAlbumDetail" -> "album" (本子) + # "JmPhotoDetail" -> "photo" (章节) + return "本子" if issubclass(cls, JmAlbumDetail) else "章节" + @classmethod def get_dirname(cls, detail: 'DetailEntity', ref: str) -> str: """ diff --git a/src/jmcomic/jm_exception.py b/src/jmcomic/jm_exception.py index 987ec0fc5..34bd3e20a 100644 --- a/src/jmcomic/jm_exception.py +++ b/src/jmcomic/jm_exception.py @@ -183,7 +183,7 @@ def new(msg, context=None, _etype=None): @classmethod def notify_all_listeners(cls, e): - registry: Dict[Type, Callable[Type]] = JmModuleConfig.REGISTRY_EXCEPTION_LISTENER + registry: Dict[Type, Callable] = JmModuleConfig.REGISTRY_EXCEPTION_LISTENER if not registry: return None diff --git a/src/jmcomic/jm_feature.py b/src/jmcomic/jm_feature.py new file mode 100644 index 000000000..9021d113e --- /dev/null +++ b/src/jmcomic/jm_feature.py @@ -0,0 +1,173 @@ +""" +该文件存放的是 Feature 机制 + +Feature 用于封装复杂、高级的功能特性,例如pdf导出插件,以前用户需要知道插件名称,调用时机,option插件参数等等,使用feature相当于包办了这些。 + +用法: + from jmcomic import download_album, Feature + + # 最简单 + download_album(id, option, extra=Feature.export_pdf) + + # 带自定义参数 + download_album(id, option, extra=Feature.export_pdf(pdf_dir='./output')) + + # 多个 Feature(列表 / 运算符均可) + download_album(id, option, extra=[Feature.export_pdf, Feature.export_zip]) + download_album(id, option, extra=Feature.export_pdf + Feature.export_zip) +""" +from .jm_plugin import * + + +class Feature: + """ + 下载特性。传入 download_album / download_photo 的 extra 参数, + 下载完成后自动执行。 + + Feature 记录在 downloader 上,由 downloader 在 after_album / after_photo + 钩子中根据 feature_from 自动判断是否执行。 + """ + + # 类型声明(保证 IDE 自动补全) + export_pdf: 'PluginFeature' + export_zip: 'PluginFeature' + export_long_img: 'PluginFeature' + + def should_invoke(self, feature_from: str, when: str) -> bool: + """ + 判断在当前钩子(when)下,根据来源(feature_from),是否应该执行。 + 默认返回 True(任何钩子都执行)。子类可覆写来限制执行时机。 + + :param feature_from: Feature 的注册来源,如 'download_album', 'download_photo' + :param when: 当前触发的钩子名称,如 'after_album', 'after_photo' + :returns: 是否应该执行 + """ + return True + + def invoke(self, option: JmOption, feature_from: str, when: str, **kwargs): + """ + 执行此 Feature。子类需实现该方法。 + + :param option: 当前的 JmOption + :param feature_from 注册来源,如 'download_album', 'download_photo' + :param when: 钩子回调时机,如 'after_album', 'after_photo' + :param kwargs: album, photo, downloader 等回调参数 + """ + raise NotImplementedError + + # ---- 组合运算符,统一返回 FeatureChain ---- + + def __add__(self, other): + return FeatureChain.combine(self, other) + + def __or__(self, other): + return FeatureChain.combine(self, other) + + def __and__(self, other): + return FeatureChain.combine(self, other) + + def to_list(self): + return [self] + + +class PluginFeature(Feature): + """ + 插件特性。封装 jmcomic 的插件,在 invoke 时调用相应的插件类。 + 参数根据 feature_from 动态适配,无需写死。 + """ + + def __init__(self, plugin_key, **kwargs): + self.plugin_key = plugin_key + self.kwargs = dict(kwargs) + + def should_invoke(self, feature_from: str, when: str) -> bool: + """ + 默认根据注册来源推导执行时机: + download_album → after_album, download_photo → after_photo + """ + if feature_from == 'download_album': + return when == 'after_album' + elif feature_from == 'download_photo': + return when == 'after_photo' + return False + + def __call__(self, **kwargs): + """带自定义参数,返回新实例(继承默认参数)""" + new_kwargs = self.kwargs.copy() + new_kwargs.update(kwargs) + new_instance = type(self)(self.plugin_key, **new_kwargs) + return new_instance + + def invoke(self, option: JmOption, feature_from: str, when: str, **extra): + """ + 执行此 Feature 对应的插件。 + 根据 feature_from 动态适配 filename_rule 等参数。 + """ + pclass: type = JmModuleConfig.REGISTRY_PLUGIN.get(self.plugin_key) + ExceptionTool.require_true(pclass is not None, f'PluginFeature 引用了未注册的插件: {self.plugin_key}, from {feature_from}, when {when}') + + # 根据 feature_from 动态适配参数 + plugin_kwargs: dict = self._adapt_plugin_kwargs(option, feature_from, when) + + option.invoke_plugin( + pclass=pclass, + kwargs=plugin_kwargs, + extra=extra, + pinfo={'plugin': self.plugin_key, 'kwargs': plugin_kwargs}, + ) + + def _adapt_plugin_kwargs(self, option: JmOption, feature_from: str, when: str) -> dict: + """ + 根据feature_from和when动态确定以下插件参数: + filename_rule + """ + kwargs = self.kwargs.copy() + kwargs.setdefault('filename_rule', '[JM{Aid}]{Atitle}' if when == 'after_album' else '[JM{Pid}]{Ptitle}') + + # 动态适配导出目录:当且仅当用户未自定义目录时,根据插件类型自动将 dir 导向 option.dir_rule.base_dir + if self.plugin_key == 'zip': + kwargs.setdefault('zip_dir', option.dir_rule.base_dir) + elif self.plugin_key == 'img2pdf': + kwargs.setdefault('pdf_dir', option.dir_rule.base_dir) + elif self.plugin_key == 'long_img': + kwargs.setdefault('img_dir', option.dir_rule.base_dir) + + return kwargs + + def __repr__(self): + if self.kwargs: + args = ', '.join(f'{k}={v!r}' for k, v in self.kwargs.items()) + return f'PluginFeature({self.plugin_key!r}, {args})' + return f'PluginFeature({self.plugin_key!r})' + + +class FeatureChain: + """多个 Feature 的组合""" + + def __init__(self, features): + self._features = features + + @classmethod + def combine(cls, left, right): + return cls(left.to_list() + right.to_list()) + + def __add__(self, other): + return FeatureChain.combine(self, other) + + def __or__(self, other): + return FeatureChain.combine(self, other) + + def __and__(self, other): + return FeatureChain.combine(self, other) + + def to_list(self): + return list(self._features) + + def __repr__(self): + return f'FeatureChain({self._features})' + + +# 内置的 PluginFeature +Feature.export_pdf = PluginFeature(Img2pdfPlugin.plugin_key) +Feature.export_zip = PluginFeature(ZipPlugin.plugin_key) +Feature.export_long_img = PluginFeature(LongImgPlugin.plugin_key) diff --git a/src/jmcomic/jm_option.py b/src/jmcomic/jm_option.py index edff61d55..c42456d5b 100644 --- a/src/jmcomic/jm_option.py +++ b/src/jmcomic/jm_option.py @@ -91,7 +91,7 @@ def apply_rule_to_path(self, album, photo, only_album_rules=False) -> str: path = parser(album, photo, rule) except BaseException as e: # noinspection PyUnboundLocalVariable - jm_log('dir_rule', f'路径规则"{rule}"的解析出错: {e}, album={album}, photo={photo}') + jm_log('dir_rule', f'路径规则"{rule}"的解析出错: {e}, album={album}, photo={photo}', e) raise e if parser != self.parse_bd_rule: # 根据配置 normalize_zh 进行繁简体统一 @@ -249,7 +249,7 @@ def decide_image_suffix(self, image: JmImageDetail) -> str: # 非动图,以配置为先 return self.download.image.suffix or image.img_file_suffix - def decide_image_save_dir(self, photo, ensure_exists=True) -> str: + def decide_image_save_dir(self, photo: JmPhotoDetail, ensure_exists=True) -> str: # 使用 self.dir_rule 决定 save_dir save_dir = self.dir_rule.decide_image_save_dir( photo.from_album, @@ -300,6 +300,8 @@ def construct(cls, origdic: Dict, cover_default=True) -> 'JmOption': log = dic.pop('log', True) if log is False: disable_jm_log() + elif log == 'pretty': + enable_pretty_log() # version version = dic.pop('version', None) @@ -328,6 +330,78 @@ def compatible_with_old_versions(cls, dic): if 'plugin' in dic: dic['plugins'] = dic.pop('plugin') + # 3: zip 插件 level 参数迁移 + # level 已废弃,打包粒度由所在钩子上下文自动推导 + plugins = dic.get('plugins', {}) + if isinstance(plugins, dict): + cls._migrate_zip_level(plugins) + + @classmethod + def _migrate_zip_level(cls, plugins: dict): + """ + zip 插件 level 参数迁移。 + + level 已废弃,打包粒度由所在钩子的上下文自动推导。 + 迁移规则:level='album' → 确保在 after_album;其他 → 确保在 after_photo。 + """ + + def log_advice(reason, plugins): + import yaml + # 意图聚焦:建议配置中只展示相关的 zip 插件,剔除其他无关插件的干扰 + advice_plugins = {} + for g, plist in plugins.items(): + zips = [p for p in plist if p.get('plugin') == 'zip'] + if zips: + advice_plugins[g] = zips + + if not advice_plugins: + return + + plugins_yml = yaml.dump({'plugins': advice_plugins}, default_flow_style=False, indent=2, sort_keys=False).strip() + + jm_log('option.migrate', + f'[zip 插件迁移] level 参数已过时,建议直接删除。' + f'{reason},建议参考如下的等价新写法:\n' + f'```yml\n' + f'{plugins_yml}\n' + f'```' + ) + + for group in ['after_album', 'after_photo']: + plugin_list = plugins.get(group) + if not isinstance(plugin_list, list): + continue + i = 0 + while i < len(plugin_list): + pinfo = plugin_list[i] + if pinfo.get('plugin') != 'zip': + i += 1 + continue + kwargs = pinfo.get('kwargs') or {} + if 'level' not in kwargs: + # 旧版本默认值是 'photo' + level = 'photo' + else: + level = kwargs.pop('level') + + if group == 'after_album' and level != 'album': + # after_album + level=photo → 等价迁移到 after_photo + plugins.setdefault('after_photo', []).append(pinfo) + plugin_list.pop(i) + log_advice('你的当前配置为:在本子下载完毕后按章节压缩', plugins) + + elif group == 'after_photo' and level == 'album': + # after_photo + level=album → 等价迁移到 after_album + plugins.setdefault('after_album', []).append(pinfo) + plugin_list.pop(i) + log_advice('你的当前配置为:在单章节下载完毕后对全本进行压缩', plugins) + + else: + if level != 'photo': + jm_log('option.migrate', + '[zip 插件迁移] level 参数已过时,你可以直接删除该参数,不会有任何影响') + i += 1 + def deconstruct(self) -> Dict: return { 'version': JmModuleConfig.JM_OPTION_VER, @@ -506,19 +580,19 @@ def merge_default_dict(cls, user_dict, default_dict=None): def download_album(self, album_id, - downloader=None, - callback=None, + *args, + **kwargs, ): from .api import download_album - download_album(album_id, self, downloader, callback) + return download_album(album_id, self, *args, **kwargs) def download_photo(self, photo_id, - downloader=None, - callback=None + *args, + **kwargs, ): from .api import download_photo - download_photo(photo_id, self, downloader, callback) + return download_photo(photo_id, self, *args, **kwargs) # 下面的方法为调用插件提供支持 @@ -547,7 +621,7 @@ def call_all_plugin(self, group: str, safe=None, **extra): def invoke_plugin(self, pclass, kwargs: Optional[Dict], extra: dict, pinfo: dict): # 检查插件的参数类型 - kwargs = self.fix_kwargs(kwargs) + kwargs: dict = self.fix_kwargs(kwargs) # 把插件的配置数据kwargs和附加数据extra合并,extra会覆盖kwargs if len(extra) != 0: kwargs.update(extra) @@ -610,13 +684,13 @@ def handle_plugin_valid_exception(self, e, pinfo: dict, kwargs: dict, _plugin, _ # noinspection PyMethodMayBeStatic,PyUnusedLocal def handle_plugin_unexpected_error(self, e, pinfo: dict, kwargs: dict, _plugin, pclass): msg = str(e) - jm_log('plugin.error', f'插件 [{pclass.plugin_key}],运行遇到未捕获异常,异常信息: [{msg}]') + jm_log('plugin.error', f'插件 [{pclass.plugin_key}],运行遇到未捕获异常,异常信息: [{msg}]', e) raise e # noinspection PyMethodMayBeStatic,PyUnusedLocal def handle_plugin_jmcomic_exception(self, e, pinfo: dict, kwargs: dict, _plugin, pclass): msg = str(e) - jm_log('plugin.exception', f'插件 [{pclass.plugin_key}] 调用失败,异常信息: [{msg}]') + jm_log('plugin.exception', f'插件 [{pclass.plugin_key}] 调用失败,异常信息: [{msg}]', e) raise e # noinspection PyMethodMayBeStatic diff --git a/src/jmcomic/jm_plugin.py b/src/jmcomic/jm_plugin.py index 8da3e1cbb..0373e762a 100644 --- a/src/jmcomic/jm_plugin.py +++ b/src/jmcomic/jm_plugin.py @@ -36,7 +36,7 @@ def build(cls, option: JmOption) -> 'JmOptionPlugin': return cls(option) def log(self, msg, topic=None): - if self.log_enable: + if not self.log_enable: return jm_log( @@ -136,7 +136,7 @@ def decide_filepath(self, filepath = os.path.join(base_dir, DirRule.apply_rule_to_filename(album, photo, filename_rule) + fix_suffix(suffix)) mkdir_if_not_exists(base_dir) - return filepath + return fix_filepath(filepath) class JmLoginPlugin(JmOptionPlugin): @@ -158,7 +158,6 @@ def invoke(self, cookies = dict(client['cookies']) self.option.update_cookies(cookies) - JmModuleConfig.APP_COOKIES = cookies self.log('登录成功') @@ -321,7 +320,7 @@ def invoke(self, album: JmAlbumDetail = None, photo: JmPhotoDetail = None, delete_original_file=False, - level='photo', + level=None, filename_rule='Ptitle', suffix='zip', zip_dir='./', @@ -332,6 +331,9 @@ def invoke(self, from .jm_downloader import JmDownloader downloader: JmDownloader self.downloader = downloader + # level 自动推导:有 album 则合并打包,只有 photo 则单章打包 + if level is None: + level = 'album' if album is not None else 'photo' self.level = level self.delete_original_file = delete_original_file @@ -378,7 +380,9 @@ def zip_photo(self, photo, image_list: list, zip_path: str, path_to_delete, encr relpath = os.path.relpath(abspath, photo_dir) f.write(abspath, relpath) - self.log(f'压缩章节[{photo.photo_id}]成功 → {zip_path}', 'finish') + # 打印结果 + self.log(f'{photo.alias_cn()}压缩成功!' + f'[{photo}] → [{zip_path}]', 'finish') path_to_delete.append(self.unified_path(photo_dir)) @staticmethod @@ -401,7 +405,9 @@ def zip_album(self, album, photo_dict: dict, zip_path, path_to_delete, encrypt_d abspath = os.path.join(photo_dir, file) relpath = os.path.relpath(abspath, album_dir) f.write(abspath, relpath) - self.log(f'压缩本子[{album.album_id}]成功 → {zip_path}', 'finish') + # 打印结果 + self.log(f'{album.alias_cn()}压缩成功!' + f'[{album}] → [{zip_path}]', 'finish') def after_zip(self, path_to_delete: List[str]): # 删除所有原文件 @@ -784,7 +790,13 @@ def invoke(self, if not result: return img_path_ls, img_dir_ls = result - self.log(f'Convert Successfully: JM{album or photo} → {pdf_filepath}') + + # noinspection PyTypeChecker + detail: DetailEntity = album or photo + + # 打印结果 + self.log(f'{detail.alias_cn()}合并PDF成功!' + f'[{detail}] → [{pdf_filepath}]', 'finish') # 执行删除 img_path_ls += img_dir_ls @@ -861,7 +873,12 @@ def invoke(self, img_path_ls = self.write_img_2_long_img(long_img_path, album, photo) if not img_path_ls: return - self.log(f'Convert Successfully: JM{album or photo} → {long_img_path}') + # noinspection PyTypeChecker + detail: DetailEntity = album or photo + + # 打印结果 + self.log(f'{detail.alias_cn()}合并长图成功!' + f'[{detail}] → [{long_img_path}]', 'finish') # 执行删除 self.execute_deletion(img_path_ls) diff --git a/src/jmcomic/jm_toolkit.py b/src/jmcomic/jm_toolkit.py index 72434f372..850bd1eec 100644 --- a/src/jmcomic/jm_toolkit.py +++ b/src/jmcomic/jm_toolkit.py @@ -350,7 +350,7 @@ def to_zh(cls, s, target=None): try: import zhconv return zhconv.convert(s, target) - except ImportError as e: + except ImportError: jm_log('zhconv.error', '繁简转换失败,未安装zhconv,请先使用命令安装: [pip install zhconv]') return s except Exception as e: diff --git a/tests/test_jmcomic/test_jm_feature.py b/tests/test_jmcomic/test_jm_feature.py new file mode 100644 index 000000000..1a65743c6 --- /dev/null +++ b/tests/test_jmcomic/test_jm_feature.py @@ -0,0 +1,200 @@ +from test_jmcomic import * + + +class Test_Feature(JmTestConfigurable): + + def test_feature_combine(self): + # 1. + 运算 + f1 = Feature.export_pdf + Feature.export_zip + self.assertIsInstance(f1, FeatureChain) + self.assertEqual(len(f1._features), 2) + + # 2. | 运算 + f2 = Feature.export_pdf | Feature.export_zip + self.assertIsInstance(f2, FeatureChain) + + # 3. & 运算 + f3 = Feature.export_pdf & Feature.export_zip + self.assertIsInstance(f3, FeatureChain) + + # 4. 连续组合 + f4 = Feature.export_pdf + Feature.export_zip + Feature.export_long_img + self.assertIsInstance(f4, FeatureChain) + self.assertEqual(len(f4._features), 3) + + def test_plugin_feature_call(self): + f = Feature.export_pdf(pdf_dir='./test', filename_rule='test') + self.assertIsInstance(f, PluginFeature) + self.assertEqual(f.plugin_key, 'img2pdf') + self.assertEqual(f.kwargs['pdf_dir'], './test') + self.assertEqual(f.kwargs['filename_rule'], 'test') + + def test_custom_feature(self): + class MyCustomFeature(Feature): + def invoke(self, option, **kwargs): + pass + + my_feature = MyCustomFeature() + combo = my_feature + Feature.export_pdf + self.assertIsInstance(combo, FeatureChain) + self.assertEqual(len(combo._features), 2) + self.assertIsInstance(combo._features[0], MyCustomFeature) + + def test_should_invoke(self): + """测试 should_invoke 判断逻辑""" + # Feature 基类默认在所有钩子中都执行 + class MyFeature(Feature): + def invoke(self, option, **kwargs): + pass + + base = MyFeature() + self.assertTrue(base.should_invoke('download_album', 'after_album')) + self.assertTrue(base.should_invoke('download_album', 'after_photo')) + + # PluginFeature 根据来源推导执行时机 + pf = Feature.export_pdf + # download_album → 只在 after_album 执行 + self.assertTrue(pf.should_invoke('download_album', 'after_album')) + self.assertFalse(pf.should_invoke('download_album', 'after_photo')) + # download_photo → 只在 after_photo 执行 + self.assertTrue(pf.should_invoke('download_photo', 'after_photo')) + self.assertFalse(pf.should_invoke('download_photo', 'after_album')) + + def test_adapt_kwargs(self): + """测试 PluginFeature 参数动态适配""" + when = 'after_album' + + pdf = Feature.export_pdf + adapted = pdf._adapt_plugin_kwargs(self.option, 'download_album', when) + self.assertEqual(adapted['filename_rule'], '[JM{Aid}]{Atitle}') + + zip_f = Feature.export_zip + adapted = zip_f._adapt_plugin_kwargs(self.option, 'download_album', when) + self.assertEqual(adapted['filename_rule'], '[JM{Aid}]{Atitle}') + + long_img = Feature.export_long_img + adapted = long_img._adapt_plugin_kwargs(self.option, 'download_album', when) + self.assertEqual(adapted['filename_rule'], '[JM{Aid}]{Atitle}') + + # download_photo 模式 + when = 'after_photo' + adapted = pdf._adapt_plugin_kwargs(self.option, 'download_photo', when) + self.assertEqual(adapted['filename_rule'], '[JM{Pid}]{Ptitle}') + + # 用户显式传入的参数不被动态适配 (通过 kwargs 机制自带) + custom = Feature.export_zip(filename_rule='Ptitle') + adapted = custom._adapt_plugin_kwargs(self.option, 'download_album', when) + self.assertEqual(adapted['filename_rule'], 'Ptitle') # 用户显式指定,不被 setdefault 覆盖 + + def test_dynamic_base_dir(self): + """测试不指定导出目录时,自动适配为 option.dir_rule.base_dir""" + self.option.dir_rule.base_dir = './custom_base' + when = 'after_album' + + # 1. PDF + adapted = Feature.export_pdf._adapt_plugin_kwargs(self.option, 'download_album', when) + self.assertEqual(adapted['pdf_dir'], './custom_base') + + # 2. ZIP + adapted = Feature.export_zip._adapt_plugin_kwargs(self.option, 'download_album', when) + self.assertEqual(adapted['zip_dir'], './custom_base') + + # 3. LongImg + adapted = Feature.export_long_img._adapt_plugin_kwargs(self.option, 'download_album', when) + self.assertEqual(adapted['img_dir'], './custom_base') + + # 4. 如果显式指定了,则不应被覆盖 + custom_pdf = Feature.export_pdf(pdf_dir='./explicit_dir') + adapted = custom_pdf._adapt_plugin_kwargs(self.option, 'download_album', when) + self.assertEqual(adapted['pdf_dir'], './explicit_dir') + + def test_download_use_feature(self): + album_id = '438516' + + # 记录被执行的次数,便于断言 + custom_feature_call_count = 0 + + class MyCounterFeature(Feature): + def invoke(self, option, **kwargs): + nonlocal custom_feature_call_count + custom_feature_call_count += 1 + + counter_feature = MyCounterFeature() + + # 测试 download_album: + # 自定义 Feature 基类 should_invoke 默认 True, + # 438516 有 1 个章节,所以 after_photo(1次) + after_album(1次) = 2次 + jmcomic.download_album(album_id, self.option, extra=counter_feature) + self.assertEqual(custom_feature_call_count, 2) + + # 测试 download_photo: after_photo 触发 1 次 + photo_id = '438516' + jmcomic.download_photo(photo_id, self.option, extra=counter_feature) + self.assertEqual(custom_feature_call_count, 3) + + # 测试 download_batch (Iterable 批量输入): 确保 extra 参数不被丢弃 + jmcomic.download_batch(jmcomic.download_album, [album_id], self.option, extra=counter_feature) + # 上面增加了 1 个 album (包含 1 个 photo),因此 invoke 追加 2 次,总计 5 次 + self.assertEqual(custom_feature_call_count, 5) + + def test_export_features(self): + album_id = '438516' + + # 直接使用测试环境配置的下载目录 + export_dir = self.option.dir_rule.base_dir + + # 定义导出路径指向测试目录 + f_pdf = Feature.export_pdf(pdf_dir=export_dir) + f_zip = Feature.export_zip(zip_dir=export_dir) + f_long_img = Feature.export_long_img(img_dir=export_dir) + + # 组合下载并导出 + combo = f_pdf + f_zip + f_long_img + album, _dler = jmcomic.download_album(album_id, self.option, extra=combo) + + # 验证文件是否精确生成 + # 通过 download_album 注册,动态适配后默认规则均为:[JM{Aid}]{Atitle} + rule = '[JM{Aid}]{Atitle}' + pdf_name = DirRule.apply_rule_to_filename(album, None, rule) + '.pdf' + zip_name = DirRule.apply_rule_to_filename(album, None, rule) + '.zip' + png_name = DirRule.apply_rule_to_filename(album, None, rule) + '.png' + + import os + pdf_path = os.path.join(export_dir, pdf_name) + zip_path = os.path.join(export_dir, zip_name) + png_path = os.path.join(export_dir, png_name) + + self.assertTrue(os.path.exists(pdf_path), f"未生成精确匹配的 PDF 文件: {pdf_path}") + self.assertTrue(os.path.exists(zip_path), f"未生成精确匹配的 ZIP 文件: {zip_path}") + self.assertTrue(os.path.exists(png_path), f"未生成精确匹配的 PNG 长图: {png_path}") + + def test_export_features_photo(self): + photo_id = '438516' + export_dir = self.option.dir_rule.base_dir + + # 测试单个章节的 PDF 导出 + f_pdf = Feature.export_pdf(pdf_dir=export_dir) + photo, _dler = jmcomic.download_photo(photo_id, self.option, extra=f_pdf) + + # 验证文件是否按照 [JM{Pid}]{Ptitle} 规则生成 + rule = '[JM{Pid}]{Ptitle}' + pdf_name = DirRule.apply_rule_to_filename(None, photo, rule) + '.pdf' + + import os + pdf_path = os.path.join(export_dir, pdf_name) + self.assertTrue(os.path.exists(pdf_path), f"未生成精确匹配的 PDF 文件 (章节级): {pdf_path}") + + def test_export_album_use_photo_rule(self): + """ + 负面测试:在 Album 模式下强行使用 Photo 级规则(Ptitle),预期报错。 + 本子=album,本子的章节=photo。下载本子时,photo对象为None。 + """ + album_id = '438516' + # 强行使用 Ptitle + f = Feature.export_pdf(filename_rule='Ptitle') + + # 验证底层 invoke 会抛出 AttributeError + # 因为在 download_album 的 after_album 阶段,photo 为 None + with self.assertRaises(AttributeError): + album = self.client.get_album_detail(album_id) + f.invoke(self.option, feature_from='download_album', when='after_album', album=album, photo=None)