Cómo Topic Filter for X elimina tuits antes de que se pinten
Topic Filter for X es una extensión de Chrome que oculta de forma permanente los temas que elijas en X. El artículo de lanzamiento cuenta qué hace; este cuenta los tres problemas de ingeniería que lo hicieron interesante: filtrar un timeline virtualizado, casar palabras clave sin falsos positivos y ejecutar un clasificador semántico sin servidor.
Problema 1: no puedes limitarte a ocultar tuits
El diseño obvio para una extensión que bloquea contenido es ocultar en el DOM: encontrar los tuits ya pintados, comprobar su texto y ponerles `display: none`. En X, esto falla a la vista.
El timeline de X está virtualizado. Cada tuit es una celda con posición absoluta, colocada a una altura fija dentro de un contenedor con altura explícita. React calcula esas alturas a partir de los datos que descargó, no de lo que el DOM muestra en cada momento. Si ocultas una celda, la maquetación no se recoloca: queda un hueco en blanco del tamaño del tuit. Si en vez de eso colapsas la altura de la celda, los huecos se acumulan de otra manera. También probamos a recolocar nosotros el resto de celdas, y eso rompió el scroll por completo. Los tres callejones sin salida se confirmaron contra el sitio real, no sobre el papel.
La conclusión: en una lista virtualizada, el DOM es una proyección de los datos. Si quieres que un tuit desaparezca, tiene que desaparecer de los datos antes de que la lista llegue a verlos.
Problema 2: filtrar los datos
La aplicación web de X carga su timeline mediante peticiones GraphQL. Así que la extensión inyecta un script en el mundo MAIN de la página en `document_start`, antes de que corra el propio código de X, y parchea tanto `XMLHttpRequest` como `fetch`. Cuando llega una respuesta del timeline, el interceptor la parsea, recorre las instrucciones `TimelineAddEntries` y borra los tuits que casan del array de entradas antes de entregar la respuesta al código de X. React pinta la lista ya filtrada, calcula las alturas correctas y el feed se ve nativo: sin huecos, sin parpadeos, y el scroll infinito rellena con naturalidad.
Los detalles que costaron iteración:
Los content scripts viven normalmente en un mundo aislado y no pueden ver el `XMLHttpRequest` de la página. El interceptor declara `"world": "MAIN"`; un segundo content script aislado le pasa la configuración del usuario mediante `window.postMessage`.
El transporte del timeline de X es XHR, no fetch (lo verificamos; fetch se parchea por si acaso). Reescribir una respuesta XHR significa sobreescribir el getter de `responseText` de la instancia, que tiene la útil propiedad de no depender del orden.
El texto del tuit se esconde en varias formas: `tweet_results.result.legacy.full_text`, una variante anidada `.tweet.legacy`, los cuerpos `note_tweet` de las publicaciones largas y los retuits, que envuelven el tuit real un nivel más adentro. Las publicaciones con vídeo se detectan por `extended_entities.media[].type`. Si te dejas una forma, esa categoría de publicaciones se escapa del filtro en silencio.
Las respuestas malformadas o no reconocidas pasan intactas. Un filtro capaz de romper el timeline es peor que ningún filtro.
Problema 3: palabras clave que no te dejen en evidencia
Cada tema trae una lista grande y bilingüe de palabras clave en inglés y español, y cada palabra se compara con límites de palabra, sin distinguir acentos y con plurales opcionales. Eso es lo que permite que "$BTC", "#IA" y "elecciones" casen mientras "diaria" no casa con "ia" y "supermercado" no casa con "mercado".
El trabajo duro fue el negativo: podar toda palabra cuyo sentido cotidiano choca con el temático. El español está lleno de estos casos. "Bolsa" es el parqué y también la de la compra; "acciones" son participaciones y también simples actos; "sin" es una preposición, no un pecado. Palabras así se eliminaron o se sustituyeron por formas desambiguadas, y una batería de frases cotidianas en los tests protege contra reintroducirlas.
Problema 4: un clasificador sin servidor
Las palabras clave no pillan las paráfrasis. "El mercado reaccionó mal al anuncio de ayer" no contiene ninguna palabra de la lista, pero es finanzas con claridad. El modo semántico opcional caza estos casos con un modelo multilingüe de embeddings de frases (una variante de MiniLM) que corre entero en el navegador con transformers.js, dentro de un documento offscreen de MV3, sobre WebGPU con reserva en WASM. Los pesos se descargan una vez (unos 240 MB); después funciona sin conexión. Es un modelo de embeddings, no un LLM generativo, y el texto de los tuits jamás sale de la máquina.
El hallazgo que merece contarse: la similitud coseno en bruto entre un tuit y las frases prototipo de un tema no separa nada. Los tuits fuera de tema puntuaban tranquilamente dentro del rango de los que sí lo eran. Lo que funcionó fue un margen contrastivo: puntuar cada tuit contra los prototipos del tema y contra un conjunto de frases ancla neutras de "vida cotidiana", y clasificar por la diferencia. En nuestro conjunto de pruebas eso convirtió una señal inutilizable en una separación limpia, con niveles de sensibilidad mapeados a umbrales del margen. Los prototipos tienen que ser frases concretas con pinta de tuit; etiquetas abstractas como "política" quedan demasiado lejos de cómo escribe la gente.
Las partes aburridas que lo convierten en producto
El interceptor y el comparador comparten un único núcleo de matching, cargado en ambos mundos y cubierto por tests unitarios que corren en CI. Las versiones se publican solas: subes la versión del manifest, haces push, y la tubería testea, empaqueta, sube por la API de la Chrome Web Store y publica automáticamente. Y todo el conjunto es privado por construcción, porque la arquitectura nunca necesitó un servidor.
Si estás peleándote con una interfaz virtualizada, un problema de interceptación o una restricción de ML en el dispositivo, este es el tipo de trabajo que hacemos.