
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海阔天空page1limit20这样的明文参数且没有签名、没有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海阔天空page1pageSize20typesong参数不多但设计代码时不能直接拼字符串而是用字典来管理这样后期改参数、加字段、做循环都方便。我习惯把参数设计写成一个函数def build_params(keyword, page1, page_size20): return { keyword: keyword, page: page, pageSize: page_size, type: song }这样设计的理由很简单搜索接口必然涉及多页抓取和关键词轮换参数做成函数后每次调用只需传不同的keyword和page逻辑清晰且不容易出错。这里还有一个小细节——关键词的URL编码问题。当你用requests的params参数时requests会自动帮你做URL编码所以中文关键词不用担心。但如果你习惯手动拼接URL就必须用urllib.parse.quote处理否则服务器拿到的是乱码或直接400。参数里的typesong是接口约定的搜索类型有些网站支持song、album、artist、playlist等多种类型。写代码时建议把类型也做成参数方便后续扩展。2.3 请求会话的管理虽然这个接口不需要登录我依然推荐用requests.Session()而不是裸的requests.get()。为什么Session有两个好处自动管理Cookie如果搜索过程中某个响应返回了Set-CookieSession会自动记录并在后续请求中带上避免因缺少Cookie被限制。连接复用Session底层使用HTTP连接池多次请求时不需要重复建立TCP连接速度会快很多。做多页抓取时这个性能差异很明显。session requests.Session() session.headers.update(headers) resp session.get(url, paramsbuild_params(海阔天空, page1))多页抓取时只需要循环调用比如for page in range(1, 6): params build_params(海阔天空, pagepage) resp session.get(url, paramsparams, timeout10) # 处理数据 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, page1, page_size20): return { keyword: keyword, page: page, pageSize: page_size, type: song } def fetch_page(session, keyword, page): resp session.get(BASE_URL, paramsbuild_params(keyword, page), timeout10) 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, encodingutf-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或500JSON解析直接崩溃报错信息又看不懂。先判断状态码才能拿到明确的错误提示。parse_song_list函数这个函数假定接口返回的JSON结构是data.list下面挂歌曲数组。不同网站的字段命名可能不同但嵌套结构大同小异。写解析函数时有一点要特别注意用.get()方法而不是下标访问[id]。因为一旦某条数据缺字段下标访问会直接KeyError中断整个程序而.get()返回None不会中断你可以自己在后面做空值处理。字段映射方面我习惯把接口原始字段名转换成更语义化的英文名。比如接口返回name我转成title因为name在代码里太容易和其他东西混淆。playUrl转成play_url则是为了统一下划线风格。save_to_csv函数我用的是CSV格式而不是JSON。原因是CSV可以直接用Excel打开方便人工查看和筛选。注意encodingutf-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, defaultNone): 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 高频报错的定位与解决无加密接口不代表不会出问题我整理了几个高频报错直接做成速查表现象可能原因解决方案返回403UA被识别或缺少Referer换真实浏览器UA补上Referer字段JSON解析报错实际返回的是HTML而非JSON先resp.text[:200]打印前200字符确认内容中文乱码编码未指定或指定错误resp.encoding统一为utf-8CSV保存用utf-8-sig抓取到空列表关键词无结果或频率限制换个词测试或加延时重试请求超时对方服务器响应慢或IP被临时限制加长timeout加重试机制403问题是我遇到最多的。很多人以为无加密接口不需要headers直接用裸requests请求结果对方服务器一看到Python默认UApython-requests/2.31.0就ban了。解决方案很简单把UA伪装成Chrome即可。4.2 重试机制的简单实现一个健壮的爬虫脚本必须有重试逻辑。搜索接口偶尔会超时或返回5xx如果没有重试一晚上跑下来可能就中断了。我常用一个装饰器实现简单的重试import time from functools import wraps def retry(max_retries3, delay1): 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第{i1}次尝试失败: {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就不够看了建议改用SQLiteimport 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把参数结构导出。这套流程是逆向加密接口的通用方法论但那是另一个话题了。先用好无加密接口把基本功打扎实再考虑升级打怪。说实话写这种无加密接口的爬虫最主要的收获不是代码本身而是完整走了一遍“分析、构造、解析、落盘、异常处理”的标准流程。这个流程在任何爬虫项目里都是通用的。等你把这套流程跑顺了以后再遇到加密接口至少知道从哪里下手不会一头雾水。