sociallinks.dev

博客 / 开发者指南

如何解析并规范化社交媒体主页链接(Instagram、TikTok、YouTube、X)

创作者粘贴的主页链接千奇百怪:带不带 www 的,从手机应用里复制、带追踪参数的,短链接,旧的 twitter.com 网址,甚至是帖子链接而不是主页。如果你的产品照原样存储这些字符串,同一位创作者就会出现三次,数据关联也会悄悄漏掉。本指南教你把任何 Instagram、TikTok、YouTube 或 X 主页链接转换为平台、账号名或频道 ID,以及一个规范链接,并提供经过测试的 JavaScript 和 Python 代码。

为什么要规范化主页链接

主页链接是一种标识符,而标识符只有写法统一时才管用。https://www.instagram.com/natgeo/、instagram.com/NatGeo?igsh=MWQ1ZGUxMzBkMA== 和 https://m.instagram.com/natgeo 都指向同一个账号,但作为字符串,它们是三个不同的值。

目标是一个小而严格的函数:接收用户实际粘贴的内容,对真正的主页链接返回规范化结果,对其他一切返回 null。

你会遇到的网址形式

下面的网址形式已于 2026 年 10 月对照线上网站核实。平台会在没有通知的情况下改变路由,所以把它当作一份快照,并保持你的测试用例是最新的。

Instagram

TikTok

YouTube

X(原 Twitter)

各平台的账号名字符规则

校验账号名可以拦住拼写错误和大多数非主页路径。平台公布了规则的,就用平台自己的规则;没有公布的,就把检查放宽一些,免得误拒真实账号。

平台允许的字符长度大小写来源
Instagram字母、数字、句点、下划线普遍说法是 30;Instagram 帮助页面中未找到当作不区分大小写;以小写存储Instagram 帮助中心提到了句点、数字和下划线
TikTok字母、数字、下划线、句点;不能以句点结尾2 到 24网址中不区分大小写(@TikTok 解析为 tiktok)TikTok 支持中心,更改用户名
YouTube受支持语言的字母或数字,外加下划线、连字符、句点和拉丁中间点(不能在首尾)大多数文字为 3 到 30;汉字、韩文和其他部分文字限制更短不区分大小写YouTube 帮助,账号名准则
X字母、数字和下划线最多 15;新用户名必须多于 4 个字符,但存在较早的短账号名不区分大小写(x.com/JACK 会规范为 x.com/jack)X 帮助中心,用户名规则

规范化算法

  1. Step 1去空格并补上协议

    去掉空白字符和多余的尖括号。如果输入没有协议,就在前面加上 https://,这样 instagram.com/natgeo 也能被解析。

  2. Step 2用真正的网址解析器解析

    在 JavaScript 中用 new URL(),在 Python 中用 urllib.parse.urlsplit,绝不要对整个字符串套正则表达式。解析器能处理端口、用户信息和编码字符,还能防止 https://instagram.com@evil.com/natgeo 冒充 Instagram 蒙混过关。

  3. Step 3规范化主机名

    把它转成小写,去掉开头的一个 www.、m. 或 mobile.。然后在精确的主机白名单中查找。绝不要用 includes("instagram.com"),它也会匹配 instagram.com.evil.com。

  4. Step 4去掉查询字符串和片段

    igsh、s、t 和 lang 这类追踪参数永远不会改变网址指向的主页。

  5. Step 5拆分并解码路径

    按 / 拆分,丢掉空段(这样就去掉了结尾斜杠),并对每一段做百分号解码。编码格式错误的视为无效输入。

  6. Step 6按平台分流

    套用每个平台的形式:Instagram 和 X 是一段,TikTok 是带 @ 的一段,YouTube 是 @handle、channel/UC...、user/name 或 c/name。拒绝保留词。

  7. Step 7校验账号名并转成小写

    按平台的字符规则检查账号名,然后转成小写。不要把 YouTube 频道 ID 转成小写:它们区分大小写。

  8. Step 8生成规范链接

    在一个固定的主机名上用各部分重建网址,这样所有等价的输入都会得到完全相同的字符串。

  9. Step 9单独处理短链接

    识别 TikTok 短链接,用 HTTP 请求跟随重定向,再把最终网址交回解析器。把这一步放在纯函数之外,让解析保持快速、可测试。

JavaScript 和 Python 代码

对于相同的输入,两个函数返回相同的对象:账号名网址返回 { platform, handle, canonicalUrl },YouTube 频道 ID 返回 { platform, channelId, canonicalUrl },两种旧式 YouTube 形式分别返回 username 或 customName,任何不是主页链接的输入都返回 null。两者都不需要任何依赖。

