简介:基于Java实现的网易云音乐数据抓取爬虫项目,面向正在学习网络爬虫与数据采集的初中级开发者,也适合作为数据分析场景下的数据获取参考。项目采用Maven标准结构,包含歌曲、评论等信息的抓取逻辑,演示了从页面请求、内容解析到数据库持久化的完整流程,并附有数据库初始化脚本与使用说明,能帮助读者快速搭建环境,理解爬虫工程化写法。压缩包共23个文件,核心为18个Java源文件,另有2个XML配置、1个Properties配置文件、1个数据库SQL脚本和1份Markdown说明,整体仅23KB,代码量适中,目录结构清晰,便于逐文件研读与二次开发。读者既可以参考其抓取思路改造为自己的采集工具,也可以学习如何管理请求参数、处理响应数据并写入数据库。目前已有90人学习下载,适合作为爬虫实战入门案例,也可为后续扩展其他音乐平台或站点采集提供基础。 做爬虫这几年,我其实很少遇到像网易云音乐这样“既好抓又难抓”的数据源。说好抓,是因为它的Web端API非常丰富,歌单、歌曲、评论、用户信息几乎都有现成接口;说难抓,是因为接口签名更新频繁、限流策略越来越严格,如果不做好规划,很容易爬到一半就被封了IP。项目《_NetEaseMusicSpider.zip》是我去年做音乐数据分析课题时写的一个完整工程,定位很简单:用Python抓取网易云音乐的歌单、歌曲详情和评论数据,清洗后统一存入SQLite,供后续做用户偏好分析和歌曲传播路径研究。这篇文章就从设计思路、核心实现到避坑经验完整复盘一遍,希望能给正在研究爬虫技术或者对音乐数据挖掘感兴趣的朋友一点参考。
1. 项目定位与整体设计思路
1.1 先想清楚抓什么数据,再想怎么抓
很多新手拿到一个爬虫项目,第一反应是“怎么绕过反爬”,但我的习惯是反过来:先把数据模型定义清楚,再决定技术方案。这个项目的目标数据最终要服务两类分析场景——歌曲热度趋势分析和评论情感词频统计,所以我把数据需求拆成了四个维度:
- 歌单基础信息:歌单ID、名称、创建者、播放量、标签、收藏数。
- 歌曲基础信息:歌曲ID、名称、歌手、专辑、时长、发行时间。
- 评论数据:评论内容、点赞数、评论时间、用户昵称。
- 用户信息(附带抓取):用户ID、用户等级、性别、创建时间。
字段明确以后,爬虫才有“完成”的标准。我之前见过不少项目,爬虫写完了却不知道字段是否完整,也没办法验证数据质量,最后分析时才发现关键字段缺失,只能返工。这里也建议你做任何爬虫项目前,先用文档列出目标表结构,再动代码。
1.2 技术选型逻辑
这个项目我没有用Scrapy,而是选择了requests+lxml+SQLite的轻量组合,原因很直接:
- Scrapy虽然功能强大,但对于单机万级数据量的抓取任务来说,框架本身的学习和配置成本偏高。
requests配合Session管理Cookie非常灵活,网易云音乐的接口需要通过Cookie维持登录态,用Session天然合适。- 数据量在十万条以内时,SQLite的单文件数据库读写效率足够,且不需要额外部署数据库服务。
- 解析层用
lxml做XPath提取,部分接口返回的是JSON,直接用json模块处理,不需要引入重型解析库。
考虑到后续可能需要扩展到百万级数据量,我在存储层做了一层抽象:定义统一的DatabaseWriter基类,SQLite和MySQL各写一个实现,切换数据源时只需要改配置项。这个设计后面抓评论数据时帮我省了不少事,因为评论量大到一定程度,SQLite频繁写入会出现锁竞争。
2. 网易云音乐API的调用细节与请求构造
2.1 请求头和Session的构造是第一步
网易云音乐的Web端API拼接方式比较统一,大部分接口都遵循/api/xxx的模式,请求头需要携带一些关键信息才能拿到完整数据。我实际使用的请求头构造如下:
import requests session = requests.Session() session.headers.update({ "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/", "Origin": "https://music.163.com", "Accept": "application/json, text/plain, */*", "Content-Type": "application/x-www-form-urlencoded", "Cookie": "换成你自己的Cookie" })这里有个关键点:User-Agent和Referer是基础的“身份标识”,但真正影响数据完整性的是Cookie。网易云音乐的很多接口,未登录状态和登录状态返回的数据字段差很多——比如歌单的creator信息、评论区的用户信息、歌曲的fee字段(是否付费),都需要登录态才能完整拿到。
2.2 歌单列表接口的请求与响应解析
这个项目里我主要使用了两个核心接口:
第一个是歌单分类接口,用于获取某个标签下的歌单ID列表:
POST /api/playlist/list 参数:cat(分类标签)、order(排序方式)、offset(偏移量)、limit(每页数量)第二个是歌单详情接口,用于获取歌单内歌曲的完整列表:
POST /api/playlist/detail 参数:id(歌单ID)重点说一下/api/playlist/list接口的参数细节。cat参数控制分类,比如“华语”“流行”“民谣”“摇滚”,通过不同标签的遍历覆盖,可以建立比较完整的歌单样本池。order参数我测试下来,hot(最热)和new(最新)返回的数据质量差异很大:hot很多是运营推荐歌单,刷量痕迹明显;new是用户新建歌单,数据更真实但质量参差不齐。我最后的抓取策略是hot和new各抓一部分,按歌单维度做去重。
响应结构里有一个需要特别注意的点:歌单详情接口返回的JSON中,trackIds字段只包含歌曲ID列表,而playlist.tracks字段才是完整歌曲对象。但如果歌单内歌曲数量超过1000首,接口默认只返回前1000首,需要在代码里判断trackCount字段是否大于返回的tracks数量,再决定是否用歌曲ID列表逐个补齐。
2.3 加密接口与Cookie的边界说明
网易云音乐Web端的部分接口(比如评论区的完整接口、部分用户详情接口)采用了名为weapi的加密调用方式,本质上是AES对称加密和RSA非对称加密的混合方案。实现思路大家可以在公开资料里找到,核心是生成一个params加密参数和一个encSecKey加密密钥,然后以表单形式POST到/weapi/xxx路径。
这里我想坦诚地说明我的做法:项目初期我尝试过weapi的全部接口,但后来重新评估了合规性和必要性,最终只在代码里保留了明文接口的抓取逻辑,只对/weapi机制做了技术验证,没有在工程里落地使用。原因是,网易云音乐的《用户协议》明确禁止未经授权的爬取行为,作为技术学习者,我对“加密绕过”始终保留限度——验证技术可行性是一回事,把绕过方案做成产品是另一回事。市面上有不少开源方案,但直接使用可能带来法律风险,这点务必自行评估。
对于评论这类需要weapi才能拿到完整数据接口的需求,我的替代方案是使用网易云音乐的开放平台API(如果目标字段有覆盖),或者使用移动端eapi接口的部分明文参数做有限度的抓取。如果只是做数据分析课题,歌单和歌曲基础数据已经足够撑起大部分研究场景了。
3. 核心模块实现与数据落地
3.1 从歌单ID到歌曲列表的串联抓取流程
整个爬虫的抓取流程我用一个状态机来控制,简化后的核心代码如下:
import time import json import sqlite3 from typing import List, Dict def fetch_playlist_ids(session, cat: str, offset: int, limit: int = 30) -> List[str]: url = "https://music.163.com/api/playlist/list" payload = { "cat": cat, "order": "hot", "offset": offset, "limit": limit, "total": True } try: resp = session.post(url, data=payload, timeout=10) resp.raise_for_status() data = resp.json() except Exception as e: print(f"[ERROR] 获取歌单列表失败: {e}") return [] playlists = data.get("playlists", []) ids = [str(item["id"]) for item in playlists if item.get("id")] return ids def fetch_playlist_detail(session, playlist_id: str) -> Dict: url = "https://music.163.com/api/playlist/detail" payload = {"id": playlist_id} resp = session.post(url, data=payload, timeout=10) data = resp.json() playlist = data.get("playlist", {}) track_list = playlist.get("tracks", []) track_count = playlist.get("trackCount", 0) # 如果返回的 tracks 数量小于 trackCount,说明歌曲被截断 # 此时需要用 trackIds 字段逐个补齐歌曲详情 if len(track_list) < track_count: track_ids = [item["id"] for item in playlist.get("trackIds", [])] track_list = [fetch_song_detail(session, tid) for tid in track_ids[:track_count]] return { "playlist_id": playlist_id, "name": playlist.get("name"), "tracks": track_list, "play_count": playlist.get("playCount"), "tags": ",".join(playlist.get("tags", [])) }看到这里你会发现,抓取逻辑本身并不复杂,真正的复杂度在于“异常处理和状态转移”。比如遇到网络抖动导致某个请求超时,是直接跳过还是重试?如果歌单详情抓了一半程序崩溃了,怎么断点续传?这些问题都需要在代码里考虑清楚。
3.2 歌曲详情与评论数据的二次请求
歌曲详情接口相对简单:
POST /api/song/detail 参数:c=[{"id": 歌曲ID}]这个接口支持批量查询,一次最多传1000个歌曲ID,返回的songs数组按请求顺序排列。批量请求是提升效率的关键,我实测从单曲逐个请求改为批量100个请求后,抓取速度提升了6倍以上。
评论数据这个项目里我主要抓了热门评论,接口为:
POST /api/comment/playlist?id=xxx&page=1&pageSize=20实际测试发现这个明文接口返回的是热门评论,且分页参数offset的有效范围有限,超过一定深度会被限流。如果要对全部评论做深度抓取,就需要weapi体系里的/weapi/comment/resource/接口了,这个我没有落地,原因前面已经说过。
3.3 数据清洗与SQLite落库
数据落地这一层我设计了一个统一的清洗函数。因为接口返回的数据并不直接适合入库,举例来说:
playCount字段是数字,但偶尔会有null值,需要做默认值填充。- 歌曲的
duration字段单位是毫秒,展示层面需要转为mm:ss格式。 - 评论的
time字段是Unix时间戳,入库前要转为datetime格式便于后续分析。 - 歌手字段
artists是一个数组,需要提取歌手名拼接为字符串。
清洗逻辑示例:
def clean_track_info(track: Dict) -> Dict: artists = [a.get("name", "") for a in track.get("artists", [])] album = track.get("album", {}) or {} return { "song_id": track.get("id"), "name": track.get("name"), "artists": " / ".join(artists), "album": album.get("name"), "duration_ms": track.get("duration", 0), "duration_text": f"{track.get('duration', 0) // 60000}:{(track.get('duration', 0) % 60000) // 1000:02d}", "publish_time": album.get("publishTime"), "fee": track.get("fee", 0) }表结构我设计得比较轻量,核心三张表:playlists、tracks、playlist_tracks。这里我特意没有建comments表,因为后续需要更完整的数据,会把评论单独做成一个爬虫任务,不混在基础数据抓取流程里。
建表SQL和插入逻辑这里不贴全代码了,核心思路是:
- 使用
INSERT OR IGNORE避免重复插入。 - 关键字段加唯一索引(如歌曲ID、歌单ID)。
- 每批次提交使用事务,避免大量小事务导致SQLite锁竞争。
数据库设计看起来不起眼,但前期把索引和唯一约束设计好,能省下后面大量清洗去重的功夫。
4. 反爬应对与性能优化实践
4.1 做一个“懂事”的爬虫
“懂事”这个词是我在实际项目里总结出来的:爬虫不是越快越好,而是要懂得克制,懂得动态调度。网易云音乐的限流策略我摸下来大概有这几条规律:
- 同一个IP的请求频率如果长期超过每秒3次,触发封禁的概率会急剧上升。
- 单接口高频访问比多接口轮流访问更容易触发风控。
- 深夜时段(凌晨2点到5点)的请求更容易被标记为异常行为。
所以我在爬虫里加了一个Ticker限速器,核心逻辑是滑动窗口限流:
class Ticker: def __init__(self, max_calls: int, period: float = 1.0): self.max_calls = max_calls self.period = period self.calls = [] def wait_if_needed(self): import time now = time.time() # 清理超出时间窗口的调用记录 self.calls = [t for t in self.calls if now - t < self.period] if len(self.calls) >= self.max_calls: sleep_time = self.period - (now - self.calls[0]) if sleep_time > 0: time.sleep(sleep_time) self.calls.append(time.time())这个简单的滑动窗口限流器,比time.sleep(1)这种方式高明在:它能在保证平均请求频率的同时,允许短时间内的微突刺,整体请求节奏更自然,也更不容易被风控识别为机器行为。
4.2 异常重试与断点续抓
抓取过程中网络错误和服务端错误是不可避免的。我的重试策略是:
- 对超时错误(
requests.exceptions.Timeout)做指数退避重试,最多重试3次。 - 对HTTP 5xx错误(服务器错误)做固定间隔重试,最多重试2次。
- 对HTTP 4xx错误(尤其是403),不重试,直接记录日志并跳过。
断点续抓这块,我维护了一个游标文件:
state = { "current_cat": "华语", "offset": 120, "failed_playlists": [], "last_update": "2024-01-15 22:30:00" } # 每次抓完一个分类,及时更新状态文件 with open("crawler_state.json", "w", encoding="utf-8") as f: json.dump(state, f, ensure_ascii=False, indent=2)这样做的好处是,即使程序因为断电或其他原因中断,重启后也能从上次的位置继续抓取,不需要从头来过。这个设计对于长时间爬取任务几乎是必备的。
4.3 多线程并发抓取的取舍
项目初期我用单线程跑,抓取一万个歌单大概需要4小时。后来优化为多线程,但一开始直接上20个线程,结果没过10分钟就被限流了。
后来我改成线程池 + 限速器组合方案:8个线程,每个线程共享同一个限速器,把整体请求频率控制在每秒不多于3次。这个策略实测下来稳定性好了很多:
from concurrent.futures import ThreadPoolExecutor, as_completed ticker = Ticker(max_calls=3, period=1.0) def worker(playlist_id: str): ticker.wait_if_needed() return fetch_playlist_detail(session, playlist_id) with ThreadPoolExecutor(max_workers=8) as executor: futures = {executor.submit(worker, pid): pid for pid in playlist_ids} for future in as_completed(futures): result = future.result() if result: write_to_db(result)这里有个容易被忽略的细节:requests.Session不是线程安全的。多线程场景下,要么为每个线程创建独立的Session,要么给Session加锁。我实测下来,为每个线程创建独立Session是更稳妥的做法,因为Session内部持有的连接池存在状态,并发写同一Session可能引发不可预期的错误。
5. 常见问题与排查技巧实录
5.1 高频报错汇总与解决办法
我把项目调试期间的典型报错整理成了一个表格,这里结合排查思路一起说明:
| 报错现象 | 可能原因 | 解决方案 |
|---|---|---|
requests.exceptions.ConnectionError | 网络不稳定或者IP被临时封禁 | 等待一段时间后重试,或切换网络出口 |
| HTTP 403 | Cookie失效,或者User-Agent被识别 | 更新Cookie,检查请求头是否完整 |
JSONDecodeError: Expecting value | 接口返回了HTML而不是JSON,可能是被跳转到了验证页 | 检查响应状态码和响应内容,判断是否被风控 |
SQLitedatabase is locked | 多线程同时写数据库 | 改为单写线程,或使用WAL模式 |
| 返回数据中歌曲列表为空 | 歌单ID无效或歌单被设置成不可见 | 跳过该歌单,并记录原因 |
排查这类问题的一个高效思路是:先用Postman手动请求一遍接口,确认接口本身没有问题,再回到代码里查问题。很多新手一上来就调试代码,其实如果接口本身返回了异常响应,代码层面再优化也解决不了。
5.2 数据完整性校验
数据抓取完之后,完整性校验往往被忽略,但这恰恰是影响后续分析质量的关键一环。我常用的三个校验手段:
- 数量校验:对比歌单表的
trackCount字段和playlist_tracks表中对应歌单的歌曲数量,不一致则说明漏抓了。 - 字段非空校验:统计关键字段(歌曲名、歌手名)的空值比例,如果超过5%,需要回头排查清洗逻辑。
- 时间分布校验:看歌曲的发行时间分布是否符合真实规律。如果某个时间段的数据明显畸高或畸低,可能说明抓取过程中存在系统性偏差。
我这里举一个真实案例:第一版爬虫抓完以后,我发现1970年发行的歌曲占比高达30%,排查后发现问题出在清洗环节——接口中部分歌曲的publishTime字段为0,我没有填充默认值,导致Unix时间戳0被转成了1970年1月1日。这种细节bug如果不做时间分布校验,几乎发现不了。
5.3 关于合规使用的一些思考
最后想认真聊一下爬虫的“边界”问题。这个是很多爬虫项目容易被忽略、但真正重要的部分。写爬虫的人往往聚焦于技术实现,但“能爬”和“该爬”是两回事。
我在这个项目中的实践是:
- 只抓取公开接口可获取的数据,不通过逆向手段获取加密接口的核心密钥。
- 控制请求频率,不对目标服务造成压力。
- 抓取数据仅用于个人学习研究,不用于商业用途,不在任何平台传播原始数据。
- 在代码仓库的README里明确写明使用条款和数据来源声明。
爬虫技术本身是中性的,但使用场景决定了它的边界。网易云音乐作为平台方,有权利保护自己的数据资产,作为技术学习者,我们更应该理解并尊重这种保护——学习的目的是提升技术能力,而不是钻空子。
如果你也在做类似的数据抓取和分析项目,我建议你在项目最开始就定义好“哪些数据不抓”的红线,这样后续做技术选型和架构设计时会有更清晰的判断依据。
写在最后:这个项目还能往哪里扩展
从《_NetEaseMusicSpider.zip》这个项目出发,能扩展的方向其实不少。如果你对数据分析感兴趣,可以基于抓取的歌单和歌曲数据做用户听歌偏好聚类,或者分析不同风格歌曲的传播路径;如果你对爬虫技术本身感兴趣,可以尝试把数据源扩展到电台、MV、歌单广场等模块;如果你对后端工程感兴趣,可以把存储层从SQLite平滑迁移到MySQL或PostgreSQL,再加上一个定时调度任务来增量更新。
我个人在完成这个项目后,最大的收获其实不是爬虫技术本身,而是养成了一套“先把数据模型想清楚再动手”的做事习惯。爬虫只是数据获取的手段,真正的价值在于后续的分析、挖掘和业务价值转化。希望这篇文章能帮你少踩一些我踩过的坑,也期待看到你基于这些数据做出更有意思的场景应用。
本文还有配套的精品资源,点击获取