ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

百度新闻评论抓取实战:从接口分析到数据清洗

百度新闻评论抓取实战:从接口分析到数据清洗 我最近在做一个热点舆论分析时发现百度新闻评论区是一个被很多人低估的信息富矿。相比新闻通稿和自媒体文章评论区里用户的真实语气、情绪倾向、争论焦点往往更能反映一件事在社会层面的接受度和争议点。基于这个需求我花了两个晚上做了一个“百度新闻评论内容抓取”的小工具把从定位接口、解析数据、处理风控再到落地存储的完整链路都走了一遍。这篇文章就围绕这条链路展开把我在实际踩坑中总结出的经验、代码和排错思路都写出来给同样想采集新闻评论做分析的朋友做一个可复现的参考。1. 盯上评论区的原因一条评论就是一个观点样本1.1 评论数据里藏着哪些可量化的维度新闻评论的价值不是“有人说了什么”这么简单。把它拆开看每一条完整评论都携带了好几个可量化维度内容文本用户说了什么表达了什么态度提及了哪些关键词。用户昵称与ID可以粗略判断评论者类型是普通路人还是高频活跃用户。发布时间评论集中在新闻发布的哪个时段能反映话题的即时热度。点赞数与回复数代表这条评论被多少人有共鸣是衡量观点传播力的直接指标。评论层级是顶层评论还是楼中楼回复能看出讨论的互动深度。这几个维度组合起来能做的事情很多。比如按时间维度统计评论量变化可以画出“新闻发布后热度衰减曲线”按内容做关键词聚合能看出用户最关心哪个切入点按点赞数排列能找到这个新闻事件里最有代表性的公众意见。我当时的需求就是做一个轻量级的舆情观察工具把目标新闻下的评论全部拉下来统计正负面情绪分布和高频词所以评论抓取是第一步也是最关键的一步。1.2 手动采集的痛点和自动化抓取的收益没有写过采集脚本之前手动采集评论大概是这样一条条复制评论内容再手动记下昵称、时间和点赞数。听起来不难但碰到三种情况就会崩溃第一评论量大的新闻动辄几百上千条还要反复滚动页面触发“加载更多”复制粘贴几个小时是常态。第二复制过程中经常漏行、重复、夹杂广告文字后期清洗成本非常高。第三无法稳定复现一旦页面翻过头就要重来而且手动方式几乎做不了按时间切片或者按楼层统计这类批量操作。自动化抓取解决的就是这三个痛点速度上几十秒能把全部评论拉完质量上结构化输出字段不会错乱扩展性上换个新闻ID就能复用同一套代码。做完这套工具之后我最大的感受是爬虫的价值不只是“替代手工”而是把过去根本不现实的数据采集方案变成随手可做的事。单篇评论分析可能不值得写程序但一旦变成常态化的信息监控需求自动化的收益就是数量级的。2. 先别急着写代码定位评论真实来源的抓包流程2.1 判断页面是服务端渲染还是异步加载拿到一个新闻页面第一件事不是打开编辑器写爬虫而是先搞清楚评论数据是怎么出现在页面上的。这是我做了好几次爬虫后养成的习惯。打开新闻详情页后在浏览器里直接右键“查看网页源代码”按CtrlF搜索评论区里某一条特有内容。如果能在HTML源码里搜到这条评论的文本说明评论是服务端渲染的直接解析HTML就可以如果搜不到那评论就是通过JavaScript异步请求加载的真正的数据躲在XHR接口里需要单独找出来。百度新闻这个场景绝大多数情况下是异步加载。评论区渲染时页面主体已经加载完成评论数据是在页面加载后通过浏览器发出额外请求拿回来的。所以抓包这一步无法跳过直接解析初始HTML只能拿到“评论区域待加载”的占位节点没有实际内容。2.2 用Network面板揪出真正的评论接口定位异步接口的操作路径很固定。我用Chrome开发者工具演示打开目标新闻页面按F12进入开发者工具切到“Network”面板勾选上“XHR”和“JS”筛选条件。然后回到页面上操作评论区——点击“加载更多”或者切换到“全部评论”。这时候观察Network面板会不断冒出新的请求。找名字里带“comment”“reply”“list”关键词的请求逐个点开看Preview面板如果返回的JSON里能看到评论内容、用户名这些字段就说明找对了。我实际抓包时遇到的情况是评论接口不是一个而是多个。第一层是获取评论列表的接口第二层是获取“楼中楼”回复的接口还有一个是发表评论的接口。我们只需要关注第一个最多再带上第二个发表评论接口跟我们无关别去碰减少不必要的风险。记录下这个关键请求的几条信息完整的请求URL、请求方法、Query参数、请求头中携带的核心Header。这些信息是后面构造程序请求的基础。2.3 接口返回格式解析与参数识别定位到接口后下一步是分析返回的数据结构。常见的返回格式有两种一种是纯JSON直接就是{data: {comments: []}}这样的嵌套结构。另一种是JSONP返回的不是纯JSON而是被一个回调函数包裹的数据形如callback_xxx({...})。百度系的接口出于历史原因很多都保留着JSONP协议ResponseType是JavaScript如果不处理这层包裹直接json.loads必然报错。处理JSONP的方法很成熟用正则把最外层的函数名(和结尾的)剥掉剩下的就是标准JSONimport re import json text cb123({data: {comments: []}}); match re.search(r^[^(]*\((.*)\)[;]?\s*$, text, re.S) if match: data json.loads(match.group(1))注意正则里re.S不能丢因为JSON里可能包含换行不开启DOTALL模式会截断匹配。接口的Query参数也要逐个确认含义。归纳下来通常包含这样几个关键参数参数名含义news_id / article_id新闻唯一标识page / offset页码或偏移量size / page_size每页条数cb / callbackJSONP回调函数名可随机timestamp时间戳有时参与签名确认这些参数后再逐个测试修改page参数看返回的是不是下一页评论修改size看能不能扩大单页条数。这一步的产出是一份“接口参数对照表”后面写代码就是按这个表填参。3. 环境准备与请求伪装让服务器认为你是个正常人3.1 工具链选择为什么用Python三件套做这类场景的采集我一般直接用Python的requests加标准库不会一上来就上Scrapy或者Selenium。原因很直接第一需求规模不大一个脚本就能跑完没有分布式调度需求第二评论接口已经拿到了是纯JSON接口不需要渲染JavaScript第三依赖越少折腾环境的时间越少。核心依赖就这么几个requests发HTTP请求。json标准库解析返回数据。re标准库处理JSONP包裹。timerandom控制抓取节奏。只有在接口完全调不通、需要模拟真实浏览器行为的时候才考虑引入Playwright或者Selenium。这两种工具能做完整的浏览器仿真但开启成本高、内存占用大、容易被页面性能拖慢能走接口就走接口。3.2 请求头完整构造与Cookie的必要性接口找到了直接拿requests.get去请求大概率会得到一个空结果或者错误页。原因在于服务器会校验请求是不是来自真实浏览器。首先是User-Agent。Python默认的UA是python-requests/x.x.x特征太明显必须替换成真实浏览器的UA字符串。我习惯在Chrome里访问about:version然后复制整段UA。其次是Referer。评论接口通常只允许来自新闻详情页的请求Header里的Referer应该填你正在采集的那个新闻页面URL缺少或造假都会被拦截。还有Cookie。这里要分两个层面理解第一百度很多页面要求先有一个基础Cookie才允许访问接口这个Cookie在浏览器首次访问时由服务器下发用Session就能自动获取。第二部分评论内容只有当用户处于登录状态时才显示全。最典型的就是“只看楼主”或某些需要登录可见的折叠评论。这种情况下需要把浏览器的登录态Cookie手动复制到脚本里。获取方式是在浏览器登录百度账号打开评论区然后从Network面板里正在发出的评论请求的Request Header里找到Cookie字段整段复制出来。复制Cookie这个动作本身很简单但它有明确的合规边界——只用来获取登录后仍然公开可见的评论数据而不是去调用登录用户才能操作的接口。3.3 频率控制策略频率控制是我做爬虫以来认为最容易被新手忽略的环节。很多新手把请求频率拉满结果就是触发风控然后教程里说的“稳定方案”全部失效。我的策略非常简单单次请求之间用random.uniform(2, 5)做随机延时既不是固定值也不是连续快速请求。抓完一篇新闻之后额外停留5到10秒再进行下一篇。批量任务量大的话每抓50篇新闻暂停1分钟。从服务器角度看一个正常人不可能每秒钟都在刷新评论接口随机延时配合合理的暂停才是一个“正常用户”的行为模型。不要觉得慢——对绝大多数分析场景来说数据的完整性比抓取速度重要得多。4. 核心抓取实现从单条新闻到批量采集4.1 单条新闻评论抓取代码先写一个单条新闻的评论抓取函数这是整个工具的地基。下面是我基于抓包结果整理出的通用模板接口URL和参数名以实际抓包为准但思路是通的。import requests import re import json import time import random HEADERS { User-Agent: ( Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36 ), Referer: https://baijiahao.baidu.com/, Accept: application/json, text/javascript, */*; q0.01, } def parse_jsonp(text): 去掉JSONP外层回调包裹返回dict。 if text.strip().startswith({): return json.loads(text) match re.search(r^[^(]*\((.*)\)[;]?\s*$, text, re.S) if match: return json.loads(match.group(1)) raise ValueError(无法解析响应内容) def fetch_comment_page(news_id, page, size50): 获取某一页评论。 url https://comment.baidu.com/.../list # 以实际抓包为准 params { news_id: news_id, page: page, size: size, cb: cb str(random.randint(1000, 9999)), } resp requests.get(url, paramsparams, headersHEADERS, timeout10) resp.raise_for_status() return parse_jsonp(resp.text)这段代码里有几个细节值得展开说明。parse_jsonp先判断响应体是否以{开头这是为了兼容纯JSON和JSONP两种返回格式实测很好用。请求参数里的cb用随机数生成是因为有些JSONP接口会校验回调名固定值容易被识别为脚本请求。拿到JSON数据后还要做一层字段提取。不同接口的字段结构不一样但基本逃不开这样的路径顶层data节点下面评论数组要么叫comments要么叫list要么叫items。我习惯在解析时先打印出整个JSON的结构确认字段路径后再写提取逻辑避免凭猜测写错。def extract_comments(data): 从接口返回中提取评论列表。 # 先打印结构确认字段路径 # print(json.dumps(data, ensure_asciiFalse, indent2)[:3000]) comments [] node data.get(data) or data raw_list node.get(comments) or node.get(list) or node.get(items) or [] for item in raw_list: comments.append({ nickname: item.get(user, {}).get(name, ), content: item.get(content, ).strip(), create_time: item.get(create_time), like_num: item.get(like_num) or item.get(vote_count) or 0, comment_id: item.get(comment_id) or item.get(id), }) return comments提取字段时一定要做空值兜底。.get(user, {}).get(name, )这种链式写法就是为了防止某个字段缺失导致整条解析崩溃。评论区数据脏是常态代码要尽量健壮。4.2 翻页与“加载更多”的处理翻页是整个抓取中最容易出现理解偏差的环节。评论接口的翻页有两种常见模式必须区分对待。第一种是页码模式参数是page和sizepage从0或1开始递增每页返回固定条数。这种最简单判断退出条件是返回的评论列表为空或者has_more字段为false。第二种是游标模式参数不是页码而是last_id或者cursor值是上一条评论的ID。这种模式下第一页请求不需要传游标拿到数据后从返回结果中提取最后一个评论的ID作为下一页的游标继续请求。循环退出条件是返回的评论条数小于每页条数。考虑到很多百度新闻评论接口实际采用游标模式我把两种都封装一下用逻辑判断来兼容def fetch_all_comments(news_id, max_pages500): all_comments [] cursor None for page in range(max_pages): params {news_id: news_id, size: 50} if cursor: params[last_id] cursor else: params[page] page data fetch_comment_page(news_id, params) page_comments extract_comments(data) if not page_comments: break all_comments.extend(page_comments) # 游标更新 cursor page_comments[-1][comment_id] time.sleep(random.uniform(2, 5)) return all_comments这段逻辑对页码模式和游标模式做了兼容第一页两个参数都传后续页优先用last_id。实测中这种兼容写法能覆盖绝大多数接口差异不需要为了每种接口单独维护一套代码。4.3 多新闻批量遍历与断点续抓单篇新闻的抓取打通后批量采集就是体力活了。我维护一个新闻链接清单每一行是一个新闻页面的ID循环处理def batch_crawl(news_ids, output_filecomments.jsonl): done_ids load_done_ids(output_file) for news_id in news_ids: if news_id in done_ids: print(f跳过已完成: {news_id}) continue try: comments fetch_all_comments(news_id) save_comments_append(news_id, comments, output_file) time.sleep(random.uniform(5, 10)) except Exception as e: print(f[失败] {news_id}: {e}) continue这里有一个我强烈建议养成的习惯断点续抓。把已完成的新闻ID记录下来下次运行脚本时先读取这个集合已经抓过的直接跳过。因为批量任务一旦跑到一半被风控或网络问题打断没有断点续抓就意味着从头再来这个损失很多时候比想象中的大。输出格式我用的是jsonl每行一条JSON记录。相比把所有数据堆在一个大JSON文件里jsonl的优势是追加写入方便、中途断电不容易损坏、后续可以一行一行流式读取对下游处理很友好。5. 实测中必定会踩的坑风控、字段缺失、评论数量对不上5.1 收到“百度安全验证”页面触发风控的第一个信号第一次跑通脚本后我满怀期待地执行批量抓取结果几分钟后返回的响应不再是JSON而是一段夹杂着“百度安全验证”字样的HTML页面。这个现象要理解它的成因。百度系的站点对异常请求有统一的风控策略当服务器判定当前请求行为“不像真人”时就会返回验证页面而不是正常数据。回看当时的请求日志问题基本出在三个地方请求频率过高单次延时小于1秒持续高频请求。User-Agent异常代码里遗漏了UA的设置用了Python默认值。Cookie缺失没有携带浏览器的基础Cookie。我的排查顺序是先停掉脚本等5到10分钟让临时风控解除然后逐项检查Header构造。把UA换掉、Referer补上、Cookie带上、延时调大之后重新请求就恢复正常了。这里有一个原则做爬虫的应该都认同触发风控后不要试图硬刚不要尝试各种绕过手段正确做法是主动降频、回归正常用户行为。风控的目的就是筛选异常流量你越像一个正常用户就越不会被挡在门外。5.2 状态码403和418从报错特征反推原因除了验证码页面另一种常见表现是HTTP状态码异常。403 Forbidden通常说明服务器明确拒绝了请求常见原因是请求头缺失尤其是Referer校验不过或者当前网络出口被临时拉黑。418 Im a teapot在百度系站点上这个状态码通常意味着风控已经识别出当前请求异常直接返回一个反爬标记状态码。遇到这两种状态码我的排查流程很固定先用浏览器的真实请求头原样跑一次如果浏览器能通而脚本不通逐项对比Header差异很快就能定位到是哪个字段没带上。如果浏览器也打不开同样的接口说明是当前网络环境被限制了这时候不要继续换出口IP去试而是等一段时间再做通常在几小时后会自动恢复。这条经验的核心是先把“接口本身问题”和“自己代码问题”区分开。不要一看到报错就往反爬方向想很可能只是请求头少了一个字段。5.3 评论数量对不上被时间戳和可见性坑了第三类问题最隐蔽接口正常返回抓完的评论数量却和页面上看到的不一致。我总结了两种常见原因。第一种是评论排序问题。页面上默认展示的是“热门评论”接口也默认返回热门排序而“最新评论”需要额外的排序参数。如果你要的是全部评论必须确认接口参数中排序字段的取值否则默认拉下来的永远只是最热门的一小部分。第二种是时间戳字段混用。接口里create_time有的返回秒级时间戳有的返回毫秒级时间戳。存储时如果不统一后续分析排序会错乱。我统一在采集阶段就把时间戳转成标准时间格式规则是大于10的12次方的按毫秒处理除以1000再转成datetime。from datetime import datetime def normalize_time(ts): if not ts: return None ts int(ts) if ts 10 ** 12: # 毫秒时间戳 ts ts / 1000 return datetime.fromtimestamp(ts).strftime(%Y-%m-%d %H:%M:%S)这类问题最需要警惕的地方在于它不会让程序报错所有的数据都“看起来正常”但统计出来的数字就是和真实情况对不上。排查的唯一方法是人工抽几条评论跟页面核对所以我把“抽样核对”写进了每次采集后的固定动作里。6. 数据清洗与落地存储半成品到能用只差这一步6.1 去重与噪音过滤抓回来的原始评论数据距离“能用的数据集”还有一段距离。直接拿来分析会碰到几类噪音重复评论同一用户对同一条新闻重复发表相同内容或者接口分页导致边缘重复。无意义内容纯表情、纯数字、单字“顶”“赞”、系统自动生成的推荐话术。HTML实体残留amp;、lt;这一类转义字符。多余空白符连续换行、空格混入。去重的逻辑我使用news_id comment_id组合作为唯一键因为评论ID本身是接口返回的全局唯一标识比用“用户内容时间”拼出来的组合可靠得多。采集阶段就维护一个set新评论进来先判断ID是否已存在存在就直接丢弃。清洗方面用一个简单的函数处理掉HTML实体和空白符import html import re def clean_content(raw): if not raw: return text html.unescape(raw) # amp; - text re.sub(r[^], , text) # 去掉残留标签 text re.sub(r\s, , text).strip() # 合并多余空白 return text需要注意的是html.unescape放在正则去标签之前执行避免标签里的实体被转义之后干扰正则匹配。这个顺序问题我一开始没注意导致部分评论清洗后带着乱码后来调整顺序才解决。6.2 存储方案选择数据量不大的时候用什么存储差别不大但要有长期维护的打算。我的建议是按数据量分三档千条以内直接存CSVExcel就能打开方便快速预览。万到十万条SQLite单文件、零配置、支持SQL查询。十万条以上MySQL或PostgreSQL适合多端共享和复杂查询。我日常用的最多的是SQLite。建表语句很简单CREATE TABLE IF NOT EXISTS comments ( id INTEGER PRIMARY KEY AUTOINCREMENT, news_id TEXT NOT NULL, comment_id TEXT UNIQUE, nickname TEXT, content TEXT, create_time TEXT, like_num INTEGER, cleaned_content TEXT );comment_id加唯一约束配合上一步的去重逻辑双保险。清洗后的文本单独放一列保留原始数据方便以后如果有新的清洗逻辑可以重新生成不用回去重新抓取。6.3 简单的热度分析和情感倾向判断数据落库以后顺手做几个统计分析就能把“采集工具”升级成“分析工具”。最直观的是按时间维度统计评论量。把create_time按小时分组看评论量随时间的变化曲线能清楚看到新闻发布后第几个小时达到讨论峰值。做法很简单正常SQL分组即可SELECT substr(create_time, 1, 13) AS hour, COUNT(*) FROM comments GROUP BY hour ORDER BY hour;第二个常用分析是高频词提取。把cleaned_content分词后统计词频能看到评论区讨论的焦点。这里不需要引入复杂框架对于几千条评论用jieba分词加Counter统计就足够了。第三个是情感倾向的粗判。最简单的做法是维护一个正负面关键词表统计每条评论中包含的正负面词数量差值大于0归为正面小于0归为负面。这种做法精度有限但作为粗粒度观察完全够用。如果要做更细的情感分析可以再接入现成的开源模型但那是另一个工程了。7. 抓取这件事的边界合规使用与可持续采集7.1 先看robots.txt再谈抓取做任何站点的数据采集第一步不应该是写代码而是看目标站点的robots.txt。访问https://www.baidu.com/robots.txt能看到百度对哪些路径允许爬取、哪些路径禁止爬取。尽管robots.txt在技术上没有强制力但它表达了站点的抓取意愿遵守它是从业者的基本素养。实际抓取中还要区分两个层面公开数据和私有数据。新闻评论区属于用户生成内容但公开可见的部分在绝大多数场景下是可以做个人学习和研究的。越过登录、绕过权限去拿非公开数据这就越界了。我的原则是只采集当前登录状态下同样可见的公开评论不做任何越权操作。7.2 频率和量级的自我约束很多爬虫问题不是技术问题而是量级问题。单机、低频率、小规模抓取和学习研究风险很小但如果无限放大并发、搞分布式、对目标服务器持续发起高负载请求性质就完全不同了。我给自己定的默认规则并发数不超过1-2个。单请求间隔最低2秒。单次任务总量控制在一个合理范围内不在短时间内重复跑同一批新闻。抓下来的数据不公开、不传播、不用于任何商业用途。这些约束不是限制而是保护。采集频率控制在对方看来是一个正常用户的行为区间整个过程会更平稳数据质量也更有保障。7.3 从采集到监控这套工具的下一步扩展单篇新闻的评论抓取打通之后这个工具自然会长出新的能力。最简单的一层是“定时重跑”和“增量采集”。把脚本部署在服务器上每隔一段时间重新抓取目标新闻对比新增评论数量就能做成一个“热点新闻讨论增长监控”一旦评论量在短时间内暴涨说明话题正在发酵。再往下走是把评论数据和分析结果通过图表展示出来比如做一个简单的趋势面板让用户选择某条新闻后直接看到评论时间分布、情感占比、高频词云。这些都建立在稳定的抓取基础上。从我个人角度看新闻评论抓取这个场景最大的难点不是代码而是坚持“克制地采集”。数据量不是越大越好频率不是越快越好把基本功做扎实把节奏控制好这个小工具才能长期稳定地为你提供有价值的数据。
返回列表