JavaScript(Node 18+)

Node.js

const HOSTS = {
  "instagram.com": "instagram",
  "tiktok.com": "tiktok",
  "youtube.com": "youtube",
  "x.com": "x",
  "twitter.com": "x",
};

const IG_RESERVED = new Set(["p", "reel", "reels", "tv", "stories", "explore", "accounts", "direct"]);
const X_RESERVED = new Set([
  "home", "explore", "search", "i", "settings", "notifications", "messages",
  "compose", "intent", "hashtag", "login", "signup", "logout", "tos", "privacy",
]);

const IG_HANDLE = /^[a-z0-9._]{1,30}$/;
const TIKTOK_HANDLE = /^[a-z0-9._]{2,24}$/;
const X_HANDLE = /^[a-z0-9_]{1,15}$/;
const YT_HANDLE = /^[\p{L}\p{M}\p{N}._\-·]{1,30}$/u;
const YT_CHANNEL_ID = /^UC[A-Za-z0-9_-]{22}$/;
const YT_LEGACY_NAME = /^[A-Za-z0-9._-]{1,100}$/;

export function parseProfileUrl(input) {
  if (typeof input !== "string") return null;
  let raw = input.trim().replace(/^<|>$/g, "");
  if (!raw) return null;
  if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(raw)) raw = "https://" + raw;

  let url;
  try {
    url = new URL(raw);
  } catch {
    return null;
  }
  if (url.protocol !== "https:" && url.protocol !== "http:") return null;

  const host = url.hostname.toLowerCase().replace(/^(www|m|mobile)\./, "");
  const platform = HOSTS[host];
  if (!platform) return null;

  let segments;
  try {
    segments = url.pathname.split("/").filter(Boolean).map(decodeURIComponent);
  } catch {
    return null; // malformed percent-encoding
  }
  const [first = "", second] = segments;

  if (platform === "instagram") {
    const handle = first.toLowerCase();
    if (segments.length !== 1 || IG_RESERVED.has(handle) || !IG_HANDLE.test(handle)) return null;
    return { platform, handle, canonicalUrl: `https://www.instagram.com/${handle}/` };
  }

  if (platform === "tiktok") {
    if (segments.length !== 1 || !first.startsWith("@")) return null;
    const handle = first.slice(1).toLowerCase();
    if (!TIKTOK_HANDLE.test(handle) || handle.endsWith(".")) return null;
    return { platform, handle, canonicalUrl: `https://www.tiktok.com/@${handle}` };
  }

  if (platform === "x") {
    const handle = first.toLowerCase();
    if (segments.length !== 1 || X_RESERVED.has(handle) || !X_HANDLE.test(handle)) return null;
    return { platform, handle, canonicalUrl: `https://x.com/${handle}` };
  }

  // YouTube: /@handle[/tab], /channel/UC...[/tab], /user/name, /c/name
  if (first.startsWith("@")) {
    const handle = first.slice(1).toLowerCase();
    if (!YT_HANDLE.test(handle) || /^[._\-·]|[._\-·]$/.test(handle)) return null;
    return { platform, handle, canonicalUrl: `https://www.youtube.com/@${encodeURIComponent(handle)}` };
  }
  if (first === "channel" && second && YT_CHANNEL_ID.test(second)) {
    return { platform, channelId: second, canonicalUrl: `https://www.youtube.com/channel/${second}` };
  }
  if ((first === "user" || first === "c") && second && segments.length === 2 && YT_LEGACY_NAME.test(second)) {
    const key = first === "user" ? "username" : "customName";
    return { platform, [key]: second, canonicalUrl: `https://www.youtube.com/${first}/${second}` };
  }
  return null; // videos, shorts, playlists, search and anything else
}

const SHORT_HOSTS = new Set(["vm.tiktok.com", "vt.tiktok.com"]);

export function isShortLink(input) {
  try {
    const url = new URL(/^https?:\/\//i.test(input) ? input : "https://" + input);
    const host = url.hostname.toLowerCase();
    return SHORT_HOSTS.has(host) || (host.replace(/^www\./, "") === "tiktok.com" && url.pathname.startsWith("/t/"));
  } catch {
    return false;
  }
}

export async function resolveShortLink(input) {
  const res = await fetch(/^https?:\/\//i.test(input) ? input : "https://" + input, {
    redirect: "follow",
    signal: AbortSignal.timeout(10_000),
  });
  await res.body?.cancel();
  return res.url; // the final URL after every redirect
}

