import requests  # 导入requests库,用于发送HTTP请求
from bs4 import BeautifulSoup  # 导入BeautifulSoup库,用于解析HTML
import re  # 导入re库,用于正则表达式操作
import os  # 导入os库,用于操作系统相关功能


class LyricDownloader:
    """
    歌词下载器类,用于从不同网站搜索和下载歌词。
    """

    def __init__(self, search_engine="wangyiyun"):  # 初始化方法,search_engine默认为网易云
        """
        初始化 LyricDownloader 对象。

        Args:
            search_engine: 指定搜索引擎,可以是 "baidu", "wangyiyun", 或 "qqmusic"。
        """
        self.search_engine = search_engine  # 设置搜索引擎
        self.headers = {  # 设置HTTP请求头,模拟浏览器
            "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"
        }

    def search_lyrics(self, song_name, artist=None):
        """
        根据歌曲名称和歌手(可选)搜索歌词。

        Args:
            song_name: 歌曲名称。
            artist: 歌手名称(可选)。

        Returns:
            一个包含搜索结果的列表,每个结果是一个字典,包含标题、URL和来源。
            如果没有找到结果,返回None。
        """
        if self.search_engine == "baidu":  # 如果搜索引擎是百度
            return self._search_baidu(song_name, artist)  # 调用百度搜索方法
        elif self.search_engine == "wangyiyun":  # 如果搜索引擎是网易云
            return self._search_wangyiyun(song_name, artist)  # 调用网易云搜索方法
        elif self.search_engine == "qqmusic":  # 如果搜索引擎是QQ音乐
            return self._search_qqmusic(song_name, artist)  # 调用QQ音乐搜索方法
        else:
            raise ValueError(f"不支持的搜索引擎: {self.search_engine}")  # 如果搜索引擎不支持,抛出异常



    def _search_baidu(self, song_name, artist=None):
        """在百度搜索歌词 (私有方法)."""
        return None # 百度搜索很难直接获得lrc.

    def _search_wangyiyun(self, song_name, artist=None):
        """
        在网易云音乐上搜索歌词(私有方法)。

        Args:
            song_name: 歌曲名称。
            artist: 歌手名称(可选)。

        Returns:
            一个包含搜索结果的列表。
        """
        query = f"{song_name}"  # 构建搜索查询字符串
        if artist:  # 如果提供了歌手名称
            query = f"{artist} {song_name}"  # 将歌手名称添加到查询字符串

        # 使用网易云音乐API(非官方)
        try:
            search_url = "https://music.163.com/api/search/get/web?csrf_token="  # 搜索API的基础URL
            data = {  # 构建POST请求的数据
                "s": query,  # 搜索关键词
                "type": 1,  # 搜索类型(1表示歌曲)
                "offset": 0,  # 偏移量(用于分页)
                "total": "true",  # 是否返回总数
                "limit": 10,  # 限制结果数量
            }

            response = requests.post(search_url, headers=self.headers, data=data)  # 发送POST请求
            response.raise_for_status()  # 检查HTTP状态码
            search_data = response.json()  # 将响应内容解析为JSON

            results = []  # 初始化搜索结果列表
            if "result" in search_data and "songs" in search_data["result"]:  # 检查JSON结构
                for song in search_data["result"]["songs"]:  # 遍历歌曲列表
                    song_id = song["id"]  # 获取歌曲ID
                    song_title = song["name"]  # 获取歌曲名称
                    artist_name = ", ".join([artist["name"] for artist in song["artists"]])  # 获取歌手名称(可能有多个歌手)

                    results.append({
                        "title": f"{song_title} - {artist_name}",  # 标题
                        "url": f"https://music.163.com/#/song?id={song_id}",  # 歌曲URL
                        "id": song_id,  # 歌曲ID
                        "source": "wangyiyun"  # 来源
                    })
            return results  # 返回搜索结果

        except requests.exceptions.RequestException as e:  # 捕获网络请求异常
            print(f"网易云音乐搜索出错: {e}")
            return None  # 发生错误时返回None
        except (KeyError, TypeError) as e:  # 捕获JSON解析错误
            print(f"解析网易云音乐响应出错: {e}")
            return None

    def _search_qqmusic(self, song_name, artist=None):
        """
        在QQ音乐上搜索歌词(私有方法)。

        Args:
            song_name: 歌曲名称。
            artist: 歌手名称(可选)。

        Returns:
            一个包含搜索结果的列表。
        """
        query = f"{song_name}"  # 构建搜索查询字符串
        if artist:  # 如果提供了歌手名称
            query = f"{artist} {song_name}"   #将歌手添加到搜索字符串中

        try:
            # 构建搜索URL
            search_url = f"https://c.y.qq.com/soso/fcgi-bin/client_search_cp?p=1&n=10&w={query}&format=json"

            response = requests.get(search_url, headers=self.headers)  # 发送GET请求
            response.raise_for_status()  # 检查HTTP状态码
            search_data = response.json()  # 将响应内容解析为JSON

            results = []  # 初始化搜索结果列表
            if "data" in search_data and "song" in search_data["data"] and "list" in search_data["data"]["song"]: # 检查JSON结构
              for song in search_data["data"]["song"]["list"]:  # 遍历歌曲列表
                  song_title = song["songname"]  # 获取歌曲名称
                  artist_name = ", ".join([singer["name"] for singer in song["singer"]])  # 获取歌手名称
                  song_mid = song["songmid"]  # 获取歌曲的songmid
                  results.append({
                      "title": f"{song_title} - {artist_name}",  # 标题
                      "url": f"https://y.qq.com/n/ryqq/songDetail/{song_mid}",  # 歌曲URL
                      "id": song_mid,  # songmid
                      "source": "qqmusic"  # 来源
                  })
            return results  # 返回搜索结果

        except requests.exceptions.RequestException as e:  # 捕获网络请求异常
            print(f"QQ音乐搜索出错: {e}")
            return None  # 发生错误时返回None
        except (KeyError, TypeError) as e:   #捕获JSON解析错误
            print(f"解析QQ音乐响应出错: {e}")
            return None

    def get_lyrics_from_url(self, url, source):
        """
        根据URL和来源获取歌词。

        Args:
            url: 歌词页面的URL。
            source: 歌词来源("baidu", "wangyiyun", "qqmusic")。

        Returns:
            歌词文本(字符串),如果获取失败则返回None。
        """

        if source == "baidu":  # 如果来源是百度
            return self._get_lyrics_from_baidu_result(url)  # 调用百度歌词获取方法
        elif source == "wangyiyun":  # 如果来源是网易云
            return self._get_lyrics_from_wangyiyun(url)  # 调用网易云歌词获取方法
        elif source == "qqmusic":  # 如果来源是QQ音乐
            return self._get_lyrics_from_qqmusic(url)  # 调用QQ音乐歌词获取方法
        else:
            print(f"不支持的歌词来源: {source}")  # 如果来源不支持
            return None  # 返回None

    def _get_lyrics_from_baidu_result(self, url):
        """从百度搜索结果提取歌词(私有方法)"""
        return None # 百度搜索很难直接获得lrc.


    def _get_lyrics_from_wangyiyun(self, url):
        """从网易云音乐获取歌词(私有方法)。

        Args:
            url: 包含歌曲id的网易云歌曲 URL

        Returns:
            歌词文本,如果获取失败则返回None。
        """

        # 从URL中提取歌曲ID
        match = re.search(r"id=(\d+)", url)
        if not match:
            print("无效的网易云音乐URL。无法提取歌曲ID。")
            return None
        song_id = match.group(1)  # 获取歌曲ID

        try:
            lyric_url = f"https://music.163.com/api/song/lyric?id={song_id}&lv=1&kv=1&tv=-1"  # 歌词API的URL
            response = requests.get(lyric_url, headers=self.headers)  # 发送GET请求
            response.raise_for_status()  # 检查HTTP状态码
            lyric_data = response.json()  # 将响应内容解析为JSON

            if "lrc" in lyric_data and "lyric" in lyric_data["lrc"]:  # 检查JSON结构
                lyrics = lyric_data["lrc"]["lyric"]  # 获取歌词文本

                #  !!!  保留时间标签  !!!
                # lyrics = re.sub(r'\[.*?\]', '', lyrics).strip()  # 注释掉这一行
                return lyrics.strip() #直接返回, 不做处理
            else:
                print(f"在网易云上找不到歌曲ID为 {song_id} 的歌词。")
                return None  # 没有找到歌词时返回None

        except requests.exceptions.RequestException as e:  # 捕获网络请求异常
            print(f"从网易云音乐获取歌词出错: {e}")
            return None
        except (KeyError, TypeError) as e:  # 捕获JSON解析错误
            print(f"解析网易云音乐歌词响应出错: {e}")
            return None


    def _get_lyrics_from_qqmusic(self, url):
        """
        从QQ音乐获取歌词(私有方法)

        Args:
            url: 包含songmid的QQ音乐歌曲url

        Returns:
            歌词文本,如果获取失败则返回None。

        """
        # 从URL中提取songmid
        match = re.search(r"songDetail/(\w+)", url)
        if not match:
            print("无效的QQ音乐URL。无法提取songmid。")
            return None
        song_mid = match.group(1)

        try:
            # 构建歌词URL(这是一个API端点)
            lyric_url = f"https://c.y.qq.com/lyric/fcgi-bin/fcg_query_lyric_new.fcg?songmid={song_mid}&format=json&nobase64=1"  # 歌词API的URL
            response = requests.get(lyric_url, headers=self.headers)  # 发送GET请求
            response.raise_for_status() # 检查HTTP状态码
            lyric_data = response.json()  # 将响应内容解析为JSON

            if "lyric" in lyric_data:  # 检查JSON结构
                lyrics = lyric_data["lyric"]  # 获取歌词文本

                # !!! 保留时间标签, 但可能需要进一步处理 !!!
                # lyrics = re.sub(r'\[.*?\]', '', lyrics).strip()  # 注释掉这一行!
                lyrics = re.sub(r' ', ' ', lyrics)  # 替换 为空格
                lyrics = re.sub(r'&#(\d+);', lambda m: chr(int(m.group(1))), lyrics)  # 转换-等HTML实体
                return lyrics.strip()
            else:
                print(f"在QQ音乐上找不到songmid为 {song_mid} 的歌词。")
                return None  # 没有找到歌词时返回None
        except requests.exceptions.RequestException as e:  # 捕获网络请求异常
            print(f"从QQ音乐获取歌词出错: {e}")
            return None
        except (KeyError, TypeError) as e:  # 捕获JSON解析错误
            print(f"解析QQ音乐歌词响应出错: {e}")
            return None

    def save_lyrics(self, lyrics, song_name, artist=None, directory="lyrics"):
        """
        将歌词保存到 .lrc 文件。

        Args:
            lyrics: 歌词文本。
            song_name: 歌曲名称。
            artist: 歌手名称(可选)。
            directory: 保存文件的目录(默认为"lyrics")。
        """
        if not lyrics:  # 如果没有歌词
            print("没有要保存的歌词。")
            return  # 直接返回

        if '.lrc' in directory:
            file_path = directory

        else:

            if not os.path.exists(directory):  # 如果目录不存在
                os.makedirs(directory)  # 创建目录

            file_name = f"{song_name}"  # 构建文件名
            if artist:  # 如果提供了歌手名称
                file_name = f"{artist} - {song_name}"  # 将歌手名称添加到文件名
            file_name = file_name.replace("/", "_").replace("\\", "_").replace(":", "_").replace("*", "_").replace("?", "_").replace('"', "_").replace("<", "_").replace(">", "_").replace("|", "_")  # 移除文件名中的非法字符

            # !!! 将文件扩展名改为 .lrc !!!
            file_path = os.path.join(directory, f"{file_name}.lrc")

        try:
            with open(file_path, "w", encoding="utf-8") as f:  # 以UTF-8编码打开文件
                f.write(lyrics)  # 写入歌词
            print(f"歌词已保存到 {file_path}")  # 提示保存成功
        except Exception as e:  # 捕获文件写入错误
            print(f"保存歌词到文件时出错: {e}")


