1. 写在前面:这个案例到底在解决什么问题
先说结论:py每日spider案例之music搜索接口,核心就一句话——用Python请求音乐平台的搜索接口,输入歌名或歌手,拿到歌曲ID、播放地址、封面图这些结构化数据。
那为什么要专门写这个案例?我自己当初入门爬虫时最大的困惑是:网上教程一抓一大把,但绝大多数都是拿静态网页练手,教完requests.get()加正则就结束了。真正到了实际工作里你会发现,稍微成熟一点的服务,接口全是动态加密的,点开开发者工具能看到请求,但一复制到Python里跑就报错。音乐搜索接口就是这类动态接口的典型代表。
这个案例适合谁?
- 刚学完Python基础,想挑战真实接口的新手
- 已经会写简单爬虫,但没接触过签名、加密参数的进阶玩家
- 日常有批量获取音乐信息需求、但不想手动一条条复制的人
它能帮你打通几件关键的事:绕过页面直接调接口的思路、请求参数加密的常见套路、JSON数据的定向提取,以及如何写一个不轻易被封的爬虫。这些东西你搞明白了,再去看其他App、小程序的接口,基本是降维打击。
我这边实测的环境是Python 3.9 + requests 2.28 + 某音乐平台的搜索接口,下面所有代码都是跑通了的,但要注意接口参数和返回结构可能会有微调,你要用的时候按同样的思路灵活应对。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 整体设计思路拆解:为什么选接口而不是解析页面
2.1 接口方案和页面解析方案的区别
先说一个很多人最初都会踩的坑:打开网页看到搜索结果,一堆HTML标签里有歌名有歌手,于是吭哧吭哧写正则去匹配,搞了半天还因为网页结构调整了直接报废。
我当时也干过这事,后来发现完全绕了远路。页面解析方案天然有三个痛点:
- 网页结构说变就变:一个小的前端改版,你的CSS选择器就全废了,维护成本极高
- 加载方式是动态的:很多页面数据是JS异步加载的,直接
requests拿到的是空壳HTML - 反爬手段多:字体反爬、CSS偏移、滑块验证,每一样都够你折腾半天
而接口方案的核心思路是:页面展示的数据从哪里来,我就从哪里拿。你打开开发者工具,切到Network面板,刷新页面,找到XHR请求,看到返回的JSON里有你想要的字段,那这个JSON就是你的目标。接口方案的好处在于数据是纯JSON,字段清晰、定位准确、无需解析复杂的HTML结构;而且只要官方接口不升级,参数和返回格式基本稳定,维护成本低。
2.2 为什么这个案例难度刚刚好
音乐搜索接口本身属于"中等偏上"难度的接口爬虫,因为它具备几个典型特征:
- 公开接口,不需要登录就能请求,适合第一步练手
- 请求参数中有加密签名,也有时间戳、随机数这类经典反爬参数
- 返回数据量大,嵌套层级深,正好练习JSON解析
- 反爬策略真实存在,但不至于完全无解
换句话说,你花两三个小时把这个接口啃下来,学到的不是某一个网站的专属知识,而是一整套可复用的方法论。后面遇到任何带加密参数的接口,你至少知道该从哪下手去逆向、去构造、去调试。
2.3 技术选型
- requests:Python里最常用的HTTP库,用来发送GET/POST请求
- json:Python标准库,解析接口返回的数据
- hashlib:Python标准库,用来生成MD5签名(签名拼接规则见下)
- time:生成时间戳,很多签名算法里都会用到
没有用到scrapy是因为这个场景是轻量级单次搜索,杀鸡不用牛刀;没有用Selenium是因为我明确知道接口存在,没必要去模拟浏览器,直接用requests效率高得多。
3. 核心细节拆解:请求参数和加密签名怎么搞定
3.1 先弄清接口的请求结构
打开浏览器开发者工具,切到Network,输入一个歌名回车,会看到类似这样的XHR请求:
code复制GET https://api.example.com/search?keyword=晴天&page=1&limit=10
但注意,真实的请求往往没这么简单。我抓包后看到的实际请求URL长这样:
code复制https://api.example.com/search?keyword=晴天&page=1&limit=10×tamp=1698326400&sign=6f5a2d3c91b4e5f6
多出来的timestamp和sign就是核心难点。timestamp是发起请求时的Unix时间戳,用来告诉服务器这个请求是"新鲜"的;sign则是对请求参数按规则拼接后跑MD5得到的签名,服务器收到请求后会进行一次同样的计算,如果对不上,直接拒绝响应。
3.2 签名参数生成原理
写爬虫最怕的就是看不懂签名逻辑,但音乐接口这个案例比较友好——它的签名规则通常是:
code复制sign = md5(keyword + page + limit + timestamp + salt)
其中salt是平台写死的一个字符串,类似密钥,需要通过分析JS源码或者多次对比请求参数与返回结果逆向出来。你可以把它理解为做菜时的"秘制酱料":原料都看得见,但放多少、什么时候放,就是核心机密。
我当时逆向这个salt的方法是:用两个不同的keyword发出请求,分别记录完整的URL参数和对应sign,再配合一段简单的脚本批量测试可能的拼接顺序,很快就锁定规律了。如果你也遇到类似情况,推荐自己写个for循环,把参数名排列组合全部跑一遍,匹配上就说明找对了。
注意:我这里给的
salt示例不是真实值,你自己实操时需要按实际逆向结果替换。千万不要试图直接拿网上旧教程里的salt套新接口,平台换salt跟换密码一样频繁。
3.3 请求头的关键字段
请求头里最容易被忽视却又最关键的是User-Agent和Referer。很多新手一上来就裸奔式请求(默认的python-requests/xxx),服务器一看就知道你是脚本,直接拦截。我当时加上的请求头是这样的:
python复制headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Safari/537.36",
"Referer": "https://www.example.com/",
"Origin": "https://www.example.com"
}
这里有个容易踩的细节:User-Agent不能是虚构的,最好从真实浏览器里复制。Windows上Chrome的UA基本长得差不多,但Mac的UA字符串里没有Windows NT字样,如果你拿Mac UA冒充Windows且还跟实际环境差太多,部分严格的反爬会校验UA与系统环境的匹配度。更稳妥的做法是:打开浏览器,F12,在任意请求的Headers里直接复制。
还有一个细节:timestamp的取值必须和当前时间近,如果服务器校验时间戳差值超过某个阈值(比如30秒),即使签名正确也会被判定为过期请求。我建议直接用int(time.time()),不要为了模拟历史请求而手填一个旧时间戳。
4. 实操过程:从构造请求到拿到干净数据
4.1 完整代码及逐段拆解
先说好,下面的代码是基于我逆向的某音乐平台接口写的,核心思路通用,但你实际使用时需要把域名、参数名、salt替换成你自己抓到的值。直接贴代码:
python复制import requests
import hashlib
import time
import json
# 构建签名的函数
def build_sign(params: dict, salt: str) -> str:
# 按参数名字母顺序排序,保证拼接顺序一致
keys = sorted(params.keys())
raw = "".join(str(params[k]) for k in keys) + salt
# 计算MD5签名
md5 = hashlib.md5(raw.encode("utf-8")).hexdigest()
print(f"[Debug] sign raw string: {raw}") # 调试用,可删除
return md5
def search_music(keyword: str, page: int = 1, limit: int = 10) -> dict:
# 接口地址,替换成你自己抓到的
url = "https://api.example.com/search"
# 构造基础参数
params = {
"keyword": keyword,
"page": page,
"limit": limit,
"timestamp": int(time.time())
}
# 生成签名
salt = "your_salt_here" # 替换为你逆向得到的salt
params["sign"] = build_sign(params, salt)
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Safari/537.36",
"Referer": "https://www.example.com/",
"Origin": "https://www.example.com"
}
try:
resp = requests.get(url, params=params, headers=headers, timeout=10)
resp.raise_for_status() # 非200状态码直接抛异常
data = resp.json()
return data
except requests.exceptions.RequestException as e:
print(f"[Error] 请求失败: {e}")
return {}
if __name__ == "__main__":
result = search_music("晴天")
print(json.dumps(result, ensure_ascii=False, indent=2))
这段代码看着不长,每一行都有讲究。先说build_sign函数——为什么参数要按字母顺序排序?因为我观察到真实请求URL里参数是按照字母序排列的,那服务端计算签名时大概率也是按同样的顺序拼接。这是从请求日志里反推出来的规律,不是凭空猜的。str(params[k])的写法则是保证所有参数值都转成字符串再拼接,避免数字和字符串拼一起报TypeError。
timeout=10这个参数很多人会漏掉,但很重要。没有超时设置的话,万一服务器连接卡死,你的程序会一直挂着,没有响应。设置10秒的意思是:连接最多等5秒,读取响应最多等5秒,超过就抛异常走异常分支。
4.2 JSON解析:从嵌套数据里提取目标字段
调通接口后你会发现返回值通常是个很深的JSON嵌套结构,直接打印看半天也看不清层次。我习惯先跑一遍,把返回的JSON复制到一个在线的JSON格式化工具(或者本地的VS Code里装了JSON插件)去看层级。
一般音乐搜索返回的结构大致是这样的:
json复制{
"code": 0,
"message": "success",
"data": {
"songs": [
{
"id": "123456",
"name": "晴天",
"artists": [{"name": "周杰伦"}],
"album": {"name": "叶惠美"},
"duration": 269000,
"play_url": "https://..."
}
]
}
}
那提取就很简单:
python复制data = result.get("data", {})
songs = data.get("songs", [])
for song in songs:
name = song.get("name")
artist = song["artists"][0]["name"] if song.get("artists") else "未知"
album = song.get("album", {}).get("name", "未知")
print(f"歌名: {name} | 歌手: {artist} | 专辑: {album}")
这里我用了.get()而不是直接["xxx"]索引,目的是防止某个字段缺失导致整个程序KeyError崩溃。做爬虫的都知道,接口返回的数据不一定每个字段都有,尤其是艺术家列表可能是空的,专辑信息可能缺失,你写严谨点能让程序稳定很多。
4.3 带上拼音忽略声调的优化
上面基础版跑起来之后,我开始考虑真实使用场景。很多人搜歌时喜欢直接输入拼音,比如打"qt"找“晴天”,或者输入英文名"love story"找Taylor Swift的歌。这时候如果接口本身不做模糊处理,直接搜索会返回空结果或错误结果。
我看了一下这个平台的搜索接口,它支持一个隐藏参数ignore_tone,传1时会忽略声调匹配拼音。加进去之后实测,搜"qt"能匹配“晴天”,搜"zhoujielun"能匹配到周杰伦的全部歌曲。这算是一个典型的高阶用法——很多接口文档里不会细写,但参数确实存在,需要你多用几个词抓包对比才能发现。
改造后的代码片段:
python复制params = {
"keyword": keyword,
"page": page,
"limit": limit,
"ignore_tone": 1, # 忽略声调匹配拼音
"timestamp": int(time.time())
}
注意:加了新参数之后别忘了重新生成签名,我就是因为一开始改完参数忘记重新跑签名函数,卡了十分钟才发现是签名的锅。
5. 常见问题与排查技巧实录
5.1 请求状态码200但返回无数据
这个是最容易让人怀疑人生的:请求没报错,返回的JSON里code=0是成功状态,但songs列表为空。我排查了一下午发现,原因是页码和条数参数搭配有上限,比如每页最多返回30条,页数最大50,超出范围后不报错,但直接返回空列表。
解决方法:限制limit的取值范围,同时检查返回对象里是否有total一类的总量字段,用total来动态计算最大页码。
5.2 签名错误导致403或者提示"invalid sign"
这个和上面的"请求码200但空内容"正好是相反的异常形态:服务器拒绝服务。最可能的原因是这几种:
- 参数拼接顺序不对,比如我不小心把
timestamp放在了参数名单之外直接拼接 salt填写错误,或者平台已经更新了salt- 新增参数后没有同步到签名函数里
我这个血的教训是:先测通一个最简单的不带任何特殊参数的请求,确认能通后再逐个加参数,这样出问题时能快速定位是哪一步引入的。
5.3 IP和账号被封
频繁请求接口,一天请求几千次,会有两种情况:短时间内请求过于频繁,接口开始返回验证码或者直接拒绝访问;使用了同一IP多条连接同时并发,触发平台的QPS限制。
我的经验是限制请求频率,每次请求之间至少间隔0.5秒:
python复制import time
time.sleep(0.5)
如果你要大批量搜索,建议每次搜索后随机sleep 1到3秒,这样更接近手动操作的行为模式。注意,我这里说的都是合法的频率控制,任何平台都应该用健康的频率去请求,不要想着钻空子,毕竟爬虫本身是为了解放重复劳动,而不是给服务器制造压力。
5.4 参数被URL编码处理
有时候你传的中文关键词明明是正常的,但回显的URL里keyword变成了%E6%99%B4%E5%A4%A9,这是正常的URL编码,requests库会自动帮你转码。但如果你是自己手动拼接URL字符串,就得多走一步urllib.parse.quote。我当初就是手写URL拼接,中文关键词处理不对,排查了很久。
python复制from urllib.parse import quote
keyword_encoded = quote("晴天")
url = f"https://api.example.com/search?keyword={keyword_encoded}"
但更偷懒的方式是直接传给params参数,让它自己处理,我后面就都这么干了。
6. 环境与脚本运行的杂症记录
6.1 conda环境下VSCode里运行py文件报错
有朋友在VSCode里运行这个脚本,发现用的是系统的Python而不是conda里的。最常见的表现是:在终端输入conda activate切换到环境后,VSCode右上角的"运行Python文件"按钮仍然调用的是全局解释器。
解决方案很简单:VSCode里按Ctrl+Shift+P,输入"Python: Select Interpreter",选你conda环境对应的那个解释器路径。如果还不生效,检查命令行是激活了base环境还是你的自定义环境,有时候环境名和解释器路径对不上,也会导致混乱。
6.2 在手机上没装Python环境想跑py脚本
有个朋友问我在手机上怎么跑这个脚本。如果你手头只有手机没有电脑,推荐用在线运行Python的网站,但要注意在线环境不一定能安装第三方库requests,可能需要切换到自带requests的环境。或者干脆用Termux这类终端模拟器,在手机上装Python环境,再把脚本传上去跑。
不过说句实话,手机调试爬虫体验很差,我建议还是先写明白逻辑,再去电脑上实际跑。
6.3 Python 2和3的print写法差异
我看到有的教程里还用的是print "xxx"这种方式,这明显是Python 2的写法了。如果你当前装的是Python 3,运行会直接SyntaxError。这个案例里的代码全部按Python 3的语法来,确保你复制粘贴就能跑。别忘了在文件开头加# -*- coding: utf-8 -*-虽然Python 3默认UTF-8,但养成习惯没坏处,特别是Windows下处理中文的时候。
6.4 关于.py怎么运行
Windows下双击.py文件默认会用关联程序打开,但往往一闪而过。我一般建议直接用命令行运行:
bash复制python music_search.py
如果提示找不到python,检查一下是不是没把Python加进环境变量。怎么检查?在cmd里输python --version,能输出版本号就说明环境变量OK,否则需要去系统设置里给Path加Python的安装路径。
7. 进阶玩法与思路扩展
7.1 让脚本接收命令行参数
我一开始写这个搜索脚本的时候,是在代码里写死歌名的,每次想搜新歌就改代码再跑,太蠢了。后面改成从命令行接收参数,方便多了:
python复制import sys
if __name__ == "__main__":
if len(sys.argv) < 2:
print("Usage: python music_search.py 晴天")
sys.exit(0)
keyword = sys.argv[1]
result = search_music(keyword)
...
这样你在命令行里直接输python music_search.py 晴天就能搜,不用再动代码。其实Python里更正式的做法是用argparse库来解析命令行参数,功能更强,但简单场景下sys.argv完全够用。
7.2 把搜索结果存成文件
搜完不存下来等于白搜。我每次跑完会把结果存成JSON或CSV,方便后续分析:
python复制with open("music_result.json", "w", encoding="utf-8") as f:
json.dump(result, f, ensure_ascii=False, indent=2)
如果想要CSV格式,可以直接遍历songs,用csv库逐行写入。这个看你的后续用途,存储成什么格式都行。
7.3 扩展到批量搜索
批量搜索的理念和单曲搜索完全一样,无非是多个关键词跑多次。我写了个简单的读文件方式:
python复制with open("keywords.txt", "r", encoding="utf-8") as f:
keywords = [line.strip() for line in f if line.strip()]
for kw in keywords:
print(f"正在搜索: {kw}")
data = search_music(kw)
# 处理data...
time.sleep(1)
但这里我要特别提醒:批量请求的频率一定不要太快,否则对你的IP和平台服务器都不友好,甚至可能影响正常用户的使用。这是每个爬虫学习者应有的职业操守,爬虫本身是工具,怎么规范使用完全取决于个人。
8. 最后的几点实在话
做这个案例给我最大的感受是:爬虫最难的从来不是写代码,而是观察和推理。逆向签名的那段时间,我盯着Network面板请求参数记录,一行一行地比对参数变化和sign差异,真的跟破案一样。但一旦你把规则摸清楚了,后面的代码反而不值一提。
如果说还有一个小技巧想分享给大家,那就是调试签名时不要太依赖脑子记,把每次请求的完整参数和对应签名都打印出来,前后对比着看,规律一下子就出来了。我代码里留的print(f"[Debug] sign raw string: {raw}")就是干这个用的,调试完删掉或注释掉就行。
另外,接口爬虫的核心能力其实是"举一反三"。你学会音乐搜索接口的签名生成,再看视频网站的搜索、电商的搜索、新闻的搜索,会发现套路高度相似:时间戳+加密签名+请求头伪装,最多再加个Cookie或Token。底层逻辑打通了,剩下只是时间和耐心的问题。
如果你把这个案例跑通了,我建议你下一步自己试试换个平台接口练手,争取不看教程自己把签名逆向出来。那种感觉,比你复制粘贴一百遍代码都有用。
