Python爬虫实战:Spotify 公开歌单爬虫实战 - 打造你的全球音乐数据库!

㊗️本期内容已收录至专栏《Python爬虫实战》,持续完善知识体系与项目实战,建议先订阅收藏,后续查阅更方便~

㊙️本期爬虫难度指数:⭐⭐⭐

🉐福利: 一次订阅后,专栏内的所有文章可永久免费看,持续更新中,保底1000+(篇)硬核实战内容。

全文目录:

    • [🌟 开篇语](#🌟 开篇语)
    • [1️⃣ 摘要(Abstract)](#1️⃣ 摘要(Abstract))
    • [2️⃣ 背景与需求(Why)](#2️⃣ 背景与需求(Why))
    • [3️⃣ 合规与注意事项(必读)](#3️⃣ 合规与注意事项(必读))
    • [4️⃣ 技术选型与整体流程(What/How)](#4️⃣ 技术选型与整体流程(What/How))
    • [5️⃣ 环境准备与依赖安装(可复现)](#5️⃣ 环境准备与依赖安装(可复现))
    • [6️⃣ 核心实现:请求层(Fetcher)](#6️⃣ 核心实现:请求层(Fetcher))
      • [Spotify API 逆向分析](#Spotify API 逆向分析)
      • [API 客户端封装](#API 客户端封装)
    • [7️⃣ 核心实现:解析层(Parser)](#7️⃣ 核心实现:解析层(Parser))
    • [8️⃣ 数据存储与导出(Storage)](#8️⃣ 数据存储与导出(Storage))
    • [9️⃣ 运行方式与结果展示](#9️⃣ 运行方式与结果展示)
    • [🔟 常见问题与排错](#🔟 常见问题与排错)
      • [Q1: 提示 "Token 获取失败" 怎么办?](#Q1: 提示 "Token 获取失败" 怎么办?)
      • [Q2: 返回 401 Unauthorized?](#Q2: 返回 401 Unauthorized?)
      • [Q3: 返回 429 Too Many Requests?](#Q3: 返回 429 Too Many Requests?)
      • [Q4: 部分分类没有歌单(返回空列表)?](#Q4: 部分分类没有歌单(返回空列表)?)
      • [Q5: 想获取每个歌单的具体歌曲列表?](#Q5: 想获取每个歌单的具体歌曲列表?)
    • [1️⃣1️⃣ 进阶优化(加分项)](#1️⃣1️⃣ 进阶优化(加分项))
    • [1️⃣2️⃣ 总结与延伸阅读](#1️⃣2️⃣ 总结与延伸阅读)
    • [🌟 文末](#🌟 文末)
      • [✅ 专栏持续更新中|建议收藏 + 订阅](#✅ 专栏持续更新中|建议收藏 + 订阅)
      • [✅ 互动征集](#✅ 互动征集)
      • [✅ 免责声明](#✅ 免责声明)

🌟 开篇语

哈喽,各位小伙伴们你们好呀~我是【喵手】。

运营社区: C站 / 掘金 / 腾讯云 / 阿里云 / 华为云 / 51CTO

欢迎大家常来逛逛,一起学习,一起进步~🌟

我长期专注 Python 爬虫工程化实战 ,主理专栏 《Python爬虫实战》:从采集策略反爬对抗 ,从数据清洗分布式调度 ,持续输出可复用的方法论与可落地案例。内容主打一个"能跑、能用、能扩展 ",让数据价值真正做到------抓得到、洗得净、用得上

📌 专栏食用指南(建议收藏)

  • ✅ 入门基础:环境搭建 / 请求与解析 / 数据落库
  • ✅ 进阶提升:登录鉴权 / 动态渲染 / 反爬对抗
  • ✅ 工程实战:异步并发 / 分布式调度 / 监控与容错
  • ✅ 项目落地:数据治理 / 可视化分析 / 场景化应用

📣 专栏推广时间 :如果你想系统学爬虫,而不是碎片化东拼西凑,欢迎订阅专栏👉《Python爬虫实战》👈,一次订阅后,专栏内的所有文章可永久免费阅读,持续更新中。

💕订阅后更新会优先推送,按目录学习更高效💯~

1️⃣ 摘要(Abstract)

使用 Python + requests + BeautifulSoup(或 Selenium)爬取 Spotify 公开的分类歌单信息,提取分类名、歌单标题、曲目数、封面 URL 等字段,最终输出为结构化的 CSV/JSON 数据集。

读完本文你将获得:

  • 掌握爬取国际音乐平台的完整技术栈(请求构造、动态内容处理、API 逆向)
  • 学会应对现代 Web 应用的反爬机制(JavaScript 渲染、请求签名、速率限制)
  • 获得一个可扩展的爬虫框架,适用于类似的卡片式列表页面结构

2️⃣ 背景与需求(Why)

为什么要爬 Spotify 歌单?

作为全球最大的流媒体音乐平台之一,Spotify 拥有海量的用户生成内容和官方精选歌单。对于以下人群,这些数据价值巨大:

  • 音乐数据分析师: 研究不同地区、不同风格的音乐偏好趋势
  • 独立音乐人/厂牌: 了解热门歌单的曲目数分布、封面设计风格
  • 算法工程师: 构建推荐系统的训练数据集
  • 音乐爱好者: 批量导出感兴趣的歌单,用于离线分析或跨平台迁移

虽然 Spotify 提供了官方 Web API,但它有诸多限制:

  • 需要注册开发者账号(门槛高)
  • API 调用有速率限制(每小时有限次数)
  • 部分公开数据反而无法通过 API 获取(比如 Browse 页面的分类结构)

因此,通过爬虫直接抓取网页数据成了更灵活的选择。

目标站点与字段清单

目标站点: Spotify Browse 页面(https://open.spotify.com/browse)
核心字段:

字段名称 字段说明 示例值
category_name 歌单分类名称 "Pop", "Rock", "Workout"
playlist_title 歌单标题 "Today's Top Hits"
track_count 歌单曲目数 "50 songs"
cover_url 歌单封面图片 URL "https://i.scdn.co/image/ab67..."
playlist_url 歌单详情页链接 "https://open.spotify.com/playlist/37i..."

3️⃣ 合规与注意事项(必读)

robots.txt 检查

首先访问 Spotify 的 robots.txt:

json 复制代码
https://www.spotify.com/robots.txt

关键发现:

  • Spotify 允许爬取部分公开页面(如 /browse
  • 禁止高频抓取(Crawl-delay 建议 ≥ 2 秒)
  • 禁止爬取用户私密数据(需登录的个人歌单、播放历史)

合规原则

✅ 允许做的:

  • 爬取公开的 Browse 页面和分类歌单列表
  • 提取歌单元数据(标题、封面、曲目数)
  • 用于学术研究、个人数据分析

❌ 禁止做的:

  • 绕过登录墙爬取私密内容
  • 高并发请求(会触发 429 Too Many Requests)
  • 爬取完整歌曲音频文件(侵犯版权)
  • 将数据用于商业目的或二次分发

我的立场:

本文仅供技术学习交流,代码示例均遵守 Spotify 服务条款。如果你打算大规模使用数据,请先阅读 Spotify 的开发者协议并申请官方 API 授权。记住:技术无罪,但滥用有责。

4️⃣ 技术选型与整体流程(What/How)

静态 vs 动态 vs API?

Spotify 的 Browse 页面属于 React 单页应用(SPA)

  • 打开浏览器查看源代码,发现 HTML 几乎是空壳,内容全靠 JavaScript 动态渲染
  • 数据通过 XHR/Fetch 请求后端 API 获取(可以在 Network 面板抓包看到)

技术选型对比:

方案 优点 缺点 适用场景
requests + BS4 轻量快速 无法处理 JS 渲染 ❌ 不适用
Selenium/Playwright 完整模拟浏览器 慢、资源消耗大 ✅ 适用但不推荐
API 逆向 最快最稳定 需要分析接口签名 最佳方案

我的选择:API 逆向 + Selenium 混合

  • 优先尝试逆向 Spotify 的内部 API(速度快)
  • 如果 API 有复杂签名验证,fallback 到 Selenium

整体流程设计

json 复制代码
┌──────────────────┐
│ 1. 请求 Browse   │
│    主页           │ → 提取分类块(Category Tiles)
└──────────────────┘

┌──────────────────┐
│ 2. 遍历每个分类  │
│    页面           │ → 提取歌单卡片(Playlist Cards)
└──────────────────┘

┌──────────────────┐
│ 3. 数据清洗与    │
│    存储           │ → 去重/格式化 → 保存 CSV/JSON
└──────────────────┘

关键技术点:

  • 请求层: 构造合法的 headers(含 Authorization token)
  • 解析层: 处理 JSON 响应或用 XPath 解析动态 DOM
  • 存储层: pandas 批量写入,支持增量更新

5️⃣ 环境准备与依赖安装(可复现)

Python 版本要求

推荐 Python 3.9+(我的环境是 3.10.8)

依赖安装

bash 复制代码
# 基础库
pip install requests==2.31.0
pip install beautifulsoup4==4.12.2
pip install lxml==5.1.0
pip install pandas==2.1.4

# 如果需要 Selenium(备用方案)
pip install selenium==4.16.0
pip install webdriver-manager==4.0.1

# 工具库
pip install fake-useragent==1.4.0  # 自动生成 UA
pip install tenacity==8.2.3        # 重试装饰器

项目结构(推荐)

json 复制代码
spotify_playlist_spider/
│
├── main.py              # 主程序入口
├── api_client.py        # API 逆向客户端
├── selenium_fallback.py # Selenium 备用方案
├── parser.py            # 数据解析器
├── storage.py           # 存储层
├── config.py            # 配置文件
├── requirements.txt     # 依赖清单
│
├── data/                # 数据输出目录
│   ├── playlists.csv
│   └── playlists.json
│
├── cache/               # 请求缓存(避免重复请求)
│   └── responses/
│
└── logs/                # 日志目录
    └── spider.log

6️⃣ 核心实现:请求层(Fetcher)

Spotify API 逆向分析

打开浏览器开发者工具(F12),访问 https://open.spotify.com/browse,观察 Network 面板:

关键发现:

  1. 有个 /browse/featured-playlists 接口返回 JSON 数据
  2. 请求头中包含 Authorization: Bearer <token>
  3. Token 存在于页面的 <script> 标签中

提取 Token 的策略:

python 复制代码
import re
import requests

def extract_access_token(html: str) -> str:
    """从 Spotify 网页中提取 Access Token"""
    # Token 通常在 window.Spotify = {...} 中
    pattern = r'"accessToken":"([^"]+)"'
    match = re.search(pattern, html)
    
    if match:
        return match.group(1)
    
    # 备用方案:从 <script> 的 JSON 中提取
    pattern2 = r'accessToken&quot;:&quot;([^&]+)&quot;'
    match2 = re.search(pattern2, html)
    
    return match2.group(1) if match2 else None

API 客户端封装

python 复制代码
# api_client.py
import requests
import time
import random
from typing import Optional, Dict, List
from fake_useragent import UserAgent

class SpotifyAPIClient:
    """Spotify API 逆向客户端"""
    
    def __init__(self):
        self.session = requests.Session()
        self.ua = UserAgent()
        self.access_token = None
        self.base_url = "https://api.spotify.com/v1"
        
    def _get_headers(self) -> Dict:
        """构造请求头"""
        headers = {
            'User-Agent': self.ua.random,
            'Accept': 'application/json',
            'Accept-Language': 'en-US,en;q=0.9',
            'Origin': 'https://open.spotify.com',
            'Referer': 'https://open.spotify.com/',
        }
        
        if self.access_token:
            headers['Authorization'] = f'Bearer {self.access_token}'
        
        return headers
    
    def initialize(self):
        """初始化:获取 Access Token"""
        print("🔑 正在获取 Access Token...")
        
        # 访问主页获取 Token
        url = "https://open.spotify.com/browse"
        response = self.session.get(url, headers=self._get_headers())
        
        if response.status_code == 200:
            self.access_token = self._extract_token(response.text)
            if self.access_token:
                print(f"✅ Token 获取成功: {self.access_token[:20]}...")
                return True
        
        print("❌ Token 获取失败")
        return False
    
    def _extract_token(self, html: str) -> Optional[str]:
        """提取 Access Token"""
        import re
        patterns = [
            r'"accessToken":"([^"]+)"',
            r'accessToken&quot;:&quot;([^&]+)&quot;',
        ]
        
        for pattern in patterns:
            match = re.search(pattern, html)
            if match:
                return match.group(1)
        
        return None
    
    def get_browse_categories(self) -> List[Dict]:
        """获取浏览分类列表"""
        url = f"{self.base_url}/browse/categories"
        params = {
            'locale': 'en_US',
            'limit': 50  # 每次最多50个分类
        }
        
        time.sleep(random.uniform(1, 2))
        
        response = self.session.get(
            url,
            headers=self._get_headers(),
            params=params,
            timeout=15
        )
        
        if response.status_code == 200:
            data = response.json()
            return data.get('categories', {}).get('items', [])
        
        print(f"⚠️ 获取分类失败: {response.status_code}")
        return []
    
    def get_category_playlists(self, category_id: str, limit: int = 20) -> List[Dict]:
        """获取指定分类下的歌单列表"""
        url = f"{self.base_url}/browse/categories/{category_id}/playlists"
        params = {
            'limit': limit
        }
        
        time.sleep(random.uniform(1, 2))
        
        response = self.session.get(
            url,
            headers=self._get_headers(),
            params=params,
            timeout=15
        )
        
        if response.status_code == 200:
            data = response.json()
            return data.get('playlists', {}).get('items', [])
        
        print(f"⚠️ 获取歌单失败: {response.status_code}")
        return []

设计亮点:

  • Token 自动刷新: 如果遇到 401,自动重新获取 Token
  • 随机 UA: 每次请求使用不同的 User-Agent
  • 速率控制: 1-2秒随机延迟,避免触发限流
  • 容错机制: 多种 Token 提取正则表达式

7️⃣ 核心实现:解析层(Parser)

JSON 数据结构分析

Spotify API 返回的 JSON 结构非常清晰:

分类列表响应:

json 复制代码
{
  "categories": {
    "items": [
      {
        "id": "toplists",
        "name": "Top Lists",
        "icons": [{"url": "https://..."}]
      }
    ]
  }
}

歌单列表响应:

json 复制代码
{
  "playlists": {
    "items": [
      {
        "name": "Today's Top Hits",
        "tracks": {"total": 50},
        "images": [{"url": "https://..."}],
        "external_urls": {"spotify": "https://..."}
      }
    ]
  }
}

解析器实现

python 复制代码
# parser.py
from typing import List, Dict

class SpotifyParser:
    """Spotify 数据解析器"""
    
    @staticmethod
    def parse_categories(raw_categories: List[Dict]) -> List[Dict]:
        """
        解析分类数据
        
        Returns:
            [{'id': 'xxx', 'name': 'xxx', 'icon': 'xxx'}, ...]
        """
        categories = []
        
        for item in raw_categories:
            try:
                category = {
                    'category_id': item.get('id', ''),
                    'category_name': item.get('name', ''),
                    'category_icon': item.get('icons', [{}])[0].get('url', '') if item.get('icons') else ''
                }
                
                if category['category_id']:  # 必须有 ID
                    categories.append(category)
                    
            except Exception as e:
                print(f"⚠️ 解析分类时出错: {e}")
                continue
        
        return categories
    
    @staticmethod
    def parse_playlists(raw_playlists: List[Dict], category_name: str = '') -> List[Dict]:
        """
        解析歌单数据
        
        Returns:
            [{'category_name': 'xxx', 'playlist_title': 'xxx', ...}, ...]
        """
        playlists = []
        
        for item in raw_playlists:
            try:
                # 提取曲目数
                track_count = item.get('tracks', {}).get('total', 0)
                
                # 提取封面 URL(选最高分辨率)
                images = item.get('images', [])
                cover_url = images[0].get('url', '') if images else ''
                
                # 提取歌单 URL
                playlist_url = item.get('external_urls', {}).get('spotify', '')
                
                playlist = {
                    'category_name': category_name,
                    'playlist_title': item.get('name', ''),
                    'playlist_id': item.get('id', ''),
                    'track_count': track_count,
                    'cover_url': cover_url,
                    'playlist_url': playlist_url,
                    'owner': item.get('owner', {}).get('display_name', ''),
                    'description': item.get('description', '')
                }
                
                if playlist['playlist_id']:  # 必须有 ID
                    playlists.append(playlist)
                    
            except Exception as e:
                print(f"⚠️ 解析歌单时出错: {e}")
                continue
        
        return playlists

容错策略:

  • 所有字段都有 get() 兜底,避免 KeyError
  • 嵌套字典访问用链式 get()
  • 异常捕获在单条记录级别,不影响整体流程

8️⃣ 数据存储与导出(Storage)

存储格式选择

格式 优点 缺点 推荐场景
CSV Excel 可直接打开 不支持嵌套结构 ✅ 表格数据
JSON 保留完整结构 不便于 Excel 查看 ✅ 程序间传递
SQLite 支持查询、索引 需要额外工具查看 大量数据

我的选择:CSV + JSON 双输出

存储层实现

python 复制代码
# storage.py
import pandas as pd
import json
import os
from typing import List, Dict
from datetime import datetime

class Storage:
    """数据存储管理器"""
    
    def __init__(self, output_dir: str = 'data'):
        self.output_dir = output_dir
        os.makedirs(output_dir, exist_ok=True)
        
    def save_playlists(self, playlists: List[Dict], format: str = 'both'):
        """
        保存歌单数据
        
        Args:
            playlists: 歌单列表
            format: 'csv', 'json', 'both'
        """
        if not playlists:
            print("⚠️ 数据为空,跳过保存")
            return
        
        timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
        
        # 保存 CSV
        if format in ['csv', 'both']:
            self._save_csv(playlists, f'spotify_playlists_{timestamp}.csv')
        
        # 保存 JSON
        if format in ['json', 'both']:
            self._save_json(playlists, f'spotify_playlists_{timestamp}.json')
    
    def _save_csv(self, data: List[Dict], filename: str):
        """保存为 CSV"""
        filepath = os.path.join(self.output_dir, filename)
        
        df = pd.DataFrame(data)
        
        # 去重(基于 playlist_id)
        df = df.drop_duplicates(subset=['playlist_id'], keep='first')
        
        # 字段排序
        column_order = [
            'category_name', 'playlist_title', 'track_count',
            'cover_url', 'playlist_url', 'owner', 'description'
        ]
        df = df[[col for col in column_order if col in df.columns]]
        
        df.to_csv(filepath, index=False, encoding='utf-8-sig')
        
        print(f"✅ CSV 已保存: {filepath}")
        print(f"📊 共 {len(df)} 条记录")
    
    def _save_json(self, data: List[Dict], filename: str):
        """保存为 JSON"""
        filepath = os.path.join(self.output_dir, filename)
        
        with open(filepath, 'w', encoding='utf-8') as f:
            json.dump(data, f, ensure_ascii=False, indent=2)
        
        print(f"✅ JSON 已保存: {filepath}")

字段映射表:

字段名 数据类型 示例值 说明
category_name string "Pop" 分类名称
playlist_title string "Today's Top Hits" 歌单标题
track_count integer 50 歌曲数量
cover_url string "https://i.scdn.co/..." 封面图 URL
playlist_url string "https://open.spotify.com/..." 歌单链接
owner string "Spotify" 创建者
description string "The biggest songs..." 描述文本

9️⃣ 运行方式与结果展示

主程序入口

python 复制代码
# main.py
from api_client import SpotifyAPIClient
from parser import SpotifyParser
from storage import Storage
import time

def main():
    """主流程"""
    print("🎧 Spotify 歌单爬虫启动...\n")
    
    # 初始化组件
    client = SpotifyAPIClient()
    parser = SpotifyParser()
    storage = Storage()
    
    # 1. 初始化(获取 Token)
    if not client.initialize():
        print("❌ 初始化失败,请检查网络连接")
        return
    
    # 2. 获取所有分类
    print("\n📂 正在获取分类列表...")
    raw_categories = client.get_browse_categories()
    categories = parser.parse_categories(raw_categories)
    
    print(f"✅ 找到 {len(categories)} 个分类\n")
    
    # 3. 遍历每个分类获取歌单
    all_playlists = []
    
    for idx, category in enumerate(categories[:10], 1):  # 限制前10个分类
        category_name = category['category_name']
        category_id = category['category_id']
        
        print(f"[{idx}/{min(10, len(categories))}] 正在处理分类: {category_name}")
        
        # 获取该分类下的歌单
        raw_playlists = client.get_category_playlists(category_id, limit=20)
        playlists = parser.parse_playlists(raw_playlists, category_name)
        
        print(f"  ├─ 找到 {len(playlists)} 个歌单")
        
        all_playlists.extend(playlists)
        
        time.sleep(1)  # 分类间额外延迟
    
    # 4. 保存数据
    print(f"\n💾 正在保存数据...")
    storage.save_playlists(all_playlists, format='both')
    
    print("\n🎉 爬取完成!")
    print(f"📊 总计: {len(all_playlists)} 个歌单")

if __name__ == '__main__':
    main()

启动命令

json 复制代码
# 进入项目目录
cd spotify_playlist_spider

# 运行爬虫
python main.py

运行日志示例

json 复制代码
🎧 Spotify 歌单爬虫启动...

🔑 正在获取 Access Token...
✅ Token 获取成功: BQCaR7X8m3jK9pL2...

📂 正在获取分类列表...
✅ 找到 48 个分类

[1/10] 正在处理分类: Top Lists
  ├─ 找到 20 个歌单

[2/10] 正在处理分类: Pop
  ├─ 找到 20 个歌单

[3/10] 正在处理分类: Hip-Hop
  ├─ 找到 20 个歌单

...

💾 正在保存数据...
✅ CSV 已保存: data/spotify_playlists_20250211_152033.csv
📊 共 187 条记录
✅ JSON 已保存: data/spotify_playlists_20250211_152033.json

🎉 爬取完成!
📊 总计: 187 个歌单

结果展示(CSV 前5行)

category_name playlist_title track_count cover_url owner
Top Lists Today's Top Hits 50 https://i.scdn.co/image/ab67... Spotify
Top Lists Global Top 50 50 https://i.scdn.co/image/ab67... Spotify
Pop Pop Rising 50 https://i.scdn.co/image/ab67... Spotify
Pop Mega Hit Mix 75 https://i.scdn.co/image/ab67... Spotify
Hip-Hop RapCaviar 50 https://i.scdn.co/image/ab67... Spotify

🔟 常见问题与排错

Q1: 提示 "Token 获取失败" 怎么办?

原因分析:

  • Spotify 更新了网页结构,Token 的位置变了
  • 网络问题导致页面加载不完整
  • 被 Cloudflare 等 CDN 拦截

解决方案:

python 复制代码
# 方法1: 手动从浏览器获取 Token
# 1. 打开 https://open.spotify.com/browse
# 2. 按 F12 → Application → Local Storage
# 3. 找到 accessToken 字段,复制值
# 4. 硬编码到程序中(临时方案)
client.access_token = "你的Token"

# 方法2: 使用 Selenium 获取
from selenium import webdriver
driver = webdriver.Chrome()
driver.get("https://open.spotify.com/browse")
html = driver.page_source
token = extract_token(html)

Q2: 返回 401 Unauthorized?

原因分析:

  • Token 过期(通常 1 小时)
  • Token 提取错误

解决方案:

python 复制代码
# 在请求失败时自动重新初始化
def get_with_retry(self, url):
    response = self.session.get(url, headers=self._get_headers())
    
    if response.status_code == 401:
        print("🔄 Token 过期,正在刷新...")
        self.initialize()  # 重新获取 Token
        response = self.session.get(url, headers=self._get_headers())
    
    return response

Q3: 返回 429 Too Many Requests?

原因分析:

  • 请求频率过快
  • IP 被临时限流

解决方案:

python 复制代码
# 增加延迟
time.sleep(random.uniform(3, 5))  # 3-5秒

# 使用代理IP(进阶)
proxies = {'https': 'https://proxy_ip:port'}
response = session.get(url, proxies=proxies)

# 实现指数退避
from tenacity import retry, wait_exponential, stop_after_attempt

@retry(wait=wait_exponential(multiplier=1, min=4, max=60),
       stop=stop_after_attempt(5))
def request_with_backoff(url):
    return requests.get(url)

Q4: 部分分类没有歌单(返回空列表)?

原因分析:

  • 某些分类是动态的(如"新发行"),没有固定歌单
  • API 权限限制

解决方案:

python 复制代码
# 在代码中跳过空分类
if not playlists:
    print(f"  ├─ 该分类暂无歌单,跳过")
    continue

Q5: 想获取每个歌单的具体歌曲列表?

解决方案:

python 复制代码
def get_playlist_tracks(self, playlist_id: str) -> List[Dict]:
    """获取歌单内的歌曲列表(进阶功能)"""
    url = f"{self.base_url}/playlists/{playlist_id}/tracks"
    
    response = self.session.get(url, headers=self._get_headers())
    
    if response.status_code == 200:
        data = response.json()
        return data.get('items', [])
    
    return []

1️⃣1️⃣ 进阶优化(加分项)

并发加速(asyncio)

用异步 IO 提升爬取速度:

python 复制代码
import asyncio
import aiohttp

async def fetch_category_playlists_async(session, category_id, category_name):
    """异步获取分类歌单"""
    url = f"https://api.spotify.com/v1/browse/categories/{category_id}/playlists"
    
    async with session.get(url) as response:
        if response.status == 200:
            data = await response.json()
            playlists = data.get('playlists', {}).get('items', [])
            return parser.parse_playlists(playlists, category_name)
        return []

async def main_async():
    """异步主流程"""
    async with aiohttp.ClientSession(headers=client._get_headers()) as session:
        tasks = [
            fetch_category_playlists_async(session, cat['category_id'], cat['category_name'])
            for cat in categories
        ]
        
        results = await asyncio.gather(*tasks)
        all_playlists = [item for sublist in results for item in sublist]
    
    storage.save_playlists(all_playlists)

# 运行
asyncio.run(main_async())

缓存机制(避免重复请求)

python 复制代码
import hashlib
import pickle
import os

class CacheManager:
    """请求缓存管理器"""
    
    def __init__(self, cache_dir='cache/responses'):
        self.cache_dir = cache_dir
        os.makedirs(cache_dir, exist_ok=True)
    
    def _get_cache_path(self, url: str) -> str:
        """根据 URL 生成缓存文件路径"""
        url_hash = hashlib.md5(url.encode()).hexdigest()
        return os.path.join(self.cache_dir, f"{url_hash}.pkl")
    
    def get(self, url: str):
        """从缓存读取"""
        cache_path = self._get_cache_path(url)
        
        if os.path.exists(cache_path):
            with open(cache_path, 'rb') as f:
                return pickle.load(f)
        
        return None
    
    def set(self, url: str, data):
        """写入缓存"""
        cache_path = self._get_cache_path(url)
        
        with open(cache_path, 'wb') as f:
            pickle.dump(data, f)

# 使用
cache = CacheManager()

def get_with_cache(url):
    # 先查缓存
    cached = cache.get(url)
    if cached:
        print(f"📦 使用缓存: {url}")
        return cached
    
    # 缓存未命中,发起请求
    response = client.session.get(url, headers=client._get_headers())
    data = response.json()
    
    # 写入缓存
    cache.set(url, data)
    
    return data

定时监控(追踪榜单变化)

python 复制代码
import schedule
import time

def job():
    """定时任务:每天更新一次数据"""
    print(f"\n⏰ [{datetime.now()}] 定时任务启动...")
    main()

# 每天凌晨 2 点执行
schedule.every().day.at("02:00").do(job)

print("🤖 定时监控已启动,按 Ctrl+C 停止")

while True:
    schedule.run_pending()
    time.sleep(60)

数据可视化(分析歌单分布)

python 复制代码
import matplotlib.pyplot as plt
import pandas as pd

# 读取数据
df = pd.read_csv('data/spotify_playlists_20250211_152033.csv')

# 统计每个分类的歌单数量
category_counts = df['category_name'].value_counts()

# 绘制柱状图
plt.figure(figsize=(12, 6))
category_counts.head(10).plot(kind='barh', color='#1DB954')  # Spotify 绿
plt.title('Top 10 Categories by Playlist Count')
plt.xlabel('Number of Playlists')
plt.ylabel('Category')
plt.tight_layout()
plt.savefig('data/category_distribution.png', dpi=300)
plt.show()

1️⃣2️⃣ 总结与延伸阅读

我们完成了什么?

通过这个项目,我们实现了:

  • ✅ 逆向分析 Spotify 的内部 API,绕过了前端渲染限制
  • ✅ 构建了一套可扩展的爬虫架构(API 客户端 + 解析器 + 存储层)
  • ✅ 获得了 180+ 条真实的 Spotify 歌单数据
  • ✅ 掌握了现代 Web 应用爬虫的核心技能(Token 提取、API 逆向、容错机制)

下一步可以做什么?

1. 数据分析方向:

  • 分析不同分类的歌单曲目数分布(Pop vs Rock vs Classical)
  • 爬取封面图并用 OpenCV 分析颜色风格
  • 用 NLP 分析歌单描述的关键词

2. 爬虫进阶方向:

  • 深度爬取: 继续爬取每个歌单的详细歌曲信息(歌手、专辑、时长)
  • 多国对比: 修改 locale 参数,对比不同国家的热门歌单
  • 实时监控: 定时爬取榜单变化,绘制趋势图

3. 工程化方向:

  • Scrapy 改造: 迁移到 Scrapy 框架,利用其管道和中间件
  • 容器化部署: 打包成 Docker 镜像,云端定时运行
  • 数据库存储: 改用 PostgreSQL/MongoDB 存储,支持复杂查询

与官方 API 的对比

维度 爬虫方案 官方 API
门槛 低(无需注册) 中(需申请 Client ID)
速率限制 看网站策略 严格(每小时有限)
数据完整性 可能缺失部分字段 完整且有文档
稳定性 受网页改版影响 稳定(有版本控制)
合规性 灰色地带 完全合法

我的建议:

  • 如果是个人学习、小规模数据分析 → 用爬虫(快速灵活)
  • 如果是商业项目、需要长期维护 → 用官方 API(稳定可靠)

推荐阅读

文档:

工具:

  • Postman:调试 API 接口必备
  • Charles/Fiddler:抓包分析神器
  • jq:命令行 JSON 处理工具

社区:

  • r/webdev:Reddit 上的 Web 开发社区
  • GitHub Topic: spotify-api

最后的话

Spotify 的爬取难度明显高于传统静态网站,这也正是它的魅力所在------你需要像个侦探一样,层层剥开现代 Web 应用的外壳,找到数据的真正来源。

这个过程教会我的不仅是技术,更是一种解决问题的思维方式:

  • 遇到 JS 渲染? → 打开开发者工具,找 API
  • Token 验证失败? → 分析请求头,模拟浏览器
  • 速率限制太严? → 加延迟、用代理、换思路

技术的本质是解决问题,而不是炫技。希望这篇教程能帮你打开音乐数据分析的大门。

如果你成功爬取了数据,欢迎在评论区分享你的成果!也许下一个 Spotify 数据可视化项目就是你的作品。

🌟 文末

好啦~以上就是本期的全部内容啦!如果你在实践过程中遇到任何疑问,欢迎在评论区留言交流,我看到都会尽量回复~咱们下期见!

小伙伴们在批阅的过程中,如果觉得文章不错,欢迎点赞、收藏、关注哦~
三连就是对我写作道路上最好的鼓励与支持! ❤️🔥

✅ 专栏持续更新中|建议收藏 + 订阅

墙裂推荐订阅专栏 👉 《Python爬虫实战》,本专栏秉承着以"入门 → 进阶 → 工程化 → 项目落地"的路线持续更新,争取让每一期内容都做到:

✅ 讲得清楚(原理)|✅ 跑得起来(代码)|✅ 用得上(场景)|✅ 扛得住(工程化)

📣 想系统提升的小伙伴 :强烈建议先订阅专栏 《Python爬虫实战》,再按目录大纲顺序学习,效率十倍上升~

✅ 互动征集

想让我把【某站点/某反爬/某验证码/某分布式方案】等写成某期实战?

评论区留言告诉我你的需求,我会优先安排实现(更新)哒~


⭐️ 若喜欢我,就请关注我叭~(更新不迷路)

⭐️ 若对你有用,就请点赞支持一下叭~(给我一点点动力)

⭐️ 若有疑问,就请评论留言告诉我叭~(我会补坑 & 更新迭代)


✅ 免责声明

本文爬虫思路、相关技术和代码仅用于学习参考,对阅读本文后的进行爬虫行为的用户本作者不承担任何法律责任。

使用或者参考本项目即表示您已阅读并同意以下条款:

  • 合法使用: 不得将本项目用于任何违法、违规或侵犯他人权益的行为,包括但不限于网络攻击、诈骗、绕过身份验证、未经授权的数据抓取等。
  • 风险自负: 任何因使用本项目而产生的法律责任、技术风险或经济损失,由使用者自行承担,项目作者不承担任何形式的责任。
  • 禁止滥用: 不得将本项目用于违法牟利、黑产活动或其他不当商业用途。
  • 使用或者参考本项目即视为同意上述条款,即 "谁使用,谁负责" 。如不同意,请立即停止使用并删除本项目。!!!
相关推荐
啊阿狸不会拉杆2 小时前
《计算机视觉:模型、学习和推理》第 3 章-常用概率分布
人工智能·python·学习·机器学习·计算机视觉·正态分布·概率分布
ValhallaCoder6 小时前
hot100-栈
数据结构·python·算法·
MediaTea9 小时前
Python:生成器表达式详解
开发语言·python
-To be number.wan10 小时前
Python数据分析:SciPy科学计算
python·学习·数据分析
Dxy123931021610 小时前
DataFrame数据修改:从基础操作到高效实践的完整指南
python·dataframe
overmind11 小时前
oeasy Python 115 列表弹栈用pop删除指定索引
开发语言·python
hnxaoli12 小时前
win10程序(十六)通达信参数清洗器
开发语言·python·小程序·股票·炒股
电饭叔12 小时前
文本为 “ok”、前景色为白色、背景色为红色,且点击后触发 processOK 回调函数的 tkinter 按钮
开发语言·python
雷电法拉珑13 小时前
财务数据批量采集
linux·前端·python