[{"data":1,"prerenderedAt":1073},["ShallowReactive",2],{"\u002Farticulos\u002Fdevextractor-por-dentro\u002F":3,"upcoming:\u002Farticulos\u002Fdevextractor-por-dentro\u002F":1072},{"id":4,"title":5,"body":6,"date":1056,"description":1057,"draft":1058,"extension":1059,"meta":1060,"navigation":1061,"path":1062,"seo":1063,"stem":1064,"tags":1065,"type":1070,"__hash__":1071},"posts\u002Farticulos\u002Fdevextractor-por-dentro.md","DevExtractor por dentro: lo que hay detrás de una extensión que parece simple",{"type":7,"value":8,"toc":1040},"minimark",[9,28,31,36,60,119,139,154,158,165,219,222,391,405,412,416,439,472,477,490,507,572,585,603,607,610,647,658,662,676,713,741,748,796,802,810,822,843,853,857,864,868,871,905,922,926,957,967,971,988,992,999,1016,1027,1031,1034,1037],[10,11,12,17,18,22,23,27],"p",{},[13,14,16],"a",{"href":15},"\u002Fproyectos\u002Fdevextractor","DevExtractor"," empezó como una utilidad para no tener que copiar a mano los ",[19,20,21],"code",{},"\u003Csvg>"," 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 ",[13,24,26],{"href":25},"\u002Farticulos\u002Fdevextractor-svg-imagenes-y-fuentes-en-un-clic","artículo para usuarios",". Este cuenta lo que hay debajo, que es bastante más de lo que parece desde fuera.",[10,29,30],{},"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.",[32,33,35],"h2",{"id":34},"la-forma-de-la-extensión","La forma de la extensión",[10,37,38,39,43,44,47,48,51,52,55,56,59],{},"Es una extensión ",[40,41,42],"strong",{},"Manifest V3"," hecha con ",[40,45,46],{},"WXT",", con ",[40,49,50],{},"Vue 3",", ",[40,53,54],{},"TypeScript"," y ",[40,57,58],{},"Tailwind CSS v4",". WXT genera el manifiesto y empaqueta cada punto de entrada por separado:",[61,62,67],"pre",{"className":63,"code":64,"language":65,"meta":66,"style":66},"language-mermaid shiki shiki-themes github-light github-dark","flowchart TD\n  UI[Popup o panel de DevTools\u003Cbr\u002F>mismos componentes Vue] -->|mensajes| SW[Service worker\u003Cbr\u002F>enrutado y reglas de red]\n  SW -->|inyecta si hace falta| CS[Content script en la página\u003Cbr\u002F>lectura del DOM y reemplazo]\n  SW --> OFF[Documento offscreen\u003Cbr\u002F>fetch, JSZip y blobs]\n  OFF -->|progreso con id\u003Cbr\u002F>y URL del blob| SW\n  SW -->|regla DNR temporal| NET[(Peticiones de la extensión)]\n  SW --> DL[chrome.downloads]\n  IG[Scripts de Instagram\u003Cbr\u002F>mundo MAIN + ISOLATED] --> SW\n","mermaid","",[19,68,69,77,83,89,95,101,107,113],{"__ignoreMap":66},[70,71,74],"span",{"class":72,"line":73},"line",1,[70,75,76],{},"flowchart TD\n",[70,78,80],{"class":72,"line":79},2,[70,81,82],{},"  UI[Popup o panel de DevTools\u003Cbr\u002F>mismos componentes Vue] -->|mensajes| SW[Service worker\u003Cbr\u002F>enrutado y reglas de red]\n",[70,84,86],{"class":72,"line":85},3,[70,87,88],{},"  SW -->|inyecta si hace falta| CS[Content script en la página\u003Cbr\u002F>lectura del DOM y reemplazo]\n",[70,90,92],{"class":72,"line":91},4,[70,93,94],{},"  SW --> OFF[Documento offscreen\u003Cbr\u002F>fetch, JSZip y blobs]\n",[70,96,98],{"class":72,"line":97},5,[70,99,100],{},"  OFF -->|progreso con id\u003Cbr\u002F>y URL del blob| SW\n",[70,102,104],{"class":72,"line":103},6,[70,105,106],{},"  SW -->|regla DNR temporal| NET[(Peticiones de la extensión)]\n",[70,108,110],{"class":72,"line":109},7,[70,111,112],{},"  SW --> DL[chrome.downloads]\n",[70,114,116],{"class":72,"line":115},8,[70,117,118],{},"  IG[Scripts de Instagram\u003Cbr\u002F>mundo MAIN + ISOLATED] --> SW\n",[10,120,121,122,51,125,51,128,51,131,134,135,138],{},"El popup y el panel de DevTools son dos páginas distintas que montan los mismos componentes (",[19,123,124],{},"AppLayout",[19,126,127],{},"MediaGallery",[19,129,130],{},"LinkManager",[19,132,133],{},"FontManager","). La única diferencia es de dónde sacan la pestaña: el panel usa ",[19,136,137],{},"chrome.devtools.inspectedWindow.tabId"," y el popup deja que el service worker busque la pestaña activa.",[10,140,141,142,145,146,149,150,153],{},"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 ",[19,143,144],{},"PING",", y si no hay ",[19,147,148],{},"PONG"," lo inyecta con ",[19,151,152],{},"scripting.executeScript",". Así funciona también en pestañas que ya estaban abiertas cuando se instaló la extensión.",[32,155,157],{"id":156},"ver-lo-que-hay-en-la-página","Ver lo que hay en la página",[10,159,160,161,164],{},"Todo empieza en ",[19,162,163],{},"utils\u002FdomParser.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:",[166,167,168,187,196,203,213],"ul",{},[169,170,171,174,175,178,179,182,183,186],"li",{},[19,172,173],{},"\u003Cimg>",", leyendo ",[19,176,177],{},"currentSrc",", que es la imagen que el navegador eligió de verdad (también dentro de un ",[19,180,181],{},"\u003Cpicture>"," o con ",[19,184,185],{},"srcset",").",[169,188,189,51,192,195],{},[19,190,191],{},"\u003Cvideo>",[19,193,194],{},"\u003Caudio>"," y los enlaces que apuntan a archivos de audio.",[169,197,198,199,202],{},"Los ",[40,200,201],{},"SVG inline",".",[169,204,205,206,209,210,202],{},"Las imágenes de fondo, leídas con ",[19,207,208],{},"getComputedStyle(...).backgroundImage",", porque una regla de CSS no aparece en el atributo ",[19,211,212],{},"style",[169,214,198,215,218],{},[19,216,217],{},"\u003Ccanvas>",", que se exportan a imagen si no están \"contaminados\" por contenido de otro origen.",[10,220,221],{},"Los SVG inline son el motivo de que exista la extensión, y la solución es corta:",[61,223,227],{"className":224,"code":225,"language":226,"meta":66,"style":66},"language-ts shiki shiki-themes github-light github-dark","const serializer = new XMLSerializer();\ndocument.querySelectorAll('svg').forEach((svg, index) => {\n  const serialized = serializer.serializeToString(svg);\n  if (!seenUrls.has(serialized)) {\n    seenUrls.add(serialized);\n    const dataUrl = `data:image\u002Fsvg+xml;charset=UTF-8,${encodeURIComponent(serialized)}`;\n    \u002F\u002F ...una tarjeta más, con el SVG como contenido y como data: URL\n  }\n});\n","ts",[19,228,229,253,294,313,333,344,374,380,385],{"__ignoreMap":66},[70,230,231,235,239,242,245,249],{"class":72,"line":73},[70,232,234],{"class":233},"szBVR","const",[70,236,238],{"class":237},"sj4cs"," serializer",[70,240,241],{"class":233}," =",[70,243,244],{"class":233}," new",[70,246,248],{"class":247},"sScJk"," XMLSerializer",[70,250,252],{"class":251},"sVt8B","();\n",[70,254,255,258,261,264,268,270,273,276,280,282,285,288,291],{"class":72,"line":79},[70,256,257],{"class":251},"document.",[70,259,260],{"class":247},"querySelectorAll",[70,262,263],{"class":251},"(",[70,265,267],{"class":266},"sZZnC","'svg'",[70,269,186],{"class":251},[70,271,272],{"class":247},"forEach",[70,274,275],{"class":251},"((",[70,277,279],{"class":278},"s4XuR","svg",[70,281,51],{"class":251},[70,283,284],{"class":278},"index",[70,286,287],{"class":251},") ",[70,289,290],{"class":233},"=>",[70,292,293],{"class":251}," {\n",[70,295,296,299,302,304,307,310],{"class":72,"line":85},[70,297,298],{"class":233},"  const",[70,300,301],{"class":237}," serialized",[70,303,241],{"class":233},[70,305,306],{"class":251}," serializer.",[70,308,309],{"class":247},"serializeToString",[70,311,312],{"class":251},"(svg);\n",[70,314,315,318,321,324,327,330],{"class":72,"line":91},[70,316,317],{"class":233},"  if",[70,319,320],{"class":251}," (",[70,322,323],{"class":233},"!",[70,325,326],{"class":251},"seenUrls.",[70,328,329],{"class":247},"has",[70,331,332],{"class":251},"(serialized)) {\n",[70,334,335,338,341],{"class":72,"line":97},[70,336,337],{"class":251},"    seenUrls.",[70,339,340],{"class":247},"add",[70,342,343],{"class":251},"(serialized);\n",[70,345,346,349,352,354,357,360,362,365,368,371],{"class":72,"line":103},[70,347,348],{"class":233},"    const",[70,350,351],{"class":237}," dataUrl",[70,353,241],{"class":233},[70,355,356],{"class":266}," `data:image\u002Fsvg+xml;charset=UTF-8,${",[70,358,359],{"class":247},"encodeURIComponent",[70,361,263],{"class":266},[70,363,364],{"class":251},"serialized",[70,366,367],{"class":266},")",[70,369,370],{"class":266},"}`",[70,372,373],{"class":251},";\n",[70,375,376],{"class":72,"line":109},[70,377,379],{"class":378},"sJ8bj","    \u002F\u002F ...una tarjeta más, con el SVG como contenido y como data: URL\n",[70,381,382],{"class":72,"line":115},[70,383,384],{"class":251},"  }\n",[70,386,388],{"class":72,"line":387},9,[70,389,390],{"class":251},"});\n",[10,392,393,396,397,400,401,404],{},[19,394,395],{},"XMLSerializer"," devuelve el SVG como texto, con su espacio de nombres, listo para guardarse como archivo. Ese texto viaja con la tarjeta: la ",[19,398,399],{},"data:"," URL sirve de vista previa, y al descargar se crea el ",[19,402,403],{},".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.",[10,406,407,408,411],{},"Cada elemento encontrado se marca con un atributo ",[19,409,410],{},"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.",[32,413,415],{"id":414},"descargar-en-manifest-v3-no-es-directo","Descargar en Manifest V3 no es directo",[10,417,418,419,422,423,426,427,430,431,434,435,438],{},"En Manifest V2 una extensión tenía una página de fondo con su ",[19,420,421],{},"document",", y descargar era hacer ",[19,424,425],{},"fetch",", crear un blob y pasárselo a ",[19,428,429],{},"chrome.downloads",". En V3 la página de fondo es un ",[40,432,433],{},"service worker",", y ahí no existe ",[19,436,437],{},"URL.createObjectURL",". Sin eso no hay blob que descargar, y menos un ZIP.",[10,440,441,442,445,446,449,450,453,454,456,457,460,461,51,464,467,468,471],{},"La salida es un ",[40,443,444],{},"documento offscreen",": una página invisible que la extensión crea con ",[19,447,448],{},"chrome.offscreen.createDocument"," y el motivo ",[19,451,452],{},"BLOBS",". El service worker comprueba si ya existe, lo crea si no, y le delega la descarga. El documento offscreen hace el ",[19,455,425],{}," de cada archivo, convierte el texto de los SVG en blobs, monta el ZIP con ",[40,458,459],{},"JSZip"," (añadiendo ",[19,462,463],{},"-1",[19,465,466],{},"-2","… si dos archivos se llaman igual) y crea la URL del blob. Esa URL vuelve al service worker, que es quien llama a ",[19,469,470],{},"chrome.downloads.download",", con el diálogo de \"Guardar como\" para que elijas dónde.",[473,474,476],"h3",{"id":475},"webs-que-no-quieren-servirte-sus-imágenes","Webs que no quieren servirte sus imágenes",[10,478,479,480,483,484,486,487,489],{},"Muchas webs comprueban el ",[19,481,482],{},"Referer"," antes de servir una imagen. Desde la página funciona, porque el navegador manda el ",[19,485,482],{}," correcto. Pero el ",[19,488,425],{}," sale del documento offscreen, y la web responde con un 403.",[10,491,492,493,496,497,55,499,502,503,506],{},"Para eso está ",[19,494,495],{},"declarativeNetRequest",". Antes de cada petición a un archivo, el service worker crea una regla que reescribe las cabeceras de esa petición: ",[19,498,482],{},[19,500,501],{},"Origin"," de la página original, las cookies que el navegador mandaría a esa URL (leídas con ",[19,504,505],{},"cookies.getAll",") y cabeceras de CORS en la respuesta. La parte importante es la condición:",[61,508,510],{"className":224,"code":509,"language":226,"meta":66,"style":66},"condition: {\n  requestDomains: [urlObj.hostname],\n  tabIds: [-1],\n  resourceTypes: ['xmlhttprequest', 'media', 'other'],\n}\n",[19,511,512,520,528,545,567],{"__ignoreMap":66},[70,513,514,517],{"class":72,"line":73},[70,515,516],{"class":247},"condition",[70,518,519],{"class":251},": {\n",[70,521,522,525],{"class":72,"line":79},[70,523,524],{"class":247},"  requestDomains",[70,526,527],{"class":251},": [urlObj.hostname],\n",[70,529,530,533,536,539,542],{"class":72,"line":85},[70,531,532],{"class":247},"  tabIds",[70,534,535],{"class":251},": [",[70,537,538],{"class":233},"-",[70,540,541],{"class":237},"1",[70,543,544],{"class":251},"],\n",[70,546,547,550,552,555,557,560,562,565],{"class":72,"line":91},[70,548,549],{"class":247},"  resourceTypes",[70,551,535],{"class":251},[70,553,554],{"class":266},"'xmlhttprequest'",[70,556,51],{"class":251},[70,558,559],{"class":266},"'media'",[70,561,51],{"class":251},[70,563,564],{"class":266},"'other'",[70,566,544],{"class":251},[70,568,569],{"class":72,"line":97},[70,570,571],{"class":251},"}\n",[10,573,574,577,578,580,581,584],{},[19,575,576],{},"tabIds: [-1]"," significa \"peticiones que no salen de ninguna pestaña\", es decir, las del documento offscreen y las de ",[19,579,429],{},". Y ",[19,582,583],{},"requestDomains"," es el host exacto del archivo.",[10,586,587,588,591,592,595,596,599,600,602],{},"No siempre fue así. La primera versión usaba ",[40,589,590],{},"reglas dinámicas",", que persisten aunque cierres el navegador. Calculaba un \"dominio base\" que fallaba con dominios como ",[19,593,594],{},"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 ",[40,597,598],{},"reglas de sesión",", el host exacto, solo peticiones sin pestaña, limpieza después de cada ",[19,601,425],{},", 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.",[473,604,606],{"id":605},"la-barra-de-progreso","La barra de progreso",[10,608,609],{},"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\u002F8\" y, al terminar, un aviso de éxito o de error. Hay que hacer que eso:",[166,611,612,618,624,634],{},[169,613,614,617],{},[40,615,616],{},"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.",[169,619,620,623],{},[40,621,622],{},"No arrastre datos viejos."," Si una descarga anterior acabó en \"14\u002F14\", la siguiente no puede empezar enseñando eso. El progreso se reinicia al lanzar cada descarga.",[169,625,626,629,630,633],{},[40,627,628],{},"Sobreviva a cerrar el popup."," El popup se destruye cada vez que haces clic fuera. Al volver a abrirlo pregunta al service worker (",[19,631,632],{},"GET_DOWNLOAD_STATE",") cómo va la descarga y retoma la barra.",[169,635,636,639,640,320,643,646],{},[40,637,638],{},"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 ",[40,641,642],{},"id",[19,644,645],{},"crypto.randomUUID()",") que el documento offscreen devuelve en cada mensaje, y cada interfaz ignora el progreso de las descargas que no ha empezado ella.",[10,648,649,650,653,654,657],{},"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 ",[19,651,652],{},"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 ",[19,655,656],{},"storage.session"," con el id de la descarga como clave, que sobrevive a esos reinicios.",[32,659,661],{"id":660},"sustituir-imágenes-y-poder-deshacerlo","Sustituir imágenes y poder deshacerlo",[10,663,664,665,668,669,672,673,202],{},"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 ",[40,666,667],{},"todo cambio se pudiera deshacer"," y la página quedara exactamente como estaba. El motor está en ",[19,670,671],{},"utils\u002FvisualReplacementEngine.ts",", y se apoya en tres piezas de ",[19,674,675],{},"utils\u002Fvisual-replacement\u002F",[10,677,678,683,684,55,687,689,690,692,693,689,695,692,698,701,702,55,705,708,709,712],{},[40,679,680],{},[19,681,682],{},"DomSnapshotter"," guarda el estado original antes de tocar nada, en mapas con el propio elemento como clave: ",[19,685,686],{},"src",[19,688,185],{}," de cada ",[19,691,173],{},", el ",[19,694,185],{},[19,696,697],{},"\u003Csource>",[19,699,700],{},"poster"," de los vídeos, ",[19,703,704],{},"background-image",[19,706,707],{},"mask-image",", y, en los SVG inline, su contenido y su ",[19,710,711],{},"viewBox",". Guarda también si el atributo existía o no, porque restaurar un atributo vacío no es lo mismo que quitarlo.",[10,714,715,716,719,720,55,722,724,725,727,728,731,732,734,735,738,739,202],{},"Con esa foto guardada, ",[40,717,718],{},"el motor"," aplica el cambio. Las imágenes cambian ",[19,721,686],{},[19,723,185],{},", y los vídeos, su ",[19,726,700],{},". Los fondos se sobrescriben con ",[19,729,730],{},"!important"," en el estilo del elemento. Un SVG inline no se puede convertir en un ",[19,733,173],{}," sin romper la maquetación, así que se vacía y dentro se pone un ",[19,736,737],{},"\u003Cimage href=\"...\">"," que ocupa todo el ",[19,740,711],{},[10,742,743,744,747],{},"Los pseudo-elementos fueron la parte divertida. Un ",[19,745,746],{},"::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:",[61,749,753],{"className":750,"code":751,"language":752,"meta":66,"style":66},"language-css shiki shiki-themes github-light github-dark","[data-devextractor-visual-replacement]::before {\n  background-image: var(--devextractor-before-background-image) !important;\n}\n","css",[19,754,755,770,792],{"__ignoreMap":66},[70,756,757,760,763,766,768],{"class":72,"line":73},[70,758,759],{"class":251},"[",[70,761,762],{"class":247},"data-devextractor-visual-replacement",[70,764,765],{"class":251},"]",[70,767,746],{"class":247},[70,769,293],{"class":251},[70,771,772,775,778,781,783,786,788,790],{"class":72,"line":79},[70,773,774],{"class":237},"  background-image",[70,776,777],{"class":251},": ",[70,779,780],{"class":237},"var",[70,782,263],{"class":251},[70,784,785],{"class":278},"--devextractor-before-background-image",[70,787,287],{"class":251},[70,789,730],{"class":233},[70,791,373],{"class":251},[70,793,794],{"class":72,"line":85},[70,795,571],{"class":251},[10,797,798,799,801],{},"Para cambiar el ",[19,800,746],{}," 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.",[10,803,804,809],{},[40,805,806],{},[19,807,808],{},"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.",[10,811,812,817,818,821],{},[40,813,814],{},[19,815,816],{},"MutationWatcher"," es un ",[19,819,820],{},"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.",[10,823,824,825,55,828,831,832,55,835,838,839,842],{},"Con las webs de una sola página (SPA), el motor parchea ",[19,826,827],{},"history.pushState",[19,829,830],{},"history.replaceState"," y escucha ",[19,833,834],{},"popstate",[19,836,837],{},"hashchange",". Al cambiar de ruta no intenta mantener los cambios: ",[40,840,841],{},"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.",[10,844,845,846,849,850,852],{},"La imagen que sueltas sobre la tarjeta se lee con ",[19,847,848],{},"FileReader"," como ",[19,851,399],{}," URL y viaja en el mensaje hasta el content script. No se sube a ningún sitio.",[32,854,856],{"id":855},"instagram","Instagram",[10,858,859,860,863],{},"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 ",[19,861,862],{},"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.",[473,865,867],{"id":866},"leer-los-datos-no-la-pantalla","Leer los datos, no la pantalla",[10,869,870],{},"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:",[166,872,873,894],{},[169,874,875,876,879,880,883,884,55,886,889,890,893],{},"Uno en el ",[40,877,878],{},"mundo MAIN"," (el mismo JavaScript que la página) que se ejecuta en ",[19,881,882],{},"document_start",", antes que el código de Instagram. Parchea ",[19,885,425],{},[19,887,888],{},"XMLHttpRequest"," para leer esas respuestas, y además escanea los ",[19,891,892],{},"\u003Cscript>"," del HTML inicial, donde Instagram mete los datos de la primera carga.",[169,895,896,897,900,901,904],{},"Otro en el ",[40,898,899],{},"mundo ISOLATED",", el normal de las extensiones, que recibe esos datos por ",[19,902,903],{},"postMessage",", coloca los botones (dentro de un Shadow DOM para que los estilos no choquen) y habla con el service worker.",[10,906,907,908,910,911,913,914,917,918,921],{},"Ahí aparecieron dos problemas de orden. El primero: Chrome puede insertar un ",[19,909,892],{}," grande en el DOM antes de tener todo su texto, y el resto llega sin que el ",[19,912,820],{}," se entere. El JSON se cortaba y no se podía leer. Ahora los scripts que fallan se vuelven a leer cuando salta ",[19,915,916],{},"DOMContentLoaded",". El segundo: el script ISOLATED se carga mucho después (",[19,919,920],{},"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.",[473,923,925],{"id":924},"encontrar-dónde-poner-los-botones-sin-leer-textos","Encontrar dónde poner los botones sin leer textos",[10,927,928,929,932,933,936,937,940,941,944,945,947,948,950,951,953,954,202],{},"Lo fácil sería buscar la barra de acciones de un post por los textos o los ",[19,930,931],{},"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 ",[40,934,935],{},"estructura",": un ",[19,938,939],{},"\u003Csection>"," con al menos dos ",[19,942,943],{},"[role=\"button\"]"," que contengan un ",[19,946,21],{},". Los contadores de likes también son ",[19,949,943],{},", pero no tienen icono, así que no cuentan. Y como un ",[19,952,939],{}," que envuelve todo el post también contiene esos botones, de todos los candidatos se queda con el de ",[40,955,956],{},"menor área en pantalla",[10,958,959,960,963,964,966],{},"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 ",[19,961,962],{},"transform: translateX(...)"," va de -100% a 0%. Funciona igual con vídeos, cuya URL en el DOM es un ",[19,965,862],{}," que no sirve para nada.",[473,968,970],{"id":969},"que-no-pese","Que no pese",[10,972,973,974,976,977,55,980,983,984,987],{},"En el feed, el botón aparece al pasar el ratón por un post. La primera versión tenía un ",[19,975,820],{}," sobre todo el documento y un listener por cada post, en una página con scroll infinito. Ahora es un único par de ",[19,978,979],{},"mouseover",[19,981,982],{},"mouseout"," delegado en el documento, y la comprobación de qué reel está activo se hace como mucho una vez por fotograma (",[19,985,986],{},"requestAnimationFrame",") durante el scroll.",[32,989,991],{"id":990},"tres-idiomas-y-tests","Tres idiomas y tests",[10,993,994,995,998],{},"La interfaz está en español e inglés con ",[19,996,997],{},"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.",[10,1000,1001,1002,47,1005,55,1008,1011,1012,1015],{},"El proyecto tiene 166 commits desde el 4 de abril. Hoy hay ",[40,1003,1004],{},"486 tests en 43 archivos",[40,1006,1007],{},"Vitest",[40,1009,1010],{},"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 ",[40,1013,1014],{},"E2E con Playwright"," que cargan la extensión de verdad en Chromium y prueban el reemplazo sobre páginas de prueba.",[10,1017,1018,1019,1022,1023,1026],{},"En cada push, ",[40,1020,1021],{},"GitHub Actions"," instala dependencias, prepara WXT, pasa la comprobación de tipos con ",[19,1024,1025],{},"vue-tsc",", los tests y la build.",[32,1028,1030],{"id":1029},"lo-que-me-llevo","Lo que me llevo",[10,1032,1033],{},"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.",[10,1035,1036],{},"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.",[212,1038,1039],{},"html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}",{"title":66,"searchDepth":79,"depth":79,"links":1041},[1042,1043,1044,1048,1049,1054,1055],{"id":34,"depth":79,"text":35},{"id":156,"depth":79,"text":157},{"id":414,"depth":79,"text":415,"children":1045},[1046,1047],{"id":475,"depth":85,"text":476},{"id":605,"depth":85,"text":606},{"id":660,"depth":79,"text":661},{"id":855,"depth":79,"text":856,"children":1050},[1051,1052,1053],{"id":866,"depth":85,"text":867},{"id":924,"depth":85,"text":925},{"id":969,"depth":85,"text":970},{"id":990,"depth":79,"text":991},{"id":1029,"depth":79,"text":1030},"2026-10-25","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.",false,"md",{},true,"\u002Farticulos\u002Fdevextractor-por-dentro",{"title":5,"description":1057},"articulos\u002Fdevextractor-por-dentro",[1066,1067,1068,1069],"devextractor","arquitectura","typescript","vue","tecnico","4cZmKhNVkY52_bL4ObvdI1YUpUY_RbRhh-sw4eqcpQc",null,1793761188319]