博客 / 开发者指南
如何从账号名获取 YouTube 频道 ID(以及反向查询)
创作者分享 YouTube 频道时用的是 @handle,但 YouTube Data API 中几乎所有东西都以频道 ID 为键。如果只存账号名,对方一改名你的记录就断了;如果只存 ID,又没法给用户显示他们认得的名字。本指南介绍如何用一次 API 调用把账号名解析为频道 ID,如何一次 50 个地从 ID 反查账号名,旧式 /user/ 和 /c/ 链接怎么处理,以及如何不超出默认配额。
账号名、频道 ID 和旧式名称
一个 YouTube 频道最多可以通过四种标识符访问,其中只有一种保证稳定。
| 标识符 | 示例 | 网址形式 | 说明 |
|---|---|---|---|
| 频道 ID | UC_x5XG1OV2P6uZZ5FSM9Ttw | youtube.com/channel/UC... | YouTube 用来识别频道的 ID。区分大小写。用它作为主键。 |
| 账号名(handle) | @GoogleDevelopers | youtube.com/@GoogleDevelopers | 唯一,不区分大小写,频道主可以在 YouTube Studio 中修改。 |
| 旧式用户名 | GoogleDevelopers | youtube.com/user/GoogleDevelopers | 来自旧的用户名体系。可以用 API 的 forUsername 筛选条件解析。 |
| 旧式自定义网址 | GoogleDevelopers | youtube.com/c/GoogleDevelopers | 旧的个性化网址,YouTube 表示仍然可用。没有任何 API 筛选条件能解析它。 |
把频道 ID 存为主键,把账号名作为需要定期刷新的显示值。本指南的其余部分讲的就是:无论用户给了你什么,如何得到那个 ID。
设置 API 密钥并了解配额
- Step 1创建 Google Cloud 项目
在 Google Cloud 控制台中创建一个项目,或选择已有项目。
- Step 2启用 YouTube Data API v3
打开“API 和服务”,在库中找到 YouTube Data API v3,并为该项目启用。
- Step 3创建 API 密钥
在“凭据”下创建一个 API 密钥。按账号名或 ID 调用
channels.list这类公开频道数据只需要密钥,不需要 OAuth。把密钥限制为只能用于 YouTube Data API。 - Step 4不要把密钥写进代码
把它放进环境变量,例如
YOUTUBE_API_KEY,在运行时读取。下面的示例就是这么做的。 - Step 5检查配额
在控制台中,该 API 的“配额”页面会显示你的每日额度和用量。
Google 文档中这几个数字最重要:
- 一次
channels.list调用花费 1 个单位,无论用哪种筛选条件。 - 项目默认获得每天 10,000 个单位,适用于除
search.list和videos.insert以外的所有接口;这两个接口现在有各自的限制,每天各 100 次调用。 - 每个请求至少花费 1 个单位,无效请求也算。
- 每日配额在太平洋时间午夜重置。
所以默认配额每天大约可以查 10,000 个账号名,而按 ID 查询则最多可以多 50 倍,因为一次调用可以按 ID 获取 50 个频道,同样只花 1 个单位。
用 forHandle 从账号名得到频道 ID
channels.list 只接受一个筛选条件。forHandle 筛选条件接收 YouTube 账号名,文档说明这个值前面可以加 @ 符号,所以 GoogleDevelopers 和 @GoogleDevelopers 都可以。只需要 ID 时就请求 part=id;响应更小,花费相同。
curl -s "https://www.googleapis.com/youtube/v3/channels?part=id&forHandle=@GoogleDevelopers&key=$YOUTUBE_API_KEY"下面的 Node 示例把请求封装在一个辅助函数 listChannels 中,本指南后面会反复用到它。没有匹配时它返回空列表,遇到真正的错误(例如密钥错误或配额用尽)时抛出异常。
const API = "https://www.googleapis.com/youtube/v3/channels";
async function listChannels(params) {
const key = process.env.YOUTUBE_API_KEY;
if (!key) throw new Error("Set YOUTUBE_API_KEY");
const url = new URL(API);
url.search = new URLSearchParams({ ...params, key }).toString();
const res = await fetch(url, { signal: AbortSignal.timeout(10_000) });
const data = await res.json();
if (!res.ok) {
const reason = data.error?.errors?.[0]?.reason;
if (reason === "channelNotFound") return [];
throw new Error(`YouTube API ${res.status} ${reason ?? ""}: ${data.error?.message}`);
}
return data.items ?? []; // no match: items is empty or missing
}
/** "@GoogleDevelopers" or "GoogleDevelopers" -> "UC_x5XG1OV2P6uZZ5FSM9Ttw" or null */
export async function channelIdFromHandle(handle) {
const items = await listChannels({ part: "id", forHandle: handle.trim() });
return items[0]?.id ?? null;
}
/** Legacy username from a youtube.com/user/<name> URL -> channel ID or null */
export async function channelIdFromUsername(username) {
const items = await listChannels({ part: "id", forUsername: username.trim() });
return items[0]?.id ?? null;
}
/** Any number of channel IDs -> Map(id -> { title, customUrl }) */
export async function channelsById(ids) {
const unique = [...new Set(ids)];
const found = new Map();
for (let i = 0; i < unique.length; i += 50) {
const batch = unique.slice(i, i + 50);
const items = await listChannels({ part: "snippet", id: batch.join(","), maxResults: "50" });
for (const ch of items) {
found.set(ch.id, { title: ch.snippet.title, customUrl: ch.snippet.customUrl ?? null });
}
}
return found; // IDs missing from the map were not found
}import os
import requests
API = "https://www.googleapis.com/youtube/v3/channels"
def list_channels(**params):
key = os.environ.get("YOUTUBE_API_KEY")
if not key:
raise RuntimeError("Set YOUTUBE_API_KEY")
response = requests.get(API, params={**params, "key": key}, timeout=10)
data = response.json()
if not response.ok:
errors = data.get("error", {}).get("errors") or [{}]
reason = errors[0].get("reason")
if reason == "channelNotFound":
return []
raise RuntimeError(f"YouTube API {response.status_code} {reason}: {data.get('error', {}).get('message')}")
return data.get("items", []) # no match: items is empty or missing
def channel_id_from_handle(handle):
"""'@GoogleDevelopers' or 'GoogleDevelopers' -> 'UC_x5XG1OV2P6uZZ5FSM9Ttw' or None"""
items = list_channels(part="id", forHandle=handle.strip())
return items[0]["id"] if items else None
def channel_id_from_username(username):
"""Legacy username from a youtube.com/user/<name> URL -> channel ID or None"""
items = list_channels(part="id", forUsername=username.strip())
return items[0]["id"] if items else None
def channels_by_id(ids):
"""Any number of channel IDs -> {id: {"title", "customUrl"}}; missing IDs were not found"""
unique = list(dict.fromkeys(ids))
found = {}
for i in range(0, len(unique), 50):
batch = unique[i:i + 50]
for ch in list_channels(part="snippet", id=",".join(batch), maxResults=50):
found[ch["id"]] = {
"title": ch["snippet"]["title"],
"customUrl": ch["snippet"].get("customUrl"),
}
return found按用户写的原样传入账号名,但要先解码网址中的百分号编码。账号名可以使用多种文字的字母,不限于 ASCII,URLSearchParams 和 requests 都会自动为你编码参数。
旧式用户名和 /c/ 自定义网址
创作者简介和表格里的旧链接,经常使用账号名出现之前的某种形式。
- `youtube.com/user/<name>`:用上面的
channelIdFromUsername把名字传给forUsername。它和其他channels.list调用一样花费 1 个单位。 - `youtube.com/c/<name>`:
channels.list没有针对自定义网址的筛选条件。很多频道选择了与旧自定义名称相同的账号名,所以用同样的字符串试试forHandle可能管用,但匹配上并不能证明是同一个频道。依赖它之前要用别的方式确认,例如核对标题,或读取页面的规范链接(见下文)。 - `search.list` 返回的是查询的排序结果,而不是精确查找,默认限制为每天 100 次调用。解析标识符时不要用它。
从频道 ID 得到账号名,一次 50 个
反向查询使用 id 筛选条件,它接收以逗号分隔的频道 ID 列表。请求 part=snippet 并读取 snippet.customUrl。上面的 channelsById 会对 ID 去重,每 50 个一批发送,并返回以 ID 为键的映射。没有出现在映射中的 ID 表示未找到、已删除,或对 API 不可见。
- 批量大小。文档把
id描述为逗号分隔的列表,并把maxResults上限定为 50。文档没有单独说明 ID 数量的上限,所以代码每次发送 50 个,并设置maxResults=50,确保默认值 5 永远不会截断一批结果。 - 顺序。不要假设结果会按你发送的顺序返回。按
id为响应建立索引。 - `customUrl` 不一定有。文档只把它描述为频道的自定义网址,2016 年的一条修订说明还指出并非所有频道都有。把它当作可为空的字段,如果开头有
@就去掉,并在依赖它之前先用几个你熟悉的频道核对其格式。 - 标题不是账号名。
snippet.title是显示名称。它不唯一,还可能包含空格和表情符号。
未找到、错误和缓存
查询没有匹配结果并不是错误。对于 forHandle 和 forUsername,响应只是没有任何条目,所以要检查 items 数组是否为空或缺失。channels.list 文档中列出的错误如下:
| 错误原因 | HTTP | 何时出现 | 怎么处理 |
|---|---|---|---|
200 响应中没有 items | 200 | 账号名或用户名不存在 | 返回 null;把未命中结果短期缓存 |
channelNotFound | 404 | id 参数中的某个频道找不到 | 视为未找到,上面的辅助函数就是这样做的 |
invalidCriteria | 400 | 请求中有不止一个筛选条件 | forHandle、forUsername 和 id 只能发一个 |
channelForbidden | 403 | 该频道不支持此请求,或请求未获得正确授权 | 记录日志并跳过该频道 |
quotaExceeded | 403 | 你的项目已用完每日配额 | 停止调用,在太平洋时间午夜后重试,或申请更多配额 |
badRequest(API key not valid) | 400 | 密钥缺失或错误 | 修正配置;不要重试 |
缓存结果可以节省配额、降低延迟,但要遵守 YouTube 关于存储数据的规定。YouTube API Services Developer Policies(开发者政策,第 III.E.4 节)规定,非授权 API 数据最多只能存储 30 个自然日,之后必须删除或刷新。以小写账号名为键、TTL 远低于 30 天的缓存,就能拿到大部分好处:
import { channelIdFromHandle } from "./youtube.mjs";
const TTL_MS = 7 * 24 * 60 * 60 * 1000; // refresh well inside YouTube's 30-day limit
const cache = new Map(); // lowercase handle -> { id, at }
export async function cachedChannelId(handle) {
const key = handle.trim().replace(/^@/, "").toLowerCase();
const hit = cache.get(key);
if (hit && Date.now() - hit.at < TTL_MS) return hit.id;
const id = await channelIdFromHandle(handle);
cache.set(key, { id, at: Date.now() }); // caches misses (null) too
return id;
}在生产环境中,把同样的逻辑放进 Redis 或带 expires_at 列的数据库表,并让未命中结果的 TTL 比命中结果更短,这样新创建的账号名能很快被识别。
无需 API 密钥的备用方案:读取频道页面
公开的频道页面会在 HTML 中包含频道 ID。目前请求 https://www.youtube.com/@GoogleDevelopers 返回的页面中,<link rel="canonical"> 指向 https://www.youtube.com/channel/UC_x5XG1OV2P6uZZ5FSM9Ttw,/c/ 和 /user/ 页面也是如此,这是不靠猜测就能解析 /c/ 名称的唯一方法。
// Unofficial fallback: reads the public channel page. Fragile, prefer the API.
export async function channelIdFromPage(handle) {
const url = `https://www.youtube.com/@${encodeURIComponent(handle.replace(/^@/, ""))}`;
const res = await fetch(url, {
headers: { "Accept-Language": "en-US" },
signal: AbortSignal.timeout(10_000),
});
if (!res.ok) return null;
const html = await res.text();
const match = html.match(/<link rel="canonical" href="https:\/\/www\.youtube\.com\/channel\/(UC[A-Za-z0-9_-]{22})"/);
return match?.[1] ?? null;
}import re
from urllib.parse import quote
import requests
CANONICAL = re.compile(r'<link rel="canonical" href="https://www\.youtube\.com/channel/(UC[A-Za-z0-9_-]{22})"')
def channel_id_from_page(handle):
"""Unofficial fallback: reads the public channel page. Fragile, prefer the API."""
url = "https://www.youtube.com/@" + quote(handle.lstrip("@"), safe="._-")
response = requests.get(url, headers={"Accept-Language": "en-US"}, timeout=10)
if not response.ok:
return None
match = CANONICAL.search(response.text)
return match.group(1) if match else None尽量少用,只在 API 无能为力时使用。它是非官方的,也很脆弱:
- 页面标记不是有文档的接口,随时可能变化。
- YouTube 可能会对自动化请求返回同意页面、频率限制或验证码,尤其是来自云服务器 IP 段的请求。在我们的测试中,从云服务器访问一个
youtu.be链接,最后停在了 Google 的验证码页面。 - 自动访问网站可能与 YouTube 的服务条款冲突。请阅读条款,并优先使用官方 API。
各种方法对比
| 方法 | 输入 | 成本 | 优点 | 缺点 |
|---|---|---|---|---|
channels.list 配合 forHandle | 账号名,带不带 @ 都行 | 每个账号名 1 个单位 | 官方、精确匹配,适用于任何账号名 | 每次调用只能查一个账号名 |
channels.list 配合 forUsername | 旧式 /user/ 名称 | 每个名称 1 个单位 | 官方、精确匹配 | 只适用于旧式用户名 |
channels.list 配合 id | 最多 50 个频道 ID | 每 50 个 ID 1 个单位 | 官方、可批量,返回标题和 customUrl | customUrl 可能缺失 |
search.list | 自由文本 | 默认单独限制为每天 100 次调用 | 能按名称找到频道 | 排序后的猜测,不精确;额度很小 |
| 频道页面的规范链接 | 任何频道网址,包括 /c/ | 不消耗 API 配额 | 无需密钥;能解析 /c/ 名称 | 非官方、脆弱,可能被拦截,可能与条款冲突 |
对大多数产品来说,合适的做法很简单:用 forHandle 解析账号名,存储频道 ID,用 id 筛选条件每 50 个一批刷新账号名,并把页面备用方案放在一个开关后面,留给少见的 /c/ 链接。如果你还需要创作者在其他平台上的账号,sociallinks.dev 是一个 API,接收一个 Instagram、TikTok、YouTube 或 X 主页链接,返回该创作者在其他平台上的账号,并为每个匹配给出置信度分数和证据。
常见问题
把账号名传给 forHandle 时需要带 @ 吗?
不需要。channels.list 文档说明 forHandle 的值前面可以加 @ 符号,并同时给出了 GoogleDevelopers 和 @GoogleDevelopers 两个示例。
一次账号名查询消耗多少配额?
1 个单位。每次 channels.list 调用花费 1 个单位,默认额度为每天 10,000 个单位,在太平洋时间午夜重置。无效请求也至少花费 1 个单位。
能在一次调用中查询多个账号名吗?
不能。forHandle 只接收一个账号名。只有 id 筛选条件接受逗号分隔的列表,所以反向查询(ID 到账号名)可以批量做,正向查询则要做好缓存。
频道 ID 会变吗?
频道 ID 是 YouTube 用来唯一识别频道的标识符。修改账号名或标题都不会改变它。这就是它适合当主键的原因。账号名、标题和自定义网址都可能变。
为什么 snippet.customUrl 返回为空?
并非每个频道都有自定义网址,YouTube 自己的修订历史也提到了这一点。把这个字段当作可选字段,缺失时退而显示 /channel/UC... 网址。
结果可以缓存多久?
YouTube 的开发者政策规定,存储的 API 数据最多保留 30 个自然日,之后必须删除或刷新。命中结果的 TTL 设为一周或更短,未命中结果更短,就能稳稳留在限制之内。