
有一回一个做内容运营的朋友找到我说想做一份“最近大家都在听什么”的主题海报需要网易云热门歌单的封面做素材还得整理成清单。我一开始也打算去解析网页 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;q0.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?idxxx获取某个歌单的详细信息result对象含tracks、trackIds、tags这三个接口的请求方式都是 GET返回的都是标准 JSON解析起来非常舒服。3.2 你可能会踩的字段坑播放量显示为 0封面地址带参数第一个坑是播放量。/api/playlist/hot返回的列表里playCount字段在大多数情况下是正常的但如果你遇到某些歌单的播放量是 0 或者null不要慌这是接口字段对部分数据做了截断。处理方法很简单单独请求一次/api/playlist/detail?idxxx详情接口里的playCount基本是准的。第二个坑在封面地址。接口返回的coverImgUrl往往是这样的格式http://p1.music.126.net/xxxxx.jpg?param140y140后面那个?param140y140是 CDN 的尺寸裁剪参数。如果不处理下载下来就是 140×140 的小图。想拿高清大图直接把这个参数去掉或者改成?param800y800就能拿到对应尺寸的封面图。3.3 拿到歌单 ID 是第一步后面能做的事太多很多人拿到歌单列表后就不知道下一步干什么了。实际上歌单 id 可以拼接出很多东西网页链接https://music.163.com/#/playlist?idxxx、歌曲列表接口、封面图地址、歌单收藏数、评论数等等。如果你后面想做一个“基于歌单风格标签的推荐工具”同样是以这个 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, headersHEADERS, timeout10) 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全部, limit30): 获取指定分类下的热门歌单 :param cat: 分类名默认全部 :param limit: 单次获取数量建议 30-50一次别贪多 :return: 歌单列表 url https://music.163.com/api/playlist/hot params {cat: cat, limit: limit} resp requests.get(url, paramsparams, headersHEADERS, timeout10) 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 fhttps://music.163.com/api/playlist/detail?id{playlist_id} resp requests.get(url, headersHEADERS, timeout10) 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, headersHEADERS, timeout15) 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 fcovers/{item[id]}{ext}4.4 主流程串起来加进度提示、异常兜底、避免崩盘写爬虫不能假设网络永远顺畅。我在主流程里加了重试和异常捕获核心思路是单个失败不中断整体但要有日志记录。def safe_request(func, *args, retries3, **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_okTrue) os.makedirs(data, exist_okTrue) all_playlists [] categories [全部, 华语, 流行, 民谣, 摇滚, 电子] for cat in categories: print(f正在获取分类 [{cat}] 的热门歌单...) playlists safe_request(get_hot_playlists, cat, limit20) 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 fcovers/{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, encodingutf-8) as f: json.dump(all_playlists, f, ensure_asciiFalse, indent2) 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, encodingutf-8)) sorted_playlists sorted(playlists, keylambda x: x.get(playCount) or 0, reverseTrue) with open(data/top_playlists.csv, w, newline, encodingutf-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(rotation45, haright)6.2 扩展方向按歌单 ID 批量获取歌曲信息如果你不满足于只拿歌单和封面还可以用歌单 ID 去获取歌曲信息。网易云有个接口/api/song/detail?ids...括号里传逗号分隔的歌曲 ID一次最多查 1000 首。详情接口返回的trackIds就是歌单内所有歌曲的 ID你可以把多个歌单的 trackIds 收集起来分批请求歌曲详情就能获得歌曲名、歌手名、专辑名等信息。这个数据量一多就能做用户口味画像哪些歌手出现频率最高哪些歌曲在多个热歌单里重复出现。更进一步如果你想做内容推荐可以对歌单的风格标签做相似度计算甚至用层次聚类之类的算法把歌单分成几大类这些都是同一个数据源可以延伸出来的方向。不过那是另一个项目了先把基础数据管道搭好才是关键。6.3 爬取礼仪与合规边界写到这里还是想认真说几句边界问题。我这段代码的定位是个人学习、内容调研、数据整理不是让你拿去批量下载付费内容更不是让你拿来刷播放量、刷收藏数。网易云的热门歌单接口是公开的但公开不意味着可以无限调用。我的底线是单次请求量控制住几百个歌单足够练手和分析不要一口气拉几万条。请求频率有限制延时必须在 0.5 秒以上遇到 403 就停手不要换 IP 硬刚。数据不商用整理出来的歌单数据如果在公司项目里用先确认版权和平台规则不要直接拿来交差。代码发布时清理敏感信息Cookie 不要提交到开源仓库。这些都是吃过亏之后总结出来的经验不只是“规范要求”四个字的问题而是涉及账号安全、平台封禁和潜在法律风险的现实问题。说回项目本身。这个脚本跑完你会得到一个挺有成就感的成果covers/里躺着几十张热门歌单封面data/里有一份结构化的歌单清单。我第二次做类似项目时把获取逻辑封装成了一个远程调用的函数不再关心具体接口地址只要传分类名进去返回的就是清洗好的列表。这种“把脏活封装起来把接口留给未来”的做法才是这类小项目最值得沉淀的东西。最后再分享一个小技巧如果你在代码运行过程中发现某个分类一直拿不到数据不要急着改代码先用浏览器手动访问一下这个分类页看看页面是否正常。很多时候不是你的代码有问题而是平台把某个分类或某个时间段的风控调高了。先用浏览器确认数据源是否活着是排查一切爬虫问题的起点。