ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Python新闻采集实战:央视新闻国内版块列表与正文抓取

Python新闻采集实战:央视新闻国内版块列表与正文抓取

1. 项目缘起与整体设计思路

做数据采集这行的朋友多半都有个共识:新闻类站点是练手和做内容聚合最理想的靶子之一。结构相对规整、更新频率稳定、字段清晰,而且内容本身有真实价值。这次我拿央视新闻国内版块做了一次完整的采集实践,目标很明确——把国内版块的新闻列表和正文内容稳定地抓下来,落成结构化数据,方便后续做内容分析、选题参考或者资讯聚合。

先说清楚这个项目能解决什么问题。很多人做资讯类产品或者做舆情观察,第一步就卡在数据源上。手动复制粘贴效率极低,而且容易漏。用采集的方式把新闻标题、发布时间、正文、来源这些字段批量拿下来,后续无论是做词频统计、选题趋势分析,还是做自己的资讯看板,都有了原始素材。这个项目适合有一定编程基础、想入门数据采集的开发者,也适合做内容运营、市场观察的朋友拿来当工具用。

整体设计上我遵循三个原则。第一是先列表后详情的两级结构,列表页负责拿到所有新闻的入口链接,详情页负责拿正文,这样职责清晰,出问题也好定位。第二是请求要克制,新闻站点对高频访问比较敏感,必须控制节奏,加合理的间隔。第三是数据要可追溯,每条记录都保留原始链接和采集时间,方便后续核对和去重。

为什么选这个思路而不是一上来就并发猛冲?因为新闻站点的页面结构相对稳定,但反爬策略往往是动态调整的。先用最朴素的方式跑通全流程,确认字段都能拿到,再考虑优化速度,这是我在多个采集项目里总结出来的稳妥路径。一上来就上多线程、上代理池,出了问题你根本不知道是结构解析错了还是被限流了,排查成本极高。

技术选型上,我用了 Python 生态里最成熟的组合:requests负责发请求,BeautifulSoup负责解析 HTML,pandas负责数据整理和导出。这套组合的好处是依赖少、上手快、调试直观。如果你更熟悉lxml或者parsel,完全可以替换,核心逻辑不变。存储方面先用 CSV 落地,简单直接,后续要入库再迁移也不费事。

提示:采集任何站点前,先花十分钟手动浏览目标页面,把列表页和详情页的 DOM 结构看清楚,比直接写代码效率高得多。这一步省不得。

2. 核心细节解析与实操要点

2.1 目标页面结构拆解

央视新闻国内版块的列表页,本质上是一个新闻卡片的集合。每个卡片里通常包含标题、摘要、发布时间、缩略图和详情页链接。这里有个关键点:列表页的链接往往是相对路径,比如/2024/xx/xx/xxxxx.shtml这种形式,需要和域名拼接成完整 URL 才能请求。

我在实际操作中发现,列表页的新闻条目一般包裹在特定的容器里,每条新闻是一个独立的节点。定位的时候不要用过于宽泛的选择器,比如直接选所有a标签,那样会把导航栏、页脚、广告位的链接全捞进来。正确做法是先定位到新闻列表的父容器,再在容器内部找条目,这样精准度高很多。

详情页的结构相对复杂一些。正文通常在一个大的内容容器里,里面可能夹杂着图片、视频、相关推荐等元素。提取正文时,我建议按段落逐个取文本再拼接,而不是直接取整个容器的文本。因为直接取容器文本会把一些无关的推荐语、版权信息也带进来,后期清洗很麻烦。

2.2 请求头的合理构造

这一步是很多新手容易忽略的地方。默认的requests请求头里User-Agent是 Python 的标识,很容易被识别出来。我的做法是构造一个贴近真实浏览器的请求头,包含User-Agent、Accept、Accept-Language这几个关键字段。

headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/120.0.0.0 Safari/537.36", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "Accept-Language": "zh-CN,zh;q=0.9", }

