1. 音乐爬虫到底在爬什么先搞清楚目标再动手很多人一听“音乐爬虫”脑子里第一反应就是“批量下载歌曲”。这个理解不能说错但太窄了。我在实际折腾这类项目的时候发现音乐相关的数据采集需求其实分好几个层次每个层次对应的技术方案、难度、注意事项完全不一样。如果你一上来就冲着“下载”去写代码大概率会在中途卡住因为你要处理的东西远比想象中复杂。先把这个领域的采集目标拆开来看。最表层的是元数据采集也就是歌曲名、歌手、专辑、时长、发行时间、曲风标签这些文本信息。这类数据通常以结构化或半结构化的形式存在于页面的某个位置采集难度相对低是入门练手的好选择。往下一层是榜单与评论数据比如某类热门歌曲排行、用户评论、播放量趋势这类数据带有时间维度适合做趋势分析。再往深一层才是音频文件本身也就是大家最关心的那部分它涉及文件地址解析、分片传输、格式处理等一系列问题。我之所以强调先分清目标是因为这三种需求的实现路径差异极大。元数据可能一个请求加一次解析就搞定了而音频文件往往需要分析请求规律、处理动态加载、应对各种校验。你要是拿下载音频的思路去采元数据属于杀鸡用牛刀反过来拿采元数据的简单脚本去碰音频文件那基本是寸步难行。提示动手之前先花十分钟把目标写清楚——我要的是文本信息、统计数据还是媒体文件这个决定会直接影响后面所有的技术选型。从应用场景来说音乐爬虫的价值也不只是“存歌”。做音乐推荐算法需要大量带标签的曲库数据做市场分析需要榜单和热度趋势做个人音乐库管理需要把散落各处的收藏整理到一起。不同的场景对数据新鲜度、完整性、更新频率的要求都不同。比如趋势分析更看重定时采集和时间序列的连续性而个人曲库整理则更看重一次性把历史数据抓全。想清楚你服务的是哪个场景才知道该把精力花在哪里。2. 技术选型为什么我最终选了这套组合2.1 请求层从裸请求到会话管理的演进刚开始写的时候很多人会用最朴素的方式发请求一个地址一个请求拿到响应就解析。这种写法在采集量小、目标站点友好的时候没问题但一旦量上来就会暴露各种问题连接频繁建立销毁效率低、被识别为异常流量、cookie 和会话状态丢失导致后续请求失败。我踩过的坑是这样的早期写了个脚本循环请求某个列表页前几十条正常后面开始返回空数据或者跳转到验证页面。排查半天才明白问题出在没有维持会话状态。后来改成用会话对象统一管理请求把 cookie、请求头、连接复用都交给它处理稳定性立刻上了一个台阶。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, Accept-Language: zh-CN,zh;q0.9, }) resp session.get(https://example-music-site.com/list)这里有个细节值得说请求头里的User-Agent不是随便填的它决定了服务端把你当成什么客户端。用默认的 python-requests 标识很多站点会直接给你返回精简版页面甚至拒绝服务。伪装成常见浏览器标识是最基础的一步但别以为这就够了后面还有更细的校验。2.2 解析层结构化数据优先正则兜底解析这块我的原则很明确能用结构化解析就不用正则。所谓结构化解析就是针对 HTML 用解析库按标签层级取数据针对 JSON 接口直接按字段取值。正则表达式虽然灵活但可读性差、维护成本高页面结构一变就得重写。对于 HTML 页面我习惯用解析库配合 CSS 选择器。它的好处是选择器写起来直观比如“取所有 class 为 song-item 的 div 里的标题链接”一行选择器就能表达清楚。而正则你得写一长串模式串过两天自己都看不懂。from bs4 import BeautifulSoup soup BeautifulSoup(resp.text, html.parser) for item in soup.select(.song-item): title item.select_one(.title).get_text(stripTrue) artist item.select_one(.artist).get_text(stripTrue) print(title, artist)但现实往往没那么理想。有些站点的数据是动态渲染的你直接请求拿到的 HTML 里根本没有歌曲信息全是空的容器加一段脚本。这时候要么去分析它背后的数据接口要么用能执行脚本的采集方式。前者效率高但需要分析请求规律后者通用但资源消耗大。我的建议是优先找接口实在找不到再上重型方案。2.3 存储层别小看数据落地的选择采集到的数据往哪放这个问题很多人不重视结果后期想分析的时候发现数据格式乱七八糟。我的经验是按数据量和用途分档小规模、临时性的用 CSV 或 JSON 文件就够了中等规模、需要查询的用轻量数据库大规模、多表关联的才上完整的数据库系统。对于音乐元数据这种字段相对固定、量级中等的场景我一般用轻量数据库。它不需要额外部署服务一个文件就是一个库查询用标准 SQL迁移和备份都方便。字段设计上歌曲名、歌手、专辑这些建普通索引采集时间和来源地址单独存一列方便后续去重和增量更新。import sqlite3 conn sqlite3.connect(music.db) conn.execute( CREATE TABLE IF NOT EXISTS songs ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, artist TEXT, album TEXT, duration INTEGER, source_url TEXT UNIQUE, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) ) conn.commit()注意source_url上加了唯一约束这是去重的关键。同一首歌可能在不同页面出现靠标题去重不可靠同名歌曲太多靠来源地址去重最稳妥。3. 核心环节实操从列表页到详情页的完整链路3.1 列表页翻页规律分析采集的第一步通常是拿到一个列表然后翻页。翻页的规律有几种常见形态识别出属于哪种代码就好写了。第一种是页码参数型地址里带page1、page2这样的参数改数字就能翻页最简单。第二种是偏移量型用offset0、offset20表示从第几条开始取每页固定条数。第三种是游标型返回数据里带一个next_cursor字段下次请求带上它才能取下一页这种常见于接口设计较新的站点。第四种是滚动加载型页面不给你翻页按钮往下滚自动加载背后其实还是接口在按偏移量或游标取数。我一般先用浏览器开发者工具观察翻页时的请求变化找到那个规律性的参数。如果是页码型直接循环构造地址就行如果是游标型就得先请求第一页从响应里取出游标再请求下一页形成链式调用。def fetch_all_pages(base_url, max_pages50): results [] cursor None for _ in range(max_pages): params {cursor: cursor} if cursor else {} resp session.get(base_url, paramsparams) data resp.json() items data.get(items, []) if not items: break results.extend(items) cursor data.get(next_cursor) if not cursor: break return results这段代码里有个防御性设计if not items: break和if not cursor: break。前者防止空页导致死循环后者在没有下一页游标时正常退出。别小看这两个判断我见过太多脚本因为缺少退出条件要么无限循环把内存撑爆要么请求到天荒地老。3.2 详情页字段提取的取舍列表页通常只给标题和歌手想要专辑、时长、发行时间这些就得进详情页。但这里有个效率问题如果列表有一万条每条都进详情页那就是一万次额外请求时间和资源成本都很高。我的做法是分情况处理。如果列表页已经包含了足够用的字段就不进详情页直接存。如果确实需要详情页的字段那就先评估必要性——是不是所有字段都要能不能只对部分记录进详情页比如做趋势分析可能只需要标题和热度那详情页完全可以跳过。确实需要进详情页的时候我会加一个请求间隔避免短时间内大量请求给目标站点造成压力也降低自己被限制的概率。间隔时间设多少合适我的经验是至少零点几秒具体看站点响应速度和你的采集量。别贪快稳比快重要。import time import random def fetch_detail(url): time.sleep(random.uniform(0.5, 1.5)) resp session.get(url) soup BeautifulSoup(resp.text, html.parser) detail { album: safe_text(soup, .album-name), duration: safe_text(soup, .duration), release_date: safe_text(soup, .release-date), } return detail def safe_text(soup, selector): node soup.select_one(selector) return node.get_text(stripTrue) if node else Nonesafe_text这个辅助函数是我强烈建议加的。页面结构不可能永远如你所愿某个字段缺失是常态。如果直接.get_text()而节点不存在程序就抛异常中断了。包一层判空缺失字段返回 None程序能继续跑事后你也能从 None 的比例看出页面结构是不是变了。3.3 音频文件地址的解析思路到了音频文件这一层情况就复杂多了。直接能拿到一个静态文件地址的情况越来越少更多时候你面对的是动态生成的地址、带时效签名的地址或者分片传输的流。先说动态地址。有些页面加载时音频地址是通过脚本拼接出来的你在 HTML 源码里搜不到完整的文件地址。这时候要么执行脚本拿到最终地址要么分析拼接逻辑自己还原。分析拼接逻辑更轻量但需要读懂它的规则比如地址由固定前缀加一个歌曲 ID 再加固定后缀组成。再说带签名的地址。这类地址通常包含一个时效参数和签名参数签名是根据地址内容加密钥算出来的。你没法伪造签名只能在使用前实时获取。这意味着你不能提前把所有地址存下来慢慢下得边取边下。分片传输的情况音频被切成很多小段每段一个地址按顺序请求再合并。这种设计本来是为了流畅播放但对采集来说意味着请求数量成倍增加。处理思路是先拿到分片列表然后按顺序请求每个分片最后拼接成完整文件。注意处理音频文件时务必确认你的使用场景符合相关规定和站点条款。个人学习研究和技术验证是常见用途但批量分发或商业使用需要格外谨慎。4. 反爬应对与稳定性保障那些文档不会写的事4.1 请求频率控制的实战参数反爬机制的核心逻辑就一条识别出非人类的访问模式。而最容易被识别的特征就是请求频率——人不可能每秒请求十次机器可以。所以频率控制是第一道防线。但频率设多少合适这个问题没有标准答案。我的经验是从保守值开始试比如每次请求间隔一秒观察一段时间看是否稳定。如果稳定且速度可接受就保持如果太慢再逐步降低间隔但每次调整后都要观察是否触发限制。千万别一上来就设零点零一秒然后怪站点封你。除了固定间隔我还会加随机抖动。固定间隔本身也是一种模式一秒一次太规律了加个随机浮动更像人。上面代码里的random.uniform(0.5, 1.5)就是这个思路。def polite_request(url, min_delay0.8, max_delay2.0): time.sleep(random.uniform(min_delay, max_delay)) resp session.get(url, timeout10) if resp.status_code 429: # 被限流了退避更长时间 time.sleep(random.uniform(5, 10)) resp session.get(url, timeout10) return resp这里处理了 429 状态码它通常表示请求过于频繁。遇到它不要硬刚退避一段时间再试。我一般会做两三次重试还不行就跳过这条记录记录下来事后处理。4.2 请求头与指纹的细节前面提过 User-Agent但请求头里值得关注的字段不止这一个。Referer表示你从哪个页面跳转过来的有些站点会校验它缺失或不对就拒绝。Accept和Accept-Encoding表示你能接受什么格式的响应设置合理能拿到更完整的页面。Connection控制连接复用配合会话对象使用效果更好。还有一个容易被忽略的点是请求头字段的顺序和大小写。听起来很玄学但确实有站点会检查这些细节。最省事的做法是直接从浏览器开发者工具里把真实请求的头部复制过来照着设置。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, Accept: text/html,application/xhtmlxml,application/xml;q0.9,*/*;q0.8, Accept-Encoding: gzip, deflate, br, Accept-Language: zh-CN,zh;q0.9,en;q0.8, Referer: https://example-music-site.com/, Connection: keep-alive, })4.3 断点续采与去重机制采集任务跑一半中断是家常便饭网络波动、程序异常、目标站点临时不可用都可能导致中断。如果没有断点续采机制每次中断都得从头再来浪费大量时间和请求配额。我的做法是每采集完一条就立即落库而不是攒一批再写。落库时靠唯一约束去重重复的记录会被自动忽略。这样即使中断重启后已经采过的记录不会重复处理程序只需要跳过已存在的继续采新的。def save_song(conn, song): try: conn.execute( INSERT OR IGNORE INTO songs (title, artist, album, source_url) VALUES (?, ?, ?, ?) , (song[title], song[artist], song[album], song[source_url])) conn.commit() return True except sqlite3.Error as e: print(f保存失败: {e}) return FalseINSERT OR IGNORE配合唯一约束重复数据直接跳过不报错。这个组合在增量采集场景里非常好用。判断某条是否已采集也不需要额外查询直接尝试插入看影响行数就行。5. 常见问题排查速查与避坑清单5.1 采集不到数据的排查顺序当你发现代码跑完但一条数据都没拿到别急着改代码按这个顺序排查效率最高。先看响应状态码。如果是 403 或 401多半是请求头或权限问题如果是 404地址可能变了如果是 200 但内容为空那问题在解析或页面结构。再看响应内容本身把resp.text打印出来看前几百个字符是正常 HTML、是验证页面、还是空壳加脚本一眼就能判断。最后才看解析选择器对不对用开发者工具确认目标元素的 class 或 id 有没有变。我整理了一个速查表覆盖最常见的几类问题现象可能原因排查方向状态码 403请求头缺失或被识别补全请求头检查 Referer状态码 429请求过于频繁加大间隔加退避重试返回内容为空数据动态渲染找接口或换渲染方案解析结果全为 None选择器失效用开发者工具核对结构翻页到某页后重复游标逻辑有误检查游标取值和传递中文乱码编码识别错误手动指定响应编码5.2 编码问题的处理中文乱码是采集里高频出现的问题。根源在于响应内容的实际编码和程序猜测的编码不一致。有些站点声明的是 UTF-8实际返回的是 GBK程序按声明去解就乱了。处理办法是手动指定编码。可以先看响应头里的Content-Type有没有带 charset没有的话看 HTML 里的 meta 声明再不行就试常见的几种编码哪个解出来正常用哪个。resp.encoding resp.apparent_encoding # 或者手动指定 resp.encoding utf-8 text resp.textapparent_encoding是让程序根据内容自动猜测编码多数情况够用但偶尔会猜错。对编码特别敏感的站点我会手动指定确保稳定。5.3 我踩过的几个典型坑第一个坑是把列表页的标题当成唯一标识。结果发现同名歌曲一大堆去重逻辑完全失效。后来改用来源地址做唯一标识才解决。这个教训是唯一标识要选真正唯一的字段别想当然。第二个坑是忽略请求超时设置。默认情况下请求可能一直挂着不返回程序就卡死在那里。加上timeout10之后超时抛异常程序能继续处理下一条。这个参数看着不起眼但能救命。第三个坑是采集完不校验数据质量。跑了几万条结果发现一半的歌手字段是空的因为页面改版了选择器没跟上。后来我养成了习惯采集完抽样检查统计各字段的填充率填充率异常就说明解析出了问题。第四个坑是没有控制并发。为了快开了几十个线程同时请求结果目标站点直接把我拒了还影响了其他正常用户。后来改成适度并发加频率控制虽然慢一点但稳定得多。快和稳之间长期看稳更重要。5.4 数据清洗的收尾工作采集到的原始数据往往带着各种杂质多余的空格、HTML 实体、不一致的格式。直接存着用起来会很难受所以采集完做一轮清洗很有必要。常见的清洗操作包括去除首尾空白、把 HTML 实体转成正常字符、统一日期格式、把时长从“3:45”转成秒数。这些操作不复杂但能大幅提升数据可用性。import html import re def clean_text(text): if not text: return None text html.unescape(text) text re.sub(r\s, , text) return text.strip() def duration_to_seconds(duration_str): if not duration_str: return None parts duration_str.split(:) if len(parts) 2: return int(parts[0]) * 60 int(parts[1]) return Nonehtml.unescape处理amp;这类实体正则把连续空白压成一个空格strip去首尾。时长转换把“分:秒”格式统一成秒数方便后续排序和统计。这些函数我会单独放一个模块采集和清洗分开职责清晰。6. 从能跑到好用工程化的一些思考6.1 配置与代码分离一开始我把目标地址、选择器、间隔时间都硬编码在代码里改一个参数就得翻代码。后来改成配置文件地址、选择器、参数都放外面代码只负责逻辑。这样换一个采集目标改配置就行代码不用动。配置格式用 JSON 或 YAML 都行我倾向 YAML写起来更清爽支持注释。配置里放目标地址模板、各字段的选择器、请求间隔范围、最大页数这些。代码读配置驱动行为灵活性和可维护性都上来了。6.2 日志与监控脚本跑起来之后你得知道它跑到哪了、有没有出错、采了多少条。这些靠打印是不够的得用日志。日志分级记录正常信息、警告、错误分开出问题的时候按级别过滤快速定位。我一般会记录这几类信息每页采集的条数、累计采集总数、遇到的异常和重试、耗时统计。跑一段时间后看日志就能判断采集是否正常、速度是否合理、有没有异常模式。6.3 增量采集的设计一次性采集只是开始很多场景需要持续更新。增量采集的核心是识别哪些是新的、哪些已经采过。最简单的方式是靠唯一标识去重每次全量扫一遍新的插入旧的忽略。但全量扫效率低更好的方式是利用时间或游标只取上次采集之后的新内容。如果目标站点支持按时间筛选那就记录上次采集的时间点下次只取这个时间之后的。如果不支持那就靠游标或页码记录上次采到哪下次从那里继续。增量采集能大幅减少重复请求长期运行的成本低很多。6.4 合规与边界意识技术能力是一回事怎么用是另一回事。采集之前花点时间看看目标站点的使用条款了解哪些行为是被允许的。控制请求频率不对站点造成额外负担这是基本的礼貌。采集到的数据怎么用也要心里有数个人学习研究和公开分发是两码事。我个人的原则是只采公开可见的数据控制频率不影响他人数据仅用于自己学习分析不二次分发。这个边界清晰了做起来也踏实。7. 一些实用的小技巧补充关于选择器的稳定性我有个习惯是优先选带语义的 class 或 id避开那些看起来像自动生成的随机字符串。随机字符串往往是构建工具生成的改版就变语义化的命名相对稳定。如果只有随机字符串可选那就多准备几个备选选择器一个失效了自动试下一个。关于异常处理别用一个大 try 包住整个循环。那样一出错整个任务就停了。正确的做法是在单条记录的处理上加 try出错记录日志跳过继续处理下一条。这样个别问题不影响整体进度。关于测试正式跑之前先用小批量试。把最大页数设成两三页跑一遍看数据质量、看有没有报错、看速度是否可接受。确认没问题再放开跑全量。这个习惯帮我避免了好几次跑了几小时才发现数据全错的情况。关于数据备份采集成果来之不易定期备份数据库文件。轻量数据库就是一个文件复制一份就行成本极低但关键时刻能救命。最后说个心态上的事。音乐爬虫这类项目技术门槛不算高但细节特别多坑也特别多。别指望一次写完美边跑边调是常态。遇到问题按排查顺序一步步来大部分问题都能定位。真正拉开差距的不是会不会写而是遇到问题时能不能快速找到原因、能不能把稳定性做上去。我做了这么久最大的体会就是慢一点、稳一点、多留日志、多做校验比追求一时的速度重要得多。