
做舆情分析的人应该都有同感真正值钱的往往不是新闻正文本身而是下方评论区里那一大片真实声音。我这阵子接了个小需求要把指定关键词在百度新闻下的评论内容批量抓下来存成结构化表格方便后续做内容分析。断断续续踩了几天坑把这套“百度新闻评论内容抓取”的完整流程理顺了正好整理出来分享。文章会比较实操向从接口分析、代码实现到数据清洗、常见坑位全走一遍适合正在做爬虫入门练习、舆情监控或者内容分析的人。我会把抓包方法和代码都写清楚你照着流程替换成实际参数就能跑通不需要依赖某个固定的接口版本。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?devicepcnewsidxxxpage1pagesize30_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 解析 HTMLpandas 和 openpyxl 用于最后的数据落盘。代码组织上我建议把新闻列表解析、评论接口抓取、数据清洗存储这三个模块分开不要全部堆在一个脚本里。后面改字段或者加功能时你会感谢这个决定尤其是我这种喜欢在命令行里反复跑函数的开发习惯拆开以后调试成本会低很多。3.2 新闻列表抓取实现新闻搜索页的解析相对简单核心是定位结果条目。这里用tnnews参数让百度返回新闻聚合结果代码里带上了完整的请求头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;q0.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, paramsparams, headersHEADERS, timeout10) 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(stripTrue) link title_tag.get(href, ) source_tag item.select_one(.c-color-gray) source source_tag.get_text(stripTrue) if source_tag else results.append({title: title, link: link, source: source}) return results if __name__ __main__: news fetch_news_list(人工智能, page0) 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, headersHEADERS, timeout10) resp.encoding utf-8 html resp.text patterns [ rnewsid[\]?\s*[:]\s*[\]?(\d), rcommentId[\]?\s*[:]\s*[\]?(\d), rcmtid[\]?\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, paramsparams, headersHEADERS, timeout10 ) 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_workersmax_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[].iduser_name评论用户名data.comments[].user.namecontent评论正文data.comments[].contentcomment_time评论时间戳/时间字符串data.comments[].timelike_count点赞数data.comments[].likereply_to被回复的评论IDdata.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 转 Excelimport pandas as pd def save_to_files(records: list, output_base: str): df pd.DataFrame(records) df df.drop_duplicates(subset[comment_id], keepfirst) csv_path f{output_base}.csv excel_path f{output_base}.xlsx df.to_csv(csv_path, indexFalse, encodingutf-8-sig) df.to_excel(excel_path, indexFalse)这里有个小细节写 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 复制到请求头里测试。在实际项目中我发现更隐蔽的一个原因是页数参数写错。有的接口第一页是page1有的却是page0如果从 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, encodingutf-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, encodingutf-8) as f: f.write(news_id \n)文件本身很小几百条新闻也就几 KB不用额外引入数据库断点续抓的需求就满足了。这个思路还可以扩展到更多的场景你的去重集合也可以用文件来保存甚至可以保存已经抓到的评论 ID 列表防止同一批评论被重复入库。日志方面我也建议写到一个文件里方便程序崩溃后回溯是哪条新闻、哪个评论出的问题。6. 经验总结与扩展方向6.1 从抓取到舆情监控自动化项目跑通之后我把它挂到了服务器的定时任务上每天固定时间跑一次关键词集合。每次只抓新增评论然后追加到同一个 Excel 里。这样一段时间下来就能看到不同话题下评论量的涨跌曲线也能通过高频词变化感知话题热度的迁移。定时任务用系统自带的 cron 就能搞定不需要额外引入复杂的调度框架关键是脚本本身要做得足够健壮单条新闻失败不能影响整体异常要能输出日志磁盘空间和文件句柄要记得释放这些都是生产环境才会暴露出来的细节。6.2 评论内容的下游分析抓到的评论如果不做分析价值就少了一大半。我建议下一步做三件事情感倾向分类用简单的情感词典或者预训练模型把评论分成正向、负向、中性高频词与词云整理评论分词后的高频关键词快速了解讨论焦点时间序列统计按小时聚合评论数量看新闻热度的衰减曲线。这些分析都不需要太重的框架pandas 加 jieba 加一个小型词库就够了。等数据量真正起来再上更完整的分析链路。实际做的时候可以先从词频统计开始词云虽然看起来不够“专业”但却是快速传达结论最直接的方式也适合给非技术的同事看。6.3 合规与边界最后聊两句边界问题。评论抓取虽然技术不复杂但使用上要守住几条线只抓公开可见的数据不碰需要绕过登录限制才能看到的内容控制采集频率不要对目标站点造成实质压力抓到的评论数据仅用于个人学习或合规范围内的分析不要用于商业牟利也不要传播涉及个人隐私的内容。做技术分享也一样重点是交流实现思路和踩坑经验而不是教人怎么无节制地采数据。在实际操作中我还有个小习惯脚本里永远保留一个全局开关能一键暂停所有请求。这个开关在调试反爬策略时帮了我大忙也让我在数据量异常飙升时能迅速停下来复盘而不是等服务端告警了才慌忙处理。做爬虫不是越快越猛越高明真正考验功力的是知道什么时候该停下来。