1. 项目背景与整体设计思路
1.1 为什么要做“新闻网爬虫 + TF-IDF/TextRank”组合
先说结论:爬虫本身只是“搬运工”,把网页内容抓下来;真正有价值的是让程序理解“这段新闻到底在讲什么”。我最初接手这个项目时,业务方提出一个诉求——每天需要监控几十个新闻站点的热点动态,并且要在半小时内汇总成一份带“关键词摘要”的简报。如果纯靠人工去读、去归纳,效率低到离谱,而且不同人对“重点”的判断还不一样。
于是就有了这样一条技术链路:爬虫负责从新闻站点采集标题和正文 → 清洗HTML标签和噪声文本 → 交给TF-IDF或TextRank算法抽取关键词 → 再做摘要句子排序,最终输出一份“机器自动生成的内容画像”。这套方案后来被我用在自己维护的一个小型新闻聚合工具里,实测在PC端新闻站上的效果相当稳定,单站单日几千篇文章的处理量完全没问题。
适合谁来参考?如果你是刚学完Python基础、想找真实场景练手的爬虫学习者,或者你在做NLP入门项目、需要一个“从数据采集到文本挖掘”的完整闭环,都可以照这篇文章的思路复现一遍。它不依赖大规模算力,全部代码在一台普通笔记本上就能跑完。
1.2 技术选型背后的几个关键取舍
爬虫部分我没选用Scrapy这种重型框架,而是用 requests + BeautifulSoup。原因是新闻站结构相对规整,数据量级不算大,用轻量库写起来更直观,也方便初学者理解每一步在做什么。如果站点规模变大、需要分布式采集,再迁移到Scrapy也不难,因为解析逻辑和字段设计可以无缝复用。
TF-IDF和TextRank两个算法则分别代表两种典型思路:TF-IDF是纯粹的“统计词频反转文档频率”,不考虑词与词之间的顺序关系;TextRank则是基于图的排序算法,利用了词之间的共现关系。我在项目里同时实现了两者,一方面是方便横向对比效果,另一方面是因为它们的适用场景确实不同——对短标题和长正文的敏感度不一样,后面会细说。
环境方面我用的是Python 3.9,依赖库就四个:requests、beautifulsoup4、lxml(解析器,速度比默认的html.parser快不少)、jieba(中文分词)。NLP部分没有直接用jieba.analyse里现成的TF-IDF和TextRank封装,因为我觉得自己手动算一遍更容易理解算法本质,调参时也更可控。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 爬虫部分:从零采集新闻数据
2.1 目标站点分析与请求头伪装
我做项目时习惯先拿一个新闻站的落地页做测试,比如某门户网站的“国内新闻”列表页。第一步不是写代码,而是打开浏览器开发者工具(F12),在Network面板里看真实的请求长什么样。这一步极其重要,因为你直接拿requests.get(url)去抓,很可能返回403或者一个验证页面。
正常的浏览器请求里会带User-Agent、Accept、Accept-Language、Referer这些字段。我在项目里维护了一个请求头字典,把关键的几项都配齐:
python复制import requests
from fake_useragent import UserAgent
HEADERS = {
"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.8",
"Connection": "keep-alive",
"Upgrade-Insecure-Requests": "1",
}
def get_headers():
headers = HEADERS.copy()
headers["User-Agent"] = UserAgent().random
return headers
fake_useragent这个库会随机生成不同浏览器、不同版本的UA字符串,能有效降低被简单反爬策略拦截的概率。实测下来,配合每次请求更换UA,连续抓几百个页面没有出现封IP的情况。
请求响应后,第一件事是确认编码。中文新闻站老站点常见GBK或GB2312编码,新站点基本都是UTF-8。requests库会根据响应头里的charset字段自动解码,但有时候服务器返回的Content-Type没写编码,或者写错了,这时候就会乱码。我的处理方式是先用response.apparent_encoding检测,再手动修正:
python复制resp = requests.get(url, headers=get_headers(), timeout=10)
resp.encoding = resp.apparent_encoding
apparent_encoding是requests基于内容字节统计分析出来的编码,对中文页面识别准确率很高。这一步踩坑的人特别多,很多新人抓下来是一堆乱码就以为是编码不对,其实往往是忘了设置resp.encoding。
2.2 列表页链接提取与去重策略
列表页的结构一般是:一个ul或者div容器里,若干li/a标签指向详情页URL。我用BeautifulSoup加lxml解析器提取所有符合规则的链接:
python复制from bs4 import BeautifulSoup
def parse_list_page(html, base_url):
soup = BeautifulSoup(html, "lxml")
links = []
for a in soup.select("div.list_wrapper a[href]"):
href = a.get("href").strip()
title = a.get_text().strip()
if not href or not title:
continue
if href.startswith("javascript") or href.startswith("#"):
continue
# 处理相对路径
if not href.startswith("http"):
href = base_url + href
links.append({"title": title, "url": href})
# 去重,保序
seen = set()
unique_links = []
for item in links:
if item["url"] not in seen:
seen.add(item["url"])
unique_links.append(item)
return unique_links
去重逻辑看起来简单,但实际场景中列表页经常会有“热门推荐”“相关阅读”这些模块,导致同一篇文章出现在多个位置。这里我用的是基于URL的精确去重,如果后续站点多了、URL规则更复杂,可以升级为基于标题相似度的去重(比如判断两篇文章标题去掉标点后是否相等)。
链接提取完不能一股脑全抓。新闻站的列表页往往有几十页,全部抓下来既慢又容易触发反爬。我在项目里设了一个深度参数,默认只抓前10页,每页间隔2秒,这样既保证数据量,又不至于把目标站点惹毛。
2.3 正文提取与HTML清洗
详情页的正文提取是整个爬虫里最脏最累的活。不同的新闻站前端模板不一样,有的是<div class="content">,有的是<article id="article">,还有的正文里夹杂着分享按钮、广告推荐、版权声明等噪声节点。
我在这个项目里采取了两级方案。第一级是“白名单策略”:先尝试一组常见的正文容器选择器,命中哪个用哪个。第二级是兜底策略:如果都没命中,就提取页面中文本密度最高的那个div块。文本密度简单的计算方式是——该节点下所有文本长度除以该节点的HTML标签数量。正文区域通常是大段大段的文字,标签比例低;而导航、侧栏这些东西文字碎片多、标签多,密度自然低。
实际写出来的核心代码如下:
python复制def extract_content(soup):
candidates = ["div.article-content", "div.content", "div.article",
"div#content", "div.post-content", "article"]
for selector in candidates:
node = soup.select_one(selector)
if node:
text = node.get_text(separator="\n", strip=True)
if len(text) >= 100: # 正文至少要有100字
return text
# 兜底:找文本密度最高的节点
best_node = None
best_density = 0
for div in soup.find_all("div"):
text_len = len(div.get_text(strip=True))
tag_count = len(div.find_all(True)) + 1
if tag_count == 0:
continue
density = text_len / tag_count
if density > best_density and text_len > 200:
best_density = density
best_node = div
if best_node:
return best_node.get_text(separator="\n", strip=True)
return ""
separator="\n"这个参数很有用,它会在每个标签边界处插入换行符,这样提取出来的文本天然分段,方便后面按段落做TextRank摘要时直接切句。
清洗阶段我维护了一个噪声词黑名单,包含“责任编辑”“版权声明”“点击查看”“广告”“下载客户端”这类高频垃圾文本,提取正文后循环移除。还有一步是把全角空格、不间断空白符\u00a0替换成普通空格,然后压缩连续换行——不然TextRank的句子切分会被一大片空白干扰。
2.4 数据落盘与断点续抓设计
新闻网站更新频率高,理想的情况是“每天增量抓一次”,而不是每次都全量重抓。我在项目里把已抓取的URL存在一个visited_urls.json里,每次启动时加载进来,遇到重复的直接跳过。数据落盘用的是JSON Lines格式,每行一条记录,字段设计为:
python复制{
"url": "https://news.example.com/2025/xxx.html",
"title": "某某地区发布新政策",
"publish_time": "2025-01-15 10:30:00",
"content": "正文内容...",
"crawl_time": "2025-01-15 11:00:00"
}
抓取过程中每处理完一篇就append一行到文件里。这样即使程序中途崩了,已抓的数据也都在,不用重新来。文件写入用追加模式open("news.jl", "a", encoding="utf-8")即可,不需要频繁flush,Python会在缓冲区满时自动写入,但为了保险起见,我每处理10条会主动flush()一次。
实测抓了差不多1000篇新闻,这个JSON Lines文件大概占用3到4MB,对于后续NLP处理来说非常友好。这里还有个细节——抓取时不要把所有数据都塞进内存再一次性写盘,万一某篇正文特别长,几十万字符堆内存里是没问题的,但如果是几万篇,那内存占用就很可观了。流式写入是更稳的做法。
3. TF-IDF关键词提取:原理与实现
3.1 数学原理与中文分词前置处理
TF-IDF的核心思想,一句话概括就是:一个词在一篇文章里出现次数越多,且在其他文章里出现次数越少,这个词就越能代表这篇文章的主题。
它由两个部分构成。TF(Term Frequency)是词频,衡量词在当前文档中出现的概率,最简单的算法是“该词出现次数 / 文档总词数”。IDF(Inverse Document Frequency)是逆文档频率,衡量词在语料库中的区分能力,公式是log(总文档数 / 包含该词的文档数 + 1),加1是为了防止分母为0。
一个实例:假设语料库里有1000篇新闻,其中500篇都提到了“记者”,那“记者”的IDF是log(1000/500)=0.693;如果只有5篇提到了“量子计算”,那它的IDF是log(1000/5)=5.298。显然,“量子计算”对所在文章的区分能力远强于“记者”。
中文和英文不同,英文按空格分词,中文必须做分词处理。我用的jieba库是目前最流行的开源中文分词工具,支持精确模式、全模式和搜索引擎模式。这里用精确模式就好,因为提取关键词需要的是完整的词,而不是可拆分的组合。
分词之前必须先清洗文本。我的策略是这样的:
python复制import re
import jieba
STOP_WORDS = set()
with open("stopwords_cn.txt", encoding="utf-8") as f:
for line in f:
STOP_WORDS.add(line.strip())
def clean_text(text):
text = re.sub(r"\s+", " ", text)
text = re.sub(r"[^\u4e00-\u9fa5a-zA-Z0-9\s]", "", text)
return text.strip()
def tokenize(text):
text = clean_text(text)
words = jieba.lcut(text)
return [w for w in words if w.strip() and w not in STOP_WORDS and len(w) > 1]
停用词表我用了网上开源的中文停用词列表,并在此基础上手工添加了新闻场景里的高频噪声词——“记者”“报道”“来源”“责任编辑”“讯”等等。len(w) > 1这个过滤,是为了去掉单字。单字在分词结果里大多是助词、量词等无实际意义元素,但也会误杀一些有意义的单字词(比如“房”“车”),在实际项目中应根据语料特点权衡。我这边新闻标题和正文偏书面化,单字词贡献太小,直接滤掉问题不大。
3.2 从零实现TF-IDF计算的完整代码
先建一个语料库,就是之前爬虫抓到的全部新闻正文。我写了一个TfidfVectorizer类,把训练和预测分开:
python复制import math
from collections import Counter
class TfidfVectorizer:
def __init__(self, documents):
# documents: list[str] 每篇文档的原始文本
self.documents = documents
self.doc_count = len(documents)
self.doc_word_count = [] # 每篇文档的词总数
self.doc_freq = Counter() # 包含某个词的文档数
self.build()
def build(self):
for doc in self.documents:
words = tokenize(doc)
unique_words = set(words)
self.doc_word_count.append(len(words))
for w in unique_words:
self.doc_freq[w] += 1
def tf(self, word, doc_words):
count = doc_words.count(word)
return count / max(1, len(doc_words))
def idf(self, word):
df = self.doc_freq.get(word, 0)
return math.log((self.doc_count + 1) / (df + 1)) + 1.0
def extract_keywords(self, doc, top_k=10):
words = tokenize(doc)
word_tfidf = {}
for w in set(words):
tf = self.tf(w, words)
idf = self.idf(w)
word_tfidf[w] = tf * idf
# 按得分排序,返回前top_k
sorted_items = sorted(word_tfidf.items(), key=lambda x: x[1], reverse=True)
return sorted_items[:top_k]
df + 1和doc_count + 1做平滑处理,避免某个词在语料库中的文档频率为0时报错。这里额外加了一个+ 1.0让IDF保持正值,是为了防止极高频词(比如“的”没被滤干净的情况)的IDF出现负值。虽然标准的sklearn实现里也有这个平滑,但手动写一遍之后,我对为什么要平滑的理解深刻了很多。
使用方式很简单:
python复制vectorizer = TfidfVectorizer(all_news_texts)
keywords = vectorizer.extract_keywords(single_news_text, top_k=10)
for word, score in keywords:
print(word, round(score, 4))
实测效果:我拿一篇关于“新能源汽车产业发展规划”的新闻测试,输出的前5个关键词是“新能源汽车”“智能网联”“充电桩”“产业链”“政策支持”,基本和人工概括的要点一致。
3.3 TF-IDF实际效果评测与边界问题
我对这个TF-IDF实现做了两组效果评测。
第一组用的是一篇3000字的政策解读文章,人工标注了5个主题词,然后和算法输出的前10个词对比。结果是:5个人工主题词全部出现在算法前10里,只是排序略有不同。这个表现对做标签提取来说已经合格了。
第二组用的是短文本——一个只有30字的新闻标题“央行宣布降准0.5个百分点,释放长期资金约1万亿元”。分词后得“央行”“宣布”“降准”“0.5”“个百分点”“释放”“长期”“资金”“约1”“万亿元”,TF-IDF给出的词里“央行”“降准”“万亿元”都还算靠谱,但“百分点”这种明显是搭配“0.5”才有意义的词也被单拎了出来。这是TF-IDF的一个典型问题——它基于词袋模型,完全不考虑词序。
我是这么处理的:对于短文本场景,重点参考“词频”信号而不是IDF信号,并额外对数字+单位的组合做二次拼接处理。对于长文本场景,TF-IDF表现优秀,是抽取关键词的首选算法。项目里最后的落地方案是:标题关键词用规则+TF-IDF混合,正文关键词直接纯TF-IDF。
4. TextRank算法:图排序在文本中的应用
4.1 从PageRank到TextRank的演进逻辑
TextRank的思想来自Google的PageRank。PageRank把互联网看成一个巨大的有向图,每个网页是一个节点,链接是边。一个网页的重要性,取决于有多少重要页面指向它,以及这些页面各自只有多少出链。
TextRank把同一套逻辑搬到了文本里:把文档里的每个词看作一个节点,词与词之间的“共现关系”看作边,然后迭代计算每个词的权重。权重高的词就是关键词。
“共现关系”的定义是:设定一个窗口大小(通常设为5),在文本中滑动窗口,只要两个词出现在同一个窗口里,就认为它们之间存在一个无向的共现边。
举个例子,文本“今天天气不错”分词后是“今天 / 天气 / 不错”,窗口大小为2时,“今天—天气”“天气—不错”之间有边;窗口大小为3时,还会多一条“今天—不错”的边。这样每个词都获得了来自邻居节点的“投票”,多轮迭代后,长期获得高权重邻居“投票”的节点(比如那些和很多重要词都共现的词)就会爬上排序榜前列。
在抽取文本摘要时,TextRank还有另一个变体——句子级TextRank。区别在于:节点从“词”变成“句子”,边权重基于句子之间的相似度(常用余弦相似度)计算。这就是我后面做摘要抽取的基础。
4.2 基于词共现图的关键词抽取实现
我实现的词级TextRank大致分四步:
第一步,分词去停用词。 和TF-IDF的分词流程一致,不再重复。
第二步,构建共现图。 遍历每篇文档的词序列,用一个固定大小的滑窗,在窗口内两两建立边的连接,并统计权重(共现次数)。
python复制import jieba.posseg as pseg
class TextRankKeyword:
def __init__(self, window_size=5, d=0.85, max_iter=100, tol=1e-4):
self.window_size = window_size
self.d = d # 阻尼系数
self.max_iter = max_iter
self.tol = tol
def _build_graph(self, words):
graph = {}
for i, word in enumerate(words):
window = words[i+1 : i+self.window_size+1]
for other in window:
if word == other:
continue
graph.setdefault(word, {}).setdefault(other, 0)
graph[word][other] += 1
# 无向图,反向也要加
graph.setdefault(other, {}).setdefault(word, 0)
graph[other][word] += 1
return graph
第三步,迭代计算权重。 每个节点的初始权重设为1.0,每轮迭代按下面的公式更新:
WS(V_i) = (1 - d) + d * Σ_{V_j ∈ In(V_i)} (w_ji / Σ_{V_k ∈ Out(V_j)} w_jk) * WS(V_j)
其中In(V_i)是指向节点i的节点集合(无向图下就是所有邻居),Out(V_j)是节点j指出去的所有节点。w_ji是节点i和j之间边的权重。公式的含义不复杂——节点i的新权重,等于基础值加上邻居们“投给”它的票,每个邻居投票的多少取决于这个邻居自身的权重以及它分给i的比例。
如果对数学细节不是很熟悉,直接用下面的迭代实现即可:
python复制 def _rank(self, graph):
scores = {node: 1.0 for node in graph}
for _ in range(self.max_iter):
new_scores = {}
max_diff = 0.0
for node in graph:
score = (1.0 - self.d)
neighbor_sum = 0.0
for neighbor, weight in graph[node].items():
total_out = sum(graph[neighbor].values()) # 邻居所有边权重之和
if total_out == 0:
continue
neighbor_sum += (weight / total_out) * scores[neighbor]
new_scores[node] = score + self.d * neighbor_sum
max_diff = max(max_diff, abs(new_scores[node] - scores[node]))
scores = new_scores
if max_diff < self.tol:
break
return scores
第四步,去重和排序。 TextRank迭代收敛后,同一个词的不同词性变体(动词、名词)可能在图上都是独立节点,排序前要做一次短语合并——把相邻的形容词+名词拼接成能表达完整意义的短语,比如“新能源汽车”这种。
这里的d=0.85是PageRank论文里的经验值,含义是“随机游走者从一个节点跳到另一个节点的概率”,1-d就是跳转到任意节点的概率。这个参数决定了网络结构的可信度,实际调参时在0.75到0.95之间浮动,对结果影响不算特别敏感,我测试下来0.85表现稳定。
4.3 句子级TextRank用于自动摘要
关键词抽取完成后,接下来就是“自动生成摘要”。我的思路是:把新闻正文按句号拆成句子列表,每个句子作为一个节点,计算句子之间的相似度作为连边权重,跑一遍TextRank,得分最高的几个句子组合起来就是摘要。
句子相似度的计算方式,是用前一步TF-IDF生成的词向量做余弦相似度。先把每篇新闻的所有句子按词频向量编码,再计算两两相似度,只有相似度超过阈值(我用0.1)的句子之间才建立连边,这样可以大幅减少稀疏连接导致的噪声。
python复制import numpy as np
def sentence_similarity(s1_words, s2_words, idf_map):
common = set(s1_words) & set(s2_words)
if not common:
return 0.0
vec1 = np.array([s1_words.count(w) * idf_map.get(w, 1.0) for w in common])
vec2 = np.array([s2_words.count(w) * idf_map.get(w, 1.0) for w in common])
norm1 = np.linalg.norm(vec1)
norm2 = np.linalg.norm(vec2)
if norm1 == 0 or norm2 == 0:
return 0.0
return float(np.dot(vec1, vec2) / (norm1 * norm2))
句子级TextRank迭代和词级完全一样,只是节点换成了句子索引。运行结束后,把分数最高的3到5个句子按原来的出现顺序重新排列输出。为什么要按原顺序?因为新闻的行文逻辑是时间线驱动的,乱序拼接出来的摘要读者读不通。
我拿一篇5000字的深度报道做测试,3句摘要基本覆盖了“导语+关键转折+结论”三个要素,具备直接对外发布的条件。作为对比,TF-IDF也能做摘要(选文内关键词覆盖度最高的句子),但效果明显不如TextRank,因为TF-IDF忽略了句子间的结构依赖。
5. 两大算法效果对比与融合策略
5.1 在同一批新闻数据上的横向评测
我选择了同一批50篇不同主题的新闻(时政、财经、科技、体育、娱乐各10篇),分别用TF-IDF和TextRank提取每篇的前10个关键词,然后将结果与人工标注的“标准关键词”比对,计算命中率。
结果如下:
| 算法 | 平均命中率 | 单篇最高 | 单篇最低 | 平均耗时(每篇) |
|---|---|---|---|---|
| TF-IDF | 68% | 90% | 40% | 0.08s |
| TextRank | 61% | 80% | 30% | 0.65s |
TF-IDF在大多数常规内容上胜出,原因是它对词频的直接统计非常符合新闻文体的特点——记者往往会在导语和正文里高频重复核心概念。TextRank在个别文章里反而更好,比如一篇讽刺评论文章,它很喜欢用隐喻和近义词替换来避免重复,核心词“房地产”可能只出现一次,但它的同义词“楼市”“房价”“购房”会轮番出现。TF-IDF把权重分散了,而TextRank基于共现图把这些近义词聚在一起“互相抬轿子”,反而是好事。
5.2 融合策略:混合加权与加权摘要
我的融合方案很简单:把两个算法的得分做归一化,然后按一定权重相加。在项目里我用的是TF-IDF权重0.6、TextRank权重0.4。这种简单加权在多数场景下比单一算法都稳,因为TF-IDF有自己的高频盲区(比如一篇大量重复“应该”的文章),TextRank又能通过共现修正一部分偏差。
具体实现:
python复制def hybrid_extract_keywords(doc, tfidf_model, textrank_model, top_k=10, alpha=0.6):
tfidf_scores = dict(tfidf_model.extract_keywords(doc, top_k=20))
textrank_scores = dict(textrank_model.extract_keywords(doc, top_k=20))
all_words = set(tfidf_scores) | set(textrank_scores)
merged = {}
for w in all_words:
tfidf_val = tfidf_scores.get(w, 0)
tr_val = textrank_scores.get(w, 0)
# min-max归一化
merged[w] = alpha * _normalize(tfidf_val) + (1 - alpha) * _normalize(tr_val)
return sorted(merged.items(), key=lambda x: x[1], reverse=True)[:top_k]
_normalize函数把原始得分映射到0到1区间,防止两个算法的分数量纲不一致导致加权失效。
融合后重新评测,50篇文章的平均命中率提升到了73%,虽然没有质的飞跃,但标准差明显变小——也就是“发挥失常”的文章少了。对项目而言,稳定比偶尔超水平发挥更重要,所以我最后线上版本用的就是融合策略。
5.3 什么时候应该放弃某个算法
需要留意的是,不是所有场景都适合融合。我总结了几条经验:
- 如果语料是短文本(小于100字),TextRank的共现图极其稀疏,节点间几乎没有边,迭代效果和随机排序差不多。这时候应该只用TF-IDF或干脆用规则匹配。
- 如果语料是洗稿或高度重复内容(比如营销号互相复制),TF-IDF的IDF分会严重失真——重复词在几乎所有文档里都出现,IDF接近0,算法会把所有词都输出为低分。这时候TextRank反而能靠局部共现识别网络结构的核心节点。
- 如果要做的是实时摘要(秒级响应),TextRank的0.65秒每篇有点悬,需要PyPy或Cython加速;TF-IDF的0.08秒则毫无压力。
在实际项目中,我倾向于搞一个简单的“文本长度开关”:少于300字走TF-IDF,超过500字走融合策略,中间区域自由。这算不上多智能,但足够实用。
6. 常见问题与避坑实战
6.1 爬虫运行只有“Process finished with exit code 0”没有任何输出
这是PyCharm初学者最经典的坑。现象是程序运行后控制台只显示这一行,其他什么都没有,感觉像是程序“静默退出”了。
排查顺序有四级。第一级,确认主函数被执行了——如果脚本里全篇只有函数定义、没有调用main(),那Python解释器加载完定义后自然就退出了,只需要在文件底部加if __name__ == "__main__": main()。第二级,确认请求没有抛异常被吞掉——有些用了try...except块但except里只写pass的代码,异常被静默吞掉,表现出来就是“什么也没发生”。这时把except里的pass改成print(e)或traceback.print_exc(),立刻就能看到真实错误。第三级,确认没有用sys.exit(0)硬性提前退出——有时候代码里调试时留下的sys.exit(0)忘了删,程序跑到那里直接退出。第四级,检查PyCharm的运行配置,有时“Run Configuration”里的工作目录不对,文件路径映射错了,请求的URL根本不存在。
这个问题在这类爬虫+NLP项目里高频出现,我自己的经验是:先加日志,再调逻辑。不要靠心里推理,在关键节点上打print标号,一行一行确认执行流到哪一步断了。
6.2 中文分词结果质量差、关键词全是“的”“了”“是”
这种情况九成是停用词表没生效。我遇到过的场景有三种:
- 停用词文件路径不对,程序打开文件失败但没报错(我早期代码里用了
open(f)而不判断f是否为None)。 - 停用词表编码问题,文件保存成了
GBK,而open时默认用的是系统编码,导致读出来的全是乱码,匹配不上。 - 自定义停用词没有判断单字长度,导致把“的”“了”“是”放过了。
我在项目里对停用词表做的最终设置是:
python复制with open("stopwords_cn.txt", encoding="utf-8") as f:
STOP_WORDS = {line.strip() for line in f if line.strip()}
# 手动补充新闻领域专属停用词
STOP_WORDS.update(["记者", "报道", "来源", "编辑", "责任编辑", "新闻", "讯"])
关键词结果如果还是有问题,还有一个技巧:用jieba.load_userdict()把领域专属词提前注入词库。比如我处理新能源新闻时,会把“新能源汽车”“充电桩”“动力电池”这些词加进去,让jieba不分错,这样后续的统计计算才有意义。
6.3 TextRank迭代不收敛或结果震荡怎么办
TextRank算法正常会收敛,但有时会出现“所有词得分都一样高”或者“每轮迭代分数剧烈震荡”的情况。我排查过,原因一般是图太稀疏或者边权重差异过大。
解决办法有三个:一是调大窗口大小,从5调到7到10,让图更稠密;二是给边权重加平滑,比如用weight = 1 + math.log(weight)来压缩极端高频词的影响;三是限制最大迭代次数,比如50轮后就强制停止,避免无限循环。
另外提一句,d值如果太接近1,算法收敛会很慢;太接近0,节点间的影响很快衰减。我用0.85是经验值,你可以根据语料规模微调,但没必要每次都从头试。
6.4 请求频繁被反爬拦截
这个项目的新闻站反爬不算凶,但通用经验你可以带走:
- 设置请求频率下限,每两个请求之间至少间隔2秒。
- 轮换User-Agent,不要每次都用同一个。
- 如果站点给出503或验证页面,检查是否被限制时,先等30秒再继续。
- 数据量不大的话,不要开并发,单线程最稳。
我还在请求里加了一个简单的重试机制,404、403、500这些状态码直接跳过,超时重试2次,累计失败3次就整体休眠10秒:
python复制for attempt in range(3):
try:
resp = requests.get(url, headers=get_headers(), timeout=8)
if resp.status_code == 200:
return resp
elif resp.status_code in (403, 404, 500):
return None
except requests.RequestException:
time.sleep(2 ** attempt)
return None
6.5 JSON Lines文件不断追加导致文件过大,如何做增量更新
单日数据追加一个文件没问题,但运行一个月后就变成几十MB,每次程序启动还要全文加载做URL去重,效率越来越差。
我采用的方案是“按天滚动存储”:文件名带日期,如news_20250115.jl,程序每次启动只加载最近三天的文件做去重,不加载历史全部文件。这样既能保证增量更新的正确性,又不会让内存无限膨胀。历史数据要分析时再单独按日期范围扫描,用pandas读取也很顺手。
7. 实操过程中的深度体验与最后心得
整个项目从爬虫数据采集到TF-IDF/TextRank关键词抽取和摘要生成,完整跑通大概花了三天——第一天解决爬虫与数据清洗,第二天把两个算法手写实现并调通,第三天做对比评测和融合调优。
有几个实操体会想特别说一下。
第一个体会是:爬虫代码的健壮性比效率重要得多。新闻网站前端结构一年能改好几次,解析模块如果写得太死,过几天就全崩。我后来给所有解析函数都加了异常兜底——解析失败时返回空数据并记录日志,而不是中断整个抓取流程。这个习惯救了我好几次,凌晨定时任务跑挂了你不会马上知道,但只要日志在,排查只要五分钟。
第二个体会是:TF-IDF和TextRank这两个算法虽然原理都很老,但用好了依然能打。不要因为是“经典算法”就轻视它们,在轻量级、无GPU的场景下,它们的性价比极高。我做这个项目的环境是一台4核8G的低配云服务器,跑50篇新闻的全部NLP流程不到1秒,这种轻量方案在大模型时代依然有不可替代的价值。
第三个体会是关于工程实现的:手写一遍算法再换封装库,收获完全不一样。如果你只用jieba.analyse.extract_tags或sklearn的TfidfVectorizer,你只学会了“调接口”;自己实现了math.log和迭代公式之后,你才真正理解什么情况下该调整参数、为什么IDF要平滑、为什么无向图和PageRank的有向图有差别。这些洞察,在以后做更复杂的NLP项目时会反复用到。
最后再分享一个优化技巧:当你的语料库超过几千篇后,建议提前把每篇的分词结果缓存成二进制文件(比如用pickle或parquet),后续做关键词分析、摘要、聚类时就完全不用重新分词了。分词是这类项目里耗时最大的单步操作,这一步优化下去,整个处理链路能快一个数量级。我现在的处理流程是先一次性对全量语料分完词,后面所有算法的输入直接读缓存,省下来的时间用来多调几组参数,比什么都值得。
