一个纯前端的静态页面:用 PKCE 在浏览器里登录 Spotify,显示你最近 4 周、6 个月或 1 年最常听的 20 首歌。没有后端,也不需要 client secret;token 只存在 sessionStorage 里,关掉标签页就清除。
先看清成品
node --test跑了 3 个测试,全部通过:RFC 7636 测试向量、verifier 的长度和字符集、缺少popularity等字段时不报错。用无头 Chrome 打开页面,拦截发往 accounts.spotify.com 的请求,检查到登录按钮生成的参数是response_type=code、code_challenge_method=S256、challenge 长 43 个字符、scope=user-top-read、回调地址是http://127.0.0.1:8080/。没有完成真实登录,所以歌曲列表的渲染只是按官方响应结构写的,没有用真实数据跑过。
注意:Spotify 订阅价格以官方页面为准,本文不列数字。官方开发者文档写明:开发模式(Development Mode)应用的所有者必须有有效的 Spotify Premium,每个应用最多 5 个授权用户,并且只开放部分接口,用于个人和非商业的学习项目。不要下载或录制音频,不要绕过 DRM 或地区限制,不要共享账号;只有你加入名单的用户才能登录你的应用。
先了解:2026 年的接口变化
官方迁移指南(February 2026 Web API Dev Mode Changes)里,对本系列有影响的几条:
| 变化 | 内容 |
|---|---|
| Premium | 开发模式应用的所有者必须有有效的 Premium,过期后应用停止工作 |
| 用户数 | 每个应用最多 5 个用户,需要在后台逐个加入名单 |
| 搜索 | limit 最大值从 50 改为 10,默认值从 20 改为 5 |
| 建歌单 | POST /users/{{user_id}}/playlists 改为 POST /me/playlists |
| 歌单曲目 | /playlists/{{id}}/tracks 改名为 /playlists/{{id}}/items,响应里的 tracks 字段改名为 items |
| 删除的字段 | 曲目的 popularity、用户的 email/country/product 等 |
| 更早(2024-11-27) | 新应用不能再用 Recommendations、Audio Features、Related Artists 等接口 |
网上很多旧教程用的还是上面这些已经废弃的接口,照抄会报错。
做完之后你会得到什么?
| 成果 | 说明 |
|---|---|
pkce.js |
浏览器和 Node 都能用的 PKCE 工具,加上一个容错的字段映射函数 |
index.html |
登录、时间范围切换、列表、退出 |
| 安全处理 | 校验 state;拿到 code 后从地址栏清掉;用 textContent 输出,不拼 HTML |
e2e.py |
不连 Spotify,只检查授权地址的参数 |
需要准备哪些环境?
| 项目 | 要求 | 说明 |
|---|---|---|
| Spotify 应用 | Redirect URI 填 http://127.0.0.1:8080/ |
和页面里的 REDIRECT 完全一致 |
| 静态服务器 | python3 -m http.server 8080 --bind 127.0.0.1 |
页面用了 ES module,不能用 file:// 打开 |
| Node.js | 18 以上(本机 v22) | 跑测试 |
文件结构
31-spotify-B-src/
├── package.json
├── pkce.js
├── pkce.test.mjs
├── index.html
└── e2e.py
第 1 步:PKCE 工具
pkce.js:
// 浏览器和 Node 18+ 通用:用 Web Crypto 生成 PKCE verifier / challenge
const CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
export function randomString(len = 64) {
const v = crypto.getRandomValues(new Uint8Array(len));
return Array.from(v, (x) => CHARS[x % CHARS.length]).join("");
}
export function base64url(buf) {
let s = ""; for (const b of new Uint8Array(buf)) s += String.fromCharCode(b);
return btoa(s).replace(/=+$/, "").replace(/\+/g, "-").replace(/\//g, "_");
}
export async function challengeFor(verifier) {
return base64url(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier)));
}
// 2026 年 2 月后开发模式下 popularity 等字段被移除:只取一定存在的字段,缺失时给默认值
export function toRow(t, i) {
return {
rank: i + 1,
name: t?.name ?? "(未知)",
artists: (t?.artists ?? []).map((a) => a.name).join(", "),
album: t?.album?.name ?? "",
image: t?.album?.images?.at(-1)?.url ?? "",
url: t?.external_urls?.spotify ?? "",
popularity: typeof t?.popularity === "number" ? t.popularity : null,
};
}
toRow 对每个字段都用 ?. 和默认值兜底。迁移指南写明 popularity 已从开发模式的响应里删除,旧代码里 t.popularity.toFixed() 这种写法会直接报错。
第 2 步:测试
pkce.test.mjs:
import test from "node:test";
import assert from "node:assert";
import { randomString, challengeFor, toRow } from "./pkce.js";
test("RFC 7636 附录 B 测试向量", async () => {
assert.strictEqual(await challengeFor("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"),
"E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM");
});
test("verifier 长度与字符集", () => {
const v = randomString(64);
assert.strictEqual(v.length, 64); assert.match(v, /^[A-Za-z0-9]+$/);
});
test("缺 popularity 等字段时不报错", () => {
const r = toRow({ name: "晴天", artists: [{ name: "周杰伦" }], album: { name: "叶惠美", images: [{ url: "big" }, { url: "small" }] } }, 0);
assert.deepStrictEqual([r.rank, r.artists, r.image, r.popularity], [1, "周杰伦", "small", null]);
assert.strictEqual(toRow(undefined, 1).name, "(未知)");
});
node --test # pass 3, fail 0
第 3 步:页面
index.html:
<!doctype html>
<html lang="zh-CN">
<head><meta charset="utf-8"><title>我的 Spotify 常听歌曲</title>
<style>body{font-family:sans-serif;max-width:720px;margin:24px auto}li{display:flex;gap:12px;align-items:center;margin:6px 0}img{width:48px;height:48px}</style></head>
<body>
<h1>我最近常听的歌</h1>
<label>时间范围
<select id="range"><option value="short_term">约 4 周</option><option value="medium_term" selected>约 6 个月</option><option value="long_term">约 1 年</option></select>
</label>
<button id="login">用 Spotify 登录</button> <button id="logout" hidden>退出</button>
<p id="msg"></p>
<ol id="list"></ol>
<script type="module">
import { randomString, challengeFor, toRow } from "./pkce.js";
const CLIENT_ID = "填你的 Client ID";
const REDIRECT = "http://127.0.0.1:8080/"; // 必须与应用后台登记的完全一致
const SCOPE = "user-top-read";
const $ = (id) => document.getElementById(id);
async function login() {
const verifier = randomString(64), state = randomString(16);
sessionStorage.setItem("pkce", JSON.stringify({ verifier, state }));
const u = new URL("https://accounts.spotify.com/authorize");
u.search = new URLSearchParams({ client_id: CLIENT_ID, response_type: "code", redirect_uri: REDIRECT,
scope: SCOPE, code_challenge_method: "S256", code_challenge: await challengeFor(verifier), state });
location.href = u;
}
async function handleCallback() {
const q = new URLSearchParams(location.search);
if (!q.has("code") && !q.has("error")) return;
const saved = JSON.parse(sessionStorage.getItem("pkce") || "{}");
history.replaceState(null, "", REDIRECT); // 把 code 从地址栏去掉
if (q.get("state") !== saved.state) throw new Error("state 不匹配");
if (q.has("error")) throw new Error("授权失败:" + q.get("error"));
const r = await fetch("https://accounts.spotify.com/api/token", {
method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({ grant_type: "authorization_code", code: q.get("code"), redirect_uri: REDIRECT,
client_id: CLIENT_ID, code_verifier: saved.verifier }) });
if (!r.ok) throw new Error("换取 token 失败:" + r.status);
const t = await r.json();
sessionStorage.setItem("token", JSON.stringify({ access: t.access_token, exp: Date.now() + t.expires_in * 1000 }));
sessionStorage.removeItem("pkce");
}
function token() {
const t = JSON.parse(sessionStorage.getItem("token") || "null");
return t && t.exp > Date.now() + 30000 ? t.access : null;
}
async function load() {
const tk = token(); $("login").hidden = !!tk; $("logout").hidden = !tk;
if (!tk) { $("msg").textContent = "请先登录"; return; }
const r = await fetch(`https://api.spotify.com/v1/me/top/tracks?limit=20&time_range=${$("range").value}`,
{ headers: { Authorization: "Bearer " + tk } });
if (r.status === 401) { sessionStorage.removeItem("token"); return load(); }
if (r.status === 403) { $("msg").textContent = "403:账号没有加入应用的用户名单,或应用所有者的 Premium 已失效"; return; }
const data = await r.json();
const rows = (data.items ?? []).map(toRow);
$("msg").textContent = rows.length ? "" : "这个时间范围内没有数据";
$("list").replaceChildren(...rows.map((x) => {
const li = document.createElement("li");
const img = document.createElement("img"); img.src = x.image; img.alt = "";
const a = document.createElement("a"); a.href = x.url; a.target = "_blank"; a.rel = "noopener";
a.textContent = `${x.name} — ${x.artists}`; // textContent,不拼 HTML
li.append(img, a); return li;
}));
}
$("login").onclick = login;
$("logout").onclick = () => { sessionStorage.clear(); load(); };
$("range").onchange = load;
handleCallback().then(load).catch((e) => ($("msg").textContent = e.message));
</script>
</body>
</html>
要点:
GET /me/top/tracks的time_range取值:short_term(约 4 周)、medium_term(约 6 个月,默认)、long_term(约 1 年);limit最大 50(见参考文档)。- verifier 和 state 放在 sessionStorage 里,换完 token 就删掉。
- 返回 403 时提示“账号不在名单里,或所有者的 Premium 已失效”,这两种是开发模式下最常见的原因。
第 4 步:运行(真实登录本机未执行)
cd 31-spotify-B-src
python3 -m http.server 8080 --bind 127.0.0.1
# 浏览器打开 http://127.0.0.1:8080/ ,点“用 Spotify 登录”
检查结果:同意授权后回到页面,地址栏里没有 ?code=,列表显示 20 首歌;切换时间范围后列表跟着变化。
第 5 步:检查授权参数
e2e.py:
"""不连 Spotify:拦截授权请求,检查登录按钮生成的地址参数。"""
import asyncio, functools, http.server, threading, urllib.parse
from playwright.async_api import async_playwright
H = functools.partial(http.server.SimpleHTTPRequestHandler, directory=__file__.rsplit("/", 1)[0])
async def main():
srv = http.server.ThreadingHTTPServer(("127.0.0.1", 0), H)
threading.Thread(target=srv.serve_forever, daemon=True).start()
async with async_playwright() as p:
b = await p.chromium.launch(channel="chrome"); pg = await b.new_page()
await pg.route("https://accounts.spotify.com/**", lambda r: r.fulfill(body="stub"))
await pg.goto(f"http://127.0.0.1:{srv.server_address[1]}/index.html"); print("msg:", await pg.inner_text("#msg"))
async with pg.expect_request("https://accounts.spotify.com/**") as req: await pg.click("#login")
q = urllib.parse.parse_qs(urllib.parse.urlparse((await req.value).url).query)
print({k: (len(v[0]) if k in ("code_challenge", "state") else v[0]) for k, v in q.items() if k != "client_id"})
await b.close()
srv.shutdown()
asyncio.run(main())
本机输出:
msg: 请先登录
{'response_type': 'code', 'redirect_uri': 'http://127.0.0.1:8080/', 'scope': 'user-top-read', 'code_challenge_method': 'S256', 'code_challenge': 43, 'state': 16}
调试示例
本地服务器起不来:Address already in use
第一次跑端到端测试时,直接绑定 8080 端口,报了 OSError: [Errno 98] Address already in use。ss -ltnp 显示本机已经有别的程序占用了 8080 和 8091。测试改成绑定 0 号端口(让系统随机分配一个空闲端口)。真实使用时,如果 8080 被占用,要同时修改页面里的 REDIRECT 和 Spotify 后台登记的 Redirect URI。只改其中一个,会报回调地址不匹配。
常见问题
| 现象 | 原因 | 处理 |
|---|---|---|
| 回到页面后提示“state 不匹配” | 在另一个标签页里发起了登录 | 在同一个标签页里重新登录 |
| 换 token 返回 400 | verifier 丢了,或者 redirect_uri 不一致 | 不要清 sessionStorage,确认两处的地址相同 |
| 列表为空 | 这个账号在该时间范围内的收听数据不够 | 换成 long_term 试试 |
| 403 | 账号没有加入名单 | 在 Dashboard 的 User Management 里添加 |
模板规则
- 前端只用 PKCE,绝不把 client secret 写进页面。
- token 存在 sessionStorage 里,并且只申请需要的 scope。
- 处理 API 响应时,每个字段都可能缺失。
- 回调地址在代码和后台两处要完全一致。
项目资源
纯前端 PKCE 可以不依赖任何服务器做个人工具。前提是只给自己和名单里的少数几个朋友用,这也正是开发模式的定位。