"""数据源协议 + 注册表。 定义 DataSource 契约,并提供全局注册表供路由层分发。 每个比赛数据源实现该协议,注册后即可通过统一入口调度。 当前只有 bzzoiro 一个数据源(Understat / injuries 已移除), 保留协议与注册表是为了统一 ingest 调度入口的结构。 """ from __future__ import annotations import logging from typing import Protocol from src.db.base import AsyncSession logger = logging.getLogger(__name__) class DataSource(Protocol): """比赛数据源契约:抓取 → 规范化 → 入库。""" @property def name(self) -> str: """数据源标识名(用于路由/日志)。""" ... async def ingest(self, db: AsyncSession, **kwargs) -> dict: """执行完整采集流程,返回统计。""" ... # ── 注册表 ── _SOURCES: dict[str, DataSource] = {} # 延迟加载标记:保证 _load_sources() 最多执行一次,重复调用廉价 _loaded = False def register(source): """装饰器:将数据源注册到全局注册表。 兼容类注册与实例注册:类会被实例化后存入(保证 get_source 返回实例)。 """ obj = source() if isinstance(source, type) else source _SOURCES[obj.name] = obj return source def get_source(name: str) -> DataSource: """按名获取数据源。""" _load_sources() if name not in _SOURCES: raise ValueError(f"未知数据源: {name}") return _SOURCES[name] def list_sources() -> list[str]: """列出所有已注册数据源名。""" _load_sources() return list(_SOURCES.keys()) def _load_sources() -> None: """延迟导入数据源触发 @register(避免循环导入)。 导入失败必须留下痕迹:静默吞掉 ImportError 会让注册表恒为空, 导致 get_source() 对所有数据源都报「未知数据源」,把导入错误 伪装成「不存在的名字」—— 这类静默失效极难定位,故在此显式记日志。 失败时保持 _loaded=False:后续调用会重新尝试导入。正常应用流程中 src.api.app 先导入本模块,此处的模块级预热是在 `src.data.bzzoiro` 尚未初始化时发起的,导入链在本模块内成环,首次必然失败(bzzoiro 仍在加载中),由后续 get_source() / list_sources() 调用完成真正的装载。 """ global _loaded if _loaded: return try: from src.data.bzzoiro import BzzoiroSource # noqa: F401 except Exception: logger.exception( "数据源模块导入失败(通常为初始化中途的环状导入),本次注册表为空;" "下次 get_source()/list_sources() 调用会自动重试" ) return # 保持 _loaded=False,下次调用可重试 _loaded = True # 模块加载时预热(失败会记录日志,不再静默) _load_sources()