resolveShortLink 使用内置的 fetch,它会跟随重定向,并通过 res.url 给出最终地址。当 isShortLink(link) 为 true 时,调用 parseProfileUrl(await resolveShortLink(link))。短链接也可能指向视频而不是主页,这时解析器会正确地返回 null。

Python 3

Python

import re
import unicodedata
import urllib.request
from urllib.parse import quote, unquote, urlsplit

HOSTS = {
    "instagram.com": "instagram",
    "tiktok.com": "tiktok",
    "youtube.com": "youtube",
    "x.com": "x",
    "twitter.com": "x",
}

IG_RESERVED = {"p", "reel", "reels", "tv", "stories", "explore", "accounts", "direct"}
X_RESERVED = {
    "home", "explore", "search", "i", "settings", "notifications", "messages",
    "compose", "intent", "hashtag", "login", "signup", "logout", "tos", "privacy",
}

IG_HANDLE = re.compile(r"[a-z0-9._]{1,30}")
TIKTOK_HANDLE = re.compile(r"[a-z0-9._]{2,24}")
X_HANDLE = re.compile(r"[a-z0-9_]{1,15}")
YT_CHANNEL_ID = re.compile(r"UC[A-Za-z0-9_-]{22}")
YT_LEGACY_NAME = re.compile(r"[A-Za-z0-9._-]{1,100}")
YT_PUNCT = "._-·"


def _is_youtube_handle(handle):
    if not 1 <= len(handle) <= 30 or handle[0] in YT_PUNCT or handle[-1] in YT_PUNCT:
        return False
    return all(
        ch.isalnum() or ch in YT_PUNCT or unicodedata.category(ch).startswith("M")
        for ch in handle
    )


def parse_profile_url(value):
    if not isinstance(value, str):
        return None
    raw = value.strip().strip("<>")
    if not raw:
        return None
    if not re.match(r"^[a-z][a-z0-9+.-]*://", raw, re.I):
        raw = "https://" + raw

    try:
        url = urlsplit(raw)
        host = (url.hostname or "").lower()
    except ValueError:
        return None
    if url.scheme.lower() not in ("http", "https"):
        return None

    host = re.sub(r"^(www|m|mobile)\.", "", host)
    platform = HOSTS.get(host)
    if not platform:
        return None

    try:
        segments = [unquote(s, errors="strict") for s in url.path.split("/") if s]
    except UnicodeDecodeError:
        return None  # malformed percent-encoding
    first = segments[0] if segments else ""
    second = segments[1] if len(segments) > 1 else None

    if platform == "instagram":
        handle = first.lower()
        if len(segments) != 1 or handle in IG_RESERVED or not IG_HANDLE.fullmatch(handle):
            return None
        return {"platform": platform, "handle": handle,
                "canonicalUrl": f"https://www.instagram.com/{handle}/"}

    if platform == "tiktok":
        if len(segments) != 1 or not first.startswith("@"):
            return None
        handle = first[1:].lower()
        if not TIKTOK_HANDLE.fullmatch(handle) or handle.endswith("."):
            return None
        return {"platform": platform, "handle": handle,
                "canonicalUrl": f"https://www.tiktok.com/@{handle}"}

    if platform == "x":
        handle = first.lower()
        if len(segments) != 1 or handle in X_RESERVED or not X_HANDLE.fullmatch(handle):
            return None
        return {"platform": platform, "handle": handle, "canonicalUrl": f"https://x.com/{handle}"}

    # YouTube: /@handle[/tab], /channel/UC...[/tab], /user/name, /c/name
    if first.startswith("@"):
        handle = first[1:].lower()
        if not _is_youtube_handle(handle):
            return None
        return {"platform": platform, "handle": handle,
                "canonicalUrl": f"https://www.youtube.com/@{quote(handle, safe='._-')}"}
    if first == "channel" and second and YT_CHANNEL_ID.fullmatch(second):
        return {"platform": platform, "channelId": second,
                "canonicalUrl": f"https://www.youtube.com/channel/{second}"}
    if first in ("user", "c") and second and len(segments) == 2 and YT_LEGACY_NAME.fullmatch(second):
        key = "username" if first == "user" else "customName"
        return {"platform": platform, key: second,
                "canonicalUrl": f"https://www.youtube.com/{first}/{second}"}
    return None  # videos, shorts, playlists, search and anything else


SHORT_HOSTS = {"vm.tiktok.com", "vt.tiktok.com"}


def is_short_link(value):
    url = urlsplit(value if re.match(r"^https?://", value, re.I) else "https://" + value)
    host = (url.hostname or "").lower()
    return host in SHORT_HOSTS or (
        re.sub(r"^www\.", "", host) == "tiktok.com" and url.path.startswith("/t/")
    )


