[{"data":1,"prerenderedAt":1248},["ShallowReactive",2],{"\u002Farticulos\u002Freddit-archive-por-dentro\u002F":3,"upcoming:\u002Farticulos\u002Freddit-archive-por-dentro\u002F":1247},{"id":4,"title":5,"body":6,"date":1232,"description":1233,"draft":1234,"extension":1235,"meta":1236,"navigation":1086,"path":1237,"seo":1238,"stem":1239,"tags":1240,"type":1245,"__hash__":1246},"posts\u002Farticulos\u002Freddit-archive-por-dentro.md","Reddit Archive por dentro: un visor de archivo encima de una web que no es tuya",{"type":7,"value":8,"toc":1217},"minimark",[9,30,33,38,67,126,129,140,143,147,154,255,282,289,351,369,372,405,409,412,415,418,521,549,552,556,559,562,584,587,590,597,601,611,711,714,718,730,826,833,846,850,853,873,901,905,920,939,980,984,987,994,1015,1019,1022,1029,1158,1168,1172,1179,1182,1186,1189,1195,1198,1202,1205,1214],[10,11,12,17,18,24,25,29],"p",{},[13,14,16],"a",{"href":15},"\u002Fproyectos\u002Freddit-archive","Reddit Archive"," muestra dentro de Reddit los posts y comentarios borrados o editados, sacados del archivo público de ",[13,19,23],{"href":20,"rel":21},"https:\u002F\u002Farctic-shift.photon-reddit.com",[22],"nofollow","Arctic Shift",". Qué hace y cómo se usa está en el ",[13,26,28],{"href":27},"\u002Farticulos\u002Freddit-archive-lo-borrado-de-reddit","artículo para usuarios",". Este cuenta cómo está hecha.",[10,31,32],{},"Contado en una línea parece poca cosa: leer el usuario de la URL, pedir sus posts a una API y pintarlos. Pero hay dos piezas que no controlo. Una es la página, que es una SPA que cambia cuando quiere. La otra son los datos, que vienen de un servicio de terceros, los escribió gente cualquiera y pueden llegar con una forma inesperada. Casi todo el código sale de desconfiar de las dos.",[34,35,37],"h2",{"id":36},"la-forma-de-la-extensión","La forma de la extensión",[10,39,40,41,45,46,49,50,53,54,57,58,61,62,66],{},"Es una extensión ",[42,43,44],"strong",{},"Manifest V3"," hecha con ",[42,47,48],{},"WXT",", ",[42,51,52],{},"Vue 3"," y ",[42,55,56],{},"TypeScript",", con ",[42,59,60],{},"Zod"," para validar. Hay tres content scripts, uno por tipo de página (perfil, post y enlace corto ",[63,64,65],"code",{},"\u002Fs\u002F","), un service worker y un popup:",[68,69,74],"pre",{"className":70,"code":71,"language":72,"meta":73,"style":73},"language-mermaid shiki shiki-themes github-light github-dark","flowchart TD\n  CS[Content script\u003Cbr\u002F>perfil · post · enlace corto] -->|runtime.sendMessage| SW[Service worker]\n  CS -->|L1: Map en memoria| CS\n  SW -->|L2: storage.session con TTL| C[(Caché)]\n  SW -->|fetch + rate limit| AS[Arctic Shift API]\n  CS -->|about.json, mismo origen| R[Reddit]\n  CS --> P[Panel Vue 3\u003Cbr\u002F>en Shadow DOM]\n  POP[Popup] -->|estado del rate limit| SW\n","mermaid","",[63,75,76,84,90,96,102,108,114,120],{"__ignoreMap":73},[77,78,81],"span",{"class":79,"line":80},"line",1,[77,82,83],{},"flowchart TD\n",[77,85,87],{"class":79,"line":86},2,[77,88,89],{},"  CS[Content script\u003Cbr\u002F>perfil · post · enlace corto] -->|runtime.sendMessage| SW[Service worker]\n",[77,91,93],{"class":79,"line":92},3,[77,94,95],{},"  CS -->|L1: Map en memoria| CS\n",[77,97,99],{"class":79,"line":98},4,[77,100,101],{},"  SW -->|L2: storage.session con TTL| C[(Caché)]\n",[77,103,105],{"class":79,"line":104},5,[77,106,107],{},"  SW -->|fetch + rate limit| AS[Arctic Shift API]\n",[77,109,111],{"class":79,"line":110},6,[77,112,113],{},"  CS -->|about.json, mismo origen| R[Reddit]\n",[77,115,117],{"class":79,"line":116},7,[77,118,119],{},"  CS --> P[Panel Vue 3\u003Cbr\u002F>en Shadow DOM]\n",[77,121,123],{"class":79,"line":122},8,[77,124,125],{},"  POP[Popup] -->|estado del rate limit| SW\n",[10,127,128],{},"Casi todas las peticiones a Arctic Shift las hace el service worker. La excepción es la del post por id, que sale directamente del panel de posts.",[10,130,131,132,135,136,139],{},"Los permisos son mínimos: ",[63,133,134],{},"storage"," y acceso a ",[63,137,138],{},"arctic-shift.photon-reddit.com",". Los content scripts se declaran solo para las rutas de Reddit que les tocan, no para toda la web.",[10,141,142],{},"Uso once endpoints de Arctic Shift: búsqueda de posts y comentarios por autor, posts por id, árbol de comentarios, agregados por fecha, interacciones por subreddit, flairs, información y normas de subreddits, y resolución de enlaces cortos. Cada uno tiene su cliente, su parser y sus tests.",[34,144,146],{"id":145},"desconfiar-de-la-respuesta","Desconfiar de la respuesta",[10,148,149,150,153],{},"Todas las peticiones pasan por una sola función, ",[63,151,152],{},"requestJson",", que nunca lanza. Devuelve un resultado tipado:",[68,155,159],{"className":156,"code":157,"language":158,"meta":73,"style":73},"language-ts shiki shiki-themes github-light github-dark","export type ArcticShiftResult\u003CTData> =\n  | { readonly ok: true; readonly data: TData }\n  | { readonly ok: false; readonly error: ArcticShiftError };\n","ts",[63,160,161,187,225],{"__ignoreMap":73},[77,162,163,167,170,174,178,181,184],{"class":79,"line":80},[77,164,166],{"class":165},"szBVR","export",[77,168,169],{"class":165}," type",[77,171,173],{"class":172},"sScJk"," ArcticShiftResult",[77,175,177],{"class":176},"sVt8B","\u003C",[77,179,180],{"class":172},"TData",[77,182,183],{"class":176},"> ",[77,185,186],{"class":165},"=\n",[77,188,189,192,195,198,202,205,209,212,214,217,219,222],{"class":79,"line":86},[77,190,191],{"class":165},"  |",[77,193,194],{"class":176}," { ",[77,196,197],{"class":165},"readonly",[77,199,201],{"class":200},"s4XuR"," ok",[77,203,204],{"class":165},":",[77,206,208],{"class":207},"sj4cs"," true",[77,210,211],{"class":176},"; ",[77,213,197],{"class":165},[77,215,216],{"class":200}," data",[77,218,204],{"class":165},[77,220,221],{"class":172}," TData",[77,223,224],{"class":176}," }\n",[77,226,227,229,231,233,235,237,240,242,244,247,249,252],{"class":79,"line":92},[77,228,191],{"class":165},[77,230,194],{"class":176},[77,232,197],{"class":165},[77,234,201],{"class":200},[77,236,204],{"class":165},[77,238,239],{"class":207}," false",[77,241,211],{"class":176},[77,243,197],{"class":165},[77,245,246],{"class":200}," error",[77,248,204],{"class":165},[77,250,251],{"class":172}," ArcticShiftError",[77,253,254],{"class":176}," };\n",[10,256,257,258,49,261,49,264,49,267,49,270,273,274,277,278,281],{},"El error tiene un tipo (",[63,259,260],{},"http",[63,262,263],{},"network",[63,265,266],{},"timeout",[63,268,269],{},"schema",[63,271,272],{},"not_found"," o ",[63,275,276],{},"invalid_request",") y, si es HTTP, el código. La interfaz lo convierte en un mensaje concreto: no es lo mismo \"el archivo no responde\" que \"has hecho demasiadas peticiones\". Hay un timeout de 10 segundos con ",[63,279,280],{},"AbortController",", porque una petición colgada no puede dejar el panel cargando para siempre.",[10,283,284,285,288],{},"La validación tiene dos capas. La primera comprueba el sobre (",[63,286,287],{},"{ data: [...] }",") y que cada elemento tenga los campos imprescindibles con el tipo correcto. Si uno no los tiene, la respuesta entera se rechaza como error de esquema. No se pinta un panel a medias con datos que no entiendo. La segunda capa es Zod, que normaliza al modelo interno:",[68,290,292],{"className":156,"code":291,"language":158,"meta":73,"style":73},"export const arcticShiftBaseItemSchema = z.object({\n  id: arcticShiftRequiredStringSchema,\n  author: arcticShiftRequiredStringSchema,\n  created_utc: arcticShiftUtcSchema,\n  subreddit: arcticShiftOptionalStringSchema,\n  permalink: arcticShiftOptionalStringSchema,\n  score: arcticShiftScoreSchema,\n});\n",[63,293,294,316,321,326,331,336,341,346],{"__ignoreMap":73},[77,295,296,298,301,304,307,310,313],{"class":79,"line":80},[77,297,166],{"class":165},[77,299,300],{"class":165}," const",[77,302,303],{"class":207}," arcticShiftBaseItemSchema",[77,305,306],{"class":165}," =",[77,308,309],{"class":176}," z.",[77,311,312],{"class":172},"object",[77,314,315],{"class":176},"({\n",[77,317,318],{"class":79,"line":86},[77,319,320],{"class":176},"  id: arcticShiftRequiredStringSchema,\n",[77,322,323],{"class":79,"line":92},[77,324,325],{"class":176},"  author: arcticShiftRequiredStringSchema,\n",[77,327,328],{"class":79,"line":98},[77,329,330],{"class":176},"  created_utc: arcticShiftUtcSchema,\n",[77,332,333],{"class":79,"line":104},[77,334,335],{"class":176},"  subreddit: arcticShiftOptionalStringSchema,\n",[77,337,338],{"class":79,"line":110},[77,339,340],{"class":176},"  permalink: arcticShiftOptionalStringSchema,\n",[77,342,343],{"class":79,"line":116},[77,344,345],{"class":176},"  score: arcticShiftScoreSchema,\n",[77,347,348],{"class":79,"line":122},[77,349,350],{"class":176},"});\n",[10,352,353,354,53,357,360,361,364,365,368],{},"Antes de salir, la petición también se valida. El usuario tiene que cumplir el formato de nombres de Reddit (de 3 a 20 caracteres, letras, números, ",[63,355,356],{},"_",[63,358,359],{},"-",") y el ",[63,362,363],{},"limit"," tiene que ser un entero entre 1 y 1000 o ",[63,366,367],{},"auto",". Un nombre raro sacado de la URL no llega nunca a la API.",[10,370,371],{},"Algunas rarezas solo las descubrí probando contra la API real, y están apuntadas en el código para que nadie las \"arregle\":",[373,374,375,383,390],"ul",{},[376,377,378,379,382],"li",{},"En el árbol de comentarios, \"sin respuestas\" no es un campo ausente ni una lista vacía: es la cadena vacía ",[63,380,381],{},"\"\"",", una herencia de la API de Reddit que Arctic Shift deja pasar tal cual. La primera versión del parser lo trataba como un error de forma y rompía el árbol entero.",[376,384,385,386,389],{},"El endpoint de flairs devuelve 422 cuando la consulta tarda demasiado en el servidor. Lo comprobé con trece usuarios: el cuerpo siempre era ",[63,387,388],{},"\"Timeout. Maybe slow down a bit\"",". Sería tentador leerlo como \"este usuario no tiene flairs\" y pintar una lista vacía, pero eso sería mentir. Se queda como un error que se puede reintentar.",[376,391,392,393,396,397,400,401,404],{},"Los agregados por fecha exigen un ",[63,394,395],{},"before"," o un ",[63,398,399],{},"after",". Para pedir la historia completa uso ",[63,402,403],{},"after=2005-01-01",", una fecha anterior a la propia Reddit, y así no hace falta averiguar antes cuándo se creó la cuenta.",[34,406,408],{"id":407},"que-lo-archivado-nunca-parezca-original","Que lo archivado nunca parezca original",[10,410,411],{},"Esta es la regla más importante del proyecto, y está escrita en el README: si un usuario puede confundir contenido archivado con el contenido actual de Reddit, la implementación es incorrecta.",[10,413,414],{},"En la práctica eso significa varias cosas. Todo lo que viene del archivo se pinta en un panel propio, con una barra naranja de \"Contenido archivado · vía Arctic Shift\". El feed nativo de Reddit no se oculta, no se reordena y no se reemplaza, y hay tests e2e que lo comprueban. Y cada elemento lleva su etiqueta y su fuente.",[10,416,417],{},"El modelo interno marca la procedencia en el tipo:",[68,419,421],{"className":156,"code":420,"language":158,"meta":73,"style":73},"export type ArchivedItemBase = {\n  id: string;\n  kind: ArchivedItemKind;\n  \u002F\u002F ...\n  isArchived: true;\n  isRestored: boolean;\n  restorationLabel: RestorationLabel;\n  source: ArchiveSource;\n};\n",[63,422,423,437,450,462,468,479,491,503,515],{"__ignoreMap":73},[77,424,425,427,429,432,434],{"class":79,"line":80},[77,426,166],{"class":165},[77,428,169],{"class":165},[77,430,431],{"class":172}," ArchivedItemBase",[77,433,306],{"class":165},[77,435,436],{"class":176}," {\n",[77,438,439,442,444,447],{"class":79,"line":86},[77,440,441],{"class":200},"  id",[77,443,204],{"class":165},[77,445,446],{"class":207}," string",[77,448,449],{"class":176},";\n",[77,451,452,455,457,460],{"class":79,"line":92},[77,453,454],{"class":200},"  kind",[77,456,204],{"class":165},[77,458,459],{"class":172}," ArchivedItemKind",[77,461,449],{"class":176},[77,463,464],{"class":79,"line":98},[77,465,467],{"class":466},"sJ8bj","  \u002F\u002F ...\n",[77,469,470,473,475,477],{"class":79,"line":104},[77,471,472],{"class":200},"  isArchived",[77,474,204],{"class":165},[77,476,208],{"class":207},[77,478,449],{"class":176},[77,480,481,484,486,489],{"class":79,"line":110},[77,482,483],{"class":200},"  isRestored",[77,485,204],{"class":165},[77,487,488],{"class":207}," boolean",[77,490,449],{"class":176},[77,492,493,496,498,501],{"class":79,"line":116},[77,494,495],{"class":200},"  restorationLabel",[77,497,204],{"class":165},[77,499,500],{"class":172}," RestorationLabel",[77,502,449],{"class":176},[77,504,505,508,510,513],{"class":79,"line":122},[77,506,507],{"class":200},"  source",[77,509,204],{"class":165},[77,511,512],{"class":172}," ArchiveSource",[77,514,449],{"class":176},[77,516,518],{"class":79,"line":517},9,[77,519,520],{"class":176},"};\n",[10,522,523,526,527,530,531,534,535,538,539,49,542,273,545,548],{},[63,524,525],{},"isArchived"," es el literal ",[63,528,529],{},"true",", no un ",[63,532,533],{},"boolean",": no hay forma de construir un elemento del panel que no esté marcado como archivado. ",[63,536,537],{},"restorationLabel"," puede ser ",[63,540,541],{},"archived",[63,543,544],{},"restored_deleted",[63,546,547],{},"restored_missing",", y la regla para los dos últimos es estricta. \"Restaurado\" solo se muestra cuando hay una señal explícita de que el contenido se borró en Reddit. Que algo no se vea en la página no basta, porque puede no verse por muchas razones: un subreddit privado, un filtro, que Reddit no lo haya cargado todavía. Por defecto, todo es \"Archivado\".",[10,550,551],{},"Al juntar posts y comentarios, se deduplican por id (o por permalink si no hay id). Si hay dos versiones del mismo elemento, gana la que tiene más campos rellenos y, a igualdad, la que tiene la etiqueta más fuerte.",[34,553,555],{"id":554},"caché-en-dos-niveles","Caché en dos niveles",[10,557,558],{},"Un perfil activo son dos peticiones (posts y comentarios), más las estadísticas, más una por cada subreddit sobre el que pasas el ratón. Sin caché, volver a un perfil o recargar la pestaña repetiría todo, y Arctic Shift limita las peticiones.",[10,560,561],{},"Hay dos niveles independientes:",[373,563,564,574],{},[376,565,566,569,570,573],{},[42,567,568],{},"L1",", en el content script: un ",[63,571,572],{},"Map"," en memoria, sin caducidad. Dura lo que dura la pestaña. Si la respuesta está aquí, el mensaje ni siquiera llega al service worker.",[376,575,576,579,580,583],{},[42,577,578],{},"L2",", en el service worker: ",[63,581,582],{},"chrome.storage.session",", que sobrevive a que Chrome duerma el service worker y a que recargues la pestaña, y se borra al cerrar el navegador. Las búsquedas caducan a los 5 minutos, y los datos que cambian poco (información de subreddits, estadísticas, enlaces cortos) a las 24 horas.",[10,585,586],{},"Las dos siguen la misma regla: solo se guarda lo que salió bien. Un error, un timeout o un 429 no se cachean nunca, para que el siguiente intento vaya de verdad a la red. La caché de información de subreddits tampoco guarda el \"no encontrado\", porque un fallo pasajero no debía convertirse en \"este subreddit no existe\" durante 24 horas. Ese bug existió.",[10,588,589],{},"La clave de la caché incluye todo lo que cambia la respuesta: tipo, autor, límite, subreddit, fechas y orden. Al principio los filtros no estaban en la clave, y aplicar un filtro devolvía los resultados sin filtrar que ya estaban en caché.",[10,591,592,593,596],{},"Si ",[63,594,595],{},"storage.session"," falla (por cuota o porque no está disponible), la caché se salta sin más. Que no se pueda cachear nunca debe hacer fallar una petición.",[34,598,600],{"id":599},"rate-limit-sin-esperar-al-429","Rate limit sin esperar al 429",[10,602,603,604,53,607,610],{},"Arctic Shift devuelve en cada respuesta ",[63,605,606],{},"X-RateLimit-Remaining",[63,608,609],{},"X-RateLimit-Reset",". El cliente guarda el último valor y, antes de cada petición, mira si quedan pocas:",[68,612,614],{"className":156,"code":613,"language":158,"meta":73,"style":73},"if (\n  lastRateLimitInfo &&\n  lastRateLimitInfo.remaining \u003C= RATE_LIMIT_WARNING_THRESHOLD\n) {\n  const waitSeconds = Math.min(\n    lastRateLimitInfo.resetSeconds,\n    MAX_RATE_LIMIT_WAIT_SECONDS,\n  );\n  await delayImpl(waitSeconds * 1000);\n}\n",[63,615,616,624,632,643,648,667,672,680,685,705],{"__ignoreMap":73},[77,617,618,621],{"class":79,"line":80},[77,619,620],{"class":165},"if",[77,622,623],{"class":176}," (\n",[77,625,626,629],{"class":79,"line":86},[77,627,628],{"class":176},"  lastRateLimitInfo ",[77,630,631],{"class":165},"&&\n",[77,633,634,637,640],{"class":79,"line":92},[77,635,636],{"class":176},"  lastRateLimitInfo.remaining ",[77,638,639],{"class":165},"\u003C=",[77,641,642],{"class":207}," RATE_LIMIT_WARNING_THRESHOLD\n",[77,644,645],{"class":79,"line":98},[77,646,647],{"class":176},") {\n",[77,649,650,653,656,658,661,664],{"class":79,"line":104},[77,651,652],{"class":165},"  const",[77,654,655],{"class":207}," waitSeconds",[77,657,306],{"class":165},[77,659,660],{"class":176}," Math.",[77,662,663],{"class":172},"min",[77,665,666],{"class":176},"(\n",[77,668,669],{"class":79,"line":110},[77,670,671],{"class":176},"    lastRateLimitInfo.resetSeconds,\n",[77,673,674,677],{"class":79,"line":116},[77,675,676],{"class":207},"    MAX_RATE_LIMIT_WAIT_SECONDS",[77,678,679],{"class":176},",\n",[77,681,682],{"class":79,"line":122},[77,683,684],{"class":176},"  );\n",[77,686,687,690,693,696,699,702],{"class":79,"line":517},[77,688,689],{"class":165},"  await",[77,691,692],{"class":172}," delayImpl",[77,694,695],{"class":176},"(waitSeconds ",[77,697,698],{"class":165},"*",[77,700,701],{"class":207}," 1000",[77,703,704],{"class":176},");\n",[77,706,708],{"class":79,"line":707},10,[77,709,710],{"class":176},"}\n",[10,712,713],{},"Con cinco o menos, espera hasta que se reinicie el contador, con un máximo de un minuto. Así, \"Cargar todo\" en una cuenta con miles de comentarios va más lento cuando se acerca al límite, en vez de estrellarse contra un 429 a mitad de camino. El popup pregunta al service worker por ese mismo estado y lo muestra: conectado, cerca del límite o límite alcanzado.",[34,715,717],{"id":716},"paginar-por-fecha-no-por-página","Paginar por fecha, no por página",[10,719,720,721,724,725,53,727,729],{},"La búsqueda por autor de Arctic Shift no tiene páginas: ordena por ",[63,722,723],{},"created_utc"," y acepta ",[63,726,395],{},[63,728,399],{},". Así que la paginación es por cursor de fecha. En orden descendente, la siguiente página empieza antes del elemento más antiguo cargado. En ascendente, después del más reciente:",[68,731,733],{"className":156,"code":732,"language":158,"meta":73,"style":73},"if (loadedSort === 'asc') {\n  const newest = items.reduce(\n    (max, item) => Math.max(max, item.createdUtc),\n    -Infinity,\n  );\n  return { subreddit, after: newest.toString(), before };\n}\n",[63,734,735,751,768,794,804,808,822],{"__ignoreMap":73},[77,736,737,739,742,745,749],{"class":79,"line":80},[77,738,620],{"class":165},[77,740,741],{"class":176}," (loadedSort ",[77,743,744],{"class":165},"===",[77,746,748],{"class":747},"sZZnC"," 'asc'",[77,750,647],{"class":176},[77,752,753,755,758,760,763,766],{"class":79,"line":86},[77,754,652],{"class":165},[77,756,757],{"class":207}," newest",[77,759,306],{"class":165},[77,761,762],{"class":176}," items.",[77,764,765],{"class":172},"reduce",[77,767,666],{"class":176},[77,769,770,773,776,778,781,784,787,789,791],{"class":79,"line":92},[77,771,772],{"class":176},"    (",[77,774,775],{"class":200},"max",[77,777,49],{"class":176},[77,779,780],{"class":200},"item",[77,782,783],{"class":176},") ",[77,785,786],{"class":165},"=>",[77,788,660],{"class":176},[77,790,775],{"class":172},[77,792,793],{"class":176},"(max, item.createdUtc),\n",[77,795,796,799,802],{"class":79,"line":98},[77,797,798],{"class":165},"    -",[77,800,801],{"class":207},"Infinity",[77,803,679],{"class":176},[77,805,806],{"class":79,"line":104},[77,807,684],{"class":176},[77,809,810,813,816,819],{"class":79,"line":110},[77,811,812],{"class":165},"  return",[77,814,815],{"class":176}," { subreddit, after: newest.",[77,817,818],{"class":172},"toString",[77,820,821],{"class":176},"(), before };\n",[77,823,824],{"class":79,"line":116},[77,825,710],{"class":176},[10,827,828,829,832],{},"El orden \"Más antiguos\" estuvo mal hasta la versión 1.4.0. Reordenaba en el cliente lo que ya estaba cargado, que eran las páginas más recientes. Elegías \"más antiguos\" y te enseñaba lo más antiguo de las últimas veinte publicaciones, no lo primero que escribió esa persona. Ahora pide ",[63,830,831],{},"sort=asc"," al servidor y pagina hacia delante. Si cambias de orden y eso le da la vuelta al orden del servidor, recarga desde cero. \"Puntuación\" sí sigue siendo un orden en el cliente, porque la API no sabe ordenar por score en la búsqueda por autor, y el ROADMAP lo deja apuntado.",[10,834,835,838,839,842,843,845],{},[42,836,837],{},"Exportar todo"," usa el mismo cursor. Recorre cada tipo (posts y luego comentarios) con ",[63,840,841],{},"limit=auto",", que deja al servidor elegir el tamaño de página, va moviendo ",[63,844,395],{}," hacia atrás y para cuando una página llega vacía o con menos elementos que el límite. Respeta los filtros del panel, así que se puede exportar solo un subreddit o un rango de fechas. Como dos elementos pueden compartir el mismo segundo, cada página se cruza con lo que ya se tiene por id antes de añadirla, y la exportación no repite filas.",[34,847,849],{"id":848},"tres-formatos-y-un-csv-que-no-ejecuta-nada","Tres formatos y un CSV que no ejecuta nada",[10,851,852],{},"La exportación genera tres formatos desde el mismo modelo, con funciones puras y probadas por separado:",[373,854,855,861,867],{},[376,856,857,860],{},[42,858,859],{},"JSON",": los posts y comentarios normalizados, con el usuario y la fecha de exportación.",[376,862,863,866],{},[42,864,865],{},"Markdown",": un documento legible, con un encabezado por elemento, su fecha, su puntuación, su etiqueta y un enlace permanente.",[376,868,869,872],{},[42,870,871],{},"CSV",": una fila por elemento, con el texto recortado a 200 caracteres.",[10,874,875,876,49,879,49,882,273,884,887,888,892,893,896,897,900],{},"El CSV tiene trampa. Si un comentario empieza por ",[63,877,878],{},"=",[63,880,881],{},"+",[63,883,359],{},[63,885,886],{},"@",", Excel o Google Sheets lo interpretan como una fórmula al abrir el archivo. Es la ",[889,890,891],"em",{},"inyección de fórmulas en CSV",", y aquí el texto lo escribieron desconocidos. La solución habitual es anteponer un apóstrofo, pero aplicarla a todas las columnas rompía otra cosa: una puntuación de ",[63,894,895],{},"-4"," se convertía en el texto ",[63,898,899],{},"'-4",". Así que la protección se aplica solo a las columnas que pueden llevar texto de usuarios (subreddit, permalink y texto), y no a las que genera el propio código, como el id, la fecha o la puntuación.",[34,902,904],{"id":903},"markdown-de-terceros-dentro-de-tu-página","Markdown de terceros dentro de tu página",[10,906,907,908,911,912,915,916,919],{},"Los posts y comentarios están en Markdown, y quería mostrarlos con su formato. Se convierten con ",[42,909,910],{},"marked"," y el HTML que sale pasa por ",[42,913,914],{},"DOMPurify"," antes de llegar al ",[63,917,918],{},"v-html"," de Vue. Es contenido escrito por cualquiera, así que el HTML generado no es de fiar.",[10,921,922,923,926,927,930,931,934,935,938],{},"DOMPurify deja pasar por defecto ",[63,924,925],{},"\u003Cstyle>"," y los atributos ",[63,928,929],{},"style",". Dentro del shadow root eso parece inofensivo, porque los selectores no salen de él. Pero un elemento con ",[63,932,933],{},"position: fixed"," sí puede tapar la página entera, y un ",[63,936,937],{},"url()"," dentro de un estilo puede hacer una petición a cualquier servidor. En la 1.4.0 los estilos se quitan:",[68,940,942],{"className":156,"code":941,"language":158,"meta":73,"style":73},"const SANITIZE_CONFIG = {\n  FORBID_TAGS: ['style'],\n  FORBID_ATTR: ['style'],\n};\n",[63,943,944,956,967,976],{"__ignoreMap":73},[77,945,946,949,952,954],{"class":79,"line":80},[77,947,948],{"class":165},"const",[77,950,951],{"class":207}," SANITIZE_CONFIG",[77,953,306],{"class":165},[77,955,436],{"class":176},[77,957,958,961,964],{"class":79,"line":86},[77,959,960],{"class":176},"  FORBID_TAGS: [",[77,962,963],{"class":747},"'style'",[77,965,966],{"class":176},"],\n",[77,968,969,972,974],{"class":79,"line":92},[77,970,971],{"class":176},"  FORBID_ATTR: [",[77,973,963],{"class":747},[77,975,966],{"class":176},[77,977,978],{"class":79,"line":98},[77,979,520],{"class":176},[34,981,983],{"id":982},"montarse-en-una-spa-que-se-repinta","Montarse en una SPA que se repinta",[10,985,986],{},"Reddit es una SPA. Cambiar de pestaña en un perfil, o pasar de un perfil a otro, no recarga la página. Y Reddit repinta partes del DOM cuando le parece, a veces llevándose por delante lo que hay dentro.",[10,988,989,990,993],{},"Cada content script vigila los cambios de URL y recalcula el contexto (qué usuario, qué ruta, qué post). Si cambia, desmonta la app de Vue y la vuelve a montar con los datos nuevos. Además hay un ",[63,991,992],{},"MutationObserver"," sobre el documento que comprueba si el host del panel sigue ahí. Si Reddit lo ha quitado, lo vuelve a poner.",[10,995,996,997,49,1000,1003,1004,1007,1008,1011,1012,1014],{},"El panel va dentro de un shadow root, para que los estilos de Reddit no le afecten y los suyos no se salgan. Para colocarlo justo encima del feed se prueba una lista de selectores, de más específico a más genérico: ",[63,998,999],{},"#main-content",[63,1001,1002],{},"main",", el slot de ",[63,1005,1006],{},"shreddit-app",", contenedores antiguos y, como último recurso, el ",[63,1009,1010],{},"body",". Un segundo ",[63,1013,992],{}," mira las clases y atributos de tema de Reddit para que el panel siga el modo oscuro.",[34,1016,1018],{"id":1017},"preguntarle-a-reddit-no-a-la-página","Preguntarle a Reddit, no a la página",[10,1020,1021],{},"El aviso de \"esta cuenta parece eliminada o suspendida\" funcionaba así hasta hace poco: buscaba en el texto de toda la página \"not found\", \"suspended\" o \"Something went wrong\". Saltaba con cualquier comentario que contuviera esas palabras, y en Reddit en otro idioma no funcionaba nunca.",[10,1023,1024,1025,1028],{},"Ahora le pregunta a Reddit. El content script pide ",[63,1026,1027],{},"\u002Fuser\u002F\u003Cnombre>\u002Fsobre-mi.json"," al mismo origen, así que no hace falta ningún permiso extra, y clasifica la respuesta:",[68,1030,1032],{"className":156,"code":1031,"language":158,"meta":73,"style":73},"\u002F\u002F Deleted, never-existing and shadowbanned accounts all 404 the same way.\nif (status === 404) {\n  return body.error === 404 ? 'deleted' : 'unknown';\n}\n\nif (status === 200 && body.kind === 't2' && isRecord(body.data)) {\n  return body.data.is_suspended === true ? 'suspended' : 'active';\n}\n\nreturn 'unknown';\n",[63,1033,1034,1039,1053,1078,1082,1088,1118,1141,1145,1149],{"__ignoreMap":73},[77,1035,1036],{"class":79,"line":80},[77,1037,1038],{"class":466},"\u002F\u002F Deleted, never-existing and shadowbanned accounts all 404 the same way.\n",[77,1040,1041,1043,1046,1048,1051],{"class":79,"line":86},[77,1042,620],{"class":165},[77,1044,1045],{"class":176}," (status ",[77,1047,744],{"class":165},[77,1049,1050],{"class":207}," 404",[77,1052,647],{"class":176},[77,1054,1055,1057,1060,1062,1064,1067,1070,1073,1076],{"class":79,"line":92},[77,1056,812],{"class":165},[77,1058,1059],{"class":176}," body.error ",[77,1061,744],{"class":165},[77,1063,1050],{"class":207},[77,1065,1066],{"class":165}," ?",[77,1068,1069],{"class":747}," 'deleted'",[77,1071,1072],{"class":165}," :",[77,1074,1075],{"class":747}," 'unknown'",[77,1077,449],{"class":176},[77,1079,1080],{"class":79,"line":98},[77,1081,710],{"class":176},[77,1083,1084],{"class":79,"line":104},[77,1085,1087],{"emptyLinePlaceholder":1086},true,"\n",[77,1089,1090,1092,1094,1096,1099,1102,1105,1107,1110,1112,1115],{"class":79,"line":110},[77,1091,620],{"class":165},[77,1093,1045],{"class":176},[77,1095,744],{"class":165},[77,1097,1098],{"class":207}," 200",[77,1100,1101],{"class":165}," &&",[77,1103,1104],{"class":176}," body.kind ",[77,1106,744],{"class":165},[77,1108,1109],{"class":747}," 't2'",[77,1111,1101],{"class":165},[77,1113,1114],{"class":172}," isRecord",[77,1116,1117],{"class":176},"(body.data)) {\n",[77,1119,1120,1122,1125,1127,1129,1131,1134,1136,1139],{"class":79,"line":116},[77,1121,812],{"class":165},[77,1123,1124],{"class":176}," body.data.is_suspended ",[77,1126,744],{"class":165},[77,1128,208],{"class":207},[77,1130,1066],{"class":165},[77,1132,1133],{"class":747}," 'suspended'",[77,1135,1072],{"class":165},[77,1137,1138],{"class":747}," 'active'",[77,1140,449],{"class":176},[77,1142,1143],{"class":79,"line":122},[77,1144,710],{"class":176},[77,1146,1147],{"class":79,"line":517},[77,1148,1087],{"emptyLinePlaceholder":1086},[77,1150,1151,1154,1156],{"class":79,"line":707},[77,1152,1153],{"class":165},"return",[77,1155,1075],{"class":747},[77,1157,449],{"class":176},[10,1159,1160,1161,1164,1165,1167],{},"Solo cuentan las respuestas JSON de Reddit. Un rate limit, una página de error en HTML o un fallo de red dan ",[63,1162,1163],{},"unknown",", y con ",[63,1166,1163],{}," no se muestra nada. Prefiero un aviso que a veces falta a uno que sale cuando no toca. La comprobación va en paralelo a la carga del archivo y no la retrasa.",[34,1169,1171],{"id":1170},"enlaces-cortos","Enlaces cortos",[10,1173,1174,1175,1178],{},"La app de Reddit comparte enlaces del tipo ",[63,1176,1177],{},"\u002Fr\u002F\u003Csub>\u002Fs\u002F\u003Ccódigo>"," que no dicen a qué post apuntan. Arctic Shift tiene un endpoint que los resuelve a la ruta completa. De ahí se saca el id del post y, si la ruta tiene un segmento más después del título, el id del comentario. Ese comentario se resalta en el árbol y la vista se desplaza hasta él.",[10,1180,1181],{},"Hay un caso que no se puede distinguir: si la ruta tiene un único segmento después del id del post, puede ser el título o un id de comentario. En ese caso solo se resuelve el post, y el código lo explica para que nadie intente adivinar.",[34,1183,1185],{"id":1184},"tests","Tests",[10,1187,1188],{},"Hay 482 tests de Vitest con happy-dom: clientes de cada endpoint, parsers, normalizadores, deduplicado, formatos de exportación, caché, detección de contexto, estado de la cuenta, sanitizado del Markdown y la lógica del panel. Los tests de integración del cliente usan respuestas guardadas de la API real. Las traducciones (español, inglés y chino) viven en dos copias: la que usa Chrome y la que carga el selector de idioma del popup. Llegaron a desincronizarse, y ahora un test comprueba que las dos tienen las mismas claves.",[10,1190,1191,1192,1194],{},"Otros 12 tests de Playwright cargan la extensión compilada en Chromium. Sirven páginas de Reddit falsas y responden con datos falsos a las peticiones a Arctic Shift: éxito, vacío, esquema roto, 429, 503 y timeout. Comprueban que el panel se monta una sola vez, que el feed nativo no se toca y que la navegación SPA cambia de usuario sin mezclar resultados. En CI corren sin interfaz, junto con el typecheck, ESLint y Prettier, en cada push a ",[63,1193,1002],{}," y en cada pull request.",[10,1196,1197],{},"Esa misma infraestructura sirvió para las capturas de los artículos: el mismo build, una página de demostración y un usuario inventado. Así no sale ninguna persona real.",[34,1199,1201],{"id":1200},"lo-que-me-llevo","Lo que me llevo",[10,1203,1204],{},"En Video Accessibility, la lección fue que una extensión tiene que ser educada con la página. Aquí hay una segunda: tiene que ser honesta con los datos. Muchas decisiones de este proyecto consisten en no inventar nada. No llamar \"restaurado\" a lo que solo está archivado. No convertir un timeout en \"sin flairs\". No avisar de una cuenta borrada porque un comentario dice \"suspended\". No cachear un error como si fuera una respuesta. Cada una cuesta unas líneas más de código, y a cambio lo que enseña el panel se puede creer.",[10,1206,1207,1208,1213],{},"Está en la ",[13,1209,1212],{"href":1210,"rel":1211},"https:\u002F\u002Fchromewebstore.google.com\u002Fdetail\u002Freddit-archive\u002Feiocepcbcfpeiehcgdoghdgonmjgconn",[22],"Chrome Web Store",".",[929,1215,1216],{},"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 .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 .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}",{"title":73,"searchDepth":86,"depth":86,"links":1218},[1219,1220,1221,1222,1223,1224,1225,1226,1227,1228,1229,1230,1231],{"id":36,"depth":86,"text":37},{"id":145,"depth":86,"text":146},{"id":407,"depth":86,"text":408},{"id":554,"depth":86,"text":555},{"id":599,"depth":86,"text":600},{"id":716,"depth":86,"text":717},{"id":848,"depth":86,"text":849},{"id":903,"depth":86,"text":904},{"id":982,"depth":86,"text":983},{"id":1017,"depth":86,"text":1018},{"id":1170,"depth":86,"text":1171},{"id":1184,"depth":86,"text":1185},{"id":1200,"depth":86,"text":1201},"2026-10-27","Cómo funciona una extensión de Chrome que muestra contenido borrado de Reddit desde la API pública de Arctic Shift. Validar respuestas que no controlas, que lo archivado nunca parezca original, caché en dos niveles, rate limit, paginar por cursor para exportar la historia entera de un usuario, Markdown de terceros en Shadow DOM y un CSV que no ejecuta fórmulas.",false,"md",{},"\u002Farticulos\u002Freddit-archive-por-dentro",{"title":5,"description":1233},"articulos\u002Freddit-archive-por-dentro",[1241,1242,1243,1244],"reddit-archive","arquitectura","typescript","vue","tecnico","ktMHzzpM5KZKOyA4wqIlXiCRdzzK616cBrEa9YMfU6A",null,1793761188267]