这里要强调一点:构造请求头是为了让请求看起来正常,不是为了绕过什么限制。采集行为本身要遵守目标站点的规则,控制频率、尊重 robots 协议,这是基本职业素养。

2.3 编码问题的处理

中文新闻站点经常遇到编码问题。有时候页面声明是 UTF-8,实际返回的却是 GBK,直接解析会出现乱码。我的处理方式是先让 requests 自动推断编码,再手动校验。

resp = requests.get(url, headers=headers, timeout=10) resp.encoding = resp.apparent_encoding # 自动推断编码 html = resp.text

apparent_encoding会根据内容实际字节推断编码,比单纯依赖响应头里的声明更可靠。如果发现还是有乱码,可以手动指定resp.encoding = "utf-8"或"gbk"试一下。这个坑我踩过不止一次,尤其是抓一些地方性新闻站点的时候。

2.4 解析库的选择考量

BeautifulSoup支持多种解析器,常用的有html.parser、lxml、html5lib。三者的区别我整理成了一张表:

解析器速度容错性依赖适用场景
html.parser中等一般无(内置)快速验证、依赖受限环境
lxml快较好需安装大批量采集、追求效率
html5lib慢最好需安装结构混乱的页面

我的建议是:开发调试阶段用html.parser,因为不用装额外依赖,跑起来快;正式批量采集时换成lxml,速度提升明显。如果遇到结构特别乱的页面,再考虑html5lib。

注意:解析器不同,对同一段 HTML 的解析结果可能有细微差异。切换解析器后,务必重新验证你的选择器是否还能正确定位元素。

2.5 数据字段的设计

采集之前先想清楚要哪些字段,这决定了你解析时关注哪些节点。我这次采集保留了以下字段:

  • 标题:新闻的标题文本
  • 发布时间:精确到日期,方便按时间排序
  • 正文:完整正文内容
  • 来源:新闻来源标注
  • 原始链接:详情页完整 URL
  • 采集时间:记录抓取时刻,便于追溯

字段设计的原则是够用且可扩展。不要一上来就设计几十个字段,很多字段你根本用不上,反而增加解析负担。先把核心字段跑通,后续需要再加。

3. 实操过程与核心环节实现

3.1 环境准备与依赖安装

先把环境搭起来。Python 版本建议 3.8 以上,依赖就三个:

pip install requests beautifulsoup4 lxml pandas

requests发请求,beautifulsoup4解析,lxml作为解析器后端,pandas做数据整理和导出。装完之后可以写个简单的测试脚本验证一下:

import requests from bs4 import BeautifulSoup import pandas as pd print("依赖加载正常")

能正常打印就说明环境没问题。

3.2 列表页采集实现

列表页的任务是拿到所有新闻的详情页链接。我封装了一个函数,接收列表页 URL,返回链接列表。

import time import requests from bs4 import BeautifulSoup def fetch_list(url, headers): resp = requests.get(url, headers=headers, timeout=10) resp.encoding = resp.apparent_encoding soup = BeautifulSoup(resp.text, "lxml") links = [] # 定位新闻列表容器,再在容器内找条目 container = soup.find("div", class_="news-list-container") if not container: return links for item in container.find_all("li"): a_tag = item.find("a", href=True) if a_tag: href = a_tag["href"] # 相对路径拼接成完整 URL if href.startswith("/"): href = "https://news.example.com" + href links.append(href) return links

这里有几个细节值得说。第一,container找不到时直接返回空列表,避免后续报错。第二,链接拼接要判断是不是以/开头,有些链接本身就是完整的。第三,find_all("li")是根据实际结构来的,如果你的目标页面用的是div或者article,要相应调整。

3.3 详情页正文提取

详情页的核心是正文提取。我的做法是定位正文容器,然后按段落取文本。

