1. 项目缘起与整体设计思路
做数据采集这行十来年,我接过不少需求,但“爬取央视新闻国内版块”这个活儿,第一次听到时还是让我多琢磨了一会儿。原因很简单:新闻类站点和电商、论坛、招聘网站完全不是一个路数。它的页面结构相对规整,但内容更新频率极高,栏目层级多,而且反爬策略往往不是靠复杂的登录验证,而是靠访问频率、请求头特征和页面动态渲染来做拦截。换句话说,你如果拿爬商品详情页那套思路直接套过来,大概率跑不了几百条就会被限流。
这个项目的核心目标很明确:把央视新闻国内版块下各个子栏目的新闻条目抓下来,包括标题、发布时间、来源、正文链接,如果进一步扩展,还可以把正文内容也一并落库。它解决的是什么问题?对于做舆情分析、内容聚合、选题研究、媒体监测的团队来说,手动复制粘贴显然不现实,而市面上通用的采集工具往往对新闻站点的适配不够精细,抓下来的数据要么字段缺失,要么时间格式混乱,要么去重逻辑一塌糊涂。所以,自己动手写一套针对性的采集方案,反而是最稳妥的路子。
适合谁来参考?我认为有三类人:第一类是有一定编程基础、想练手数据采集的开发者,这个项目结构清晰、难度适中,非常适合作为第一个完整的采集项目;第二类是做内容运营或数据分析的朋友,需要定期获取新闻数据但不想依赖第三方服务;第三类是做技术调研的同行,想看看新闻类站点采集和普通站点采集在策略上到底有什么区别。
整体设计上,我采用的是“列表页解析 + 详情页按需抓取 + 本地结构化存储”的三段式架构。为什么不直接上分布式爬虫框架?因为央视新闻国内版块的数据量级并没有大到需要多节点协同的程度,单机加合理的并发控制完全够用。而且新闻站点的反爬机制通常对高频请求非常敏感,分布式反而容易触发风控。所以,我选择用轻量级的请求库配合解析库,加上一套自己写的调度和去重逻辑,既可控又容易调试。
在技术选型上,请求层我用的是requests,原因是它足够成熟,配合Session对象可以很方便地维持会话和复用连接。解析层用的是BeautifulSoup配合lxml解析器,虽然有人推荐parsel或直接上Scrapy,但对于这种页面结构相对固定的新闻站点,BeautifulSoup的容错性和可读性更胜一筹。存储层我选了SQLite,轻量、无需额外部署,适合中小规模的数据落地,后期要迁移到MySQL或PostgreSQL也很方便。如果你需要做全文检索,可以在存储之后再加一层Elasticsearch,但那是后话。
还有一个关键设计决策:列表页和详情页分离处理。列表页只负责抓取新闻条目的元信息(标题、链接、时间、来源),详情页则按需抓取正文。这样做的好处是,当你只需要做标题级别的分析时,完全不用碰详情页,采集效率能提升好几倍。而且详情页的请求可以单独控制频率,避免因为正文抓取拖慢整体进度。
注意:新闻站点的页面结构会不定期调整,所以解析逻辑一定要写得足够健壮,不能把某个
class名写死到无法维护的程度。我的做法是把所有选择器集中配置在一个字典里,页面改版时只需要改配置,不用动核心代码。
2. 核心细节解析与实操要点
2.1 目标页面结构分析与字段定义
在动手写代码之前,我花了大概半小时把国内版块的列表页和详情页结构摸了一遍。国内版块通常是一个聚合页面,下面会分多个子栏目,比如时政、社会、经济、科技等。每个子栏目有自己的列表页,列表页上每条新闻一般包含这几个字段:标题、缩略图(可选)、发布时间、来源、详情页链接。有些条目还会带一个简短的摘要。
这里有个细节值得注意:发布时间格式不统一。有的条目显示“2024-06-15 14:30”,有的只显示“14:30”,还有的显示“昨天”“前天”这种相对时间。如果你直接存字符串,后期做时间排序和分析时会非常痛苦。所以我在解析阶段就做了一层时间归一化处理,把所有时间统一转换成YYYY-MM-DD HH:MM:SS格式。对于相对时间,我会结合当前日期做推算,虽然不能保证百分之百精确,但误差通常在一分钟以内,对于新闻分析来说完全够用。
字段定义我整理成了下面这张表,方便你在建库和写解析逻辑时对照:
| 字段名 | 类型 | 说明 | 是否必填 |
|---|---|---|---|
| title | 文本 | 新闻标题 | 是 |
| url | 文本 | 详情页链接 | 是 |
| publish_time | 时间 | 归一化后的发布时间 | 是 |
| source | 文本 | 新闻来源 | 否 |
| category | 文本 | 所属子栏目 | 是 |
| summary | 文本 | 摘要 | 否 |
| content | 文本 | 正文内容 | 否 |
| crawl_time | 时间 | 采集时间 | 是 |
提示:
url字段建议加唯一索引,这是去重的第一道防线。crawl_time虽然看起来不起眼,但在排查数据问题时非常有用,能帮你判断某条数据是什么时候抓进来的。
2.2 请求头与访问频率控制策略
新闻站点对请求头的检查比一般站点严格。我实测下来,如果只带一个默认的User-Agent,前几十个请求可能没问题,但很快就会遇到返回空内容或者直接拒绝的情况。所以,请求头至少要包含以下几项:
User-Agent:建议准备一组常见的浏览器 UA,随机轮换使用。Referer:设置为国内版块的首页地址,模拟从首页点进去的正常行为。Accept:设置为text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8。Accept-Language:设置为zh-CN,zh;q=0.9。
访问频率这块,我的经验是列表页请求间隔控制在 2 到 4 秒之间,详情页请求间隔控制在 1 到 2 秒之间。这个频率听起来很慢,但新闻站点的更新速度并没有你想象的那么快,国内版块一天新增的条目通常也就几百条,用这个频率完全能在合理时间内抓完。如果你追求更高的效率,可以适当缩短间隔,但建议不要低于 1 秒,否则很容易触发风控。
另外,我强烈建议使用Session对象来复用 TCP 连接。这不仅能让请求看起来更像正常用户的连续访问,还能减少握手开销,提升整体效率。下面是我常用的请求头配置代码片段:
import random import requests USER_AGENTS = [ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36", "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/115.0", ] def build_session(): session = requests.Session() session.headers.update({ "User-Agent": random.choice(USER_AGENTS), "Referer": "https://news.example.com/domestic/", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "Accept-Language": "zh-CN,zh;q=0.9", }) return session注意:UA 不要频繁切换,同一个 Session 内最好保持一致。频繁切换 UA 反而会让站点觉得异常,因为正常用户不会在几分钟内换好几个浏览器。
2.3 列表页解析的关键技巧
列表页解析的核心任务是提取每一条新闻的链接和元信息。这里最容易踩的坑是选择器写得太死。比如你写div.news-list > ul > li > a,一旦页面结构调整,整个解析就崩了。我的做法是用“语义化选择器 + 多级回退”的策略。先尝试用最精确的选择器,如果匹配不到,再降级到更宽泛的选择器。
具体来说,我会先定位到新闻列表的容器,然后在容器内遍历每一条新闻。对于标题和链接,优先找a标签的href和文本内容;对于时间,优先找带有时间特征的span或em标签。如果某个字段解析失败,不会让整个条目丢弃,而是把该字段置空,保证数据不丢失。
还有一个细节:列表页可能存在分页或滚动加载。央视新闻国内版块的分页通常是 URL 参数控制,比如?page=2。你可以通过分析分页链接的规律来构造请求。如果遇到滚动加载,那就需要分析接口请求,找到返回 JSON 数据的接口地址,直接请求接口比解析 HTML 更高效。
2.4 详情页正文提取的容错方案
详情页的正文提取是另一个难点。新闻正文通常包裹在div或article标签里,但不同子栏目的模板可能略有差异。我的做法是先用一组候选选择器去匹配正文容器,匹配到之后,再对容器内的p标签做遍历,过滤掉空行、广告、版权声明等无关内容。
这里有个经验:正文中经常夹杂图片说明、编辑署名、责任编辑等信息。这些内容要不要保留,取决于你的用途。如果做全文检索,建议保留;如果做纯文本分析,可以过滤掉。我一般会保留,但在存储时加一个标记字段,方便后期筛选。
另外,详情页的编码问题也要注意。有些页面返回的编码声明和实际编码不一致,导致中文乱码。我的处理方式是:先让requests自动推断编码,如果推断结果不可靠,就手动指定为utf-8或gbk,然后检查解码后的文本是否包含乱码字符。
3. 实操过程与核心环节实现
3.1 环境准备与依赖安装
这个项目对环境的依赖非常轻量,Python 3.8 以上版本即可。我建议用虚拟环境来管理依赖,避免和系统里的其他项目冲突。下面是完整的依赖清单:
pip install requests==2.31.0 pip install beautifulsoup4==4.12.2 pip install lxml==4.9.3如果你打算把数据存到SQLite,Python 标准库自带sqlite3,不需要额外安装。如果你需要更强大的数据处理能力,可以再加一个pandas,但这不是必须的。
提示:
lxml在某些系统上安装时可能需要编译工具,如果遇到问题,可以改用html.parser作为解析器,虽然速度慢一点,但兼容性更好。
3.2 数据库表结构设计与初始化
存储层我用的是SQLite,表结构设计如下:
CREATE TABLE IF NOT EXISTS news ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, url TEXT NOT NULL UNIQUE, publish_time TEXT, source TEXT, category TEXT, summary TEXT, content TEXT, crawl_time TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_publish_time ON news(publish_time); CREATE INDEX IF NOT EXISTS idx_category ON news(category);这里有几个设计考量:url加唯一索引是为了去重,避免重复抓取同一条新闻;publish_time和category加普通索引是为了加速查询,后期做时间范围筛选或栏目筛选时会明显更快;crawl_time不加索引,因为它主要用于排查问题,查询频率不高。
3.3 列表页采集与分页处理
列表页采集的流程可以拆成四步:构造请求、解析页面、提取字段、翻页。我以国内版块某个子栏目为例,假设它的列表页 URL 是https://news.example.com/domestic/politics/,分页参数是page。
import time import requests from bs4 import BeautifulSoup def fetch_list_page(session, base_url, page): url = f"{base_url}?page={page}" 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 def parse_list_page(html, category): soup = BeautifulSoup(html, "lxml") items = [] # 多级回退选择器 container = soup.select_one("div.news-list") or soup.select_one("ul.news-list") or soup for li in container.select("li"): a_tag = li.select_one("a") if not a_tag or not a_tag.get("href"): continue title = a_tag.get_text(strip=True) url = a_tag["href"] if not url.startswith("http"): url = "https://news.example.com" + url time_tag = li.select_one("span.time") or li.select_one("em.time") publish_time = time_tag.get_text(strip=True) if time_tag else "" source_tag = li.select_one("span.source") or li.select_one("em.source") source = source_tag.get_text(strip=True) if source_tag else "" items.append({ "title": title, "url": url, "publish_time": publish_time, "source": source, "category": category, }) return items翻页逻辑我一般设置一个最大页数限制,比如 50 页,避免因为分页参数异常导致无限循环。每翻一页,sleep2 到 4 秒,模拟正常浏览节奏。
3.4 详情页正文抓取与时间归一化
详情页抓取是在列表页数据入库之后进行的。我会先从数据库里查出所有content为空的记录,然后逐条抓取正文。这样做的好处是,如果中途中断,下次可以接着上次的进度继续,不用从头再来。
import re from datetime import datetime, timedelta def normalize_time(raw_time): raw_time = raw_time.strip() now = datetime.now() # 处理 "14:30" 这种只有时间的格式 if re.match(r"^\d{1,2}:\d{2}$", raw_time): hour, minute = map(int, raw_time.split(":")) dt = now.replace(hour=hour, minute=minute, second=0, microsecond=0) if dt > now: dt -= timedelta(days=1) return dt.strftime("%Y-%m-%d %H:%M:%S") # 处理 "昨天 14:30" 这种格式 if raw_time.startswith("昨天"): time_part = raw_time.replace("昨天", "").strip() if re.match(r"^\d{1,2}:\d{2}$", time_part): hour, minute = map(int, time_part.split(":")) dt = (now - timedelta(days=1)).replace(hour=hour, minute=minute, second=0, microsecond=0) return dt.strftime("%Y-%m-%d %H:%M:%S") # 处理标准格式 for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M", "%Y-%m-%d"): try: return datetime.strptime(raw_time, fmt).strftime("%Y-%m-%d %H:%M:%S") except ValueError: continue return raw_time正文提取部分,我会先定位正文容器,然后遍历其中的段落标签,过滤掉空段落和明显的广告文本。这里有个小技巧:正文段落通常长度较长,而广告和版权声明往往很短。所以我会设置一个最小长度阈值,比如 10 个字符,低于这个长度的段落直接跳过。
3.5 数据入库与去重逻辑
入库时我用的是INSERT OR IGNORE,配合url字段的唯一索引,天然实现去重。如果某条新闻已经存在,数据库会自动忽略,不会报错。这样即使重复抓取同一个列表页,也不会产生重复数据。
import sqlite3 def save_news(items): conn = sqlite3.connect("news.db") cursor = conn.cursor() for item in items: cursor.execute(""" INSERT OR IGNORE INTO news (title, url, publish_time, source, category, crawl_time) VALUES (?, ?, ?, ?, ?, ?) """, ( item["title"], item["url"], normalize_time(item["publish_time"]), item["source"], item["category"], datetime.now().strftime("%Y-%m-%d %H:%M:%S"), )) conn.commit() conn.close()注意:
INSERT OR IGNORE虽然方便,但它不会更新已有记录。如果你希望每次抓取都更新crawl_time,那就需要改用INSERT OR REPLACE,但这样会丢失自增id。我的建议是保持INSERT OR IGNORE,如果需要更新时间,单独写一条UPDATE语句。
4. 常见问题与排查技巧实录
4.1 请求被拒绝或返回空内容
这是最常见的问题,表现是resp.status_code返回 403 或 200 但内容为空。排查思路如下:
| 现象 | 可能原因 | 解决方法 |
|---|---|---|
| 403 Forbidden | 请求头缺失或 UA 被识别 | 补全 Referer、Accept 等头部,更换 UA |
| 200 但内容为空 | 触发频率限制 | 增大请求间隔,降低并发 |
| 200 但返回验证页 | 站点要求验证 | 检查是否需要 Cookie,适当增加访问间隔 |
| 连接超时 | 网络不稳定或站点限流 | 增加 timeout,重试 2 到 3 次 |
我的经验是,遇到 403 先别急着换 IP,先检查请求头。很多时候补上Referer就能解决。如果补了还不行,再考虑降低频率。
4.2 解析结果为空或字段缺失
解析失败通常有两个原因:选择器写错了,或者页面结构变了。排查时我会先把 HTML 保存到本地,然后用BeautifulSoup在本地反复调试选择器。这样比在线调试快得多,也不会因为反复请求触发风控。
另一个常见问题是编码错误导致中文乱码。如果你看到类似æ–°é—»这样的字符,说明编码推断错了。解决办法是手动指定编码,或者用resp.content.decode("utf-8", errors="ignore")来强制解码。
4.3 时间格式混乱导致排序异常
前面提到过,新闻站点的时间格式不统一。如果你不做归一化,直接存字符串,那么按时间排序时会出现“14:30”排在“2024-06-15”前面的情况。我的建议是在入库前就完成归一化,数据库里只存标准格式。对于无法识别的时间,可以存原始字符串,但加一个标记字段,方便后期人工修正。
4.4 数据重复与漏抓
数据重复通常是因为列表页翻页时出现了重复条目,或者详情页被多次抓取。解决办法就是前面说的url唯一索引加INSERT OR IGNORE。漏抓则可能是因为分页逻辑有误,或者某些条目被选择器过滤掉了。我一般会在采集完成后做一个统计:对比列表页显示的条目总数和实际入库数量,如果差距较大,就说明有漏抓,需要检查选择器。
提示:建议每次采集完成后输出一份简单的统计报告,包括本次抓取条数、新增条数、跳过条数、失败条数。这样能快速判断采集是否正常。
4.5 正文提取不完整或包含无关内容
正文提取不完整,通常是因为正文容器选择器匹配到了错误的div。解决办法是打印出候选容器的文本长度,选择最长的那个作为正文容器。如果正文中包含了广告或版权声明,可以通过关键词过滤,比如包含“责任编辑”“版权声明”“免责声明”的段落直接跳过。
还有一个容易被忽略的问题:正文中的图片和视频。如果你只需要文本,那直接忽略img和video标签即可。如果需要保留图片链接,可以在解析时单独提取src属性,存到一个附加字段里。
5. 采集策略优化与扩展思路
5.1 增量采集与定时任务
新闻站点的内容是持续更新的,所以没必要每次都全量抓取。我的做法是记录上次采集的时间戳,每次只抓取比这个时间戳更新的条目。具体实现上,可以在列表页解析时判断发布时间,如果早于上次采集时间,就停止翻页。这样能大幅减少请求量,也能降低触发风控的概率。
定时任务可以用cron或schedule库来实现。我一般设置为每小时跑一次,每次只抓最新的一两页。这样既能保证数据及时性,又不会给站点造成压力。
5.2 数据清洗与结构化增强
抓下来的原始数据往往需要进一步清洗。比如标题中可能包含多余的空格、换行符,正文中可能包含 HTML 实体字符。我一般会用正则表达式做一轮清洗,把 、&这类实体替换成正常字符,把连续的空格合并成一个。
如果要做更深入的分析,还可以对正文做分词和关键词提取。这部分可以用jieba做中文分词,然后用TF-IDF或TextRank提取关键词。不过这就超出了采集本身的范围,属于数据应用的范畴了。
5.3 存储方案升级与查询优化
SQLite适合中小规模数据,但如果你的数据量超过百万条,查询性能会明显下降。这时候可以考虑迁移到MySQL或PostgreSQL。迁移时只需要改一下连接配置和建表语句,核心逻辑不用动。
如果要做全文检索,可以在存储之后再加一层Elasticsearch。把新闻标题和正文导入ES,就能实现毫秒级的全文搜索。不过ES的部署和维护成本较高,建议在数据量确实很大、检索需求确实很强时再考虑。
5.4 合规采集的边界与注意事项
做数据采集,合规是底线。我的原则是:只采集公开可见的内容,不绕过任何登录或验证机制,不采集个人隐私信息,不将数据用于商业转售。采集频率要控制在合理范围内,不对目标站点造成明显压力。如果站点提供了robots.txt,建议先看一下相关约定,虽然它不具备法律强制力,但尊重站点意愿是基本的行业礼仪。
另外,采集到的新闻内容版权归原站点所有,如果要在自己的产品中展示,需要获得授权。如果只是做内部研究或分析,一般问题不大,但也要注意不要大规模复制传播。
6. 实操心得与避坑经验
这个项目我前后迭代了三个版本,踩过的坑不算少,这里挑几个最有代表性的分享一下。
第一个坑是过度依赖单一选择器。第一版代码里我把列表页的选择器写成了div.list > ul > li > a,结果站点改版后整个采集直接挂掉。后来我改成了多级回退策略,虽然代码复杂了一点,但稳定性提升非常明显。这个教训让我明白,采集代码的健壮性比简洁性更重要。
第二个坑是忽略时间归一化。第一版直接把时间字符串存进数据库,后来做时间范围查询时发现排序完全乱套。补上归一化逻辑后,查询效率和分析准确性都上了一个台阶。所以我现在做任何采集项目,时间字段一定是重点处理对象。
第三个坑是没有做增量采集。早期版本每次都是全量抓取,跑一次要十几分钟,而且经常触发风控。后来改成增量采集,每次只抓最新内容,跑一次不到一分钟,风控问题也基本消失了。这个优化带来的收益远超我的预期。
最后一个心得是日志要写清楚。采集过程中难免会遇到各种异常,如果日志只记录“请求失败”,排查起来会很痛苦。我现在的做法是记录完整的 URL、状态码、错误信息和时间戳,这样出问题时能快速定位。日志不用写得太花哨,但关键信息一个都不能少。
如果你也在做类似的新闻采集项目,我的建议是先把列表页跑通,确保能稳定拿到标题和链接,然后再逐步加上详情页抓取、时间归一化、增量采集这些功能。不要一上来就追求大而全,小步快跑、逐步迭代,反而更容易做出稳定可靠的东西。