Si comparas precios, contenidos o disponibilidad entre mercados, el primer problema es encontrar la misma página en cada mercado. Adivinar patrones de URL funciona en algunos sitios y falla en la mayoría: un sitio usa /de/, otro de.example.com, otro un parámetro de consulta, y un cuarto un dominio independiente por país.
Muchos sitios ya publican la respuesta. La anotación hreflang, que los sitios añaden para los motores de búsqueda, enumera cada versión de idioma y país de una página y dónde se encuentra. Léela, y una sola petición te da el conjunto completo. Esta guía explica cómo funciona, qué encontramos al comprobar cómo la usan los principales sitios, y por qué tener la URL correcta no siempre basta para ver la página correcta.
Conclusiones clave
- El 1 de octubre de 2026, 82 de las 253 páginas de inicio de los principales sitios que obtuvimos (32%) declaraban alternativas hreflang, con una mediana de 17,5 versiones cada una y hasta 157.
- Una sola petición a una página anotada te da cada URL localizada de esa página, sin necesidad de adivinar patrones de URL.
- Las anotaciones no siempre son limpias: 12 de los 82 sitios usaban al menos un código que Google no admite, como
en-uk,es-419,en_GBcon guion bajo, o el país antes del idioma. - El 86% de las alternativas que comprobamos enlazaban directamente de vuelta, como exige Google. La mayoría del resto apuntaba a una URL diferente para la misma página, o redirigía.
- Algunas versiones solo se abren desde el lugar correcto. La página de inicio de EE. UU. de un sitio de noticias y el escaparate alemán de un marketplace redirigieron nuestra conexión desde Rumanía a otro lugar, y cargaron con normalidad a través de salidas en Estados Unidos y Alemania.
Cómo funciona hreflang
Una página enumera sus alternativas en su <head>:
<link rel="alternate" hreflang="en" href="https://example.com/" />
<link rel="alternate" hreflang="de" href="https://example.com/de/" />
<link rel="alternate" hreflang="pt-BR" href="https://example.com/pt/" />
<link rel="alternate" hreflang="x-default" href="https://example.com/" />
Cada valor es un código de idioma, seguido opcionalmente de un sistema de escritura o una región: de para alemán en cualquier lugar, en-GB para inglés en el Reino Unido, zh-Hant para chino tradicional. x-default designa la versión de respaldo para los visitantes que no coinciden con ninguna de ellas. La documentación de Google establece las reglas que sigue la mayoría de los sitios:
- Los idiomas usan códigos ISO 639-1, los sistemas de escritura ISO 15924 y las regiones ISO 3166-1 alpha-2. Una región por sí sola no es válida, y los códigos fuera de esos estándares, como
es-419para el español latinoamericano, no son compatibles. - Las URL deben estar completamente cualificadas, incluyendo
https://. - Cada versión debe enumerarse a sí misma y a todas las demás versiones, y si dos páginas no se apuntan mutuamente, las anotaciones pueden ignorarse.
- La misma información se puede indicar en el HTML, en una cabecera HTTP
Link(útil para PDF) o en un sitemap.
Para la recopilación de datos, la tercera regla es la útil. Como se supone que cada versión enumera todas las demás, cualquiera de ellas es un mapa completo del conjunto.
Qué encontramos en los principales sitios
Obtuvimos las páginas de inicio de los 500 dominios principales en el ranking Tranco de sitios populares. 253 sitios distintos devolvieron una página de inicio en HTML; el resto eran redes de contenido, hosts de API y otros dominios sin página de inicio, o duplicados de un sitio ya contado.
| Medida | Resultado |
|---|---|
| Páginas de inicio con anotaciones hreflang | 82 de 253 (32%) |
| Alternativas por página anotada | mediana 17,5, máximo 157 |
Páginas con un x-default | 63 de 82 |
Páginas de inicio que envían hreflang en una cabecera HTTP Link | 0 |
| Sitios con al menos un código no admitido | 12 de 82 |
| Sitios que usan URL relativas | 2 |
| Sitios que enumeran el mismo código dos veces | 6 |
Los códigos no admitidos seguían algunos patrones:
| Patrón | Ejemplo | Sitios |
|---|---|---|
| País antes del idioma, más etiquetas regionales | mx-es, cz-cs, emea_africa-en | 1 (48 códigos) |
| Guion bajo en lugar de guion | en_GB, de_DE | 1 (25 códigos) |
| Código de región que no es ISO 3166-1 | en-uk, en-eu, sq-xk | 3 |
| Español latinoamericano | es-419 | 2 |
| Código de idioma que no es ISO 639-1 | ceb, skr, el obsoleto iw para hebreo | 3 |
| Códigos inventados | tc, zh-FT, ms-en | 3 |
Algunos sitios aparecen en más de una fila. El patrón de país primero es el más traicionero para un analizador, porque varios de esos códigos son válidos accidentalmente al revés: ca es el código del catalán, id del indonesio y th del tailandés, así que ca-en se interpreta como catalán en lugar de inglés para Canadá. Nunca deduzcas un mercado a partir solo del código sin comprobar que tiene sentido.
Enlaces de retorno
Tomamos hasta dos alternativas de cada página de inicio anotada, 155 en total, las obtuvimos y comprobamos si cada una enumeraba la página de inicio de vuelta.
| Resultado | Alternativas |
|---|---|
| Enlazaba directamente de vuelta | 133 (86%) |
| Enlazaba de vuelta a una URL diferente para la misma página | 9 |
| Redirigía a otro sitio | 9 |
| Realmente no enumeraba la página | 4, en dos sitios |
La segunda fila es la sutil. En varios sitios, la página de inicio a la que llegamos residía en una URL mientras que el conjunto nombraba otra: / frente a /en/, /homepage o /home.html, o una URL sin parámetros frente a una con un parámetro de idioma. Las anotaciones eran coherentes; nuestro punto de entrada simplemente no era la URL que el sitio considera canónica. Cuando recopiles, clasifica cada versión por la URL que usa el propio conjunto, no por la que casualmente visitaste.
La URL correcta no siempre basta
Las redirecciones fueron la parte más instructiva. Volvimos a obtener cada alternativa que redirigía, directamente y a través de salidas de Shifter en el país de destino, con cabeceras Accept-Language en inglés y locales:
| Tipo de sitio | Alternativa | Desde Rumanía | Desde el país de destino | Decidido por |
|---|---|---|---|---|
| Sitio de noticias | Página de inicio de EE. UU. | Redirigió a la edición internacional | Cargó, vía una salida de EE. UU. | Ubicación |
| Marketplace | Escaparate alemán | Redirigió al sitio global | Cargó, vía una salida alemana | Ubicación |
| Proveedor de videollamadas | Página de inicio en japonés | Cargó solo con Accept-Language en japonés | Igual, vía una salida japonesa | Idioma |
| Editora de videojuegos | Página de inicio en árabe | Redirigió | Redirigió, vía una salida saudí | Ninguno: listado pero inalcanzable |
Dos sitios decidían según la ubicación del visitante, el marketplace sin importar el idioma, de modo que sus propias anotaciones apuntaban a páginas que un visitante de otro lugar no podía abrir. Uno decidía según el idioma, sin importar la ubicación. Y uno enumeraba una versión que redirigía en todas las combinaciones que probamos, algo que conviene saber antes de construir una comparación de mercados sobre ella.
Aquí es donde se encuentran hreflang y la ubicación de salida. Las anotaciones te dicen qué versiones existen y dónde; recopilarlas fielmente implica solicitar cada una como lo haría un visitante local, desde ese país y con ese idioma, como se trata en cómo hacer coincidir la geolocalización, la zona horaria y el idioma del proxy. De lo contrario, corres el riesgo de registrar la versión internacional bajo una etiqueta alemana.
El código
El siguiente módulo lee las alternativas HTML de una página, señala códigos no admitidos y URL relativas, y comprueba los enlaces de retorno, informando por qué falla cada alternativa fallida:
from html.parser import HTMLParser
from urllib.parse import urljoin, urlsplit, urlunsplit
import requests
from babel import Locale
# Google accepts ISO 639-1 languages, ISO 15924 scripts and ISO 3166-1 alpha-2 regions.
LANGUAGES = {code for code in Locale("en").languages if len(code) == 2}
NOT_ISO_3166 = {"AC", "CP", "CQ", "DG", "EA", "EU", "EZ", "IC", "QO", "TA", "UN", "XA", "XB", "XK", "ZZ"}
REGIONS = {code for code in Locale("en").territories if code.isalpha()} - NOT_ISO_3166
SCRIPTS = set(Locale("en").scripts)
class AlternateLinks(HTMLParser):
"""Collect <link rel="alternate" hreflang="..."> elements from a page's markup."""
def __init__(self):
super().__init__()
self.links = []
def handle_starttag(self, tag, attrs):
a = dict(attrs)
if tag == "link" and "alternate" in (a.get("rel") or "").lower().split() and a.get("hreflang") and a.get("href"):
self.links.append((a["hreflang"].strip(), a["href"].strip()))
def valid_hreflang(code):
"""True for x-default, a language, or language-region / language-script(-region) codes."""
if code.lower() == "x-default":
return True
parts = code.split("-")
if parts[0].lower() not in LANGUAGES:
return False
rest = parts[1:]
if rest and rest[0].title() in SCRIPTS:
rest = rest[1:]
if rest and rest[0].upper() in REGIONS:
rest = rest[1:]
return not rest
def hreflang_map(url, session=None, timeout=20):
"""Fetch a page and return its declared alternates as {hreflang: absolute URL}, plus any problems found."""
session = session or requests.Session()
response = session.get(url, timeout=timeout)
parser = AlternateLinks()
parser.feed(response.text) # link tags are normally ASCII, so the decoding choice rarely matters here
alternates, problems = {}, []
for code, href in parser.links:
if not href.startswith(("http://", "https://")):
problems.append(f"relative URL for {code}: {href}")
if not valid_hreflang(code):
problems.append(f"invalid code: {code}")
alternates[code] = urljoin(response.url, href)
return response.url, alternates, problems
def normalise(url):
"""Compare URLs without default ports, host case or a trailing slash getting in the way."""
parts = urlsplit(url)
port = f":{parts.port}" if parts.port and parts.port not in (80, 443) else ""
return urlunsplit((parts.scheme.lower(), (parts.hostname or "") + port, parts.path.rstrip("/") or "/", parts.query, ""))
def check_return_links(url, alternates, session=None, timeout=20):
"""Fetch each alternate and report why it breaks the return-link rule. An empty result means all link back."""
session = session or requests.Session()
target = normalise(url)
problems = {}
for code, alternate in alternates.items():
if normalise(alternate) == target:
continue
final, back, _ = hreflang_map(alternate, session, timeout)
if normalise(final) != normalise(alternate):
problems[code] = f"redirects to {final}"
elif not back:
problems[code] = "has no hreflang annotations"
elif target not in {normalise(u) for u in back.values()}:
problems[code] = "does not link back"
return problems
Ejecutado contra nuestra propia página de inicio:
import requests
from hreflang import hreflang_map, check_return_links
session = requests.Session()
session.headers["User-Agent"] = "ExampleCollector/1.0 (+https://example.com/bot)"
url, alternates, problems = hreflang_map("https://shifter.io/", session)
for code, alternate in sorted(alternates.items()):
print(code, alternate)
print("problems:", problems)
print("return-link problems:", check_return_links(url, alternates, session))
de https://shifter.io/de
en https://shifter.io/
es https://shifter.io/es
fr https://shifter.io/fr
ja https://shifter.io/ja
ko https://shifter.io/ko
pt-BR https://shifter.io/pt
x-default https://shifter.io/
zh-Hans https://shifter.io/cn
problems: []
return-link problems: {}
Vale la pena conocer dos límites. El código lee las anotaciones solo en el HTML; los sitios que las publican en una cabecera HTTP o en un sitemap necesitan que estas también se lean, y los sitemaps como fuente de descubrimiento cubre el lado del sitemap. Y check_return_links obtiene cada alternativa que se le proporciona, así que en una página con 150 versiones, conviene muestrear algunas o espaciar las peticiones.
Ponerlo en práctica
- Empieza por una página por plantilla. Un producto, una categoría y una página de artículo suelen compartir un patrón de anotación, así que una petición a cada una muestra cómo mapea el sitio completo sus mercados.
- Clasifica las versiones por las URL propias del conjunto. Almacena la URL que declara cada alternativa, y trata las redirecciones como hallazgos en lugar de ruido.
- Obtén cada versión como un visitante local. Usa una salida en el mercado y el idioma de ese mercado, y luego comprueba que no fuiste redirigido. La ubicación decidió dos de nuestros cuatro casos de redirección, como ocurre en qué países sufren más bloqueo geográfico.
- Valida los códigos antes de confiar en ellos. Un código invertido o inventado puede asignar silenciosamente una página al mercado equivocado.
- Normaliza lo que recopilas. Una vez que tienes cada versión, los precios, fechas y números todavía difieren en formato, como se trata en normalizar precios, números y fechas entre locales, y la comparación en sí es el tema de detectar precios geopersonalizados.
Conclusión
hreflang es lo más parecido que tiene la web a un índice publicado de los mercados de una página. Un tercio de los principales sitios lo proporciona, una sola petición revela el conjunto completo, y la mayor parte del conjunto enlaza entre sí como debería.
Es un mapa, no una garantía. Los códigos pueden estar equivocados, la URL a la que llegas puede no ser la que usa el conjunto, y algunas versiones solo se abren para visitantes en el lugar correcto o con el idioma correcto. Lee las anotaciones, valídalas, y obtén cada versión como lo haría un visitante local.
Fuentes y referencias
- Google Search Central, Informa a Google sobre las versiones localizadas de tu página.
- Tranco, lista Q2K34, que agrega clasificaciones de dominios del 1 al 30 de septiembre de 2026.
- Babel, cuyos datos CLDR proporcionan las listas de idiomas, sistemas de escritura y regiones en el código.
- Páginas de inicio y alternativas obtenidas por Shifter el 1 de octubre de 2026, directamente y a través de salidas de Shifter, usando el código anterior.