
现在我得先说一句大实话个性化推荐系统本身并不难难的往往是数据从哪来。自己做推荐项目最头疼的不是协同过滤怎么写而是手里根本没有像样的用户行为数据。我之前做过一个小项目用爬虫去采集QQ音乐的歌单和歌曲信息攒了几万个歌单和几十万首歌曲的数据然后用这些数据搭了一个基于物品协同过滤的推荐系统。整个过程走下来发现爬虫部分和数据处理部分花的精力远超推荐算法本身这里把完整思路和踩过的坑都整理出来给想自己做推荐系统又缺数据的朋友做个参考。这个项目适合三类人想练习爬虫但不是只会抓静态页面的朋友、想做推荐系统但缺乏真实数据的研究者、以及想在本地跑一套“数据采集存储推荐”全链路demo的开发者。我不会教你怎么绕过版权保护也不涉及任何解锁、破解类内容纯粹是围绕公开可见的歌单页面做数据采集和聚合分析技术方案本身用于学习研究。1. 项目整体设计与选型思路1.1 先想清楚歌单数据为什么适合做推荐我最早考虑过去爬歌曲评论、用户主页这类数据但很快就放弃了。评论区内容虽然丰富但结构松散清洗成本极高用户主页则涉及个人信息采集边界不好把握法律和合规风险都大。对比下来歌单是一个极其合适的数据入口。歌单本身是用户主动编排的歌曲集合里面包含了用户的听歌偏好、场景主题、情绪标签。一个叫“深夜加班BGM”的歌单和一个叫“健身燃脂100首”的歌单它们的歌曲构成天然就有区分度。这意味着歌单本身就是高质量的隐式行为数据——用户用脚投票把歌曲按自己的喜好归类好了。推荐系统拿到这种结构化的偏好数据做相似度计算会非常舒服。另外还有一个很实际的原因歌单页面是公开的平台上搜索歌单不需要登录返回的数据里就带歌曲列表、标签、播放量这些字段爬虫实现成本低稳定性也高。整个项目我把范围边界划定得很清楚只采集歌单列表、歌单详情、歌曲基础信息和歌手信息不做任何用户私人数据采集。1.2 技术栈选型为什么是requests加Selenium再加SQLAlchemy技术选型上我没有一上来就上Scrapy因为这套系统的数据量级用Scrapy有点重。我的目标是一天内能跑完几个GB级别的数据单机足够。最终的技术栈是这样的requests负责大部分JSON接口的请求轻量、可控、调试方便。Selenium少数接口参数带签名、直接请求拿不到正确数据时用浏览器渲染的方式兜底让页面自身的JavaScript把请求发出去。SQLAlchemy所有解析结果统一写入MySQL用ORM建模后头做推荐时查询方便。pandas中间做数据清洗和特征矩阵构建。scikit-learn最后算余弦相似度用。这套组合的选型逻辑很简单requests搞定90%的请求Selenium搞定剩下的10%SQLAlchemy让数据层变得规整pandas和sklearn做分析时顺手。不要迷信框架够用就好。1.3 整条链路的架构拆解整个系统的数据流是这样的先通过搜索歌单的关键词入口拿到第一批歌单ID列表然后逐个请求歌单详情页解析出歌曲列表和歌单元信息写入数据库。清洗之后构建“歌单-歌曲”的共现矩阵用余弦相似度算歌单与歌单之间的相似度最后根据用户输入的种子歌单输出一组相似歌单和其中高频出现但用户还没听过的歌曲。这里有一个关键设计我选择“歌单”作为推荐的基本单元而不是“用户”。因为爬下来的数据里没有真实的用户收听行为记录但歌单本身就是用户行为的聚合产物。基于歌单的协同过滤其实比基于用户的协同过滤更适合这个场景——歌单的数量比用户少得多但每个歌单的歌曲集合信息量非常大稀疏度也更低。这个思路后来被验证是对的。2. 数据模型与存储设计2.1 表结构设计三张核心表搞定关系数据库设计我用了三张核心表歌单表、歌曲表、歌单与歌曲的关联表。先看建表语句和字段设计的思路。歌单表playlist的核心字段有playlist_id平台歌单ID主键、playlist_name歌单名、description歌单描述、tags标签逗号分隔、song_count歌曲数量、play_count播放量、subscribed_count收藏量、creator_name创建者、url歌单URL。这里我特别强调一下主键一定要用平台自带的歌单ID不要自增ID做唯一键。因为增量爬取的时候你得靠这个ID去判断歌单是否已经入库。歌曲表song的核心字段有song_id歌曲全局ID主键、song_name、artist_name、album_name、duration_ms时长毫秒为单位、url。这里要注意一个坑QQ音乐的歌曲ID有两套一套是全局的songmid另一套是专辑内序号。爬虫解析时如果取错字段后面做歌曲去重和匹配会出大问题我建议全表统一用songmid作为唯一标识。关联表playlist_song记录playlist_id和song_id的对应关系另外加一个position字段记录歌曲在歌单里的位置。别小看这个position它反映的是用户编排歌单的顺序靠前的歌曲往往是歌单的核心曲目在推荐加权时可以给更高的权重。2.2 SQLAlchemy ORM建模示例表结构设计好之后用SQLAlchemy做ORM映射相当顺手。核心代码大概是这样from sqlalchemy import Column, String, Integer, ForeignKey, Table, Text from sqlalchemy.orm import relationship, declarative_base from sqlalchemy.dialects.mysql import BIGINT Base declarative_base() playlist_song_association Table( playlist_song, Base.metadata, Column(playlist_id, String(64), ForeignKey(playlist.playlist_id), primary_keyTrue), Column(song_id, String(64), ForeignKey(song.song_id), primary_keyTrue), Column(position, Integer, nullableTrue) ) class Playlist(Base): __tablename__ playlist playlist_id Column(String(64), primary_keyTrue) playlist_name Column(String(255), nullableFalse) description Column(Text) tags Column(String(500)) song_count Column(Integer, default0) play_count Column(BIGINT, default0) subscribed_count Column(BIGINT, default0) creator_name Column(String(255)) url Column(String(1024)) songs relationship(Song, secondaryplaylist_song_association, back_populatesplaylists) class Song(Base): __tablename__ song song_id Column(String(64), primary_keyTrue) song_name Column(String(255), nullableFalse) artist_name Column(String(255)) album_name Column(String(255)) duration_ms Column(Integer) url Column(String(1024)) playlists relationship(Playlist, secondaryplaylist_song_association, back_populatessongs)这样设计的好处是关联关系由ORM自动维护后面做推荐查询时直接通过playlist.songs就能拿到歌单下所有歌曲反向通过song.playlists也能知道一首歌出现在哪些歌单里做协同过滤简直不要太方便。2.3 编码与去重中文乱码和重复数据数据写入时最容易踩的坑就是中文乱码。MySQL建表时字符集必须明确指定为utf8mb4否则歌单名里的emoji或者生僻字直接写入失败。我建库时用的DDL是这样CREATE DATABASE music_data CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;连接串里也要带上charset参数。SQLAlchemy的engine创建时加上?charsetutf8mb4不然连接层的字符集会覆盖掉库表设置。去重策略上主键冲突是常态。同一个歌单在搜索关键词“华语”“流行”“经典”时可能反复出现同一首歌也会出现在几千个歌单里。我的写入逻辑用了先查后插的方式也可以通过INSERT ... ON DUPLICATE KEY UPDATE做原子操作。数据量到几十万量级后后者的性能优势会更明显推荐优先使用。2.4 增量更新设计推荐系统需要新鲜血液推荐系统的数据越新效果越好。老歌单当然有价值但如果歌单里的歌曲列表长期不更新用户很快会腻。我设计了一个增量更新策略每天定时任务扫描已入库的歌单对比页面上的更新时间字段如果有变化就重新抓取歌单详情并更新歌曲关联。增量更新的关键是在歌单表中加一个last_scraped_at字段记录该歌单上次抓取时间定时任务只处理超过一定时间没更新的歌单避免无意义地重复抓取。歌曲表则按song_id做增量新出现的歌曲插入已有的歌曲如果歌手名或专辑信息变化就更新歌曲本身的唯一性不会变。3. 爬虫核心链路与反爬应对3.1 搜索入口从关键词到歌单ID列表整个爬虫链路的起点是搜索接口。你可以直接用平台网页版的搜索接口传入关键词和分页参数拿到返回的JSON数据。先用一些宽泛的关键词比如流行、摇滚、民谣、电子、纯音乐、古典、粤语、日语、欧美、华语把入口铺开每一类拿前几十页的歌单ID这样第一批种子数据就建立了。搜索接口返回的JSON结构里有歌单ID、歌单名、播放量这些信息这一步相对简单。我踩过的一个坑是分页参数类型有些接口的页数从0开始有些从1开始传错的话返回的数据会重复或者一直返回第一页。建议先手动请求两页对比一下返回内容的差异确认分页机制后再批量跑。3.2 歌单详情页解析数据结构与字段提取拿到歌单ID之后访问歌单详情页。这里有两种方式直接请求页面内置的数据脚本或者调歌单详情接口。我在实践中的经验是优先解析页面源码里内置的初始数据因为这种方式不用处理新增的签名参数拿到的是一个完整的JSON对象解析稳定。JSON里歌单主体信息、歌曲列表都在同一个对象中按层级提取就行。要特别说明的是歌曲列表接口是异步分页加载的默认只返回前一部分歌曲如果歌单有几千首歌就得额外请求分页接口拿全量数据。分页接口的参数包含歌单ID和分页偏移量返回格式也是JSON解析逻辑跟列表页类似。3.3 动态签名参数与Selenium兜底方案我在实际采集过程中遇到的真正麻烦是部分接口在请求时带上了时间戳和签名相关的参数直接用requests构造同样的请求返回的结果要么是无效数据要么直接报错。这个时候我不建议去硬啃签名算法更稳的做法是让浏览器自己去发这请求。Selenium在这里的角色不是替代requests而是当requests这条路走不通时的兜底方案。用无头浏览器打开歌单页面等待歌曲列表渲染完成后直接取渲染后的数据。实现上有个经验不要试图去等所有歌曲加载完那样太慢更聪明的方式是先用浏览器打开页面触发一次请求然后拦截网络响应把返回的JSON截取下来。from selenium import webdriver from selenium.webdriver.chrome.options import Options from selenium.webdriver.common.by import By from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC options Options() options.add_argument(--headless) options.add_argument(--window-size1920,1080) options.add_argument(--disable-gpu) options.add_experimental_option(excludeSwitches, [enable-automation]) options.add_experimental_option(useAutomationExtension, False) driver webdriver.Chrome(optionsoptions) driver.execute_cdp_cmd(Page.addScriptToEvaluateOnNewDocument, { source: Object.defineProperty(navigator, webdriver, {get: () undefined}) }) driver.get(https://music.qq.com/playlist/你的歌单ID) WebDriverWait(driver, 15).until( EC.presence_of_element_located((By.CSS_SELECTOR, .songlist__list)) ) page_source driver.page_source driver.quit()这段代码里有几个关键细节。excludeSwitches和execute_cdp_cmd是为了降低自动化特征被识别的概率等待条件用CSS选择器而不是固定sleep能省大量时间且更稳定。说实话Selenium方案调试起来会比requests麻烦但遇到签名问题时它是性价比最好的解决办法。3.4 反爬三板斧UA、延迟、代理整个采集过程里反爬策略是保证任务能跑完的关键。我没有做什么激进的操作就靠三个基础手段第一是User-Agent池。维护一组常见的UA字符串每次请求随机挑选一个至少在请求头层面不像一个固定脚本在跑。第二是随机延迟。两次请求之间至少间隔1到3秒这个延迟幅度足以避开大多数限流逻辑又不会让整个采集过程慢到无法接受。第三是失败重试机制。单个歌单请求失败后不要立刻重试先等5秒再试连续失败三次就跳过记录到日志里等下一轮增量更新再补抓。代理这块如果单机跑且请求频率控制住了普通家用IP短期内问题不大。但如果你要跑几百个歌单以上的批量任务建议准备一个代理池或者干脆用分布式思路把任务分散到不同IP上。我的原则是能不用代理就不用把频率控制好比什么技术都管用。3.5 并发采集把单线程效率提上去爬虫单线程跑实在太慢一个歌单的详情页请求加解析加写库接近2秒一万个歌单就要五个多小时。我后来用ThreadPoolExecutor做了线程池并发线程数控制在8到16之间整体速度提升了六倍以上。from concurrent.futures import ThreadPoolExecutor, as_completed playlist_ids load_all_playlist_ids() def crawl_one(playlist_id): try: data fetch_playlist_detail(playlist_id) save_to_database(data) return playlist_id, True except Exception as exc: log_failure(playlist_id, exc) return playlist_id, False with ThreadPoolExecutor(max_workers12) as executor: future_map {executor.submit(crawl_one, pid): pid for pid in playlist_ids} for future in as_completed(future_map): pid future_map[future] ok future.result() if not ok: failed_ids.append(pid)这里的核心操作是把每次请求放到独立线程里数据库连接要使用连接池否则线程一多容易把数据库连接耗尽。还有一点很重要就是失败的歌单ID要单独收集起来整个过程结束后做一轮补抓。这个“断点续采”的思路在数据量上万之后几乎是必须的。4. 从数据到推荐个性化推荐系统搭建4.1 为什么选基于物品的协同过滤数据入库之后真正有意思的部分来了。推荐系统选型上我对比了基于用户的协同过滤、基于物品的协同过滤、以及简单的关联规则挖掘。最后选了基于物品的协同过滤核心原因有两点。第一数据形态天然适配。我们拥有的是海量歌单及其歌曲集合在这个场景下“物品”既可以理解为歌单也可以理解为歌曲。基于歌单的相似性推荐逻辑很顺如果歌单A和歌单B包含大量相同的歌曲那么喜欢歌单A的用户很可能也喜欢歌单B。第二稀疏度可控。歌单和歌曲之间的关系矩阵虽然也稀疏但相比用户和歌曲的矩阵要稠密得多千级别的行和十万级别的列计算开销在单机上完全可控。4.2 特征构建从数据库到共现矩阵做相似度计算前首先要构建歌单-歌曲的布尔矩阵。这里我没用稀疏矩阵库因为十万首歌曲乘几千个歌单的规模普通numpy数组也就几百MB内存完全可以接受。构建思路是用字典映射歌单到歌曲ID集合然后向量化。import numpy as np from collections import defaultdict # 读取关联表数据 playlist_songs defaultdict(set) for pid, sid in db_session.query(PlaylistSong.playlist_id, PlaylistSong.song_id): playlist_songs[pid].add(sid) playlist_ids list(playlist_songs.keys()) song_ids list(all_song_id_set) song_index {sid: idx for idx, sid in enumerate(song_ids)} matrix np.zeros((len(playlist_ids), len(song_ids)), dtypenp.float32) for i, pid in enumerate(playlist_ids): for sid in playlist_songs[pid]: matrix[i][song_index[sid]] 1.0构建完矩阵后做归一化然后用余弦相似度计算歌单之间的相似度矩阵。公式上就是两个歌单的歌曲向量做内积再除以模长乘积。实际计算我用的是sklearn.metrics.pairwise.cosine_similarity一行代码搞定底层已经做了矩阵运算优化。4.3 推荐生成从相似歌单到推荐歌曲相似度矩阵拿到手之后推荐生成逻辑就非常直接了。给定一个种子歌单取相似度最高的N个歌单作为候选池然后统计候选池里歌曲的出现频次剔除种子歌单中已有的歌曲按“频次×相似度权重”排序取TopK作为推荐结果。这里有个细节值得展开歌曲权重不能只统计频次要跟歌单相似度挂钩。一个相似度0.9的歌单里出现的歌其权重应该高于相似度0.5的歌单里的歌。具体做法是对候选歌单按相似度排序给每个歌单一个排名权重歌曲得分等于它在各个歌单中出现的总权重。def recommend_songs(seed_playlist_id, sim_matrix, playlist_ids, playlist_songs, top_k50): seed_idx playlist_ids.index(seed_playlist_id) sim_scores list(enumerate(sim_matrix[seed_idx])) sim_scores sorted(sim_scores, keylambda x: x[1], reverseTrue) candidates sim_scores[1:11] song_scores defaultdict(float) for idx, sim_score in candidates: rank_weight 1.0 sim_score for sid in playlist_songs[playlist_ids[idx]]: if sid in playlist_songs[seed_playlist_id]: continue song_scores[sid] rank_weight ranked_songs sorted(song_scores.items(), keylambda x: x[1], reverseTrue) return ranked_songs[:top_k]这个逻辑虽然简单但实测效果相当能打。用“华语流行”类歌单做种子推荐结果里出现大量的经典情歌和民谣歌曲排序靠前的歌曲与种子歌单风格高度吻合。对于demo级别的推荐系统这个精度已经超出预期了。4.4 冷启动和推荐质量评估推荐系统绕不开冷启动问题。新歌单刚创建没有历史相似度数据协同过滤直接失效。我的处理方案是退回到内容匹配用歌单的标签和歌曲的歌手信息做特征匹配同标签、同歌手、同时长的歌单做粗糙但有效的补充推荐。这部分不需要重新建模一个简单的倒排索引就能搞定。质量评估方面我没有做在线评测而是做了离线验证。将每个歌单的歌曲随机拆成训练集和测试集用训练集构建相似度看测试集歌曲在推荐列表中的命中率。命中率能做到20%左右对于纯离线模型来说已经是不错的表现。如果你后面想做得更严谨可以引入NDCG、MRR这些排序指标但在当前数据量级上命中率已经足够说明问题。5. 常见问题与排查技巧实录5.1 高频踩坑速查表操作过程中我遇到了不少问题整理成一张速查表省得大家踩同样的坑。问题现象可能原因解决思路请求返回错误JSON或HTML乱码缺少请求头或Cookie可能被判定为异常请求补全UA和Referer必要时挂上已登录Cookie歌曲列表永远只有前几首歌曲列表是异步分页加载找到分页接口按偏移量逐页请求中文歌单名写入数据库变成问号MySQL连接字符集不是utf8mb4库表DDL和连接串都显式指定utf8mb4单机跑一会儿后请求全部超时请求频率过高触发限流加大随机延迟间隔每批次休息30秒以上歌单ID重复入库没有做存在性检查主键冲突时做INSERT ON DUPLICATE KEY UPDATE部分歌曲字段缺失如专辑为空歌曲确实没有专辑信息字段设为可空不做强校验Selenium偶发加载超时页面资源多或网络波动增加重试机制超时后重新加载页面推荐结果全是热门歌曲相似歌单量太少热点被放大增加采集歌单数量限制流行度上限5.2 我踩过的三个真实的大坑第一个坑是IP被限流之后整个任务全挂了。我初期用10个线程并发抓间隔只有0.5秒跑了二十分钟后所有请求开始报错。排查了一下发现不是被封IP是所有请求都在等待响应触发了平台的限流策略。解决方案很简单粗暴间隔调到2到3秒并且每抓完500个歌单暂停一分钟让请求频率呈锯齿形变化而不是匀速直线。从那之后再没出现过类似问题。第二个坑是Selenium被识别。一开始我以为加上headless参数就万事大吉结果跑了没几轮就弹出了验证码页。这个问题的根源在于webdriver的特征太明显即便headless模式下也会被前端脚本检测到navigator.webdriver属性。解决方式是在页面加载前通过CDP命令修改这个属性同时隐藏自动化扩展特征。改完之后验证码出现的概率大幅下降前提还是把请求频率降下来单靠参数伪装治标不治本。第三个坑是最隐蔽的歌曲ID字段拿错导致推荐效果奇差。歌曲在平台内部有多个ID本地数据库里存成了专辑内序号等到做匹配时同一首歌在不同歌单里的ID对不上共现矩阵稀疏度暴涨推荐质量大幅下降。后来花了半天时间用歌曲名加歌手名做二次匹配把数据统一到全局歌曲ID上。这个坑提醒我解析阶段对ID的选择直接决定了下游数据质量第一时间就要确认清楚。5.3 让系统持续运行定时任务与扩展方向整套系统跑通之后我把它部署成了一条稳定的数据流水线。定时任务每天凌晨自动跑增量爬取更新歌单和歌曲数据然后重算相似度矩阵。因为增量更新往往只涉及少量歌单重算矩阵加推荐生成在单机上几分钟内就能完成完全够用。数据积累到一定程度后还有几个值得尝试的扩展方向。基于歌曲共现数据做关联规则挖掘找出类似“听这首歌的人还会听什么”的强关联对结合歌单的播放量和收藏量做热门与冷门的平衡避免推荐结果全是大热歌曲还可以把相似歌单的结果做成候选池接一个简单的排序模型给特征加权重。每一步扩展都建立在已有数据基础上不需要改动爬虫层这也是当初把数据层和算法层解耦带来的最大好处。最后说点实在的。这个项目做下来我最大的体会是爬虫和数据处理才是耗时的大头推荐算法反而是最快落地的一环。如果你也想复现类似项目我的建议是先别追求数据量挑两三个热门关键词爬几百个歌单先把全链路跑通再慢慢扩大采集规模。控制好请求频率管理好数据质量遵守平台的服务条款和应用规范这个项目能给你带来的收获绝对不只是“跑通了一个demo”那么简单。