做爬虫这几年,我最大的感受是:真正有价值的反而不是那些花里胡哨的加密算法,而是你拿到一个接口后,能快速判断它是什么级别、用什么姿势去请求、返回数据怎么处理。今天分享的这个案例,主体是一个典型无加密的音乐搜索接口——没有sign、没有token、没有时间戳拼接,连Cookie都可有可无,靠requests一把梭就能把数据拿下来。这个案例很适合刚接触爬虫的朋友,同时我也会把工程化部分讲透:怎么给py脚本传参数、怎么在PyCharm里打包exe、怎么让Windows双击就能跑py,以及旧脚本在Python 3.12上常见的兼容性坑。内容会稍微偏实战向,不是那种贴一段代码就完事的教程。
先说清楚这个接口复现的前提:目标站是一个相对小众的音乐聚合站点,搜索入口没有做任何参数加密,接口返回的是标准JSON。我平时做接口分析时有个习惯,第一眼先看Network面板里的请求参数,再看返回结构。对于那些无加密接口,核心考察点就三个:URL是不是稳定的、返回字段是否完整、以及服务器是否对请求频率敏感。只要这三关过了,剩下的就是纯代码功夫。
1. 项目背景与需求拆解
1.1 这个爬虫到底要解决什么问题
音乐搜索接口的爬取需求,通常不是要你把整个曲库搬走,而是某个场景里需要一批歌曲的“名称-歌手-播放链接-封面”四元组。比如你想做歌单分析、歌词匹配、或者是给某个聊天机器人做一个“点歌”功能,真正起决定作用的只是“搜索”这一步。目标站的搜索接口是/api/search/music,GET请求,参数就是keyword和limit,返回JSON。没有加密,意味着我们不需要处理JS逆向、不需要模拟浏览器指纹,requests发出的请求和浏览器发出的请求在服务器看来几乎没有区别。
这背后体现了一个选型逻辑:判断一个接口值不值得爬,先看它的成本收益比。无加密接口的收益极高,因为整个流程里没有“加密链断裂”导致的反爬风险,出问题只会出在反爬频率和字段解析上,技术天花板很低,但应用价值一点都不低。
1.2 从标题拆出三条核心线索
标题里“py每日spider案例”其实定义了三个东西。
第一,py工程化。用户大概率不是拿Python脚本当玩具玩,而是要放在服务器上定跑,甚至给不懂Python的人用。所以我除了写搜索函数之外,还会把命令行参数、打包exe、Windows直接运行这三件事一起处理掉。第二,spider的例行感。每天跑一次的爬虫,最重要的不是代码多炫,而是稳定、可重启、可排查。第三,无加密接口。这决定了我们不需要引入任何重型依赖,只需要requests和解析模块。
这个案例适合谁来参考?两类人。一类是刚入门爬虫、想找一个无加密完整案例练手的新手;另一类是手里有一堆小脚本,想规范一下工程结构、顺便解决Windows环境运行问题的进阶用户。如果你对JS逆向感兴趣、想研究加密参数,今天这个案例不够刺激,但如果你想把“从请求到落地”的整套环节跑通,那这篇里的坑你几乎都会踩到。
2. 接口分析与无加密接口的识别逻辑
2.1 开发者工具里如何快速判断“无加密”
打开浏览器开发者工具,切到Network面板,勾选Fetch/XHR,在搜索框里输入一个测试词,比如“晴天”。这时候你会看到一条/api/search/music的请求,点开来看它的Query String Parameters,往往只有两个字段:keyword=晴天、limit=10。
判断加密与否,我一般看三条红线:一是请求参数里有没有sign、sig、token、timestamp、nonce这类字段,二是Request Headers里有没有强制的动态Cookie或Authorization头,三是请求体是不是被编码成看不懂的东西。三条全不占,那就是无加密接口。不需要再看JS源码、不需要断点调试、不需要搜索加密函数入口,直接在控制台把这段请求复制为cURL,再转成Python代码,就能拿到第一份可用的请求模板。
无加密接口在现实中为什么常见?因为不少站点后台换了好几次技术架构,前端却一直沿用简单的JSON API,只做了Referer校验和频率限制,并没有升级到加密体系。这类接口是“临时方案”,但往往会存活一年以上。对爬虫工程师来说,它是最好的练手对象,因为你能把注意力完全放在HTTP层,而不是耗在逆向层。
2.2 参数选型与构造要点
以这次搜索接口为例,请求URL是:
https://music.example.com/api/search/keyword=晴天&limit=10注意,这里的music.example.com是我隐去真实域名后的代称。参数设计上有几个容易忽略的细节。
第一,limit参数的值决定了每次搜索返回的条目数。这不是固定的,你需要通过“翻页对比法”来确认它的上限。我一般先用limit=5和limit=20各请求一次,比较返回数组长度,再用limit=999试探一次,如果服务器返回的数组长度和limit不一致、且不报错,那说明后端对这个参数做了截断,常见上限可能是10、20、50这三档。取上限值能减少请求次数,但要承担更大的被封风险,所以我的经验是:日常跑任务时用20,批量拉取时才用50。
第二,编码问题。中文关键词如果直接拼接在URL里,很容易因为编码不一致导致搜索失败。正确做法是用params字典交给requests处理,它会自动把中文转成URL编码。手拼URL的方式,如果忘了urllib.parse.quote,返回的不是空结果就是乱码。
第三,Header构造。无加密接口不代表可以裸奔。我通常至少带两组头:User-Agent和Referer。User-Agent用浏览器默认值,Referer填站点首页,这两者在服务器眼里构成了“这是一个正常的浏览器访客”的假象。有些接口不做Referer校验,但带上没有坏处,顶多多传一个字段。
2.3 返回数据的结构分析与字段定位
无加密接口的返回JSON通常也有固定套路。以这个案例来说,返回结构大致是:
{ "code": 200, "data": { "list": [ { "id": 123456, "name": "晴天", "artist": "周杰伦", "album": "叶惠美", "duration": 269000, "play_url": "https://cdn.example.com/media/123456.mp3" } ], "total": 32 } }拿到返回内容后,不要着急写解析代码,先在Json格式化工具里把结构看清楚。我见过太多人一把梭写data["data"]["list"],结果某天接口结构调整,程序直接崩溃。一个更稳妥的做法是写一个“容错解析函数”:先判断code是否等于200,再用.get()方法逐层取值,避免直接下标访问抛KeyError。无加密接口虽然没有加密成本,但它的数据结构可能就是设计者随手定的,可能不规范,所以宁可写多几行防御代码,也不要图省事。
3. 核心代码实现与参数计算
3.1 最基础的搜索函数写法
直接上代码,这是全程最基础、也是最重要的部分:
import requests import json SEARCH_URL = "https://music.example.com/api/search/music" def build_headers(): return { "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://music.example.com/", "Accept": "application/json, text/plain, */*" } def search_music(keyword, limit=20, timeout=10): params = { "keyword": keyword, "limit": limit } resp = requests.get(SEARCH_URL, params=params, headers=build_headers(), timeout=timeout) resp.raise_for_status() return resp.json() def parse_song_list(data): if not data or data.get("code") != 200: return [] data_section = data.get("data") or {} song_list = data_section.get("list") or [] results = [] for item in song_list: results.append({ "song_id": item.get("id"), "name": item.get("name"), "artist": item.get("artist"), "album": item.get("album"), "duration_ms": item.get("duration"), "play_url": item.get("play_url") }) return results if __name__ == "__main__": raw = search_music("晴天") for song in parse_song_list(raw): print(json.dumps(song, ensure_ascii=False, indent=2))这里有一个参数计算细节:timeout=10不是随手写的。搜索接口的响应时间一般在1到3秒,但如果目标服务器使用了CDN,在极少数回源场景下,响应时间可能拉到10秒以上。我选择10秒作为超时阈值,考虑的是“如果10秒还没返回,那这次请求大概率失败了,与其让程序卡住,不如直接抛异常并记入日志”。超时时间不是越大越好,因为每一次超时都意味着线程占满、无法继续执行后续任务,在定时爬虫里,这会导致任务积压。
3.2 处理搜索关键词的特殊字符
音乐搜索里最常遇到的是空格和引号。比如用户输入“周杰伦 晴天”,这种空格在请求里会被编码成%20或者+,服务器解析时一般没问题,但有些老接口会把空格当成分隔符,导致搜索错误。我踩过这个坑,所以后来的实现里会加一个关键词清洗函数,把连续空格合并成单个,去掉首尾空白,同时过滤掉会影响URL语义的字符(比如#、&、=)。
清洗函数如下:
import re def clean_keyword(keyword): keyword = keyword.strip() keyword = re.sub(r"\s+", " ", keyword) keyword = keyword.replace("&", "%26").replace("#", "%23") return keyword这样做的好处是,不管调用方传入的是什么,最终发给服务器的始终是合法参数。真正的生产环境里,你永远假设外部输入是“脏”的,而不是假设调用方已经处理好了。
3.3 批量搜索多首歌时的频率控制
如果你要做每日固定任务,比如每天早上8点搜20首歌,那这个需求很简单,循环调用即可。但如果你的搜索词有几百上千个,并发请求就很有必要了。无加密接口通常没有太强的签名验证,但它会统计IP的请求频率,如果短时间请求过多,很容易触发临时封禁。
我的做法是用线程池加限速:
import time from concurrent.futures import ThreadPoolExecutor, as_completed def search_batch(keywords, max_workers=3, delay=0.5): results = {} def worker(word): time.sleep(delay) try: data = search_music(word) return word, parse_song_list(data) except Exception as e: return word, [] with ThreadPoolExecutor(max_workers=max_workers) as executor: future_map = {executor.submit(worker, word): word for word in keywords} for future in as_completed(future_map): word = future_map[future] try: word, songs = future.result() results[word] = songs except Exception as e: results[word] = [] return results这个线程数3和延迟0.5秒是基于“单IP、无代理、低频搜索”场景设计的。如果你只有一二十个关键词,串行跑可能也就几十秒;但如果你有上千个关键词,串行就要几十分钟了。三线程加半秒延迟,平均每秒能发出大约5到6个请求,这个频率对绝大多数无加密接口都是安全的。如果目标站比较敏感,我会把max_workers降到1,延迟加到1秒。
3.4 结果落地:从JSON到CSV
拿到数据不落地等于白爬,所以我一般会顺手写一个保存函数,把结果写入CSV,方便后续用Excel或者pandas做分析。
import csv def save_to_csv(song_results, filename="music_search_result.csv"): fieldnames = ["song_id", "name", "artist", "album", "duration_ms", "play_url"] with open(filename, "w", newline="", encoding="utf-8") as f: writer = csv.DictWriter(f, fieldnames=fieldnames) writer.writeheader() for keyword, songs in song_results.items(): for song in songs: song["keyword"] = keyword writer.writerow(song)注意encoding="utf-8"这个参数:在Windows上如果写成encoding="utf-8",Excel打开CSV时可能出现中文乱码,因为Windows Excel默认按GBK解析。解决方式有两种,要么在文件头加入\ufeff(BOM),要么直接把编码写成utf-8-sig。稳妥起见,我通常写encoding="utf-8-sig"。
4. 脚本工程化:参数传递、打包与Windows直接运行
4.1 用argparse实现给py脚本传参
最开始我写这种脚本时,搜索词都写在if __name__ == "__main__"块里,每次改关键词就要改代码。后来发现一旦脚本要交给别人用,或者要放到服务器上定时跑,硬编码关键词非常不灵活。正确的做法是把关键词和条数都变成命令行参数。
import argparse def parse_args(): parser = argparse.ArgumentParser(description="音乐搜索接口爬虫") parser.add_argument("keywords", nargs="+", help="要搜索的音乐关键词,可传多个") parser.add_argument("--limit", type=int, default=20, help="每个关键词返回多少条结果,默认20") parser.add_argument("--output", default="music_result.csv", help="输出文件名") return parser.parse_args()这里有个小设计:keywords用nargs="+",允许你一个命令快速搜多首歌:
python music_search.py 晴天 海阔天空 --limit 10 --output result.csv用--limit这种长参数,而不是-l,是因为-l很容易和后面要扩展的其他参数冲突。命令行交互模式极大方便了后续的定时调用,不管是在任务计划程序里还是在Linux的crontab里,本质上都是在跑一条带上参数的命令,代码本身不需要任何改动。
4.2 PyCharm中把py程序打包成exe
如果你要把脚本发给没有Python环境的同事,打包成exe是标配方案。在PyCharm里打包,步骤其实就是打开Terminal,执行PyInstaller命令。
先安装:
pip install pyinstaller然后在项目根目录执行:
pyinstaller -F -w music_search.py参数含义要搞清楚:
-F:打包成单个exe文件。适合这种小型工具脚本,因为只有requests这一个第三方依赖,单文件模式启动速度也能接受。-w:去掉控制台窗口。如果脚本是命令行交互式的,请千万别加-w,否则用户看不到输出结果。如果是给小白用的双击程序,可以保留控制台,方便查看错误信息。
打包完成后,exe会生成在dist目录下。如果你想确认打包有没有遗漏依赖,可以用命令:
pyinstaller -F -w --paths . music_search.py或者直接测试exe。我第一次打包时犯过一个典型错误:在PyCharm里配置了虚拟环境,但PyInstaller默认安装在系统Python里,导致打包出来的exe运行时报ModuleNotFoundError: No module named 'requests'。解决办法是在PyCharm的Terminal里使用当前虚拟环境的pip重新安装PyInstaller,再执行打包。
4.3 Windows直接运行py文件的几种方案
Windows上直接运行py文件,最明显的体验问题是:双击.py文件时,默认会用记事本打开,而不是执行。解决思路有两个方向。
一是在命令行里用文件关联:
assoc .py=Python.File ftype Python.File="C:\Windows\py.exe" "%L" %*注意,这里指定的py.exe是Python安装时自带的Launcher,它会自动选择合适的Python版本来运行脚本。如果你装了多个Python版本,用这个方式可以用py命令来统一调用。
二是写一个.bat批处理包装:
@echo off py "%~dp0music_search.py" %* pause把上面内容保存为run_music.bat,和music_search.py放在同一目录。以后用户只需要双击bat文件,就能运行并传入参数。这两种方式里,我更推荐bat方案,因为它对用户的要求最低,而且方便你额外加上日志重定向等操作,例如:
py "%~dp0music_search.py" %* >> run.log 2>&1 pause这样脚本运行的所有输出都会写入run.log,排查问题的时候非常有用。
4.4 把每日任务挂到Windows任务计划程序
既然是“每日spider案例”,定时运行是绕不开的一步。Windows自带的“任务计划程序”配合上面的bat文件就能搞定,不需要装什么shadower软件。创建任务时注意三点:第一,“触发器”里选择每天,设置好执行时间;第二,“操作”里“程序或脚本”选择你的bat文件,起始路径填项目目录;第三,“条件”里取消“只有在计算机使用交流电时才启动此任务”的勾选,避免笔记本睡眠时任务不执行。
实际执行过程中,任务计划管理器会以当前用户的权限启动bat,如果脚本里需要访问网络、读取外部文件,一般权限都足够。唯一要警惕的是,不要把脚本放在C:\Program Files这类需要管理员权限的目录下,否则每次运行都可能弹出UAC提示,甚至直接失败。
5. 常见问题与排查实录
5.1 Python 3.12上旧py文件运行报错的经典案例
标题里提到的热搜词“旧的py文件在python3.12上运行出错”,我在迁移一批2019年左右写的老爬虫脚本时深有体会。Python 3.12弃用和移除了一批旧模块,最典型的有三个。
第一个是distutils,Python 3.12里直接移除了这个模块,很多老脚本里会有from distutils.util import strtobool之类的导入,直接报ModuleNotFoundError: No module named 'distutils'。解决办法是用setuptools替代,因为setuptools里仍在维护一个兼容版本的distutils接口,很多情况下只需要:
pip install setuptools然后老脚本就能跑了。注意这是兼容方案,不是长远方案,长远来看应该改用strtobool的简单实现或者直接删掉。
第二个是imp模块被移除,旧代码里如果用了import imp加载动态模块,在3.12里完全不可用,要改成importlib。
第三个是datetime.datetime.utcnow()的弃用警告,在3.12里继续用虽然还能跑,但会在日志里刷DeprecationWarning。爬虫脚本如果用了日志模块,这些警告会污染日志,影响排错。我一般统一替换成datetime.now(timezone.utc)。
针对老旧脚本,我有一条排查路线:先直接运行看第一条报错,大概率是import阶段的问题;然后全局搜索distutils、imp、utcnow等关键词,逐一替换;最后再考虑逻辑层面的兼容性,比如asyncio.get_event_loop()在新版本里的行为变化。
5.2 请求态报403:无加密接口也可能触发反爬
无加密不等于无反爬。我在某次批量搜索时,连续跑了一百多个关键词后,突然收到一堆403响应。排查后发现:目标站有一个隐性的IP频率限制,单IP每分钟超过60次请求就会被临时加入黑名单。
遇到这种情况,第一步是确认请求头是否完整;第二步是看返回内容里有没有验证页面特征码,比如302跳转到验证页;第三步是评估是否需要代理池。如果只是临时封禁,往往等15分钟到30分钟就能恢复。我的处理方案是代码里加上状态码判断和指数退避:
def search_with_retry(keyword, retries=3): for attempt in range(retries): resp = requests.get(SEARCH_URL, params={"keyword": keyword}, headers=build_headers(), timeout=10) if resp.status_code == 200: return resp.json() elif resp.status_code == 403: wait = 2 ** attempt * 5 time.sleep(wait) return None指数退避的核心思想很简单:第一次失败等5秒,第二次失败等10秒,第三次失败等20秒,等待时间按指数递增。这让服务器有时间把IP从黑名单里释放出来。不要用固定重试间隔,因为固定间隔往往在服务器还没解封时就重试,白白浪费请求。
5.3 搜索返回乱码和字段缺失的处理
在Windows控制台直接打印搜索结果时,经常遇到中文乱码。这未必是接口返回错误,很可能是控制台的编码问题,代码里已经用了ensure_ascii=False,但Windows默认的GBK控制台解析UTF-8字节流就会出现乱码。解决方式是把控制台代码页切到UTF-8,在运行前执行:
chcp 65001或者在Python代码里强制重配标准输出:
import sys import io sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8")字段缺失问题更隐蔽。无加密接口的返回结构不一定稳定,有时某个歌曲条目没有专辑名,item.get("album")就会返回None,写入CSV时空白,看起来就像数据丢了。我的做法是解析函数里做默认值填充:
def parse_song_list(data): ... for item in song_list: results.append({ "name": item.get("name") or "", "artist": item.get("artist") or "未知歌手", "album": item.get("album") or "未知专辑", "play_url": item.get("play_url") or "" })用or来做空值兜底,比写if not item.get(...)简洁得多,也能保证CSV表格里永远是有效字符串。
5.4 短信通知联调:没有短信网关时怎么办
搜索任务跑完后,有时需要一个短信通知来提醒“任务完成”或者“搜索失败”。热搜词里的“py短信测试网页入口”指的其实是“在真实短信网关还没申请下来时,用服务商提供的模拟短信网页入口做联调”。我在自己的项目里就干过这件事:先调通短信服务商的测试API,网页入口里能看到所有模拟发出的短信内容,确认格式无误后再切换到正式网关。
这里要提醒的是:短信服务商提供的测试入口,本身也是一种“网页接口”,在调试时不要把它当成真实短信通道,因为不同服务商的测试环境速率限制和内容过滤策略都不一样。如果你只是需要任务完成提醒,其实优先考虑的是邮件或者Webhook,比如推送到钉钉机器人的Webhook,成本更低、调试更快。所有通知类功能,都要做好失败不阻塞主流程的设计,核心搜索逻辑跑完了,短信发不发得出去都不应该影响结果落盘。
5.5 无加密接口爬虫的合规底线
看到无加密接口,第一反应可以是“好简单”,但不该是“随便爬”。我在代码里写了搜索频率控制,不是为了炫技,而是为了不给目标服务器制造压力。无加密接口可能只是暂时没做防护,并不等于我们可以无限制地抓取数据。这里有两道底线:一是只爬公开的搜索接口,不做绕过登录态或者破解加密这种突破访问控制的动作;二是限制请求速率,不影响站点的正常对外服务。
从法律角度讲,爬虫抓取公开数据本身是灰色地带,具体是否合规要看访问条款和数据使用方式。实际项目中,如果需要把爬取的数据做二次分发甚至商用,一定要先确认目标站的使用条款,必要时咨询法务。我在这篇教程里只讲解技术实现,也建议读者把它当成本地学习案例或者合法范围内的个人自动化任务来做。
6. 几个可以直接抄的扩展方向
6.1 在搜索脚本中封装一个单曲下载功能
搜索接口返回的play_url通常就是可直接访问的音频文件地址。如果你只是想下载几首歌离线听,可以在解析完结果后,用requests下载文件。
def download_song(play_url, save_path): resp = requests.get(play_url, headers=build_headers(), stream=True, timeout=30) with open(save_path, "wb") as f: for chunk in resp.iter_content(chunk_size=1024 * 64): f.write(chunk)注意两点:一是stream=True必须加上,否则requests会一次性把整个音频加载进内存,如果文件比较大,内存占用就有点难看;二是下载时也要带Referer,有些站点的音频链接做了防盗链校验,不带Referer会返回403。下载后的文件,我会先用文件头校验一下是不是真的是音频,防止拿到一个HTML错误页面:
def is_audio_file(filepath): with open(filepath, "rb") as f: header = f.read(12) return header.startswith(b"ID3") or header[4:8] == b"ftyp"6.2 把参数配置独立成config.json
进一步工程化,可以把搜索关键词、limit、输出文件名、请求间隔都挪到config.json里:
{ "keywords": ["晴天", "海阔天空", "七里香"], "limit": 20, "output": "result.csv", "delay": 0.5 }代码里读配置:
import json with open("config.json", "r", encoding="utf-8") as f: config = json.load(f)这样做的价值是:让不懂Python的人也能通过编辑JSON文件来调整爬取内容,不需要碰代码。定时任务运行前,只需要确认config.json里的配置是对的就行。
6.3 对接pandas做进一步清洗分析
搜索接口返回的歌曲数据,如果只是写CSV,后续做统计还要靠Excel。你可以直接用pandas读取或者处理:
import pandas as pd df = pd.read_csv("music_search_result.csv") artist_count = df["artist"].value_counts() print(artist_count.head(10))如果你的环境里pandas安装过但导入速度很慢,可以考虑只对最终结果用pandas,不在爬取阶段使用,因为pandas导入本身有几百毫秒开销,放在每日任务里不算什么,但如果做成高频抓取,这个开销就不可忽略了。
6.4 适配其他无加密搜索接口的思路
今天的案例只针对音乐接口,但这个“观察Network参数、构造统一Headers、解析JSON、落地CSV”的思路,可以复用到非常多场景,比如小说搜索、图片搜索、词条搜索。区别只在字段名不同而已。遇到新的无加密接口,我会先问自己四个问题:接口地址是什么?请求方法是什么?参数有哪些?返回结构长什么样?四步走完,代码框架基本不需要大改。
7. 写在最后的几点实操心得
这个案例从接口分析到落地,看起来步骤不多,但我实际做的时候还是踩了不少坑,尤其是其中几个属于“文档里不会写、不跑一遍真不知道”的细节。比如在Windows上打包exe时,PyInstaller默认会引入当前环境能看到的全部模块,如果你的环境很乱,exe会异常庞大。我的习惯是给每个爬虫项目单独建一个虚拟环境,只装项目依赖,这样打包出来的exe体积能小不少。
还有一点是关于“每日任务”的日志。很多人写爬虫不记日志,出了问题全靠现场复现,但定时任务里这是致命的。我的做法是给脚本简单加一个日志配置,把运行状态写入spider.log:
import logging logging.basicConfig( filename="spider.log", level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s", encoding="utf-8" ) logger = logging.getLogger("music_spider")跑了几次之后,你会发现日志里最重要的不是成功信息,而是异常信息。每次调试都能从日志里快速定位到具体是哪个关键词、哪一步出了问题,效率提升非常明显。
最后再分享一个小经验:写爬虫脚本时,尽量保持“请求函数”和“业务函数”分离。搜索函数只负责发请求、返回JSON;解析函数只负责把JSON变成干净数据;落地函数只管写文件。这样的结构,未来即使接口换了、字段变了,你也只需要改其中一个模块,而不是在几十行代码里找修改点。这个案例的代码不算复杂,但如果你一开始就能养成这种拆分习惯,后面维护和扩展都会轻松很多。