refactor(chain): chain 与 module 仅经 run_module 契约互联,实现模块内容封闭

- mTorrent 字幕链接解析下沉为 IndexerModule.site_subtitle_links,subtitle 模块自行跳过 API 站点
- TMDB/MusicBrainz 识别缓存管理改为模块方法(tmdb_cache_*/music_cache_*)经 run_module 分发
- WechatClawBot 客户端查找/临时客户端/缓存迁移内聚为 wechatclawbot_* 模块方法,
  chain 移除 WechatClawBot 类与 ModuleManager 内省
- LISTENBRAINZ_* 常量迁至 schemas/types.py,模块与链层再导入保持兼容
- TMDbException 升为 schemas/exception.py 跨层契约,vendored 异常保持类身份一致
- 架构守护测试新增 test_chain_does_not_import_module_internals(共 18 项)
- 文档同步:chain->module 仅允许 run_module 分发,直接导入禁止
This commit is contained in:
jxxghp
2026-08-16 05:25:28 +08:00
parent 02f0dd0b9a
commit 4345fcfa22
21 changed files with 241 additions and 131 deletions
+23 -1
View File
@@ -1,7 +1,7 @@
from datetime import datetime
from typing import List, Optional, Tuple, Union
from app.domain.context import SubtitleInfo, TorrentInfo
from app.domain.context import Context, SubtitleInfo, TorrentInfo
from app.db.oper.site import SiteOper
from app.foundation.reflection import ModuleHelper
from app.application.site.sites import SitesHelper # pylint: disable=no-name-in-module
@@ -94,6 +94,28 @@ class IndexerModule(_ModuleBase):
"""索引模块无需独立开关配置"""
pass
def site_subtitle_links(self, context: Context) -> Optional[List[str]]:
"""
解析采用API访问的站点的字幕下载链接,非API站点返回None交由页面解析模块处理
:param context: 上下文,包括识别信息、媒体信息、种子信息
:return: 字幕下载链接列表,不适用时返回None
"""
torrent = context.torrent_info
if torrent.site is None:
return None
site = SiteOper().get(torrent.site)
if not site:
return None
indexer = SitesHelper().get_indexer(site.domain)
if not indexer:
return None
if indexer.get("parser") == "mTorrent":
return MTorrentSpider(indexer).get_subtitle_links(
torrent.page_url
)
# TODO 其它采用API访问的站点
return None
@staticmethod
def __search_check(site: dict, search_word: Optional[str] = None) -> bool:
"""
+3 -21
View File
@@ -6,6 +6,9 @@ from app.domain.context import MusicInfo
from app.runtime.log import logger
from app.modules import _ModuleBase
from app.schemas.types import (
LISTENBRAINZ_CHART_RANGES,
LISTENBRAINZ_FRESH_MAX_DAYS,
LISTENBRAINZ_FRESH_SORTS,
MUSIC_ENTITY_ALBUM,
MUSIC_ENTITY_RECORDING,
MediaSource,
@@ -14,27 +17,6 @@ from app.schemas.types import (
)
from app.adapters.network.http import RequestUtils
# ListenBrainz 全站统计支持的周期,取值与官方统计页面完全一致
LISTENBRAINZ_CHART_RANGES = (
"this_week",
"this_month",
"this_year",
"week",
"month",
"quarter",
"half_yearly",
"year",
"all_time",
)
# ListenBrainz 官方新发行页面支持的排序方式
LISTENBRAINZ_FRESH_SORTS = (
"release_date",
"artist_credit_name",
"release_name",
)
# ListenBrainz 新发行页面允许回溯或预告的最大天数
LISTENBRAINZ_FRESH_MAX_DAYS = 90
class ListenBrainzModule(_ModuleBase):
"""通过 ListenBrainz 全站统计与新发行数据提供音乐探索能力。"""
+13
View File
@@ -111,6 +111,19 @@ class MusicBrainzModule(_ModuleBase):
self.cache.clear()
logger.info("音乐识别缓存清除完成")
def music_cache_items(self) -> list[dict]:
"""查询音乐识别缓存条目列表。"""
return self.cache.list_items() if self.cache else []
def music_cache_delete(self, cache_key: str) -> dict:
"""按缓存键删除单条音乐识别缓存。"""
return self.cache.delete(cache_key) if self.cache else {}
def music_cache_clear(self) -> None:
"""清空全部音乐识别缓存。"""
if self.cache:
self.cache.clear()
def test(self) -> Tuple[bool, str]:
"""测试 MusicBrainz 搜索接口连通性。"""
result = self._request_json(
+8
View File
@@ -6,6 +6,8 @@ from lxml import etree
from app.runtime.config import settings
from app.domain.context import Context
from app.db.oper.site import SiteOper
from app.application.site.sites import SitesHelper # pylint: disable=no-name-in-module
from app.runtime.log import logger
from app.modules import _ModuleBase
from app.schemas.types import ModuleType, OtherModulesType
@@ -133,6 +135,12 @@ class SubtitleModule(_ModuleBase):
torrent = context.torrent_info
if not torrent.page_url:
return None
# 采用API访问的站点由对应爬虫模块处理,详情页HTML不含字幕元素
if torrent.site is not None:
site = SiteOper().get(torrent.site)
if site and (indexer := SitesHelper().get_indexer(site.domain)):
if indexer.get("parser") == "mTorrent":
return None
request = RequestUtils(
cookies=torrent.site_cookie,
ua=torrent.site_ua,
+18
View File
@@ -818,6 +818,24 @@ class TheMovieDbModule(_ModuleBase):
"""
return self.update_recognize_cache(meta=meta, mediainfo=mediainfo)
def tmdb_cache_items(self) -> list:
"""
查询TMDB识别缓存条目列表
"""
return self.cache.list_items()
def tmdb_cache_delete(self, cache_key: str) -> dict:
"""
按缓存键删除单条TMDB识别缓存
"""
return self.cache.delete(cache_key)
def tmdb_cache_clear(self) -> None:
"""
清空全部TMDB识别缓存
"""
self.cache.clear()
def media_category(self) -> Optional[Dict[str, list]]:
"""
获取媒体分类
@@ -1,5 +1,6 @@
class TMDbException(Exception):
pass
# TMDbException 是跨层异常契约,定义在 schemas 层,供链层与API层捕获;
# 模块内部沿用原导入路径,保持类身份一致
from app.schemas.exception import TMDbException # noqa: F401
class TMDbConnectionError(TMDbException):
+59
View File
@@ -83,6 +83,65 @@ class WechatClawBotModule(_ModuleBase, _MessageBase[WechatClawBot]):
"""初始化模块设置。"""
pass
def wechatclawbot_client(
self,
source: Optional[str] = None,
fallback_source: Optional[str] = None,
):
"""按名称解析已加载的微信 ClawBot 客户端,候选名均无配置时返回默认实例。"""
source_name = str(source or "").strip() or None
fallback_name = str(fallback_source or "").strip() or None
candidate_names = []
for candidate in (fallback_name, source_name):
if candidate and candidate not in candidate_names:
candidate_names.append(candidate)
if candidate_names:
for candidate in candidate_names:
config = self.get_config(candidate)
if not config:
continue
client = self.get_instance(config.name)
if client:
return client
return None
return self.get_instance()
def wechatclawbot_temp_client(
self,
source: Optional[str] = None,
WECHATCLAWBOT_BASE_URL: Optional[str] = None,
WECHATCLAWBOT_DEFAULT_TARGET: Optional[str] = None,
WECHATCLAWBOT_ADMINS: Optional[str] = None,
WECHATCLAWBOT_POLL_TIMEOUT: Optional[int] = None,
):
"""基于当前表单配置创建一个临时客户端,用于未保存时的扫码状态预览。"""
source_name = str(source or "").strip()
if not source_name:
return None
return WechatClawBot(
name=source_name,
WECHATCLAWBOT_BASE_URL=WECHATCLAWBOT_BASE_URL,
WECHATCLAWBOT_DEFAULT_TARGET=WECHATCLAWBOT_DEFAULT_TARGET,
WECHATCLAWBOT_ADMINS=WECHATCLAWBOT_ADMINS,
WECHATCLAWBOT_POLL_TIMEOUT=WECHATCLAWBOT_POLL_TIMEOUT,
auto_start_polling=False,
)
def wechatclawbot_migrate_cache(
self,
old_name: str,
new_name: str,
cleanup_old: bool = False,
overwrite: bool = False,
):
"""在通知名称变更时迁移对应的微信 ClawBot 登录缓存。"""
return WechatClawBot.migrate_cached_state(
old_name=old_name,
new_name=new_name,
cleanup_old=cleanup_old,
overwrite=overwrite,
)
@staticmethod
def _load_json(body: Any) -> Optional[dict]:
"""将内容解析为 JSON 字典。"""