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-22
+ 更新日期 : 2025-04-23
+ 2K
+ 78K
+ 次觀看
+
+ 作品A
+ 人物A
+ 作者A
+ 标签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-22
+ 更新日期 : 2025-04-23
+ 2K
+ 78K
+ 次觀看
+
+ 作品A
+ 人物A
+ 作者A
+ 标签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='