sociallinks.dev

ブログ / 開発者ガイド

SNSのプロフィールURLを解析して正規化する方法(Instagram、TikTok、YouTube、X)

クリエイターはあらゆる形でプロフィールのリンクを貼ります。wwwの有無、モバイルアプリからのトラッキングパラメータ付き、短縮リンク、古いtwitter.comのURL、プロフィールではなく投稿へのリンク。プロダクトがこうした文字列を受け取ったまま保存すると、同じクリエイターが3回登場し、結合は気づかないうちに漏れていきます。このガイドでは、Instagram、TikTok、YouTube、XのどんなプロフィールURLでも、プラットフォーム、ハンドルまたはチャンネルID、1つの正規URLに変換する方法を、テスト済みのJavaScriptとPythonのコードで紹介します。

プロフィールURLの正規化が重要な理由

プロフィールURLは識別子であり、識別子は1通りに書かれていて初めて機能します。https://www.instagram.com/natgeo/、instagram.com/NatGeo?igsh=MWQ1ZGUxMzBkMA==、https://m.instagram.com/natgeoはすべて同じアカウントを指しますが、文字列としては3つの異なる値です。

目指すのは小さく厳格な関数です。ユーザーが実際に貼り付けるものを受け付け、本物のプロフィールURLには正規化した結果を返し、それ以外にはすべてnullを返します。

実際に目にするURLの形

以下の形は2026年10月に実際のサイトで確認したものです。プラットフォームは予告なくルーティングを変えるので、これはスナップショットとして扱い、テストケースを最新に保ちましょう。

Instagram

TikTok

YouTube

X(旧Twitter)

プラットフォームごとのハンドルの文字のルール

ハンドルを検証すると、入力ミスや、プロフィールではないパスの大半を見つけられます。プラットフォームがルールを公開している場合はそれを使い、公開していない場合は本物のアカウントを決して弾かないよう、確認をゆるめにしておきます。

