1. 案例背景与需求拆解
1.1 为什么从无加密接口入手
先聊点实在的。我日常写爬虫的频率不算低,每周都会拆两三个站点练手。有朋友问我为什么总挑“无加密”的接口来写,答案其实很简单:无加密接口是理解爬虫底层逻辑的最好教材。
所谓“无加密”,指的是目标服务端的请求参数没有经过签名校验、没有时间戳动态混淆、也没有复杂的header校验逻辑。换句话说,你拿着浏览器开发者工具随便翻翻,就能在Network面板里看到一个明明白白的请求URL,参数清清楚楚摆在Query String里,直接用requests库GET一下就能返回JSON。这种接口对新手极度友好,拿来学习“请求构造 → 响应解析 → 数据落盘”这条主线再合适不过。
但注意,无加密不等于无门槛。真正写起来,还是有不少细节坑:编码问题、UA校验、翻页参数、结果为空时的处理逻辑,这些东西如果不提前想清楚,写出来的脚本一跑就报错,很容易劝退初学者。
我这次拿“某音乐网站”的搜索接口做案例,主要有三个原因:
- 音乐搜索是典型的“关键词 → 列表结果”结构,非常规整,适合拆解。
- 它的接口返回的是标准JSON,字段含义清晰,适合讲解数据解析。
- 无加密特性意味着可以省去签名逆向的时间,把精力集中在流程设计上。
这个案例适合谁看?两种人。一种是刚学完Python基础、想接触爬虫但不知道从哪下手的同学;另一种是写过简单爬虫但没系统整理过“参数构造、结果保存、异常处理”这套完整方法论的人。
1.2 接口分析的常规路径
拿到一个网站,第一步不是写代码,而是打开开发者工具,手动操作一遍流程。我以“搜索歌曲关键字”为例,说一下常规分析路径:
- 打开目标网站首页,按F12进入开发者工具,切到Network面板。
- 在搜索框输入一个关键词,比如“海阔天空”,点搜索。
- 在Network面板里找到对应的XHR请求,通常名字里带search、query、suggest这类关键字。
- 点击该请求,查看Headers里的Request URL、Request Method、Query String Parameters。
- 切换到Preview或Response标签,确认返回的是JSON还是HTML。
这一步做完,你基本就能判断出接口是否“无加密”:如果URL里就是?keyword=海阔天空&page=1&limit=20这样的明文参数,且没有签名、没有token、没有加密的headers字段,那就可以直接进入代码实现阶段。
我见过很多人一上来就翻JS源码找加密逻辑,其实完全没必要。先把Network面板翻一遍,很多接口根本就是裸奔的。这也是我反复强调的:先看协议,再谈逆向。
2. 请求设计与参数构造
2.1 请求头的处理:不是所有header都要带
确认接口无加密之后,第一个要处理的就是请求头。很多人写爬虫习惯于把浏览器里的所有headers一股脑复制过来,包括Accept、Accept-Encoding、Sec-Fetch-*这些字段。我的建议是:只保留User-Agent和Referer就够了,最多再加一个Cookie,如果登录态不是必需的话Cookie都可以不带。
为什么?因为header带得越多,服务器能用来识别你的特征就越多。无加密接口的校验逻辑通常很薄弱,但如果你把浏览器的完整指纹暴露出来,反而可能触发一些风控策略。精简header是爬虫的基本素养。
举个例子,我写这个音乐搜索案例时,最终只保留了三个字段:
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://www.example-music.com/", "Accept": "application/json, text/plain, */*" }这里的User-Agent用Chrome的默认UA即可,不要整什么爬虫UA,一眼就会被识别。Referer的作用是模拟从网站页面发起的请求,很多站点对无Referer的请求会直接拒绝或返回403。Accept则告诉服务器“我期望拿到JSON”,部分接口会根据Accept字段决定返回格式。
有个细节要强调:不要用浏览器里复制出来的完整Cookie。因为Cookie通常包含会话信息,你手动复制一次之后,如果目标网站更新了会话,旧Cookie就会失效,而且Cookie里的某些字段(如sessionid)是绑定IP的,换网络环境就废了。这个案例里接口不强制要求登录态,所以完全不传Cookie,干净利落。
2.2 搜索参数的结构化封装
无加密接口的参数通常很直白,比如这样:
GET https://www.example-music.com/api/search? keyword=海阔天空&page=1&pageSize=20&type=song参数不多,但设计代码时不能直接拼字符串,而是用字典来管理,这样后期改参数、加字段、做循环都方便。我习惯把参数设计写成一个函数:
def build_params(keyword, page=1, page_size=20): return { "keyword": keyword, "page": page, "pageSize": page_size, "type": "song" }这样设计的理由很简单:搜索接口必然涉及多页抓取和关键词轮换,参数做成函数后,每次调用只需传不同的keyword和page,逻辑清晰且不容易出错。
这里还有一个小细节——关键词的URL编码问题。当你用requests的params参数时,requests会自动帮你做URL编码,所以中文关键词不用担心。但如果你习惯手动拼接URL,就必须用urllib.parse.quote处理,否则服务器拿到的是乱码或直接400。
参数里的type=song是接口约定的搜索类型,有些网站支持song、album、artist、playlist等多种类型。写代码时建议把类型也做成参数,方便后续扩展。
2.3 请求会话的管理
虽然这个接口不需要登录,我依然推荐用requests.Session()而不是裸的requests.get()。为什么?
Session有两个好处:
- 自动管理Cookie:如果搜索过程中某个响应返回了Set-Cookie,Session会自动记录并在后续请求中带上,避免因缺少Cookie被限制。
- 连接复用:Session底层使用HTTP连接池,多次请求时不需要重复建立TCP连接,速度会快很多。做多页抓取时,这个性能差异很明显。
session = requests.Session() session.headers.update(headers) resp = session.get(url, params=build_params("海阔天空", page=1))多页抓取时只需要循环调用,比如:
for page in range(1, 6): params = build_params("海阔天空", page=page) resp = session.get(url, params=params, timeout=10) # 处理数据 time.sleep(0.5) # 控制请求频率,避免给对方服务器造成压力这里的time.sleep(0.5)是很多新手容易忽略的。爬虫写出来能跑是一回事,跑起来不把对方服务器搞挂是另一回事。尤其是无加密接口,通常意味着服务端没有做严格的风控,但这不代表你可以无限速地请求。给每个请求之间加一个0.3到1秒的延时,是对目标网站的尊重,也是让脚本能长期稳定运行的保障。
3. 核心代码实现与解析
3.1 主流程:请求、解析、保存
先放完整的主流程代码,再拆开讲。这是我在实际项目中使用的版本,经过多次验证,稳定性和健壮性都有保障。
import requests import json import time import csv from urllib.parse import quote BASE_URL = "https://www.example-music.com/api/search" 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://www.example-music.com/", "Accept": "application/json, text/plain, */*" } def build_params(keyword, page=1, page_size=20): return { "keyword": keyword, "page": page, "pageSize": page_size, "type": "song" } def fetch_page(session, keyword, page): resp = session.get(BASE_URL, params=build_params(keyword, page), timeout=10) resp.raise_for_status() return resp.json() def parse_song_list(data): songs = [] # 假设返回结构是 data.list 下挂歌曲数组 for item in data.get("data", {}).get("list", []): song = { "song_id": item.get("id"), "title": item.get("name"), "artist": item.get("artist", ""), "album": item.get("album", ""), "duration": item.get("duration", 0), "play_url": item.get("playUrl", "") } songs.append(song) return songs def save_to_csv(songs, filename): with open(filename, "a", newline="", encoding="utf-8-sig") as f: writer = csv.DictWriter(f, fieldnames=["song_id", "title", "artist", "album", "duration", "play_url"]) if f.tell() == 0: writer.writeheader() writer.writerows(songs) def main(): keyword = "海阔天空" session = requests.Session() session.headers.update(headers) all_songs = [] for page in range(1, 6): # 抓5页 try: data = fetch_page(session, keyword, page) except requests.RequestException as e: print(f"第{page}页请求失败: {e}") continue songs = parse_song_list(data) if not songs: print(f"第{page}页没有获取到数据,提前结束") break all_songs.extend(songs) print(f"第{page}页解析完成,共{len(songs)}条") time.sleep(0.5) if all_songs: save_to_csv(all_songs, "search_result.csv") print(f"共保存{len(all_songs)}条数据到 search_result.csv") else: print("未获取到任何数据") if __name__ == "__main__": main() print("脚本运行完毕,按任意键退出")这段代码看着不长,但涵盖了爬虫的核心链路:请求会话管理、参数构造、响应解析、数据落盘、异常处理、频率控制。我一个个拆开讲。
3.2 关键逻辑逐个说明
fetch_page函数:这里调用了resp.raise_for_status(),作用是当HTTP状态码不是200时主动抛出异常。很多新手会忽略这一步,直接resp.json(),如果服务器返回404或500,JSON解析直接崩溃,报错信息又看不懂。先判断状态码,才能拿到明确的错误提示。
parse_song_list函数:这个函数假定接口返回的JSON结构是data.list下面挂歌曲数组。不同网站的字段命名可能不同,但嵌套结构大同小异。写解析函数时有一点要特别注意:用.get()方法而不是下标访问["id"]。因为一旦某条数据缺字段,下标访问会直接KeyError中断整个程序,而.get()返回None,不会中断,你可以自己在后面做空值处理。
字段映射方面,我习惯把接口原始字段名转换成更语义化的英文名。比如接口返回name,我转成title,因为name在代码里太容易和其他东西混淆。playUrl转成play_url则是为了统一下划线风格。
save_to_csv函数:我用的是CSV格式而不是JSON。原因是CSV可以直接用Excel打开,方便人工查看和筛选。注意encoding="utf-8-sig"这个参数,如果只用utf-8,用Excel打开CSV时中文会乱码。加sig会让文件开头写入BOM头,Excel就能正确识别。这个小坑,我见过很多人踩过。
3.3 分页抓取的边界处理
分页逻辑是搜索类爬虫最容易出问题的地方。常见情况有三种:
- 请求失败:某页超时或返回500,不能直接让整个程序崩溃,应该捕获异常并跳过。
- 数据为空:某页返回的列表为空,说明已经翻到底了,此时应该break退出循环,而不是继续空转。
- 数据重复:有些接口在翻页时会有数据重叠,需要在主流程里做去重。
我在代码里同时处理了前两种。再去重方面,一个小技巧是用song_id作为唯一标识,维护一个集合:
seen_ids = set() for song in songs: if song["song_id"] not in seen_ids: seen_ids.add(song["song_id"]) all_songs.append(song)这样即使接口偶发返回重复数据,最终保存的文件里也不会出现重复条目。
3.4 关于JSON响应里的字段提取方式
再补充一个解析细节。有时候接口返回的JSON层级很深,比如data.list[0].privileges[0].songId这种路径,如果直接用一层层.get()去取,代码会非常啰嗦。我常用的做法是写一个通用的深度取值函数:
def deep_get(data, path, default=None): keys = path.split(".") cur = data for key in keys: if isinstance(cur, dict): cur = cur.get(key) if cur is None: return default else: return default return cur使用方式:deep_get(item, "privileges.0.songId"),遇到中间层级缺失就返回None。这种写法在字段层级深的接口里特别省力,推荐收藏。
4. 常见问题与排查技巧实录
4.1 高频报错的定位与解决
无加密接口不代表不会出问题,我整理了几个高频报错,直接做成速查表:
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 返回403 | UA被识别或缺少Referer | 换真实浏览器UA,补上Referer字段 |
| JSON解析报错 | 实际返回的是HTML而非JSON | 先resp.text[:200]打印前200字符确认内容 |
| 中文乱码 | 编码未指定或指定错误 | resp.encoding统一为utf-8,CSV保存用utf-8-sig |
| 抓取到空列表 | 关键词无结果或频率限制 | 换个词测试,或加延时重试 |
| 请求超时 | 对方服务器响应慢或IP被临时限制 | 加长timeout,加重试机制 |
403问题是我遇到最多的。很多人以为无加密接口不需要headers,直接用裸requests请求,结果对方服务器一看到Python默认UA(python-requests/2.31.0)就ban了。解决方案很简单,把UA伪装成Chrome即可。
4.2 重试机制的简单实现
一个健壮的爬虫脚本必须有重试逻辑。搜索接口偶尔会超时或返回5xx,如果没有重试,一晚上跑下来可能就中断了。我常用一个装饰器实现简单的重试:
import time from functools import wraps def retry(max_retries=3, delay=1): def decorator(func): @wraps(func) def wrapper(*args, **kwargs): for i in range(max_retries): try: return func(*args, **kwargs) except Exception as e: print(f"第{i+1}次尝试失败: {e}") if i < max_retries - 1: time.sleep(delay) raise RuntimeError("超过最大重试次数") return wrapper return decorator然后在fetch_page函数上加上@retry(3, 2)即可。这样一个简单的装饰器,就能让脚本的稳定性提升一大截。
4.3 脚本参数化:让不懂代码的人也能用
写爬虫的人都有一个习惯:写出来的脚本不仅自己要能跑,还得让同事、朋友也能跑。最直接的方式就是把关键词、页数这些变量改成命令行参数。
这里顺带提一下Python给py脚本传参的标准做法:
import sys def main(): if len(sys.argv) < 2: print("用法: python search_music.py <关键词> [页码数]") return keyword = sys.argv[1] pages = int(sys.argv[2]) if len(sys.argv) > 2 else 5 # 后续逻辑...用命令行传参的好处是:不改代码就能换关键词、换抓取深度。比如跑完“海阔天空”想换“光辉岁月”,直接python search_music.py 光辉岁月 3,不需要打开编辑器改代码。这个习惯特别值得养成,因为实际工作中,需求方经常临时要换关键词,用命令行参数就能省去反复沟通的麻烦。
4.4 打包成exe:给不会装Python的人用
说到让不懂代码的人也能用,就绕不开把py转exe这个话题。搜索接口的脚本,通常写完之后是拿给别人用的。对方可能连Python都没装,你总不能让他装个环境再跑脚本。我的做法是用PyInstaller打包成单文件exe。
打包命令很简单:
pip install pyinstaller pyinstaller -F -w search_music.py参数说明:
-F:打包成单文件,生成一个exe,方便分发。-w:不显示控制台窗口,适合给普通用户用。但如果调试,先不加-w,方便看输出。
打包完在dist目录下找到search_music.exe,双击就能运行。注意:如果你的脚本用了命令行参数,加-w之后用户看不到控制台输出,交互体验会受影响,这种情况下建议保留控制台窗口。
有一个比较大的坑:PyInstaller打包的exe会被某些杀毒软件误报。这是因为PyInstaller生成的程序包含Python运行时,特征比较明显。实际解决方法是:在代码里减少不必要的第三方库依赖(比如能用requests就不要用scrapy),同时建议用pipenv或venv创建干净环境后再打包,能有效减小体积、降低误报率。
4.5 Python版本升级带来的兼容性问题
我身边已经有朋友把Python升到了3.12,结果发现之前写的脚本跑不起来了。这确实是个很现实的问题。Python 3.12对某些旧语法、旧库做了清理,常见的坑有三个:
distutils被移除:如果代码里用了from distutils import util,在3.12下直接报ModuleNotFoundError。解决方案是改用setuptools或packaging。asyncio相关API变化:异步爬虫脚本在3.12下可能需要改事件循环写法。- 某些第三方库还没适配3.12:比如部分老版本的
lxml、scrapy在3.12下编译失败。
针对这个案例的脚本,因为只用了requests和csv这两个基础库,在3.12下运行没有任何问题。但如果你的旧脚本在别的爬虫项目里,建议先检查依赖库是否支持3.12,再决定是否升级。
5. 接口抓取的进阶扩展思路
5.1 从单关键词到批量关键词
实际需求很少是只搜一个词的。比如你想做某段时间的热门歌曲分析,可能需要一次性跑几百个关键词。把脚本改成批量模式很简单:
keywords = ["海阔天空", "光辉岁月", "真的爱你", "不再犹豫"] all_songs = [] for keyword in keywords: for page in range(1, 3): data = fetch_page(session, keyword, page) songs = parse_song_list(data) # 给每个结果加上关键词来源,方便后续分析 for song in songs: song["keyword"] = keyword all_songs.extend(songs) time.sleep(0.5)这里给每条数据加上keyword来源字段是很有用的技巧。因为多个关键词的搜索结果会重叠,知道数据是从哪个词搜出来的,后面做数据清洗时会省很多力气。
5.2 数据字段的扩展与结构化
搜索接口通常不只是返回歌曲名和歌手,还可能包含专辑封面、歌词片段、热度值、发行时间等信息。做数据分析时,这些字段往往比歌名更有价值。我的建议是:
- 把
duration转成mm:ss格式方便人工阅读。 - 如果接口有
hot或playCount字段,保留下来,后续可以做热度排序。 - 如果接口返回了
albumCover图片URL,可以批量下载缩略图,用于制作海报墙。
当然,字段越多,存储结构就越需要考虑。当数据量超过几千条时,CSV就不够看了,建议改用SQLite:
import sqlite3 conn = sqlite3.connect("music.db") cursor = conn.cursor() cursor.execute(""" CREATE TABLE IF NOT EXISTS songs ( id INTEGER PRIMARY KEY AUTOINCREMENT, song_id TEXT, title TEXT, artist TEXT, album TEXT, duration INTEGER, play_url TEXT, keyword TEXT ) """)SQLite的好处是查询方便,支持SQL语法,数据量上万条也毫无压力。更重要的是,它不需要安装额外的数据库服务,Python自带的sqlite3库就能操作。
5.3 接口频率控制的工程化处理
最后再说一下频率控制。无加密接口通常没有官方限流说明,但我们自己要有点分寸。我的经验值是:单线程、间隔0.5秒、每1000条数据暂停5分钟。这个节奏既不会给对方服务器造成压力,也不会因为请求太慢而浪费时间。
有的场景需要更快,比如每天要抓几万条数据。这种情况下可以考虑:
- 用
ThreadPoolExecutor做多线程,但线程数控制在5以内。 - 加随机延时,比如
time.sleep(random.uniform(0.3, 0.8)),模拟人工操作的节奏。 - 做好日志记录,每完成100条记录一下进度,方便中断后续传。
多线程的代码并不复杂,但有个前提:只有确认接口无加密、无严格防盗链时,才建议开线程。如果接口有IP频率限制,多线程只会加速被封。
5.4 如何判断一个接口是否加密
最后补充一个判断接口是否加密的通用方法,比较实用。打开开发者工具的Network面板,找到目标请求,看它的Headers和Payload:
- 如果Query String Parameters里出现
sig、sign、token、timestamp这类字段,基本可以判定是加密接口。 - 如果请求头里有自定义字段,比如
x-token、authorization,通常也需要动态生成。 - 如果URL里能看到明文参数,且headers里只有标准的UA、Referer、Accept,那就是无加密接口。
如果真的遇到加密接口,思路也明确,分为三步:先在Sources里搜索关键字(比如sig、sign)找到生成函数,然后利用浏览器调试工具在函数处打断点,最后通过JSON.stringify或Object.entries把参数结构导出。这套流程是逆向加密接口的通用方法论,但那是另一个话题了。先用好无加密接口,把基本功打扎实,再考虑升级打怪。
说实话,写这种无加密接口的爬虫,最主要的收获不是代码本身,而是完整走了一遍“分析、构造、解析、落盘、异常处理”的标准流程。这个流程在任何爬虫项目里都是通用的。等你把这套流程跑顺了,以后再遇到加密接口,至少知道从哪里下手,不会一头雾水。