From c37949869647bad28ff897fec7084665c3f38f46 Mon Sep 17 00:00:00 2001 From: check <137591557+yifenliwu@users.noreply.github.com> Date: Fri, 11 Sep 2026 15:18:41 +0800 Subject: [PATCH 01/14] =?UTF-8?q?feat(plugin):=20=E6=96=B0=E5=A2=9E=20cali?= =?UTF-8?q?bre=5Fmetadata=20=E6=8F=92=E4=BB=B6=EF=BC=8C=E4=B8=BA=20Calibre?= =?UTF-8?q?=20=E7=94=A8=E6=88=B7=E7=94=9F=E6=88=90=20metadata.opf=20(#576)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/jmcomic/jm_plugin.py | 114 +++++++++++++++++++++++++++ tests/test_jmcomic/test_jm_plugin.py | 108 +++++++++++++++++++++++++ 2 files changed, 222 insertions(+) diff --git a/src/jmcomic/jm_plugin.py b/src/jmcomic/jm_plugin.py index 203036776..b99479368 100644 --- a/src/jmcomic/jm_plugin.py +++ b/src/jmcomic/jm_plugin.py @@ -1846,3 +1846,117 @@ def invoke(self, self.log(f'album-{album_id}的封面已存在,跳过下载: [{save_path}]', 'skip') return downloader.client.download_album_cover(album_id, save_path, size) + + +class CalibreMetadataPlugin(JmOptionPlugin): + """ + 功能:为本子生成 Calibre 可识别的元数据文件 metadata.opf。 + + 通常挂在 after_album 上,每个本子在其目录下生成一份 metadata.opf, + Calibre 导入(从 OPF 读元数据)时可以自动带上书名、作者、标签、简介和封面。 + + 配置示例: + + ```yml + plugins: + after_album: + - plugin: calibre_metadata + kwargs: + dir_rule: + rule: "Bd/Aid/metadata.opf" + base_dir: "./" + include_cover: true + fields: # 追加静态字段 + language: "zh" + ``` + + 说明: + - identifier 固定写为 jmcomic:{album_id},可在 Calibre 中反查回禁漫的 album_id + - fields 中的 title/author 可覆盖默认取值,其余键值对须为 Dublin Core 元素名(如 language/publisher/date),按 dc:{key} 写入,不支持的键会忽略并告警 + - include_cover 依赖 downloader(after_album 阶段自动传入) + """ + plugin_key = 'calibre_metadata' + + # fields 允许的 Dublin Core 元素名(核心15元素),避免非法元素名生成无效 XML + DUBLIN_CORE_ELEMENTS = frozenset({ + 'title', 'creator', 'subject', 'description', 'publisher', 'contributor', + 'date', 'type', 'format', 'identifier', 'source', 'language', 'relation', + 'coverage', 'rights', + }) + + def invoke(self, + dir_rule: dict, + album: JmAlbumDetail = None, + photo: JmPhotoDetail = None, + downloader=None, + include_cover=False, + fields=None, + **kwargs) -> None: + from xml.sax.saxutils import escape + + self.require_param(album, '本插件需在after_album阶段使用,需要album参数') + opf_path = self.decide_filepath(album, photo, None, None, None, dir_rule) + opf_dir = os.path.dirname(opf_path) + + fields = dict(fields or {}) + + # 标题与作者,允许通过 fields 覆盖;作者为空时由 album.author 兜底为 DEFAULT_AUTHOR + title = str(fields.pop('title', album.title)) + author = str(fields.pop('author', album.author)) + + # 封面:与 metadata.opf 同目录存放 cover.jpg,并在 OPF 中引用 + cover_line = '' + manifest_line = '' + if include_cover: + cover_path = os.path.join(opf_dir, 'cover.jpg') + self.download_cover_if_needed(album.id, cover_path, downloader) + manifest_line = ( + ' \n' + ' \n' + ' \n' + ) + cover_line = ' \n' + + subject_lines = ''.join( + f' {escape(str(tag))}\n' + for tag in (album.tags or []) + ) + + extra_lines = '' + for key, value in fields.items(): + key = str(key) + if key not in self.DUBLIN_CORE_ELEMENTS: + self.log(f'calibre_metadata: 忽略不支持的fields字段 [{key}],' + f'仅支持Dublin Core元素: {", ".join(sorted(self.DUBLIN_CORE_ELEMENTS))}', 'warning') + continue + extra_lines += f' {escape(str(value))}\n' + + xml = ( + '\n' + '\n' + ' \n' + f' {escape(title)}\n' + f' {escape(author)}\n' + f'{subject_lines}' + f' jmcomic:{album.id}\n' + f' {escape(album.description)}\n' + f'{extra_lines}' + f'{cover_line}' + ' \n' + f'{manifest_line}' + '\n' + ) + + mkdir_if_not_exists(opf_dir) + with open(opf_path, 'w', encoding='utf-8') as f: + f.write(xml) + self.log(f'已生成Calibre元数据文件 → [{opf_path}]') + + def download_cover_if_needed(self, album_id: str, cover_path: str, downloader): + if self.option.download.cache and os.path.exists(cover_path): + self.log(f'album-{album_id}的封面已存在,跳过下载: [{cover_path}]', 'skip') + return + self.require_param(downloader, 'include_cover=true时需要downloader参数(after_album阶段会自动传入)') + downloader.client.download_album_cover(album_id, cover_path, '') diff --git a/tests/test_jmcomic/test_jm_plugin.py b/tests/test_jmcomic/test_jm_plugin.py index 8efa286db..02872cf45 100644 --- a/tests/test_jmcomic/test_jm_plugin.py +++ b/tests/test_jmcomic/test_jm_plugin.py @@ -35,3 +35,111 @@ def test_plugin_missing_album_context(self): download_photo(photo_id, option, downloader=DoNotDownloadImage) print('✅ All folder rule plugins assert completed safely without KeyError.') + + def test_calibre_metadata(self): + """ + source: https://github.com/hect0x7/JMComic-Crawler-Python/issues/573 + + 测试 calibre_metadata 插件: + 1. after_album 阶段生成 metadata.opf,包含书名/作者/标签/identifier + 2. fields 静态字段(如 language)按维护者建议写入 + 3. 作者为空时兜底 DEFAULT_AUTHOR;XML 特殊字符正确转义 + """ + import tempfile + import shutil + import xml.etree.ElementTree as ET + + from jmcomic import JmOption, JmModuleConfig, JmAlbumDetail + + album = JmAlbumDetail( + album_id='123456', + scramble_id='220980', + name='测试<本子>名 & 特殊"字符"', + episode_list=[], + page_count=10, + pub_date='2026-01-01', + update_date='2026-01-02', + likes='1K', + views='2K', + comment_count=0, + works=['作品A'], + actors=['角色A'], + authors=['作者甲'], + tags=['tag1', '中文标签'], + description='简介 含XML特殊字符', + ) + + tmp = tempfile.mkdtemp(prefix='jm_test_calibre_') + try: + dic = { + 'dir_rule': {'rule': 'Bd_Atitle', 'base_dir': tmp}, + 'plugins': { + 'after_album': [ + { + 'plugin': 'calibre_metadata', + 'kwargs': { + 'dir_rule': { + 'rule': 'Bd/Atitle/metadata.opf', + 'base_dir': tmp, + }, + 'fields': {'language': 'zh', 'series index': '1'}, + }, + }, + ], + }, + } + option = JmOption.construct(dic) + option.call_all_plugin('after_album', album=album, downloader=None) + + import glob + opf_list = glob.glob(os.path.join(tmp, '**', 'metadata.opf'), recursive=True) + self.assertEqual(len(opf_list), 1, f'expected 1 opf, got: {opf_list}') + opf_path = opf_list[0] + + root = ET.parse(opf_path).getroot() + ns = {'dc': 'http://purl.org/dc/elements/1.1/', 'opf': 'http://www.idpf.org/2007/opf'} + + self.assertEqual(root.find('.//dc:title', ns).text, album.title) + self.assertEqual(root.find('.//dc:creator', ns).text, '作者甲') + self.assertEqual( + [e.text for e in root.findall('.//dc:subject', ns)], + ['tag1', '中文标签'], + ) + self.assertEqual(root.find('.//dc:identifier', ns).text, 'jmcomic:123456') + self.assertEqual(root.find('.//dc:language', ns).text, 'zh') + self.assertIsNone(root.find('.//dc:series_index', ns), '非法Dublin Core元素名应被忽略') + self.assertIsNone(root.find('.//{"series index"}', ns)) + self.assertEqual(root.find('.//dc:description', ns).text, '简介 含XML特殊字符') + self.assertIsNone(root.find('.//{*}manifest'), 'include_cover=False 时不应有 manifest') + print('✅ metadata.opf generated with escaped title/tags/identifier/fields.') + + # 作者为空时兜底 DEFAULT_AUTHOR + album2 = JmAlbumDetail( + album_id='654321', scramble_id='220980', name='无作者本子', + episode_list=[], page_count=1, pub_date='', update_date='', + likes='', views='', comment_count=0, + works=[], actors=[], authors=[], tags=[], + ) + dic2 = { + 'plugins': { + 'after_album': [ + { + 'plugin': 'calibre_metadata', + 'kwargs': {'dir_rule': {'rule': 'Bd/Aid/metadata.opf', 'base_dir': tmp}}, + }, + ], + }, + } + option2 = JmOption.construct(dic2) + option2.call_all_plugin('after_album', album=album2, downloader=None) + + opf2 = os.path.join(tmp, '654321', 'metadata.opf') + root2 = ET.parse(opf2).getroot() + self.assertEqual( + root2.find('.//dc:creator', ns).text, + JmModuleConfig.DEFAULT_AUTHOR, + ) + self.assertEqual(root2.find('.//dc:identifier', ns).text, 'jmcomic:654321') + print('✅ author falls back to DEFAULT_AUTHOR when authors is empty.') + finally: + shutil.rmtree(tmp, ignore_errors=True) From e75b3929bdef9ffec8c61d75c8d6e071a82f6f10 Mon Sep 17 00:00:00 2001 From: check <137591557+yifenliwu@users.noreply.github.com> Date: Fri, 11 Sep 2026 19:12:16 +0800 Subject: [PATCH 02/14] =?UTF-8?q?feat(plugin):=20calibre=5Fmetadata=20?= =?UTF-8?q?=E7=9A=84=20OPF=20=E7=94=9F=E6=88=90=E6=94=B9=E7=94=B1=20jmcomi?= =?UTF-8?q?c-calibre=20=E6=8F=90=E4=BE=9B=20(#578)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/requirements-dev.txt | 1 + src/jmcomic/jm_plugin.py | 83 +++++++--------------------- tests/test_jmcomic/test_jm_plugin.py | 12 +++- 3 files changed, 32 insertions(+), 64 deletions(-) diff --git a/.github/requirements-dev.txt b/.github/requirements-dev.txt index 223e02fa7..7c4cd727f 100644 --- a/.github/requirements-dev.txt +++ b/.github/requirements-dev.txt @@ -9,3 +9,4 @@ rich jm-view-server zhconv img2pdf +jmcomic-calibre @ https://github.com/yifenliwu/jmcomic-calibre/archive/refs/tags/v0.2.0.tar.gz diff --git a/src/jmcomic/jm_plugin.py b/src/jmcomic/jm_plugin.py index b99479368..ec27a9b0b 100644 --- a/src/jmcomic/jm_plugin.py +++ b/src/jmcomic/jm_plugin.py @@ -1855,6 +1855,9 @@ class CalibreMetadataPlugin(JmOptionPlugin): 通常挂在 after_album 上,每个本子在其目录下生成一份 metadata.opf, Calibre 导入(从 OPF 读元数据)时可以自动带上书名、作者、标签、简介和封面。 + OPF 的生成逻辑由 jmcomic-calibre 提供(pip install jmcomic-calibre), + 避免同一份 XML 拼接逻辑在两处各维护一份。 + 配置示例: ```yml @@ -1877,13 +1880,6 @@ class CalibreMetadataPlugin(JmOptionPlugin): """ plugin_key = 'calibre_metadata' - # fields 允许的 Dublin Core 元素名(核心15元素),避免非法元素名生成无效 XML - DUBLIN_CORE_ELEMENTS = frozenset({ - 'title', 'creator', 'subject', 'description', 'publisher', 'contributor', - 'date', 'type', 'format', 'identifier', 'source', 'language', 'relation', - 'coverage', 'rights', - }) - def invoke(self, dir_rule: dict, album: JmAlbumDetail = None, @@ -1892,66 +1888,27 @@ def invoke(self, include_cover=False, fields=None, **kwargs) -> None: - from xml.sax.saxutils import escape - self.require_param(album, '本插件需在after_album阶段使用,需要album参数') - opf_path = self.decide_filepath(album, photo, None, None, None, dir_rule) - opf_dir = os.path.dirname(opf_path) - - fields = dict(fields or {}) - - # 标题与作者,允许通过 fields 覆盖;作者为空时由 album.author 兜底为 DEFAULT_AUTHOR - title = str(fields.pop('title', album.title)) - author = str(fields.pop('author', album.author)) - - # 封面:与 metadata.opf 同目录存放 cover.jpg,并在 OPF 中引用 - cover_line = '' - manifest_line = '' - if include_cover: - cover_path = os.path.join(opf_dir, 'cover.jpg') - self.download_cover_if_needed(album.id, cover_path, downloader) - manifest_line = ( - ' \n' - ' \n' - ' \n' - ) - cover_line = ' \n' - subject_lines = ''.join( - f' {escape(str(tag))}\n' - for tag in (album.tags or []) - ) + try: + import jmcomic_calibre + except ImportError: + self.warning_lib_not_install('jmcomic-calibre') + return - extra_lines = '' - for key, value in fields.items(): - key = str(key) - if key not in self.DUBLIN_CORE_ELEMENTS: - self.log(f'calibre_metadata: 忽略不支持的fields字段 [{key}],' - f'仅支持Dublin Core元素: {", ".join(sorted(self.DUBLIN_CORE_ELEMENTS))}', 'warning') - continue - extra_lines += f' {escape(str(value))}\n' - - xml = ( - '\n' - '\n' - ' \n' - f' {escape(title)}\n' - f' {escape(author)}\n' - f'{subject_lines}' - f' jmcomic:{album.id}\n' - f' {escape(album.description)}\n' - f'{extra_lines}' - f'{cover_line}' - ' \n' - f'{manifest_line}' - '\n' - ) + opf_path = self.decide_filepath(album, photo, None, None, None, dir_rule) - mkdir_if_not_exists(opf_dir) - with open(opf_path, 'w', encoding='utf-8') as f: - f.write(xml) + jmcomic_calibre.export_opf( + album=album, + opf_path=opf_path, + fields=fields, + include_cover=include_cover, + download_cover=lambda cover_path: self.download_cover_if_needed( + album.id, cover_path, downloader), + on_ignored=lambda key, allowed: self.log( + f'calibre_metadata: 忽略不支持的fields字段 [{key}],' + f'仅支持Dublin Core元素: {", ".join(sorted(allowed))}', 'warning'), + ) self.log(f'已生成Calibre元数据文件 → [{opf_path}]') def download_cover_if_needed(self, album_id: str, cover_path: str, downloader): diff --git a/tests/test_jmcomic/test_jm_plugin.py b/tests/test_jmcomic/test_jm_plugin.py index 02872cf45..83dc97abe 100644 --- a/tests/test_jmcomic/test_jm_plugin.py +++ b/tests/test_jmcomic/test_jm_plugin.py @@ -1,5 +1,11 @@ from test_jmcomic import * +try: + import jmcomic_calibre # noqa: F401 + HAS_JMCOMIC_CALIBRE = True +except ImportError: + HAS_JMCOMIC_CALIBRE = False + class Test_Plugin(JmTestConfigurable): @@ -36,11 +42,15 @@ def test_plugin_missing_album_context(self): download_photo(photo_id, option, downloader=DoNotDownloadImage) print('✅ All folder rule plugins assert completed safely without KeyError.') + @unittest.skipUnless( + HAS_JMCOMIC_CALIBRE, + '这条用例依赖 jmcomic-calibre 生成 OPF:pip install jmcomic-calibre', + ) def test_calibre_metadata(self): """ source: https://github.com/hect0x7/JMComic-Crawler-Python/issues/573 - 测试 calibre_metadata 插件: + 测试 calibre_metadata 插件(OPF 由 jmcomic-calibre 生成): 1. after_album 阶段生成 metadata.opf,包含书名/作者/标签/identifier 2. fields 静态字段(如 language)按维护者建议写入 3. 作者为空时兜底 DEFAULT_AUTHOR;XML 特殊字符正确转义 From 3cb52c4ebf81da49d55505793930b21c499edf57 Mon Sep 17 00:00:00 2001 From: hect0x7 <93357912+hect0x7@users.noreply.github.com> Date: Fri, 11 Sep 2026 19:21:39 +0800 Subject: [PATCH 03/14] =?UTF-8?q?=E4=B8=8D=E6=B5=8B=E8=AF=95jmcomic-calibr?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/requirements-dev.txt | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/requirements-dev.txt b/.github/requirements-dev.txt index 7c4cd727f..223e02fa7 100644 --- a/.github/requirements-dev.txt +++ b/.github/requirements-dev.txt @@ -9,4 +9,3 @@ rich jm-view-server zhconv img2pdf -jmcomic-calibre @ https://github.com/yifenliwu/jmcomic-calibre/archive/refs/tags/v0.2.0.tar.gz From f6c54562f534f4a55723d7b0eb7af1ae0d25bd6c Mon Sep 17 00:00:00 2001 From: hect0x7 <93357912+hect0x7@users.noreply.github.com> Date: Fri, 11 Sep 2026 19:50:06 +0800 Subject: [PATCH 04/14] =?UTF-8?q?refactor(plugin):=20=E8=A7=A3=E8=80=A6=20?= =?UTF-8?q?calibre=5Fmetadata=20=E5=B0=81=E9=9D=A2=E4=B8=8B=E8=BD=BD?= =?UTF-8?q?=EF=BC=8C=E7=A7=BB=E9=99=A4=20download=5Fcover=20=E5=9B=9E?= =?UTF-8?q?=E8=B0=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/jmcomic/jm_plugin.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/jmcomic/jm_plugin.py b/src/jmcomic/jm_plugin.py index ec27a9b0b..ac7d1e131 100644 --- a/src/jmcomic/jm_plugin.py +++ b/src/jmcomic/jm_plugin.py @@ -1898,13 +1898,16 @@ def invoke(self, opf_path = self.decide_filepath(album, photo, None, None, None, dir_rule) + # 处理封面下载 + if include_cover: + cover_path = os.path.join(os.path.dirname(opf_path), 'cover.jpg') + self.download_cover_if_needed(album.id, cover_path, downloader) + jmcomic_calibre.export_opf( album=album, opf_path=opf_path, fields=fields, include_cover=include_cover, - download_cover=lambda cover_path: self.download_cover_if_needed( - album.id, cover_path, downloader), on_ignored=lambda key, allowed: self.log( f'calibre_metadata: 忽略不支持的fields字段 [{key}],' f'仅支持Dublin Core元素: {", ".join(sorted(allowed))}', 'warning'), From 078f62eef1c8293572c4311325842c7e6dad1dac Mon Sep 17 00:00:00 2001 From: check <137591557+yifenliwu@users.noreply.github.com> Date: Fri, 11 Sep 2026 20:54:06 +0800 Subject: [PATCH 05/14] =?UTF-8?q?feat(plugin):=20=E5=8F=AF=E9=80=89?= =?UTF-8?q?=E4=BE=9D=E8=B5=96=E6=8F=90=E4=BE=9B=20extras=20=E5=AE=89?= =?UTF-8?q?=E8=A3=85=E9=A1=B9=E4=B8=8E=20strict=5Fdependencies=20=E4=B8=A5?= =?UTF-8?q?=E6=A0=BC=E6=A0=A1=E9=AA=8C=20(#575)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: hect0x7 <93357912+hect0x7@users.noreply.github.com> --- pyproject.toml | 11 ++ src/jmcomic/jm_option.py | 52 ++++++++- src/jmcomic/jm_plugin.py | 56 ++++++++++ tests/test_jmcomic/test_jm_plugin.py | 152 +++++++++++++++++++++++++++ 4 files changed, 270 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 007c68baa..be2224380 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,6 +32,17 @@ dependencies = [ ] dynamic = ["version"] +[project.optional-dependencies] +plugins = [ + "img2pdf", + "pikepdf", + "psutil", + "py7zr", + "pyzipper", + "rich", + "browser_cookie3", +] + [project.urls] Homepage = "https://github.com/hect0x7/JMComic-Crawler-Python" Documentation = "https://jmcomic.readthedocs.io" diff --git a/src/jmcomic/jm_option.py b/src/jmcomic/jm_option.py index 701e198ab..68ff0a817 100644 --- a/src/jmcomic/jm_option.py +++ b/src/jmcomic/jm_option.py @@ -203,6 +203,8 @@ def __init__(self, self.need_wait_plugins = [] if call_after_init_plugin: + if self.plugins.src_dict.get('strict_dependencies', False): + self.check_plugins_dependencies() self.call_all_plugin('after_init', safe=True) def copy_option(self): @@ -351,7 +353,9 @@ def log_advice(reason, plugins): # 意图聚焦:建议配置中只展示相关的 zip 插件,剔除其他无关插件的干扰 advice_plugins = {} for g, plist in plugins.items(): - zips = [p for p in plist if p.get('plugin') == 'zip'] + if not isinstance(plist, list): + continue + zips = [p for p in plist if isinstance(p, dict) and p.get('plugin') == 'zip'] if zips: advice_plugins[g] = zips @@ -633,6 +637,52 @@ async def download_photo_async(self, photo_id, *args, **kwargs): # 下面的方法为调用插件提供支持 + def check_plugins_dependencies(self) -> None: + """ + 校验已启用插件的可选依赖库是否安装,缺失则直接报错。 + + 由 plugins.strict_dependencies 配置项开启,在option初始化(after_init)阶段执行, + 让依赖问题在任务启动时就暴露,而不是等到插件静默跳过、产物缺失时才发现。 + + 各插件通过类属性 optional_dependencies 声明所依赖的可选库(import名), + 也可重写 required_dependencies_for(kwargs) 按插件配置返回实际需要的库 + (如未加密的 zip 无需 pyzipper/py7zr)。 + """ + import importlib.util + + # 保证 jm_plugin.py 被加载 + from .jm_plugin import JmOptionPlugin + + plugin_registry = JmModuleConfig.REGISTRY_PLUGIN + missing: Dict[str, List[str]] = {} + + for group, plist in self.plugins.src_dict.items(): + if not isinstance(plist, list): + continue + + for pinfo in plist: + if not isinstance(pinfo, dict): + continue + + pclass: Optional[Type[JmOptionPlugin]] = plugin_registry.get(pinfo.get('plugin'), None) + if pclass is None: + continue + + for lib in pclass.required_dependencies_for(pinfo.get('kwargs') or {}): + if importlib.util.find_spec(lib) is None: + missing.setdefault(lib, []).append(pclass.plugin_key) + + if missing: + detail = '; '.join( + f'库[{lib}] 被插件 {plugin_keys} 使用' + for lib, plugin_keys in sorted(missing.items()) + ) + ExceptionTool.raises( + '插件依赖校验失败(plugins.strict_dependencies = true): ' + f'{detail}。' + '请安装缺失的依赖库,或一键安装全部插件依赖: pip install jmcomic[plugins]' + ) + def call_all_plugin(self, group: str, safe=None, **extra): plugin_list: List[dict] = self.plugins.get(group, []) if plugin_list is None or len(plugin_list) == 0: diff --git a/src/jmcomic/jm_plugin.py b/src/jmcomic/jm_plugin.py index ac7d1e131..462dc0db4 100644 --- a/src/jmcomic/jm_plugin.py +++ b/src/jmcomic/jm_plugin.py @@ -20,6 +20,20 @@ def __init__(self, plugin: 'JmOptionPlugin', msg: str): class JmOptionPlugin: plugin_key: str + # 插件运行所需的非核心依赖库(import名)。 + # 声明后,开启 plugins.strict_dependencies 的option会在初始化阶段统一校验。 + optional_dependencies: tuple = () + + @classmethod + def required_dependencies_for(cls, kwargs: dict) -> tuple: + """ + 返回该插件在给定 kwargs 配置下实际需要的可选库,供 strict_dependencies 校验使用。 + + 默认返回类声明的 optional_dependencies。 + 插件可按配置重写此方法,避免对合法配置误报 + (如未加密的 zip 用标准库 zipfile 即可,无需 pyzipper/py7zr)。 + """ + return cls.optional_dependencies def __init__(self, option: JmOption): self.option = option @@ -175,6 +189,7 @@ def invoke(self, class UsageLogPlugin(JmOptionPlugin): plugin_key = 'usage_log' + optional_dependencies = ('psutil',) def invoke(self, **kwargs) -> None: import threading @@ -324,6 +339,35 @@ class ZipPlugin(JmOptionPlugin): """ plugin_key = 'zip' + # zip 依赖取决于加密配置:未加密用标准库 zipfile,加密 zip 用 pyzipper,7z 用 py7zr + optional_dependencies = () + + @classmethod + def required_dependencies_for(cls, kwargs: dict) -> tuple: + encrypt = cls.check_encrypt_param(kwargs.get('encrypt')) + if not encrypt: + return () + if encrypt.get('impl', '') == '7z': + return ('py7zr',) + return ('pyzipper',) + + @staticmethod + def check_encrypt_param(encrypt): + """ + 校验 encrypt 配置的类型,返回规范化后的值(未配置时返回 None)。 + + encrypt 必须是映射(如 {type: sha256, password: xxx}), + 写成真值标量(encrypt: enabled)时后续的 encrypt.get(...) 会抛 + AttributeError,绕过了配置校验机制、报错也难以理解,这里统一拦掉。 + """ + if encrypt is None: + return None + if not isinstance(encrypt, dict): + ExceptionTool.raises( + f'zip插件的encrypt参数类型有误,预期为映射(如 {{type: sha256, password: xxx}}),' + f'实际类型为{type(encrypt)}' + ) + return encrypt # noinspection PyAttributeOutsideInit def invoke(self, @@ -347,6 +391,8 @@ def invoke(self, level = 'album' if album is not None else 'photo' self.level = level self.delete_original_file = delete_original_file + # 未开启 strict_dependencies 时也拦掉非法的 encrypt 类型 + encrypt = self.check_encrypt_param(encrypt) # 确保压缩文件所在文件夹存在 zip_dir = JmcomicText.parse_to_abspath(zip_dir) @@ -927,6 +973,7 @@ async def __aexit__(self, exc_type, exc_val, exc_tb): class DownloadProgressPlugin(JmOptionPlugin): plugin_key = 'download_progress' + optional_dependencies = ('rich',) log_file = 'jmcomic-download.log' @staticmethod @@ -1036,6 +1083,7 @@ def invoke(self, class AutoSetBrowserCookiesPlugin(JmOptionPlugin): plugin_key = 'auto_set_browser_cookies' + optional_dependencies = ('browser_cookie3',) accepted_cookies_keys = str_to_set(''' yuo1 @@ -1211,6 +1259,14 @@ def zip_with_password(self): class Img2pdfPlugin(JmOptionPlugin): plugin_key = 'img2pdf' + # img2pdf 总是需要;pikepdf 仅在加密 pdf 时需要 + optional_dependencies = ('img2pdf',) + + @classmethod + def required_dependencies_for(cls, kwargs: dict) -> tuple: + if kwargs.get('encrypt'): + return cls.optional_dependencies + ('pikepdf',) + return cls.optional_dependencies def invoke(self, photo: JmPhotoDetail = None, diff --git a/tests/test_jmcomic/test_jm_plugin.py b/tests/test_jmcomic/test_jm_plugin.py index 83dc97abe..c2b2c257b 100644 --- a/tests/test_jmcomic/test_jm_plugin.py +++ b/tests/test_jmcomic/test_jm_plugin.py @@ -42,6 +42,158 @@ def test_plugin_missing_album_context(self): download_photo(photo_id, option, downloader=DoNotDownloadImage) print('✅ All folder rule plugins assert completed safely without KeyError.') + def test_strict_dependencies(self): + """ + source: https://github.com/hect0x7/JMComic-Crawler-Python/issues/572 + + 测试开启 plugins.strict_dependencies 后: + 1. 启用的插件缺少依赖库时,在 option 初始化(after_init)阶段直接报错 + 2. 未开启时保持原有行为,不校验 + """ + from jmcomic import JmOption, JmModuleConfig, JmcomicException + + # 临时给 img2pdf 插件声明一个必然不存在的依赖,保证测试不依赖运行环境装了什么库 + pclass = JmModuleConfig.REGISTRY_PLUGIN['img2pdf'] + origin_deps = pclass.optional_dependencies + pclass.optional_dependencies = ('__lib_not_exists__',) + try: + # 1) 开启 strict_dependencies,构建 option 时应直接抛异常 + dic = { + 'plugins': { + 'strict_dependencies': True, + 'after_album': [{'plugin': 'img2pdf'}], + } + } + with self.assertRaises(JmcomicException) as ctx: + JmOption.construct(dic) + self.assertIn('strict_dependencies', str(ctx.exception)) + self.assertIn('pip install jmcomic[plugins]', str(ctx.exception)) + print('✅ strict_dependencies on: missing lib raises at option init.') + + # 2) 未开启时,不做校验,正常构建 + dic2 = { + 'plugins': { + 'after_album': [{'plugin': 'img2pdf'}], + } + } + option = JmOption.construct(dic2) + self.assertIsNotNone(option) + print('✅ strict_dependencies off: keeps silent as before.') + finally: + pclass.optional_dependencies = origin_deps + + def test_strict_dependencies_kwargs_aware(self): + """ + source: https://github.com/hect0x7/JMComic-Crawler-Python/pull/575 (CodeRabbit review) + + 校验应感知插件配置(required_dependencies_for),并直接测真实解析器, + 只 mock importlib.util.find_spec 模拟库缺失,不替换解析器本身: + 1. 未加密 zip:真实解析器返回 (),即便 pyzipper/py7zr 缺失也不误报 + 2. 加密 zip(impl=7z):解析器返回 py7zr,缺失时报错 + 3. 默认加密 zip:解析器返回 pyzipper,缺失时报错 + 4. img2pdf 未加密:解析器返回 (img2pdf,),不查 pikepdf + 5. img2pdf 加密:解析器返回 (img2pdf, pikepdf),缺失时报错 + """ + import importlib.util as _iu + from unittest import mock + + from jmcomic import JmOption, JmcomicException + + real_find_spec = _iu.find_spec + + def find_spec_missing(*missing): + def fake(name, *args, **kwargs): + if name in missing: + return None + # img2pdf 是 img2pdf 插件的无条件依赖,未显式列入 missing 时 + # 固定返回非 None,避免用例结果取决于运行环境装没装 img2pdf, + # 也让加密用例能确定性地走到 pikepdf 检查 + if name == 'img2pdf': + return object() + return real_find_spec(name, *args, **kwargs) + return fake + + def build_zip(kwargs): + return {'plugins': {'strict_dependencies': True, + 'after_album': [{'plugin': 'zip', 'kwargs': kwargs}]}} + + # 1) 未加密 zip:不查任何加密库 + with mock.patch('importlib.util.find_spec', side_effect=find_spec_missing('pyzipper', 'py7zr')): + self.assertIsNotNone(JmOption.construct(build_zip({'zip_dir': './'}))) + print('✅ real resolver: plain zip needs no optional lib.') + + # 2) 加密 zip(impl=7z):查 py7zr + with mock.patch('importlib.util.find_spec', side_effect=find_spec_missing('py7zr')): + with self.assertRaises(JmcomicException): + JmOption.construct(build_zip({'zip_dir': './', 'encrypt': {'impl': '7z'}})) + print('✅ real resolver: 7z zip requires py7zr.') + + # 3) 默认加密 zip:查 pyzipper + with mock.patch('importlib.util.find_spec', side_effect=find_spec_missing('pyzipper')): + with self.assertRaises(JmcomicException): + JmOption.construct(build_zip({'zip_dir': './', 'encrypt': {'type': 'sha256'}})) + print('✅ real resolver: encrypted zip requires pyzipper.') + + # 4) img2pdf 未加密:不查 pikepdf + dic4 = {'plugins': {'strict_dependencies': True, + 'after_album': [{'plugin': 'img2pdf'}]}} + with mock.patch('importlib.util.find_spec', side_effect=find_spec_missing('pikepdf')): + self.assertIsNotNone(JmOption.construct(dic4)) + + # 5) img2pdf 加密:查 pikepdf + dic5 = {'plugins': {'strict_dependencies': True, + 'after_album': [{'plugin': 'img2pdf', 'kwargs': {'encrypt': {'type': 'sha256'}}}]}} + with mock.patch('importlib.util.find_spec', side_effect=find_spec_missing('pikepdf')): + with self.assertRaises(JmcomicException): + JmOption.construct(dic5) + print('✅ real resolver: img2pdf requires pikepdf only when encrypt.') + + def test_strict_dependencies_rejects_non_mapping_encrypt(self): + """ + source: https://github.com/hect0x7/JMComic-Crawler-Python/pull/575 (CodeRabbit review) + + encrypt 必须是映射。写成真值标量(encrypt: enabled)时,之前会在 + required_dependencies_for 里 encrypt.get(...) 抛 AttributeError, + 报错既不是 JmcomicException、也绕过了配置校验机制。 + 现在应统一抛出可读的配置错误。 + """ + from jmcomic import JmOption, JmcomicException + + for bad in ('enabled', True, 1): + dic = {'plugins': {'strict_dependencies': True, + 'after_album': [{'plugin': 'zip', + 'kwargs': {'zip_dir': './', 'encrypt': bad}}]}} + # 不能是 AttributeError + try: + JmOption.construct(dic) + except JmcomicException as e: + self.assertIn('encrypt', str(e)) + except AttributeError as e: + self.fail(f'encrypt={bad!r} 仍抛 AttributeError: {e}') + else: + self.fail(f'encrypt={bad!r} 应当抛配置错误,实际构建成功') + print('✅ non-mapping encrypt rejected with a readable config error.') + + # 未开启 strict_dependencies 时,construct 阶段不校验插件配置, + # 但真正调用 zip 插件时应抛出可读的配置错误,而不是 AttributeError + from jmcomic import JmModuleConfig + + zip_cls = JmModuleConfig.REGISTRY_PLUGIN['zip'] + + class _FakeOption: + pass + + fake = _FakeOption() + try: + zip_cls(fake).check_encrypt_param('enabled') + except JmcomicException as e: + self.assertIn('encrypt', str(e)) + except AttributeError as e: + self.fail(f'zip 插件仍抛 AttributeError: {e}') + else: + self.fail('非 mapping 的 encrypt 应当抛配置错误') + print('✅ non-mapping encrypt rejected instead of raising AttributeError.') + @unittest.skipUnless( HAS_JMCOMIC_CALIBRE, '这条用例依赖 jmcomic-calibre 生成 OPF:pip install jmcomic-calibre', From 01e54dd0b37a8f20146250162dd09593ed010dea Mon Sep 17 00:00:00 2001 From: hect0x7 <93357912+hect0x7@users.noreply.github.com> Date: Fri, 11 Sep 2026 21:42:32 +0800 Subject: [PATCH 06/14] =?UTF-8?q?feat(plugin):=20=E6=8F=92=E4=BB=B6?= =?UTF-8?q?=E4=BE=9D=E8=B5=96=E7=AD=96=E7=95=A5=E6=94=AF=E6=8C=81=20failed?= =?UTF-8?q?-fast=20/=20auto-install=20/=20ignore-only-log?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/docs/sources/option_file_syntax.md | 23 ++++ src/jmcomic/jm_config.py | 2 + src/jmcomic/jm_option.py | 36 ++---- src/jmcomic/jm_plugin.py | 132 +++++++++++++++++++--- tests/test_jmcomic/test_jm_plugin.py | 103 ++++++++++++----- 5 files changed, 228 insertions(+), 68 deletions(-) diff --git a/assets/docs/sources/option_file_syntax.md b/assets/docs/sources/option_file_syntax.md index 1b50236f6..5613cb9dd 100644 --- a/assets/docs/sources/option_file_syntax.md +++ b/assets/docs/sources/option_file_syntax.md @@ -144,9 +144,32 @@ dir_rule: * **插件配置中的kwargs参数支持引用环境变量,语法为 ${环境变量名}** +> **💡 依赖安装建议**: +> +> 部分插件可能需要安装额外依赖,这里推荐: +> +> - **纯小白 / 完全不关心具体依赖的用户**:推荐直接在终端执行一键安装插件依赖全家桶: +> ```bash +> pip install jmcomic[plugins] +> ``` +> - **关注环境体积 / 仅需特定功能的用户**:无需安装全家桶,只需在用到具体插件时按需安装对应依赖即可。也可通过下面的 `plugins.dependencies_strategy` 配置处理策略。 + ```yaml # 插件的配置示例 plugins: + dependencies_strategy: failed-fast + # dependencies_strategy: 插件依赖处理策略(默认: failed-fast) + # 只有在不采用全家桶安装时,才需要根据你的使用场景权衡选择以下策略: + # 1. failed-fast (默认): 缺失依赖时快速失败,在启动阶段直接报错并给出解决方案。 + # - 利:防呆、安全性最高,避免任务跑了一半因为缺依赖而报错或生成物缺失。 + # - 弊:只要启用的插件缺少依赖就会阻断运行,必须先安装依赖。 + # 2. auto-install: 启动时检测到缺失依赖,自动调用 pip 在当前 Python 环境中排队静默安装。 + # - 利:省心无感,免去手动执行 pip install 的繁琐步骤。 + # - 弊:若处于无外网环境或无系统安装权限(如未激活虚拟环境),安装失败也会严格抛错。 + # 3. ignore-only-log: 缺失依赖时仅打印 Warning 日志,不阻断运行。 + # - 利:容错性最强,不会因缺库中断主流程。 + # - 弊:依赖缺失的插件可能在执行阶段被跳过,容易造成“配置了插件却没有产出”的困惑。 + after_init: - plugin: download_progress kwargs: diff --git a/src/jmcomic/jm_config.py b/src/jmcomic/jm_config.py index 0ac46a38a..17db8c654 100644 --- a/src/jmcomic/jm_config.py +++ b/src/jmcomic/jm_config.py @@ -540,6 +540,8 @@ def new_postman(cls, session=False, **kwargs): # 如果插件抛出参数校验异常,只log。(全局配置,可以被插件的局部配置覆盖) # 可选值:ignore(忽略),log(打印日志),raise(抛异常)。 'valid': 'log', + # 插件依赖处理策略。可选值:failed-fast(快速失败),auto-install(自动安装),ignore-only-log(只记录日志不报错)。 + 'dependencies_strategy': 'failed-fast', }, } diff --git a/src/jmcomic/jm_option.py b/src/jmcomic/jm_option.py index 68ff0a817..a712a5cf9 100644 --- a/src/jmcomic/jm_option.py +++ b/src/jmcomic/jm_option.py @@ -202,9 +202,10 @@ def __init__(self, # 需要主线程等待完成的插件 self.need_wait_plugins = [] + # 依赖策略预检/处理 + self.check_plugins_dependencies() + if call_after_init_plugin: - if self.plugins.src_dict.get('strict_dependencies', False): - self.check_plugins_dependencies() self.call_all_plugin('after_init', safe=True) def copy_option(self): @@ -639,22 +640,20 @@ async def download_photo_async(self, photo_id, *args, **kwargs): def check_plugins_dependencies(self) -> None: """ - 校验已启用插件的可选依赖库是否安装,缺失则直接报错。 + 根据 plugins.dependencies_strategy 策略校验或安装已启用插件所需的依赖库。 - 由 plugins.strict_dependencies 配置项开启,在option初始化(after_init)阶段执行, - 让依赖问题在任务启动时就暴露,而不是等到插件静默跳过、产物缺失时才发现。 + 支持的策略值: + - failed-fast(默认):缺失依赖时快速失败,抛出带清晰解决方案指引的异常。 + - auto-install:缺失依赖时以插件为维度自动调用 pip 安装缺失包。 + - ignore-only-log:缺失依赖时仅打印 warning 日志,不阻断运行。 - 各插件通过类属性 optional_dependencies 声明所依赖的可选库(import名), - 也可重写 required_dependencies_for(kwargs) 按插件配置返回实际需要的库 - (如未加密的 zip 无需 pyzipper/py7zr)。 + 具体检查、安装与错误处理逻辑收口于 JmOptionPlugin.check_plugin_dependency。 """ - import importlib.util - # 保证 jm_plugin.py 被加载 from .jm_plugin import JmOptionPlugin + strategy = self.plugins.dependencies_strategy plugin_registry = JmModuleConfig.REGISTRY_PLUGIN - missing: Dict[str, List[str]] = {} for group, plist in self.plugins.src_dict.items(): if not isinstance(plist, list): @@ -668,20 +667,7 @@ def check_plugins_dependencies(self) -> None: if pclass is None: continue - for lib in pclass.required_dependencies_for(pinfo.get('kwargs') or {}): - if importlib.util.find_spec(lib) is None: - missing.setdefault(lib, []).append(pclass.plugin_key) - - if missing: - detail = '; '.join( - f'库[{lib}] 被插件 {plugin_keys} 使用' - for lib, plugin_keys in sorted(missing.items()) - ) - ExceptionTool.raises( - '插件依赖校验失败(plugins.strict_dependencies = true): ' - f'{detail}。' - '请安装缺失的依赖库,或一键安装全部插件依赖: pip install jmcomic[plugins]' - ) + pclass.check_plugin_dependency(pinfo.get('kwargs') or {}, strategy=strategy) def call_all_plugin(self, group: str, safe=None, **extra): plugin_list: List[dict] = self.plugins.get(group, []) diff --git a/src/jmcomic/jm_plugin.py b/src/jmcomic/jm_plugin.py index 462dc0db4..d48abb051 100644 --- a/src/jmcomic/jm_plugin.py +++ b/src/jmcomic/jm_plugin.py @@ -20,20 +20,126 @@ def __init__(self, plugin: 'JmOptionPlugin', msg: str): class JmOptionPlugin: plugin_key: str - # 插件运行所需的非核心依赖库(import名)。 - # 声明后,开启 plugins.strict_dependencies 的option会在初始化阶段统一校验。 - optional_dependencies: tuple = () + # 插件运行所需的依赖库。 + # 元素可以是 import 模块名(如 'psutil'),或者元组 (import_name, pip_pkg_name)。 + plugin_dependencies: tuple = () @classmethod def required_dependencies_for(cls, kwargs: dict) -> tuple: """ - 返回该插件在给定 kwargs 配置下实际需要的可选库,供 strict_dependencies 校验使用。 + 返回该插件在给定 kwargs 配置下实际需要的依赖库,供依赖策略校验使用。 - 默认返回类声明的 optional_dependencies。 + 默认返回类声明的 plugin_dependencies。 插件可按配置重写此方法,避免对合法配置误报 (如未加密的 zip 用标准库 zipfile 即可,无需 pyzipper/py7zr)。 """ - return cls.optional_dependencies + return cls.plugin_dependencies + + @classmethod + def parse_dependency_spec(cls, dep: Union[str, Tuple[str, str]]) -> Tuple[str, str]: + """ + 解析依赖规格,返回 (import_name, pip_pkg_name)。 + """ + if isinstance(dep, tuple): + return dep[0], dep[1] + return dep, dep + + @classmethod + def check_plugin_dependency(cls, kwargs: dict, strategy: str = 'failed-fast') -> None: + """ + 检查当前插件在给定配置下所需的依赖库是否已安装,并按全局策略处理缺失情况。 + + 该方法由 JmOption 初始化(__init__)阶段在执行 after_init 之前统一触发, + 确保依赖问题在任务启动伊始即被捕获或解决。 + + :param kwargs: 当前插件在 option 中配置的参数字典,用于动态推断依赖 + :param strategy: 依赖处理策略,对应配置 plugins.dependencies_strategy: + - 'failed-fast': 快速失败(默认),抛出异常并提示具体的解决方案; + - 'auto-install': 自动通过 pip 安装缺失的依赖包,安装失败严格报错; + - 'ignore-only-log': 仅输出 warning 日志,不阻断运行。 + """ + import importlib.util + + # 1. 获取当前插件在特定配置下实际需要的可选依赖列表(如未加密 zip 无需额外库) + req_deps = cls.required_dependencies_for(kwargs) + if not req_deps: + return + + # 2. 逐项检查依赖模块是否可用,收集所有缺失项 (import_name, pip_pkg_name) + missing: List[Tuple[str, str]] = [] + for dep in req_deps: + import_name, pip_name = cls.parse_dependency_spec(dep) + if importlib.util.find_spec(import_name) is None: + missing.append((import_name, pip_name)) + + # 全部依赖已就绪,无需后续处理 + if not missing: + return + + # 3. 整理缺失模块名与 pip 安装包名,拼接命令与提示文案 + missing_import_names = [m[0] for m in missing] + missing_pip_names = [m[1] for m in missing] + import_names_str = ', '.join(missing_import_names) + pip_install_cmd = 'pip install ' + ' '.join(missing_pip_names) + + # 4. 根据策略分发处理: + if strategy == 'auto-install': + # 策略一:自动安装。以插件为单位排队调用 pip 安装缺失包 + cls.install_missing_dependencies(missing_pip_names) + elif strategy == 'ignore-only-log': + # 策略二:仅打日志。记录警告信息,不抛出异常,保持最大容错 + jm_log( + topic=f'plugin.{cls.plugin_key}.dependency', + msg=f'插件 [{cls.plugin_key}] 缺少依赖库 [{import_names_str}],' + f'可能会影响该插件执行。安装命令: [{pip_install_cmd}]' + ) + else: + # 策略三:failed-fast(默认)。阻断执行并输出包含3种方案的用户指引 + error_msg = ( + f"插件 [{cls.plugin_key}] 缺少依赖库 [{import_names_str}],无法执行插件。有3种解决方案需要你选择一种手动执行:\n" + f"1. 仅安装该库,手动执行: {pip_install_cmd}\n" + f"2. 一键安装jmcomic插件依赖全家桶,手动执行: pip install jmcomic[plugins]\n" + f"3. 修改option,让jmcomic自动安装或不要报错\n" + f"plugins:\n" + f" dependencies_strategy: failed-fast # 👈当前默认配置,可配置为以下值\n" + f" # auto-install # 缺失时自动安装依赖\n" + f" # ignore-only-log # 缺失时仅打印失败日志不报错" + ) + ExceptionTool.raises(error_msg) + + @classmethod + def install_missing_dependencies(cls, pip_packages: List[str]) -> None: + """ + 通过 pip 自动安装缺失的依赖包。如果安装失败则严格抛出异常。 + """ + import sys + import subprocess + import importlib + + cmd = [sys.executable, '-m', 'pip', 'install'] + pip_packages + jm_log( + topic=f'plugin.{cls.plugin_key}.dependency', + msg=f'检测到插件 [{cls.plugin_key}] 缺少依赖,auto-install 策略正在自动安装: {" ".join(cmd)}' + ) + + try: + res = subprocess.run(cmd, capture_output=True, text=True, check=True) + if res.stdout: + jm_log(topic=f'plugin.{cls.plugin_key}.dependency', msg=res.stdout.strip()) + # 刷新导入缓存,保证后续 import 能够正常找到新装的包 + importlib.invalidate_caches() + jm_log( + topic=f'plugin.{cls.plugin_key}.dependency', + msg=f'插件 [{cls.plugin_key}] 依赖安装成功: {" ".join(pip_packages)}' + ) + except Exception as e: + err_output = getattr(e, 'stderr', '') or str(e) + ExceptionTool.raises( + f'插件 [{cls.plugin_key}] 自动安装依赖 [{" ".join(pip_packages)}] 失败。\n' + f'执行命令: {" ".join(cmd)}\n' + f'错误详情: {err_output}\n' + f'请排查网络/权限问题,或手动执行: pip install {" ".join(pip_packages)}' + ) def __init__(self, option: JmOption): self.option = option @@ -189,7 +295,7 @@ def invoke(self, class UsageLogPlugin(JmOptionPlugin): plugin_key = 'usage_log' - optional_dependencies = ('psutil',) + plugin_dependencies = ('psutil',) def invoke(self, **kwargs) -> None: import threading @@ -340,7 +446,7 @@ class ZipPlugin(JmOptionPlugin): plugin_key = 'zip' # zip 依赖取决于加密配置:未加密用标准库 zipfile,加密 zip 用 pyzipper,7z 用 py7zr - optional_dependencies = () + plugin_dependencies = () @classmethod def required_dependencies_for(cls, kwargs: dict) -> tuple: @@ -973,7 +1079,7 @@ async def __aexit__(self, exc_type, exc_val, exc_tb): class DownloadProgressPlugin(JmOptionPlugin): plugin_key = 'download_progress' - optional_dependencies = ('rich',) + plugin_dependencies = ('rich',) log_file = 'jmcomic-download.log' @staticmethod @@ -1083,7 +1189,7 @@ def invoke(self, class AutoSetBrowserCookiesPlugin(JmOptionPlugin): plugin_key = 'auto_set_browser_cookies' - optional_dependencies = ('browser_cookie3',) + plugin_dependencies = ('browser_cookie3',) accepted_cookies_keys = str_to_set(''' yuo1 @@ -1260,13 +1366,13 @@ def zip_with_password(self): class Img2pdfPlugin(JmOptionPlugin): plugin_key = 'img2pdf' # img2pdf 总是需要;pikepdf 仅在加密 pdf 时需要 - optional_dependencies = ('img2pdf',) + plugin_dependencies = ('img2pdf',) @classmethod def required_dependencies_for(cls, kwargs: dict) -> tuple: if kwargs.get('encrypt'): - return cls.optional_dependencies + ('pikepdf',) - return cls.optional_dependencies + return cls.plugin_dependencies + ('pikepdf',) + return cls.plugin_dependencies def invoke(self, photo: JmPhotoDetail = None, diff --git a/tests/test_jmcomic/test_jm_plugin.py b/tests/test_jmcomic/test_jm_plugin.py index c2b2c257b..fd6595922 100644 --- a/tests/test_jmcomic/test_jm_plugin.py +++ b/tests/test_jmcomic/test_jm_plugin.py @@ -42,47 +42,95 @@ def test_plugin_missing_album_context(self): download_photo(photo_id, option, downloader=DoNotDownloadImage) print('✅ All folder rule plugins assert completed safely without KeyError.') - def test_strict_dependencies(self): + def test_dependencies_strategy_failed_fast(self): """ source: https://github.com/hect0x7/JMComic-Crawler-Python/issues/572 - 测试开启 plugins.strict_dependencies 后: - 1. 启用的插件缺少依赖库时,在 option 初始化(after_init)阶段直接报错 - 2. 未开启时保持原有行为,不校验 + 测试默认/显式配置 plugins.dependencies_strategy: failed-fast: + 1. 默认情况下,启用的插件缺少依赖库时,在 option 初始化阶段直接报错,报错提示包含3种解决方案 + 2. 显式配置 dependencies_strategy: failed-fast 同样快速失败 + 3. 配置 ignore-only-log 时,仅打印日志不报错 """ from jmcomic import JmOption, JmModuleConfig, JmcomicException - # 临时给 img2pdf 插件声明一个必然不存在的依赖,保证测试不依赖运行环境装了什么库 pclass = JmModuleConfig.REGISTRY_PLUGIN['img2pdf'] - origin_deps = pclass.optional_dependencies - pclass.optional_dependencies = ('__lib_not_exists__',) + origin_deps = pclass.plugin_dependencies + pclass.plugin_dependencies = ('__lib_not_exists__',) try: - # 1) 开启 strict_dependencies,构建 option 时应直接抛异常 - dic = { + # 1) 默认策略(即 failed-fast),构建 option 时应直接抛异常 + dic_default = { 'plugins': { - 'strict_dependencies': True, 'after_album': [{'plugin': 'img2pdf'}], } } with self.assertRaises(JmcomicException) as ctx: - JmOption.construct(dic) - self.assertIn('strict_dependencies', str(ctx.exception)) - self.assertIn('pip install jmcomic[plugins]', str(ctx.exception)) - print('✅ strict_dependencies on: missing lib raises at option init.') + JmOption.construct(dic_default) + err_text = str(ctx.exception) + self.assertIn('dependencies_strategy: failed-fast', err_text) + self.assertIn('pip install jmcomic[plugins]', err_text) + self.assertIn('pip install __lib_not_exists__', err_text) + print('✅ default strategy (failed-fast): missing lib raises at option init with guide.') + + # 2) 显式配置 dependencies_strategy: failed-fast + dic_explicit = { + 'plugins': { + 'dependencies_strategy': 'failed-fast', + 'after_album': [{'plugin': 'img2pdf'}], + } + } + with self.assertRaises(JmcomicException) as ctx: + JmOption.construct(dic_explicit) + self.assertIn('dependencies_strategy: failed-fast', str(ctx.exception)) + print('✅ explicit failed-fast: missing lib raises at option init.') - # 2) 未开启时,不做校验,正常构建 - dic2 = { + # 3) 配置 ignore-only-log: 仅打印警告,不阻断构建 + dic_ignore = { 'plugins': { + 'dependencies_strategy': 'ignore-only-log', 'after_album': [{'plugin': 'img2pdf'}], } } - option = JmOption.construct(dic2) + option = JmOption.construct(dic_ignore) self.assertIsNotNone(option) - print('✅ strict_dependencies off: keeps silent as before.') + print('✅ ignore-only-log: logs warning and builds successfully.') + finally: + pclass.plugin_dependencies = origin_deps + + def test_dependencies_strategy_auto_install(self): + """ + 测试 plugins.dependencies_strategy: auto-install 行为: + 1. 缺失依赖时,触发 install_missing_dependencies + 2. 若 pip 安装失败,严格抛出异常 + """ + from unittest import mock + from jmcomic import JmOption, JmModuleConfig, JmcomicException + + pclass = JmModuleConfig.REGISTRY_PLUGIN['img2pdf'] + origin_deps = pclass.plugin_dependencies + pclass.plugin_dependencies = ('__fake_lib_to_install__',) + try: + dic = { + 'plugins': { + 'dependencies_strategy': 'auto-install', + 'after_album': [{'plugin': 'img2pdf'}], + } + } + # 模拟安装成功 + with mock.patch.object(pclass, 'install_missing_dependencies') as mock_install: + option = JmOption.construct(dic) + self.assertIsNotNone(option) + mock_install.assert_called_once_with(['__fake_lib_to_install__']) + print('✅ auto-install: triggers install_missing_dependencies with missing packages.') + + # 模拟安装抛错,严格失败 + with self.assertRaises(JmcomicException) as ctx: + JmOption.construct(dic) + self.assertIn('自动安装依赖', str(ctx.exception)) + print('✅ auto-install: strict failure when pip install fails.') finally: - pclass.optional_dependencies = origin_deps + pclass.plugin_dependencies = origin_deps - def test_strict_dependencies_kwargs_aware(self): + def test_dependencies_strategy_kwargs_aware(self): """ source: https://github.com/hect0x7/JMComic-Crawler-Python/pull/575 (CodeRabbit review) @@ -114,8 +162,7 @@ def fake(name, *args, **kwargs): return fake def build_zip(kwargs): - return {'plugins': {'strict_dependencies': True, - 'after_album': [{'plugin': 'zip', 'kwargs': kwargs}]}} + return {'plugins': {'after_album': [{'plugin': 'zip', 'kwargs': kwargs}]}} # 1) 未加密 zip:不查任何加密库 with mock.patch('importlib.util.find_spec', side_effect=find_spec_missing('pyzipper', 'py7zr')): @@ -135,14 +182,12 @@ def build_zip(kwargs): print('✅ real resolver: encrypted zip requires pyzipper.') # 4) img2pdf 未加密:不查 pikepdf - dic4 = {'plugins': {'strict_dependencies': True, - 'after_album': [{'plugin': 'img2pdf'}]}} + dic4 = {'plugins': {'after_album': [{'plugin': 'img2pdf'}]}} with mock.patch('importlib.util.find_spec', side_effect=find_spec_missing('pikepdf')): self.assertIsNotNone(JmOption.construct(dic4)) # 5) img2pdf 加密:查 pikepdf - dic5 = {'plugins': {'strict_dependencies': True, - 'after_album': [{'plugin': 'img2pdf', 'kwargs': {'encrypt': {'type': 'sha256'}}}]}} + dic5 = {'plugins': {'after_album': [{'plugin': 'img2pdf', 'kwargs': {'encrypt': {'type': 'sha256'}}}]}} with mock.patch('importlib.util.find_spec', side_effect=find_spec_missing('pikepdf')): with self.assertRaises(JmcomicException): JmOption.construct(dic5) @@ -160,8 +205,7 @@ def test_strict_dependencies_rejects_non_mapping_encrypt(self): from jmcomic import JmOption, JmcomicException for bad in ('enabled', True, 1): - dic = {'plugins': {'strict_dependencies': True, - 'after_album': [{'plugin': 'zip', + dic = {'plugins': {'after_album': [{'plugin': 'zip', 'kwargs': {'zip_dir': './', 'encrypt': bad}}]}} # 不能是 AttributeError try: @@ -174,8 +218,7 @@ def test_strict_dependencies_rejects_non_mapping_encrypt(self): self.fail(f'encrypt={bad!r} 应当抛配置错误,实际构建成功') print('✅ non-mapping encrypt rejected with a readable config error.') - # 未开启 strict_dependencies 时,construct 阶段不校验插件配置, - # 但真正调用 zip 插件时应抛出可读的配置错误,而不是 AttributeError + # 真正调用 zip 插件时也应抛出可读的配置错误,而不是 AttributeError from jmcomic import JmModuleConfig zip_cls = JmModuleConfig.REGISTRY_PLUGIN['zip'] From 2673abf79262811653bb98b5032324ac2f858dd8 Mon Sep 17 00:00:00 2001 From: hect0x7 <93357912+hect0x7@users.noreply.github.com> Date: Fri, 11 Sep 2026 18:11:08 +0800 Subject: [PATCH 07/14] =?UTF-8?q?feat:=20=E6=89=93=E5=8D=A1=E6=97=A5?= =?UTF-8?q?=E5=8E=86=E3=80=81=E7=AD=BE=E5=88=B0=E5=8A=9F=E8=83=BD=EF=BC=8C?= =?UTF-8?q?=E5=90=8C=E6=97=B6=E6=94=AF=E6=8C=81html+api?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 41 +++ .../docs/sources/tutorial/0_common_usage.md | 29 +- src/jmcomic/__init__.py | 2 +- src/jmcomic/jm_async_client.py | 60 +++- src/jmcomic/jm_client_impl.py | 143 +++++++- src/jmcomic/jm_client_interface.py | 127 +++++++ src/jmcomic/jm_entity.py | 4 + src/jmcomic/jm_toolkit.py | 30 +- tests/test_jmcomic/test_jm_album_favorite.py | 146 ++++++++ tests/test_jmcomic/test_jm_checkin.py | 331 ++++++++++++++++++ tests/test_jmcomic/test_jm_client.py | 33 ++ 11 files changed, 939 insertions(+), 7 deletions(-) create mode 100644 tests/test_jmcomic/test_jm_album_favorite.py create mode 100644 tests/test_jmcomic/test_jm_checkin.py diff --git a/CHANGELOG.md b/CHANGELOG.md index a9f87541e..57ec07e90 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,47 @@ 条目分类参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.0.0/), 版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。 +## [2.7.7] - 2026-09-11 + +### Summary + +本次更新新增 `calibre_metadata` 插件为 Calibre 用户生成元数据文件,引入插件依赖声明与三种依赖策略,提供 `pip install jmcomic[plugins]` 一键安装全部插件可选依赖,实现网页端每日签到打卡,修复 HTML 登录状态码校验缺陷,并为本子详情实体增加 `is_favorite` 与 `liked` 字段。 + +### Added + +#### 插件系统 + +| 功能 | 说明 | +|:---|:---| +| `calibre_metadata` 插件 | 挂在 `after_album` 阶段为每个本子生成 Calibre 可识别的 `metadata.opf`,OPF 生成由 `jmcomic-calibre` 包提供;支持 `include_cover` 下载封面、`fields` 追加自定义 Dublin Core 元素。 | +| `plugin_dependencies` 声明 | 插件基类新增 `plugin_dependencies` 类属性声明运行时依赖,并提供 `required_dependencies_for(kwargs)` 钩子按配置动态扩展(如 `zip` 按加密方式追加 `pyzipper`/`py7zr`,`img2pdf` 启用加密时追加 `pikepdf`)。 | +| `dependencies_strategy` 配置 | 新增 `plugins.dependencies_strategy`,支持三种策略:`failed-fast`(默认,缺失即报错并给出安装指引)、`auto-install`(自动 pip 安装)、`ignore-only-log`(仅警告不阻断)。 | +| `[plugins]` extras | `pyproject.toml` 新增 extras,`pip install jmcomic[plugins]` 一次性安装全部插件可选依赖(`img2pdf`、`pikepdf`、`psutil`、`py7zr`、`pyzipper`、`rich`、`browser_cookie3`)。 | +| 已有插件依赖补齐 | 为 `usage_log`、`download_progress`、`auto_set_browser_cookies` 等已有插件补齐 `plugin_dependencies` 声明。 | + +#### 每日签到 + +| 功能 | 说明 | +|:---|:---| +| `get_daily` / `daily_checkin` | 网页端 HTML 客户端获取每日签到信息与打卡日历(`/ajax/user_daily_event`)、每日签到(`/ajax/user_daily_sign`),未提供 `daily_id` 时自动从首页提取。 | +| `JmDailyCheckinResp` | 双端统一返回标准打卡状态(`code=0` 签到成功,`code=1` 今日重复打卡,透传服务端 `msg`),其余失败直接抛异常。 | + +#### 实体字段 + +| 字段 | 说明 | +|:---|:---| +| `JmAlbumDetail.is_favorite` | `bool`,HTML 网页端与 API 移动端双端识别本子收藏状态。 | +| `JmAlbumDetail.liked` | `bool`,HTML 网页端与 API 移动端双端识别本子点赞/喜欢状态。 | + +### Changed + +| 变更点 | 说明 | +|:---|:---| +| `zip` 插件 `encrypt` 校验 | 新增参数类型校验,非法标量(如 `encrypt: enabled`)在初始化阶段即拦截并给出清晰报错,不再等到运行时抛出 `AttributeError`。 | +| HTML 登录接口返回值更新 | `POST /login` 密码错误时 HTTP 200 误判为成功,增加 `status == 1` 严格校验并提取错误信息。 | +| API `page_count` / `pub_date` 字段适配最新返回值 | `page_count` 正确映射为全本总图片数(`total_photos`),`pub_date` 格式化为发布日期。 | +| `PhotoConcurrentFetcherProxy` 代理委托 | 补齐 `__getattr__` 委托,修复底层客户端方法丢失的问题。 | + ## [2.7.6] - 2026-09-09 ### Summary diff --git a/assets/docs/sources/tutorial/0_common_usage.md b/assets/docs/sources/tutorial/0_common_usage.md index 0da5a4428..e6d1c7122 100644 --- a/assets/docs/sources/tutorial/0_common_usage.md +++ b/assets/docs/sources/tutorial/0_common_usage.md @@ -292,7 +292,7 @@ page = client.favorite_folder(page=1, ### 添加与取消收藏 -支持直接通过本子 ID 添加或取消收藏(HTML 网页端与 API 端均支持): +支持直接通过本子 ID 添加或取消收藏: ```python # 把本子加入收藏 @@ -302,6 +302,33 @@ client.add_favorite_album('438696') client.delete_favorite_album('438696') ``` +## 每日签到与打卡日历 + +```python +from jmcomic import * + +client = JmOption.default().build_jm_client() + +# 先登录(打卡和获取签到日历都需要登录) +client.login('你的用户名', '你的密码') # 也支持在 option.yml 中启用自动登录插件 + +# 获取当月每日签到与打卡日历信息(包含已签到、漏签等个人签到记录) +daily_resp = client.get_daily() +# 不同客户端实现的返回值不一样,不好统一,因此具体json格式请查看 get_daily() 的方法注释。 +print(daily_resp) + +# 执行今日签到打卡 +try: + checkin_resp: JmDailyCheckinResp = client.daily_checkin() + # code: [0]-签到成功,[1]-今日重复签到,其余失败情况会直接抛出异常。 + if checkin_resp.code == 0: + print(f'签到成功: {checkin_resp.msg}') + elif checkin_resp.code == 1: + print(f'今日重复签到: {checkin_resp.msg}') +except JmcomicException as e: + print(f'签到失败: {e}') +``` + ## 分类 / 排行榜 禁漫的分类是一个和搜索有些类似的功能。 diff --git a/src/jmcomic/__init__.py b/src/jmcomic/__init__.py index 25d6c7b18..4745c5cad 100644 --- a/src/jmcomic/__init__.py +++ b/src/jmcomic/__init__.py @@ -2,7 +2,7 @@ # 被依赖方 <--- 使用方 # config <--- entity <--- toolkit <--- client <--- option <--- downloader -__version__ = '2.7.6' +__version__ = '2.7.7' from .jm_task_context import * from .api import * diff --git a/src/jmcomic/jm_async_client.py b/src/jmcomic/jm_async_client.py index d5bf0577e..5947d8db7 100644 --- a/src/jmcomic/jm_async_client.py +++ b/src/jmcomic/jm_async_client.py @@ -14,7 +14,7 @@ from curl_cffi.requests import AsyncSession from .jm_client_interface import ( - JmApiResp, JmImageResp, JmAlbumCommentResp, + JmApiResp, JmImageResp, JmAlbumCommentResp, JmDailyCheckinResp, AsyncJmcomicClient, ) from .jm_entity import ( @@ -49,6 +49,8 @@ class AsyncJmApiClient(AsyncJmcomicClient): API_SCRAMBLE = '/chapter_view_template' API_FAVORITE = '/favorite' API_FORUM = '/forum' + API_DAILY = '/daily' + API_DAILY_CHK = '/daily_chk' # 缓存未命中标记 _SENTINEL = object() @@ -81,6 +83,7 @@ def __init__(self, option: JmOption, max_clients=None, domain_list=None, **kwarg # 缓存默认关闭,由外部配置决定是否启用。 self._cache: dict | None = None self._username: str | None = None + self._user_id: str | None = None # 接收并保存额外的会话级元数据参数 self._meta_kwargs = kwargs @@ -618,13 +621,15 @@ async def login(self, username: str, password: str) -> JmApiResp: 'username': username, 'password': password, }) + res_data = resp.res_data cookies = dict(resp.resp.cookies) - cookies.update({'AVS': resp.res_data['s']}) + cookies.update({'AVS': res_data['s']}) # noinspection PyUnresolvedReferences,PyTypeChecker self._session.cookies.update(cookies) # 同步到 Option 配置,确保 cookies 持久化 self.option.update_cookies(cookies) self._username = username + self._user_id = str(res_data['uid']) if 'uid' in res_data else None return resp async def favorite_folder(self, @@ -720,6 +725,57 @@ async def delete_favorite_album(self, album_id, folder_id='0'): """ return await self.toggle_favorite_album(album_id, folder_id, expected_type='remove') + async def get_daily(self, user_id: str | None = None) -> JmApiResp: + """ + 获取每日签到信息与日历打卡记录 + :param user_id: 用户ID,默认读取当前登录用户的uid + """ + if user_id is None: + ExceptionTool.require_true(self._user_id is not None, '签到需要传入 user_id 参数,或者先调用 login 方法') + user_id = self._user_id + + return await self.req_api(self.API_DAILY, params={'user_id': user_id}) + + async def daily_checkin(self, daily_id: str | None = None, user_id: str | None = None) -> JmDailyCheckinResp: + """ + 执行每日打卡签到(异步)。 + 返回 JmDailyCheckinResp 对象: + - code=0 (或 status=0): 签到成功 + - code=1 (或 status=1): 重复签到(今日已完成打卡) + - 其余失败情况直接抛出异常 + :param daily_id: 打卡任务ID,未提供时会自动请求 get_daily 获取 + :param user_id: 用户ID,默认读取当前登录用户的uid + :return: JmDailyCheckinResp + """ + if user_id is None: + ExceptionTool.require_true(self._user_id is not None, '签到需要传入 user_id 参数,或者先调用 login 方法') + user_id = self._user_id + + if daily_id is None: + daily_resp = await self.get_daily(user_id) + daily_id = daily_resp.res_data['daily_id'] + + resp: JmApiResp = await self.req_api( + self.API_DAILY_CHK, + get=False, + data={ + 'user_id': user_id, + 'daily_id': daily_id, + }, + ) + + res_data = resp.res_data + msg = str(res_data.get('msg', '')) + + if any(kw in msg for kw in ('今天已經簽到過了', '已簽到', '簽到過', '已完成', '已签到')): + code = JmDailyCheckinResp.CODE_ALREADY_CHECKED_IN + elif 'Jcoin' in msg or 'EXP' in msg or res_data.get('status') == 'ok' or '成功' in msg: + code = JmDailyCheckinResp.CODE_SUCCESS + else: + ExceptionTool.raises_resp(f'签到失败:{msg or res_data}', resp) + + return JmDailyCheckinResp(resp, code, msg, res_data) + async def album_comment(self, video_id, comment, diff --git a/src/jmcomic/jm_client_impl.py b/src/jmcomic/jm_client_impl.py index 31d8fbeec..49b865994 100644 --- a/src/jmcomic/jm_client_impl.py +++ b/src/jmcomic/jm_client_impl.py @@ -32,6 +32,7 @@ def __init__(self, self.domain_retry_strategy = domain_retry_strategy self.CLIENT_CACHE = None self._username = None # help for favorite_folder method + self._user_id = None # help for check_in / get_daily method if domain_retry_strategy: domain_retry_strategy(self) self.enable_cache() @@ -446,7 +447,12 @@ def login(self, ) if resp.status_code != 200: - ExceptionTool.raises_resp(f'登录失败,状态码为{resp.status_code}', resp) + self.raise_request_error(resp, f'登录失败,状态码为{resp.status_code}') + + res = resp.json() + if res.get('status') != 1: + errors = res.get('errors') or [res.get('msg', '登录失败')] + self.raise_request_error(resp, f'登录失败:{errors[0]}') orig_cookies = self.get_meta_data('cookies') or {} new_cookies = dict(resp.cookies) @@ -488,6 +494,73 @@ def get_username_from_cookies(self) -> str: # 解析cookies,可能需要用到 phpserialize,比较麻烦,暂不实现 pass + def get_or_fetch_daily_id(self, daily_id: str | None = None) -> str: + if daily_id is None: + resp_home = self.get_jm_html('/') + daily_id = JmcomicText.parse_daily_id(resp_home.text) + ExceptionTool.require_true(bool(daily_id), '未能从首页获取到 daily_id,请手动传入 daily_id 参数') + return str(daily_id) + + def get_daily(self, + user_id: str | None = None, + daily_id: str | None = None + ) -> JmJsonResp: + """ + 网页端获取每日签到信息与日历打卡记录。 + 返回值格式参考 JmUserClient.get_daily 文档。 + :param user_id: 兼容参数,网页端不需要 + :param daily_id: 如果外界提前获取了,可直接传递参数,使得本方法内部少一次网络请求查询。 + :return: JmJsonResp + """ + daily_id = self.get_or_fetch_daily_id(daily_id) + resp = self.get( + '/ajax/user_daily_event', + params={'daily_id': daily_id}, + ) + ret = JmJsonResp(resp) + ret.require_success() + return ret + + def daily_checkin(self, + daily_id: str | None = None, + user_id: str | None = None, + old_step: int = 1 + ) -> JmDailyCheckinResp: + """ + 网页端每日签到。 + 返回 JmDailyCheckinResp 对象: + - code=0 (或 status=0): 签到成功 + - code=1 (或 status=1): 重复签到(今日已完成打卡) + - 其余失败情况直接抛出异常 + :param daily_id: 打卡任务ID,未提供时会自动从首页提取 + :param user_id: 兼容参数,网页端不需要 + :param old_step: 连续签到天数阶段,默认为 1 + :return: JmDailyCheckinResp + """ + daily_id = self.get_or_fetch_daily_id(daily_id) + resp = self.post( + '/ajax/user_daily_sign', + data={ + 'daily_id': daily_id, + 'oldStep': str(old_step), + }, + ) + if resp.status_code != 200: + self.raise_request_error(resp, f'签到请求失败,HTTP状态码: {resp.status_code}') + + res = resp.json() + status = res.get('status') + msg = str(res.get('msg', '')) + + if status == 1: + code = JmDailyCheckinResp.CODE_SUCCESS + elif status == 0 and any(kw in msg for kw in ('已完成打卡', '今天已经签到', '今日已完成', '已签到', '已打卡')): + code = JmDailyCheckinResp.CODE_ALREADY_CHECKED_IN + else: + self.raise_request_error(resp, f'签到失败:{msg or status}') + + return JmDailyCheckinResp(resp, code, msg, res) + def get_jm_html(self, url, require_200=True, **kwargs): """ 请求禁漫网页的入口 @@ -694,6 +767,8 @@ class JmApiClient(AbstractJmClient): API_SCRAMBLE = '/chapter_view_template' API_FAVORITE = '/favorite' API_FORUM = '/forum' + API_DAILY = '/daily' + API_DAILY_CHK = '/daily_chk' def search(self, search_query: str, @@ -917,9 +992,12 @@ def login(self, 'password': password, }) + res_data = resp.res_data cookies = dict(resp.resp.cookies) - cookies.update({'AVS': resp.res_data['s']}) + cookies.update({'AVS': res_data['s']}) self['cookies'] = cookies + self._username = username + self._user_id = str(res_data['uid']) if 'uid' in res_data else None return resp @@ -1031,6 +1109,64 @@ def delete_favorite_album(self, """ return self.toggle_favorite_album(album_id, folder_id, expected_type='remove') + def get_daily(self, + user_id: str | None = None, + ) -> JmApiResp: + """ + 移动端获取每日签到信息与日历打卡记录。 + 返回值格式参考 JmUserClient.get_daily 文档。 + :param user_id: 用户ID,默认读取当前登录用户的uid + :return: JmApiResp + """ + if user_id is None: + ExceptionTool.require_true(self._user_id is not None, '签到需要传入 user_id 参数,或者先调用 login 方法') + user_id = self._user_id + + return self.req_api(self.API_DAILY, params={'user_id': user_id}) + + def daily_checkin(self, + daily_id: str | None = None, + user_id: str | None = None, + ) -> JmDailyCheckinResp: + """ + 执行每日打卡签到。 + 返回 JmDailyCheckinResp 对象: + - code=0 (或 status=0): 签到成功 + - code=1 (或 status=1): 重复签到(今日已完成打卡) + - 其余失败情况直接抛出异常 + :param daily_id: 打卡任务ID,未提供时会自动请求 get_daily 获取 + :param user_id: 用户ID,默认读取当前登录用户的uid + :return: JmDailyCheckinResp + """ + if user_id is None: + ExceptionTool.require_true(self._user_id is not None, '签到需要传入 user_id 参数,或者先调用 login 方法') + user_id = self._user_id + + if daily_id is None: + daily_resp = self.get_daily(user_id) + daily_id = daily_resp.res_data['daily_id'] + + resp: JmApiResp = self.req_api( + self.API_DAILY_CHK, + get=False, + data={ + 'user_id': user_id, + 'daily_id': daily_id, + }, + ) + + res_data = resp.res_data + msg = str(res_data.get('msg', '')) + + if any(kw in msg for kw in ('今天已經簽到過了', '已簽到', '簽到過', '已完成', '已签到')): + code = JmDailyCheckinResp.CODE_ALREADY_CHECKED_IN + elif 'Jcoin' in msg or 'EXP' in msg or res_data.get('status') == 'ok' or '成功' in msg: + code = JmDailyCheckinResp.CODE_SUCCESS + else: + ExceptionTool.raises_resp(f'签到失败:{msg or res_data}', resp) + + return JmDailyCheckinResp(resp, code, msg, res_data) + # noinspection PyMethodMayBeStatic def require_resp_status_ok(self, resp: JmApiResp): """ @@ -1375,3 +1511,6 @@ def get_photo_detail(self, photo_id, fetch_album=True, fetch_scramble_id=True) - photo.scramble_id = scramble_id return photo + + def __getattr__(self, item): + return getattr(self.client, item) diff --git a/src/jmcomic/jm_client_interface.py b/src/jmcomic/jm_client_interface.py index 09fc83b53..6b4eb6de4 100644 --- a/src/jmcomic/jm_client_interface.py +++ b/src/jmcomic/jm_client_interface.py @@ -96,6 +96,22 @@ def model(self) -> AdvancedDict: class JmApiResp(JmJsonResp): + """ + 移动端 API 响应包装类。 + + 原始响应示例(resp.text): + {"code": 200, "data": "abc123XYZ..."} + + 内部数据流动机制: + 1. encoded_data: 从原始响应 JSON 中提取 resp.json()['data'](服务端经 AES 加密后的 Base64 密文字符串)。 + 示例: "abc123XYZ..." + 2. decoded_data: 将 encoded_data 经 Base64 解码 + AES-ECB 解密并去除 Padding 后,得到的明文字符串(通常为 JSON 字符串)。 + 示例: '{"msg": "Jcoin:40 EXP:40"}' + 3. res_data: 通过 json.loads 将 decoded_data 解析为 Python 字典/列表(Any)。 + 示例: {'msg': 'Jcoin:40 EXP:40'} + 4. model_data: 将 res_data 包装为便于属性访问的 AdvancedDict 字典对象。 + 示例: model_data.msg -> 'Jcoin:40 EXP:40' + """ def __init__(self, resp, ts: str): super().__init__(resp) @@ -124,6 +140,8 @@ def encoded_data(self) -> str: def require_have_data(self): data = self.encoded_data + if data is None: + ExceptionTool.raises_resp(f'响应数据为空: {self.text}', self) if isinstance(data, list) and len(data) == 0 and self.json().get('errorMsg', None): ExceptionTool.raises_resp(f'data返回值异常: {self.text}', self) @@ -149,6 +167,24 @@ def is_success(self) -> bool: return super().is_success and self.json()['err'] is False +class JmDailyCheckinResp(JmResp): + """ + 每日签到打卡响应类 + """ + CODE_SUCCESS = 0 # 签到成功 + CODE_ALREADY_CHECKED_IN = 1 # 重复签到(今日已签) + + def __init__(self, resp, code: int, msg: str, raw_data: dict = None): + super().__init__(resp) + self.code: int = code # 0=签到成功, 1=重复签到 + self.status: int = code # 兼容字段,值等同于 code + self.msg: str = msg # 透传服务端的提示信息或奖励内容 + self.raw_data: dict = raw_data or {} + + def __repr__(self): + return f'' + + """ Client Interface @@ -321,6 +357,76 @@ def delete_favorite_album(self, """ raise NotImplementedError + def get_daily(self, + user_id: str | None = None, + ) -> Union[JmApiResp, JmJsonResp]: + """ + 获取每日签到信息与日历打卡记录。 + 两端均返回包含活动信息、打卡记录、背景图与打卡状态的完整响应对象: + - HTML 客户端 (JmJsonResp):明文 JSON,可通过 resp.json() 获取字典,或 resp.model() 属性访问。 + - API 客户端 (JmApiResp):加密响应,可通过 resp.res_data 获取解密字典,或 resp.model_data 属性访问。 + + 两端返回数据结构示例: + + 1. HTML 客户端 (JmJsonResp.json() / JmJsonResp.model()): + { + "daily_id": 72, # 签到活动ID(已注入对齐) + "dateArray": [11], # 当月已签到的日期数组 + "dateMiss": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], # 当月漏签的日期数组 + "dateEvent": [ # 特殊活动/双倍奖励日期 + "2026-09-05 00:00:00", + "2026-09-06 00:00:00" + ], + "oldStep": 1, # 连续签到天数 + "error": "finished", # 今日打卡状态(若今日已签到则为 "finished") + "pc_img": "/media/logo/pc/9PC.jpg", # 签到弹窗 PC 背景图 + "mb_img": "/media/logo/phone/9PH.jpg" # 签到弹窗移动端背景图 + } + + 2. API 客户端 (JmApiResp.res_data / JmApiResp.model_data): + { + "daily_id": 72, # 当前签到活动ID + "event_name": "9月-签到活动", # 活动标题 + "currentProgress": "14.3%", # 连续进度百分比 + "three_days_coin": "150", # 满3天奖励金币 + "three_days_exp": "150", # 满3天奖励经验 + "seven_days_coin": "350", # 满7天奖励金币 + "seven_days_exp": "350", # 满7天奖励经验 + "background_pc": "/media/logo/pc/9PC.jpg", # 背景图路径 + "background_phone": "/media/logo/phone/9PH.jpg", + "record": [ # 按周划分的完整签到日历矩阵 + [ + {"date": "01", "signed": false, "bonus": false}, + {"date": "02", "signed": false, "bonus": false} + ], + [ + {"date": "11", "signed": true, "bonus": false}, + {"date": "12", "signed": null, "bonus": true} + ] + ] + } + + :param user_id: 用户ID,API 客户端默认读取当前登录用户的uid + """ + raise NotImplementedError + + def daily_checkin(self, + daily_id: str | None = None, + user_id: str | None = None, + ) -> JmDailyCheckinResp: + """ + 执行每日打卡签到。 + 返回 JmDailyCheckinResp 对象: + - code=0 (或 status=0): 签到成功,msg 包含奖励信息(如金币/经验) + - code=1 (或 status=1): 重复签到(今日已完成打卡) + - 其余非预期错误(未登录、网络失败、活动失效等)直接抛出异常 + + :param daily_id: 打卡任务ID,未提供时会自动获取 + :param user_id: 用户ID,API 客户端默认读取当前登录用户的uid + :return: JmDailyCheckinResp + """ + raise NotImplementedError + class JmImageClient: @@ -1061,6 +1167,27 @@ async def add_favorite_album(self, album_id, folder_id='0'): async def delete_favorite_album(self, album_id, folder_id='0'): raise NotImplementedError + async def get_daily(self, user_id: str | None = None) -> JmApiResp: + """ + 获取每日签到信息与日历打卡记录 + :param user_id: 用户ID,默认读取当前登录用户的uid + """ + raise NotImplementedError + + async def daily_checkin(self, daily_id: str | None = None, user_id: str | None = None) -> JmDailyCheckinResp: + """ + 执行每日打卡签到(异步)。 + 返回 JmDailyCheckinResp 对象: + - code=0 (或 status=0): 签到成功,msg 包含奖励信息(如金币/经验) + - code=1 (或 status=1): 重复签到(今日已完成打卡) + - 其余非预期错误(未登录、网络失败、活动失效等)直接抛出异常 + + :param daily_id: 打卡任务ID,未提供时会自动请求 get_daily 获取 + :param user_id: 用户ID,默认读取当前登录用户的uid + :return: JmDailyCheckinResp + """ + raise NotImplementedError + async def album_comment(self, video_id, comment, diff --git a/src/jmcomic/jm_entity.py b/src/jmcomic/jm_entity.py index 15dfc4bf7..eb66959ab 100644 --- a/src/jmcomic/jm_entity.py +++ b/src/jmcomic/jm_entity.py @@ -481,6 +481,8 @@ def __init__(self, tags, related_list=None, description='', + is_favorite=False, + liked=False, ): super().__init__() self.album_id: str = str(album_id) @@ -498,6 +500,8 @@ def __init__(self, self.actors: List[str] = actors # 登場人物 self.tags: List[str] = tags # 標籤 self.authors: List[str] = authors # 作者 + self.is_favorite: bool = bool(is_favorite) # 是否已收藏 + self.liked: bool = bool(liked) # 是否已点赞/喜欢 # 有的 album 没有章节,则自成一章。 episode_list: List[Tuple[str, str, str]] diff --git a/src/jmcomic/jm_toolkit.py b/src/jmcomic/jm_toolkit.py index a518d7e84..5c95fed48 100644 --- a/src/jmcomic/jm_toolkit.py +++ b/src/jmcomic/jm_toolkit.py @@ -1,3 +1,4 @@ +from datetime import datetime from html.parser import HTMLParser from urllib.parse import unquote, urlparse @@ -61,6 +62,13 @@ class JmcomicText: pattern_html_album_views = compile(r'(.*?)\n *(次觀看|观看次数|次观看次数)') # 評論(div) pattern_html_album_comment_count = compile(r'
]*?id="total_video_comments">(\d+)
'), 0 + # 是否已收藏 (HTML端通过主本子收藏按钮带有 btn-primary 判定) + pattern_html_album_is_favorite = compile( + r']*?(id="album_favorite_\d+"[^>]*?class="[^"]*?' + r'\bbtn-primary\b|class="[^"]*?\bbtn-primary\b[^>]*?id="album_favorite_\d+)[^>]*?>'), False + # 是否已点赞/喜欢 (HTML端通过顶栏爱心按钮带有 style="color:red" 判定) + pattern_html_album_liked = compile( + r'id=[\'"]love_likes_\d+[\'"][^>]*>\s*]+style=[\'"][^\'"]*color:\s*(red)[^\'"]*[\'"]'), False # 提取接口返回值信息 pattern_ajax_favorite_msg = compile(r'(.*?)') @@ -68,6 +76,8 @@ class JmcomicText: pattern_api_response_json_object = compile(r'\{[\s\S]*?}') pattern_html_comment_next_page = compile(r'id=["\']p_album_comments_\d+_(\d+)["\']') + # 网页端签到活动ID (data-dailyid) + pattern_html_daily_id = compile(r'data-dailyid="(\d+)"') @classmethod def parse_to_jm_domain(cls, text: str): @@ -140,6 +150,19 @@ def analyse_jm_album_html(cls, html: str) -> JmAlbumDetail: JmModuleConfig.album_class() ) + @classmethod + def parse_daily_id(cls, html: str, default=None) -> Optional[str]: + """ + 从网页文本(如禁漫首页)中提取每日签到活动 ID(data-dailyid) + """ + return PatternTool.match_or_default( + html, + cls.pattern_html_daily_id, + default, + ) + + parse_html_daily_id = parse_daily_id + @classmethod def reflect_new_instance(cls, html: str, cls_field_prefix: str, clazz: type): @@ -948,6 +971,8 @@ class JmApiAdaptTool: 'related_list', 'name', 'description', + 'is_favorite', + 'liked', ('id', 'album_id'), ('author', 'authors'), ('total_views', 'views'), @@ -1003,7 +1028,10 @@ def post_adapt_album(cls, data: dict, _clazz: type, fields: dict): (chapter.id, chapter.sort, chapter.name) ) fields['episode_list'] = episode_list - for it in 'scramble_id', 'page_count', 'pub_date', 'update_date': + fields['page_count'] = int(data['total_photos']) + fields['pub_date'] = datetime.fromtimestamp(int(data['addtime'])).strftime('%Y-%m-%d') + + for it in 'scramble_id', 'update_date': fields[it] = '0' @classmethod diff --git a/tests/test_jmcomic/test_jm_album_favorite.py b/tests/test_jmcomic/test_jm_album_favorite.py new file mode 100644 index 000000000..09ee6ac24 --- /dev/null +++ b/tests/test_jmcomic/test_jm_album_favorite.py @@ -0,0 +1,146 @@ +import unittest +from jmcomic import ( + JmAlbumDetail, + JmcomicText, + JmApiAdaptTool, +) + + +class Test_Album_Favorite(unittest.TestCase): + + def test_entity_defaults(self): + album = JmAlbumDetail( + album_id=123, + scramble_id=220980, + name="测试本子", + episode_list=[], + page_count=10, + pub_date="2026-01-01", + update_date="2026-01-02", + likes="100", + views="1000", + comment_count=5, + works=[], + actors=[], + authors=["作者A"], + tags=["标签1"], + ) + self.assertIs(album.is_favorite, False) + self.assertIs(album.liked, False) + self.assertEqual(album.page_count, 10) + self.assertEqual(album.pub_date, "2026-01-01") + + # Explicit True + album2 = JmAlbumDetail( + album_id=123, + scramble_id=220980, + name="测试本子", + episode_list=[], + page_count=10, + pub_date="2026-01-01", + update_date="2026-01-02", + likes="100", + views="1000", + comment_count=5, + works=[], + actors=[], + authors=["作者A"], + tags=["标签1"], + is_favorite=True, + liked=True, + ) + self.assertIs(album2.is_favorite, True) + self.assertIs(album2.liked, True) + + def test_html_reflect_is_favorite_and_liked(self): + # 模拟包含已收藏(btn-primary)和已点赞(style="color:red")的HTML + html_favorited_and_liked = ''' + + 作品編號:JM1133603 + var scramble_id = 220980; +

