1. 新闻流量入口的页面结构认知:目标网站选择与信息架构分析
先做个基本共识。做新闻类网站的爬虫项目,绝对不是把HTML抓下来、用正则抠几个标签就完事,这套老思路在今天已经很难落地。新闻网站是所有垂直行业站里页面结构最不规整、更新频率最高、同类页面模板变体最多的一类目标。
我在做这个项目的时候,最初被问的第一个问题是:为什么要拿新闻网站来练手?原因其实很直接——新闻页的正文结构具有极强的重复性,但同时又有大量的“页面级别噪声”。比如推荐阅读、热门排行、评论热帖、相关链接、广告植入、图片附件、作者简介、版权声明,这些块在每个新闻页里都存在但位置不固定。这种“结构相似但不完全一致”的特征,正好是锻炼正文提取和文本去噪的好场景。
而 TF-IDF 和 TextRank 这两个算法选型在这类场景里非常合适。TF-IDF 做的是“基于统计的主题词抓取”,不依赖外部知识库;TextRank 是“基于图模型的句子重要性排序”,同样不需要语料库训练。两个算法都属于无监督文本挖掘手段,不需要标注数据,跑起来快,效果可解释,适合作为新闻关键词抽取和摘要生成的第一版方案。
先明确信息架构。新闻站点普遍具备四级结构:首页、栏目页、列表页、详情页。栏目页负责展示某类新闻的标题列表,列表项往往只有标题、时间和摘要,详情页才是完整正文所在。这个结构意味着爬虫至少需要两跳:第一跳从栏目页拿详情页URL,第二跳从详情页提取正文。
需要注意的一个细节是:很多新闻详情页会把完整正文放在<div id="content">或者<article>标签内,但栏目页列表的分页逻辑并不统一。有的站用纯静态链接,有的站是JS动态加载,有的站是无限滚动。这些都会直接影响采集方案选型,我不建议一上来就上Selenium,静态页能解决的绝不用浏览器渲染,成本完全不在一个量级。
以最常见的静态栏目页为例,URL模式通常形如/list/1.html、/list/2.html。这个分页模式可以先拿到,再逐页解析列表里的<a>标签。项目和标题聚焦的链路就是“列表页采集 -> 正文页抓取 -> 文本清洗 -> 关键词抽取 -> 摘要生成”这五单,后面所有的内容都围绕这个主链路展开。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 请求策略里的细节:从UA伪装到延迟控制,爬虫与风控的软对抗
2.1 requests 还是 scrapy:轻量采集下的选型判断
调型阶段我用的requests+BeautifulSoup的组合,没有直接上scrapy。很多新手容易被“scrapy性能高、分布式能力强”这个标签带着走,但在一个以算法展示为核心目标的练手项目里,scrapy的调度器、中间件、Twisted异步模型反而会分散注意力。requests的同步请求模型足够直观,出了错也好定位。
还有一点,scrapy的XPath选择器确实好用,但BeautifulSoup的find()系列对新手更友好,排查标签层级关系时可以快速打印soup.prettify()。等后续需要扩大采集规模,再从requests切换到scrapy,核心的业务解析代码可以直接复用。
2.2 请求头的完整伪装:不是只有UA那么简单
这一块是整个采集环节里最容易出问题的地方。先说一个常见的反例:只设置User-Agent为Chrome字符串就去请求,结果被服务器返回403。
问题不在UA本身,而在请求特征组合。正常的浏览器请求会同时携带Accept、Accept-Language、Accept-Encoding、Referer、Connection等头部,服务端风控引擎会综合判断请求指纹。UA是一个维度,Referer和Cookie是另外两个高频检查项。
我实践中比较稳妥的一套头部配置是这样:
python复制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/avif,"
"image/webp,*/*;q=0.8",
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
"Accept-Encoding": "gzip, deflate, br",
"Referer": "https://news.example.com/",
"Connection": "keep-alive",
"Upgrade-Insecure-Requests": "1",
}
关键点:
Accept-Encoding不要自己去搞,让requests自动处理。如果手动写了gzip, deflate,收到响应后必须手动response.content解压,否则乱码问题排查起来很耽误时间。我在初版代码里踩过这个坑,后来干脆把这行去掉,交给requests的decompress逻辑。
UA池不是必需的,但必须要准备两三个备用的。不同新闻站的UA校验策略差异很大,有的只认Chrome最近两个大版本,有的对PhantomJS特征做了黑名单,有的对无头浏览器指纹有检测。一个UA被拉黑时,先换UA重试一次再上延迟策略,这是一条成本最低的试探路径。
2.3 频率控制:公平采集的底线,也是技术参数的必修课
请求间隔这个参数多少合适?我见过很多网上的公开代码直接写time.sleep(1),这个值说实话偏激进。大型新闻网站的单机并发承载能力很强,但风控引擎更关注的是“单一IP的单位时间请求数”和“请求路径的规律性”。
我自己的参数经验是这样:
| 场景 | 请求间隔 | 说明 |
|---|---|---|
| 栏目列表页 | 2~3秒 | 列表页请求量小,但URL规律性强,间隔太短容易被识别为脚本 |
| 详情页 | 3~5秒 | 正文页频率最高,建议随机抖动,不要固定间隔 |
| 失败重试 | 等指数退避 | 首次失败等30秒,第二次等60秒,最多重试3次 |
这里有一个容易被忽略的技术细节:请求间隔的随机抖动很关键。如果你每个请求都是精确的3秒,风控模型反而更容易识别出机器行为,因为人的操作节奏一定有波动。我当时是用random.uniform(2.5, 4.5)来模拟这种波动。
还要处理重定向和响应状态码。allow_redirects=True是默认行为,但有些新闻详情页会做302跳转到登录页或验证页,这需要检查最终URL和响应内容长度。我习惯在每次下载后判断response.status_code == 200且len(response.text) > 500,否则记录异常URL,统一丢进待重试队列。
2.4 采集完成后的数据存储格式选择
新闻采集的原始数据建议保留HTML和解析后的结构化字段两份。解析后的字段用JSON存储比较方便后续算法读取,HTML原文保留在本地则方便回溯编码问题、标签结构变化。
json复制{
"url": "http://news.example.com/politics/2024/1122.html",
"title": "文章标题",
"publish_time": "2024-11-22 10:30:00",
"author": "作者名",
"content_text": "正文纯文本内容",
"raw_html": "归档用,可省略",
"crawl_time": "2024-11-22 11:00:00"
}
每个字段都有存在的价值:publish_time在做时间衰减加权时有用,author可以做聚类分析,content_text就是后面的算法输入。这个文件结构从项目第一天就要定好,不然后面算法模块读数据时会反复改接口。
3. 正文提取与去噪:半结构化新闻页里捞出真正的报道文本
3.1 为什么不太依赖通用抽取库
正文抽取这一步容易翻车。
一开始我考虑过newspaper3k和goose3这类通用文章提取库,它们的设计思路是基于“正文文本密度”和“链接密度”做打分。在英文网页上表现不错,但中文新闻页面的槽点在于:标签结构往往嵌套混乱、段落文字和广告位混排、页脚有大量版权信息文本,通用库的分辨率往往不够。
我最后选择的是“结构化规则 + 候选块密度打分”两条腿走路。先用规则定位候选节点,再用密度评分筛选正文块。具体做法是:在BeautifulSoup里先删除script、style、noscript、iframe这些无关节点,然后遍历所有<p>标签,统计每个父容器的文本长度和链接文本占比。
3.2 正文块判定公式与实验参数
判定标准其实可以讲得很直白:如果一个容器内<p>标签文本长度之和超过200个字符,且链接文本长度占比低于30%,很大概率就是正文容器。
链接文本占比这个指标非常重要。新闻页的推荐阅读、相关新闻块的链接密度非常高,但正文区的链接密度天然偏低,这是一个显著特征。
python复制def score_block(container):
ps = container.find_all("p")
if not ps:
return 0
total_text_len = sum(len(p.get_text(strip=True)) for p in ps)
link_text_len = sum(len(a.get_text(strip=True)) for p in ps for a in p.find_all("a"))
if total_text_len < 200:
return 0
link_ratio = link_text_len / total_text_len
if link_ratio > 0.3:
return 0
return total_text_len + (1 - link_ratio) * 100
从实验结果看,这个规则在大多数新闻详情页上能拿到超过95%的正确率。但也有例外,比如部分网站的正文段落不是用<p>,而是用<div>包含纯文本,或者用<br>换行,这种情况下规则需要扩展。我在项目里加了一个兜底逻辑:如果没有候选块得分超过阈值,就取所有<div>里文本最长的那个作为正文容器,然后再做一次清洗。
3.3 清洗规则:不是只有去标签那么简单
去掉HTML标签只能算第一步,真实新闻文本里还有大量隐藏的噪声:
- 文中嵌入“责任编辑:xxx”这样的尾注
- 段落中间插入“点击查看大图”这类提示语
- 带“记者 xxx 电”的导语与正文首段重复
- 全文结尾的“版权声明”和法律声明文字
我的做法是对content_text做一次线级过滤。把完整文本按换行符切割成行,逐行判断:如果该行包含版权、责任编辑、免责声明、点击查看等关键词,直接丢弃。之后再合并剩余行,用正则清理多余空白字符。
两个正则很值得收藏:
python复制import re
# 清理html实体
CLEAN_HTML_ENTITY_RE = re.compile(r'&[a-zA-Z]+;|&#\d+;')
# 清理重复空白字符
CLEAN_SPACES_RE = re.compile(r'\s+')
def clean_text(text: str) -> str:
text = CLEAN_HTML_ENTITY_RE.sub("", text)
text = CLEAN_SPACES_RE.sub(" ", text)
return text.strip()
清洗不干净对后面的TF-IDF和TextRank影响很大。关键词抽取时,噪声文本如果反复出现“编辑”“点击”这类词,会拉高它们的统计权重,最终top关键词全是无效词。
3.4 编码问题的兜底处理
新闻网站的编码坑主要出在GB2312和GBK混用场景。requests的response.text虽然会根据Content-Type猜测编码,但猜错的情况在中文站里太常见了。
正确姿势是先用response.apparent_encoding拿结果,再用response.content.decode(real_encoding, errors="ignore")手动解码:
python复制resp = requests.get(url, headers=headers, timeout=10)
if resp.apparent_encoding:
html_text = resp.content.decode(resp.apparent_encoding, errors="ignore")
else:
html_text = resp.text
apparent_encoding底层用的是charset-normalizer或chardet,对中文编码的识别可靠度很高。如果识别出来还是乱码,多半是页面里用JS动态写入编码声明,这种情况就要直接看<meta>标签里的charset属性来兜底。
4. TF-IDF关键词提取:词的稀缺度如何反映新闻主题
4.1 先从词频到逆文档频率的思维转换
TF-IDF是信息检索领域的经典权重算法,它回答的核心问题是:在一堆文档里,哪些词最能代表当前这篇文档的主题?
它由两部分组成。TF,Term Frequency,词频,反映的是一个词在当前文档里出现的次数。但单独看词频没有意义,因为“的”“了”“在”这类停用词几乎出现在每篇文章里,频率必然很高。IDF,Inverse Document Frequency,逆文档频率,解决的就是这个问题:如果一个词在很多文档中都出现,那它的区分度就低,IDF值就小;如果一个词只出现在少数特定文档里,那它的区分度就高,IDF值就大。
IDF的计算公式是:
[
IDF(w) = \ln\frac{N}{1 + df(w)} + 1
]
其中(N)是总文档数,(df(w))是包含词(w)的文档数。加1是因为要避免分母为0(某个词在所有文档里都没出现的情况),同时起到平滑作用。
TF-IDF就是把两者乘起来:
[
TF\text{-}IDF(w) = TF(w) \times IDF(w)
]
要说明的是,如果直接用sklearn的TfidfVectorizer,它内部对TF也做了L2归一化,这是为了向量计算方便,但实际效果上对关键词排序影响不大。我在项目里用的是sklearn自带的实现,没必要自己去写TF-IDF。
4.2 中文分词与停用词表:决定关键词质量的真正分水岭
中文文本和英文最大的区别在于没有天然词边界。英文用空格分词,中文必须依赖分词工具。我在项目里用的是jieba,它的cut_for_search模式比精确模式更适合做关键词抽取,因为搜索引擎模式下会把长词再切分成更小的粒度,比如“人工智能”在cut_for_search下会额外产出“人工”和“智能”。
这一步带来的实际影响是:如果只用精确模式,“深度学习”会作为一个完整词被保留,但“深度”单拎出来可能在新闻里也是一个高频有意义的词。到底是保长词还是保短词,完全取决于文本场景,没有绝对标准。我的经验是做两路并采:jieba.analyse.extract_tags默认带TF-IDF实现,但它内部用的是自己的一套IDF语料库,并不是根据你当前采集的新闻集动态计算的。这会导致某些领域专有名词权重偏高或偏低。
更可控的方案是自己维护文档集,动态计算IDF。做法也很简单:
python复制from sklearn.feature_extraction.text import TfidfVectorizer
import jieba
def jieba_tokenize(text: str):
return [w for w in jieba.cut_for_search(text)
if w.strip() and w not in STOP_WORDS and len(w) > 1]
vectorizer = TfidfVectorizer(tokenizer=jieba_tokenize, max_features=500)
tfidf_matrix = vectorizer.fit_transform(doc_list)
len(w) > 1这个过滤很重要,单个汉字在新闻正文里绝大多数是噪声,过滤掉可以显著降低特征维度。max_features=500限制特征数量,避免生成几千维稀疏向量拖慢后续处理。
4.3 停用词表的选择与扩充
停用词表是另一个容易翻车的地方。网上公开的“中文停用词表”多数能用,但对新闻场景明显不够。
举例来说,“记者”“报道”“电”“讯”这些词在普通停用词表里未必收录,但在新闻语料里它们出现在大量文档中,IDF值很低,本来就不太可能进top关键词。真正要手动添加的是那些在你的新闻集里高频且无语义贡献的词,比如“更多”“内容”“点击”“查看”“相关”“阅读”。
我给这个项目准备了一份基础停用词表,包含三类:
- 通用虚词:的、了、在、是、我、有、和、就、不、人、都
- 新闻高频非语义词:记者、报道、讯、电、编辑、责编、热线
- 站点通用功能词:点击、访问、下载、分享、评论、登录、注册、阅读原文
提示:停用词表不是一成不变的。你在项目运行一段时间后,把每篇文档的top20关键词拉出来,凡是发现明显无意义的词,就手动加进停用词表。这个迭代过程会持续一两周,之后关键词质量才逐步稳定下来。
4.4 可视化观察关键词权重的分布
做算法和做产品有个共同点,就是中间结果一定要可视化。我把每篇文章的top10关键词和对应TF-IDF值输出成一个柱状图,一眼就能看出哪些文章的关键词抽取效果好,哪些出了问题。
常用的输出手段有两种。一是用matplotlib绘制横向柱状图,按TF-IDF值从高到低排列;二是直接用pandas DataFrame展示关键词和权重的前20行。两张图配合观察效率最高。
如果发现某篇文章的top关键词全部是通用词,优先检查两件事:停用词表是否需要补充,以及正文清洗是否漏掉了噪声块。这两个问题修复后,大部分关键词质量都会明显好转。
5. TextRank摘要生成:让句子在无监督图模型里互相投票
5.1 从PageRank到TextRank的核心思想
TextRank的想法脱胎于PageRank论文。PageRank把互联网页面看作图的节点,页面之间的链接关系看作边,通过迭代计算得到每个页面的重要性分数。TextRank的迁移很巧妙:把文档里的每个句子当作一个节点,句子之间的相似度当作边的权重,然后迭代计算句子权重,权重最高的几个句子就是摘要。
PageRank的经典公式是:
[
PR(V_i) = (1 - d) + d \times \sum_{V_j \in In(V_i)} \frac{PR(V_j)}{Out(V_j)}
]
其中(d)是阻尼系数,通常取0.85。迭代收敛后,(PR)值就是节点的“重要性”。
TextRank把公式里的PR(V)换成句子权重WS(V),边权换成句子间的相似度。相似度的计算方式有很多种,最常用的是基于词向量的余弦相似度,但在没引入外部词向量的情况下,可以用基于TF-IDF向量后的余弦相似度,或者用更简单的词共现比例。我用的是TF-IDF向量后的余弦相似度,好处是可以复用上一模块的特征向量,整个pipeline不需要额外引入模型。
5.2 句子相似度的平滑处理与超参数选择
单词共现比例的相似度计算有个坑:在短句子之间几乎都会得到0分,导致图变得非常稀疏,TextRank效果很差。解决方法是加平滑项。
我实际用的是这种相似度计算公式:
[
Sim(S_i, S_j) = \frac{|{w \in S_i \cap S_j}|}{\log(|S_i|) + \log(|S_j|)}
]
用log对分母做平滑,避免长句权重被天然压低。实测下来,分词后的短句相似度分布会比原始比例法均匀不少。
TextRank的主要超参数有这几个:
| 参数 | 取值 | 影响 |
|---|---|---|
| 阻尼系数 d | 0.85 | PageRank的默认值,1-d是随机跳转概率,一般不需要动 |
| 窗口大小 window | 5 | 词共现式TextRank里,窗口越小越看重局部关系,越大越看重全局关系 |
| 迭代次数 max_iter | 200 | 通常50次内就收敛,200次是为了保险 |
| 收敛阈值 tol | 0.0001 | 权重变化小于阈值即停止迭代 |
| topK | 3~5 | 新闻摘要建议取3句,过长摘要反而没有观点密度 |
用图模型做摘要有一个很有价值的副产品:TextRank迭代收敛后,每个句子的权重本身就包含语义重要性信息,你可以按权重从高到低给句子排序,直接作为关键句列表输出。这个列表能用于新闻速览、简报生成,甚至可以对接下游的简报推荐系统。
5.3 jieba.analyse.textrank的便捷实现与局限
其实jieba.analyse模块里也内置了TextRank实现,调用方式比手写图模型简单很多:
python复制import jieba.analyse
top_k = 10
keywords = jieba.analyse.textrank(
content,
topK=top_k,
withWeight=True,
allowPOS=("ns", "n", "vn", "v", "nr")
)
这里的allowPOS参数很有讲究,它通过词性过滤只保留名词、动词、地名、人名等有实际含义的词性。在实际新闻摘要场景里,这个过滤对结果质量的提升非常明显。
但注意,jieba.analyse.textrank的默认输入是“词序列字符串”,它内部会先做一次关键词抽取再建图,和“先分词、再建图”的手写流程在细节上有差异。我的项目最终选择手写基于TF-IDF向量的句子级TextRank,是因为想要把“关键词抽取”和“摘要生成”两个模块解耦,分别调参。如果你追求快速出效果,直接调用jieba内置实现完全够用。
5.4 摘要抽取策略:正文长度决定取句数
摘要抽取还需要考虑正文长度长的文章。对一篇8000字的深度报道,top3句子可能无法覆盖全文多个子主题;而一篇300字的短新闻,top3句子又可能把导语和结尾都抽进去,反而重复。
我做了一个简单的分段策略。正文分词后按500字为一段切分,每段内部独立跑一遍TextRank,各自取权重最高的1句,最后按原文顺序拼接。这个策略在长短文上的表现都很稳定,算是无监督摘要里低成本且收益明显的优化。
6. 完整流程串联与项目效果验证:从采集到可视化的最小闭环
6.1 主流程代码架构:三个模块、一个调度器
整个项目的模块划分非常清晰,我在实际编码时严格遵循了“采集-清洗-算法”三层分离,方便后续单点替换。主流程调度器大约是这样:
python复制def main():
start_urls = load_channel_urls("config/channels.json")
crawled_items = []
for url in start_urls:
article_urls = fetch_list_page(url)
for article_url in article_urls:
html_text = fetch_detail_page(article_url)
item = parse_detail_page(article_url, html_text)
if item:
crawled_items.append(item)
time.sleep(random.uniform(2.5, 4.5))
json.dump(crawled_items, open("data/news.json", "w", encoding="utf-8"),
ensure_ascii=False, indent=2)
for item in crawled_items:
item["keywords"] = extract_tfidf_keywords(item["content_text"])
item["summary"] = extract_textrank_summary(item["content_text"])
item["summary"] = restore_summary_order(item["summary"], item["content_text"])
json.dump(crawled_items, open("data/news_with_algorithm.json", "w", encoding="utf-8"),
ensure_ascii=False, indent=2)
这里的fetch_list_page负责完成列表页请求与详情页URL提取,parse_detail_page返回清洗后的结构化数据,extract_tfidf_keywords和extract_textrank_summary是两个算法模块的入口函数。整个流程跑一遍的时间取决于文章总量,我测试时采集了200篇,总共耗时约20分钟,其中大头在请求间隔上。
6.2 算法结果测评:主观评价与客观指标双验证
无监督文本挖掘的质量评估是个经典难题,因为没有一个绝对正确的标准答案。我的做法是双轨验证。
主观评价方面,每个算法结果让团队两个人独立打分,维度包括:关键词是否贴合主题、摘要是否可独立成文、摘要是否包含关键数据/人名/机构名、摘要之间是否重复冗余。
客观指标方面,我用了最简单有效的ROUGE评测思路。先用TextRank生成摘要句,然后与人工拟写的导语做字面重合度计算。虽然导语和正文摘要不一定完全一致,但重合度高通常说明模型确实抓到了文章最核心的信息。
两轮测试下来,TF-IDF在“识别主题词”方面比TextRank关键词效果好,因为TF-IDF天然偏向判别性强的词汇;而TextRank在“概括句”方面更接近人的表达习惯。两者互补性很强,这也是我把它们在同一篇项目里组合使用的原因。
6.3 典型失败case复盘:为什么某个文本段全军覆没
在所有测试文本中,最典型的失败case是那种内容极其简短的“一句话新闻”。一篇文章总共两句话,TextRank建出来的图只有两个节点,迭代几次后两个节点的权重会趋同,导致无法区分重要性。
这种场景下,抽取式摘要本身就失去了意义,更好的方案是直接输出第一句话作为摘要,或者引入摘要式生成模型。但这种case在真实新闻网站里占比很低,不影响整体管线价值。
另一个高频失败case是正文包含大段数据表格。表格内容被清洗成纯文本后,会变成一堆连续数字和单位,这类内容在分词后几乎全被过滤掉,相当于浪费了一大段正文。我当时加了一个额外逻辑:如果检测到“%”和数字比例超过文本长度的20%,就把这行从文本中去掉,优先级高于正文清洗。
6.4 参数文件化:让整个pipeline从写死走向配置化
项目第一次完整跑通后,我把所有超参数都挪到了config/params.yaml里,包括请求间隔范围、TF-IDF的max_features、TextRank的阻尼系数、窗口大小、topK等等。这样做的好处立刻体现出来:调参时不用改代码,改完配置文件重跑就行。
yaml复制crawler:
request_interval: [2.5, 4.5]
retry_times: 3
timeout: 10
tfidf:
max_features: 500
min_ngram_range: 1
max_ngram_range: 2
textrank:
damping: 0.85
top_k: 3
max_iter: 200
tol: 0.0001
segment_len: 500
配置化是我在这类练手项目中养成的习惯了。开始觉得多写几个参数没啥,但跑实验的次数一多就明白过来,写死参数等于拒绝迭代。
7. 爬虫逆向与风控对抗的常见坑:从静态页面到动态接口的升级路径
7.1 栏目页是静态HTML,但正文页数据在接口里怎么办
很多新闻网站的列表页可以正常requests请求,但点进详情页会发现正文内容是通过Ajax异步加载的,直接抓详情页URL拿不到正文文本。这个场景在如今的新闻站里非常普遍,特别是那些对接了前端渲染框架(Vue、React)的站。
排查方法也简单:用浏览器开发者工具打开Network面板,刷新页面,找到返回正文JSON数据的XHR请求。通常请求路径里包含content、article、detail这样的关键字,返回结构一般是JSON字段里带正文HTML。
拿到接口地址后,请求头还要补上X-Requested-With: XMLHttpRequest或者特定的自定义Header,有些站还要带签名参数。签名参数的计算逻辑可能需要逆向前端JS。这时才需要引入Js逆向,而前面基于requests的纯静态方案已经不适用了。
但不要上Selenium,这个经验我反复强调。动态接口的解析速度比浏览器渲染至少快一个数量级。我遇到过不少开发者看到动态加载就无脑切Selenium,结果被性能拖垮、被风控轻松识别。正确的处理路线是:先抓接口 -> 分析参数生成位置 -> 用Python原生模拟请求。只有在接口参数加密复杂且无规律时,才考虑用浏览器渲染做兜底。
7.2 定制化Headers与签名参数的构造
新闻站的接口签名没有电商、社交平台那么复杂,多数是时间戳加固定盐值做MD5,或者把一些参数拼接后再去做SHA1。
我的做法是先用Charles或Chrome DevTools把请求参数全量导出,逐个字段排查。时间戳(timestamp)字段通常一眼就能认出,但参数里像sign、token、nonce这种才是真正需要逆向的。
为了减少逆向工作量,可以优先确认站点是否同时提供PC端和移动端API。移动端接口的签名强度往往低于PC端,而且很多站移动端和PC端共用一个数据源,只是返回结构有差异。这个技巧在很多新闻采集项目里都很实用。
7.3 页面改版对规则的影响:如何用增量校验发现变化
新闻站页面重构的频率比想象中高。我做一个采集任务时遇到过一次栏目页结构变化,导致解析器直接提取不到详情页URL。
应对方案是在调度器里加一个轻量校验步骤:每次采集前解析列表页时,对比本批次与上一批次的详情页URL数量、URL域名分布、标题长度均值三个指标。如果某个指标的偏差超过30%,就要警惕页面结构变化或者被风控拦截了。
这个增量校验逻辑不要写得太重,否则会影响采集效率。我一般只对第一批和最后一批做快照对比,中间的批量采集不做校验。
7.4 采集反爬的伦理与边界问题
顺着这个话题多说一句,爬虫项目做到后面一定会遇到反爬对抗升级的问题。这时要清楚一个基本事实:网站方有权利对自己的数据接口做访问控制,爬虫工程化应用时需要遵守网站的robots.txt和用户协议。
这个项目的定位是“学习与验证算法”,采集到的新闻数据仅用于本地的关键词和摘要效果演示,不会对外发布或商用,这样才能安心地用requests做采集。如果目标站的风控特别强、持续拦截请求,我建议直接放弃该目标,而不是花大量精力去写绕过逻辑。
把时间放在算法侧,收益是更长远的。毕竟爬虫只是数据前置环节,TF-IDF和TextRank的工程化落地才是这个项目的核心目标。
8. 关键词与摘要的联合调优路径:单跑文本管线的可视化实验
8.1 一个可复现的调参实验脚本
做无监督文本处理项目,最忌讳的就是凭感觉调参数。我在这类项目里习惯把参数检索直接脚本化,输出对比表格。
针对TextRank,我控制变量地跑不同窗口大小、阻尼系数和topK组合,统一输出摘要句和关键词表。判断参数好坏时主要看两个维度:摘要句是否能覆盖文章首段的导语信息,以及摘要句之间是否语义重合过高。
python复制import itertools
for damping, top_k in itertools.product([0.80, 0.85, 0.90], [2, 3, 5]):
result = run_textrank_pipeline(text, damping=damping, top_k=top_k)
print(f"damping={damping}, top_k={top_k}, summary={result}")
这样的实验跑一轮,通常只需要几分钟,但能获得远比“凭感觉调参”可靠的经验数据。
8.2 TF-IDF的n-gram参数:单字与短语的取舍
TfidfVectorizer的ngram_range默认是(1, 1),也就是只统计单字或单词。对中文新闻来说,(1, 2)通常效果好于(1, 1),因为不少新闻主题词汇是双字词甚至三字词组合。但如果把max_ngram_range设得太大,特征维度会急剧膨胀,关键词里会混入大量无意义的偶合词对。
推荐配置是min_ngram_range=1, max_ngram_range=2,然后配合max_features=500做截断。
这个环节还有一个容易被忽略的点:分词结果的粒度会直接决定n-gram的语义质量。如果用jieba.cut_for_search,输出已经包含较多细粒度词,再用bigram时很容易出现重复表达;如果用jieba.cut的精确模式,再加bigram则能很好补充短语信息。我在项目里最终使用的是精确模式加bigram的组合。
8.3 使用词性过滤来修正关键词语义漂移
不管是jieba内置还是手写TF-IDF,一个通病是分词和权重计算都不会管词的语义价值。很多新闻主题实体是专有名词,但它们在语料里频率并不一定很高,区分度虽高但原始权重可能被一些高频名词盖过。
所以我在关键词提取后加入了一个词性过滤步骤:只保留名词(n)、地名(ns)、机构名(nt)、人名(nr)和动词(v)这五类词性,其余全部丢弃。注意这些代码里是指jieba.posseg的输出标记。
posseg的引入会让整个pipeline变慢一些,每一篇新闻的分词时间大约增加30毫秒,但对关键词质量的提升非常明显。尤其是过滤掉形容词、副词和成语之后,top关键词几乎不会出现“非常”“进一步”“重点”这类修饰性词汇。
8.4 摘要去重:防止TextRank抽出的三句话语义重复
TextRank排名靠前的句子不一定在表达上互补,很可能三句话都在反复强调同一个事实。这在新闻导语写得特别“满”的文稿里非常常见——开头一段就把所有核心要素都交代完了,后面的句子自然就会与导语高度相似。
我的处理方式是用余弦相似度做贪心去重。把候选句子按TextRank权重从高到低排序,逐句判断与已选中句子的最大相似度,超过0.75就跳过,否则选入。相似度计算继续复用TF-IDF向量。这个后处理把摘要的信息覆盖率提升了不少。
9. 方案效果评价与局限:这套组合拳在真实场景里的上限
9.1 在30篇新闻样本上的数据指标
项目阶段性收尾时,我用30篇不同分类(时政、财经、体育、科技、娱乐)的新闻做了效果抽样测试。关键词抽取方面,TF-IDF跑出的top5关键词与编辑给出的文章标签对比,平均重合约2.7个;TextRank生成的3句摘要,人工判定的可读性评分为7.5分(10分制)。
放在无监督文本挖掘的普遍水准里,这个表现算是够用了。特别是关键词部分的准确率,已经接近一些需要轻度标注的弱监督方案。
9.2 中文新闻与英文新闻在算法表现上的核心差异
同样一套TF-IDF和TextRank,处理中文和英文时的差异主要来自两处。第一是分词,英文天然自带空格分隔,中文需要额外引入分词器,分词的错误会直接传导到后续所有环节;第二是停用词处理,英文停用词表已经很成熟,中文停用词表则高度依赖领域适配。
9.3 无监督方案的固有缺陷:缺少语义理解
这两个算法本质都是频率统计或图结构分析,它们不理解词的上下文语义。最典型的例子是“苹果公司发布新款iPhone”和“苹果价格上涨”这两句话,TF-IDF和TextRank都会把“苹果”识别为重要词,但无法区分“苹果”具体指代公司还是水果。
要突破这个瓶颈,就只能引入预训练语言模型(如BERT系列)做语义向量化。但这样做的成本是算力和模型依赖,在轻量级新闻摘要任务里未必划算。我的建议是:先把无监督方案的效果吃透,再按需升级语义方案,不要一开始就用大模型。
10. 这个项目的下一步扩展方向:从单机采集到工程化落地的空间
10.1 用Scrapy + Bloom Filter替代requests单机模式
当前单机requests的采集速度上限受限于请求间隔,大约在每分钟20~30个页面。扩大到上千篇文章时,单线程的耗时就很明显了。
升级方案是把解析流程迁移到Scrapy框架,把已经访问过的URL存入Bloom Filter做去重。Bloom Filter的数学原理是用多个哈希函数映射到一个位数组,用很小的空间代价换极高的去重速度。在实际爬虫中,它比直接存set更省内存,能支持亿级URL的去重判断。
python复制from pybloom_live import BloomFilter
visited = BloomFilter(capacity=1000000, error_rate=0.001)
def seen_url(url):
if url in visited:
return True
visited.add(url)
return False
同时,Scrapy的AutoThrottle扩展会根据响应延迟自动调节请求速度,配合DOWNLOAD_DELAY参数可以很好地替代手动time.sleep。
10.2 引入词向量对关键词做语义聚类
如果想让关键词抽取从“词频统计”升级到“话题聚类”,可以在TF-IDF之后接一层词向量。把每篇文章的top20关键词用Word2Vec或Sentence-BERT编码成向量,再做K-Means聚类,每类取中心最近的词作为该话题的要点词。
这不会改变关键词挖掘的底层逻辑,但会让输出从“散词列表”变成“话题分组”,对新闻信息流的自动归类编辑场景非常有用。
10.3 从关键词、摘要到简报生成的完整服务化
如果这个项目要达到“开箱即用”的程度,最后一公里是把算法包成一个HTTP服务。用FastAPI写一个接口,输入新闻URL,输出关键词、摘要句和发布时间,返回给前端展示。
这个服务化改造本身不难,但需要考虑两个细节:一是模型加载需要常驻内存,不要每次请求都重新加载分词典;二是文本长度要做限制,避免超大文本导致请求超时。
10.4 我对这类算法组合项目的最终经验总结
做这个新闻网爬虫加TF-IDF、TextRank的项目,最核心的收获是对“数据质量决定算法上限”的认知加深了。爬虫部分做得再完善,如果正文清洗不到位,关键词抽取效果永远上不去。反过来,算法部分不管多精致,如果采集端没有做好频率控制、没有处理好页面结构变化,整个Pipeline都无法稳定运行。
把采集和算法当成一个完整的工程系统来设计,而不是按“爬虫项目”和“NLP项目”分开做,这应该是这个项目最大的实践价值。
