☰
百度指数爬虫进阶:突破签名加密,实现长期稳定数据采集
2026/10/14 8:14:05 网站建设 项目流程

简介:面向爬虫开发者与数据采集需求者的百度指数历史数据爬虫进阶源码包,针对百度指数接口调用、登录验证、逆向解密等核心痛点,提供可直接运行的Python源码实现。该源码包共7个文件,包含4个Python脚本、1个HTML页面和若干配置与忽略规则文件,压缩包仅18KB,结构紧凑。已有128人学习下载,适合对爬虫加密机制有一定兴趣、希望突破反爬限制的开发者。通过源码可掌握登录态维持、参数解析、解密密钥提取、加密数据清洗等关键环节;项目内对登录步骤和接口参数做了明确划分,便于对照调试。源码中体现了爬虫时效问题的处理思路,例如合理调整请求频率、利用开源组件优化开发流程;整体目录组织清晰,可直接用于个人学习或改造为商用数据采集工具。

1. 百度指数爬虫进阶:从“能请求通”到“能稳定入库”,卡点到底在哪

百度指数爬虫进阶,真正难的不是爬虫本身,而是那些藏在接口背后的签名参数和加密返回。拿一个做竞品分析的运营朋友举例:他需要某行业二十个关键词近一年的逐日指数,手工在网页上导出只能按周拿,数据还不连续;换程序员写脚本,第一步就直接请求数据接口,结果返回的是一段看不懂的乱码,底部还有一串长度固定的签名值。下面就把这条链路拆开讲:接口从哪里定位、签名怎么过、返回怎么解、频率怎么控,目标是把“能出一个数”升级成“能长期稳定出数”。适合已经写过基础爬虫、想处理动态数据和反爬场景的开发者。

2. 先顺着数据流摸底:抓包定位接口与必要请求参数

2.1 在控制台看XHR:先分清页面渲染和真正的数据源

百度指数页面本身是一个前端工程,折线图是JavaScript动态画出来的。直接在网页里右键查看源码,看不到任何趋势数据;真正有用的内容在异步请求里。打开控制台的Network面板,勾选Fetch/XHR,然后切换一个关键词的时间范围,会看到一个路径里带SearchApi的请求,这才是数据源头。

import requests session = requests.Session() headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36", "Referer": "https://index.baidu.com/", } # 第一步:先访问入口页面,让session带上基础Cookie session.get("https://index.baidu.com/", headers=headers) # 第二步:请求趋势数据接口,参数以Network里抓到的为准 api = "https://index.baidu.com/api/SearchApi/index" params = { "area": 0, # 0 表示全国范围 "word": "某关键词", # 目标关键词 "startDate": "2024-01-01", "endDate": "2024-06-30", } resp = session.get(api, params=params, headers=headers) print(resp.status_code) print(resp.text[:600])

这段代码做的事情很简单:先访问首页拿基础Cookie,再用相同会话请求数据接口。requests默认不会带上浏览器里那一堆请求头,但在这类接口上,UA和Referer两个字段足够应付大部分情况。resp.status_code是200只说明请求到了服务端,不代表数据正常;真正的判断要看响应体里data字段是否完整,很多情况下返回的是一段加密字符串。

2.2 接口返回里的“加密串”长什么样,别被一坨乱码劝退

如果响应正常,你会看到类似下面的结构:一个JSON,里面data.userSearchResults是一个数组,数组的每个元素对应一个关键词。每个元素里有一个很长的字符串字段,有些接口版本叫result,有些版本直接放在data里。这个字符串不是普通的JSON,开头看起来像被URL编码过,还有大量数字和字母混合。

// 在浏览器Console里把Network里复制的Response贴进来 const resp = { data: { userSearchResults: [{ result: "..." }] } }; const raw = resp.data.userSearchResults[0].result; // 先看前200个字符,判断它是URL编码还是纯Base64 console.log(decodeURIComponent(raw).slice(0, 200));

这段代码是为了确认加密串的处理方式。经验是:先别急着写解密逻辑,多抓几个关键词、几个不同时间段的响应,对比字符串长度和特征。如果长度稳定且字符集带着“%”或“+”,说明外面有一层URL编码;如果直接是一串带“=”结尾的文本,则是Base64。判断错了,后面解密再怎么调都会错位。

2.3 请求头与Cookie清单:哪些必须带,哪些带错反而触发风控

