Detectar la localidad del usuario en una aplicación web

Uno de los problemas más comunes en el desarrollo de aplicaciones web es detectar la localidad del usuario. Esto es la manera correcta de hacerlo.

Ya estemos desarrollando un blog simple o una sofisticada y moderna aplicación de página única (SPA), a menudo, al considerar la internacionalización en una aplicación web, surge una pregunta importante: ¿cómo detectamos la preferencia de idioma de un usuario? Es importante porque siempre queremos proporcionar la mejor experiencia de usuario, y si este ha establecido un conjunto de idiomas preferidos en su navegador, debemos esforzarnos al máximo para presentar nuestro contenido en dichos idiomas.
En este artículo, veremos tres formas diferentes de detectar la configuración regional de un usuario: a través del objeto navigator.languages del navegador (en el cliente), a través del encabezado HTTP Accept-Language (en el servidor) y mediante la geolocalización usando la dirección IP del usuario (en el servidor).

Cliente: El objeto navigator.languages

Los navegadores modernos proporcionan un objeto navigator.language que podemos usar para obtener todos los idiomas que el usuario ha configurado como preferidos en su navegador.
Objeto navigator.languages del navegador para los ajustes de idioma de la página web | Phrase

Configuración del idioma en Firefox

Dada la configuración anterior, si abriéramos la consola de Firefox y comprobáramos el valor de navigator.languages, obtendríamos lo siguiente:
Valor del objeto navigator.languages de Firefox | Phrase

Los códigos de las localizaciones coinciden con los de la configuración del navegador

navigator.languages está disponible en todos los navegadores web modernos y, generalmente, es fiable. Así que escribamos una función reutilizable en JavaScript que nos indique los idiomas preferidos del usuario actual.

function getBrowserLocales(options = {}) {
  const defaultOptions = {
    languageCodeOnly: false,
  };
  const opt = {
    ...defaultOptions,
    ...options,
  };
  const browserLocales =
    navigator.languages === undefined
      ? [navigator.language]
      : navigator.languages;
  if (!browserLocales) {
    return undefined;
  }
  return browserLocales.map(locale => {
    const trimmedLocale = locale.trim(); // Elimina los espacios en blanco al principio y al final del locale.
    return opt.languageCodeOnly
      ? trimmedLocale.split(/-|_/)[0]
      : trimmedLocale;
  });
}

getBrowserLocales() comprueba la matriz de navigator.languages, y recurre a navigator.language si esta no está disponible. Cabe señalar que en algunos navegadores, como Chrome, navigator.language será el idioma de la IU, que probablemente sea el idioma al que está configurado el sistema operativo. No es lo mismo que navigator.languages, que contiene los idiomas preferidos establecidos por el usuario en el propio navegador.

✋🏽 Atención » Si ofreces compatibilidad con Internet Explorer, tendrás que usar las propiedades navigator.userLanguage y navigator.browserLanguage. Por supuesto, también tendrás que reemplazar todas las instancias de const con var en el código anterior.

Nuestra función también tiene una práctica opción languageCodeOnly, que eliminará los códigos de país de las regiones antes de devolverlos. Esto puede ser útil cuando nuestra aplicación no gestiona realmente las sutilezas regionales de un idioma (si, por ejemplo, solo disponemos de una versión del contenido en inglés).
Con languageCodeOnly: true, obtenemos los idiomas sin el código de país | Phrase

Con languageCodeOnly: true, obtenemos los idiomas sin el código de país

Servidor: El encabezado HTTP Accept-Language

Si configuras tus preferencias de idioma en un navegador moderno, el navegador enviará a su vez una cabecera HTTP que transmite estas preferencias de idioma al servidor con cada solicitud. Este es el encabezado Accept-Language, y a menudo tiene un aspecto como este: Accept-Language: en-CA,ar-EG;q=0.5.
El encabezado enumera los idiomas preferidos del usuario, con un peso definido por un valor de q, asignado a cada uno. Cuando no se especifica un valor q explícito, se asume por defecto 1.0. Así que en el valor del encabezado anterior, el cliente está indicando que el usuario prefiere el inglés canadiense (con un peso de q = 1.0) y luego el árabe egipcio (con un peso de q = 0.5).
Podemos usar este encabezado HTTP estándar para determinar las regiones preferidas del usuario. Escribamos una clase llamada HttpAcceptLanguageHeaderLocaleDetector para hacer esto. Aquí usaremos PHP, pero puedes usar el lenguaje que quieras; en cualquier caso, el encabezado Accept-Language debería ser el mismo (o lo bastante parecido) en todos los entornos.

