sociallinks.dev

博客 / 开发者指南

如何从账号名获取 YouTube 频道 ID(以及反向查询)

创作者分享 YouTube 频道时用的是 @handle,但 YouTube Data API 中几乎所有东西都以频道 ID 为键。如果只存账号名,对方一改名你的记录就断了;如果只存 ID,又没法给用户显示他们认得的名字。本指南介绍如何用一次 API 调用把账号名解析为频道 ID,如何一次 50 个地从 ID 反查账号名,旧式 /user/ 和 /c/ 链接怎么处理,以及如何不超出默认配额。

账号名、频道 ID 和旧式名称

一个 YouTube 频道最多可以通过四种标识符访问,其中只有一种保证稳定。

标识符示例网址形式说明
频道 IDUC_x5XG1OV2P6uZZ5FSM9Ttwyoutube.com/channel/UC...YouTube 用来识别频道的 ID。区分大小写。用它作为主键。
账号名(handle)@GoogleDevelopersyoutube.com/@GoogleDevelopers唯一,不区分大小写,频道主可以在 YouTube Studio 中修改。
旧式用户名GoogleDevelopersyoutube.com/user/GoogleDevelopers来自旧的用户名体系。可以用 API 的 forUsername 筛选条件解析。
旧式自定义网址GoogleDevelopersyoutube.com/c/GoogleDevelopers旧的个性化网址,YouTube 表示仍然可用。没有任何 API 筛选条件能解析它。

把频道 ID 存为主键,把账号名作为需要定期刷新的显示值。本指南的其余部分讲的就是:无论用户给了你什么,如何得到那个 ID。

设置 API 密钥并了解配额

  1. Step 1创建 Google Cloud 项目

    在 Google Cloud 控制台中创建一个项目,或选择已有项目。

  2. Step 2启用 YouTube Data API v3

    打开“API 和服务”,在库中找到 YouTube Data API v3,并为该项目启用。

  3. Step 3创建 API 密钥

    在“凭据”下创建一个 API 密钥。按账号名或 ID 调用 channels.list 这类公开频道数据只需要密钥,不需要 OAuth。把密钥限制为只能用于 YouTube Data API。

  4. Step 4不要把密钥写进代码

    把它放进环境变量,例如 YOUTUBE_API_KEY,在运行时读取。下面的示例就是这么做的。

  5. Step 5检查配额

    在控制台中,该 API 的“配额”页面会显示你的每日额度和用量。

Google 文档中这几个数字最重要:

所以默认配额每天大约可以查 10,000 个账号名,而按 ID 查询则最多可以多 50 倍,因为一次调用可以按 ID 获取 50 个频道,同样只花 1 个单位。

用 forHandle 从账号名得到频道 ID

channels.list 只接受一个筛选条件。forHandle 筛选条件接收 YouTube 账号名,文档说明这个值前面可以加 @ 符号,所以 GoogleDevelopers 和 @GoogleDevelopers 都可以。只需要 ID 时就请求 part=id;响应更小,花费相同。

cURL

curl -s "https://www.googleapis.com/youtube/v3/channels?part=id&forHandle=@GoogleDevelopers&key=$YOUTUBE_API_KEY"

下面的 Node 示例把请求封装在一个辅助函数 listChannels 中,本指南后面会反复用到它。没有匹配时它返回空列表,遇到真正的错误(例如密钥错误或配额用尽)时抛出异常。

Node.js

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
}

Python

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/ 自定义网址

创作者简介和表格里的旧链接,经常使用账号名出现之前的某种形式。

从频道 ID 得到账号名,一次 50 个

反向查询使用 id 筛选条件,它接收以逗号分隔的频道 ID 列表。请求 part=snippet 并读取 snippet.customUrl。上面的 channelsById 会对 ID 去重,每 50 个一批发送,并返回以 ID 为键的映射。没有出现在映射中的 ID 表示未找到、已删除,或对 API 不可见。

未找到、错误和缓存

查询没有匹配结果并不是错误。对于 forHandle 和 forUsername,响应只是没有任何条目,所以要检查 items 数组是否为空或缺失。channels.list 文档中列出的错误如下:

错误原因HTTP何时出现怎么处理
200 响应中没有 items200账号名或用户名不存在返回 null;把未命中结果短期缓存
channelNotFound404id 参数中的某个频道找不到视为未找到,上面的辅助函数就是这样做的
invalidCriteria400请求中有不止一个筛选条件forHandle、forUsername 和 id 只能发一个
channelForbidden403该频道不支持此请求,或请求未获得正确授权记录日志并跳过该频道
quotaExceeded403你的项目已用完每日配额停止调用,在太平洋时间午夜后重试,或申请更多配额
badRequest(API key not valid)400密钥缺失或错误修正配置;不要重试

缓存结果可以节省配额、降低延迟,但要遵守 YouTube 关于存储数据的规定。YouTube API Services Developer Policies(开发者政策,第 III.E.4 节)规定,非授权 API 数据最多只能存储 30 个自然日,之后必须删除或刷新。以小写账号名为键、TTL 远低于 30 天的缓存,就能拿到大部分好处:

Node.js 缓存

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/ 名称的唯一方法。

Node.js

// 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;
}

Python

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 无能为力时使用。它是非官方的,也很脆弱:

各种方法对比

方法输入成本优点缺点
channels.list 配合 forHandle账号名,带不带 @ 都行每个账号名 1 个单位官方、精确匹配,适用于任何账号名每次调用只能查一个账号名
channels.list 配合 forUsername旧式 /user/ 名称每个名称 1 个单位官方、精确匹配只适用于旧式用户名
channels.list 配合 id最多 50 个频道 ID每 50 个 ID 1 个单位官方、可批量,返回标题和 customUrlcustomUrl 可能缺失
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 设为一周或更短,未命中结果更短,就能稳稳留在限制之内。

查找创作者的账号