不需要登录、不需要 token,做一个“我的推荐”页面:Python 脚本用 Spotify oEmbed 把一组分享链接转成标题、封面和 URI;页面用官方的 iFrame API 嵌入播放器,点击列表就能切换内容,并显示播放进度。
先看清成品
本机真实调用了 oEmbed:两个链接分别返回 “Never Gonna Give You Up” 和 “Whenever You Need Somebody”;一个不存在的 ID 返回 404,被跳过;一个非 Spotify 链接被拒绝;一个
intl-ja形式的重复链接被识别出来(见调试示例)。用无头 Chrome 打开页面,播放器触发了ready事件,点击第二项后 iframe 变成了embed/album/6eUW0wxWtzkFdaEFsTJto6。pytest 2 个测试通过。没有测试实际播放:未登录时 Embed 一般只能播放预览片段,具体以官方说明为准。
注意:Spotify 订阅价格以官方页面为准,本文不列数字。官方开发者文档写明:开发模式(Development Mode)应用的所有者必须有有效的 Spotify Premium,每个应用最多 5 个授权用户,并且只开放部分接口,用于个人和非商业的学习项目。不要下载或录制音频,不要绕过 DRM 或地区限制,不要共享账号;只有你加入名单的用户才能登录你的应用。
做完之后你会得到什么?
| 成果 | 说明 |
|---|---|
build_list.py |
链接 → URI,去掉 ?si= 和 intl-xx,用 oEmbed 取标题和封面,去重 |
index.html |
iFrame API 播放器,加上可以点击的列表和进度显示 |
e2e.py |
检查 ready 事件和切换功能 |
需要准备哪些环境?
| 项目 | 要求 | 说明 |
|---|---|---|
| Python | 3.8 以上 | 只用标准库 |
| 静态服务器 | 任意 | 页面需要用 fetch 读取 items.json |
文件结构
31-spotify-C-src/
├── build_list.py
├── test_build.py
├── links.txt
├── items.json # 本机生成的结果
├── index.html
└── e2e.py
第 1 步:生成列表
build_list.py:
"""用 Spotify oEmbed(无需 token)把一组链接转成 items.json:标题、封面、URI。
用法:python build_list.py links.txt items.json"""
import json, re, sys, urllib.parse, urllib.request, urllib.error
PAT = re.compile(r"^https://open\.spotify\.com/(?:intl-[a-z-]+/)?(track|album|playlist|episode|show)/([A-Za-z0-9]{22})")
def to_uri(link):
m = PAT.match(link.strip())
if not m: raise ValueError(f"不是 Spotify 链接:{link}")
return f"spotify:{m.group(1)}:{m.group(2)}", f"https://open.spotify.com/{m.group(1)}/{m.group(2)}"
def oembed(url, opener=urllib.request.urlopen):
q = "https://open.spotify.com/oembed?" + urllib.parse.urlencode({"url": url})
with opener(urllib.request.Request(q, headers={"User-Agent": "embed-list/1.0"}), timeout=15) as r:
return json.load(r)
def build(links, fetch=oembed):
items, errors, seen = [], [], set()
for link in links:
try:
uri, clean = to_uri(link)
if uri in seen: errors.append(f"{link.strip()} → 重复"); continue
seen.add(uri)
d = fetch(clean)
items.append({"uri": uri, "title": d.get("title", uri), "thumb": d.get("thumbnail_url", "")})
except (ValueError, urllib.error.HTTPError) as e:
errors.append(f"{link.strip()} → {e}")
return items, errors
if __name__ == "__main__":
links = [l for l in open(sys.argv[1], encoding="utf-8") if l.strip() and not l.startswith("#")]
items, errors = build(links)
json.dump(items, open(sys.argv[2], "w", encoding="utf-8"), ensure_ascii=False, indent=1)
print(f"{len(items)} 条写入 {sys.argv[2]}")
for e in errors: print("SKIP", e)
links.txt:
# 每行一个 open.spotify.com 链接(分享菜单 → 复制链接)
https://open.spotify.com/track/4cOdK2wGLETKBW3PvgPWqT?si=abc123
https://open.spotify.com/intl-ja/track/4cOdK2wGLETKBW3PvgPWqT
https://open.spotify.com/album/6eUW0wxWtzkFdaEFsTJto6
https://open.spotify.com/track/0000000000000000000000
https://music.example.com/song/1
$ python build_list.py links.txt items.json
2 条写入 items.json
SKIP https://open.spotify.com/intl-ja/track/4cOdK2wGLETKBW3PvgPWqT → 重复
SKIP https://open.spotify.com/track/0000000000000000000000 → HTTP Error 404: Not Found
SKIP https://music.example.com/song/1 → 不是 Spotify 链接:https://music.example.com/song/1
第 2 步:页面
index.html:
<!doctype html>
<html lang="zh-CN">
<head><meta charset="utf-8"><title>我的推荐歌单</title>
<style>body{font-family:sans-serif;max-width:760px;margin:24px auto}ul{list-style:none;padding:0;display:flex;gap:12px;flex-wrap:wrap}
button.item{display:flex;gap:8px;align-items:center;border:1px solid #ddd;background:#fff;padding:6px;cursor:pointer}button.item img{width:40px;height:40px}
button.item[aria-current=true]{border-color:#1db954}</style></head>
<body>
<h1>我的推荐</h1>
<div id="embed"></div>
<p id="status">加载中…</p>
<ul id="list"></ul>
<script>
const fmt = (ms) => { const s = Math.floor(ms / 1000); return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`; };
window.onSpotifyIframeApiReady = async (IFrameAPI) => {
const items = await (await fetch("items.json")).json();
if (!items.length) { document.getElementById("status").textContent = "items.json 为空"; return; }
IFrameAPI.createController(document.getElementById("embed"), { uri: items[0].uri, width: "100%", height: 352 }, (ctrl) => {
window.__ctrl = ctrl;
ctrl.addListener("ready", () => { window.__ready = true; document.getElementById("status").textContent = "播放器已就绪"; });
ctrl.addListener("playback_update", (e) => {
const { position, duration, isPaused } = e.data;
document.getElementById("status").textContent = `${isPaused ? "暂停" : "播放中"} ${fmt(position)} / ${fmt(duration)}`;
});
const ul = document.getElementById("list");
items.forEach((it, i) => {
const b = document.createElement("button"); b.className = "item"; b.setAttribute("aria-current", i === 0);
const img = document.createElement("img"); img.src = it.thumb; img.alt = "";
const span = document.createElement("span"); span.textContent = it.title;
b.append(img, span);
b.onclick = () => { ctrl.loadUri(it.uri); ul.querySelectorAll("button").forEach((x) => x.setAttribute("aria-current", x === b)); };
const li = document.createElement("li"); li.append(b); ul.append(li);
});
});
};
</script>
<script src="https://open.spotify.com/embed/iframe-api/v1" async></script>
</body>
</html>
要点(来自 iFrame API 参考文档):
- 先定义
window.onSpotifyIframeApiReady,再加载https://open.spotify.com/embed/iframe-api/v1。 IFrameAPI.createController(element, options, callback)会把 element 替换成播放器 iframe。controller.loadUri(uri)用来切换内容;事件有ready、playback_started、playback_update(e.data里有 position、duration、isPaused)。
第 3 步:测试
python -m pytest -q # 2 passed
pip install playwright && python e2e.py
本机输出:
status: 播放器已就绪
iframe: https://open.spotify.com/embed/track/4cOdK2wGLETKBW3PvgPWqT
after click: https://open.spotify.com/embed/album/6eUW0wxWtzkFdaEFsTJto6
调试示例
同一首歌出现了两次
第一版脚本没有去重。links.txt 里有一个带 ?si= 的链接和一个 intl-ja 的链接,看起来不同,其实是同一首歌。运行后 items.json 里有两条一模一样的 “Never Gonna Give You Up”。正则先把两种链接都还原成 spotify:track:4cOdK2wGLETKBW3PvgPWqT,再按 URI 去重,第二条就被标记为“重复”跳过了。测试里也加了这种情况。
常见问题
| 现象 | 原因 | 处理 |
|---|---|---|
| 页面一直显示“加载中…” | onSpotifyIframeApiReady 定义得太晚 |
先定义回调,再加载脚本(带 async) |
items.json 读不到 |
用 file:// 打开了页面 |
用静态服务器打开 |
| 只能播放片段 | 访客没有登录 Spotify | 正常现象,引导访客去 Spotify 收听完整版 |
| oEmbed 返回 404 | 链接错了,或者内容已下架 | 重新复制分享链接 |
模板规则
- 嵌入只用官方的 Embed 和 iFrame API,不要下载或转存音频。
- 链接先规范成 URI,再去重。
- 外部数据用
textContent输出。 - 出错的链接跳过并说明原因,不要让整个列表生成失败。
项目资源
嵌入播放器是最轻量的接入方式,不需要应用审核,也不受开发模式名单的限制。用 iFrame API 控制切换和显示进度,一个静态页面就够用了。