<?php
class HttpAcceptLanguageHeaderLocaleDetector
{
  const HTTP_ACCEPT_LANGUAGE_HEADER_KEY = 'HTTP_ACCEPT_LANGUAGE';
  public static function detect()
  {
    $httpAcceptLanguageHeader = static::getHttpAcceptLanguageHeader();
    if ($httpAcceptLanguageHeader == null) {
      return [];
    }
    $locales = static::getWeightedLocales($httpAcceptLanguageHeader);
    $sortedLocales = static::sortLocalesByWeight($locales);
    return array_map(function ($weightedLocale) {
      return $weightedLocale['locale'];
    }, $sortedLocales);
  }
  private static function getHttpAcceptLanguageHeader()
  {
    if (isset($_SERVER[static::HTTP_ACCEPT_LANGUAGE_HEADER_KEY])) {
      return trim($_SERVER['HTTP_ACCEPT_LANGUAGE']);
    } else {
      return null;
    }
  }
  private static function getWeightedLocales($httpAcceptLanguageHeader)
  {
    if (strlen($httpAcceptLanguageHeader) == 0) {
      return [];
    }
    $weightedLocales = [];
    // Dividimos la cadena 'en-CA,ar-EG;q=0.5' por las comas,
    // y vamos a iterar sobre la matriz resultante de regiones individuales. Una vez
    // hemos terminado, $weightedLocales debería verse como
    // [['locale' => 'en-CA', 'q' => 1.0], ['locale' => 'ar-EG', 'q' => 0.5]]
    foreach (explode(',', $httpAcceptLanguageHeader) as $locale) {
      // separar la clave de región ("ar-EG") de su peso ("q=0.5")
      $localeParts = explode(';', $locale);
      $weightedLocale = ['locale' => $localeParts[0]];
      if (count($localeParts) == 2) {
        // peso explícito p. ej. 'q=0.5'
        $weightParts = explode('=', $localeParts[1]);
        // obtener la parte '0.5' y convertirla a un float
        $weightedLocale['q'] = floatval($weightParts[1]);
      } else {
        // no se proporciona peso en la cadena; es decir, un peso implícito de 'q=1.0'
        $weightedLocale['q'] = 1.0;
      }
      $weightedLocales[] = $weightedLocale;
    }
    return $weightedLocales;
  }
  /**
   * Ordenar por valor `q` de mayor a menor
   */
  private static function sortLocalesByWeight($locales)
  {
    usort($locales, function ($a, $b) {
      // usort forzará el tipo de los valores de float que devolvemos aquí a enteros,
      // lo que puede arruinar nuestra clasificación. Así que en lugar de restar los valores `q`,
      // y devolver la diferencia, comparamos los valores `q` y
      // devolvemos explícitamente valores enteros.
      if ($a['q'] == $b['q']) {
        return 0;
      }
      if ($a['q'] > $b['q']) {
        return -1;
      }
      return 1;
    });
    return $locales;
  }
}

Este largo fragmento de código en realidad no es muy complicado. En el único método público, detect(), nuestra clase hace lo siguiente:

  1. Obtiene el valor de cadena sin procesar del encabezado Accept-Language, por ejemplo, "en-CA,ar-EG;q=0.5"
  2. Utiliza el método auxiliar getWeightedLocales() para analizar la cadena del encabezado en una matriz que se ve como [['locale' => 'en-CA', 'q' => 1.0], ['locale' => 'ar-EG', 'q' => 0.5]].
  3. Utiliza el método auxiliar sortLocalesByWeight() para clasificar la matriz anterior de mayor a menor valor q.
  4. Extrae los valores de locale de la matriz ordenada, devolviendo una matriz que se ve como ['en-CA', 'ar-EG'].

Ahora podemos usar nuestra nueva clase para obtener una práctica matriz de códigos de idioma a partir del encabezado HTTP Accept-Language.

<?php
$locales = HttpAcceptLanguageHeaderLocaleDetector::detect();
// => ['en-CA', 'ar-EG']

