nonebot-plugin-ba-gamekee 0.2.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.
- nonebot_plugin_ba_gamekee/__init__.py +57 -0
- nonebot_plugin_ba_gamekee/bot/__init__.py +3 -0
- nonebot_plugin_ba_gamekee/bot/article_delivery.py +70 -0
- nonebot_plugin_ba_gamekee/bot/dependencies.py +134 -0
- nonebot_plugin_ba_gamekee/bot/handlers/__init__.py +3 -0
- nonebot_plugin_ba_gamekee/bot/handlers/guides.py +173 -0
- nonebot_plugin_ba_gamekee/config.py +13 -0
- nonebot_plugin_ba_gamekee/core/__init__.py +17 -0
- nonebot_plugin_ba_gamekee/core/guide_kinds.py +92 -0
- nonebot_plugin_ba_gamekee/core/models.py +61 -0
- nonebot_plugin_ba_gamekee/core/servers.py +47 -0
- nonebot_plugin_ba_gamekee/infra/__init__.py +3 -0
- nonebot_plugin_ba_gamekee/infra/article_renderer.py +678 -0
- nonebot_plugin_ba_gamekee/infra/current_guides_cache.py +56 -0
- nonebot_plugin_ba_gamekee/infra/gamekee.py +199 -0
- nonebot_plugin_ba_gamekee/infra/screenshot_cache.py +215 -0
- nonebot_plugin_ba_gamekee-0.2.0.dist-info/METADATA +134 -0
- nonebot_plugin_ba_gamekee-0.2.0.dist-info/RECORD +19 -0
- nonebot_plugin_ba_gamekee-0.2.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
from nonebot import get_driver, require
|
|
2
|
+
from nonebot.plugin import PluginMetadata, inherit_supported_adapters
|
|
3
|
+
|
|
4
|
+
require("nonebot_plugin_alconna")
|
|
5
|
+
require("nonebot_plugin_uninfo")
|
|
6
|
+
require("nonebot_plugin_localstore")
|
|
7
|
+
require("nonebot_plugin_apscheduler")
|
|
8
|
+
|
|
9
|
+
from nonebot_plugin_apscheduler import scheduler
|
|
10
|
+
|
|
11
|
+
from . import bot as bot
|
|
12
|
+
from .bot.dependencies import (
|
|
13
|
+
get_current_guide_cache,
|
|
14
|
+
get_current_guide_client,
|
|
15
|
+
refresh_current_guides,
|
|
16
|
+
startup_current_guides,
|
|
17
|
+
)
|
|
18
|
+
from .config import Config, plugin_config
|
|
19
|
+
|
|
20
|
+
__plugin_meta__ = PluginMetadata(
|
|
21
|
+
name="BA GameKee",
|
|
22
|
+
description="蔚蓝档案 GameKee Wiki 攻略查询",
|
|
23
|
+
usage="/ba活动 国际服\n/ba卡池 国服",
|
|
24
|
+
type="application", # application: 功能性插件 | library: 库插件
|
|
25
|
+
homepage="https://github.com/Misty02600/nonebot-plugin-ba-gamekee",
|
|
26
|
+
config=Config,
|
|
27
|
+
supported_adapters=inherit_supported_adapters("nonebot_plugin_alconna"),
|
|
28
|
+
extra={"author": "Misty02600 <xiao02600@gmail.com>"},
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
driver = get_driver()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@driver.on_startup
|
|
36
|
+
async def _sync_current_guides_on_startup() -> None:
|
|
37
|
+
await startup_current_guides(
|
|
38
|
+
client=get_current_guide_client(),
|
|
39
|
+
cache=get_current_guide_cache(),
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
async def _refresh_current_guides_job() -> None:
|
|
44
|
+
await refresh_current_guides(
|
|
45
|
+
client=get_current_guide_client(),
|
|
46
|
+
cache=get_current_guide_cache(),
|
|
47
|
+
silent=True,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
scheduler.add_job(
|
|
52
|
+
_refresh_current_guides_job,
|
|
53
|
+
"interval",
|
|
54
|
+
minutes=plugin_config.ba_gamekee_refresh_interval_minutes,
|
|
55
|
+
id="ba_gamekee_refresh_current_guides",
|
|
56
|
+
replace_existing=True,
|
|
57
|
+
)
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Sequence
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from nonebot.adapters import Bot, Event
|
|
7
|
+
from nonebot_plugin_alconna import CustomNode, UniMessage
|
|
8
|
+
from nonebot_plugin_uninfo import get_interface
|
|
9
|
+
|
|
10
|
+
DEFAULT_FORWARD_SENDER_NAME = "BA GameKee"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
async def send_article_pages(
|
|
14
|
+
*,
|
|
15
|
+
bot: Bot,
|
|
16
|
+
event: Event,
|
|
17
|
+
page_paths: Sequence[Path],
|
|
18
|
+
) -> None:
|
|
19
|
+
"""按平台能力发送文章分页图片。
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
bot: 当前响应 bot。
|
|
23
|
+
event: 当前事件。
|
|
24
|
+
page_paths: 已渲染完成的分页图片路径。
|
|
25
|
+
"""
|
|
26
|
+
if not page_paths:
|
|
27
|
+
raise ValueError("article pages is empty")
|
|
28
|
+
|
|
29
|
+
if len(page_paths) == 1:
|
|
30
|
+
await UniMessage.image(path=page_paths[0], name=page_paths[0].name).send(
|
|
31
|
+
target=event,
|
|
32
|
+
bot=bot,
|
|
33
|
+
fallback=False,
|
|
34
|
+
)
|
|
35
|
+
return
|
|
36
|
+
|
|
37
|
+
await _send_forward_message(bot=bot, event=event, page_paths=page_paths)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
async def _send_forward_message(
|
|
41
|
+
*,
|
|
42
|
+
bot: Bot,
|
|
43
|
+
event: Event,
|
|
44
|
+
page_paths: Sequence[Path],
|
|
45
|
+
) -> None:
|
|
46
|
+
sender_name = await _resolve_forward_sender_name(bot)
|
|
47
|
+
nodes = [
|
|
48
|
+
CustomNode(
|
|
49
|
+
uid=str(bot.self_id),
|
|
50
|
+
name=sender_name,
|
|
51
|
+
content=UniMessage.image(path=path, name=path.name),
|
|
52
|
+
)
|
|
53
|
+
for path in page_paths
|
|
54
|
+
]
|
|
55
|
+
await UniMessage.reference(*nodes).send(target=event, bot=bot)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
async def _resolve_forward_sender_name(bot: Bot) -> str:
|
|
59
|
+
interface = get_interface(bot)
|
|
60
|
+
if interface is None:
|
|
61
|
+
return DEFAULT_FORWARD_SENDER_NAME
|
|
62
|
+
|
|
63
|
+
try:
|
|
64
|
+
user = await interface.get_user(str(bot.self_id))
|
|
65
|
+
except Exception:
|
|
66
|
+
return DEFAULT_FORWARD_SENDER_NAME
|
|
67
|
+
|
|
68
|
+
if user is None or not user.name:
|
|
69
|
+
return DEFAULT_FORWARD_SENDER_NAME
|
|
70
|
+
return user.name
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from nonebot import logger
|
|
4
|
+
|
|
5
|
+
from ..core.models import GuideEntry
|
|
6
|
+
from ..infra.article_renderer import ArticleRenderer
|
|
7
|
+
from ..infra.current_guides_cache import CurrentGuideCache
|
|
8
|
+
from ..infra.gamekee import GameKeeClient
|
|
9
|
+
from ..infra.screenshot_cache import ScreenshotCache
|
|
10
|
+
|
|
11
|
+
_current_guide_client = GameKeeClient()
|
|
12
|
+
_current_guide_cache = CurrentGuideCache()
|
|
13
|
+
_article_renderer = ArticleRenderer()
|
|
14
|
+
_screenshot_cache = ScreenshotCache()
|
|
15
|
+
_current_guide_entries: dict[str, GuideEntry] = {}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def get_current_guide_client() -> GameKeeClient:
|
|
19
|
+
"""获取共享的当前攻略入口数据源。
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
GameKee 当前攻略入口客户端。
|
|
23
|
+
"""
|
|
24
|
+
return _current_guide_client
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def get_current_guide_cache() -> CurrentGuideCache:
|
|
28
|
+
"""获取共享的当前攻略入口缓存。
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
当前攻略入口缓存实例。
|
|
32
|
+
"""
|
|
33
|
+
return _current_guide_cache
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def get_current_guide_entries() -> dict[str, GuideEntry]:
|
|
37
|
+
"""获取当前攻略入口内存快照。
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
当前攻略入口内存快照。
|
|
41
|
+
"""
|
|
42
|
+
return _current_guide_entries
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def get_article_renderer() -> ArticleRenderer:
|
|
46
|
+
"""获取共享的文章渲染器。
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
完整文章截图渲染器。
|
|
50
|
+
"""
|
|
51
|
+
return _article_renderer
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def get_screenshot_cache() -> ScreenshotCache:
|
|
55
|
+
"""获取共享的文章截图缓存。
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
完整文章截图缓存实例。
|
|
59
|
+
"""
|
|
60
|
+
return _screenshot_cache
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def load_current_guides_cache(*, cache: CurrentGuideCache) -> dict[str, GuideEntry]:
|
|
64
|
+
"""从本地缓存加载最近一次成功同步的攻略入口快照。
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
cache: 当前攻略入口缓存。
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
当前攻略入口内存快照。
|
|
71
|
+
"""
|
|
72
|
+
global _current_guide_entries
|
|
73
|
+
_current_guide_entries = cache.load()
|
|
74
|
+
return _current_guide_entries
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
async def refresh_current_guides(
|
|
78
|
+
*,
|
|
79
|
+
client: GameKeeClient,
|
|
80
|
+
cache: CurrentGuideCache,
|
|
81
|
+
silent: bool = False,
|
|
82
|
+
) -> dict[str, GuideEntry]:
|
|
83
|
+
"""从 GameKee 刷新内存中的攻略入口快照。
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
client: 当前攻略入口数据源。
|
|
87
|
+
cache: 当前攻略入口缓存。
|
|
88
|
+
silent: 为 `True` 时,刷新失败会保留旧缓存并记录 warning,
|
|
89
|
+
而不是继续抛出异常。
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
当前攻略入口内存快照。
|
|
93
|
+
"""
|
|
94
|
+
global _current_guide_entries
|
|
95
|
+
try:
|
|
96
|
+
entries = await client.fetch_current_guide_entries()
|
|
97
|
+
if not entries:
|
|
98
|
+
raise RuntimeError("GameKee 当前入口为空")
|
|
99
|
+
_current_guide_entries = entries
|
|
100
|
+
cache.save(_current_guide_entries)
|
|
101
|
+
logger.info(f"BA GameKee 当前入口已同步:{len(entries)} 条")
|
|
102
|
+
except Exception as e:
|
|
103
|
+
if silent:
|
|
104
|
+
logger.warning(f"同步 BA GameKee 当前入口失败,将使用旧缓存:{e}")
|
|
105
|
+
return _current_guide_entries
|
|
106
|
+
raise
|
|
107
|
+
return _current_guide_entries
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
async def startup_current_guides(
|
|
111
|
+
*,
|
|
112
|
+
client: GameKeeClient,
|
|
113
|
+
cache: CurrentGuideCache,
|
|
114
|
+
) -> None:
|
|
115
|
+
"""启动时先加载本地缓存,再尝试静默刷新远端数据。
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
client: 当前攻略入口数据源。
|
|
119
|
+
cache: 当前攻略入口缓存。
|
|
120
|
+
"""
|
|
121
|
+
load_current_guides_cache(cache=cache)
|
|
122
|
+
await refresh_current_guides(client=client, cache=cache, silent=True)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
__all__ = [
|
|
126
|
+
"get_article_renderer",
|
|
127
|
+
"get_current_guide_cache",
|
|
128
|
+
"get_current_guide_client",
|
|
129
|
+
"get_current_guide_entries",
|
|
130
|
+
"get_screenshot_cache",
|
|
131
|
+
"load_current_guides_cache",
|
|
132
|
+
"refresh_current_guides",
|
|
133
|
+
"startup_current_guides",
|
|
134
|
+
]
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from arclet.alconna import Alconna, Args, Arparma, CommandMeta, Empty, Subcommand
|
|
4
|
+
from nonebot import logger
|
|
5
|
+
from nonebot.adapters import Bot, Event
|
|
6
|
+
from nonebot_plugin_alconna import AlconnaMatches, Match, UniMessage, on_alconna
|
|
7
|
+
|
|
8
|
+
from ...core.guide_kinds import GuideKindKey, guide_kind_by_key
|
|
9
|
+
from ...core.servers import server_for, supported_server_names
|
|
10
|
+
from ..article_delivery import send_article_pages
|
|
11
|
+
from ..dependencies import (
|
|
12
|
+
get_article_renderer,
|
|
13
|
+
get_current_guide_client,
|
|
14
|
+
get_current_guide_entries,
|
|
15
|
+
get_screenshot_cache,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
ba_gamekee_command = Alconna(
|
|
19
|
+
"ba",
|
|
20
|
+
Subcommand("活动", Args["server?", str]),
|
|
21
|
+
Subcommand("卡池", Args["server?", str]),
|
|
22
|
+
meta=CommandMeta(description="BA GameKee 查询", compact=True),
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
ba_gamekee_matcher = on_alconna(
|
|
26
|
+
ba_gamekee_command,
|
|
27
|
+
use_cmd_start=True,
|
|
28
|
+
block=True,
|
|
29
|
+
priority=10,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@ba_gamekee_matcher.handle()
|
|
34
|
+
async def handle_guide_command(
|
|
35
|
+
bot: Bot,
|
|
36
|
+
event: Event,
|
|
37
|
+
result: Arparma = AlconnaMatches(),
|
|
38
|
+
) -> None:
|
|
39
|
+
"""处理 BA GameKee 当前攻略类子命令。"""
|
|
40
|
+
if result.query("活动", Empty) is not Empty:
|
|
41
|
+
await handle_activity(
|
|
42
|
+
bot=bot,
|
|
43
|
+
event=event,
|
|
44
|
+
server=_server_match(result, "活动"),
|
|
45
|
+
)
|
|
46
|
+
return
|
|
47
|
+
|
|
48
|
+
if result.query("卡池", Empty) is not Empty:
|
|
49
|
+
await handle_gacha_review(
|
|
50
|
+
bot=bot,
|
|
51
|
+
event=event,
|
|
52
|
+
server=_server_match(result, "卡池"),
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
async def handle_activity(
|
|
57
|
+
bot: Bot,
|
|
58
|
+
event: Event,
|
|
59
|
+
server: Match[str | None],
|
|
60
|
+
) -> None:
|
|
61
|
+
"""处理 `ba 活动` 命令。
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
server: Alconna 解析出的服务器参数。
|
|
65
|
+
"""
|
|
66
|
+
await _handle_current_guide(
|
|
67
|
+
bot=bot,
|
|
68
|
+
event=event,
|
|
69
|
+
server=server,
|
|
70
|
+
kind_key="activity",
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
async def handle_gacha_review(
|
|
75
|
+
bot: Bot,
|
|
76
|
+
event: Event,
|
|
77
|
+
server: Match[str | None],
|
|
78
|
+
) -> None:
|
|
79
|
+
"""处理 `ba 卡池` 命令。
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
server: Alconna 解析出的服务器参数。
|
|
83
|
+
"""
|
|
84
|
+
await _handle_current_guide(
|
|
85
|
+
bot=bot,
|
|
86
|
+
event=event,
|
|
87
|
+
server=server,
|
|
88
|
+
kind_key="gacha_review",
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _server_match(result: Arparma, subcommand: str) -> Match[str | None]:
|
|
93
|
+
server = result.query(f"{subcommand}.args.server", Empty)
|
|
94
|
+
return Match(None, False) if server is Empty else Match(str(server), True)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
async def _handle_current_guide(
|
|
98
|
+
*,
|
|
99
|
+
bot: Bot,
|
|
100
|
+
event: Event,
|
|
101
|
+
server: Match[str | None],
|
|
102
|
+
kind_key: GuideKindKey,
|
|
103
|
+
) -> None:
|
|
104
|
+
kind = guide_kind_by_key(kind_key)
|
|
105
|
+
if not server.available or server.result is None:
|
|
106
|
+
await UniMessage.text(
|
|
107
|
+
f"请提供服务器,例如:/ba{kind.command_name} 国际服\n"
|
|
108
|
+
f"支持:{supported_server_names()}"
|
|
109
|
+
).finish()
|
|
110
|
+
|
|
111
|
+
current_server = server_for(server.result)
|
|
112
|
+
if current_server is None:
|
|
113
|
+
await UniMessage.text(
|
|
114
|
+
f"当前仅支持这些服务器:{supported_server_names()}"
|
|
115
|
+
).finish()
|
|
116
|
+
|
|
117
|
+
entries = get_current_guide_entries()
|
|
118
|
+
if not entries:
|
|
119
|
+
await UniMessage.text("BA GameKee 当前入口尚未同步成功,请稍后再试。").finish()
|
|
120
|
+
|
|
121
|
+
entry_name = kind.entry_name_for(current_server)
|
|
122
|
+
entry = (
|
|
123
|
+
entries.get(entry_name)
|
|
124
|
+
if entry_name is not None
|
|
125
|
+
else next(
|
|
126
|
+
(
|
|
127
|
+
entry
|
|
128
|
+
for entry in entries.values()
|
|
129
|
+
if kind.matches_entry(entry, current_server)
|
|
130
|
+
),
|
|
131
|
+
None,
|
|
132
|
+
)
|
|
133
|
+
)
|
|
134
|
+
if entry:
|
|
135
|
+
if entry.content_id is None:
|
|
136
|
+
await UniMessage.text("图片发送失败").finish()
|
|
137
|
+
|
|
138
|
+
screenshot_cache = get_screenshot_cache()
|
|
139
|
+
cached_pages = screenshot_cache.load(entry.content_id)
|
|
140
|
+
if cached_pages is None:
|
|
141
|
+
client = get_current_guide_client()
|
|
142
|
+
renderer = get_article_renderer()
|
|
143
|
+
try:
|
|
144
|
+
article = await client.fetch_article_detail(entry.content_id)
|
|
145
|
+
rendered = await renderer.render(article)
|
|
146
|
+
try:
|
|
147
|
+
cached_pages = screenshot_cache.save(
|
|
148
|
+
article=article,
|
|
149
|
+
page_paths=rendered.page_paths,
|
|
150
|
+
)
|
|
151
|
+
finally:
|
|
152
|
+
rendered.cleanup()
|
|
153
|
+
except Exception:
|
|
154
|
+
logger.exception(f"渲染 BA GameKee {kind.display_name}失败")
|
|
155
|
+
await UniMessage.text("图片发送失败").finish()
|
|
156
|
+
|
|
157
|
+
assert cached_pages is not None
|
|
158
|
+
try:
|
|
159
|
+
await send_article_pages(
|
|
160
|
+
bot=bot,
|
|
161
|
+
event=event,
|
|
162
|
+
page_paths=cached_pages.page_paths,
|
|
163
|
+
)
|
|
164
|
+
except Exception:
|
|
165
|
+
logger.exception(f"发送 BA GameKee {kind.display_name}图片失败")
|
|
166
|
+
await UniMessage.text("图片发送失败").finish()
|
|
167
|
+
return
|
|
168
|
+
|
|
169
|
+
expected = entry_name or f"{current_server}{kind.display_name}"
|
|
170
|
+
await UniMessage.text(
|
|
171
|
+
f"当前未找到对应{kind.display_name}入口:{expected}\n"
|
|
172
|
+
"请稍后再试,或等待 BA GameKee 首页入口更新。"
|
|
173
|
+
).finish()
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from nonebot import get_plugin_config
|
|
2
|
+
from pydantic import BaseModel, Field
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class Config(BaseModel):
|
|
6
|
+
ba_gamekee_refresh_interval_minutes: int = Field(
|
|
7
|
+
default=10,
|
|
8
|
+
ge=1,
|
|
9
|
+
description="后台刷新当前攻略入口缓存的间隔,单位为分钟。",
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
plugin_config: Config = get_plugin_config(Config)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from .guide_kinds import (
|
|
2
|
+
GuideKind,
|
|
3
|
+
GuideKindKey,
|
|
4
|
+
guide_kind_by_key,
|
|
5
|
+
)
|
|
6
|
+
from .models import GuideEntry
|
|
7
|
+
from .servers import ServerName, server_for, supported_server_names
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"GuideEntry",
|
|
11
|
+
"GuideKind",
|
|
12
|
+
"GuideKindKey",
|
|
13
|
+
"ServerName",
|
|
14
|
+
"guide_kind_by_key",
|
|
15
|
+
"server_for",
|
|
16
|
+
"supported_server_names",
|
|
17
|
+
]
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Literal
|
|
5
|
+
|
|
6
|
+
from .models import GuideEntry
|
|
7
|
+
from .servers import ServerName
|
|
8
|
+
|
|
9
|
+
GuideKindKey = Literal["activity", "gacha_review", "raid", "grand_assault"]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True, slots=True)
|
|
13
|
+
class GuideKind:
|
|
14
|
+
key: GuideKindKey
|
|
15
|
+
display_name: str
|
|
16
|
+
command_name: str
|
|
17
|
+
entry_name_template: str | None = None
|
|
18
|
+
required_keywords: tuple[str, ...] = ()
|
|
19
|
+
|
|
20
|
+
def entry_name_for(self, server: ServerName) -> str | None:
|
|
21
|
+
"""生成指定服务器对应的 GameKee 入口名。
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
server: 已归一化的服务器显示名。
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
使用固定命名规则时返回精确入口名。
|
|
28
|
+
需要关键词匹配时返回 `None`。
|
|
29
|
+
"""
|
|
30
|
+
if self.entry_name_template is None:
|
|
31
|
+
return None
|
|
32
|
+
return self.entry_name_template.format(server=server)
|
|
33
|
+
|
|
34
|
+
def matches_entry(self, entry: GuideEntry, server: ServerName) -> bool:
|
|
35
|
+
"""判断缓存入口是否属于当前攻略类型。
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
entry: 已解析的 GameKee 入口。
|
|
39
|
+
server: 已归一化的服务器显示名。
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
入口匹配指定服务器和攻略类型时返回 `True`。
|
|
43
|
+
"""
|
|
44
|
+
entry_name = self.entry_name_for(server)
|
|
45
|
+
if entry_name is not None:
|
|
46
|
+
return entry.name == entry_name
|
|
47
|
+
return server in entry.name and all(
|
|
48
|
+
keyword in entry.name for keyword in self.required_keywords
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
_GUIDE_KINDS: tuple[GuideKind, ...] = (
|
|
53
|
+
GuideKind(
|
|
54
|
+
key="activity",
|
|
55
|
+
display_name="活动攻略",
|
|
56
|
+
command_name="活动",
|
|
57
|
+
entry_name_template="{server}活动攻略",
|
|
58
|
+
),
|
|
59
|
+
GuideKind(
|
|
60
|
+
key="gacha_review",
|
|
61
|
+
display_name="卡池评测",
|
|
62
|
+
command_name="卡池",
|
|
63
|
+
entry_name_template="{server}当期卡池评测",
|
|
64
|
+
),
|
|
65
|
+
GuideKind(
|
|
66
|
+
key="raid",
|
|
67
|
+
display_name="总力战",
|
|
68
|
+
command_name="总力",
|
|
69
|
+
required_keywords=("总力战",),
|
|
70
|
+
),
|
|
71
|
+
GuideKind(
|
|
72
|
+
key="grand_assault",
|
|
73
|
+
display_name="大决战",
|
|
74
|
+
command_name="大决战",
|
|
75
|
+
required_keywords=("大决战",),
|
|
76
|
+
),
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
_GUIDE_KIND_BY_KEY = {kind.key: kind for kind in _GUIDE_KINDS}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def guide_kind_by_key(key: GuideKindKey) -> GuideKind:
|
|
84
|
+
"""根据稳定的内部 key 获取攻略类型描述。
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
key: 内部攻略类型 key。
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
对应的攻略类型描述。
|
|
91
|
+
"""
|
|
92
|
+
return _GUIDE_KIND_BY_KEY[key]
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import asdict, dataclass
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(slots=True, frozen=True)
|
|
8
|
+
class GuideEntry:
|
|
9
|
+
"""可直接回复给用户的攻略入口。"""
|
|
10
|
+
|
|
11
|
+
name: str
|
|
12
|
+
url: str
|
|
13
|
+
content_id: int | None = None
|
|
14
|
+
|
|
15
|
+
def to_dict(self) -> dict[str, Any]:
|
|
16
|
+
"""转换为缓存文件可序列化的字典。
|
|
17
|
+
|
|
18
|
+
Returns:
|
|
19
|
+
当前攻略入口的字典表示。
|
|
20
|
+
"""
|
|
21
|
+
return asdict(self)
|
|
22
|
+
|
|
23
|
+
@classmethod
|
|
24
|
+
def from_dict(cls, data: dict[str, Any]) -> "GuideEntry":
|
|
25
|
+
"""从缓存文件中的字典恢复攻略入口。
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
data: 缓存文件中的单条攻略入口数据。
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
当前攻略入口对象。
|
|
32
|
+
"""
|
|
33
|
+
return cls(
|
|
34
|
+
name=str(data["name"]),
|
|
35
|
+
url=str(data["url"]),
|
|
36
|
+
content_id=_optional_int(data.get("content_id")),
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(slots=True, frozen=True)
|
|
41
|
+
class GuideArticleDetail:
|
|
42
|
+
"""可用于完整渲染的 GameKee 文章详情。"""
|
|
43
|
+
|
|
44
|
+
content_id: int
|
|
45
|
+
title: str
|
|
46
|
+
url: str
|
|
47
|
+
content_json: str
|
|
48
|
+
updated_at: int | None = None
|
|
49
|
+
author: str | None = None
|
|
50
|
+
view_count: int | None = None
|
|
51
|
+
like_count: int | None = None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _optional_int(value: Any) -> int | None:
|
|
55
|
+
"""尽量把可空字段转换为整数。"""
|
|
56
|
+
if value in (None, ""):
|
|
57
|
+
return None
|
|
58
|
+
try:
|
|
59
|
+
return int(value)
|
|
60
|
+
except (TypeError, ValueError):
|
|
61
|
+
return None
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Literal
|
|
4
|
+
|
|
5
|
+
ServerName = Literal["日服", "国际服", "国服"]
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
# 用户命令只接受这些显示名;不维护别名和 GameKee 内部区服 id。
|
|
9
|
+
_SERVER_NAMES: tuple[ServerName, ...] = (
|
|
10
|
+
"日服",
|
|
11
|
+
"国际服",
|
|
12
|
+
"国服",
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _normalize_server_name(raw_server: str) -> str:
|
|
17
|
+
"""在服务器查找前归一化用户输入。
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
raw_server: 命令中解析出的原始服务器文本。
|
|
21
|
+
|
|
22
|
+
Returns:
|
|
23
|
+
去除空白并完成大小写折叠后的服务器名。
|
|
24
|
+
"""
|
|
25
|
+
return "".join(raw_server.strip().split()).casefold()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
_SERVER_BY_NAME: dict[str, ServerName] = {
|
|
29
|
+
_normalize_server_name(server): server for server in _SERVER_NAMES
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def server_for(raw_server: str) -> ServerName | None:
|
|
34
|
+
"""按显示名解析受支持的服务器。
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
raw_server: 命令中解析出的原始服务器文本。
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
匹配到的服务器描述;不支持时返回 `None`。
|
|
41
|
+
"""
|
|
42
|
+
return _SERVER_BY_NAME.get(_normalize_server_name(raw_server))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def supported_server_names() -> str:
|
|
46
|
+
"""返回面向用户展示的服务器名称列表。"""
|
|
47
|
+
return "、".join(_SERVER_NAMES)
|