Detectando el idioma de un usuario en una aplicación web

Uno de los problemas más comunes en el desarrollo de aplicaciones web es detectar la configuración regional de un usuario. Así se hace de la manera correcta.

Ya sea que estemos desarrollando un blog simple o una sofisticada y moderna aplicación de página única (SPA), a menudo, al considerar i18n en una aplicación web, nos encontramos con una pregunta importante: ¿cómo detectamos la preferencia de idioma de un usuario? Esto es importante porque siempre queremos proporcionar la mejor experiencia de usuario, y si el usuario ha definido un conjunto de idiomas preferidos en su navegador, queremos esforzarnos al máximo para presentar nuestro contenido en esos 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 preferidos que el usuario ha configurado en su navegador.
Objeto navigator.languages del navegador para los ajustes de idioma de la página web | Phrase

La configuración de idioma en Firefox

Dadas las configuraciones anteriores, si abriéramos la consola de Firefox y verificáramos el valor de navigator.languages, obtendríamos lo siguiente:
Valor del objeto navigator.languages de Firefox | Phrase

Los códigos de los locales 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 navigator.languages, recurriendo a navigator.language si la matriz no está disponible. Vale la pena señalar que en algunos navegadores, como Chrome, navigator.language será el idioma de la UI, que probablemente sea el idioma al que está configurado el sistema operativo. Esto es diferente de navigator.languages, que tiene los idiomas preferidos establecidos por el usuario en el propio navegador.

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

Nuestra función también tiene una opción languageCodeOnly práctica, que eliminará los códigos de país de los locales antes de devolverlos. Esto puede ser útil cuando nuestra aplicación no gestiona realmente las sutilezas regionales de un idioma, por ejemplo, solo disponemos de una versión del contenido en inglés.
Con languageCodeOnly: true, obtenemos los idiomas sin especificar países | 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 se ve algo así. 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), luego el árabe egipcio (con un peso de q = 0.5).
Podemos usar este encabezado HTTP estándar para determinar los locales preferidos del usuario. Escribamos una clase llamada HttpAcceptLanguageHeaderLocaleDetector para hacer esto. Usaremos PHP aquí, pero puedes usar cualquier idioma que desees; el encabezado Accept-Language debería ser el mismo (o lo suficientemente similar) 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 el array resultante de locales 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) { // Para cada idioma en la cabecera HTTP Accept-Language
      // separar la clave de localización ("ar-EG") de su peso ("q=0.5")
      $localeParts = explode(';', $locale);
      $weightedLocale = ['locale' => $localeParts[0]];
      si (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 punto flotante que devolvemos aquí a enteros,
      // lo que puede arruinar nuestra clasificación. Así que en lugar de restar el `q`,
      // valores y devolver la diferencia, comparamos los valores `q` y
      // devolver 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 un array 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 el array anterior de mayor a menor valor q.
  4. Extrae los valores de locale del array ordenado, devolviendo un array que se ve como ['en-CA', 'ar-EG'].

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

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

Del lado del 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, podríamos querer usar la dirección IP del usuario para determinar el país del usuario e inferir la configuración regional o el 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.

Usando 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, y son de baja latencia y por 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 dice que son menos precisas que sus contrapartes comerciales, son más que suficientes para comenzar. 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 los locales o 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';
Usar GeoIp2\Database\Reader;
Usar 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) {
      devuelve null;
    }
  }
  función estática privada getIpAddress()
  {
    return $_SERVER['REMOTE_ADDR'];
  }
  función estática privada 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 un array, 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.

Del lado del servidor: Detección de locales 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, consulta Phrase: una plataforma i18n 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

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 usuarios internacionales.

Software localization blog category featured image | Phrase

Blog post

La Guía Definitiva para la Localización de Flutter

Descodifiquemos los secretos de la localización de Flutter para que puedas hablar el idioma de tus usuarios y seguir codificando tu camino hacia la dominación global.

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 de la app de iOS de forma inalámbrica con Phrase Strings

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