Servidor: Geolocalización por dirección IP

A veces, el encabezado Accept-Language no estará presente en las solicitudes a nuestro servidor. En estos casos, nos puede interesar usar la dirección IP del usuario para determinar su país y detectar la localidad del usuario o su idioma a partir de ese país.

✋🏽 Atención » La geolocalización debe usarse como último recurso para detectar la configuración regional del usuario, ya que a menudo puede provocar una detección incorrecta. Por ejemplo, si vemos que nuestro usuario viene de Canadá, ¿asumimos que prefiere inglés o francés? Ambos son idiomas formales y ampliamente utilizados en el país. Y, por supuesto, el usuario podría pertenecer a una minoría de habla árabe, o ser un visitante de habla española.

Usar MaxMind para la geolocalización

Para determinar el país del usuario a partir de la dirección IP de la solicitud, utilizaremos la API de MaxMind para PHP y la base de datos de geolocalización de MaxMind. MaxMind es una empresa que ofrece algunos productos relacionados con IP, y entre ellos hay dos que son de interés para nosotros aquí.

  • Las bases de datos GeoIP2: estas son las bases de datos de geolocalización comerciales de MaxMind, son de baja latencia y requieren suscripción. Puede que quieras actualizar a estas si quieres bases de datos más actualizadas o rápidas.
  • Las bases de datos GeoLite2: estas son las bases de datos de geolocalización gratuitas de MaxMind, y aunque se suele decir que son menos precisas que sus contrapartidas comerciales, son más que suficientes si estás empezando. Usaremos una base de datos GeoLite2 aquí. Ten en cuenta que tendrás que dar crédito a Maxmind en tu página web pública y enlazar a su sitio si usas una de sus bases de datos gratuitas.

Para instalar la base de datos, solo regístrate para obtener una cuenta gratuita de MaxMind. Recibirás un correo electrónico con un enlace para iniciar sesión. Sigue el enlace e inicia sesión. Una vez que lo hagas, deberías llegar a la página Resumen de Cuenta.
Descargar bases de datos de MaxMind | Phrase

Haz clic en el enlace Descargar bases de datos en la página Resumen de cuenta

Esto te llevará a una página con la lista de bases de datos GeoLite2 gratuitas. Descarga la base de datos binaria del país de allí.
Base de datos binaria de países de MaxMind | Phrase

Queremos la base de datos binaria del país para nuestros propósitos

Coloca el archivo que descargaste en algún lugar de tu proyecto.
También vamos a necesitar la API de PHP de MaxMind para trabajar con la base de datos. Podemos instalar eso con Composer.

composer require geoip2/geoip2:~2.0

Paquete de país a configuración regional de Peter Kahl

Necesitaremos un paquete más antes de llegar a nuestro código. Para determinar las regiones (localidades) o los idiomas de un país, utilizaremos el paquete country-to-locale de Peter Kahl. Podemos instalarlo usando Composer también.

composer require peterkahl/country-to-locale

Clase de detección de ubicación por dirección IP

Con nuestra configuración en su lugar, podemos pasar a nuestra propia clase, IpAddressLocaleDetector.

<?php
require '../vendor/autoload.php';
use GeoIp2\Database\Reader;
use peterkahl\locale\locale;
class IpAddressLocaleDetector
{
  const MAX_MIND_DB_FILEPATH =
    __DIR__ . '/GeoLite2-Country_20200121/GeoLite2-Country.mmdb';
  private static $maxMindDbReader;
  public static function detect()
  {
    $ipAddress = static::getIpAddress();
    try {
      $record = static::getMaxMindDbReader()->country($ipAddress);
      $locales = locale::country2locale($record->country->isoCode);
      $normalizedLocales = str_replace('_', '-', $locales);
      return explode(',', $normalizedLocales);
    } catch (Exception $ex) {
      return null;
    }
  }
  private static function getIpAddress()
  {
    return $_SERVER['REMOTE_ADDR'];
  }
  private static function getMaxMindDbReader()
  {
    if (static::$maxMindDbReader == null) {
      static::$maxMindDbReader = new Reader(static::MAX_MIND_DB_FILEPATH);
    }
    return static::$maxMindDbReader;
  }
}

