效率工具

一个纯前端的静态页面:用 PKCE 在浏览器里登录 Spotify,显示你最近 4 周、6 个月或 1 年最常听的 20 首歌

一个纯前端的静态页面:用 PKCE 在浏览器里登录 Spotify,显示你最近 4 周、6 个月或 1 年最常听的 20 首歌。没有后端,也不需要 client secret;token 只存在 sessionStorage 里,关掉标签页就清除。

BINBHO · 图文教程

一个纯前端的静态页面:用 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 授权流程 https://developer.spotify.com/documentation/web-api/tutorials/code-pkce-flow
Get User's Top Items https://developer.spotify.com/documentation/web-api/reference/get-users-top-artists-and-tracks
Redirect URI 规则 https://developer.spotify.com/documentation/web-api/concepts/redirect_uri
2026 年 2 月开发模式变更迁移指南 https://developer.spotify.com/documentation/web-api/tutorials/february-2026-migration-guide
配额模式 https://developer.spotify.com/documentation/web-api/concepts/quota-modes
开发者条款 https://developer.spotify.com/terms

纯前端 PKCE 可以不依赖任何服务器做个人工具。前提是只给自己和名单里的少数几个朋友用,这也正是开发模式的定位。