sociallinks.dev

Blog / Guía para desarrolladores

Cómo analizar y normalizar URL de perfiles de redes sociales (Instagram, TikTok, YouTube, X)

Los creadores pegan sus enlaces de perfil de todas las formas imaginables: con y sin www, desde la app móvil con parámetros de seguimiento, como enlaces cortos, como antiguas URL de twitter.com o como enlace a una publicación en lugar del perfil. Si tu producto guarda esas cadenas tal como llegan, el mismo creador aparece tres veces y tus joins fallan sin avisar. Esta guía te enseña a convertir cualquier URL de perfil de Instagram, TikTok, YouTube o X en una plataforma, un nombre de usuario o ID de canal y una URL canónica, con código probado en JavaScript y Python.

Por qué importa normalizar las URL de perfil

Una URL de perfil es un identificador, y los identificadores solo funcionan si se escriben de una única forma. https://www.instagram.com/natgeo/, instagram.com/NatGeo?igsh=MWQ1ZGUxMzBkMA== y https://m.instagram.com/natgeo apuntan a la misma cuenta, pero como cadenas son tres valores distintos.

El objetivo es una función pequeña y estricta: acepta lo que los usuarios pegan de verdad, devuelve un resultado normalizado para las URL de perfil reales y devuelve null para todo lo demás.

Las formas de URL que te vas a encontrar

Las formas de abajo se comprobaron con los sitios en vivo en octubre de 2026. Las plataformas cambian sus rutas sin avisar, así que tómalo como una foto del momento y mantén tus casos de prueba al día.

Instagram

TikTok

YouTube

X (antes Twitter)

Reglas de caracteres de los nombres de usuario por plataforma

Validar el nombre de usuario detecta erratas y la mayoría de las rutas que no son perfiles. Usa las reglas de las propias plataformas cuando las publiquen, y deja las comprobaciones flexibles cuando no, para no rechazar nunca una cuenta real.

PlataformaCaracteres permitidosLongitudMayúsculasFuente
InstagramLetras, números, puntos, guiones bajosSe cita mucho 30; no aparece en las páginas de ayuda de InstagramTrátalo como insensible a mayúsculas; guárdalo en minúsculasEl Centro de ayuda de Instagram menciona puntos, números y guiones bajos
TikTokLetras, números, guiones bajos, puntos; no puede terminar en puntoDe 2 a 24Insensible a mayúsculas en las URL (@TikTok lleva a tiktok)Soporte de TikTok, cambiar tu nombre de usuario
YouTubeLetras o números de los idiomas admitidos, más guiones bajos, guiones, puntos y puntos medios latinos (no al principio ni al final)De 3 a 30 en la mayoría de alfabetos; límites más cortos para han, hangul y algunos otrosNo distingue mayúsculasAyuda de YouTube, pautas de los handles
XLetras, números y guiones bajosHasta 15; los nombres nuevos deben tener más de 4, pero existen handles cortos antiguosNo distingue mayúsculas (x.com/JACK se normaliza a x.com/jack)Centro de ayuda de X, reglas del nombre de usuario