def main():
    """
    主函数,用于与用户交互,获取输入,调用下载器。
    """
    downloader = LyricDownloader()  # 创建LyricDownloader实例,默认使用网易云
    main_dir = 'Z:\share\jellyfin\media\music' # 遍历目录
    directory=r'F:\gi\lrc' # 保存目录
    gc_dic = {}
    # # 1.方法输入歌词和歌手
    # song_name = input("请输入歌曲名称: ")  # 提示用户输入歌曲名称
    # artist = input("请输入歌手名称(可选,按Enter键跳过): ")  # 提示用户输入歌手名称
    # gc_dic[song_name] = {"artist": artist, 'save_dir': directory}
    #  2.方法2 遍历
    
    for root, dirs, files in os.walk(main_dir):
        for file in files:
            # print(file)
            if file.endswith('.mp3') or file.endswith('.flac') or file.endswith('.ape') or file.endswith('.wav') or file.endswith('.m4a') or file.endswith('.wma')  :
                # print(os.path.join(root, file))
                # print(os.path.join(root, file))
                files = os.listdir(root)
                gc = file.split('.')[0] + '.lrc'
                # print(gc, dirs)
                if gc in files:
                    # print(gc)
                    print(file + ',✅')
                else:
                    if '-' in file:
                        song_name = file.split('-')[1].split('.')[0].strip()
                        artist = file.split('-')[0].strip()
                        print(f'{song_name} - {artist}')
                    else:
                        song_name = file.split('.')[0].strip()
                        artist = ''
                    directory = os.path.join(root, gc)
                    gc_dic[song_name] = {"artist": artist, 'save_dir': directory}
                    print("需要下载歌词,歌曲名:", song_name, "歌手:", artist)
                    
    # 3.方法3 遍历字典
    for song_name in gc_dic:
        # print('gc_dic[song_name]=', gc_dic[song_name])
        artist = gc_dic[song_name]['artist']
        directory = gc_dic[song_name]['save_dir']

        search_results = downloader.search_lyrics(song_name, artist if artist else None)  # 搜索歌词
        if not search_results:  # 如果没有找到搜索结果
            print("没有找到搜索结果。")
            continue  # 直接返回
        print("\n[%s %s]搜索结果:" % (song_name, artist))  # 显示搜索结果
        for i, result in enumerate(search_results):  # 遍历搜索结果
            print(f"{i+1}. {result['title']} ({result['source']})")  # 打印搜索结果

        while True:  # 循环获取用户输入
            try:
                choice = int(input("请输入要下载歌词的结果编号(输入0退出): "))  # 提示用户选择
                if 0 <= choice <= len(search_results): # 检查输入是否有效
                    break  # 输入有效,跳出循环
                else:
                    print("无效的选择。请输入列表中的数字。")  # 输入无效,提示用户重新输入
            except ValueError:
                print("无效的输入。请输入一个数字。")   #输入无效,提示用户重新输入

        if choice == 0:  # 如果用户选择退出
            continue  # 直接返回

        selected_result = search_results[choice - 1]   # 获取用户选择的结果
        lyrics = downloader.get_lyrics_from_url(selected_result['url'], selected_result['source'])  # 获取歌词

        if lyrics:  # 如果成功获取歌词
            downloader.save_lyrics(lyrics, song_name, artist if artist else None, directory=directory)   # 保存歌词



