sociallinks.dev

Блог / Руководство для разработчиков

Как разбирать и нормализовать URL профилей в соцсетях (Instagram, TikTok, YouTube, X)

Авторы вставляют ссылки на профили в любом мыслимом виде: с www и без, из мобильного приложения с параметрами отслеживания, как короткие ссылки, как старые URL twitter.com или как ссылку на публикацию вместо профиля. Если ваш продукт хранит эти строки как есть, один и тот же автор появляется трижды, а объединения данных незаметно промахиваются. В этом руководстве мы показываем, как превратить любой URL профиля в Instagram, TikTok, YouTube или X в платформу, никнейм или ID канала и один канонический URL, с проверенным кодом на JavaScript и Python.

Зачем нормализовать URL профилей

URL профиля это идентификатор, а идентификаторы работают, только когда записаны одним способом. https://www.instagram.com/natgeo/, instagram.com/NatGeo?igsh=MWQ1ZGUxMzBkMA== и https://m.instagram.com/natgeo ведут на один аккаунт, но как строки это три разных значения.

Цель: небольшая строгая функция, которая принимает то, что пользователи реально вставляют, возвращает нормализованный результат для настоящих URL профилей и null для всего остального.

Какие виды URL вам встретятся

Форматы ниже проверены на работающих сайтах в октябре 2026 года. Платформы меняют маршрутизацию без предупреждения, поэтому считайте это снимком и поддерживайте тестовые случаи в актуальном состоянии.

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

    Используйте new URL() в JavaScript или urllib.parse.urlsplit в Python, но никогда не регулярное выражение по всей строке. Парсер обрабатывает порты, данные пользователя и закодированные символы и не даёт 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, никогда не меняют профиль, на который ведёт URL.

  5. Step 5Разделите и раскодируйте путь

    Разделите по /, отбросьте пустые сегменты (так уходят слеши в конце) и раскодируйте percent-encoding в каждом. Некорректную кодировку считайте недопустимым вводом.

  6. Step 6Маршрутизируйте по платформе

    Применяйте формат каждой платформы: один сегмент в Instagram и X, сегмент с @ в TikTok и @handle, channel/UC..., user/name или c/name на YouTube. Отклоняйте зарезервированные слова.

  7. Step 7Проверьте никнейм и переведите в нижний регистр

    Сверьте никнейм с правилами символов платформы, затем переведите его в нижний регистр. Не меняйте регистр ID каналов YouTube: они чувствительны к регистру.

  8. Step 8Соберите канонический URL

    Соберите URL из частей на одном фиксированном хосте, чтобы любой равнозначный ввод давал ровно одну и ту же строку.

  9. Step 9Обрабатывайте короткие ссылки отдельно

    Распознавайте короткие ссылки TikTok, проходите по перенаправлению HTTP-запросом и снова пропускайте итоговый URL через парсер. Держите это вне чистой функции, чтобы разбор оставался быстрым и тестируемым.

Код на JavaScript и Python

Обе функции возвращают одинаковые объекты для одного и того же ввода: { platform, handle, canonicalUrl } для URL с никнеймом, { platform, channelId, canonicalUrl } для ID каналов YouTube, username или customName для двух старых форм YouTube и null для всего, что не является URL профиля. Ни одной не нужны зависимости.

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. Вызывайте parseProfileUrl(await resolveShortLink(link)), когда isShortLink(link) возвращает true. Короткая ссылка может вести и на видео, а не на профиль, и тогда парсер правильно вернёт 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/7106594312292453675nullURL видео
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_x5XG1OV2P6uZZ5FSM9TtwID канала сохраняет регистр
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 в percent-encoding
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, например ID канала YouTube, храните его как долгосрочный ключ, а никнейм считайте отображаемым значением, которое вы обновляете. Как это сделать, показано в соседнем руководстве о том, как получить ID канала YouTube по никнейму.

От одного профиля к остальным

Когда URL профиля нормализован, следующий вопрос обычно такой: какие ещё аккаунты принадлежат тому же автору. sociallinks.dev это API, которое принимает один URL профиля в Instagram, TikTok, YouTube или X и возвращает аккаунты автора на других платформах с оценкой уверенности и доказательствами для каждого совпадения.

Частые вопросы

Нужно ли переводить никнеймы в нижний регистр перед сохранением?

Для ключа поиска да. YouTube и X говорят, что никнеймы не зависят от регистра, а TikTok открывает по @TikTok тот же аккаунт, что и по @tiktok. Instagram не документирует правило о регистре, но имена пользователей отображаются строчными буквами, поэтому нижний регистр это безопасный вариант по умолчанию. Если хотите показывать исходное написание, сохраните его в исходном URL. Не меняйте регистр ID каналов YouTube: они чувствительны к регистру.

Можно ли извлечь никнейм из URL публикации или видео?

Для видео в TikTok (/@handle/video/id) и публикаций в X (/handle/status/id) никнейм в пути принадлежит автору, так что его можно извлечь, если вам нужно именно это. URL публикаций и Reels в Instagram (/p/ и /reel/) никнейма не содержат. Функции в этом руководстве возвращают null для всех них, потому что это не ссылки на профили.

Что делать со ссылками vm.tiktok.com?

Короткие ссылки содержат только код. Сделайте HTTP-запрос, пройдите по перенаправлениям и разберите итоговый URL. Делайте это отдельным шагом с тайм-аутом и кешем, потому что это сетевой вызов, а срок действия кода может истечь.

twitter.com всё ещё допустимый URL профиля?

Да. URL профилей twitter.com перенаправляют на x.com, поэтому считайте оба домена платформой X и храните форму x.com как каноническую.

Почему бы не использовать одно большое регулярное выражение?

Одно регулярное выражение по всей строке трудно читать и легко обмануть похожими хостами, данными пользователя или закодированными символами. Сначала разберите URL стандартной библиотекой, а затем применяйте небольшие регулярные выражения только к никнейму.

Как превратить URL YouTube вида /c/ или /user/ в ID канала?

URL /user/<name> соответствуют фильтру forUsername в YouTube Data API. Для персональных имён /c/ фильтра в API нет. Оба случая разобраны в руководстве о том, как получить ID канала YouTube по никнейму.

Найти аккаунты автора