测试本子

+

叙述:这是一本测试

+ 頁數:50 + + 更新日期 : 2025-04-23 + 2K + 78K + 次觀看 +
12
+ + + + 标签A + 已收藏 + + + + + ''' + album = JmcomicText.analyse_jm_album_html(html_favorited_and_liked) + self.assertIs(album.is_favorite, True) + self.assertIs(album.liked, True) + self.assertEqual(album.page_count, 50) + self.assertEqual(album.pub_date, "2025-04-22") + + # 模拟未收藏(未带btn-primary)和未点赞(无style="color:red")的HTML + html_unfavorited_and_unliked = ''' + + 作品編號:JM1133603 + var scramble_id = 220980; +

测试本子

+

叙述:这是一本测试

+ 頁數:50 + + 更新日期 : 2025-04-23 + 2K + 78K + 次觀看 +
12
+ + + + 标签A + 收藏 + + + + + ''' + album2 = JmcomicText.analyse_jm_album_html(html_unfavorited_and_unliked) + self.assertIs(album2.is_favorite, False) + self.assertIs(album2.liked, False) + + def test_api_adapt_album_fields(self): + api_data = { + 'id': 1133603, + 'name': 'API本子', + 'author': ['测试作者'], + 'total_views': '12345', + 'likes': '678', + 'comment_total': '9', + 'tags': ['Tag1'], + 'works': [], + 'actors': [], + 'related_list': [], + 'series': [], + 'total_photos': 120, + 'addtime': 1745337600, + 'is_favorite': True, + 'liked': True, + 'description': '测试描述', + } + album: JmAlbumDetail = JmApiAdaptTool.parse_entity(api_data, JmAlbumDetail) + self.assertIs(album.is_favorite, True) + self.assertIs(album.liked, True) + self.assertEqual(album.page_count, 120) + self.assertEqual(album.id, '1133603') + self.assertEqual(album.name, 'API本子') + + # 验证 False 场景 + api_data_false = dict(api_data) + api_data_false['is_favorite'] = False + api_data_false['liked'] = False + album_false = JmApiAdaptTool.parse_entity(api_data_false, JmAlbumDetail) + self.assertIs(album_false.is_favorite, False) + self.assertIs(album_false.liked, False) diff --git a/tests/test_jmcomic/test_jm_checkin.py b/tests/test_jmcomic/test_jm_checkin.py new file mode 100644 index 000000000..807f15ef7 --- /dev/null +++ b/tests/test_jmcomic/test_jm_checkin.py @@ -0,0 +1,331 @@ +import asyncio +import unittest +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock + +from jmcomic import ( + AsyncJmApiClient, + JmApiClient, + JmHtmlClient, + JmcomicText, + JmDailyCheckinResp, +) +from jmcomic.jm_exception import JmcomicException + + +class Test_CheckIn(unittest.TestCase): + + def test_html_client_get_daily(self): + client = object.__new__(JmHtmlClient) + client.get_jm_html = Mock(return_value=SimpleNamespace( + text='
' + )) + text = '{"dateArray": [5], "oldStep": 1}' + mock_raw_resp = Mock(status_code=200, text=text, content=text.encode('utf-8')) + mock_raw_resp.json = Mock(return_value={"dateArray": [5], "oldStep": 1}) + client.get = Mock(return_value=mock_raw_resp) + + resp = JmHtmlClient.get_daily(client) + client.get_jm_html.assert_called_once_with('/') + client.get.assert_called_once_with( + '/ajax/user_daily_event', + params={'daily_id': '99'}, + ) + self.assertEqual(resp.json()['dateArray'], [5]) + self.assertEqual(resp.model().daily_id, 99) + + def test_html_client_daily_checkin_success(self): + client = object.__new__(JmHtmlClient) + text = '{"status": 1, "msg": "打卡成功!獲得經驗: 50 金幣: 50"}' + mock_raw_resp = Mock(status_code=200, text=text, content=text.encode('utf-8')) + mock_raw_resp.json = Mock(return_value={"status": 1, "msg": "打卡成功!獲得經驗: 50 金幣: 50"}) + client.post = Mock(return_value=mock_raw_resp) + + resp = JmHtmlClient.daily_checkin(client, daily_id='72', old_step=2) + self.assertIsInstance(resp, JmDailyCheckinResp) + self.assertEqual(resp.code, JmDailyCheckinResp.CODE_SUCCESS) + self.assertEqual(resp.status, 0) + self.assertEqual(resp.msg, '打卡成功!獲得經驗: 50 金幣: 50') + client.post.assert_called_once_with( + '/ajax/user_daily_sign', + data={'daily_id': '72', 'oldStep': '2'}, + ) + + def test_html_client_daily_checkin_already_checked_in(self): + client = object.__new__(JmHtmlClient) + text = '{"status": 0, "msg": "您今日已完成打卡"}' + mock_raw_resp = Mock(status_code=200, text=text, content=text.encode('utf-8')) + mock_raw_resp.json = Mock(return_value={"status": 0, "msg": "您今日已完成打卡"}) + client.post = Mock(return_value=mock_raw_resp) + + resp = JmHtmlClient.daily_checkin(client, daily_id='72') + self.assertIsInstance(resp, JmDailyCheckinResp) + self.assertEqual(resp.code, JmDailyCheckinResp.CODE_ALREADY_CHECKED_IN) + self.assertEqual(resp.status, 1) + self.assertEqual(resp.msg, '您今日已完成打卡') + + def test_html_client_daily_checkin_other_status_raises(self): + client = object.__new__(JmHtmlClient) + text = '{"status": 0, "msg": "请先登录"}' + mock_raw_resp = Mock(status_code=200, text=text, content=text.encode('utf-8')) + mock_raw_resp.json = Mock(return_value={"status": 0, "msg": "请先登录"}) + client.post = Mock(return_value=mock_raw_resp) + client.raise_request_error = Mock(side_effect=JmcomicException('请先登录', {})) + + with self.assertRaises(JmcomicException): + JmHtmlClient.daily_checkin(client, daily_id='72') + + def test_html_client_daily_checkin_http_error_raises(self): + client = object.__new__(JmHtmlClient) + mock_raw_resp = Mock(status_code=500, text='Server Error', content=b'Server Error') + client.post = Mock(return_value=mock_raw_resp) + client.raise_request_error = Mock(side_effect=JmcomicException('HTTP 500', {})) + + with self.assertRaises(JmcomicException): + JmHtmlClient.daily_checkin(client, daily_id='72') + + def test_html_client_daily_checkin_auto_fetch_daily_id(self): + client = object.__new__(JmHtmlClient) + client.get_jm_html = Mock(return_value=SimpleNamespace( + text='
' + )) + text = '{"status": 1, "msg": "打卡成功"}' + mock_raw_resp = Mock(status_code=200, text=text, content=text.encode('utf-8')) + mock_raw_resp.json = Mock(return_value={"status": 1, "msg": "打卡成功"}) + client.post = Mock(return_value=mock_raw_resp) + + resp = JmHtmlClient.daily_checkin(client) + client.get_jm_html.assert_called_once_with('/') + client.post.assert_called_once_with( + '/ajax/user_daily_sign', + data={'daily_id': '88', 'oldStep': '1'}, + ) + self.assertEqual(resp.code, JmDailyCheckinResp.CODE_SUCCESS) + self.assertEqual(resp.msg, '打卡成功') + + def test_html_client_daily_checkin_missing_daily_id_raises(self): + client = object.__new__(JmHtmlClient) + client.get_jm_html = Mock(return_value=SimpleNamespace(text='no daily id')) + with self.assertRaises(JmcomicException): + JmHtmlClient.daily_checkin(client) + + def test_parse_daily_id(self): + html = '