DevExtractor por dentro: lo que hay detrás de una extensión que parece simple
Cómo funciona una extensión de Chrome que extrae imágenes, SVG inline, enlaces y fuentes de cualquier web y sustituye imágenes en vivo. Manifest V3 con WXT, descargas desde un documento offscreen, reglas de red que no se salen de su sitio, un motor de reemplazo que se puede deshacer y botones de Instagram que no dependen del idioma.
DevExtractor empezó como una utilidad para no tener que copiar a mano los <svg> de una web a un bloc de notas. Pulsas un botón, te salen todas las imágenes de la página y te las bajas. Dicho así parece un fin de semana de trabajo. Qué hace y cómo se usa está en el artículo para usuarios. Este cuenta lo que hay debajo, que es bastante más de lo que parece desde fuera.
La mayor parte del trabajo no estuvo en la idea, sino en los detalles: que una descarga no pise a otra, que una regla de red no afecte a la navegación normal, que una barra de progreso desaparezca cuando tiene que desaparecer. Ese tipo de cosas.
La forma de la extensión
Es una extensión Manifest V3 hecha con WXT, con Vue 3, TypeScript y Tailwind CSS v4. WXT genera el manifiesto y empaqueta cada punto de entrada por separado:
El popup y el panel de DevTools son dos páginas distintas que montan los mismos componentes (AppLayout, MediaGallery, LinkManager, FontManager). La única diferencia es de dónde sacan la pestaña: el panel usa chrome.devtools.inspectedWindow.tabId y el popup deja que el service worker busque la pestaña activa.
El content script genérico está declarado para todas las URL, pero el service worker no se fía: antes de pedirle nada le manda un PING, y si no hay PONG lo inyecta con scripting.executeScript. Así funciona también en pestañas que ya estaban abiertas cuando se instaló la extensión.
Ver lo que hay en la página
Todo empieza en utils/domParser.ts, que recorre el DOM y devuelve una lista de elementos con su URL, su tipo, su dominio y un tamaño aproximado. Busca en varios sitios:
<img>, leyendocurrentSrc, que es la imagen que el navegador eligió de verdad (también dentro de un<picture>o consrcset).<video>,<audio>y los enlaces que apuntan a archivos de audio.- Los SVG inline.
- Las imágenes de fondo, leídas con
getComputedStyle(...).backgroundImage, porque una regla de CSS no aparece en el atributostyle. - Los
<canvas>, que se exportan a imagen si no están "contaminados" por contenido de otro origen.
Los SVG inline son el motivo de que exista la extensión, y la solución es corta:
const serializer = new XMLSerializer();
document.querySelectorAll('svg').forEach((svg, index) => {
const serialized = serializer.serializeToString(svg);
if (!seenUrls.has(serialized)) {
seenUrls.add(serialized);
const dataUrl = `data:image/svg+xml;charset=UTF-8,${encodeURIComponent(serialized)}`;
// ...una tarjeta más, con el SVG como contenido y como data: URL
}
});
XMLSerializer devuelve el SVG como texto, con su espacio de nombres, listo para guardarse como archivo. Ese texto viaja con la tarjeta: la data: URL sirve de vista previa, y al descargar se crea el .svg directamente a partir del texto, sin pedir nada a la red. La clave para quitar duplicados es el propio contenido: si una web pinta el mismo icono en diez sitios, sale una vez.
Cada elemento encontrado se marca con un atributo data-devextractor-id. Ese id es lo que permite luego las acciones sobre un elemento concreto: hacer scroll hasta él, ocultarlo, copiar su CSS o sustituirlo, aunque haya otros con la misma URL.
Descargar en Manifest V3 no es directo
En Manifest V2 una extensión tenía una página de fondo con su document, y descargar era hacer fetch, crear un blob y pasárselo a chrome.downloads. En V3 la página de fondo es un service worker, y ahí no existe URL.createObjectURL. Sin eso no hay blob que descargar, y menos un ZIP.
La salida es un documento offscreen: una página invisible que la extensión crea con chrome.offscreen.createDocument y el motivo BLOBS. El service worker comprueba si ya existe, lo crea si no, y le delega la descarga. El documento offscreen hace el fetch de cada archivo, convierte el texto de los SVG en blobs, monta el ZIP con JSZip (añadiendo -1, -2… si dos archivos se llaman igual) y crea la URL del blob. Esa URL vuelve al service worker, que es quien llama a chrome.downloads.download, con el diálogo de "Guardar como" para que elijas dónde.
Webs que no quieren servirte sus imágenes
Muchas webs comprueban el Referer antes de servir una imagen. Desde la página funciona, porque el navegador manda el Referer correcto. Pero el fetch sale del documento offscreen, y la web responde con un 403.
Para eso está declarativeNetRequest. Antes de cada petición a un archivo, el service worker crea una regla que reescribe las cabeceras de esa petición: Referer y Origin de la página original, las cookies que el navegador mandaría a esa URL (leídas con cookies.getAll) y cabeceras de CORS en la respuesta. La parte importante es la condición:
condition: {
requestDomains: [urlObj.hostname],
tabIds: [-1],
resourceTypes: ['xmlhttprequest', 'media', 'other'],
}
tabIds: [-1] significa "peticiones que no salen de ninguna pestaña", es decir, las del documento offscreen y las de chrome.downloads. Y requestDomains es el host exacto del archivo.
No siempre fue así. La primera versión usaba reglas dinámicas, que persisten aunque cierres el navegador. Calculaba un "dominio base" que fallaba con dominios como co.uk y se aplicaba a todos los tipos de petición. En la práctica, una descarga podía dejar una regla viva que tocaba las cabeceras de tu navegación normal en ese dominio. Lo reescribí con reglas de sesión, el host exacto, solo peticiones sin pestaña, limpieza después de cada fetch, un temporizador de respaldo por si esa limpieza no llega y una limpieza completa de reglas antiguas cada vez que arranca el service worker. Es de los cambios con tests propios: hay un archivo entero para comprobar que la regla se crea con esa forma y se borra.
La barra de progreso
Esto es lo que yo llamaría una tontería que costó más de lo que parece. Durante un ZIP aparece una barra con "Descargando 3/8" y, al terminar, un aviso de éxito o de error. Hay que hacer que eso:
- Desaparezca cuando toca. Al principio, si una descarga terminaba con algún fallo, el aviso de "completada con errores" se quedaba fijo. Parecía que la extensión se había colgado. Ahora el resultado se borra solo a los cuatro segundos, salga bien o mal.
- No arrastre datos viejos. Si una descarga anterior acabó en "14/14", la siguiente no puede empezar enseñando eso. El progreso se reinicia al lanzar cada descarga.
- Sobreviva a cerrar el popup. El popup se destruye cada vez que haces clic fuera. Al volver a abrirlo pregunta al service worker (
GET_DOWNLOAD_STATE) cómo va la descarga y retoma la barra. - No se mezcle con otras. El popup, el panel de DevTools y los botones de Instagram pueden lanzar descargas a la vez, y los mensajes de progreso se difunden a todos. Cada descarga lleva ahora un id (
crypto.randomUUID()) que el documento offscreen devuelve en cada mensaje, y cada interfaz ignora el progreso de las descargas que no ha empezado ella.
Hay un caso más retorcido. Los botones de Instagram viven en un content script, y el progreso tiene que llegarle a esa pestaña concreta. El reenvío lo hacía un listener que se registraba dentro de la propia descarga, pero un service worker se duerme cuando no hay mensajes, y un ZIP grande pasa varios segundos en zip.generateAsync() sin mandar ninguno. Al despertarse, ese listener ya no existía y el progreso no llegaba a ninguna parte. Ahora la pestaña de origen se guarda en storage.session con el id de la descarga como clave, que sobrevive a esos reinicios.
Sustituir imágenes y poder deshacerlo
El reemplazo visual es lo que más uso con clientes: soltar una imagen sobre la tarjeta del logo y ver la web con otro logo al instante. La regla de partida era que todo cambio se pudiera deshacer y la página quedara exactamente como estaba. El motor está en utils/visualReplacementEngine.ts, y se apoya en tres piezas de utils/visual-replacement/.
DomSnapshotter guarda el estado original antes de tocar nada, en mapas con el propio elemento como clave: src y srcset de cada <img>, el srcset de cada <source>, el poster de los vídeos, background-image y mask-image, y, en los SVG inline, su contenido y su viewBox. Guarda también si el atributo existía o no, porque restaurar un atributo vacío no es lo mismo que quitarlo.
Con esa foto guardada, el motor aplica el cambio. Las imágenes cambian src y srcset, y los vídeos, su poster. Los fondos se sobrescriben con !important en el estilo del elemento. Un SVG inline no se puede convertir en un <img> sin romper la maquetación, así que se vacía y dentro se pone un <image href="..."> que ocupa todo el viewBox.
Los pseudo-elementos fueron la parte divertida. Un ::before no se puede tocar desde JavaScript: no tiene nodo en el DOM ni estilo propio. La solución es inyectar una sola hoja de estilos con reglas que leen variables CSS:
[data-devextractor-visual-replacement]::before {
background-image: var(--devextractor-before-background-image) !important;
}
Para cambiar el ::before de un elemento, se le pone el atributo y se le da valor a la variable en su estilo. Para deshacerlo, se quitan las dos cosas.
DomMutator se encarga del camino de vuelta: restaura cada atributo y cada propiedad tal como estaban en la foto, e inyecta o retira esa hoja de estilos de los pseudo-elementos.
MutationWatcher es un MutationObserver que, mientras el reemplazo está activo, aplica el cambio también a los nodos que aparecen después: imágenes con carga diferida, un carrusel que añade diapositivas.
Con las webs de una sola página (SPA), el motor parchea history.pushState y history.replaceState y escucha popstate y hashchange. Al cambiar de ruta no intenta mantener los cambios: lo restaura todo. Una imagen sustituida en la portada no tiene por qué tener sentido en la siguiente página, y dejar cambios sueltos en una vista que no has revisado es peor que volver a aplicarlos.
La imagen que sueltas sobre la tarjeta se lee con FileReader como data: URL y viaja en el mensaje hasta el content script. No se sube a ningún sitio.
Instagram es una web que hace todo lo posible para que no la entiendas desde fuera: clases CSS generadas, textos traducidos al idioma de cada usuario, contenido que llega por peticiones internas y vídeos servidos como blob:. Yo no lo uso, pero vi que mucha gente se quejaba de que las extensiones para descargar de Instagram ya no funcionaban, y quise ver si se podía hacer bien.
Leer los datos, no la pantalla
La información buena (la URL del vídeo en máxima calidad, todas las historias de un usuario, la foto de perfil en HD) no está en el DOM, sino en las respuestas JSON que la web pide. Para verlas hay dos content scripts que trabajan juntos:
- Uno en el mundo MAIN (el mismo JavaScript que la página) que se ejecuta en
document_start, antes que el código de Instagram. ParcheafetchyXMLHttpRequestpara leer esas respuestas, y además escanea los<script>del HTML inicial, donde Instagram mete los datos de la primera carga. - Otro en el mundo ISOLATED, el normal de las extensiones, que recibe esos datos por
postMessage, coloca los botones (dentro de un Shadow DOM para que los estilos no choquen) y habla con el service worker.
Ahí aparecieron dos problemas de orden. El primero: Chrome puede insertar un <script> grande en el DOM antes de tener todo su texto, y el resto llega sin que el MutationObserver se entere. El JSON se cortaba y no se podía leer. Ahora los scripts que fallan se vuelven a leer cuando salta DOMContentLoaded. El segundo: el script ISOLATED se carga mucho después (document_idle), así que todo lo que el script MAIN anunciaba antes se perdía. Lo arreglé con un intercambio de petición y respuesta: cuando el script ISOLATED empieza a escuchar, pide todo lo que se ha capturado hasta entonces.
Encontrar dónde poner los botones sin leer textos
Lo fácil sería buscar la barra de acciones de un post por los textos o los aria-label de sus botones ("Me gusta", "Comentar"). Eso solo funciona en un idioma, y las clases tampoco sirven porque cambian. Así que la diseñé desde el principio para detectarla por estructura: un <section> con al menos dos [role="button"] que contengan un <svg>. Los contadores de likes también son [role="button"], pero no tienen icono, así que no cuentan. Y como un <section> que envuelve todo el post también contiene esos botones, de todos los candidatos se queda con el de menor área en pantalla.
En las historias, para saber cuál se está viendo se usa la barra de progreso de arriba: hay un segmento por historia, y el que se está reproduciendo es el único con un hijo cuyo transform: translateX(...) va de -100% a 0%. Funciona igual con vídeos, cuya URL en el DOM es un blob: que no sirve para nada.
Que no pese
En el feed, el botón aparece al pasar el ratón por un post. La primera versión tenía un MutationObserver sobre todo el documento y un listener por cada post, en una página con scroll infinito. Ahora es un único par de mouseover y mouseout delegado en el documento, y la comprobación de qué reel está activo se hace como mucho una vez por fotograma (requestAnimationFrame) durante el scroll.
Tres idiomas y tests
La interfaz está en español e inglés con vue-i18n, y elige el idioma del navegador si es uno de los dos. También hay una traducción al chino, pero de momento está oculta, marcada como experimental. Un test comprueba que el inglés y el español tienen exactamente las mismas claves, para que no falte una traducción en silencio.
El proyecto tiene 166 commits desde el 4 de abril. Hoy hay 486 tests en 43 archivos, con Vitest y happy-dom. Cubren el parser del DOM, los filtros, el motor de reemplazo (con estilos calculados simulados para los pseudo-elementos), el enrutado de mensajes del service worker, las reglas de red, las descargas simultáneas, el nombre de los ZIP y casi todo lo de Instagram: detección de la barra de acciones, lectura de las respuestas, escaneo del HTML inicial. Encima hay tests E2E con Playwright que cargan la extensión de verdad en Chromium y prueban el reemplazo sobre páginas de prueba.
En cada push, GitHub Actions instala dependencias, prepara WXT, pasa la comprobación de tipos con vue-tsc, los tests y la build.
Lo que me llevo
Que en una extensión la parte visible es la pequeña. Una galería de imágenes con un botón de descargar se monta rápido. Lo que lleva tiempo es todo lo que pasa entre ese botón y el archivo en tu carpeta: un service worker que se duerme, un documento que existe solo para crear blobs, una regla de red que no debe tocar nada más, y una barra de progreso que tiene que saber de quién es cada mensaje.
Y que hacer algo para uno mismo también lleva a usos que no esperabas. La hice para bajar SVG, y lo que más uso con clientes es cambiar un logo en vivo.