请求头/参数是否必须作用与踩坑
Referer必须必须指向index.baidu.com域名下的页面,缺失会直接拒绝
User-Agent必须建议用一个较新的浏览器UA,老UA容易进入验证码流程
Cookie必须需要至少一个登录态的Cookie,未登录时很多返回是空数组
Accept可选默认即可,不需要完全复刻浏览器
自定义签名参数看接口版本新版接口会校验签名相关的请求参数,写死会失效

Cookie是所有字段里最容易出问题的。未登录状态访问会拿到一个匿名Cookie,但接口返回的数据时常是空数组;登录态的Cookie有效期通常不长,长任务做到一半Cookie失效就会出现“前一百个词正常,后面全是空”。所以正式跑批量任务前,先把Cookie有效性检测函数写好——用一个已知趋势稳定的词请求一次,能解析出非空数值就算有效。

3. 拆掉签名这道门槛:从定位JS函数到最小可用签名实现

3.1 在Sources里定位签名函数:少走三个弯路

签名参数一般不是直接写在请求体里的明文,而是页面加载时某个JavaScript函数计算出来的。不要在Python里猜算法,也不要在Sources里一眼一眼翻文件。常见做法是打开Sources面板,在代码搜索框里输入接口路径关键词,比如SearchApi或api/index,找到发起请求的代码位置,然后在它调用XMLHttpRequest.send的上一行下断点。

// 在Console里执行这段Hook,能打印出所有接口请求的URL const originalOpen = XMLHttpRequest.prototype.open; XMLHttpRequest.prototype.open = function (method, url, ...rest) { this.__requestUrl = url; return originalOpen.call(this, method, url, ...rest); }; const originalSend = XMLHttpRequest.prototype.send; XMLHttpRequest.prototype.send = function (...args) { console.log("请求URL:", this.__requestUrl); console.log("请求体:", args[0]); return originalSend.apply(this, args); };

这个Hook的作用是拦截浏览器所有XHR请求,在控制台打印URL和请求体。与直接在Network面板里看相比,它的优势是可以结合Sources里的调用栈,看到请求体在哪个函数里被拼出来,顺着调用栈向上走,就能找到计算签名的那一行。走过两轮之后你会发现,签名函数的命名往往不是sign这种直白名字,更多是某个缩写或者混淆变量,所以断点比单纯搜索关键词更可靠。

3.2 用Node直接跑原版JS:不做逐行翻译,把黑匣子留着

找到签名函数之后,最稳的方法不是把它翻译成Python,而是把整个文件保存到本地,用Node虚拟机跑起来。原因很简单:签名算法通常混着位运算、字符串拼接、甚至是常量表,逐行翻译很容易在某个边界条件上翻车,比如0和空字符串的差异、字符编码不一致。直接跑原版JavaScript,输入参数、拿到输出,整个过程对Python来说就是一个黑匣子。

const fs = require("fs"); const vm = require("vm"); // 从Sources里保存下来的原版JS,内容可能是自执行函数或函数声明 const src = fs.readFileSync("./raw_sign.js", "utf-8"); const sandbox = { window: {}, document: { cookie: "" }, navigator: {}, location: { href: "https://index.baidu.com/" }, }; // 把原版JS的导出挂到全局,具体函数名以你定位到的为准 const wrapped = src + `\n; globalThis.getSign = getSign;`; vm.createContext(sandbox); vm.runInContext(wrapped, sandbox); // 按调用栈抓到的参数顺序调用 const sign = sandbox.getSign({ word: "某关键词", startDate: "2024-01-01", endDate: "2024-06-30", }); console.log("签名结果:", sign);

需要注意几个点。第一,vm.createContext创建的沙箱里没有浏览器对象,脚本运行到window或document时会报错,报错缺什么就在sandbox里补什么。第二,不要试图把所有对象都补全,很多脚本只会在运行到某一行时才访问某个属性,补成空对象就够用。第三,把导出语句追加在文件末尾时,要先确认函数名是全局声明还是模块内私有,私有情况下要改原文件最后一行,把内部函数暴露出来。

3.3 签名为什么不能复用:参数绑定与单会话机制

签名并不是一个固定字符串,而是与关键词、时间范围、会话标识绑定在一起的。服务端收到请求后,会用同样的算法把参数重新计算一遍并比对,参数变了签名必然失效。这就是很多人“昨天还好好的,今天换了个词就失败”的原因——写死了签名值。

