"""新闻抓取模块的数据模型与配置模型。 所有核心对象统一使用 Pydantic 定义(CLAUDE.md 第十条要求)。 """ from __future__ import annotations from datetime import datetime from enum import StrEnum from pydantic import BaseModel, Field, HttpUrl, field_validator class CrawlStage(StrEnum): """抓取阶段枚举,区分列表页和文章页。""" LIST = "list" # 列表/首页(用于发现文章链接) ARTICLE = "article" # 文章详情页 class SourceConfig(BaseModel): """单个新闻源的配置。 映射 configs/sources.yaml 中 sources 列表的每一项。 """ id: str = Field(..., description="新闻源短 ID,用作目录名,仅小写字母与下划线") name: str = Field(..., description="中文名,用于日志展示") enabled: bool = Field(default=True, description="是否启用") homepage: HttpUrl = Field(..., description="新闻列表页/首页 URL") extra_homepages: list[HttpUrl] = Field( default_factory=list, description="额外的列表页 URL(同站多频道时使用),共用同一个 article_url_pattern", ) def all_homepages(self) -> list[str]: """返回所有入口 URL(主入口 + 额外入口)。""" urls = [str(self.homepage)] urls.extend(str(u) for u in self.extra_homepages) return urls # 链接发现规则 article_url_pattern: str = Field( ..., description="正则表达式,从首页所有链接中筛选出文章 URL", ) article_link_selector: str | None = Field( default=None, description="可选 CSS 选择器,优先在选择器内查找文章链接", ) # JS 渲染相关 js_render: bool = Field( default=True, description="是否需要 JS 渲染(财联社/东方财富等动态站点必须为 true)", ) wait_for: str | None = Field( default=None, description="可选 CSS,等待该元素出现后再抓取(JS 渲染时使用)", ) page_timeout_ms: int = Field(default=30_000, ge=1_000, description="页面加载超时(毫秒)") # GNE 提取提示(可选) body_xpath: str | None = Field( default=None, description="GNE body_xpath 参数,强制指定正文容器 XPath", ) # 抓取数量限制 max_articles_per_run: int = Field( default=20, ge=1, le=200, description="单次运行最多抓取的文章数", ) @field_validator("id") @classmethod def _validate_id(cls, v: str) -> str: if not v.replace("_", "").isalnum() or not v.islower(): raise ValueError("source.id 必须为小写字母/数字/下划线") return v class CrawlerSettings(BaseModel): """全局抓取设置。""" concurrency: int = Field(default=3, ge=1, le=20, description="并发抓取上限") retry_max_attempts: int = Field(default=3, ge=1, le=10, description="单 URL 最大重试次数") retry_min_wait_sec: float = Field(default=1.0, ge=0.0, description="重试最小等待秒") retry_max_wait_sec: float = Field(default=10.0, ge=0.0, description="重试最大等待秒") user_agent: str = Field( default=( "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " "(KHTML, like Gecko) Chrome/124.0 Safari/537.36" ), description="浏览器 User-Agent", ) headless: bool = Field(default=True, description="浏览器是否无头") output_root: str = Field(default="data/raw", description="抓取结果根目录") class CrawlerConfig(BaseModel): """整个抓取系统的配置(对应 sources.yaml 顶层)。""" settings: CrawlerSettings = Field(default_factory=CrawlerSettings) sources: list[SourceConfig] def enabled_sources(self) -> list[SourceConfig]: """返回启用的源,按 id 排序。""" return sorted([s for s in self.sources if s.enabled], key=lambda s: s.id) class CninfoItem(BaseModel): """cninfo 公告/调研/互动易数据条目。 一条记录对应一条公告、一次调研活动或一组互动问答。 """ stock_code: str = Field(..., description="股票代码,6 位数字") stock_name: str = Field(..., description="公司简称") title: str = Field(..., description="标题") content: str = Field(default="", description="正文/摘要内容") publish_time: str = Field(default="", description="发布时间,格式 YYYY-MM-DD") item_type: str = Field( default="announcement", description="数据类型: announcement(公告)|research(调研)|irm(互动易)", ) url: str = Field(default="", description="详情页 URL 或 PDF 链接") ann_id: str = Field(default="", description="公告唯一 ID,用于增量去重") extra: dict = Field(default_factory=dict, description="附加字段(公告类型等)") class ArticleLink(BaseModel): """从列表页发现的文章链接。""" source_id: str url: str anchor_text: str | None = None class CrawlResult(BaseModel): """单次抓取结果(列表页或文章页通用)。""" source_id: str stage: CrawlStage url: str success: bool status_code: int | None = None title: str | None = None html: str = "" markdown: str = "" error: str | None = None fetched_at: datetime = Field(default_factory=datetime.now) attempts: int = 1 def short_summary(self) -> str: """生成单行摘要,便于日志输出。""" flag = "OK" if self.success else "FAIL" size = len(self.html) return f"[{flag}] {self.source_id} {self.stage.value} {self.url} html={size}B"