从零搭建新闻聚合分析系统:Python爬虫与TF-IDF、TextRank关键词提取实战
我们平时刷新闻,刷完就过去了,根本没在意那些标题和正文背后能挖出什么信息。这阵子刚好接手一个需求:用爬虫抓取新闻网站的数据,然后用TF-IDF和TextRank算法做关键词提取,帮运营快速了解一篇文章到底在讲什么。
说实话,这个组合很经典,也很有针对性——爬虫负责把数据拿过来,算法负责把数据变成“人话”。我不打算只讲概念,直接把整套流程拆开揉碎,从爬虫架构选型、反爬应对策略,到TF-IDF和TextRank的实现细节与融合调优,完整走一遍。这篇内容适合三类人:刚入门想练手爬虫的Python爱好者、做文本挖掘不知道选哪种关键词提取算法的同学、以及想给新闻产品加自动摘要功能的开发。
1. 项目整体设计与选型思路
很多初学者看到“爬虫+TF-IDF+TextRank”这个组合,第一反应是“那我先写个爬虫,再调个库算一下关键词不就行了”。真这么干,很快就会发现到处是坑:爬虫被反爬封了、抓下来的正文带一堆HTML标签、分词结果全是语气词、两个算法得出的关键词还互相打架。
我在动手之前,先把整个项目拆成了五个模块:数据采集、内容清洗、中文分词、关键词计算、结果汇总。每个模块单独跑通后再串联,这样排查问题会轻松很多。
1.1 为什么选择Requests库而非其他方案
数据采集这一步,我用的是Python的Requests库,而不是Selenium或者Scrapy。很多人会纠结这个选择,其实关键看场景:新闻网站的结构一般比较规整,标题、正文、时间都在清晰的HTML标签里,用Requests可以直接拿到原始HTML再解析,足够用了。
Selenium适合渲染JavaScript才能出内容的网站,但新闻站很少这么干(除了部分前端框架重构的);Scrapy是重型框架,支持分布式、并发、调度,适合大规模采集,但起步成本高,一个简单的新闻爬虫没必要上来就上Scrapy。
Requests的语法非常简单,几行代码就能完成一次GET请求,配合headers伪造成浏览器访问,基本能通过绝大多数新闻网站的初级反爬。再配合requests.Session()保持连接,请求效率也能提升。
1.2 清洗模块是决定算法上限的关键
我最早犯的错就是忽略了清洗模块的重要性。抓下来的HTML里有各种无关信息:导航栏的链接、底部的版权声明、页面里夹带的推荐新闻标题、脚本中的代码片段,这些如果不过滤掉,直接丢给分词器,出来的关键词会离谱到让人怀疑人生。
清洗的目标只有一个:最大程度还原新闻正文的原始文本。我会先删除所有script和style标签,再抽离正文所在的标签。很多新闻网站的正文都在<div class="content">或<article>里,可以用BeautifulSoup的select_one定位后,再用get_text()提取纯文本。
清洗完的空行、空格、乱码符号也都要处理,最后得到一个干净的文本字符串,这时才能进入分词环节。
1.3 为什么选用jieba做中文分词
中文NLP和英文有一个巨大差异:英文单词天然有空格隔开,中文全靠分词算法划分。关键词提取的基础就是分词,分词漏一个字,后面的算法输出就全变了。
jieba是目前中文分词的主流选择,它基于前缀词典实现高效的词图扫描,生成句子中汉字所有可能成词的情况,再用动态规划找出最大概率路径作为分词结果。对于新闻这种规范化文本,jieba的效果非常稳定。
除了基础分词,jieba还提供了jieba.analyse.extract_tags,这个接口直接封装了TF-IDF算法的实现,甚至内置了TF-IDF的IDF语料库。而TextRank也有对应的jieba.analyse.textrank接口。也就是说,在选型层面,我们不必重复造轮子,直接调用即可。但我会在后面专门讲讲这两个封装接口背后的原理和它们各自的适用边界,因为只调用不理解的“调包侠”路线,遇到效果不好时根本不知道从哪里调参。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 爬虫模块的落地实现与反爬应对
爬虫写起来快,真正让人头疼的是反爬。下面直接看代码,顺带说几个我在实际运行中遇到的坑。
2.1 携带Headers伪装浏览器请求
python复制import requests
from bs4 import BeautifulSoup
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.5",
"Referer": "https://news.example.com/",
"Connection": "keep-alive"
}
session = requests.Session()
session.headers.update(headers)
def fetch_news_page(url):
try:
resp = session.get(url, timeout=10)
resp.raise_for_status()
resp.encoding = resp.apparent_encoding
return resp.text
except requests.RequestException as e:
print(f"[请求失败] {url} -> {e}")
return None
有人问resp.encoding = resp.apparent_encoding这行为什么要写,因为新闻网站有的用UTF-8,有的用GBK,直接默认编码经常会乱码。让Requests从内容中自动检测编码,虽然会多消耗一点性能,但可靠性高很多。实测下来,这是中文网页乱码问题最高性价比的解法。
2.2 解析详情页提取标题与正文
python复制def parse_news_item(html):
soup = BeautifulSoup(html, "html.parser")
# 移除无用的脚本与样式
for tag in soup(["script", "style", "meta", "noscript", "iframe"]):
tag.decompose()
title = soup.select_one("h1")
title_text = title.get_text().strip() if title else ""
content_div = soup.select_one("div.content, article#content, div#content")
if not content_div:
content_div = soup.select_one("div.article-text, div.news-content")
if content_div:
paragraphs = content_div.find_all("p")
content_text = "\n".join(p.get_text().strip() for p in paragraphs if p.get_text().strip())
else:
# 兜底:取整个body的文本
content_text = soup.get_text(separator="\n")
return {
"title": title_text,
"content": content_text,
"url": "",
"timestamp": ""
}
解析这块,最怕的就是新闻站改版把类名换了。所以代码里我写了多个候选选择器做兜底,select_one返回空时继续尝试下一个。还有一个经验是:不靠单个标签的class,而是用结构推测正文。很多新闻网站的正文段落都在<p>标签里,且是连续排列的,这比依赖一个精确的CSS类名稳健得多。
2.3 反爬策略与请求频率控制的实际做法
新闻网站的反爬从轻到重大概分三个等级。第一级:校验User-Agent和请求头,这种最简单,伪装headers即可绕过。第二级:统计IP在单位时间内的请求次数,超过阈值直接封IP,这种需要控制请求频率。第三级:需要登录Cookie才能访问完整内容,或者前端有动态加密参数,这类就不是一个简单爬虫能搞定的了。
我这次面对的主要是第二级,对策如下:
python复制import time
import random
def crawl_news_list(urls):
results = []
for url in urls:
html = fetch_news_page(url)
if html:
item = parse_news_item(html)
results.append(item)
# 随机休眠,模拟真实用户浏览行为
time.sleep(random.uniform(2, 5))
return results
这个随机休眠很关键。固定2秒不行,因为机器行为的规律性太强,容易被风控识别。采用2到5秒之间的随机值,配合前面的浏览器headers,实测抓上千条新闻没有被封过。
2.4 一个必须绕开的坑:Pycharm运行显示Process finished with exit code 0
很多新手在Pycharm里跑爬虫,发现控制台只有“Process finished with exit code 0”,没有任何输出,第一反应是代码出问题了,其实不是——这个提示的意思是程序正常结束了,只是没有数据打印出来,或者没有数据写入文件。
我当时排查这个问题的思路分享给大家:
- 先确认是否有
print()语句。我只在fetch_news_page里打了错误日志,正常流程没打印任何内容,程序跑完自然没输出。 - 再检查URL列表是否为空。如果列表是空的,循环一次都不会执行,直接结束。
- 最后看函数是否有返回值、是否被调用。如果定义了
parse_news_item但忘了在crawl_news_list里调用,也是白跑。
如果你想让结果“可见”,最简单的做法是把抓取结果写入CSV或JSON文件,不要只依赖控制台print。这也是我推荐的做法,因为爬虫做完数据清洗后,后续算法还需要从文件中读取文本。
3. TF-IDF关键词提取:统计之美与实现细节
爬虫拿到数据后,核心部分就来了。先说TF-IDF。
3.1 TF-IDF的核心思想与数学表达式
TF-IDF是两个指标的乘积:词频(Term Frequency)和逆文档频率(Inverse Document Frequency)。
词频表示一个词在当前文档中出现的次数(也可以归一化处理)。逆文档频率表示一个词在整个语料库中的稀疏程度——如果一个词在越多的文档中出现,它的区分能力就越弱,IDF值就越低;反之,一个词只在极少数文档中出现,说明它很有代表性,IDF值就越高。
公式就是:
code复制TF = 词在文档中出现的次数 / 文档的总词数
IDF = ln(总文档数 / (包含该词的文档数 + 1)) + 1
TF-IDF = TF * IDF
注意,IDF公式里分母加1是防止除数为0,外部加1是为了防止IDF出现负值。很多资料忽略这个细节,自己去实现时就没算对。
3.2 用jieba实现TF-IDF的两种姿势
第一种:直接调用封装好的接口。
python复制import jieba.analyse
def extract_keywords_tfidf(text, topK=10):
return jieba.analyse.extract_tags(
text,
topK=topK,
withWeight=True,
allowPOS=('ns', 'n', 'vn', 'v', 'nr')
)
这里allowPOS限定词性,是中文新闻关键词提取很重要的调参手段。新闻里最有关键词价值的是名词和动词,尤其是一些事件类名词(ns地方、nr人名、n普通名词、vn动名词),把这些词性筛出来,效果会立竿见影。
第二种:自己手写TF-IDF计算逻辑,加深理解,也方便自定义。
python复制import math
from collections import Counter
def compute_tf_idf(documents, target_index, target_word):
# documents: 分词后的文档列表,每个元素是一个list
# 计算TF
word_list = documents[target_index]
total_words = len(word_list)
tf = word_list.count(target_word) / total_words
# 计算IDF
containing = sum(1 for doc in documents if target_word in doc)
idf = math.log(len(documents) / (containing + 1)) + 1
return tf * idf
我建议你把两种方式都实现一遍,只调extract_tags永远不知道参数背后的逻辑,手写一遍后,遇到allowPOS调不动、权重要自定义的场景心里就有底了。
提示:TF-IDF本质上是“词在文档中的重要性”与“词在语料库中的独特性”的乘积。它没有考虑词的上下文关系,这是它和TextRank最大的区别。
3.3 TF-IDF在新闻场景下的局限与痛处
TF-IDF在新闻关键词提取中有一个明显毛病:它依赖整个语料库的统计信息。如果你只抓了十几篇新闻,IDF统计根本不够准确,一些低频但重要的词会得到过高的IDF权重,一些常见的新闻词(“记者”、“报道”、“今天”)又可能被误判为关键词。
另一个痛点是,TF-IDF完全基于词频,不考虑词的语义。一篇关于“苹果发布新手机”的新闻里,“苹果”“手机”“发布”大概率得分最高,但如果这篇新闻同时反复提到“创新”“科技”“AI”,这些词也会挤进关键词前列,而它们未必是编辑最想突出的主题。
所以在实际项目中,我会对TF-IDF结果做停用词过滤,把“我们”“他们”“可以”“为了”这类无意义词全部干掉。jieba自带了一个停用词表,但不全,建议自己维护一份新闻领域的常见停用词列表。
4. TextRank关键词提取:图算法在文本中的运用
说完TF-IDF,再说TextRank。如果把TF-IDF比作“用频率投票”,那TextRank就是“用关系投票”。
4.1 TextRank的原理:从PageRank到文本图模型
TextRank算法最早是从谷歌的PageRank算法借鉴过来的。PageRank把网页看作节点,网页之间的链接看作边,通过迭代计算每个网页的权重。TextRank把这种思路迁移到了文本:把每个候选词看作一个节点,如果两个词在同一个窗口内共现,就在它们之间建立一条边,然后通过迭代计算每个节点的得分。
核心公式是:
code复制WS(Vi) = (1 - d) + d * sum(WS(Vj) / |Out(Vj)|) for Vj in In(Vi)
d是阻尼系数,通常取0.85,含义是当前节点的得分有85%来自邻居节点的贡献,15%来自自身。In(Vi)是指向节点Vi的节点集合,Out(Vj)是节点Vj向外链接的节点集合。
4.2 jieba的TextRank实现与参数调优
python复制import jieba.analyse
def extract_keywords_textrank(text, topK=10):
return jieba.analyse.textrank(
text,
topK=topK,
withWeight=True,
allowPOS=('ns', 'n', 'vn', 'v', 'nr'),
withFlag=False
)
jieba自带的TextRank接口实现里,有一个span参数用于定义窗口大小,默认是5。窗口大小直接影响词与词的共现关系——窗口太小,长距离依赖抓不到;窗口太大,关系图会过于稠密,计算量暴增且区分度下降。我实测的时候发现,新闻文本窗口设为3到5效果都还好,如果是短文本(比如标题提取),窗口建议缩到2或3。
注意:TextRank接口在
withFlag=True时返回的词对象包含词性标记,默认返回纯字符串。如果你同时需要词性和分值,就设置withFlag=True。
4.3 TextRank与TF-IDF的直观差异对比
我拿同一篇科技新闻分别跑两种算法,得到的关键词截然不同。TF-IDF更倾向给出新闻里“提到次数多的”词,比如人名、产品名;TextRank更倾向给出“和很多词都有关系的”词,也就是上下文网络中的枢纽词。
| 维度 | TF-IDF | TextRank |
|---|---|---|
| 核心思想 | 词频 × 逆文档频率 | 词图上的PageRank迭代 |
| 是否需要语料库 | 需要,依赖IDF统计 | 不需要,单篇文档即可运行 |
| 是否考虑词序 | 不考虑 | 通过共现窗口间接考虑 |
| 输出稳定性 | 受语料库质量影响大 | 单篇文本独立稳定 |
| 适用场景 | 有完整语料库、需要横向比较时 | 单篇文档、没语料库时 |
需要特别提一句:TextRank不看语料库这个特点在实际项目中非常香。很多场景是你只有一篇文章,也没法构建一个大语料库去算IDF,那TextRank就是优先选择。
但如果只是想快速得到一个“能看”的结果,且你的爬虫已经抓了几百篇新闻、有完整语料库,那TF-IDF的效果通常更贴合人工感知。
5. 关键词提取结果融合策略与效果对比
用的时候你会发现,两个算法单独跑都有偏科现象,所以这个项目里我最满意的环节,是把两者融合起来。
5.1 简单有效的加权融合评分法
我采用的方案是:分别用TF-IDF和TextRank提取出候选关键词及其权重,然后做归一化,再乘一个融合系数相加。
python复制def fuse_keywords(tfidf_keywords, textrank_keywords, alpha=0.5):
# tfidf_keywords: [(word, weight), ...]
# textrank_keywords: [(word, weight), ...]
weight_map = {}
max_tfidf = max(w for _, w in tfidf_keywords) if tfidf_keywords else 1
max_tr = max(w for _, w in textrank_keywords) if textrank_keywords else 1
for word, weight in tfidf_keywords:
score = alpha * (weight / max_tfidf)
weight_map[word] = weight_map.get(word, 0) + score
for word, weight in textrank_keywords:
score = (1 - alpha) * (weight / max_tr)
weight_map[word] = weight_map.get(word, 0) + score
return sorted(weight_map.items(), key=lambda x: x[1], reverse=True)[:10]
这个alpha就是融合系数。我跑了多篇新闻做了对照实验,取值0.5时效果比较均衡;如果语料库很丰富且质量高,TF-IDF的权重可以调高到0.6到0.7;如果只有单篇文本,那我建议把TextRank的权重调大,因为此时IDF统计本身就不可靠。
5.2 一个新闻的实证:从提取结果看算法差异
我随便拿一条科技新闻报道做了提取测试,结果如下:
TF-IDF提取结果前五名:芯片、5G、华为、发布、手机
TextRank提取结果前五名:5G、芯片、华为、技术、市场
融合结果前五名:华为、5G、芯片、发布、技术
可以看到,TF-IDF给出的“发布”是典型的新闻叙事词,TextRank没有把它排进前五,反而给出了“技术”“市场”这种偏主题层面的词。融合结果保留了单边算法的优势,整体更接近编辑视角的主题概括。
5.3 如何评估关键词提取效果
这就得把话说得实在点。关键词提取没有一个绝对的标准答案——同一篇文章,运营想突出“5G”,技术想突出“芯片”,公关想突出“华为”,三个岗位侧重点全不一样。
我在项目里用的落地评估方法是:人工抽取30篇新闻,每篇让两个标注人员分别写出5个核心词,再跟算法结果算重叠率。当算法结果与人工标注的重叠率达到50%以上,基本就够上线使用了。如果不够,优先查分词是否把关键实体切碎了(比如“华为手机”被切成“华为”和“手机”),再查停用词表是否需要扩充。
提示:分词错误是关键词提取效果差的根源。比如“新冠疫苗”被切成了“新冠”和“疫苗”,在医学新闻里可能还行,但在政治新闻里“新冠”和“疫苗”的含义就完全不同了。遇到专业领域,考虑加入自定义词典。
python复制import jieba
jieba.add_word("联邦学习")
jieba.add_word("量子计算")
6. 任务调度与工程化:让爬虫和算法自动衔接
项目做到这里,爬虫能抓数,算法能出关键词,但还有一个问题:怎么让这套东西自动化?总不能每天手动跑一遍脚本吧。
6.1 定时任务调度方案
我用的方案很轻量,Python自带的schedule库加一个永远循环的调度器。这个跑在服务器上就行,稳定可靠。
python复制import schedule
import time
def daily_job():
# 1. 抓取最新新闻列表
urls = get_news_urls()
# 2. 爬取详情页
items = crawl_news_list(urls)
# 3. 清洗文本
cleaned = [clean_item(item) for item in items]
# 4. 计算关键词并入库
for item in cleaned:
keywords = extract_keywords_tfidf(item["content"])
save_to_db(item, keywords)
schedule.every().day.at("08:00").do(daily_job)
while True:
schedule.run_pending()
time.sleep(60)
如果你不想一直挂机运行,也可以用操作系统的crontab定时触发脚本,把这个Python脚本当作一次性任务去跑。这两种方案都可以,看你的运行环境是Windows服务器还是Linux服务器。
6.2 数据存储与查询设计
关键词结果存哪?我最开始是直接追加写入CSV,后来数据量上来了就迁移到SQLite。新闻标题、正文、抓取时间、关键词JSON字符串,四个字段就够用了。查询的时候直接按时间过滤,很方便。
python复制import sqlite3
import json
def save_news_to_db(db_path, news_id, title, content, keywords):
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
cursor.execute("""
CREATE TABLE IF NOT EXISTS news_keywords (
news_id TEXT PRIMARY KEY,
title TEXT,
content TEXT,
keywords TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
cursor.execute(
"INSERT OR REPLACE INTO news_keywords (news_id, title, content, keywords) VALUES (?, ?, ?, ?)",
(news_id, title, content, json.dumps(keywords, ensure_ascii=False))
)
conn.commit()
conn.close()
用JSON存关键词列表,读取时直接json.loads转回Python对象,省得多建一张表。
6.3 爬虫和算法之间的数据流转细节
最后说一个很多人忽略的点:爬虫抓下来的文本直接做分词和关键词提取,效果很差。我在爬虫和算法之间加了一道“文本瘦身”工序:
- 做正则替换,把连续空白符压缩为一个空格
- 删除所有网址、邮箱、特殊符号
- 按段落切分,过滤太短的段落(比如少于5个字符的),这些往往是导航栏残留或乱码
- 去除所有非中文字符和英文字母之外的内容
python复制import re
def clean_text(raw_text):
text = re.sub(r"<[^>]+>", "", raw_text) # 去HTML标签
text = re.sub(r"http\S+|https\S+", "", text) # 去URL
text = re.sub(r"[^\u4e00-\u9fa5a-zA-Z0-9\s]", "", text) # 去特殊符号
text = re.sub(r"\s+", " ", text) # 压缩空白
return text.strip()
这样处理完的文本,再喂给jieba分词,关键词提取结果的准确率会明显提升。我踩过这个坑,之前正文里混了大量“©2024某某网版权所有”之类的英文和符号,导致分词器把英文单词都分出来当成关键词候选了。
再说说整个项目跑通后的感受。一个看似简单的“爬新闻+提关键词”,真正做下来才发现工程细节全藏在数据清洗和算法边界里。爬虫不是难点,难点是怎么持续稳定地拿到干净数据;TF-IDF和TextRank也不是难点,难点是搞明白它们各自适合什么场景、怎么融合才更贴近业务需求。最后配合定时任务和数据库存储,这套流程才算真正落地,而不是只停留在控制台print两行关键词的demo阶段。
如果你现在准备复现这个项目,我建议顺序是:先跑通爬虫单篇抓取,再手动清洗文本,然后分别输出TF-IDF和TextRank的Top10关键词做对比,最后再考虑融合和定时调度。每个环节都稳定了再进入下一环,排查问题时你就能定位到底是从哪里开始出错的。
