diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index bfa7d6a3..6a095b02 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -861,7 +861,7 @@ description: 从 PDF 文件中提取文本和表格,填写表单。当用户 | **Bilibili** | `bilibili.auto_extract_enabled`, `bilibili.cookie`, `bilibili.prefer_quality` | B站视频自动提取与下载 | | **arXiv** | `arxiv.auto_extract_enabled`, `arxiv.max_file_size`, `arxiv.auto_extract_max_items` | arXiv 论文自动提取、搜索与 PDF 发送 | | **GitHub** | `github.auto_extract_enabled`, `github.request_timeout_seconds`, `github.request_retries`, `github.auto_extract_max_items` | GitHub public 仓库自动提取与图片卡片发送 | -| **JM(禁漫)** | `jm.auto_extract_enabled`, `jm.max_file_size`, `jm.max_chapters`, `jm.auto_extract_max_items` | JM 车号自动提取、整本加密 PDF 与合并转发 | +| **JM(禁漫)** | `jm.auto_extract_enabled`, `jm.chapter_max_file_size`, `jm.max_chapters`, `jm.auto_extract_max_items` | JM 车号自动提取、一章一份加密 PDF 打包 zip 与合并转发 | | **思考链** | `*.thinking_enabled` | 思维链支持 | | **思维链兼容** | `*.thinking_tool_call_compat` | 思维链 + 工具调用兼容 | | **WebUI** | `webui.url`, `webui.port`, `webui.password` | 配置控制台 | diff --git a/CHANGELOG.md b/CHANGELOG.md index 67acd070..830de2b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,15 @@ +## v3.18.3 JM 本子改为一章一份 PDF 打包 zip + +本版本把禁漫自动提取的交付物从「整本一个加密 PDF」改成「**每章一份加密 PDF**,统一密码打包成一个**无密码 zip**」:多章节本子不再需要一本几十上百 MB 的单文件才能翻开,zip 解压后按文件名就是阅读顺序。 + +- 交付结构:`JM<车号> <标题>.zip`(zip 本身不加密,不需要密码)内部是 `JM<车号> <三位章节序号>[ <章节标题>].pdf`,序号取自服务端章节 `order`、补零排序,服务端没给标题的章节只留序号;每章都用同一个随机 8 位密码做 AES-256 加密,密码节点改为写「zip 内所有 PDF 的解密密码(8 位):…」,避免用户以为 zip 要密码。 +- 体积语义改为**单章**上限:`[jm].max_file_size` 改名为 `[jm].chapter_max_file_size`(默认仍 100MB,`0` 不限、`<0` 回退 100),超限只跳过那一章、其余章节照常打包,信息节点会写明「N 章未打包」;**旧键 `max_file_size` 已失效并回退默认值**,请改用新键。zip 总体积随章节数增长、不再受配置约束。 +- 逐章组装取代整本组装:每章页面在该章写完 PDF 后即可释放,峰值内存 ≈ 一章页面 + 已完成章节 PDF;不再有「整本源图字节累计超限就中止整本」的预判。所有章节都没能出 PDF(全部超限或没有下到任何页面)时,仍只发「本子信息 / 状态」两个节点。 +- 发送与投递语义不变:zip 依旧不进合并转发,转发之后单独发一条文件消息(转发里的文件在 QQ 客户端下载会失败);转发被明确拒绝才降级为「信息 / 密码」两条普通消息;`delivery_uncertain` / `file_transfer_error` 不降级重发,zip 此前从未发出所以照常单独发送;密码始终不进历史摘要。 +- `jm_book` 工具的 `output_mode=uid` 维持现状(所有章节合成**一份未加密 PDF** 注册为附件 UID,供 `file_analysis_agent` 解析),只有它与其它的下载入口共用新的单章体积上限校验。 + +--- + ## v3.18.2 禁漫列表查询工具 本版本给 `info_agent` 新增 `jm_search` 工具:AI 可以直接查询禁漫本子列表(关键词检索 / 分类浏览 / 日周月排行),拿到车号后再由主 AI 的 `jm_book` 取详情或 PDF;工具本身只查列表,不下载也不发送。 diff --git a/README.md b/README.md index 4da6e927..e71816b9 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,7 @@ - **可选音乐工具集**:配置独立部署的 [lxmusic2api](https://github.com/69gg/lxmusic2api) 后,主 AI 可搜索歌曲/歌单、浏览热搜与排行榜、读取歌词/封面/评论、跨平台匹配并交付普通音频附件;完整 Track 通过任务内短引用流转,只暴露面向用户的高层 `music.*` 能力,不暴露下载作业生命周期接口。详见 [使用指南](docs/usage.md#音乐-music) 与 [配置说明](docs/configuration.md#4201-lxmusic2api-音乐服务)。 - **arXiv 论文提取、搜索与分析**:自动检测消息中的 arXiv 链接/标识并发送论文信息与 PDF;`arxiv_paper` 也可只返回 PDF 附件 UID,供 `file_analysis_agent` 做文本提取或指定页视觉分析;`arxiv_search` 负责论文检索。 - **GitHub 仓库卡片**:自动检测 GitHub 仓库链接或 `owner/repo` 仓库 ID,获取 public 仓库信息并发送简洁图片卡片,展示头像、简介、stars、forks、issues、contributors 等概览;API 与头像下载统一遵循 `[github].use_proxy`,头像以内联资源进入离线渲染器。 -- **禁漫(JM)本子提取**:自动检测 `JM`+车号或禁漫链接,下载全部章节并合成为一个 AES-256 加密 PDF,以「本子信息 / 解密密码」两节点合并转发发送,加密 PDF 不进转发、随后单独作为一条文件消息发送(转发里的文件在 QQ 客户端下载会失败,独立文件消息才下得动,群聊下同时进入群文件列表),密码每次随机生成且不写入历史;`jm_book` 工具也可只返回本子信息或未加密 PDF 附件 UID,供 `file_analysis_agent` 解析;`info_agent` 另有 `jm_search` 工具:关键词检索(可限定作者/标签/角色与分类)、分类浏览、日/周/月排行三种模式,返回车号清单。默认关闭,`[jm].auto_extract_enabled` 打开。详见 [配置说明](docs/configuration.md#4203-jm-禁漫自动提取)。 +- **禁漫(JM)本子提取**:自动检测 `JM`+车号或禁漫链接,下载全部章节,**每章各出一份 AES-256 加密 PDF**(同一个随机密码)并打包成一个**无密码 zip**,以「本子信息 / 解密密码」两节点合并转发发送,zip 不进转发、随后单独作为一条文件消息发送(转发里的文件在 QQ 客户端下载会失败,独立文件消息才下得动,群聊下同时进入群文件列表),密码每次随机生成且不写入历史;单章超过 `[jm].chapter_max_file_size` 只跳过该章、其余章节照常打包;`jm_book` 工具也可只返回本子信息或未加密 PDF 附件 UID,供 `file_analysis_agent` 解析;`info_agent` 另有 `jm_search` 工具:关键词检索(可限定作者/标签/角色与分类)、分类浏览、日/周/月排行三种模式,返回车号清单。默认关闭,`[jm].auto_extract_enabled` 打开。详见 [配置说明](docs/configuration.md#4203-jm-禁漫自动提取)。 - **自动处理管线**:Bilibili、arXiv、GitHub、JM 等自动提取统一运行在 `skills/pipelines` 中,斜杠命令优先级更高;命令输入/输出会写入历史,非命令消息会并行检测和处理命中管线,结果通过统一发送层写入历史并登记附件 UID 后再进入 AI 回复。远程大附件超过 `[attachments].remote_download_max_size_mb` 时只登记 URL 引用,避免无界下载和缓存膨胀。 - **同 sender 短时消息合并**:默认开启。连续发的多条消息会合并到同一轮 AI 调用,AI 一次看到全部意图自行识别"独立请求/修正/打断";告别"画猫→改成狗"的重复触发与回复打架。主提示词按 batcher 的"当前输入批次"语义适配,关闭该功能可能导致连续补充/修正消息与提示词不匹配,需要单独适配。可选投机预发送让用户停顿时 LLM 提前开跑、新消息可在未发出回复前取消,进一步压低响应延迟。详见 [docs/message-batching.md](docs/message-batching.md)。 - **思维链支持**:支持开启思维链,提升复杂逻辑推理能力。 diff --git a/apps/undefined-chat/package-lock.json b/apps/undefined-chat/package-lock.json index c1fdeab7..899e52d3 100644 --- a/apps/undefined-chat/package-lock.json +++ b/apps/undefined-chat/package-lock.json @@ -1,12 +1,12 @@ { "name": "undefined-chat", - "version": "3.18.2", + "version": "3.18.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "undefined-chat", - "version": "3.18.2", + "version": "3.18.3", "dependencies": { "@tauri-apps/api": "^2.3.0", "@tauri-apps/plugin-dialog": "^2.7.1", diff --git a/apps/undefined-chat/package.json b/apps/undefined-chat/package.json index 8798db26..e1121673 100644 --- a/apps/undefined-chat/package.json +++ b/apps/undefined-chat/package.json @@ -1,7 +1,7 @@ { "name": "undefined-chat", "private": true, - "version": "3.18.2", + "version": "3.18.3", "type": "module", "scripts": { "tauri": "tauri", diff --git a/apps/undefined-chat/src-tauri/Cargo.lock b/apps/undefined-chat/src-tauri/Cargo.lock index 23b16e30..d62da265 100644 --- a/apps/undefined-chat/src-tauri/Cargo.lock +++ b/apps/undefined-chat/src-tauri/Cargo.lock @@ -5431,7 +5431,7 @@ dependencies = [ [[package]] name = "undefined_chat" -version = "3.18.2" +version = "3.18.3" dependencies = [ "futures-util", "keyring", diff --git a/apps/undefined-chat/src-tauri/Cargo.toml b/apps/undefined-chat/src-tauri/Cargo.toml index 4e9612ab..06bcb92a 100644 --- a/apps/undefined-chat/src-tauri/Cargo.toml +++ b/apps/undefined-chat/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "undefined_chat" -version = "3.18.2" +version = "3.18.3" description = "Undefined native chat client" authors = ["Undefined contributors"] license = "MIT" diff --git a/apps/undefined-chat/src-tauri/tauri.conf.json b/apps/undefined-chat/src-tauri/tauri.conf.json index 58504b45..c2e7896f 100644 --- a/apps/undefined-chat/src-tauri/tauri.conf.json +++ b/apps/undefined-chat/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Undefined Chat", - "version": "3.18.2", + "version": "3.18.3", "identifier": "com.undefined.chat", "build": { "beforeDevCommand": "npm run dev", diff --git a/apps/undefined-console/package-lock.json b/apps/undefined-console/package-lock.json index 9079b440..68855d4d 100644 --- a/apps/undefined-console/package-lock.json +++ b/apps/undefined-console/package-lock.json @@ -1,12 +1,12 @@ { "name": "undefined-console", - "version": "3.18.2", + "version": "3.18.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "undefined-console", - "version": "3.18.2", + "version": "3.18.3", "dependencies": { "@tauri-apps/api": "^2.3.0", "@tauri-apps/plugin-http": "^2.3.0" diff --git a/apps/undefined-console/package.json b/apps/undefined-console/package.json index f26f1362..0d9d2a00 100644 --- a/apps/undefined-console/package.json +++ b/apps/undefined-console/package.json @@ -1,7 +1,7 @@ { "name": "undefined-console", "private": true, - "version": "3.18.2", + "version": "3.18.3", "type": "module", "scripts": { "tauri": "tauri", diff --git a/apps/undefined-console/src-tauri/Cargo.lock b/apps/undefined-console/src-tauri/Cargo.lock index 31d8e242..f6ec655d 100644 --- a/apps/undefined-console/src-tauri/Cargo.lock +++ b/apps/undefined-console/src-tauri/Cargo.lock @@ -4063,7 +4063,7 @@ checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" [[package]] name = "undefined_console" -version = "3.18.2" +version = "3.18.3" dependencies = [ "serde", "serde_json", diff --git a/apps/undefined-console/src-tauri/Cargo.toml b/apps/undefined-console/src-tauri/Cargo.toml index e42d9103..14a7c0a6 100644 --- a/apps/undefined-console/src-tauri/Cargo.toml +++ b/apps/undefined-console/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "undefined_console" -version = "3.18.2" +version = "3.18.3" description = "Undefined cross-platform management console" authors = ["Undefined contributors"] license = "MIT" diff --git a/apps/undefined-console/src-tauri/tauri.conf.json b/apps/undefined-console/src-tauri/tauri.conf.json index d64fabaf..88947981 100644 --- a/apps/undefined-console/src-tauri/tauri.conf.json +++ b/apps/undefined-console/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Undefined Console", - "version": "3.18.2", + "version": "3.18.3", "identifier": "com.undefined.console", "build": { "beforeDevCommand": "npm run dev", diff --git a/config.toml.example b/config.toml.example index 1d0d7b6f..a556f52a 100644 --- a/config.toml.example +++ b/config.toml.example @@ -1572,8 +1572,8 @@ auto_extract_private_ids = [] # en: Max number of GitHub repositories to auto-process from one message. <=0 falls back to 3, >10 is clamped to 10. auto_extract_max_items = 3 -# zh: 禁漫(JM / 18comic)自动提取配置。命中后下载整本并合成为一个加密 PDF,以合并转发发送(信息 / 解密密码 / PDF 文件)。 -# en: JM (18comic) auto-extraction settings. On match, the whole book is downloaded, merged into one encrypted PDF, and sent as a merged forward (info / password / PDF file). +# zh: 禁漫(JM / 18comic)自动提取配置。命中后下载整本,每章合成一份加密 PDF(同一个密码),打包成一个无密码 zip,以合并转发发送(本子信息 / 解密密码)+ 独立文件消息(zip)。 +# en: JM (18comic) auto-extraction settings. On match, the whole book is downloaded, each chapter becomes its own encrypted PDF (one shared password), all packed into an unencrypted zip sent as a merged forward (info / password) plus a standalone file message (the zip). [jm] # zh: 是否让禁漫接口与图片下载使用 [proxy] 代理地址。默认关闭。 # en: Whether JM API and image downloads use proxy addresses from [proxy]. Disabled by default. @@ -1587,14 +1587,14 @@ auto_extract_group_ids = [] # zh: 自动提取功能的私聊白名单(空=跟随全局 access.allowed_private_ids)。 # en: Private chat allowlist for auto-extraction (empty = follow global access.allowed_private_ids). auto_extract_private_ids = [] -# zh: 单条消息最多自动处理几个车号。<=0 回退 1,>5 截断到 5;每项是一整本 PDF,所以默认只处理 1 个。 -# en: Max number of book IDs to auto-process from one message. <=0 falls back to 1, >5 is clamped to 5; each item is a whole-book PDF, so only one is processed by default. +# zh: 单条消息最多自动处理几个车号。<=0 回退 1,>5 截断到 5;每项是一整本(多份 PDF 的 zip),所以默认只处理 1 个。 +# en: Max number of book IDs to auto-process from one message. <=0 falls back to 1, >5 is clamped to 5; each item is a whole book (one zip of per-chapter PDFs), so only one is processed by default. auto_extract_max_items = 1 -# zh: PDF 体积上限(MB),超过则只发送信息节点(按编码后字节判定,合成中即会中止)。0=不限,<0 回退 100。 -# en: PDF size limit in MB; exceeding it sends the info node only (checked on encoded bytes, aborts mid-build). 0=unlimited, <0 falls back to 100. -max_file_size = 100 -# zh: 单本最多下载并合并几章(取前 N 章)。0=不限(全部章节),<0 回退 0。 -# en: Max chapters downloaded and merged per book (first N). 0=unlimited, <0 falls back to 0. +# zh: 单章 PDF 体积上限(MB):超过则跳过该章,其余章节照常打包(按编码后字节判定,组装中即会中止,zip 总体积不受此限)。0=不限,<0 回退 100。 +# en: Per-chapter PDF size limit in MB; an oversized chapter is skipped while the rest are still packed (checked on encoded bytes, aborts mid-build; the zip itself is not capped). 0=unlimited, <0 falls back to 100. +chapter_max_file_size = 100 +# zh: 单本最多下载几章(取前 N 章,每章一份 PDF)。0=不限(全部章节),<0 回退 0。 +# en: Max chapters downloaded per book (first N, one PDF each). 0=unlimited, <0 falls back to 0. max_chapters = 0 # zh: PDF 页面分辨率(DPI),所有页统一按它换算物理尺寸(页宽 = 像素宽 ÷ DPI × 72)。<=0 回退 150。页像素不做缩放。 # en: PDF page resolution (DPI); every page uses it for its physical size (page width = pixels / DPI * 72). <=0 falls back to 150. Page pixels are never rescaled. diff --git a/docs/configuration.md b/docs/configuration.md index aebd7b20..2f55a95e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1124,8 +1124,8 @@ api_key = "replace-with-your-key" | `auto_extract_group_ids` | `[]` | 功能级群白名单 | 空时跟随全局 access | | `auto_extract_private_ids` | `[]` | 功能级私聊白名单 | 空时跟随全局 access | | `auto_extract_max_items` | `1` | 单条消息最多自动处理几个车号 | `<=0` 回退 `1`,`>5` 截断到 `5` | -| `max_file_size` | `100` | PDF 体积上限(MB),`0` 不限 | `<0` 回退 `100` | -| `max_chapters` | `0` | 单本最多下载并合并几章,`0` 不限 | `<0` 回退 `0` | +| `chapter_max_file_size` | `100` | 单章 PDF 体积上限(MB),超限只跳过该章,`0` 不限 | `<0` 回退 `100`;`[jm].max_file_size` 为旧键,已失效 | +| `max_chapters` | `0` | 单本最多下载几章(每章一份 PDF),`0` 不限 | `<0` 回退 `0` | | `pdf_dpi` | `150.0` | PDF 页面分辨率(DPI),所有页统一按它换算物理尺寸 | `<=0` 回退 `150` | | `image_quality` | `95` | PDF 内页 JPEG 质量(1-100,色度 4:4:4) | `<1` 回退 `95`,`>100` 截断到 `100` | | `download_concurrency` | `8` | 图片下载并发数 | `<1` 回退 `8`,`>32` 截断到 `32` | @@ -1139,21 +1139,22 @@ api_key = "replace-with-your-key" - 裸数字不触发,避免群号、时间戳等误报;QQ 分享卡片(`json` 消息段)里的链接同样会解析。 自动提取行为: -- 命中后发送一次外层合并转发,固定两个节点:**本子信息**(车号、标题、作者、章节数、标签、观看/点赞、页数、大小、简介预览,末尾是可直接复制的 `JM<车号>`)、**PDF 解密密码**(每次随机生成 8 位,字母表去掉 `0/O/1/l/I` 等形近字符)。加密 PDF **不进转发**,而是紧随转发之后单独作为一条文件消息发送。 -- 多章节本子会把全部章节按顺序下载后合成为一个 PDF(`max_chapters` 可限制章数),PDF 用 AES-256 加密,打开时需要密码。 +- 命中后发送一次外层合并转发,固定两个节点:**本子信息**(车号、标题、作者、章节数、标签、观看/点赞、页数、zip 大小、被跳过的章节数、简介预览,末尾是可直接复制的 `JM<车号>`)、**PDF 解密密码**(每次随机生成 8 位,字母表去掉 `0/O/1/l/I` 等形近字符,适用于 zip 内所有 PDF)。打包好的 zip **不进转发**,而是紧随转发之后单独作为一条文件消息发送。 +- 多章节本子会把全部章节按顺序下载(`max_chapters` 可限制章数),**每章各出一份 AES-256 加密 PDF**(都用同一个密码),再打包成一个**无密码** zip:`JM<车号> <标题>.zip`,内部条目是 `JM<车号> <序号>[ <章节标题>].pdf`,序号补零所以解压后按文件名就是阅读顺序。zip 不需要密码,密码在每个章节 PDF 内部由阅读器询问。 - 每页按 `pdf_dpi` 统一换算物理尺寸(`页宽 = 像素宽 ÷ DPI × 72`),下载与合成都不缩放像素:PDF 里的页就是站点源图的原始分辨率,画质上限由站点自己提供的图决定。 - 每页只做一次有损编码:取服务端原始字节(无损落盘)→ 解扰 → JPEG(`image_quality`,4:4:4)→ 直接作为 PDF 的图像数据,中间不再多一代重编码。需要更高画质时可把 `image_quality` 提到 `98`/`100`(约 `+30%`/`+50%` 体积),源图本身的分辨率无法通过配置提升。 -- 单次合并转发的 PDF 在内存中合成后写盘,所以 `max_file_size` 只是**近似**内存上限:先按各章节原始图片累计预判(超限即停止下载),组装时再按**编码后字节**累计复核,超过上限就提前中止、不发文件;PDF 是重编码 JPEG(4:4:4 / q95 可能比源 WebP 大),峰值可以略高于该值。 +- 单章 PDF 按 `chapter_max_file_size` 判定:组装时累计**编码后字节**(JPEG 重编码 4:4:4 / q95 可能比源 WebP 大),超过上限就中止这一章、不落盘,其余章节照常打包,信息节点会写明「N 章未打包」。这是**单章**上限,zip 总体积随章节数增长、不受配置约束;旧键 `[jm].max_file_size` 已失效(回退默认 100),请改用 `chapter_max_file_size`。 +- 组装逐章进行:每章页面在该章写完 PDF 后即可释放,只有章节 PDF 字节留在内存里等待打包,峰值内存 ≈ 一章页面 + 已完成章节 PDF。 - 密码只出现在转发节点里;写入历史的摘要只说明「密码见转发节点」,因此后续 AI 轮次能看到「发过什么」,但不会把密码复述出来。 -- PDF 不放进转发:转发节点里的文件在 QQ 客户端下载会失败(2026-10-01 实测),所以加密 PDF 在转发之后单独作为一条文件消息发送,独立文件消息可以正常下载;群聊下同一个 PDF 因此也会出现在群文件列表里。 -- 转发本身被明确拒绝时退化为两条普通消息(信息、密码),文件照旧单独发送;密码消息写进历史时只记「PDF 解密密码已单独发送」,不重复写摘要。独立文件也上传失败时再补一条会写入历史的「PDF 上传失败」提示(转发投递结果未确认时,提示不声称信息与密码已经送达)。历史摘要只说文件「随后单独发送」,上传成功由 `send_group_file` 自己补一条 `[文件] …` 记录,因此历史能区分文件到底发出去没有。 +- zip 不放进转发:转发节点里的文件在 QQ 客户端下载会失败(2026-10-01 实测),所以 zip 在转发之后单独作为一条文件消息发送,独立文件消息可以正常下载;群聊下同一个 zip 因此也会出现在群文件列表里。 +- 转发本身被明确拒绝时退化为两条普通消息(信息、密码),zip 照旧单独发送;密码消息写进历史时只记「PDF 解密密码已单独发送」,不重复写摘要。独立文件也上传失败时再补一条会写入历史的「zip 上传失败」提示(转发投递结果未确认时,提示不声称信息与密码已经送达)。历史摘要只说文件「随后单独发送」,上传成功由 `send_group_file` 自己补一条 `[文件] …` 记录,因此历史能区分文件到底发出去没有。 - 「投递结果未确认」(`delivery_uncertain`)与「文件传输错误」(`file_transfer_error`)**不降级重发**转发与信息/密码:这两种情况下转发可能已经送达,换个 action 再发会造成真实重复投递(判据与 B 站发送器一致)。文件是另一条此前从未发出的消息,这种情况下仍会继续单独发送;文件自身投递结果未确认时直接上抛,不重发。 - 降级路径会把密码作为普通消息发出(转发发不出去时这是唯一能拿到密码的方式),因此这条消息的正文也会进入本地 INFO 日志的「内容摘要」,转发节点路径则不会。密码始终不进历史摘要与转发卡片之外的用户可见正文。 -- 下载量超过 `max_file_size` 或没有下到任何页面时,只发送信息与状态说明两个节点(此时信息里不展示 PDF 大小,因为还没有 PDF),不发密码与文件。 +- 一份章节 PDF 都没能出(全部超过单章上限,或没有下到任何页面)时,只发送信息与状态说明两个节点,不发密码与文件;信息里此时不展示 zip 大小,因为还没有 zip。 - 车号不存在、接口不可用时发送一行 `JM 提取失败:<原因>`,日志记录异常类型与堆栈;车号与链接混排时按原文顺序取第一个,分享卡片结构异常只跳过该段。 -- 除了自动提取,`jm_book` 工具让 AI 也能按需处理车号:`output_mode=send`(默认,等价自动提取)、`uid`(只下载并注册**未加密** PDF 附件 UID,交给 `file_analysis_agent` 用 `extract_pdf` / `describe_pdf_page` 解析,不发送消息)、`info`(只返回本子信息,不下载)。 +- 除了自动提取,`jm_book` 工具让 AI 也能按需处理车号:`output_mode=send`(默认,等价自动提取)、`uid`(只下载并**把所有章节合成一份未加密 PDF** 注册为附件 UID,交给 `file_analysis_agent` 用 `extract_pdf` / `describe_pdf_page` 解析,不发送消息、不打包 zip)、`info`(只返回本子信息,不下载)。 - `info_agent` 的 `jm_search` 工具查询本子列表,`mode` 三种:`search`(关键词检索,可用 `target` 限定站内/作品/作者/标签/角色,也可直接输入纯数字车号)、`browse`(按分类浏览,不需要关键词)与 `ranking`(日/周/月排行,用 `time_range` 选日榜/周榜/月榜,固定按观看数排序)。三种模式共用 `genre` 大分类、`page` 翻页与 `n`(默认 5、上限 20);`search` / `browse` 另支持 `sort`(最新/观看/图片/点赞/评分/评论)与 `time_range` 时间范围;`sub_genre` 副分类必须与 `genre` 同时给出,且只有网页端接口支持,网页端被反爬拦截时会直接返回失败原因。只返回车号、标题、作者、分类与更新时间,不下载也不发送。查询复用同一份 `[jm]` 配置(代理、`request_timeout`、`session_dir`),且**不受** `auto_extract_enabled` 影响——该开关只约束自动提取管线。 -- jmcpy 的 HTTP 请求是异步的,但图片解码与 PDF 合成是同步 CPU 工作;这两步在线程中执行,不阻塞事件循环。副作用是单次自动提取不可取消,由 `request_timeout` / `image_timeout` 与 jmcpy 的多端点重试兜底。 +- jmcpy 的 HTTP 请求是异步的,但图片解码与 PDF 合成是同步 CPU 工作;这些步骤在线程中执行,不阻塞事件循环。副作用是单次自动提取不可取消,由 `request_timeout` / `image_timeout` 与 jmcpy 的多端点重试兜底。 - 默认不登录即可使用;需要在 jmcpy 中登录(例如访问账号可见内容)时,用 jmcpy 自己的方式保存会话,再把 `session_dir` 指向同一配置目录(或设置 `JMCPY_HOME`)。 - 请遵守所在地法律法规与目标站点服务条款,只处理你有权获取的内容。 diff --git a/docs/development.md b/docs/development.md index 8fae759e..94fa2249 100644 --- a/docs/development.md +++ b/docs/development.md @@ -24,7 +24,7 @@ src/Undefined/ │ └── job_queue.py # 有序文件队列、阶段回队、检查点与遗留任务恢复(梗库共用) ├── config/ # 配置系统(parsers/ 域解析 + load_sections/ 分段加载 + loader shim) ├── handlers/ # OneBot 消息分流(message_flow / poke / repeat / auto_extract) -├── jm/ # 禁漫(JM / 18comic)车号解析、关键词搜索、整本 PDF 下载合成与合并转发 +├── jm/ # 禁漫(JM / 18comic)车号解析、关键词搜索、逐章 PDF 下载合成与 zip 打包 ├── onebot/ # OneBot WebSocket 客户端 ├── skills/ # 技能插件核心目录 (存放所有的工具与智能体) │ ├── tools/ # 基础原子的工具 (独立的功能单元,如读写文件、网络请求等) diff --git a/docs/pipelines.md b/docs/pipelines.md index 94980bdc..3626bae5 100644 --- a/docs/pipelines.md +++ b/docs/pipelines.md @@ -52,24 +52,24 @@ Bilibili 图文管线命中 `bilibili.com/opus/`、`t.bilibili.com/`、` JM 管线命中 `JM` 前缀加 5–8 位车号(`JM1114751`、`jm 1114751`、`jm:1114751`)或主机名含 `18comic` / `jmcomic` 的链接(`/album/`、`/photo/`、`?id=`)后,发送一次外层合并转发,节点顺序固定: -1. `本子信息`:车号与标题、作者、章节数、标签、观看/点赞、页数、PDF 大小、简介预览,末行是可直接复制的 `JM<车号>`(不带站点链接); -2. `解密密码`:每次随机生成的 8 位 PDF 打开密码。 +1. `本子信息`:车号与标题、作者、章节数(本次出了几份 PDF)、标签、观看/点赞、页数与 zip 大小、被跳过的章节数、简介预览,末行是可直接复制的 `JM<车号>`(不带站点链接); +2. `解密密码`:每次随机生成的 8 位密码,适用于 zip 内**所有** PDF。 -加密 PDF **不进转发**,而是紧随转发之后单独发一条文件消息:转发节点里的文件在 QQ 客户端下载会失败(2026-10-01 实测,同一份文件独立发送可正常下载),独立文件消息才是可下载的入口,群聊下同一个 PDF 也会出现在群文件列表里。 +打包好的 zip **不进转发**,而是紧随转发之后单独发一条文件消息:转发节点里的文件在 QQ 客户端下载会失败(2026-10-01 实测,同一份文件独立发送可正常下载),独立文件消息才是可下载的入口,群聊下同一个 zip 也会出现在群文件列表里。 -多章节本子按章节顺序全部下载(`[jm].max_chapters` 可限制),再合成为一个 AES-256 加密 PDF。裸数字不触发,避免群号、时间戳等误报;`xxjm1234567` 这类前缀也不触发。 +多章节本子按章节顺序全部下载(`[jm].max_chapters` 可限制),**每章各出一份 AES-256 加密 PDF**,再打包成一个**无密码** zip(`JM<车号> <标题>.zip`,内部条目 `JM<车号> <序号>[ <章节标题>].pdf`,序号补零所以解压后按文件名就是阅读顺序)。zip 本身不需要密码,密码在每个章节 PDF 内部由用户逐份输入。裸数字不触发,避免群号、时间戳等误报;`xxjm1234567` 这类前缀也不触发。 失败语义: -- 转发被明确拒绝时退化为两条普通消息(信息、密码),文件照旧单独发送;密码始终不会写进历史摘要。 -- 投递结果未确认(`delivery_uncertain` / `file_transfer_error`)时不重发转发与信息/密码(可能已经送达),但文件此前从未发出,仍会继续单独发送,否则整本下载白跑;文件自身投递结果未确认时直接上抛,不重发。 -- 下载量或 PDF 体积超过 `[jm].max_file_size`(原始图片累计预判 + 组装时按编码后字节复核)、以及没有下到任何页面时,只发信息与状态两个节点,不发密码与文件。 +- 转发被明确拒绝时退化为两条普通消息(信息、密码),zip 照旧单独发送;密码始终不会写进历史摘要。 +- 投递结果未确认(`delivery_uncertain` / `file_transfer_error`)时不重发转发与信息/密码(可能已经送达),但 zip 此前从未发出,仍会继续单独发送,否则整本下载白跑;文件自身投递结果未确认时直接上抛,不重发。 +- 单章 PDF 编码后体积超过 `[jm].chapter_max_file_size`、或该章页面全部下载失败时,只跳过这一章,其余章节照常打进 zip,信息节点会写明「N 章未打包」(不会静默少发)。一份章节 PDF 都没能出(全部超限或没有下到任何页面)时只发信息与状态两个节点,不发密码与文件。 -PDF 合成没有走 `jmcpy.imaging.write_pdf`,而是自己用 PyMuPDF 逐页写入:jmcpy 的下载 API 一章只能产出一个 PDF,而这里要把多个章节合进同一个文件;同时 PATH 输出会先把解扰后的图重新编码一次、合成时再编码第二次,这里改为 `decode=False` 取服务端原始字节(无损落盘)、自己解扰、每页只编码一次 JPEG(`[jm].image_quality`,色度 4:4:4)后直接作为 PDF 图像数据,并按 `[jm].pdf_dpi` 统一页尺寸、做 AES-256 加密。页像素始终不做缩放,画质上限由站点源图自身分辨率决定。(jmcpy ≤0.1.1 的 `write_pdf` 另有「追加页退回 72 DPI、同文档页尺寸不一致」的问题,已在 0.1.2 修复;页内色度采样从 0.1.3 起默认也是 4:4:4。本管线依赖 `jmcpy>=0.1.3`。) +PDF 合成没有走 `jmcpy.imaging.write_pdf`,而是自己用 PyMuPDF 逐页写入:jmcpy 的 PATH 输出会先把解扰后的图重新编码一次、合成时再编码第二次,这里改为 `decode=False` 取服务端原始字节(无损落盘)、自己解扰、每页只编码一次 JPEG(`[jm].image_quality`,色度 4:4:4)后直接作为 PDF 图像数据,并按 `[jm].pdf_dpi` 统一页尺寸、做 AES-256 加密(每章都用同一个密码)。页像素始终不做缩放,画质上限由站点源图自身分辨率决定。(jmcpy ≤0.1.1 的 `write_pdf` 另有「追加页退回 72 DPI、同文档页尺寸不一致」的问题,已在 0.1.2 修复;页内色度采样从 0.1.3 起默认也是 4:4:4。本管线依赖 `jmcpy>=0.1.3`。) AI 侧另有 `jm_book` 工具(`src/Undefined/skills/tools/jm_book/`),与 `arxiv_paper` 同构:`send` 等价自动提取,`uid` 只注册未加密 PDF 附件 UID(共享给 `file_analysis_agent`),`info` 只取详情。 -实现说明:图片解码与 PDF 合成是同步 CPU 工作,下载整体在 `asyncio.to_thread` 中通过 jmcpy 的同步客户端执行,不阻塞事件循环;单次任务不可取消,由 `[jm].request_timeout` / `image_timeout` 与 jmcpy 的多端点重试兜底。PDF 在内存中合成后写盘,因此 `[jm].max_file_size` 只是近似内存上限(预判按源图字节、复核按编码后字节,JPEG 重编码可能更大)。下载跑在模块专用的线程池(上限 2 个并发本子)里,不占用事件循环默认执行器;单页解码失败只跳过该页并计入「下载失败 N 页」,不会让整本失败;任务目录在成功、超限、空结果与异常四条路径上都会清理;协程被取消时线程仍在写盘,改为等它跑完再删,避免残留原图或未加密 PDF。 +实现说明:图片解码与 PDF 合成是同步 CPU 工作,下载整体在 `asyncio.to_thread` 中通过 jmcpy 的同步客户端执行,不阻塞事件循环;单次任务不可取消,由 `[jm].request_timeout` / `image_timeout` 与 jmcpy 的多端点重试兜底。组装是逐章进行的:每章页面在该章写完 PDF 后即可释放,只有章节 PDF 字节留在内存里等待打包,因此峰值内存 ≈ 一章页面 + 已完成章节 PDF(比「整本页面全驻留」更低),`[jm].chapter_max_file_size` 是**单章**上限,zip 总体积随章节数增长、不受配置约束。下载跑在模块专用的线程池(上限 2 个并发本子)里,不占用事件循环默认执行器;单页解码失败只跳过该页并计入「下载失败 N 页」,不会让整章失败;任务目录在成功、空结果与异常三条路径上都会清理(章节 PDF 进了 zip 后会删掉散落副本,目录里只留 zip);协程被取消时线程仍在写盘,改为等它跑完再删,避免残留原图或未加密 PDF。 ## 目录结构 diff --git a/docs/usage.md b/docs/usage.md index d4a35cc8..9cc81df6 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -374,7 +374,7 @@ QQ/NapCat 在 `sendMsg` 阶段返回超时并不等于消息未送达:服务 | `bilibili_opus` | 发送哔哩哔哩图文(opus)合并转发、返回正文图片附件 UID、获取图文信息,或按字数区间 / 关键词读取正文文字(支持动态 ID、图文链接、b23.tv 短链) | | `douyin_video` | 发送抖音视频、返回视频附件 UID,或只获取视频信息(支持短链、长链、aweme_id) | | `arxiv_paper` | 发送 arXiv 论文 PDF、返回 PDF 附件 UID,或只获取论文信息(支持 arXiv ID、链接) | -| `jm_book` | 发送禁漫(JM)本子合并转发、返回未加密 PDF 附件 UID,或只获取本子信息(支持 `JM350234`、裸车号、禁漫链接) | +| `jm_book` | 发送禁漫(JM)本子合并转发与「一章一份加密 PDF」的 zip、返回未加密 PDF 附件 UID,或只获取本子信息(支持 `JM350234`、裸车号、禁漫链接) | | `fetch_image_uid` | 将指定 URL 的图片下载并转换为系统内部 uid | | `task_progress` | 向用户发送长任务的阶段性进度通知 | | `changelog_query` | 查询系统内置版本更新日志 | diff --git a/pyproject.toml b/pyproject.toml index 8552795a..3c0561f7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "Undefined-bot" -version = "3.18.2" +version = "3.18.3" description = "QQ bot platform with cognitive memory architecture and multi-agent Skills, via OneBot V11." readme = "README.md" authors = [ diff --git a/src/Undefined/__init__.py b/src/Undefined/__init__.py index a21f6cfd..c18a84bb 100644 --- a/src/Undefined/__init__.py +++ b/src/Undefined/__init__.py @@ -24,7 +24,7 @@ from .skills.registry import BaseRegistry as BaseRegistry from .skills.tools import ToolRegistry as ToolRegistry -__version__: str = "3.18.2" +__version__: str = "3.18.3" # symbol -> (module_path, attribute_name);首次访问时才 importlib 加载 _LAZY_IMPORTS: dict[str, tuple[str, str]] = { diff --git a/src/Undefined/config/config_class.py b/src/Undefined/config/config_class.py index db7f710e..b23d0e7e 100644 --- a/src/Undefined/config/config_class.py +++ b/src/Undefined/config/config_class.py @@ -247,7 +247,7 @@ class Config: jm_auto_extract_group_ids: list[int] jm_auto_extract_private_ids: list[int] jm_auto_extract_max_items: int - jm_max_file_size: int + jm_chapter_max_file_size: int jm_max_chapters: int jm_pdf_dpi: float jm_image_quality: int diff --git a/src/Undefined/config/load_sections/integrations.py b/src/Undefined/config/load_sections/integrations.py index 804fa346..a4fd1857 100644 --- a/src/Undefined/config/load_sections/integrations.py +++ b/src/Undefined/config/load_sections/integrations.py @@ -33,7 +33,7 @@ _MAX_JM_IMAGE_TIMEOUT_SECONDS: float = 600.0 _DEFAULT_JM_AUTO_EXTRACT_MAX_ITEMS: int = 1 _MAX_JM_AUTO_EXTRACT_MAX_ITEMS: int = 5 -_DEFAULT_JM_MAX_FILE_SIZE_MB: int = 100 +_DEFAULT_JM_CHAPTER_MAX_FILE_SIZE_MB: int = 100 _DEFAULT_JM_PDF_DPI: float = 150.0 _DEFAULT_JM_IMAGE_QUALITY: int = 95 _DEFAULT_JM_DOWNLOAD_CONCURRENCY: int = 8 @@ -253,11 +253,12 @@ def load_integrations( jm_auto_extract_max_items = _DEFAULT_JM_AUTO_EXTRACT_MAX_ITEMS if jm_auto_extract_max_items > _MAX_JM_AUTO_EXTRACT_MAX_ITEMS: jm_auto_extract_max_items = _MAX_JM_AUTO_EXTRACT_MAX_ITEMS - jm_max_file_size = _coerce_int( - _get_value(data, ("jm", "max_file_size"), None), _DEFAULT_JM_MAX_FILE_SIZE_MB + jm_chapter_max_file_size = _coerce_int( + _get_value(data, ("jm", "chapter_max_file_size"), None), + _DEFAULT_JM_CHAPTER_MAX_FILE_SIZE_MB, ) - if jm_max_file_size < 0: - jm_max_file_size = _DEFAULT_JM_MAX_FILE_SIZE_MB + if jm_chapter_max_file_size < 0: + jm_chapter_max_file_size = _DEFAULT_JM_CHAPTER_MAX_FILE_SIZE_MB jm_max_chapters = _coerce_int(_get_value(data, ("jm", "max_chapters"), None), 0) if jm_max_chapters < 0: jm_max_chapters = 0 @@ -469,7 +470,7 @@ def load_integrations( "jm_auto_extract_group_ids": jm_auto_extract_group_ids, "jm_auto_extract_private_ids": jm_auto_extract_private_ids, "jm_auto_extract_max_items": jm_auto_extract_max_items, - "jm_max_file_size": jm_max_file_size, + "jm_chapter_max_file_size": jm_chapter_max_file_size, "jm_max_chapters": jm_max_chapters, "jm_pdf_dpi": jm_pdf_dpi, "jm_image_quality": jm_image_quality, diff --git a/src/Undefined/jm/downloader.py b/src/Undefined/jm/downloader.py index a15782ee..9f470288 100644 --- a/src/Undefined/jm/downloader.py +++ b/src/Undefined/jm/downloader.py @@ -1,13 +1,9 @@ -"""禁漫本子下载:逐章取图,再合成一个加密 PDF。 - -jmcpy 的异步实现内部仍会同步执行图片解码与 PDF 合成(``_to_artifact`` / -``_finalize`` 直接在协程里跑),放在事件循环里会阻塞其它消息处理,因此这里用 -同步 ``Client`` 配合 :func:`asyncio.to_thread`,线程里可以放心做 CPU 与磁盘工作。 -代价是单次任务不可取消,由 jmcpy 自己的请求超时与多端点重试兜底。 +"""禁漫本子下载:逐章取图,一章合成一份加密 PDF,再打包成一个无密码 zip。 合成不走 ``jmcpy.imaging.write_pdf``,而是自己用 PyMuPDF 逐页写入,原因是: -* 要把**多个章节**合进同一个 PDF——jmcpy 的下载 API 只能一章一个 PDF; +* 每章一份 PDF 再打包成 zip——jmcpy 的下载 API 虽然也是一章一个 PDF,但它产出的 + 是「解码 + 重编码」后的成品,页尺寸由它决定;这里要自己控制页尺寸与编码次数; * 每页只编码一次:``download(output=PATH)`` 会先把解扰后的图重新编码一次 (WebP/JPEG),合成时再编码第二次;这里用 ``decode=False`` 取服务端原始字节 (无损落盘),自己解扰后只编码一次 JPEG(4:4:4,漫画的彩色描边在 4:2:0 下 @@ -17,20 +13,31 @@ jmcpy ≤0.1.1 的 ``write_pdf`` 还会让追加页退回默认 72 DPI(同一份 PDF 里第一页 5.6in 宽、其余页 11.7in 宽),该问题已在 0.1.2 修复;这里保留自建组装是为了上面 三点,与那个 bug 无关。 + +zip 用标准库 :mod:`zipfile` 写成、**不加密**:每个章节 PDF 各自用同一个密码做 +AES-256 加密,用户解压后逐份输入同一个密码即可。``[jm].chapter_max_file_size`` +按**单章 PDF** 判定,超限的那一章跳过(不再中止整本),其余章节照常打包。 + +jmcpy 的异步实现内部仍会同步执行图片解码与 PDF 合成(``_to_artifact`` / +``_finalize`` 直接在协程里跑),放在事件循环里会阻塞其它消息处理,因此这里用 +同步 ``Client`` 配合 :func:`asyncio.to_thread`,线程里可以放心做 CPU 与磁盘工作。 +代价是单次任务不可取消,由 jmcpy 自己的请求超时与多端点重试兜底。 """ from __future__ import annotations -import asyncio +from collections.abc import Callable, Sequence from concurrent.futures import ThreadPoolExecutor -from dataclasses import dataclass +from dataclasses import dataclass, field +import asyncio import functools import io import logging import shutil from pathlib import Path -from typing import Any, Literal +from typing import Any, Literal, TypeAlias import uuid +import zipfile import fitz from jmcpy import Book, Client, ExportFormat, Settings @@ -57,15 +64,65 @@ _JPEG_SUBSAMPLING = 0 #: PDF 页物理尺寸 = 像素 ÷ DPI,所有页统一用这个 DPI _POINTS_PER_INCH = 72.0 +#: zip 条目名与磁盘上的章节 PDF 同名:章节序号补零,解压后按文件名就是阅读顺序 +_CHAPTER_INDEX_DIGITS = 3 +#: 章节 PDF 在 zip 里的扩展名 +_PDF_SUFFIX = ".pdf" +#: 交付物 zip 的扩展名 +_ZIP_SUFFIX = ".zip" + +#: 打包结果:ok=全部章节成 PDF;partial=有章节被跳过但仍出了 zip;empty=一份都没有 +JmArchiveStatus: TypeAlias = Literal["ok", "partial", "empty"] +#: 单份 PDF 结果:ok=可用;oversize=超过单章体积上限;empty=没有可写入的页面 +JmPlainStatus: TypeAlias = Literal["ok", "oversize", "empty"] @dataclass(frozen=True, slots=True) -class JmDownload: - """一次本子下载的结果。""" +class ChapterPdfInfo: + """一份章节 PDF 的基本信息(体积取 zip 内的字节数)。""" - #: ok=PDF 可用;oversize=超过体积上限;empty=没有下到任何页面 - status: Literal["ok", "oversize", "empty"] + order: int + chapter_id: int + title: str + page_count: int + size_bytes: int + + +@dataclass(frozen=True, slots=True) +class JmChapterArchive: + """自动提取路径的下载结果:每章一份加密 PDF + 一个无密码 zip。""" + + status: JmArchiveStatus book: Book + #: 没有可打包的章节时为 ``None`` + zip_path: Path | None + #: zip 大小;``None`` 表示还没有 zip + size_bytes: int | None + #: 实际写进 zip 的页数合计 + page_count: int + #: 本子声明的章节总数(单章本子为 1) + chapter_count: int + #: 成功下载的章节来源数(含被体积上限跳过、最终没进 zip 的章节) + downloaded_chapters: int + #: 下载失败的页面数(jmcpy 逐页收集,不打断整章) + failed_pages: int + #: 没有进 zip 的章节数(超过单章体积上限、PDF 为空或该章页面全部解码失败) + skipped_chapters: int + #: 已进 zip 的章节,按阅读顺序 + pdfs: tuple[ChapterPdfInfo, ...] = field(default=()) + + @property + def ok(self) -> bool: + return self.status != "empty" and self.zip_path is not None + + +@dataclass(frozen=True, slots=True) +class JmPlainDownload: + """``jm_book`` 工具 ``uid`` 路径的结果:一份**未加密** PDF(供 PDF 解析工具打开)。""" + + status: JmPlainStatus + book: Book + #: ``oversize`` / ``empty`` 时为 ``None`` pdf_path: Path | None page_count: int size_bytes: int | None @@ -73,7 +130,6 @@ class JmDownload: chapter_count: int #: 本次实际下载并合并的章节数 downloaded_chapters: int - #: 下载失败的页面数(jmcpy 逐页收集,不打断整章) failed_pages: int @property @@ -81,24 +137,50 @@ def ok(self) -> bool: return self.status == "ok" and self.pdf_path is not None -def _chapter_ids(book: Book, *, max_chapters: int) -> list[int]: - # 按本子内的章节序号排序,合出来的 PDF 与阅读顺序一致 +#: 两种下载结果:自动提取拿 :class:`JmChapterArchive`,``uid`` 路径拿 :class:`JmPlainDownload` +JmDownload: TypeAlias = JmChapterArchive | JmPlainDownload + + +def _ordered_chapters(book: Book, *, max_chapters: int) -> list[tuple[int, int, str]]: + """按本子内的章节序号排出 ``(章节号, 章节序号, 章节标题)``,顺序就是阅读顺序。 + + 序号取自 ``ChapterBrief.order`` 而不是「第几次下载」:服务端没给图的章节会被跳过, + 按下载次序编号会让后面的章节缺号。单章本子的章节号就是车号,序号是 1。 + """ ordered = sorted(book.chapters, key=lambda chapter: int(chapter.order)) - chapter_ids = [int(chapter.chapter_id) for chapter in ordered] - if not chapter_ids: - # 单章本子的章节号就是车号 - chapter_ids = [int(book.book_id)] + chapters = [ + (int(chapter.chapter_id), int(chapter.order), str(chapter.title or "")) + for chapter in ordered + ] + if not chapters: + chapters = [(int(book.book_id), 1, "")] if max_chapters > 0: - chapter_ids = chapter_ids[:max_chapters] - return chapter_ids + chapters = chapters[:max_chapters] + return chapters -def _pdf_file_name(book: Book) -> str: +def _book_file_name(book: Book, suffix: str) -> str: + """交付物文件名:``JM<车号> <净化后的标题><后缀>``。""" title = sanitize_filename(book.title) or "漫画" - return f"{_BOOK_ID_PREFIX}{book.book_id} {title}.pdf" + return f"{_BOOK_ID_PREFIX}{book.book_id} {title}{suffix}" + + +def _chapter_pdf_name(book: Book, order: int, title: str) -> str: + """章节 PDF 名:``JM<车号> <章节序号>[ <章节标题>].pdf``。 + + 章节标题写在文件名里便于挑选章节;服务端没给标题时只留序号,不再重复本子标题。 + """ + stem = f"{_BOOK_ID_PREFIX}{book.book_id} {order:0{_CHAPTER_INDEX_DIGITS}d}" + chapter_title = sanitize_filename(title) + return ( + f"{stem} {chapter_title}{_PDF_SUFFIX}" + if chapter_title + else f"{stem}{_PDF_SUFFIX}" + ) -def _limit_bytes(max_file_size_mb: int) -> int | None: +def _chapter_limit_bytes(max_file_size_mb: int) -> int | None: + """单章 PDF 上限;``None`` 表示不限。""" return max_file_size_mb * 1024 * 1024 if max_file_size_mb > 0 else None @@ -121,14 +203,14 @@ class _PdfBuild: #: ``oversize`` / ``empty`` 时为 ``None`` path: Path | None page_count: int - #: 成功写入的页在 PDF 里的编码字节数(``max_file_size`` 按它判定) + #: 成功写入的页在 PDF 里的编码字节数(单章上限按它判定) encoded_bytes: int #: 组装阶段因解码失败被跳过的页数 failed_pages: int def _write_pdf( - pages: list[tuple[Path, Picture]], + pages: Sequence[tuple[Path, Picture]], output: Path, *, dpi: float, @@ -136,11 +218,12 @@ def _write_pdf( password: str | None, limit_bytes: int | None, ) -> _PdfBuild: - """把页面按顺序合成一个 PDF(页尺寸统一、每页只编码一次)。 + """把一章的页面按顺序合成一份 PDF(页尺寸统一、每页只编码一次)。 ``password`` 为 ``None`` 时不加密——附件 UID 模式要交给 PDF 解析,加密会让 - ``extract_pdf`` / ``describe_pdf_page`` 打不开。单页解码失败只跳过该页并计数, - 不整本放弃;累计编码字节超过 ``limit_bytes`` 时提前中止(此时 PDF 未落盘)。 + ``extract_pdf`` / ``describe_page`` 打不开。单页解码失败只跳过该页并计数, + 不整章放弃;累计编码字节超过 ``limit_bytes`` 时提前中止(此时 PDF 未落盘), + 由调用方决定是跳过这一章还是放弃整本。 """ document = fitz.open() page_count = 0 @@ -172,7 +255,7 @@ def _write_pdf( encoded_bytes += len(data) if limit_bytes is not None and encoded_bytes > limit_bytes: logger.info( - "[JM] PDF 编码后体积超过上限,提前中止: bytes=%s limit=%s", + "[JM] 单章 PDF 编码后体积超过上限,跳过该章: bytes=%s limit=%s", encoded_bytes, limit_bytes, ) @@ -202,29 +285,52 @@ def _write_pdf( return _PdfBuild("ok", output, page_count, encoded_bytes, failed_pages) -def _download_sync( +def _write_zip(parts: Sequence[tuple[str, bytes]], output: Path) -> int: + """把各章节 PDF 写成无密码 zip,返回 zip 字节数。 + + zip 本身不加密:密码在每个章节 PDF 内部,用户解压后逐份输入同一个密码。 + """ + with zipfile.ZipFile(output, "w", zipfile.ZIP_DEFLATED) as archive: + for name, data in parts: + archive.writestr(name, data) + return output.stat().st_size + + +@dataclass(frozen=True, slots=True) +class _ChapterPdf: + """一份刚写好的章节 PDF:zip 条目名 + 字节 + 便于回报的元信息。""" + + name: str + data: bytes + info: ChapterPdfInfo + + +def _download_archive_sync( book_id: str, task_dir: Path, *, - password: str | None, + password: str, settings: Settings, max_chapters: int, - max_file_size_mb: int, + chapter_max_file_size_mb: int, pdf_dpi: float, image_quality: int, -) -> JmDownload: - limit_bytes = _limit_bytes(max_file_size_mb) +) -> JmChapterArchive: + """逐章下载并各出一份加密 PDF,最后打包成无密码 zip。""" + limit_bytes = _chapter_limit_bytes(chapter_max_file_size_mb) + built: list[_ChapterPdf] = [] + downloaded_chapters = 0 + skipped_chapters = 0 + failed_pages = 0 + page_count = 0 + with Client(settings) as client: book = client.get_book(book_id) # 声明总数取本子自身的章节列表,不受 max_chapters 截断影响 declared_chapters = len(book.chapters) or 1 - chapter_ids = _chapter_ids(book, max_chapters=max_chapters) - pages: list[tuple[Path, Picture]] = [] - failed_pages = 0 - downloaded_chapters = 0 - total_bytes = 0 + chapters = _ordered_chapters(book, max_chapters=max_chapters) - for index, chapter_id in enumerate(chapter_ids, start=1): + for chapter_id, chapter_order, chapter_title in chapters: chapter = client.get_chapter(chapter_id) if not len(chapter): logger.info( @@ -236,36 +342,141 @@ def _download_sync( result = client.download( chapter, output=ExportFormat.PATH, - dest=task_dir / f"c{index:03d}", + dest=task_dir / f"c{chapter_order:0{_CHAPTER_INDEX_DIGITS}d}", decode=False, concurrency=settings.concurrency, ) - for item in result: - if item.path is not None: - pages.append((item.path, item.picture)) + pages = [ + (item.path, item.picture) for item in result if item.path is not None + ] failed_pages += len(result.failures) downloaded_chapters += 1 - total_bytes += sum(item.size or 0 for item in result) - if limit_bytes is not None and total_bytes > limit_bytes: + + if not pages: + # 这一章的页面全部下载失败:计一次跳过,继续下一章 + skipped_chapters += 1 + logger.warning( + "[JM] 章节没有可用页面,跳过: book=%s chapter=%s", + book_id, + chapter_id, + ) + continue + + output = task_dir / _chapter_pdf_name(book, chapter_order, chapter_title) + build = _write_pdf( + pages, + output, + dpi=pdf_dpi, + quality=image_quality, + password=password, + limit_bytes=limit_bytes, + ) + failed_pages += build.failed_pages + if build.status != "ok" or build.path is None: + # 单章超限/无可用页面只丢这一章;整本因此仍可能发出 zip + skipped_chapters += 1 logger.info( - "[JM] 图片总量超过体积上限,提前结束: book=%s bytes=%s limit=%sMB", + "[JM] 章节 PDF 未生成,跳过该章: book=%s chapter=%s status=%s pages=%s bytes=%s", book_id, - total_bytes, - max_file_size_mb, + chapter_id, + build.status, + build.page_count, + build.encoded_bytes, ) - return JmDownload( - status="oversize", - book=book, - pdf_path=None, - page_count=len(pages), - size_bytes=total_bytes, - chapter_count=declared_chapters, - downloaded_chapters=downloaded_chapters, - failed_pages=failed_pages, + continue + + data = build.path.read_bytes() + built.append( + _ChapterPdf( + name=build.path.name, + data=data, + info=ChapterPdfInfo( + order=chapter_order, + chapter_id=chapter_id, + title=chapter_title, + page_count=build.page_count, + size_bytes=len(data), + ), + ) + ) + page_count += build.page_count + + if not built: + return JmChapterArchive( + status="empty", + book=book, + zip_path=None, + size_bytes=None, + page_count=0, + chapter_count=declared_chapters, + downloaded_chapters=downloaded_chapters, + failed_pages=failed_pages, + skipped_chapters=skipped_chapters, + ) + + zip_path = task_dir / _book_file_name(book, _ZIP_SUFFIX) + zip_bytes = _write_zip([(item.name, item.data) for item in built], zip_path) + # 章节 PDF 已进 zip,删掉散落副本,任务目录里只留最终交付物 + for item in built: + (task_dir / item.name).unlink(missing_ok=True) + + return JmChapterArchive( + status="partial" if skipped_chapters else "ok", + book=book, + zip_path=zip_path, + size_bytes=zip_bytes, + page_count=page_count, + chapter_count=declared_chapters, + downloaded_chapters=downloaded_chapters, + failed_pages=failed_pages, + skipped_chapters=skipped_chapters, + pdfs=tuple(item.info for item in built), + ) + + +def _download_plain_sync( + book_id: str, + task_dir: Path, + *, + settings: Settings, + max_chapters: int, + chapter_max_file_size_mb: int, + pdf_dpi: float, + image_quality: int, +) -> JmPlainDownload: + """把各章页面合并成**一份未加密** PDF(``jm_book`` 的 ``uid`` 路径)。""" + limit_bytes = _chapter_limit_bytes(chapter_max_file_size_mb) + with Client(settings) as client: + book = client.get_book(book_id) + # 声明总数取本子自身的章节列表,不受 max_chapters 截断影响 + declared_chapters = len(book.chapters) or 1 + chapters = _ordered_chapters(book, max_chapters=max_chapters) + pages: list[tuple[Path, Picture]] = [] + failed_pages = 0 + downloaded_chapters = 0 + + for chapter_id, chapter_order, _title in chapters: + chapter = client.get_chapter(chapter_id) + if not len(chapter): + logger.info( + "[JM] 章节没有图片,跳过: book=%s chapter=%s", book_id, chapter_id ) + continue + result = client.download( + chapter, + output=ExportFormat.PATH, + dest=task_dir / f"c{chapter_order:0{_CHAPTER_INDEX_DIGITS}d}", + decode=False, + concurrency=settings.concurrency, + ) + pages.extend( + (item.path, item.picture) for item in result if item.path is not None + ) + failed_pages += len(result.failures) + downloaded_chapters += 1 if not pages: - return JmDownload( + return JmPlainDownload( status="empty", book=book, pdf_path=None, @@ -278,10 +489,10 @@ def _download_sync( build = _write_pdf( pages, - task_dir / _pdf_file_name(book), + task_dir / _book_file_name(book, _PDF_SUFFIX), dpi=pdf_dpi, quality=image_quality, - password=password, + password=None, limit_bytes=limit_bytes, ) failed_pages += build.failed_pages @@ -293,7 +504,7 @@ def _download_sync( build.page_count, build.encoded_bytes, ) - return JmDownload( + return JmPlainDownload( status=build.status, book=book, pdf_path=None, @@ -304,7 +515,7 @@ def _download_sync( failed_pages=failed_pages, ) - return JmDownload( + return JmPlainDownload( status="ok", book=book, pdf_path=build.path, @@ -326,34 +537,38 @@ async def fetch_book(book_id: str, *, config: Any) -> Book: return await asyncio.to_thread(_fetch_book_sync, book_id, build_settings(config)) -async def download_book_pdf( +async def download_book( book_id: str, *, config: Any, password: str | None = None, ) -> tuple[JmDownload, Path]: - """下载整本并合成 PDF,返回 ``(结果, 任务目录)``。 + """下载整本,返回 ``(结果, 任务目录)``。 - ``password`` 给出时用 AES-256 加密(自动提取用),``None`` 时输出未加密 PDF - (附件 UID 模式用,便于 PDF 解析)。调用方负责在发送或登记完成后清理任务目录 + ``password`` 给出时每章各出一份 AES-256 加密 PDF,并打包成**无密码** zip + (自动提取用);``None`` 时所有章节合成一份**未加密** PDF(``uid`` 模式用, + 便于 PDF 解析)。调用方负责在发送或登记完成后清理任务目录 (``cleanup_download_path``);本函数抛异常或被取消时自己清理,不留残留目录。 """ task_dir = ensure_dir(_JM_DOWNLOAD_DIR / uuid.uuid4().hex) loop = asyncio.get_running_loop() - future = loop.run_in_executor( - _DOWNLOAD_POOL, - functools.partial( - _download_sync, - book_id, - task_dir, - password=password, - settings=build_settings(config), - max_chapters=int(getattr(config, "jm_max_chapters", 0)), - max_file_size_mb=int(getattr(config, "jm_max_file_size", 100)), - pdf_dpi=float(getattr(config, "jm_pdf_dpi", 150.0)), - image_quality=int(getattr(config, "jm_image_quality", 95)), + options: dict[str, Any] = { + "settings": build_settings(config), + "max_chapters": int(getattr(config, "jm_max_chapters", 0)), + "chapter_max_file_size_mb": int( + getattr(config, "jm_chapter_max_file_size", 100) ), - ) + "pdf_dpi": float(getattr(config, "jm_pdf_dpi", 150.0)), + "image_quality": int(getattr(config, "jm_image_quality", 95)), + } + download: Callable[[], JmDownload] + if password is None: + download = functools.partial(_download_plain_sync, book_id, task_dir, **options) + else: + download = functools.partial( + _download_archive_sync, book_id, task_dir, password=password, **options + ) + future: asyncio.Future[JmDownload] = loop.run_in_executor(_DOWNLOAD_POOL, download) try: # shield:协程被取消时不要把线程一起取消——同步下载停不下来,强行删目录 # 只会被后续写入重新创建,所以等它跑完再清理 diff --git a/src/Undefined/jm/sender.py b/src/Undefined/jm/sender.py index 6e339c5f..34a2345b 100644 --- a/src/Undefined/jm/sender.py +++ b/src/Undefined/jm/sender.py @@ -1,8 +1,9 @@ """禁漫的合并转发发送与附件交付。 -自动提取先发「本子信息 + PDF 解密密码」两节点合并转发,随后把本地合成的加密 PDF -作为独立文件消息单独发出:转发节点里的文件在 QQ 客户端下载会失败,所以文件不进转发。 -工具侧另有两条路:``format_jm_book_info`` 只输出详情(不下载), +自动提取先发「本子信息 + 解密密码」两节点合并转发,随后把**一章一份加密 PDF 的 +zip** 作为独立文件消息单独发出:转发节点里的文件在 QQ 客户端下载会失败,所以文件 +不进转发。zip 本身不加密,密码在每个章节 PDF 内部,同一个密码适用于 zip 里所有 +PDF。工具侧另有两条路:``format_jm_book_info`` 只输出详情(不下载), ``fetch_jm_book_attachment`` 下载整本并把**未加密** PDF 注册为附件 UID(不发送), 供 ``file_analysis_agent`` 继续解析。密码始终不会写进历史。 """ @@ -17,9 +18,10 @@ from jmcpy import Book from Undefined.jm.downloader import ( - JmDownload, + JmChapterArchive, + JmPlainDownload, cleanup_download_path, - download_book_pdf, + download_book, ) if TYPE_CHECKING: @@ -72,7 +74,15 @@ def _preview(text: str | None, limit: int = _DESCRIPTION_PREVIEW_CHARS) -> str: return normalized[:limit].rstrip() + "..." -def build_info_text(result: JmDownload, *, note: str = "") -> str: +def _build_stats_line(result: JmChapterArchive) -> str: + """「页数 | zip 大小」一行;还没有 zip 时只报页数。""" + summary = f"页数: {result.page_count}" + if result.size_bytes is None: + return summary + return f"{summary} | zip: {_format_size(result.size_bytes)}" + + +def build_info_text(result: JmChapterArchive, *, note: str = "") -> str: """信息节点(也是历史摘要的来源)的正文。""" book: Book = result.book lines = [f"「JM{book.book_id} {book.title or '未知标题'}」"] @@ -80,7 +90,7 @@ def build_info_text(result: JmDownload, *, note: str = "") -> str: if book.authors: lines.append(f"作者: {', '.join(book.authors)}") lines.append( - f"章节: {result.chapter_count} 章(本次合并 {result.downloaded_chapters} 章)" + f"章节: {result.chapter_count} 章(本次出 {len(result.pdfs)} 份 PDF,每章一份)" ) if book.tags: lines.append(f"标签: {'、'.join(book.tags[:_MAX_TAGS])}") @@ -93,10 +103,12 @@ def build_info_text(result: JmDownload, *, note: str = "") -> str: if stats: lines.append(" | ".join(stats)) - summary = f"页数: {result.page_count}" - if result.pdf_path is not None and result.size_bytes is not None: - summary += f" | PDF: {_format_size(result.size_bytes)}" - lines.append(summary) + lines.append(_build_stats_line(result)) + if result.skipped_chapters: + # 少了哪几章必须写在用户能看到的地方,不能静默少发 + lines.append( + f"{result.skipped_chapters} 章未打包(超过单章体积上限或没有可用页面)" + ) if result.failed_pages: lines.append(f"下载失败 {result.failed_pages} 页") if note: @@ -112,7 +124,12 @@ def build_info_text(result: JmDownload, *, note: str = "") -> str: def build_password_text(password: str) -> str: - return f"PDF 解密密码({len(password)} 位):{password}" + """密码节点正文。 + + 写明密码适用于 zip 内所有 PDF——用户先解压再逐个打开,不写清楚容易以为 + zip 本身要密码。 + """ + return f"zip 内所有 PDF 的解密密码({len(password)} 位):{password}" def build_forward_nodes( @@ -123,14 +140,14 @@ def build_forward_nodes( ) -> list[dict[str, Any]]: """构建合并转发节点:信息 +(密码或状态)。 - PDF 不放进转发:转发节点里的文件在 QQ 客户端下载会失败,改由 :func:`_send_result` + 文件不放进转发:转发节点里的文件在 QQ 客户端下载会失败,改由 :func:`_send_result` 在转发之后单独发一条文件消息(2026-10-01 实测,同一份文件独立发送可正常下载)。 """ if not password: - # 没有可发送的 PDF 时用状态说明替代密码节点 + # 没有可发送的文件时用状态说明替代密码节点 return [ _node(info_text, name="本子信息"), - _node(status_text or "PDF 未发送", name="状态"), + _node(status_text or "文件未发送", name="状态"), ] return [ _node(info_text, name="本子信息"), @@ -138,7 +155,7 @@ def build_forward_nodes( ] -def build_history_message(result: JmDownload) -> str: +def build_history_message(result: JmChapterArchive) -> str: """历史摘要:只写可复述的信息,不含 PDF 密码。""" book = result.book lines = [ @@ -150,10 +167,11 @@ def build_history_message(result: JmDownload) -> str: # 这条摘要随转发写入,此时文件还没上传:不能提前声称已发送。 # 上传成功时 send_group_file 会自己补一条「[文件] … 」历史,失败时补提示。 lines.append( - f"PDF: 加密文件随后单独发送({_format_size(result.size_bytes)},密码见转发节点)" + f"zip: 加密 PDF(每章一份,共 {len(result.pdfs)} 份)随后单独发送" + f"({_format_size(result.size_bytes)},密码见转发节点)" ) else: - lines.append("PDF: 未发送") + lines.append("zip: 未发送") lines.append(build_album_url(book.book_id)) return "\n".join(lines) @@ -185,7 +203,7 @@ def format_jm_book_info(book: Book) -> str: def _build_uid_message( - result: JmDownload, + result: JmPlainDownload, *, uid: str, file_name: str, @@ -223,20 +241,24 @@ async def fetch_jm_book_attachment( if not str(scope_key or "").strip(): return "无法确定附件作用域,不能注册禁漫本子 PDF" - result, task_dir = await download_book_pdf(book_id, config=config, password=None) + result, task_dir = await download_book(book_id, config=config, password=None) try: + if not isinstance(result, JmPlainDownload): + # 没给密码时不可能拿到归档结果,这里只作类型收口 + return f"未能获取禁漫本子 PDF:内部状态异常|JM{book_id}" if not result.ok or result.pdf_path is None: reason = ( - f"PDF 超过体积上限(已下载 {result.page_count} 页)" + "PDF 超过单章体积上限" if result.status == "oversize" else "没有下载到任何页面" ) return f"未能获取禁漫本子 PDF:{reason}|JM{book_id}" + pdf_path = result.pdf_path record = await attachment_registry.register_local_file( scope_key, - result.pdf_path, + pdf_path, kind="file", - display_name=result.pdf_path.name, + display_name=pdf_path.name, source_kind="jm_book", source_ref=build_album_url(result.book.book_id), segment_data={ @@ -246,9 +268,7 @@ async def fetch_jm_book_attachment( "chapters": result.chapter_count, }, ) - return _build_uid_message( - result, uid=str(record.uid), file_name=result.pdf_path.name - ) + return _build_uid_message(result, uid=str(record.uid), file_name=pdf_path.name) finally: await cleanup_download_path(task_dir) @@ -334,17 +354,17 @@ async def _send_result( book_id: str, info_text: str, password: str, - pdf_path: Path, + zip_path: Path, history_message: str, ) -> str: - """发送「信息 + 密码」合并转发,再单独发送 PDF 文件,返回可记录的真实投递状态。 + """发送「信息 + 密码」合并转发,再单独发送 zip 文件,返回可记录的真实投递状态。 文件不进转发:转发节点里的文件在 QQ 客户端下载会失败,独立文件消息才下得动。 转发被协议端**明确拒绝**时降级为两条普通消息(信息 / 密码)。投递结果未确认 (``delivery_uncertain`` / ``file_transfer_error``)时不能降级重发信息与密码—— 转发可能已经送达;但文件是另一条消息、此前从未发出,必须继续单独发送,否则用户 整本下载白跑。文件自身投递结果未确认时同样不重发,直接上抛调用方;普通失败时补 - 一条写入历史的「PDF 上传失败」提示,转发投递未确认时提示不声称信息与密码已送达。 + 一条写入历史的「文件上传失败」提示,转发投递未确认时提示不声称信息与密码已送达。 """ nodes = build_forward_nodes(info_text, password=password) forward_status = _FORWARD_SENT @@ -355,7 +375,7 @@ async def _send_result( except Exception as exc: if _is_fatal_delivery_error(exc): logger.error( - "[JM] 合并转发投递结果未确认,不重发信息与密码,继续单独发送 PDF: book=%s", + "[JM] 合并转发投递结果未确认,不重发信息与密码,继续单独发送 zip: book=%s", book_id, ) forward_status = _FORWARD_UNCERTAIN @@ -382,23 +402,23 @@ async def _send_result( try: await _send_file( - sender, target_type, target_id, str(pdf_path), str(pdf_path.name) + sender, target_type, target_id, str(zip_path), str(zip_path.name) ) except Exception as file_exc: if _is_fatal_delivery_error(file_exc): logger.error("[JM] 文件投递结果未确认,不重发: book=%s", book_id) raise - logger.exception("[JM] PDF 文件发送失败: book=%s", book_id) + logger.exception("[JM] zip 文件发送失败: book=%s", book_id) # 转发投递未确认时不能声称「信息与密码如上」——转发可能压根没送达 notice = ( - "PDF 上传失败,本次没有发送文件(合并转发投递结果未确认,信息与密码可能没有送达)。" + "zip 上传失败,本次没有发送文件(合并转发投递结果未确认,信息与密码可能没有送达)。" if forward_status == _FORWARD_UNCERTAIN - else "PDF 上传失败,本次没有发送文件(信息与密码如上)。" + else "zip 上传失败,本次没有发送文件(信息与密码如上)。" ) # 写进历史:转发摘要只说「随后发送」,真正发没发出去靠这条记录收口 await _send_text(sender, target_type, target_id, notice) - return f"{forward_status},PDF 文件发送失败" - return f"{forward_status},PDF 文件已单独发送" + return f"{forward_status},zip 文件发送失败" + return f"{forward_status},zip 文件已单独发送" async def send_jm_book( @@ -409,19 +429,22 @@ async def send_jm_book( target_id: int, config: Any, ) -> str: - """下载整本,发送「信息 + 密码」合并转发,再单独发送加密 PDF 文件。""" + """下载整本,发送「信息 + 密码」合并转发,再单独发送「一章一份 PDF」的 zip。""" password = generate_pdf_password() - result, task_dir = await download_book_pdf( - book_id, config=config, password=password - ) + result, task_dir = await download_book(book_id, config=config, password=password) try: + if not isinstance(result, JmChapterArchive): + # 给了密码就必须拿到归档结果,这里只作类型收口 + logger.error("[JM] 下载结果不是章节归档: book=%s", book_id) + return f"JM{book_id} 下载结果异常,未发送任何文件" + history_message = build_history_message(result) - pdf_path = result.pdf_path - if not result.ok or pdf_path is None: + if not result.ok: + # 没有出任何章节 PDF:只发信息与状态两节点,不发密码与文件 note = ( - "PDF 未发送:文件超过体积上限" - if result.status == "oversize" - else "PDF 未发送:没有下载到任何页面" + "文件未发送:单章都超过体积上限" + if result.status == "empty" and result.skipped_chapters + else "文件未发送:没有下载到任何页面" ) info_text = build_info_text(result, note=note) nodes = build_forward_nodes(info_text, status_text=note) @@ -449,6 +472,8 @@ async def send_jm_book( ) return f"JM{book_id} 已发送信息({note})" + zip_path = result.zip_path + assert zip_path is not None # result.ok 已经保证有 zip status = await _send_result( sender, target_type, @@ -456,12 +481,12 @@ async def send_jm_book( book_id=book_id, info_text=build_info_text(result), password=password, - pdf_path=pdf_path, + zip_path=zip_path, history_message=history_message, ) return ( f"JM{book_id} {status}" - f"({result.downloaded_chapters} 章 / {result.page_count} 页 / " + f"({len(result.pdfs)} 章 / {result.page_count} 页 / " f"{_format_size(result.size_bytes)})" ) finally: diff --git a/src/Undefined/skills/shared.py b/src/Undefined/skills/shared.py index b326f95c..4c12d647 100644 --- a/src/Undefined/skills/shared.py +++ b/src/Undefined/skills/shared.py @@ -153,7 +153,7 @@ async def jm_send_book( target_id: int, config: Any, ) -> str: - """下载整本,发送「信息 / 密码」合并转发并单独发送加密 PDF 文件。""" + """下载整本,发送「信息 / 密码」合并转发并单独发送「一章一份加密 PDF」的 zip。""" from Undefined.jm.sender import send_jm_book as _send_jm_book return await _send_jm_book( diff --git a/src/Undefined/skills/tools/jm_book/README.md b/src/Undefined/skills/tools/jm_book/README.md index d87f463c..b8ebab93 100644 --- a/src/Undefined/skills/tools/jm_book/README.md +++ b/src/Undefined/skills/tools/jm_book/README.md @@ -1,21 +1,22 @@ # jm_book 工具 -处理禁漫(JM / 18comic)本子。默认下载整本并通过合并转发 + 独立文件消息发送;也支持只注册为附件 UID 供文件分析使用,或只获取本子信息。支持 `JM350234`、`350234`、`https://18comic.vip/album/350234` 与 `?id=` 链接。 +处理禁漫(JM / 18comic)本子。默认下载整本(一章一份加密 PDF,打包成一个无密码 zip)并通过合并转发 + 独立文件消息发送;也支持只注册为附件 UID 供文件分析使用,或只获取本子信息。支持 `JM350234`、`350234`、`https://18comic.vip/album/350234` 与 `?id=` 链接。 常用参数: - `book_id`:本子车号(`JM350234` / `350234` / 禁漫链接,车号 5-8 位) - `target_type`:可选,目标会话类型(`group`/`private`) - `target_id`:可选,目标会话 ID -- `output_mode`:可选,`send`(默认,发送「信息 / 密码」合并转发并单独发送 PDF 文件)、`uid`(只返回 ``,不发送消息)或 `info`(只返回本子信息,不下载不发送) +- `output_mode`:可选,`send`(默认,发送「信息 / 密码」合并转发并单独发送 zip 文件)、`uid`(只返回 ``,不发送消息)或 `info`(只返回本子信息,不下载不发送) `send` 模式流程: 1. 解析车号 -2. 逐章下载并按章节顺序合成为一个 AES-256 加密 PDF(`[jm].max_chapters` / `max_file_size` 限制) -3. 发送「本子信息 / PDF 解密密码」两节点合并转发,再单独发一条 PDF 文件消息(转发里的文件在 QQ 客户端下载会失败,独立文件消息才下得动,PDF 同时进入群文件列表) -4. 密码随机生成 8 位,只出现在转发节点里,不写入历史 +2. 逐章下载,每章合成一份 AES-256 加密 PDF(`[jm].max_chapters` / `chapter_max_file_size` 限制;单章超限只跳过该章) +3. 把各章 PDF 打包成一个**无密码** zip(`JM<车号> <标题>.zip`,内部条目是 `JM<车号> <序号>[ <章节标题>].pdf`) +4. 发送「本子信息 / PDF 解密密码」两节点合并转发,再单独发一条 zip 文件消息(转发里的文件在 QQ 客户端下载会失败,独立文件消息才下得动,zip 同时进入群文件列表) +5. 密码随机生成 8 位,zip 内所有 PDF 共用一个密码;密码只出现在转发节点里,不写入历史 `uid` 模式流程: -1. 下载整本并合成为**未加密** PDF(加密会让 PDF 解析工具打不开) +1. 下载整本并合成为**一份未加密** PDF(加密会让 PDF 解析工具打不开) 2. 注册为当前会话附件 UID 3. 返回本子概要和 ``,供 `file_analysis_agent` 继续解析 @@ -25,7 +26,7 @@ 3. 不下载、不发送、不注册附件 配置依赖: -- `config.toml` 中的 `[jm]` 段控制功能开关、白名单、体积上限、页数上限、DPI 与画质等 +- `config.toml` 中的 `[jm]` 段控制功能开关、白名单、单章体积上限、章数上限、DPI 与画质等 目录结构: - `config.json`:工具定义 diff --git a/src/Undefined/skills/tools/jm_book/config.json b/src/Undefined/skills/tools/jm_book/config.json index cdb42e9e..884bf0ab 100644 --- a/src/Undefined/skills/tools/jm_book/config.json +++ b/src/Undefined/skills/tools/jm_book/config.json @@ -2,7 +2,7 @@ "type": "function", "function": { "name": "jm_book", - "description": "处理禁漫(JM / 18comic)本子。默认下载整本,发送信息与密码的合并转发并单独发送加密 PDF 文件;可设置 output_mode=uid 只注册 PDF 附件 UID,或 output_mode=info 只获取本子信息。支持 JM350234、350234 或禁漫链接。", + "description": "处理禁漫(JM / 18comic)本子。默认下载整本,每章一份加密 PDF 打包成无密码 zip,发送「本子信息 / 解密密码」合并转发并单独发送 zip 文件;可设置 output_mode=uid 只注册未加密 PDF 附件 UID,或 output_mode=info 只获取本子信息。支持 JM350234、350234 或禁漫链接。", "parameters": { "type": "object", "properties": { @@ -22,7 +22,7 @@ "output_mode": { "type": "string", "enum": ["send", "uid", "info"], - "description": "输出模式:send(默认,下载整本、发送「信息 / 解密密码」合并转发并单独发送加密 PDF 文件)、uid(只下载并注册未加密 PDF 附件 UID,不发送消息)、info(只返回本子信息,不下载不发送)" + "description": "输出模式:send(默认,下载整本、每章一份加密 PDF 打包成 zip,发送「信息 / 解密密码」合并转发并单独发送 zip 文件)、uid(只下载并注册未加密 PDF 附件 UID,不发送消息)、info(只返回本子信息,不下载不发送)" } }, "required": ["book_id"] diff --git a/tests/test_jm_config.py b/tests/test_jm_config.py index 8517e780..156134c1 100644 --- a/tests/test_jm_config.py +++ b/tests/test_jm_config.py @@ -31,7 +31,7 @@ def test_jm_defaults_disable_feature(tmp_path: Path) -> None: assert config.jm_auto_extract_group_ids == [] assert config.jm_auto_extract_private_ids == [] assert config.jm_auto_extract_max_items == 1 - assert config.jm_max_file_size == 100 + assert config.jm_chapter_max_file_size == 100 assert config.jm_max_chapters == 0 assert config.jm_pdf_dpi == 150.0 assert config.jm_image_quality == 95 @@ -48,7 +48,7 @@ def test_jm_config_clamps_invalid_values(tmp_path: Path) -> None: "[jm]\n" "auto_extract_enabled = true\n" "auto_extract_max_items = 99\n" - "max_file_size = -5\n" + "chapter_max_file_size = -5\n" "max_chapters = -1\n" "pdf_dpi = 0\n" "image_quality = 0\n" @@ -61,7 +61,7 @@ def test_jm_config_clamps_invalid_values(tmp_path: Path) -> None: assert config.jm_auto_extract_enabled is True assert config.jm_auto_extract_max_items == 5 - assert config.jm_max_file_size == 100 + assert config.jm_chapter_max_file_size == 100 assert config.jm_max_chapters == 0 assert config.jm_pdf_dpi == 150.0 assert config.jm_image_quality == 95 @@ -79,7 +79,7 @@ def test_jm_config_keeps_explicit_values(tmp_path: Path) -> None: "auto_extract_enabled = true\n" "use_proxy = true\n" "auto_extract_max_items = 3\n" - "max_file_size = 0\n" + "chapter_max_file_size = 0\n" "max_chapters = 2\n" "pdf_dpi = 200.0\n" "image_quality = 100\n" @@ -91,7 +91,7 @@ def test_jm_config_keeps_explicit_values(tmp_path: Path) -> None: assert config.jm_use_proxy is True assert config.jm_auto_extract_max_items == 3 - assert config.jm_max_file_size == 0 + assert config.jm_chapter_max_file_size == 0 assert config.jm_max_chapters == 2 assert config.jm_pdf_dpi == 200.0 assert config.jm_image_quality == 100 @@ -180,7 +180,7 @@ def test_jm_config_rejects_negative_values(tmp_path: Path) -> None: ( "[jm]\n" "auto_extract_max_items = -3\n" - "max_file_size = -1\n" + "chapter_max_file_size = -1\n" "max_chapters = -9\n" "pdf_dpi = -150.0\n" "image_quality = -5\n" @@ -191,7 +191,7 @@ def test_jm_config_rejects_negative_values(tmp_path: Path) -> None: ) assert config.jm_auto_extract_max_items == 1 - assert config.jm_max_file_size == 100 + assert config.jm_chapter_max_file_size == 100 assert config.jm_max_chapters == 0 assert config.jm_pdf_dpi == 150.0 assert config.jm_image_quality == 95 diff --git a/tests/test_jm_downloader.py b/tests/test_jm_downloader.py index f1d459aa..f82c6d47 100644 --- a/tests/test_jm_downloader.py +++ b/tests/test_jm_downloader.py @@ -1,8 +1,11 @@ from __future__ import annotations -from pathlib import Path +from collections.abc import Sequence +import re from types import SimpleNamespace -from typing import Any, Literal +from typing import Any, Literal, TypeAlias +import zipfile +from pathlib import Path import asyncio @@ -15,12 +18,24 @@ import Undefined.jm.client as jm_client import Undefined.jm.downloader as jm_downloader -from Undefined.jm.downloader import JmDownload, download_book_pdf +from Undefined.jm.downloader import ( + JmChapterArchive, + JmDownload, + JmPlainDownload, + download_book, +) + +#: ``(章节号, 章节序号[, 章节标题])``,测试里用来描述一个本子的章节表 +ChapterSpec: TypeAlias = tuple[int, int] | tuple[int, int, str] + +#: 章节文件名里的三位序号(``JM<车号> <序号>[ <标题>].pdf``;整本 PDF 名没有序号) +_CHAPTER_INDEX_RE = re.compile(r"^JM\d{6,} (\d{3})(?: |\.)") class _FakeChapter: - def __init__(self, chapter_id: int, pages: int) -> None: + def __init__(self, chapter_id: int, pages: int, *, title: str = "") -> None: self.chapter_id = chapter_id + self.title = title self.pictures = tuple(f"{index:05d}.webp" for index in range(1, pages + 1)) def __len__(self) -> int: @@ -56,14 +71,22 @@ def _fake_picture(path: Path) -> Any: ) -def _fake_book(chapter_ids: list[tuple[int, int]]) -> Any: +def _fake_book( + chapters: Sequence[ChapterSpec], + *, + title: str = "测试 本子/标题", +) -> Any: + entries = [] + for item in chapters: + chapter_id, order = item[0], item[1] + chapter_title = item[2] if len(item) > 2 else "" + entries.append( + SimpleNamespace(chapter_id=chapter_id, order=order, title=chapter_title) + ) return SimpleNamespace( book_id=1114751, - title="测试 本子/标题", - chapters=tuple( - SimpleNamespace(chapter_id=chapter_id, order=order) - for chapter_id, order in chapter_ids - ), + title=title, + chapters=tuple(entries), ) @@ -73,11 +96,17 @@ class _FakeClient: instances: list["_FakeClient"] = [] def __init__( - self, settings: Any, *, chapter_ids: list[tuple[int, int]], page_size: int + self, + settings: Any, + *, + chapters: Sequence[ChapterSpec], + page_size: int, + book_title: str, ) -> None: self.settings = settings - self.chapter_ids = chapter_ids + self.chapters = chapters self.page_size = page_size + self.book_title = book_title self.downloads: list[dict[str, Any]] = [] _FakeClient.instances.append(self) @@ -89,7 +118,7 @@ def __exit__(self, *exc_info: object) -> Literal[False]: def get_book(self, book_id: str) -> Any: self.book_id = book_id - return _fake_book(self.chapter_ids) + return _fake_book(self.chapters, title=self.book_title) def get_chapter(self, chapter_id: int) -> _FakeChapter: self.chapter_id = chapter_id @@ -132,17 +161,44 @@ def _patch_env(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: def _install_client( monkeypatch: pytest.MonkeyPatch, *, - chapter_ids: list[tuple[int, int]], + chapters: Sequence[ChapterSpec], page_size: int = 10, + book_title: str = "测试 本子/标题", ) -> None: def _factory(settings: Any) -> _FakeClient: - return _FakeClient(settings, chapter_ids=chapter_ids, page_size=page_size) + return _FakeClient( + settings, + chapters=chapters, + page_size=page_size, + book_title=book_title, + ) monkeypatch.setattr(jm_downloader, "Client", _factory) -def _capture_write_pdf(monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]: - captured: dict[str, Any] = {} +def _write_stub_pdf(output: Path, pages: list[tuple[Path, Picture]]) -> None: + """替身 PDF:页数与传入页面一致,内容留空(只为验证结构,不解码图片)。""" + document = fitz.open() + for _ in pages: + document.new_page(width=100, height=150) + document.save(str(output), garbage=3, deflate=True) + document.close() + + +def _capture_write_pdf( + monkeypatch: pytest.MonkeyPatch, + *, + oversize_orders: set[int] | None = None, + empty_orders: set[int] | None = None, +) -> dict[str, Any]: + """替换 ``_write_pdf``:按输出名记录每章参数,可指定某些章节超限/无页。 + + ``oversize_orders`` / ``empty_orders`` 按**章节序号**(文件名里的三位数)生效, + 与真实实现一致:超限不落盘并返回 oversize。 + """ + captured: dict[str, Any] = {"calls": []} + oversize = oversize_orders or set() + empty = empty_orders or set() def _write_pdf( pages: Any, @@ -153,16 +209,29 @@ def _write_pdf( password: str | None, limit_bytes: int | None, ) -> Any: - captured["pages"] = list(pages) - captured["sources"] = [path for path, _picture in pages] - captured["dpi"] = dpi - captured["quality"] = quality - captured["password"] = password - captured["limit_bytes"] = limit_bytes + page_list = list(pages) output.parent.mkdir(parents=True, exist_ok=True) - output.write_bytes(b"%PDF-1.4") + # 章节序号写在文件名里;整本 PDF(uid 路径)没有序号,超限判定按 1 处理 + match = _CHAPTER_INDEX_RE.search(output.name) + index = int(match.group(1)) if match else 1 + captured["calls"].append( + { + "output": output, + "pages": page_list, + "sources": [path for path, _picture in page_list], + "dpi": dpi, + "quality": quality, + "password": password, + "limit_bytes": limit_bytes, + } + ) + if index in oversize: + return jm_downloader._PdfBuild("oversize", None, len(page_list), 1234, 0) + if index in empty: + return jm_downloader._PdfBuild("empty", None, 0, 0, len(page_list)) + _write_stub_pdf(output, page_list) return jm_downloader._PdfBuild( - "ok", output, len(captured["pages"]), output.stat().st_size, 0 + "ok", output, len(page_list), output.stat().st_size, 0 ) monkeypatch.setattr(jm_downloader, "_write_pdf", _write_pdf) @@ -176,7 +245,7 @@ def _config(**overrides: Any) -> Any: "jm_image_timeout": 60.0, "jm_download_concurrency": 4, "jm_max_chapters": 0, - "jm_max_file_size": 100, + "jm_chapter_max_file_size": 100, "jm_pdf_dpi": 150.0, "jm_image_quality": 95, } @@ -184,33 +253,70 @@ def _config(**overrides: Any) -> Any: return SimpleNamespace(**base) +def _archive(result: JmDownload) -> JmChapterArchive: + assert isinstance(result, JmChapterArchive), result + return result + + +def _plain(result: JmDownload) -> JmPlainDownload: + assert isinstance(result, JmPlainDownload), result + return result + + +def _zip_entries(zip_path: Path) -> list[str]: + with zipfile.ZipFile(zip_path) as archive: + return archive.namelist() + + @pytest.mark.asyncio -async def test_download_book_pdf_merges_all_chapters_in_order( +async def test_download_book_writes_one_pdf_per_chapter_into_zip( monkeypatch: pytest.MonkeyPatch, ) -> None: - _install_client(monkeypatch, chapter_ids=[(111, 1), (222, 2)]) + _install_client( + monkeypatch, + chapters=[(111, 1, "序章"), (222, 2)], + ) captured = _capture_write_pdf(monkeypatch) - result, task_dir = await download_book_pdf( + result, task_dir = await download_book( "1114751", config=_config(), password="Ab3xK9Qm" ) - - assert result.status == "ok" - assert result.chapter_count == 2 - assert result.downloaded_chapters == 2 - assert result.page_count == 4 - assert result.pdf_path is not None - assert result.pdf_path.name == "JM1114751 测试 本子_标题.pdf" - assert result.pdf_path.parent == task_dir - # 页序 = 章节顺序 × 章节内页序,且每章写入独立目录 - assert [path.name for path in captured["sources"]] == [ - "00001.webp", - "00002.webp", + archive = _archive(result) + + assert archive.status == "ok" + assert archive.chapter_count == 2 + assert archive.downloaded_chapters == 2 + assert archive.skipped_chapters == 0 + assert archive.page_count == 4 + assert archive.zip_path is not None + assert archive.zip_path.name == "JM1114751 测试 本子_标题.zip" + assert archive.zip_path.parent == task_dir + assert archive.size_bytes == archive.zip_path.stat().st_size + # 每章一份 PDF:序号补零,有标题的带标题;空标题只留序号 + assert _zip_entries(archive.zip_path) == [ + "JM1114751 001 序章.pdf", + "JM1114751 002.pdf", + ] + assert [(pdf.order, pdf.title, pdf.page_count) for pdf in archive.pdfs] == [ + (1, "序章", 2), + (2, "", 2), + ] + # 每个章节 PDF 单独写盘,页序 = 章节顺序 × 章节内页序 + assert [call["output"].name for call in captured["calls"]] == [ + "JM1114751 001 序章.pdf", + "JM1114751 002.pdf", + ] + assert [path.name for path in captured["calls"][0]["sources"]] == [ "00001.webp", "00002.webp", ] - assert captured["password"] == "Ab3xK9Qm" - assert captured["dpi"] == 150.0 + assert all(call["password"] == "Ab3xK9Qm" for call in captured["calls"]) + assert all(call["dpi"] == 150.0 for call in captured["calls"]) + assert all(call["limit_bytes"] == 100 * 1024 * 1024 for call in captured["calls"]) + # 章节 PDF 已进 zip,任务目录里只留最终交付物 + assert sorted(path.name for path in task_dir.glob("*") if path.is_file()) == [ + "JM1114751 测试 本子_标题.zip" + ] client = _FakeClient.instances[0] assert [item["chapter_id"] for item in client.downloads] == [111, 222] assert client.downloads[0]["dest"] != client.downloads[1]["dest"] @@ -220,95 +326,206 @@ async def test_download_book_pdf_merges_all_chapters_in_order( @pytest.mark.asyncio -async def test_download_book_pdf_sorts_chapters_by_order( +async def test_download_book_sorts_chapters_by_order( monkeypatch: pytest.MonkeyPatch, ) -> None: - # 服务端返回顺序不可信:按 order 排序后合出的 PDF 才是阅读顺序 - _install_client(monkeypatch, chapter_ids=[(111, 2), (222, 1)]) + # 服务端返回顺序不可信:按 order 排序后 zip 条目才是阅读顺序 + _install_client(monkeypatch, chapters=[(111, 2), (222, 1)]) captured = _capture_write_pdf(monkeypatch) - result, _ = await download_book_pdf( - "1114751", config=_config(), password="Ab3xK9Qm" - ) + result, _ = await download_book("1114751", config=_config(), password="Ab3xK9Qm") + archive = _archive(result) - assert result.status == "ok" + assert archive.status == "ok" assert [item["chapter_id"] for item in _FakeClient.instances[0].downloads] == [ 222, 111, ] - assert [path.parent.name for path in captured["sources"]] == [ + # 章节序号跟着阅读顺序走,与章节号无关 + assert [call["output"].name for call in captured["calls"]] == [ + "JM1114751 001.pdf", + "JM1114751 002.pdf", + ] + assert [pdf.chapter_id for pdf in archive.pdfs] == [222, 111] + assert [path.parent.name for path in captured["calls"][0]["sources"]] == [ "c001", "c001", + ] + assert [path.parent.name for path in captured["calls"][1]["sources"]] == [ "c002", "c002", ] @pytest.mark.asyncio -async def test_download_book_pdf_falls_back_to_book_id_for_single_chapter( +async def test_download_book_falls_back_to_book_id_for_single_chapter( monkeypatch: pytest.MonkeyPatch, ) -> None: - _install_client(monkeypatch, chapter_ids=[]) - _capture_write_pdf(monkeypatch) + _install_client(monkeypatch, chapters=[]) + captured = _capture_write_pdf(monkeypatch) - result, _ = await download_book_pdf( - "1114751", config=_config(), password="Ab3xK9Qm" - ) + result, _ = await download_book("1114751", config=_config(), password="Ab3xK9Qm") + archive = _archive(result) - assert result.status == "ok" - assert result.chapter_count == 1 + assert archive.status == "ok" + assert archive.chapter_count == 1 assert _FakeClient.instances[0].downloads[0]["chapter_id"] == 1114751 + assert _zip_entries(archive.zip_path) == ["JM1114751 001.pdf"] # type: ignore[arg-type] + assert len(captured["calls"]) == 1 @pytest.mark.asyncio -async def test_download_book_pdf_skips_empty_chapters_and_honours_max_chapters( +async def test_download_book_skips_empty_chapters_and_honours_max_chapters( monkeypatch: pytest.MonkeyPatch, ) -> None: - _install_client(monkeypatch, chapter_ids=[(999, 1), (111, 2), (222, 3)]) + _install_client(monkeypatch, chapters=[(999, 1), (111, 2), (222, 3)]) _capture_write_pdf(monkeypatch) - result, _ = await download_book_pdf( + result, _ = await download_book( "1114751", config=_config(jm_max_chapters=2), password="Ab3xK9Qm" ) + archive = _archive(result) # 只取前两章,其中一章没有图片 - assert result.chapter_count == 3 - assert result.downloaded_chapters == 1 - assert result.page_count == 2 + assert archive.chapter_count == 3 + assert archive.downloaded_chapters == 1 + assert archive.skipped_chapters == 0, "服务端没给图的章节不算「被跳过」" + assert archive.page_count == 2 assert [item["chapter_id"] for item in _FakeClient.instances[0].downloads] == [111] + # 序号取自服务端章节序号:被跳过的第 1 章仍占号,所以留下的是 002 + assert _zip_entries(archive.zip_path) == ["JM1114751 002.pdf"] # type: ignore[arg-type] @pytest.mark.asyncio -async def test_download_book_pdf_stops_when_images_exceed_limit( +async def test_download_book_skips_oversized_chapter_and_keeps_the_rest( monkeypatch: pytest.MonkeyPatch, ) -> None: - _install_client(monkeypatch, chapter_ids=[(111, 1), (222, 2)], page_size=600_000) - captured = _capture_write_pdf(monkeypatch) + """单章超限只丢那一章:其余章节照常打包,状态是 partial。""" + _install_client(monkeypatch, chapters=[(111, 1), (222, 2), (333, 3)]) + captured = _capture_write_pdf(monkeypatch, oversize_orders={2}) - result, _ = await download_book_pdf( - "1114751", config=_config(jm_max_file_size=1), password="Ab3xK9Qm" + result, _ = await download_book( + "1114751", config=_config(jm_chapter_max_file_size=1), password="Ab3xK9Qm" ) + archive = _archive(result) + + assert archive.status == "partial" + assert archive.ok + assert archive.skipped_chapters == 1 + assert archive.downloaded_chapters == 3 + assert archive.page_count == 4 + assert len(captured["calls"]) == 3 + assert _zip_entries(archive.zip_path) == [ # type: ignore[arg-type] + "JM1114751 001.pdf", + "JM1114751 003.pdf", + ] + assert [pdf.order for pdf in archive.pdfs] == [1, 3] - assert result.status == "oversize" - assert result.pdf_path is None - assert captured == {} - assert len(_FakeClient.instances[0].downloads) == 1 + +@pytest.mark.asyncio +async def test_download_book_skips_chapter_whose_pages_all_failed( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _install_client(monkeypatch, chapters=[(111, 1), (222, 2)]) + _capture_write_pdf(monkeypatch, empty_orders={2}) + + result, _ = await download_book("1114751", config=_config(), password="Ab3xK9Qm") + archive = _archive(result) + + assert archive.status == "partial" + assert archive.skipped_chapters == 1 + assert _zip_entries(archive.zip_path) == ["JM1114751 001.pdf"] # type: ignore[arg-type] @pytest.mark.asyncio -async def test_download_book_pdf_reports_empty_result( +async def test_download_book_reports_empty_when_no_chapter_makes_it( monkeypatch: pytest.MonkeyPatch, ) -> None: - _install_client(monkeypatch, chapter_ids=[(999, 1)]) + """所有章节都超限时不出 zip:交给上层只发信息节点。""" + _install_client(monkeypatch, chapters=[(111, 1), (222, 2)]) + _capture_write_pdf(monkeypatch, oversize_orders={1, 2}) + + result, task_dir = await download_book( + "1114751", config=_config(jm_chapter_max_file_size=1), password="Ab3xK9Qm" + ) + archive = _archive(result) + + assert archive.status == "empty" + assert not archive.ok + assert archive.zip_path is None + assert archive.size_bytes is None + assert archive.page_count == 0 + assert archive.skipped_chapters == 2 + assert list(task_dir.glob("*.zip")) == [] + + +@pytest.mark.asyncio +async def test_download_book_reports_empty_result( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _install_client(monkeypatch, chapters=[(999, 1)]) captured = _capture_write_pdf(monkeypatch) - result, _ = await download_book_pdf( + result, task_dir = await download_book( "1114751", config=_config(), password="Ab3xK9Qm" ) + archive = _archive(result) + + assert archive.status == "empty" + assert archive.zip_path is None + assert captured["calls"] == [] + assert list(task_dir.glob("*")) == [] + + +def test_chapter_limit_bytes_follows_config() -> None: + assert jm_downloader._chapter_limit_bytes(0) is None + assert jm_downloader._chapter_limit_bytes(2) == 2 * 1024 * 1024 + + +@pytest.mark.asyncio +async def test_download_book_merges_into_plain_pdf_without_password( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """uid 路径维持现状:所有章节合成一份未加密 PDF,不产生 zip。""" + _install_client(monkeypatch, chapters=[(111, 1), (222, 2)]) + captured = _capture_write_pdf(monkeypatch) + + result, task_dir = await download_book("1114751", config=_config(), password=None) + plain = _plain(result) + + assert plain.status == "ok" + assert plain.ok + assert plain.chapter_count == 2 + assert plain.downloaded_chapters == 2 + assert plain.page_count == 4 + assert plain.pdf_path is not None + assert plain.pdf_path.name == "JM1114751 测试 本子_标题.pdf" + assert len(captured["calls"]) == 1 + assert captured["calls"][0]["password"] is None + assert [path.name for path in captured["calls"][0]["sources"]] == [ + "00001.webp", + "00002.webp", + "00001.webp", + "00002.webp", + ] + assert list(task_dir.glob("*.zip")) == [] + + +@pytest.mark.asyncio +async def test_download_book_plain_reports_oversize( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _install_client(monkeypatch, chapters=[(111, 1), (222, 2)]) + _capture_write_pdf(monkeypatch, oversize_orders={1}) + + result, _ = await download_book( + "1114751", config=_config(jm_chapter_max_file_size=1), password=None + ) + plain = _plain(result) - assert result.status == "empty" - assert result.pdf_path is None - assert captured == {} + assert plain.status == "oversize" + assert plain.pdf_path is None + assert not plain.ok def test_build_settings_maps_config(monkeypatch: pytest.MonkeyPatch) -> None: @@ -498,28 +715,44 @@ def test_write_pdf_reports_empty_when_every_page_fails(tmp_path: Path) -> None: assert build.failed_pages == 1 +def test_write_zip_has_no_password_and_keeps_entry_order(tmp_path: Path) -> None: + zip_path = tmp_path / "book.zip" + + size = jm_downloader._write_zip( + [("JM1 001.pdf", b"%PDF-1.4 first"), ("JM1 002 a.pdf", b"%PDF-1.4 second")], + zip_path, + ) + + assert size == zip_path.stat().st_size + with zipfile.ZipFile(zip_path) as archive: + assert archive.namelist() == ["JM1 001.pdf", "JM1 002 a.pdf"] + # zip 不加密:没有密码位,条目可以直接读 + assert all(not info.flag_bits & 0x1 for info in archive.infolist()) + assert archive.read("JM1 001.pdf") == b"%PDF-1.4 first" + + @pytest.mark.asyncio -async def test_download_book_pdf_cleans_up_when_download_raises( +async def test_download_book_cleans_up_when_download_raises( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: def _boom(*_args: Any, **_kwargs: Any) -> Any: raise RuntimeError("boom") - monkeypatch.setattr(jm_downloader, "_download_sync", _boom) + monkeypatch.setattr(jm_downloader, "_download_archive_sync", _boom) with pytest.raises(RuntimeError, match="boom"): - await download_book_pdf("1114751", config=_config(), password="Ab3xK9Qm") + await download_book("1114751", config=_config(), password="Ab3xK9Qm") leftovers = list((tmp_path / "jm").glob("*")) assert leftovers == [], f"异常路径不得留下任务目录: {leftovers}" @pytest.mark.asyncio -async def test_download_book_pdf_counts_failed_pages( +async def test_download_book_counts_failed_pages( monkeypatch: pytest.MonkeyPatch, ) -> None: - # 每章 2 页,其中 1 页下载失败:失败数应汇总到 JmDownload.failed_pages - _install_client(monkeypatch, chapter_ids=[(111, 1)]) + # 每章 2 页,其中 1 页下载失败:失败数应汇总到 failed_pages + _install_client(monkeypatch, chapters=[(111, 1)]) _capture_write_pdf(monkeypatch) original_download = _FakeClient.download @@ -531,16 +764,15 @@ def _download_with_failure(self: Any, chapter: Any, **kwargs: Any) -> Any: monkeypatch.setattr(_FakeClient, "download", _download_with_failure) - result, _ = await download_book_pdf( - "1114751", config=_config(), password="Ab3xK9Qm" - ) + result, _ = await download_book("1114751", config=_config(), password="Ab3xK9Qm") + archive = _archive(result) - assert result.status == "ok" - assert result.failed_pages == 1 + assert archive.status == "ok" + assert archive.failed_pages == 1 @pytest.mark.asyncio -async def test_download_book_pdf_cleans_up_after_cancellation( +async def test_download_book_cleans_up_after_cancellation( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: """取消后线程仍在写盘:必须等它结束再清理,不留残留原图/未加密 PDF。""" @@ -552,21 +784,22 @@ async def test_download_book_pdf_cleans_up_after_cancellation( def _slow(*_args: Any, **_kwargs: Any) -> Any: started.set() release.wait(10) - return JmDownload( - status="ok", + return JmChapterArchive( + status="empty", book=Book(book_id=1114751, title="测试本子"), - pdf_path=None, + zip_path=None, + size_bytes=None, page_count=0, - size_bytes=0, chapter_count=1, downloaded_chapters=0, failed_pages=0, + skipped_chapters=0, ) - monkeypatch.setattr(jm_downloader, "_download_sync", _slow) + monkeypatch.setattr(jm_downloader, "_download_archive_sync", _slow) task = asyncio.create_task( - download_book_pdf("1114751", config=_config(), password=None) + download_book("1114751", config=_config(), password="Ab3xK9Qm") ) assert await asyncio.to_thread(started.wait, 10), "下载线程没能启动" task.cancel() diff --git a/tests/test_jm_sender.py b/tests/test_jm_sender.py index 83a35b3d..e66f7a8c 100644 --- a/tests/test_jm_sender.py +++ b/tests/test_jm_sender.py @@ -1,6 +1,7 @@ from __future__ import annotations from pathlib import Path +import zipfile from types import SimpleNamespace from typing import Any, Literal from unittest.mock import AsyncMock @@ -9,12 +10,20 @@ from jmcpy import Book import Undefined.jm.sender as jm_sender -from Undefined.jm.downloader import JmDownload +from Undefined.jm.downloader import ( + ChapterPdfInfo, + JmChapterArchive, + JmDownload, + JmPlainDownload, +) from Undefined.jm.sender import ( generate_pdf_password, send_jm_book, ) +#: 一个 8 位密码,与 generate_pdf_password 的字母表一致 +_PASSWORD = "Ab3xK9Qm" + def _book() -> Book: return Book( @@ -29,18 +38,60 @@ def _book() -> Book: ) -def _result( +def _archive_result( + tmp_path: Path, + *, + status: Literal["ok", "partial", "empty"] = "ok", + skipped_chapters: int = 0, + size_bytes: int | None = None, +) -> JmChapterArchive: + """自动提取路径的结果:一个装了 N 份章节 PDF 的 zip。""" + zip_path = tmp_path / "JM1114751 测试本子.zip" + pdfs = ( + ( + ChapterPdfInfo( + order=1, chapter_id=111, title="序章", page_count=60, size_bytes=2048 + ), + ChapterPdfInfo( + order=2, chapter_id=222, title="", page_count=60, size_bytes=2048 + ), + ) + if status != "empty" + else () + ) + if status != "empty": + with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as archive: + for pdf in pdfs: + archive.writestr(f"JM1114751 {pdf.order:03d}.pdf", b"%PDF-1.4") + if size_bytes is None: + size_bytes = 12_345_678 if status != "empty" else None + return JmChapterArchive( + status=status, + book=_book(), + zip_path=zip_path if status != "empty" else None, + size_bytes=size_bytes, + page_count=0 if status == "empty" else 120, + chapter_count=3, + downloaded_chapters=1 if status == "empty" else 3, + failed_pages=0, + skipped_chapters=skipped_chapters, + pdfs=pdfs, + ) + + +def _plain_result( tmp_path: Path, *, status: Literal["ok", "oversize", "empty"] = "ok", size_bytes: int | None = None, -) -> JmDownload: +) -> JmPlainDownload: + """uid 路径的结果:一份未加密整本 PDF。""" pdf_path = tmp_path / "JM1114751 测试本子.pdf" if status == "ok": pdf_path.write_bytes(b"%PDF-1.4") if size_bytes is None: size_bytes = 12_345_678 if status == "ok" else None - return JmDownload( + return JmPlainDownload( status=status, book=_book(), pdf_path=pdf_path if status == "ok" else None, @@ -78,14 +129,14 @@ def _patch_download( captured: dict[str, Any] = {} async def _fake_download( - book_id: str, *, config: Any, password: str + book_id: str, *, config: Any, password: str | None = None ) -> tuple[JmDownload, Path]: captured["book_id"] = book_id captured["password"] = password captured["config"] = config return result, task_dir - monkeypatch.setattr(jm_sender, "download_book_pdf", _fake_download) + monkeypatch.setattr(jm_sender, "download_book", _fake_download) cleanup = AsyncMock() monkeypatch.setattr(jm_sender, "cleanup_download_path", cleanup) captured["cleanup"] = cleanup @@ -102,11 +153,11 @@ def test_generate_pdf_password_uses_safe_eight_char_alphabet() -> None: @pytest.mark.asyncio -async def test_send_jm_book_sends_forward_then_standalone_file( +async def test_send_jm_book_sends_forward_then_standalone_zip( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: sender = _sender() - result = _result(tmp_path) + result = _archive_result(tmp_path) captured = _patch_download(monkeypatch, result, tmp_path / "task") status = await send_jm_book( @@ -117,9 +168,10 @@ async def test_send_jm_book_sends_forward_then_standalone_file( config=SimpleNamespace(), ) - assert status.startswith("JM1114751 已发送合并转发,PDF 文件已单独发送") - pdf_path = result.pdf_path - assert pdf_path is not None + assert status.startswith("JM1114751 已发送合并转发,zip 文件已单独发送") + assert status.endswith("(2 章 / 120 页 / 11.8MB)") + zip_path = result.zip_path + assert zip_path is not None args = sender.send_group_forward_message.await_args nodes = args.args[1] # 文件不进转发:转发节点里的文件在 QQ 客户端下载会失败 @@ -128,13 +180,18 @@ async def test_send_jm_book_sends_forward_then_standalone_file( info_text = nodes[0]["data"]["content"] assert "JM1114751" in info_text assert "测试本子" in info_text + # 信息节点说明每章一份 PDF 与 zip 大小 + assert "本次出 2 份 PDF" in info_text + assert "zip: 11.8MB" in info_text # 末尾展示可复制的车号,不再放站点链接 assert info_text.strip().endswith("JM1114751") assert "18comic" not in info_text + # 密码节点写明这是 zip 内所有 PDF 共用的密码 + assert "zip 内所有 PDF 的解密密码" in nodes[1]["data"]["content"] assert f":{captured['password']}" in nodes[1]["data"]["content"] assert len(captured["password"]) == 8 # 可下载的入口是转发之后那条独立文件消息 - sender.send_group_file.assert_awaited_once_with(20001, str(pdf_path), pdf_path.name) + sender.send_group_file.assert_awaited_once_with(20001, str(zip_path), zip_path.name) assert sender.order == ["group_forward", "group_file"] # 历史摘要不含密码,只说明密码在转发节点里;文件还没上传,不能提前声称已发送 history_message = args.kwargs["history_message"] @@ -142,15 +199,41 @@ async def test_send_jm_book_sends_forward_then_standalone_file( assert "密码见转发节点" in history_message assert "随后单独发送" in history_message assert "已单独发送" not in history_message + assert "每章一份" in history_message captured["cleanup"].assert_awaited_once_with(tmp_path / "task") @pytest.mark.asyncio -async def test_send_jm_book_falls_back_to_plain_messages_then_file( +async def test_send_jm_book_mentions_skipped_chapters_in_info( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """被跳过的章节必须写在用户看得到的地方,不能静默少发。""" + sender = _sender() + result = _archive_result(tmp_path, status="partial", skipped_chapters=2) + _patch_download(monkeypatch, result, tmp_path / "task") + + await send_jm_book( + "1114751", + sender=sender, + target_type="group", + target_id=20001, + config=SimpleNamespace(), + ) + + info_text = sender.send_group_forward_message.await_args.args[1][0]["data"][ + "content" + ] + assert "2 章未打包" in info_text + # partial 仍然照发 zip + sender.send_group_file.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_send_jm_book_falls_back_to_plain_messages_then_zip( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: sender = _sender() - result = _result(tmp_path) + result = _archive_result(tmp_path) sender.send_group_forward_message.side_effect = RuntimeError("forward rejected") captured = _patch_download(monkeypatch, result, tmp_path / "task") @@ -162,18 +245,19 @@ async def test_send_jm_book_falls_back_to_plain_messages_then_file( config=SimpleNamespace(), ) - # 转发发不出去时才退化为两条普通消息,文件照旧单独发送 + # 转发发不出去时才退化为两条普通消息,zip 照旧单独发送 assert sender.order == ["group_message", "group_message", "group_file"] password_call = sender.send_group_message.await_args_list[1] assert captured["password"] in password_call.args[1] + assert "zip 内所有 PDF 的解密密码" in password_call.args[1] # 普通消息兜底时历史里同样不留密码 for call in sender.send_group_message.await_args_list: history_message = call.kwargs["history_message"] assert history_message is not None assert captured["password"] not in history_message - pdf_path = result.pdf_path - assert pdf_path is not None - sender.send_group_file.assert_awaited_once_with(20001, str(pdf_path), pdf_path.name) + zip_path = result.zip_path + assert zip_path is not None + sender.send_group_file.assert_awaited_once_with(20001, str(zip_path), zip_path.name) @pytest.mark.asyncio @@ -186,7 +270,7 @@ class _Uncertain(RuntimeError): delivery_uncertain = True sender = _sender() - result = _result(tmp_path) + result = _archive_result(tmp_path) sender.send_group_forward_message.side_effect = _Uncertain("timeout") _patch_download(monkeypatch, result, tmp_path / "task") @@ -204,7 +288,7 @@ class _Uncertain(RuntimeError): # 文件丢在这里等于整本下载白跑 sender.send_group_file.assert_awaited_once() assert "合并转发投递结果未确认" in status - assert "PDF 文件已单独发送" in status + assert "zip 文件已单独发送" in status @pytest.mark.asyncio @@ -215,7 +299,7 @@ class _TransferFailed(RuntimeError): file_transfer_error = True sender = _sender() - result = _result(tmp_path) + result = _archive_result(tmp_path) sender.send_group_forward_message.side_effect = _TransferFailed("prepare failed") _patch_download(monkeypatch, result, tmp_path / "task") @@ -242,7 +326,7 @@ class _Uncertain(RuntimeError): delivery_uncertain = True sender = _sender() - result = _result(tmp_path) + result = _archive_result(tmp_path) sender.send_group_file.side_effect = _Uncertain("timeout") _patch_download(monkeypatch, result, tmp_path / "task") @@ -267,7 +351,7 @@ class _TransferFailed(RuntimeError): file_transfer_error = True sender = _sender() - result = _result(tmp_path) + result = _archive_result(tmp_path) sender.send_group_file.side_effect = _TransferFailed("prepare failed") _patch_download(monkeypatch, result, tmp_path / "task") @@ -285,11 +369,11 @@ class _TransferFailed(RuntimeError): @pytest.mark.asyncio -async def test_send_jm_book_reports_when_pdf_upload_fails( +async def test_send_jm_book_reports_when_zip_upload_fails( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: sender = _sender() - result = _result(tmp_path) + result = _archive_result(tmp_path) sender.send_group_file.side_effect = RuntimeError("upload failed") _patch_download(monkeypatch, result, tmp_path / "task") @@ -301,9 +385,9 @@ async def test_send_jm_book_reports_when_pdf_upload_fails( config=SimpleNamespace(), ) - assert status.startswith("JM1114751 已发送合并转发,PDF 文件发送失败"), status + assert status.startswith("JM1114751 已发送合并转发,zip 文件发送失败"), status last = sender.send_group_message.await_args_list[-1] - assert "PDF 上传失败" in last.args[1] + assert "zip 上传失败" in last.args[1] assert "信息与密码如上" in last.args[1] # 提示写入历史:转发摘要只说「随后发送」,真正发没发出去靠这条收口 assert last.args[2] is True @@ -319,7 +403,7 @@ class _Uncertain(RuntimeError): delivery_uncertain = True sender = _sender() - result = _result(tmp_path) + result = _archive_result(tmp_path) sender.send_group_forward_message.side_effect = _Uncertain("timeout") sender.send_group_file.side_effect = RuntimeError("upload failed") _patch_download(monkeypatch, result, tmp_path / "task") @@ -332,7 +416,7 @@ class _Uncertain(RuntimeError): config=SimpleNamespace(), ) - assert status.startswith("JM1114751 合并转发投递结果未确认,PDF 文件发送失败"), ( + assert status.startswith("JM1114751 合并转发投递结果未确认,zip 文件发送失败"), ( status ) notice = sender.send_group_message.await_args_list[-1].args[1] @@ -347,7 +431,7 @@ async def test_send_jm_book_reports_fallback_status( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: sender = _sender() - result = _result(tmp_path) + result = _archive_result(tmp_path) sender.send_group_forward_message.side_effect = RuntimeError("forward rejected") _patch_download(monkeypatch, result, tmp_path / "task") @@ -360,15 +444,16 @@ async def test_send_jm_book_reports_fallback_status( ) assert "已改为普通消息发送信息与密码" in status - assert "PDF 文件已单独发送" in status + assert "zip 文件已单独发送" in status @pytest.mark.asyncio -async def test_send_jm_book_skips_pdf_when_empty( +async def test_send_jm_book_skips_file_when_no_chapter_pdf_was_built( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: + """一章都没出时不发密码与文件,只发信息与状态两个节点。""" sender = _sender() - result = _result(tmp_path, status="empty") + result = _archive_result(tmp_path, status="empty", skipped_chapters=3) _patch_download(monkeypatch, result, tmp_path / "task") status = await send_jm_book( @@ -379,22 +464,22 @@ async def test_send_jm_book_skips_pdf_when_empty( config=SimpleNamespace(), ) - assert status == "JM1114751 已发送信息(PDF 未发送:没有下载到任何页面)" + assert "JM1114751 已发送信息" in status nodes = sender.send_group_forward_message.await_args.args[1] - assert "没有下载到任何页面" in nodes[0]["data"]["content"] + assert [node["data"]["name"] for node in nodes] == ["本子信息", "状态"] + assert "3 章未打包" in nodes[0]["data"]["content"] sender.send_group_file.assert_not_awaited() @pytest.mark.asyncio -async def test_send_jm_book_oversize_does_not_show_source_bytes_as_pdf_size( +async def test_send_jm_book_skips_file_when_nothing_downloaded( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: - """预判超限时只有原图字节,信息节点不能把它写成 PDF 大小。""" sender = _sender() - result = _result(tmp_path, status="oversize", size_bytes=88 * 1024 * 1024) + result = _archive_result(tmp_path, status="empty") _patch_download(monkeypatch, result, tmp_path / "task") - await send_jm_book( + status = await send_jm_book( "1114751", sender=sender, target_type="group", @@ -402,19 +487,22 @@ async def test_send_jm_book_oversize_does_not_show_source_bytes_as_pdf_size( config=SimpleNamespace(), ) - info = sender.send_group_forward_message.await_args.args[1][0]["data"]["content"] - assert "PDF:" not in info + assert status == "JM1114751 已发送信息(文件未发送:没有下载到任何页面)" + nodes = sender.send_group_forward_message.await_args.args[1] + assert "没有下载到任何页面" in nodes[0]["data"]["content"] + sender.send_group_file.assert_not_awaited() @pytest.mark.asyncio -async def test_send_jm_book_skips_pdf_when_oversize( +async def test_send_jm_book_empty_result_does_not_show_size_as_zip_size( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: + """没有 zip 时信息节点不能把原图字节写成 zip 大小。""" sender = _sender() - result = _result(tmp_path, status="oversize") + result = _archive_result(tmp_path, status="empty") _patch_download(monkeypatch, result, tmp_path / "task") - status = await send_jm_book( + await send_jm_book( "1114751", sender=sender, target_type="group", @@ -422,11 +510,8 @@ async def test_send_jm_book_skips_pdf_when_oversize( config=SimpleNamespace(), ) - assert status == "JM1114751 已发送信息(PDF 未发送:文件超过体积上限)" - nodes = sender.send_group_forward_message.await_args.args[1] - assert [node["data"]["name"] for node in nodes] == ["本子信息", "状态"] - assert "超过体积上限" in nodes[0]["data"]["content"] - sender.send_group_file.assert_not_awaited() + info = sender.send_group_forward_message.await_args.args[1][0]["data"]["content"] + assert "zip:" not in info @pytest.mark.asyncio @@ -434,7 +519,7 @@ async def test_send_jm_book_uses_private_forward( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: sender = _sender() - result = _result(tmp_path) + result = _archive_result(tmp_path) _patch_download(monkeypatch, result, tmp_path / "task") await send_jm_book( @@ -469,7 +554,7 @@ def test_format_jm_book_info_lists_metadata() -> None: async def test_fetch_jm_book_attachment_registers_unencrypted_pdf( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: - result = _result(tmp_path) + result = _plain_result(tmp_path) captured = _patch_download(monkeypatch, result, tmp_path / "task") record = SimpleNamespace(uid="file_jm123") registry = SimpleNamespace(register_local_file=AsyncMock(return_value=record)) @@ -514,7 +599,7 @@ async def test_fetch_jm_book_attachment_requires_scope_and_registry( async def test_fetch_jm_book_attachment_reports_oversize( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: - result = _result(tmp_path, status="oversize") + result = _plain_result(tmp_path, status="oversize") captured = _patch_download(monkeypatch, result, tmp_path / "task") registry = SimpleNamespace(register_local_file=AsyncMock()) @@ -525,6 +610,6 @@ async def test_fetch_jm_book_attachment_reports_oversize( config=SimpleNamespace(), ) - assert "PDF 超过体积上限" in text + assert "PDF 超过单章体积上限" in text registry.register_local_file.assert_not_awaited() captured["cleanup"].assert_awaited_once() diff --git a/uv.lock b/uv.lock index 01ae792e..3d8512b2 100644 --- a/uv.lock +++ b/uv.lock @@ -4729,7 +4729,7 @@ wheels = [ [[package]] name = "undefined-bot" -version = "3.18.2" +version = "3.18.3" source = { editable = "." } dependencies = [ { name = "aiofiles" },