# 演示签名不可复用的原因,不是真实算法 def make_sign(word: str, start: str, end: str, uniqid: str) -> str: payload = f"{word}|{start}|{end}|{uniqid}" # 真实实现是在JS里做的,这里只表达“参数参与签名”这个事实 return hex(hash(payload))

所以爬虫的设计上,每次请求前都应该重新调用Node计算出新的签名。批量任务里还要注意:如果会话标识uniqid是从页面初始数据里取的,那么长时间任务最好定期重新访问首页刷新它,否则跑一段时间后接口会返回“签名过期”或干脆返回空。

4. 解密与数据还原:把接口乱码变成可入库的结构化序列

4.1 解密不是玄学:URL解码、Base64与偏移量三步走

把加密串还原成可读字符串,常见套路是三步。第一步做URL解码,把百分号开头的字节还原;第二步做Base64解码,补足等号再解;第三步按字符偏移量映射回真实字符。这个偏移量是跟接口版本绑定的一个整数,怎么确定它?拿一个关键词的真实响应,配合页面显示的数值,用不同偏移量试,能对得上当前版本的就是正确值。

import base64 from urllib.parse import unquote def decode_index_result(raw: str, offset: int) -> str: # 第一步:URL解码,去除百分号编码 unquoted = unquote(raw) # 第二步:Base64解码,长度不是4的倍数时补等号 padded = unquoted + "=" * (-len(unquoted) % 4) decoded = base64.b64decode(padded).decode("utf-8", errors="ignore") # 第三步:逐字符偏移映射 chars = [] for ch in decoded: chars.append(chr(ord(ch) - offset)) return "".join(chars)

偏移量offset是一个整数。如果解密后出现大量不可读符号,说明offset偏了;出现负数更是明显信号。把offset作为参数而不是常量写死,目的是未来接口更新时,只需要改一个配置值就能恢复。

4.2 用一段Python把趋势数组组装成DataFrame

解出来的字符串是一段JSON,结构里通常有一个data数组,每个元素里放着该关键词的trend字段。trend字段里以日期为维度存了一串数值。不同接口版本结构不一样,有的日期是对象key,有的是数组下标,写代码时要做兼容。

import json import pandas as pd def parse_trend(decrypted: str, keyword: str) -> pd.DataFrame: obj = json.loads(decrypted) records = [] # 部分接口版本把结果放在 data 下,部分放在 data 的 list 里 for series in obj.get("data", []): trend = series.get("trend", {}) # trend 可能是 {"日期": [值序列]},也可能是 [[日期, 值]] if isinstance(trend, dict): for date, values in trend.items(): if isinstance(values, (list, tuple)): records.append({ "keyword": keyword, "date": date, "total": values[0], }) else: records.append({"keyword": keyword, "date": date, "total": values}) else: for row in trend: records.append({"keyword": keyword, "date": row[0], "total": row[1]}) df = pd.DataFrame(records) return df # 使用示例 decrypted = decode_index_result(raw_result, offset=23) # 示例值,以实际抓包为准 df = parse_trend(decrypted, "某关键词") print(df.head())

这里有一个常见误用:有些人直接把trend里第一个值当作整体指数,但实际里面可能同时存了整体、PC端、移动端三个序列。入库时最好把字段名区分开,不要混在一个total列里。另一个容易踩的点是日期格式,接口里可能是“2024-01-01”也可能是“20240101”,统一转成标准日期字符串再入库。

4.3 先搞懂指数口径:为什么同一个词不同时间跨度数值对不上

百度指数本身是一个相对值,不是搜索次数。在同一个词下,查询一个月和查询一年,同一个日期的数值很可能不一样,因为指数在展示上会做平滑和均值处理。直观点说,时间跨度变了,算法权重组合也会变。

查询跨度常见现象使用建议
近7天波动明显,适合看短期热点不适合做长期对比
近90天数值相对稳定适合月度分析
一年及以上跨年数据,部分接口返回空分段查询再拼接

所以爬回来的数据,入库时要存原始密文和解密后的结构化数据双份。原始密文用于以后用新算法重新计算;结构化数据用于日常分析。只存一边,未来接口版本升级或者你发现offset取错了,就要重新爬一遍。

5. 百度指数爬虫进阶的避坑清单:风控、频率与数据一致性

5.1 同IP高频请求触发验证码:现象、原因与退避策略

