做舆情分析的人应该都有同感:真正值钱的往往不是新闻正文本身,而是下方评论区里那一大片真实声音。我这阵子接了个小需求,要把指定关键词在百度新闻下的评论内容批量抓下来,存成结构化表格,方便后续做内容分析。断断续续踩了几天坑,把这套“百度新闻评论内容抓取”的完整流程理顺了,正好整理出来分享。文章会比较实操向,从接口分析、代码实现到数据清洗、常见坑位全走一遍,适合正在做爬虫入门练习、舆情监控或者内容分析的人。我会把抓包方法和代码都写清楚,你照着流程替换成实际参数就能跑通,不需要依赖某个固定的接口版本。
1. 项目整体设计与思路拆解
1.1 需求定位:评论区数据到底能拿来做什么
开始写代码之前,先把需求聊透。我这次抓评论,不是随便抓几条看看,而是要做三件事:
- 按关键词聚合:盯着某个行业词或事件词,把相关新闻下的评论全部收过来;
- 结构化输出:每一条评论都要能对应到新闻标题、来源、发布时间、用户、评论时间,不能是糊成一团的页面文本;
- 可增量更新:隔一段时间再跑一次,只补新增评论,不能每次全量重抓。
评论区数据最大的价值在于,它天然带有情绪倾向和关注热点。一条新闻的评论量、评论速度,甚至是评论里的高频词,都能反映公众对某个话题的真实态度。所以这个项目不是简单写个爬虫,它更接近一个小型的舆情数据管道。需求定了,后续所有技术选型都得围绕这三条来。
1.2 技术选型:为什么用 Python + requests 而不是更重的框架
技术栈我选的是 Python 3.9 + requests + BeautifulSoup + pandas。有朋友会问,都这个年头了,为什么不用 Scrapy、Playwright 或者去看大厂的开源采集框架?原因很简单:目标页面的评论列表是通过 XHR 动态加载的 JSON 接口,不需要模拟浏览器渲染;需要抓取的数据结构又比较固定,用轻量库反而好维护。
如果强行上 Scrapy,还得处理中间件、管道、调度器一堆概念,对这样一个百来行就能跑通的项目来说是过度设计。Playwright 也要在无头浏览器里等待滚动、渲染,速度慢不说,对服务器资源的消耗也大。直接请求评论接口是最快、最稳的路径。
当然,如果后续要把抓取规模扩大到几十万条、需要分布式调度,再迁移到 Scrapy 或者自研任务队列也不迟。但第一步,先把链路跑通。
1.3 完整流程梳理
整个抓取链路分五步:
- 用关键词请求百度新闻搜索页,拿到新闻列表的标题、链接、来源和发布时间;
- 从新闻详情页或搜索结果的链接参数中提取新闻唯一标识;
- 拼接评论接口地址,带上新闻标识请求评论数据;
- 不断翻页拉取某条新闻下的全部评论;
- 把评论清洗后写入本地存储。
这个流程的关键点在第 2 步和第 3 步。新闻唯一标识找对了,后面评论接口才能通;接口参数理解错了,拿到的可能是空数据或者直接 403。后面我会把这部分单独拿出来,结合抓包讲清楚。
2. 抓包分析与核心接口解析
2.1 从新闻搜索到详情页的链路
先交代一下我实际抓包的入口。这次需求里,我既用了百度新闻的新闻搜索,也手动打开了搜索结果里的详情页。两种路径对应两种拿新闻标识的方式:
- 路径一:直接解析搜索列表页。搜索结果里每条新闻的点击链接通常带有一串参数,比如
url、id、newsid或者commentid,利用正则或者 URL 解析库可以抽出来。 - 路径二:进入详情页后,在页面源代码中找
comment相关的字段。有些详情页会直接把评论配置项的 JSON 塞在 HTML 里,用正则匹配就能拿到,不用额外发起请求。
这两种方式我都试过,路径二更稳,因为详情页里的标识通常更完整,评论接口直接拿来用就行。路径一的问题是,不同来源的新闻 URL 格式差异大,有的带重定向参数,很容易抽错。新闻聚合平台最大的特点就是来源杂、跳转多,今天能用的解析规则,明天换个媒体源就失效,所以一定要养成“拿真实页面验证规则”的习惯。
提示:百度新闻聚合了很多媒体源,不同媒体跳转的 URL 规则不同。遇到抽取不到标识的情况,直接在浏览器里打开详情页,用开发者工具定位,比盲猜参数名靠谱得多。
2.2 评论接口的参数与返回结构
用开发者工具观察评论加载过程时,会看到类似这样的请求(我这里给的是近期抓包遇到的一个典型示例,实际地址以你 F12 里看到的为准):
https://comment.news.baidu.com/rcd?device=pc&newsid=xxx&page=1&pagesize=30&_=1732600000000主要参数解释:
| 参数 | 含义 | 备注 |
|---|---|---|
| device | 请求方设备 | 固定为 pc 即可 |
| newsid | 新闻唯一标识 | 从详情页提取 |
| page | 评论页码 | 从 1 开始 |
| pagesize | 每页数量 | 常用 20 或 30 |
| _ | 时间戳 | 防止缓存 |
这个接口返回的是 JSON 格式,常见结构里会有一个评论列表字段,里面每条评论包含用户名、评论内容、评论时间、点赞数、是否楼主,以及被回复的评论 ID。不同时间点字段名会变,但大体思路一致:拿到列表,解析,翻页。
抓包的具体操作步骤贴一下,新手照着做就行:
- 打开新闻详情页,按下 F12 打开开发者工具;
- 切到 Network 标签,在筛选框输入 xhr,把请求类型过滤出来;
- 往下滚动评论区,触发下一页加载;
- 在请求列表里找到返回 JSON 且名称中带 comment 或 rcd 的记录;
- 点击该请求,查看 Payload 和 Response,确认返回结构。
2.3 为什么必须学会抓包而不是硬记接口
我在最开始写这个项目时也犯过懒,直接上网搜“百度新闻评论接口”,结果搜出来的老文章里的接口早就失效了。新闻站点改版太频繁,评论模块的接口路径、参数名、返回字段一年能改好几次。真正一劳永逸的办法,就是把抓包这套方法论练熟,不管站点怎么改,最多半小时就能定位到新接口。
顺便提醒一句:抓包时注意看请求头。有些接口必须带 Referer,不带的话服务端直接拒绝;User-Agent 也要用常见的浏览器 UA,不然容易被反爬策略拦截。这些细节我们放到第三节的代码里一起处理。
3. 完整代码实现
3.1 环境准备与依赖安装
建议用 Python 3.9 及以上版本,先装依赖:
pip install requests beautifulsoup4 lxml pandas openpyxlrequests 负责发 HTTP 请求,BeautifulSoup 解析 HTML,pandas 和 openpyxl 用于最后的数据落盘。代码组织上,我建议把新闻列表解析、评论接口抓取、数据清洗存储这三个模块分开,不要全部堆在一个脚本里。后面改字段或者加功能时你会感谢这个决定,尤其是我这种喜欢在命令行里反复跑函数的开发习惯,拆开以后调试成本会低很多。
3.2 新闻列表抓取实现
新闻搜索页的解析相对简单,核心是定位结果条目。这里用tn=news参数让百度返回新闻聚合结果,代码里带上了完整的请求头:
import requests import time from bs4 import BeautifulSoup 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', 'Referer': 'https://www.baidu.com/', 'Accept-Language': 'zh-CN,zh;q=0.9', } def fetch_news_list(keyword: str, page: int = 0) -> list: params = { 'tn': 'news', 'word': keyword, 'pn': page * 20, 'rn': 20, } resp = requests.get('https://www.baidu.com/s', params=params, headers=HEADERS, timeout=10) resp.raise_for_status() resp.encoding = 'utf-8' soup = BeautifulSoup(resp.text, 'lxml') results = [] for item in soup.select('.result'): title_tag = item.select_one('h3 a') if not title_tag: continue title = title_tag.get_text(strip=True) link = title_tag.get('href', '') source_tag = item.select_one('.c-color-gray') source = source_tag.get_text(strip=True) if source_tag else '' results.append({'title': title, 'link': link, 'source': source}) return results if __name__ == '__main__': news = fetch_news_list('人工智能', page=0) for n in news[:5]: print(n) time.sleep(2)选择器.result是搜索结果里新闻卡片常用的 class,如果以后页面结构改了,可以先用soup.select('div[class*=result]')这种宽松选择器调试,找到稳定容器再收窄。每次请求之后sleep一下,是给自己留余量,也是给目标服务器一点喘息空间。这里要注意,搜索结果里的链接很多是百度跳转链接,里面带重定向参数,如果你需要直接对新闻详情页请求,最好先手动确认这层跳转到的是什么地址。
3.3 新闻标识提取与评论抓取
拿到新闻链接后,第二步是提取新闻唯一标识。我的做法是优先看详情页源码里是否有newsid或commentId字段,其次才考虑从 URL 参数解析。
import re import requests def extract_news_id(detail_url: str) -> str: """优先在详情页 HTML 中查找评论配置字段""" resp = requests.get(detail_url, headers=HEADERS, timeout=10) resp.encoding = 'utf-8' html = resp.text patterns = [ r'newsid["\']?\s*[:=]\s*["\']?(\d+)', r'commentId["\']?\s*[:=]\s*["\']?(\d+)', r'cmtid["\']?\s*[:=]\s*["\']?(\d+)', ] for pattern in patterns: match = re.search(pattern, html, re.IGNORECASE) if match: return match.group(1) match = re.search(r'[?&]newsid=(\d+)', detail_url) if match: return match.group(1) return ''这个函数用到了上一节定义的HEADERS,实际项目里我会把公共配置抽到一个config.py,避免来回粘贴。正则匹配时,IGNORECASE参数非常重要,线上代码里同一个字段可能一会儿大写一会儿小写,不忽略大小写很容易漏掉目标。
拿到新闻标识后,就可以请求评论接口。这里用一个封装好的函数,注意导入random,因为后面翻页延时要用到:
import json import time import random def fetch_comments(news_id: str, max_pages: int = 50) -> list: comments = [] for page in range(1, max_pages + 1): params = { 'device': 'pc', 'newsid': news_id, 'page': page, 'pagesize': 30, '_': int(time.time() * 1000), } # 接口地址请以实际抓包结果为准,下面是一个参考示例 resp = requests.get( 'https://comment.news.baidu.com/rcd', params=params, headers=HEADERS, timeout=10 ) if resp.status_code != 200: time.sleep(3) continue # 有些接口返回 JSONP 格式,需要去掉回调前缀 text = resp.text.strip() if text.startswith(('callback', 'jQuery')): text = text[text.find('(') + 1: text.rfind(')')] try: data = json.loads(text) except json.JSONDecodeError: break items = data.get('data', {}).get('comments', []) if not items: break comments.extend(items) time.sleep(random.uniform(1, 3)) return comments代码里加了 JSONP 处理,是因为很多接口为了跨域会包一层回调函数。这一层不剥掉,json.loads直接报错。下一页判断也做了兜底:当返回评论列表为空时,说明没有更多数据了,直接跳出翻页循环。每次抓完一页后间隔 1 到 3 秒,这个随机范围是我实际测试后固定的,太短容易触发频率限制,太长又影响整体速度,1 到 3 秒算是一个平衡点。
3.4 增量去重与多线程加速
单条新闻的评论量不大,但如果要跟踪多个关键词、几百条新闻,串行抓取会非常慢。我后来用ThreadPoolExecutor做了个简单的线程池,同时控制并发数,避免把反爬阈值触发得太明显。
from concurrent.futures import ThreadPoolExecutor, as_completed def collect_comments_multi(news_list: list, max_workers: int = 4) -> dict: result = {} with ThreadPoolExecutor(max_workers=max_workers) as executor: future_map = { executor.submit(fetch_comments, item['news_id']): item for item in news_list } for future in as_completed(future_map): item = future_map[future] try: result[item['title']] = future.result() except Exception as exc: print(f'抓取失败: {item["title"]} -> {exc}') return result增量去重我单独写了一个基于集合的方案:每抓一条评论,就把它的评论 ID 存进内存集合,如果某条新闻之前抓过,就先把旧的评论 ID 加载进来,再在写入前检查 ID 是否已存在。这样第二次运行时,只会有新增评论进入结果集。并发线程数量我没有开太大,4 到 6 个线程已经能明显提速,没必要为了几分钟的时间差去冒被限制的风险。
4. 数据清洗与落盘存储
4.1 字段设计
评论接口返回的原始字段往往比较乱,直接落库后面用起来很痛苦。我建议在入库前做一层字段映射,把原始字段转成统一格式:
| 目标字段 | 说明 | 原始字段常见来源 |
|---|---|---|
| news_title | 新闻标题 | 新闻列表解析 |
| news_url | 新闻链接 | 新闻列表解析 |
| comment_id | 评论唯一标识 | data.comments[].id |
| user_name | 评论用户名 | data.comments[].user.name |
| content | 评论正文 | data.comments[].content |
| comment_time | 评论时间戳/时间字符串 | data.comments[].time |
| like_count | 点赞数 | data.comments[].like |
| reply_to | 被回复的评论ID | data.comments[].reply_to |
字段名以你实际抓到的 JSON 为准,但最终落到文件里的表结构建议统一成这一套。后面做分页、按时间筛选、统计点赞都能直接用。特别是reply_to这个字段,如果直接抓原始接口,你可能看不到,但它在还原互动关系时非常有用,能在分析时区分“楼中楼回复”和“独立评论”。
4.2 评论去重与清洗规则
清洗这一步看起来不起眼,实际上能帮你省很多事。我在项目里处理了这么几类脏数据:
- 纯空白或超短内容(只有一两个字符)直接过滤;
- 内容里的 HTML 标签、转义字符、多余空白全部清理;
- 表情和 emoji 按需保留或转成通用标记,防止 Excel 处理时出现写入异常;
- 对用户名做脱敏处理,去掉不可见字符和异常符号。
去重的策略上,我选择以comment_id为主键,内存集合做判重;如果接口没返回稳定的评论 ID,就退而求其次用(user_name, content, comment_time)三者拼接做哈希,也能达到差不多的效果。
import re def clean_comment(raw: dict) -> dict: content = re.sub(r'<[^>]+>', '', raw.get('content', '')) content = content.replace('\\n', ' ').strip() if not content or len(content) < 2: return None return { 'comment_id': raw.get('id'), 'user_name': re.sub(r'[\x00-\x1f\x7f]', '', raw.get('user_name', '')), 'content': content, 'comment_time': raw.get('time'), 'like_count': raw.get('like', 0), 'reply_to': raw.get('reply_to', ''), }这段清洗代码最关键的是[\x00-\x1f\x7f]这个正则,它负责删除 ASCII 控制字符。很多时候从接口拿到的字符串里隐藏着换行符、制表符、退格符,直接写入 Excel 会导致单元格内容异常,用这行就能全部剔除干净。清理完的数据尽量不要丢掉原始字段的完整时间戳,因为后面做时间序列分析时,字符串格式的时间要转换成 datetime,保留原始值能方便你处理时区差异。
4.3 导出 Excel 与 CSV
数据量不大的时候,Excel 最直观;数据量上万,建议直接写 CSV 或 SQLite。我倾向于先落一份 CSV,再用 pandas 转 Excel:
import pandas as pd def save_to_files(records: list, output_base: str): df = pd.DataFrame(records) df = df.drop_duplicates(subset=['comment_id'], keep='first') csv_path = f'{output_base}.csv' excel_path = f'{output_base}.xlsx' df.to_csv(csv_path, index=False, encoding='utf-8-sig') df.to_excel(excel_path, index=False)这里有个小细节:写 CSV 时用utf-8-sig而不是utf-8。直接用utf-8出的表格用 Excel 双击打开,中文大概率是乱码,因为 Excel 默认按 ANSI 解析文件。加 BOM 后 Excel 就能正确识别 Unicode 了。这个坑我当初踩过一次,每次都有新同学重复踩,顺便写出来。如果你偏好用 SQLite 存,pandas 的to_sql也很方便,但要注意 SQLite 对同一个连接的多线程写入支持不太友好,建议集中写入,别多个线程同时开连接。
5. 常见问题与排查技巧
5.1 请求被拦截,返回 403 或验证码
这是爬虫遇到最多的场景。搜索引擎对自动请求的识别挺有一套,触发拦截通常有几种原因:UA 太短、缺少 Referer、单 IP 请求频率太高、或者短时间内重复请求同一个接口。我印象深刻的一次是只改了关键词不变请求参数,结果连续抓了十分钟后所有请求都开始返回验证码页面,最后排查发现是我漏配了Referer,补上之后拦截明显减少。
我的处理思路是:
- 请求头完整模拟浏览器,UA、Referer、Accept、Accept-Language 全部带上;
- 单次请求间隔至少 1 秒,随机抖动到 3 秒;
- 同一 IP 被抓的话,固定时间窗口内减少并发;
- 加上指数退避重试,比如第一次失败等 5 秒,第二次等 10 秒,最多重试三次。
注意:这里说到的重试和延时,目的是让脚本对目标站点更友好,不是鼓励绕过访问控制。做数据采集一定要控制频率,不要死磕一个站点。
5.2 评论接口返回空数据
接口通了,但返回的评论列表为空,先别急着改代码。检查这几种可能:
- 新闻本身没有评论。很多新闻聚合页的评论量本来就是 0,这是正常现象;
newsid提取错了。重新打开详情页对照一下源码里的字段,确认拿到的 ID 确实是评论配置里那个;- 评论接口需要带 Cookie。有些新闻详情页的评论只在登录态下可见,这种情况可以先用浏览器手动打开页面,观察是否真的能拉到评论,再把浏览器的 Cookie 复制到请求头里测试。
在实际项目中,我发现更隐蔽的一个原因是页数参数写错。有的接口第一页是page=1,有的却是page=0,如果从 1 开始恰好碰上接口要求从 0 开始,第一页就会返回空,但你不容易察觉,因为脚本不会报错。遇到空数据时,把返回的 JSON 原样打印出来看一遍,比反复改参数更有效率。
5.3 中文乱码与 emoji 处理
乱码问题分两种情况。解析阶段中文乱码,通常是响应编码识别错误,用resp.encoding = 'utf-8'或resp.apparent_encoding修正;如果页面返回的是 gbk 编码,直接指定 utf-8 反而会出乱码,这时候resp.apparent_encoding能帮你自动猜测正确编码。落盘阶段 Excel 乱码,就是上一节说的编码格式问题,统一用utf-8-sig。
emoji 的问题是 Excel 老版本接收不了部分特殊字符,强行写入会导致 xlsx 文件打不开。稳妥的办法是清洗阶段就把非 BMP 字符剔除,或者转成[表情]这种占位符。如果确实需要保留 emoji,可以考虑改用 CSV 格式存储,CSV 对字符的容纳度更高,不会因为一个 emoji 把整个文件搞崩。
5.4 断点续抓与进度保存
评论抓了一半程序崩了,从头再跑一遍很浪费。我后来做了个简单的进度文件机制:每抓完一条新闻,就把它的 ID 追加进一个done.txt,启动时先读取这个文件,已经抓过的新闻直接跳过。
def load_done_ids(path: str) -> set: try: with open(path, 'r', encoding='utf-8') as f: return {line.strip() for line in f if line.strip()} except FileNotFoundError: return set() def mark_done(path: str, news_id: str): with open(path, 'a', encoding='utf-8') as f: f.write(news_id + '\n')文件本身很小,几百条新闻也就几 KB,不用额外引入数据库,断点续抓的需求就满足了。这个思路还可以扩展到更多的场景:你的去重集合也可以用文件来保存,甚至可以保存已经抓到的评论 ID 列表,防止同一批评论被重复入库。日志方面我也建议写到一个文件里,方便程序崩溃后回溯是哪条新闻、哪个评论出的问题。
6. 经验总结与扩展方向
6.1 从抓取到舆情监控自动化
项目跑通之后,我把它挂到了服务器的定时任务上,每天固定时间跑一次关键词集合。每次只抓新增评论,然后追加到同一个 Excel 里。这样一段时间下来,就能看到不同话题下评论量的涨跌曲线,也能通过高频词变化感知话题热度的迁移。定时任务用系统自带的 cron 就能搞定,不需要额外引入复杂的调度框架,关键是脚本本身要做得足够健壮:单条新闻失败不能影响整体,异常要能输出日志,磁盘空间和文件句柄要记得释放,这些都是生产环境才会暴露出来的细节。
6.2 评论内容的下游分析
抓到的评论如果不做分析,价值就少了一大半。我建议下一步做三件事:
- 情感倾向分类:用简单的情感词典或者预训练模型,把评论分成正向、负向、中性;
- 高频词与词云:整理评论分词后的高频关键词,快速了解讨论焦点;
- 时间序列统计:按小时聚合评论数量,看新闻热度的衰减曲线。
这些分析都不需要太重的框架,pandas 加 jieba 加一个小型词库就够了。等数据量真正起来,再上更完整的分析链路。实际做的时候,可以先从词频统计开始,词云虽然看起来不够“专业”,但却是快速传达结论最直接的方式,也适合给非技术的同事看。
6.3 合规与边界
最后聊两句边界问题。评论抓取虽然技术不复杂,但使用上要守住几条线:只抓公开可见的数据,不碰需要绕过登录限制才能看到的内容;控制采集频率,不要对目标站点造成实质压力;抓到的评论数据仅用于个人学习或合规范围内的分析,不要用于商业牟利,也不要传播涉及个人隐私的内容。做技术分享也一样,重点是交流实现思路和踩坑经验,而不是教人怎么无节制地采数据。
在实际操作中我还有个小习惯:脚本里永远保留一个全局开关,能一键暂停所有请求。这个开关在调试反爬策略时帮了我大忙,也让我在数据量异常飙升时能迅速停下来复盘,而不是等服务端告警了才慌忙处理。做爬虫不是越快越猛越高明,真正考验功力的,是知道什么时候该停下来。