有一回,一个做内容运营的朋友找到我,说想做一份“最近大家都在听什么”的主题海报,需要网易云热门歌单的封面做素材,还得整理成清单。我一开始也打算去解析网页 HTML,折腾了二十多分钟后发现一个特别朴素的道理:与其跟 DOM 里的动态渲染死磕,不如直接去找背后的接口。这篇文章就从这个项目出发,讲清楚怎么用 Python 把网易云热门歌单和封面批量拿下来。整套流程没有重型框架,代码量也不大,适合 Python 入门到进阶阶段的人当练手项目,也适合运营、内容从业者直接照着抄。
这篇文章不会只丢一份代码给你,我会把我选接口的理由、踩过的坑、以及那些文档里不会写的细节全部摊开讲。毕竟这种小项目真正值钱的地方,不在代码本身,而在排查问题的思路。
1. 为什么选“热门歌单”这个项目练手:接口比网页好啃得多
1.1 网页解析为什么让人抓狂
很多人一开始的思路就是写个 requests 去请求网易云音乐首页,然后发现拿回来的 HTML 里根本没有歌单数据。这不是你代码写错了,而是网易云首页的歌单列表全是 JavaScript 动态渲染的,requests 拿到的只是空壳子。
于是有人转去用 Selenium 模拟浏览器,那当然能拿到内容,但代价很大:要装浏览器驱动,启动速度慢,还容易被识别。更关键的是,网易云的歌手页、歌单页、排行榜页,其实背后都挂着一批 JSON 接口,网页只是把接口的返回值渲染成了页面。我们只要找到这批接口,就能绕过整个浏览器渲染的过程。
我在实际项目里一直坚持一个原则:凡是网页上能看到的数据,优先怀疑背后有接口,而不是第一反应去解析 HTML。这不是说解析 HTML 不行,而是接口方案更稳定、数据更干净、代码也更短。尤其是这种列表型数据,接口返回的 JSON 可以直接进 pandas、进 Excel、进数据库,省掉一遍清洗。
1.2 把目标拆成“三步走”
整个“获取网易云热门歌单及封面”的需求,拆开以后其实非常清晰:
- 拿到歌单分类列表。网易云的热门歌单分“全部”“华语”“流行”“古风”等一堆类别,第一步先拿分类,是为了后面可以按类别定向抓取。
- 拿到某个分类下的热门歌单列表。这一步会得到一批歌单,每个歌单都有 id、名称、封面图地址、播放量、歌曲数量、创建者等信息。
- 下载封面图。拿到封面图的 URL 后批量下载到本地,同时把歌单元数据保存成 JSON 或 CSV,方便后续做分析。
这里有个关键点容易被新手忽略:歌单 id 是整个流程的“通用钥匙”。不管是拼歌单链接、查歌单详情、拉歌曲列表,还是下载封面,全都靠这个 id。所以第一步拿到列表时,别的字段可以先不管,id 一定要存好。
还有一个选型上的建议:网易云音乐有些接口走的是 weapi 加密通道,需要做 AES 加密、RSA 加密,代码写起来又长又繁琐,而且官方经常调整加密细节。这个项目我全部选用的是老牌/api/开头明文接口,比如/api/playlist/hot、/api/playlist/detail,不需要处理加密逻辑。能用明文接口解决的问题,就尽量不要碰加密那套。选型不是越复杂越好,而是越贴合需求越好。当然,如果后续某个明文接口被关闭或加了风控,你再去抓包看当前实际请求的是哪个接口,思路是一样的。
2. 开工前的环境准备:版本、依赖、请求头、Cookie 一个都不能少
2.1 Python 版本与依赖库选择
这个项目对 Python 版本要求不高,3.8 及以上都行。如果你用的是 3.10 或 3.12,也完全没问题。核心依赖就两个:requests用来发 HTTP 请求,json和csv处理数据时用。
pip install requests如果你在安装第三方库时速度很慢,记得用国内镜像源,别硬等国外源:
pip install requests -i https://pypi.tuna.tsinghua.edu.cn/simple2.2 请求头伪装:没有 UA 的请求基本白给
这是新手最容易踩的坑。很多人的第一版代码只写了requests.get(url),然后就收到 403 或者奇怪的返回。原因很简单,服务器看到你连个浏览器标识都没有,直接认定你是爬虫,拒之门外。
我在项目里习惯把这几个请求头全部带上:
HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", "Referer": "https://music.163.com/", "Accept": "application/json, text/plain, */*", "Accept-Language": "zh-CN,zh;q=0.9", }User-Agent用来伪装成浏览器,Referer用来告诉服务器请求是从网易云页面内部发起的。很多接口校验了 Referer,不带的话很容易被拦。
2.3 Cookie 从哪来,怎么带上
网易云的部分接口在频繁请求后会对未登录状态做风控,返回{"code": 403}之类的结果。这时候带上自己的登录 Cookie 能明显降低概率。
获取方法很简单:浏览器打开 music.163.com,登录账号,按 F12 打开开发者工具,切到 Network(网络)面板,刷新一下页面,随便点开一个接口请求,找到请求头里的 Cookie 字符串,完整复制出来。
HEADERS["Cookie"] = "你的Cookie字符串"这里说个题外话,Cookie 相当于你的账号凭证,千万别把它提交到 Github 公开仓库里,否则别人可以用你的身份操作账号相关的接口。我在平时写代码时会把 Cookie 放到单独的文件里,并记得加入.gitignore。
2.4 项目目录结构建议
开工前先把目录结构想清楚,后面代码会清爽很多:
netease_playlist/ ├── main.py # 主流程:抓列表 -> 保存元数据 -> 下载封面 ├── covers/ # 封面图片存放目录 └── data/ # JSON/CSV 文件输出目录3. 网易云热门歌单的核心接口拆解:分类、列表、详情、封面
3.1 三个关键接口的调用逻辑
先放一张接口对照表,这是整个项目的根基,后面写代码时你会反复用到:
| 接口 | 作用 | 返回 JSON 里的关键字段 |
|---|---|---|
/api/playlist/catalogue | 获取全部歌单分类 | sub数组,每个元素含name、category |
/api/playlist/hot?cat=全部 | 获取某个分类的热门歌单列表 | playlists数组,含id、name、coverImgUrl、playCount |
/api/playlist/detail?id=xxx | 获取某个歌单的详细信息 | result对象,含tracks、trackIds、tags |
这三个接口的请求方式都是 GET,返回的都是标准 JSON,解析起来非常舒服。
3.2 你可能会踩的字段坑:播放量显示为 0,封面地址带参数
第一个坑是播放量。/api/playlist/hot返回的列表里,playCount字段在大多数情况下是正常的,但如果你遇到某些歌单的播放量是 0 或者null,不要慌,这是接口字段对部分数据做了截断。处理方法很简单:单独请求一次/api/playlist/detail?id=xxx,详情接口里的playCount基本是准的。
第二个坑在封面地址。接口返回的coverImgUrl往往是这样的格式:
http://p1.music.126.net/xxxxx.jpg?param=140y140后面那个?param=140y140是 CDN 的尺寸裁剪参数。如果不处理,下载下来就是 140×140 的小图。想拿高清大图,直接把这个参数去掉,或者改成?param=800y800,就能拿到对应尺寸的封面图。
3.3 拿到歌单 ID 是第一步,后面能做的事太多
很多人拿到歌单列表后就不知道下一步干什么了。实际上,歌单 id 可以拼接出很多东西:网页链接https://music.163.com/#/playlist?id=xxx、歌曲列表接口、封面图地址、歌单收藏数、评论数等等。如果你后面想做一个“基于歌单风格标签的推荐工具”,同样是以这个 id 为中心去扩展数据。
我在做这个项目时,最深的感受是:爬虫项目一半时间花在数据获取上,另一半时间花在数据梳理上。拿到 id、名称、封面地址、播放量之后,先想清楚自己要存哪些字段,再动手写代码。我一般会存 id、name、playCount、trackCount、creator、tags、coverImgUrl 这几个字段,既够用又不会太臃肿。
4. 完整代码实现:从抓列表到出封面,一步步带注释
4.1 第一步:获取歌单分类
先写一个获取分类的函数。这个函数主要是为后续“按分类抓取”做铺垫,如果你只想要“全部”类别的热门歌单,它可以省略。但对一个可复用的脚本来说,分类获取器是值得保留的。
import requests HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", "Referer": "https://music.163.com/", } def get_catalogue(): """获取网易云歌单分类列表,返回分类名列表""" url = "https://music.163.com/api/playlist/catalogue" resp = requests.get(url, headers=HEADERS, timeout=10) data = resp.json() if data.get("code") != 200: raise RuntimeError(f"获取分类失败: {data}") categories = [item.get("name") for item in data.get("sub", [])] return categories if __name__ == "__main__": cats = get_catalogue() print(cats[:10])这里有个细节:我只取了sub里的 name,但实际返回的每个分类元素里还有category字段,这个字段可以用作后续请求的参数,需要的话一并提取出来。
4.2 第二步:批量获取热门歌单列表
这是核心步骤。按分类请求热门歌单列表,然后遍历所有歌单,提取关键字段。
import json import time def get_hot_playlists(cat="全部", limit=30): """ 获取指定分类下的热门歌单 :param cat: 分类名,默认全部 :param limit: 单次获取数量,建议 30-50,一次别贪多 :return: 歌单列表 """ url = "https://music.163.com/api/playlist/hot" params = {"cat": cat, "limit": limit} resp = requests.get(url, params=params, headers=HEADERS, timeout=10) data = resp.json() if data.get("code") != 200: # 风控时返回的 code 可能是 403,这里做防御性判断 print(f"[警告] {cat} 分类下获取失败,接口返回: {data.get('code')}") return [] playlists = [] for item in data.get("playlists", []): playlists.append({ "id": item.get("id"), "name": item.get("name"), "coverImgUrl": item.get("coverImgUrl"), "playCount": item.get("playCount"), "trackCount": item.get("trackCount"), "creator": item.get("creator", {}).get("nickname") if item.get("creator") else "", "tags": item.get("tags", []), }) return playlists注意两点:第一,params里的cat参数必须 URL 编码,但 requests 库会自动帮你处理,所以中文分类名直接传进去就行。第二,接口返回的creator是一个嵌套对象,我直接用.get()链式取值,避免键不存在时报错。
4.3 第三步:歌单详情补全 + 封面落盘 + 元数据保存
列表接口拿到的数据已经能用,但某些字段在部分歌单上会被截断(比如播放量),所以对重点歌单再做一次详情补全是值得的。同时这个函数也负责把封面图下载到covers/目录。
import os def get_playlist_detail(playlist_id): """获取歌单详情,补充更完整的信息""" url = f"https://music.163.com/api/playlist/detail?id={playlist_id}" resp = requests.get(url, headers=HEADERS, timeout=10) data = resp.json() if data.get("code") != 200: print(f"[警告] 歌单 {playlist_id} 详情获取失败") return None result = data.get("result", {}) return { "id": result.get("id"), "name": result.get("name"), "playCount": result.get("playCount"), "trackCount": result.get("trackCount"), "updateTime": result.get("updateTime"), "trackIds": [t.get("id") for t in result.get("trackIds", [])], } def download_cover(cover_url, save_path): """下载封面图,保存到本地""" img_resp = requests.get(cover_url, headers=HEADERS, timeout=15) if img_resp.status_code != 200: print(f"[警告] 封面下载失败: {cover_url}") return False with open(save_path, "wb") as f: f.write(img_resp.content) return True这里有个封面后缀名的坑:接口返回的coverImgUrl可能以.jpg结尾,也可能以.png或.webp结尾。我建议下载时不要硬编码扩展名,而是根据Cover-Url去掉参数后的路径后缀来生成文件名。比如这样:
from urllib.parse import urlparse def get_image_ext(url): path = urlparse(url).path ext = os.path.splitext(path)[1] return ext if ext in (".jpg", ".png", ".webp") else ".jpg"然后再拼出保存路径:
ext = get_image_ext(item["coverImgUrl"]) save_path = f"covers/{item['id']}{ext}"4.4 主流程串起来:加进度提示、异常兜底、避免崩盘
写爬虫不能假设网络永远顺畅。我在主流程里加了重试和异常捕获,核心思路是:单个失败不中断整体,但要有日志记录。
def safe_request(func, *args, retries=3, **kwargs): """简易重试机制:请求失败后最多重试 3 次""" for attempt in range(1, retries + 1): try: return func(*args, **kwargs) except Exception as e: print(f"[第{attempt}次重试] {func.__name__} 出错: {e}") time.sleep(2 * attempt) return None def main(): os.makedirs("covers", exist_ok=True) os.makedirs("data", exist_ok=True) all_playlists = [] categories = ["全部", "华语", "流行", "民谣", "摇滚", "电子"] for cat in categories: print(f"正在获取分类 [{cat}] 的热门歌单...") playlists = safe_request(get_hot_playlists, cat, limit=20) if not playlists: continue all_playlists.extend(playlists) # 对每个歌单下载封面,并补充详情 for item in playlists: detail = safe_request(get_playlist_detail, item["id"]) if detail and detail.get("playCount"): item["playCount"] = detail["playCount"] ext = get_image_ext(item["coverImgUrl"]) save_path = f"covers/{item['id']}{ext}" if not os.path.exists(save_path): safe_request(download_cover, item["coverImgUrl"], save_path) time.sleep(0.5) # 控制请求间隔,礼貌爬取 with open("data/playlists.json", "w", encoding="utf-8") as f: json.dump(all_playlists, f, ensure_ascii=False, indent=2) print(f"共获取 {len(all_playlists)} 个歌单,封面已保存到 covers/ 目录")这里我用了safe_request包装函数,无论是最开始的列表请求还是后面的详情、封面下载,都走同一个重试逻辑。请求间隔设置了 0.5 秒,这个数值不是随意拍的。对于单机小批量爬虫来说,0.5 到 1 秒的间隔既能保证速度,又不容易触发风控。
5. 请求高频返回 403/418?我踩过的坑和最终解法
5.1 完整排查链路:从超时到封 IP
写这个项目时我遇到过一段特别糟心的经历:前 50 个请求一切正常,第 51 个开始,接口陆续返回 403。这里把排查过程写出来,因为这个链路比最终答案重要得多。
最开始我以为是 IP 被临时拉黑了,于是尝试加大 sleep 到 3 秒,但仍然偶尔失败。接着我怀疑是请求头不够,于是补上了完整的浏览器请求头,甚至把Accept、Accept-Language都填齐,问题缓解了一部分,但没根除。
后来我抓包对比了浏览器发出去的真实请求,发现浏览器请求在访问详情接口时还带了一个关键字段:sec-fetch-dest、sec-fetch-mode、sec-fetch-site。我把这一组sec-fetch开头的字段也补上后,403 出现的频率大幅下降。最终稳定的方案是:
- 请求头必须完整,尽量模仿浏览器的真实请求头,缺哪个补哪个。
- 带上登录 Cookie,未登录状态下风控阈值更低。
- 请求之间加随机延时,不要每次都固定 sleep 0.5 秒,用
random.uniform(0.5, 1.5)模拟人类操作节奏。 - 单个分类失败不要 panic,跳过并继续下一个,最后再补跑。
5.2 频率控制:sleep 不是浪费,是保命
很多人嫌 sleep 拖慢速度,一删了之,结果就是被封 IP。我在做这类项目时有个习惯:请求频率宁慢勿快。你要想清楚,一次性把几千个歌单全拉下来其实很少是刚需,多数场景下几百个歌单已经能说明问题了。既然数据量不需要那么大,何必冒着被封的风险去高频请求。
这里分享一个实用技巧:输出进度提示,让你在安全感上先稳住。每完成 10 个歌单就打印一次当前分类、当前序号、累计时长,这样即使程序跑得慢,你也知道它没卡死。
5.3 图片和接口防盗链问题
封面图下载偶尔会返回 403,这不是 IP 问题,而是图片服务器校验了Referer。只要给封面下载请求也加上Referer: https://music.163.com/,问题立刻解决。
我在下载封面时也遇到过返回内容不是图片的情况,比如接口返回了一段 JSON 错误提示,或者返回了一个空字节。所以下载完成后可以简单检查一下文件头:
def is_valid_image(file_path): """简易判断文件是否为有效图片""" with open(file_path, "rb") as f: head = f.read(3) return head in (b"\xff\xd8\xff", b"\x89PN")b"\xff\xd8\xff"是 JPEG 的文件头,b"\x89PN"是 PNG 的文件头。用这个函数过滤掉下载失败的占位文件,保证covers/目录里存的都是真能看的图。
6. 拿到数据之后:可视化分析、歌单推荐小工具与合规边界
6.1 播放量排序与风格标签词频统计
数据拿到手后,第一件事永远是做排序。按playCount降序排列,取 Top 20,输出 CSV,这一步非常快:
import csv playlists = json.load(open("data/playlists.json", encoding="utf-8")) sorted_playlists = sorted(playlists, key=lambda x: x.get("playCount") or 0, reverse=True) with open("data/top_playlists.csv", "w", newline="", encoding="utf-8-sig") as f: writer = csv.DictWriter(f, fieldnames=["id", "name", "playCount", "creator", "tags"]) writer.writeheader() writer.writerows(sorted_playlists[:20])注意写入 CSV 时用utf-8-sig编码,否则用 Excel 打开 CSV 会乱码,这个坑很经典。
如果要分析“哪些风格更热门”,可以遍历所有歌单的 tags 字段,做一个词频统计:
from collections import Counter tag_counter = Counter() for item in playlists: for tag in item.get("tags", []): tag_counter[tag] += 1 print(tag_counter.most_common(10))后面如果要做柱状图可视化,用 matplotlib 画播放量 Top 20 的条形图时,注意设置横坐标标签旋转,否则歌单名字一长就全部叠在一起。代码就一行:
plt.xticks(rotation=45, ha="right")6.2 扩展方向:按歌单 ID 批量获取歌曲信息
如果你不满足于只拿歌单和封面,还可以用歌单 ID 去获取歌曲信息。网易云有个接口/api/song/detail?ids=...,括号里传逗号分隔的歌曲 ID,一次最多查 1000 首。
详情接口返回的trackIds就是歌单内所有歌曲的 ID,你可以把多个歌单的 trackIds 收集起来,分批请求歌曲详情,就能获得歌曲名、歌手名、专辑名等信息。这个数据量一多,就能做用户口味画像:哪些歌手出现频率最高,哪些歌曲在多个热歌单里重复出现。更进一步,如果你想做内容推荐,可以对歌单的风格标签做相似度计算,甚至用层次聚类之类的算法把歌单分成几大类,这些都是同一个数据源可以延伸出来的方向。不过那是另一个项目了,先把基础数据管道搭好才是关键。
6.3 爬取礼仪与合规边界
写到这里,还是想认真说几句边界问题。我这段代码的定位是个人学习、内容调研、数据整理,不是让你拿去批量下载付费内容,更不是让你拿来刷播放量、刷收藏数。网易云的热门歌单接口是公开的,但公开不意味着可以无限调用。
我的底线是:
- 单次请求量控制住,几百个歌单足够练手和分析,不要一口气拉几万条。
- 请求频率有限制,延时必须在 0.5 秒以上,遇到 403 就停手,不要换 IP 硬刚。
- 数据不商用,整理出来的歌单数据如果在公司项目里用,先确认版权和平台规则,不要直接拿来交差。
- 代码发布时清理敏感信息,Cookie 不要提交到开源仓库。
这些都是吃过亏之后总结出来的经验,不只是“规范要求”四个字的问题,而是涉及账号安全、平台封禁和潜在法律风险的现实问题。
说回项目本身。这个脚本跑完,你会得到一个挺有成就感的成果:covers/里躺着几十张热门歌单封面,data/里有一份结构化的歌单清单。我第二次做类似项目时,把获取逻辑封装成了一个远程调用的函数,不再关心具体接口地址,只要传分类名进去,返回的就是清洗好的列表。这种“把脏活封装起来,把接口留给未来”的做法,才是这类小项目最值得沉淀的东西。
最后再分享一个小技巧:如果你在代码运行过程中发现某个分类一直拿不到数据,不要急着改代码,先用浏览器手动访问一下这个分类页,看看页面是否正常。很多时候不是你的代码有问题,而是平台把某个分类或某个时间段的风控调高了。先用浏览器确认数据源是否活着,是排查一切爬虫问题的起点。