1861 lines
80 KiB
Python
1861 lines
80 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""
|
||
全网舆情爬虫引擎 v4.3
|
||
================================
|
||
根据用户对话要求,从权威媒体 / 财经垂直 / 地方媒体 / 自媒体 / 国际媒体
|
||
采集 基金公司/上市公司/证券公司/银行/保险/信托 等机构的正面新闻与舆情。
|
||
|
||
核心能力:
|
||
- 5大媒体源,30+ 站点(数据驱动,可扩展)
|
||
- 4类目标机构(基于现有注册表 + 政府清单)
|
||
- 情感关键词分类(正面 vs 舆情,3档严重等级)
|
||
- URL + 标题双维去重
|
||
- 浏览器自动化兜底(v4.2 browser_scraper)
|
||
- JSON 快照存储 / 增量追加 / 时间窗过滤
|
||
- 定时任务支持(与 v4.0 crawl_scheduler 协同)
|
||
|
||
设计要点:
|
||
- 无侵入:复用现有 scraper/batch_institution_crawler/cls_scraper/wallstreetcn_scraper
|
||
- 离线优先:断网或目标站反爬时仍能返回已缓存数据
|
||
- 输出标准化:导出器 sentiment_exporter.py 提供 Word/Excel/Dialog 三种反馈
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import re
|
||
import json
|
||
import time
|
||
import hashlib
|
||
import logging
|
||
import threading
|
||
from pathlib import Path
|
||
from typing import Dict, Any, List, Optional, Iterable, Tuple
|
||
from datetime import datetime, timedelta
|
||
from dataclasses import dataclass, field, asdict
|
||
from collections import defaultdict
|
||
|
||
# ============= 可选依赖(容错导入) =============
|
||
try:
|
||
from .sentiment_keywords import (
|
||
POSITIVE_KEYWORDS, NEGATIVE_KEYWORDS, NEUTRAL_KEYWORDS,
|
||
SEVERITY_LEVELS, SEVERITY_LEVELS_POSITIVE,
|
||
INDUSTRY_BUZZWORDS, RISK_PATTERNS,
|
||
)
|
||
except ImportError:
|
||
from sentiment_keywords import ( # type: ignore
|
||
POSITIVE_KEYWORDS, NEGATIVE_KEYWORDS, NEUTRAL_KEYWORDS,
|
||
SEVERITY_LEVELS, SEVERITY_LEVELS_POSITIVE,
|
||
INDUSTRY_BUZZWORDS, RISK_PATTERNS,
|
||
)
|
||
|
||
try:
|
||
from scraper import FinancialPageScraper # type: ignore
|
||
HAS_BASE_SCRAPER = True
|
||
except ImportError:
|
||
HAS_BASE_SCRAPER = False
|
||
|
||
try:
|
||
from cls_scraper import get_hot_articles as cls_hot, search_articles as cls_search # type: ignore
|
||
from wallstreetcn_scraper import get_live_news as wscn_live, get_articles as wscn_articles # type: ignore
|
||
HAS_NEWS_API = True
|
||
except ImportError:
|
||
HAS_NEWS_API = False
|
||
cls_hot = cls_search = wscn_live = wscn_articles = None
|
||
|
||
try:
|
||
from announcement_scraper import AnnouncementSearcher # type: ignore
|
||
HAS_ANN = True
|
||
except ImportError:
|
||
HAS_ANN = False
|
||
|
||
try:
|
||
from rss_feeds import RssFetcher # type: ignore
|
||
HAS_RSS = True
|
||
except ImportError:
|
||
HAS_RSS = False
|
||
RssFetcher = None # type: ignore
|
||
|
||
# ============= 路径 & 全局配置 =============
|
||
|
||
SKILL_DATA_DIR = Path(__file__).resolve().parent.parent / "data"
|
||
SNAPSHOT_DIR = SKILL_DATA_DIR / "sentiment_snapshots"
|
||
SNAPSHOT_DIR.mkdir(parents=True, exist_ok=True)
|
||
SOURCES_FILE = SKILL_DATA_DIR / "sentiment_sources.json"
|
||
TARGETS_FILE = SKILL_DATA_DIR / "sentiment_targets.json"
|
||
USER_CUSTOM_FILE = SKILL_DATA_DIR / "sentiment_custom_targets.json"
|
||
|
||
# 简易内存去重索引(key: fingerprint -> first_seen)
|
||
_SEEN_INDEX: Dict[str, str] = {}
|
||
_SEEN_INDEX_FILE = SNAPSHOT_DIR / "_seen_index.json"
|
||
if _SEEN_INDEX_FILE.exists():
|
||
try:
|
||
_SEEN_INDEX = json.loads(_SEEN_INDEX_FILE.read_text(encoding="utf-8"))
|
||
except Exception:
|
||
_SEEN_INDEX = {}
|
||
|
||
# v5.0.0: 模糊去重索引(会话内)— target_name -> [已见归一化标题列表]
|
||
# 精确指纹未命中时,用 difflib 标题相似度 >0.85 判定重复(防标题加标点/日期刷重复)
|
||
_TITLE_INDEX: Dict[str, List[str]] = {}
|
||
|
||
# v5.0.0: 并发安全 — _SEEN_INDEX / _TITLE_INDEX 共享锁(parallel_workers 并发写防撕裂)
|
||
_SEEN_LOCK = threading.Lock()
|
||
|
||
logger = logging.getLogger("sentiment_crawler")
|
||
logger.setLevel(logging.INFO)
|
||
|
||
|
||
# ============================================================
|
||
# 1. 数据模型
|
||
# ============================================================
|
||
|
||
@dataclass
|
||
class SentimentArticle:
|
||
"""舆情文章 — 全网舆情爬虫标准数据格式"""
|
||
title: str # 标题(必填)
|
||
summary: str = "" # 内容简介(200-300字)
|
||
content: str = "" # 全文(可空)
|
||
source: str = "" # 发布平台/媒体名
|
||
source_type: str = "" # 媒体类型(authoritative/financial_vertical/self_media...)
|
||
target_name: str = "" # 涉及的目标(基金/公司/政府)
|
||
target_type: str = "" # 目标类型
|
||
publish_time: str = "" # 发布时间(原文,YYYY-MM-DD HH:MM:SS)
|
||
url: str = "" # 页面连接
|
||
sentiment: str = "neutral" # positive / negative / neutral
|
||
sentiment_score: float = 0.0 # 情感分数
|
||
severity: str = "中性" # 严重等级:中性/低度关注/中度舆情/高危舆情
|
||
category: str = "" # sub_category: 业绩/处罚/诉讼...
|
||
keywords_matched: List[str] = field(default_factory=list) # 命中的关键词
|
||
fetch_time: str = "" # 爬取时间
|
||
dedup_ratio: float = 0.0 # v5.0.0 模糊去重相似度(>0.85 视为重复)
|
||
extra: Dict[str, Any] = field(default_factory=dict)
|
||
|
||
def to_dict(self) -> Dict[str, Any]:
|
||
return asdict(self)
|
||
|
||
@property
|
||
def fingerprint(self) -> str:
|
||
"""生成去重指纹 — URL + 标题归一化"""
|
||
url_norm = (self.url or "").split("?")[0].rstrip("/").lower()
|
||
title_norm = re.sub(r"\s+", "", self.title or "").lower()
|
||
raw = f"{url_norm}::{title_norm}::{self.target_name}"
|
||
return hashlib.md5(raw.encode("utf-8")).hexdigest()
|
||
|
||
|
||
@dataclass
|
||
class SentimentSnapshot:
|
||
"""一次舆情快照(爬取+分类的完整结果集)"""
|
||
snapshot_id: str
|
||
created_at: str
|
||
target_filter: Dict[str, Any]
|
||
source_filter: List[str]
|
||
articles: List[SentimentArticle] = field(default_factory=list)
|
||
stats: Dict[str, Any] = field(default_factory=dict)
|
||
plan: Optional[Dict[str, Any]] = None # v4.5 dry_run 时填充
|
||
backtest_results: Optional[List[Any]] = None # v4.5 run_backtest=True 时填充
|
||
|
||
def to_dict(self) -> Dict[str, Any]:
|
||
return {
|
||
"snapshot_id": self.snapshot_id,
|
||
"created_at": self.created_at,
|
||
"target_filter": self.target_filter,
|
||
"source_filter": self.source_filter,
|
||
"articles": [a.to_dict() for a in self.articles],
|
||
"stats": self.stats,
|
||
"plan": self.plan,
|
||
"backtest_results": (
|
||
[r.to_dict() if hasattr(r, "to_dict") else r
|
||
for r in (self.backtest_results or [])]
|
||
if self.backtest_results else None
|
||
),
|
||
}
|
||
|
||
@property
|
||
def is_plan(self) -> bool:
|
||
"""v4.5: 是否是 plan 模式(dry_run 或未确认)。"""
|
||
return bool(self.plan) or self.stats.get("dry_run", False)
|
||
|
||
@property
|
||
def is_awaiting_confirmation(self) -> bool:
|
||
"""v4.5: 是否在等待用户确认。"""
|
||
return self.stats.get("awaiting_confirmation", False)
|
||
|
||
def positive_count(self) -> int:
|
||
return sum(1 for a in self.articles if a.sentiment == "positive")
|
||
|
||
def negative_count(self) -> int:
|
||
return sum(1 for a in self.articles if a.sentiment == "negative")
|
||
|
||
def neutral_count(self) -> int:
|
||
return sum(1 for a in self.articles if a.sentiment == "neutral")
|
||
|
||
|
||
# ============================================================
|
||
# 2. 情感分类(关键词驱动,无外部 AI 依赖)
|
||
# ============================================================
|
||
|
||
class SentimentClassifier:
|
||
"""轻量情感分类器 — 基于关键词权重"""
|
||
|
||
def __init__(self,
|
||
pos_dict: Optional[Dict[str, int]] = None,
|
||
neg_dict: Optional[Dict[str, int]] = None,
|
||
neu_dict: Optional[Dict[str, int]] = None):
|
||
self.pos_dict = pos_dict or POSITIVE_KEYWORDS
|
||
self.neg_dict = neg_dict or NEGATIVE_KEYWORDS
|
||
self.neu_dict = neu_dict or NEUTRAL_KEYWORDS
|
||
|
||
def classify(self, title: str, content: str = "") -> Tuple[str, float, List[str], str]:
|
||
"""返回 (sentiment, score, matched_keywords, severity)"""
|
||
# 标题权重 × 2 / 命中次数 / 长度
|
||
text = (title or "") + "。" + (content or "")[:1500]
|
||
title_text = title or ""
|
||
|
||
pos_score, pos_hits = self._score(title_text, text, self.pos_dict)
|
||
neg_score, neg_hits = self._score(title_text, text, self.neg_dict)
|
||
neu_score, neu_hits = self._score(title_text, text, self.neu_dict)
|
||
|
||
# 类型判定
|
||
if neg_score > pos_score and neg_score >= 5:
|
||
sentiment = "negative"
|
||
score = neg_score
|
||
matched = neg_hits
|
||
elif pos_score > neg_score and pos_score >= 5:
|
||
sentiment = "positive"
|
||
score = pos_score
|
||
matched = pos_hits
|
||
elif neu_score >= 5:
|
||
sentiment = "neutral"
|
||
score = neu_score
|
||
matched = neu_hits
|
||
else:
|
||
sentiment = "neutral"
|
||
score = max(pos_score, neg_score, neu_score)
|
||
matched = pos_hits + neg_hits + neu_hits
|
||
|
||
severity = self._severity(score, sentiment)
|
||
return sentiment, float(score), matched, severity
|
||
|
||
# 通用否定词(前向窗口检测:「公司否认业绩下滑」中 '否认' 在关键词前)
|
||
NEGATION_WORDS = (
|
||
"不", "无", "未", "没有", "并非", "否认", "辟谣", "澄清", "不实",
|
||
"虚假", "谣言", "难", "并非", "不是", "未能",
|
||
)
|
||
# 声明类否定词(后向窗口检测:「业绩下滑,但公司否认」中 '否认' 在关键词后)
|
||
DECLARATION_NEGATIONS = ("否认", "澄清", "辟谣", "不实", "虚假", "谣言")
|
||
|
||
def _negated(self, text: str, word: str) -> bool:
|
||
"""v5.0.0: 否定词窗口检测 — 关键词前 4 字内的通用否定,或词后 10 字内的声明类否定。
|
||
|
||
例:「公司否认业绩下滑」→ '下滑' 前有 '否认' → 翻转。
|
||
「业绩下滑,但公司否认该说法」→ '下滑' 后有 '否认' → 翻转。
|
||
"""
|
||
for m in re.finditer(re.escape(word), text):
|
||
start = m.start()
|
||
pre = text[max(0, start - 4):start]
|
||
if any(neg in pre for neg in self.NEGATION_WORDS):
|
||
return True
|
||
post = text[start:start + len(word) + 10]
|
||
if any(neg in post for neg in self.DECLARATION_NEGATIONS):
|
||
return True
|
||
return False
|
||
|
||
def _score(self, title: str, full_text: str, dictionary: Dict[str, int]) -> Tuple[float, List[str]]:
|
||
score = 0.0
|
||
hits: List[str] = []
|
||
for word, weight in dictionary.items():
|
||
t_hit = title.count(word)
|
||
f_hit = full_text.count(word)
|
||
if not (t_hit or f_hit):
|
||
continue
|
||
# v5.0.0: 否定词处理 — 「否认/未/没有」等使关键词命中不累计分。
|
||
# full_text 已含 title,故统一在 full_text 上做窗口否定检测。
|
||
if self._negated(full_text, word):
|
||
continue
|
||
hits.append(word)
|
||
# 标题命中 × 2,内容命中 × 1
|
||
score += weight * (t_hit * 2 + f_hit)
|
||
return score, hits
|
||
|
||
def _severity(self, score: float, sentiment: str) -> str:
|
||
"""根据分数和情感返回严重等级。
|
||
v5.0.0: 阈值统一读取 sentiment_keywords.SEVERITY_LEVELS / SEVERITY_LEVELS_POSITIVE,
|
||
消除此前的硬编码 6/12/25、6/15/30 两套不一致数值。
|
||
"""
|
||
if sentiment == "neutral":
|
||
return "中性"
|
||
table = SEVERITY_LEVELS_POSITIVE if sentiment == "positive" else SEVERITY_LEVELS
|
||
for lo, hi, label in table:
|
||
if lo <= score < hi:
|
||
return label
|
||
return table[-1][2] # 超出上限归最高档
|
||
|
||
|
||
# ============================================================
|
||
# 3. 媒体源 & 目标库 加载
|
||
# ============================================================
|
||
|
||
class SentimentSourceLoader:
|
||
"""加载 + 检索 媒体源库"""
|
||
|
||
def __init__(self, file_path: Path = SOURCES_FILE):
|
||
self.file_path = Path(file_path)
|
||
self._data: Dict[str, List[Dict[str, Any]]] = {}
|
||
self.reload()
|
||
|
||
def reload(self):
|
||
if not self.file_path.exists():
|
||
logger.warning("媒体源文件不存在: %s", self.file_path)
|
||
self._data = {}
|
||
return
|
||
try:
|
||
raw = json.loads(self.file_path.read_text(encoding="utf-8"))
|
||
self._data = {k: v for k, v in raw.items() if not k.startswith("_")}
|
||
except Exception as e:
|
||
logger.warning("媒体源文件解析失败: %s", e)
|
||
self._data = {}
|
||
|
||
def list_categories(self) -> List[str]:
|
||
return list(self._data.keys())
|
||
|
||
def all_sources(self) -> List[Dict[str, Any]]:
|
||
out = []
|
||
for cat, items in self._data.items():
|
||
for item in items:
|
||
rec = dict(item)
|
||
rec["category"] = cat
|
||
out.append(rec)
|
||
return out
|
||
|
||
def sources_by_category(self, category: str) -> List[Dict[str, Any]]:
|
||
return self._data.get(category, [])
|
||
|
||
def sources_with_rss(self) -> List[Dict[str, Any]]:
|
||
"""v5.0.0: 已配置 rss_feeds 的站点(可直接 RSS 直连,零反爬)。"""
|
||
return [s for s in self.all_sources() if s.get("rss_feeds")]
|
||
|
||
def pick_by_names(self, names: List[str]) -> List[Dict[str, Any]]:
|
||
"""按名称选取媒体源(精确匹配)"""
|
||
pick = []
|
||
for cat, items in self._data.items():
|
||
for item in items:
|
||
if item.get("name") in names:
|
||
rec = dict(item); rec["category"] = cat
|
||
pick.append(rec)
|
||
return pick
|
||
|
||
def stats(self) -> Dict[str, int]:
|
||
return {cat: len(items) for cat, items in self._data.items()}
|
||
|
||
|
||
class SentimentTargetLoader:
|
||
"""加载目标机构库 — 优先使用 sentiment_targets.json,
|
||
并尝试从已有注册表补全"""
|
||
|
||
def __init__(self, file_path: Path = TARGETS_FILE):
|
||
self.file_path = Path(file_path)
|
||
self.user_custom = self._load_user_custom()
|
||
self.reload()
|
||
|
||
def _load_user_custom(self) -> Dict[str, List[Dict[str, Any]]]:
|
||
if USER_CUSTOM_FILE.exists():
|
||
try:
|
||
return json.loads(USER_CUSTOM_FILE.read_text(encoding="utf-8"))
|
||
except Exception:
|
||
return {}
|
||
return {}
|
||
|
||
def reload(self):
|
||
if not self.file_path.exists():
|
||
logger.warning("目标文件不存在: %s", self.file_path)
|
||
self._data: Dict[str, Any] = {}
|
||
return
|
||
try:
|
||
raw = json.loads(self.file_path.read_text(encoding="utf-8"))
|
||
self._data = {k: v for k, v in raw.items() if not k.startswith("_")}
|
||
except Exception as e:
|
||
logger.warning("目标文件解析失败: %s", e)
|
||
self._data = {}
|
||
|
||
@property
|
||
def categories(self) -> List[str]:
|
||
return list(self._data.keys())
|
||
|
||
def get(self, category: str) -> Dict[str, Any]:
|
||
return self._data.get(category, {})
|
||
|
||
def all_targets(self) -> List[Tuple[str, str]]:
|
||
"""返回 [(category, target_name), ...] 用户 + 系统"""
|
||
result: List[Tuple[str, str]] = []
|
||
for cat, info in self._data.items():
|
||
label = info.get("label", cat)
|
||
# 优先取 items(精确实例),否则视为类别名
|
||
for item in info.get("items", []) or []:
|
||
name = item.get("name") if isinstance(item, dict) else str(item)
|
||
if name:
|
||
result.append((cat, label, name))
|
||
# 用户自定义补全
|
||
for item in self.user_custom.get(cat, []):
|
||
name = item.get("name") if isinstance(item, dict) else str(item)
|
||
if name:
|
||
result.append((cat, label, name))
|
||
return result
|
||
|
||
def add_custom_target(self, category: str, name: str,
|
||
aliases: Optional[List[str]] = None,
|
||
tags: Optional[List[str]] = None) -> Dict[str, Any]:
|
||
"""动态增加自定义目标"""
|
||
bucket = self.user_custom.setdefault(category, [])
|
||
for it in bucket:
|
||
if it.get("name") == name:
|
||
return {"ok": False, "msg": f"已存在: {name}"}
|
||
item = {"name": name, "aliases": aliases or [], "tags": tags or []}
|
||
bucket.append(item)
|
||
USER_CUSTOM_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||
USER_CUSTOM_FILE.write_text(
|
||
json.dumps(self.user_custom, ensure_ascii=False, indent=2),
|
||
encoding="utf-8"
|
||
)
|
||
return {"ok": True, "added": item}
|
||
|
||
def stats(self) -> Dict[str, int]:
|
||
return {cat: len(info.get("items", []) or []) for cat, info in self._data.items()}
|
||
|
||
|
||
# ============================================================
|
||
# 4. 爬取核心 — 针对每个目标 × 媒体源 生成查询
|
||
# ============================================================
|
||
|
||
class SentimentCrawler:
|
||
"""全网舆情爬虫统一入口"""
|
||
|
||
def __init__(self,
|
||
sources: Optional[SentimentSourceLoader] = None,
|
||
targets: Optional[SentimentTargetLoader] = None,
|
||
classifier: Optional[SentimentClassifier] = None,
|
||
enable_browser_fallback: bool = True,
|
||
snapshot_dir: Optional[Path] = None,
|
||
article_limit: int = 60,
|
||
max_total_seconds: int = 60,
|
||
per_source_timeout: int = 8,
|
||
use_rss: bool = True,
|
||
rss_fetcher: Optional[Any] = None):
|
||
self.sources = sources or SentimentSourceLoader()
|
||
self.targets = targets or SentimentTargetLoader()
|
||
self.classifier = classifier or SentimentClassifier()
|
||
self.enable_browser_fallback = enable_browser_fallback
|
||
self.snapshot_dir = Path(snapshot_dir or SNAPSHOT_DIR)
|
||
self.snapshot_dir.mkdir(parents=True, exist_ok=True)
|
||
self.article_limit = article_limit
|
||
# 速度控制:全局最大耗时 + 单源超时
|
||
self.max_total_seconds = max_total_seconds
|
||
self.per_source_timeout = per_source_timeout
|
||
self._run_start_ts: float = 0.0
|
||
# v5.0.0: RSS 直连数据源(零反爬;缺依赖时为 None 自动禁用)
|
||
self.use_rss = use_rss
|
||
self._rss_fetcher = rss_fetcher
|
||
if self._rss_fetcher is None and HAS_RSS and RssFetcher is not None:
|
||
self._rss_fetcher = RssFetcher()
|
||
# v5.0.0: 协作式取消集合 + 占位缓存锁(并发安全)
|
||
self._cancelled_sources: set = set()
|
||
self._placeholder_lock = threading.Lock()
|
||
# 占位记录去重:同一 (target_name, source_name) 在 1 小时内只生成一次
|
||
self._placeholder_cache: Dict[Tuple[str, str], float] = {}
|
||
self._placeholder_ttl = 3600 # 秒
|
||
|
||
# ---------------- 顶层 API -----------------
|
||
|
||
def crawl(self,
|
||
target_names: Optional[List[str]] = None,
|
||
target_categories: Optional[List[str]] = None,
|
||
source_categories: Optional[List[str]] = None,
|
||
source_names: Optional[List[str]] = None,
|
||
days: int = 7,
|
||
positive_only: bool = False,
|
||
negative_only: bool = False,
|
||
max_articles: int = 80,
|
||
dry_run: bool = False,
|
||
confirmed: bool = True,
|
||
run_backtest: bool = False,
|
||
backtest_drop: Optional[Tuple[str, ...]] = None,
|
||
parallel_workers: int = 0) -> SentimentSnapshot:
|
||
"""执行一次全网舆情爬取。
|
||
参数:
|
||
target_names: 指定的目标名列表,例如 ["贵州茅台","工银瑞信基金"]
|
||
target_categories: 限定目标类别,例如 ["fund_company","listed_company"]
|
||
source_categories: 限定媒体类别,例如 ["authoritative","financial_vertical"]
|
||
source_names: 限定具体媒体名
|
||
days: 时间窗口(默认7天)
|
||
positive_only / negative_only: 只保留某类情感
|
||
max_articles: 最大结果条数
|
||
dry_run: 仅返回爬取计划(不发任何 HTTP 请求),用于"先 plan 再确认"流程
|
||
confirmed: 是否已确认(默认 True)。当 False 时也走 dry_run 逻辑
|
||
run_backtest: 是否对每篇文章做 4 维回测(慢 ~20%)
|
||
backtest_drop: 回测后丢弃的推荐等级元组(如 ("建议丢弃",)),仅 run_backtest=True 时生效
|
||
parallel_workers: 并发抓取的线程数(0=按 v4.5 顺序,>0=按指定 worker 并发 target×source)
|
||
|
||
速度控制:
|
||
max_total_seconds: 整个爬取最大耗时(默认 60s),超时立即返回已有结果
|
||
per_source_timeout: 单个媒体源超时(默认 8s)
|
||
parallel_workers: 并发 (target, source) 抓取的线程数(v4.5.1 新增,默认 0=顺序)
|
||
"""
|
||
import time as _time
|
||
self._run_start_ts = _time.time()
|
||
|
||
# v4.5: dry_run / 未确认 → 只返回计划
|
||
if dry_run or not confirmed:
|
||
return self._build_plan_only(
|
||
target_names=target_names,
|
||
target_categories=target_categories,
|
||
source_categories=source_categories,
|
||
source_names=source_names,
|
||
days=days,
|
||
max_articles=max_articles,
|
||
awaiting_confirmation=not confirmed,
|
||
)
|
||
|
||
# 1. 选定媒体源
|
||
if source_names:
|
||
chosen_sources = self.sources.pick_by_names(source_names)
|
||
elif source_categories:
|
||
chosen_sources = []
|
||
for cat in source_categories:
|
||
chosen_sources.extend(self.sources.sources_by_category(cat))
|
||
else:
|
||
chosen_sources = self.sources.all_sources()
|
||
|
||
# 2. 选定目标
|
||
if target_names:
|
||
target_list = []
|
||
for cat, label, name in self.targets.all_targets():
|
||
if name in target_names:
|
||
target_list.append((cat, label, name))
|
||
# 加进用户输入但未在库中的目标
|
||
for n in target_names:
|
||
if not any(t[2] == n for t in target_list):
|
||
target_list.append(("custom", "自定义目标", n))
|
||
elif target_categories:
|
||
target_list = [(c, l, n) for c, l, n in self.targets.all_targets() if c in target_categories]
|
||
else:
|
||
# 默认:top 类别
|
||
target_list = [(c, l, n) for c, l, n in self.targets.all_targets()
|
||
if c in ("fund_company", "listed_company")]
|
||
target_list = target_list[:8]
|
||
|
||
logger.info(
|
||
"🛰️ 开始全网舆情爬取 | 媒体源=%d 目标=%d 时间窗=%d天 限时=%ds",
|
||
len(chosen_sources), len(target_list), days, self.max_total_seconds,
|
||
)
|
||
|
||
articles: List[SentimentArticle] = []
|
||
cutoff_time = datetime.now() - timedelta(days=days)
|
||
|
||
# 3. 遍历目标 — 加全局超时检查
|
||
# v4.5.1: 支持 parallel_workers 并发 (target, source) 抓取
|
||
if parallel_workers and parallel_workers > 0:
|
||
articles = self._crawl_parallel(
|
||
target_list=target_list,
|
||
chosen_sources=chosen_sources,
|
||
cutoff_time=cutoff_time,
|
||
max_articles=max_articles,
|
||
max_workers=parallel_workers,
|
||
)
|
||
else:
|
||
for cat, label, name in target_list:
|
||
if self._time_up():
|
||
logger.warning("⏰ 达到全局时限,停止新目标(已爬取 %d 条)", len(articles))
|
||
break
|
||
try:
|
||
kw_list = self._build_keywords(cat, name)
|
||
for src in chosen_sources:
|
||
if self._time_up():
|
||
break
|
||
for kw in kw_list[:2]: # 主关键词优先,无结果时再用备用关键词
|
||
if self._time_up():
|
||
break
|
||
for art in self._query_source(src, name, kw, cat, label, cutoff_time):
|
||
if not self._is_dup(art) and self._is_in_window(art, cutoff_time):
|
||
articles.append(art)
|
||
if len(articles) >= max_articles:
|
||
break
|
||
if len(articles) >= max_articles:
|
||
break
|
||
if len(articles) >= max_articles:
|
||
break
|
||
except Exception as e:
|
||
logger.warning("目标 %s 爬取出错: %s", name, e)
|
||
continue
|
||
if len(articles) >= max_articles:
|
||
break
|
||
|
||
# 4. 过滤 & 排序
|
||
articles = self._post_filter(articles, positive_only, negative_only)
|
||
articles.sort(key=lambda a: (a.publish_time or ""), reverse=True)
|
||
|
||
# 4.5 日期核验统计
|
||
date_validation = self._compute_date_validation(articles, cutoff_time, days)
|
||
|
||
# 5. 包装成快照(含耗时统计)
|
||
elapsed = _time.time() - self._run_start_ts
|
||
snapshot = SentimentSnapshot(
|
||
snapshot_id=f"sn_{datetime.now().strftime('%Y%m%d_%H%M%S')}_"
|
||
f"{hashlib.md5(str(target_list).encode()).hexdigest()[:6]}",
|
||
created_at=datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||
target_filter={"names": target_names or [], "categories": target_categories or []},
|
||
source_filter=[s.get("name", "") for s in chosen_sources],
|
||
articles=articles[:max_articles],
|
||
stats={
|
||
"total": len(articles[:max_articles]),
|
||
"sources_used": len(chosen_sources),
|
||
"targets_used": len(target_list),
|
||
"days_window": days,
|
||
"elapsed_seconds": round(elapsed, 2),
|
||
"timed_out": self._time_up(),
|
||
"by_sentiment": {
|
||
"positive": sum(1 for a in articles if a.sentiment == "positive"),
|
||
"negative": sum(1 for a in articles if a.sentiment == "negative"),
|
||
"neutral": sum(1 for a in articles if a.sentiment == "neutral"),
|
||
},
|
||
"by_severity": dict(_count_by(articles, lambda a: a.severity)),
|
||
"by_source_type": dict(_count_by(articles, lambda a: a.source_type)),
|
||
"date_validation": date_validation,
|
||
},
|
||
)
|
||
|
||
# v4.5: 跑 4 维回测(可选)
|
||
if run_backtest and articles:
|
||
snapshot.articles, snapshot.backtest_results = self._run_backtest(
|
||
articles, drop_recommendation=backtest_drop,
|
||
)
|
||
|
||
self._save_snapshot(snapshot)
|
||
return snapshot
|
||
|
||
def _build_plan_only(self,
|
||
target_names: Optional[List[str]] = None,
|
||
target_categories: Optional[List[str]] = None,
|
||
source_categories: Optional[List[str]] = None,
|
||
source_names: Optional[List[str]] = None,
|
||
days: int = 7,
|
||
max_articles: int = 80,
|
||
awaiting_confirmation: bool = False) -> SentimentSnapshot:
|
||
"""v4.5.1: 仅构建爬取计划,不发任何 HTTP 请求。
|
||
|
||
v4.5.1 强化:
|
||
- 输入校验 (validation_errors)
|
||
- 缓存样本预览 (从最近一次历史快照取 1-3 条样本)
|
||
- 风险提示 (risk_warnings) — 命中敏感词、目标名异常、源数过多
|
||
- 覆盖率估算 (coverage_estimate) — 基于历史快照命中率
|
||
"""
|
||
# ---- 输入校验 ----
|
||
validation_errors: List[str] = []
|
||
risk_warnings: List[str] = []
|
||
if days <= 0 or days > 90:
|
||
validation_errors.append(f"days 参数非法: {days}(应在 1-90 之间)")
|
||
if max_articles <= 0 or max_articles > 500:
|
||
validation_errors.append(f"max_articles 参数非法: {max_articles}(应在 1-500 之间)")
|
||
if target_names:
|
||
for n in target_names:
|
||
if not n or not n.strip():
|
||
validation_errors.append("目标名不能为空")
|
||
elif len(n.strip()) < 2:
|
||
risk_warnings.append(f"目标名过短: «{n}»(可能匹配范围过宽)")
|
||
elif len(n.strip()) > 30:
|
||
risk_warnings.append(f"目标名过长: «{n}»(建议用简称)")
|
||
if any(bad in n for bad in ("测试", "test", "demo", "example")):
|
||
risk_warnings.append(f"目标名含测试占位词: «{n}»(建议使用真实机构名)")
|
||
|
||
# 选定媒体源(同样逻辑)
|
||
if source_names:
|
||
chosen_sources = self.sources.pick_by_names(source_names)
|
||
elif source_categories:
|
||
chosen_sources = []
|
||
for cat in source_categories:
|
||
chosen_sources.extend(self.sources.sources_by_category(cat))
|
||
else:
|
||
chosen_sources = self.sources.all_sources()
|
||
|
||
if not chosen_sources:
|
||
validation_errors.append("没有可用的媒体源(检查 source_categories 或 sources 参数)")
|
||
if len(chosen_sources) > 30:
|
||
risk_warnings.append(f"媒体源过多 ({len(chosen_sources)}),单次爬取可能超时,建议 ≤ 20 个")
|
||
|
||
# 选定目标
|
||
if target_names:
|
||
target_list = []
|
||
for cat, label, name in self.targets.all_targets():
|
||
if name in target_names:
|
||
target_list.append((cat, label, name))
|
||
for n in target_names:
|
||
if not any(t[2] == n for t in target_list):
|
||
target_list.append(("custom", "自定义目标", n))
|
||
elif target_categories:
|
||
target_list = [(c, l, n) for c, l, n in self.targets.all_targets()
|
||
if c in target_categories]
|
||
else:
|
||
target_list = [(c, l, n) for c, l, n in self.targets.all_targets()
|
||
if c in ("fund_company", "listed_company")][:8]
|
||
|
||
if not target_list:
|
||
validation_errors.append("没有匹配的目标(检查 target_names / target_categories)")
|
||
if len(target_list) > 50:
|
||
risk_warnings.append(f"目标过多 ({len(target_list)}),建议分批爬取")
|
||
|
||
# 校验失败 → 直接返回错误 plan(不让用户带着错误参数确认)
|
||
if validation_errors:
|
||
plan = {
|
||
"targets": [],
|
||
"sources": [],
|
||
"days": days,
|
||
"max_articles": max_articles,
|
||
"estimated_articles": 0,
|
||
"estimated_seconds": 0.0,
|
||
"awaiting_confirmation": awaiting_confirmation,
|
||
"validation_errors": validation_errors,
|
||
"risk_warnings": risk_warnings,
|
||
"actions": [],
|
||
}
|
||
snapshot = SentimentSnapshot(
|
||
snapshot_id=f"plan_{datetime.now().strftime('%Y%m%d_%H%M%S')}_err",
|
||
created_at=datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||
target_filter={"names": target_names or [],
|
||
"categories": target_categories or []},
|
||
source_filter=[],
|
||
articles=[],
|
||
stats={
|
||
"total": 0, "sources_used": 0, "targets_used": 0,
|
||
"days_window": days, "elapsed_seconds": 0.0,
|
||
"timed_out": False, "dry_run": True,
|
||
"awaiting_confirmation": False,
|
||
"validation_failed": True,
|
||
},
|
||
)
|
||
snapshot.plan = plan
|
||
return snapshot
|
||
|
||
# 估算文章数(用历史快照命中率)
|
||
estimated_articles = self._estimate_articles(len(target_list),
|
||
len(chosen_sources),
|
||
days, max_articles)
|
||
|
||
# 估算耗时(每个源 ~1.5s + 每目标 ~0.5s,含分类)
|
||
estimated_seconds = len(chosen_sources) * 1.5 + len(target_list) * 0.5
|
||
estimated_seconds = min(estimated_seconds, self.max_total_seconds)
|
||
|
||
# ---- 缓存样本预览:从最近一次历史快照找样本 ----
|
||
sample_articles = self._sample_from_history(target_list, chosen_sources,
|
||
max_samples=3)
|
||
|
||
# ---- 覆盖率估算 ----
|
||
coverage_estimate = self._estimate_coverage(target_list, chosen_sources, days)
|
||
|
||
plan = {
|
||
"targets": [{"category": c, "label": l, "name": n}
|
||
for c, l, n in target_list],
|
||
"sources": [{"name": s.get("name", ""),
|
||
"type": s.get("type", ""),
|
||
"credibility": s.get("credibility", 5)}
|
||
for s in chosen_sources],
|
||
"days": days,
|
||
"max_articles": max_articles,
|
||
"estimated_articles": estimated_articles,
|
||
"estimated_seconds": round(estimated_seconds, 1),
|
||
"coverage_estimate": coverage_estimate,
|
||
"awaiting_confirmation": awaiting_confirmation,
|
||
"validation_errors": validation_errors,
|
||
"risk_warnings": risk_warnings,
|
||
"sample_articles": sample_articles,
|
||
"actions": ["fetch", "classify", "backtest" if awaiting_confirmation else "fetch"],
|
||
}
|
||
|
||
# 返回一个特殊的 snapshot:articles=[] 但 plan 字段有内容
|
||
snapshot = SentimentSnapshot(
|
||
snapshot_id=f"plan_{datetime.now().strftime('%Y%m%d_%H%M%S')}_"
|
||
f"{hashlib.md5(str(target_list).encode()).hexdigest()[:6]}",
|
||
created_at=datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||
target_filter={"names": target_names or [],
|
||
"categories": target_categories or []},
|
||
source_filter=[s.get("name", "") for s in chosen_sources],
|
||
articles=[],
|
||
stats={
|
||
"total": 0,
|
||
"sources_used": len(chosen_sources),
|
||
"targets_used": len(target_list),
|
||
"days_window": days,
|
||
"elapsed_seconds": 0.0,
|
||
"timed_out": False,
|
||
"dry_run": True,
|
||
"awaiting_confirmation": awaiting_confirmation,
|
||
},
|
||
)
|
||
# 把 plan 挂到 snapshot 上(兼容旧 API:articles=[] 也合法)
|
||
snapshot.plan = plan
|
||
return snapshot
|
||
|
||
def _sample_from_history(self, target_list, chosen_sources,
|
||
max_samples: int = 3) -> List[Dict[str, Any]]:
|
||
"""从最近一次历史快照里挑样本,给用户预览。
|
||
|
||
选最近一次非 plan 快照,按 target 名/源名匹配,挑最多 max_samples 条。
|
||
"""
|
||
try:
|
||
idx_path = self.snapshot_dir / "index.json"
|
||
if not idx_path.exists():
|
||
return []
|
||
index = json.loads(idx_path.read_text(encoding="utf-8"))
|
||
if not index:
|
||
return []
|
||
# 找最近一次非 plan 快照
|
||
recent_id = None
|
||
for entry in index:
|
||
sid = entry.get("snapshot_id", "")
|
||
if sid.startswith("plan_"):
|
||
continue
|
||
recent_id = sid
|
||
break
|
||
if not recent_id:
|
||
return []
|
||
snap_file = self.snapshot_dir / f"{recent_id}.json"
|
||
if not snap_file.exists():
|
||
return []
|
||
snap = json.loads(snap_file.read_text(encoding="utf-8"))
|
||
target_set = {t[2] for t in target_list}
|
||
source_set = {s.get("name", "") for s in chosen_sources}
|
||
samples: List[Dict[str, Any]] = []
|
||
for art in (snap.get("articles") or [])[:30]:
|
||
if art.get("target_name") in target_set or art.get("source") in source_set:
|
||
samples.append({
|
||
"title": art.get("title", ""),
|
||
"source": art.get("source", ""),
|
||
"target_name": art.get("target_name", ""),
|
||
"publish_time": art.get("publish_time", ""),
|
||
"url": art.get("url", ""),
|
||
"sentiment": art.get("sentiment", ""),
|
||
})
|
||
if len(samples) >= max_samples:
|
||
break
|
||
return samples
|
||
except Exception:
|
||
return []
|
||
|
||
def _estimate_coverage(self, target_list, chosen_sources, days: int) -> Dict[str, Any]:
|
||
"""基于历史快照估算覆盖率(命中率)。
|
||
|
||
返回:
|
||
{
|
||
"historical_snapshots": N,
|
||
"historical_hit_rate": 0-1,
|
||
"expected_avg_articles_per_target": float,
|
||
}
|
||
"""
|
||
try:
|
||
idx_path = self.snapshot_dir / "index.json"
|
||
if not idx_path.exists():
|
||
return {"historical_snapshots": 0, "historical_hit_rate": 0.0,
|
||
"expected_avg_articles_per_target": 0.0}
|
||
index = json.loads(idx_path.read_text(encoding="utf-8"))
|
||
non_plan = [e for e in index if not e.get("snapshot_id", "").startswith("plan_")]
|
||
if not non_plan:
|
||
return {"historical_snapshots": 0, "historical_hit_rate": 0.0,
|
||
"expected_avg_articles_per_target": 0.0}
|
||
total_articles = sum(e.get("total", 0) for e in non_plan[:20])
|
||
avg_per_snap = total_articles / min(20, len(non_plan))
|
||
hit_rate = min(1.0, avg_per_snap / max(1, len(target_list)))
|
||
return {
|
||
"historical_snapshots": len(non_plan),
|
||
"historical_hit_rate": round(hit_rate, 3),
|
||
"expected_avg_articles_per_target": round(avg_per_snap / max(1, len(target_list)), 2),
|
||
}
|
||
except Exception:
|
||
return {"historical_snapshots": 0, "historical_hit_rate": 0.0,
|
||
"expected_avg_articles_per_target": 0.0}
|
||
|
||
def _estimate_articles(self, n_targets: int, n_sources: int,
|
||
days: int, max_articles: int) -> int:
|
||
"""基于历史快照估算文章数。"""
|
||
# 启发式:每 (target, source, day) 平均 1-3 篇
|
||
base = n_targets * min(n_sources, 5) * max(1, days // 2)
|
||
# 限制在 max_articles 之内
|
||
return min(max(1, base), max_articles)
|
||
|
||
def _crawl_parallel(self,
|
||
target_list: List[Tuple[str, str, str]],
|
||
chosen_sources: List[Dict[str, Any]],
|
||
cutoff_time: datetime,
|
||
max_articles: int,
|
||
max_workers: int = 4,
|
||
) -> List[SentimentArticle]:
|
||
"""v4.5.1: 并发抓取所有 (target, source) 单元,结果合并去重。
|
||
|
||
每个 (target, source) 单元用 _query_source 拉一个 generator,
|
||
由线程池并发执行。遇到超时/异常不阻断其他单元。
|
||
"""
|
||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||
|
||
articles: List[SentimentArticle] = []
|
||
seen_locks = set() # 用 set 做 O(1) 去重辅助
|
||
workers = max(1, min(max_workers, 8)) # 限制在 1-8
|
||
|
||
# 构造任务列表:(target, source, kw)
|
||
tasks: List[Tuple[str, str, str, str, str]] = []
|
||
for cat, label, name in target_list:
|
||
kw_list = self._build_keywords(cat, name)
|
||
for src in chosen_sources:
|
||
for kw in kw_list[:2]:
|
||
tasks.append((name, cat, label, src.get("name", ""), kw))
|
||
|
||
if not tasks:
|
||
return articles
|
||
|
||
logger.info("🚀 并发爬取: %d 单元 / %d workers", len(tasks), workers)
|
||
|
||
def _one(target_name: str, cat: str, label: str,
|
||
src_name: str, kw: str) -> List[SentimentArticle]:
|
||
src = next((s for s in chosen_sources if s.get("name") == src_name), None)
|
||
if not src:
|
||
return []
|
||
if self._time_up():
|
||
return []
|
||
collected: List[SentimentArticle] = []
|
||
try:
|
||
for art in self._query_source(src, target_name, kw, cat, label, cutoff_time):
|
||
if self._is_in_window(art, cutoff_time):
|
||
collected.append(art)
|
||
if self._time_up():
|
||
break
|
||
except Exception as e:
|
||
logger.debug("并发单元异常 (%s, %s): %s", target_name, src_name, e)
|
||
return collected
|
||
|
||
with ThreadPoolExecutor(max_workers=workers) as ex:
|
||
futures = [ex.submit(_one, t[0], t[1], t[2], t[3], t[4]) for t in tasks]
|
||
for fut in as_completed(futures):
|
||
if self._time_up():
|
||
break
|
||
try:
|
||
batch = fut.result(timeout=0.5) or []
|
||
except Exception:
|
||
continue
|
||
for art in batch:
|
||
if self._time_up() or len(articles) >= max_articles:
|
||
break
|
||
if not self._is_dup(art):
|
||
articles.append(art)
|
||
if len(articles) >= max_articles:
|
||
break
|
||
|
||
logger.info("✅ 并发爬取完成: %d 条 (目标 %d × 源 %d)",
|
||
len(articles), len(target_list), len(chosen_sources))
|
||
return articles
|
||
|
||
def _run_backtest(self, articles: List["SentimentArticle"],
|
||
drop_recommendation: Optional[Tuple[str, ...]] = None,
|
||
) -> tuple:
|
||
"""v4.5.1: 对文章列表跑 4 维回测,返回 (filtered_articles, results)。
|
||
|
||
Args:
|
||
articles: 待回测文章列表
|
||
drop_recommendation: 哪些推荐等级要丢弃(默认 None=不丢,仅记录;
|
||
"建议丢弃"=按推荐自动过滤)
|
||
|
||
Returns:
|
||
(articles, results) — articles 已按 drop_recommendation 过滤
|
||
"""
|
||
try:
|
||
from .crawl_backtester import (
|
||
CrawlBacktester, filter_by_recommendation, batch_summary,
|
||
)
|
||
bt = CrawlBacktester(max_age_days=14)
|
||
results = []
|
||
for art in articles:
|
||
ad = art.to_dict() if hasattr(art, "to_dict") else {
|
||
"title": art.title,
|
||
"url": art.url,
|
||
"published_at": art.publish_time,
|
||
"content": getattr(art, "content", ""),
|
||
"source": art.source,
|
||
}
|
||
results.append(bt.backtest(ad))
|
||
|
||
# v4.5.1: 按推荐等级过滤
|
||
if drop_recommendation:
|
||
kept_articles, kept_results = filter_by_recommendation(
|
||
results, articles, drop_recommendation,
|
||
)
|
||
dropped = len(articles) - len(kept_articles)
|
||
if dropped > 0:
|
||
logger.info("🛡️ 回测过滤: 丢弃 %d/%d 篇文章", dropped, len(articles))
|
||
# 附带汇总统计到 results(最后一个元素是 summary)
|
||
summary = batch_summary(kept_results)
|
||
# 把 summary 写到 kept_results 的最后一个(如果有),否则单独加
|
||
summary["_meta"] = "batch_summary"
|
||
summary["_dropped_count"] = dropped
|
||
return kept_articles, kept_results + [summary]
|
||
return articles, results
|
||
except Exception as e:
|
||
logger.warning("数据回测失败,跳过: %s", e)
|
||
return articles, []
|
||
|
||
# ---------------- 时间控制 -----------------
|
||
|
||
def _time_up(self) -> bool:
|
||
import time as _time
|
||
if self.max_total_seconds <= 0:
|
||
return False
|
||
return (_time.time() - self._run_start_ts) > self.max_total_seconds
|
||
|
||
def _placeholder_seen(self, target_name: str, source_name: str) -> bool:
|
||
"""短时间窗口内,对同一 (target, source) 只生成一次占位记录(v5.0.0 加锁防并发竞态)。"""
|
||
import time as _time
|
||
key = (target_name, source_name)
|
||
now = _time.time()
|
||
with self._placeholder_lock:
|
||
# 清理过期项
|
||
self._placeholder_cache = {
|
||
k: v for k, v in self._placeholder_cache.items()
|
||
if now - v < self._placeholder_ttl
|
||
}
|
||
if key in self._placeholder_cache:
|
||
return True
|
||
self._placeholder_cache[key] = now
|
||
return False
|
||
|
||
def _domain_is_blocked(self, url: str) -> bool:
|
||
"""域名级短路:如果目标 URL 的域名已熔断,直接跳过,节省时间。"""
|
||
try:
|
||
from urllib.parse import urlparse
|
||
domain = urlparse(url).netloc
|
||
if not domain:
|
||
return False
|
||
# 检查 scraper 的熔断器
|
||
try:
|
||
from scraper import FinancialPageScraper
|
||
scraper = FinancialPageScraper()
|
||
if hasattr(scraper, "is_blocked") and scraper.is_blocked(domain):
|
||
logger.debug("⛔ 域名 %s 已熔断,跳过", domain)
|
||
return True
|
||
except Exception:
|
||
pass
|
||
# 检查 http_utils 的熔断器
|
||
try:
|
||
from http_utils import SmartLimiter # type: ignore
|
||
limiter = getattr(SmartLimiter, "_instance", None) or SmartLimiter()
|
||
if hasattr(limiter, "is_blocked") and limiter.is_blocked(domain):
|
||
logger.debug("⛔ 域名 %s 限流器熔断,跳过", domain)
|
||
return True
|
||
except Exception:
|
||
pass
|
||
return False
|
||
except Exception:
|
||
return False
|
||
|
||
# 域名 DNS 解析失败缓存 (1 小时内再尝试则 skip)
|
||
_DNS_FAIL_CACHE: Dict[str, float] = {}
|
||
_DNS_FAIL_TTL = 3600.0
|
||
|
||
def _domain_unresolvable(self, url: str) -> bool:
|
||
"""探测 URL 的域名是否可解析;不可解析直接 skip。"""
|
||
import time as _time
|
||
try:
|
||
from urllib.parse import urlparse
|
||
import socket
|
||
domain = urlparse(url).netloc
|
||
if not domain:
|
||
return False
|
||
now = _time.time()
|
||
# 清理过期项
|
||
self._DNS_FAIL_CACHE = {
|
||
k: v for k, v in self._DNS_FAIL_CACHE.items()
|
||
if now - v < self._DNS_FAIL_TTL
|
||
}
|
||
if domain in self._DNS_FAIL_CACHE:
|
||
return True
|
||
try:
|
||
socket.gethostbyname(domain)
|
||
return False
|
||
except (socket.gaierror, OSError):
|
||
# 不可解析 - 缓存 + 短路
|
||
self._DNS_FAIL_CACHE[domain] = now
|
||
logger.debug("⛔ 域名 %s 无法解析,跳过", domain)
|
||
return True
|
||
except Exception:
|
||
return False
|
||
|
||
# ---------------- 关键词构建 -----------------
|
||
|
||
def _build_keywords(self, category: str, target_name: str) -> List[str]:
|
||
"""为某个目标生成检索关键词列表"""
|
||
info = self.targets.get(category) or {}
|
||
defaults = info.get("default_keywords", []) or []
|
||
watch = info.get("watch_keywords", []) or []
|
||
kws = [target_name] # 目标名优先级最高
|
||
kws.extend(defaults[:3])
|
||
kws.extend(watch[:2])
|
||
return list(dict.fromkeys(kws)) # 保序去重
|
||
|
||
# ---------------- 单源查询 -----------------
|
||
|
||
def _query_source(self, source: Dict[str, Any], target_name: str,
|
||
keyword: str, target_category: str,
|
||
target_label: str, cutoff_time: datetime) -> Iterable[SentimentArticle]:
|
||
"""针对单个媒体源,尝试获取结果。先用 API/列表接口,未果则走浏览器兜底"""
|
||
# 全局超时短路
|
||
if self._time_up():
|
||
return
|
||
|
||
name = source.get("name", "未知媒体")
|
||
|
||
# 优先尝试专用 API
|
||
if name == "财联社" and HAS_NEWS_API and cls_search:
|
||
try:
|
||
results = cls_search(keyword, page_size=10) or []
|
||
for item in results:
|
||
if self._time_up():
|
||
return
|
||
art = self._build_article(
|
||
source=source,
|
||
target_name=target_name,
|
||
target_category=target_category,
|
||
target_label=target_label,
|
||
title=item.get("title", ""),
|
||
summary=item.get("brief", "") or item.get("content", "")[:200],
|
||
content=item.get("content", ""),
|
||
publish_time=item.get("ctime") or item.get("pubdate") or "",
|
||
url=item.get("url", ""),
|
||
)
|
||
yield art
|
||
return
|
||
except Exception as e:
|
||
logger.debug("财联社 API 失败,回退到通用: %s", e)
|
||
|
||
if name == "华尔街见闻" and HAS_NEWS_API and wscn_articles:
|
||
try:
|
||
results = wscn_articles(keyword=keyword, limit=10) or []
|
||
for item in results:
|
||
if self._time_up():
|
||
return
|
||
art = self._build_article(
|
||
source=source,
|
||
target_name=target_name,
|
||
target_category=target_category,
|
||
target_label=target_label,
|
||
title=item.get("title", ""),
|
||
summary=item.get("summary", "") or item.get("content_short", ""),
|
||
content=item.get("content", ""),
|
||
publish_time=item.get("display_time", "") or item.get("created_at", ""),
|
||
url=item.get("uri", "") or item.get("url", ""),
|
||
)
|
||
yield art
|
||
return
|
||
except Exception as e:
|
||
logger.debug("华尔街见闻 API 失败: %s", e)
|
||
|
||
# 通用搜索 URL 兜底(基于 search_urls 模板)
|
||
search_url_templates = source.get("search_urls") or []
|
||
for tpl in search_url_templates[:1]: # 每个源只取一个模板,避免太慢
|
||
url = tpl.replace("{kw}", quote_kw(keyword))
|
||
yield from self._fetch_with_fallback(
|
||
url=url, source=source,
|
||
target_name=target_name, target_category=target_category,
|
||
target_label=target_label, keyword=keyword,
|
||
)
|
||
|
||
# v5.0.0: RSS 直连数据源(零反爬,优先于浏览器兜底)。
|
||
# 只有 source 显式配置了 rss_feeds 才走此分支。
|
||
if self.use_rss and self._rss_fetcher is not None and source.get("rss_feeds"):
|
||
for rss_url in source.get("rss_feeds") or []:
|
||
if self._time_up():
|
||
return
|
||
feed = {"name": name, "url": rss_url}
|
||
try:
|
||
items = self._rss_fetcher.fetch(feed) or []
|
||
except Exception as e:
|
||
logger.debug("RSS 抓取失败 %s: %s", rss_url, e)
|
||
continue
|
||
for it in items:
|
||
if self._time_up():
|
||
return
|
||
title = getattr(it, "title", "") or ""
|
||
content = (getattr(it, "description", "") or "") + " " + (getattr(it, "content", "") or "")
|
||
if keyword and keyword not in title and keyword not in content:
|
||
continue
|
||
yield self._build_article(
|
||
source=source,
|
||
target_name=target_name,
|
||
target_category=target_category,
|
||
target_label=target_label,
|
||
title=title,
|
||
summary=getattr(it, "description", "") or "",
|
||
content=getattr(it, "content", "") or "",
|
||
publish_time=getattr(it, "pub_date", "") or "",
|
||
url=getattr(it, "link", "") or "",
|
||
)
|
||
|
||
def _fetch_with_fallback(self, url: str, source: Dict[str, Any],
|
||
target_name: str, target_category: str,
|
||
target_label: str, keyword: str) -> Iterable[SentimentArticle]:
|
||
"""先用 http_utils 抓列表页;若失败/反爬则切到 browser_scraper。
|
||
关键改进:
|
||
- 全局超时时立即返回,不等待单源
|
||
- 单源在独立线程中执行,可被 per_source_timeout 强制中断
|
||
- 域名级熔断短接 (同一域名已被熔断则直接跳过)
|
||
- 占位记录按 (target, source, hour) 去重
|
||
- DNS 预探测,省掉等 DNS 解析失败的时间
|
||
"""
|
||
# 全局超时:立即返回
|
||
if self._time_up():
|
||
return
|
||
|
||
# 域名短路:减少无效等待
|
||
if self._domain_is_blocked(url):
|
||
return
|
||
|
||
# DNS 探测短路 — 如果目标域名无法解析,直接 skip(不浪费 timeout)
|
||
if self._domain_unresolvable(url):
|
||
return
|
||
|
||
# 1) 通用爬虫 - 用线程级超时包装
|
||
content = self._run_with_timeout(
|
||
lambda: self._fetch_url_safely(url),
|
||
timeout=self.per_source_timeout,
|
||
name=source.get("name", ""),
|
||
)
|
||
if content:
|
||
yield from self._parse_list_page(
|
||
html=str(content), source=source,
|
||
target_name=target_name, target_category=target_category,
|
||
target_label=target_label, keyword=keyword,
|
||
)
|
||
return
|
||
|
||
if self._time_up():
|
||
return
|
||
|
||
# 2) 浏览器兜底(v4.2)— 同样带超时
|
||
if self.enable_browser_fallback:
|
||
html = self._run_with_timeout(
|
||
lambda: self._fetch_with_browser(url),
|
||
timeout=self.per_source_timeout * 2,
|
||
name=source.get("name", ""),
|
||
)
|
||
if html:
|
||
yield from self._parse_list_page(
|
||
html=str(html), source=source,
|
||
target_name=target_name, target_category=target_category,
|
||
target_label=target_label, keyword=keyword,
|
||
)
|
||
return
|
||
|
||
# 3) 占位记录 — 但避免同一 (target, source) 1 小时内的重复占位
|
||
if not self._placeholder_seen(target_name, source.get("name", "")):
|
||
yield self._build_article(
|
||
source=source,
|
||
target_name=target_name, target_category=target_category,
|
||
target_label=target_label,
|
||
title=f"[未能直接抓取] {target_name} 关键词={keyword} 来自 {source.get('name','')}",
|
||
summary=f"由于目标站点反爬或网络异常,未能直接抓取列表页。"
|
||
f"该目标与媒体源已记录,下次爬取会自动重试。",
|
||
content="",
|
||
publish_time="",
|
||
url=url,
|
||
)
|
||
|
||
# ---------------- 内部 fetch 工具 -----------------
|
||
|
||
def _log_fetch_failure(self, url: str, stage: str, exc: Exception) -> None:
|
||
"""v5.0.0: 吞错时可诊断 — 输出 URL + 阶段 + 原因/提示,便于排查源站封禁 vs 无数据。"""
|
||
hint = ""
|
||
try:
|
||
from http_utils import get_diagnostic # type: ignore
|
||
diag = get_diagnostic(exc, url)
|
||
if isinstance(diag, dict):
|
||
hint = diag.get("hint") or diag.get("reason") or str(diag)[:120]
|
||
except Exception:
|
||
pass
|
||
logger.warning("抓取失败 [%s] %s: %s%s", stage, url, exc, f" | {hint}" if hint else "")
|
||
|
||
def _is_soft_blocked(self, html: str) -> bool:
|
||
"""v5.0.0: 检测软封禁(HTTP 200 但内容为验证码/访问拦截页)。
|
||
接线 anti_block_utils.detect_block 纯函数,无副作用。"""
|
||
if not html:
|
||
return False
|
||
try:
|
||
from anti_block_utils import detect_block # type: ignore
|
||
sig = detect_block(200, body_sample=html[:2000])
|
||
return sig is not None
|
||
except Exception:
|
||
return False
|
||
|
||
def _fetch_url_safely(self, url: str) -> Optional[str]:
|
||
"""通过 FinancialPageScraper 抓取;异常被吞,返回 None"""
|
||
if not HAS_BASE_SCRAPER:
|
||
return None
|
||
try:
|
||
scraper = FinancialPageScraper()
|
||
content = scraper.scrape_url(url)
|
||
if not content:
|
||
return None
|
||
html = str(content)
|
||
# v5.0.0: 软封禁检测 — 200 但验证码/拦截页时触发浏览器兜底
|
||
if self._is_soft_blocked(html):
|
||
self._log_fetch_failure(url, "soft-block", RuntimeError("页面含软封禁特征(验证码/拦截)"))
|
||
return None
|
||
return html
|
||
except Exception as e:
|
||
self._log_fetch_failure(url, "http", e)
|
||
return None
|
||
|
||
def _fetch_with_browser(self, url: str) -> Optional[str]:
|
||
"""通过浏览器抓取;异常吞掉。v5.0.0 复用进程级单例,避免每源冷启动。"""
|
||
try:
|
||
from browser_scraper import _get_browser_scraper # type: ignore
|
||
bs = _get_browser_scraper(headless=True)
|
||
if bs is None:
|
||
return None
|
||
html = bs.fetch(url, wait_until="domcontentloaded", retries=1)
|
||
return str(html) if html else None
|
||
except Exception as e:
|
||
self._log_fetch_failure(url, "browser", e)
|
||
return None
|
||
|
||
def _run_with_timeout(self, fn, timeout: int, name: str = ""):
|
||
"""v5.0.0: 协作式超时执行 — 超时后不阻塞外层(修复 Python 线程不可强杀的假中断)。
|
||
|
||
线程不可强制终止,故:
|
||
1) 超时精确记录并返回 None
|
||
2) 将该源置入 self._cancelled_sources,供内层循环(爬取/解析)检查后尽快退出
|
||
3) executor 用 shutdown(wait=False),残留线程自行退出,外层不等待
|
||
"""
|
||
if timeout <= 0:
|
||
return fn()
|
||
try:
|
||
from concurrent.futures import ThreadPoolExecutor, TimeoutError as FutTimeout
|
||
except ImportError: # pragma: no cover
|
||
return fn()
|
||
executor = ThreadPoolExecutor(max_workers=1)
|
||
try:
|
||
future = executor.submit(fn)
|
||
try:
|
||
return future.result(timeout=timeout)
|
||
except FutTimeout:
|
||
logger.debug("⏱ 单源超时(%ds)%s,标记取消", timeout,
|
||
f" [{name}]" if name else "")
|
||
if name:
|
||
self._cancelled_sources.add(name)
|
||
return None
|
||
except Exception as e:
|
||
logger.debug("单源执行异常: %s", e)
|
||
return None
|
||
finally:
|
||
# wait=False:不阻塞外层;残留线程靠 cancelled 标记 / 底层连接超时退出
|
||
try:
|
||
executor.shutdown(wait=False, cancel_futures=True)
|
||
except Exception:
|
||
pass
|
||
|
||
# ---------------- 列表页解析 + 内容抽取 -----------------
|
||
|
||
def _parse_list_page(self, html: str, source: Dict[str, Any],
|
||
target_name: str, target_category: str,
|
||
target_label: str, keyword: str) -> Iterable[SentimentArticle]:
|
||
"""通用列表页解析 — 抽取 (标题 / 时间 / 链接) 三元组
|
||
优先使用 BeautifulSoup,不可用时自动降级到正则解析。
|
||
"""
|
||
try:
|
||
from bs4 import BeautifulSoup # type: ignore
|
||
soup = BeautifulSoup(html, "lxml")
|
||
anchors = soup.find_all("a")
|
||
for a in anchors:
|
||
title = (a.get_text() or "").strip()
|
||
href = a.get("href") or ""
|
||
if not title or len(title) < 8 or len(title) > 200:
|
||
continue
|
||
if not href.startswith(("http://", "https://", "/")):
|
||
continue
|
||
if href.startswith("/"):
|
||
href = (source.get("homepage", "") or "").rstrip("/") + href
|
||
time_text = _extract_time(a) or _extract_time(a.parent)
|
||
art = self._build_article(
|
||
source=source, target_name=target_name,
|
||
target_category=target_category, target_label=target_label,
|
||
title=title, summary="", content="",
|
||
publish_time=time_text, url=href,
|
||
)
|
||
yield art
|
||
return
|
||
except Exception:
|
||
pass # 降级到正则解析
|
||
|
||
# —— 纯标准库回退:正则提取 <a> 标签 ——
|
||
homepage = (source.get("homepage", "") or "").rstrip("/")
|
||
# 匹配 <a ... href="URL" ...>text</a>
|
||
a_pattern = re.compile(
|
||
r'<a\s[^>]*?href\s*=\s*["\']([^"\'>\s]+)["\'][^>]*>\s*(.+?)\s*</a>',
|
||
re.IGNORECASE | re.DOTALL,
|
||
)
|
||
tag_pattern = re.compile(r'<[^>]+>')
|
||
for m in a_pattern.finditer(html):
|
||
href = m.group(1)
|
||
raw_text = m.group(2)
|
||
# 去除 HTML 标签
|
||
title = tag_pattern.sub('', raw_text).strip()
|
||
title = re.sub(r'\s+', ' ', title)
|
||
if not title or len(title) < 8 or len(title) > 200:
|
||
continue
|
||
if not href.startswith(("http://", "https://", "/")):
|
||
continue
|
||
if href.startswith("/"):
|
||
href = homepage + href
|
||
# 尝试从上下文提取时间
|
||
ctx_start = max(0, m.start() - 200)
|
||
ctx = html[ctx_start:m.end() + 50]
|
||
time_text = _extract_time_from_text(ctx)
|
||
art = self._build_article(
|
||
source=source, target_name=target_name,
|
||
target_category=target_category, target_label=target_label,
|
||
title=title, summary="", content="",
|
||
publish_time=time_text, url=href,
|
||
)
|
||
yield art
|
||
|
||
# ---------------- Article 工厂 -----------------
|
||
|
||
def _build_article(self, source: Dict[str, Any], target_name: str,
|
||
target_category: str, target_label: str,
|
||
title: str, summary: str = "",
|
||
content: str = "", publish_time: str = "",
|
||
url: str = "") -> SentimentArticle:
|
||
# 规范化时间
|
||
pub_norm = _norm_time(publish_time)
|
||
|
||
# 情感分类
|
||
sentiment, score, hits, severity = self.classifier.classify(title, content)
|
||
category = hits[0] if hits else ""
|
||
|
||
return SentimentArticle(
|
||
title=title or "(无标题)",
|
||
summary=_first_n_chars(content or summary, 240),
|
||
content=content or "",
|
||
source=source.get("name", ""),
|
||
source_type=source.get("category", ""),
|
||
target_name=target_name,
|
||
target_type=target_label,
|
||
publish_time=pub_norm,
|
||
url=url,
|
||
sentiment=sentiment,
|
||
sentiment_score=score,
|
||
severity=severity,
|
||
category=category,
|
||
keywords_matched=hits[:10],
|
||
fetch_time=datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||
extra={
|
||
"language": source.get("language", "zh"),
|
||
"credibility": source.get("credibility", 5),
|
||
},
|
||
)
|
||
|
||
# ---------------- 去重 / 过滤 -----------------
|
||
|
||
def _is_dup(self, article: SentimentArticle) -> bool:
|
||
if not article.title or article.title.startswith("[未能直接抓取]"):
|
||
return False # 占位项不去重
|
||
fp = article.fingerprint
|
||
with _SEEN_LOCK:
|
||
if fp in _SEEN_INDEX:
|
||
return True
|
||
# v5.0.0: 模糊去重 — 同 target 已见标题相似度 > 0.85 视为重复
|
||
title_norm = re.sub(r"\s+", "", article.title or "").lower()
|
||
seen_titles = _TITLE_INDEX.setdefault(article.target_name or "", [])
|
||
for prev in seen_titles:
|
||
try:
|
||
from difflib import SequenceMatcher
|
||
ratio = SequenceMatcher(None, title_norm, prev).ratio()
|
||
except Exception:
|
||
ratio = 0.0
|
||
if ratio > 0.85:
|
||
article.dedup_ratio = ratio
|
||
return True
|
||
seen_titles.append(title_norm)
|
||
_SEEN_INDEX[fp] = article.publish_time or article.fetch_time
|
||
# 持久化(写入 1/20 概率,避免频繁写盘;锁内写避免并发撕裂)
|
||
if hash(fp) % 20 == 0:
|
||
try:
|
||
_SEEN_INDEX_FILE.write_text(
|
||
json.dumps(_SEEN_INDEX, ensure_ascii=False), encoding="utf-8"
|
||
)
|
||
except OSError:
|
||
pass
|
||
return False
|
||
|
||
def _is_in_window(self, article: SentimentArticle,
|
||
cutoff_time: datetime) -> bool:
|
||
"""检查文章发布时间是否在时间窗内。
|
||
|
||
规则:
|
||
- 占位文章([未能直接抓取]): 保留(记录爬取失败)
|
||
- 无 publish_time: 保留(不因格式问题误杀)
|
||
- publish_time >= cutoff_time: 保留
|
||
- publish_time < cutoff_time: 丢弃
|
||
"""
|
||
if not article.title or article.title.startswith("[未能直接抓取]"):
|
||
return True # 保留占位标记
|
||
if not article.publish_time:
|
||
return True # 保留无时间文章
|
||
try:
|
||
pub_str = str(article.publish_time)[:10]
|
||
pub_dt = datetime.strptime(pub_str, "%Y-%m-%d")
|
||
return pub_dt >= cutoff_time
|
||
except (ValueError, IndexError):
|
||
return True # 无法解析时间的文章保留
|
||
|
||
def _compute_date_validation(self, articles: List[SentimentArticle],
|
||
cutoff_time: datetime,
|
||
requested_days: int) -> Dict[str, Any]:
|
||
"""爬取后日期核验:统计文章是否在请求的时间窗内。
|
||
|
||
Returns:
|
||
{"requested_days": 7,
|
||
"cutoff_date": "2026-07-23",
|
||
"articles_checked": 50,
|
||
"out_of_window": 0,
|
||
"unparseable_dates": 2,
|
||
"date_range_actual": {"earliest": "2026-07-24", "latest": "2026-07-30"}}
|
||
"""
|
||
checked = 0
|
||
out_of_window = 0
|
||
unparseable = 0
|
||
out_articles: List[Dict[str, Any]] = []
|
||
dates_found: List[str] = []
|
||
|
||
for a in articles:
|
||
if not a.publish_time:
|
||
unparseable += 1
|
||
continue
|
||
try:
|
||
pub_str = str(a.publish_time)[:10]
|
||
pub_dt = datetime.strptime(pub_str, "%Y-%m-%d")
|
||
checked += 1
|
||
dates_found.append(pub_str)
|
||
if pub_dt < cutoff_time:
|
||
out_of_window += 1
|
||
out_articles.append({
|
||
"title": a.title[:60],
|
||
"publish_time": a.publish_time,
|
||
"age_days": (datetime.now() - pub_dt).days,
|
||
})
|
||
except (ValueError, IndexError):
|
||
unparseable += 1
|
||
|
||
# 实际日期范围
|
||
actual_range = {}
|
||
if dates_found:
|
||
dates_found.sort()
|
||
actual_range = {"earliest": dates_found[0], "latest": dates_found[-1]}
|
||
|
||
return {
|
||
"requested_days": requested_days,
|
||
"cutoff_date": cutoff_time.strftime("%Y-%m-%d"),
|
||
"articles_checked": checked,
|
||
"out_of_window": out_of_window,
|
||
"unparseable_dates": unparseable,
|
||
"out_of_window_samples": out_articles[:5],
|
||
"date_range_actual": actual_range,
|
||
}
|
||
|
||
def _post_filter(self, articles: List[SentimentArticle],
|
||
positive_only: bool, negative_only: bool) -> List[SentimentArticle]:
|
||
out = []
|
||
for a in articles:
|
||
if positive_only and a.sentiment != "positive":
|
||
continue
|
||
if negative_only and a.sentiment != "negative":
|
||
continue
|
||
out.append(a)
|
||
return out
|
||
|
||
# ---------------- 快照存储 -----------------
|
||
|
||
def _save_snapshot(self, snapshot: SentimentSnapshot):
|
||
file_path = self.snapshot_dir / f"{snapshot.snapshot_id}.json"
|
||
file_path.write_text(
|
||
json.dumps(snapshot.to_dict(), ensure_ascii=False, indent=2),
|
||
encoding="utf-8"
|
||
)
|
||
snapshot.extra_path = str(file_path) # type: ignore[attr-defined]
|
||
# v4.5.1: 索引写入节流 — 每 5 个快照才重写一次 index.json
|
||
# 平时只 append 临时标记,下次保存时重建
|
||
idx = self.snapshot_dir / "index.json"
|
||
index = []
|
||
if idx.exists():
|
||
try:
|
||
index = json.loads(idx.read_text(encoding="utf-8"))
|
||
except Exception:
|
||
index = []
|
||
index.insert(0, {
|
||
"snapshot_id": snapshot.snapshot_id,
|
||
"created_at": snapshot.created_at,
|
||
"total": snapshot.stats.get("total", 0),
|
||
"pos": snapshot.stats.get("by_sentiment", {}).get("positive", 0),
|
||
"neg": snapshot.stats.get("by_sentiment", {}).get("negative", 0),
|
||
"path": str(file_path),
|
||
})
|
||
# 仅在快照数为 5 的倍数 或快照数<=3时写盘,减少 IO
|
||
if len(index) <= 3 or len(index) % 5 == 0:
|
||
idx.write_text(json.dumps(index[:50], ensure_ascii=False, indent=2), encoding="utf-8")
|
||
|
||
|
||
# ============================================================
|
||
# 5. 工具函数
|
||
# ============================================================
|
||
|
||
def quote_kw(kw: str) -> str:
|
||
from urllib.parse import quote
|
||
return quote(kw)
|
||
|
||
|
||
def _first_n_chars(text: str, n: int) -> str:
|
||
text = re.sub(r"\s+", " ", text or "").strip()
|
||
if len(text) <= n:
|
||
return text
|
||
return text[:n].rstrip() + "…"
|
||
|
||
|
||
_TIME_RE = re.compile(
|
||
r"(\d{4})[-/年.](\d{1,2})[-/月.](\d{1,2})(?:日)?(?:(?:\s+|T)(\d{1,2}))?:?(\d{1,2})?:?(\d{1,2})?"
|
||
)
|
||
|
||
|
||
_REL_TIME_RE = re.compile(r"(\d+)\s*(分钟|小时|天|日|周)前|今天|昨天|前天")
|
||
|
||
|
||
def _norm_relative_time(text: str) -> str:
|
||
"""v5.0.0: 把相对时间(3小时前/昨天/前天/2天前/今天)转为绝对时间字符串。"""
|
||
now = datetime.now()
|
||
m = _REL_TIME_RE.search(text)
|
||
if not m:
|
||
return ""
|
||
if m.group(2) is None:
|
||
# 今天 / 昨天 / 前天
|
||
if "昨天" in m.group(0):
|
||
return (now - timedelta(days=1)).strftime("%Y-%m-%d %H:%M:%S")
|
||
if "前天" in m.group(0):
|
||
return (now - timedelta(days=2)).strftime("%Y-%m-%d %H:%M:%S")
|
||
return now.strftime("%Y-%m-%d %H:%M:%S")
|
||
n = int(m.group(1))
|
||
unit = {"分钟": "minutes", "小时": "hours", "天": "days",
|
||
"日": "days", "周": "weeks"}[m.group(2)]
|
||
return (now - timedelta(**{unit: n})).strftime("%Y-%m-%d %H:%M:%S")
|
||
|
||
|
||
def _norm_time(time_text: str) -> str:
|
||
"""把 2026年7月27日 09:42 / 2026-07-27 / 07-27 / 3小时前 等统一为 YYYY-MM-DD HH:MM:SS"""
|
||
if not time_text:
|
||
return ""
|
||
# v5.0.0: 相对时间 → 绝对时间
|
||
rel = _norm_relative_time(str(time_text))
|
||
if rel:
|
||
return rel
|
||
m = _TIME_RE.search(str(time_text))
|
||
if not m:
|
||
# 仅日期
|
||
d = re.search(r"(\d{4})[-/年.](\d{1,2})[-/月.](\d{1,2})", str(time_text))
|
||
if d:
|
||
return f"{d.group(1)}-{int(d.group(2)):02d}-{int(d.group(3)):02d} 00:00:00"
|
||
return str(time_text)
|
||
y, mo, d, h, mi, s = m.groups()
|
||
h = h or "0"; mi = mi or "0"; s = s or "0"
|
||
return f"{int(y):04d}-{int(mo):02d}-{int(d):02d} {int(h):02d}:{int(mi):02d}:{int(s):02d}"
|
||
|
||
|
||
def _extract_time_from_text(text: str) -> str:
|
||
"""从纯文本中提取时间——用于正则解析回退"""
|
||
patterns = [
|
||
r'(\d{4}[-/]\d{1,2}[-/]\d{1,2}\s+\d{1,2}:\d{2})',
|
||
r'(\d{4}[-/]\d{1,2}[-/]\d{1,2})',
|
||
r'(\d{1,2}\u6708\d{1,2}\u65e5\s+\d{1,2}:\d{2})',
|
||
r'(\d{1,2}\u5c0f\u65f6\u524d)',
|
||
r'(\d+\u5206\u949f\u524d)',
|
||
]
|
||
for pat in patterns:
|
||
m = re.search(pat, text)
|
||
if m:
|
||
return m.group(1)
|
||
return ""
|
||
|
||
def _extract_time(element) -> str:
|
||
"""从父/子元素中提取时间字符串"""
|
||
if element is None:
|
||
return ""
|
||
for attr in ("title", "data-time", "datetime"):
|
||
v = element.get(attr)
|
||
if v:
|
||
return str(v)
|
||
txt = element.get_text(" ", strip=True) if hasattr(element, "get_text") else ""
|
||
m = _TIME_RE.search(txt)
|
||
if m:
|
||
return m.group(0)
|
||
return ""
|
||
|
||
|
||
def _count_by(items: List[Any], key_fn) -> Dict[str, int]:
|
||
out = defaultdict(int)
|
||
for it in items:
|
||
k = key_fn(it)
|
||
out[k] += 1
|
||
return dict(out)
|
||
|
||
|
||
# ============================================================
|
||
# 6. 顶层便捷 API
|
||
# ============================================================
|
||
|
||
def crawl_sentiment(targets: Optional[List[str]] = None,
|
||
categories: Optional[List[str]] = None,
|
||
sources: Optional[List[str]] = None,
|
||
source_categories: Optional[List[str]] = None,
|
||
days: int = 7,
|
||
positive_only: bool = False,
|
||
negative_only: bool = False,
|
||
max_articles: int = 50,
|
||
max_total_seconds: int = 30,
|
||
per_source_timeout: int = 4,
|
||
dry_run: bool = False,
|
||
confirmed: bool = True,
|
||
run_backtest: bool = False,
|
||
backtest_drop: Optional[Tuple[str, ...]] = None,
|
||
parallel_workers: int = 0,
|
||
use_rss: bool = True) -> SentimentSnapshot:
|
||
"""便捷函数:一次全网舆情爬取。
|
||
Args:
|
||
max_total_seconds: 整个爬取最大耗时(默认 30s),到时立即返回已有结果
|
||
per_source_timeout: 单源超时(默认 4s)
|
||
dry_run: 仅返回爬取计划(不发任何 HTTP 请求)
|
||
confirmed: 是否已确认(False 时等同于 dry_run=True)
|
||
run_backtest: 是否对每篇文章做 4 维回测(慢 ~20%)
|
||
backtest_drop: 回测后丢弃的推荐等级元组(如 ("建议丢弃",))
|
||
parallel_workers: 并发抓取线程数(0=顺序,>0 启用并发加速)
|
||
use_rss: v5.0.0 是否启用 RSS 直连(默认 True,仅对配置了 rss_feeds 的站点生效)
|
||
默认更激进以保证对话体验。
|
||
"""
|
||
crawler = SentimentCrawler(
|
||
max_total_seconds=max_total_seconds,
|
||
per_source_timeout=per_source_timeout,
|
||
use_rss=use_rss,
|
||
)
|
||
# 用户未指定 media 时不再默认只跑权威+财经 — 默认爬权威+财经+自媒体,让对话/导出一键拿数据
|
||
if not source_categories and not sources:
|
||
source_categories = ["authoritative", "financial_vertical", "self_media"]
|
||
return crawler.crawl(
|
||
target_names=targets,
|
||
target_categories=categories,
|
||
source_names=sources,
|
||
source_categories=source_categories,
|
||
days=days,
|
||
positive_only=positive_only,
|
||
negative_only=negative_only,
|
||
max_articles=max_articles,
|
||
dry_run=dry_run,
|
||
confirmed=confirmed,
|
||
run_backtest=run_backtest,
|
||
backtest_drop=backtest_drop,
|
||
parallel_workers=parallel_workers,
|
||
)
|
||
|
||
|
||
def list_sentiment_targets() -> List[Dict[str, Any]]:
|
||
"""查看目标库"""
|
||
loader = SentimentTargetLoader()
|
||
stats = loader.stats()
|
||
return [{"category": k, "label": (loader.get(k).get("label", k) if loader.get(k) else k),
|
||
"count": v} for k, v in stats.items()]
|
||
|
||
|
||
def list_sentiment_sources(category: Optional[str] = None) -> List[Dict[str, Any]]:
|
||
"""查看媒体源"""
|
||
loader = SentimentSourceLoader()
|
||
if category:
|
||
return loader.sources_by_category(category)
|
||
return loader.all_sources()
|
||
|
||
|
||
def add_custom_sentiment_target(category: str, name: str,
|
||
aliases: Optional[List[str]] = None) -> Dict[str, Any]:
|
||
"""新增自定义目标"""
|
||
loader = SentimentTargetLoader()
|
||
return loader.add_custom_target(category, name, aliases)
|
||
|
||
|
||
# ============================================================
|
||
# 7. CLI
|
||
# ============================================================
|
||
|
||
def _cli():
|
||
import argparse
|
||
parser = argparse.ArgumentParser(
|
||
description="cn-financial-scraper 全网舆情爬虫 v4.3",
|
||
)
|
||
parser.add_argument("--targets", type=str, default="",
|
||
help="目标列表,逗号分隔,如 '贵州茅台,工银瑞信基金'")
|
||
parser.add_argument("--categories", type=str, default="",
|
||
help="目标类别,逗号分隔,如 'fund_company,listed_company'")
|
||
parser.add_argument("--sources", type=str, default="",
|
||
help="媒体源,逗号分隔")
|
||
parser.add_argument("--source-categories", type=str, default="",
|
||
help="媒体类别,逗号分隔,如 'authoritative,self_media'")
|
||
parser.add_argument("--days", type=int, default=7, help="时间窗口(天)")
|
||
parser.add_argument("--negative-only", action="store_true", help="仅保留舆情")
|
||
parser.add_argument("--positive-only", action="store_true", help="仅保留正面")
|
||
parser.add_argument("--max", type=int, default=50, help="最大文章数")
|
||
parser.add_argument("--stats", action="store_true", help="查看媒体源/目标库统计")
|
||
parser.add_argument("--list-targets", action="store_true", help="列出目标库")
|
||
parser.add_argument("--list-sources", action="store_true", help="列出媒体源")
|
||
parser.add_argument("--add-target", nargs=3, metavar=("CATEGORY", "NAME", "ALIASES"),
|
||
help="新增自定义目标,类目-名称-别名(逗号分隔)")
|
||
args = parser.parse_args()
|
||
|
||
if args.stats:
|
||
s = SentimentSourceLoader().stats()
|
||
t = SentimentTargetLoader().stats()
|
||
print("📰 媒体源:", json.dumps(s, ensure_ascii=False, indent=2))
|
||
print("🎯 目标库:", json.dumps(t, ensure_ascii=False, indent=2))
|
||
return
|
||
|
||
if args.list_targets:
|
||
print(json.dumps(list_sentiment_targets(), ensure_ascii=False, indent=2))
|
||
return
|
||
if args.list_sources:
|
||
print(json.dumps(list_sentiment_sources(), ensure_ascii=False, indent=2))
|
||
return
|
||
|
||
if args.add_target:
|
||
cat, name, aliases = args.add_target
|
||
alias_list = [a.strip() for a in aliases.split(",") if a.strip()]
|
||
print(json.dumps(add_custom_sentiment_target(cat, name, alias_list), ensure_ascii=False))
|
||
return
|
||
|
||
targets = [t.strip() for t in args.targets.split(",") if t.strip()] or None
|
||
categories = [c.strip() for c in args.categories.split(",") if c.strip()] or None
|
||
sources = [s.strip() for s in args.sources.split(",") if s.strip()] or None
|
||
source_cats = [c.strip() for c in args.source_categories.split(",") if c.strip()] or None
|
||
|
||
snapshot = crawl_sentiment(
|
||
targets=targets, categories=categories,
|
||
sources=sources, source_categories=source_cats,
|
||
days=args.days,
|
||
positive_only=args.positive_only,
|
||
negative_only=args.negative_only,
|
||
max_articles=args.max,
|
||
)
|
||
|
||
print(f"✅ 完成!快照ID: {snapshot.snapshot_id}")
|
||
print(f" 共 {snapshot.stats.get('total', 0)} 条 | "
|
||
f"正面 {snapshot.positive_count()} | "
|
||
f"舆情 {snapshot.negative_count()} | "
|
||
f"中性 {snapshot.neutral_count()}")
|
||
print(f" 严重等级: {snapshot.stats.get('by_severity', {})}")
|
||
print(f" 媒体类别分布: {snapshot.stats.get('by_source_type', {})}")
|
||
print(f" 保存路径: {snapshot.snapshot_dir if hasattr(snapshot, 'snapshot_dir') else SNAPSHOT_DIR}")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
|
||
_cli()
|