1. 项目概述与核心价值
最近在整理个人音乐库,发现很多老歌在主流平台要么下架了,要么音质版本不理想。手动一首首去找去下载,效率实在太低。于是,一个念头冒了出来:能不能用Python写个工具,自动从像酷狗这样的音乐平台,把歌曲、歌词、封面图一并“请”回来?这不仅是解决个人需求,更是一个典型的网络爬虫与数据采集实战项目,涉及HTTP请求、数据解析、文件处理等多个核心技能点。
这个项目,我们称之为“Python实现搜索爬取酷狗音乐(歌曲、歌词、图片)”。它的核心价值在于,通过一个完整的自动化流程,将分散在网页中的音乐资源(音频流、歌词文本、图片链接)精准定位、解析并下载到本地,构建一个结构化的个人音乐库。整个过程,你会接触到如何模拟浏览器行为绕过基础反爬、如何从复杂的JSON数据或HTML页面中提取关键信息、如何处理不同类型的文件流。无论你是想系统学习Python网络爬虫,还是想拥有一个定制化的音乐收藏工具,这个项目都能提供一条清晰的实践路径。
2. 整体思路与技术选型
2.1 核心思路拆解
爬取一个音乐平台,本质上是一个“搜索-定位-获取”的三步走流程。我们的目标是输入一个关键词(如歌名或歌手),最终得到对应的MP3文件、LRC歌词文件和JPG/PNG封面图片。
- 搜索接口分析:首先,我们需要找到酷狗音乐用于搜索的API接口。这通常不是直接访问
www.kugou.com的搜索页,而是通过浏览器开发者工具(F12)的“网络”(Network)标签,捕获在搜索框输入时浏览器实际发送的XHR(Ajax)请求。这个接口会返回一个结构化的JSON数据,里面包含了歌曲列表,以及每首歌的唯一标识(如hash和album_id)。 - 数据解析与定位:从搜索接口返回的JSON中,我们需要解析出目标歌曲的详细信息,特别是用于获取音频文件、歌词和封面的关键参数。例如,音频文件可能需要
hash和album_id组合成一个新的请求URL;歌词可能有独立的API;封面图则可能嵌入在歌曲信息或专辑信息中。 - 资源获取与存储:根据解析出的URL,分别发送HTTP请求获取音频二进制流、歌词文本和图片二进制流。然后,根据歌曲信息(歌名、歌手)合理命名文件,并分门别类地保存到本地文件夹中。
2.2 关键技术栈与工具选型
为什么选择以下工具?因为它们组合起来能高效、稳定地完成上述任务,并且是Python生态中的主流选择。
requests:这是处理HTTP请求的绝对主力库。相比Python内置的urllib,它的API更加简洁优雅,会话管理、请求头设置、代理支持等功能一应俱全。我们将用它来模拟浏览器发送搜索请求、获取音频/歌词/图片数据。json/re(正则表达式):json库用于解析搜索API返回的JSON数据。而re(正则表达式)则作为补充,在某些情况下,如果数据嵌套在HTML或JavaScript代码片段中,可以用正则快速提取关键字符串。优先使用json,因为它更精确。os/pathlib:用于本地文件系统的操作,包括创建用于存放歌曲、歌词、图片的目录,以及检查文件是否已存在避免重复下载。pathlib提供了更面向对象的路径操作方式,是现代Python的首选。logging:一个好用的工具必须有清晰的运行日志。使用logging模块可以方便地记录程序运行状态、成功下载了哪些文件、遇到了什么错误等,便于后期调试和监控。- (可选)
concurrent.futures:如果你有大量歌曲需要下载,可以考虑使用这个模块进行简单的多线程或多进程并发下载,以显著提升效率。但初期建议先实现单线程版本,确保逻辑正确。
注意:在开始编码前,务必、反复、仔细地使用浏览器开发者工具分析目标网站。网络环境、网站前端架构随时可能变化,直接使用过时的接口或参数会导致爬虫失效。本指南提供的思路和代码框架是基于常见模式,具体参数需要你动手分析获取。
3. 核心环节实现与代码解析
3.1 环境准备与基础配置
首先,确保你的Python环境(建议3.7以上)已经安装了requests库。如果没有,通过pip安装:
pip install requests接下来,我们创建一个Python脚本文件,比如kugou_music_downloader.py,并开始搭建基础框架。我们先导入必要的库,并配置一些全局变量,如请求头、保存路径等。
import requests import json import re import os from pathlib import Path import logging from typing import Optional, Dict, Any # 配置日志,方便查看运行情况 logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') logger = logging.getLogger(__name__) class KugouMusicDownloader: def __init__(self, save_dir: str = "./downloaded_music"): """ 初始化下载器 :param save_dir: 音乐保存的根目录 """ self.session = requests.Session() # 使用会话,可以保持一些连接状态 self.save_dir = Path(save_dir) # 创建子目录 self.song_dir = self.save_dir / "songs" self.lyric_dir = self.save_dir / "lyrics" self.cover_dir = self.save_dir / "covers" for d in [self.song_dir, self.lyric_dir, self.cover_dir]: d.mkdir(parents=True, exist_ok=True) # 关键:设置请求头,模拟浏览器访问。User-Agent必不可少。 self.headers = { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36', 'Referer': 'https://www.kugou.com/', # 添加来源页,更逼真 } self.session.headers.update(self.headers) # 搜索API的基础URL(需要你通过浏览器开发者工具实时分析获取,这里是一个示例格式) # 重要:这个URL不是固定的,必须通过分析得到。 self.search_api_url = "https://complexsearch.kugou.com/v2/search/song" # 歌曲详情/播放地址API(同样需要分析) self.song_info_api_url = "https://wwwapi.kugou.com/yy/index.php"代码解读与注意事项:
- 使用
Session对象:requests.Session()可以复用底层的TCP连接,在多次请求同一主机时效率更高,并且能自动管理cookies。 - 路径管理:使用
pathlib.Path让路径操作更安全、跨平台。mkdir(parents=True, exist_ok=True)能一次性创建多级目录,并且如果目录已存在也不会报错。 - 请求头(Headers):这是绕过基础反爬的第一道关。
User-Agent告诉服务器我们是一个“浏览器”,Referer告诉服务器我们是从哪个页面跳转过来的,这两个是大多数API检查的常见字段。后续根据实际情况,可能还需要添加Cookie或其他特定头部。
3.2 搜索功能实现与数据解析
这是整个流程的起点。我们需要构造一个搜索请求,并解析返回的歌曲列表。
def search_song(self, keyword: str, page: int = 1, pagesize: int = 30) -> Optional[list]: """ 根据关键词搜索歌曲 :param keyword: 搜索关键词(歌名、歌手等) :param page: 页码 :param pagesize: 每页数量 :return: 歌曲信息列表,失败返回None """ # 构造请求参数。这些参数(如`keyword`, `page`, `pagesize`)需要根据实际API分析。 params = { 'keyword': keyword, 'page': page, 'pagesize': pagesize, # 可能还需要其他参数,如`platform`、`filter`、`iscorrection`等,请自行分析补充 'platform': 'WebFilter', 'format': 'json', } try: logger.info(f"正在搜索: {keyword}") # 发送GET请求到搜索API resp = self.session.get(self.search_api_url, params=params, timeout=10) resp.raise_for_status() # 如果状态码不是200,抛出HTTPError异常 data = resp.json() # 解析JSON响应 # 解析数据结构:需要根据实际返回的JSON格式来定位歌曲列表。 # 示例:假设返回结构是 data -> lists -> 歌曲列表 if data.get('status') == 1: # 假设状态码1表示成功 songs = data.get('data', {}).get('lists', []) logger.info(f"搜索到 {len(songs)} 首歌曲") return songs else: logger.error(f"搜索API返回错误状态: {data}") return None except requests.exceptions.RequestException as e: logger.error(f"搜索请求失败: {e}") return None except json.JSONDecodeError as e: logger.error(f"解析搜索结果JSON失败: {e}, 响应文本: {resp.text[:200]}") return None实操心得:
resp.raise_for_status():这是一个好习惯,它能立即捕获HTTP错误(如404, 500),避免程序在后续解析时因无效响应而崩溃。- JSON解析的健壮性:使用
.get()方法而不是直接键名(如data['lists'])来访问字典,可以避免因API返回结构微调或缺少某个键而导致的KeyError异常。如果lists不存在,.get('lists', [])会返回一个空列表,程序可以继续运行。 - 分析API是动态的:上面代码中的
params和解析路径data['data']['lists']都是示例。你必须打开浏览器,在酷狗搜索一首歌,然后在“网络”面板里找到那个真正的搜索请求(通常是XHR类型),查看它的“负载”(Payload)和“响应”(Response),才能确定正确的参数名和数据结构。这是本项目最核心、最需要动手的一步。
3.3 获取歌曲详情与下载链接
搜索返回的列表通常只包含歌曲的基本信息和关键ID(如hash和album_id)。我们需要用这些ID去请求另一个API,以获取真正的音频文件直链、歌词链接和封面图链接。
def get_song_detail(self, song_hash: str, album_id: str) -> Optional[Dict[str, Any]]: """ 根据歌曲hash和album_id获取详细信息,包括播放地址、歌词、封面 :param song_hash: 歌曲唯一hash :param album_id: 专辑ID :return: 包含详细信息的字典,失败返回None """ # 构造获取歌曲播放信息的参数。这个API的规则也需要分析。 params = { 'r': 'play/getdata', 'hash': song_hash, 'album_id': album_id, 'dfid': '-', # 这些参数可能需要,具体看分析 'mid': '...', 'platid': '4', 'format': 'json', } try: logger.info(f"获取歌曲详情: hash={song_hash}, album_id={album_id}") resp = self.session.get(self.song_info_api_url, params=params, timeout=10) resp.raise_for_status() data = resp.json() if data.get('status') == 1: # 假设成功状态码为1 song_info = data.get('data', {}) # 提取关键信息 play_url = song_info.get('play_url', '') # 音频文件地址 lyrics = song_info.get('lyrics', '') # 歌词文本或歌词URL img_url = song_info.get('img', '') # 封面图片地址 song_name = song_info.get('song_name', '未知歌曲') author_name = song_info.get('author_name', '未知歌手') return { 'play_url': play_url, 'lyrics': lyrics, 'img_url': img_url, 'song_name': self._sanitize_filename(song_name), 'author_name': self._sanitize_filename(author_name), } else: logger.error(f"获取歌曲详情失败,响应: {data}") return None except requests.exceptions.RequestException as e: logger.error(f"请求歌曲详情失败: {e}") return None @staticmethod def _sanitize_filename(filename: str) -> str: """清理文件名中的非法字符,防止保存文件时出错""" # 移除Windows/Linux文件名中不允许的字符 illegal_chars = r'[<>:"/\\|?*\x00-\x1f]' return re.sub(illegal_chars, '_', filename)关键点解析:
hash和album_id:这两个是定位一首歌资源的核心ID。它们通常从搜索结果的歌曲信息中获取。- 信息提取:从详情API的响应中,我们需要解析出
play_url(MP3地址)、lyrics(可能是LRC格式的文本,也可能是一个URL)、img_url(封面图地址)。同样,具体的字段名需要你根据实际API响应来确定。 - 文件名清理:歌曲名和歌手名可能包含斜杠、冒号等文件系统禁止的字符。
_sanitize_filename方法使用正则表达式将这些字符替换为下划线,确保能成功创建文件。
3.4 核心下载功能实现
拿到资源地址后,我们就可以下载了。这里我们将实现三个独立的下载函数,并统一调用。
def download_file(self, url: str, save_path: Path, file_type: str = '') -> bool: """ 通用文件下载函数 :param url: 文件下载地址 :param save_path: 本地保存路径 :param file_type: 文件类型描述,用于日志 :return: 是否成功 """ if not url: logger.warning(f"{file_type} URL为空,跳过下载") return False if save_path.exists(): logger.info(f"文件已存在,跳过下载: {save_path}") return True try: logger.info(f"开始下载{file_type}: {url}") resp = self.session.get(url, stream=True, timeout=30) # stream模式用于大文件 resp.raise_for_status() # 以二进制写入模式保存文件 with open(save_path, 'wb') as f: for chunk in resp.iter_content(chunk_size=8192): # 分块写入 if chunk: f.write(chunk) logger.info(f"{file_type}下载成功: {save_path}") return True except requests.exceptions.RequestException as e: logger.error(f"下载{file_type}失败 ({url}): {e}") return False except IOError as e: logger.error(f"保存{file_type}文件失败 ({save_path}): {e}") return False def download_song(self, song_detail: Dict[str, Any]) -> bool: """下载一首歌的所有资源(音频、歌词、封面)""" song_name = song_detail['song_name'] author_name = song_detail['author_name'] # 1. 下载音频 audio_success = False play_url = song_detail.get('play_url') if play_url: # 构造文件名,例如:歌手 - 歌名.mp3 audio_filename = f"{author_name} - {song_name}.mp3" audio_path = self.song_dir / audio_filename audio_success = self.download_file(play_url, audio_path, '音频') else: logger.warning(f"歌曲 {song_name} 无播放地址,跳过音频下载") # 2. 下载歌词(假设lyrics字段直接是LRC文本内容) lyric_success = False lyrics = song_detail.get('lyrics') if lyrics and len(lyrics) > 10: # 简单判断是否为有效歌词文本 lyric_filename = f"{author_name} - {song_name}.lrc" lyric_path = self.lyric_dir / lyric_filename try: with open(lyric_path, 'w', encoding='utf-8') as f: f.write(lyrics) logger.info(f"歌词保存成功: {lyric_path}") lyric_success = True except IOError as e: logger.error(f"保存歌词失败: {e}") else: logger.warning(f"歌曲 {song_name} 无有效歌词,跳过歌词下载") # 3. 下载封面 cover_success = False img_url = song_detail.get('img_url') if img_url: # 从URL中提取文件扩展名,或默认使用.jpg ext = os.path.splitext(img_url)[1] if not ext: ext = '.jpg' cover_filename = f"{author_name} - {song_name}{ext}" cover_path = self.cover_dir / cover_filename cover_success = self.download_file(img_url, cover_path, '封面') else: logger.warning(f"歌曲 {song_name} 无封面地址,跳过封面下载") # 返回整体成功状态(这里定义为音频下载成功即算成功) return audio_success代码细节与优化:
stream=True:在下载音频、图片等可能较大的文件时,设置stream=True非常重要。它不会立即将整个响应内容加载到内存,而是允许你以数据块(chunk)的方式迭代读取和写入文件,避免内存消耗过大。- 分块写入:
resp.iter_content(chunk_size=8192)以8KB为单位读取数据并写入文件,这是一种高效且内存友好的做法。 - 歌词处理:示例中假设
lyrics字段直接是LRC格式的文本。但实际情况可能是返回一个歌词文件的URL。如果是URL,你需要像下载音频一样,再发起一次请求获取歌词文件内容。这里需要根据API实际返回进行调整。 - 文件命名:统一的命名格式(
歌手 - 歌名.扩展名)能让本地音乐库井然有序。你可以根据自己的喜好调整这个格式。
3.5 主流程整合与用户交互
最后,我们把所有功能串联起来,并提供一个简单的命令行交互界面。
def run(self): """主运行流程""" print("=== 酷狗音乐下载工具 ===") keyword = input("请输入要搜索的歌名或歌手: ").strip() if not keyword: print("输入不能为空!") return # 1. 搜索 songs = self.search_song(keyword) if not songs: print("未搜索到相关歌曲或搜索失败。") return # 2. 展示搜索结果,让用户选择 print(f"\n找到 {len(songs)} 首相关歌曲:") for idx, song in enumerate(songs[:10], 1): # 只显示前10条 # 从搜索结果中提取显示信息,字段名需根据实际API调整 song_name = song.get('SongName', 'N/A') singer_name = song.get('SingerName', 'N/A') album_name = song.get('AlbumName', 'N/A') print(f"{idx}. {singer_name} - {song_name} [{album_name}]") try: choice = input(f"\n请输入要下载的歌曲编号 (1-{min(10, len(songs))}), 或输入 'a' 下载前10首: ").strip() if choice.lower() == 'a': selected_indices = range(0, min(10, len(songs))) else: selected_idx = int(choice) - 1 if selected_idx < 0 or selected_idx >= len(songs): print("编号无效!") return selected_indices = [selected_idx] except ValueError: print("输入无效!") return # 3. 遍历选择,下载每一首 for idx in selected_indices: song = songs[idx] # 从搜索结果中提取hash和album_id,字段名需调整 song_hash = song.get('FileHash', '') album_id = song.get('AlbumID', '') if not song_hash or not album_id: logger.warning(f"跳过第 {idx+1} 首歌曲,缺少必要ID信息") continue print(f"\n正在处理: {song.get('SingerName')} - {song.get('SongName')}") # 获取详情 detail = self.get_song_detail(song_hash, album_id) if not detail: print(" 获取歌曲详情失败,跳过。") continue # 下载 success = self.download_song(detail) if success: print(f" 下载完成!") else: print(f" 下载失败。") print("\n=== 程序执行完毕 ===") if __name__ == '__main__': downloader = KugouMusicDownloader(save_dir="./my_music_library") downloader.run()4. 常见问题排查与进阶技巧
4.1 高频问题与解决方案速查表
在实际操作中,你几乎一定会遇到下面这些问题。这里整理了排查思路和解决方法。
| 问题现象 | 可能原因 | 排查步骤与解决方案 |
|---|---|---|
| 搜索返回空列表或状态码错误 | 1. 搜索API地址或参数已变更。 2. 请求头(Headers)不完整或被识别为爬虫。 3. IP请求频率过高被暂时限制。 | 1.重新分析API:打开浏览器无痕模式,清空缓存,重新搜索并抓包,确认最新的请求URL和参数(Payload)。 2.补全请求头:除了 User-Agent和Referer,检查真实请求是否还有Cookie、Accept、Accept-Language等头部,一并复制过来。有时需要携带一个有效的Cookie。3.降低频率,添加延迟:在循环请求间使用 time.sleep(random.uniform(1, 3))添加随机延迟,模拟人工操作。 |
| 能搜索到歌,但获取详情时失败(无play_url) | 1. 获取详情的API或参数错误。 2. hash和album_id的对应关系或格式有误。3. 歌曲需要VIP或特定区域才能播放。 | 1.核对详情API:在搜索到歌曲后,点击播放,在“网络”面板中找到获取音频地址的那个请求,分析其URL和参数。 2.验证ID:确保从搜索结果中提取的 hash和album_id字段名正确,且值不为空。3.识别付费资源:在详情API的响应中,可能会有 privilege、fee_type等字段标识歌曲权限。对于VIP歌曲,普通接口可能无法获取有效播放地址,这是正常限制。 |
| 下载的MP3文件无法播放或只有几KB | 1. 获取的play_url是临时的、有鉴权的或过期的链接。2. 下载请求缺少必要的认证信息(如Cookie)。 | 1.检查URL有效性:将代码中获取到的play_url直接复制到浏览器地址栏,看是否能直接下载或播放。如果不能,说明链接需要特定上下文(如Session、Referer)。2.携带Cookie下载:确保下载音频文件的请求使用了同一个 Session对象,它会自动管理Cookie。如果不行,可能需要手动从详情API的响应中提取一个token或key,并作为参数附加到音频URL上。 |
| 歌词下载下来是乱码或非LRC格式 | 1. 歌词编码问题。 2. lyrics字段返回的是JSON或HTML,而非纯文本。 | 1.指定编码:在保存歌词文件时,明确使用encoding='utf-8'。如果源是其他编码(如gbk),需要转换。2.解析歌词内容:如果 lyrics是一个URL,需要再发起一次请求获取内容。如果返回的是包含歌词的JSON,则需要解析json后提取歌词文本字段。 |
| 程序运行一段时间后报错或停止响应 | 1. 网络连接超时或不稳定。 2. 请求过于频繁,服务器返回429(请求过多)或其他错误码。 | 1.增加超时和重试:在requests.get()中设置timeout参数(如10秒)。可以封装一个带重试机制的请求函数,使用tenacity库或简单的try-except循环。2.实现请求间隔:这是最重要的反爬策略。在每次请求(尤其是搜索和获取详情)后,强制休眠一段时间。 time.sleep(random.uniform(2, 5))是比较友好的做法。 |
4.2 进阶优化与扩展思路
当基础功能跑通后,你可以考虑以下方向来完善你的下载工具:
多线程/异步并发下载:如果你需要下载整个歌单或大量歌曲,顺序下载会非常慢。可以使用
concurrent.futures.ThreadPoolExecutor实现多线程,或者使用aiohttp和asyncio实现异步IO。注意:并发度不宜过高,否则极易触发反爬机制,导致IP被封。建议控制在3-5个并发任务以内,并加上随机延迟。# 简易多线程示例(需导入 concurrent.futures) def batch_download(self, song_list): with ThreadPoolExecutor(max_workers=3) as executor: futures = [] for song in song_list: future = executor.submit(self.process_and_download_single, song) futures.append(future) # 可以在这里等待所有任务完成,或处理结果音质选择:有些API可能会返回不同音质(128kbps, 320kbps, 无损)的播放地址。你可以在解析详情时,检查是否有
bitrate或quality相关字段,让用户选择或默认下载最高可用音质。元数据写入:使用第三方库如
mutagen,可以将歌手、歌名、专辑、封面图(作为内嵌封面)等信息写入下载的MP3文件的ID3标签中,这样在任何播放器里都能正确显示。图形化界面(GUI):使用
tkinter、PyQt或DearPyGui为你的工具制作一个简单的桌面界面,让不熟悉命令行的用户也能方便使用。错误恢复与断点续传:对于大文件下载,可以检查本地已下载文件的大小,然后在请求时通过设置
headers中的Range头部来实现断点续传。requests库本身不支持断点续传,需要自己实现这部分逻辑。
最后再分享一个小技巧:在开发调试阶段,善用日志和打印输出。将关键步骤(如请求的URL、解析出的关键ID、获取到的资源链接)都打印或记录到日志文件中。当程序出错时,这些信息是定位问题最直接的依据。你可以将logging的级别设置为DEBUG来获取更详细的信息。记住,爬虫开发是一个“分析-实现-测试-调整”的循环过程,网站一变,你的代码可能就需要跟着变。保持代码的模块化和可读性,能让这个维护过程轻松很多。