diff --git a/CHANGELOG.md b/CHANGELOG.md index a9f87541..5549b535 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,48 @@ 条目分类参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.0.0/), 版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。 +## [2.7.7] - 2026-09-12 + +### Summary + +本次更新新增签到及打卡日历接口,为本子详情增加收藏与点赞状态,修复 HTML 登录结果校验。引入插件依赖声明、依赖策略,并完善收藏夹导出插件的失败重试与汇总提示,新增 +Calibre metadata 插件。 + +### Added + +#### 每日签到 + +| 功能 | 说明 | +|:------------------------------|:----------------------------------------------------------------------------------------------------------------------------------------------------| +| `get_daily` / `daily_checkin` | HTML/API 客户端支持获取每日签到信息与打卡日历、执行每日签到,未提供 `daily_id` 时自动获取。 | +| `JmDailyCheckinResp` | 双端统一返回标准打卡状态(`code=0` 签到成功,`code=1` 今日重复打卡,透传服务端 `msg`),其余失败直接抛异常。 | + +#### 实体字段 + +| 字段 | 说明 | +|:----------------------------|:------------------------------------------------------------| +| `JmAlbumDetail.is_favorite` | `bool`,HTML 网页端与 API 移动端双端识别本子收藏状态。 | +| `JmAlbumDetail.liked` | `bool`,HTML 网页端与 API 移动端双端识别本子点赞/喜欢状态。 | + +#### 插件系统 + +| 功能 | 说明 | +|:-----------------------------|:-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `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` 声明。 | +| `calibre_metadata` 插件 | 在 `after_album` 阶段生成 Calibre 可识别的 `metadata.opf`,支持 `include_cover` 下载封面、`fields` 自定义元数据;OPF 生成由 `jmcomic-calibre` 提供。impl by [@yifenliwu](https://github.com/yifenliwu)。 | + +### 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/README.md b/README.md index 2d80417d..40beb66d 100644 --- a/README.md +++ b/README.md @@ -248,10 +248,7 @@ jmv 350234 -y - 支持自定义本子/章节/图片下载前后事件的回调函数 - 支持自定义类:`Downloader(负责调度)` `Option(负责配置)` `Client(负责请求)` `实体类`等 - 支持自定义日志、异常监听器 - - **支持Plugin插件,可以方便地扩展功能,以及使用别人的插件,目前核心内置插件有**: - - `登录插件`、`只下载新章插件`、`导出收藏夹为csv文件插件` - - `合并所有图片为pdf文件插件`、`合并所有图片为长图png插件` - - `压缩文件插件`、`自动获取浏览器cookies插件`、`订阅更新插件`等 + - **支持 Plugin 扩展,目前内置 21 个实用插件,包括**:`美观下载进度条`、`图片合并 PDF`、`一图流长图拼接`、`生成zip/7z压缩包`、`浏览器 Cookie 获取`、`订阅更新`、`收藏夹导出`、`封面下载`等。 ## 使用小说明 diff --git a/assets/docs/sources/option_file_syntax.md b/assets/docs/sources/option_file_syntax.md index 1b50236f..548498c1 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: @@ -296,10 +319,21 @@ plugins: title: jmcomic # 标题 content: jmcomic finished !!! # 内容 + # impl by @yifenliwu https://github.com/yifenliwu/jmcomic-calibre + - plugin: calibre_metadata # 生成 Calibre 元数据 + kwargs: + dir_rule: + rule: 'Bd/Aid/metadata.opf' # 保存到 base_dir/本子ID/metadata.opf + base_dir: './' + include_cover: true # 同时下载封面 + fields: + language: zh # 自定义元数据字段 + main: - plugin: favorite_folder_export # 导出收藏夹插件 log: false kwargs: + max_retry: 2 # 失败后重试次数,0 表示不重试 zip_enable: true # 对导出文件进行压缩 zip_filepath: ${JM_DOWNLOAD_DIR}/export.zip # 压缩文件路径 zip_password: ${ZIP_PASSWORD} # 压缩密码 diff --git a/assets/docs/sources/tutorial/0_common_usage.md b/assets/docs/sources/tutorial/0_common_usage.md index 0da5a442..abe2818b 100644 --- a/assets/docs/sources/tutorial/0_common_usage.md +++ b/assets/docs/sources/tutorial/0_common_usage.md @@ -236,6 +236,58 @@ for page in client.forum_pagination_gen(page=1): print(f'本子 {comment.album_id} | {comment}') ``` +## 登录 + +登录可以下载某些特定本子,以及访问收藏夹、签到、查看本子的收藏与点赞状态。 + +你可以使用代码或者插件来登录。 + + +### 1、配置登录插件 + +下载本子时推荐使用这种方式,简单直接。 + +```yaml +plugins: + after_init: + - plugin: login + kwargs: + username: '你的用户名' + password: '你的密码' +``` + +配置以后,使用option下载/创建client都自带登录状态 + +```python +from jmcomic import create_option_by_file, download_album + +option = create_option_by_file('op.yml') + +# 创建client会自动获得登录状态 +client = option.build_jm_client() + +# 下载本子,也会已登录状态下载 +download_album(123, option) +``` + +### 2、代码写法 + +```python +from jmcomic import JmOption + +option = JmOption.default() + +# 创建一个新的client,此时是无登录状态的 +client = option.new_jm_client() + +# 登录 +client.login('你的用户名', '你的密码') + +# 后续使用这个 client 时,就是带上登录状态的了 +album = client.get_album_detail(123) +print(f'本子 {album.title},是否收藏: {album.is_favorite},是否点赞: {album.liked}') +``` + ## 获取收藏夹 可参考discussions: https://github.com/hect0x7/JMComic-Crawler-Python/discussions/235 @@ -292,7 +344,7 @@ page = client.favorite_folder(page=1, ### 添加与取消收藏 -支持直接通过本子 ID 添加或取消收藏(HTML 网页端与 API 端均支持): +支持直接通过本子 ID 添加或取消收藏: ```python # 把本子加入收藏 @@ -302,6 +354,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/assets/docs/sources/tutorial/14_async_usage.md b/assets/docs/sources/tutorial/14_async_usage.md index 5d134252..3482ef86 100644 --- a/assets/docs/sources/tutorial/14_async_usage.md +++ b/assets/docs/sources/tutorial/14_async_usage.md @@ -188,7 +188,33 @@ async def main(): asyncio.run(main()) ``` -## 7. 异步分类 / 排行榜 +## 7. 异步每日签到与打卡日历 + +登录后可以查询打卡日历,并执行今日签到: + +```python +import asyncio +from jmcomic import JmOption, JmcomicException + +async def main(): + async with JmOption.default().new_jm_async_client() as cl: + await cl.login('你的用户名', '你的密码') + daily = await cl.get_daily() + print(daily.res_data) # 移动端返回的打卡日历数据 + + try: + result = await cl.daily_checkin() + if result.code == 0: + print(f'签到成功: {result.msg}') + elif result.code == 1: + print(f'今日已签到: {result.msg}') + except JmcomicException as e: + print(f'签到失败: {e}') + +asyncio.run(main()) +``` + +## 8. 异步分类 / 排行榜 分类和排行榜本质上都是过滤请求,可以使用 `categories_filter` 获取单页,或使用 `categories_filter_gen` 异步生成器自动翻页。 @@ -220,7 +246,7 @@ async def main(): asyncio.run(main()) ``` -## 8. 关于 `async_impl` 配置 +## 9. 关于 `async_impl` 配置 注意:仅仅在 `option.yml` 中增加配置**并不能**让代码自动变成异步,你必须要在代码中改为调用 `_async` 相关方法(如上文所示)。 @@ -234,7 +260,7 @@ client: async_impl: async_api ``` -## 9. 查看下载耗时 +## 10. 查看下载耗时 异步下载完成后,可以直接查看自己总共等了多久,也可以继续查看具体是哪个本子、章节或图片比较慢。所有 `duration` 的单位都是秒。 diff --git a/assets/readme/README-en.md b/assets/readme/README-en.md index f4238f6b..4e069b74 100644 --- a/assets/readme/README-en.md +++ b/assets/readme/README-en.md @@ -242,10 +242,7 @@ Please check the documentation homepage → [jmcomic.readthedocs.io (Chinese lan - Supports custom callbacks before/after downloading albums/chapters/images - Customizable objects: `Downloader` `Option` `Client` `Entities`, etc. - Supports custom logging and exception listener mechanics - - **Embedded with powerful core Plugins** to easily extend features or inject others': - - `Login Plugin`, `Filter-new-chapter plugin`, `Export favorites to CSV plugin` - - `Merge images into PDF plugin`, `Merge images into Long png plugin` - - `Zip-files plugin`, `Auto fetch browser cookies plugin`, `Subscribe album update plugin`, etc. + - **Supports plugin extensions, with 21 useful built-in plugins**, including `download progress display`, `image-to-PDF conversion`, `long-image stitching`, `ZIP/7z compression`, `browser cookie extraction`, `album update subscriptions`, `favorites export`, and `cover downloads`. ## Prerequisites diff --git a/assets/readme/README-jp.md b/assets/readme/README-jp.md index 5ffca77f..286c0973 100644 --- a/assets/readme/README-jp.md +++ b/assets/readme/README-jp.md @@ -237,10 +237,7 @@ jmv 350234 -y - アルバム/チャプター/画像ダウンロード前後のコールバック関数のカスタムをサポート - 各種クラスのカスタマイズ対応: `Downloader(スケジューリング担当)` `Option(設定担当)` `Client(リクエスト担当)` `エンティティクラス` など - カスタムログ出力・例外リスナーの実装 - - **プラグイン(Plugin)システムにより、機能を容易に拡張したり、他者の製作物を利用可能**。主要な組み込みプラグインの一例: - - `ログインプラグイン`, `最新チャプターのみダウンロードするプラグイン`, `お気に入りをCSV形式でエクスポートするプラグイン` - - `すべての画像を1つのPDFファイルに結合するプラグイン`, `すべての画像を縦長の1つのPNGファイルに結合するプラグイン` - - `ファイル圧縮(Zip)プラグイン`, `ブラウザのクッキーを自動で抽出するプラグイン`, `アルバム更新購読プラグイン` など + - **プラグインによる機能拡張に対応し、現在21個の便利なプラグインを内蔵**。`ダウンロード進捗表示`、`画像のPDF結合`、`縦長画像の結合`、`ZIP/7z圧縮`、`ブラウザCookieの取得`、`アルバム更新の購読`、`お気に入りのエクスポート`、`表紙のダウンロード`などが利用できます。 ## ご利用上の注意点 diff --git a/assets/readme/README-kr.md b/assets/readme/README-kr.md index 34375bf5..a19f80d1 100644 --- a/assets/readme/README-kr.md +++ b/assets/readme/README-kr.md @@ -237,10 +237,7 @@ jmv 350234 -y - 챕터/이미지의 다운로드 전, 다운로드 후의 콜백 기능 및 커스텀 함수 동작. - 여러 사용자 정의 클래스를 구성하도록 개방: `Downloader (스케줄 관리)` `Option (구성 관리)` `Client (요청 관리)` `Entity 생성` 및 등등. - 사용자 맞춤형 로거, 에러 감지기 시스템 - - **강력한 'Plugin 시스템', 타인의 플러그인 이용가능, 현재 지원되는 핵심 플러그인 목록**: - - `로그인 플러그인`, `새로 올라온 챕터만을 받는 플러그인`, `북마크 목록을 CSV 표로 추출하는 플러그인` - - `모든 이미지를 읽기용 PDF 파일 한 개로 결합하는 플러그인`, `모든 이미지를 좁고 긴 하나의 원본 PNG 사진으로 결합하는 플러그인` - - `압축(Archive) 지원 플러그인`, `웹 브라우저의 쿠키(Cookies)를 능동적으로 받는 플러그인`, `앨범 업데이트 구독 플러그인` 등 + - **플러그인 확장을 지원하며, 현재 21개의 실용적인 플러그인을 내장하고 있습니다**. `다운로드 진행률 표시`, `이미지 PDF 병합`, `긴 이미지 이어 붙이기`, `ZIP/7z 압축`, `브라우저 쿠키 가져오기`, `앨범 업데이트 구독`, `즐겨찾기 내보내기`, `표지 다운로드` 등을 제공합니다. ## 사용 팁 diff --git a/pyproject.toml b/pyproject.toml index 007c68ba..be222438 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/__init__.py b/src/jmcomic/__init__.py index 25d6c7b1..4745c5ca 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 d5bf0577..5980c056 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,59 @@ 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) + if 'daily_id' not in daily_resp.res_data: + ExceptionTool.raises_resp('签到失败:签到信息缺少 daily_id', daily_resp) + 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.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 31d8fbee..da84f211 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,66 @@ 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) + if 'daily_id' not in daily_resp.res_data: + ExceptionTool.raises_resp('签到失败:签到信息缺少 daily_id', daily_resp) + 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.resp, code, msg, res_data) + # noinspection PyMethodMayBeStatic def require_resp_status_ok(self, resp: JmApiResp): """ @@ -1375,3 +1513,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 09fc83b5..1b78d607 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,75 @@ 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()): + { + "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 +1166,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_config.py b/src/jmcomic/jm_config.py index 0ac46a38..5e57ce88 100644 --- a/src/jmcomic/jm_config.py +++ b/src/jmcomic/jm_config.py @@ -146,7 +146,7 @@ class JmMagicConstants: APP_TOKEN_SECRET_2 = '18comicAPPContent' APP_DATA_SECRET = '185Hcomic3PAPP7R' API_DOMAIN_SERVER_SECRET = 'diosfjckwpqpdfjkvnqQjsik' - APP_VERSION = '2.1.6' + APP_VERSION = '2.1.7' # 模块级别共用配置 @@ -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_entity.py b/src/jmcomic/jm_entity.py index 15dfc4bf..eb66959a 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_option.py b/src/jmcomic/jm_option.py index 701e198a..a712a5cf 100644 --- a/src/jmcomic/jm_option.py +++ b/src/jmcomic/jm_option.py @@ -202,6 +202,9 @@ def __init__(self, # 需要主线程等待完成的插件 self.need_wait_plugins = [] + # 依赖策略预检/处理 + self.check_plugins_dependencies() + if call_after_init_plugin: self.call_all_plugin('after_init', safe=True) @@ -351,7 +354,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 +638,37 @@ async def download_photo_async(self, photo_id, *args, **kwargs): # 下面的方法为调用插件提供支持 + def check_plugins_dependencies(self) -> None: + """ + 根据 plugins.dependencies_strategy 策略校验或安装已启用插件所需的依赖库。 + + 支持的策略值: + - failed-fast(默认):缺失依赖时快速失败,抛出带清晰解决方案指引的异常。 + - auto-install:缺失依赖时以插件为维度自动调用 pip 安装缺失包。 + - ignore-only-log:缺失依赖时仅打印 warning 日志,不阻断运行。 + + 具体检查、安装与错误处理逻辑收口于 JmOptionPlugin.check_plugin_dependency。 + """ + # 保证 jm_plugin.py 被加载 + from .jm_plugin import JmOptionPlugin + + strategy = self.plugins.dependencies_strategy + plugin_registry = JmModuleConfig.REGISTRY_PLUGIN + + 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 + + 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, []) 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 20303677..859061e2 100644 --- a/src/jmcomic/jm_plugin.py +++ b/src/jmcomic/jm_plugin.py @@ -20,6 +20,126 @@ def __init__(self, plugin: 'JmOptionPlugin', msg: str): class JmOptionPlugin: plugin_key: str + # 插件运行所需的依赖库。 + # 元素可以是 import 模块名(如 'psutil'),或者元组 (import_name, pip_pkg_name)。 + plugin_dependencies: tuple = () + + @classmethod + def required_dependencies_for(cls, kwargs: dict) -> tuple: + """ + 返回该插件在给定 kwargs 配置下实际需要的依赖库,供依赖策略校验使用。 + + 默认返回类声明的 plugin_dependencies。 + 插件可按配置重写此方法,避免对合法配置误报 + (如未加密的 zip 用标准库 zipfile 即可,无需 pyzipper/py7zr)。 + """ + 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 @@ -175,6 +295,7 @@ def invoke(self, class UsageLogPlugin(JmOptionPlugin): plugin_key = 'usage_log' + plugin_dependencies = ('psutil',) def invoke(self, **kwargs) -> None: import threading @@ -324,6 +445,35 @@ class ZipPlugin(JmOptionPlugin): """ plugin_key = 'zip' + # zip 依赖取决于加密配置:未加密用标准库 zipfile,加密 zip 用 pyzipper,7z 用 py7zr + plugin_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 +497,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 +1079,7 @@ async def __aexit__(self, exc_type, exc_val, exc_tb): class DownloadProgressPlugin(JmOptionPlugin): plugin_key = 'download_progress' + plugin_dependencies = ('rich',) log_file = 'jmcomic-download.log' @staticmethod @@ -1036,6 +1189,7 @@ def invoke(self, class AutoSetBrowserCookiesPlugin(JmOptionPlugin): plugin_key = 'auto_set_browser_cookies' + plugin_dependencies = ('browser_cookie3',) accepted_cookies_keys = str_to_set(''' yuo1 @@ -1097,13 +1251,18 @@ def invoke(self, zip_filepath=None, zip_password=None, delete_original_file=False, + max_retry=2, ): self.save_dir = os.path.abspath(save_dir if save_dir is not None else (os.getcwd() + '/export/')) self.zip_enable = zip_enable self.zip_filepath = os.path.abspath(zip_filepath) self.zip_password = zip_password self.delete_original_file = delete_original_file + # max_retry 表示「首次失败后的重试次数」,所以总尝试次数是 max_retry + 1。 + # 允许配 0 表示不重试(只尝试一次),不要把它强行抬成 1。 + self.max_retry = max(0, int(max_retry)) self.files = [] + self.failed_folders = [] mkdir_if_not_exists(self.save_dir) mkdir_if_not_exists(of_dir_path(self.zip_filepath)) @@ -1127,34 +1286,81 @@ def main(self): apply_each_obj_func=bind_jm_task_context(self.handle_folder), ) - if not self.zip_enable: - return + # 压缩导出的文件(放在失败检查之前:即便有收藏夹失败, + # 已经成功抓下来的那部分也应该照常打包,不能因为一个收藏夹失败就丢掉整批数据) + if self.zip_enable: + self.require_param(self.zip_filepath, '如果开启zip,请指定zip_filepath参数(压缩文件保存路径)') - # 压缩导出的文件 - self.require_param(self.zip_filepath, '如果开启zip,请指定zip_filepath参数(压缩文件保存路径)') + if self.zip_password is None: + self.zip_folder_without_password(self.files, self.zip_filepath) + else: + self.zip_with_password(self.files, self.zip_filepath) - if self.zip_password is None: - self.zip_folder_without_password(self.files, self.zip_filepath) - else: - self.zip_with_password() + self.execute_deletion(self.files) - self.execute_deletion(self.files) + # 汇总导出失败的收藏夹,避免数据静默缺失 + self.raise_if_failed_folders() def handle_folder(self, fid: str, fname: str): self.log(f'【收藏夹: {fname}, fid: {fid}】开始获取数据') - # 获取收藏夹数据 - page_data = self.fetch_folder_page_data(fid) + # 第 0 次是首次尝试,之后每次都是重试;总共 1 + max_retry 次 + for attempt in range(self.max_retry + 1): + try: + # 获取收藏夹数据 + page_data = self.fetch_folder_page_data(fid) + + # 序列化到文件 + filepath = self.save_folder_page_data_to_file(page_data, fid, fname) + except Exception as e: + # 单个收藏夹失败不应该中断其他收藏夹, + # 但也不能无声无息地丢掉这份数据,这里记录并在结束后统一汇报 + self.log(f'【收藏夹: {fname}, fid: {fid}】第 {attempt + 1}/{self.max_retry + 1} 次获取失败: [{e}]') + + if attempt >= self.max_retry: + self.failed_folders.append((fid, fname, e)) + return + + self.retry_backoff(attempt + 1) + continue + + if filepath is None: + self.log(f'【收藏夹: {fname}, fid: {fid}】收藏夹无数据') + return + + self.log(f'【收藏夹: {fname}, fid: {fid}】保存文件成功 → [{filepath}]') + self.files.append(filepath) + return + + # noinspection PyMethodMayBeStatic + def retry_backoff(self, attempt: int): + """ + 重试前的等待,避免短时间内反复请求加剧服务端限流。 + + :param attempt: 当前是第几次尝试(从 1 开始) + """ + import time - # 序列化到文件 - filepath = self.save_folder_page_data_to_file(page_data, fid, fname) + # attempt 从 1 开始,首次重试等 2s,之后 4s、8s,上限 10s + time.sleep(min(2 ** attempt, 10)) - if filepath is None: - self.log(f'【收藏夹: {fname}, fid: {fid}】收藏夹无数据') + def raise_if_failed_folders(self): + """ + 导出结束后统一检查失败的收藏夹。 + + 收藏夹数据量大、耗时长时,登录态可能在服务端被提前过期, + 导致个别收藏夹抓取失败;这类失败此前会被静默吞掉,导出的结果看起来 + 是成功的、实际却缺了数据。这里抛出运行时异常,交由 option 的 safe + 策略决定记录后继续或向外抛出,不走参数校验的 valid 策略。 + """ + if not self.failed_folders: return - self.log(f'【收藏夹: {fname}, fid: {fid}】保存文件成功 → [{filepath}]') - self.files.append(filepath) + detail = '、'.join(f'【{fname}】(fid={fid})' for fid, fname, _ in self.failed_folders) + msg = (f'以下 {len(self.failed_folders)} 个收藏夹导出失败(已重试 {self.max_retry} 次): {detail}。' + f'可稍后重新执行导出以补全这部分数据。') + self.log(msg) + ExceptionTool.raises(msg) def fetch_folder_page_data(self, fid): # 一页一页获取,不使用并行 @@ -1196,11 +1402,31 @@ def zip_folder_without_password(self, files, zip_path): for file in files: zipf.write(file, arcname=of_file_name(file)) - def zip_with_password(self): - # 构造shell命令 + def zip_with_password(self, files, zip_path): + """ + 用 7z 打包指定文件并加密。 + + 只打包传入的 files,不打包整个 save_dir:否则会连上一轮遗留的旧导出、 + 以及失败收藏夹写了一半的 csv 一起塞进包里,而这些文件并不在 + execute_deletion 的删除范围内,等于往产物里混入无关数据。 + + :param files: 要压缩的文件的绝对路径的列表 + :param zip_path: 压缩文件的保存路径 + """ + # 未指定输入文件时,7z 会默认打包整个目录。 + if not files: + return + + import shlex + + # 在 save_dir 中逐个列举本次成功导出的文件。 + file_args = ' '.join( + shlex.quote(of_file_name(f)) for f in files + ) + cmd_list = f''' cd {self.save_dir} - 7z a "{self.zip_filepath}" "./" -p{self.zip_password} -mhe=on > "../7z_output.txt" + 7z a "{zip_path}" {file_args} -p{self.zip_password} -mhe=on > "../7z_output.txt" ''' self.log(f'运行命令: {cmd_list}') @@ -1211,6 +1437,14 @@ def zip_with_password(self): class Img2pdfPlugin(JmOptionPlugin): plugin_key = 'img2pdf' + # img2pdf 总是需要;pikepdf 仅在加密 pdf 时需要 + plugin_dependencies = ('img2pdf',) + + @classmethod + def required_dependencies_for(cls, kwargs: dict) -> tuple: + if kwargs.get('encrypt'): + return cls.plugin_dependencies + ('pikepdf',) + return cls.plugin_dependencies def invoke(self, photo: JmPhotoDetail = None, @@ -1846,3 +2080,78 @@ 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 读元数据)时可以自动带上书名、作者、标签、简介和封面。 + + OPF 的生成逻辑由 jmcomic-calibre 提供, + 避免同一份 XML 拼接逻辑在两处各维护一份。 + + 配置示例: + + ```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' + plugin_dependencies = (('jmcomic_calibre', 'jmcomic-calibre'),) + + def invoke(self, + dir_rule: dict, + album: JmAlbumDetail = None, + photo: JmPhotoDetail = None, + downloader=None, + include_cover=False, + fields=None, + **kwargs) -> None: + self.require_param(album, '本插件需在after_album阶段使用,需要album参数') + + try: + import jmcomic_calibre + except ImportError: + self.warning_lib_not_install('jmcomic-calibre') + return + + 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, + 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): + 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/src/jmcomic/jm_toolkit.py b/src/jmcomic/jm_toolkit.py index a518d7e8..5c95fed4 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 00000000..09ee6ac2 --- /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 00000000..acebf3d1 --- /dev/null +++ b/tests/test_jmcomic/test_jm_checkin.py @@ -0,0 +1,350 @@ +import asyncio +import unittest +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock + +from jmcomic import ( + AsyncJmApiClient, + JmApiClient, + JmApiResp, + JmHtmlClient, + JmcomicText, + JmDailyCheckinResp, +) +from jmcomic.jm_exception import JmcomicException + + +class _CheckinApiResp(JmApiResp): + """保留真实响应继承链,仅替换与本测试无关的解密数据。""" + + def __init__(self, res_data): + super().__init__(SimpleNamespace(status_code=200, content=b'{}', text='{}'), '0') + self._data = res_data + + @property + def res_data(self): + return self._data + + +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().oldStep, 1) + + 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 = '