前段时间有个需求,要批量采集某个关键词相关的新闻数据做舆情分析。选了一圈数据源,最后落在新浪新闻搜索上。原因有几个:收录量大,搜索结果带时间戳和来源媒体字段,数据结构相对规整,适合做时间序列分析。另外返回的是服务端渲染的 HTML,不需要处理 JavaScript 动态加载,采集成本低。
这篇文章记录整个爬取过程的技术细节,包括请求构造、响应解析、反爬策略和代理池设计。
搜索接口分析
新浪新闻搜索的入口地址是https://search.sina.com.cn/。在浏览器中搜索一个关键词,通过 Chrome DevTools 抓包,可以看到结果是通过一个 GET 请求返回的:
GET https://search.sina.com.cn/?q=人工智能&c=news&sort=rel&range=all&num=10&page=1| 参数 | 含义 | 可选值 |
|---|---|---|
| q | 搜索关键词,需 URL 编码 | 任意字符串 |
| c | 频道分类 | news / video / blog 等 |
| sort | 排序方式 | rel(相关性)/ time(时间) |
| range | 时间范围 | all / 1d / 1w / 1m 等 |
| num | 每页条数 | 默认 10 |
| page | 页码 | 从 1 开始 |
新浪对User-Agent做了基本校验,缺失或为空会直接返回 403。Referer字段建议携带,模拟从搜索页面发起的正常浏览行为。Accept-Encoding设为gzip, deflate可以减少传输体积,requests库会自动处理解压。
请求封装
用requests.Session复用 TCP 连接,减少握手开销。在批量翻页场景下能明显减少请求耗时。配合HTTPAdapter设置重试策略,对 429 和 5xx 自动重试并指数退避,避免对目标服务器造成瞬时压力:
importrequestsfromrequests.adaptersimportHTTPAdapterfromurllib3.util.retryimportRetrydefcreate_session()->requests.Session:session=requests.Session()retry=Retry(total=3,backoff_factor=1,status_forcelist=[429,500,502,503],allowed_methods=["GET"])adapter=HTTPAdapter(max_retries=retry,pool_connections=10,pool_maxsize=10)session.mount("http://",adapter)session.mount("https://",adapter)returnsessiondefsearch_sina_news(session,keyword,page=1,sort="time",num=10,timeout=15):url="https://search.sina.com.cn/"params={"q":keyword,"c":"news","sort":sort,"range":"all","num":num,"page":page}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://search.sina.com.cn/"}resp=session.get(url,params=params,headers=headers,timeout=timeout)ifresp.encodingandresp.encoding.lower()=="iso-8859-1":resp.encoding=resp.apparent_encoding resp.encoding=resp.encodingor"utf-8"returnresp.text编码处理这段值得说明。requests在响应头没有声明charset时会默认用 ISO-8859-1,导致中文乱码。这里检测到这个默认值就回退到apparent_encoding(基于chardet的自动检测),再兜底用 UTF-8。新浪搜索结果页实际是 UTF-8,但这个防御性写法可以兼容后续可能遇到的非标准页面。
结果解析
分析返回的 HTML 结构,每条搜索结果包裹在<div class="box-result">中,包含标题链接、摘要和来源时间信息。用 CSS 选择器提取字段,同时做基本的数据清洗:
frombs4importBeautifulSoupfromdataclassesimportdataclass,asdictimportre@dataclassclassNewsItem:title:strurl:strsummary:strsource:strpub_time:strdefparse_results(html):soup=BeautifulSoup(html,"lxml")results=[]foriteminsoup.select(".box-result"):title_tag=item.select_one("h2 a")ifnottitle_tag:continuemeta=item.select_one(".fgray_time")meta_text=meta.get_text(strip=True)ifmetaelse""m=re.match(r"^(.*?)\s+(\d{4}[-/年]\d{1,2}[-/月]\d{1,2}日?.*)$",meta_text)source,pub_time=(m.group(1).strip(),m.group(2).strip())ifmelse(meta_text,"")summary_tag=item.select_one("p.content")results.append(NewsItem(title=title_tag.get_text(strip=True),url=title_tag.get("href",""),summary=summary_tag.get_text(strip=True)ifsummary_tagelse"",source=source,pub_time=pub_time))returnresults用dataclass定义数据模型比裸字典安全,字段类型在编码阶段就约束好,后续序列化到 CSV 或数据库不容易出问题。_parse_meta里的正则兼容了两种常见时间格式(2024-01-15和2024年01月15日),比简单字符串拆分健壮。
反爬策略与代理池
新浪的反爬分两个层级。第一层是基于 IP 的请求频率统计,同一 IP 短时间发送过多请求会触发限流,返回 403 或跳转验证码。第二层是行为检测,请求间隔过于均匀也会被标记为爬虫。对应策略是随机延迟加代理 IP 轮换。
纯延迟策略在采集量大时效率太低。100 个关键词每个 30 页,单 IP 延迟 3 秒要跑 2.5 小时。引入代理后多线程并发能压到 20 分钟以内。免费代理可用率通常低于 20%,建议用付费代理,关注代理类型和并发限制即可。
代理池的核心是健康检查和淘汰机制,不能拿到代理就直接用,需要在使用中记录成功率和响应时间:
importtimeimportrandomfromtypingimportOptional@dataclassclassProxyNode:address:strsuccess_count:int=0fail_count:int=0avg_latency:float=0.0@propertydefscore(self):total=self.success_count+self.fail_countiftotal==0:return0.5returnself.success_count/total*0.7+max(0,1-self.avg_latency/5.0)*0.3classProxyPool:def__init__(self,fetch_func,min_size=5):self._fetch=fetch_func self._min=min_size self._nodes=[]defrefresh(self):foraddrinself._fetch():ifnotany(n.address==addrforninself._nodes):self._nodes.append(ProxyNode(address=addr))defget_proxy(self):available=[nforninself._nodesifn.fail_count<3]iflen(available)<self._min:self.refresh()available=[nforninself._nodesifn.fail_count<3]ifnotavailable:returnNoneweights=[max(n.score,0.01)forninavailable]returnrandom.choices(available,weights=weights,k=1)[0]defreport(self,node,success,latency=0):ifsuccess:node.success_count+=1node.avg_latency=node.avg_latency*0.7+latency*0.3else:node.fail_count+=1ifnode.fail_count>=5:self._nodes.remove(node)按健康分加权选择代理,成功率高的优先调度,连续失败 5 次自动淘汰。avg_latency用滑动平均(系数 0.7)平滑波动,避免单次偶发慢响应拉偏评分。代理请求的 timeout 建议设短一点(10 秒左右),质量差的代理经常建立连接后不返回数据,timeout 太长会阻塞整个采集线程。
完整采集流程
把前面的模块组装起来,加上数据存储和去重:
importcsvimportloggingimporttimeimportrandomimportrequests logging.basicConfig(level=logging.INFO,format="%(asctime)s [%(levelname)s] %(message)s")logger=logging.getLogger(__name__)# 亿牛云隧道代理配置TUNNEL_HOST="proxy.16yun.cn"TUNNEL_PORT="3128"TUNNEL_USER="xxxxx"TUNNEL_PASS="xxxxx"defget_tunnel_proxies()->dict:"""构建亿牛云隧道代理,每次请求由隧道自动切换出口 IP"""proxy_url=f"http://{TUNNEL_USER}:{TUNNEL_PASS}@{TUNNEL_HOST}:{TUNNEL_PORT}"return{"http":proxy_url,"https":proxy_url}def_is_blocked(html:str)->bool:returnany(kinhtmlforkin["验证码","访问受限","暂时无法访问"])defsearch_with_tunnel(session:requests.Session,keyword:str,page:int,proxies:dict,max_retries:int=3)->str:"""通过亿牛云隧道代理请求搜索接口,被拦截时自动重试换 IP"""url="https://search.sina.com.cn/"params={"q":keyword,"c":"news","sort":"time","range":"all","num":10,"page":page}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://search.sina.com.cn/"}forattemptinrange(max_retries):try:resp=session.get(url,params=params,headers=headers,proxies=proxies,timeout=10)ifresp.encodingandresp.encoding.lower()=="iso-8859-1":resp.encoding=resp.apparent_encoding resp.encoding=resp.encodingor"utf-8"ifresp.status_code==200andnot_is_blocked(resp.text):returnresp.text logger.warning(f"page={page}attempt={attempt+1}被拦截或状态码异常")except(requests.Timeout,requests.ConnectionError)ase:logger.warning(f"page={page}attempt={attempt+1}请求失败:{e}")time.sleep(random.uniform(1.0,2.0))return""defcrawl_and_save(keyword,max_pages=30,use_proxy=True):session=create_session()proxies=get_tunnel_proxies()ifuse_proxyelseNoneresults,seen=[],set()forpageinrange(1,max_pages+1):ifuse_proxy:html=search_with_tunnel(session,keyword,page,proxies)else:html=search_sina_news(session,keyword,page=page)ifnothtmlor_is_blocked(html):logger.warning(f"page={page}触发反爬,跳过")continueitems=parse_results(html)ifnotitems:breaknew=[rforrinitemsifr.urlnotinseen]seen.update(r.urlforrinnew)results.extend(new)logger.info(f"page={page}new={len(new)}total={len(results)}")time.sleep(random.uniform(1.5,3.0))ifresults:withopen(f"sina_news_{keyword}.csv","w",newline="",encoding="utf-8-sig")asf:writer=csv.DictWriter(f,fieldnames=asdict(results[0]).keys())writer.writeheader()writer.writerows([asdict(r)forrinresults])returnresultsURL 去重用 `set` 存储已抓链接,新浪搜索结果偶尔跨页重复,尤其是按相关性排序时同一篇新闻可能出现在多个页码。CSV 用 `utf-8-sig` 编码(带 BOM),Windows 下 Excel 直接打开不乱码。 ## 踩过的坑 编码不一致。搜索页是 UTF-8,但跳转到详情页后编码可能是 GB2312 或 GBK,`<meta charset>` 和实际字节流偶尔对不上。不要信任单一来源的编码声明,用 `chardet.detect()` 检测字节流再解码。 搜索结果有页码上限。新浪新闻搜索最多返回约 760 条(76 页),超过后返回空页面。关键词命中量大时需要用 `range` 参数按时间分段查询再合并。 短效代理的生存周期。短效代理有效期通常 1 到 5 分钟,拉取后必须尽快消费。池子不要囤太多,快用完时再拉下一批,配合实际消费速度。 摘要字段是截断的。搜索页返回的 `content` 是正文前 100 到 200 字,不是完整正文。需要全文要额外请求详情页,详情页反爬比搜索页严格。 ## 最后 新浪新闻搜索采集整体难度不高。请求是标准 GET 参数拼接,响应是服务端渲染 HTML,不需要 Selenium 或 Playwright 这类浏览器自动化工具。主要工程挑战在反爬应对:频率控制、代理池管理、健康检查和淘汰策略。这些模块设计好了,换目标站点只需改请求参数和解析逻辑,底层框架可以复用。 单线程顺序采集对多数舆情分析场景够用。需要提速的话用 `concurrent.futures.ThreadPoolExecutor`,线程数控制在 5 到 10 个,配合代理池的并发限制。不建议开太多线程,一方面代理有并发上限,另一方面对目标站点压力过大可能触发更严格的封禁策略。 代码里用 `logging` 替代 `print` 输出日志,带时间戳和级别标签,方便后续接入日志收集系统。如果采集任务需要长期运行,建议加上异常捕获和断点续采逻辑:把已完成的页码持久化到本地文件,程序中断后从断点恢复,避免重复采集。