现象:批量任务跑到一半,接口开始返回一段HTML或者一个包含验证码标识的JSON,再往后新请求全部被拒。 原因:短时间内在同一个IP上请求次数过多,触发了服务端的风控阈值。这个阈值没有一个公开数字,不同账号状态也不一样,所以看起来有点玄学。 解决:在请求循环里加限速和退避。出问题时不要立刻重试,让出充足间隔。

import time import random def request_with_backoff(fn, max_retries=4): for attempt in range(1, max_retries + 1): try: resp = fn() if resp.status_code == 200 and "验证码" not in resp.text: return resp raise RuntimeError("响应异常") except Exception as e: wait = 2 ** attempt + random.uniform(0, 1) print(f"第{attempt}次失败:{e},等待{wait:.2f}s") time.sleep(wait) return None

指数退避的间隔分别是约2秒、4秒、8秒、16秒,再加上随机抖动,避免多个线程同时重试造成二次风暴。这里最容易被忽略的是日志:一定要记录每次请求的耗时和失败原因,否则策略调了也不知道有没有效果。

5.2 解密出负数和错位:多半是偏移量取错了

现象:用某个offset解密后,数值整体出现大段负数,或者日期和数值错位。 原因:偏移量取错,或者Base64标准选错。较新的接口偶尔会用URL-safe的Base64,里面的字符集和标准Base64不同。 解决:选一个你完全确定数值的词和时间段,把页面显示值作为基准。然后用不同offset试,直到完全对齐。对齐后再用第二个词交叉验证一次,防止第一个词正好落在某个巧合区间。

5.3 跨年时间窗口返回空数组:分段查询才是正解

现象:startDate是去年12月、endDate是今年1月,接口返回data为空。换个短区间就正常。 原因:接口对单次查询的时间跨度或跨年边界做了限制,具体限制条件没有公开文档。 解决:把时间范围拆成按月或按90天的小段,逐段请求,然后按date字段去重合并。合并时注意边界日期不要重复入库,用主键或唯一索引兜底。

5.4 接口数值与页面折线图对不上:以原始数据为准

现象:解密出来的某个日期数值和页面折线图上鼠标悬停显示的数值差一些。 原因:页面展示时可能做了二次聚合或取整,接口原始数据才是后端返回的原始值。 解决:以接口解析出的原始值为准。如果拿不准,就把当天抓到的加密串原文也存下来,等接口版本稳定后再统一重新解密。这种做法在原始数据量不大时很有用,回头校验不会翻旧账。

6. 进阶收尾:把爬虫做成能每周稳定跑的历史趋势采集器

前面每一步都是在突破单次请求的限制,但真正让这个方向产生价值的,是把爬虫变成一个可持续运行的采集任务。我一般会加三层设计:增量存储、断点续采、抽样校验。

增量存储用一张简单的表就行:keyword(关键词)、date(日期)、total(指数值)、raw(加密串原文件)。以keyword和date作为联合主键,重复插入时用upsert更新total,不新增垃圾行。断点续采需要记录每个关键词当前已采到的最新日期,下次任务从最新日期的次日开始。抽样校验则是每天任务跑完后,随机抽几个词与页面实际显示值比对,偏差超标的发一条提醒。

import sqlite3 from datetime import date, timedelta def daily_run(keywords, days=30): conn = sqlite3.connect("index_history.db") end = date.today() - timedelta(days=1) start = end - timedelta(days=days) for kw in keywords: # 读取断点:没有记录就从start开始 row = conn.execute( "SELECT max(date) FROM index_history WHERE keyword=?", (kw,) ).fetchone() begin = end - timedelta(days=days) if row and row[0]: begin = max(begin, date.fromisoformat(row[0]) + timedelta(days=1)) if begin > end: continue # 拉取并解析,失败时重试 data = fetch_and_parse(kw, begin.isoformat(), end.isoformat()) conn.execute("BEGIN") for rec in data: conn.execute( "INSERT OR REPLACE INTO index_history(keyword,date,total,raw) VALUES(?,?,?,?)", (kw, rec["date"], rec["total"], rec["raw"]), ) conn.commit() conn.close()

这段代码的关键是把“增量”和“断点”做成显式逻辑,而不是每天从头爬。最深的血泪经验是我自己早期把签名参数写死,导致第二天任务全部失败;后来改成每次启动都重新计算签名、定期刷新首页Cookie后,才连续稳定跑了几周。期间又被跨年空数组坑过一次,现在所有时间范围都默认分段查询,宁可多请求几次也不赌接口的边界行为。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询