手把手构建QQ音乐爬虫与个性化推荐系统:从数据采集到协同过滤实践
发布时间:2026/9/30 10:19:20来源:尧图网络
1. 从“听歌”到“懂你”这个推荐项目到底在做什么先说结论我花了两个周末用 Python 写了一套完整的 QQ 音乐数据采集与个性化推荐系统可以爬取海量歌单及其歌曲信息并基于这些数据构建一个“猜你喜欢”的推荐引擎。听起来是不是有点耳熟正好是网易云那套思路的核心——但我没借任何现成的推荐 SDK从数据采集到推荐计算全部自己实现。为什么选 QQ 音乐而不是网易云虽然网易云的热评文化让它很有“社区感”但从接口层面看QQ 音乐的歌单数据结构更规整歌单广场分类明确榜单、标签体系完整API 的抽象程度也更高非常适合用来做数据采集练习。而且 QQ 音乐的用户基数大、歌单数量多采集下来之后做推荐的效果会更好。适合谁来参考如果你是 Python 爬虫刚入门想找实战项目的人或者已经能写简单 requests 脚本、想进阶到存储层和推荐算法的人这篇文章都值得看一看。我会从 API 接口分析讲起再到 SQLAlchemy 数据建模最后到推荐逻辑的实现和反爬应对思路尽量做到每一步都可复现。这个项目的核心不仅仅在于“爬”更大的价值在于如何把爬下来的零散数据变成结构化、可用的资产再进一步变成能反馈给用户的推荐结果。这是一条从数据采集到数据应用的完整链路比单纯写一个爬虫脚本要有意思得多。2. 技术选型为什么是 requests SQLAlchemy而不是 Scrapy 全家桶2.1 框架选择上的取舍很多人在做爬虫项目时第一反应是上 Scrapy。Scrapy 确实非常强大内置了并发调度、中间件、Item Pipeline 等各种能力用好了像一台精密的纺织机。但在这个项目里我刻意避开了它核心原因是QQ 音乐的接口需要动态签名而且推荐算法的代码还得和爬虫共处一个工程里Scrapy 的异步架构会让整个调试链路变长。我最终选定的组合是requests发 HTTP 请求简单直白配合 Session 管理 Cookie 非常方便SQLAlchemyORM 框架把爬到的数据直接映射为 Python 对象避免手写 SQLPandas拉取数据后的清洗、聚合、特征提取全靠它scikit-learn推荐算法部分我会先用 NearestNeighbors 做基于物品的协同过滤原型这样的组合是一个偏“数据工程师”而不是“爬虫工程师”的思路——把爬虫当作数据管道的第一环而不是终点。2.2 数据流设计整个系统的数据流是这样的爬虫采集 → 数据清洗 → SQLAlchemy 入库 → 读取特征 → 推荐计算 → 输出推荐结果单个模块解耦每一层都可以独立测试和替换。如果某一层出了问题不需要动其他地方这也是我选择自己搭流程而不是用现成框架的原因之一——可控性比快更重要。2.3 为什么不用官方 APIQQ 音乐是有官方开放平台的但它的开放接口主要面向版权方和企业用户个人开发者基本申请不到权限。退而求其次Web 端和移动端的接口是公网可访问的但带了复杂的签名和加密参数。这就衍生出两个方向逆向 JS 算法还原签名逻辑门槛高、弯路多抓包分析直接模拟请求我能在一小时内跑通立刻走了这条我选择了第二条路线理由非常务实我的目标是做推荐系统不是做验证码对抗专家。能稳定拿到数据并且遵守基本的技术伦理就足够了。3. 核心代码实现从找接口到拿数据3.1 找到可用的数据接口如果你的浏览器开着 F12 开发者工具随便打开 QQ 音乐网页版点进一个歌单就能看到网络面板里请求了一大堆 JSON。关键接口都集中在y.qq.com域下只是请求参数里带着一个明显的sign字段这正是防爬的关键。我花了一下午梳理接口最终确定只需要三个接口用途请求方式说明获取热门歌单列表GET返回歌单 ID、名称、封面、播放量获取歌单详情GET返回歌单内所有歌曲的 MID 和名称获取歌曲详情GET返回歌手、专辑、时长、热度等完整信息幸运的是webpack 打包后的 JS 里能搜到固定的sign函数实现用本地跑个 Python 脚本复现一下即可。这里我不放完整算法因为不同时间点 QQ 音乐的签名规则会变化GPT 时代学会自己分析接口才是长效技能。3.2 爬虫主代码我先把核心的爬虫文件拆出来讲。import requests import time import random from sqlalchemy.orm import sessionmaker from models import Playlist, Song, engine class QQMusicCrawler: def __init__(self): self.session requests.Session() self.session.headers.update({ User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36, Referer: https://y.qq.com/, Origin: https://y.qq.com, }) self.SessionDB sessionmaker(bindengine) def get_sign(self, param_dict): # 简单版签名示意真实项目中这里是一个时间戳参数拼接的哈希过程 raw .join([f{k}{v} for k, v in sorted(param_dict.items())]) return hashlib.md5((raw salt).encode()).hexdigest()这段代码里值得关注的是Referer和Origin头。我自己调试时踩过坑如果这两个头不带接口偶尔会返回 403带了就稳定很多。Cookie也没写死因为 QQ 音乐对未登录用户的接口是有放开的热门歌单广场不需要登录也能爬。获取歌单列表的函数很简单def fetch_playlists(self, category全部, page1, page_size30): params { category: category, page: page, pageSize: page_size, sign: self.get_sign({category: category, page: page}) } resp self.session.get(https://c.y.qq.com/splcloud/fcgi-bin/fcg_get_diss_by_tag.fcg, paramsparams) data resp.json() playlists data.get(data, {}).get(list, []) result [] for item in playlists: result.append({ playlist_id: item[dissid], title: item[dissname], play_count: item[listennum], song_count: item[song_count], }) return result拿到歌单 ID 之后再请求歌单详情def fetch_playlist_detail(self, playlist_id): params { id: playlist_id, sign: self.get_sign({id: str(playlist_id)}) } resp self.session.get(https://c.y.qq.com/qzone/fcg-bin/fcg_ucc_getcdinfo_byids_cp.fcg, paramsparams) data resp.json() songs data[cdlist][0][songlist] return [{mid: s[songmid], name: s[songname], singer: s[singer][0][name]} for s in songs]3.3 并发控制别把自己爬进黑名单QQ 音乐的频率限制是存在的但不像一些防守严格的大厂那样触发即封。我自己试下来的经验是单 IP 下控制到每秒 2~3 个请求玩一天完全没问题。我用了concurrent.futures里的ThreadPoolExecutor开了 5 个线程去抓歌单详情速度提升了 5 倍。但要注意线程数量不是越大越好太快会触发服务端的限流逻辑导致请求直接失败。我在这上面吃过一次 403 的亏后来加了重试逻辑和随机延时def fetch_with_retry(self, func, *args, retries3): for attempt in range(retries): try: return func(*args) except requests.exceptions.RequestException: time.sleep(random.uniform(2, 5)) raise Exception(f重试3次后仍然失败: {func.__name__})注意很多人写爬虫只关注“怎么拿到数据”却忽略了“怎么不被识别为恶意请求”。随机延时和重试机制看起来没技术含量但它们是爬虫能长期运行的保命符。4. 数据建模SQLAlchemy 如何优雅存储爬虫数据4.1 表结构设计爬虫爬到的原始数据是嵌套的 JSON直接存 JSON 字段是偷懒的做法查询和分析都会变得很痛苦。我用 SQLAlchemy 定义了三个模型Playlist、Song、PlaylistSong其中PlaylistSong是关联表用来表示歌单和歌曲之间的多对多关系。from sqlalchemy import create_engine, Column, Integer, String, ForeignKey, Table, Float from sqlalchemy.orm import declarative_base, relationship Base declarative_base() playlist_song Table( playlist_song, Base.metadata, Column(playlist_id, Integer, ForeignKey(playlists.id)), Column(song_mid, String(50), ForeignKey(songs.mid)), ) class Playlist(Base): __tablename__ playlists id Column(Integer, primary_keyTrue) title Column(String(200)) play_count Column(Integer) songs relationship(Song, secondaryplaylist_song, back_populatesplaylists) class Song(Base): __tablename__ songs mid Column(String(50), primary_keyTrue) name Column(String(200)) singer Column(String(100)) duration Column(Integer) # 时长秒 songs relationship(Playlist, secondaryplaylist_song, back_populatessongs)这个设计的关键点在于用song_mid作为主键而不是用自增 ID。因为 QQ 音乐的歌曲 MID 是全局唯一的在不同歌单里出现同一首歌时去重就非常方便。4.2 入库时的去重策略实际操作中不同歌单之间歌曲重复率特别高。如果直接用 INSERT数据库里会积累大量重复数据。我的做法是每次入库前先查一下def save_song(session, song_info): existing session.get(Song, song_info[mid]) if existing: return False song Song(midsong_info[mid], namesong_info[name], singersong_info[singer]) session.add(song) return True这里有个性能问题需要说明一下每次都查一次再插入比直接 INSERT 慢很多。但考虑到我们的数据量级在万级不是千万级这种“稳妥型”写法带来的性能开销完全可以接受。简单可靠永远优先于花哨优化这是我在工程实践中反复学到的教训。4.3 事务与会话管理一次性爬几万个歌单如果每爬一首歌就 commit 一次性能很拉胯。我的做法是批量积累每满 100 条才 commit 一次def batch_save(self, song_list): session self.SessionDB() count 0 for song_info in song_list: if self.save_song(session, song_info): count 1 if count % 100 0: session.commit() session.commit() session.close()注意SQLAlchemy 的 Session 不是线程安全的。如果开了多线程爬虫每个线程都必须创建独立的 Session不能共享。第一次写的时候我把一个 Session 传给了多个线程结果出现重复记录和奇怪的报错排查了很久才发现是这个原因。5. 个性化推荐核心把“歌单”转成“用户偏好”5.1 推荐思路选择数据都入库了接下来就是重头戏——推荐算法。在这个项目里我没有搞复杂的深度学习用的是基于物品的协同过滤Item-based Collaborative Filtering核心假设是如果一个歌单里同时出现歌曲 A 和歌曲 B说明 A 和 B 之间存在一种“被一起收听”的关联。用户听了 A系统就可以推荐 B。这个思路和电商的“买了 A 的人还买了 B”是同一个逻辑。它的优势在于不需要用户的历史行为数据冷启动效果好只要歌单数据够多实现简单计算的维度是歌单-歌曲矩阵5.2 构建歌单-歌曲矩阵第一步是把数据库里的数据转成一个稀疏矩阵行是歌单列是歌曲值是 0/1 表示是否存在。from scipy.sparse import lil_matrix import pandas as pd data pd.read_sql( SELECT pl.id as playlist_id, s.mid as song_mid FROM playlists pl JOIN playlist_song ps ON pl.id ps.playlist_id JOIN songs s ON ps.song_mid s.mid , engine) playlist_ids data[playlist_id].unique() song_mids data[song_mid].unique() playlist_index {pid: i for i, pid in enumerate(playlist_ids)} song_index {mid: j for j, mid in enumerate(song_mids)} matrix lil_matrix((len(playlist_ids), len(song_mids)), dtypeint) for row in data.itertuples(): i playlist_index[row.playlist_id] j song_index[row.song_mid] matrix[i, j] 1注意我用的是lil_matrix它非常适合按行构建矩阵。如果直接用numpy.array内存会爆炸——想象一下 10 万首歌 x 1 万个歌单就是 10 亿个整数哪怕每个 8 字节也要 8GB。5.3 相似度计算与推荐生成矩阵建好之后调用NearestNeighbors做相似度搜索from sklearn.neighbors import NearestNeighbors # 转成 CSR 格式加速计算 matrix_csr matrix.tocsr() model NearestNeighbors(metriccosine, algorithmbrute) model.fit(matrix_csr) def recommend_by_song(song_mid, top_k10): if song_mid not in song_index: return [] j song_index[song_mid] # 构造一个只有目标歌曲的“虚拟歌单”向量 dummy lil_matrix((1, len(song_mids)), dtypeint) dummy[0, j] 1 distances, indices model.kneighbors(dummy.tocsr(), n_neighborstop_k) # 这里要在 song_index 的反向映射找到 mid reverse_map {idx: mid for mid, idx in song_index.items()} return [reverse_map[idx] for idx in indices[0]]和直觉不同的一点是这里我直接用了“虚拟歌单”的方式来做推荐请求。它的好处是灵活——不管用户是点了某一首歌还是上传了一个歌单都可以转化成同样的向量去计算。5.4 基于标签的轻量推荐升级版协同过滤需要积累数据如果歌单数据量不够还可以走基于标签的推荐来兜底。QQ 音乐的歌单分类标签流行、摇滚、民谣、电子可以直接从接口拿到。我把标签也存进了数据库做了一个简易的内容过滤def recommend_by_tag(tag, top_n20, sessionNone): playlists session.query(Playlist).filter(Playlist.tag tag).order_by(Playlist.play_count.desc()).limit(top_n).all() song_mids set() for pl in playlists: for s in pl.songs: song_mids.add(s.mid) return list(song_mids)这个函数看起来简单但在冷启动阶段比协同过滤靠谱得多。我在实际运行中是“双通道推荐”用户行为多了走协同过滤新用户或数据不足时走标签兜底。两种方法各有各的使用场景没有银弹。6. 反爬虫博弈签名、频率限制与应对策略6.1 QQ音乐的反爬机制拆解在我做这个项目的过程中观察到 QQ 音乐的反爬机制大致分成三层层级机制我的应对第一层请求头校验Referer、UA模拟完整的浏览器请求头第二层参数签名sign字段在本地复现签名的哈希逻辑第三层频率限制单位时间请求数随机延时 多线程降速第一层和第二层是“能不能拿到数据”的门槛第三层是“能不能持续拿到数据”的门槛。前者靠分析后者靠自律。6.2 Selenium 是不是万能解网上有很多教程推荐用 Selenium 模拟浏览器操作来绕过反爬实测下来我觉得——分场景。Selenium 能解决 JS 渲染和动态签名的复杂场景缺点是慢、耗资源而且很容易被检测出自动化特征比如navigator.webdriver属性为 true。在这个项目里接口是明文 JSON 传输签名规则也能还原所以我完全没必要用 Selenium 去模拟点击。只有一种情况我会考虑 Selenium接口返回的数据被加密过而且加密算法极其复杂、短时间无法逆向。但那样的话我大概率会换一个数据源而不是死磕到底。6.3 我的“爬虫道德”清单这是我做爬虫的原则分享给大家参考控制请求频率不给目标服务器造成压力只采集公开可见的数据不碰用户隐私不强撑接口的频控上限触发风险就停爬下来的数据只用于个人学习和研究不商用、不二次传播重要提示QQ 音乐的歌曲试听链接是加密的且受版权保护我做的只是爬取歌单和歌曲的元数据歌名、歌手等不涉及音频文件的下载和解锁。任何绕过版权保护的下载行为都有法律风险请务必守住底线。7. 踩坑记录与经验总结下面这几点全是实际操作中掉过坑后才学到的东西拿出来单独说一篇都值得。7.1 请求头里 Referer 不能乱写最开始我调接口时把 Referer 设置成了https://www.baidu.com/觉得没人在意这个字段。结果 QQ 音乐返回了-2001错误码排查了半天才发现是 Referer 校验没过。改成https://y.qq.com/之后一切正常。从此我再也不“自作聪明”地随意简化请求头了。7.2 SQLAlchemy 中不要把 Session 传进多线程我在前文提到了这个问题但值得再说一遍。它不是报错到你的脸上而是以一种非常隐晦的方式产生重复数据。排查方式也很简单你在库存数据里对song_mid做一下去重计数如果比预期少很多八成就是 Session 被多个线程共用了。7.3 相似度计算里的“维度灾难”构建歌单-歌曲矩阵时维度可能是几万甚至几十万。用cosine距离时要注意矩阵越稀疏特征向量之间的相似度区分度越差。解决办法是对歌曲 ID 做频次过滤只保留在至少 3 个歌单里出现过的歌曲把无效维度砍掉。# 过滤低频歌曲 song_freq data.groupby(song_mid).size() keep_mids song_freq[song_freq 3].index data_filtered data[data[song_mid].isin(keep_mids)]这一步能让矩阵维度缩小一个数量级推荐效果反而更好。有时候刨掉低信息量的数据比增加更多数据更有用。7.4 异步爬虫的诱惑与陷阱热词里有人提到了“爬虫异步”我试过用aiohttp写全异步的爬虫速度确实能到每秒 50 个请求但 QQ 音乐的服务端会立刻拒绝服务最终的结果是大量 403 和超时重试实际可用数据量和同步版本差不多。这个教训告诉我在反爬场景里服务端对你的容忍度才是真的瓶颈不是你代码的并发能力。8. 扩展方向这套系统还能怎么进化8.1 从爬虫到实时数据管道目前的实现是一次性批量采集。如果想让推荐结果随时更新可以加上定时调度比如用APScheduler每天固定时间增量爬取热门歌单更新歌曲热度数据。这样系统的实时性就出来了用户的推荐结果也能在第二天就反映最新热门趋势。8.2 加入用户行为数据升级推荐模型现阶段我的推荐是基于歌单的“共现”关系冷启动友好但个性化程度有限。下一步可以为用户建一个点击、收藏、播放记录表收集真实的用户行为用矩阵分解比如 SVD或隐语义模型来生成用户向量。但我要提醒大家先把手里的数据用明白再去堆算法。我见过太多项目第一步就上 SVD结果因为数据量不够效果还不如简单的共现推荐。8.3 可视化展示爬下来的数据不好好展示就太浪费了。可以用 Flask 或 FastAPI 写一个极简的 Web 页面输入歌曲名字返回推荐结果列表再用 ECharts 画歌手分布饼图、歌曲流行度 Top50 柱状图。视觉化的呈现会让整个系统的价值感提升几个档次。从我个人的经验来看这个项目最值得骄傲的部分并不是爬了多少数据、用了多前沿的算法而是把整个链路完整地打通了。爬虫只是起点数据流的设计和推荐逻辑的取舍才是真正有价值的经验沉淀。如果你也想练手我建议可以先不急着优化代码结构先用最小的代码量把这个链路跑通然后再在某一环上深入优化——这不只是做爬虫的方法也是做所有数据类项目的通用心法。
网站建设高端定制企业官网