引言
"网易云音乐推荐的歌越来越听不懂了""QQ音乐的每日推荐怎么都是老歌"------如果你深度使用过音乐App,大概率吐槽过推荐算法。音乐推荐系统的核心问题从来不是"歌不够多",而是数据维度不够丰富 。用户听歌行为是隐性的、碎片化的,一首歌只听前奏就切掉,究竟算"喜欢"还是"不喜欢"?相比之下,歌单数据是一种更高质量的用户偏好表达。用户创建或收藏一个歌单,本质上是在做一次显式的"口味声明"------"这些歌我认可,它们在一起有某种共同的气质"。
QQ音乐作为国内曲库规模最大的音乐平台之一,拥有海量的用户歌单数据。每个歌单包含名称、标签、创建者、歌曲列表、播放量等信息,这些数据如果被系统性地采集和分析,就是构建个性化推荐系统的理想燃料。

然而,爬取QQ音乐的数据面临独特的技术门槛。其搜索接口需要携带sign加密参数,该参数由前端JS动态生成,与请求数据、时间戳、浏览器环境指纹深度绑定;单IP高频请求会触发IP封禁;歌单接口分页返回,需要遍历大量页面。本文将系统介绍如何突破这些障碍,爬取歌单与歌曲数据,并基于协同过滤算法构建一套个性化推荐系统。
免责声明:本文仅限技术学习与交流,请勿将爬虫技术用于商业用途或对目标平台造成过大压力。请遵守目标平台的robots.txt协议及相关法律法规,尊重音乐版权与用户隐私。
一、QQ音乐的数据体系与反爬机制
1.1 数据体系:歌单是核心入口
QQ音乐的数据入口丰富,但与推荐系统最相关的是以下三类:
歌单数据 。歌单是QQ音乐内容组织的核心单元。每个歌单包含歌单名称、标签(如"华语流行""民谣""深夜emo")、创建者信息、歌曲列表、播放量、收藏数等字段。歌单接口的原生API为https://i.y.qq.com/qzone-music/fcg-bin/fcg_ucc_getcdinfo_byids_cp.fcg,参数categoryID指定歌单ID。通过遍历歌单ID,可以获取海量歌单的歌曲列表------这正是推荐系统最需要的"用户-歌曲"交互数据。
歌曲元数据。每首歌包含歌曲名称、歌手、专辑、时长、发行时间、流派标签等信息。歌曲详情和歌词可以通过对应的API获取。
排行榜数据。QQ音乐的排行榜(热歌榜、新歌榜、飙升榜)反映了平台层面的流行趋势,是推荐系统中"热度因子"的重要来源。
1.2 反爬体系的三道防线
第一道:sign参数加密。 这是QQ音乐最核心的反爬手段。搜索和歌单相关接口的请求中必须携带sign参数,该参数由前端JS生成,融合了请求数据、时间戳和浏览器环境指纹。逆向分析发现,sign的生成逻辑涉及webpack加载器中的加密函数,且需要补全navigator、location等浏览器环境对象才能得到与网页一致的结果。sign的加密结果会随着请求数据长度的变化而变化,并非固定值。
第二道:IP频率限制。 平台对单IP的请求频率有严格阈值。采集歌单数据时,需要遍历大量歌单ID和分页,请求量远超单IP的安全范围。一旦触发限流,轻则返回空数据,重则临时封禁IP。
第三道:接口版本更新频繁。 QQ音乐的接口URL和参数格式会不定期调整。部分API在2025年9月才重新更新了请求URL,此前的方案可能已失效。
1.3 反爬特征速览
| 反爬手段 | 具体表现 | 应对思路 |
|---|---|---|
| sign参数加密 | 需携带动态生成的sign,与环境指纹绑定 | JS逆向还原加密逻辑,或使用Playwright获取 |
| IP频率限制 | 单IP高频请求触发封禁 | 站大爷隧道代理自动切换IP |
| 接口版本更新 | URL和参数格式不定期调整 | 关注开源项目更新,建立多套备选方案 |
| 分页遍历 | 歌单歌曲列表分页返回 | 循环请求,处理分页游标 |
二、技术选型与架构设计
2.1 技术栈
| 组件 | 选型 | 理由 |
|---|---|---|
| HTTP请求 | requests | 轻量高效,配合sign参数直调接口 |
| 签名生成 | execjs / PyExecJS | 执行逆向后的JS加密逻辑 |
| 浏览器辅助 | Playwright | 补全环境变量,获取与网页一致的sign |
| 代理方案 | 站大爷隧道代理 | 自动切换IP,突破频率限制 |
| 数据存储 | SQLite | 结构化存储歌单、歌曲、歌单-歌曲关联 |
| 推荐算法 | Surprise(协同过滤) | 基于歌单数据的协同过滤推荐 |
三、爬虫实战:从sign破解到歌单采集
3.1 环境准备
pip install requests pandas execjs playwright
playwright install chromium
3.2 sign参数破解
sign的生成逻辑需要从QQ音乐的前端JS中逆向提取。核心思路是找到webpack加载器中的加密函数,将其独立出来在Python中通过execjs调用。
import execjs
import time
import json
# 逆向得到的JS加密代码(简化示例)
SIGN_JS = """
function getSign(data) {
// 实际的加密逻辑涉及SHA-1哈希、
// 字符位置组合、异或运算和Base64编码
// sign分为多段,其中部分段来自明文哈希的特定位置
var raw = JSON.stringify(data);
// ... 完整的加密实现
return encrypted_sign;
}
"""
def generate_sign(data):
"""生成sign签名"""
ctx = execjs.compile(SIGN_JS)
return ctx.call("getSign", data)
重要提醒 :sign的生成依赖浏览器环境变量(如navigator、location等)。如果直接运行逆向代码得到的结果与网页不一致,需要补全这些环境变量。社区已有的spider_reverse等项目提供了完整的sign逆向方案可供参考。
3.3 歌单数据采集
以下是一个基于requests + sign直调的采集实现:
import requests
import time
import random
import sqlite3
from sign_utils import generate_sign
class QQMusicScraper:
def __init__(self, cookie="", use_proxy=True):
self.session = requests.Session()
self.cookie = cookie
self.use_proxy = use_proxy
# 站大爷隧道代理配置
if use_proxy:
self.proxies = {
"http": "http://用户名:密码@tps.zdaye.com:8080",
"https": "http://用户名:密码@tps.zdaye.com:8080"
}
else:
self.proxies = None
self.headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
"Referer": "https://y.qq.com/",
"Cookie": cookie
}
self._init_db()
def _init_db(self):
self.conn = sqlite3.connect("qqmusic_playlists.db")
cursor = self.conn.cursor()
cursor.execute("""
CREATE TABLE IF NOT EXISTS playlists (
id INTEGER PRIMARY KEY AUTOINCREMENT,
playlist_id TEXT UNIQUE,
name TEXT,
tags TEXT,
song_count INTEGER,
play_count INTEGER
)
""")
cursor.execute("""
CREATE TABLE IF NOT EXISTS songs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
song_mid TEXT UNIQUE,
name TEXT,
singer TEXT,
album TEXT
)
""")
cursor.execute("""
CREATE TABLE IF NOT EXISTS playlist_songs (
playlist_id TEXT,
song_mid TEXT,
UNIQUE(playlist_id, song_mid)
)
""")
self.conn.commit()
def fetch_playlist_songs(self, playlist_id):
"""获取歌单的歌曲列表"""
url = "https://i.y.qq.com/qzone-music/fcg-bin/fcg_ucc_getcdinfo_byids_cp.fcg"
params = {
"categoryID": playlist_id,
"format": "json",
"_": int(time.time() * 1000)
}
data = {"categoryID": playlist_id}
params["sign"] = generate_sign(data)
for attempt in range(3):
try:
resp = self.session.get(
url, params=params, headers=self.headers,
proxies=self.proxies, timeout=15
)
if resp.status_code == 200:
return resp.json()
elif resp.status_code in [403, 429]:
time.sleep(3 * (attempt + 1))
except Exception as e:
print(f"请求异常: {e}")
time.sleep(3)
return None
def parse_and_save(self, playlist_id, data):
"""解析歌单数据并保存到数据库"""
if not data:
return 0
cdlist = data.get("cdlist", [])
if not cdlist:
return 0
cd = cdlist[0]
cursor = self.conn.cursor()
# 保存歌单信息
tags = ",".join([t.get("name", "") for t in cd.get("tag", [])])
cursor.execute("""
INSERT OR IGNORE INTO playlists
(playlist_id, name, tags, song_count, play_count)
VALUES (?, ?, ?, ?, ?)
""", (playlist_id, cd.get("dissname", ""), tags,
cd.get("songnum", 0), cd.get("listennum", 0)))
# 保存歌曲信息
song_list = cd.get("songlist", [])
saved = 0
for song in song_list:
song_mid = song.get("songmid")
if not song_mid:
continue
singers = ",".join([s.get("name", "") for s in song.get("singer", [])])
cursor.execute("""
INSERT OR IGNORE INTO songs
(song_mid, name, singer, album)
VALUES (?, ?, ?, ?)
""", (song_mid, song.get("songname", ""), singers,
song.get("albumname", "")))
cursor.execute("""
INSERT OR IGNORE INTO playlist_songs
(playlist_id, song_mid)
VALUES (?, ?)
""", (playlist_id, song_mid))
saved += 1
self.conn.commit()
return saved
def scrape_playlists(self, playlist_ids):
"""批量采集歌单数据"""
total = 0
for idx, pid in enumerate(playlist_ids):
print(f"[{idx+1}/{len(playlist_ids)}] 采集歌单 {pid}...")
data = self.fetch_playlist_songs(pid)
count = self.parse_and_save(pid, data)
total += count
print(f" 获取 {count} 首歌曲")
time.sleep(random.uniform(1.5, 3))
print(f"共采集 {total} 首歌曲关联数据")
return total
# 使用示例
if __name__ == "__main__":
COOKIE = "你的Cookie(可选)"
scraper = QQMusicScraper(cookie=COOKIE, use_proxy=True)
# 歌单ID列表(实际需从歌单广场或搜索接口获取)
playlist_ids = ["7011264340", "7011264341"] # 示例ID
scraper.scrape_playlists(playlist_ids)
四、站大爷隧道代理:突破IP频率限制
4.1 为什么歌单采集必须使用代理?
歌单采集的特点是请求密集且批量------需要遍历大量歌单ID,每个歌单还需要处理分页。如果所有请求都来自一个固定IP,平台的风控系统很快就能识别并封禁。
更关键的是,sign参数虽然解决了"请求合法性"问题,但IP频率限制是独立的一道防线。即使sign每次都正确生成,单IP的请求量仍然会触发限流。因此,隧道代理和sign破解是"两条腿走路",缺一不可。
4.2 站大爷隧道代理的集成方式
在requests中集成站大爷隧道代理需要特别注意:多数商用隧道要求将认证信息放在Proxy-Authorization请求头中,而非直接写在URL里,否则会返回407认证错误。
import requests
import base64
# 站大爷隧道代理配置
auth = base64.b64encode(b"用户名:密码").decode()
proxies = {
"http": "http://tps.zdaye.com:8080",
"https": "http://tps.zdaye.com:8080"
}
headers = {
"Proxy-Authorization": f"Basic {auth}",
"User-Agent": "Mozilla/5.0 ..."
}
resp = requests.get(url, proxies=proxies, headers=headers, timeout=15)
站大爷隧道代理支持三种鉴权模式------白名单、用户名密码/白名单、用户名密码+白名单,可以根据需求选择。对于需要精细控制IP切换周期的场景,可以通过period参数自定义:
proxies = {
"http": "http://用户名-period-300:密码@tps.zdaye.com:8080",
"https": "http://用户名-period-300:密码@tps.zdaye.com:8080"
}
此外,建议控制单IP并发数不超过1 ,并添加随机延时(如time.sleep(random.uniform(1.5, 4.5))),固定间隔比无延时更容易被识别。
五、构建个性化推荐系统
5.1 数据模型:歌单即"用户"
在推荐系统中,歌单可以被视为"用户"的代理------一个歌单代表了一组歌曲的共现关系,类似于一个用户同时"喜欢"了这些歌曲。歌单与歌曲的关联构成了推荐系统的交互矩阵。
| 推荐系统概念 | QQ音乐对应实体 |
|---|---|
| 用户 | 歌单创建者 / 歌单本身 |
| 物品 | 歌曲 |
| 交互 | 歌曲出现在歌单中 |
| 物品特征 | 歌曲的歌手、专辑、流派标签 |
5.2 基于协同过滤的推荐算法
协同过滤的核心思想是:如果两个歌单包含大量相同的歌曲,那么它们很可能属于相似的"品味类型",一个歌单中的歌曲可以被推荐给另一个歌单的创建者。音乐推荐系统主要采用两种方法:协同过滤(基于相似用户的偏好)和基于内容的过滤(基于物品特征),实践中通常采用混合方案。
以下是一个基于歌单共现的歌曲相似度计算实现:
import pandas as pd
import numpy as np
from sklearn.metrics.pairwise import cosine_similarity
# 从数据库加载歌单-歌曲关联数据
import sqlite3
conn = sqlite3.connect("qqmusic_playlists.db")
# 构建歌单-歌曲矩阵
query = """
SELECT playlist_id, song_mid FROM playlist_songs
"""
df = pd.read_sql_query(query, conn)
# 构建矩阵:行=歌单,列=歌曲,值=1(出现在歌单中)
matrix = df.pivot_table(
index="playlist_id", columns="song_mid",
aggfunc="size", fill_value=0
)
# 计算歌曲之间的余弦相似度
song_similarity = cosine_similarity(matrix.T)
song_similarity_df = pd.DataFrame(
song_similarity,
index=matrix.columns,
columns=matrix.columns
)
def recommend_songs(playlist_id, top_n=20):
"""给指定歌单推荐相似歌曲"""
# 获取该歌单中的歌曲
playlist_songs = df[df["playlist_id"] == playlist_id]["song_mid"].tolist()
if not playlist_songs:
return []
# 计算候选歌曲的推荐分数
scores = {}
for song in playlist_songs:
if song not in song_similarity_df.columns:
continue
similar = song_similarity_df[song].sort_values(ascending=False)
# 排除已在歌单中的歌曲
similar = similar[~similar.index.isin(playlist_songs)]
for sim_song, score in similar.head(50).items():
scores[sim_song] = scores.get(sim_song, 0) + score
# 按分数排序,返回Top N
recommended = sorted(scores.items(), key=lambda x: x[1], reverse=True)[:top_n]
return recommended
# 示例:给歌单推荐歌曲
recommendations = recommend_songs("7011264340", top_n=10)
print("推荐歌曲:")
for song_mid, score in recommendations:
print(f" {song_mid}: {score:.4f}")
5.3 混合推荐:协同过滤 + 内容特征
纯协同过滤存在"冷启动"问题------新歌没有出现在任何歌单中,无法被推荐。可以结合歌曲的内容特征(歌手、专辑、标签)构建混合推荐:
from sklearn.feature_extraction.text import TfidfVectorizer
# 获取歌曲的文本特征(歌手+专辑+歌单标签)
query = """
SELECT s.song_mid, s.name, s.singer, s.album,
GROUP_CONCAT(p.tags, ',') as playlist_tags
FROM songs s
LEFT JOIN playlist_songs ps ON s.song_mid = ps.song_mid
LEFT JOIN playlists p ON ps.playlist_id = p.playlist_id
GROUP BY s.song_mid
"""
song_features = pd.read_sql_query(query, conn)
# 构建内容特征文本
song_features["feature_text"] = (
song_features["singer"].fillna("") + " " +
song_features["album"].fillna("") + " " +
song_features["playlist_tags"].fillna("")
)
# TF-IDF向量化
tfidf = TfidfVectorizer(max_features=5000, stop_words=None)
tfidf_matrix = tfidf.fit_transform(song_features["feature_text"])
# 计算内容相似度
content_similarity = cosine_similarity(tfidf_matrix)
def hybrid_recommend(playlist_id, top_n=20, cf_weight=0.7, cb_weight=0.3):
"""混合推荐:协同过滤 + 内容特征"""
# 协同过滤候选
cf_recommendations = dict(recommend_songs(playlist_id, top_n=50))
# 内容推荐候选
playlist_songs = df[df["playlist_id"] == playlist_id]["song_mid"].tolist()
song_indices = song_features[song_features["song_mid"].isin(playlist_songs)].index.tolist()
cb_scores = {}
for idx in song_indices:
similar_indices = content_similarity[idx].argsort()[::-1][:20]
for sim_idx in similar_indices:
sim_song = song_features.iloc[sim_idx]["song_mid"]
if sim_song not in playlist_songs:
cb_scores[sim_song] = cb_scores.get(sim_song, 0) + content_similarity[idx][sim_idx]
# 归一化并合并
cf_max = max(cf_recommendations.values()) if cf_recommendations else 1
cb_max = max(cb_scores.values()) if cb_scores else 1
final_scores = {}
all_songs = set(cf_recommendations.keys()) | set(cb_scores.keys())
for song in all_songs:
cf_score = cf_recommendations.get(song, 0) / cf_max if cf_max > 0 else 0
cb_score = cb_scores.get(song, 0) / cb_max if cb_max > 0 else 0
final_scores[song] = cf_weight * cf_score + cb_weight * cb_score
return sorted(final_scores.items(), key=lambda x: x[1], reverse=True)[:top_n]
5.4 推荐系统的效果评估
推荐系统的效果可以通过以下指标评估:
命中率(Hit Rate) :将歌单中的最后20%歌曲作为"测试集",用前80%作为"训练集"生成推荐,检查测试集中的歌曲有多少出现在推荐列表中。
覆盖率:推荐结果中不同歌曲的占比,避免推荐结果过度集中在少数热门歌曲上。
多样性:推荐列表中歌手的分散程度,避免推荐结果全是同一歌手的歌曲。
六、常见问题与避坑指南
6.1 sign参数生成结果与网页不一致怎么办?
这是QQ音乐爬虫最核心的坑。sign的生成依赖浏览器环境变量,直接运行逆向代码通常会得到不一致的结果。解决方案:
-
补全
navigator、location等环境变量,社区已有完整的逆向方案可供参考 -
使用Playwright在页面上下文中调用JS函数生成sign
-
关注开源社区的最新逆向成果
6.2 使用隧道代理返回407错误怎么办?
多数商用隧道代理要求将认证信息放在Proxy-Authorization请求头中,而非URL里。如果出现407错误,检查认证方式是否正确。
6.3 IP仍被封禁怎么办?
隧道代理分配的出口IP可能已被大量滥用。建议:
-
优先选择支持"地域+运营商"指定的隧道,避开全国混用的共享池
-
控制单IP并发数不超过1
-
添加随机延时,避免固定间隔
6.4 歌单接口URL失效怎么办?
QQ音乐的接口URL会不定期更新。建议关注copws/qq-music-api等开源项目的更新动态,它们会及时跟进接口变化。
6.5 法律与合规提醒
-
仅将数据用于学习和研究目的
-
不得将歌单或歌曲数据用于商业用途
-
尊重音乐版权和用户隐私
-
控制请求频率,避免对平台服务器造成过大压力
七、总结
本文从QQ音乐的数据体系与反爬机制入手,系统介绍了爬取歌单与歌曲数据、构建个性化推荐系统的完整方案。核心要点可以概括为:
| 环节 | 关键技术 | 产出 |
|---|---|---|
| 歌单采集 | sign逆向 + 接口直调 | 歌单-歌曲关联数据 |
| 签名生成 | execjs / Playwright补环境 | 与网页一致的sign参数 |
| IP突破 | 站大爷隧道代理(Proxy-Authorization认证) | 稳定持续采集 |
| 数据存储 | SQLite三表设计 | 结构化歌单数据集 |
| 推荐算法 | 协同过滤 + 内容特征混合 | 个性化歌曲推荐 |
技术选型速览 :推荐"execjs(sign生成)+ requests(接口直调)+ 站大爷隧道代理 + Surprise(协同过滤)"的组合方案。execjs解决sign加密,requests高效直调接口,隧道代理突破IP限制,Surprise快速实现协同过滤。
QQ音乐的歌单数据是音乐推荐系统的"金矿"。每个歌单都是用户品味的一次显式表达,海量歌单的共现关系构成了推荐系统最核心的交互矩阵。通过合理的采集策略和推荐算法,我们可以将这座金矿转化为真正懂用户口味的个性化推荐系统。
最后需要提醒的是:数据采集行为应当遵循平台规则,控制请求频率以避免对目标服务器造成过载。请遵守相关法律法规,仅将爬虫技术用于学习和研究目的。希望本文能帮助你在音乐推荐系统构建的道路上迈出坚实的一步。