ARTICLE · 1315226
Ai实战12:从接口分析到 GUI:用 Python 写一个音色库批量下载器
📢 大家好,我是「Python学在坚持」。 上一篇我们做了「声音克隆」,有同学留言问:那些现成的 AI 音色网站,几百个音色能不能一键全扒下来做素材库? 今天就手把手教你:从零分析一个真实网站的接口,再用 Python 写一个带图形界面的"音色库批量下载器",抓取、搜索、单下、批量下、下头像,全都有。 全程只用 Python 标准库,不装任何第三方包。文末有完整源码,觉得有用先点个「关注」,学 Python 这事,贵在坚持~
从接口分析到 GUI:用 Python 写一个音色库批量下载器


一、先看我们要做什么
目标网站是一个在线 AI 音色平台的「音色库」页面,上面陈列着几百个预设音色,每个都能点播放试听。我们想做的是:
一键把全部音色的试听音频抓下来,存到本地当素材库; 有个图形界面,能搜索、能看列表、能选中下载、能批量下载; 不依赖任何第三方库,拿来即用。
最终成品是一个桌面小工具,长这样:

[抓取音色列表] 搜索:[____] □同时下载头像 保存目录:[下载/]
┌─────────────────────────────────────────────┐
│ 音色名称 │ 性别 │ 年龄 │ 语言 │ 热度 │ 描述 │
│ 曼小波 │ 女声 │ 儿童 │普通话│ 105 │ ... │
│ 新小闻 │ 男声 │ 青年 │普通话│ ... │ ... │
│ ...(共 411 个) │
└─────────────────────────────────────────────┘
[下载选中] [下载全部] [打开目录] 进度:[==== ] 日志...
⚠️ 先说清楚:本文纯技术教学,演示如何分析公开接口与编写下载程序。请只下载你有权使用的素材,尊重版权与网站服务条款,不要把素材用于商业或侵权用途。
二、最难的一步:数据到底藏在哪?
写爬虫/下载器,80% 的功夫在「找数据源」,真正写代码反而快。我们一步步来还原这个过程。
1. 直接抓网页?行不通
先用浏览器打开音色库页面,按 F12 看源码,你会发现:HTML 里根本没有音色数据。只有一堆 <script> 和空的 <div id="__nuxt">。
这说明它是个前端渲染的单页应用(SPA)。页面框架是先加载的,真正的音色列表是页面打开后,由 JavaScript 再去后端「要」回来的。
所以直接 urlopen(页面地址) 拿到的只是空壳,没用。
2. 换思路:找后端接口
既然数据是 JS 动态请求来的,那一定有个后端 API。找它有两个办法:
办法 A(最快):浏览器 F12 → Network(网络)面板 → 筛选 Fetch/XHR→ 刷新页面,看页面加载时请求了哪些接口,哪个返回了音色列表的 JSON。办法 B(本文用的):把前端的 JS 文件下载下来,在里面搜接口路径。因为接口地址是写死在打包后的 JS 里的。
我们用 Python 把页面里引用的所有 JS 抓下来,正则搜一遍 API 路径:
import re, urllib.request
BASE = "https://目标站点"
HEADERS = {"User-Agent": "Mozilla/5.0 ... Chrome/120.0 Safari/537.36",
"Referer": BASE + "/音色库页面"}
# 1) 抓页面 HTML,提取所有 /_nuxt/xxx.js
html = urllib.request.urlopen(
urllib.request.Request(BASE + "/音色库页面", headers=HEADERS)
).read().decode("utf-8", "replace")
js_files = set(re.findall(r'/_nuxt/[A-Za-z0-9_\-]+\.js', html))
# 2) 逐个下载 JS,搜索接口路径
for jf in js_files:
code = urllib.request.urlopen(
urllib.request.Request(BASE + jf, headers=HEADERS)
).read().decode("utf-8", "replace")
for m in re.findall(r'["\'`](/(?:api|voice|role)[A-Za-z0-9/_\-]*)["\'`]', code):
print(m)
跑完就从 JS 里挖出了一批接口路径,其中有两个很可疑:
/role/common/list—— 看名字像「公共音色列表」/voice/list—— 像「我的音色列表」
再结合 JS 里的 baseURL 前缀 /api/v1,拼出完整地址去试。
3. 验证接口
直接请求 GET https://目标站点/api/v1/role/common/list:
{
"code": 0,
"data": {
"list": [
{
"roleId": "A1fK...",
"name": "曼小波",
"describe": "...",
"headPath": "https://.../xxx.webp",
"voicePath": "https://.../xxx.wav",
"gender": "女声",
"ageGroup": "儿童",
"language": "普通话",
"createdNum": 105
}
],
"total": 411,
"page": 1,
"pageSize": 10
}
}
一下子全明白了:
code: 0表示成功,不需要登录;data.list就是音色数组,每项的voicePath就是播放用的音频地址,headPath是头像;data.total = 411、page/pageSize说明有分页,默认一页才 10 条。
对比一下
/voice/list,它返回{"code":5,"msg":"未登录或登录过期"}—— 那是「我的音色」,需要登录,和我们无关,跳过。
4. 搞定分页
一页 10 条、共 411 条,总不能翻 42 次。试试把 pageSize 调大:
GET /api/v1/role/common/list?page=1&pageSize=100 → 真的返回 100 条!
说明 pageSize 能自定义。那策略就清晰了:循环翻页,直到拿满 total 条为止。
到这里,数据源彻底搞定。你看,真正写代码之前,分析占了大半篇幅——这才是爬虫/数据采集的核心能力。
三、核心代码拆解
整个程序分两层:网络层(抓数据、下文件)和 GUI 层(界面交互)。先讲网络层,这部分和界面无关,拎出来单独就能用。
1. 抓一页
import json, urllib.request
API = "https://目标站点/api/v1/role/common/list"
HEADERS = {
"User-Agent": "Mozilla/5.0 ... Chrome/120.0 Safari/537.36",
"Referer": "https://目标站点/音色库页面",
"Accept": "application/json, text/plain, */*",
}
deffetch_page(page, page_size=100):
url = f"{API}?page={page}&pageSize={page_size}"
req = urllib.request.Request(url, headers=HEADERS)
with urllib.request.urlopen(req, timeout=60) as resp:
obj = json.loads(resp.read().decode("utf-8", "replace"))
data = obj.get("data", {}) or {}
return data.get("list", []) or [], int(data.get("total", 0) or0)
几个小细节,都是实战踩出来的:
**一定要带 User-Agent和Referer**。很多站点对"没有浏览器标识"的请求直接返回 403。前面我们试过,不带 UA 就被拒,带上就 200。decode("utf-8", "replace"):遇到个别非法字节不报错,用占位符替代,程序更健壮。用 .get(..., default)层层取值,接口偶尔少字段也不会KeyError崩掉。
2. 翻页抓全部
deffetch_all(progress_cb=None):
first, total = fetch_page(1)
items = list(first)
if progress_cb:
progress_cb(len(items), total)
page = 2
while len(items) < total: # 没拿满就继续翻
batch, total = fetch_page(page)
ifnot batch: # 防御:返回空就停,避免死循环
break
items.extend(batch)
if progress_cb:
progress_cb(len(items), total)
page += 1
return items
这里的 progress_cb 是个回调函数:抓的是网络层,但进度要显示在界面上。网络层不直接碰界面,而是"抓到多少就喊一声",由界面决定怎么展示。这种解耦写法,让这套下载逻辑脱离 GUI 也能单独在命令行里用。
3. 下载单个文件(断点友好)
import os
from urllib.parse import urlparse, unquote
defdownload_file(url, dst):
if os.path.exists(dst) and os.path.getsize(dst) > 0:
return"skip"# 已存在,跳过,支持中断后续跑
tmp = dst + ".part"# 先写临时文件
try:
req = urllib.request.Request(url, headers=HEADERS)
with urllib.request.urlopen(req, timeout=60) as resp, open(tmp, "wb") as f:
whileTrue:
chunk = resp.read(1024 * 64) # 分块读,省内存
ifnot chunk:
break
f.write(chunk)
os.replace(tmp, dst) # 下完整了再改名,保证文件完整
return"ok"
except Exception as e:
if os.path.exists(tmp):
os.remove(tmp) # 失败清理半截文件
return"fail"
三个值得学的健壮性技巧:
已存在就跳过:程序中断后再跑,不会重复下,天然支持"续传任务"。 先写 .part临时文件,下完再os.replace改名:避免下到一半断网,留下一个"看起来下好了其实不完整"的坏文件。分块读写:音频可能几 MB,一次性读进内存不优雅, read(64KB)循环写最稳。
4. 文件名清洗
接口里的音色名可能带 / : * ? 这种非法字符,直接当文件名会报错:
import re
defsafe_filename(name):
name = re.sub(r'[\\/:*?"<>|\r\n\t]+', "_", name).strip().strip(".")
return name[:60] or"voice"# 限长,兜底默认名
最终保存的文件名做成 音色名_原始文件名.wav,既好认又不冲突。
四、GUI 怎么搭:tkinter 够用了
很多同学一想到"图形界面"就去装 PyQt、wxPython。其实 Python 自带的 tkinter 做这种工具绰绰有余,零安装、跨平台。
1. 界面三段式
import tkinter as tk
from tkinter import ttk
classVoiceApp(tk.Tk):
def__init__(self):
super().__init__()
self.title("NiceVoice 音色库下载工具")
self.geometry("1040x620")
self._build_top_bar() # 顶部:抓取按钮、搜索框、目录选择
self._build_table() # 中间:Treeview 表格
self._build_bottom() # 底部:下载按钮、进度条、日志
把界面拆成"顶/中/底"三个 _build_xxx 方法,结构清爽,后面要加按钮知道往哪塞。
2. 用 Treeview 做表格
ttk.Treeview 是 tkinter 里现成的表格控件,设成 show="headings" 就是纯表格:
cols = ["name", "gender", "ageGroup", "language", "createdNum", "describe"]
self.tree = ttk.Treeview(frame, columns=cols, show="headings", selectmode="extended")
for key, title, width in COLUMNS:
self.tree.heading(key, text=title) # 列标题
self.tree.column(key, width=width) # 列宽
selectmode="extended" 让用户能按住 Ctrl/Shift 多选——这正是"批量下载选中"的基础。
3. 最关键的坑:别让界面"卡死"
这是新手做 GUI 最常翻车的地方。如果你在按钮的点击函数里直接写"抓 411 条 + 下载",整个窗口会假死,转圈点不动,因为下载把界面主线程占满了。
正确做法:耗时操作丢到子线程,子线程通过"队列"把进度/日志传回界面主线程更新。
import threading, queue
# 点"抓取":开一个后台线程,不阻塞界面
defon_fetch(self):
threading.Thread(target=self._fetch_worker, daemon=True).start()
def_fetch_worker(self):
items = fetch_all(progress_cb=lambda got, total:
self.post("status", text=f"抓取中 {got}/{total}"))
self.post("items", items=items) # 把结果放进队列
# 后台只往队列放消息,不直接碰界面控件
defpost(self, kind, **kw):
self.msg_queue.put((kind, kw))
# 界面每 100ms 来队列取一次消息,统一在主线程更新
def_drain_queue(self):
try:
whileTrue:
kind, kw = self.msg_queue.get_nowait()
getattr(self, f"_on_{kind}")(**kw) # 分发给对应处理函数
except queue.Empty:
pass
self.after(100, self._drain_queue) # 定时轮询自己
记住这条铁律:
tkinter 的控件只能在主线程里改。 子线程想更新界面,必须通过
queue把消息传回来,由主线程用after定时取出来处理。
这套"子线程干活 + 队列通信 + after 轮询"的模式,是 Python GUI 做耗时任务的标准解法,学会了能复用到任何类似工具。
五、跑起来
把完整代码存成 voice_gui.py,命令行运行:
python voice_gui.py
操作流程:
点「抓取音色列表」,稍等几秒,411 个音色加载进表格; 想找特定音色,在搜索框输关键字(名称/描述/语言/性别都能搜),实时过滤; 选中一个或多个(Ctrl/Shift 多选),点「下载选中」;双击某行也能直接下; 想全要,点「下载全部」;勾上「同时下载头像」会连 .webp头像一起下;默认存到 下载/,可改目录,「打开目录」直接弹出文件夹。
底部进度条 + 日志实时滚动,已下过的文件自动跳过,中断了重跑也不怕。
六、这个项目你能学到什么
别只当它是个下载器。回头看,这一路覆盖了几个很通用的 Python 技能:
urllib | |
这些拼在一起,就是一个完整的"数据采集 + 桌面工具"实战项目。把其中任何一块换个数据源,你就能做出下一个工具。
七、几句提醒
接口和字段是会变的。哪天网站改版、换了接口,按第二节的思路重新分析一遍即可——分析能力才是真正带得走的东西,具体地址只是一次性的。 再强调一次版权:下载来的音色只用于个人学习、合法授权的场景,别拿去商用或侵权。 想进阶?可以给它加:下载失败自动重试、多线程并发下载、把音色清单导出成 Excel、按语言/性别分文件夹保存……这些都是很好的练手方向。
🎯 今天从「看不到数据」一路做到「批量下载 + 图形界面」,核心不是代码多难,而是遇到问题一步步拆解的思路。 完整源码在
voice_gui.py,配套还有一个命令行版download.py(读 URL 清单批量下),都可以直接跑。 如果这篇对你有帮助,点个「赞」+「在看」,关注「Python学在坚持」。 学编程没有捷径,但每天进步一点点,坚持下来你会谢谢现在的自己。我们下期见~















































