プラットフォーム使える文字長さ大文字と小文字出典
Instagram英字、数字、ピリオド、アンダースコア30文字という説が広く引用されているが、Instagramのヘルプページには見当たらない区別しないものとして扱い、小文字で保存するInstagramヘルプセンターがピリオド、数字、アンダースコアに言及
TikTok英字、数字、アンダースコア、ピリオド。ピリオドで終わることはできない2〜24文字URLでは区別しない(@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本物のURLパーサーで解析する

    JavaScriptではnew URL()、Pythonではurllib.parse.urlsplitを使い、文字列全体に正規表現をかけてはいけません。パーサーはポート、ユーザー情報、エンコードされた文字を処理し、https://instagram.com@evil.com/natgeoがInstagramとして通るのを防ぎます。

  3. Step 3ホストを正規化する

    小文字にし、先頭のwww.、m.、mobile.を1つ取り除きます。そのうえで、ホストの許可リストと完全一致で照合します。instagram.com.evil.comにも一致してしまうincludes("instagram.com")は決して使わないでください。

  4. Step 4クエリ文字列とフラグメントを削除する

    igsh、s、t、langなどのトラッキングパラメータで、URLが指すプロフィールが変わることはありません。

  5. Step 5パスを分割してデコードする

    /で分割し、空のセグメントを除き(これで末尾のスラッシュが消えます)、それぞれをパーセントデコードします。不正なエンコーディングは無効な入力として扱います。

  6. Step 6プラットフォームごとに振り分ける

    各プラットフォームの形を当てはめます。InstagramとXは1セグメント、TikTokは@付きのセグメント、YouTubeは@handle、channel/UC...、user/name、c/nameです。予約語は弾きます。

  7. Step 7ハンドルを検証して小文字にする

    ハンドルをプラットフォームの文字のルールで確認してから、小文字にします。YouTubeのチャンネルIDは大文字と小文字を区別するので、小文字にしてはいけません。

  8. Step 8正規URLを作る

    1つの決まったホストで部品からURLを作り直し、同等の入力がすべてまったく同じ文字列になるようにします。

  9. Step 9短縮リンクは別に処理する

    TikTokの短縮リンクを検出し、HTTPリクエストでリダイレクトをたどり、最終的なURLを再びパーサーに通します。解析を高速でテストしやすく保つため、この処理は純粋な関数の外に置きます。

JavaScriptとPythonのコード

どちらの関数も、同じ入力に対して同じオブジェクトを返します。ハンドルのURLには{ platform, handle, canonicalUrl }、YouTubeのチャンネルIDには{ platform, channelId, canonicalUrl }、YouTubeの2つの旧形式にはusernameかcustomName、プロフィールURLでないものには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動画のURL
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旧形式のカスタムURL。IDを得るには検索が必要
https://www.youtube.com/@%E3%83%86%E3%82%B9%E3%83%88youtube、handle テストパーセントエンコードされた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_longnull15文字を超える
https://instagram.com.evil.com/natgeonullよく似たホスト
https://instagram.com@evil.com/natgeonullユーザー情報を使ったトリック。本当のホストはevil.com
@natgeonullハンドルだけではプラットフォームが分からない

テストケースはリポジトリに置き、CIで実行しましょう。プラットフォームがルートや共有パラメータを追加したら、1行追加すれば直し方は明らかです。

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")

元の入力と正規形の両方を保存する

正規化はもともと情報を落とす処理であり、ルールは時間とともに改善されていきます。両方の値を保存しましょう。

ハンドルは永続的なものではありません。クリエイターはどのプラットフォームでもアカウント名を変えるので、昨年保存したハンドルが今は別人のものになっているかもしれません。YouTubeのチャンネルIDのように公式APIが安定したIDを提供している場合は、それを長期的なキーとして保存し、ハンドルは更新していく表示用の値として扱いましょう。その方法は関連ガイドハンドルからYouTubeのチャンネルIDを取得する方法で紹介しています。

1つのプロフィールから他のアカウントへ

プロフィールURLを正規化したら、次の問いはたいてい、同じクリエイターのほかのアカウントはどれか、です。sociallinks.devは、Instagram、TikTok、YouTube、XのプロフィールURLを1つ受け取り、そのクリエイターの他のプラットフォームのアカウントを、確信度スコアと一致ごとの根拠付きで返すAPIです。

よくある質問

ハンドルは保存する前に小文字にすべきですか?

検索用のキーについては、そうすべきです。YouTubeとXはハンドルが大文字と小文字を区別しないとしており、TikTokは@TikTokを@tiktokと同じアカウントとして扱います。Instagramは大文字と小文字のルールをドキュメント化していませんが、ユーザーネームは小文字で表示されるので、小文字にするのが安全な既定です。表示したい場合は、元の大文字小文字を元のURLに残しておきましょう。YouTubeのチャンネルIDは大文字と小文字を区別するので、小文字にしてはいけません。

投稿や動画のURLからハンドルを取り出せますか?

TikTokの動画(/@handle/video/id)とXの投稿(/handle/status/id)では、パスのハンドルは投稿者のものなので、それが目的なら取り出せます。Instagramの投稿とリールのURL(/p/と/reel/)にはハンドルが含まれていません。このガイドの関数は、どれもプロフィールのリンクではないので、すべてにnullを返します。

vm.tiktok.comのリンクはどう扱えばよいですか?

短縮リンクにはコードしか含まれていません。HTTPリクエストを送り、リダイレクトをたどって、最終的なURLを解析します。ネットワーク呼び出しであり、コードは期限切れになることもあるので、タイムアウトとキャッシュを付けた別の手順で行いましょう。

twitter.comは今も有効なプロフィールURLですか?

有効です。twitter.comのプロフィールURLはx.comにリダイレクトされるので、両方のドメインをXのプラットフォームとして扱い、x.comの形を正規形として保存します。

大きな正規表現を1つ使えばよいのでは?

文字列全体にかける1つの正規表現は読みにくく、よく似たホスト、ユーザー情報、エンコードされた文字で簡単にだまされます。まず標準ライブラリでURLを解析し、小さな正規表現はハンドルだけに使いましょう。

YouTubeの/c/や/user/のURLをチャンネルIDに変換するには?

/user/<name>のURLは、YouTube Data APIのforUsernameフィルタに対応します。/c/のカスタム名に対応するAPIフィルタはありません。両方のケースについては、ハンドルからYouTubeのチャンネルIDを取得する方法のガイドをご覧ください。

クリエイターのアカウントを探す