if __name__ == "__main__":  # 如果脚本是直接运行的(而不是作为模块导入的)
    main()  # 调用main函数

这段代码实现了一个名为 LyricDownloader 的 Python 类,用于从多个音乐网站(目前支持网易云音乐和 QQ 音乐)搜索和下载歌词。它允许用户通过歌曲名称和可选的歌手名称来搜索歌词,并能将歌词保存为 .lrc 文件。

代码结构与解析

  1. 导入模块

    import requests
    from bs4 import BeautifulSoup
    import re
    import os
    • requests: 用于发送 HTTP 请求,从网站获取数据。
    • BeautifulSoup: 用于解析 HTML 和 XML 文档,方便从网页中提取信息。
    • re: 用于正则表达式操作,处理文本和提取特定模式的内容(如歌曲 ID)。
    • os: 用于与操作系统交互,如创建目录、处理文件路径等。
  2. LyricDownloader 类

    • __init__(self, search_engine="wangyiyun"):

      • 初始化方法,设置默认搜索引擎为网易云音乐。
      • search_engine: 指定要使用的搜索引擎("baidu", "wangyiyun", "qqmusic")。
      • headers: 设置 HTTP 请求头,模拟浏览器访问,避免被网站的反爬虫机制阻止。
    • search_lyrics(self, song_name, artist=None):

      • 根据歌曲名称和歌手(可选)搜索歌词。
      • 根据 self.search_engine 的值,调用相应的私有方法(_search_baidu_search_wangyiyun_search_qqmusic)进行搜索。
      • 如果搜索引擎不支持,抛出 ValueError 异常。
    • _search_baidu(self, song_name, artist=None):

      • 在百度上搜索歌词(目前返回 None,因为百度搜索结果不易直接获取 .lrc 文件)。
    • _search_wangyiyun(self, song_name, artist=None):

      • 在网易云音乐上搜索歌词。
      • 使用网易云音乐的非官方 API 进行搜索。
      • 构建 POST 请求,发送到搜索 API。
      • 解析 JSON 响应,提取歌曲信息(标题、URL、ID、来源)。
      • 返回搜索结果列表。
      • 处理可能出现的请求异常和 JSON 解析错误。
    • _search_qqmusic(self, song_name, artist=None):

      • 在 QQ 音乐上搜索歌词。
      • 构建搜索 URL,发送 GET 请求。
      • 解析 JSON 响应,提取歌曲信息。
      • 返回搜索结果列表。
      • 处理可能出现的请求异常和 JSON 解析错误。
    • get_lyrics_from_url(self, url, source):

      • 根据 URL 和来源获取歌词。
      • 根据 source 的值,调用相应的私有方法(_get_lyrics_from_baidu_result_get_lyrics_from_wangyiyun_get_lyrics_from_qqmusic)获取歌词。
      • 如果来源不支持,打印错误信息并返回 None
    • _get_lyrics_from_baidu_result(self, url):

      • 从百度搜索结果中提取歌词(目前返回 None)。
    • _get_lyrics_from_wangyiyun(self, url):

      • 从网易云音乐获取歌词。
      • 从 URL 中提取歌曲 ID。
      • 使用网易云音乐的歌词 API 获取歌词。
      • 解析 JSON 响应,提取歌词文本。
      • 关键修改: 保留歌词中的时间标签(没有使用 re.sub 删除)。
      • 返回歌词文本。
      • 处理可能出现的请求异常和 JSON 解析错误。
    • _get_lyrics_from_qqmusic(self, url):

      • 从 QQ 音乐获取歌词。
      • 从 URL 中提取 songmid。
      • 使用 QQ 音乐的歌词 API 获取歌词。
      • 解析 JSON 响应,提取歌词文本。
      • 关键修改: 保留歌词中的时间标签,并进行了一些额外的文本处理(替换特殊字符、转换 HTML 实体)。
      • 返回歌词文本。
      • 处理可能出现的请求异常和 JSON 解析错误。
    • save_lyrics(self, lyrics, song_name, artist=None, directory="lyrics"):

      • 将歌词保存到 .lrc 文件。
      • 如果 directory 参数是 .lrc 结尾,则直接将 directory 作为保存路径。
      • 如果 directory 参数不是 .lrc 结尾,如果目录不存在,则创建目录。
      • 构建文件名(包含歌曲名称和歌手名称,并处理文件名中的非法字符)。
      • 关键修改: 将文件扩展名设置为 .lrc
      • 以 UTF-8 编码将歌词写入文件。
      • 处理可能出现的文件写入错误。
  3. main() 函数

    • 创建 LyricDownloader 实例。

    • 设置主目录 main_dir(用于遍历音乐文件)和保存目录 directory

    • 初始化一个空字典gc_dic

    • 提供三种方式来获取歌曲和歌手信息:

      • (已注释)方法1: 通过 input() 函数提示用户输入歌曲名称和歌手名称。
      • 方法2: 遍历 main_dir 目录下的音乐文件(支持多种格式:.mp3, .flac, .ape, .wav, .m4a, .wma)。
        • 检查与音乐文件同名的 .lrc 文件是否已存在。如果存在,则输出 ✅;否则,根据文件名推断出歌曲名和歌手,并把这些数据加入到gc_dic字典
      • 方法3:遍历gc_dic 字典.
    • 循环遍历 gc_dic 字典的每一首歌曲:

      • 调用 search_lyrics 方法搜索歌词。
      • 如果找不到歌词,打印提示信息并继续下一首歌曲。
      • 显示搜索结果,让用户选择要下载的歌词。
      • 获取用户选择的歌词。
      • 如果成功获取歌词,调用 save_lyrics 方法保存歌词。
  4. if __name__ == "__main__":

    • 这个条件判断确保 main() 函数只在脚本直接运行时执行,而在作为模块导入时不执行。

