1. 荔枝FM个人作品抓取:从手动下载到脚本跑通
荔枝FM这个平台,很多朋友可能不太熟,它算是比较小众的一款音频APP,早年也能和喜马拉雅、蜻蜓FM比一比。可惜逐渐没落了。孩子小时候,我和娃一起在上面录了不少故事,想着趁服务器没停,赶紧把音频文件下载下来,做个纪念。网上查遍各种方法,通过手机就是没法导出来,只能下载到应用内部,iOS下压根找不到文件路径,也找不到客服来回答。
既然正规渠道不行,那干脆写个程序爬下来吧。也顺便给Trae一个机会,说干就干。通过浏览器F12看了一下,音频地址url规则非常简单。实际地址是https://cdn5.lizhi.fm/audio/2020/08/11/2821634_hd.mp3,参数应该都可以从列表页接口里拿到。我希望文件存下来的时候,文件命名成2020_08_11_漫画宋词第一章.mp3,存到目录下的audio文件夹里。这是列表页的接口地址https://njnew.lizhi.fm/voice/getVoiceList?page=1&voiceType=-1&status=-1&keyword=,作品一共有18页。
这篇文章我会把整个流程拆开:先用Trae生成可运行的Python爬虫骨架,再把TaoToken的统一Key/API通道接进项目配置里,让后续调用模型做解析、排障、字段映射时不用来回切平台。你跟着做,十分钟内能跑通第一页,半小时内能把全部作品拉下来。
2. TaoToken前置准备:统一Key与API通道
在写爬虫之前,先把TaoToken的配置搞定。为什么要先做这一步?因为后面解析接口返回的JSON、处理字段缺失、生成下载URL规则,这些环节我都习惯让模型帮我做一次校验和补全。TaoToken的好处是它把多家模型的调用收敛到一个Key、一个API地址上,项目里只需要维护一份配置,不用每个模型单独申请。
你需要先拿到API Key。打开TaoToken官网,注册后在控制台里创建一个Key,复制出来备用。API地址统一用https://taotoken.net/api,注意这个地址后面不加任何UTM参数,保持干净。
拿到Key之后,在项目根目录建一个settings.json,骨架长这样:
{ "taotoken": { "api_key": "sk-你的Key粘贴在这里", "base_url": "https://taotoken.net/api", "default_model": "claude-sonnet-4-5", "timeout": 60 }, "lizhi": { "user_id": "1234567", "base_url": "https://njnew.lizhi.fm/voice/getVoiceList", "start_page": 1, "end_page": 18, "download_dir": "audio", "request_interval": 0.5 } }这个文件的作用是把敏感信息和业务参数分离。爬虫代码只读lizhi段,模型调用只读taotoken段。后面如果你要换模型,只改default_model一个字段就行。
注意:
api_key不要提交到公开仓库,建议在.gitignore里加上settings.json,或者用环境变量覆盖。
如果你还没创建Key,可以直接去API Keys页面生成一个。接入文档在doc页面里有完整的参数说明,包括请求头格式、超时设置、错误码含义,建议先扫一遍再动手。
3. 可复制配置:requests爬虫与解析骨架
现在进入核心部分。整个爬虫分三层:请求层负责拿列表数据,解析层负责把JSON里的字段映射成下载任务,下载层负责按年份归档存文件。
先装依赖:
pip install requests然后建lizhi_downloader.py,把下面的代码完整贴进去。这段代码我实测过,能直接跑:
import os import json import time import requests from datetime import datetime class LizhiFMDownloader: """荔枝FM音频下载器""" def __init__(self, config_path="settings.json"): with open(config_path, "r", encoding="utf-8") as f: cfg = json.load(f) lz = cfg["lizhi"] self.base_url = lz["base_url"] self.user_id = lz["user_id"] self.download_dir = os.path.join( os.path.dirname(os.path.abspath(__file__)), lz["download_dir"] ) self.interval = lz.get("request_interval", 0.5) self.headers = { "Host": "njnew.lizhi.fm", "User-Agent": ( "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/130.0.0.0 Safari/537.36" ), "content-type": "application/json;charset=utf-8", "Accept": "*/*", "Origin": "https://nj.lizhi.fm", "Referer": "https://nj.lizhi.fm/", "Cookie": "在这里粘贴你的Cookie" } os.makedirs(self.download_dir, exist_ok=True) def get_voice_list(self, page=1, voice_type=-1, status=-1, keyword=""): params = { "page": page, "voiceType": voice_type, "status": status, "keyword": keyword } try: resp = requests.get( self.base_url, params=params, headers=self.headers, timeout=10 ) print(f"第{page}页状态码: {resp.status_code}") return resp.json() except requests.exceptions.RequestException as e: print(f"请求失败: {e}") return None except ValueError as e: print(f"JSON解析失败: {e}") return None def parse_date(self, ts): dt = datetime.fromtimestamp(int(ts) / 1000) return dt.strftime("%Y_%m_%d"), dt.year def clean_filename(self, name): for ch in ['\\', '/', ':', '*', '?', '"', '<', '>', '|']: name = name.replace(ch, '_') return name def build_audio_url(self, audio_id, create_time): dt = datetime.fromtimestamp(int(create_time) / 1000) date_str = dt.strftime("%Y/%m/%d") return f"https://cdn5.lizhi.fm/audio/{date_str}/{audio_id}_hd.mp3" def download_audio(self, url, filename, year): year_dir = os.path.join(self.download_dir, str(year)) os.makedirs(year_dir, exist_ok=True) file_path = os.path.join(year_dir, filename) if os.path.exists(file_path): print(f"已存在,跳过: {filename}") return True try: resp = requests.get( url, headers=self.headers, stream=True, timeout=30 ) resp.raise_for_status() with open(file_path, "wb") as f: for chunk in resp.iter_content(chunk_size=8192): if chunk: f.write(chunk) print(f"下载完成: {filename}") return True except requests.exceptions.RequestException as e: print(f"下载失败: {url} - {e}") return False def process_item(self, item): audio_id = item.get("id") name = item.get("name") create_time = item.get("createTime") if not all([audio_id, name, create_time]): print(f"字段缺失,跳过: {item.get('name', '未知')}") return False date_str, year = self.parse_date(create_time) filename = f"{date_str}_{self.clean_filename(name)}.mp3" url = self.build_audio_url(audio_id, create_time) return self.download_audio(url, filename, year) def run(self, start_page=1, end_page=18): total, success = 0, 0 for page in range(start_page, end_page + 1): print(f"\n正在处理第 {page} 页...") data = self.get_voice_list(page=page) if not data or "data" not in data: print(f"第{page}页数据异常,跳过") continue voice_list = data["data"].get("list", []) if not voice_list: print(f"第{page}页无数据") continue print(f"本页共 {len(voice_list)} 条") for i, item in enumerate(voice_list, 1): print(f"[{page}-{i}/{len(voice_list)}] {item.get('name', '未知')}") if self.process_item(item): success += 1 total += 1 time.sleep(self.interval) time.sleep(1) print(f"\n完成! 总计: {total}, 成功: {success}, 失败: {total - success}") if __name__ == "__main__": downloader = LizhiFMDownloader() downloader.run(start_page=1, end_page=18)几个关键点说明一下。headers里的Cookie必须换成你自己登录荔枝FM后从浏览器里复制的值,否则接口会返回“没有权限访问”。build_audio_url里的CDN域名cdn5是实测可用的,不同地区可能略有差异,你随便点开一个作品看一下实际地址就能找到规律。request_interval设成0.5秒,避免请求过于频繁。
4. 验证请求与结果校验
代码写完之后,先跑第一页验证:
python lizhi_downloader.py如果一切正常,你会看到类似输出:
正在处理第 1 页... 第1页状态码: 200 本页共 20 条 [1-1/20] 漫画宋词第一章 下载完成: 2020_08_11_漫画宋词第一章.mp3 ...目录结构会自动按年份归档:
audio/ ├── 2019/ │ └── 2019_01_01_标题.mp3 ├── 2020/ │ └── 2020_08_11_漫画宋词第一章.mp3 └── 2021/ └── 2021_03_15_标题.mp3验证成功的三个标志:状态码200、JSON里data.list有内容、audio目录下出现mp3文件。如果状态码是200但list为空,说明Cookie失效了,重新登录复制一次。如果下载下来的文件是几KB的碎片,说明URL规则变了,需要重新抓包确认。
我试过第一遍跑完,发现拿到的m4a比特率很低,改一下路径就能得到128kbps的,APP端貌似有更高清的,不过抓包没成功,算了,128k也够用了。
5. 本篇常见错误排查
5.1 返回“没有权限访问”
这是最常见的报错。原因是接口需要登录态。解决办法是在浏览器里登录荔枝FM,按F12打开开发者工具,在Network面板里找到getVoiceList请求,把Request Headers里的Cookie整段复制出来,替换代码里的占位符。
5.2 JSON解析失败或字段缺失
接口返回的结构可能随版本变化。如果data.list取不到,先打印完整响应看看实际结构:
data = downloader.get_voice_list(page=1) print(json.dumps(data, ensure_ascii=False, indent=2)[:500])确认字段名是id、name、createTime还是别的。有些版本用audioId、title、createAt,代码里加个兼容就行。
5.3 下载失败但列表正常
通常是CDN域名或路径规则变了。打开任意一个作品的播放页,F12看实际音频请求的URL,对比build_audio_url生成的地址。如果域名从cdn5变成了cdn或其他,改一下就行。
5.4 请求被限流
如果连续请求后开始返回403或超时,说明触发了频率限制。把request_interval调到1秒以上,页与页之间加2秒延迟。别贪快,个人作品几百条,慢慢跑也就半小时。
5.5 文件名乱码或含非法字符
Windows下文件名不能包含\/:*?"<>|,代码里的clean_filename已经处理了。如果还有问题,检查标题里是否有emoji或特殊Unicode字符,可以再加一层过滤。
遇到这些报错时,如果你不确定是代码问题还是接口变更,可以把报错信息和响应片段丢给模型对话做一次分析,让它帮你判断是Cookie过期、字段改名还是URL规则调整。这比自己逐行debug快很多。
6. 长期维护与Coding Plan接入建议
这个爬虫脚本本身不复杂,但荔枝FM的接口策略可能会变。如果你打算长期维护,或者想把它扩展成定时任务、多用户批量抓取,建议把模型调用也纳入工程化流程。
具体做法是在settings.json里已经配好的taotoken段基础上,写一个简单的校验函数:每次跑之前,先把接口返回的第一条数据发给模型,让它判断字段结构是否和预期一致,不一致就输出差异报告。这样接口一变你立刻知道,不用等下载失败才发现。
对于需要长期跑编码任务、Agent自动化的场景,Coding Plan比按次调用更划算,额度固定、不用每次算token。如果你只是偶尔跑一次爬虫,用模型对话手动问几次就够了。
接入文档里有完整的请求示例和错误码说明,建议收藏。API Keys页面可以随时查看和轮换Key,控制台里能看到调用量和余额。整个流程跑通之后,你手里就有了一套可复用的“爬虫+模型校验”骨架,换个目标站点改改参数就能用。