def fetch_detail(url, headers): resp = requests.get(url, headers=headers, timeout=10) resp.encoding = resp.apparent_encoding soup = BeautifulSoup(resp.text, "lxml") data = {"url": url} # 标题 title_tag = soup.find("h1") data["title"] = title_tag.get_text(strip=True) if title_tag else "" # 发布时间 time_tag = soup.find("div", class_="publish-time") data["publish_time"] = time_tag.get_text(strip=True) if time_tag else "" # 来源 source_tag = soup.find("div", class_="source") data["source"] = source_tag.get_text(strip=True) if source_tag else "" # 正文:按段落拼接 content_tag = soup.find("div", class_="content-body") if content_tag: paragraphs = [p.get_text(strip=True) for p in content_tag.find_all("p")] data["content"] = "\n".join([p for p in paragraphs if p]) else: data["content"] = "" return data

正文提取这里,我用列表推导式过滤掉空段落,再用换行符拼接。这样得到的正文段落清晰,后续做文本分析也方便。如果直接把整个容器的get_text()拿来用,段落之间会粘连在一起,可读性差。

3.4 主流程串联与节奏控制

把列表和详情串起来,加上合理的请求间隔。

def main(): headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/120.0.0.0 Safari/537.36", "Accept-Language": "zh-CN,zh;q=0.9", } list_url = "https://news.example.com/china/" links = fetch_list(list_url, headers) print(f"共获取到 {len(links)} 条新闻链接") results = [] for i, link in enumerate(links): try: detail = fetch_detail(link, headers) detail["crawl_time"] = time.strftime("%Y-%m-%d %H:%M:%S") results.append(detail) print(f"[{i+1}/{len(links)}] 已采集:{detail['title'][:20]}") except Exception as e: print(f"采集失败 {link}:{e}") # 控制节奏,每条间隔 1-2 秒 time.sleep(1.5) # 导出 CSV df = pd.DataFrame(results) df.to_csv("news_data.csv", index=False, encoding="utf-8-sig") print(f"采集完成,共 {len(results)} 条,已导出 CSV") if __name__ == "__main__": main()

关于间隔时间,我设的是 1.5 秒。这个数值不是拍脑袋定的。假设一个列表页有 30 条新闻,加上列表页本身,总共 31 次请求,按 1.5 秒间隔算,大约 46 秒跑完。这个节奏对站点压力很小,也不容易触发限流。如果你要采集的量很大,间隔可以适当拉长到 2-3 秒。

导出 CSV 时用了utf-8-sig编码,这是个小技巧。普通的utf-8编码在 Excel 里打开会乱码,加上-sig后缀(带 BOM)就能正常显示中文。这个坑我早期踩过,导出的文件自己看着没问题,发给同事打开全是乱码,很尴尬。

3.5 数据落地的完整示例

跑完上面的流程,你会得到一个news_data.csv,字段包括标题、发布时间、来源、正文、原始链接、采集时间。用 pandas 读进来看看:

df = pd.read_csv("news_data.csv") print(df.shape) print(df[["title", "publish_time", "source"]].head())

正常情况下,你能看到每条新闻的标题、时间和来源整齐排列。如果发现某些字段大量为空,说明对应的选择器没匹配上,需要回到页面重新核对 DOM 结构。

4. 常见问题与排查技巧实录

采集过程中遇到的问题,我整理成了一张速查表,方便对照排查。

问题现象可能原因排查与解决
返回内容为空请求被拦截或 URL 错误检查状态码,打印响应内容前 500 字
中文乱码编码推断错误手动指定 encoding 为 utf-8 或 gbk
链接数量为 0选择器不匹配打印 soup 结构,核对容器 class 名
正文缺失正文容器定位错误检查详情页 DOM,确认容器标识
请求超时网络波动或站点限流加 timeout,增加请求间隔
数据重复列表页有重复条目用 URL 去重,set 或 drop_duplicates

4.1 请求被拦截怎么办

如果发现返回的状态码是 403 或者返回内容明显不是正常页面,先别急着怀疑代码。第一步是打印响应内容,看看返回的到底是什么。有时候是验证页面,有时候是错误提示。确认是拦截后,检查请求头是否完整,间隔是否太短。把间隔拉长到 3 秒以上再试,多数情况下能恢复。