关键改进与解释

  1. 保留时间标签: 在 _get_lyrics_from_wangyiyun 和 _get_lyrics_from_qqmusic 方法中,注释掉了原本用于删除时间标签的代码 (re.sub(r'\[.*?\]', '', lyrics).strip())。这使得下载的歌词保留了原始的时间信息,符合 .lrc 文件的标准格式。

  2. QQ 音乐歌词处理: 在 _get_lyrics_from_qqmusic 方法中,添加了以下处理:

    • lyrics = re.sub(r' ', ' ', lyrics): 将特殊的 " " 字符替换为空格。
    • lyrics = re.sub(r'&#(\d+);', lambda m: chr(int(m.group(1))), lyrics): 将 HTML 实体(如 &#123;)转换为对应的 Unicode 字符。
  3. 文件名和路径处理:

    • 在save_lyrics 函数中:如果 directory 参数是 .lrc 结尾,则直接将 directory 作为保存路径。
    • 如果 directory 参数不是 .lrc 结尾,创建目录,对文件名进行过滤,保证文件名的合法性。
  4. 遍历本地文件: main() 函数中现在包含了一个遍历指定目录(main_dir)下音乐文件的功能。它会检查每个音乐文件对应的 .lrc 文件是否已存在,并根据文件名自动提取歌曲和歌手信息,进行下载。

  5. 字典管理:使用字典 gc_dic 来存储待下载歌词的信息,避免重复下载。

文章结构建议

  1. 引言:

    • 介绍歌词下载的常见需求。
    • 简要介绍 .lrc 歌词文件格式及其重要性。
    • 引出本文要介绍的 Python 歌词下载器项目。
  2. 项目概述:

    • 介绍 LyricDownloader 类的功能和特点。
    • 列出支持的音乐网站。
    • 强调项目的核心优势(如保留时间标签、自动处理文件名、遍历本地文件等)。
  3. 代码详解:

    • 按照代码结构,逐步解析每个模块、类和方法的功能。
    • 重点解释关键代码段(如 API 请求、JSON 解析、正则表达式、文件操作等)。
    • 突出关键改进(如保留时间标签、QQ 音乐歌词处理等)。
  4. 使用方法:

    • 详细说明如何使用 LyricDownloader 类。
    • 提供示例代码,展示如何搜索、选择和保存歌词。
    • 解释 main() 函数中的交互逻辑和遍历文件功能。
  5. 扩展与改进:

    • 讨论项目的潜在改进方向,如:
      • 支持更多音乐网站。
      • 添加 GUI 界面。
      • 实现更智能的搜索(如模糊匹配)。
      • 自动匹配歌曲文件和歌词文件。
      • 添加代理支持,应对更复杂的网络环境。
    • 鼓励读者参与项目的开发和完善。
  6. 总结:

    • 回顾项目的主要功能和特点。
    • 强调项目的实用性和学习价值。
    • 提供项目源代码的获取方式(如 GitHub 链接)。

注意:

  • 代码中使用了网易云音乐和 QQ 音乐的非官方 API。这些 API 可能会随时变化,导致代码失效。
  • 频繁的 API 请求可能会触发网站的反爬虫机制。建议在使用时添加适当的延时,或使用代理。
  • 代码的错误处理还可以进一步完善,例如更详细地记录错误日志。