㊙️本期内容已收录至专栏《Python爬虫实战》,持续完善知识体系与项目实战,建议先订阅收藏,后续查阅更方便~持续更新中!
㊗️爬虫难度指数:⭐⭐
🚫声明:本数据&代码仅供学习交流,严禁用于商业用途、倒卖数据或违反目标站点的服务条款等,一切后果皆由使用者本人承担。公开榜单数据一般允许访问,但请务必遵守"君子协议",技术无罪,责任在人。

全文目录:
-
- [🌟 开篇语](#🌟 开篇语)
- [📌 摘要(Abstract)](#📌 摘要(Abstract))
- [1️⃣ 背景与需求(Why)](#1️⃣ 背景与需求(Why))
- [2️⃣ 合规与注意事项(必读)](#2️⃣ 合规与注意事项(必读))
- [3️⃣ 技术选型与整体流程(What/How)](#3️⃣ 技术选型与整体流程(What/How))
- [4️⃣ 环境准备与依赖安装](#4️⃣ 环境准备与依赖安装)
- [5️⃣ 核心实现:请求层(Fetcher)](#5️⃣ 核心实现:请求层(Fetcher))
- [6️⃣ 核心实现:解析层(Parser)](#6️⃣ 核心实现:解析层(Parser))
- [7️⃣ 数据存储与导出(Storage)](#7️⃣ 数据存储与导出(Storage))
- [8️⃣ 运行方式与结果展示](#8️⃣ 运行方式与结果展示)
- [9️⃣ 常见问题与排错](#9️⃣ 常见问题与排错)
-
- [Q1: 遇到403/429怎么办?](#Q1: 遇到403/429怎么办?)
- [Q2: 返回空JSON或HTML登录页?](#Q2: 返回空JSON或HTML登录页?)
- [Q3: 数据库锁死?](#Q3: 数据库锁死?)
- [Q4: 中文乱码?](#Q4: 中文乱码?)
- [🔟 进阶优化(可选)](#🔟 进阶优化(可选))
-
- [1. 异步并发(提速3-5倍)](#1. 异步并发(提速3-5倍))
- [2. 断点续跑](#2. 断点续跑)
- [3. 日志与监控](#3. 日志与监控)
- [4. 定时任务(每日自动更新)](#4. 定时任务(每日自动更新))
- [1️⃣1️⃣ 总结与延伸](#1️⃣1️⃣ 总结与延伸)
- 写在最后
- [🌟 文末](#🌟 文末)
-
- [📌 专栏持续更新中|建议收藏 + 订阅](#📌 专栏持续更新中|建议收藏 + 订阅)
- [✅ 互动征集](#✅ 互动征集)
🌟 开篇语
哈喽,各位小伙伴们你们好呀~我是【喵手】。
运营社区: C站 / 掘金 / 腾讯云 / 阿里云 / 华为云 / 51CTO
欢迎大家常来逛逛,一起学习,一起进步~🌟
我长期专注 Python 爬虫工程化实战 ,主理专栏 《Python爬虫实战》:从采集策略 到反爬对抗 ,从数据清洗 到分布式调度 ,持续输出可复用的方法论与可落地案例。内容主打一个"能跑、能用、能扩展 ",让数据价值真正做到------抓得到、洗得净、用得上。
📌 专栏食用指南(建议收藏)
- ✅ 入门基础:环境搭建 / 请求与解析 / 数据落库
- ✅ 进阶提升:登录鉴权 / 动态渲染 / 反爬对抗
- ✅ 工程实战:异步并发 / 分布式调度 / 监控与容错
- ✅ 项目落地:数据治理 / 可视化分析 / 场景化应用
📣 专栏推广时间 :如果你想系统学爬虫,而不是碎片化东拼西凑,欢迎订阅/关注专栏👉《Python爬虫实战》👈
💕订阅后更新会优先推送,按目录学习更高效💯~
📌 摘要(Abstract)
本文将手把手教你爬取小宇宙播客平台 的节目数据,提取期数、标题、时长、发布时间、shownotes等核心字段,最终存入SQLite数据库。使用requests直接调用移动端API,避免复杂的HTML解析,整个流程轻量、高效、可维护。
读完你能获得:
- ✅ 小宇宙API的逆向分析方法(抓包→复现→字段映射)
- ✅ 一套完整的播客归档脚本(请求→解析→存储→去重)
- ✅ 实用的反爬对抗技巧(频控、UA伪装、异常重试)
1️⃣ 背景与需求(Why)
为什么要做播客归档?
作为一个重度播客用户,我曾遇到过这些痛点:
- 📌 想统计自己订阅的播客更新频率,却没有现成工具
- 📊 想分析某档节目的时长分布、主题演变,平台不提供数据导出
- 🔍 想搜索历史节目的shownotes关键词,App内搜索功能太弱
- 💾 担心播客停更或下架,想做本地备份(仅元数据)
所以我决定写一个自动化脚本,定期抓取订阅播客的最新目录,存入本地数据库,方便后续分析和检索。
目标字段清单
| 字段名 | 说明 | 示例值 |
|---|---|---|
episode_id |
单集唯一ID | 6384a2b1e0f1e723bb395374 |
podcast_id |
播客ID | 60d4d6e8e0f1e7a0bb6f3c9a |
title |
单集标题 | #127 对话李厂长:开源的真正意义 |
description |
shownotes正文 | 本期我们聊了开源社区... |
duration |
时长(秒) | 3542 |
pub_date |
发布时间 | 2023-11-28T10:30:00Z |
audio_url |
音频文件地址 | https://media.xyzcdn.net/xxx.m4a |
enclosure_url |
封面图 | https://image.xyzcdn.net/cover.jpg |
2️⃣ 合规与注意事项(必读)
robots.txt说明
小宇宙网页版(xiaoyuzhoufm.com)的robots.txt对爬虫有限制,但我们采用的是移动端API (api.xiaoyuzhoufm.com),这类接口通常用于App正常业务,只要:
- ✅ 控制请求频率(间隔≥1秒)
- ✅ 不暴力并发(单线程即可)
- ✅ 仅采集公开数据(无需登录的播客目录)
就属于合理使用范畴。
三不原则
- 不绕过付费限制:仅爬取免费公开的节目列表,不抓付费内容
- 不采集敏感信息:不记录用户评论、个人听单等隐私数据
- 不影响服务稳定:严格限速,避免对服务器造成压力
个人观点
我认为爬取公开API用于个人学习、数据分析是合理的,但有三个底线:
- 不用于商业牟利
- 不二次分发数据
- 尊重创作者版权(音频本身不下载)
3️⃣ 技术选型与整体流程(What/How)
为什么选API而不是网页?
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 网页爬取 | 无需抓包 | HTML结构变化频繁,解析脆弱 | 简单静态站 |
| API抓取 | 数据结构稳定,JSON解析简单 | 需要抓包分析 | 移动端为主的平台 |
| RSS订阅 | 标准化,最稳定 | 小宇宙不提供标准RSS | 传统播客平台 |
小宇宙是移动端优先的产品,网页版功能阉割,且大量数据由JS动态渲染。反观移动端API:
- ✅ 返回标准JSON,字段完整
- ✅ 接口设计相对稳定(业务需要)
- ✅ 无需处理JS执行、Cookie等复杂问题
整体流程
json
┌─────────────┐
│ 1. 抓包分析 │ → 找到播客列表API
└──────┬──────┘
↓
┌─────────────┐
│ 2. 请求层 │ → 模拟App请求,带UA/Header
└──────┬──────┘
↓
┌─────────────┐
│ 3. 解析层 │ → 提取JSON中的目标字段
└──────┬──────┘
↓
┌─────────────┐
│ 4. 清洗层 │ → 处理缺失值、时间格式转换
└──────┬──────┘
↓
┌─────────────┐
│ 5. 存储层 │ → SQLite去重写入
└─────────────┘
4️⃣ 环境准备与依赖安装
Python版本
推荐 Python 3.8+(需要f-string和类型提示支持)
安装依赖
bash
pip install requests==2.31.0
就这一个!我们不用Scrapy(杀鸡用牛刀),不用bs4(没HTML要解析),只需要requests搞定HTTP请求。
项目结构
json
podcast_archiver/
├── fetcher.py # 请求层
├── parser.py # 解析层
├── storage.py # 存储层
├── main.py # 主入口
├── config.py # 配置文件
├── requirements.txt # 依赖列表
└── data/
└── podcasts.db # SQLite数据库
5️⃣ 核心实现:请求层(Fetcher)
抓包分析过程
- 打开Charles/Fiddler
- 启动小宇宙App,进入某播客详情页
- 筛选
api.xiaoyuzhoufm.com域名 - 找到类似
/v1/podcast/{podcast_id}/episodes的接口 - 查看Request Headers和Response格式
关键发现
http
GET /v1/podcast/60d4d6e8e0f1e7a0bb6f3c9a/episodes?page=1&per_page=20
Host: api.xiaoyuzhoufm.com
User-Agent: XiaoYuZhou/2.48.0 (iPhone; iOS 16.5.1; Scale/3.00)
X-DEVICE-ID: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
fetcher.py 完整代码:
python
import requests
import time
import random
from typing import Optional, Dict, Any
class PodcastFetcher:
"""播客API请求封装"""
BASE_URL = "https://api.xiaoyuzhoufm.com/v1"
def __init__(self):
self.session = requests.Session()
self.session.headers.update({
'User-Agent': 'XiaoYuZhou/2.48.0 (iPhone; iOS 16.5.1; Scale/3.00)',
'Accept': 'application/json',
'X-DEVICE-ID': self._generate_device_id()
})
def _generate_device_id(self) -> str:
"""生成伪随机设备ID(格式模拟)"""
import uuid
return str(uuid.uuid4())
def fetch_episodes(
self,
podcast_id: str,
page: int = 1,
per_page: int = 20,
max_retries: int = 3
) -> Optional[Dict[str, Any]]:
"""
获取播客单集列表
Args:
podcast_id: 播客ID
page: 页码(从1开始)
per_page: 每页数量
max_retries: 最大重试次数
Returns:
JSON响应字典,失败返回None
"""
url = f"{self.BASE_URL}/podcast/{podcast_id}/episodes"
params = {'page': page, 'per_page': per_page}
for attempt in range(max_retries):
try:
# 随机延迟1-2秒,避免频控
time.sleep(random.uniform(1.0, 2.0))
response = self.session.get(
url,
params=params,
timeout=15
)
# 检查HTTP状态码
if response.status_code == 200:
return response.json()
elif response.status_code == 429:
# 触发频控,指数退避
wait_time = 2 ** attempt * 5
print(f"⚠️ 触发频控,等待{wait_time}秒后重试...")
time.sleep(wait_time)
else:
print(f"❌ HTTP {response.status_code}: {response.text[:100]}")
return None
except requests.exceptions.Timeout:
print(f"⏱️ 请求超时,第{attempt + 1}次重试...")
except requests.exceptions.RequestException as e:
print(f"❌ 网络错误: {e}")
return None
print(f"❌ 重试{max_retries}次后仍失败")
return None
设计要点解析
- Session复用:避免每次请求重新建立TCP连接
- 随机延迟 :
time.sleep(random.uniform(1, 2))模拟人类操作 - 指数退避:遇到429时等待时间翻倍(5s → 10s → 20s)
- 超时设置:15秒超时防止程序挂死
- 设备ID伪装:虽然小宇宙不强校验,但保持完整性
6️⃣ 核心实现:解析层(Parser)
API返回的JSON结构示例:
json
{
"data": {
"episodes": [
{
"eid": "6384a2b1e0f1e723bb395374",
"title": "#127 对话李厂长:开源的真正意义",
"description": "<p>本期我们聊了...</p>",
"duration": 3542,
"pubDate": "2023-11-28T10:30:00.000Z",
"enclosure": {
"url": "https://media.xyzcdn.net/xxx.m4a"
},
"image": {
"picUrl": "https://image.xyzcdn.net/cover.jpg"
}
}
],
"pagination": {
"total": 150,
"page": 1,
"perPage": 20
}
}
}
parser.py 完整代码:
python
from typing import List, Dict, Any, Optional
import re
from datetime import datetime
class EpisodeParser:
"""播客单集数据解析器"""
@staticmethod
def parse_episodes(response_data: Dict[str, Any]) -> List[Dict[str, Any]]:
"""
解析API响应,提取单集列表
Returns:
标准化后的单集字典列表
"""
if not response_data or 'data' not in response_data:
return []
raw_episodes = response_data.get('data', {}).get('episodes', [])
parsed_episodes = []
for ep in raw_episodes:
try:
parsed = EpisodeParser._parse_single_episode(ep)
if parsed:
parsed_episodes.append(parsed)
except Exception as e:
print(f"⚠️ 解析单集失败: {e}, 原始数据: {ep.get('title', 'Unknown')}")
continue
return parsed_episodes
@staticmethod
def _parse_single_episode(ep: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""解析单个单集(含容错处理)"""
# 必需字段校验
if not ep.get('eid') or not ep.get('title'):
return None
return {
'episode_id': ep['eid'],
'title': EpisodeParser._clean_title(ep['title']),
'description': EpisodeParser._clean_html(ep.get('description', '')),
'duration': ep.get('duration', 0),
'pub_date': EpisodeParser._parse_date(ep.get('pubDate')),
'audio_url': ep.get('enclosure', {}).get('url', ''),
'cover_url': ep.get('image', {}).get('picUrl', ''),
'raw_data': ep # 保留原始JSON备查
}
@staticmethod
def _clean_title(title: str) -> str:
"""清洗标题(去除异常字符)"""
# 去除emoji(可选)
# title = re.sub(r'[^\w\s\u4e00-\u9fff]', '', title)
return title.strip()
@staticmethod
def _clean_html(html: str) -> str:
"""从shownotes中提取纯文本"""
# 简单粗暴:去除所有HTML标签
text = re.sub(r'<[^>]+>', '', html)
# 去除多余空白
text = re.sub(r'\s+', ' ', text)
return text.strip()
@staticmethod
def _parse_date(date_str: Optional[str]) -> Optional[str]:
"""
标准化时间格式
输入: "2023-11-28T10:30:00.000Z"
输出: "2023-11-28 10:30:00"
"""
if not date_str:
return None
try:
dt = datetime.fromisoformat(date_str.replace('Z', '+00:00'))
return dt.strftime('%Y-%m-%d %H:%M:%S')
except:
return date_str # 解析失败直接返回原值
@staticmethod
def get_total_pages(response_data: Dict[str, Any], per_page: int = 20) -> int:
"""计算总页数"""
total = response_data.get('data', {}).get('pagination', {}).get('total', 0)
return (total + per_page - 1) // per_page # 向上取整
容错设计重点
- 字段缺失处理 :用
.get()而非直接索引,默认值为空字符串/0 - HTML清洗 :shownotes通常包含
<p>、<a>等标签,用正则去除 - 时间格式统一 :ISO 8601 → MySQL友好的
YYYY-MM-DD HH:MM:SS - 异常捕获:单个单集解析失败不影响整体流程
7️⃣ 数据存储与导出(Storage)
为什么选SQLite?
- ✅ 零配置,单文件数据库
- ✅ 支持SQL查询,方便后续分析
- ✅ 原生支持
INSERT OR IGNORE实现去重
storage.py 完整代码:
python
import sqlite3
from typing import List, Dict, Any
import json
class PodcastStorage:
"""SQLite存储层"""
def __init__(self, db_path: str = 'data/podcasts.db'):
self.db_path = db_path
self._init_database()
def _init_database(self):
"""初始化数据库表结构"""
with sqlite3.connect(self.db_path) as conn:
conn.execute('''
CREATE TABLE IF NOT EXISTS episodes (
episode_id TEXT PRIMARY KEY,
podcast_id TEXT,
title TEXT NOT NULL,
description TEXT,
duration INTEGER,
pub_date TEXT,
audio_url TEXT,
cover_url TEXT,
raw_json TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
''')
# 创建索引加速查询
conn.execute('''
CREATE INDEX IF NOT EXISTS idx_podcast_id
ON episodes(podcast_id)
''')
conn.execute('''
CREATE INDEX IF NOT EXISTS idx_pub_date
ON episodes(pub_date DESC)
''')
conn.commit()
def save_episodes(self, episodes: List[Dict[str, Any]], podcast_id: str) -> int:
"""
批量保存单集(自动去重)
Returns:
新插入的记录数
"""
if not episodes:
return 0
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
inserted = 0
for ep in episodes:
try:
cursor.execute('''
INSERT OR IGNORE INTO episodes (
episode_id, podcast_id, title, description,
duration, pub_date, audio_url, cover_url, raw_json
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
''', (
ep['episode_id'],
podcast_id,
ep['title'],
ep['description'],
ep['duration'],
ep['pub_date'],
ep['audio_url'],
ep['cover_url'],
json.dumps(ep['raw_data'], ensure_ascii=False)
))
if cursor.rowcount > 0:
inserted += 1
except sqlite3.IntegrityError:
continue # 主键冲突,跳过
conn.commit()
return inserted
def export_to_csv(self, podcast_id: str, output_path: str):
"""导出指定播客的所有单集为CSV"""
import csv
with sqlite3.connect(self.db_path) as conn:
conn.row_factory = sqlite3.Row
cursor = conn.execute('''
SELECT episode_id, title, duration, pub_date, audio_url
FROM episodes
WHERE podcast_id = ?
ORDER BY pub_date DESC
''', (podcast_id,))
rows = cursor.fetchall()
with open(output_path, 'w', newline='', encoding='utf-8-sig') as f:
writer = csv.writer(f)
writer.writerow(['单集ID', '标题', '时长(秒)', '发布时间', '音频链接'])
for row in rows:
writer.writerow([row['episode_id'], row['title'],
row['duration'], row['pub_date'], row['audio_url']])
print(f"✅ 已导出 {len(rows)} 条记录到 {output_path}")
字段映射表
| 数据库字段 | 类型 | 示例值 | 说明 |
|---|---|---|---|
episode_id |
TEXT | 6384a2b1... |
主键,确保唯一 |
podcast_id |
TEXT | 60d4d6e8... |
外键,关联播客 |
title |
TEXT | #127 对话李厂长 |
单集标题 |
description |
TEXT | 本期我们聊了... |
shownotes纯文本 |
duration |
INTEGER | 3542 |
时长(秒) |
pub_date |
TEXT | 2023-11-28 10:30:00 |
发布时间 |
audio_url |
TEXT | https://... |
音频地址 |
raw_json |
TEXT | {"eid":...} |
原始JSON备份 |
去重策略
使用INSERT OR IGNORE + PRIMARY KEY(episode_id):
- 首次运行:插入所有记录
- 后续运行:自动跳过已存在的
episode_id
8️⃣ 运行方式与结果展示
main.py 完整代码:
python
from fetcher import PodcastFetcher
from parser import EpisodeParser
from storage import PodcastStorage
def crawl_podcast(podcast_id: str, max_pages: int = 5):
"""
爬取指定播客的所有单集
Args:
podcast_id: 播客ID(从小宇宙URL中获取)
max_pages: 最大爬取页数(防止意外)
"""
fetcher = PodcastFetcher()
parser = EpisodeParser()
storage = PodcastStorage()
print(f"🚀 开始爬取播客: {podcast_id}")
# 第一次请求获取总页数
first_response = fetcher.fetch_episodes(podcast_id, page=1)
if not first_response:
print("❌ 获取第一页失败,请检查podcast_id")
return
total_pages = min(
parser.get_total_pages(first_response),
max_pages
)
print(f"📄 共 {total_pages} 页数据")
# 解析第一页
episodes = parser.parse_episodes(first_response)
new_count = storage.save_episodes(episodes, podcast_id)
print(f"✅ 第1页: 新增 {new_count}/{len(episodes)} 条记录")
# 爬取剩余页
for page in range(2, total_pages + 1):
response = fetcher.fetch_episodes(podcast_id, page=page)
if not response:
print(f"⚠️ 第{page}页获取失败,跳过")
continue
episodes = parser.parse_episodes(response)
new_count = storage.save_episodes(episodes, podcast_id)
print(f"✅ 第{page}页: 新增 {new_count}/{len(episodes)} 条记录")
print(f"🎉 爬取完成!")
# 导出CSV
storage.export_to_csv(podcast_id, f'data/{podcast_id}_episodes.csv')
if __name__ == '__main__':
# 示例:爬取「忽左忽右」播客
# podcast_id可从小宇宙URL获取: https://www.xiaoyuzhoufm.com/podcast/5e280fab418a84a0461fa548
PODCAST_ID = '5e280fab418a84a0461fa548'
crawl_podcast(PODCAST_ID, max_pages=10)
如何获取podcast_id?
- 浏览器打开小宇宙网页版
- 进入目标播客主页
- 从URL中提取ID:
https://www.xiaoyuzhoufm.com/podcast/{这里就是podcast_id}
运行命令
bash
python main.py
输出示例
json
🚀 开始爬取播客: 5e280fab418a84a0461fa548
📄 共 8 页数据
✅ 第1页: 新增 20/20 条记录
✅ 第2页: 新增 20/20 条记录
✅ 第3页: 新增 20/20 条记录
...
🎉 爬取完成!
✅ 已导出 150 条记录到 data/5e280fab418a84a0461fa548_episodes.csv
数据库查询示例
sql
-- 查询最新5期节目
SELECT title, duration, pub_date
FROM episodes
ORDER BY pub_date DESC
LIMIT 5;
-- 统计平均时长
SELECT AVG(duration) / 60 AS avg_minutes
FROM episodes;
-- 搜索shownotes关键词
SELECT title, description
FROM episodes
WHERE description LIKE '%开源%';
9️⃣ 常见问题与排错
Q1: 遇到403/429怎么办?
现象 :返回HTTP 403 Forbidden或429 Too Many Requests
原因分析:
- 403:User-Agent校验失败或IP被封
- 429:请求频率过高触发限流
解决方案:
python
# 1. 更换User-Agent(模拟不同设备)
ua_pool = [
'XiaoYuZhou/2.48.0 (iPhone; iOS 16.5.1; Scale/3.00)',
'XiaoYuZhou/2.47.0 (iPhone; iOS 15.6; Scale/2.00)',
'XiaoYuZhou/2.46.0 (Android; 12; Pixel 5)'
]
self.session.headers['User-Agent'] = random.choice(ua_pool)
# 2. 增加延迟
time.sleep(random.uniform(2.0, 4.0)) # 改为2-4秒
# 3. 使用代理(最后手段)
proxies = {'http': 'http://your_proxy:port'}
response = self.session.get(url, proxies=proxies)
Q2: 返回空JSON或HTML登录页?
现象 :response.json()报错或返回{}
排查步骤:
- 打印
response.text[:500]查看实际内容 - 检查是否需要登录(小宇宙公开播客不需要)
- 检查URL是否正确(注意
v1版本号)
案例:有些播客设置了会员专属,API会返回:
json
{"error": "premium_only", "message": "该内容需要订阅"}
处理 :在_parse_single_episode中增加错误码判断
Q3: 数据库锁死?
现象 :sqlite3.OperationalError: database is locked
原因:多进程/线程同时写入SQLite
解决方案:
python
# 方案1:单进程运行(推荐)
# 本脚本已按单线程设计
# 方案2:使用WAL模式(支持并发读)
conn.execute('PRAGMA journal_mode=WAL')
# 方案3:改用PostgreSQL/MySQL
Q4: 中文乱码?
现象 :CSV中显示\u4e2d\u6587
原因:未指定UTF-8编码
修复:
python
# 写入时
with open(output_path, 'w', encoding='utf-8-sig') as f: # 注意utf-8-sig
...
# 读取时
df = pd.read_csv('episodes.csv', encoding='utf-8-sig')
🔟 进阶优化(可选)
1. 异步并发(提速3-5倍)
python
import asyncio
import aiohttp
class AsyncFetcher:
async def fetch_episodes_async(self, podcast_id, page):
async with aiohttp.ClientSession() as session:
async with session.get(url, headers=headers) as response:
return await response.json()
async def crawl_all_pages(self, podcast_id, total_pages):
tasks = [
self.fetch_episodes_async(podcast_id, page)
for page in range(1, total_pages + 1)
]
return await asyncio.gather(*tasks)
注意:并发会增加触发频控的风险,需配合信号量限制:
python
sem = asyncio.Semaphore(3) # 最多同时3个请求
async with sem:
result = await fetch()
2. 断点续跑
python
def get_last_crawled_page(podcast_id) -> int:
"""从数据库查询已爬取的最大页码"""
with sqlite3.connect('podcasts.db') as conn:
cursor = conn.execute('''
SELECT MAX(page_num) FROM crawl_log
WHERE podcast_id = ?
''', (podcast_id,))
last_page = cursor.fetchone()[0]
return last_page or 0
# 使用
start_page = get_last_crawled_page(PODCAST_ID) + 1
for page in range(start_page, total_pages + 1):
# ...
log_crawled_page(podcast_id, page) # 记录进度
3. 日志与监控
python
import logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s [%(levelname)s] %(message)s',
handlers=[
logging.FileHandler('crawler.log'),
logging.StreamHandler()
]
)
logger = logging.getLogger(__name__)
# 使用
logger.info(f"成功爬取第{page}页,新增{new_count}条")
logger.error(f"请求失败: {response.status_code}")
4. 定时任务(每日自动更新)
bash
# Linux crontab
0 3 * * * cd /path/to/project && python main.py >> cron.log 2>&1
# Windows任务计划程序
# 创建基本任务 → 选择每日 → 执行python.exe main.py
1️⃣1️⃣ 总结与延伸
我们完成了什么?
✅ 逆向分析小宇宙移动端API
✅ 实现了完整的请求→解析→存储 流程
✅ 处理了频控、重试、去重等工程细节
✅ 支持CSV导出和SQL查询
这套方案的价值
- 📊 数据分析:统计播客更新频率、时长分布、主题演变
- 🔍 全文搜索:建立本地shownotes索引,秒级检索历史内容
- 💾 私人归档:防止播客下架或平台倒闭导致的数据丢失
- 🤖 自动化:配合定时任务实现订阅播客的自动同步
下一步可以做什么?
方向1:深度爬取
- 爬取播客评论(需登录,涉及Cookie处理)
- 下载音频文件(注意版权,仅个人使用)
- 抓取播主简介、社交链接
方向2:技术升级
- Scrapy框架:适合爬取多个播客平台(小宇宙+喜马拉雅+...)
- Playwright:如果目标站点需要JS执行(小宇宙暂不需要)
- 分布式爬虫:Scrapy-Redis实现多机协作
方向3:数据应用
- 可视化:用Plotly绘制播客时长趋势图
- NLP分析:对shownotes做主题建模(LDA/BERTopic)
- 推荐系统:基于描述相似度推荐相关节目
方向4:通用化
- 改造成RSS通用爬虫(支持所有播客平台)
- 开发Web界面(Flask + Vue)让非技术用户也能用
- 发布为Python包:
pip install podcast-archiver
写在最后
从抓包到数据库,我们用不到300行代码实现了一个生产级的播客归档工具。这个过程中最重要的不是技术本身,而是工程化思维:
- 容错优先(try-except everywhere)
- 日志完善(出错能快速定位)
- 可维护性(清晰的分层架构)
爬虫不是暴力抓取,而是对数据流的精细控制。希望这篇教程能帮你建立正确的爬虫观:合规、高效、可持续。
如果你在实践中遇到问题,欢迎查阅:
- 小宇宙开发者文档(虽然没有公开API文档,但可参考App行为)
- Python Requests官方文档:https://requests.readthedocs.io
- SQLite教程:https://www.sqlitetutorial.net
最后一句话:技术是中立的,但使用技术的人应该有原则。请记住三不原则,让爬虫成为学习和创造的工具,而不是伤害他人的武器。
🌟 文末
好啦~以上就是本期 《Python爬虫实战》的全部内容啦!如果你在实践过程中遇到任何疑问,欢迎在评论区留言交流,我看到都会尽量回复~咱们下期见!
小伙伴们在批阅的过程中,如果觉得文章不错,欢迎点赞、收藏、关注哦~
三连就是对我写作道路上最好的鼓励与支持! ❤️🔥
📌 专栏持续更新中|建议收藏 + 订阅
专栏 👉 《Python爬虫实战》,我会按照"入门 → 进阶 → 工程化 → 项目落地"的路线持续更新,争取让每一篇都做到:
✅ 讲得清楚(原理)|✅ 跑得起来(代码)|✅ 用得上(场景)|✅ 扛得住(工程化)
📣 想系统提升的小伙伴:强烈建议先订阅专栏,再按目录顺序学习,效率会高很多~

✅ 互动征集
想让我把【某站点/某反爬/某验证码/某分布式方案】写成专栏实战?
评论区留言告诉我你的需求,我会优先安排更新 ✅
⭐️ 若喜欢我,就请关注我叭~(更新不迷路)
⭐️ 若对你有用,就请点赞支持一下叭~(给我一点点动力)
⭐️ 若有疑问,就请评论留言告诉我叭~(我会补坑 & 更新迭代)

免责声明:本文仅用于学习与技术研究,请在合法合规、遵守站点规则与 Robots 协议的前提下使用相关技术。严禁将技术用于任何非法用途或侵害他人权益的行为。技术无罪,责任在人!!!