Initial commit

This commit is contained in:
2026-07-18 15:51:01 +08:00
commit f2c80c5a9c
799 changed files with 133475 additions and 0 deletions
+32
View File
@@ -0,0 +1,32 @@
"""新闻抓取模块 (M1)。
公共 API:
- load_crawler_config: 加载 sources.yaml
- crawl_all: 一键抓取所有启用的源
- crawl_source: 抓取单个源(供测试/调试)
- CrawlerConfig / SourceConfig / CrawlResult: 数据模型
"""
from .config import load_crawler_config
from .engine import crawl_all, crawl_source, extract_article_links
from .models import (
ArticleLink,
CrawlerConfig,
CrawlerSettings,
CrawlResult,
CrawlStage,
SourceConfig,
)
__all__ = [
"ArticleLink",
"CrawlResult",
"CrawlStage",
"CrawlerConfig",
"CrawlerSettings",
"SourceConfig",
"crawl_all",
"crawl_source",
"extract_article_links",
"load_crawler_config",
]
+499
View File
@@ -0,0 +1,499 @@
"""cninfo 巨潮资讯网爬虫 v2.0。
从 watchlist.yaml 的 code + orgId 拼接 URL,通过 Crawl4AI(Playwright)渲染 SPA 页面提取数据。
三种数据类型:
1. 公司最新公告 → https://www.cninfo.com.cn/new/disclosure/stock?stockCode={code}&orgId={orgId}#latestAnnouncement
2. 投资者调研 → 同上, #research
3. 互动易问答 → https://irm.cninfo.com.cn/ircs/search?keyword={code}
策略:
- 公告/调研: Playwright SPA 渲染 → DOM 提取(A 方案,REST API 的 stock 参数不可靠)
- 互动易: requests 优先,失败回退 Playwright
- 增量: ann_id 去重,首次从 2026-01-01 全量,后续增量 7 天
"""
from __future__ import annotations
import asyncio
import hashlib
import json
import os
import re
import time
from datetime import date, datetime, timedelta
from pathlib import Path
from typing import Any
import requests
from bs4 import BeautifulSoup
from loguru import logger
from .models import CninfoItem
# --------------------------------------------------------------------------- #
# 常量
# --------------------------------------------------------------------------- #
SOURCE_ID = "cninfo"
CNINFO_PDF_BASE = os.environ.get("CNINFO_PDF_BASE", "http://static.cninfo.com.cn")
# 日期范围: 从 2026-01-01 开始
DATE_START = "2026-01-01"
# 翻页限制
MAX_ANNOUNCE_PAGES = 5
MAX_RESEARCH_PAGES = 1
MAX_IRM_ITEMS = 20
# 请求间隔(秒)
REQUEST_DELAY = 0.5
# --------------------------------------------------------------------------- #
# 工具函数
# --------------------------------------------------------------------------- #
def _url_hash(url: str) -> str:
return hashlib.sha1(url.encode("utf-8")).hexdigest()[:16]
def _today_str() -> str:
return date.today().strftime("%Y%m%d")
def _load_watchlist() -> list[dict]:
import yaml
try:
with open("configs/watchlist.yaml", encoding="utf-8") as f:
data = yaml.safe_load(f) or {}
return list(data.get("watchlist") or [])
except Exception:
logger.exception("加载 watchlist.yaml 失败")
return []
def _make_api_headers() -> dict[str, str]:
return {
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36",
"Accept": "text/html,application/xhtml+xml",
}
def _build_stock_url(code: str, org_id: str) -> str:
"""拼接 cninfo 个股公告页 URL。"""
return f"https://www.cninfo.com.cn/new/disclosure/stock?stockCode={code}&orgId={org_id}"
# --------------------------------------------------------------------------- #
# Crawl4AI SPA 渲染
# --------------------------------------------------------------------------- #
async def _render_page(url: str, timeout_ms: int = 60000,
delay_ms: int = 20) -> str:
"""用 Crawl4AI 渲染 SPA 页面,返回 HTML 字符串。"""
from crawl4ai import AsyncWebCrawler, BrowserConfig, CacheMode, CrawlerRunConfig
bconf = BrowserConfig(headless=True, verbose=False)
rconf = CrawlerRunConfig(
cache_mode=CacheMode.BYPASS,
page_timeout=timeout_ms,
delay_before_return_html=delay_ms,
)
async with AsyncWebCrawler(config=bconf) as c:
result = await c.arun(url=url, config=rconf)
return getattr(result, "html", "") or ""
# --------------------------------------------------------------------------- #
# 公告 + 调研: 从渲染后的 SPA 页面 DOM 提取
# --------------------------------------------------------------------------- #
def _parse_announcement_list(html: str, code: str,
item_type: str = "announcement") -> list[CninfoItem]:
"""从 cninfo 个股页面的渲染 HTML 中提取公告/调研列表。
页面结构: 公告列表以 <a> 标签呈现,关键属性:
- data-seccode: 股票代码
- data-id: 公告 ID
- href: 包含 announcementTime / announcementType 等参数
"""
soup = BeautifulSoup(html, "html.parser")
items: list[CninfoItem] = []
seen: set[str] = set()
# 查找所有带 data-seccode=code 的公告链接
for el in soup.find_all(attrs={"data-seccode": code}):
ann_id = (el.get("data-id") or "").strip()
if not ann_id or ann_id in seen:
continue
seen.add(ann_id)
title = el.get_text(strip=True)
if len(title) < 5:
continue
href = el.get("href", "")
# 从 href 提取发布时间
pub_time = ""
date_match = re.search(r"announcementTime=(\d{4}-\d{2}-\d{2})", href)
if date_match:
pub_time = date_match.group(1)
# 公告类型
ann_type = ""
type_match = re.search(r"announcementType=(\w+)", href)
if type_match:
ann_type = type_match.group(1)
# 板块代码
plate = ""
plate_match = re.search(r"plate=(\w+)", href)
if plate_match:
plate = plate_match.group(1)
# PDF URL
pdf_url = ""
if pub_time and ann_id:
pdf_url = f"{CNINFO_PDF_BASE}/finalpage/{pub_time}/{ann_id}.PDF"
items.append(CninfoItem(
stock_code=code,
stock_name="",
title=title,
content="",
publish_time=pub_time,
item_type=item_type,
url=pdf_url,
ann_id=ann_id,
extra={"announcement_type": ann_type, "plate": plate},
))
return items
def _parse_irm_text(text: str, code: str) -> list[CninfoItem]:
"""从互动易页面文本中提取问答。
注意: cninfo 互动易搜索页是 Vue SPA,问答数据通过需认证的 API 加载。
公开访问时页面显示"暂无数据",此时应返回空列表。
"""
items: list[CninfoItem] = []
url = f"https://irm.cninfo.com.cn/ircs/search?keyword={code}"
# 检测是否为无数据页面
if "暂无数据" in text:
logger.debug(" {} 互动易页面显示'暂无数据'(需登录认证)", code)
return items
# 检测是否为 SPA 空壳(无 JS 渲染时只有导航文本)
text_stripped = text.strip()
if len(text_stripped) < 200 and code in text_stripped:
logger.debug(" {} 互动易页面内容过短(SPA 空壳)", code)
return items
# 按常见分隔模式拆分问答块
blocks = re.split(r"\n(?=\d+\.|\b[问答][:])", text)
for i, block in enumerate(blocks[:MAX_IRM_ITEMS]):
block = block.strip()
if len(block) > 30 and code in block:
items.append(CninfoItem(
stock_code=code,
stock_name="",
title=f"{code} 互动问答 #{i + 1}",
content=block[:5000],
publish_time="",
item_type="irm",
url=url,
ann_id=_url_hash(f"{url}#{i}"),
))
return items
# --------------------------------------------------------------------------- #
# 互动易
# --------------------------------------------------------------------------- #
async def _fetch_irm_playwright(code: str) -> list[CninfoItem]:
"""Playwright 渲染互动易搜索页。"""
url = f"https://irm.cninfo.com.cn/ircs/search?keyword={code}"
try:
html = await _render_page(url, timeout_ms=30000, delay_ms=8)
except Exception as e:
logger.warning(" {} 互动易 Playwright 失败: {}", code, e)
return []
soup = BeautifulSoup(html, "html.parser")
body_text = soup.get_text()
return _parse_irm_text(body_text, code)
def _fetch_irm_requests(code: str) -> list[CninfoItem]:
"""requests 获取互动易搜索页。"""
url = f"https://irm.cninfo.com.cn/ircs/search?keyword={code}"
headers = _make_api_headers()
headers["Referer"] = "https://irm.cninfo.com.cn/"
try:
r = requests.get(url, headers=headers, timeout=15)
r.raise_for_status()
except Exception as e:
logger.debug(" {} 互动易 requests 失败: {}", code, e)
return []
soup = BeautifulSoup(r.text, "html.parser")
body_text = soup.get_text()
return _parse_irm_text(body_text, code)
# --------------------------------------------------------------------------- #
# 单股票抓取
# --------------------------------------------------------------------------- #
async def _crawl_one_stock_async(stock: dict,
start_date: str = DATE_START,
end_date: str | None = None) -> list[CninfoItem]:
"""异步抓取单个公司的公告 + 调研 + 互动易。"""
code = stock["code"]
name = stock["name"]
org_id = stock.get("orgId", "").strip()
if not org_id:
logger.warning("{} ({}) 未配置 orgId,跳过", code, name)
return []
end_date = end_date or date.today().strftime("%Y-%m-%d")
base_url = _build_stock_url(code, org_id)
results: list[CninfoItem] = []
# --- 1) 公告 (#latestAnnouncement) ---
announce_url = f"{base_url}#latestAnnouncement"
logger.info("抓取 {} ({}) 公告: {}", code, name, announce_url[:80])
try:
html = await _render_page(announce_url, timeout_ms=60000, delay_ms=20)
# 日期过滤: 只保留 start_date 之后的
items = _parse_announcement_list(html, code, item_type="announcement")
filtered = [it for it in items if it.publish_time >= start_date]
# 限制页数: 每个页面约 30 条, 5 页 ≈ 150 条(SPA 一次加载可能超过一页)
filtered = filtered[:MAX_ANNOUNCE_PAGES * 30]
for it in filtered:
it.stock_name = name
results.extend(filtered)
logger.info(" {} 公告: {} 条 (过滤后)", code, len(filtered))
except Exception as e:
logger.error(" {} 公告抓取失败: {}", code, e)
# --- 2) 调研 (#research) ---
research_url = f"{base_url}#research"
logger.info("抓取 {} ({}) 调研: {}", code, name, research_url[:80])
try:
html = await _render_page(research_url, timeout_ms=60000, delay_ms=20)
items = _parse_announcement_list(html, code, item_type="research")
filtered = [it for it in items if it.publish_time >= start_date]
filtered = filtered[:MAX_RESEARCH_PAGES * 30]
for it in filtered:
it.stock_name = name
results.extend(filtered)
logger.info(" {} 调研: {} 条 (过滤后)", code, len(filtered))
except Exception as e:
logger.warning(" {} 调研抓取失败(可能无调研页面): {}", code, e)
# --- 3) 互动易 ---
logger.info("抓取 {} ({}) 互动易", code, name)
try:
irm_items = _fetch_irm_requests(code)
if not irm_items:
logger.info(" {} 互动易 requests 无结果,回退 Playwright...", code)
irm_items = await _fetch_irm_playwright(code)
for it in irm_items:
it.stock_name = name
results.extend(irm_items)
logger.info(" {} 互动易: {}", code, len(irm_items))
except Exception as e:
logger.error(" {} 互动易抓取失败: {}", code, e)
return results
# --------------------------------------------------------------------------- #
# 增量保存
# --------------------------------------------------------------------------- #
def _load_seen_ids(out_dir: Path) -> set[str]:
"""从 index.jsonl 加载已保存的公告 ID 集合。"""
seen: set[str] = set()
index_path = out_dir / "index.jsonl"
if index_path.is_file():
with index_path.open("r", encoding="utf-8") as f:
for line in f:
try:
rec = json.loads(line.strip())
aid = rec.get("ann_id", "")
if aid:
seen.add(aid)
except (json.JSONDecodeError, KeyError):
continue
return seen
def _save_items(items: list[CninfoItem], out_dir: Path) -> int:
"""增量保存 CninfoItem 列表,跳过已存在的 ann_id。返回新增条数。"""
out_dir.mkdir(parents=True, exist_ok=True)
seen = _load_seen_ids(out_dir)
logger.info("cninfo 增量模式: 已有 {} 条历史记录", len(seen))
index_path = out_dir / "index.jsonl"
new_count = 0
with index_path.open("a", encoding="utf-8") as index_f:
for item in items:
if item.ann_id and item.ann_id in seen:
continue
seen.add(item.ann_id)
new_count += 1
fname = _url_hash(item.ann_id or item.title + item.stock_code)
json_path = out_dir / f"{fname}.json"
json_path.write_text(
item.model_dump_json(indent=2, ensure_ascii=False),
encoding="utf-8",
)
meta = item.model_dump(mode="json")
meta["source_id"] = SOURCE_ID
meta["json_file"] = f"{fname}.json"
index_f.write(json.dumps(meta, ensure_ascii=False) + "\n")
logger.info("cninfo 已保存: 新增 {} 条 (总计 {} 条) -> {}", new_count, len(seen), out_dir)
return new_count
# --------------------------------------------------------------------------- #
# 主入口
# --------------------------------------------------------------------------- #
def crawl_watchlist(*, save: bool = True) -> list[CninfoItem]:
"""从 watchlist 抓取所有公司的公告+调研+互动易。
首次运行从 2026-01-01 开始,后续增量运行(最近 7 天)。
通过判定 data/raw/cninfo/ 下是否有历史 index.jsonl 来区分首次/增量。
"""
watchlist = _load_watchlist()
if not watchlist:
logger.warning("关注列表为空")
return []
out_dir = Path("data/raw") / SOURCE_ID / _today_str()
# 判断首次还是增量
has_history = False
raw_root = Path("data/raw") / SOURCE_ID
if raw_root.is_dir():
for d in sorted(raw_root.glob("*"), reverse=True):
idx = d / "index.jsonl"
if idx.is_file() and idx.stat().st_size > 0:
has_history = True
break
if has_history:
start = (date.today() - timedelta(days=7)).strftime("%Y-%m-%d")
logger.info("cninfo 增量模式: 日期范围 {} ~ 今天", start)
else:
start = DATE_START
logger.info("cninfo 首次全量: 日期范围 {} ~ 今天", start)
end = date.today().strftime("%Y-%m-%d")
# 并发抓取所有股票
async def _run_all():
tasks = [_crawl_one_stock_async(s, start_date=start, end_date=end)
for s in watchlist]
all_items: list[CninfoItem] = []
for coro in asyncio.as_completed(tasks):
try:
items = await coro
all_items.extend(items)
except Exception as e:
logger.error("某股票抓取异常: {}", e)
return all_items
all_items = asyncio.run(_run_all())
# 统计
ann_count = sum(1 for it in all_items if it.item_type == "announcement")
res_count = sum(1 for it in all_items if it.item_type == "research")
irm_count = sum(1 for it in all_items if it.item_type == "irm")
logger.info(
"cninfo 抓取完成: 公告 {} / 调研 {} / 互动易 {} ({} 家公司,共 {} 条)",
ann_count, res_count, irm_count, len(watchlist), len(all_items),
)
if save and all_items:
new_count = _save_items(all_items, out_dir)
logger.info("cninfo 本轮新增 {}", new_count)
return all_items
# --------------------------------------------------------------------------- #
# PDF 正文提取
# --------------------------------------------------------------------------- #
def enrich_articles_with_pdf(day_str: str | None = None, *, limit: int = 0) -> int:
"""下载 PDF 并用 MarkItDown 提取正文补充到 Article.content。"""
day_str = day_str or _today_str()
proc_dir = Path("data/processed") / SOURCE_ID / day_str
if not proc_dir.is_dir():
return 0
from markitdown import MarkItDown
enriched = 0
for fp in sorted(proc_dir.glob("*.json")):
if limit and enriched >= limit:
break
try:
article_data = json.loads(fp.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
continue
content = article_data.get("content", "")
pdf_url = ""
# 多种方式提取 PDF URL
pdf_match = re.search(r"原文链接:\s*(https?://\S+\.pdf)", content, re.IGNORECASE)
if pdf_match:
pdf_url = pdf_match.group(1)
else:
pdf_match = re.search(r"PDF链接:\s*(https?://\S+\.pdf)", content, re.IGNORECASE)
if pdf_match:
pdf_url = pdf_match.group(1)
else:
url = article_data.get("url", "")
if url.lower().endswith(".pdf"):
pdf_url = url
if not pdf_url:
continue
try:
r = requests.get(pdf_url, headers=_make_api_headers(), timeout=30)
r.raise_for_status()
tmp_path = Path("/tmp") / f"cninfo_pdf_{_url_hash(pdf_url)}.pdf"
tmp_path.write_bytes(r.content)
md = MarkItDown()
result = md.convert(str(tmp_path))
text = (result.text_content or "").strip()[:20000]
if tmp_path.exists():
tmp_path.unlink()
if text and len(text) >= 50:
article_data["content"] = text
article_data["word_count"] = len(text)
fp.write_text(json.dumps(article_data, ensure_ascii=False, indent=2),
encoding="utf-8")
enriched += 1
except Exception as e:
logger.warning("PDF 提取失败 {}: {}", pdf_url, e)
return enriched
+52
View File
@@ -0,0 +1,52 @@
"""抓取配置加载器。
从 YAML 文件读取 sources.yaml,并校验为 CrawlerConfig。
"""
from __future__ import annotations
from pathlib import Path
from typing import Any
import yaml
from loguru import logger
from .models import CrawlerConfig
DEFAULT_CONFIG_PATH = Path("configs/sources.yaml")
def load_crawler_config(path: str | Path | None = None) -> CrawlerConfig:
"""加载并校验抓取配置。
参数:
path: YAML 配置路径,默认 configs/sources.yaml。
返回:
CrawlerConfig 实例。
异常:
FileNotFoundError: 配置文件不存在。
ValueError: YAML 顶层不是 mapping 或 sources 缺失。
pydantic.ValidationError: schema 校验失败。
"""
config_path = Path(path) if path else DEFAULT_CONFIG_PATH
if not config_path.is_file():
raise FileNotFoundError(f"抓取配置文件不存在: {config_path}")
with config_path.open("r", encoding="utf-8") as f:
raw: Any = yaml.safe_load(f)
if not isinstance(raw, dict):
raise ValueError(f"配置文件顶层必须是 mapping,实际类型: {type(raw).__name__}")
if "sources" not in raw:
raise ValueError("配置文件缺少 sources 字段")
config = CrawlerConfig.model_validate(raw)
logger.info(
"已加载抓取配置: 总 {} 个源, 启用 {} 个, 并发上限 {}",
len(config.sources),
len(config.enabled_sources()),
config.settings.concurrency,
)
return config
+445
View File
@@ -0,0 +1,445 @@
"""Crawl4AI 异步抓取引擎。
设计:
1. 一个 AsyncWebCrawler 实例服务所有源(共用浏览器,降低开销);
2. 每个 URL 独立的 CrawlerRunConfig(wait_for/timeout 来自源配置);
3. 全局 Semaphore 控制并发(默认 3);
4. 每个 URL 用 tenacity 重试(默认 3 次,指数退避);
5. 两阶段: 先抓首页 -> 解析文章链接 -> 并发抓文章详情。
"""
from __future__ import annotations
import asyncio
import re
from typing import Any
from urllib.parse import urljoin, urlparse
import httpx
from bs4 import BeautifulSoup
from crawl4ai import AsyncWebCrawler, BrowserConfig, CacheMode, CrawlerRunConfig
from loguru import logger
from markdownify import markdownify as _md_convert
from .models import (
ArticleLink,
CrawlerConfig,
CrawlerSettings,
CrawlResult,
CrawlStage,
SourceConfig,
)
from .storage import mark_url_seen, save_result
# --------------------------------------------------------------------------- #
# 工具函数
# --------------------------------------------------------------------------- #
def _markdown_text(crawl4ai_markdown: Any) -> str:
"""从 Crawl4AI 的 markdown 字段中安全取出字符串。
新版 Crawl4AI(0.4+) 返回 MarkdownGenerationResult 对象;
旧版可能直接是 str。这里做兼容。
"""
if crawl4ai_markdown is None:
return ""
if isinstance(crawl4ai_markdown, str):
return crawl4ai_markdown
raw = getattr(crawl4ai_markdown, "raw_markdown", None)
if isinstance(raw, str):
return raw
fit = getattr(crawl4ai_markdown, "fit_markdown", None)
if isinstance(fit, str):
return fit
return str(crawl4ai_markdown)
def extract_article_links(
html: str,
base_url: str,
source: SourceConfig,
) -> list[ArticleLink]:
"""从列表页 HTML 中抽取文章链接,使用源配置的正则筛选。
步骤:
1. 若设置了 article_link_selector,先 narrow 到选择器范围;
2. 找出所有 <a href>;
3. urljoin 转绝对 URL;
4. 用 article_url_pattern 正则筛选;
5. 去重保持顺序;
6. 截断到 max_articles_per_run。
"""
soup = BeautifulSoup(html, "html.parser")
scope = (
soup.select_one(source.article_link_selector)
if source.article_link_selector
else soup
)
if scope is None:
logger.warning(
"{} 设置了 article_link_selector={!r},但页面中未匹配到", source.id, source.article_link_selector
)
return []
pattern = re.compile(source.article_url_pattern)
seen: set[str] = set()
links: list[ArticleLink] = []
for a in scope.find_all("a", href=True):
href = a["href"].strip()
if not href or href.startswith(("javascript:", "#", "mailto:")):
continue
absolute = urljoin(base_url, href)
# 去掉 fragment
absolute = absolute.split("#", 1)[0]
if not pattern.match(absolute):
continue
if absolute in seen:
continue
seen.add(absolute)
anchor = (a.get_text() or "").strip() or None
links.append(ArticleLink(source_id=source.id, url=absolute, anchor_text=anchor))
if len(links) >= source.max_articles_per_run:
break
logger.info(
"{} 列表页抽取链接 {} 条 (上限 {})",
source.id,
len(links),
source.max_articles_per_run,
)
return links
# --------------------------------------------------------------------------- #
# 单 URL 抓取
# --------------------------------------------------------------------------- #
def _build_run_config(source: SourceConfig) -> CrawlerRunConfig:
"""根据源配置构造 CrawlerRunConfig。"""
return CrawlerRunConfig(
cache_mode=CacheMode.BYPASS,
wait_for=source.wait_for,
page_timeout=source.page_timeout_ms,
excluded_tags=["script", "style", "noscript"],
)
class _TimeoutGuard:
"""单源连续超时计数器,达到阈值后标记跳过,防止整个进程被拖死。"""
def __init__(self, source_id: str, max_consecutive: int = 2) -> None:
self.source_id = source_id
self.max_consecutive = max_consecutive
self._count = 0
self.skip = False
def record(self, error: str | None, success: bool) -> None:
"""根据抓取结果更新计数器。"""
if self.skip:
return
if success:
self._count = 0
return
if error and "timeout" in error.lower():
self._count += 1
if self._count >= self.max_consecutive:
self.skip = True
logger.warning(
"{} 连续超时 {} 次,跳过剩余请求",
self.source_id,
self._count,
)
def skipped_result(self, url: str, stage: CrawlStage) -> CrawlResult:
"""生成"已跳过"结果。"""
return CrawlResult(
source_id=self.source_id,
stage=stage,
url=url,
success=False,
error="Skipped: 源连续超时已跳过",
attempts=0,
)
async def _fetch_static(
url: str,
source: SourceConfig,
stage: CrawlStage,
attempt: int,
) -> CrawlResult:
"""使用 httpx 直连抓取静态页面(js_render=False),绕过 Playwright 反爬检测。"""
headers = {
"User-Agent": (
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36"
),
}
timeout_sec = source.page_timeout_ms / 1000.0
try:
async with httpx.AsyncClient(
timeout=timeout_sec,
follow_redirects=True,
headers=headers,
) as client:
r = await client.get(url)
html = r.text
# HTML → Markdown:先清洗再转换
markdown = ""
if html:
try:
soup = BeautifulSoup(html, "html.parser")
for tag in soup(["script", "style", "noscript", "meta", "link"]):
tag.decompose()
markdown = _md_convert(str(soup)) or ""
except Exception:
markdown = ""
return CrawlResult(
source_id=source.id,
stage=stage,
url=url,
success=True,
status_code=r.status_code,
html=html,
markdown=markdown,
attempts=attempt,
)
except Exception as e:
logger.warning("静态抓取异常 {} {}: {}", source.id, url, e)
return CrawlResult(
source_id=source.id,
stage=stage,
url=url,
success=False,
error=f"{type(e).__name__}: {e}",
attempts=attempt,
)
async def _crawl_once(
crawler: AsyncWebCrawler,
url: str,
source: SourceConfig,
stage: CrawlStage,
attempt: int,
) -> CrawlResult:
"""单次抓取(无重试),用于被 retry 循环包裹。
- js_render=False 且无 wait_for → httpx 直连,绕过浏览器反爬;
- 其他情况 → Playwright。
"""
# 静态页面走 httpx 直连
if not source.js_render and not source.wait_for:
return await _fetch_static(url, source, stage, attempt)
# 动态页面走 Playwright
run_config = _build_run_config(source)
try:
c4_result = await crawler.arun(url=url, config=run_config)
except Exception as e:
logger.warning("抓取异常 {} {}: {}", source.id, url, e)
return CrawlResult(
source_id=source.id,
stage=stage,
url=url,
success=False,
error=f"{type(e).__name__}: {e}",
attempts=attempt,
)
success = bool(getattr(c4_result, "success", False))
return CrawlResult(
source_id=source.id,
stage=stage,
url=url,
success=success,
status_code=getattr(c4_result, "status_code", None),
html=getattr(c4_result, "html", "") or "",
markdown=_markdown_text(getattr(c4_result, "markdown", None)),
error=None if success else (getattr(c4_result, "error_message", None) or "unknown"),
attempts=attempt,
)
async def crawl_url_with_retry(
crawler: AsyncWebCrawler,
url: str,
source: SourceConfig,
stage: CrawlStage,
settings: CrawlerSettings,
semaphore: asyncio.Semaphore,
guard: _TimeoutGuard | None = None,
) -> CrawlResult:
"""带并发限制与指数退避重试的单 URL 抓取。
重试策略:
最多 settings.retry_max_attempts 次;
失败 (success=False) 触发重试,等待时间 min(min_wait * 2^(n-1), max_wait)。
若传入 guard,每次重试前检查是否已被跳过。
"""
async with semaphore:
# 检查是否已被跳过
if guard and guard.skip:
return guard.skipped_result(url, stage)
max_attempts = settings.retry_max_attempts
result: CrawlResult | None = None
for attempt_no in range(1, max_attempts + 1):
result = await _crawl_once(crawler, url, source, stage, attempt_no)
if result.success:
if guard:
guard.record(None, True)
return result
if attempt_no >= max_attempts:
break
# 重试前检查是否已被其他并发请求触发跳过
if guard and guard.skip:
return guard.skipped_result(url, stage)
wait = min(
settings.retry_min_wait_sec * (2 ** (attempt_no - 1)),
settings.retry_max_wait_sec,
)
logger.info(
"{} {}{}/{} 次失败,{} 秒后重试: {}",
source.id,
url,
attempt_no,
max_attempts,
wait,
result.error,
)
await asyncio.sleep(wait)
# 此处 result 必非 None(循环至少跑一次)
assert result is not None
if guard:
guard.record(result.error, False)
return result
# --------------------------------------------------------------------------- #
# 抓取流程
# --------------------------------------------------------------------------- #
async def crawl_source(
crawler: AsyncWebCrawler,
source: SourceConfig,
settings: CrawlerSettings,
semaphore: asyncio.Semaphore,
save: bool = True,
) -> list[CrawlResult]:
"""抓取单个新闻源:遍历所有入口 → 抽取链接(去重) → 并发抓文章。"""
homepages = source.all_homepages()
logger.info(
"===== 开始抓取源: {} ({}) {} 个入口 =====",
source.id, source.name, len(homepages),
)
all_list_results: list[CrawlResult] = []
all_links: list[ArticleLink] = []
# 增量:加载历史 URL hash
from .storage import load_seen_urls
from .storage import url_hash as storage_url_hash
seen_hashes = load_seen_urls(source.id)
if seen_hashes:
logger.info("{} 增量模式: 已记录 {} 条历史 URL", source.id, len(seen_hashes))
# 遍历每个入口
for hp_url in homepages:
list_result = await crawl_url_with_retry(
crawler=crawler, url=hp_url, source=source,
stage=CrawlStage.LIST, settings=settings, semaphore=semaphore,
)
if save:
save_result(list_result, settings.output_root)
all_list_results.append(list_result)
if not list_result.success:
logger.warning("{} 入口 {} 抓取失败,跳过: {}", source.id, hp_url, list_result.error)
continue
parsed_base = urlparse(hp_url)
base = f"{parsed_base.scheme}://{parsed_base.netloc}"
links = extract_article_links(list_result.html, base, source)
# 去重 + 增量:按 hash 去重(当前运行跨入口 + 历史记录)
new_links = [ln for ln in links if storage_url_hash(ln.url) not in seen_hashes]
for ln in new_links:
seen_hashes.add(storage_url_hash(ln.url))
all_links.extend(new_links)
# 旧链接数(跨入口去重前)
old_count = len(links) - len(new_links)
logger.info("{} 入口 {} 抽取链接 {} 条(去重后新增 {}, 跳过旧 {} 条)",
source.id, hp_url, len(links), len(new_links), old_count)
if not all_links:
logger.warning("{} 所有入口未发现任何文章链接", source.id)
return all_list_results
# 连续超时保护:同源连续超时 2 次后跳过剩余请求
guard = _TimeoutGuard(source.id)
article_tasks = [
crawl_url_with_retry(
crawler=crawler, url=link.url, source=source,
stage=CrawlStage.ARTICLE, settings=settings, semaphore=semaphore,
guard=guard,
)
for link in all_links
]
article_results = await asyncio.gather(*article_tasks, return_exceptions=False)
if save:
for r in article_results:
save_result(r, settings.output_root)
success_cnt = sum(1 for r in article_results if r.success)
# 记录成功抓取的 URL(增量去重)
new_saved = 0
for r in article_results:
if r.success:
mark_url_seen(source.id, r.url)
new_saved += 1
logger.info(
"{} 完成: 文章 {}/{} 成功, 新增 {} 条已记录",
source.id, success_cnt, len(article_results), new_saved,
)
return [*all_list_results, *article_results]
async def crawl_all(
config: CrawlerConfig,
save: bool = True,
) -> list[CrawlResult]:
"""抓取所有启用的源,返回扁平的 CrawlResult 列表。"""
settings = config.settings
enabled = config.enabled_sources()
if not enabled:
logger.warning("没有启用的新闻源,直接返回")
return []
browser_config = BrowserConfig(
headless=settings.headless,
user_agent=settings.user_agent,
verbose=False,
)
semaphore = asyncio.Semaphore(settings.concurrency)
all_results: list[CrawlResult] = []
async with AsyncWebCrawler(config=browser_config) as crawler:
for source in enabled:
try:
results = await crawl_source(crawler, source, settings, semaphore, save=save)
all_results.extend(results)
except Exception as e: # 单源异常不应中断整体抓取
logger.exception("{} 抓取异常: {}", source.id, e)
total = len(all_results)
succ = sum(1 for r in all_results if r.success)
logger.info("全部完成: {}/{} 成功 (成功率 {:.0%})", succ, total, succ / max(total, 1))
return all_results
+161
View File
@@ -0,0 +1,161 @@
"""新闻抓取模块的数据模型与配置模型。
所有核心对象统一使用 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"
+98
View File
@@ -0,0 +1,98 @@
"""抓取结果本地保存。
目录结构:
{output_root}/{source_id}/{YYYYMMDD}/
├── {url_hash}.html # 原始 HTML
├── {url_hash}.md # Crawl4AI 输出的 Markdown
└── index.jsonl # 元数据(每行一条 CrawlResult)
"""
from __future__ import annotations
import hashlib
import json
from datetime import date
from pathlib import Path
from loguru import logger
from .models import CrawlResult
def url_hash(url: str) -> str:
"""对 URL 取 SHA1,取前 16 位作为文件名。"""
return hashlib.sha1(url.encode("utf-8")).hexdigest()[:16]
def build_output_dir(output_root: str | Path, source_id: str, day: date | None = None) -> Path:
"""构造保存目录: {output_root}/{source_id}/{YYYYMMDD}/。"""
target_day = day or date.today()
return Path(output_root) / source_id / target_day.strftime("%Y%m%d")
def save_result(
result: CrawlResult,
output_root: str | Path,
day: date | None = None,
*,
skip_existing: bool = True,
) -> Path | None:
"""保存单条抓取结果到本地。
返回 HTML 文件路径;失败结果只追加 index.jsonl,不写 HTML/MD,返回 None。
skip_existing=True 时:如果当日该 URL 已保存(HTML 文件已存在),跳过写入,
仅记录日志,返回已存在路径。保证增量抓取不会重复保存。
"""
out_dir = build_output_dir(output_root, result.source_id, day)
out_dir.mkdir(parents=True, exist_ok=True)
h = url_hash(result.url)
html_path: Path | None = None
# 增量去重:如果当日已抓取过同一 URL,跳过
existing_html = out_dir / f"{h}.html"
if skip_existing and existing_html.exists():
logger.debug("跳过重复 URL (今日已抓取): {} -> {}", result.url, h)
return existing_html
if result.success and result.html:
html_path = out_dir / f"{h}.html"
html_path.write_text(result.html, encoding="utf-8")
if result.markdown:
md_path = out_dir / f"{h}.md"
md_path.write_text(result.markdown, encoding="utf-8")
# 元数据(不含 html/markdown 大字段,避免 jsonl 巨大)
meta = result.model_dump(exclude={"html", "markdown"}, mode="json")
meta["url_hash"] = h
meta["html_file"] = html_path.name if html_path else None
index_path = out_dir / "index.jsonl"
with index_path.open("a", encoding="utf-8") as f:
f.write(json.dumps(meta, ensure_ascii=False) + "\n")
logger.debug("已保存抓取结果: {}", result.short_summary())
return html_path
def load_seen_urls(source_id: str, output_root: str | Path = "data/raw") -> set[str]:
"""加载某个源已抓取的所有 URL hash(跨日期累积)。
读取 {output_root}/{source_id}/seen_urls.txt,每行一个 url_hash。
"""
seen_path = Path(output_root) / source_id / "seen_urls.txt"
if not seen_path.is_file():
return set()
with seen_path.open("r", encoding="utf-8") as f:
return {line.strip() for line in f if line.strip()}
def mark_url_seen(source_id: str, url: str, output_root: str | Path = "data/raw") -> None:
"""追加一个已抓取 URL 的 hash 到 seen_urls.txt。"""
seen_path = Path(output_root) / source_id / "seen_urls.txt"
seen_path.parent.mkdir(parents=True, exist_ok=True)
h = url_hash(url)
with seen_path.open("a", encoding="utf-8") as f:
f.write(h + "\n")