Un algoritmo de normalización

  1. Step 1Recorta y añade un esquema

    Quita los espacios y los corchetes angulares sueltos. Si la entrada no tiene esquema, antepón https:// para que instagram.com/natgeo se pueda analizar.

  2. Step 2Analiza con un parser de URL de verdad

    Usa new URL() en JavaScript o urllib.parse.urlsplit en Python, nunca una regex sobre toda la cadena. Un parser gestiona puertos, información de usuario y caracteres codificados, y evita que https://instagram.com@evil.com/natgeo pase por Instagram.

  3. Step 3Normaliza el host

    Pásalo a minúsculas y quita un www., m. o mobile. inicial. Después búscalo en una lista exacta de hosts permitidos. Nunca uses includes("instagram.com"), que también coincide con instagram.com.evil.com.

  4. Step 4Quita la query string y el fragmento

    Los parámetros de seguimiento como igsh, s, t y lang nunca cambian el perfil al que apunta una URL.

  5. Step 5Divide y decodifica la ruta

    Divide por /, descarta los segmentos vacíos (así se quitan las barras finales) y decodifica cada uno. Trata una codificación mal formada como entrada no válida.

  6. Step 6Enruta por plataforma

    Aplica la forma de cada plataforma: un segmento en Instagram y X, un segmento con @ en TikTok, y @handle, channel/UC..., user/name o c/name en YouTube. Rechaza las palabras reservadas.

  7. Step 7Valida el nombre de usuario y pásalo a minúsculas

    Comprueba el nombre de usuario con las reglas de caracteres de la plataforma y después pásalo a minúsculas. No pases a minúsculas los ID de canal de YouTube: distinguen mayúsculas.

  8. Step 8Construye la URL canónica

    Reconstruye la URL a partir de las partes en un host fijo, para que todas las entradas equivalentes produzcan exactamente la misma cadena.

  9. Step 9Trata los enlaces cortos aparte

    Detecta los enlaces cortos de TikTok, sigue la redirección con una petición HTTP y vuelve a pasar la URL final por el analizador. Mantén esto fuera de la función pura para que el análisis siga siendo rápido y fácil de probar.

El código en JavaScript y Python

Las dos funciones devuelven los mismos objetos para la misma entrada: { platform, handle, canonicalUrl } para las URL con nombre de usuario, { platform, channelId, canonicalUrl } para los ID de canal de YouTube, username o customName para las dos formas antiguas de YouTube, y null para todo lo que no sea una URL de perfil. Ninguna necesita dependencias.

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 usa el fetch integrado, que sigue las redirecciones y expone la dirección final como res.url. Llama a parseProfileUrl(await resolveShortLink(link)) cuando isShortLink(link) sea verdadero. Un enlace corto también puede llevar a un vídeo en lugar de a un perfil, y en ese caso el analizador devuelve null, como debe.

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

El \w de Python y str.isalnum() cubren letras y dígitos Unicode, pero no las marcas combinables que usan alfabetos como el devanagari, por eso la comprobación de YouTube también acepta caracteres de las categorías Unicode M.

Casos límite y pruebas

Cada fila de abajo forma parte del conjunto de pruebas que superan las dos implementaciones.

EntradaSalidaPor qué
instagram.com/NatGeo?igsh=MWQ1ZGUxMzBkMA==instagram, natgeo, https://www.instagram.com/natgeo/Sin esquema, mayúsculas mezcladas, parámetro de compartir
https://m.instagram.com/nat.geo_/#bioinstagram, nat.geo_Host móvil y fragmento
https://www.instagram.com/p/C9xYzAbCdEf/nullUna publicación, no un perfil
https://www.instagram.com/stories/natgeo/1234567890/nullUna historia, aunque el nombre de usuario esté en la ruta
tiktok.com/@Khaby.Lame/tiktok, khaby.lame, https://www.tiktok.com/@khaby.lameBarra final y mayúsculas
https://www.tiktok.com/@khaby.lame/video/7106594312292453675nullUna URL de vídeo
https://vm.tiktok.com/ZMabc123/null (sigue primero la redirección)Enlace corto sin nombre de usuario
https://m.youtube.com/@GoogleDevelopers/videos?app=desktopyoutube, googledevelopersHost móvil, pestaña del canal, query
https://www.youtube.com/channel/UC_x5XG1OV2P6uZZ5FSM9Ttw/featuredyoutube, channelId UC_x5XG1OV2P6uZZ5FSM9TtwEl ID de canal conserva sus mayúsculas
https://www.youtube.com/c/GoogleDevelopersyoutube, customName GoogleDevelopersURL personalizada antigua; necesita una consulta para el ID
https://www.youtube.com/@%E3%83%86%E3%82%B9%E3%83%88youtube, handle テストHandle Unicode con codificación porcentual
https://youtu.be/dQw4w9WgXcQnullUn vídeo, no un canal
https://mobile.twitter.com/jack?s=21&t=abcx, jack, https://x.com/jackDominio antiguo y parámetros de compartir
https://x.com/i/flow/loginnullRuta de la app
https://x.com/this_handle_is_too_longnullMás de 15 caracteres
https://instagram.com.evil.com/natgeonullHost que imita al real
https://instagram.com@evil.com/natgeonullTruco de información de usuario; el host real es evil.com
@natgeonullUn nombre de usuario suelto no tiene plataforma

