nonebot-plugin-anime 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,17 @@
1
+ from .core import *
2
+ from .handlers import *
3
+ from .config import Config, ensure_dirs
4
+
5
+ from nonebot.plugin import PluginMetadata
6
+
7
+ __plugin_meta__ = PluginMetadata(
8
+ name="nonebot-plugin-anime",
9
+ description="Bangumi 番剧查询等查看番剧角色详情",
10
+ usage="使用anime help查看详情",
11
+ type="application",
12
+ config=Config,
13
+ supported_adapters={"~onebot.v11"},
14
+ homepage="https://github.com/ShioMI39/nonebot-plugin-anime"
15
+ )
16
+
17
+ ensure_dirs()
@@ -0,0 +1,88 @@
1
+ from pathlib import Path
2
+
3
+ from nonebot import require
4
+ require("nonebot_plugin_localstore")
5
+ import nonebot_plugin_localstore as store
6
+
7
+ from nonebot.plugin import get_plugin_config
8
+ from pydantic import BaseModel
9
+
10
+
11
+ # Bangumi API 固定标识(请勿修改)
12
+ BANGUMI_API_UA = "ShioMI39/nonebot-plugin-anime (https://github.com/ShioMI39/nonebot-plugin-anime)"
13
+
14
+ class AnimeConfig(BaseModel):
15
+ """番剧插件配置"""
16
+ http_proxy: str = ""
17
+ bgm_api_token: str = ""
18
+ resource_dir: str = ""
19
+ private_allow: bool = True
20
+
21
+
22
+ class Config(BaseModel):
23
+ anime: AnimeConfig = AnimeConfig()
24
+
25
+
26
+ _cfg = get_plugin_config(Config).anime
27
+
28
+ HTTP_PROXY = _cfg.http_proxy
29
+ BANGUMI_API_TOKEN = _cfg.bgm_api_token
30
+ PRIVATE_ALLOW = _cfg.private_allow
31
+
32
+ # ====== 路径配置 ======
33
+ _data_dir = store.get_plugin_data_dir()
34
+ _cache_dir = store.get_plugin_cache_dir()
35
+
36
+ if not _cfg.resource_dir:
37
+ raise ValueError(
38
+ "未配置 ANIME__RESOURCE_DIR,"
39
+ "请先下载 bangumi_resource 资源包并解压,详见 README"
40
+ )
41
+
42
+ RESOURCE_DIR: Path = Path(_cfg.resource_dir)
43
+ CACHE_DIR = _cache_dir
44
+ FONTS_DIR = RESOURCE_DIR / "fonts"
45
+ BGM_JSON = RESOURCE_DIR / "bgm.json"
46
+
47
+ SCORE_JSON = store.get_plugin_data_file("score.json")
48
+ CHARACTER_SCORE_JSON = store.get_plugin_data_file("character_score.json")
49
+ GROUP_PERMISSION_JSON = store.get_plugin_data_file("group_permission.json")
50
+
51
+ # ====== 固定常量 ======
52
+ STAFF_ROLE_MAPPING = {
53
+ "导演": "director", "脚本": "script", "分镜": "storyboard",
54
+ "演出": "episode_director", "音乐": "music",
55
+ "人物原案": "character_design", "系列构成": "series_composition",
56
+ "director": "director", "监督": "director", "script": "script",
57
+ "storyboard": "storyboard", "episode_director": "episode_director",
58
+ "music": "music", "character_design": "character_design",
59
+ "series_composition": "series_composition",
60
+ }
61
+
62
+ FILTER_KEYWORDS_LOWER = {
63
+ '标签', 'tag', 'tags',
64
+ '分数', '评分', 'score',
65
+ 'cv', '声优', '配音',
66
+ '导演', 'director','监督',
67
+ '制作', '制作公司', '动画制作',
68
+ 'staff', '制作人员',
69
+ '季度', '时间',
70
+ '类型',
71
+ '关键词', '标题', 'keyword'
72
+ }
73
+
74
+ MIN_YEAR = 1900
75
+ MONTH_NAMES = {1: "一月", 4: "四月", 7: "七月", 10: "十月"}
76
+
77
+ SUBJECT_TYPE_LABEL = {1: "书籍", 2: "动画", 3: "音乐", 4: "游戏", 6: "三次元"}
78
+
79
+ QUARTER_MONTHS = {
80
+ 1: ("01", "03"), 4: ("04", "06"),
81
+ 7: ("07", "09"), 10: ("10", "12"),
82
+ }
83
+
84
+
85
+ def ensure_dirs() -> None:
86
+ RESOURCE_DIR.mkdir(parents=True, exist_ok=True)
87
+ _cache_dir.mkdir(parents=True, exist_ok=True)
88
+ _data_dir.mkdir(parents=True, exist_ok=True)
@@ -0,0 +1,42 @@
1
+ from .anime_downloader import ImageManager
2
+ from .anime_data import BGMDataManager
3
+ from .anime_tag import TagManager
4
+ from .anime_score import UserScoreManager, CharacterScoreManager
5
+ from .anime_permissons import group_permission_manager
6
+ from .anime_image import CharacterScoreImageGenerator, AnimeScoreImageGenerator
7
+ from .anime_api import BangumiAPIClient
8
+
9
+ # 1. 图片管理器
10
+ image_manager = ImageManager()
11
+
12
+ # 2. BGM 数据管理器
13
+ bgm_manager = BGMDataManager(image_manager)
14
+
15
+ # 3. 标签管理器
16
+ tag_manager = TagManager(bgm_manager)
17
+
18
+ # 4. 用户评分管理器
19
+ user_score_manager = UserScoreManager()
20
+
21
+ # 5. 角色评级管理器
22
+ character_score_manager = CharacterScoreManager(bgm_manager)
23
+
24
+ # 6. Bangumi API
25
+ bgm_api = BangumiAPIClient()
26
+
27
+ # 7. 图片生成器
28
+ character_image_generator = CharacterScoreImageGenerator(bgm_manager, character_score_manager)
29
+ anime_score_image_generator = AnimeScoreImageGenerator(bgm_manager, user_score_manager)
30
+
31
+
32
+ __all__ = [
33
+ "image_manager",
34
+ "bgm_manager",
35
+ "tag_manager",
36
+ "user_score_manager",
37
+ "character_score_manager",
38
+ "bgm_api",
39
+ "group_permission_manager",
40
+ "character_image_generator",
41
+ "anime_score_image_generator",
42
+ ]
@@ -0,0 +1,370 @@
1
+ import asyncio
2
+ import random
3
+ import aiohttp
4
+ from typing import Dict, Optional
5
+
6
+ from nonebot import logger
7
+
8
+ from ..config import BANGUMI_API_TOKEN, BANGUMI_API_UA, HTTP_PROXY, STAFF_ROLE_MAPPING, QUARTER_MONTHS, SUBJECT_TYPE_LABEL
9
+
10
+
11
+
12
+ class BangumiAPIClient:
13
+ """Bangumi API"""
14
+
15
+ def __init__(self, proxy: str = HTTP_PROXY):
16
+ self._base = "https://api.bgm.tv"
17
+ self._token = BANGUMI_API_TOKEN
18
+ self._ua = BANGUMI_API_UA
19
+ self._proxy = proxy
20
+ self._max_retries = 3
21
+
22
+ def _headers(self) -> dict:
23
+ h = {
24
+ "User-Agent": self._ua,
25
+ "Accept": "application/json",
26
+ "Content-Type": "application/json",
27
+ }
28
+ if self._token:
29
+ h["Authorization"] = f"Bearer {self._token}"
30
+ return h
31
+
32
+ async def _request(self, method: str, path: str, *, params: dict = None, json_body: dict = None) -> Optional[dict]:
33
+ url = f"{self._base}{path}"
34
+ for attempt in range(self._max_retries):
35
+ try:
36
+ async with aiohttp.ClientSession() as sess:
37
+ async with sess.request(
38
+ method, url,
39
+ headers=self._headers(),
40
+ params=params,
41
+ json=json_body,
42
+ proxy=self._proxy or None,
43
+ timeout=aiohttp.ClientTimeout(total=30),
44
+ ) as resp:
45
+ if resp.status == 200:
46
+ return await resp.json()
47
+ elif resp.status == 429:
48
+ wait = (attempt + 1) * 10
49
+ logger.warning(f"Bangumi API 请求过多,等待 {wait}s")
50
+ await asyncio.sleep(wait)
51
+ else:
52
+ text = await resp.text()
53
+ logger.warning(f"Bangumi API HTTP {resp.status}: {text[:200]}")
54
+ except aiohttp.ClientConnectorError as e:
55
+ logger.warning(f"Bangumi API 连接错误 ({attempt + 1}/{self._max_retries}): {e}")
56
+ except asyncio.TimeoutError:
57
+ logger.warning(f"Bangumi API 超时 ({attempt + 1}/{self._max_retries})")
58
+ except Exception as e:
59
+ logger.opt(exception=True).error(f"Bangumi API 异常 ({attempt + 1}/{self._max_retries})")
60
+
61
+ if attempt < self._max_retries - 1:
62
+ delay = 2 * (2 ** attempt) + random.uniform(0, 1)
63
+ await asyncio.sleep(delay)
64
+
65
+ logger.error(f"Bangumi API 所有重试均失败: {method} {path}")
66
+ return None
67
+
68
+ async def _get(self, path: str, **params) -> Optional[dict]:
69
+ return await self._request("GET", path, params=params or None)
70
+
71
+ async def _post(self, path: str, body: dict, **params) -> Optional[dict]:
72
+ return await self._request("POST", path, params=params or None, json_body=body)
73
+
74
+ # ---------- 条目 ----------
75
+
76
+ async def get_subject(self, subject_id: int) -> Optional[dict]:
77
+ """获取条目详情 GET /v0/subjects/{id}"""
78
+ return await self._get(f"/v0/subjects/{subject_id}")
79
+
80
+ async def get_subject_persons(self, subject_id: int) -> Optional[list]:
81
+ """获取制作人员 GET /v0/subjects/{id}/persons"""
82
+ return await self._get(f"/v0/subjects/{subject_id}/persons")
83
+
84
+ async def get_subject_characters(self, subject_id: int) -> Optional[list]:
85
+ """获取角色列表 GET /v0/subjects/{id}/characters"""
86
+ return await self._get(f"/v0/subjects/{subject_id}/characters")
87
+
88
+ async def get_episodes(self, subject_id: int, limit: int = 100) -> Optional[dict]:
89
+ """获取章节列表 GET /v0/episodes"""
90
+ return await self._get("/v0/episodes", subject_id=subject_id, limit=limit, offset=0)
91
+
92
+ async def search_subjects(self, keyword: str, *, filters: dict = None, sort: str = "rank", limit: int = 50) -> Optional[dict]:
93
+ """搜索条目 POST /v0/search/subjects"""
94
+ body = {"keyword": keyword, "sort": sort, "filter": filters or {}}
95
+ return await self._post("/v0/search/subjects", body, limit=limit, offset=0)
96
+
97
+ async def fetch_anime_by_id(self, subject_id: int) -> Optional[dict]:
98
+ """通过 ID 获取番剧完整数据"""
99
+ subject = await self.get_subject(subject_id)
100
+ if not subject:
101
+ return None
102
+
103
+ # 非动画类型跳过
104
+ if subject.get("type") != 2:
105
+ logger.debug(f"条目 {subject_id} 类型非动画({SUBJECT_TYPE_LABEL.get(subject['type'], '未知')}),跳过")
106
+ return None
107
+
108
+ persons = await self.get_subject_persons(subject_id) or []
109
+ characters = await self.get_subject_characters(subject_id) or []
110
+
111
+ # 补充角色译名(逐个调角色详情取 infobox 中的 简体中文名)
112
+ characters = await self._enrich_characters(characters)
113
+
114
+ return self._assemble_anime_data(subject, persons, characters)
115
+
116
+ async def fetch_anime_by_month(self, year: int, month: int, media_type: str = "tv", max_concurrency: int = 1) -> Dict[str, dict]:
117
+ """获取指定月份的番剧列表,返回 {中文标题: 完整数据} 字典"""
118
+ season_info = QUARTER_MONTHS.get(month, (f"{month:02d}", f"{month:02d}"))
119
+ air_from = f">={year}-{season_info[0]}-01"
120
+
121
+ end_month_num = int(season_info[1]) + 1
122
+ if end_month_num > 12:
123
+ air_to = f"<{year + 1}-01-01"
124
+ else:
125
+ air_to = f"<{year}-{end_month_num:02d}-01"
126
+
127
+ filters = {
128
+ "type": [2],
129
+ "air_date": [air_from, air_to],
130
+ }
131
+
132
+ logger.info(f"开始获取 {year}年{month}月 {media_type} 番剧")
133
+
134
+ result = await self.search_subjects("", filters=filters, sort="date", limit=50)
135
+ if not result or "data" not in result:
136
+ logger.warning(f"{year}年{month}月搜索结果为空")
137
+ return {}
138
+
139
+ items = result["data"]
140
+
141
+ if media_type == "tv":
142
+ items = [
143
+ s for s in items
144
+ if s.get("platform", "").lower() in ("tv", "web", "ova", "")
145
+ ]
146
+ elif media_type == "movie":
147
+ items = [
148
+ s for s in items
149
+ if s.get("platform", "").lower() in ("movie", "film", "剧场版")
150
+ ]
151
+
152
+ logger.info(f"找到 {len(items)} 个条目,开始获取详情(并发={max_concurrency})")
153
+
154
+ all_data = {}
155
+
156
+ async def _fetch_one(item: dict):
157
+ sid = item["id"]
158
+ name = item.get('name_cn') or item['name']
159
+ logger.debug(f"获取 {name} ({sid})")
160
+ anime = await self.fetch_anime_by_id(sid)
161
+ if anime:
162
+ key = anime["basic_info"].get("chinese_title") or anime.get("title_cn", "")
163
+ if not key:
164
+ key = anime["basic_info"].get("original_title", "")
165
+ all_data[key] = anime
166
+
167
+ if max_concurrency > 1:
168
+ sem = asyncio.Semaphore(max_concurrency)
169
+
170
+ async def _fetch_with_limit(item):
171
+ async with sem:
172
+ await _fetch_one(item)
173
+ await asyncio.sleep(random.uniform(0.5, 1.5))
174
+
175
+ await asyncio.gather(*[_fetch_with_limit(item) for item in items])
176
+ else:
177
+ for i, item in enumerate(items):
178
+ await _fetch_one(item)
179
+ if i < len(items) - 1:
180
+ await asyncio.sleep(random.uniform(0.5, 1.5))
181
+
182
+ return all_data
183
+
184
+ async def _enrich_characters(self, characters: list) -> list:
185
+ """为角色列表补充译名"""
186
+ enriched = []
187
+ for i, c in enumerate(characters):
188
+ cid = c.get("id")
189
+ translated = ""
190
+ if cid:
191
+ detail = await self._get(f"/v0/characters/{cid}")
192
+ if detail:
193
+ infobox = detail.get("infobox", []) or []
194
+ for item in infobox:
195
+ raw = item.get("value")
196
+ if item.get("key") == "简体中文名" and raw:
197
+ if isinstance(raw, str):
198
+ translated = raw.strip()
199
+ elif isinstance(raw, list) and raw:
200
+ translated = str(raw[0] if isinstance(raw[0], str) else raw[0].get("v", "")).strip()
201
+ break
202
+ # 请求间隔
203
+ if i < len(characters) - 1:
204
+ await asyncio.sleep(0.3)
205
+ c["translated_name"] = translated
206
+ enriched.append(c)
207
+ return enriched
208
+
209
+ def _assemble_anime_data(self, subject: dict, persons: list, characters: list) -> dict:
210
+ """数据组装为兼容格式"""
211
+ name_cn = subject.get("name_cn", "") or ""
212
+ name_jp = subject.get("name", "")
213
+
214
+ # 媒体类型
215
+ media_type = "tv"
216
+ platform = (subject.get("platform") or "").lower()
217
+ if platform in ("movie", "film", "剧场版"):
218
+ media_type = "movie"
219
+ elif platform in ("web", "ova"):
220
+ media_type = platform
221
+
222
+ # 评分
223
+ rating = subject.get("rating", {}) or {}
224
+ score = rating.get("score", 0)
225
+ votes = rating.get("total", 0)
226
+ rank_val = rating.get("rank", 0)
227
+ score_desc = self._score_description(score)
228
+
229
+ # 标签
230
+ tags = [
231
+ {"name": t["name"], "count": t.get("count", 0)}
232
+ for t in (subject.get("tags", []) or [])
233
+ ]
234
+
235
+ # 封面
236
+ images = subject.get("images", {}) or {}
237
+ cover = images.get("large", "") or images.get("common", "")
238
+
239
+ # infobox 解析(放送星期、别名)
240
+ infobox = subject.get("infobox", []) or []
241
+ broadcast_day = ""
242
+ aliases = []
243
+ for item in infobox:
244
+ key = item.get("key", "")
245
+ raw = item.get("value")
246
+ if not raw:
247
+ continue
248
+
249
+ if isinstance(raw, str):
250
+ value = raw.strip()
251
+ if key == "放送星期" and value:
252
+ broadcast_day = value
253
+ elif key in ("别名", "其他译名", "别称") and value:
254
+ for a in value.split(","):
255
+ a = a.strip()
256
+ if a:
257
+ aliases.append(a)
258
+
259
+ elif isinstance(raw, list):
260
+ values = []
261
+ for v in raw:
262
+ if isinstance(v, dict) and v.get("v"):
263
+ values.append(v["v"].strip())
264
+ if key == "放送星期" and values:
265
+ broadcast_day = values[0]
266
+ elif key in ("别名", "其他译名", "别称"):
267
+ aliases.extend(values)
268
+
269
+ # 话数
270
+ eps = subject.get("eps", 0) or subject.get("total_episodes", 0) or 0
271
+ eps_str = str(eps) if eps else ""
272
+
273
+ # 日期
274
+ date = subject.get("date", "") or ""
275
+
276
+ # Staff 组装
277
+ staff = {
278
+ "director": [],
279
+ "script": [],
280
+ "storyboard": [],
281
+ "episode_director": [],
282
+ "music": [],
283
+ "character_design": [],
284
+ "series_composition": [],
285
+ }
286
+ production_companies = []
287
+
288
+ for p in persons:
289
+ relation = (p.get("relation") or "").strip()
290
+ name = p.get("name", "")
291
+ if not relation or not name:
292
+ continue
293
+
294
+ # 动画制作 / 製作
295
+ if relation in ("动画制作", "製作", "制作"):
296
+ production_companies.append(name)
297
+ continue
298
+
299
+ # 按 relation 映射到 staff 角色
300
+ mapped = STAFF_ROLE_MAPPING.get(relation)
301
+ if mapped and mapped in staff:
302
+ staff[mapped].append(name)
303
+ else:
304
+ # 未知关系放到对应键
305
+ if relation not in staff:
306
+ staff[relation] = []
307
+ staff[relation].append(name)
308
+
309
+ # 角色 + CV 组装
310
+ char_list = []
311
+ for c in characters:
312
+ actors = c.get("actors", []) or []
313
+ cv_name = actors[0].get("name", "") if actors else ""
314
+ char_list.append({
315
+ "original_name": c.get("name", ""),
316
+ "translated_name": c.get("translated_name", ""),
317
+ "role": (c.get("relation") or "配角") if c.get("relation") else "配角",
318
+ "cv": cv_name,
319
+ "image_url": (c.get("images", {}) or {}).get("large", ""),
320
+ })
321
+
322
+ return {
323
+ "id": str(subject["id"]),
324
+ "title_cn": name_cn,
325
+ "title_jp": name_jp,
326
+ "cover_url": cover,
327
+ "basic_info": {
328
+ "original_title": name_jp,
329
+ "chinese_title": name_cn,
330
+ "start_date": date,
331
+ "episodes": eps_str,
332
+ "media_type": media_type,
333
+ "broadcast_day": broadcast_day,
334
+ },
335
+ "rating": {
336
+ "score": float(score) if score else 0,
337
+ "votes": votes,
338
+ "rank": rank_val,
339
+ "description": score_desc,
340
+ },
341
+ "production": {
342
+ "animation_production": production_companies,
343
+ },
344
+ "staff": staff,
345
+ "characters": char_list,
346
+ "tags": tags,
347
+ "aliases": aliases,
348
+ }
349
+
350
+ @staticmethod
351
+ def _score_description(score: float) -> str:
352
+ if score <= 0:
353
+ return ""
354
+ if score >= 8.5:
355
+ return "神作"
356
+ if score >= 7.5:
357
+ return "力荐"
358
+ if score >= 6.5:
359
+ return "推荐"
360
+ if score >= 5.5:
361
+ return "还行"
362
+ if score >= 4.5:
363
+ return "不过不失"
364
+ if score >= 3.5:
365
+ return "较差"
366
+ if score >= 2.5:
367
+ return "差"
368
+ if score >= 1.5:
369
+ return "很差"
370
+ return "不忍直视"