def resolve_short_link(value, timeout=10):
    if not re.match(r"^https?://", value, re.I):
        value = "https://" + value
    request = urllib.request.Request(value, headers={"User-Agent": "Mozilla/5.0"})
    with urllib.request.urlopen(request, timeout=timeout) as response:
        return response.geturl()  # the final URL after every redirect

Python 的 \w 和 str.isalnum() 覆盖了 Unicode 字母和数字,但不包括天城文等文字使用的组合符号,所以 YouTube 的检查还接受 Unicode M 类别中的字符。

边界情况和测试

下表中的每一行都属于测试套件,两种实现都能通过。

输入输出原因
instagram.com/NatGeo?igsh=MWQ1ZGUxMzBkMA==instagram、natgeo、https://www.instagram.com/natgeo/没有协议,大小写混合,带分享参数
https://m.instagram.com/nat.geo_/#bioinstagram、nat.geo_移动版主机名和片段
https://www.instagram.com/p/C9xYzAbCdEf/null是帖子,不是主页
https://www.instagram.com/stories/natgeo/1234567890/null是快拍,尽管路径里有账号名
tiktok.com/@Khaby.Lame/tiktok、khaby.lame、https://www.tiktok.com/@khaby.lame结尾斜杠和大小写
https://www.tiktok.com/@khaby.lame/video/7106594312292453675null视频网址
https://vm.tiktok.com/ZMabc123/null(先跟随重定向)短链接里没有账号名
https://m.youtube.com/@GoogleDevelopers/videos?app=desktopyoutube、googledevelopers移动版主机名、频道标签页、查询参数
https://www.youtube.com/channel/UC_x5XG1OV2P6uZZ5FSM9Ttw/featuredyoutube、channelId UC_x5XG1OV2P6uZZ5FSM9Ttw频道 ID 保留大小写
https://www.youtube.com/c/GoogleDevelopersyoutube、customName GoogleDevelopers旧式自定义网址;需要查询才能得到 ID
https://www.youtube.com/@%E3%83%86%E3%82%B9%E3%83%88youtube、账号名 テスト百分号编码的 Unicode 账号名
https://youtu.be/dQw4w9WgXcQnull是视频,不是频道
https://mobile.twitter.com/jack?s=21&t=abcx、jack、https://x.com/jack旧域名和分享参数
https://x.com/i/flow/loginnull应用路由
https://x.com/this_handle_is_too_longnull超过 15 个字符
https://instagram.com.evil.com/natgeonull仿冒主机名
https://instagram.com@evil.com/natgeonull用户信息伎俩;真正的主机是 evil.com
@natgeonull单独的账号名没有平台信息

把这些用例放进你的代码仓库,并在 CI 中运行。当某个平台新增路由或分享参数时,你只需加一行,修复方法一目了然。

Node.js 测试

import assert from "node:assert/strict";
import { parseProfileUrl } from "./parse.mjs";

const cases = [
  ["instagram.com/NatGeo?igsh=MWQ1ZGUxMzBkMA==", { platform: "instagram", handle: "natgeo", canonicalUrl: "https://www.instagram.com/natgeo/" }],
  ["https://www.instagram.com/reel/C9xYzAbCdEf/", null],
  ["https://www.instagram.com/explore/", null],
  ["tiktok.com/@Khaby.Lame/", { platform: "tiktok", handle: "khaby.lame", canonicalUrl: "https://www.tiktok.com/@khaby.lame" }],
  ["https://www.tiktok.com/@khaby.lame/video/7106594312292453675", null],
  ["https://vm.tiktok.com/ZMabc123/", null],
  ["https://m.youtube.com/@GoogleDevelopers/videos", { platform: "youtube", handle: "googledevelopers", canonicalUrl: "https://www.youtube.com/@googledevelopers" }],
  ["https://www.youtube.com/channel/UC_x5XG1OV2P6uZZ5FSM9Ttw/featured", { platform: "youtube", channelId: "UC_x5XG1OV2P6uZZ5FSM9Ttw", canonicalUrl: "https://www.youtube.com/channel/UC_x5XG1OV2P6uZZ5FSM9Ttw" }],
  ["https://www.youtube.com/user/GoogleDevelopers", { platform: "youtube", username: "GoogleDevelopers", canonicalUrl: "https://www.youtube.com/user/GoogleDevelopers" }],
  ["https://youtu.be/dQw4w9WgXcQ", null],
  ["https://twitter.com/Jack/", { platform: "x", handle: "jack", canonicalUrl: "https://x.com/jack" }],
  ["https://mobile.twitter.com/jack?s=21", { platform: "x", handle: "jack", canonicalUrl: "https://x.com/jack" }],
  ["https://x.com/jack/status/20", null],
  ["https://x.com/i/flow/login", null],
  ["https://instagram.com.evil.com/natgeo", null],
];