4.2 选择器失效的应对

新闻站点的页面结构会不定期调整,今天能用的选择器明天可能就失效了。我的经验是不要写死过于具体的 class 名,因为 class 名往往带随机后缀。优先用相对稳定的标识,比如id、语义化的标签名,或者用层级关系定位。

如果实在找不到稳定标识,可以退而求其次,用文本特征辅助定位。比如正文容器通常是页面上文字最多的那个div,可以通过统计子节点文本长度来动态判断。这种方案不够优雅,但在结构频繁变动时很实用。

4.3 数据去重的处理

列表页有时候会有重复条目,或者分页之间有重叠。去重最简单的方式是按 URL 去重:

df = df.drop_duplicates(subset=["url"])

如果 URL 也可能重复但内容不同(比如同一新闻的不同版本),可以按标题加发布时间组合去重。去重这一步建议放在导出之前,保证最终数据的干净。

4.4 断点续采的思路

采集量大或者中途中断时,从头再来很浪费。我的做法是每采集若干条就落一次盘,同时记录已采集的 URL。重新启动时先读取已采集的 URL 集合,跳过这些链接。

import os done_urls = set() if os.path.exists("news_data.csv"): old_df = pd.read_csv("news_data.csv") done_urls = set(old_df["url"].tolist()) for link in links: if link in done_urls: continue # 继续采集...

这个机制在采集几百上千条数据时特别有用,避免因为一次网络波动就前功尽弃。

提示:断点续采的落盘频率建议每 10-20 条一次,太频繁影响速度,太稀疏中断损失大。

4.5 正文清洗的几个细节

原始正文里经常夹杂一些不需要的内容,比如"责任编辑:某某"、"来源:某某"这类标注。我的处理方式是在提取段落时做过滤:

skip_keywords = ["责任编辑", "来源:", "编辑:", "分享到"] paragraphs = [] for p in content_tag.find_all("p"): text = p.get_text(strip=True) if not text: continue if any(kw in text for kw in skip_keywords): continue paragraphs.append(text)

这样过滤后,正文干净很多。关键词列表可以根据实际采集结果逐步补充,属于越用越顺手的配置。

4.6 采集频率与合规意识

最后说一个容易被忽视但很重要的点:采集行为要有边界感。控制频率、避开高峰时段、不采集非公开内容,这些都是基本要求。我一般会把间隔设在 1.5 到 3 秒之间,单次采集量控制在合理范围内。如果只是做个人学习和小规模分析,这个节奏完全够用。真要大规模采集,应该先了解目标站点的相关规则,走正规渠道获取数据授权。

我在实际项目里养成了一个习惯:每次采集前先手动访问几个页面,确认站点当前状态正常,再启动脚本。这样能避免因为站点临时维护导致的批量失败,也能顺便观察页面结构有没有变化。这个习惯看起来多余,但帮我省过好几次重新排查的时间。

另外,采集下来的数据要做好管理。我一般会按日期分目录存放,文件名带上采集日期,比如news_20240115.csv。这样后续回溯方便,也不会因为多次采集覆盖掉历史数据。数据量大了之后,可以考虑用 SQLite 做本地存储,查询和去重都比 CSV 方便。

关于正文提取,还有一个细节值得分享。有些新闻页面的正文是分页的,第一页只显示一部分,需要点击"下一页"才能看全。这种情况要先在详情页里找到分页链接,把后续页面也采集下来再拼接。判断方法很简单:如果正文末尾出现"1/2"、"下一页"这类字样,基本就是分页了。处理逻辑就是循环请求后续页面,直到没有下一页为止。

这套流程跑下来,一个国内版块的新闻采集就完整了。从列表到详情,从解析到落盘,每个环节都有对应的处理方案和避坑经验。真正动手做一遍,你会发现难点不在代码本身,而在于对页面结构的理解和各种边界情况的处理。多跑几次,把这些细节都摸熟,后面换任何新闻站点,思路都是通用的。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进