Nuestra clase es relativamente sencilla. Al igual que HttpAcceptLanguageHeaderLocaleDetector, tiene un método público, detect(), que hace lo siguiente:

  1. Obtén la dirección IP de la petición desde la matriz global $_SERVER.
  2. Alimenta esta dirección IP al método country de Reader de la base de datos MaxMind, que intenta geolocalizar un país en función de la dirección IP.
  3. Utiliza locale::country2locale() de Peter Kahl para obtener los idiomas del país especificado.
  4. Normaliza los locales adquiridos de modo que "en_CA,ar_EG" se conviertan en "en-CA,ar-EG".
  5. Devuelve las configuraciones regionales que normalizó como una matriz, por ejemplo ["en-CA", "ar-EG"].

📖 Profundiza más » El Reader de MaxMind tiene muchos más métodos. Consulta la documentación oficial de la API si deseas profundizar un poco más en la información disponible en las bases de datos de MaxMind.

Servidor: Detección la localidad o región en cascada

Dadas las dos estrategias de detección del lado del servidor que cubrimos anteriormente, podemos escribir una pequeña función detect_user_locales() que puede intentar primero la estrategia del encabezado HTTP.

<?php
require './HttpAcceptLanguageHeaderLocaleDetector.php';
require './IpAddressLocaleDetector.php';
function detect_user_locales()
{
  $locales = HttpAcceptLanguageHeaderLocaleDetector::detect();
  if (count($locales) == 0) {
    $locales = IPAddressLocaleDetector::detect();
  }
  if (count($locales) == 0) {
    // usar una configuración regional predeterminada, inglés en este caso
    $locales = ['en'];
  }
  return $locales;
}

Si la detección del encabezado HTTP falla, detect_user_locales() intentará la detección de geolocalización IP. Si lo último no da resultado, la función recurrirá a una configuración regional predeterminada.
Si se maneja con cuidado, detectar la configuración regional del usuario puede ayudar a ofrecer una mejor experiencia de usuario en nuestras aplicaciones web. Afortunadamente, el objeto navigator.languages y el encabezado HTTP Accept-Language están disponibles para reducir las incertidumbres al momento de detectar la configuración regional del usuario.

Si tú y tu equipo estáis trabajando en una aplicación web internacionalizada, echad un vistazo a Phrase, una plataforma de internacionalización profesional pensada para desarrolladores. Con una CLI y API flexibles, sincronización de traducciones con integración de GitHub y Bitbucket, traducciones OTA y mucho más, Phrase se encarga de tu i18n, para que puedas concentrarte en la lógica de tu negocio.

Consulta todas las características de Phrase para desarrolladores y comprueba por ti mismo cómo puede optimizar tus flujos de trabajo de localización de software.

Publicaciones relacionadas

Blog post

Localizar juegos de Unity con el plugin oficial de Phrase

¿Quieres localizar tu juego de Unity sin el caos de CSV? Descubre cómo el plugin oficial de Phrase Strings para Unity simplifica el flujo de trabajo de localización de tu juego, desde la configuración de la tabla de cadenas hasta la obtención de traducciones directamente en tu proyecto. Ya estés desarrollando para alemán, serbio o cualquier otro idioma, esta guía muestra cómo empezar rápido y localizar como un experto.

Software localization blog category featured image | Phrase

Blog post

La guía definitiva para la localización de JavaScript

Pon en marcha la localización de JavaScript para tu navegador con esta guía completa y prepara tu aplicación para los usuarios internacionales.

Software localization blog category featured image | Phrase

Blog post

Guía definitiva para la localización Flutter

Vamos a descifrar los secretos de la localización Flutter para que puedas hablar el idioma de tus usuarios y seguir tu camino hacia la dominación global, línea de código tras línea de código.

Software localization blog category featured image | Phrase

Blog post

Usa el mejor plugin traductor de Phrase Strings para WordPress

Aprende a traducir páginas, publicaciones y más de WordPress a múltiples idiomas con la integración de Phrase Strings para WordPress.

Software localization blog category featured image | Phrase

Blog post

Traducción Over-the-air de apps en iOS con Phrase Strings

Si quieres asegurarte de que todos los usuarios de tu aplicación obtengan el texto correcto, la función de traducción Over-the-Air de Phrase puede ayudarte a publicar tus actualizaciones de traducción al instante.