for (const [input, expected] of cases) {
  assert.deepEqual(parseProfileUrl(input), expected, input);
}
console.log(`${cases.length} cases passed`);

Python 测试

from parse_profile import parse_profile_url

CASES = [
    ("instagram.com/NatGeo?igsh=MWQ1ZGUxMzBkMA==", {"platform": "instagram", "handle": "natgeo", "canonicalUrl": "https://www.instagram.com/natgeo/"}),
    ("https://www.instagram.com/reel/C9xYzAbCdEf/", None),
    ("tiktok.com/@Khaby.Lame/", {"platform": "tiktok", "handle": "khaby.lame", "canonicalUrl": "https://www.tiktok.com/@khaby.lame"}),
    ("https://vm.tiktok.com/ZMabc123/", None),
    ("https://m.youtube.com/@GoogleDevelopers/videos", {"platform": "youtube", "handle": "googledevelopers", "canonicalUrl": "https://www.youtube.com/@googledevelopers"}),
    ("https://www.youtube.com/channel/UC_x5XG1OV2P6uZZ5FSM9Ttw/featured", {"platform": "youtube", "channelId": "UC_x5XG1OV2P6uZZ5FSM9Ttw", "canonicalUrl": "https://www.youtube.com/channel/UC_x5XG1OV2P6uZZ5FSM9Ttw"}),
    ("https://youtu.be/dQw4w9WgXcQ", None),
    ("https://twitter.com/Jack/", {"platform": "x", "handle": "jack", "canonicalUrl": "https://x.com/jack"}),
    ("https://x.com/i/flow/login", None),
    ("https://instagram.com.evil.com/natgeo", None),
]

for value, expected in CASES:
    assert parse_profile_url(value) == expected, value
print(f"{len(CASES)} cases passed")

同时存储原始输入和规范形式

规范化在设计上就会丢失信息,而且你的规则会随时间不断改进。两个值都要存:

账号名不是永久的。创作者在每个平台上都会改名,你去年存下的账号名现在可能已经属于别人。如果官方 API 能给你一个稳定的 ID(例如 YouTube 频道 ID),就把它存为长期键,把账号名当作需要定期刷新的显示值。配套指南从账号名获取 YouTube 频道 ID介绍了具体做法。

从一个主页到其他主页

主页链接规范化之后,下一个问题通常是:同一位创作者还有哪些其他账号?sociallinks.dev 是一个 API,接收一个 Instagram、TikTok、YouTube 或 X 主页链接,返回该创作者在其他平台上的账号,并为每个匹配给出置信度分数和证据。

常见问题

存储账号名之前应该转成小写吗?

作为查询键,应该。YouTube 和 X 都说明账号名不区分大小写,TikTok 也会把 @TikTok 解析为和 @tiktok 相同的账号。Instagram 没有公布大小写规则,但用户名以小写显示,所以转小写是稳妥的默认做法。如果想显示原始大小写,就保留在原始网址里。不要把 YouTube 频道 ID 转成小写,它们区分大小写。

能从帖子或视频网址中提取账号名吗?

对于 TikTok 视频(/@handle/video/id)和 X 帖子(/handle/status/id),路径中的账号名就是作者,如果你需要的就是作者,可以提取。Instagram 的帖子和 Reel 网址(/p/ 和 /reel/)不包含账号名。本指南中的函数对这些网址一律返回 null,因为它们不是主页链接。

vm.tiktok.com 链接怎么处理?

短链接只包含一个代码。发起 HTTP 请求,跟随重定向,再解析最终的网址。把这一步单独处理,并设置超时和缓存,因为它是一次网络调用,而且代码可能会过期。

twitter.com 还是有效的主页链接吗?

是的。twitter.com 的主页链接会重定向到 x.com,所以把两个域名都当作 X 平台,并以 x.com 形式作为规范链接存储。

为什么不用一个大正则表达式?

对整个字符串套一个正则表达式,既难读,又很容易被仿冒主机名、用户信息或编码字符骗过。先用标准库解析网址,再只对账号名使用小的正则表达式。

怎样把 YouTube 的 /c/ 或 /user/ 网址转换为频道 ID?

/user/<name> 网址对应 YouTube Data API 的 forUsername 筛选条件。/c/ 自定义名称没有对应的 API 筛选条件。两种情况都可以参考“从账号名获取 YouTube 频道 ID”那篇指南。

查找创作者的账号