fork
This commit is contained in:
7
.claude/settings.local.json
Normal file
7
.claude/settings.local.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(del \"d:\\\\Project\\\\astrbot_plugin_bangumi\\\\src\\\\utils\\\\browser.py\")"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
13
.gitignore
vendored
Normal file
13
.gitignore
vendored
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
__pycache__/
|
||||||
|
.DS_Store
|
||||||
|
.idea/
|
||||||
|
.pytest_cache/
|
||||||
|
.ruff_cache
|
||||||
|
.vscode/
|
||||||
|
settings.json
|
||||||
|
data/
|
||||||
|
.idea/
|
||||||
|
*.xml
|
||||||
|
*.iml
|
||||||
|
.env
|
||||||
|
.png
|
||||||
37
CHANGELOG.md
Normal file
37
CHANGELOG.md
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
## v1.1.1
|
||||||
|
|
||||||
|
### 功能拓展
|
||||||
|
- **搜索优化**: `/追番` 现在支持候选确认:当搜索到多个结果时,会先返回列表,可通过 `/追番 序号` 选择目标,减少订错番剧的情况。
|
||||||
|
- **取消订阅优化**: `/弃坑` 的匹配更贴近群聊使用场景:优先在本群已订阅列表中匹配,取消订阅更准确。
|
||||||
|
|
||||||
|
## v1.1.0
|
||||||
|
|
||||||
|
### 新增功能
|
||||||
|
- **取消订阅**: 新增 `/弃坑` 命令,支持群组移除已订阅的番剧更新提醒。
|
||||||
|
- **更新卡片渲染**: 引入 `EpisodeRenderer`,在番剧更新时自动推送精美的单集图文通知卡片。
|
||||||
|
- **命令别名**: 简化常用命令,支持使用 `/bgm` 代替 `/bgm搜索`。
|
||||||
|
|
||||||
|
### 核心优化
|
||||||
|
- **更新检测逻辑**: 重构剧集更新判定算法,结合播出日期与评论互动数据(`comment > 0`),显著降低更新误报率。
|
||||||
|
- **全链路 Base64 渲染**: 渲染引擎(放送表、剧集卡片)全面转向 Base64 内存流,移除临时文件 IO,提升并发性能。
|
||||||
|
- **Playwright 鲁棒性**: 优化浏览器安装与初始化逻辑,支持非交互式环境安装,并提供实时状态日志。
|
||||||
|
- **类型系统增强**: 引入完整的 `SubjectType`、`ImageSize` 等枚举类型,提升代码可维护性。
|
||||||
|
- **代码重构**: 优化 `SubjectsService` 的数据解析流,通过 Pydantic 严格过滤异常 API 返回。
|
||||||
|
|
||||||
|
## v1.0.0
|
||||||
|
|
||||||
|
### 新增功能
|
||||||
|
- **分类搜索**: 新增 `/bgm番剧`、`/bgm剧场版`、`/bgm漫画` 命令,支持更精准的类型过滤。
|
||||||
|
- **每日放送**: 新增 `/today` 命令,渲染精美的每日番剧放送表图片。
|
||||||
|
- **追番系统**: 新增 `/追番` 功能,支持订阅番剧并在有新集数更新时自动向群组推送通知。
|
||||||
|
- **通用搜索优化**: `/bgm搜索` 命令现在支持更完善的参数处理和 top_k 结果返回。
|
||||||
|
|
||||||
|
### 代码优化
|
||||||
|
- **渲染引擎重构**: 引入 `SubjectRenderer` 和 `CalendarRenderer`,基于 Playwright 实现更美观的图文卡片。
|
||||||
|
- **数据库集成**: 引入 SQLAlchemy 驱动的 SQLite 存储,用于管理番剧信息和订阅关系。
|
||||||
|
- **自动更新逻辑**: 新增定时任务,每小时自动检查订阅番剧的更新状态。
|
||||||
|
- **重构逻辑**: 将搜索与渲染逻辑分离,提取出 `_render_subjects` 和 `_handle_subject` 核心方法,提高代码复用性。
|
||||||
|
- **修复 Bug**: 修复了搜索命令中生成器未正确迭代导致无响应的问题。
|
||||||
|
- **类型提示**: 为核心方法添加了完善的类型注解和文档说明。
|
||||||
202
LICENSE-2.0
Normal file
202
LICENSE-2.0
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
|
||||||
|
Apache License
|
||||||
|
Version 2.0, January 2004
|
||||||
|
http://www.apache.org/licenses/
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
|
|
||||||
|
1. Definitions.
|
||||||
|
|
||||||
|
"License" shall mean the terms and conditions for use, reproduction,
|
||||||
|
and distribution as defined by Sections 1 through 9 of this document.
|
||||||
|
|
||||||
|
"Licensor" shall mean the copyright owner or entity authorized by
|
||||||
|
the copyright owner that is granting the License.
|
||||||
|
|
||||||
|
"Legal Entity" shall mean the union of the acting entity and all
|
||||||
|
other entities that control, are controlled by, or are under common
|
||||||
|
control with that entity. For the purposes of this definition,
|
||||||
|
"control" means (i) the power, direct or indirect, to cause the
|
||||||
|
direction or management of such entity, whether by contract or
|
||||||
|
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||||
|
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||||
|
|
||||||
|
"You" (or "Your") shall mean an individual or Legal Entity
|
||||||
|
exercising permissions granted by this License.
|
||||||
|
|
||||||
|
"Source" form shall mean the preferred form for making modifications,
|
||||||
|
including but not limited to software source code, documentation
|
||||||
|
source, and configuration files.
|
||||||
|
|
||||||
|
"Object" form shall mean any form resulting from mechanical
|
||||||
|
transformation or translation of a Source form, including but
|
||||||
|
not limited to compiled object code, generated documentation,
|
||||||
|
and conversions to other media types.
|
||||||
|
|
||||||
|
"Work" shall mean the work of authorship, whether in Source or
|
||||||
|
Object form, made available under the License, as indicated by a
|
||||||
|
copyright notice that is included in or attached to the work
|
||||||
|
(an example is provided in the Appendix below).
|
||||||
|
|
||||||
|
"Derivative Works" shall mean any work, whether in Source or Object
|
||||||
|
form, that is based on (or derived from) the Work and for which the
|
||||||
|
editorial revisions, annotations, elaborations, or other modifications
|
||||||
|
represent, as a whole, an original work of authorship. For the purposes
|
||||||
|
of this License, Derivative Works shall not include works that remain
|
||||||
|
separable from, or merely link (or bind by name) to the interfaces of,
|
||||||
|
the Work and Derivative Works thereof.
|
||||||
|
|
||||||
|
"Contribution" shall mean any work of authorship, including
|
||||||
|
the original version of the Work and any modifications or additions
|
||||||
|
to that Work or Derivative Works thereof, that is intentionally
|
||||||
|
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||||
|
or by an individual or Legal Entity authorized to submit on behalf of
|
||||||
|
the copyright owner. For the purposes of this definition, "submitted"
|
||||||
|
means any form of electronic, verbal, or written communication sent
|
||||||
|
to the Licensor or its representatives, including but not limited to
|
||||||
|
communication on electronic mailing lists, source code control systems,
|
||||||
|
and issue tracking systems that are managed by, or on behalf of, the
|
||||||
|
Licensor for the purpose of discussing and improving the Work, but
|
||||||
|
excluding communication that is conspicuously marked or otherwise
|
||||||
|
designated in writing by the copyright owner as "Not a Contribution."
|
||||||
|
|
||||||
|
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||||
|
on behalf of whom a Contribution has been received by Licensor and
|
||||||
|
subsequently incorporated within the Work.
|
||||||
|
|
||||||
|
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
copyright license to reproduce, prepare Derivative Works of,
|
||||||
|
publicly display, publicly perform, sublicense, and distribute the
|
||||||
|
Work and such Derivative Works in Source or Object form.
|
||||||
|
|
||||||
|
3. Grant of Patent License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
(except as stated in this section) patent license to make, have made,
|
||||||
|
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||||
|
where such license applies only to those patent claims licensable
|
||||||
|
by such Contributor that are necessarily infringed by their
|
||||||
|
Contribution(s) alone or by combination of their Contribution(s)
|
||||||
|
with the Work to which such Contribution(s) was submitted. If You
|
||||||
|
institute patent litigation against any entity (including a
|
||||||
|
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||||
|
or a Contribution incorporated within the Work constitutes direct
|
||||||
|
or contributory patent infringement, then any patent licenses
|
||||||
|
granted to You under this License for that Work shall terminate
|
||||||
|
as of the date such litigation is filed.
|
||||||
|
|
||||||
|
4. Redistribution. You may reproduce and distribute copies of the
|
||||||
|
Work or Derivative Works thereof in any medium, with or without
|
||||||
|
modifications, and in Source or Object form, provided that You
|
||||||
|
meet the following conditions:
|
||||||
|
|
||||||
|
(a) You must give any other recipients of the Work or
|
||||||
|
Derivative Works a copy of this License; and
|
||||||
|
|
||||||
|
(b) You must cause any modified files to carry prominent notices
|
||||||
|
stating that You changed the files; and
|
||||||
|
|
||||||
|
(c) You must retain, in the Source form of any Derivative Works
|
||||||
|
that You distribute, all copyright, patent, trademark, and
|
||||||
|
attribution notices from the Source form of the Work,
|
||||||
|
excluding those notices that do not pertain to any part of
|
||||||
|
the Derivative Works; and
|
||||||
|
|
||||||
|
(d) If the Work includes a "NOTICE" text file as part of its
|
||||||
|
distribution, then any Derivative Works that You distribute must
|
||||||
|
include a readable copy of the attribution notices contained
|
||||||
|
within such NOTICE file, excluding those notices that do not
|
||||||
|
pertain to any part of the Derivative Works, in at least one
|
||||||
|
of the following places: within a NOTICE text file distributed
|
||||||
|
as part of the Derivative Works; within the Source form or
|
||||||
|
documentation, if provided along with the Derivative Works; or,
|
||||||
|
within a display generated by the Derivative Works, if and
|
||||||
|
wherever such third-party notices normally appear. The contents
|
||||||
|
of the NOTICE file are for informational purposes only and
|
||||||
|
do not modify the License. You may add Your own attribution
|
||||||
|
notices within Derivative Works that You distribute, alongside
|
||||||
|
or as an addendum to the NOTICE text from the Work, provided
|
||||||
|
that such additional attribution notices cannot be construed
|
||||||
|
as modifying the License.
|
||||||
|
|
||||||
|
You may add Your own copyright statement to Your modifications and
|
||||||
|
may provide additional or different license terms and conditions
|
||||||
|
for use, reproduction, or distribution of Your modifications, or
|
||||||
|
for any such Derivative Works as a whole, provided Your use,
|
||||||
|
reproduction, and distribution of the Work otherwise complies with
|
||||||
|
the conditions stated in this License.
|
||||||
|
|
||||||
|
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||||
|
any Contribution intentionally submitted for inclusion in the Work
|
||||||
|
by You to the Licensor shall be under the terms and conditions of
|
||||||
|
this License, without any additional terms or conditions.
|
||||||
|
Notwithstanding the above, nothing herein shall supersede or modify
|
||||||
|
the terms of any separate license agreement you may have executed
|
||||||
|
with Licensor regarding such Contributions.
|
||||||
|
|
||||||
|
6. Trademarks. This License does not grant permission to use the trade
|
||||||
|
names, trademarks, service marks, or product names of the Licensor,
|
||||||
|
except as required for reasonable and customary use in describing the
|
||||||
|
origin of the Work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||||
|
agreed to in writing, Licensor provides the Work (and each
|
||||||
|
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||||
|
implied, including, without limitation, any warranties or conditions
|
||||||
|
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||||
|
appropriateness of using or redistributing the Work and assume any
|
||||||
|
risks associated with Your exercise of permissions under this License.
|
||||||
|
|
||||||
|
8. Limitation of Liability. In no event and under no legal theory,
|
||||||
|
whether in tort (including negligence), contract, or otherwise,
|
||||||
|
unless required by applicable law (such as deliberate and grossly
|
||||||
|
negligent acts) or agreed to in writing, shall any Contributor be
|
||||||
|
liable to You for damages, including any direct, indirect, special,
|
||||||
|
incidental, or consequential damages of any character arising as a
|
||||||
|
result of this License or out of the use or inability to use the
|
||||||
|
Work (including but not limited to damages for loss of goodwill,
|
||||||
|
work stoppage, computer failure or malfunction, or any and all
|
||||||
|
other commercial damages or losses), even if such Contributor
|
||||||
|
has been advised of the possibility of such damages.
|
||||||
|
|
||||||
|
9. Accepting Warranty or Additional Liability. While redistributing
|
||||||
|
the Work or Derivative Works thereof, You may choose to offer,
|
||||||
|
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||||
|
or other liability obligations and/or rights consistent with this
|
||||||
|
License. However, in accepting such obligations, You may act only
|
||||||
|
on Your own behalf and on Your sole responsibility, not on behalf
|
||||||
|
of any other Contributor, and only if You agree to indemnify,
|
||||||
|
defend, and hold each Contributor harmless for any liability
|
||||||
|
incurred by, or claims asserted against, such Contributor by reason
|
||||||
|
of your accepting any such warranty or additional liability.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
APPENDIX: How to apply the Apache License to your work.
|
||||||
|
|
||||||
|
To apply the Apache License to your work, attach the following
|
||||||
|
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||||
|
replaced with your own identifying information. (Don't include
|
||||||
|
the brackets!) The text should be enclosed in the appropriate
|
||||||
|
comment syntax for the file format. We also recommend that a
|
||||||
|
file or class name and description of purpose be included on the
|
||||||
|
same "printed page" as the copyright notice for easier
|
||||||
|
identification within third-party archives.
|
||||||
|
|
||||||
|
Copyright [yyyy] [name of copyright owner]
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
98
README.md
Normal file
98
README.md
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
<div align="center">
|
||||||
|
|
||||||
|
# Bangumi 搜索插件使用指南
|
||||||
|
[](https://github.com/united-pooh/astrbot_plugin_bangumi)
|
||||||
|
[](LICENSE-2.0)
|
||||||
|
[](https://github.com/Soulter/AstrBot)
|
||||||
|
[](https://www.python.org/)
|
||||||
|
|
||||||
|
**和群友一起追番**
|
||||||
|
</div>
|
||||||
|
|
||||||
|
> **astrbot-plugin-bangumi** 是一个基于 AstrBot 框架的 Bangumi (番组计划) 信息查询与追番插件。它通过对接 Bangumi API,为机器人用户提供精美的图文条目详情、实时放送时刻表,并具备自动化的订阅更新监控系统。无论是想快速查询评分,还是在群内实时接收番剧更新通知,它都能为您提供优雅的交互体验。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> 本项目在 [astrbot_plugin_bangumi](https://github.com/Amatsutsumi/astrbot_plugin_bangumi) 的基础上进行二次开发
|
||||||
|
|
||||||
|
## 📌 核心命令
|
||||||
|
|
||||||
|
### 1. 基础搜索(图文卡片)
|
||||||
|
|
||||||
|
| 命令 | 功能 | 参数 | 示例 |
|
||||||
|
|:-----|:-----|:-----|:-----|
|
||||||
|
| `/bgm` | 全类别搜索 | `<关键词\|ID> [top_k]` | `/bgm 进击的巨人 3` |
|
||||||
|
| `/bgm番剧` | 仅搜索 TV 动画 | `<关键词\|ID> [top_k]` | `/bgm番剧 命运石之门` |
|
||||||
|
| `/bgm剧场版` | 仅搜索剧场版动画 | `<关键词\|ID> [top_k]` | `/bgm剧场版 凉宫春日的消失` |
|
||||||
|
| `/bgm漫画` | 仅搜索漫画条目 | `<关键词\|ID> [top_k]` | `/bgm漫画 迷宫饭` |
|
||||||
|
|
||||||
|
> `top_k`(可选):返回结果数量,默认为 `1`。
|
||||||
|
|
||||||
|
### 2. 放送与订阅
|
||||||
|
|
||||||
|
| 命令 | 功能 | 参数 | 示例 |
|
||||||
|
|:-----|:-----|:-----|:-----|
|
||||||
|
| `/today` | 获取今日番剧放送表 | 无 | `/today` |
|
||||||
|
| `/追番` | 订阅番剧,更新时自动通知 | `<关键词\|ID>` | `/追番 进击的巨人` |
|
||||||
|
| `/弃坑` | 取消订阅番剧 | `<关键词\|ID>` | `/弃坑 进击的巨人` |
|
||||||
|
|
||||||
|
**功能亮点**:
|
||||||
|
- **精美卡片**:自动生成包含封面、评分、排名、简介及剧集进度的图文卡片。
|
||||||
|
- **每日放送**:渲染精美的每日放送时刻表。
|
||||||
|
- **自动追番**:订阅后自动监控集数更新并实时推送通知。
|
||||||
|
|
||||||
|
## 🛠️ 配置参数
|
||||||
|
|
||||||
|
在 AstrBot 的管理面板或配置文件中设置:
|
||||||
|
|
||||||
|
| 参数名 | 类型 | 默认值 | 说明 |
|
||||||
|
|:-------|:----:|:------:|:-----|
|
||||||
|
| `access_token` | string | 无 | Bangumi API 访问令牌(部分接口需授权)[¹](#access-token-获取) |
|
||||||
|
| `user_agent` | string | 无 | 请求头 User-Agent 标识,为空时使用插件默认值 |
|
||||||
|
| `max_fuzzy_results` | int | `5` | 模糊搜索最大返回数量(范围:1–200) |
|
||||||
|
| `proxy_http` | string | 无 | HTTP 代理地址(仅 IP,例如 `192.168.0.1`) |
|
||||||
|
| `port` | string | 无 | HTTP 代理端口(例如 `7890`) |
|
||||||
|
| `max_retries` | int | `3` | 网络错误最大重试次数(范围:1–10) |
|
||||||
|
| `render_server_url` | string | `https://api.unitedpooh.top/rpc` | 远程渲染图片的 RPC 服务器地址 |
|
||||||
|
|
||||||
|
### Access Token 获取
|
||||||
|
|
||||||
|
虽然不强制,但建议配置 Access Token 以避免 API 限流。
|
||||||
|
|
||||||
|
1. 注册/登录 [Bangumi](https://bgm.tv/)
|
||||||
|
2. 访问 [个人令牌页面](https://next.bgm.tv/demo/access-token/create) 创建新令牌
|
||||||
|
3. 将生成的 Token 填入插件配置的 `access_token` 字段
|
||||||
|
|
||||||
|
## 📦 环境依赖
|
||||||
|
|
||||||
|
插件首次运行时会自动检查并安装以下依赖:
|
||||||
|
- **Playwright 浏览器内核**:用于渲染卡片图片。
|
||||||
|
|
||||||
|
如果遇到环境问题,可尝试手动安装:
|
||||||
|
```bash
|
||||||
|
pip install -r requirements.txt
|
||||||
|
playwright install chromium
|
||||||
|
```
|
||||||
|
|
||||||
|
## ✅ 强类型与本地检查
|
||||||
|
|
||||||
|
本项目已切换为 Python 3.12 风格类型写法,并在 CI 中启用阻断式质量门禁(`ruff + mypy + pytest`)。
|
||||||
|
|
||||||
|
### 本地执行命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ruff check .
|
||||||
|
ruff format --check .
|
||||||
|
mypy src main.py
|
||||||
|
PYTHONPATH=. pytest tests/test_search_service.py tests/test_subscription_service.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### 强类型编码规则
|
||||||
|
|
||||||
|
1. 禁止 `Optional[T]`,统一使用 `T | None`。
|
||||||
|
2. 禁止 `typing.List/Dict/Tuple/Set`,统一使用 `list/dict/tuple/set`。
|
||||||
|
3. 禁止新增 `Any`;优先使用 `TypedDict`、Pydantic 模型或明确类型别名。
|
||||||
|
4. 公共方法必须显式标注参数和返回类型。
|
||||||
|
5. 业务接口层禁止使用 `dict[str, Any]` 作为输入/输出类型。
|
||||||
|
6. 需要可空时必须在类型中明确体现,禁止隐式可空。
|
||||||
48
_conf_schema.json
Normal file
48
_conf_schema.json
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
{
|
||||||
|
"access_token": {
|
||||||
|
"description": "Bangumi API访问令牌(部分接口需授权)",
|
||||||
|
"type": "string",
|
||||||
|
"hint": "在https://next.bgm.tv/demo/access-token生成,格式为Bearer令牌",
|
||||||
|
"default": ""
|
||||||
|
},
|
||||||
|
"user_agent": {
|
||||||
|
"description": "请求头User-Agent标识",
|
||||||
|
"type": "string",
|
||||||
|
"hint": "如果为空,则使用插件默认值",
|
||||||
|
"default": ""
|
||||||
|
},
|
||||||
|
"max_fuzzy_results": {
|
||||||
|
"description": "模糊搜索返回的最大结果数量",
|
||||||
|
"type": "int",
|
||||||
|
"hint": "取值范围1-200,数值越大返回结果越多",
|
||||||
|
"default": 5,
|
||||||
|
"min": 1,
|
||||||
|
"max": 200
|
||||||
|
},
|
||||||
|
"proxy_http": {
|
||||||
|
"description": "代理地址",
|
||||||
|
"type": "string",
|
||||||
|
"hint": "IP, 例: 192.168.0.x",
|
||||||
|
"default": ""
|
||||||
|
},
|
||||||
|
"port": {
|
||||||
|
"description": "端口",
|
||||||
|
"type": "string",
|
||||||
|
"hint": "代理端口, 例: 7890",
|
||||||
|
"default": ""
|
||||||
|
},
|
||||||
|
"max_retries": {
|
||||||
|
"description": "最大重试次数",
|
||||||
|
"type": "int",
|
||||||
|
"hint": "网络错误时最大的重试次数",
|
||||||
|
"default": 3,
|
||||||
|
"min": 1,
|
||||||
|
"max": 10
|
||||||
|
},
|
||||||
|
"render_server_url": {
|
||||||
|
"description": "RPC 渲染服务器地址",
|
||||||
|
"type": "string",
|
||||||
|
"hint": "用于远程渲染图片的 RPC 服务器地址",
|
||||||
|
"default": "https://api.unitedpooh.top/rpc"
|
||||||
|
}
|
||||||
|
}
|
||||||
339
main.py
Normal file
339
main.py
Normal file
@@ -0,0 +1,339 @@
|
|||||||
|
import copy
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from collections.abc import AsyncGenerator
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
import astrbot.api.message_components as Comp
|
||||||
|
from astrbot.api import logger
|
||||||
|
from astrbot.api.all import AstrBotConfig
|
||||||
|
from astrbot.api.event import AstrMessageEvent, MessageChain, filter
|
||||||
|
|
||||||
|
# 导入配置与管理
|
||||||
|
from astrbot.api.star import Context, Star, StarTools, register
|
||||||
|
from astrbot.core.utils.session_waiter import (
|
||||||
|
SessionController,
|
||||||
|
SessionFilter,
|
||||||
|
session_waiter,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .src.config import ConfigManager
|
||||||
|
from .src.db import BangumiRepository
|
||||||
|
|
||||||
|
# 导入逻辑服务
|
||||||
|
from .src.services import BangumiService, SearchService, SubscriptionService
|
||||||
|
from .src.utils import SchedulerManager
|
||||||
|
|
||||||
|
|
||||||
|
@register(
|
||||||
|
"astrbot_plugin_bangumi_enhance",
|
||||||
|
"united_pooh",
|
||||||
|
"AstrBot Bangumi 增强版:为 AstrBot 打造的一站式 Bangumi 追番助手。支持番剧/漫画图文搜索、每日放送时刻表查看及集数更新自动提醒。",
|
||||||
|
"v1.1.1",
|
||||||
|
"https://github.com/united-pooh/astrbot_plugin_bangumi",
|
||||||
|
)
|
||||||
|
class BangumiPlugin(Star):
|
||||||
|
def __init__(self, context: Context, config: AstrBotConfig) -> None:
|
||||||
|
"""
|
||||||
|
初始化 BangumiPlugin 插件。
|
||||||
|
"""
|
||||||
|
super().__init__(context)
|
||||||
|
self.config = config
|
||||||
|
self.config_manager = ConfigManager(config)
|
||||||
|
self.scheduler_manager = SchedulerManager()
|
||||||
|
|
||||||
|
self.session: aiohttp.ClientSession | None = None
|
||||||
|
self.storage: BangumiRepository | None = None
|
||||||
|
self.service: BangumiService | None = None
|
||||||
|
self.subscription_service: SubscriptionService | None = None
|
||||||
|
self.search_service: SearchService | None = None
|
||||||
|
|
||||||
|
async def initialize(self) -> None:
|
||||||
|
"""
|
||||||
|
插件加载时自动运行的初始化方法。
|
||||||
|
"""
|
||||||
|
# 0. 提前获取插件数据目录(必须先于所有依赖 StarTools 的操作)
|
||||||
|
plugin_data_dir = StarTools.get_data_dir()
|
||||||
|
|
||||||
|
# 1. 初始化数据库
|
||||||
|
try:
|
||||||
|
db_path = os.path.join(plugin_data_dir, "data.db")
|
||||||
|
self.storage = BangumiRepository(db_path=db_path)
|
||||||
|
except (OSError, RuntimeError, ValueError, TypeError) as e:
|
||||||
|
logger.error(f"数据库初始化失败: {e}")
|
||||||
|
|
||||||
|
# 2. 初始化网络会话 (Shared Session)
|
||||||
|
self.session = aiohttp.ClientSession()
|
||||||
|
|
||||||
|
# 3. 初始化核心 API 服务
|
||||||
|
try:
|
||||||
|
proxy_url = None
|
||||||
|
proxy_host = self.config_manager.get_proxy_http()
|
||||||
|
proxy_port = self.config_manager.get_port()
|
||||||
|
if proxy_host and proxy_port:
|
||||||
|
proxy_url = f"{proxy_host}:{proxy_port}"
|
||||||
|
|
||||||
|
self.service = BangumiService(
|
||||||
|
access_token=self.config_manager.get_access_token(),
|
||||||
|
user_agent=self.config_manager.get_user_agent(),
|
||||||
|
proxy=proxy_url,
|
||||||
|
session=self.session,
|
||||||
|
)
|
||||||
|
except (RuntimeError, ValueError, TypeError) as e:
|
||||||
|
logger.error(f"服务初始化失败: {e}")
|
||||||
|
|
||||||
|
# 4. 初始化业务逻辑服务 (Dependency Injection)
|
||||||
|
if self.service:
|
||||||
|
# 搜索服务
|
||||||
|
self.search_service = SearchService(
|
||||||
|
service=self.service,
|
||||||
|
config_manager=self.config_manager,
|
||||||
|
session=self.session,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 订阅服务
|
||||||
|
if self.storage:
|
||||||
|
self.subscription_service = SubscriptionService(
|
||||||
|
repository=self.storage,
|
||||||
|
service=self.service,
|
||||||
|
config_manager=self.config_manager,
|
||||||
|
session=self.session,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 5. 添加定时更新任务
|
||||||
|
if self.subscription_service:
|
||||||
|
try:
|
||||||
|
self.scheduler_manager.add_job(
|
||||||
|
func=self.subscription_service.check_updates,
|
||||||
|
trigger="cron",
|
||||||
|
minute=0,
|
||||||
|
)
|
||||||
|
logger.info("Bangumi 插件定时更新任务已启动")
|
||||||
|
except (RuntimeError, ValueError, TypeError) as e:
|
||||||
|
logger.error(f"添加定时任务失败: {e}")
|
||||||
|
|
||||||
|
logger.info("Bangumi 插件初始化流程结束")
|
||||||
|
|
||||||
|
# --- 命令处理区 ---
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _resolve_session_key(event: AstrMessageEvent) -> str | None:
|
||||||
|
session_key: str | None = getattr(event, "session_id", None)
|
||||||
|
if hasattr(event, "message_obj") and hasattr(event.message_obj, "group_id"):
|
||||||
|
session_key = event.message_obj.group_id
|
||||||
|
return session_key
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_subscribe_selection(raw_text: str) -> int | None:
|
||||||
|
match = re.match(r"^/?追番\s+(\d+)\s*$", raw_text.strip())
|
||||||
|
if not match:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return int(match.group(1))
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
@filter.command("bgm")
|
||||||
|
async def search(
|
||||||
|
self, event: AstrMessageEvent, query: str, top_k: int = 1
|
||||||
|
) -> AsyncGenerator[object, None]:
|
||||||
|
"""全类别搜索 Bangumi 条目。"""
|
||||||
|
if not self.search_service:
|
||||||
|
yield event.plain_result("❌ 搜索服务未就绪")
|
||||||
|
return
|
||||||
|
async for result in self.search_service.handle_subject_search(
|
||||||
|
event, query, top_k, subject_type=None
|
||||||
|
):
|
||||||
|
yield result
|
||||||
|
|
||||||
|
@filter.command("bgm番剧")
|
||||||
|
async def search_anime(
|
||||||
|
self, event: AstrMessageEvent, query: str, top_k: int = 1
|
||||||
|
) -> AsyncGenerator[object, None]:
|
||||||
|
"""仅搜索 TV 动画条目。"""
|
||||||
|
if not self.search_service:
|
||||||
|
yield event.plain_result("❌ 搜索服务未就绪")
|
||||||
|
return
|
||||||
|
async for result in self.search_service.handle_subject_search(
|
||||||
|
event, query, top_k, subject_type=[2], subject_tags=["TV"]
|
||||||
|
):
|
||||||
|
yield result
|
||||||
|
|
||||||
|
@filter.command("bgm剧场版")
|
||||||
|
async def search_movie(
|
||||||
|
self, event: AstrMessageEvent, query: str, top_k: int = 1
|
||||||
|
) -> AsyncGenerator[object, None]:
|
||||||
|
"""仅搜索剧场版动画条目。"""
|
||||||
|
if not self.search_service:
|
||||||
|
yield event.plain_result("❌ 搜索服务未就绪")
|
||||||
|
return
|
||||||
|
async for result in self.search_service.handle_subject_search(
|
||||||
|
event, query, top_k, subject_type=[2], subject_tags=["剧场版"]
|
||||||
|
):
|
||||||
|
yield result
|
||||||
|
|
||||||
|
@filter.command("bgm漫画")
|
||||||
|
async def search_manga(
|
||||||
|
self, event: AstrMessageEvent, query: str, top_k: int = 1
|
||||||
|
) -> AsyncGenerator[object, None]:
|
||||||
|
"""仅搜索漫画条目。"""
|
||||||
|
if not self.search_service:
|
||||||
|
yield event.plain_result("❌ 搜索服务未就绪")
|
||||||
|
return
|
||||||
|
async for result in self.search_service.handle_subject_search(
|
||||||
|
event, query, top_k, subject_type=[1], subject_tags=["漫画"]
|
||||||
|
):
|
||||||
|
yield result
|
||||||
|
|
||||||
|
@filter.command("today")
|
||||||
|
async def calendar(self, event: AstrMessageEvent) -> AsyncGenerator[object, None]:
|
||||||
|
"""获取今日番剧放送表。"""
|
||||||
|
if not self.search_service:
|
||||||
|
yield event.plain_result("❌ 搜索服务未就绪")
|
||||||
|
return
|
||||||
|
async for result in self.search_service.handle_calendar(event):
|
||||||
|
yield result
|
||||||
|
|
||||||
|
@filter.command("追番")
|
||||||
|
async def subscribe(
|
||||||
|
self, event: AstrMessageEvent, query: str
|
||||||
|
) -> AsyncGenerator[object, None]:
|
||||||
|
"""订阅番剧,更新时自动通知。"""
|
||||||
|
if not self.subscription_service:
|
||||||
|
yield event.plain_result("❌ 订阅服务未就绪")
|
||||||
|
return
|
||||||
|
|
||||||
|
group_id = self._resolve_session_key(event)
|
||||||
|
if not group_id:
|
||||||
|
yield event.plain_result("❌ 无法获取群组ID")
|
||||||
|
return
|
||||||
|
|
||||||
|
(
|
||||||
|
error_msg,
|
||||||
|
candidates,
|
||||||
|
) = await self.subscription_service.get_subscribe_candidates(
|
||||||
|
keyword=query,
|
||||||
|
limit=self.config_manager.get_max_fuzzy_results(),
|
||||||
|
)
|
||||||
|
if error_msg:
|
||||||
|
yield event.plain_result(error_msg)
|
||||||
|
return
|
||||||
|
if not candidates:
|
||||||
|
yield event.plain_result("🔍 未找到相关番剧")
|
||||||
|
return
|
||||||
|
|
||||||
|
if len(candidates) == 1:
|
||||||
|
result = await self.subscription_service.subscribe_by_subject_id(
|
||||||
|
group_id=group_id,
|
||||||
|
subject_id=candidates[0]["subject_id"],
|
||||||
|
)
|
||||||
|
yield event.plain_result(result)
|
||||||
|
return
|
||||||
|
|
||||||
|
candidate_lines = ["⚠️ 匹配到多个候选,请使用 `/追番 序号` 确认:"]
|
||||||
|
for index, candidate in enumerate(candidates, start=1):
|
||||||
|
candidate_lines.append(
|
||||||
|
f"{index}. {candidate['name']} (ID: {candidate['subject_id']})"
|
||||||
|
)
|
||||||
|
candidate_lines.append("5分钟内有效;若发送其他命令将自动取消本次确认。")
|
||||||
|
yield event.plain_result("\n".join(candidate_lines))
|
||||||
|
|
||||||
|
cancel_commands = {
|
||||||
|
"bgm",
|
||||||
|
"bgm番剧",
|
||||||
|
"bgm剧场版",
|
||||||
|
"bgm漫画",
|
||||||
|
"today",
|
||||||
|
"弃坑",
|
||||||
|
}
|
||||||
|
session_key = group_id
|
||||||
|
|
||||||
|
class GroupSessionFilter(SessionFilter):
|
||||||
|
def filter(self, wait_event: AstrMessageEvent) -> str:
|
||||||
|
wait_session_key = BangumiPlugin._resolve_session_key(wait_event)
|
||||||
|
return wait_session_key or wait_event.unified_msg_origin
|
||||||
|
|
||||||
|
@session_waiter(timeout=300)
|
||||||
|
async def subscribe_confirm_waiter(
|
||||||
|
controller: SessionController,
|
||||||
|
wait_event: AstrMessageEvent,
|
||||||
|
) -> None:
|
||||||
|
incoming_text = wait_event.get_message_str().strip()
|
||||||
|
|
||||||
|
first_token = incoming_text.split(maxsplit=1)[0] if incoming_text else ""
|
||||||
|
normalized_token = (
|
||||||
|
first_token[1:] if first_token.startswith("/") else first_token
|
||||||
|
)
|
||||||
|
if normalized_token in cancel_commands:
|
||||||
|
new_event = copy.copy(wait_event)
|
||||||
|
self.context.get_event_queue().put_nowait(new_event)
|
||||||
|
wait_event.stop_event()
|
||||||
|
controller.stop()
|
||||||
|
return
|
||||||
|
|
||||||
|
selected_index = self._parse_subscribe_selection(incoming_text)
|
||||||
|
if selected_index is None:
|
||||||
|
if normalized_token == "追番":
|
||||||
|
new_event = copy.copy(wait_event)
|
||||||
|
self.context.get_event_queue().put_nowait(new_event)
|
||||||
|
wait_event.stop_event()
|
||||||
|
controller.stop()
|
||||||
|
return
|
||||||
|
controller.keep(timeout=0)
|
||||||
|
return
|
||||||
|
if selected_index < 1 or selected_index > len(candidates):
|
||||||
|
await wait_event.send(
|
||||||
|
MessageChain(
|
||||||
|
[Comp.Plain(f"❌ 序号超出范围,请输入 1-{len(candidates)}。")]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
controller.keep(timeout=0)
|
||||||
|
return
|
||||||
|
|
||||||
|
selected = candidates[selected_index - 1]
|
||||||
|
result = await self.subscription_service.subscribe_by_subject_id(
|
||||||
|
group_id=session_key,
|
||||||
|
subject_id=selected["subject_id"],
|
||||||
|
)
|
||||||
|
await wait_event.send(MessageChain([Comp.Plain(result)]))
|
||||||
|
wait_event.stop_event()
|
||||||
|
controller.stop()
|
||||||
|
|
||||||
|
try:
|
||||||
|
await subscribe_confirm_waiter(
|
||||||
|
event,
|
||||||
|
session_filter=GroupSessionFilter(),
|
||||||
|
)
|
||||||
|
except TimeoutError:
|
||||||
|
yield event.plain_result("⏰ 候选确认已过期,请重新使用 `/追番 关键词`。")
|
||||||
|
|
||||||
|
@filter.command("弃坑")
|
||||||
|
async def unsubscribe(
|
||||||
|
self, event: AstrMessageEvent, query: str
|
||||||
|
) -> AsyncGenerator[object, None]:
|
||||||
|
"""取消订阅番剧。"""
|
||||||
|
if not self.subscription_service:
|
||||||
|
yield event.plain_result("❌ 订阅服务未就绪")
|
||||||
|
return
|
||||||
|
|
||||||
|
group_id: str | None = getattr(event, "session_id", None)
|
||||||
|
if hasattr(event, "message_obj") and hasattr(event.message_obj, "group_id"):
|
||||||
|
group_id = event.message_obj.group_id
|
||||||
|
|
||||||
|
if not group_id:
|
||||||
|
yield event.plain_result("❌ 无法获取群组ID")
|
||||||
|
return
|
||||||
|
|
||||||
|
result = await self.subscription_service.unsubscribe(group_id, query)
|
||||||
|
yield event.plain_result(result)
|
||||||
|
|
||||||
|
async def terminate(self) -> None:
|
||||||
|
logger.info("正在清理 Bangumi 插件资源...")
|
||||||
|
if self.scheduler_manager.scheduler.running:
|
||||||
|
self.scheduler_manager.scheduler.shutdown(wait=False)
|
||||||
|
|
||||||
|
if self.session and not self.session.closed:
|
||||||
|
await self.session.close()
|
||||||
|
logger.info("已关闭共享网络会话")
|
||||||
|
|
||||||
|
await super().terminate()
|
||||||
20
metadata.yaml
Normal file
20
metadata.yaml
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
name: astrbot_plugin_bangumi_enhance
|
||||||
|
desc: AstrBot Bangumi 增强版:为 AstrBot 打造的一站式 Bangumi 追番助手。支持番剧/漫画图文搜索、每日放送时刻表查看及集数更新自动提醒。
|
||||||
|
version: v1.1.1
|
||||||
|
author: united_pooh
|
||||||
|
license:
|
||||||
|
repo: https://github.com/united-pooh/astrbot_plugin_bangumi
|
||||||
|
tags:
|
||||||
|
- bangumi
|
||||||
|
- 追番
|
||||||
|
- 自动提醒
|
||||||
|
keywords:
|
||||||
|
- bangumi
|
||||||
|
- anime
|
||||||
|
- manga
|
||||||
|
- 番剧
|
||||||
|
- 漫画
|
||||||
|
- 追番
|
||||||
|
- 自动提醒
|
||||||
|
- 时刻表
|
||||||
|
- 集数更新
|
||||||
6
package-lock.json
generated
Normal file
6
package-lock.json
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"name": "astrbot_plugin_bangumi_enhance",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {}
|
||||||
|
}
|
||||||
25
pyproject.toml
Normal file
25
pyproject.toml
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
[tool.ruff]
|
||||||
|
target-version = "py311"
|
||||||
|
line-length = 88
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = ["E", "F", "I", "B", "UP", "SIM", "RUF"]
|
||||||
|
ignore = ["E501", "RUF001", "RUF002", "RUF003"]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
pythonpath = ["."]
|
||||||
|
addopts = "-q"
|
||||||
|
|
||||||
|
[tool.mypy]
|
||||||
|
python_version = "3.11"
|
||||||
|
strict = true
|
||||||
|
warn_unused_ignores = true
|
||||||
|
warn_return_any = true
|
||||||
|
no_implicit_optional = true
|
||||||
|
show_error_codes = true
|
||||||
|
pretty = true
|
||||||
|
files = ["src", "main.py"]
|
||||||
|
|
||||||
|
[[tool.mypy.overrides]]
|
||||||
|
module = ["astrbot.*", "playwright.*", "apscheduler.*", "pytz", "jinja2", "yaml"]
|
||||||
|
ignore_missing_imports = true
|
||||||
9
requirements.txt
Normal file
9
requirements.txt
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
jinja2
|
||||||
|
pillow>=9.2.0
|
||||||
|
aiohttp
|
||||||
|
apscheduler
|
||||||
|
pytz
|
||||||
|
|
||||||
|
SQLAlchemy
|
||||||
|
|
||||||
|
astrbot
|
||||||
3
src/bangumi_types/__init__.py
Normal file
3
src/bangumi_types/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from .json_types import JsonArray, JsonObject, JsonPrimitive, JsonValue
|
||||||
|
|
||||||
|
__all__ = ["JsonArray", "JsonObject", "JsonPrimitive", "JsonValue"]
|
||||||
6
src/bangumi_types/json_types.py
Normal file
6
src/bangumi_types/json_types.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from typing import TypeAlias
|
||||||
|
|
||||||
|
JsonPrimitive: TypeAlias = str | int | float | bool | None
|
||||||
|
JsonValue: TypeAlias = JsonPrimitive | list["JsonValue"] | dict[str, "JsonValue"]
|
||||||
|
JsonObject: TypeAlias = dict[str, JsonValue]
|
||||||
|
JsonArray: TypeAlias = list[JsonValue]
|
||||||
3
src/config/__init__.py
Normal file
3
src/config/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from .config_manager import ConfigManager
|
||||||
|
|
||||||
|
__all__ = ["ConfigManager"]
|
||||||
51
src/config/config_manager.py
Normal file
51
src/config/config_manager.py
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
from astrbot.api import AstrBotConfig, logger
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigManager:
|
||||||
|
def __init__(self, config: AstrBotConfig) -> None:
|
||||||
|
self.config = config
|
||||||
|
|
||||||
|
def get_access_token(self) -> str:
|
||||||
|
"""
|
||||||
|
获取bangumi的access_token
|
||||||
|
"""
|
||||||
|
return self.config.get("access_token", "")
|
||||||
|
|
||||||
|
def get_user_agent(self) -> str:
|
||||||
|
user_agent = self.config.get("user_agent", "")
|
||||||
|
if user_agent == "":
|
||||||
|
with open(
|
||||||
|
f"{Path(__file__).resolve().parent.parent.parent}/metadata.yaml",
|
||||||
|
encoding="utf-8",
|
||||||
|
) as f:
|
||||||
|
metadata = yaml.safe_load(f)
|
||||||
|
user_agent = f"AstrBot-Bangumi-Plugin/{metadata['version']} (https://github.com/united-pooh/astrbot_plugin_bangumi)"
|
||||||
|
return user_agent
|
||||||
|
|
||||||
|
def get_max_fuzzy_results(self) -> int:
|
||||||
|
return self.config.get("max_fuzzy_results", 5)
|
||||||
|
|
||||||
|
def get_proxy_http(self) -> str:
|
||||||
|
return self.config.get("proxy_http", "127.0.0.1")
|
||||||
|
|
||||||
|
def get_port(self) -> str:
|
||||||
|
return self.config.get("port", "7890")
|
||||||
|
|
||||||
|
def get_max_retries(self) -> int:
|
||||||
|
return self.config.get("max_retries", 3)
|
||||||
|
|
||||||
|
def get_render_server_url(self) -> str:
|
||||||
|
return self.config.get("render_server_url", "https://api.unitedpooh.top/rpc")
|
||||||
|
|
||||||
|
def save_config(self) -> None:
|
||||||
|
"""
|
||||||
|
保存bgm插件配置到配置文件中, 并重新加载配置
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
self.config.save_config()
|
||||||
|
logger.info("配置已保存")
|
||||||
|
except (AttributeError, OSError, RuntimeError, ValueError, TypeError) as e:
|
||||||
|
logger.error(f"保存bgm插件配置失败: {e}")
|
||||||
11
src/db/__init__.py
Normal file
11
src/db/__init__.py
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
"""
|
||||||
|
数据库层公共接口
|
||||||
|
|
||||||
|
导出 ORM 模型和数据访问层,供业务层使用。
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .models import BangumiSubject, Base, Subscription
|
||||||
|
from .repository import BangumiRepository
|
||||||
|
|
||||||
|
__all__ = ["BangumiRepository", "BangumiSubject", "Base", "Subscription"]
|
||||||
60
src/db/models.py
Normal file
60
src/db/models.py
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
"""
|
||||||
|
数据库 ORM 模型定义
|
||||||
|
|
||||||
|
此模块包含所有 SQLAlchemy ORM 模型,用于定义数据库表结构和关系。
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from sqlalchemy import Column, DateTime, ForeignKey, Integer, String, func
|
||||||
|
from sqlalchemy.orm import declarative_base, relationship
|
||||||
|
|
||||||
|
Base = declarative_base()
|
||||||
|
|
||||||
|
|
||||||
|
class BangumiSubject(Base):
|
||||||
|
"""
|
||||||
|
番剧条目模型
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "bangumi_subjects"
|
||||||
|
|
||||||
|
subject_id = Column(String, primary_key=True)
|
||||||
|
name = Column(String)
|
||||||
|
air_date = Column(String) # 开播日期/时间
|
||||||
|
total_episodes = Column(Integer, default=0)
|
||||||
|
current_episode = Column(Integer, default=0) # 当前已更新/已通知集数
|
||||||
|
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
# 建立与 Subscription 的一对多关系
|
||||||
|
subscriptions = relationship(
|
||||||
|
"Subscription", back_populates="subject", cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<BangumiSubject(id={self.subject_id}, name={self.name})>"
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"{self.name} ({self.subject_id}) [{self.current_episode}/{self.total_episodes}]"
|
||||||
|
|
||||||
|
|
||||||
|
class Subscription(Base):
|
||||||
|
"""
|
||||||
|
订阅关系模型
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "subscriptions"
|
||||||
|
|
||||||
|
group_id = Column(String, primary_key=True)
|
||||||
|
subject_id = Column(
|
||||||
|
String, ForeignKey("bangumi_subjects.subject_id"), primary_key=True
|
||||||
|
)
|
||||||
|
created_at = Column(DateTime, default=func.now())
|
||||||
|
|
||||||
|
# 建立与 BangumiSubject 的多对一关系
|
||||||
|
subject = relationship("BangumiSubject", back_populates="subscriptions")
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<Subscription(id={self.subject_id}, group_id={self.group_id}, created_at={self.created_at})>"
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"- 群 {self.group_id} 订阅了 {self.subject.name} ({self.subject.subject_id})"
|
||||||
392
src/db/repository.py
Normal file
392
src/db/repository.py
Normal file
@@ -0,0 +1,392 @@
|
|||||||
|
"""
|
||||||
|
数据访问层(Repository 模式)
|
||||||
|
|
||||||
|
此模块封装所有数据库操作,为业务层提供数据访问接口。
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from difflib import SequenceMatcher
|
||||||
|
|
||||||
|
from astrbot.api import logger
|
||||||
|
from sqlalchemy import create_engine, or_
|
||||||
|
from sqlalchemy.orm import joinedload, scoped_session, sessionmaker
|
||||||
|
|
||||||
|
from ..services import DatabaseError
|
||||||
|
from .models import BangumiSubject, Base, Subscription
|
||||||
|
|
||||||
|
|
||||||
|
class BangumiRepository:
|
||||||
|
"""
|
||||||
|
番剧数据访问层
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, db_path: str) -> None:
|
||||||
|
"""
|
||||||
|
初始化数据访问层
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db_path: 数据库文件路径
|
||||||
|
"""
|
||||||
|
os.makedirs(os.path.dirname(db_path), exist_ok=True)
|
||||||
|
self.db_path = db_path
|
||||||
|
self._init_db()
|
||||||
|
|
||||||
|
def _init_db(self) -> None:
|
||||||
|
"""
|
||||||
|
初始化数据库连接和表结构
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 使用 sqlite
|
||||||
|
engine = create_engine(f"sqlite:///{self.db_path}")
|
||||||
|
# 创建表
|
||||||
|
Base.metadata.create_all(engine)
|
||||||
|
# 创建 session factory
|
||||||
|
self.Session = scoped_session(sessionmaker(bind=engine))
|
||||||
|
except Exception as e:
|
||||||
|
raise DatabaseError(f"初始化数据库失败: {e}") from e
|
||||||
|
|
||||||
|
def update_subject(self, subject_id: str, **kwargs) -> bool:
|
||||||
|
"""
|
||||||
|
更新或保存番剧信息
|
||||||
|
|
||||||
|
Args:
|
||||||
|
subject_id: 番剧 ID
|
||||||
|
**kwargs: 支持传入 name, air_date, total_episodes, current_episode 等
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
操作是否成功
|
||||||
|
|
||||||
|
"""
|
||||||
|
session = self.Session()
|
||||||
|
try:
|
||||||
|
subject = (
|
||||||
|
session.query(BangumiSubject)
|
||||||
|
.filter_by(subject_id=str(subject_id))
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not subject:
|
||||||
|
name = kwargs.pop("name", "未知番剧")
|
||||||
|
subject = BangumiSubject(
|
||||||
|
subject_id=str(subject_id), name=name, **kwargs
|
||||||
|
)
|
||||||
|
session.add(subject)
|
||||||
|
else:
|
||||||
|
for key, value in kwargs.items():
|
||||||
|
if hasattr(subject, key) and value is not None:
|
||||||
|
setattr(subject, key, value)
|
||||||
|
session.commit()
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"更新番剧信息失败: {e}")
|
||||||
|
session.rollback()
|
||||||
|
raise DatabaseError(f"更新番剧信息失败: {e}") from e
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
def add_subscription(self, group_id: str, subject_id: str) -> bool:
|
||||||
|
"""
|
||||||
|
添加订阅关系
|
||||||
|
|
||||||
|
Args:
|
||||||
|
group_id: 群组 ID
|
||||||
|
subject_id: 番剧 ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
操作是否成功
|
||||||
|
|
||||||
|
"""
|
||||||
|
session = self.Session()
|
||||||
|
try:
|
||||||
|
# 确保 Subject 存在
|
||||||
|
subject = (
|
||||||
|
session.query(BangumiSubject)
|
||||||
|
.filter_by(subject_id=str(subject_id))
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not subject:
|
||||||
|
subject = BangumiSubject(subject_id=str(subject_id), name="未知番剧")
|
||||||
|
session.add(subject)
|
||||||
|
|
||||||
|
existing = (
|
||||||
|
session.query(Subscription)
|
||||||
|
.filter_by(group_id=str(group_id), subject_id=str(subject_id))
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
if not existing:
|
||||||
|
new_sub = Subscription(
|
||||||
|
group_id=str(group_id), subject_id=str(subject_id)
|
||||||
|
)
|
||||||
|
session.add(new_sub)
|
||||||
|
|
||||||
|
session.commit() # 单次 commit,保证原子性
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"添加订阅失败: {e}")
|
||||||
|
session.rollback()
|
||||||
|
raise DatabaseError(f"添加订阅失败: {e}") from e
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
def remove_subscription(self, group_id: str, subject_id: str) -> bool:
|
||||||
|
"""
|
||||||
|
移除订阅关系
|
||||||
|
|
||||||
|
Args:
|
||||||
|
group_id: 群组 ID
|
||||||
|
subject_id: 番剧 ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
操作是否成功
|
||||||
|
|
||||||
|
"""
|
||||||
|
session = self.Session()
|
||||||
|
try:
|
||||||
|
sub = (
|
||||||
|
session.query(Subscription)
|
||||||
|
.filter_by(group_id=str(group_id), subject_id=str(subject_id))
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if sub:
|
||||||
|
session.delete(sub)
|
||||||
|
session.commit()
|
||||||
|
return True
|
||||||
|
return False # 订阅不存在
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"移除订阅失败: {e}")
|
||||||
|
session.rollback()
|
||||||
|
raise DatabaseError(f"移除订阅失败: {e}") from e
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
def get_subscriptions(self, group_id: str) -> list[str]:
|
||||||
|
"""
|
||||||
|
获取指定群组的所有订阅
|
||||||
|
|
||||||
|
Args:
|
||||||
|
group_id: 群组 ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
订阅的番剧 ID 列表
|
||||||
|
|
||||||
|
"""
|
||||||
|
session = self.Session()
|
||||||
|
try:
|
||||||
|
subs = session.query(Subscription).filter_by(group_id=str(group_id)).all()
|
||||||
|
return [sub.subject_id for sub in subs]
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"获取订阅失败: {e}")
|
||||||
|
raise DatabaseError(f"获取订阅失败: {e}") from e
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
def get_monitored_subjects(self) -> list[BangumiSubject]:
|
||||||
|
"""
|
||||||
|
获取所有已订阅的番剧列表,用于轮询更新
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
番剧对象列表
|
||||||
|
|
||||||
|
"""
|
||||||
|
session = self.Session()
|
||||||
|
try:
|
||||||
|
# Eager load subscriptions 避免 DetachedInstanceError
|
||||||
|
subjects = (
|
||||||
|
session.query(BangumiSubject)
|
||||||
|
.options(joinedload(BangumiSubject.subscriptions))
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return subjects
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"获取监控番剧失败: {e}")
|
||||||
|
raise DatabaseError(f"获取监控番剧失败: {e}") from e
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
def update_subject_episode(self, subject_id: str, new_episode: int) -> bool:
|
||||||
|
"""
|
||||||
|
更新番剧最新集数(快捷方法)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
subject_id: 番剧 ID
|
||||||
|
new_episode: 新的集数
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
操作是否成功
|
||||||
|
|
||||||
|
"""
|
||||||
|
return self.update_subject(subject_id, current_episode=new_episode)
|
||||||
|
|
||||||
|
def subscribe_subject(
|
||||||
|
self,
|
||||||
|
group_id: str,
|
||||||
|
subject_id: str,
|
||||||
|
name: str,
|
||||||
|
air_date: str = "",
|
||||||
|
total_episodes: int = 0,
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
原子性地 upsert 番剧信息并建立订阅关系。
|
||||||
|
|
||||||
|
将 update_subject + add_subscription 合并到单一事务中,
|
||||||
|
避免两次独立调用之间发生异常导致脏数据。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
group_id: 群组 ID
|
||||||
|
subject_id: 番剧 ID
|
||||||
|
name: 番剧名称
|
||||||
|
air_date: 开播日期
|
||||||
|
total_episodes: 总集数
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
操作是否成功
|
||||||
|
"""
|
||||||
|
session = self.Session()
|
||||||
|
try:
|
||||||
|
# 1. upsert BangumiSubject
|
||||||
|
subject = (
|
||||||
|
session.query(BangumiSubject)
|
||||||
|
.filter_by(subject_id=str(subject_id))
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not subject:
|
||||||
|
subject = BangumiSubject(
|
||||||
|
subject_id=str(subject_id),
|
||||||
|
name=name,
|
||||||
|
air_date=air_date,
|
||||||
|
total_episodes=total_episodes,
|
||||||
|
)
|
||||||
|
session.add(subject)
|
||||||
|
else:
|
||||||
|
subject.name = name
|
||||||
|
if air_date:
|
||||||
|
subject.air_date = air_date
|
||||||
|
if total_episodes:
|
||||||
|
subject.total_episodes = total_episodes
|
||||||
|
|
||||||
|
# 2. 添加订阅关系(若不存在)
|
||||||
|
existing = (
|
||||||
|
session.query(Subscription)
|
||||||
|
.filter_by(group_id=str(group_id), subject_id=str(subject_id))
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not existing:
|
||||||
|
session.add(
|
||||||
|
Subscription(group_id=str(group_id), subject_id=str(subject_id))
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. 单次 commit,保证 subject 与 subscription 同时成功或同时回滚
|
||||||
|
session.commit()
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"原子订阅失败: {e}")
|
||||||
|
session.rollback()
|
||||||
|
raise DatabaseError(f"原子订阅失败: {e}") from e
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
def get_subject_subscribers(self, subject_id: str) -> list[str]:
|
||||||
|
"""
|
||||||
|
获取订阅了某番剧的所有群组 ID
|
||||||
|
|
||||||
|
Args:
|
||||||
|
subject_id: 番剧 ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
群组 ID 列表
|
||||||
|
|
||||||
|
"""
|
||||||
|
session = self.Session()
|
||||||
|
try:
|
||||||
|
subs = (
|
||||||
|
session.query(Subscription).filter_by(subject_id=str(subject_id)).all()
|
||||||
|
)
|
||||||
|
return [sub.group_id for sub in subs]
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"获取订阅群组失败: {e}")
|
||||||
|
raise DatabaseError(f"获取订阅群组失败: {e}") from e
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
def get_all_subscribed_groups(self) -> list[str]:
|
||||||
|
"""
|
||||||
|
获取所有拥有订阅的群组 ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
群组 ID 列表
|
||||||
|
|
||||||
|
"""
|
||||||
|
session = self.Session()
|
||||||
|
try:
|
||||||
|
groups = session.query(Subscription.group_id).distinct().all()
|
||||||
|
return [g[0] for g in groups]
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"获取所有订阅群组失败: {e}")
|
||||||
|
raise DatabaseError(f"获取所有订阅群组失败: {e}") from e
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
def find_group_subscription_candidates(
|
||||||
|
self, group_id: str, keyword: str, limit: int = 5
|
||||||
|
) -> list[BangumiSubject]:
|
||||||
|
"""
|
||||||
|
在指定群组的订阅中查找与关键词匹配的番剧候选。
|
||||||
|
|
||||||
|
匹配优先级:
|
||||||
|
1. subject_id 精确匹配
|
||||||
|
2. subject_id 前缀匹配
|
||||||
|
3. name 包含匹配(忽略大小写)
|
||||||
|
4. name 相似度(SequenceMatcher)
|
||||||
|
"""
|
||||||
|
session = self.Session()
|
||||||
|
try:
|
||||||
|
normalized_keyword = str(keyword).strip()
|
||||||
|
if not normalized_keyword:
|
||||||
|
return []
|
||||||
|
|
||||||
|
keyword_lower = normalized_keyword.lower()
|
||||||
|
search_pattern = f"%{normalized_keyword}%"
|
||||||
|
|
||||||
|
candidates = (
|
||||||
|
session.query(BangumiSubject)
|
||||||
|
.join(
|
||||||
|
Subscription, Subscription.subject_id == BangumiSubject.subject_id
|
||||||
|
)
|
||||||
|
.filter(Subscription.group_id == str(group_id))
|
||||||
|
.filter(
|
||||||
|
or_(
|
||||||
|
BangumiSubject.subject_id == normalized_keyword,
|
||||||
|
BangumiSubject.subject_id.like(f"{normalized_keyword}%"),
|
||||||
|
BangumiSubject.name.ilike(search_pattern),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
def score(subject: BangumiSubject) -> tuple[int, int, int, float, str]:
|
||||||
|
subject_id = str(subject.subject_id or "")
|
||||||
|
name = str(subject.name or "")
|
||||||
|
name_lower = name.lower()
|
||||||
|
exact_id = int(subject_id == normalized_keyword)
|
||||||
|
prefix_id = int(subject_id.startswith(normalized_keyword))
|
||||||
|
name_contains = int(keyword_lower in name_lower)
|
||||||
|
similarity = SequenceMatcher(None, keyword_lower, name_lower).ratio()
|
||||||
|
return (exact_id, prefix_id, name_contains, similarity, subject_id)
|
||||||
|
|
||||||
|
sorted_candidates = sorted(
|
||||||
|
candidates,
|
||||||
|
key=lambda subject: (
|
||||||
|
-score(subject)[0],
|
||||||
|
-score(subject)[1],
|
||||||
|
-score(subject)[2],
|
||||||
|
-score(subject)[3],
|
||||||
|
score(subject)[4],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return sorted_candidates[:limit]
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"查询群组订阅候选失败: {e}")
|
||||||
|
raise DatabaseError(f"查询群组订阅候选失败: {e}") from e
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
5
src/render/__init__.py
Normal file
5
src/render/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
from .calendar_renderer import CalendarRenderer
|
||||||
|
from .episode_renderer import EpisodeRenderer
|
||||||
|
from .subject_renderer import SubjectRenderer
|
||||||
|
|
||||||
|
__all__ = ["CalendarRenderer", "EpisodeRenderer", "SubjectRenderer"]
|
||||||
149
src/render/base_renderer.py
Normal file
149
src/render/base_renderer.py
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
from collections.abc import Awaitable, Callable
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
import jinja2
|
||||||
|
from astrbot.api import logger
|
||||||
|
|
||||||
|
from ..services import RenderData
|
||||||
|
|
||||||
|
|
||||||
|
class BaseRenderer:
|
||||||
|
def __init__(self, session: aiohttp.ClientSession | None = None) -> None:
|
||||||
|
self.template_dir = Path(__file__).resolve().parent.parent / "templates"
|
||||||
|
self.template_env = jinja2.Environment(
|
||||||
|
loader=jinja2.FileSystemLoader(str(self.template_dir)), autoescape=True
|
||||||
|
)
|
||||||
|
self._session = session
|
||||||
|
|
||||||
|
def _generate_html(
|
||||||
|
self, template_path: str, render_data: RenderData, sub_dir: str = ""
|
||||||
|
) -> str:
|
||||||
|
"""Render a Jinja2 template and inject a <base> tag for static assets."""
|
||||||
|
template = self.template_env.get_template(template_path)
|
||||||
|
html = template.render(**render_data)
|
||||||
|
base_path = self.template_dir / sub_dir if sub_dir else self.template_dir
|
||||||
|
base_url = base_path.as_uri() + "/"
|
||||||
|
if "<head>" in html:
|
||||||
|
return html.replace("<head>", f'<head><base href="{base_url}">', 1)
|
||||||
|
return f'<base href="{base_url}">{html}'
|
||||||
|
|
||||||
|
async def _handle_rpc_response(
|
||||||
|
self, response: aiohttp.ClientResponse
|
||||||
|
) -> str | None:
|
||||||
|
if response.status != 200:
|
||||||
|
logger.error(f"[-] RPC 渲染服务器返回错误状态码: {response.status}")
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
result = await response.json()
|
||||||
|
except aiohttp.ContentTypeError:
|
||||||
|
logger.error("[-] RPC 响应内容不是有效的 JSON")
|
||||||
|
return None
|
||||||
|
except (ValueError, TypeError, RuntimeError) as e:
|
||||||
|
logger.error(f"[-] 解析 RPC JSON 响应失败: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not isinstance(result, dict):
|
||||||
|
logger.error(f"[-] RPC 响应格式错误: {type(result)}")
|
||||||
|
return None
|
||||||
|
if "error" in result:
|
||||||
|
logger.error(f"[-] RPC 渲染返回业务错误: {result['error']}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
res_obj = result.get("result")
|
||||||
|
if isinstance(res_obj, dict) and "image" in res_obj:
|
||||||
|
image = res_obj["image"]
|
||||||
|
return image if isinstance(image, str) else None
|
||||||
|
|
||||||
|
logger.error(f"[-] RPC 响应中未找到 result.image: {result}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _render_via_rpc(
|
||||||
|
self,
|
||||||
|
rpc_url: str,
|
||||||
|
html_content: str,
|
||||||
|
selector: str,
|
||||||
|
timeout: int = 30000,
|
||||||
|
wait_time: float = 0,
|
||||||
|
) -> str | None:
|
||||||
|
"""Send HTML to the remote RPC renderer and return a base64 image."""
|
||||||
|
if not rpc_url:
|
||||||
|
return None
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"method": "screenshot",
|
||||||
|
"params": {
|
||||||
|
"html": html_content,
|
||||||
|
"selector": selector,
|
||||||
|
"wait_time": wait_time,
|
||||||
|
"timeout": timeout,
|
||||||
|
"scale": 3,
|
||||||
|
},
|
||||||
|
"id": int(asyncio.get_event_loop().time() * 1000),
|
||||||
|
}
|
||||||
|
client_timeout = aiohttp.ClientTimeout(total=timeout / 1000.0)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if self._session and not self._session.closed:
|
||||||
|
async with self._session.post(
|
||||||
|
rpc_url, json=payload, timeout=client_timeout
|
||||||
|
) as response:
|
||||||
|
return await self._handle_rpc_response(response)
|
||||||
|
else:
|
||||||
|
async with (
|
||||||
|
aiohttp.ClientSession() as session,
|
||||||
|
session.post(rpc_url, json=payload, timeout=client_timeout) as response,
|
||||||
|
):
|
||||||
|
return await self._handle_rpc_response(response)
|
||||||
|
|
||||||
|
except aiohttp.ClientConnectorError as e:
|
||||||
|
logger.error(f"[-] RPC 渲染服务器连接失败: {e}")
|
||||||
|
except TimeoutError:
|
||||||
|
logger.error(f"[-] RPC 渲染请求超时 ({timeout}ms)")
|
||||||
|
except aiohttp.ClientResponseError as e:
|
||||||
|
logger.error(f"[-] RPC 渲染服务器响应异常: {e.status} {e.message}")
|
||||||
|
except (RuntimeError, ValueError, TypeError) as e:
|
||||||
|
logger.error(f"[-] RPC 渲染请求发生未知异常: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def render(
|
||||||
|
self,
|
||||||
|
template_path: str,
|
||||||
|
render_data: RenderData,
|
||||||
|
selector: str,
|
||||||
|
local_render_func: Callable[[], Awaitable[str | None]],
|
||||||
|
rpc_url: str | None = None,
|
||||||
|
sub_dir: str = "",
|
||||||
|
timeout: int = 30000,
|
||||||
|
wait_time: float = 0,
|
||||||
|
) -> str | None:
|
||||||
|
"""
|
||||||
|
Unified render entry point.
|
||||||
|
|
||||||
|
Priority:
|
||||||
|
1. Remote RPC server (if *rpc_url* is configured)
|
||||||
|
2. Local Pillow rendering via *local_render_func*
|
||||||
|
"""
|
||||||
|
if rpc_url:
|
||||||
|
logger.debug(f"[+] 尝试通过 RPC 渲染: {template_path}")
|
||||||
|
html_content = self._generate_html(template_path, render_data, sub_dir)
|
||||||
|
result = await self._render_via_rpc(
|
||||||
|
rpc_url=rpc_url,
|
||||||
|
html_content=html_content,
|
||||||
|
selector=selector,
|
||||||
|
timeout=timeout,
|
||||||
|
wait_time=wait_time,
|
||||||
|
)
|
||||||
|
if result:
|
||||||
|
return result
|
||||||
|
logger.warning(f"[-] RPC 渲染失败 ({template_path}),回退到本地 Pillow 渲染...")
|
||||||
|
|
||||||
|
logger.debug(f"[+] 本地 Pillow 渲染: {template_path}")
|
||||||
|
try:
|
||||||
|
return await local_render_func()
|
||||||
|
except (RuntimeError, ValueError, TypeError) as e:
|
||||||
|
logger.error(f"[-] 本地 Pillow 渲染失败 ({template_path}): {e}")
|
||||||
|
return None
|
||||||
47
src/render/calendar_renderer.py
Normal file
47
src/render/calendar_renderer.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import datetime
|
||||||
|
from typing import cast
|
||||||
|
|
||||||
|
from astrbot.api import logger
|
||||||
|
|
||||||
|
from ..services import CalendarDay, CalendarWeekday, RenderData
|
||||||
|
from .base_renderer import BaseRenderer
|
||||||
|
from .pillow.calendar_card import draw_calendar_card
|
||||||
|
|
||||||
|
|
||||||
|
def reorder_days(calendar_data: list[CalendarDay]) -> list[CalendarDay]:
|
||||||
|
today_id = datetime.datetime.now().isoweekday()
|
||||||
|
today_index = 0
|
||||||
|
for i, day in enumerate(calendar_data):
|
||||||
|
weekday: CalendarWeekday = day.get("weekday", {})
|
||||||
|
if weekday.get("id") == today_id:
|
||||||
|
today_index = i
|
||||||
|
day["is_today"] = True
|
||||||
|
break
|
||||||
|
return calendar_data[today_index:] + calendar_data[:today_index]
|
||||||
|
|
||||||
|
|
||||||
|
class CalendarRenderer(BaseRenderer):
|
||||||
|
async def render_calendar(
|
||||||
|
self,
|
||||||
|
calendar_data: list[CalendarDay],
|
||||||
|
rpc_url: str | None = None,
|
||||||
|
headless: bool = True,
|
||||||
|
max_retries: int = 3,
|
||||||
|
) -> str | None:
|
||||||
|
try:
|
||||||
|
reordered_days = reorder_days(calendar_data)
|
||||||
|
except (ValueError, TypeError, RuntimeError) as e:
|
||||||
|
logger.error(f"[-] 处理日历数据失败: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
render_data = cast(RenderData, {"days": reordered_days})
|
||||||
|
return await self.render(
|
||||||
|
template_path="calendar/calendar.html",
|
||||||
|
render_data=render_data,
|
||||||
|
selector=".container",
|
||||||
|
local_render_func=lambda: draw_calendar_card(render_data, self._session),
|
||||||
|
rpc_url=rpc_url,
|
||||||
|
sub_dir="calendar",
|
||||||
|
timeout=30000,
|
||||||
|
wait_time=0,
|
||||||
|
)
|
||||||
22
src/render/episode_renderer.py
Normal file
22
src/render/episode_renderer.py
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
from ..services import Episode, RenderData
|
||||||
|
from .base_renderer import BaseRenderer
|
||||||
|
from .pillow.episode_card import draw_episode_card
|
||||||
|
|
||||||
|
|
||||||
|
class EpisodeRenderer(BaseRenderer):
|
||||||
|
async def render_episode(
|
||||||
|
self,
|
||||||
|
episode_data: Episode,
|
||||||
|
rpc_url: str | None = None,
|
||||||
|
headless: bool = True,
|
||||||
|
max_retries: int = 3,
|
||||||
|
) -> str | None:
|
||||||
|
render_data: RenderData = episode_data.model_dump()
|
||||||
|
return await self.render(
|
||||||
|
template_path="update/episode.html",
|
||||||
|
render_data=render_data,
|
||||||
|
selector="#card-container",
|
||||||
|
local_render_func=lambda: draw_episode_card(render_data, self._session),
|
||||||
|
rpc_url=rpc_url,
|
||||||
|
timeout=30000,
|
||||||
|
)
|
||||||
5
src/render/pillow/__init__.py
Normal file
5
src/render/pillow/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
from .calendar_card import draw_calendar_card
|
||||||
|
from .episode_card import draw_episode_card
|
||||||
|
from .subject_card import draw_subject_card
|
||||||
|
|
||||||
|
__all__ = ["draw_subject_card", "draw_calendar_card", "draw_episode_card"]
|
||||||
268
src/render/pillow/calendar_card.py
Normal file
268
src/render/pillow/calendar_card.py
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
"""
|
||||||
|
Pillow-based calendar card renderer.
|
||||||
|
|
||||||
|
Renders a 1400×auto image with a 7-column daily broadcast grid.
|
||||||
|
Today's column is highlighted in orange.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
from PIL import Image, ImageDraw
|
||||||
|
|
||||||
|
from .font_manager import get_font
|
||||||
|
from .image_utils import (
|
||||||
|
fetch_image,
|
||||||
|
fit_cover,
|
||||||
|
image_to_base64,
|
||||||
|
placeholder_cover,
|
||||||
|
rounded_clip,
|
||||||
|
strip_supplementary,
|
||||||
|
text_line_height,
|
||||||
|
text_width,
|
||||||
|
wrap_text,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Palette ────────────────────────────────────────────────────────────────────
|
||||||
|
BG_PAGE = (240, 242, 245)
|
||||||
|
WHITE = (255, 255, 255)
|
||||||
|
PRIMARY = (251, 140, 0)
|
||||||
|
TEXT_MAIN = (26, 26, 26)
|
||||||
|
TEXT_SUB = (133, 144, 166)
|
||||||
|
TEXT_LIGHT = (153, 153, 153)
|
||||||
|
BORDER = (234, 234, 234)
|
||||||
|
COL_BG = (255, 255, 255, 200) # slightly translucent column bg (drawn as solid)
|
||||||
|
|
||||||
|
# ── Layout constants ───────────────────────────────────────────────────────────
|
||||||
|
CANVAS_W = 1400
|
||||||
|
CANVAS_PAD = 30
|
||||||
|
GRID_GAP = 16
|
||||||
|
COLS = 7
|
||||||
|
COL_W = (CANVAS_W - 2 * CANVAS_PAD - (COLS - 1) * GRID_GAP) // COLS # ≈ 177
|
||||||
|
|
||||||
|
DAY_HEADER_H = 66 # day column header
|
||||||
|
ITEM_PAD = 10 # horizontal padding inside an anime item
|
||||||
|
COVER_ASPECT = 1.5 # height = width * COVER_ASPECT (2∶3)
|
||||||
|
COVER_W = COL_W - 2 * ITEM_PAD
|
||||||
|
COVER_H = int(COVER_W * COVER_ASPECT)
|
||||||
|
INFO_H = 78 # fixed height for title + score/rank under cover
|
||||||
|
ITEM_H = ITEM_PAD + COVER_H + ITEM_PAD + INFO_H + ITEM_PAD
|
||||||
|
SEPARATOR = 1
|
||||||
|
|
||||||
|
HEADER_AREA_H = 64 # "每日放送表" title bar
|
||||||
|
HEADER_GAP = 20 # gap between header and grid
|
||||||
|
|
||||||
|
MAX_CONCURRENT_FETCH = 10
|
||||||
|
|
||||||
|
|
||||||
|
# ── Helpers ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def _fetch_all(
|
||||||
|
items_by_col: list[list[dict]],
|
||||||
|
session: aiohttp.ClientSession | None,
|
||||||
|
) -> list[list[Image.Image | None]]:
|
||||||
|
"""Fetch all cover images concurrently (capped at MAX_CONCURRENT_FETCH)."""
|
||||||
|
sem = asyncio.Semaphore(MAX_CONCURRENT_FETCH)
|
||||||
|
|
||||||
|
async def _fetch_one(url: str) -> Image.Image | None:
|
||||||
|
async with sem:
|
||||||
|
return await fetch_image(url, session, timeout=10)
|
||||||
|
|
||||||
|
tasks: list[asyncio.Task[Image.Image | None]] = []
|
||||||
|
for items in items_by_col:
|
||||||
|
for item in items:
|
||||||
|
images = item.get("images") or {}
|
||||||
|
url = str(
|
||||||
|
images.get("common") or images.get("large") or images.get("medium") or ""
|
||||||
|
)
|
||||||
|
tasks.append(asyncio.create_task(_fetch_one(url)))
|
||||||
|
|
||||||
|
results_flat = await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
|
||||||
|
# Re-group by column
|
||||||
|
images_by_col: list[list[Image.Image | None]] = []
|
||||||
|
idx = 0
|
||||||
|
for items in items_by_col:
|
||||||
|
col_imgs: list[Image.Image | None] = []
|
||||||
|
for _ in items:
|
||||||
|
r = results_flat[idx]
|
||||||
|
col_imgs.append(r if isinstance(r, Image.Image) else None)
|
||||||
|
idx += 1
|
||||||
|
images_by_col.append(col_imgs)
|
||||||
|
return images_by_col
|
||||||
|
|
||||||
|
|
||||||
|
# ── Main renderer ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def draw_calendar_card(
|
||||||
|
data: dict,
|
||||||
|
session: aiohttp.ClientSession | None = None,
|
||||||
|
) -> str | None:
|
||||||
|
"""Render the weekly broadcast calendar and return a base64-encoded PNG string."""
|
||||||
|
try:
|
||||||
|
return await _render(data, session)
|
||||||
|
except Exception as exc:
|
||||||
|
try:
|
||||||
|
from astrbot.api import logger
|
||||||
|
logger.error(f"[-] Pillow calendar card render failed: {exc}")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def _render(data: dict, session: aiohttp.ClientSession | None) -> str | None: # noqa: C901
|
||||||
|
days: list[dict] = data.get("days") or []
|
||||||
|
if not days:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Normalise to exactly 7 columns (pad with empty if needed)
|
||||||
|
while len(days) < COLS:
|
||||||
|
days.append({"weekday": {"cn": "", "en": ""}, "items": [], "is_today": False})
|
||||||
|
|
||||||
|
items_by_col: list[list[dict]] = [
|
||||||
|
[item for item in (day.get("items") or []) if isinstance(item, dict)]
|
||||||
|
for day in days
|
||||||
|
]
|
||||||
|
|
||||||
|
# ── Fetch all covers concurrently ──────────────────────────────────────────
|
||||||
|
images_by_col = await _fetch_all(items_by_col, session)
|
||||||
|
|
||||||
|
# ── Compute column heights ─────────────────────────────────────────────────
|
||||||
|
col_heights = [
|
||||||
|
DAY_HEADER_H + len(items) * (ITEM_H + SEPARATOR) + GRID_GAP
|
||||||
|
for items in items_by_col
|
||||||
|
]
|
||||||
|
max_col_h = max(col_heights) if col_heights else DAY_HEADER_H + 200
|
||||||
|
|
||||||
|
# ── Canvas ─────────────────────────────────────────────────────────────────
|
||||||
|
canvas_h = CANVAS_PAD + HEADER_AREA_H + HEADER_GAP + max_col_h + CANVAS_PAD
|
||||||
|
canvas = Image.new("RGBA", (CANVAS_W, canvas_h), (*BG_PAGE, 255))
|
||||||
|
d = ImageDraw.Draw(canvas)
|
||||||
|
|
||||||
|
# ── Fonts ──────────────────────────────────────────────────────────────────
|
||||||
|
F = {
|
||||||
|
"h_title": get_font(28, bold=True),
|
||||||
|
"h_sub": get_font(14),
|
||||||
|
"day_cn": get_font(16, bold=True),
|
||||||
|
"day_en": get_font(11),
|
||||||
|
"item_title": get_font(13, bold=True),
|
||||||
|
"score": get_font(13, bold=True),
|
||||||
|
"rank": get_font(11),
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Page header: "每日放送表" ─────────────────────────────────────────────
|
||||||
|
hx = CANVAS_PAD + 10
|
||||||
|
hy = CANVAS_PAD
|
||||||
|
# Orange accent bar
|
||||||
|
d.rounded_rectangle([hx, hy + 4, hx + 7, hy + 36], radius=3, fill=PRIMARY)
|
||||||
|
d.text((hx + 18, hy), "每日放送表", font=F["h_title"], fill=TEXT_MAIN)
|
||||||
|
sub_x = hx + 18 + text_width("每日放送表", F["h_title"]) + 14
|
||||||
|
d.text((sub_x, hy + 8), "Bangumi Calendar", font=F["h_sub"], fill=TEXT_SUB)
|
||||||
|
|
||||||
|
# ── Day columns ────────────────────────────────────────────────────────────
|
||||||
|
grid_top = CANVAS_PAD + HEADER_AREA_H + HEADER_GAP
|
||||||
|
|
||||||
|
for col_idx, day in enumerate(days):
|
||||||
|
cx = CANVAS_PAD + col_idx * (COL_W + GRID_GAP)
|
||||||
|
cy = grid_top
|
||||||
|
is_today: bool = bool(day.get("is_today"))
|
||||||
|
items = items_by_col[col_idx]
|
||||||
|
col_imgs = images_by_col[col_idx]
|
||||||
|
col_h = max_col_h # all columns same height for visual alignment
|
||||||
|
|
||||||
|
weekday = day.get("weekday") or {}
|
||||||
|
day_cn = strip_supplementary(str(weekday.get("cn") or ""))
|
||||||
|
day_en = str(weekday.get("en") or "").upper()
|
||||||
|
|
||||||
|
# Column background
|
||||||
|
col_fill: tuple[int, int, int] = WHITE
|
||||||
|
col_outline = (180, 180, 180) if not is_today else PRIMARY
|
||||||
|
border_w = 1 if not is_today else 2
|
||||||
|
d.rounded_rectangle(
|
||||||
|
[cx, cy, cx + COL_W, cy + col_h],
|
||||||
|
radius=14,
|
||||||
|
fill=col_fill,
|
||||||
|
outline=col_outline,
|
||||||
|
width=border_w,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Day header
|
||||||
|
header_fill = PRIMARY if is_today else WHITE
|
||||||
|
header_text = WHITE if is_today else TEXT_MAIN
|
||||||
|
d.rounded_rectangle(
|
||||||
|
[cx, cy, cx + COL_W, cy + DAY_HEADER_H],
|
||||||
|
radius=14,
|
||||||
|
fill=header_fill,
|
||||||
|
)
|
||||||
|
# Flatten bottom corners of header by overdrawing a rect
|
||||||
|
d.rectangle(
|
||||||
|
[cx, cy + DAY_HEADER_H - 14, cx + COL_W, cy + DAY_HEADER_H],
|
||||||
|
fill=header_fill,
|
||||||
|
)
|
||||||
|
cn_w = text_width(day_cn, F["day_cn"])
|
||||||
|
d.text((cx + (COL_W - cn_w) // 2, cy + 14), day_cn,
|
||||||
|
font=F["day_cn"], fill=header_text)
|
||||||
|
en_w = text_width(day_en, F["day_en"])
|
||||||
|
d.text((cx + (COL_W - en_w) // 2, cy + 14 + text_line_height(F["day_cn"]) + 4),
|
||||||
|
day_en, font=F["day_en"], fill=(*header_text[:3], 180))
|
||||||
|
|
||||||
|
# Anime items
|
||||||
|
item_y = cy + DAY_HEADER_H + SEPARATOR
|
||||||
|
|
||||||
|
if not items:
|
||||||
|
d.text(
|
||||||
|
(cx + 16, item_y + 40),
|
||||||
|
"今日无更新",
|
||||||
|
font=F["day_en"],
|
||||||
|
fill=TEXT_LIGHT,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
for item_idx, item in enumerate(items):
|
||||||
|
img = col_imgs[item_idx] if item_idx < len(col_imgs) else None
|
||||||
|
|
||||||
|
# Separator line between items (skip for first)
|
||||||
|
if item_idx > 0:
|
||||||
|
d.line([cx + 1, item_y, cx + COL_W - 1, item_y], fill=BORDER)
|
||||||
|
|
||||||
|
# Cover image
|
||||||
|
cover_top = item_y + ITEM_PAD
|
||||||
|
if img:
|
||||||
|
cover = fit_cover(img, COVER_W, COVER_H)
|
||||||
|
else:
|
||||||
|
cover = placeholder_cover(COVER_W, COVER_H, color=(230, 230, 230))
|
||||||
|
clipped = rounded_clip(cover, 8)
|
||||||
|
canvas.alpha_composite(clipped, (cx + ITEM_PAD, cover_top))
|
||||||
|
|
||||||
|
# Info section
|
||||||
|
info_top = cover_top + COVER_H + ITEM_PAD
|
||||||
|
name_cn = strip_supplementary(str(item.get("name_cn") or item.get("name") or ""))
|
||||||
|
name_lines = wrap_text(name_cn, F["item_title"], COL_W - 2 * ITEM_PAD)[:2]
|
||||||
|
LH = text_line_height(F["item_title"]) + 3
|
||||||
|
for line_idx, line in enumerate(name_lines):
|
||||||
|
d.text((cx + ITEM_PAD, info_top + line_idx * LH), line,
|
||||||
|
font=F["item_title"], fill=TEXT_MAIN)
|
||||||
|
|
||||||
|
# Score + rank
|
||||||
|
meta_y = info_top + 2 * LH + 6
|
||||||
|
rating = item.get("rating") if isinstance(item.get("rating"), dict) else {}
|
||||||
|
score = rating.get("score") if rating else None
|
||||||
|
rank = item.get("rank")
|
||||||
|
meta_x = cx + ITEM_PAD
|
||||||
|
if score:
|
||||||
|
score_txt = f"★ {score}"
|
||||||
|
d.text((meta_x, meta_y), score_txt, font=F["score"], fill=PRIMARY)
|
||||||
|
meta_x += text_width(score_txt, F["score"]) + 10
|
||||||
|
if rank:
|
||||||
|
rank_txt = f"#{rank}"
|
||||||
|
rank_tw = text_width(rank_txt, F["rank"]) + 12
|
||||||
|
d.rounded_rectangle(
|
||||||
|
[meta_x, meta_y, meta_x + rank_tw, meta_y + text_line_height(F["rank"]) + 4],
|
||||||
|
radius=4,
|
||||||
|
fill=(245, 245, 245),
|
||||||
|
)
|
||||||
|
d.text((meta_x + 6, meta_y + 2), rank_txt, font=F["rank"], fill=TEXT_LIGHT)
|
||||||
|
|
||||||
|
item_y += ITEM_H + SEPARATOR
|
||||||
|
|
||||||
|
return image_to_base64(canvas)
|
||||||
154
src/render/pillow/episode_card.py
Normal file
154
src/render/pillow/episode_card.py
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
"""
|
||||||
|
Pillow-based episode update card renderer.
|
||||||
|
|
||||||
|
Renders a 768×(768*4/3) card with a full-bleed cover image,
|
||||||
|
gradient overlay, and episode metadata overlaid at the bottom.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
from PIL import Image, ImageDraw
|
||||||
|
|
||||||
|
from .font_manager import get_font
|
||||||
|
from .image_utils import (
|
||||||
|
fetch_image,
|
||||||
|
fit_cover,
|
||||||
|
image_to_base64,
|
||||||
|
make_gradient_overlay,
|
||||||
|
placeholder_cover,
|
||||||
|
strip_supplementary,
|
||||||
|
text_line_height,
|
||||||
|
text_width,
|
||||||
|
wrap_text,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Palette ────────────────────────────────────────────────────────────────────
|
||||||
|
BLACK = (0, 0, 0)
|
||||||
|
WHITE = (255, 255, 255)
|
||||||
|
PINK = (236, 72, 153) # --accent-pink: #ec4899
|
||||||
|
WHITE_80 = (255, 255, 255, 204)
|
||||||
|
WHITE_85 = (255, 255, 255, 217)
|
||||||
|
|
||||||
|
# ── Layout constants ───────────────────────────────────────────────────────────
|
||||||
|
CARD_W = 768
|
||||||
|
CARD_H = int(CARD_W * 4 / 3) # 1024
|
||||||
|
|
||||||
|
|
||||||
|
# ── Main renderer ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def draw_episode_card(
|
||||||
|
data: dict,
|
||||||
|
session: aiohttp.ClientSession | None = None,
|
||||||
|
) -> str | None:
|
||||||
|
"""Render an episode update card and return a base64-encoded PNG string."""
|
||||||
|
try:
|
||||||
|
return await _render(data, session)
|
||||||
|
except Exception as exc:
|
||||||
|
try:
|
||||||
|
from astrbot.api import logger
|
||||||
|
logger.error(f"[-] Pillow episode card render failed: {exc}")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def _render(data: dict, session: aiohttp.ClientSession | None) -> str | None:
|
||||||
|
image_url = str(data.get("image_url") or "")
|
||||||
|
name = strip_supplementary(str(data.get("name") or ""))
|
||||||
|
name_cn = strip_supplementary(str(data.get("name_cn") or ""))
|
||||||
|
title = name_cn or name or ""
|
||||||
|
desc = strip_supplementary(str(data.get("desc") or ""))
|
||||||
|
sort_num = data.get("sort") or data.get("ep") or 1
|
||||||
|
airdate = str(data.get("airdate") or "")
|
||||||
|
comment = int(data.get("comment") or 0)
|
||||||
|
|
||||||
|
# ── Fonts ──────────────────────────────────────────────────────────────────
|
||||||
|
F = {
|
||||||
|
"ep_num": get_font(52, bold=True),
|
||||||
|
"title": get_font(44, bold=True),
|
||||||
|
"meta": get_font(16, bold=True),
|
||||||
|
"desc": get_font(16),
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Fetch cover image ──────────────────────────────────────────────────────
|
||||||
|
cover_img = await fetch_image(image_url, session, timeout=10)
|
||||||
|
|
||||||
|
# ── Base canvas (black) ────────────────────────────────────────────────────
|
||||||
|
canvas = Image.new("RGBA", (CARD_W, CARD_H), (*BLACK, 255))
|
||||||
|
|
||||||
|
if cover_img:
|
||||||
|
cover = fit_cover(cover_img, CARD_W, CARD_H)
|
||||||
|
canvas.alpha_composite(cover, (0, 0))
|
||||||
|
else:
|
||||||
|
# Fallback: dark radial-ish background
|
||||||
|
placeholder = placeholder_cover(CARD_W, CARD_H, color=(42, 42, 53))
|
||||||
|
canvas.alpha_composite(placeholder)
|
||||||
|
|
||||||
|
# ── Gradient overlay ───────────────────────────────────────────────────────
|
||||||
|
overlay = make_gradient_overlay(CARD_W, CARD_H, start_pct=0.4)
|
||||||
|
canvas.alpha_composite(overlay)
|
||||||
|
|
||||||
|
# ── Content overlay ────────────────────────────────────────────────────────
|
||||||
|
d = ImageDraw.Draw(canvas)
|
||||||
|
|
||||||
|
SIDE_PAD = 28
|
||||||
|
content_x = SIDE_PAD
|
||||||
|
content_bottom = CARD_H - 28
|
||||||
|
|
||||||
|
# Build lines bottom-up
|
||||||
|
# 1. Description (max 3 lines)
|
||||||
|
desc_lines: list[str] = []
|
||||||
|
if desc:
|
||||||
|
desc_lines = wrap_text(desc, F["desc"], CARD_W - 2 * SIDE_PAD)[:3]
|
||||||
|
|
||||||
|
LH_desc = int(text_line_height(F["desc"]) * 1.7)
|
||||||
|
desc_block_h = len(desc_lines) * LH_desc + (24 if desc_lines else 0)
|
||||||
|
|
||||||
|
# 2. Metadata row
|
||||||
|
meta_parts: list[str] = []
|
||||||
|
if airdate and "-" in airdate:
|
||||||
|
meta_parts.append(airdate.split("-")[0])
|
||||||
|
meta_parts.append("24min")
|
||||||
|
if comment > 0:
|
||||||
|
meta_parts.append(f"{comment} comments")
|
||||||
|
meta_txt = " | ".join(meta_parts)
|
||||||
|
meta_h = text_line_height(F["meta"]) + 20
|
||||||
|
|
||||||
|
# 3. EP + Title row
|
||||||
|
ep_txt = f"EP.{int(sort_num):02d}"
|
||||||
|
ep_h = text_line_height(F["ep_num"])
|
||||||
|
LH_title = text_line_height(F["title"]) + 6
|
||||||
|
title_lines = wrap_text(title, F["title"], CARD_W - 2 * SIDE_PAD - text_width(ep_txt, F["ep_num"]) - 16)[:2]
|
||||||
|
title_block_h = len(title_lines) * LH_title
|
||||||
|
|
||||||
|
header_h = max(ep_h, title_block_h)
|
||||||
|
|
||||||
|
# ── Draw from bottom up ────────────────────────────────────────────────────
|
||||||
|
cur_y = content_bottom
|
||||||
|
|
||||||
|
# Description
|
||||||
|
if desc_lines:
|
||||||
|
cur_y -= desc_block_h - LH_desc
|
||||||
|
for line in reversed(desc_lines):
|
||||||
|
d.text((content_x, cur_y), line, font=F["desc"], fill=WHITE_85)
|
||||||
|
cur_y -= LH_desc
|
||||||
|
cur_y -= 24
|
||||||
|
|
||||||
|
# Metadata row
|
||||||
|
cur_y -= meta_h
|
||||||
|
d.text((content_x, cur_y), meta_txt, font=F["meta"], fill=(*WHITE[:3], 200))
|
||||||
|
cur_y -= 20
|
||||||
|
|
||||||
|
# EP + Title row
|
||||||
|
ep_w = text_width(ep_txt, F["ep_num"])
|
||||||
|
ep_y = cur_y - header_h
|
||||||
|
d.text((content_x, ep_y), ep_txt, font=F["ep_num"], fill=(*PINK, 255))
|
||||||
|
|
||||||
|
title_x = content_x + ep_w + 16
|
||||||
|
title_available_w = CARD_W - title_x - SIDE_PAD
|
||||||
|
# Re-wrap with accurate available width
|
||||||
|
title_lines = wrap_text(title, F["title"], title_available_w)[:2]
|
||||||
|
for i, line in enumerate(title_lines):
|
||||||
|
d.text((title_x, ep_y + i * LH_title), line, font=F["title"], fill=WHITE)
|
||||||
|
|
||||||
|
return image_to_base64(canvas)
|
||||||
97
src/render/pillow/font_manager.py
Normal file
97
src/render/pillow/font_manager.py
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from PIL import ImageFont
|
||||||
|
|
||||||
|
_cache: dict[tuple[bool, int], ImageFont.FreeTypeFont | ImageFont.ImageFont] = {}
|
||||||
|
_regular_path: str | None = None
|
||||||
|
_bold_path: str | None = None
|
||||||
|
_initialized: bool = False
|
||||||
|
|
||||||
|
_CANDIDATES: dict[str, dict[bool, list[str]]] = {
|
||||||
|
"win32": {
|
||||||
|
False: [
|
||||||
|
"C:/Windows/Fonts/msyh.ttc",
|
||||||
|
"C:/Windows/Fonts/simsun.ttc",
|
||||||
|
"C:/Windows/Fonts/simhei.ttf",
|
||||||
|
],
|
||||||
|
True: [
|
||||||
|
"C:/Windows/Fonts/msyhbd.ttc",
|
||||||
|
"C:/Windows/Fonts/simhei.ttf",
|
||||||
|
"C:/Windows/Fonts/msyh.ttc",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"darwin": {
|
||||||
|
False: [
|
||||||
|
"/System/Library/Fonts/PingFang.ttc",
|
||||||
|
"/System/Library/Fonts/Supplemental/Arial Unicode MS.ttf",
|
||||||
|
],
|
||||||
|
True: [
|
||||||
|
"/System/Library/Fonts/PingFang.ttc",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"linux": {
|
||||||
|
False: [
|
||||||
|
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
|
||||||
|
"/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc",
|
||||||
|
"/usr/share/fonts/truetype/noto/NotoSansSC-Regular.otf",
|
||||||
|
"/usr/share/fonts/google-noto-cjk/NotoSansCJK-Regular.ttc",
|
||||||
|
"/usr/share/fonts/noto/NotoSansCJK-Regular.ttc",
|
||||||
|
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
||||||
|
],
|
||||||
|
True: [
|
||||||
|
"/usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc",
|
||||||
|
"/usr/share/fonts/noto-cjk/NotoSansCJK-Bold.ttc",
|
||||||
|
"/usr/share/fonts/truetype/noto/NotoSansSC-Bold.otf",
|
||||||
|
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _init() -> None:
|
||||||
|
global _regular_path, _bold_path, _initialized
|
||||||
|
if _initialized:
|
||||||
|
return
|
||||||
|
|
||||||
|
platform = sys.platform if sys.platform in _CANDIDATES else "linux"
|
||||||
|
|
||||||
|
def _find(bold: bool) -> str | None:
|
||||||
|
for p in _CANDIDATES[platform][bold]:
|
||||||
|
if Path(p).exists():
|
||||||
|
return p
|
||||||
|
# Cross-platform fallback: try all platforms' regular fonts
|
||||||
|
for plat_cands in _CANDIDATES.values():
|
||||||
|
for p in plat_cands[False]:
|
||||||
|
if Path(p).exists():
|
||||||
|
return p
|
||||||
|
return None
|
||||||
|
|
||||||
|
_regular_path = _find(False)
|
||||||
|
_bold_path = _find(True) or _regular_path
|
||||||
|
_initialized = True
|
||||||
|
|
||||||
|
|
||||||
|
def get_font(size: int, bold: bool = False) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
|
||||||
|
"""Return a CJK-compatible font at the requested size."""
|
||||||
|
_init()
|
||||||
|
key = (bold, size)
|
||||||
|
if key in _cache:
|
||||||
|
return _cache[key]
|
||||||
|
|
||||||
|
path = _bold_path if bold else _regular_path
|
||||||
|
if path:
|
||||||
|
try:
|
||||||
|
font = ImageFont.truetype(path, size)
|
||||||
|
_cache[key] = font
|
||||||
|
return font
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Ultimate fallback: PIL built-in bitmap font
|
||||||
|
try:
|
||||||
|
fb: ImageFont.FreeTypeFont | ImageFont.ImageFont = ImageFont.load_default(size=size)
|
||||||
|
except TypeError:
|
||||||
|
fb = ImageFont.load_default()
|
||||||
|
_cache[key] = fb
|
||||||
|
return fb
|
||||||
147
src/render/pillow/image_utils.py
Normal file
147
src/render/pillow/image_utils.py
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
import base64
|
||||||
|
import io
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
from PIL import Image, ImageDraw
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_image(
|
||||||
|
url: str,
|
||||||
|
session: aiohttp.ClientSession | None = None,
|
||||||
|
timeout: int = 10,
|
||||||
|
) -> Image.Image | None:
|
||||||
|
"""Fetch an image from a URL or data-URI and return a PIL Image."""
|
||||||
|
if not url:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Handle data URIs (e.g. "data:image/png;base64,...")
|
||||||
|
if url.startswith("data:"):
|
||||||
|
try:
|
||||||
|
_, encoded = url.split(",", 1)
|
||||||
|
raw = base64.b64decode(encoded)
|
||||||
|
return Image.open(io.BytesIO(raw)).convert("RGBA")
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
ct = aiohttp.ClientTimeout(total=timeout)
|
||||||
|
headers = {"Referer": "https://bgm.tv/"}
|
||||||
|
if session and not session.closed:
|
||||||
|
async with session.get(url, timeout=ct, headers=headers) as resp:
|
||||||
|
if resp.status == 200:
|
||||||
|
data = await resp.read()
|
||||||
|
return Image.open(io.BytesIO(data)).convert("RGBA")
|
||||||
|
else:
|
||||||
|
async with aiohttp.ClientSession() as s:
|
||||||
|
async with s.get(url, timeout=ct, headers=headers) as resp:
|
||||||
|
if resp.status == 200:
|
||||||
|
data = await resp.read()
|
||||||
|
return Image.open(io.BytesIO(data)).convert("RGBA")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def image_to_base64(img: Image.Image) -> str:
|
||||||
|
"""Convert a PIL Image to a base64-encoded PNG string."""
|
||||||
|
buf = io.BytesIO()
|
||||||
|
img.convert("RGB").save(buf, format="PNG", optimize=True)
|
||||||
|
return base64.b64encode(buf.getvalue()).decode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def fit_cover(img: Image.Image, width: int, height: int) -> Image.Image:
|
||||||
|
"""Resize and center-crop to fill the target box (CSS object-fit: cover)."""
|
||||||
|
src_ratio = img.width / img.height
|
||||||
|
tgt_ratio = width / height
|
||||||
|
if src_ratio > tgt_ratio:
|
||||||
|
new_h, new_w = height, int(height * src_ratio)
|
||||||
|
else:
|
||||||
|
new_w, new_h = width, int(width / src_ratio)
|
||||||
|
img = img.resize((new_w, new_h), Image.LANCZOS)
|
||||||
|
left = (new_w - width) // 2
|
||||||
|
top = (new_h - height) // 2
|
||||||
|
return img.crop((left, top, left + width, top + height))
|
||||||
|
|
||||||
|
|
||||||
|
def rounded_clip(img: Image.Image, radius: int) -> Image.Image:
|
||||||
|
"""Apply rounded corners to an image via an alpha mask."""
|
||||||
|
img = img.convert("RGBA")
|
||||||
|
mask = Image.new("L", img.size, 0)
|
||||||
|
ImageDraw.Draw(mask).rounded_rectangle(
|
||||||
|
[0, 0, img.width - 1, img.height - 1], radius=radius, fill=255
|
||||||
|
)
|
||||||
|
result = Image.new("RGBA", img.size, (0, 0, 0, 0))
|
||||||
|
result.paste(img, mask=mask)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def placeholder_cover(width: int, height: int, color: tuple[int, int, int] = (224, 224, 224)) -> Image.Image:
|
||||||
|
"""Create a solid-color placeholder cover."""
|
||||||
|
return Image.new("RGBA", (width, height), (*color, 255))
|
||||||
|
|
||||||
|
|
||||||
|
def text_width(text: str, font: object) -> int:
|
||||||
|
"""Return the rendered width of *text* in pixels."""
|
||||||
|
if hasattr(font, "getlength"):
|
||||||
|
return int(font.getlength(text)) # type: ignore[union-attr]
|
||||||
|
try:
|
||||||
|
return font.getsize(text)[0] # type: ignore[union-attr]
|
||||||
|
except Exception:
|
||||||
|
size = getattr(font, "size", 12)
|
||||||
|
return len(text) * size
|
||||||
|
|
||||||
|
|
||||||
|
def text_line_height(font: object) -> int:
|
||||||
|
"""Return the line height (ascent + descent) of a font."""
|
||||||
|
try:
|
||||||
|
ascent, descent = font.getmetrics() # type: ignore[union-attr]
|
||||||
|
return ascent + descent
|
||||||
|
except Exception:
|
||||||
|
return getattr(font, "size", 12) + 4
|
||||||
|
|
||||||
|
|
||||||
|
def wrap_text(text: str, font: object, max_width: int) -> list[str]:
|
||||||
|
"""Word-wrap *text* to fit within *max_width* pixels (character-level wrap for CJK)."""
|
||||||
|
lines: list[str] = []
|
||||||
|
for paragraph in text.split("\n"):
|
||||||
|
if not paragraph:
|
||||||
|
lines.append("")
|
||||||
|
continue
|
||||||
|
line = ""
|
||||||
|
for char in paragraph:
|
||||||
|
candidate = line + char
|
||||||
|
if text_width(candidate, font) <= max_width:
|
||||||
|
line = candidate
|
||||||
|
else:
|
||||||
|
if line:
|
||||||
|
lines.append(line)
|
||||||
|
line = char
|
||||||
|
if line:
|
||||||
|
lines.append(line)
|
||||||
|
return lines or [""]
|
||||||
|
|
||||||
|
|
||||||
|
def strip_supplementary(text: str) -> str:
|
||||||
|
"""Remove supplementary-plane characters (emoji above U+FFFF) that most system fonts can't render."""
|
||||||
|
return "".join(c for c in text if ord(c) <= 0xFFFF)
|
||||||
|
|
||||||
|
|
||||||
|
def make_gradient_overlay(width: int, height: int, start_pct: float = 0.4) -> Image.Image:
|
||||||
|
"""
|
||||||
|
Create a vertical black-to-opaque gradient overlay.
|
||||||
|
The first *start_pct* of the height is fully transparent; the remainder fades to ~95% opacity.
|
||||||
|
"""
|
||||||
|
strip = Image.new("L", (1, 256))
|
||||||
|
for i in range(256):
|
||||||
|
pct = i / 255
|
||||||
|
if pct < start_pct:
|
||||||
|
v = 0
|
||||||
|
else:
|
||||||
|
progress = (pct - start_pct) / (1.0 - start_pct)
|
||||||
|
v = min(255, int(progress * 242))
|
||||||
|
strip.putpixel((0, i), v)
|
||||||
|
alpha_strip = strip.resize((1, height), Image.BICUBIC)
|
||||||
|
alpha_full = alpha_strip.resize((width, height), Image.NEAREST)
|
||||||
|
overlay = Image.new("RGBA", (width, height), (0, 0, 0, 255))
|
||||||
|
overlay.putalpha(alpha_full)
|
||||||
|
return overlay
|
||||||
441
src/render/pillow/subject_card.py
Normal file
441
src/render/pillow/subject_card.py
Normal file
@@ -0,0 +1,441 @@
|
|||||||
|
"""
|
||||||
|
Pillow-based subject card renderer.
|
||||||
|
|
||||||
|
Renders a 800×auto card with:
|
||||||
|
- Left column (210px): cover image · episode progress grid · rating histogram
|
||||||
|
- Right column (514px): title · score/rank · tags · summary · footer
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
from PIL import Image, ImageDraw, ImageFilter
|
||||||
|
|
||||||
|
from .font_manager import get_font
|
||||||
|
from .image_utils import (
|
||||||
|
fetch_image,
|
||||||
|
fit_cover,
|
||||||
|
image_to_base64,
|
||||||
|
placeholder_cover,
|
||||||
|
rounded_clip,
|
||||||
|
strip_supplementary,
|
||||||
|
text_line_height,
|
||||||
|
text_width,
|
||||||
|
wrap_text,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Palette ────────────────────────────────────────────────────────────────────
|
||||||
|
WHITE = (255, 255, 255)
|
||||||
|
PRIMARY = (251, 140, 0)
|
||||||
|
SECONDARY_BG = (255, 243, 224)
|
||||||
|
TEXT_MAIN = (26, 26, 26)
|
||||||
|
TEXT_SUB = (133, 144, 166)
|
||||||
|
TEXT_LIGHT = (153, 153, 153)
|
||||||
|
BORDER = (234, 234, 234)
|
||||||
|
EP_GRAY_BG = (232, 232, 232)
|
||||||
|
EP_GRAY_TEXT = (102, 102, 102)
|
||||||
|
EP_ORANGE = (255, 152, 0)
|
||||||
|
ORANGE_DEEP = (230, 81, 0)
|
||||||
|
FOOTER_BG = (249, 249, 249)
|
||||||
|
HIST_LOW = (255, 224, 178) # low scores (1-6)
|
||||||
|
|
||||||
|
# ── Layout constants ───────────────────────────────────────────────────────────
|
||||||
|
CARD_W = 800
|
||||||
|
CARD_PAD = 24
|
||||||
|
LEFT_W = 210
|
||||||
|
COL_GAP = 28
|
||||||
|
RIGHT_W = CARD_W - CARD_PAD * 2 - LEFT_W - COL_GAP # = 514
|
||||||
|
COVER_W = LEFT_W
|
||||||
|
COVER_H = 315 # 2∶3 default
|
||||||
|
SHADOW_PAD = 20
|
||||||
|
SHADOW_BLUR = 12
|
||||||
|
|
||||||
|
|
||||||
|
# ── Helpers ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _draw_rounded_rect(
|
||||||
|
draw: ImageDraw.ImageDraw,
|
||||||
|
box: tuple[int, int, int, int],
|
||||||
|
radius: int,
|
||||||
|
fill: tuple | None = None,
|
||||||
|
outline: tuple | None = None,
|
||||||
|
width: int = 1,
|
||||||
|
) -> None:
|
||||||
|
draw.rounded_rectangle(box, radius=radius, fill=fill, outline=outline, width=width)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_shadow(card_w: int, card_h: int) -> Image.Image:
|
||||||
|
"""Return an RGBA image containing only the blurred shadow."""
|
||||||
|
sw = card_w + SHADOW_PAD * 2
|
||||||
|
sh = card_h + SHADOW_PAD * 2
|
||||||
|
shadow = Image.new("RGBA", (sw, sh), (0, 0, 0, 0))
|
||||||
|
ImageDraw.Draw(shadow).rounded_rectangle(
|
||||||
|
[SHADOW_PAD, SHADOW_PAD + 8, SHADOW_PAD + card_w, SHADOW_PAD + card_h + 8],
|
||||||
|
radius=20,
|
||||||
|
fill=(0, 0, 0, 28),
|
||||||
|
)
|
||||||
|
return shadow.filter(ImageFilter.GaussianBlur(SHADOW_BLUR))
|
||||||
|
|
||||||
|
|
||||||
|
def _int_rating_count(rating_count: object, key: int) -> int:
|
||||||
|
if not isinstance(rating_count, dict):
|
||||||
|
return 0
|
||||||
|
v = rating_count.get(str(key)) or rating_count.get(key) or 0
|
||||||
|
return int(v) if isinstance(v, (int, float)) else 0
|
||||||
|
|
||||||
|
|
||||||
|
# ── Main renderer ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def draw_subject_card(
|
||||||
|
data: dict,
|
||||||
|
session: aiohttp.ClientSession | None = None,
|
||||||
|
) -> str | None:
|
||||||
|
"""Render a subject info card and return a base64-encoded PNG string."""
|
||||||
|
try:
|
||||||
|
return await _render(data, session)
|
||||||
|
except Exception as exc:
|
||||||
|
try:
|
||||||
|
from astrbot.api import logger
|
||||||
|
logger.error(f"[-] Pillow subject card render failed: {exc}")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def _render(data: dict, session: aiohttp.ClientSession | None) -> str | None: # noqa: C901
|
||||||
|
# ── Extract fields ─────────────────────────────────────────────────────────
|
||||||
|
name_cn: str = strip_supplementary(str(data.get("name_cn") or ""))
|
||||||
|
name: str = strip_supplementary(str(data.get("name") or ""))
|
||||||
|
title = name_cn or name
|
||||||
|
subtitle = name if (name_cn and name and name != name_cn) else ""
|
||||||
|
|
||||||
|
image_url = str(data.get("image_url") or "")
|
||||||
|
rating = data.get("rating") if isinstance(data.get("rating"), dict) else {}
|
||||||
|
score = rating.get("score") if rating else None
|
||||||
|
rating_total = rating.get("total") if rating else None
|
||||||
|
rating_rank = rating.get("rank") if rating else None
|
||||||
|
rating_count = rating.get("count") if rating else {}
|
||||||
|
rank = data.get("rank") or rating_rank
|
||||||
|
|
||||||
|
tags_raw = data.get("tags") or []
|
||||||
|
tags = [strip_supplementary(str(t.get("name", ""))) for t in tags_raw[:8] if isinstance(t, dict)]
|
||||||
|
tags = [t for t in tags if t]
|
||||||
|
|
||||||
|
summary = strip_supplementary(str(data.get("summary") or "暂无简介"))
|
||||||
|
date_str = strip_supplementary(str(data.get("date") or ""))
|
||||||
|
platform = strip_supplementary(str(data.get("platform") or ""))
|
||||||
|
# strip leading emoji like "🎬 " from platform
|
||||||
|
platform = platform.lstrip()
|
||||||
|
|
||||||
|
subject_id = data.get("id")
|
||||||
|
episode_list: list[dict] = [ep for ep in (data.get("episode_list") or []) if isinstance(ep, dict)]
|
||||||
|
air_weekday = strip_supplementary(str(data.get("air_weekday") or ""))
|
||||||
|
collection = data.get("collection") if isinstance(data.get("collection"), dict) else {}
|
||||||
|
collection_doing = collection.get("doing") if collection else None
|
||||||
|
|
||||||
|
# ── Fonts ──────────────────────────────────────────────────────────────────
|
||||||
|
F = {
|
||||||
|
"title": get_font(26, bold=True),
|
||||||
|
"subtitle": get_font(13),
|
||||||
|
"score": get_font(34, bold=True),
|
||||||
|
"star": get_font(22),
|
||||||
|
"rank_tag": get_font(13, bold=True),
|
||||||
|
"count": get_font(13),
|
||||||
|
"tag": get_font(12),
|
||||||
|
"sum_label": get_font(13, bold=True),
|
||||||
|
"summary": get_font(14),
|
||||||
|
"footer": get_font(12),
|
||||||
|
"ep_label": get_font(10, bold=True),
|
||||||
|
"ep_cell": get_font(10, bold=True),
|
||||||
|
"chart_title": get_font(10, bold=True),
|
||||||
|
"weekday": get_font(20, bold=True),
|
||||||
|
"weekday_sub": get_font(9),
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Fetch cover image ──────────────────────────────────────────────────────
|
||||||
|
cover_img = await fetch_image(image_url, session, timeout=10)
|
||||||
|
|
||||||
|
# ── Compute left column height ─────────────────────────────────────────────
|
||||||
|
if cover_img:
|
||||||
|
ratio = cover_img.width / cover_img.height
|
||||||
|
cover_h = max(int(COVER_W / ratio), COVER_H)
|
||||||
|
cover_h = min(cover_h, 420)
|
||||||
|
else:
|
||||||
|
cover_h = COVER_H
|
||||||
|
|
||||||
|
ep_block_h = 0
|
||||||
|
CELLS_PER_ROW = 6
|
||||||
|
CELL = 28
|
||||||
|
CELL_GAP = 4
|
||||||
|
if episode_list:
|
||||||
|
n_rows = (len(episode_list) + CELLS_PER_ROW - 1) // CELLS_PER_ROW
|
||||||
|
ep_block_h = 10 + text_line_height(F["ep_label"]) + 8 + n_rows * (CELL + CELL_GAP) - CELL_GAP + 10
|
||||||
|
|
||||||
|
hist_block_h = 0
|
||||||
|
has_hist = isinstance(rating_count, dict) and any(
|
||||||
|
_int_rating_count(rating_count, i) for i in range(1, 11)
|
||||||
|
)
|
||||||
|
if has_hist:
|
||||||
|
hist_block_h = 12 + text_line_height(F["chart_title"]) + 4 + 45 + 2 + text_line_height(F["chart_title"]) + 12
|
||||||
|
|
||||||
|
left_h = cover_h
|
||||||
|
if ep_block_h:
|
||||||
|
left_h += 12 + ep_block_h
|
||||||
|
if hist_block_h:
|
||||||
|
left_h += 12 + hist_block_h
|
||||||
|
|
||||||
|
# ── Compute right column height ────────────────────────────────────────────
|
||||||
|
LH_title = text_line_height(F["title"]) + 4
|
||||||
|
title_lines = wrap_text(title, F["title"], RIGHT_W - 70)[:3]
|
||||||
|
title_block_h = len(title_lines) * LH_title
|
||||||
|
|
||||||
|
subtitle_block_h = (text_line_height(F["subtitle"]) + 6) if subtitle else 0
|
||||||
|
|
||||||
|
score_row_h = 46
|
||||||
|
|
||||||
|
# Tags height (flex-wrap)
|
||||||
|
tag_row_h = text_line_height(F["tag"]) + 8
|
||||||
|
tag_block_h = 0
|
||||||
|
if tags:
|
||||||
|
row_x = 0
|
||||||
|
rows = 1
|
||||||
|
for tag in tags:
|
||||||
|
tw = text_width(tag, F["tag"]) + 24
|
||||||
|
if row_x + tw + 8 > RIGHT_W:
|
||||||
|
rows += 1
|
||||||
|
row_x = tw + 8
|
||||||
|
else:
|
||||||
|
row_x += tw + 8
|
||||||
|
tag_block_h = rows * tag_row_h + (rows - 1) * 8
|
||||||
|
|
||||||
|
# Summary
|
||||||
|
LH_sum = int(text_line_height(F["summary"]) * 1.75)
|
||||||
|
summary_lines = wrap_text(summary, F["summary"], RIGHT_W)[:7]
|
||||||
|
summary_block_h = (
|
||||||
|
20 # border-top gap
|
||||||
|
+ text_line_height(F["sum_label"]) + 8 # label
|
||||||
|
+ len(summary_lines) * LH_sum
|
||||||
|
)
|
||||||
|
|
||||||
|
footer_block_h = 16 + 32
|
||||||
|
|
||||||
|
right_h = (
|
||||||
|
16
|
||||||
|
+ title_block_h
|
||||||
|
+ (subtitle_block_h if subtitle_block_h else 0)
|
||||||
|
+ 16
|
||||||
|
+ score_row_h
|
||||||
|
+ 16
|
||||||
|
+ (tag_block_h + 16 if tag_block_h else 0)
|
||||||
|
+ summary_block_h
|
||||||
|
+ footer_block_h
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Create card surface ────────────────────────────────────────────────────
|
||||||
|
inner_h = max(left_h, right_h, 360)
|
||||||
|
card_h = inner_h + CARD_PAD * 2
|
||||||
|
|
||||||
|
card = Image.new("RGBA", (CARD_W, card_h), (255, 255, 255, 255))
|
||||||
|
d = ImageDraw.Draw(card)
|
||||||
|
d.rounded_rectangle([0, 0, CARD_W - 1, card_h - 1], radius=20, fill=WHITE)
|
||||||
|
|
||||||
|
# ── LEFT COLUMN ───────────────────────────────────────────────────────────
|
||||||
|
lx = CARD_PAD
|
||||||
|
ly = CARD_PAD
|
||||||
|
|
||||||
|
# Cover
|
||||||
|
if cover_img:
|
||||||
|
raw = fit_cover(cover_img, COVER_W, cover_h)
|
||||||
|
else:
|
||||||
|
raw = placeholder_cover(COVER_W, cover_h)
|
||||||
|
card.alpha_composite(rounded_clip(raw, 12), (lx, ly))
|
||||||
|
|
||||||
|
cur_ly = ly + cover_h + 12
|
||||||
|
|
||||||
|
# Episode grid block
|
||||||
|
if ep_block_h and episode_list:
|
||||||
|
_draw_rounded_rect(d, (lx, cur_ly, lx + LEFT_W, cur_ly + ep_block_h), radius=10,
|
||||||
|
fill=WHITE, outline=BORDER, width=1)
|
||||||
|
# Label row
|
||||||
|
label_y = cur_ly + 10
|
||||||
|
d.text((lx + 10, label_y), "放送进度", font=F["ep_label"], fill=TEXT_LIGHT)
|
||||||
|
aired_count = sum(1 for ep in episode_list if ep.get("aired"))
|
||||||
|
prog = f"{aired_count} / {len(episode_list)}"
|
||||||
|
pw = text_width(prog, F["ep_label"])
|
||||||
|
d.text((lx + LEFT_W - 10 - pw, label_y), prog, font=F["ep_label"], fill=PRIMARY)
|
||||||
|
|
||||||
|
grid_y = label_y + text_line_height(F["ep_label"]) + 8
|
||||||
|
for i, ep_item in enumerate(episode_list):
|
||||||
|
col = i % CELLS_PER_ROW
|
||||||
|
row = i // CELLS_PER_ROW
|
||||||
|
cx = lx + 10 + col * (CELL + CELL_GAP)
|
||||||
|
cy = grid_y + row * (CELL + CELL_GAP)
|
||||||
|
fill_c = EP_ORANGE if ep_item.get("aired") else EP_GRAY_BG
|
||||||
|
text_c = WHITE if ep_item.get("aired") else EP_GRAY_TEXT
|
||||||
|
d.rounded_rectangle([cx, cy, cx + CELL - 1, cy + CELL - 1], radius=5, fill=fill_c)
|
||||||
|
ep_num = str(ep_item.get("ep", ""))
|
||||||
|
ew = text_width(ep_num, F["ep_cell"])
|
||||||
|
eh = text_line_height(F["ep_cell"])
|
||||||
|
d.text((cx + (CELL - ew) // 2, cy + (CELL - eh) // 2), ep_num,
|
||||||
|
font=F["ep_cell"], fill=text_c)
|
||||||
|
cur_ly += ep_block_h + 12
|
||||||
|
|
||||||
|
# Rating histogram block
|
||||||
|
if hist_block_h and has_hist:
|
||||||
|
_draw_rounded_rect(d, (lx, cur_ly, lx + LEFT_W, cur_ly + hist_block_h), radius=10,
|
||||||
|
fill=WHITE, outline=BORDER, width=1)
|
||||||
|
ct_txt = "评分分布"
|
||||||
|
ct_w = text_width(ct_txt, F["chart_title"])
|
||||||
|
d.text((lx + (LEFT_W - ct_w) // 2, cur_ly + 12), ct_txt,
|
||||||
|
font=F["chart_title"], fill=TEXT_LIGHT)
|
||||||
|
|
||||||
|
bars_top = cur_ly + 12 + text_line_height(F["chart_title"]) + 4
|
||||||
|
BAR_AREA_W = LEFT_W - 16
|
||||||
|
BAR_GAP = 2
|
||||||
|
bar_w = (BAR_AREA_W - 9 * BAR_GAP) // 10
|
||||||
|
BARS_H = 45
|
||||||
|
values = [_int_rating_count(rating_count, i) for i in range(1, 11)]
|
||||||
|
max_v = max(values) if values else 1
|
||||||
|
if max_v == 0:
|
||||||
|
max_v = 1
|
||||||
|
for i, v in enumerate(values):
|
||||||
|
bar_px = max(2, int(v / max_v * BARS_H))
|
||||||
|
bx = lx + 8 + i * (bar_w + BAR_GAP)
|
||||||
|
by = bars_top + BARS_H - bar_px
|
||||||
|
bar_fill = PRIMARY if i >= 6 else HIST_LOW
|
||||||
|
d.rounded_rectangle([bx, by, bx + bar_w, bars_top + BARS_H], radius=2, fill=bar_fill)
|
||||||
|
|
||||||
|
line_y = bars_top + BARS_H + 2
|
||||||
|
d.line([lx + 8, line_y, lx + LEFT_W - 8, line_y], fill=BORDER)
|
||||||
|
lbl_y = line_y + 2
|
||||||
|
d.text((lx + 8, lbl_y), "1", font=F["chart_title"], fill=(200, 200, 200))
|
||||||
|
mid_x = lx + 8 + 4 * (bar_w + BAR_GAP)
|
||||||
|
d.text((mid_x, lbl_y), "5", font=F["chart_title"], fill=(200, 200, 200))
|
||||||
|
right_x = lx + 8 + 9 * (bar_w + BAR_GAP)
|
||||||
|
d.text((right_x, lbl_y), "10", font=F["chart_title"], fill=(200, 200, 200))
|
||||||
|
|
||||||
|
# ── RIGHT COLUMN ──────────────────────────────────────────────────────────
|
||||||
|
rx = CARD_PAD + LEFT_W + COL_GAP
|
||||||
|
ry = CARD_PAD + 16
|
||||||
|
|
||||||
|
# Title
|
||||||
|
for line in title_lines:
|
||||||
|
d.text((rx, ry), line, font=F["title"], fill=TEXT_MAIN)
|
||||||
|
ry += LH_title
|
||||||
|
|
||||||
|
# Subtitle
|
||||||
|
if subtitle:
|
||||||
|
d.text((rx, ry), subtitle[:60], font=F["subtitle"], fill=TEXT_SUB)
|
||||||
|
ry += subtitle_block_h
|
||||||
|
|
||||||
|
ry += 16 # gap
|
||||||
|
|
||||||
|
# Score row
|
||||||
|
if score is not None:
|
||||||
|
cur_rx = rx
|
||||||
|
# Star + score value
|
||||||
|
d.text((cur_rx, ry + 8), "★", font=F["star"], fill=PRIMARY)
|
||||||
|
cur_rx += text_width("★", F["star"]) + 4
|
||||||
|
score_str = str(score)
|
||||||
|
d.text((cur_rx, ry), score_str, font=F["score"], fill=PRIMARY)
|
||||||
|
cur_rx += text_width(score_str, F["score"]) + 12
|
||||||
|
|
||||||
|
# Rank badge
|
||||||
|
if rank:
|
||||||
|
rank_txt = f"#{rank}"
|
||||||
|
rank_tw = text_width(rank_txt, F["rank_tag"]) + 20
|
||||||
|
_draw_rounded_rect(d, (cur_rx, ry + 8, cur_rx + rank_tw, ry + 34),
|
||||||
|
radius=8, fill=SECONDARY_BG)
|
||||||
|
d.text((cur_rx + 10, ry + 10), rank_txt, font=F["rank_tag"], fill=ORANGE_DEEP)
|
||||||
|
cur_rx += rank_tw + 12
|
||||||
|
|
||||||
|
# Vote count
|
||||||
|
if rating_total:
|
||||||
|
ct = f"{rating_total} 人评分"
|
||||||
|
ct_w = text_width(ct, F["count"]) + 20
|
||||||
|
_draw_rounded_rect(d, (cur_rx, ry + 10, cur_rx + ct_w, ry + 34),
|
||||||
|
radius=16, fill=(245, 245, 245))
|
||||||
|
d.text((cur_rx + 10, ry + 12), ct, font=F["count"], fill=TEXT_SUB)
|
||||||
|
cur_rx += ct_w + 8
|
||||||
|
|
||||||
|
# Watching count
|
||||||
|
if collection_doing:
|
||||||
|
dt = f"{collection_doing} 人在看"
|
||||||
|
dt_w = text_width(dt, F["count"]) + 20
|
||||||
|
_draw_rounded_rect(d, (cur_rx, ry + 10, cur_rx + dt_w, ry + 34),
|
||||||
|
radius=16, fill=(245, 245, 245))
|
||||||
|
d.text((cur_rx + 10, ry + 12), dt, font=F["count"], fill=TEXT_SUB)
|
||||||
|
|
||||||
|
ry += score_row_h + 16
|
||||||
|
else:
|
||||||
|
d.text((rx, ry), "暂无评分", font=F["subtitle"], fill=TEXT_LIGHT)
|
||||||
|
ry += score_row_h + 16
|
||||||
|
|
||||||
|
# Tags
|
||||||
|
if tags:
|
||||||
|
tag_x, tag_y = rx, ry
|
||||||
|
for tag_txt in tags:
|
||||||
|
tw = text_width(tag_txt, F["tag"]) + 24
|
||||||
|
if tag_x + tw > rx + RIGHT_W:
|
||||||
|
tag_x = rx
|
||||||
|
tag_y += tag_row_h + 8
|
||||||
|
_draw_rounded_rect(d, (tag_x, tag_y, tag_x + tw, tag_y + tag_row_h),
|
||||||
|
radius=6, fill=WHITE, outline=(220, 220, 220), width=1)
|
||||||
|
d.text((tag_x + 12, tag_y + 4), tag_txt, font=F["tag"], fill=TEXT_SUB)
|
||||||
|
tag_x += tw + 8
|
||||||
|
ry = tag_y + tag_row_h + 16
|
||||||
|
|
||||||
|
# Summary section (with dashed separator)
|
||||||
|
ry += 20 # space for dashed line
|
||||||
|
dash_y = ry - 12
|
||||||
|
for dx in range(rx, rx + RIGHT_W, 12):
|
||||||
|
d.line([dx, dash_y, min(dx + 6, rx + RIGHT_W), dash_y], fill=(220, 220, 220))
|
||||||
|
d.text((rx, ry), "简介", font=F["sum_label"], fill=(*TEXT_MAIN, 200))
|
||||||
|
ry += text_line_height(F["sum_label"]) + 8
|
||||||
|
|
||||||
|
for line in summary_lines:
|
||||||
|
d.text((rx, ry), line, font=F["summary"], fill=(74, 74, 74))
|
||||||
|
ry += LH_sum
|
||||||
|
|
||||||
|
# Footer (pinned to bottom of card)
|
||||||
|
footer_y = card_h - CARD_PAD - 30
|
||||||
|
d.line([rx, footer_y, rx + RIGHT_W, footer_y], fill=BORDER)
|
||||||
|
fi_x = rx
|
||||||
|
|
||||||
|
if date_str:
|
||||||
|
d.text((fi_x, footer_y + 8), date_str, font=F["footer"], fill=TEXT_LIGHT)
|
||||||
|
fi_x += text_width(date_str, F["footer"]) + 20
|
||||||
|
|
||||||
|
if platform:
|
||||||
|
d.text((fi_x, footer_y + 8), platform, font=F["footer"], fill=TEXT_LIGHT)
|
||||||
|
|
||||||
|
if subject_id is not None:
|
||||||
|
id_txt = f"ID: {subject_id}"
|
||||||
|
id_w = text_width(id_txt, F["footer"])
|
||||||
|
d.text((rx + RIGHT_W - id_w, footer_y + 8), id_txt, font=F["footer"], fill=(200, 200, 200))
|
||||||
|
|
||||||
|
# ── Weekday badge ─────────────────────────────────────────────────────────
|
||||||
|
if air_weekday:
|
||||||
|
BADGE_W, BADGE_H = 56, 48
|
||||||
|
badge = Image.new("RGBA", (BADGE_W, BADGE_H), (0, 0, 0, 0))
|
||||||
|
bd = ImageDraw.Draw(badge)
|
||||||
|
bd.polygon(
|
||||||
|
[(0, 0), (BADGE_W, 0), (BADGE_W, BADGE_H), (0, BADGE_H)],
|
||||||
|
fill=(*EP_ORANGE, 255),
|
||||||
|
)
|
||||||
|
wk_w = text_width(air_weekday, F["weekday"])
|
||||||
|
bd.text(((BADGE_W - wk_w) // 2, 6), air_weekday, font=F["weekday"], fill=WHITE)
|
||||||
|
sub_txt = "曜日"
|
||||||
|
sub_w = text_width(sub_txt, F["weekday_sub"])
|
||||||
|
bd.text(((BADGE_W - sub_w) // 2, 30), sub_txt, font=F["weekday_sub"],
|
||||||
|
fill=(255, 255, 255, 210))
|
||||||
|
# Rounded left-bottom corner only: clip top-right corner of card
|
||||||
|
card.alpha_composite(badge, (CARD_W - BADGE_W, 0))
|
||||||
|
|
||||||
|
# ── Composite with shadow ─────────────────────────────────────────────────
|
||||||
|
shadow = _make_shadow(CARD_W, card_h)
|
||||||
|
result = shadow
|
||||||
|
result.alpha_composite(card, (SHADOW_PAD, SHADOW_PAD))
|
||||||
|
|
||||||
|
return image_to_base64(result)
|
||||||
157
src/render/subject_renderer.py
Normal file
157
src/render/subject_renderer.py
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import asyncio
|
||||||
|
import datetime
|
||||||
|
from collections import Counter
|
||||||
|
from typing import cast
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
from astrbot.api import logger
|
||||||
|
|
||||||
|
from ..services import EpisodeItem, RenderData, SubjectType
|
||||||
|
from .base_renderer import BaseRenderer
|
||||||
|
from .pillow.subject_card import draw_subject_card
|
||||||
|
|
||||||
|
|
||||||
|
def _process_images(data: RenderData) -> None:
|
||||||
|
if "image_url" in data:
|
||||||
|
return
|
||||||
|
images = data.get("images")
|
||||||
|
if not isinstance(images, dict):
|
||||||
|
return
|
||||||
|
images = cast(dict[str, object], images)
|
||||||
|
data["image_url"] = (
|
||||||
|
images.get("large") or images.get("common") or images.get("medium") or ""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _process_dates(data: RenderData) -> None:
|
||||||
|
if "date" in data:
|
||||||
|
return
|
||||||
|
if "air_date" in data:
|
||||||
|
data["date"] = data["air_date"]
|
||||||
|
|
||||||
|
|
||||||
|
def _process_platform(data: RenderData) -> None:
|
||||||
|
if "platform" in data:
|
||||||
|
return
|
||||||
|
if "type" not in data:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
type_id = int(data["type"])
|
||||||
|
data["platform"] = SubjectType(type_id).to_display()
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
data["platform"] = "未知"
|
||||||
|
|
||||||
|
|
||||||
|
def _infer_air_weekday(aired_weekdays: list[int]) -> str:
|
||||||
|
if not aired_weekdays:
|
||||||
|
return ""
|
||||||
|
weekday_names = {1: "月", 2: "火", 3: "水", 4: "木", 5: "金", 6: "土", 7: "日"}
|
||||||
|
recent = aired_weekdays[-4:]
|
||||||
|
most_common = Counter(recent).most_common(1)[0][0]
|
||||||
|
return weekday_names.get(most_common, "")
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_episode_list(
|
||||||
|
episodes: list[EpisodeItem], today: datetime.date
|
||||||
|
) -> tuple[list[dict[str, int | bool | None]], list[int]]:
|
||||||
|
episode_list: list[dict[str, int | bool | None]] = []
|
||||||
|
aired_weekdays: list[int] = []
|
||||||
|
for ep in episodes:
|
||||||
|
if ep.get("type", 0) != 0 or ep.get("ep", 0) == 0:
|
||||||
|
continue
|
||||||
|
aired = False
|
||||||
|
airdate_str = ep.get("airdate")
|
||||||
|
if airdate_str:
|
||||||
|
try:
|
||||||
|
airdate = datetime.datetime.strptime(airdate_str, "%Y-%m-%d").date()
|
||||||
|
aired = airdate <= today
|
||||||
|
if aired:
|
||||||
|
aired_weekdays.append(airdate.isoweekday())
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
if ep.get("comment", 0) > 0:
|
||||||
|
aired = True
|
||||||
|
episode_list.append({"ep": ep.get("ep"), "aired": aired})
|
||||||
|
return episode_list, aired_weekdays
|
||||||
|
|
||||||
|
|
||||||
|
def _process_episodes(data: RenderData) -> None:
|
||||||
|
episodes = data.get("episodes")
|
||||||
|
if not isinstance(episodes, list):
|
||||||
|
return
|
||||||
|
today = datetime.date.today()
|
||||||
|
normalized_episodes: list[EpisodeItem] = []
|
||||||
|
for episode in episodes:
|
||||||
|
if isinstance(episode, dict):
|
||||||
|
normalized_episodes.append(cast(EpisodeItem, episode))
|
||||||
|
episode_list, aired_weekdays = _parse_episode_list(normalized_episodes, today)
|
||||||
|
data["episode_list"] = episode_list
|
||||||
|
air_weekday = _infer_air_weekday(aired_weekdays)
|
||||||
|
if air_weekday:
|
||||||
|
data["air_weekday"] = air_weekday
|
||||||
|
|
||||||
|
|
||||||
|
def preprocess_data(data: RenderData) -> RenderData:
|
||||||
|
processed = data.copy()
|
||||||
|
_process_images(processed)
|
||||||
|
_process_dates(processed)
|
||||||
|
_process_platform(processed)
|
||||||
|
_process_episodes(processed)
|
||||||
|
return processed
|
||||||
|
|
||||||
|
|
||||||
|
class SubjectRenderer(BaseRenderer):
|
||||||
|
async def render_subject_card(
|
||||||
|
self,
|
||||||
|
data: RenderData,
|
||||||
|
rpc_url: str | None = None,
|
||||||
|
headless: bool = True,
|
||||||
|
wait_time: int = 0,
|
||||||
|
max_retries: int = 3,
|
||||||
|
timeout: int = 30000,
|
||||||
|
) -> str | None:
|
||||||
|
render_data = preprocess_data(data)
|
||||||
|
return await self.render(
|
||||||
|
template_path="subject/subject.html",
|
||||||
|
render_data=render_data,
|
||||||
|
selector="#card",
|
||||||
|
local_render_func=lambda: draw_subject_card(render_data, self._session),
|
||||||
|
rpc_url=rpc_url,
|
||||||
|
sub_dir="subject",
|
||||||
|
timeout=timeout,
|
||||||
|
wait_time=wait_time,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def render_batch_subject_cards_to_base64(
|
||||||
|
self,
|
||||||
|
data_list: list[RenderData],
|
||||||
|
rpc_url: str | None = None,
|
||||||
|
headless: bool = True,
|
||||||
|
wait_time: int = 0,
|
||||||
|
max_retries: int = 3,
|
||||||
|
timeout: int = 30000,
|
||||||
|
max_concurrency: int = 3,
|
||||||
|
) -> list[str]:
|
||||||
|
semaphore = asyncio.Semaphore(max_concurrency)
|
||||||
|
|
||||||
|
async def _limited_render(data: RenderData) -> str | None:
|
||||||
|
async with semaphore:
|
||||||
|
return await self.render_subject_card(
|
||||||
|
data=data,
|
||||||
|
rpc_url=rpc_url,
|
||||||
|
headless=headless,
|
||||||
|
wait_time=wait_time,
|
||||||
|
max_retries=max_retries,
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
|
||||||
|
tasks = [_limited_render(data) for data in data_list]
|
||||||
|
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
|
||||||
|
valid_results: list[str] = []
|
||||||
|
for i, res in enumerate(results):
|
||||||
|
if isinstance(res, Exception):
|
||||||
|
logger.warning(f"批量渲染第 {i + 1} 项失败: {res}")
|
||||||
|
elif res:
|
||||||
|
valid_results.append(res)
|
||||||
|
return valid_results
|
||||||
74
src/services/__init__.py
Normal file
74
src/services/__init__.py
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
|
||||||
|
from .calendar import CalendarService
|
||||||
|
from .contracts import (
|
||||||
|
CalendarDay,
|
||||||
|
CalendarWeekday,
|
||||||
|
EpisodeItem,
|
||||||
|
MessageResult,
|
||||||
|
RenderData,
|
||||||
|
)
|
||||||
|
from .exceptions import (
|
||||||
|
BangumiApiError,
|
||||||
|
BangumiRateLimitError,
|
||||||
|
DatabaseError,
|
||||||
|
NoSubjectFound,
|
||||||
|
SubscriptionError,
|
||||||
|
)
|
||||||
|
from .schemas import Episode
|
||||||
|
from .subjects import SubjectsService
|
||||||
|
from .types import ImageSize, SubjectType
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from .search import SearchService
|
||||||
|
from .subscription import SubscriptionService
|
||||||
|
|
||||||
|
|
||||||
|
# 聚合类:继承所有子Service的功能
|
||||||
|
class BangumiService(SubjectsService, CalendarService):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
access_token: str,
|
||||||
|
user_agent: str,
|
||||||
|
proxy: str | None = None,
|
||||||
|
session: aiohttp.ClientSession | None = None,
|
||||||
|
) -> None:
|
||||||
|
# 初始化最基础的父类 (BaseBangumiService)
|
||||||
|
# 因为所有Service都继承自BaseBangumiService,super会自动处理MRO链
|
||||||
|
super().__init__(access_token, user_agent, proxy, session=session)
|
||||||
|
|
||||||
|
|
||||||
|
def __getattr__(name: str) -> Any:
|
||||||
|
if name == "SearchService":
|
||||||
|
from .search import SearchService
|
||||||
|
|
||||||
|
return SearchService
|
||||||
|
if name == "SubscriptionService":
|
||||||
|
from .subscription import SubscriptionService
|
||||||
|
|
||||||
|
return SubscriptionService
|
||||||
|
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"BangumiApiError",
|
||||||
|
"BangumiRateLimitError",
|
||||||
|
"BangumiService",
|
||||||
|
"CalendarDay",
|
||||||
|
"CalendarService",
|
||||||
|
"CalendarWeekday",
|
||||||
|
"DatabaseError",
|
||||||
|
"Episode",
|
||||||
|
"EpisodeItem",
|
||||||
|
"ImageSize",
|
||||||
|
"MessageResult",
|
||||||
|
"NoSubjectFound",
|
||||||
|
"RenderData",
|
||||||
|
"SearchService",
|
||||||
|
"SubjectType",
|
||||||
|
"SubjectsService",
|
||||||
|
"SubscriptionError",
|
||||||
|
"SubscriptionService",
|
||||||
|
]
|
||||||
174
src/services/base.py
Normal file
174
src/services/base.py
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from typing import Literal, cast, overload
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
from astrbot.api import logger
|
||||||
|
|
||||||
|
from ..bangumi_types import JsonArray, JsonObject
|
||||||
|
from .contracts import SearchSubjectsResponse
|
||||||
|
from .exceptions import BangumiApiError, BangumiRateLimitError, NoSubjectFound
|
||||||
|
|
||||||
|
|
||||||
|
class BaseBangumiService:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
access_token: str,
|
||||||
|
user_agent: str,
|
||||||
|
proxy: str | None = None,
|
||||||
|
max_retries: int = 3,
|
||||||
|
session: aiohttp.ClientSession | None = None,
|
||||||
|
) -> None:
|
||||||
|
if not access_token:
|
||||||
|
raise ValueError("Bangumi access_token 未设置")
|
||||||
|
self.base_url = "https://api.bgm.tv"
|
||||||
|
self.headers = {
|
||||||
|
"Authorization": f"Bearer {access_token}",
|
||||||
|
"Accept": "application/json",
|
||||||
|
"User-Agent": user_agent,
|
||||||
|
}
|
||||||
|
self.proxy = proxy
|
||||||
|
self.last_request_time = 0.0
|
||||||
|
self._rate_lock = asyncio.Lock()
|
||||||
|
self._timeout = aiohttp.ClientTimeout(total=30, connect=10)
|
||||||
|
# 兜底 session(惰性创建,避免每次新建 TCP 连接)
|
||||||
|
self._fallback_session: aiohttp.ClientSession | None = None
|
||||||
|
# 这里只放通用的缓存,或者具体业务的缓存放到具体类中
|
||||||
|
self.search_cache: dict[str, SearchSubjectsResponse] = {}
|
||||||
|
self.max_retries = max_retries
|
||||||
|
self._session = session
|
||||||
|
|
||||||
|
@overload
|
||||||
|
async def _request(
|
||||||
|
self,
|
||||||
|
url: str,
|
||||||
|
method: str = "GET",
|
||||||
|
params: JsonObject | None = None,
|
||||||
|
json_data: JsonObject | None = None,
|
||||||
|
is_json: Literal[True] = True,
|
||||||
|
) -> JsonObject | JsonArray: ...
|
||||||
|
|
||||||
|
@overload
|
||||||
|
async def _request(
|
||||||
|
self,
|
||||||
|
url: str,
|
||||||
|
method: str = "GET",
|
||||||
|
params: JsonObject | None = None,
|
||||||
|
json_data: JsonObject | None = None,
|
||||||
|
is_json: Literal[False] = False,
|
||||||
|
) -> bytes: ...
|
||||||
|
|
||||||
|
async def _request(
|
||||||
|
self,
|
||||||
|
url: str,
|
||||||
|
method: str = "GET",
|
||||||
|
params: JsonObject | None = None,
|
||||||
|
json_data: JsonObject | None = None,
|
||||||
|
is_json: bool = True,
|
||||||
|
) -> JsonObject | JsonArray | bytes:
|
||||||
|
"""
|
||||||
|
通用API请求函数, 带限流和重试处理
|
||||||
|
"""
|
||||||
|
last_exception: Exception | None = None
|
||||||
|
|
||||||
|
for attempt in range(self.max_retries):
|
||||||
|
async with self._rate_lock:
|
||||||
|
now = time.time()
|
||||||
|
gap = 1.1 - (now - self.last_request_time)
|
||||||
|
if gap > 0:
|
||||||
|
await asyncio.sleep(gap)
|
||||||
|
self.last_request_time = time.time()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Bangumi API请求 (尝试 {attempt + 1}/{self.max_retries}): {method} {url}"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 优先使用外部注入的 Session
|
||||||
|
session = (
|
||||||
|
self._session
|
||||||
|
if self._session and not self._session.closed
|
||||||
|
else await self._get_fallback_session()
|
||||||
|
)
|
||||||
|
request_context = (
|
||||||
|
session.post(
|
||||||
|
url,
|
||||||
|
json=json_data,
|
||||||
|
params=params,
|
||||||
|
proxy=self.proxy,
|
||||||
|
headers=self.headers,
|
||||||
|
timeout=self._timeout,
|
||||||
|
)
|
||||||
|
if method.upper() == "POST"
|
||||||
|
else session.get(
|
||||||
|
url,
|
||||||
|
params=params,
|
||||||
|
proxy=self.proxy,
|
||||||
|
headers=self.headers,
|
||||||
|
timeout=self._timeout,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
async with request_context as response:
|
||||||
|
if response.status >= 500:
|
||||||
|
last_exception = BangumiApiError(
|
||||||
|
f"服务器错误 ({response.status}),尝试 {attempt + 1}/{self.max_retries}"
|
||||||
|
)
|
||||||
|
logger.warning(f"服务器返回错误状态码: {response.status}")
|
||||||
|
await asyncio.sleep(1.5)
|
||||||
|
continue
|
||||||
|
return await self._handle_response(response, is_json=is_json)
|
||||||
|
|
||||||
|
except aiohttp.ClientError as e:
|
||||||
|
logger.warning(f"网络请求失败: {e}")
|
||||||
|
last_exception = e
|
||||||
|
if attempt < self.max_retries - 1:
|
||||||
|
await asyncio.sleep(1.5)
|
||||||
|
else:
|
||||||
|
logger.error("达到最大重试次数,请求失败")
|
||||||
|
|
||||||
|
raise BangumiApiError(f"请求失败,请稍后再试: {last_exception}")
|
||||||
|
|
||||||
|
async def _get_fallback_session(self) -> aiohttp.ClientSession:
|
||||||
|
"""惰性创建并复用兜底 ClientSession。"""
|
||||||
|
if self._fallback_session is None or self._fallback_session.closed:
|
||||||
|
self._fallback_session = aiohttp.ClientSession(headers=self.headers)
|
||||||
|
return self._fallback_session
|
||||||
|
|
||||||
|
@overload
|
||||||
|
async def _handle_response(
|
||||||
|
self, response: aiohttp.ClientResponse, is_json: Literal[True] = True
|
||||||
|
) -> JsonObject | JsonArray: ...
|
||||||
|
|
||||||
|
@overload
|
||||||
|
async def _handle_response(
|
||||||
|
self, response: aiohttp.ClientResponse, is_json: Literal[False]
|
||||||
|
) -> bytes: ...
|
||||||
|
|
||||||
|
async def _handle_response(
|
||||||
|
self, response: aiohttp.ClientResponse, is_json: bool = True
|
||||||
|
) -> JsonObject | JsonArray | bytes:
|
||||||
|
"""
|
||||||
|
处理api响应
|
||||||
|
|
||||||
|
"""
|
||||||
|
if response.status == 200:
|
||||||
|
if is_json:
|
||||||
|
raw = await response.json()
|
||||||
|
if isinstance(raw, (dict, list)):
|
||||||
|
return cast(JsonObject | JsonArray, raw)
|
||||||
|
raise BangumiApiError("API 返回了非 JSON 对象/数组类型")
|
||||||
|
return await response.read()
|
||||||
|
if response.status == 404:
|
||||||
|
raise NoSubjectFound("未找到相关条目")
|
||||||
|
if response.status == 429:
|
||||||
|
raise BangumiRateLimitError("API请求过于频繁")
|
||||||
|
|
||||||
|
try:
|
||||||
|
error_data = await response.json()
|
||||||
|
error_text = json.dumps(error_data, ensure_ascii=False)
|
||||||
|
except (aiohttp.ContentTypeError, ValueError, TypeError):
|
||||||
|
error_text = await response.text()
|
||||||
|
logger.error(f"API错误: {response.status} - {error_text}")
|
||||||
|
raise BangumiApiError(f"API服务异常 ({response.status})")
|
||||||
65
src/services/calendar.py
Normal file
65
src/services/calendar.py
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import asyncio
|
||||||
|
import copy
|
||||||
|
import time
|
||||||
|
from typing import cast
|
||||||
|
|
||||||
|
from astrbot.api import logger
|
||||||
|
|
||||||
|
from .base import BaseBangumiService
|
||||||
|
from .contracts import CalendarDay
|
||||||
|
from .exceptions import BangumiApiError
|
||||||
|
|
||||||
|
|
||||||
|
class CalendarService(BaseBangumiService):
|
||||||
|
CALENDAR_CACHE_TTL_SECONDS = 12 * 60 * 60
|
||||||
|
|
||||||
|
def __init__(self, *args: object, **kwargs: object) -> None:
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self._calendar_cache: list[CalendarDay] | None = None
|
||||||
|
self._calendar_cache_expire_at: float = 0.0
|
||||||
|
self._calendar_cache_lock = asyncio.Lock()
|
||||||
|
|
||||||
|
def _is_calendar_cache_valid(self, now: float) -> bool:
|
||||||
|
return self._calendar_cache is not None and now < self._calendar_cache_expire_at
|
||||||
|
|
||||||
|
def invalidate_calendar_cache(self) -> None:
|
||||||
|
self._calendar_cache = None
|
||||||
|
self._calendar_cache_expire_at = 0.0
|
||||||
|
|
||||||
|
async def get_calendar(self) -> list[CalendarDay]:
|
||||||
|
now = time.time()
|
||||||
|
if self._is_calendar_cache_valid(now):
|
||||||
|
return copy.deepcopy(self._calendar_cache)
|
||||||
|
|
||||||
|
# 双重检查 + 锁,避免并发下重复请求远端 API
|
||||||
|
async with self._calendar_cache_lock:
|
||||||
|
now = time.time()
|
||||||
|
if self._is_calendar_cache_valid(now):
|
||||||
|
return copy.deepcopy(self._calendar_cache)
|
||||||
|
|
||||||
|
url = f"{self.base_url}/calendar"
|
||||||
|
previous_cache = copy.deepcopy(self._calendar_cache)
|
||||||
|
try:
|
||||||
|
data = await self._request(url, method="GET")
|
||||||
|
except (BangumiApiError, RuntimeError, ValueError, TypeError) as e:
|
||||||
|
logger.error(f"get_calendar 刷新缓存失败: {e}")
|
||||||
|
if previous_cache is not None:
|
||||||
|
return previous_cache
|
||||||
|
return []
|
||||||
|
|
||||||
|
if not isinstance(data, list):
|
||||||
|
logger.warning(f"get_calendar 返回了非 list 类型: {type(data)}")
|
||||||
|
if previous_cache is not None:
|
||||||
|
return previous_cache
|
||||||
|
return []
|
||||||
|
|
||||||
|
normalized: list[CalendarDay] = []
|
||||||
|
for item in data:
|
||||||
|
if isinstance(item, dict):
|
||||||
|
normalized.append(cast(CalendarDay, item))
|
||||||
|
else:
|
||||||
|
logger.warning(f"get_calendar 列表元素类型异常: {type(item)}")
|
||||||
|
|
||||||
|
self._calendar_cache = copy.deepcopy(normalized)
|
||||||
|
self._calendar_cache_expire_at = now + self.CALENDAR_CACHE_TTL_SECONDS
|
||||||
|
return copy.deepcopy(self._calendar_cache)
|
||||||
30
src/services/characters.py
Normal file
30
src/services/characters.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
from typing import cast
|
||||||
|
|
||||||
|
from ..bangumi_types import JsonObject
|
||||||
|
from .base import BaseBangumiService
|
||||||
|
from .contracts import PersonDetailsResponse, PersonsSearchResponse
|
||||||
|
|
||||||
|
|
||||||
|
class CharactersService(BaseBangumiService):
|
||||||
|
def __init__(self, *args: object, **kwargs: object) -> None:
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
async def search_persons(
|
||||||
|
self, keyword: str, limit: int = 10
|
||||||
|
) -> PersonsSearchResponse:
|
||||||
|
"""通过关键词搜索人物"""
|
||||||
|
url = f"{self.base_url}/v0/search/persons"
|
||||||
|
json_data: JsonObject = {"keyword": keyword}
|
||||||
|
params: JsonObject = {"limit": limit}
|
||||||
|
data = await self._request(
|
||||||
|
url, method="POST", json_data=json_data, params=params
|
||||||
|
)
|
||||||
|
if isinstance(data, dict) and isinstance(data.get("data"), list):
|
||||||
|
return cast(PersonsSearchResponse, data)
|
||||||
|
return {"data": []}
|
||||||
|
|
||||||
|
async def get_person_details(self, person_id: int) -> PersonDetailsResponse:
|
||||||
|
"""获取单个人物的详细信息"""
|
||||||
|
url = f"{self.base_url}/v0/persons/{person_id}"
|
||||||
|
data = await self._request(url)
|
||||||
|
return cast(PersonDetailsResponse, data if isinstance(data, dict) else {})
|
||||||
111
src/services/contracts.py
Normal file
111
src/services/contracts.py
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
from typing import TypeAlias, TypedDict
|
||||||
|
|
||||||
|
from ..bangumi_types import JsonValue
|
||||||
|
|
||||||
|
|
||||||
|
class SearchSubjectItem(TypedDict, total=False):
|
||||||
|
id: int | str
|
||||||
|
name: str
|
||||||
|
name_cn: str
|
||||||
|
type: int
|
||||||
|
|
||||||
|
|
||||||
|
class SearchSubjectsResponse(TypedDict):
|
||||||
|
data: list[SearchSubjectItem]
|
||||||
|
|
||||||
|
|
||||||
|
class EpisodeItem(TypedDict, total=False):
|
||||||
|
id: int
|
||||||
|
subject_id: int
|
||||||
|
type: int
|
||||||
|
ep: int
|
||||||
|
sort: int
|
||||||
|
name: str
|
||||||
|
name_cn: str
|
||||||
|
airdate: str
|
||||||
|
comment: int
|
||||||
|
disc: int
|
||||||
|
duration: str
|
||||||
|
duration_seconds: int
|
||||||
|
|
||||||
|
|
||||||
|
class EpisodeListResponse(TypedDict):
|
||||||
|
data: list[EpisodeItem]
|
||||||
|
|
||||||
|
|
||||||
|
class SubjectDetailsResponse(TypedDict, total=False):
|
||||||
|
id: int | str
|
||||||
|
name: str
|
||||||
|
name_cn: str
|
||||||
|
date: str
|
||||||
|
air_date: str
|
||||||
|
eps: int
|
||||||
|
episodes: list[EpisodeItem]
|
||||||
|
platform: str
|
||||||
|
type: int
|
||||||
|
images: dict[str, JsonValue]
|
||||||
|
image_url: str
|
||||||
|
summary: str
|
||||||
|
tags: list[dict[str, JsonValue]]
|
||||||
|
infobox: list[dict[str, JsonValue]]
|
||||||
|
total_episodes: int
|
||||||
|
rating: dict[str, JsonValue]
|
||||||
|
episode_list: list[dict[str, JsonValue]]
|
||||||
|
air_weekday: str
|
||||||
|
|
||||||
|
|
||||||
|
class CalendarWeekday(TypedDict, total=False):
|
||||||
|
id: int
|
||||||
|
cn: str
|
||||||
|
en: str
|
||||||
|
ja: str
|
||||||
|
|
||||||
|
|
||||||
|
class CalendarItem(TypedDict, total=False):
|
||||||
|
id: int | str
|
||||||
|
name: str
|
||||||
|
name_cn: str
|
||||||
|
images: dict[str, JsonValue]
|
||||||
|
|
||||||
|
|
||||||
|
class CalendarDay(TypedDict, total=False):
|
||||||
|
weekday: CalendarWeekday
|
||||||
|
items: list[CalendarItem]
|
||||||
|
is_today: bool
|
||||||
|
|
||||||
|
|
||||||
|
class UserDetailsResponse(TypedDict, total=False):
|
||||||
|
id: int | str
|
||||||
|
username: str
|
||||||
|
nickname: str
|
||||||
|
|
||||||
|
|
||||||
|
class PersonDetailsResponse(TypedDict, total=False):
|
||||||
|
id: int | str
|
||||||
|
name: str
|
||||||
|
summary: str
|
||||||
|
|
||||||
|
|
||||||
|
class PersonsSearchResponse(TypedDict):
|
||||||
|
data: list[PersonDetailsResponse]
|
||||||
|
|
||||||
|
|
||||||
|
class SubscribeMatch(TypedDict):
|
||||||
|
subject_id: str
|
||||||
|
name: str
|
||||||
|
air_date: str
|
||||||
|
total_episodes: int
|
||||||
|
|
||||||
|
|
||||||
|
class SubscribeCandidate(TypedDict):
|
||||||
|
subject_id: str
|
||||||
|
name: str
|
||||||
|
|
||||||
|
|
||||||
|
class UnsubscribeMatch(TypedDict):
|
||||||
|
subject_id: str
|
||||||
|
name: str
|
||||||
|
|
||||||
|
|
||||||
|
RenderData: TypeAlias = dict[str, JsonValue]
|
||||||
|
MessageResult: TypeAlias = object
|
||||||
28
src/services/exceptions.py
Normal file
28
src/services/exceptions.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
class NoSubjectFound(Exception):
|
||||||
|
"""找不到对应条目的异常类"""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class BangumiApiError(Exception):
|
||||||
|
"""Bangumi API请求错误的异常类"""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class BangumiRateLimitError(Exception):
|
||||||
|
"""API限流异常类"""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class DatabaseError(Exception):
|
||||||
|
"""数据库操作异常:替换 repository 层宽泛的 except Exception,提供更精准的错误上下文。"""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class SubscriptionError(Exception):
|
||||||
|
"""订阅业务异常:替换 subscription 服务层宽泛的 except Exception,提供更精准的错误反馈。"""
|
||||||
|
|
||||||
|
pass
|
||||||
32
src/services/persons.py
Normal file
32
src/services/persons.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
from typing import cast
|
||||||
|
|
||||||
|
from ..bangumi_types import JsonObject
|
||||||
|
from .base import BaseBangumiService
|
||||||
|
from .contracts import PersonDetailsResponse, PersonsSearchResponse
|
||||||
|
|
||||||
|
|
||||||
|
class PersonsService(BaseBangumiService):
|
||||||
|
def __init__(self, *args: object, **kwargs: object) -> None:
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
# --- 新增人物相关方法 ---
|
||||||
|
|
||||||
|
async def search_persons(
|
||||||
|
self, keyword: str, limit: int = 10
|
||||||
|
) -> PersonsSearchResponse:
|
||||||
|
"""通过关键词搜索人物"""
|
||||||
|
url = f"{self.base_url}/v0/search/persons"
|
||||||
|
json_data: JsonObject = {"keyword": keyword}
|
||||||
|
params: JsonObject = {"limit": limit}
|
||||||
|
data = await self._request(
|
||||||
|
url, method="POST", json_data=json_data, params=params
|
||||||
|
)
|
||||||
|
if isinstance(data, dict) and isinstance(data.get("data"), list):
|
||||||
|
return cast(PersonsSearchResponse, data)
|
||||||
|
return {"data": []}
|
||||||
|
|
||||||
|
async def get_person_details(self, person_id: int) -> PersonDetailsResponse:
|
||||||
|
"""获取单个人物的详细信息"""
|
||||||
|
url = f"{self.base_url}/v0/persons/{person_id}"
|
||||||
|
data = await self._request(url)
|
||||||
|
return cast(PersonDetailsResponse, data if isinstance(data, dict) else {})
|
||||||
25
src/services/schemas.py
Normal file
25
src/services/schemas.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
|
||||||
|
class Episode(BaseModel):
|
||||||
|
"""
|
||||||
|
Bangumi Episode 数据模型
|
||||||
|
用于校验和解析从 API 返回的剧集信息
|
||||||
|
"""
|
||||||
|
|
||||||
|
airdate: str | None = Field(None, description="播出日期,格式: YYYY-MM-DD")
|
||||||
|
name: str = Field(..., description="剧集日文名称")
|
||||||
|
name_cn: str = Field(..., description="剧集中文名称")
|
||||||
|
duration: str | None = Field(None, description="时长,格式: HH:MM:SS")
|
||||||
|
desc: str = Field(default="", description="剧集简介")
|
||||||
|
ep: int = Field(..., description="集数", ge=0)
|
||||||
|
sort: int = Field(..., description="排序号", ge=0)
|
||||||
|
id: int = Field(..., description="剧集ID")
|
||||||
|
subject_id: int = Field(..., description="条目ID")
|
||||||
|
comment: int = Field(default=0, description="评论数", ge=0)
|
||||||
|
type: int = Field(..., description="剧集类型")
|
||||||
|
disc: int = Field(default=0, description="碟片号", ge=0)
|
||||||
|
duration_seconds: int | None = Field(None, description="时长(秒)", ge=0)
|
||||||
|
|
||||||
|
# 允许额外字段,API 可能返回更多数据
|
||||||
|
model_config = ConfigDict(extra="allow")
|
||||||
141
src/services/search.py
Normal file
141
src/services/search.py
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
from collections.abc import AsyncGenerator
|
||||||
|
from typing import TYPE_CHECKING, cast
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
import astrbot.api.message_components as Comp
|
||||||
|
from astrbot.api import logger
|
||||||
|
from astrbot.api.event import AstrMessageEvent
|
||||||
|
|
||||||
|
from ..config import ConfigManager
|
||||||
|
from ..render import CalendarRenderer, SubjectRenderer
|
||||||
|
from .contracts import (
|
||||||
|
MessageResult,
|
||||||
|
RenderData,
|
||||||
|
SearchSubjectItem,
|
||||||
|
SubjectDetailsResponse,
|
||||||
|
)
|
||||||
|
from .exceptions import BangumiApiError
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from . import BangumiService
|
||||||
|
|
||||||
|
|
||||||
|
class SearchService:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
service: "BangumiService",
|
||||||
|
config_manager: ConfigManager,
|
||||||
|
session: aiohttp.ClientSession | None = None,
|
||||||
|
) -> None:
|
||||||
|
self.service = service
|
||||||
|
self.config_manager = config_manager
|
||||||
|
self.subject_renderer = SubjectRenderer(session=session)
|
||||||
|
self.calendar_renderer = CalendarRenderer(session=session)
|
||||||
|
|
||||||
|
async def handle_subject_search(
|
||||||
|
self,
|
||||||
|
event: AstrMessageEvent,
|
||||||
|
query: str,
|
||||||
|
top_k: int = 1,
|
||||||
|
subject_type: list[int] | None = None,
|
||||||
|
subject_tags: list[str] | None = None,
|
||||||
|
) -> AsyncGenerator[MessageResult, None]:
|
||||||
|
"""
|
||||||
|
处理条目搜索的核心流程:搜索 -> 渲染 (Base64) -> 发送。
|
||||||
|
"""
|
||||||
|
if not query:
|
||||||
|
yield event.plain_result("❌ 请提供搜索关键词")
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info(f"搜索请求: {query}, type={subject_type}, top_k={top_k}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 1. 搜索条目
|
||||||
|
search_res = await self.service.search_subjects(
|
||||||
|
keyword=query, subject_type=subject_type, subject_tags=subject_tags
|
||||||
|
)
|
||||||
|
if not search_res or "data" not in search_res or not search_res["data"]:
|
||||||
|
yield event.plain_result("🔍 未找到相关条目")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 2. 渲染并获取 Base64 组件
|
||||||
|
image_components = await self._prepare_subject_images_base64(
|
||||||
|
search_res["data"], top_k
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. 发送结果
|
||||||
|
if image_components:
|
||||||
|
yield event.chain_result(image_components)
|
||||||
|
else:
|
||||||
|
yield event.plain_result("❌ 未能生成渲染图片")
|
||||||
|
|
||||||
|
except (BangumiApiError, RuntimeError, ValueError) as e:
|
||||||
|
logger.error(f"SearchService.handle_subject_search 失败: {e}")
|
||||||
|
yield event.plain_result(f"❌ 处理失败: {e}")
|
||||||
|
|
||||||
|
async def handle_calendar(
|
||||||
|
self, event: AstrMessageEvent
|
||||||
|
) -> AsyncGenerator[MessageResult, None]:
|
||||||
|
"""
|
||||||
|
处理每日放送逻辑。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
calendar_res = await self.service.get_calendar()
|
||||||
|
if not calendar_res:
|
||||||
|
yield event.plain_result("❌ 未获取到放送数据")
|
||||||
|
return
|
||||||
|
|
||||||
|
base64_image = await self.calendar_renderer.render_calendar(
|
||||||
|
calendar_res,
|
||||||
|
rpc_url=self.config_manager.get_render_server_url(),
|
||||||
|
max_retries=self.config_manager.get_max_retries(),
|
||||||
|
)
|
||||||
|
|
||||||
|
if base64_image:
|
||||||
|
yield event.chain_result([Comp.Image.fromBase64(base64_image)])
|
||||||
|
else:
|
||||||
|
yield event.plain_result("❌ 图片生成失败")
|
||||||
|
except (BangumiApiError, RuntimeError, ValueError) as e:
|
||||||
|
logger.error(f"SearchService.handle_calendar 失败: {e}")
|
||||||
|
yield event.plain_result(f"❌ 处理失败: {e}")
|
||||||
|
|
||||||
|
async def _prepare_subject_images_base64(
|
||||||
|
self, subjects: list[SearchSubjectItem], top_k: int
|
||||||
|
) -> list[Comp.Image]:
|
||||||
|
"""
|
||||||
|
内部逻辑:准备渲染数据并生成 Base64 图片组件。
|
||||||
|
"""
|
||||||
|
data_list: list[SubjectDetailsResponse] = []
|
||||||
|
|
||||||
|
for item in subjects[:top_k]:
|
||||||
|
subject_id = item.get("id")
|
||||||
|
if not subject_id:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 获取详情
|
||||||
|
subject_data = await self.service.get_subject_details(str(subject_id))
|
||||||
|
if not subject_data:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 补充剧集进度信息
|
||||||
|
try:
|
||||||
|
episodes_data = await self.service.get_subject_episodes(int(subject_id))
|
||||||
|
if episodes_data and "data" in episodes_data:
|
||||||
|
subject_data["episodes"] = episodes_data["data"]
|
||||||
|
except (BangumiApiError, ValueError, TypeError) as e:
|
||||||
|
logger.warning(f"获取剧集信息失败 (subject_id={subject_id}): {e}")
|
||||||
|
|
||||||
|
data_list.append(subject_data)
|
||||||
|
|
||||||
|
if not data_list:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# 批量渲染为 Base64
|
||||||
|
base64_list = await self.subject_renderer.render_batch_subject_cards_to_base64(
|
||||||
|
data_list=cast(list[RenderData], data_list),
|
||||||
|
rpc_url=self.config_manager.get_render_server_url(),
|
||||||
|
max_retries=self.config_manager.get_max_retries(),
|
||||||
|
)
|
||||||
|
|
||||||
|
# 包装成消息组件
|
||||||
|
return [Comp.Image.fromBase64(b64) for b64 in base64_list]
|
||||||
171
src/services/subjects.py
Normal file
171
src/services/subjects.py
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
import base64
|
||||||
|
import datetime
|
||||||
|
from typing import cast
|
||||||
|
|
||||||
|
from astrbot.api import logger
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
from ..bangumi_types import JsonObject
|
||||||
|
from .base import BaseBangumiService
|
||||||
|
from .contracts import (
|
||||||
|
EpisodeItem,
|
||||||
|
EpisodeListResponse,
|
||||||
|
SearchSubjectItem,
|
||||||
|
SearchSubjectsResponse,
|
||||||
|
SubjectDetailsResponse,
|
||||||
|
)
|
||||||
|
from .schemas import Episode
|
||||||
|
from .types import ImageSize
|
||||||
|
|
||||||
|
|
||||||
|
class SubjectsService(BaseBangumiService):
|
||||||
|
def __init__(self, *args: object, **kwargs: object) -> None:
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
async def search_subjects(
|
||||||
|
self,
|
||||||
|
keyword: str,
|
||||||
|
limit: int = 5,
|
||||||
|
offset: int = 0,
|
||||||
|
subject_type: list[int] | None = None,
|
||||||
|
subject_tags: list[str] | None = None,
|
||||||
|
) -> SearchSubjectsResponse:
|
||||||
|
cache_key = f"search:{keyword}:{limit}"
|
||||||
|
if cache_key in self.search_cache:
|
||||||
|
return self.search_cache[cache_key]
|
||||||
|
|
||||||
|
url = f"{self.base_url}/v0/search/subjects"
|
||||||
|
filters: dict[str, object] = {}
|
||||||
|
json_data: JsonObject = {
|
||||||
|
"keyword": keyword,
|
||||||
|
"limit": limit,
|
||||||
|
"offset": offset,
|
||||||
|
"filter": filters,
|
||||||
|
}
|
||||||
|
if subject_type is not None:
|
||||||
|
filters["type"] = subject_type
|
||||||
|
if subject_tags is not None:
|
||||||
|
filters["tag"] = subject_tags
|
||||||
|
data = await self._request(
|
||||||
|
url,
|
||||||
|
method="POST",
|
||||||
|
json_data=json_data,
|
||||||
|
)
|
||||||
|
if isinstance(data, dict):
|
||||||
|
raw_items = data.get("data")
|
||||||
|
if isinstance(raw_items, list):
|
||||||
|
normalized: SearchSubjectsResponse = {"data": []}
|
||||||
|
for item in raw_items:
|
||||||
|
if isinstance(item, dict):
|
||||||
|
normalized["data"].append(cast(SearchSubjectItem, item))
|
||||||
|
self.search_cache[cache_key] = cast(SearchSubjectsResponse, normalized)
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
fallback: SearchSubjectsResponse = {"data": []}
|
||||||
|
self.search_cache[cache_key] = fallback
|
||||||
|
return fallback
|
||||||
|
|
||||||
|
async def get_subject_details(self, subject_id: str) -> SubjectDetailsResponse:
|
||||||
|
"""
|
||||||
|
获取条目的信息
|
||||||
|
"""
|
||||||
|
url = f"{self.base_url}/v0/subjects/{subject_id}"
|
||||||
|
data = await self._request(url)
|
||||||
|
return cast(SubjectDetailsResponse, data if isinstance(data, dict) else {})
|
||||||
|
|
||||||
|
async def get_subject_image(self, subject_id: str, size: ImageSize) -> bytes:
|
||||||
|
"""
|
||||||
|
获取条目的图片原始二进制数据
|
||||||
|
"""
|
||||||
|
url = f"{self.base_url}/v0/subjects/{subject_id}/image"
|
||||||
|
params: JsonObject = {"type": size.value}
|
||||||
|
return await self._request(url, params=params, is_json=False)
|
||||||
|
|
||||||
|
async def get_subject_base64image(
|
||||||
|
self, subject_id: str, size: ImageSize
|
||||||
|
) -> str | None:
|
||||||
|
"""
|
||||||
|
获取条目的图片并转换为 Base64 编码的字符串
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
image_bytes = await self.get_subject_image(subject_id, size)
|
||||||
|
if image_bytes:
|
||||||
|
return base64.b64encode(image_bytes).decode("utf-8")
|
||||||
|
except (ValueError, TypeError, RuntimeError) as e:
|
||||||
|
logger.error(f"获取条目 {subject_id} 的 Base64 图片失败: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def get_subject_episodes(self, subject_id: int) -> EpisodeListResponse:
|
||||||
|
"""
|
||||||
|
获取条目的剧集信息
|
||||||
|
|
||||||
|
Args:
|
||||||
|
subject_id: 条目的id
|
||||||
|
Returns:
|
||||||
|
data: 剧集信息
|
||||||
|
total: 总集数
|
||||||
|
"""
|
||||||
|
url = f"{self.base_url}/v0/episodes"
|
||||||
|
params: JsonObject = {"subject_id": subject_id}
|
||||||
|
data = await self._request(url, params=params)
|
||||||
|
if isinstance(data, dict):
|
||||||
|
raw_items = data.get("data")
|
||||||
|
if isinstance(raw_items, list):
|
||||||
|
normalized: EpisodeListResponse = {"data": []}
|
||||||
|
for item in raw_items:
|
||||||
|
if isinstance(item, dict):
|
||||||
|
normalized["data"].append(cast(EpisodeItem, item))
|
||||||
|
return normalized
|
||||||
|
return {"data": []}
|
||||||
|
|
||||||
|
async def get_latest_episode(self, subject_id: int) -> Episode | None:
|
||||||
|
"""
|
||||||
|
从 episodes 数据中提取最新一集的信息。
|
||||||
|
最新一集的定义:已播出且有互动(评论)的普通剧集。
|
||||||
|
"""
|
||||||
|
episodes_data = await self.get_subject_episodes(subject_id)
|
||||||
|
raw_list = episodes_data.get("data", [])
|
||||||
|
if not raw_list:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 解析并校验数据
|
||||||
|
episodes = self._parse_episodes(raw_list)
|
||||||
|
|
||||||
|
# 获取今天的日期用于比较
|
||||||
|
today = datetime.date.today()
|
||||||
|
|
||||||
|
# 逆序查找:从最后一集向前找第一个符合条件的
|
||||||
|
for episode in reversed(episodes):
|
||||||
|
if episode.ep == 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 检查播出状态
|
||||||
|
is_aired = True
|
||||||
|
if episode.airdate:
|
||||||
|
try:
|
||||||
|
episode_date = datetime.datetime.strptime(
|
||||||
|
episode.airdate, "%Y-%m-%d"
|
||||||
|
).date()
|
||||||
|
is_aired = episode_date <= today
|
||||||
|
except ValueError:
|
||||||
|
# 日期格式异常时,不因为日期判定为未播出
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 核心业务逻辑:已播出且有评论互动
|
||||||
|
if is_aired and episode.comment > 0:
|
||||||
|
return episode
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_episodes(raw_data: list[EpisodeItem]) -> list[Episode]:
|
||||||
|
"""
|
||||||
|
辅助函数:将原始字典列表解析为 Episode 模型列表,自动过滤校验失败的数据。
|
||||||
|
"""
|
||||||
|
parsed_episodes: list[Episode] = []
|
||||||
|
for item in raw_data:
|
||||||
|
try:
|
||||||
|
parsed_episodes.append(Episode(**item))
|
||||||
|
except ValidationError as e:
|
||||||
|
logger.warning(f"解析剧集数据失败,已跳过: {e}, 原始数据: {item}")
|
||||||
|
return parsed_episodes
|
||||||
330
src/services/subscription.py
Normal file
330
src/services/subscription.py
Normal file
@@ -0,0 +1,330 @@
|
|||||||
|
from typing import TYPE_CHECKING, cast
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
from astrbot.api import logger
|
||||||
|
from astrbot.api.star import StarTools
|
||||||
|
from astrbot.core.message.message_event_result import MessageChain
|
||||||
|
|
||||||
|
from ..config import ConfigManager
|
||||||
|
from ..db import BangumiRepository
|
||||||
|
from ..render import EpisodeRenderer
|
||||||
|
from .contracts import SubscribeCandidate, SubscribeMatch, UnsubscribeMatch
|
||||||
|
from .exceptions import BangumiApiError, DatabaseError, SubscriptionError
|
||||||
|
from .schemas import Episode
|
||||||
|
from .types import ImageSize
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from . import BangumiService
|
||||||
|
|
||||||
|
|
||||||
|
class SubscriptionService:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
repository: BangumiRepository,
|
||||||
|
service: "BangumiService",
|
||||||
|
config_manager: ConfigManager,
|
||||||
|
session: aiohttp.ClientSession | None = None,
|
||||||
|
) -> None:
|
||||||
|
self.storage = repository
|
||||||
|
self.service = service
|
||||||
|
self.config_manager = config_manager
|
||||||
|
self.renderer = EpisodeRenderer(session=session)
|
||||||
|
|
||||||
|
async def get_subscribe_candidates(
|
||||||
|
self, keyword: str, limit: int
|
||||||
|
) -> tuple[str | None, list[SubscribeCandidate]]:
|
||||||
|
"""
|
||||||
|
查询订阅候选,命中多条时由上层进行二次确认。
|
||||||
|
"""
|
||||||
|
normalized_keyword = keyword.strip()
|
||||||
|
if not normalized_keyword:
|
||||||
|
return "❌ 请提供要订阅的番剧关键词或ID。", []
|
||||||
|
|
||||||
|
effective_limit = max(1, min(limit, 10))
|
||||||
|
search_res = await self.service.search_subjects(
|
||||||
|
keyword=normalized_keyword,
|
||||||
|
limit=effective_limit,
|
||||||
|
subject_type=[2],
|
||||||
|
subject_tags=None,
|
||||||
|
)
|
||||||
|
raw_items = search_res.get("data", [])
|
||||||
|
if not raw_items:
|
||||||
|
return "🔍 未找到相关番剧", []
|
||||||
|
|
||||||
|
candidates: list[SubscribeCandidate] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for item in raw_items:
|
||||||
|
subject_id_raw = item.get("id")
|
||||||
|
if subject_id_raw is None:
|
||||||
|
continue
|
||||||
|
subject_id = str(subject_id_raw)
|
||||||
|
if subject_id in seen:
|
||||||
|
continue
|
||||||
|
seen.add(subject_id)
|
||||||
|
raw_name = item.get("name_cn") or item.get("name") or f"ID:{subject_id}"
|
||||||
|
candidates.append({"subject_id": subject_id, "name": str(raw_name)})
|
||||||
|
|
||||||
|
if not candidates:
|
||||||
|
return "🔍 未找到相关番剧", []
|
||||||
|
return None, candidates
|
||||||
|
|
||||||
|
async def _build_subscribable_subject(
|
||||||
|
self, subject_id: str
|
||||||
|
) -> tuple[str | None, SubscribeMatch | None]:
|
||||||
|
"""
|
||||||
|
根据 subject_id 构建可订阅条目(详情 + 放送表校验)。
|
||||||
|
"""
|
||||||
|
details = await self.service.get_subject_details(subject_id)
|
||||||
|
if not details:
|
||||||
|
return "❌ 获取番剧详情失败", None
|
||||||
|
|
||||||
|
raw_name = details.get("name_cn") or details.get("name")
|
||||||
|
name = str(raw_name) if raw_name else "未知番剧"
|
||||||
|
|
||||||
|
calendar_res = await self.service.get_calendar()
|
||||||
|
is_in_calendar = False
|
||||||
|
if calendar_res:
|
||||||
|
for day_item in calendar_res:
|
||||||
|
for item in day_item.get("items", []):
|
||||||
|
if str(item.get("id")) == subject_id:
|
||||||
|
is_in_calendar = True
|
||||||
|
break
|
||||||
|
if is_in_calendar:
|
||||||
|
break
|
||||||
|
|
||||||
|
if not is_in_calendar:
|
||||||
|
return (
|
||||||
|
f"⚠️ {name} 不在当前的每日放送列表中 (可能已完结或未开播),暂不支持自动追踪。",
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
total_episodes_raw = details.get("eps", 0)
|
||||||
|
total_episodes = (
|
||||||
|
int(total_episodes_raw) if isinstance(total_episodes_raw, (int, str)) else 0
|
||||||
|
)
|
||||||
|
air_date = str(details.get("date", ""))
|
||||||
|
result_data: SubscribeMatch = {
|
||||||
|
"subject_id": subject_id,
|
||||||
|
"name": name,
|
||||||
|
"air_date": air_date,
|
||||||
|
"total_episodes": total_episodes,
|
||||||
|
}
|
||||||
|
return None, cast(SubscribeMatch, result_data)
|
||||||
|
|
||||||
|
async def _match_subscribable_subject(
|
||||||
|
self, keyword: str
|
||||||
|
) -> tuple[str | None, SubscribeMatch | None]:
|
||||||
|
"""
|
||||||
|
查找可订阅的番剧逻辑(从 API 层迁移至此)。
|
||||||
|
"""
|
||||||
|
error_msg, candidates = await self.get_subscribe_candidates(
|
||||||
|
keyword=keyword, limit=1
|
||||||
|
)
|
||||||
|
if error_msg:
|
||||||
|
return error_msg, None
|
||||||
|
if not candidates:
|
||||||
|
return "🔍 未找到相关番剧", None
|
||||||
|
return await self._build_subscribable_subject(candidates[0]["subject_id"])
|
||||||
|
|
||||||
|
async def subscribe_by_subject_id(self, group_id: str, subject_id: str) -> str:
|
||||||
|
"""
|
||||||
|
基于明确 subject_id 完成订阅。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
error_msg, subject_info = await self._build_subscribable_subject(subject_id)
|
||||||
|
if error_msg:
|
||||||
|
return error_msg
|
||||||
|
if not subject_info:
|
||||||
|
return "❌ 未知错误:未能获取番剧信息"
|
||||||
|
|
||||||
|
success = self.storage.subscribe_subject(
|
||||||
|
group_id=group_id,
|
||||||
|
subject_id=subject_info["subject_id"],
|
||||||
|
name=subject_info["name"],
|
||||||
|
air_date=subject_info["air_date"],
|
||||||
|
total_episodes=subject_info["total_episodes"],
|
||||||
|
)
|
||||||
|
if success:
|
||||||
|
return (
|
||||||
|
f"✅ 成功订阅《{subject_info['name']}》!\n如有更新将推送到本群。"
|
||||||
|
)
|
||||||
|
return "❌ 订阅失败,数据库错误。"
|
||||||
|
except (BangumiApiError, DatabaseError, SubscriptionError) as e:
|
||||||
|
logger.error(f"SubscriptionService.subscribe_by_subject_id 失败: {e}")
|
||||||
|
return f"❌ 处理失败: {e}"
|
||||||
|
|
||||||
|
async def subscribe(self, group_id: str, query: str) -> str:
|
||||||
|
"""
|
||||||
|
处理订阅逻辑:匹配条目 -> 存入数据库 -> 建立订阅关系。
|
||||||
|
"""
|
||||||
|
logger.info(f"处理追番请求: {query}, group_id={group_id}")
|
||||||
|
try:
|
||||||
|
# 1. 匹配条目 (调用内部迁移后的逻辑)
|
||||||
|
error_msg, subject_info = await self._match_subscribable_subject(query)
|
||||||
|
if error_msg:
|
||||||
|
return error_msg
|
||||||
|
if not subject_info:
|
||||||
|
return "❌ 未知错误:未能获取番剧信息"
|
||||||
|
|
||||||
|
subject_id = subject_info["subject_id"]
|
||||||
|
name = subject_info["name"]
|
||||||
|
|
||||||
|
# 2 & 3. 原子性地写入条目信息并建立订阅关系
|
||||||
|
success = self.storage.subscribe_subject(
|
||||||
|
group_id=group_id,
|
||||||
|
subject_id=subject_id,
|
||||||
|
name=name,
|
||||||
|
air_date=subject_info["air_date"],
|
||||||
|
total_episodes=subject_info["total_episodes"],
|
||||||
|
)
|
||||||
|
if success:
|
||||||
|
return f"✅ 成功订阅《{name}》!\n如有更新将推送到本群。"
|
||||||
|
else:
|
||||||
|
return "❌ 订阅失败,数据库错误。"
|
||||||
|
except (BangumiApiError, DatabaseError, SubscriptionError) as e:
|
||||||
|
logger.error(f"SubscriptionService.subscribe 失败: {e}")
|
||||||
|
return f"❌ 处理失败: {e}"
|
||||||
|
|
||||||
|
async def unsubscribe(self, group_id: str, query: str) -> str:
|
||||||
|
"""
|
||||||
|
取消订阅逻辑。
|
||||||
|
"""
|
||||||
|
logger.info(f"处理取消追番请求: {query}, group_id={group_id}")
|
||||||
|
try:
|
||||||
|
error_msg, subject_info = self._match_local_subscription(group_id, query)
|
||||||
|
if error_msg:
|
||||||
|
return error_msg
|
||||||
|
if not subject_info:
|
||||||
|
return "❌ 未知错误:未能获取番剧信息"
|
||||||
|
|
||||||
|
subject_id = subject_info["subject_id"]
|
||||||
|
name = subject_info["name"]
|
||||||
|
|
||||||
|
success = self.storage.remove_subscription(group_id, subject_id)
|
||||||
|
if success:
|
||||||
|
return f"✅ 已成功取消订阅《{name}》。"
|
||||||
|
else:
|
||||||
|
return f"❌ 取消订阅失败:你可能并没有订阅《{name}》。"
|
||||||
|
except (BangumiApiError, DatabaseError, SubscriptionError) as e:
|
||||||
|
logger.error(f"SubscriptionService.unsubscribe 失败: {e}")
|
||||||
|
return f"❌ 处理失败: {e}"
|
||||||
|
|
||||||
|
def _match_local_subscription(
|
||||||
|
self, group_id: str, query: str
|
||||||
|
) -> tuple[str | None, UnsubscribeMatch | None]:
|
||||||
|
"""
|
||||||
|
在当前群组的本地订阅中做模糊匹配。
|
||||||
|
"""
|
||||||
|
normalized_query = str(query).strip()
|
||||||
|
if not normalized_query:
|
||||||
|
return "❌ 请提供要取消订阅的番剧关键词或ID。", None
|
||||||
|
|
||||||
|
# 取 6 条用于判断是否超过默认展示上限(5 条)
|
||||||
|
candidates = self.storage.find_group_subscription_candidates(
|
||||||
|
group_id=group_id, keyword=normalized_query, limit=6
|
||||||
|
)
|
||||||
|
if not candidates:
|
||||||
|
return f"❌ 未找到与「{normalized_query}」匹配的本群订阅番剧。", None
|
||||||
|
|
||||||
|
if len(candidates) == 1:
|
||||||
|
subject = candidates[0]
|
||||||
|
return None, {
|
||||||
|
"subject_id": str(subject.subject_id),
|
||||||
|
"name": str(subject.name),
|
||||||
|
}
|
||||||
|
|
||||||
|
display_limit = 5
|
||||||
|
display_candidates = candidates[:display_limit]
|
||||||
|
lines = [
|
||||||
|
"⚠️ 匹配到多个已订阅番剧,请提供更精确名称或直接使用 ID:",
|
||||||
|
]
|
||||||
|
for idx, subject in enumerate(display_candidates, start=1):
|
||||||
|
lines.append(f"{idx}. {subject.name} (ID: {subject.subject_id})")
|
||||||
|
if len(candidates) > display_limit:
|
||||||
|
lines.append("(仅显示前 5 项)")
|
||||||
|
return "\n".join(lines), None
|
||||||
|
|
||||||
|
async def check_updates(self) -> None:
|
||||||
|
"""
|
||||||
|
定时任务核心逻辑:检查所有监控中的番剧是否有更新。
|
||||||
|
"""
|
||||||
|
subjects = self.storage.get_monitored_subjects()
|
||||||
|
logger.info(f"开始更新 {len(subjects)} 个番剧的集数信息")
|
||||||
|
|
||||||
|
for subject in subjects:
|
||||||
|
try:
|
||||||
|
# 获取最新集数
|
||||||
|
latest_episode = await self.service.get_latest_episode(
|
||||||
|
int(subject.subject_id)
|
||||||
|
)
|
||||||
|
if not latest_episode:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 尝试获取封面图用于渲染
|
||||||
|
try:
|
||||||
|
image_base64 = await self.service.get_subject_base64image(
|
||||||
|
subject.subject_id, size=ImageSize.LARGE
|
||||||
|
)
|
||||||
|
if image_base64:
|
||||||
|
latest_episode.image_url = (
|
||||||
|
f"data:image/png;base64,{image_base64}"
|
||||||
|
)
|
||||||
|
except BangumiApiError as e:
|
||||||
|
logger.error(f"获取条目 {subject.name} 图片失败: {e}")
|
||||||
|
|
||||||
|
# 比对更新
|
||||||
|
if latest_episode.ep > subject.current_episode:
|
||||||
|
logger.info(
|
||||||
|
f"番剧《{subject.name}》有更新: {subject.current_episode} -> {latest_episode.ep}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 更新数据库
|
||||||
|
# 显式转换为 str 以解决 Pylance 对 SQLAlchemy Column 对象的类型报错
|
||||||
|
self.storage.update_subject_episode(
|
||||||
|
str(subject.subject_id), latest_episode.ep
|
||||||
|
)
|
||||||
|
|
||||||
|
# 发送通知
|
||||||
|
await self._notify_subscribers(
|
||||||
|
latest_episode, str(subject.subject_id), str(subject.name)
|
||||||
|
)
|
||||||
|
|
||||||
|
except (BangumiApiError, DatabaseError) as e:
|
||||||
|
logger.error(f"更新番剧《{subject.name}》失败: {e}")
|
||||||
|
|
||||||
|
async def _notify_subscribers(
|
||||||
|
self, episode: Episode, subject_id: str, subject_name: str
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
渲染并发送更新通知。
|
||||||
|
"""
|
||||||
|
subscribed_groups = self.storage.get_subject_subscribers(subject_id)
|
||||||
|
if not subscribed_groups:
|
||||||
|
return
|
||||||
|
|
||||||
|
# 渲染图片
|
||||||
|
base64_image = await self.renderer.render_episode(
|
||||||
|
episode,
|
||||||
|
rpc_url=self.config_manager.get_render_server_url(),
|
||||||
|
max_retries=self.config_manager.get_max_retries(),
|
||||||
|
)
|
||||||
|
|
||||||
|
chain = MessageChain()
|
||||||
|
if base64_image:
|
||||||
|
chain = chain.base64_image(base64_image)
|
||||||
|
else:
|
||||||
|
# 如果图片渲染失败,发送纯文本通知作为兜底
|
||||||
|
chain = chain.message(
|
||||||
|
f"🔔 番剧《{subject_name}》更新啦!\n第 {episode.ep} 集:{episode.name_cn or episode.name}"
|
||||||
|
)
|
||||||
|
|
||||||
|
for group_id in subscribed_groups:
|
||||||
|
try:
|
||||||
|
await StarTools.send_message_by_id(
|
||||||
|
type="GroupMessage", id=group_id, message_chain=chain
|
||||||
|
)
|
||||||
|
logger.info(f"向群组 {group_id} 发送《{subject_name}》更新通知成功。")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"向群组 {group_id} 发送《{subject_name}》更新通知失败: {e}"
|
||||||
|
)
|
||||||
57
src/services/types.py
Normal file
57
src/services/types.py
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
from enum import Enum, IntEnum, StrEnum
|
||||||
|
|
||||||
|
|
||||||
|
class SubjectType(IntEnum):
|
||||||
|
"""Bangumi 条目类型"""
|
||||||
|
|
||||||
|
BOOK = 1
|
||||||
|
ANIME = 2
|
||||||
|
MUSIC = 3
|
||||||
|
GAME = 4
|
||||||
|
REAL = 6
|
||||||
|
|
||||||
|
def to_display(self) -> str:
|
||||||
|
"""获取带 Emoji 的显示名称"""
|
||||||
|
_map = {
|
||||||
|
SubjectType.BOOK: "📚 书籍",
|
||||||
|
SubjectType.ANIME: "🎬 动画",
|
||||||
|
SubjectType.MUSIC: "🎵 音乐",
|
||||||
|
SubjectType.GAME: "🎮 游戏",
|
||||||
|
SubjectType.REAL: "🌐 三次元",
|
||||||
|
}
|
||||||
|
return _map.get(self, "未知")
|
||||||
|
|
||||||
|
|
||||||
|
class PersonType(IntEnum):
|
||||||
|
"""Bangumi 人物类型"""
|
||||||
|
|
||||||
|
INDIVIDUAL = 1
|
||||||
|
COMPANY = 2
|
||||||
|
GROUP = 3
|
||||||
|
|
||||||
|
def to_display(self) -> str:
|
||||||
|
"""获取带 Emoji 的显示名称"""
|
||||||
|
_map = {
|
||||||
|
PersonType.INDIVIDUAL: "👤 个人",
|
||||||
|
PersonType.COMPANY: "🏢 公司",
|
||||||
|
PersonType.GROUP: "👥 组合",
|
||||||
|
}
|
||||||
|
return _map.get(self, "未知")
|
||||||
|
|
||||||
|
|
||||||
|
class ImageSize(Enum):
|
||||||
|
"""图片尺寸规格"""
|
||||||
|
|
||||||
|
SMALL = "small"
|
||||||
|
GRID = "grid"
|
||||||
|
LARGE = "large"
|
||||||
|
MEDIUM = "medium"
|
||||||
|
COMMON = "common"
|
||||||
|
|
||||||
|
|
||||||
|
class CommonTag(StrEnum):
|
||||||
|
"""常用标签常量"""
|
||||||
|
|
||||||
|
TV = "TV"
|
||||||
|
MOVIE = "剧场版"
|
||||||
|
MANGA = "漫画"
|
||||||
17
src/services/users.py
Normal file
17
src/services/users.py
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
from typing import cast
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
from .base import BaseBangumiService
|
||||||
|
from .contracts import UserDetailsResponse
|
||||||
|
|
||||||
|
|
||||||
|
class UsersService(BaseBangumiService):
|
||||||
|
def __init__(self, *args: object, **kwargs: object) -> None:
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
async def get_user_details(self, username: str) -> UserDetailsResponse:
|
||||||
|
"""获取用户详细信息"""
|
||||||
|
encoded_username = quote(username)
|
||||||
|
url = f"{self.base_url}/v0/users/{encoded_username}"
|
||||||
|
data = await self._request(url)
|
||||||
|
return cast(UserDetailsResponse, data if isinstance(data, dict) else {})
|
||||||
266
src/templates/calendar/calendar.html
Normal file
266
src/templates/calendar/calendar.html
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Bangumi Calendar</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg-color: #f7f8fa;
|
||||||
|
--card-bg: #ffffff;
|
||||||
|
--primary-color: #FB8C00;
|
||||||
|
--secondary-color: #FFF3E0;
|
||||||
|
--text-main: #1a1a1a;
|
||||||
|
--text-sub: #8590a6;
|
||||||
|
--text-light: #999;
|
||||||
|
--border-color: #eaeaea;
|
||||||
|
--font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
|
||||||
|
--shadow: 0 4px 20px rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 30px;
|
||||||
|
font-family: var(--font-family);
|
||||||
|
background-color: #f0f2f5;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
width: 1400px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-title {
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--text-main);
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-title::before {
|
||||||
|
content: "";
|
||||||
|
width: 8px;
|
||||||
|
height: 32px;
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-column {
|
||||||
|
background-color: rgba(255, 255, 255, 0.7);
|
||||||
|
border-radius: 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 600px;
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.03);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-column.today {
|
||||||
|
background-color: var(--card-bg);
|
||||||
|
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.08);
|
||||||
|
border: 2px solid var(--primary-color);
|
||||||
|
transform: scale(1.02);
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-header {
|
||||||
|
padding: 16px;
|
||||||
|
text-align: center;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.today .day-header {
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
color: white;
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-name-cn {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 800;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-name-en {
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.7;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
margin-top: 2px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.items-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1px;
|
||||||
|
background-color: var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.anime-item {
|
||||||
|
background-color: white;
|
||||||
|
padding: 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anime-item:hover {
|
||||||
|
background-color: #fafafa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anime-cover-wrapper {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 2/3;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
background-color: #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anime-cover {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anime-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anime-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-main);
|
||||||
|
line-height: 1.4;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
min-height: 2.8em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anime-meta {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anime-score {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--primary-color);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anime-rank {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-light);
|
||||||
|
background: #f5f5f5;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.today-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
right: 8px;
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
color: white;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 800;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow: 0 2px 8px rgba(251, 140, 0, 0.4);
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-day {
|
||||||
|
padding: 40px 20px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-light);
|
||||||
|
font-size: 14px;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<header class="calendar-header">
|
||||||
|
<h1 class="calendar-title">每日放送表 <span style="font-size: 16px; color: var(--text-sub); font-weight: 500;">Bangumi Calendar</span>
|
||||||
|
</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="calendar-grid">
|
||||||
|
{% for day in days %}
|
||||||
|
<div class="day-column {{ 'today' if day.is_today else '' }}">
|
||||||
|
<div class="day-header">
|
||||||
|
<span class="day-name-cn">{{ day.weekday.cn }}</span>
|
||||||
|
<span class="day-name-en">{{ day.weekday.en }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="items-list">
|
||||||
|
{% for item in day['items'] %}
|
||||||
|
<div class="anime-item">
|
||||||
|
<div class="anime-cover-wrapper">
|
||||||
|
<img class="anime-cover"
|
||||||
|
src="{{ item.images.common or item.images.large or item.images.medium }}"
|
||||||
|
alt="{{ item.name_cn or item.name }}"
|
||||||
|
referrerpolicy="no-referrer">
|
||||||
|
</div>
|
||||||
|
<div class="anime-info">
|
||||||
|
<div class="anime-title">{{ item.name_cn or item.name }}</div>
|
||||||
|
<div class="anime-meta">
|
||||||
|
{% if item.rating and item.rating.score %}
|
||||||
|
<div class="anime-score">
|
||||||
|
<span>★</span>
|
||||||
|
<span>{{ item.rating.score }}</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if item.rank %}
|
||||||
|
<div class="anime-rank">#{{ item.rank }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="empty-day">今日无更新内容</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
596
src/templates/subject/subject.html
Normal file
596
src/templates/subject/subject.html
Normal file
@@ -0,0 +1,596 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Subject Card</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg-color: #f7f8fa;
|
||||||
|
--card-bg: #ffffff;
|
||||||
|
--primary-color: #FB8C00;
|
||||||
|
/* 更现代的暖橙色 */
|
||||||
|
--secondary-color: #FFF3E0;
|
||||||
|
/* 浅橙色背景 */
|
||||||
|
--text-main: #1a1a1a;
|
||||||
|
--text-sub: #8590a6;
|
||||||
|
--text-light: #999;
|
||||||
|
--border-color: #eaeaea;
|
||||||
|
--font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
|
||||||
|
--card-shadow: 0 12px 40px rgba(0, 0, 0, 0.08);
|
||||||
|
--img-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 40px;
|
||||||
|
font-family: var(--font-family);
|
||||||
|
background-color: transparent;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
#card {
|
||||||
|
width: 800px;
|
||||||
|
display: flex;
|
||||||
|
background-color: var(--card-bg);
|
||||||
|
border-radius: 20px;
|
||||||
|
box-shadow: var(--card-shadow);
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 24px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
gap: 28px;
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.02);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 装饰性背景圆 */
|
||||||
|
#card::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: -50px;
|
||||||
|
right: -50px;
|
||||||
|
width: 150px;
|
||||||
|
height: 150px;
|
||||||
|
background: linear-gradient(135deg, var(--secondary-color) 0%, rgba(255, 255, 255, 0) 70%);
|
||||||
|
border-radius: 50%;
|
||||||
|
z-index: 0;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 右上角更新日角标 */
|
||||||
|
.weekday-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 10;
|
||||||
|
/* 上边和右边直角,左下角圆角 */
|
||||||
|
border-radius: 0 20px 0 16px;
|
||||||
|
background: linear-gradient(135deg, #FF9800 0%, #F57C00 100%);
|
||||||
|
padding: 8px 18px 10px 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1px;
|
||||||
|
box-shadow: -2px 2px 8px rgba(245, 124, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekday-badge::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
background: linear-gradient(135deg, transparent 50%, rgba(230, 81, 0, 0.15) 50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekday-kanji {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 900;
|
||||||
|
color: #fff;
|
||||||
|
line-height: 1;
|
||||||
|
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekday-sub {
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: rgba(255, 255, 255, 0.85);
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 左侧图片容器 */
|
||||||
|
.cover-container {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 210px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
align-self: stretch;
|
||||||
|
/* 充满高度 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-wrapper {
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: var(--img-shadow);
|
||||||
|
background-color: #f0f0f0;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
font-size: 0;
|
||||||
|
/* 消除图片底部间隙 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-image {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
display: block;
|
||||||
|
object-fit: cover;
|
||||||
|
min-height: 297px;
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 剧集进度(左侧) */
|
||||||
|
.episode-container {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 10px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode-label {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-light);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode-progress-text {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ep-cell {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 6px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 已播出:橙色醒目背景 + 白色数字 */
|
||||||
|
.ep-aired {
|
||||||
|
background: linear-gradient(135deg, #FF9800, #FB8C00);
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 2px 6px rgba(251, 140, 0, 0.35);
|
||||||
|
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 未播出:灰色背景 + 深灰数字 */
|
||||||
|
.ep-unaired {
|
||||||
|
background-color: #e8e8e8;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 评分直方图 */
|
||||||
|
.chart-container {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 12px 8px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.04);
|
||||||
|
margin-top: auto;
|
||||||
|
/* 推到底部 */
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-title {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-light);
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-bars {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
|
height: 45px;
|
||||||
|
gap: 2px;
|
||||||
|
padding-bottom: 2px;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-wrapper {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
height: 100%;
|
||||||
|
justify-content: flex-end;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar {
|
||||||
|
width: 80%;
|
||||||
|
background-color: #ffe0b2;
|
||||||
|
border-radius: 2px 2px 0 0;
|
||||||
|
min-height: 2px;
|
||||||
|
/* 确保0分也能看到一点点底 */
|
||||||
|
transition: height 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 高分段颜色加深 */
|
||||||
|
.bar-wrapper:nth-child(n+8) .bar {
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 底部坐标轴 */
|
||||||
|
.chart-labels {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-top: 2px;
|
||||||
|
padding: 0 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-label {
|
||||||
|
font-size: 9px;
|
||||||
|
color: #ccc;
|
||||||
|
font-family: monospace;
|
||||||
|
width: 12px;
|
||||||
|
/* 固定宽度以便对齐 */
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 右侧内容区域 */
|
||||||
|
.content {
|
||||||
|
flex-grow: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
justify-content: flex-start;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 标题部分 */
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--text-main);
|
||||||
|
line-height: 1.3;
|
||||||
|
margin: 0;
|
||||||
|
letter-spacing: -0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-sub);
|
||||||
|
margin: 0;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 评分行 */
|
||||||
|
.meta-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-badge {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 4px;
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.star-icon {
|
||||||
|
font-size: 24px;
|
||||||
|
line-height: 1;
|
||||||
|
filter: drop-shadow(0 2px 4px rgba(251, 140, 0, 0.3));
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-val {
|
||||||
|
font-size: 36px;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1;
|
||||||
|
letter-spacing: -1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-label {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-light);
|
||||||
|
font-weight: 500;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rank-badge {
|
||||||
|
background-color: var(--secondary-color);
|
||||||
|
color: #E65100;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rating-count {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-sub);
|
||||||
|
margin-left: auto;
|
||||||
|
background: #f5f5f5;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tags */
|
||||||
|
.tags-container {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag {
|
||||||
|
background-color: #fff;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
color: var(--text-sub);
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 简介 */
|
||||||
|
.summary-container {
|
||||||
|
margin-top: 8px;
|
||||||
|
padding-top: 20px;
|
||||||
|
border-top: 1px dashed var(--border-color);
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-label {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-main);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-text {
|
||||||
|
font-size: 15px;
|
||||||
|
color: #4a4a4a;
|
||||||
|
line-height: 1.8;
|
||||||
|
margin: 0;
|
||||||
|
text-align: justify;
|
||||||
|
/* 限制显示行数,防止过长 */
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 7;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 底部信息 */
|
||||||
|
.footer {
|
||||||
|
margin-top: auto;
|
||||||
|
display: flex;
|
||||||
|
gap: 24px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-light);
|
||||||
|
padding-top: 16px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
background: #f9f9f9;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-icon {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.id-badge {
|
||||||
|
margin-left: auto;
|
||||||
|
font-family: monospace;
|
||||||
|
opacity: 0.5;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 空状态占位 */
|
||||||
|
.no-data {
|
||||||
|
color: #ccc;
|
||||||
|
font-style: italic;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div id="card">
|
||||||
|
<!-- 右上角更新日角标 -->
|
||||||
|
{% if air_weekday %}
|
||||||
|
<div class="weekday-badge">
|
||||||
|
<span class="weekday-kanji">{{ air_weekday }}</span>
|
||||||
|
<span class="weekday-sub">曜日</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- 左侧封面 -->
|
||||||
|
<div class="cover-container">
|
||||||
|
<div class="cover-wrapper">
|
||||||
|
{% if image_url %}
|
||||||
|
<img class="cover-image" src="{{ image_url }}" alt="Cover" referrerpolicy="no-referrer"
|
||||||
|
onerror="this.style.display='none'">
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 剧集进度 -->
|
||||||
|
{% if episode_list %}
|
||||||
|
<div class="episode-container">
|
||||||
|
<div class="episode-label">
|
||||||
|
放送进度
|
||||||
|
{% set aired_count = episode_list | selectattr('aired') | list | length %}
|
||||||
|
<span class="episode-progress-text">{{ aired_count }} / {{ episode_list | length }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="episode-grid">
|
||||||
|
{% for ep_item in episode_list %}
|
||||||
|
<div class="ep-cell {{ 'ep-aired' if ep_item.aired else 'ep-unaired' }}">
|
||||||
|
{{ ep_item.ep }}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- 评分分布 -->
|
||||||
|
{% if rating and rating.count %}
|
||||||
|
<div class="chart-container">
|
||||||
|
<div class="chart-title">评分分布</div>
|
||||||
|
<div class="chart-bars">
|
||||||
|
{% set max_count = rating.count.values() | max if rating.count else 1 %}
|
||||||
|
|
||||||
|
{% for i in range(1, 11) %}
|
||||||
|
{% set count = rating.count[i|string] or 0 %}
|
||||||
|
{% set height_percent = (count / max_count * 100) if max_count > 0 else 0 %}
|
||||||
|
{% set final_height = height_percent if height_percent > 1 else 1 %}
|
||||||
|
<div class="bar-wrapper" title="{{ i }}分: {{ count }}人">
|
||||||
|
<div class="bar" style="height: {{ final_height }}%;"></div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div class="chart-labels">
|
||||||
|
<span class="chart-label">1</span>
|
||||||
|
<span class="chart-label"></span>
|
||||||
|
<span class="chart-label"></span>
|
||||||
|
<span class="chart-label"></span>
|
||||||
|
<span class="chart-label">5</span>
|
||||||
|
<span class="chart-label"></span>
|
||||||
|
<span class="chart-label"></span>
|
||||||
|
<span class="chart-label"></span>
|
||||||
|
<span class="chart-label"></span>
|
||||||
|
<span class="chart-label">10</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 右侧信息 -->
|
||||||
|
<div class="content">
|
||||||
|
<!-- 标题 -->
|
||||||
|
<div class="header">
|
||||||
|
<h1 class="title">{{ name_cn or name }}</h1>
|
||||||
|
{% if name_cn and name != name_cn %}
|
||||||
|
<p class="subtitle">{{ name }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 评分与排名 -->
|
||||||
|
{% if rating %}
|
||||||
|
<div class="meta-row">
|
||||||
|
<div class="score-badge">
|
||||||
|
<span class="star-icon">★</span>
|
||||||
|
<span class="score-val">{{ rating.score }}</span>
|
||||||
|
</div>
|
||||||
|
{% set display_rank = rating.rank or rank %}
|
||||||
|
{% if display_rank %}
|
||||||
|
<div class="rank-badge">
|
||||||
|
#{{ display_rank }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="rating-count">
|
||||||
|
{{ rating.total }} 人评分
|
||||||
|
</div>
|
||||||
|
{% if collection and collection.doing %}
|
||||||
|
<div class="rating-count" style="margin-left: 8px;">
|
||||||
|
{{ collection.doing }} 人在看
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="meta-row">
|
||||||
|
<span class="no-data">暂无评分</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- 标签 -->
|
||||||
|
{% if tags %}
|
||||||
|
<div class="tags-container">
|
||||||
|
{% for tag in tags[:8] %}
|
||||||
|
<span class="tag">{{ tag.name }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- 简介 -->
|
||||||
|
<div class="summary-container">
|
||||||
|
<div class="summary-label">简介</div>
|
||||||
|
<p class="summary-text">
|
||||||
|
{{ summary if summary else '暂无简介' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 底部额外信息 -->
|
||||||
|
<div class="footer">
|
||||||
|
{% if date %}
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="info-icon">📅</span>
|
||||||
|
<span>{{ date }}</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if platform %}
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="info-icon">📺</span>
|
||||||
|
<span>{{ platform }}</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if id %}
|
||||||
|
<div class="id-badge">
|
||||||
|
ID: {{ id }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
237
src/templates/update/episode.html
Normal file
237
src/templates/update/episode.html
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Episode Card Update</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800;900&family=Noto+Sans+SC:wght@400;700;900&display=swap"
|
||||||
|
rel="stylesheet">
|
||||||
|
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg-dark: #09090b;
|
||||||
|
--text-white: #ffffff;
|
||||||
|
--accent-pink: #ec4899;
|
||||||
|
--font-main: 'Inter', 'Noto Sans SC', system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--font-main);
|
||||||
|
background-color: #000;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-container {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 768px;
|
||||||
|
background-color: #000;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Image Section */
|
||||||
|
.image-wrapper {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
padding-top: 133.33%; /* 3:4 aspect ratio */
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-image {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Gradient Overlay */
|
||||||
|
.gradient-overlay {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 60%;
|
||||||
|
background: linear-gradient(to bottom,
|
||||||
|
transparent 0%,
|
||||||
|
rgba(0, 0, 0, 0.3) 30%,
|
||||||
|
rgba(0, 0, 0, 0.7) 60%,
|
||||||
|
rgba(0, 0, 0, 0.95) 100%);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Content Section */
|
||||||
|
.content-section {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
padding: 32px 28px 28px;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* EP and Title */
|
||||||
|
.episode-header {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ep-title-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ep-number {
|
||||||
|
font-size: 56px;
|
||||||
|
font-weight: 900;
|
||||||
|
color: var(--accent-pink);
|
||||||
|
letter-spacing: -1px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode-title {
|
||||||
|
font-size: 48px;
|
||||||
|
font-weight: 900;
|
||||||
|
color: white;
|
||||||
|
letter-spacing: -1px;
|
||||||
|
line-height: 1.1;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Metadata Row */
|
||||||
|
.metadata-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-row span {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.separator {
|
||||||
|
color: rgba(255, 255, 255, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Description */
|
||||||
|
.description-text {
|
||||||
|
font-size: 17px;
|
||||||
|
line-height: 1.7;
|
||||||
|
color: rgba(255, 255, 255, 0.85);
|
||||||
|
margin-bottom: 24px;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fallback */
|
||||||
|
.fallback-container {
|
||||||
|
width: 100%;
|
||||||
|
padding-top: 133.33%;
|
||||||
|
background: radial-gradient(circle at center, #2a2a35 0%, #09090b 100%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fallback-icon {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
font-size: 120px;
|
||||||
|
opacity: 0.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.ep-number {
|
||||||
|
font-size: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode-title {
|
||||||
|
font-size: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-section {
|
||||||
|
padding: 24px 20px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-row {
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description-text {
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="card-container" id="card-container">
|
||||||
|
<!-- Image Section -->
|
||||||
|
<div class="image-wrapper">
|
||||||
|
{% if image_url %}
|
||||||
|
<img src="{{ image_url }}" alt="Cover" class="bg-image" referrerpolicy="no-referrer">
|
||||||
|
{% else %}
|
||||||
|
<div class="fallback-container">
|
||||||
|
<span class="fallback-icon">🎬</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Gradient Overlay -->
|
||||||
|
<div class="gradient-overlay"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content Section -->
|
||||||
|
<div class="content-section">
|
||||||
|
<div class="episode-header">
|
||||||
|
<div class="ep-title-row">
|
||||||
|
<span class="ep-number">EP.{{ '%02d' % sort if sort else '01' }}</span>
|
||||||
|
<h1 class="episode-title">{{ name_cn or name or "第 " + (sort|string) + " 话" }}</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="metadata-row">
|
||||||
|
{% if airdate %}
|
||||||
|
<span>{{ airdate.split('-')[0] if '-' in airdate else '2026' }}</span>
|
||||||
|
<span class="separator">|</span>
|
||||||
|
{% endif %}
|
||||||
|
<span>24min</span>
|
||||||
|
{% if comment > 0 %}
|
||||||
|
<span class="separator">|</span>
|
||||||
|
<span>{{ comment }} comments</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if desc %}
|
||||||
|
<p class="description-text">{{ desc }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
5
src/utils/__init__.py
Normal file
5
src/utils/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
from .async_utils import retry
|
||||||
|
from .env_manager import EnvManager
|
||||||
|
from .scheduler import SchedulerManager
|
||||||
|
|
||||||
|
__all__ = ["EnvManager", "SchedulerManager", "retry"]
|
||||||
42
src/utils/async_utils.py
Normal file
42
src/utils/async_utils.py
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import asyncio
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
|
from typing import TypeVar
|
||||||
|
|
||||||
|
from astrbot.api import logger
|
||||||
|
|
||||||
|
T = TypeVar("T")
|
||||||
|
|
||||||
|
|
||||||
|
async def retry(
|
||||||
|
func: Callable[..., Awaitable[T]],
|
||||||
|
retries: int = 3,
|
||||||
|
delay: float = 1.0,
|
||||||
|
label: str = "任务",
|
||||||
|
*args: object,
|
||||||
|
**kwargs: object,
|
||||||
|
) -> T:
|
||||||
|
"""
|
||||||
|
通用异步重试方法
|
||||||
|
:param func: 需要重试的异步函数
|
||||||
|
:param retries: 最大重试次数
|
||||||
|
:param delay: 重试间隔(秒)
|
||||||
|
:param label: 用于日志显示的标签
|
||||||
|
:param args: 传递给 func 的位置参数
|
||||||
|
:param kwargs: 传递给 func 的关键字参数
|
||||||
|
:return: 异步函数的返回结果
|
||||||
|
|
||||||
|
"""
|
||||||
|
last_exception: Exception | None = None
|
||||||
|
for i in range(retries):
|
||||||
|
try:
|
||||||
|
return await func(*args, **kwargs)
|
||||||
|
except Exception as e:
|
||||||
|
last_exception = e
|
||||||
|
logger.warning(f"{label} 执行失败 (尝试 {i + 1}/{retries}): {e}")
|
||||||
|
if i < retries - 1:
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
|
||||||
|
logger.error(f"{label} 在 {retries} 次尝试后最终失败")
|
||||||
|
if last_exception is None:
|
||||||
|
raise RuntimeError(f"{label} 在 {retries} 次尝试后最终失败")
|
||||||
|
raise last_exception
|
||||||
18
src/utils/env_manager.py
Normal file
18
src/utils/env_manager.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
from astrbot.api import logger
|
||||||
|
|
||||||
|
|
||||||
|
class EnvManager:
|
||||||
|
"""
|
||||||
|
Stub kept for API compatibility. Playwright has been removed;
|
||||||
|
local rendering now uses Pillow with no additional setup required.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, data_dir: str) -> None:
|
||||||
|
self.data_dir = data_dir
|
||||||
|
|
||||||
|
def is_installed(self) -> bool:
|
||||||
|
"""Always returns True — no external renderer needs to be installed."""
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def install_dependencies(self) -> None:
|
||||||
|
logger.info("[+] Pillow renderer: no additional dependencies to install.")
|
||||||
83
src/utils/scheduler.py
Normal file
83
src/utils/scheduler.py
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
"""
|
||||||
|
APScheduler 管理器
|
||||||
|
|
||||||
|
此模块提供了一个为 asyncio 和特定时区配置的 APScheduler 单例管理器。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from collections.abc import Callable
|
||||||
|
|
||||||
|
import pytz
|
||||||
|
from apscheduler.jobstores.base import JobLookupError
|
||||||
|
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||||
|
from astrbot.api import logger
|
||||||
|
|
||||||
|
|
||||||
|
class SchedulerManager:
|
||||||
|
"""
|
||||||
|
APScheduler 的管理器类。
|
||||||
|
它使用 Asia/Shanghai 时区初始化调度器,并提供添加、删除和管理任务的方法。
|
||||||
|
"""
|
||||||
|
|
||||||
|
_instance = None
|
||||||
|
_lock = asyncio.Lock()
|
||||||
|
|
||||||
|
def __new__(cls, *args: object, **kwargs: object) -> "SchedulerManager":
|
||||||
|
# 伪单例实现,确保只存在一个调度器实例。
|
||||||
|
if cls._instance is None:
|
||||||
|
cls._instance = super().__new__(cls)
|
||||||
|
return cls._instance
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
"""
|
||||||
|
初始化 SchedulerManager。
|
||||||
|
每次调用 SchedulerManager() 时都会调用此方法,但调度器本身只创建一次。
|
||||||
|
"""
|
||||||
|
if not hasattr(self, "scheduler"):
|
||||||
|
self.scheduler = AsyncIOScheduler(timezone=pytz.timezone("Asia/Shanghai"))
|
||||||
|
self.scheduler.start()
|
||||||
|
logger.info("调度器已初始化并在 Asia/Shanghai 时区启动.")
|
||||||
|
|
||||||
|
def add_job(
|
||||||
|
self, func: Callable[..., object], trigger: str, **kwargs: object
|
||||||
|
) -> str | None:
|
||||||
|
"""
|
||||||
|
向调度器添加一个任务。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
func (Callable): 要执行的异步函数。
|
||||||
|
trigger (str): 触发器类型(例如:'interval'、'cron'、'date')。
|
||||||
|
**kwargs: 触发器的参数(例如:seconds=30, hour=8, minute=0)。
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str | None: 添加的任务ID,如果失败则返回 None。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
job = self.scheduler.add_job(func, trigger, **kwargs)
|
||||||
|
return job.id
|
||||||
|
except (RuntimeError, ValueError, TypeError) as e:
|
||||||
|
logger.error(f"Error adding job: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def cancel_job(self, job_id: str) -> None:
|
||||||
|
"""
|
||||||
|
根据任务ID取消任务。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
job_id (str): 要取消的任务的ID。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
self.scheduler.remove_job(job_id)
|
||||||
|
logger.info(f"定时任务{job_id}已取消.")
|
||||||
|
except JobLookupError:
|
||||||
|
logger.warning(f"未找到定时任务{job_id}")
|
||||||
|
except (RuntimeError, ValueError, TypeError) as e:
|
||||||
|
logger.error(f"取消任务失败{job_id}: {e}")
|
||||||
|
|
||||||
|
def shutdown(self) -> None:
|
||||||
|
"""
|
||||||
|
关闭调度器。
|
||||||
|
"""
|
||||||
|
if self.scheduler.running:
|
||||||
|
self.scheduler.shutdown()
|
||||||
|
logger.info("调度器已关闭.")
|
||||||
107
tests/test_calendar_service.py
Normal file
107
tests/test_calendar_service.py
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import asyncio
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import src.services.calendar as calendar_module
|
||||||
|
from src.services import BangumiApiError, CalendarService
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def service() -> CalendarService:
|
||||||
|
return CalendarService(access_token="token", user_agent="ua")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_calendar_cache_hit(service: CalendarService) -> None:
|
||||||
|
payload = [{"weekday": {"id": 1}, "items": [{"id": 1, "name": "A"}]}]
|
||||||
|
service._request = AsyncMock(return_value=payload)
|
||||||
|
|
||||||
|
first = await service.get_calendar()
|
||||||
|
second = await service.get_calendar()
|
||||||
|
|
||||||
|
assert first == second
|
||||||
|
assert service._request.await_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_calendar_cache_expired_refresh(
|
||||||
|
service: CalendarService, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
now = 1_000_000.0
|
||||||
|
|
||||||
|
def fake_time() -> float:
|
||||||
|
return now
|
||||||
|
|
||||||
|
monkeypatch.setattr(calendar_module.time, "time", fake_time)
|
||||||
|
|
||||||
|
service._request = AsyncMock(
|
||||||
|
side_effect=[
|
||||||
|
[{"weekday": {"id": 1}, "items": []}],
|
||||||
|
[{"weekday": {"id": 2}, "items": []}],
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
first = await service.get_calendar()
|
||||||
|
now += service.CALENDAR_CACHE_TTL_SECONDS + 1
|
||||||
|
second = await service.get_calendar()
|
||||||
|
|
||||||
|
assert first != second
|
||||||
|
assert service._request.await_count == 2
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_calendar_cache_returns_deepcopy(service: CalendarService) -> None:
|
||||||
|
payload = [{"weekday": {"id": 1}, "items": []}]
|
||||||
|
service._request = AsyncMock(return_value=payload)
|
||||||
|
|
||||||
|
first = await service.get_calendar()
|
||||||
|
first[0]["weekday"]["id"] = 7
|
||||||
|
second = await service.get_calendar()
|
||||||
|
|
||||||
|
assert second[0]["weekday"]["id"] == 1
|
||||||
|
assert service._request.await_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_calendar_cache_refresh_failed_fallback_stale(
|
||||||
|
service: CalendarService, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
now = 2_000_000.0
|
||||||
|
|
||||||
|
def fake_time() -> float:
|
||||||
|
return now
|
||||||
|
|
||||||
|
monkeypatch.setattr(calendar_module.time, "time", fake_time)
|
||||||
|
|
||||||
|
service._request = AsyncMock(
|
||||||
|
side_effect=[
|
||||||
|
[{"weekday": {"id": 1}, "items": [{"id": 1}]}],
|
||||||
|
BangumiApiError("boom"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
first = await service.get_calendar()
|
||||||
|
now += service.CALENDAR_CACHE_TTL_SECONDS + 1
|
||||||
|
second = await service.get_calendar()
|
||||||
|
|
||||||
|
assert second == first
|
||||||
|
assert service._request.await_count == 2
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_calendar_cache_concurrent_single_refresh(
|
||||||
|
service: CalendarService,
|
||||||
|
) -> None:
|
||||||
|
payload = [{"weekday": {"id": 3}, "items": []}]
|
||||||
|
|
||||||
|
async def slow_fetch(*args: object, **kwargs: object) -> list[dict[str, object]]:
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
service._request = AsyncMock(side_effect=slow_fetch)
|
||||||
|
|
||||||
|
first, second = await asyncio.gather(service.get_calendar(), service.get_calendar())
|
||||||
|
|
||||||
|
assert first == second
|
||||||
|
assert service._request.await_count == 1
|
||||||
70
tests/test_search_service.py
Normal file
70
tests/test_search_service.py
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from astrbot.api.event import AstrMessageEvent
|
||||||
|
|
||||||
|
from src.services import SearchService
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_service() -> MagicMock:
|
||||||
|
service = MagicMock()
|
||||||
|
service.search_subjects = AsyncMock()
|
||||||
|
service.get_subject_details = AsyncMock()
|
||||||
|
service.get_subject_episodes = AsyncMock()
|
||||||
|
service.get_calendar = AsyncMock()
|
||||||
|
return service
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_config_manager() -> MagicMock:
|
||||||
|
config_manager = MagicMock()
|
||||||
|
config_manager.get_render_server_url.return_value = "https://api.unitedpooh.top/rpc"
|
||||||
|
config_manager.get_max_retries.return_value = 1
|
||||||
|
return config_manager
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_handle_calendar_success(
|
||||||
|
mock_service: MagicMock, mock_config_manager: MagicMock
|
||||||
|
) -> None:
|
||||||
|
# 准备 Mock 数据
|
||||||
|
mock_service.get_calendar.return_value = [{"weekday": {"id": 1}, "items": []}]
|
||||||
|
|
||||||
|
search_service = SearchService(
|
||||||
|
service=mock_service, config_manager=mock_config_manager
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mock 渲染器,避免进入模板渲染逻辑
|
||||||
|
search_service.calendar_renderer.render_calendar = AsyncMock(
|
||||||
|
return_value="fake_base64"
|
||||||
|
)
|
||||||
|
|
||||||
|
event = MagicMock(spec=AstrMessageEvent)
|
||||||
|
event.chain_result = MagicMock(side_effect=lambda x: x)
|
||||||
|
|
||||||
|
results: list[object] = []
|
||||||
|
async for res in search_service.handle_calendar(event):
|
||||||
|
results.append(res)
|
||||||
|
|
||||||
|
assert len(results) > 0
|
||||||
|
mock_service.get_calendar.assert_called_once()
|
||||||
|
event.chain_result.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_handle_subject_search_no_query(
|
||||||
|
mock_service: MagicMock, mock_config_manager: MagicMock
|
||||||
|
) -> None:
|
||||||
|
search_service = SearchService(
|
||||||
|
service=mock_service, config_manager=mock_config_manager
|
||||||
|
)
|
||||||
|
event = MagicMock(spec=AstrMessageEvent)
|
||||||
|
event.plain_result = MagicMock(side_effect=lambda x: x)
|
||||||
|
|
||||||
|
results: list[object] = []
|
||||||
|
async for res in search_service.handle_subject_search(event, query=""):
|
||||||
|
results.append(res)
|
||||||
|
|
||||||
|
assert len(results) > 0
|
||||||
|
assert "❌ 请提供搜索关键词" in str(results[0])
|
||||||
72
tests/test_subject_renderer.py
Normal file
72
tests/test_subject_renderer.py
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import pytest
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from src.render import SubjectRenderer
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_render_subject_card_success() -> None:
|
||||||
|
# 准备测试数据
|
||||||
|
subject_data = {
|
||||||
|
"date": "2026-01-11",
|
||||||
|
"platform": "TV",
|
||||||
|
"images": {
|
||||||
|
"small": "https://lain.bgm.tv/r/200/pic/cover/l/71/50/525565_OxOv7.jpg",
|
||||||
|
"grid": "https://lain.bgm.tv/r/100/pic/cover/l/71/50/525565_OxOv7.jpg",
|
||||||
|
"large": "https://lain.bgm.tv/pic/cover/l/71/50/525565_OxOv7.jpg",
|
||||||
|
"medium": "https://lain.bgm.tv/r/800/pic/cover/l/71/50/525565_OxOv7.jpg",
|
||||||
|
"common": "https://lain.bgm.tv/r/400/pic/cover/l/71/50/525565_OxOv7.jpg",
|
||||||
|
},
|
||||||
|
"summary": "总是活力充沛,却又很在意周遭目光的女孩:铃木实优\r\n以及个性文静,却能清楚表达自己意见的男生:谷悠介\r\n\r\n本次故事将讲述这两人的生活点滴。铃木喜欢着谷,却一直无法鼓起勇气告白。直到某天,两人放学回家时走在同一条路上并牵起了手。借由该契机,两人相互倾诉对彼此的好感并开始了交往。同学们虽然感到讶异,但也都很支持两人的恋情。\r\n这部恋爱喜剧描写的,正是这对个性截然相反的两人,在彼此尊重之下慢慢加深互相的理解,并与朋友们一同度过的校园生活点滴。如此温暖的故事就此开幕!\r\n\r\n\r\n\r\n[简介原文]\r\nいつも元気いっぱいだけど周りの目を気にしてしまう女子・鈴木と、\r\n物静かだけど自分の意見をしっかり言える男子・谷。\r\n正反対な二人が误解や勘違いをしながらもお互いを尊重し、\r\nゆっくりと理解を深めていく姿と、友人たちとの学校生活を描くラブコメディ。",
|
||||||
|
"name": "正反対な君と僕",
|
||||||
|
"name_cn": "相反的你和我",
|
||||||
|
"tags": [
|
||||||
|
{"name": "恋爱", "count": 1356},
|
||||||
|
{"name": "校园", "count": 1071},
|
||||||
|
{"name": "2026年1月", "count": 1033},
|
||||||
|
{"name": "漫画改", "count": 823},
|
||||||
|
],
|
||||||
|
"infobox": [
|
||||||
|
{"key": "中文名", "value": "相反的你和我"},
|
||||||
|
{"key": "别名", "value": [{"v": "正相反的你与我"}]},
|
||||||
|
{"key": "话数", "value": "12"},
|
||||||
|
{"key": "放送开始", "value": "2026年1月11日"},
|
||||||
|
],
|
||||||
|
"total_episodes": 12,
|
||||||
|
"id": 525565,
|
||||||
|
"type": 2,
|
||||||
|
"rating": {
|
||||||
|
"rank": 677,
|
||||||
|
"total": 2517,
|
||||||
|
"count": {
|
||||||
|
"1": 6,
|
||||||
|
"2": 3,
|
||||||
|
"3": 7,
|
||||||
|
"4": 13,
|
||||||
|
"5": 40,
|
||||||
|
"6": 167,
|
||||||
|
"7": 753,
|
||||||
|
"8": 1234,
|
||||||
|
"9": 194,
|
||||||
|
"10": 100,
|
||||||
|
},
|
||||||
|
"score": 7.6,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
renderer = SubjectRenderer()
|
||||||
|
|
||||||
|
# 运行渲染器
|
||||||
|
base64_image = await renderer.render_subject_card(
|
||||||
|
rpc_url="https://api.unitedpooh.top/rpc",
|
||||||
|
data=subject_data,
|
||||||
|
headless=True,
|
||||||
|
timeout=60000,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 验证结果
|
||||||
|
assert base64_image is not None, "[-] 渲染失败,未返回 Base64 字符串"
|
||||||
|
assert isinstance(base64_image, str), "返回值应为 Base64 字符串"
|
||||||
|
assert len(base64_image) > 100, "Base64 字符串过短"
|
||||||
|
|
||||||
|
logger.info(f"[+] 渲染成功!图片长度: {len(base64_image)} 字符")
|
||||||
206
tests/test_subscription_service.py
Normal file
206
tests/test_subscription_service.py
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.services import SubscriptionService
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_repo() -> MagicMock:
|
||||||
|
repo = MagicMock()
|
||||||
|
repo.subscribe_subject = MagicMock(return_value=True)
|
||||||
|
repo.remove_subscription = MagicMock(return_value=True)
|
||||||
|
repo.find_group_subscription_candidates = MagicMock(return_value=[])
|
||||||
|
return repo
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_service() -> MagicMock:
|
||||||
|
service = MagicMock()
|
||||||
|
service.search_subjects = AsyncMock()
|
||||||
|
service.get_subject_details = AsyncMock()
|
||||||
|
service.get_calendar = AsyncMock()
|
||||||
|
service.get_latest_episode = AsyncMock()
|
||||||
|
service.get_subject_base64image = AsyncMock()
|
||||||
|
return service
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_subscribe_success(mock_repo, mock_service) -> None:
|
||||||
|
mock_service.search_subjects.return_value = {"data": [{"id": 123}]}
|
||||||
|
mock_service.get_subject_details.return_value = {
|
||||||
|
"id": 123,
|
||||||
|
"name": "Test Anime",
|
||||||
|
"name_cn": "测试番剧",
|
||||||
|
"date": "2024-01-01",
|
||||||
|
"eps": 12,
|
||||||
|
}
|
||||||
|
mock_service.get_calendar.return_value = [{"items": [{"id": 123}]}]
|
||||||
|
|
||||||
|
sub_service = SubscriptionService(
|
||||||
|
repository=mock_repo, service=mock_service, config_manager=MagicMock()
|
||||||
|
)
|
||||||
|
result = await sub_service.subscribe("group_1", "Test Anime")
|
||||||
|
|
||||||
|
assert "成功订阅《测试番剧》" in result
|
||||||
|
mock_repo.subscribe_subject.assert_called_once_with(
|
||||||
|
group_id="group_1",
|
||||||
|
subject_id="123",
|
||||||
|
name="测试番剧",
|
||||||
|
air_date="2024-01-01",
|
||||||
|
total_episodes=12,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_unsubscribe_local_single_match_success(mock_repo, mock_service) -> None:
|
||||||
|
mock_repo.find_group_subscription_candidates.return_value = [
|
||||||
|
SimpleNamespace(subject_id="123", name="测试番剧")
|
||||||
|
]
|
||||||
|
|
||||||
|
sub_service = SubscriptionService(
|
||||||
|
repository=mock_repo, service=mock_service, config_manager=MagicMock()
|
||||||
|
)
|
||||||
|
result = await sub_service.unsubscribe("group_1", "测")
|
||||||
|
|
||||||
|
assert "已成功取消订阅《测试番剧》" in result
|
||||||
|
mock_repo.find_group_subscription_candidates.assert_called_once_with(
|
||||||
|
group_id="group_1", keyword="测", limit=6
|
||||||
|
)
|
||||||
|
mock_repo.remove_subscription.assert_called_once_with("group_1", "123")
|
||||||
|
mock_service.search_subjects.assert_not_called()
|
||||||
|
mock_service.get_subject_details.assert_not_called()
|
||||||
|
mock_service.get_calendar.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_unsubscribe_local_no_match(mock_repo, mock_service) -> None:
|
||||||
|
mock_repo.find_group_subscription_candidates.return_value = []
|
||||||
|
|
||||||
|
sub_service = SubscriptionService(
|
||||||
|
repository=mock_repo, service=mock_service, config_manager=MagicMock()
|
||||||
|
)
|
||||||
|
result = await sub_service.unsubscribe("group_1", "不存在")
|
||||||
|
|
||||||
|
assert "未找到与「不存在」匹配的本群订阅番剧" in result
|
||||||
|
mock_repo.remove_subscription.assert_not_called()
|
||||||
|
mock_service.search_subjects.assert_not_called()
|
||||||
|
mock_service.get_subject_details.assert_not_called()
|
||||||
|
mock_service.get_calendar.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_unsubscribe_local_multi_match_returns_candidates(
|
||||||
|
mock_repo, mock_service
|
||||||
|
) -> None:
|
||||||
|
mock_repo.find_group_subscription_candidates.return_value = [
|
||||||
|
SimpleNamespace(subject_id="1", name="进击的巨人"),
|
||||||
|
SimpleNamespace(subject_id="2", name="进击!巨人中学"),
|
||||||
|
SimpleNamespace(subject_id="3", name="巨人族的新娘"),
|
||||||
|
]
|
||||||
|
|
||||||
|
sub_service = SubscriptionService(
|
||||||
|
repository=mock_repo, service=mock_service, config_manager=MagicMock()
|
||||||
|
)
|
||||||
|
result = await sub_service.unsubscribe("group_1", "巨人")
|
||||||
|
|
||||||
|
assert "匹配到多个已订阅番剧" in result
|
||||||
|
assert "1. 进击的巨人 (ID: 1)" in result
|
||||||
|
assert "2. 进击!巨人中学 (ID: 2)" in result
|
||||||
|
assert "3. 巨人族的新娘 (ID: 3)" in result
|
||||||
|
mock_repo.remove_subscription.assert_not_called()
|
||||||
|
mock_service.search_subjects.assert_not_called()
|
||||||
|
mock_service.get_subject_details.assert_not_called()
|
||||||
|
mock_service.get_calendar.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_unsubscribe_local_remove_failed(mock_repo, mock_service) -> None:
|
||||||
|
mock_repo.find_group_subscription_candidates.return_value = [
|
||||||
|
SimpleNamespace(subject_id="123", name="测试番剧")
|
||||||
|
]
|
||||||
|
mock_repo.remove_subscription.return_value = False
|
||||||
|
|
||||||
|
sub_service = SubscriptionService(
|
||||||
|
repository=mock_repo, service=mock_service, config_manager=MagicMock()
|
||||||
|
)
|
||||||
|
result = await sub_service.unsubscribe("group_1", "测")
|
||||||
|
|
||||||
|
assert "取消订阅失败:你可能并没有订阅《测试番剧》" in result
|
||||||
|
mock_repo.remove_subscription.assert_called_once_with("group_1", "123")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_subscribe_candidates_multi_match(mock_repo, mock_service) -> None:
|
||||||
|
mock_service.search_subjects.return_value = {
|
||||||
|
"data": [
|
||||||
|
{"id": 1, "name_cn": "进击的巨人"},
|
||||||
|
{"id": 2, "name": "进击!巨人中学"},
|
||||||
|
{"id": 1, "name_cn": "进击的巨人"},
|
||||||
|
{"name_cn": "无ID条目"},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
sub_service = SubscriptionService(
|
||||||
|
repository=mock_repo, service=mock_service, config_manager=MagicMock()
|
||||||
|
)
|
||||||
|
error_msg, candidates = await sub_service.get_subscribe_candidates("巨人", 5)
|
||||||
|
|
||||||
|
assert error_msg is None
|
||||||
|
assert candidates == [
|
||||||
|
{"subject_id": "1", "name": "进击的巨人"},
|
||||||
|
{"subject_id": "2", "name": "进击!巨人中学"},
|
||||||
|
]
|
||||||
|
mock_service.search_subjects.assert_awaited_once_with(
|
||||||
|
keyword="巨人",
|
||||||
|
limit=5,
|
||||||
|
subject_type=[2],
|
||||||
|
subject_tags=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_subscribe_by_subject_id_success(mock_repo, mock_service) -> None:
|
||||||
|
mock_service.get_subject_details.return_value = {
|
||||||
|
"id": 456,
|
||||||
|
"name": "Test Name",
|
||||||
|
"name_cn": "测试番剧2",
|
||||||
|
"date": "2025-01-01",
|
||||||
|
"eps": 24,
|
||||||
|
}
|
||||||
|
mock_service.get_calendar.return_value = [{"items": [{"id": 456}]}]
|
||||||
|
|
||||||
|
sub_service = SubscriptionService(
|
||||||
|
repository=mock_repo, service=mock_service, config_manager=MagicMock()
|
||||||
|
)
|
||||||
|
result = await sub_service.subscribe_by_subject_id("group_1", "456")
|
||||||
|
|
||||||
|
assert "✅ 成功订阅《测试番剧2》" in result
|
||||||
|
mock_repo.subscribe_subject.assert_called_once_with(
|
||||||
|
group_id="group_1",
|
||||||
|
subject_id="456",
|
||||||
|
name="测试番剧2",
|
||||||
|
air_date="2025-01-01",
|
||||||
|
total_episodes=24,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_subscribe_by_subject_id_not_in_calendar(mock_repo, mock_service) -> None:
|
||||||
|
mock_service.get_subject_details.return_value = {
|
||||||
|
"id": 789,
|
||||||
|
"name": "Not In Calendar",
|
||||||
|
"name_cn": "未放送番剧",
|
||||||
|
"date": "2025-06-01",
|
||||||
|
"eps": 12,
|
||||||
|
}
|
||||||
|
mock_service.get_calendar.return_value = [{"items": [{"id": 456}]}]
|
||||||
|
|
||||||
|
sub_service = SubscriptionService(
|
||||||
|
repository=mock_repo, service=mock_service, config_manager=MagicMock()
|
||||||
|
)
|
||||||
|
result = await sub_service.subscribe_by_subject_id("group_1", "789")
|
||||||
|
|
||||||
|
assert "不在当前的每日放送列表中" in result
|
||||||
|
mock_repo.subscribe_subject.assert_not_called()
|
||||||
Reference in New Issue
Block a user