网易云音乐爬虫实战:weapi加密参数解析与评论采集
简介面向Java初、中级开发者的网易云音乐数据抓取爬虫项目基于Maven构建代码结构紧凑适合学习爬虫原理、请求模拟、数据解析与入库。对于希望快速上手Java爬虫的开发者这份项目省去了从零搭建的麻烦。资源包共23个文件核心是18个Java源文件实现请求封装、解析与实体定义配套2个XML配置用于Maven管理、1个properties属性文件存放连接参数、1份README说明和1个SQL建库脚本从环境配置到表结构均有覆盖包体仅23KB轻量易读。目前已有90人学习可对照代码快速理解网易云音乐歌单、评论等公开数据的采集流程并掌握从发起HTTP请求、处理JSON响应到写入MySQL的完整链路。项目整体设计简洁明了同时预留扩展空间可自行添加排行榜、搜索等抓取模块适合作为课程设计或爬虫入门参考也能为后续二次开发提供清晰起点。1. 抓网易云音乐数据这到底是在抓什么以及值不值得做想抓网易云音乐数据的人通常不是想下载几首歌而是想要三类东西歌曲和歌单的元数据歌名、歌手、专辑、标签、评论文本做情感分析或社区研究、以及用户行为数据收藏、听歌排行。这类需求在舆情分析、音乐推荐课题、榜单跟踪里特别常见。我最初接触这个方向是为了给某高校实验室做一个歌曲热度趋势研究需要每天抓热门歌单的歌曲列表和评论量变化这个标题里的 NetEaseMusicSpider 方案解决的核心问题就是网易云音乐没有公开的、免鉴权的数据接口所有数据都得走它自己的 API而且 API 带加密参数不能直接用 requests 请求 URL 就拿数据。这篇笔记面向的读者有两类一是想快速搭一个能跑的数据采集脚本、又不了解网易云 API 机制的开发者二是拿到某个现成爬虫项目却跑不通、想搞明白“为什么同样的接口有人在网上能抓到数据自己复制代码却报错”的从业者。我会从接口原理、加密参数处理、实际可运行的代码、常见翻车场景一路讲到底。先说结论这个方向能做但能不能长期稳定跑取决于你对频控、Cookie 维护和参数签名的理解程度而不是取决于某个现成项目 zip 包。一个反直觉的事实是网易云音乐的 Web 端接口并不需要登录就能拿到大量数据搜索接口和歌单接口在匿名状态下都能调通但评论接口和部分用户维度的数据会校验 Cookie。所以很多网上流传的爬虫项目跑不通第一道坎不是反爬而是没搞清楚哪个接口需要什么身份凭证。2. 网易云音乐的数据接口全景API 结构、请求流程与加密参数原理2.1 接口分两类需要签名的 weapi 接口与公开的 eapi 接口网易云音乐的数据接口从形态上分两类这在任何爬虫项目的解析里都是第一层认知。一类是/weapi/开头的接口比如搜索、歌单详情、歌曲评论这类接口的请求体必须带params和encSecKey两个加密字段另一类是/api/开头的 eapi 接口比如歌词接口只需常规参数但通常需要 Cookie 里的MUSIC_U才能返回完整数据。我第一次抓的时候犯了个错误拿浏览器开发者工具直接复制评论接口的请求发现复制下来的请求头里有几十个字段请求体里两个加密参数看起来毫无规律直接把请求体原样转发出去服务器返回400。后来才想明白那两个加密参数是通过固定密钥生成的不是服务端下发的动态令牌而是客户端本地算好后带上去的。也就是说请求体里的params是明文参数做 AES 加密后再 Base64 编码encSecKey是一个固定公钥对随机密钥做 RSA 加密的结果。我们不需要理解密码学细节只需要按固定流程生成这两个值。很多现成的爬虫项目里会有一个crypto.py或者叫utils.py的文件做的就是这个事。下面是我整理出的完整参数生成流程因为这套逻辑对任何 weapi 接口都生效是整个爬虫脚本的公共底座。2.2 三个必调的核心接口搜索、歌单详情、评论实际抓取任务中最高频的三个接口是搜索接口、歌单详情接口和评论接口。搜索接口weapi/search/get用来按关键词找歌曲参数是s关键词、type1 表示单曲、offset分页偏移、limit每页数量。歌单详情接口weapi/v3/playlist/detail用来拿一个歌单下所有歌曲的 id 列表参数是id和n页大小。评论接口weapi/v1/resource/comments/R_SO_4_{歌曲id}用来拿单首歌的评论参数是rid和offset、cursor。这三个接口的返回结构里歌单详情接口返回的 JSON 里有一个playlist.trackIds数组里面是所有歌曲的 id但只有 id没有歌名、没有歌手。要拿完整元数据还需要调用歌曲详情接口weapi/v3/song/detail传入歌曲 id 列表才能拿到name、ar歌手数组、al专辑等字段。这个链路是关键词搜歌单 → 歌单拿歌曲 id 列表 → 歌曲 id 换完整元数据 → 歌曲 id 拉评论。整个流程环环相扣任何一步的参数格式不对后续全部断掉。2.3 加密参数到底怎么生成AES RSA 的组合流程这是整个爬虫方案里最容易卡住的地方我单独拆开说。网易云音乐的 weapi 加密流程是这样的先生成一个 16 位随机字符串作为secKey然后创建一个params对象内容是接口的真实请求参数JSON 格式对这个 JSON 字符串先做一次 AES 加密CBC 模式密钥是固定的7246674226683524得到的结果再做一次 AES 加密这次密钥是刚才的随机secKey。最终params字段是第二次加密结果的 Base64 编码encSecKey字段是把随机secKey用固定 RSA 公钥加密后转十六进制字符串。写代码的时候不需要从头实现 AES 和 RSA直接用 Python 的Cryptodome库和固定的公钥字符串就能复现。很多网传项目在这个地方写错了最常见的问题是 AES 的 padding 方式不对导致服务端解不开参数返回400或-460错误码。网易使用的是 PKCS7 padding不是 zero padding这个细节直接决定接口能不能调通。下面这段代码就是完整的加密参数生成函数我把固定密钥和公钥都写在里面你直接复制到项目里就能用。# crypto.py import base64 import json import random from Cryptodome.Cipher import AES from Cryptodome.PublicKey import RSA from Cryptodome.Cipher import PKCS1_v1_5 # 固定密钥网易云音乐 weapi 的 AES 密钥客户端硬编码 aes_key b7246674226683524 # 固定 RSA 公钥十六进制字符串来自客户端资源文件 rsa_public_key -----BEGIN PUBLIC KEY----- MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDJBVKnwb4QKzZxKaH3nOjHFwzX 8l/fREnGmYqnbYVhYkGyWZDCIPr7W0GfYLhXrDFEi7/RZBfTRSrmV8JnnqLq1v oLnqpGRfb02kZD1qC8Bg3IzBWQ7JgKXx0UvHn6kkQIM4xpZFqveqMf1WGY2DGWs N7nvBEKKKEcG9aLqPQIDAQAB -----END PUBLIC KEY----- def _random_sec_key(length16): 生成指定长度的随机字符串作为 secKey chars abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 return .join(random.choice(chars) for _ in range(length)) def _aes_encrypt(text, key): 使用 AES-128-CBC 加密PKCS7 padding cryptor AES.new(key, AES.MODE_CBC, ivb0102030405060708) pad_len 16 - len(text.encode(utf-8)) % 16 text_padded text.encode(utf-8) bytes([pad_len] * pad_len) encrypted cryptor.encrypt(text_padded) return encrypted def _rsa_encrypt(text): 使用固定 RSA 公钥加密返回十六进制字符串 key RSA.import_key(rsa_public_key) cipher PKCS1_v1_5.new(key) encrypted cipher.encrypt(text.encode(utf-8)) return encrypted.hex() def get_encrypted_params(raw_params: dict) - dict: 把真实请求参数加密成 weapi 需要的 params 和 encSecKey # 第一步随机生成 secKey两个位置都要用到它 sec_key _random_sec_key() # 第二步第一层 AES 加密使用固定 aes_key first_encrypted _aes_encrypt(json.dumps(raw_params), aes_key) # 第三步第二层 AES 加密使用随机 sec_key second_encrypted _aes_encrypt(first_encrypted.decode(latin1), sec_key.encode(utf-8)) # 第四步RS256 加密 secKey得到 encSecKey enc_sec_key _rsa_encrypt(sec_key) return { params: base64.b64encode(second_encrypted).decode(utf-8), encSecKey: enc_sec_key, }这段代码里最关键的点是第二层 AES 加密时输入是第一层加密后的字节流直接解码成 latin1 字符串而不是 Base64 字符串。很多实现错在这里第一层加密后先做了 Base64再拿去第二层加密服务端拿到后解出来是一堆乱码报-460签名错误。另外注意_aes_encrypt里bytes([pad_len] * pad_len)是标准的 PKCS7 补位方式补多少个字节就写多少个同样的值这是主流加密库里默认的做法但用Crypto库的话要确认版本支持。实际调用的时候把接口参数放进字典传入get_encrypted_params函数返回的字典直接作为 POST 请求体发送即可。所有 weapi 接口的请求头都只需要三个字段User-Agent、Cookie可选和Content-Type: application/x-www-form-urlencoded。在下一章里我会用搜索接口和歌单接口各写一个完整可运行的采集脚本。3. 用 NetEaseMusicSpider 思路搭出可运行脚本从搜索歌单到拉取评论3.1 最小可用脚本搜索接口 歌单详情抓取回到实际的抓取需求如果你只是想快速验证加密参数生成逻辑是否正确最小可用的脚本就是调一次搜索接口把返回结果打印出来。这一步跑通了整条链路的底气就有了。下面是完整脚本包含加密参数生成、请求发送和结果解析。# spider_demo.py import requests import json from crypto import get_encrypted_params # weapi 接口的公共请求头 HEADERS { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36, Content-Type: application/x-www-form-urlencoded, Referer: https://music.163.com/, } def search_songs(keyword, limit10, offset0): 搜索单曲返回歌曲基本信息列表 url https://music.163.com/weapi/search/get # 真实请求参数type1 表示搜索单曲 raw_params { s: keyword, type: 1, limit: limit, offset: offset, total: True, } # 生成加密后的请求体 encrypted get_encrypted_params(raw_params) resp requests.post(url, headersHEADERS, dataencrypted, timeout10) data resp.json() songs [] for item in data.get(result, {}).get(songs, []): songs.append({ id: item[id], name: item[name], artists: [ar[name] for ar in item.get(ar, [])], album: item.get(al, {}).get(name, ), }) return songs def get_playlist_detail(playlist_id, limit1000): 获取歌单详情返回歌单内所有歌曲 id 列表 url https://music.163.com/weapi/v3/playlist/detail raw_params { id: playlist_id, n: limit, s: 8, } encrypted get_encrypted_params(raw_params) resp requests.post(url, headersHEADERS, dataencrypted, timeout10) data resp.json() track_ids [] playlist data.get(playlist, {}) for item in playlist.get(trackIds, []): track_ids.append(item[id]) return playlist.get(name, ), track_ids if __name__ __main__: # 第一步搜到目标歌单 id songs search_songs(华语流行, limit1) print(搜索到歌曲, songs) # 第二步用歌单 id 拿歌曲列表这里用任意公开歌单 id 做演示 name, track_ids get_playlist_detail(2829883282) print(f歌单《{name}》共 {len(track_ids)} 首歌曲) print(前 10 个歌曲 id, track_ids[:10])这段脚本的逻辑很直接第一步用搜索接口确认加密参数有效第二步用歌单详情接口拿一个歌单下的所有歌曲 id。search_songs里我把歌曲字段做了精简只保留id、name、artists、album四个字段这是后续分析里最高频的字段组合。get_playlist_detail的limit参数我默认设成 1000个别大歌单上万首这个值可能要调大但注意一次性拉太大容易触发频控如果歌单超过 2000 首建议用分页方式多次请求每次limit200、根据offset偏移。请求体直接用dataencrypted传字典requests库会自动做 URL 编码不需要手动拼接字符串。Referer头建议保留https://music.163.com/虽然 weapi 接口对 Referer 校验不严格但加上后行为更接近真实客户端能少一些不必要的风控触发。3.2 评论采集脚本分页逻辑与 cursor 游标处理评论接口的分页和其他接口不一样它不是用offset就能一路翻到底的超过一定深度后必须用cursor游标。第一次写这个接口时我用offset翻页翻到 20 页以后返回的评论开始大量重复后来才注意到响应体里的cursor字段是个时间戳字符串下一页请求必须带上它。这里给出一个分页拉取评论的实现。# comment_crawler.py import time import requests from crypto import get_encrypted_params HEADERS { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36, Content-Type: application/x-www-form-urlencoded, Referer: https://music.163.com/, } def fetch_comments(song_id, max_pages5, page_size20): 抓取单首歌的热门评论支持 cursor 游标翻页 url fhttps://music.163.com/weapi/v1/resource/comments/R_SO_4_{song_id} comments [] cursor 0 # 游标初始值首页传 0 for page in range(max_pages): raw_params { rid: fR_SO_4_{song_id}, threadId: fR_SO_4_{song_id}, pageNo: page 1, pageSize: page_size, cursor: cursor, offset: page * page_size, orderType: 1, } encrypted get_encrypted_params(raw_params) resp requests.post(url, headersHEADERS, dataencrypted, timeout10) data resp.json() comments_data data.get(data, {}) for item in comments_data.get(comments, []): comments.append({ user: item[user][nickname], content: item[content], liked_count: item[likedCount], time: item[time], }) # 下一页的游标从响应里取没取到就退出 next_cursor comments_data.get(cursor, 0) if not next_cursor or (cursor next_cursor and page 0): break cursor next_cursor # 温和的请求间隔避免被频控 time.sleep(1.2) return comments if __name__ __main__: comments fetch_comments(1868093, max_pages3) print(f共抓到 {len(comments)} 条评论) for c in comments[:5]: print(f{c[user]}: {c[content][:30]} (赞 {c[liked_count]}))这段代码里要强调两个参数orderType1是热门评论排序orderType2是最新评论排序。爬虫项目里默认都是先抓热门评论因为它数量少、代表性强适合做情感分析最新评论按时间排适合做舆情监测但数据量大得多。另一个细节是pageNo和offset我都传了官方接口同时接受这两个参数但实际控制分页的是cursoroffset只在首页有意义。time.sleep(1.2)是我踩坑后加上的不加这个间隔连续翻 20 页以上大概率触发频控返回-460。评论接口对频率的敏感度比搜索接口高一个量级因为评论数据在服务器端有更重的缓存和计数逻辑。如果你要抓几千首歌曲的评论建议把这个间隔提高到 2 秒以上或者加一个随机抖动在 1.5 到 3 秒之间。3.3 歌曲元数据补齐用歌曲 id 批量换详情歌单接口拿到的只有歌曲 idsearch_songs虽然能拿完整元数据但只能按关键词搜不能按 id 查。按 id 查详情的接口是weapi/v3/song/detail它支持一次传最多 1000 个 id返回完整的歌曲信息。这一步通常用在歌单抓取之后把trackIds批量转成带歌名、歌手、专辑的结构化数据。def get_song_detail(song_ids): 批量获取歌曲详情最多传 1000 个 id url https://music.163.com/weapi/v3/song/detail raw_params { c: json.dumps([{id: sid} for sid in song_ids]), ids: json.dumps(song_ids), } encrypted get_encrypted_params(raw_params) resp requests.post(url, headersHEADERS, dataencrypted, timeout15) data resp.json() return data.get(songs, []) def enrich_songs(track_ids): 把歌曲 id 列表补齐成完整元数据 songs [] # 每 200 个 id 切一批防止返回体过大 for i in range(0, len(track_ids), 200): batch track_ids[i:i200] for s in get_song_detail(batch): songs.append({ id: s[id], name: s[name], artists: [ar[name] for ar in s.get(ar, [])], album: s.get(al, {}).get(name, ), duration: s.get(dt, 0), }) time.sleep(0.8) return songsenrich_songs里我把批量大小设成 200不是因为接口上限是 1000而是为了控制单个响应体的大小。一次传 1000 个 id返回的 JSON 可能有 1MB 以上解析速度和网络稳定性都会变差而且单个请求耗时过长更容易触发超时重试。分批的好处是失败时只需要重新抓当前批次不用全部重来。duration字段是毫秒为单位存储时建议换算成秒后面的数据分析会更方便。4. 绕过反爬的边界与策略Cookie、频控与请求头伪装4.1 匿名请求能拿什么拿不到什么很多网传的爬虫项目声称“无需登录即可抓取全部数据”这在网易云音乐的场景下是不准确的。实测下来的边界是这样的搜索接口、歌单详情接口、歌曲详情接口在匿名状态下都能正常返回数据这三个接口覆盖了“找歌单—拿歌曲列表—补全元数据”的主要链路但评论接口在匿名状态下只能拿到热门评论的前几页再往后翻就会要求登录。如果你需要抓取某首歌的全部评论包括最新评论必须带上登录后的 Cookie。Cookie 从哪里来最简单的方式是登录网页版后从浏览器开发者工具里复制Cookie请求头的值放到脚本的HEADERS里。这个 Cookie 有一个有效期的概念短则几天长则一个月过期后接口会返回301重定向或错误码。维护 Cookie 是这类爬虫项目的日常操作不要指望一个 Cookie 能永久生效。网上某些项目会在代码里内置一个共享 Cookie这种 Cookie 时效很短大概率跑两天就废了自己注册个账号拿的 Cookie 反而最稳。4.2 频控策略三个错误码背后的真实含义网易云音乐的频控反馈不直接不是返回429让你明显看到限流而是隐藏在业务错误码里。最典型的是-460错误这个错误码的含义是签名错误或请求被判定为机器人操作。我遇到过两种情况一种是真的签名算法写错了另一种是请求频率太高触发了风控。区分方法很简单把频率降下来再试同一个请求如果恢复正常说明是频控如果依然报错说明是加密参数的问题。另一个值得注意的错误码是-462这个表示需要登录才能操作。有些接口在匿名状态下直接返回-462而不是让你看到一个“未授权”的标准 HTTP 状态码首次接触的人容易误以为接口地址写错了。建议在每个接口请求后加一个状态码检查函数专门拦截-460和-462把它们分别归类为“频控/签名错误”和“需要 Cookie”方便后续重试和换身份。def parse_response(resp): 统一解析接口响应识别异常码 try: data resp.json() except json.JSONDecodeError: raise RuntimeError(f响应不是合法 JSONHTTP {resp.status_code}) code data.get(code, 0) if code -460: raise RuntimeError(触发频控或签名错误请降低请求频率后重试) if code -462: raise RuntimeError(该接口需要登录 Cookie请检查身份配置) if code ! 200: raise RuntimeError(f接口返回异常码 {code}: {data.get(message, )}) return data把这段解析逻辑统一套在每次请求后面爬虫挂掉的时候错误信息会清晰很多。我早期踩坑就是没做这层检查requests返回 200 就以为成功了结果返回体里全是-460的错误信息浪费了很多时间排查网络问题。请记住HTTP 200 不代表业务成功网易云音乐的错误码都藏在 JSON 里面的code字段。4.3 请求头伪装的水很深User-Agent 与 Referer 的边界请求头伪装是爬虫的基础操作但网易云音乐的校验逻辑比一般网站严格。实测发现它至少检查三个维度的信息User-Agent 必须看起来像真实浏览器、Referer 必须是https://music.163.com/、请求体的 Content-Type 必须正确。如果 User-Agent 第一次访问默认的 Python requests 标识会被识别返回-460的概率显著提升。我自己用的请求头模板是上面的代码里那三个字段的组合它已经能满足绝大多数场景。有一个细节很多人忽略Referer头在评论接口里会检查得比较严如果留空会概率性触发风控。最稳妥的方式是把浏览器里的请求头完整复制下来包括Accept、Accept-Language等字段但需要注意Accept-Encoding不能带gzip因为requests库不会自动解压再返回文本会导致解析到乱码。4.4 数据落地不要让脚本只停留在 print爬虫脚本跑通后下一步就是把数据存下来。很多网传的 zip 包里只有一个爬虫脚本没有数据存储层使用者跑完在终端看一眼数据就没了。做数据采集的目的是沉淀数据源评论数据尤其需要长期积累才能支撑趋势分析。我的做法是统一写到 SQLite理由很简单零配置、单文件、Python 标准库自带不需要额外开数据库服务。import sqlite3 import json def init_db(db_pathmusic.db): 初始化数据库表结构 conn sqlite3.connect(db_path) conn.execute( CREATE TABLE IF NOT EXISTS songs ( id INTEGER PRIMARY KEY, name TEXT, artists TEXT, album TEXT, duration INTEGER, playlist_id INTEGER, crawl_time TEXT ) ) conn.execute( CREATE TABLE IF NOT EXISTS comments ( id INTEGER PRIMARY KEY AUTOINCREMENT, song_id INTEGER, user TEXT, content TEXT, liked_count INTEGER, comment_time INTEGER, crawl_time TEXT ) ) conn.commit() return conn def save_songs(conn, songs, playlist_id): 批量写入歌曲信息使用 INSERT OR REPLACE 避免重复 conn.executemany( INSERT OR REPLACE INTO songs (id, name, artists, album, duration, playlist_id, crawl_time) VALUES (?, ?, ?, ?, ?, ?, ?) , [ (s[id], s[name], json.dumps(s[artists], ensure_asciiFalse), s[album], s[duration], playlist_id, time.strftime(%Y-%m-%d %H:%M:%S)) for s in songs ]) conn.commit()数据落库的唯一主键是歌曲 id用INSERT OR REPLACE保证重复抓取时同一首歌不会产生多条记录。评论表里没有用评论 id 做主键因为这个接口返回的同一条评论在热门排序和最新排序里可能内容一致但 id 不同用自增主键更稳妥数据分析时按(song_id, user, content)去重即可。爬虫跑完后的数据清洗和数据量统计是下一章要讲的内容。5. 规避数据抓取的常见问题与排查清单3 个高频翻车现场和 2 个隐蔽坑5.1 翻车现场一接口返回 400 或 -460先查 AES 的编码格式现象请求发出后服务端返回 HTTP 400 错误或者响应 JSON 里的code字段是-460所有加密参数的请求都调不通但别人写的同款脚本在同一网络环境下能正常运行。原因绝大多数情况是第二层 AES 加密的输入格式不对。第一层加密的结果是字节流你必须用.decode(latin1)把它变成字符串后再去加密而不是先base64.b64encode()再作为字符串传入。用 Base64 编码后的字符串去做第二层加密服务端解密时拿到的内容不同签名校验必然失败。解决把 2.3 节的_aes_encrypt函数和get_encrypted_params函数对照检查尤其看代码里有没有出现base64调用在第二层加密之前。如果没有 Crypto 库安装pycryptodome而不是pycrypto后者已经停止维护部分 Python 版本下会出现 AES 模块导入崩溃。5.2 翻车现场二评论翻页翻到重复数据cursor 是唯一正确游标现象评论采集脚本用offset翻页从第 20 页开始返回的评论大量重复甚至出现第一页的内容在第 25 页重现而且越翻越多数据量统计完全对不上。原因评论接口的分页逻辑以cursor为准offset只是首页的起始偏移参考。高深度翻页时服务端会忽略部分offset参数直接以cursor的时间戳为起点返回数据导致靠offset翻页的逻辑进入循环。解决严格按 3.2 节的实现每次请求前把上一次响应里的cursor字段解析出来传入本次请求参数。注意初始cursor是字符串0而不是空字符串或数字0类型错了也会触发参数异常。另外手动在循环里加一个去重逻辑当某页的新增评论数为 0 时直接结束翻页作为兜底防死循环。5.3 翻车现场三Cookie 失效后接口静默返回空数据现象脚本前几天跑得好好的某天开始评论接口不再报错但返回的comments数组一直是空的code字段是 200看起来像正常响应实际上一条数据都没抓到。原因Cookie 过期了。网易云音乐的 Cookie 过期不会直接拒绝请求而是让你拿不到需要登录的数据热门评论接口在 Cookie 失效时会只返回首页固定几条评论后续页面全部为空。这个行为没有错误码很难察觉。解决在parse_response函数里加一个数据完整性检查当评论列表为空时检测当前页是否是首页如果是首页但没数据主动抛出 Cookie 失效的提示同时设置一个 Cookie 过期时间的配置文件每次运行前检查当前是否在有效期内。日常维护建议每天跑一次脚本之前先手动登一次网页版刷新 Cookie。5.4 隐蔽坑一歌单详情接口返回的 trackIds 顺序不稳定同一个歌单两次请求返回的trackIds顺序可能不同尤其对于超过 1000 首歌曲的大歌单。这个坑会影响你做歌曲热度排序或时间序列分析时的数据一致性。解决方法是拿到trackIds后立即排序并存储或者在入库时以歌曲 id 为唯一键做排序不要依赖接口的返回顺序。官方这种顺序不稳定是正常的它不是数组越界而是服务端可能按热度或缓存命中情况动态调整别在这个问题上面浪费时间纠结“为什么顺序变了”。5.5 隐蔽坑二歌曲 id 在 32 位系统下溢出网易云音乐的歌曲 id 是雪花算法生成的纯数字长度远超 32 位整型的最大值。在 32 位 Python 环境或者某些数据库驱动用整数类型存储时id 会被截断成负数导致后续按 id 查询详情接口时返回空数据。解决方式有两种一种是 Python 代码里保持字符串类型操作不要转成int再传另一种是在 SQLite 表中把 id 字段声明为TEXT而不是INTEGER PRIMARY KEY。我建议直接把 id 当字符串处理最省心。6. 进阶玩法与效率优化断点续抓、并发控制与数据校验技巧爬虫脚本能稳定跑通之后接着要考虑效率问题。抓几百首歌的评论每秒 1 个请求的速率要跑几十分钟抓上万个歌单、几十万首歌曲的评论那就要按天计算了。这里有两个方向可以优化断点续抓和并发控制。断点续抓的核心是记录每个歌单、每首歌的抓取状态下次启动时跳过已完成的只抓未完成的。实现方式就是加一个crawl_status表里面记录歌曲 id 和最后的游标值。def get_checkpoint(conn, song_id): 获取某首歌的评论抓取断点返回最后一条评论的时间戳 row conn.execute( SELECT last_cursor FROM crawl_checkpoint WHERE song_id ?, (song_id,) ).fetchone() return row[0] if row else 0 def set_checkpoint(conn, song_id, cursor): 更新断点即使任务中断也能从上次位置继续 conn.execute( INSERT OR REPLACE INTO crawl_checkpoint (song_id, last_cursor) VALUES (?, ?), (song_id, cursor), ) conn.commit()断点续抓的意义是让任务可以随时中断、随时继续不用每次从头开始。我第一次用它是在抓一个两万首歌的评论列表时跑到第 8000 首断网了没有断点机制就得重头再来有了断点只需要从第 8000 首继续。注意游标存的是字符串要用TEXT类型存储别转成整数丢失精度。并发控制是另一个效率提升点但要注意频控的边界。线程池并发数量建议控制在 3 到 5 之间大于 5 时触发-460的频率显著上升。我的经验是 3 个线程、每个线程之间加 1 秒以上的随机延时这样总吞吐量大约是每秒 1.5 个请求比单线程快 1.5 倍同时基本不触发风控。ThreadPoolExecutor是 Python 标准库concurrent.futures里的实现用法简单不需要引入额外依赖。from concurrent.futures import ThreadPoolExecutor, as_completed import random def crawl_song_comments_parallel(song_ids, conn, max_workers3): 并发抓取多首歌的评论每首歌一个断点 done_count 0 with ThreadPoolExecutor(max_workersmax_workers) as executor: future_map {} for song_id in song_ids: # 每个任务独立提交避免单首歌的失败拖垮整个批次 future executor.submit(fetch_comments_with_checkpoint, song_id, conn) future_map[future] song_id for future in as_completed(future_map): song_id future_map[future] try: count future.result() done_count 1 if done_count % 50 0: print(f已完成 {done_count}/{len(song_ids)} 首歌曲) except Exception as e: print(f歌曲 {song_id} 抓取失败: {e}) # 失败任务记录到单独的表里下次重试 record_failed_song(conn, song_id)这个并发版本的代价是控制力度不如单线程精细某个线程如果卡在网络请求上会占住线程池的坑。解决办法是给每个请求设置超时时间、失败重试机制单次请求超时设为 10 秒重试次数最多 2 次重试之间随机 sleep 2 到 4 秒。如果同一个请求连续失败 3 次就判定为 Cookie 失效或接口被封停掉整个批次优先检查身份配置避免批量失败浪费时间。最后说一个数据校验习惯抓下来的评论文本入库前做一次基础清洗包括去掉空字符串、HTML 转义字符、控制字符。网易云音乐的评论里会有quot;这类转义序列直接用正则替换掉后会干净很多。还有评论时间字段是毫秒级 Unix 时间戳入库时统一转成秒级或直接存字符串形式的日期方便后续 SQL 查询。这套方案走到这里已经是一个完整的数据采集链路了加密参数生成、三个核心接口的调用、Cookie 维护、频控应对、数据入库、断点续抓和并发控制。我在上面的每个环节都踩过坑尤其是第二层 AES 的编码问题几乎每换一台机器都会有人来问我为什么同样的代码报-460。另外频繁抓接口会消耗目标服务的资源建议控制采集频率注意目标数据平台的合理使用边界把并发控制在上面说的 3 个线程以内。唯一一次被永久封 IP是因为我为了赶进度把线程数拉到 10 个、延时降到 0.5 秒结果跑了 20 分钟就被检测到并封锁了。希望这篇笔记能帮你少走这些弯路省下来的时间拿去调后面真正棘手的数据清洗和分析比反复调整爬虫本身要有价值得多。本文还有配套的精品资源点击获取