Las etiquetas Alt son el aspecto más conocido de la creación de páginas web accesibles, pero lamentablemente a menudo se descuidan o se implementan de forma deficiente. Las etiquetas Alt son breves descripciones de texto que se añaden a las imágenes. Los lectores de pantalla leen el contenido de una página web a los usuarios, y las descripciones de las imágenes son lo que leen para comunicar lo que hay en las imágenes de la página a los usuarios con discapacidad visual, ya que no pueden verlas.
Lamentablemente, es común que a las imágenes les falten por completo las etiquetas alt. También he visto etiquetas alt mal utilizadas de manera que dificultan las cosas para un usuario con discapacidad visual, como etiquetas que solo dicen "imagen" o "fotografía", etiquetas que son títulos cursis que un autor agregó sin ninguna referencia a lo que hay en la imagen (es decir, una imagen de un café y una computadora portátil en una página de blog, con el título "querido diario, me encantaría ser seleccionado como escritor invitado"). También he visto etiquetas alt que incluyen 3 líneas de palabras clave de SEO. ¿Te imaginas intentar escuchar lo que hay en un sitio web solo para escuchar "imagen de imagen" o una larga lista de palabras clave de SEO?
Esta es una extensión de Chrome diseñada para ayudar a los usuarios con discapacidad visual a sobrescribir etiquetas Alt incorrectas y aprovechar Open AI para insertar descripciones generadas por IA. Esto permite que un usuario con discapacidad visual acceda a todo el contenido de una página web al que un usuario sin discapacidad visual puede acceder (o al menos no verse ralentizado por una larga lista de palabras clave de SEO).
Si solo desea la extensión, puede descargar este repositorio y seguir las instrucciones en el README.
Sin embargo, si estás interesado en una guía paso a paso sobre cómo crear una extensión de Chrome con OpenAI, a continuación encontrarás un tutorial.
Primero, vamos a poner en funcionamiento un código básico de Chrome. Clone este repositorio y siga las instrucciones del README:
Una vez que lo tengas instalado, deberías tener un ícono de imagen en tu barra de extensión (recomiendo fijarlo para que la prueba sea más rápida) y cuando hagas clic en él, deberías ver una ventana emergente con "Hola mundo".
Abramos el código repetitivo y revisemos los archivos existentes. Esto también cubrirá algunos conceptos básicos de las extensiones de Chrome:
Static/manifest.json : cada extensión de Chrome tiene un archivo manifest.json. Este incluye información básica y configuración sobre la extensión. En nuestro archivo Manifest, tenemos un nombre, una descripción, un archivo de fondo configurado como src/background.js, un ícono configurado como image-icon.png (este es el ícono que aparecerá representando la extensión en el menú de extensiones) y configura popup.html como la fuente del archivo para nuestra ventana emergente.
src/background.js : un archivo background.js configurado en nuestro manifiesto. El código de este archivo se ejecutará en segundo plano y supervisará los eventos que activan la funcionalidad de la extensión.
src/content.js : cualquier script que se ejecute en el contexto de la página web o que modifique la página web se coloca en un script de contenido.
src/popup.js, static/popup.css y static/popup.html : estos archivos controlan la ventana emergente que ves cuando haces clic en el ícono de la extensión.
Configuremos algunos conceptos básicos: abra static/manifest.json y cambie el nombre y la descripción a “Generador de descripción de imágenes del lector de pantalla” (o lo que prefiera).
Habilitar la interacción con páginas web mediante un script de contenido
Nuestra extensión va a sobrescribir las etiquetas alt en el sitio web en el que se encuentra el usuario, lo que significa que necesitamos acceder al código HTML de la página. La forma de hacerlo en las extensiones de Chrome es a través de scripts de contenido. Nuestro script de contenido estará en nuestro archivo src/content.js.
La forma más sencilla de inyectar un script de contenido es añadiendo un campo "scripts" al manifiesto con una referencia a un archivo js. Cuando se configura un script de contenido de esta manera, el script vinculado se ejecutará siempre que se cargue la extensión. Sin embargo, en nuestro caso, no queremos que nuestra extensión se ejecute automáticamente cuando un usuario la abra. Algunos sitios web tienen etiquetas alt perfectamente adecuadas configuradas en las imágenes, por lo que queremos ejecutar el código solo cuando el usuario decida que es necesario.
Vamos a agregar un botón en nuestra ventana emergente y un registro de consola en nuestro script de contenido, de modo que cuando el usuario haga clic en el botón, se cargue el script de contenido y podamos confirmarlo viendo nuestra declaración impresa en la consola de Chrome.
Ventana emergente.html
<button id="generate-alt-tags-button">Generate image descriptions</button>
fuente/contenido.js
console.log('hello console')
La forma de conectar ese clic del botón en la ventana emergente al script de contenido involucra tanto popup.js como background.js.
En popup.js, tomaremos el botón del DOM y agregaremos un detector de eventos. Cuando un usuario haga clic en ese botón, enviaremos un mensaje indicando que se debe inyectar el script de contenido. Llamaremos al mensaje "injectContentScript".
const generateAltTagButton = document.body.querySelector('#generate-alt-tags-button'); generateAltTagButton.addEventListener('click', async () => { chrome.runtime.sendMessage({action: 'injectContentScript'}) });
En background.js, tenemos el código que monitorea los eventos y reacciona ante ellos. Aquí, estamos configurando un detector de eventos y, si el mensaje recibido es "injectContentScript", ejecutará el script de contenido en la pestaña activa (la página web actual del usuario).
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { if (message.action === 'injectContentScript') { chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => { chrome.scripting.executeScript({ target: { tabId: tabs[0].id }, files: ['content.js'] }); }); } });
El último paso para configurar esto es agregar los permisos "activeTab" y "scripting" a nuestro manifiesto. El permiso "scripting" es necesario para ejecutar cualquier script de contenido. También tenemos que agregar permisos para las páginas en las que inyectamos el script. En este caso, inyectaremos el script en el sitio web actual del usuario, es decir, su pestaña activa, y eso es lo que permite el permiso activeTab.
En manifest.json:
"permissions": [ "activeTab", "scripting" ],
En este punto, es posible que tengas que eliminar la extensión de Chrome y volver a cargarla para que se ejecute correctamente. Una vez que se esté ejecutando, deberíamos ver el registro de nuestra consola en nuestra consola de Chrome.
Aquí hay un enlace de Github para el código de trabajo para el repositorio en esta etapa .
Recopilación de imágenes de páginas e inserción de etiquetas alt de prueba
Nuestro próximo paso es usar nuestro archivo de script de contenido para capturar todas las imágenes de la página, de modo que tengamos esa información lista para enviar nuestras llamadas API para obtener descripciones de imágenes. También queremos asegurarnos de que solo estamos haciendo llamadas para imágenes donde sea útil tener descripciones. Algunas imágenes son puramente decorativas y no tienen ninguna necesidad de ralentizar los lectores de pantalla con sus descripciones. Por ejemplo, si tienes una barra de búsqueda que tiene la etiqueta "buscar" y un ícono de lupa. Si una imagen tiene su etiqueta alt configurada en una cadena vacía, o tiene aria-hidden configurado en verdadero, eso significa que la imagen no necesita ser incluida en un lector de pantalla y podemos omitir la generación de una descripción para ella.
Primero, en content.js, recopilaremos todas las imágenes de la página. Voy a agregar un console.log para poder confirmar rápidamente que funciona correctamente:
const images = document.querySelectorAll("img"); console.log(images)
Luego, recorreremos las imágenes y buscaremos las imágenes para las que necesitamos generar una etiqueta alt. Esto incluye todas las imágenes que no tienen una etiqueta alt, las imágenes que tienen una etiqueta alt que no es una cadena vacía y las imágenes que no se han ocultado explícitamente a los lectores de pantalla con el atributo aria-hidden.
for (let image of images) { const imageHasAltTag = image.hasAttribute('alt'); const imageAltTagIsEmptyString = image.hasAttribute('alt') && image.alt === ""; const isAriaHidden = image.ariaHidden ?? false; if (!imageHasAltTag || !imageAltTagIsEmptyString || !isAriaHidden) { // this is an image we want to generate an alt tag for! } }
Luego, podemos agregar una cadena de prueba para configurar las etiquetas alt, de modo que podamos confirmar que tenemos una forma funcional de configurarlas antes de continuar con nuestras llamadas OpenAI. Nuestro content.js ahora se ve así:
function scanPhotos() { const images = document.querySelectorAll("img"); console.log(images) for (let image of images) { const imageHasAltTag = image.hasAttribute('alt'); const imageAltTagIsEmptyString = image.hasAttribute('alt') && image.alt === ""; const isAriaHidden = image.ariaHidden ?? false; if (!imageHasAltTag || !imageAltTagIsEmptyString || !isAriaHidden) { image.alt = 'Test Alt Text' } } } scanPhotos()
En este punto, si abrimos las herramientas de desarrollo de Chrome Elements y hacemos clic en una imagen, deberíamos ver “Test Alt Text” configurado como etiqueta alt.
Aquí encontrará un repositorio funcional que muestra dónde se encuentra el código en esta etapa.
Instalar OpenAI y generar descripciones de imágenes
Para utilizar OpenAI, deberá generar una clave OpenAI y también agregar crédito a su cuenta. Para generar una clave OpenAI:
Guarde esta clave. Además, manténgala privada: asegúrese de no enviarla a ningún repositorio público de Git.
Ahora, de nuevo en nuestro repositorio, primero queremos instalar OpenAi. En la terminal dentro del directorio del proyecto, ejecuta:
npm install openai
Ahora en content.js, importaremos OpenAI agregando este código en la parte superior del archivo, con su clave OpenAI pegada en la línea 1:
const openAiSecretKey = 'YOUR_KEY_GOES_HERE' import OpenAI from "openai"; const openai = new OpenAI({ apiKey: openAiSecretKey, dangerouslyAllowBrowser: true });
“DangerouslyAllowBrowser” permite que la llamada se realice con su clave desde el navegador. Por lo general, esta es una práctica insegura. Dado que solo estamos ejecutando este proyecto localmente, lo dejaremos así, en lugar de configurar una recuperación de back-end. Si usa OpenAI en otros proyectos, asegúrese de seguir las mejores prácticas con respecto a mantener la clave en secreto.
Ahora agregamos nuestra llamada para que OpenAI genere descripciones de imágenes. Estamos llamando al punto final de finalización de chat ( documentación de OpenAI para el punto final de finalización de chat ).
Tenemos que escribir nuestro propio mensaje y también pasar la URL de origen de la imagen ( más información sobre la ingeniería de mensajes de IA ). Puedes adaptar el mensaje como quieras. Elegí limitar las descripciones a 20 obras porque OpenAI estaba devolviendo descripciones largas. Además, noté que estaba describiendo completamente logotipos como los logotipos de Yelp o Facebook (es decir, "un gran cuadro azul con una f minúscula blanca adentro"), lo que no era útil. En caso de que sea una infografía, pido que se ignore el límite de palabras y se comparta el texto completo de la imagen.
Aquí está la llamada completa, que devuelve el contenido de la primera respuesta de IA y también pasa el error a una función "handleError". He incluido un console.log de cada respuesta para que podamos obtener una respuesta más rápida sobre si la llamada es exitosa o no:
async function generateDescription(imageSrcUrl) { const response = await openai.chat.completions.create({ model: "gpt-4o-mini", messages: [ { role: "user", content: [ { type: "text", text: "Describe this image in 20 words or less. If the image looks like the logo of a large company, just say the company name and then the word logo. If the image has text, share the text. If the image has text and it is more than 20 words, ignore the earlier instruction to limit the words and share the full text."}, { type: "image_url", image_url: { "url": imageSrcUrl, }, }, ], }, ], }).catch(handleError); console.log(response) if (response) { return response.choices[0].message.content;} } function handleError(err) { console.log(err); }
Agregamos una llamada a esta función en la declaración if que escribimos antes (también tenemos que agregar una palabra clave async al comienzo de la función scanImages para incluir esta llamada asincrónica):
const imageDescription = await generateDescription(image.src) if (!imageDescription) { return; } image.alt = imageDescription
Aquí hay un enlace al contenido completo .js y al repositorio en este momento.
Desarrollando la interfaz de usuario
A continuación, queremos crear nuestra interfaz de usuario para que el usuario sepa qué está sucediendo después de hacer clic en el botón para generar las etiquetas. Las etiquetas tardan unos segundos en cargarse, por lo que queremos un mensaje de "carga" para que el usuario sepa que está funcionando. Además, queremos informarle si se ha realizado correctamente o si hay un error. Para simplificar las cosas, tendremos un div de mensaje de usuario general en el HTML y luego usaremos popup.js para insertar dinámicamente el mensaje apropiado para el usuario en función de lo que esté sucediendo en la extensión.
La forma en que se configuran las extensiones de Chrome hace que nuestro script de contenido (content.js) esté separado de nuestro popup.js y no puedan compartir variables como lo hacen los archivos JavaScript típicos. La forma en que el script de contenido puede informar a la ventana emergente que las etiquetas se están cargando o que se cargaron correctamente es mediante el paso de mensajes. Ya usamos el paso de mensajes cuando le informamos al trabajador en segundo plano que debe inyectar el script de contenido cuando un usuario hace clic en el botón original.
Primero, en nuestro código HTML, agregaremos un div con el id 'user-message' debajo de nuestro botón. También agregué un poco más de descripción para el mensaje inicial.
<div id="user-message"> <img src="image-icon.png" width="40" class="icon" alt=""/> This extension uses OpenAI to generate alternative image descriptions for screen readers. </div>
Luego, en nuestro archivo popup.js, agregaremos un detector que escuche cualquier mensaje enviado que pueda contener una actualización del estado de la extensión. También escribiremos algo de código HTML para inyectar en función del resultado de estado que obtengamos del script de contenido.
const userMessage = document.body.querySelector('#user-message'); chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { renderUI(message.action) } ); function renderUI(extensionState) { generateAltTagButton.disabled=true; if (extensionState === 'loading') { userMessage.innerHTML = '<img src="loading-icon.png" width="50" class="icon" alt=""/> New image descriptions are loading... <br> <br>Please wait. We will update you when the descriptions have loaded.' } else if (extensionState === 'success') { userMessage.innerHTML = '<img src="success-icon.png" width="50" class="icon" alt=""/> New image descriptions have been loaded! <br> <br> If you would like to return to the original image descriptions set by the web page author, please refresh the page.' } else if (extensionState === 'errorGeneric') { userMessage.innerHTML = '<img src="error-icon.png" width="50" class="icon"alt=""/> There was an error generating new image descriptions. <br> <br> Please refresh the page and try again.' } else if (extensionState === 'errorAuthentication') { userMessage.innerHTML = '<img src="error-icon.png" width="50" class="icon"alt=""/> There was an error generating new image descriptions. <br> <br> Your OpenAI key is not valid. Please double check your key and try again.' } else if (extensionState === 'errorMaxQuota') { userMessage.innerHTML = '<img src="error-icon.png" width="50" class="icon"alt=""/> There was an error generating new image descriptions. <br> <br> You\'ve either used up your current OpenAI plan and need to add more credit, or you\'ve made too many requests too quickly. Please check your plan, add funds if needed, or slow down the requests.' } }
Dentro de nuestro script de contenido, definiremos una nueva variable llamada "extensionState", que puede ser "initial" (la extensión está cargada pero aún no ha sucedido nada), "loading", "success" o "error" (agregaremos otros estados de error también basados en los mensajes de error de OpenAI). También actualizaremos la variable de estado de la extensión y enviaremos un mensaje a popup.js cada vez que cambie el estado.
let extensionState = 'initial';
Nuestro controlador de errores se convierte en:
function handleError(err) { if (JSON.stringify(err).includes('401')) { extensionState = 'errorAuthentication' chrome.runtime.sendMessage({action: extensionState}) } else if (JSON.stringify(err).includes('429')) { extensionState = 'errorMaxQuota' chrome.runtime.sendMessage({action: extensionState}) } else { extensionState = 'errorGeneric' chrome.runtime.sendMessage({action: extensionState}) } console.log(err); }
Y dentro de nuestra función scanPhotos, establecemos el estado en 'cargando' al comienzo de la función, y en 'éxito' si se ejecuta completamente sin errores.
async function scanPhotos() { extensionState = 'loading' chrome.runtime.sendMessage({action: extensionState}) const images = document.querySelectorAll("img"); for (let image of images) { const imageHasAltTag = image.hasAttribute('alt'); const imageAltTagIsEmptyString = image.hasAttribute('alt') && image.alt === ""; const isAriaHidden = image.ariaHidden ?? false; if (!imageHasAltTag || !imageAltTagIsEmptyString || !isAriaHidden) { const imageDescription = await generateDescription(image.src) if (!imageDescription) { return; } image.alt = imageDescription } } extensionState = 'success' chrome.runtime.sendMessage({action: extensionState}) }
Corrección del comportamiento confuso de las ventanas emergentes: conservación del estado de la extensión cuando las ventanas emergentes se cierran y vuelven a abrir
En este punto, puede notar que si genera etiquetas alt, recibe un mensaje de éxito y cierra y vuelve a abrir la ventana emergente, se mostrará el mensaje inicial que solicita al usuario que genere nuevas etiquetas alt. ¡Aunque las etiquetas alt generadas ahora están en el código!
En Chrome, cada vez que abres una ventana emergente de extensión, se trata de una ventana emergente completamente nueva. No recordará nada de lo que haya hecho la extensión anteriormente ni lo que se esté ejecutando en el script de contenido. Sin embargo, podemos asegurarnos de que cualquier ventana emergente recién abierta muestre el estado preciso de la extensión haciendo que llame y verifique el estado de la extensión cuando se abra. Para ello, haremos que una ventana emergente pase otro mensaje, esta vez solicitando el estado de la extensión, y agregaremos un detector de mensajes en nuestro content.js que escuche ese mensaje y envíe de vuelta el estado actual.
ventana emergente.js
chrome.tabs.query({active: true, currentWindow: true}, function(tabs) { chrome.tabs.sendMessage(tabs[0].id, {action: "getExtensionState"}, function(response) { // if the content script hasn't been injected, then the code in that script hasn't been run, and we'll get an error or no response if (chrome.runtime.lastError || !response) { return; } else if (response) { // if the code in content script HAS been injected, we'll get a response which tells us what state the code is at (loading, success, error, etc) renderUI(response.extensionState) } }); });
contenido.js
chrome.runtime.onMessage.addListener( function(request, sender, sendResponse) { if (request.action === "getExtensionState") sendResponse({extensionState}); });
Si el script de contenido nunca se ha ejecutado (es decir, el usuario nunca hizo clic en el botón para generar etiquetas alt), no habrá ninguna variable de estado de extensión ni detector de eventos. En este caso, Chrome devolverá un error de tiempo de ejecución como respuesta. Por lo tanto, incluimos una verificación de errores y, si recibimos uno, dejamos la interfaz de usuario predeterminada como está.
Accesibilidad de extensiones: aria-live, contraste de color y botón de cierre
Esta extensión está diseñada para personas que usan lectores de pantalla, por lo que ahora debemos asegurarnos de que realmente se pueda usar con un lector de pantalla. Ahora es un buen momento para activar el lector de pantalla y ver si todo funciona bien.
Hay algunas cosas que queremos mejorar para mejorar la accesibilidad. En primer lugar, queremos asegurarnos de que todo el texto tenga un nivel de contraste lo suficientemente alto. Para el botón, decidí configurar el fondo en #0250C5 y la fuente en negrita blanca. Esto tiene una relación de contraste de 7,1 y cumple con WCAG tanto en los niveles AA como AAA. Puede comprobar las relaciones de contraste de los colores que desee utilizar aquí en el Comprobador de contraste de WebAim.
En segundo lugar, cuando uso mi lector de pantalla, observo que el lector de pantalla no lee automáticamente las actualizaciones cuando el mensaje del usuario cambia a un mensaje de carga, éxito o error. Para solucionar esto, usaremos un atributo html llamado aria-live. Aria-live permite a los desarrolladores avisar a los lectores de pantalla que deben actualizar a los usuarios sobre los cambios. Puede configurar aria-live como asertivo o cortés: si está configurado como asertivo, las actualizaciones se leerán de inmediato, independientemente de si hay otros elementos esperando ser leídos en la cola del lector de pantalla. Si está configurado como cortés, la actualización se leerá al final de todo lo que el lector de pantalla esté en proceso de leer. En nuestro caso, queremos actualizar al usuario lo antes posible. Entonces, en popup-container, el elemento padre de nuestro elemento user-message, agregaremos ese atributo.
<div class="popup-container" aria-live="assertive">
Por último, al utilizar el lector de pantalla, me doy cuenta de que no hay una forma sencilla de cerrar la ventana emergente. Cuando se utiliza un ratón, basta con hacer clic en cualquier lugar fuera de la ventana emergente y esta se cierra, pero no consigo averiguar cómo cerrarla con el teclado. Por eso, añadiremos un botón de "cerrar" en la parte inferior de la ventana emergente, para que los usuarios puedan cerrarla fácilmente y volver a la página web.
En popup.html, agregamos:
<div> <button id="close-button">Close</button> </div>
En popup.js, agregamos la función de cierre al onclick:
const closeButton = document.body.querySelector('#close-button'); closeButton.addEventListener('click', async () => { window.close() });
¡Y eso es todo! Si tienes alguna pregunta o sugerencia, no dudes en contactarnos.