Guarda los casos en tu repositorio y ejecútalos en CI. Cuando una plataforma añada una ruta o un parámetro de compartir, añades una fila y la corrección salta a la vista.

Prueba en 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`);

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

Guarda la entrada original y la forma canónica

La normalización pierde información por diseño, y tus reglas mejorarán con el tiempo. Guarda los dos valores:

Los nombres de usuario no son permanentes. Los creadores cambian el nombre de sus cuentas en todas las plataformas, y un nombre que guardaste el año pasado puede ser hoy de otra persona. Cuando una API oficial te dé un ID estable, como el ID de un canal de YouTube, guárdalo como clave a largo plazo y trata el nombre de usuario como un valor visible que actualizas. La guía complementaria sobre cómo obtener el ID de un canal de YouTube a partir de su handle explica cómo.

De un perfil a los demás

Una vez normalizada una URL de perfil, la siguiente pregunta suele ser qué otras cuentas pertenecen al mismo creador. sociallinks.dev es una API que toma una URL de perfil de Instagram, TikTok, YouTube o X y devuelve las cuentas del creador en otras plataformas, con una puntuación de confianza y las pruebas de cada coincidencia.

Preguntas frecuentes

¿Debo pasar los nombres de usuario a minúsculas antes de guardarlos?

Sí, para la clave de búsqueda. YouTube y X dicen que los handles no distinguen mayúsculas, y TikTok lleva @TikTok a la misma cuenta que @tiktok. Instagram no documenta ninguna regla de mayúsculas, pero muestra los nombres de usuario en minúsculas, así que pasarlos a minúsculas es la opción segura. Conserva las mayúsculas originales en la URL sin procesar si quieres mostrarlas. No pases a minúsculas los ID de canal de YouTube, que distinguen mayúsculas.

¿Puedo extraer el nombre de usuario de una URL de publicación o de vídeo?

En los vídeos de TikTok (/@handle/video/id) y las publicaciones de X (/handle/status/id), el nombre de usuario de la ruta es el del autor, así que puedes extraerlo si es lo que quieres. Las URL de publicaciones y reels de Instagram (/p/ y /reel/) no incluyen el nombre de usuario. Las funciones de esta guía devuelven null en todos estos casos porque no son enlaces de perfil.

¿Cómo trato los enlaces vm.tiktok.com?

Los enlaces cortos solo contienen un código. Haz una petición HTTP, sigue las redirecciones y analiza la URL final. Hazlo en un paso aparte con un tiempo de espera y una caché, porque es una llamada de red y el código puede caducar.

¿twitter.com sigue siendo una URL de perfil válida?

Sí. Las URL de perfil de twitter.com redirigen a x.com, así que trata los dos dominios como la plataforma X y guarda la forma de x.com como canónica.

¿Por qué no usar una sola expresión regular grande?

Una sola regex sobre toda la cadena es difícil de leer y fácil de engañar con hosts que imitan al real, información de usuario o caracteres codificados. Analiza primero la URL con la biblioteca estándar y después usa regex pequeñas solo sobre el nombre de usuario.

¿Cómo convierto una URL /c/ o /user/ de YouTube en un ID de canal?

Las URL /user/<name> corresponden al filtro forUsername de la YouTube Data API. No hay ningún filtro de la API para los nombres personalizados /c/. Consulta la guía sobre cómo obtener el ID de un canal de YouTube a partir de su handle para los dos casos.

Encuentra las cuentas de un creador