上周五的Virginia bus crash事故,BBC和CBS几乎同时发出报道,但细节差异不小——BBC说34人就医,CBS说19人去了Mary Washington医院。如果你是一个需要实时追踪重大事件的信息平台开发者,手动拼凑这些零散的消息显然是噩梦。

今天我们就用这个真实案例,走一遍如何搭建一个轻量级多源新闻聚合系统,核心包括:抓取、去重、可信度评估和API暴露。读完你可以直接用在自己项目里。

为什么自己搭,而不是用现成聚合服务?

当然有NewsAPI、GDELT这样的服务,但它们的免费额度限制多,而且无法自定义源列表(比如只抓你信任的几个媒体)。自己搭的好处是:

  • 源完全可控(BBC、CBS、当地警察局Twitter等)
  • 去重逻辑自定义(同一事件不同表述能否合并)
  • 评分机制透明(你想让警察局官方账号权重高于自媒体)
  • 数据隐私(新闻内容无需经过第三方)

第一步:抓取多个源的原始内容

先确定三个目标源:BBC的报道页面、CBS的报道页面、Virginia State Police的官方新闻稿(如果有)。我们只演示文本内容,不考虑版权许可问题(实际项目要遵守robots.txt和API使用条款)。

python
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
import requests
from bs4 import BeautifulSoup

def fetch_bbc(url):
    resp = requests.get(url, timeout=10)
    soup = BeautifulSoup(resp.text, 'html.parser')
    # BBC文章内容通常在article标签下
    article = soup.find('article')
    paragraphs = article.find_all('p')
    return ' '.join(p.get_text() for p in paragraphs)

def fetch_cbs(url):
    resp = requests.get(url, timeout=10)
    soup = BeautifulSoup(resp.text, 'html.parser')
    # CBS新闻正文在div.content__body
    body = soup.find('div', class_='content__body')
    if not body:
        body = soup.find('div', class_='article-body')
    return body.get_text(strip=True)

这里的关键是每个网站结构不同,需要写适配器。我建议用Feedparser抓RSS,结构更统一。BBC和CBS都提供RSS,但这次事故突发未必立刻进RSS。实践中可以先用RSS主循环,再补抓单条。

第二步:用TF-IDF去除重复内容

同一事件的不同报道,主体信息完全重复。我们需要检测语义相似度而非URL去重。用TF-IDF + 余弦相似度就够了,比BERT快得多。

python
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics.pairwise import cosine_similarity

def dedup_articles(articles, threshold=0.85):
    """articles: list of {source, text, url, timestamp}"""
    texts = [a['text'] for a in articles]
    vectorizer = TfidfVectorizer(stop_words='english')
    tfidf = vectorizer.fit_transform(texts)
    sim_matrix = cosine_similarity(tfidf)
    
    keep = []
    for i in range(len(articles)):
        duplicate = False
        for j in keep:
            if sim_matrix[i][j] > threshold:
                duplicate = True
                break
        if not duplicate:
            keep.append(i)
    return [articles[i] for i in keep]

阈值0.85是经验值,我测试过几组事故报道,同一事件的不同媒体改写后相似度通常在0.75~0.95之间。如果想保守一点,可以设0.9。但要注意,如果事件有重大进展(比如死亡人数从5变6),相似度会降低,此时应保留两条。

第三步:计算可信度评分

这是你自己决定哪条新闻更可靠的关键。简单做法:给每个源一个基础分,再根据时效性、引用链接数量、是否含官方声明做加减。

python
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
def credibility_score(article):
    score = 0.0
    # 源权重
    source_weights = {
        'bbc': 0.9,
        'cbs': 0.85,
        'police_official': 1.0,
        'twitter_police': 0.7
    }
    score += source_weights.get(article['source'], 0.5)
    # 时效性(小时偏差)假设最新时间戳为基准
    import datetime
    hours_ago = (datetime.datetime.now() - article['published']).total_seconds() / 3600
    score += max(0, 0.1 * (1 - hours_ago / 24))  # 越新分数越高
    # 文本长度(太短的通常信息不全)
    length_score = min(len(article['text']) / 2000, 0.1)
    score += length_score
    # 是否包含数字(受伤人数、时间等)
    import re
    num_count = len(re.findall(r'\d+', article['text']))
    score += min(num_count * 0.01, 0.1)
    return score

这个函数可以优化:比如警察局官方新闻稿应该得到最高分,即使发布时间晚于媒体。我个人的观点是来源权威性比时效性重要,所以source_weights范围是0.5-1.0,而时效性最多加0.1。

第四步:暴露成REST API

用FastAPI三分钟搭好。

python
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI()

class ArticleOut(BaseModel):
    title: str
    source: str
    url: str
    credibility: float
    text_preview: str

@app.get("/news/aggregate", response_model=list[ArticleOut])
def get_aggregated(event_id: str = None, limit: int = 5):
    # 从缓存(如Redis)读取去重后的文章列表
    articles = cache.get(f"event:{event_id}", [])
    # 按可信度降序
    articles.sort(key=lambda a: a['credibility'], reverse=True)
    return [
        ArticleOut(
            title=a['title'][:80],
            source=a['source'],
            url=a['url'],
            credibility=round(a['credibility'], 2),
            text_preview=a['text'][:200]
        )
        for a in articles[:limit]
    ]

实际项目中你还需要爬虫调度(每15分钟轮询)、缓存策略、限流。生产环境建议用Celery + Redis做异步抓取。

值得注意的坑

  1. BBC/CBS的反爬:频率过高会被封IP。方案是使用代理池或直接爬RSS(BBC有有效RSS但可能有延迟)。
  2. 事件关联:同一事故在不同媒体可能使用不同的称呼(“I-95 crash” vs “Fredericksburg bus accident”)。这里可以用地名+时间做hash,或者用LLM做主题聚类(成本高)。我的建议是对于严重事故,先手动建立一个事件ID,然后抓取时通过关键词匹配绑定。
  3. 动态内容:部分新闻网站的正文由JavaScript渲染,requests拿不到。此时需要用Selenium或直接抓取AMP页面。

你的下一步

如果你也想做这类工具,我建议从RSS开始,加一两个自己信任的媒体,先把数据流水线跑通。不要一开始追求完美去重和评分,能跑起来、看到效果,就有动力迭代。

事故报道截图对比与Python代码片段

这个聚合系统的核心价值在于让你不再依赖单一信息源,同时能用算法自动挑出最可信的版本。下次再有类似Virginia bus crash这样的事件,你的API可以第一时间输出结构化的、可信度标注过的信息。

当然,代码里每一个阈值和权重都带主观判断,你可以根据自己的需求调整——比如更看重时效性,就把时效性分数权重翻倍。这就是自己搭的最大的自由。

(所有代码基于Python 3.11 + FastAPI 0.104,测试通过。实际使用时请遵守各网站的服务条款。)