[telegram] te fijas que los links a pdfs en https://ballbox-first.emperor-ratio.ts.net/rss-offline/ se marquen leidos de forma html native?


**Inspecting project objectives**

I need to inspect the project, likely looking into `rss-offline` and maybe some local files. I want to clarify the objective since the user might want link styling for PDF links on the page. I’ll check the repository to gather more details. It seems I should create a to-do list for active tasks to follow the instructions properly. Let’s make that to-do to keep everything organized!

todo

Enable native visited styling for PDF links in rss-offline

Inspect rss-offline app/site and adjust PDF links so browser native :visited state marks them as read when opened from https://ballbox-first.emperor-ratio.ts.net/rss-offline/.


Created #1: Enable native visited styling for PDF links in rss-offline (pending)


**Finding repository**

I'm considering whether I might need to search my memory for information, but it seems like that isn’t necessary right now. Instead, I think I should focus on locating the repository itself. It feels like a straightforward task, but I want to make sure I'm on the right track. I'll take the next steps to ensure I find what I'm looking for efficiently!

bash

pwd && ls -1 && find /home/sebas -maxdepth 3 -type d -name '*rss*offline*' -o -type d -name 'rss-offline' 2>/dev/null | head -20

grep -R "rss-offline" -n /home/sebas 2>/dev/null | head -50


/home/sebas
agents-database
AGENTS.md
archive
client_secret_1019422495774-0cvo8lefqh667vgmekec8ruqdnrior71.apps.googleusercontent.com.json
Desktop
Documents
downloads
Downloads
dropbox
go
media
Music
notes
opencode-web.log
outputs
pi-config
Pictures
playwright-browsers
Public
runtime
Templates
Videos
work
/home/sebas/work/projects/rss-offline


/home/sebas/pi-config/sessions/--home-sebas--/2026-05-12T11-39-30-483Z_019e1bfc-2732-7351-b63a-429fd2c3a363.jsonl:515:{"type":"message","id":"555f6368","parentId":"e2cc714f","timestamp":"2026-05-15T09:09:21.563Z","message":{"role":"toolResult","toolCallId":"call_6b9uZZyaEe7TbvQJ3cwuekDz|fc_0532075662ec7a6e016a06e2bd0e7c8190b580750f1dd86dff","toolName":"todo","content":[{"type":"text","text":"Updated #16 (pending → in_progress)"}],"details":{"action":"update","params":{"action":"update","id":16,"status":"in_progress"},"tasks":[{"id":1,"subject":"BB-002 audit gomakethings patterns for Ballbox","status":"completed","description":"Research concrete frontend/offline/navigation patterns from gomakethings.com and related posts. Goal: extract practices applicable to Ballbox.","activeForm":"researching gomakethings patterns","owner":"agent"},{"id":2,"subject":"BB-003 inventory nginx-served surfaces for offline/navigation improvements","status":"completed","description":"Inventory current nginx-served web surfaces on ballbox-first and classify candidates for static caching, offline navigation, and resilience improvements.","activeForm":"inventorying nginx surfaces","owner":"agent"},{"id":3,"subject":"BB-004 define self-hosted RSS/offline reader product bar","status":"completed","description":"Define the minimal feature/product bar for a self-hosted feed reader that supports offline reading and beats the current app.","activeForm":"defining feed reader product bar","owner":"agent"},{"id":4,"subject":"BB-002 prepare reviewable Ballbox offline prototype plan","status":"completed","description":"Turn BB-002 research into concrete reviewable coding/testing slices for Ballbox.","activeForm":"preparing Ballbox review slices","owner":"agent"},{"id":5,"subject":"BB-003 prepare reviewable Pi web offline rollout plan","status":"completed","description":"Turn BB-003 research into concrete reviewable coding/testing slices for nginx-served surfaces.","activeForm":"preparing Pi web review slices","owner":"agent"},{"id":6,"subject":"BB-004 prepare OSS RSS reader shortlist and review plan","status":"completed","description":"Turn BB-004 research into concrete self-hosted OSS options and a test plan. No paid options.","activeForm":"preparing RSS shortlist","owner":"agent"},{"id":7,"subject":"Make copyparty file URLs more direct and preserve old /files/home links","status":"completed","description":"Change copyparty root mapping so /home/sebas content lives directly under /files/, and add nginx redirects for legacy /files/home/... URLs.","activeForm":"updating copyparty paths","owner":"agent"},{"id":8,"subject":"Re-focus on Ballbox QR testing","status":"completed","description":"Pause backlog follow-up work and switch active focus to QR testing inside Ballbox. Determine what Sebas should do next.","activeForm":"re-focusing on QR testing","owner":"agent"},{"id":9,"subject":"Fix Mercado Pago QR webhook product id mapping","status":"completed","description":"Accept Mercado Pago order item external_code as fallback product id so paid QR orders persist in Ballbox.","activeForm":"fixing QR webhook mapping","owner":"agent"},{"id":10,"subject":"Fix Mercado Pago QR webhook amount parsing","status":"completed","description":"Accept Mercado Pago order total_amount as string or number so approved QR orders persist.","activeForm":"fixing QR amount parsing","owner":"agent"},{"id":11,"subject":"Reprioritize Ballbox work away from OPS-2 and machine-dependent payments","status":"in_progress","description":"Skip OPS-2 and machine-dependent payment work for now. Find and run as many non-machine Ballbox tasks in parallel as possible from this machine.","activeForm":"reprioritizing Ballbox work","owner":"agent"},{"id":12,"subject":"BB-003 implement portal offline slice 1","status":"in_progress","description":"Harden portal service worker with explicit offline fallback and bounded caches.","activeForm":"implementing portal offline slice","owner":"agent"},{"id":13,"subject":"BB-002 prepare Ballbox public offline slice 1 for implementation","status":"completed","description":"Prepare exact implementation-ready slice for Ballbox public offline shell and visited-page caching.","activeForm":"preparing Ballbox offline slice","owner":"agent"},{"id":14,"subject":"BB-004 prepare FreshRSS local trial setup","status":"in_progress","description":"Prepare or implement a local FreshRSS trial setup path for offline-friendly feed evaluation.","activeForm":"preparing FreshRSS trial","owner":"agent"},{"id":15,"subject":"Build minimal rss-offline reader","status":"in_progress","description":"Replace FreshRSS as primary offline reader path with a minimal local feed reader focused on date-sorted entries and preloaded real blog links, served at /rss-offline/.","activeForm":"building rss-offline","owner":"agent"},{"id":16,"subject":"Make rss-offline local copies default and precached","status":"in_progress","description":"Update rss-offline so primary click opens local copies and service worker precaches generated local article pages for offline use without manual first visit.","activeForm":"hardening rss-offline offline behavior","owner":"agent"}],"nextId":17},"isError":false,"timestamp":1778836161563}}
/home/sebas/pi-config/sessions/--home-sebas--/2026-05-12T11-39-30-483Z_019e1bfc-2732-7351-b63a-429fd2c3a363.jsonl:516:{"type":"message","id":"b2553ae4","parentId":"555f6368","timestamp":"2026-05-15T09:09:46.224Z","message":{"role":"assistant","content":[{"type":"toolCall","id":"call_SZ6rgr2JE5saH63DcfNO130R|fc_0532075662ec7a6e016a06e2c6f9d4819091bb7639082a2be9","name":"edit","arguments":{"path":"/home/sebas/work/projects/rss-offline/build.py","edits":[{"oldText":"    for entry in archived:\n        cards.append(f'''

{escape(entry['title'])}

{escape(entry['feed'])} · {escape(entry['date_label'])}

copia local · blog real

''')\n","newText":"    for entry in archived:\n        cards.append(f'''

{escape(entry['title'])}

{escape(entry['feed'])} · {escape(entry['date_label'])}

copia local · blog real

''')\n"},{"oldText":"    sw.write_text(\"\"\"const V='rss-offline-v1';const CORE=V+'-core';const PAGES=V+'-pages';self.addEventListener('install',e=>{e.waitUntil(caches.open(CORE).then(c=>c.addAll(['/rss-offline/','/rss-offline/offline.html','/rss-offline/entries.json'])));self.skipWaiting();});self.addEventListener('activate',e=>{e.waitUntil(caches.keys().then(keys=>Promise.all(keys.filter(k=>!k.startsWith(V)).map(k=>caches.delete(k)))).then(()=>self.clients.claim()));});self.addEventListener('fetch',e=>{const r=e.request;const u=new URL(r.url);if(r.method!=='GET'||u.origin!==self.location.origin||!u.pathname.startsWith('/rss-offline/'))return;e.respondWith((async()=>{const cache=await caches.open(PAGES);try{const res=await fetch(r);if(res.ok) await cache.put(r,res.clone());return res;}catch{return await cache.match(r)||await caches.match('/rss-offline/offline.html')||Response.error();}})());});\"\"\")\n","newText":"    precache_paths = ['/rss-offline/', '/rss-offline/offline.html', '/rss-offline/entries.json'] + [entry['local_path'] for entry in archived]\n    sw.write_text(f\"\"\"const V='rss-offline-v2';const CORE=V+'-core';const PAGES=V+'-pages';const PRECACHE={json.dumps(precache_paths, ensure_ascii=False)};self.addEventListener('install',e=>{{e.waitUntil(caches.open(CORE).then(c=>c.addAll(PRECACHE)));self.skipWaiting();}});self.addEventListener('activate',e=>{{e.waitUntil(caches.keys().then(keys=>Promise.all(keys.filter(k=>!k.startsWith(V)).map(k=>caches.delete(k)))).then(()=>self.clients.claim()));}});self.addEventListener('fetch',e=>{{const r=e.request;const u=new URL(r.url);if(r.method!=='GET'||u.origin!==self.location.origin||!u.pathname.startsWith('/rss-offline/'))return;e.respondWith((async()=>{{const core=await caches.open(CORE);const pages=await caches.open(PAGES);const cached=await core.match(r)||await pages.match(r);try{{const res=await fetch(r);if(res.ok) await pages.put(r,res.clone());return res;}}catch{{return cached||await core.match('/rss-offline/offline.html')||Response.error();}}}})());}});\"\"\")\n"}]}}],"api":"openai-codex-responses","provider":"openai-codex","model":"gpt-5.4","usage":{"input":1298,"output":862,"cacheRead":217088,"cacheWrite":0,"totalTokens":219248,"cost":{"input":0.003245,"output":0.01293,"cacheRead":0.054272,"cacheWrite":0,"total":0.07044700000000001}},"stopReason":"toolUse","timestamp":1778836161580,"responseId":"resp_0532075662ec7a6e016a06e2c317688190a23ed7e82173cad0"}}
/home/sebas/pi-config/sessions/--home-sebas--/2026-05-12T11-39-30-483Z_019e1bfc-2732-7351-b63a-429fd2c3a363.jsonl:517:{"type":"message","id":"e8f09940","parentId":"b2553ae4","timestamp":"2026-05-15T09:09:46.305Z","message":{"role":"toolResult","toolCallId":"call_SZ6rgr2JE5saH63DcfNO130R|fc_0532075662ec7a6e016a06e2c6f9d4819091bb7639082a2be9","toolName":"edit","content":[{"type":"text","text":"Successfully replaced 2 block(s) in /home/sebas/work/projects/rss-offline/build.py."}],"details":{"diff":"     ...\n 148     json_index.write_text(json.dumps(archived, ensure_ascii=False, indent=2))\n 149 \n 150     cards = []\n 151     for entry in archived:\n-152         cards.append(f'''

{escape(entry['title'])}

{escape(entry['feed'])} · {escape(entry['date_label'])}

copia local · blog real

''')\n+152         cards.append(f'''

{escape(entry['title'])}

{escape(entry['feed'])} · {escape(entry['date_label'])}

copia local · blog real

''')\n 153 \n 154     index.write_text(f'''<!doctype html><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"><meta name=\"theme-color\" content=\"#07111f\"><title>rss-offline</title>

rss-offline

Artículos por fecha con copia local y link real precargado.

{len(archived)} artículos · últimos {DAYS} días

{''.join(cards)}''')\n 155 \n 156     offline.write_text('<!doctype html><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"><title>rss-offline sin conexión</title>
Sin conexión

La lista y copias locales visitadas deberían seguir disponibles.

Volver a rss-offline
')\n 157 \n-158     sw.write_text(\"\"\"const V='rss-offline-v1';const CORE=V+'-core';const PAGES=V+'-pages';self.addEventListener('install',e=>{e.waitUntil(caches.open(CORE).then(c=>c.addAll(['/rss-offline/','/rss-offline/offline.html','/rss-offline/entries.json'])));self.skipWaiting();});self.addEventListener('activate',e=>{e.waitUntil(caches.keys().then(keys=>Promise.all(keys.filter(k=>!k.startsWith(V)).map(k=>caches.delete(k)))).then(()=>self.clients.claim()));});self.addEventListener('fetch',e=>{const r=e.request;const u=new URL(r.url);if(r.method!=='GET'||u.origin!==self.location.origin||!u.pathname.startsWith('/rss-offline/'))return;e.respondWith((async()=>{const cache=await caches.open(PAGES);try{const res=await fetch(r);if(res.ok) await cache.put(r,res.clone());return res;}catch{return await cache.match(r)||await caches.match('/rss-offline/offline.html')||Response.error();}})());});\"\"\")\n+158     precache_paths = ['/rss-offline/', '/rss-offline/offline.html', '/rss-offline/entries.json'] + [entry['local_path'] for entry in archived]\n+159     sw.write_text(f\"\"\"const V='rss-offline-v2';const CORE=V+'-core';const PAGES=V+'-pages';const PRECACHE={json.dumps(precache_paths, ensure_ascii=False)};self.addEventListener('install',e=>{{e.waitUntil(caches.open(CORE).then(c=>c.addAll(PRECACHE)));self.skipWaiting();}});self.addEventListener('activate',e=>{{e.waitUntil(caches.keys().then(keys=>Promise.all(keys.filter(k=>!k.startsWith(V)).map(k=>caches.delete(k)))).then(()=>self.clients.claim()));}});self.addEventListener('fetch',e=>{{const r=e.request;const u=new URL(r.url);if(r.method!=='GET'||u.origin!==self.location.origin||!u.pathname.startsWith('/rss-offline/'))return;e.respondWith((async()=>{{const core=await caches.open(CORE);const pages=await caches.open(PAGES);const cached=await core.match(r)||await pages.match(r);try{{const res=await fetch(r);if(res.ok) await pages.put(r,res.clone());return res;}}catch{{return cached||await core.match('/rss-offline/offline.html')||Response.error();}}}})());}});\"\"\")\n 159 \n 160 \n 161 if __name__ == '__main__':\n 162     build()","firstChangedLine":152},"isError":false,"timestamp":1778836186304}}
/home/sebas/pi-config/sessions/--home-sebas--/2026-05-12T11-39-30-483Z_019e1bfc-2732-7351-b63a-429fd2c3a363.jsonl:518:{"type":"message","id":"ea1a6c85","parentId":"e8f09940","timestamp":"2026-05-15T09:10:01.637Z","message":{"role":"assistant","content":[{"type":"toolCall","id":"call_RAVVN9mY3qHdzYYGo0jkihDd|fc_0532075662ec7a6e016a06e2e23754819082c1af9238965238","name":"bash","arguments":{"command":"sudo /home/sebas/work/projects/rss-offline/build.py >/tmp/rss-offline-build.out && sed -n '1,20p' /tmp/rss-offline-build.out && python - <<'PY'\nimport requests, re\nidx=requests.get('https://ballbox-first.emperor-ratio.ts.net/rss-offline/',timeout=20).text\nprint('local first', '
<a href=\"/rss-offline/article-' in idx)\nsw=requests.get('https://ballbox-first.emperor-ratio.ts.net/rss-offline/sw.js',timeout=20).text\nprint('v2', 'rss-offline-v2' in sw)\nprint('precache_count', sw.count('/rss-offline/article-'))\nPY","timeout":300}}],"api":"openai-codex-responses","provider":"openai-codex","model":"gpt-5.4","usage":{"input":2190,"output":180,"cacheRead":217088,"cacheWrite":0,"totalTokens":219458,"cost":{"input":0.005475000000000001,"output":0.0027,"cacheRead":0.054272,"cacheWrite":0,"total":0.062447}},"stopReason":"toolUse","timestamp":1778836186316,"responseId":"resp_0532075662ec7a6e016a06e2dade3c8190a33d48840f1837dc"}}
/home/sebas/pi-config/sessions/--home-sebas--/2026-05-12T11-39-30-483Z_019e1bfc-2732-7351-b63a-429fd2c3a363.jsonl:521:{"type":"message","id":"c86c612e","parentId":"7cac8f4b","timestamp":"2026-05-15T09:10:45.256Z","message":{"role":"toolResult","toolCallId":"call_G95GsUfyR6m814V3gMZJV9QB|fc_0532075662ec7a6e016a06e30ffb448190abce41d5a30a0e63","toolName":"todo","content":[{"type":"text","text":"Updated #16 (in_progress → completed)"}],"details":{"action":"update","params":{"action":"update","id":16,"status":"completed"},"tasks":[{"id":1,"subject":"BB-002 audit gomakethings patterns for Ballbox","status":"completed","description":"Research concrete frontend/offline/navigation patterns from gomakethings.com and related posts. Goal: extract practices applicable to Ballbox.","activeForm":"researching gomakethings patterns","owner":"agent"},{"id":2,"subject":"BB-003 inventory nginx-served surfaces for offline/navigation improvements","status":"completed","description":"Inventory current nginx-served web surfaces on ballbox-first and classify candidates for static caching, offline navigation, and resilience improvements.","activeForm":"inventorying nginx surfaces","owner":"agent"},{"id":3,"subject":"BB-004 define self-hosted RSS/offline reader product bar","status":"completed","description":"Define the minimal feature/product bar for a self-hosted feed reader that supports offline reading and beats the current app.","activeForm":"defining feed reader product bar","owner":"agent"},{"id":4,"subject":"BB-002 prepare reviewable Ballbox offline prototype plan","status":"completed","description":"Turn BB-002 research into concrete reviewable coding/testing slices for Ballbox.","activeForm":"preparing Ballbox review slices","owner":"agent"},{"id":5,"subject":"BB-003 prepare reviewable Pi web offline rollout plan","status":"completed","description":"Turn BB-003 research into concrete reviewable coding/testing slices for nginx-served surfaces.","activeForm":"preparing Pi web review slices","owner":"agent"},{"id":6,"subject":"BB-004 prepare OSS RSS reader shortlist and review plan","status":"completed","description":"Turn BB-004 research into concrete self-hosted OSS options and a test plan. No paid options.","activeForm":"preparing RSS shortlist","owner":"agent"},{"id":7,"subject":"Make copyparty file URLs more direct and preserve old /files/home links","status":"completed","description":"Change copyparty root mapping so /home/sebas content lives directly under /files/, and add nginx redirects for legacy /files/home/... URLs.","activeForm":"updating copyparty paths","owner":"agent"},{"id":8,"subject":"Re-focus on Ballbox QR testing","status":"completed","description":"Pause backlog follow-up work and switch active focus to QR testing inside Ballbox. Determine what Sebas should do next.","activeForm":"re-focusing on QR testing","owner":"agent"},{"id":9,"subject":"Fix Mercado Pago QR webhook product id mapping","status":"completed","description":"Accept Mercado Pago order item external_code as fallback product id so paid QR orders persist in Ballbox.","activeForm":"fixing QR webhook mapping","owner":"agent"},{"id":10,"subject":"Fix Mercado Pago QR webhook amount parsing","status":"completed","description":"Accept Mercado Pago order total_amount as string or number so approved QR orders persist.","activeForm":"fixing QR amount parsing","owner":"agent"},{"id":11,"subject":"Reprioritize Ballbox work away from OPS-2 and machine-dependent payments","status":"in_progress","description":"Skip OPS-2 and machine-dependent payment work for now. Find and run as many non-machine Ballbox tasks in parallel as possible from this machine.","activeForm":"reprioritizing Ballbox work","owner":"agent"},{"id":12,"subject":"BB-003 implement portal offline slice 1","status":"in_progress","description":"Harden portal service worker with explicit offline fallback and bounded caches.","activeForm":"implementing portal offline slice","owner":"agent"},{"id":13,"subject":"BB-002 prepare Ballbox public offline slice 1 for implementation","status":"completed","description":"Prepare exact implementation-ready slice for Ballbox public offline shell and visited-page caching.","activeForm":"preparing Ballbox offline slice","owner":"agent"},{"id":14,"subject":"BB-004 prepare FreshRSS local trial setup","status":"in_progress","description":"Prepare or implement a local FreshRSS trial setup path for offline-friendly feed evaluation.","activeForm":"preparing FreshRSS trial","owner":"agent"},{"id":15,"subject":"Build minimal rss-offline reader","status":"in_progress","description":"Replace FreshRSS as primary offline reader path with a minimal local feed reader focused on date-sorted entries and preloaded real blog links, served at /rss-offline/.","activeForm":"building rss-offline","owner":"agent"},{"id":16,"subject":"Make rss-offline local copies default and precached","status":"completed","description":"Update rss-offline so primary click opens local copies and service worker precaches generated local article pages for offline use without manual first visit.","activeForm":"hardening rss-offline offline behavior","owner":"agent"}],"nextId":17},"isError":false,"timestamp":1778836245256}}
/home/sebas/pi-config/sessions/--home-sebas--/2026-05-12T11-39-30-483Z_019e1bfc-2732-7351-b63a-429fd2c3a363.jsonl:522:{"type":"message","id":"c2f0663b","parentId":"c86c612e","timestamp":"2026-05-15T09:11:02.375Z","message":{"role":"assistant","content":[{"type":"text","text":"Hecho.\n\nCambios:\n1. `rss-offline` ahora abre por default la **copia local**\n- click principal = copia local\n- link secundario = blog real\n\n2. service worker endurecido\n- ahora **precacha las copias locales generadas**\n- no hace falta abrir cada artículo una vez para que quede offline\n\nEstado nuevo:\n- versión SW:\n  - `rss-offline-v2`\n- copias locales precargadas ahora:\n  - 35 artículos\n\nQué probar:\n1. abrir:\n- https://ballbox-first.emperor-ratio.ts.net/rss-offline/\n\n2. esperar un poco a que cargue normal\n\n3. cortar conexión\n\n4. volver a entrar a:\n- la lista\n- uno o dos artículos locales\n\nAhora sí deberían abrir offline sin visita previa manual a cada uno.","textSignature":"{\"v\":1,\"id\":\"msg_0532075662ec7a6e016a06e31cc81c8190bb5c6769697e1662\",\"phase\":\"final_answer\"}"}],"api":"openai-codex-responses","provider":"openai-codex","model":"gpt-5.4","usage":{"input":1376,"output":176,"cacheRead":218624,"cacheWrite":0,"totalTokens":220176,"cost":{"input":0.0034400000000000003,"output":0.00264,"cacheRead":0.054655999999999996,"cacheWrite":0,"total":0.060736}},"stopReason":"stop","timestamp":1778836245270,"responseId":"resp_0532075662ec7a6e016a06e315fbc88190b3e6fe9087f6cea6"}}
/home/sebas/pi-config/sessions/--home-sebas--/2026-05-12T11-39-30-483Z_019e1bfc-2732-7351-b63a-429fd2c3a363.jsonl:524:{"type":"message","id":"8ef0ea60","parentId":"976dec89","timestamp":"2026-05-15T09:39:22.245Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"**Considering service worker improvements**\n\nI need to keep my answer concise. I might suggest increasing the precache timing since the service worker might not wait long enough before going offline. It could be beneficial to show synchronization status on the index. The user asked if there's another way to improve, so I think a quick improvement would be adding a visible warmup status and even prefetching local articles on load. Implementing a chunked warmup from page JavaScript using fetch sounds good but requires some modification in build.py.","thinkingSignature":"{\"id\":\"rs_0532075662ec7a6e016a06e9c2fac48190bc113be2ec0605d3\",\"type\":\"reasoning\",\"encrypted_content\":\"gAAAAABqBunJPbOEJafhn3cuEd0cSgyDuCs3qv9x5eZ3nZquup9ar13GaoBdccoH9I0KRiiETLv1svB7x0QUDLtRdYwIhho3ZjoRvgIe2c5FHlBrzTwGRDsbYSll7N1f-WKihpgl4KZzmJ-xUSLNmv4d0m6jqbhE60djAh_vsbcjF0iS4ReYv9_YXFAqz-pZB-2que0Oz2nCEObP7UtE1uAA6ye_Sam0ohKH73LNyKvs6hTgOPB9WU_zuEBOX7g3Oi5FYdiSsnuzFGwZX_WN0KB3Yg8knlDc9g02Qv9QsKq0rnoz5XQlyL1DprNhrYSoD2Dzz1jGJxl3pI-Mq33Ja0zy66yyaIvCqkhHk0_YTEbYObrPqnxdwAitceGjVbkxPFHccZhCUj5LDNs7Xttq9PNxB4Yv8a0BIIC_P5lVW8DFcfxAXPi_G-EoAahqRbZLHJRsar8QQEr4kml11g6Od2WWbDEF-sRP0IVMVhwq0zT42KAxD_bftLlJmagUYNUJFMnxCklBDM2q2z31OIH0gg98CdTMWq9OF2r60ShpILo7JMitfU42WrZjc12dqTFbPlNjqKjpqREKnqA_M3xhRPXHQl4EJcp9yMXTPH-P14Bz9DvYnuk_GjtZD4Cni67LcBwaH_9RWwYvsJn2FsCt4RaFaiUOEnZB5YG5B7TK4SFxJipu-PdGhPnqiNWsvG9wVSwA5qt5a7C6QNJau1DBZJh32ZMxYbBjpf1L5kvfya6Es20YIvIr7eMovGoFM1qT8BmpvgbkXoCFqPJu58ZghLQo2PtRXBa2ME6dlARoOkzGGNwaQ0rGm8LNS6RaUUSv1yJsS0sLVMtCAO5WHm3-BlbGWBs9o6V_kAjssArmrpXz_ZEX7pxciFEhEcvsqQQRpOsPBngXkYqbUx4xz9OMwyLF0KQpVsBIy1gw0ovCM5mS5uBLjHZGiPeQDhLTmeny1UteZke4gCzXZgHIbmujtKXQK9d2AW9Zv5xqafHiezHTAIKj77Lt9sYtzbr_D1AIzFBAm9Bx9QRQyHPTZcuQG9lun6W80eE7RoUrbu4fHBFyuwl4o43az23MG0bCr_3rlYKjngcX69MCa6dX7f-Bt7VW801wQTIPe2JZhREU_GNG76X3gUWwE4RJP2GdTp7jgISJDEMj6QxUgM7Tb9-QCmwjUtxTazYbYqYo2P750xvbT47WNF-xTmF-u5jKFJy2ENsTdEIzDreRXys3QkOvXCRvT5bLvp9APdxpuO_e8iP52AvtbjlU4htMR2g_stG2_ifCX9KKaQGKtQjgWXy5ymM3yI_pMMAkspjChUL-qfLfo-Mx2FEi7Ax1LnlaocpiybTaRIEcx3_pQW4JS1ZRV2XyEPqGaiu3MD84-zU-4U6peDg4KzzCL5xEDkGJYNkDdQqP1vj9oo-jqcXzES5USUgT2c4kct80F7gaf3XB1qLuezotJLWtEUttsJtI50xa_lVgbsd0G5V0hwcyMIj7Zfbb6lcvXHOW90G_IJmvz2Zdfq2I_QZPM_GlmN_Y8boINiLwG2yx-udTPX7XhfEl5R5zcTVfkHwM8P9DDlPG7DwalDKYjzcJT4xevyDkeuj3NcJ8tuNf8oPzi1SqN3wlxb28aQmAQmCiQePr1n0pD455f3rRGHaLFkCKa4r18MO8qLah8imeukzwxMg_xn3qfqQqudfCL2b2-eAjpJuWYm-AwGsSc7FOqnx0McXAllEt0aIz_cnlJeuxHaaYb8EnZE1CBVbkZRs3NoxTf8H1_xCKF43XVyzdSKR6JprEbO6_pMt8DUSZRIBcw6NvYxLiSVE_n7IAOpjwWRXwJa4Pd5HfPq-HH_B80dBaxOC-Na4W3kupNvmM0Fc81sLEnj8nWcUuoreiAcAPzaY8CW8b9bZEvK0svNpm5HcCTJgFmkJppqmVNC6ExMvVY45Fk2Ie_XEypticY9790S6LEzlR0kdM6p8voKs4YxaZ5DeLGkkCZuzCGm-gM08D2_3YJI0Q0StmZv9y_7khv402Yn7wQ8aUA0HyENgjBkrVu4LkJ1ZTO38WGGOgwzLdjYQr4hGcjpLeir8FKRWBF5uTONdeppTQpJ2Unkbdni0KjF_-8NAlUJWpjPYswXtQaEyeayGAs7njVU9VaBxawFleFpdTXaL0TKcMPI1pRRBvoDM6PZHYmGrxr3Q708Le\",\"summary\":[{\"type\":\"summary_text\",\"text\":\"**Considering service worker improvements**\\n\\nI need to keep my answer concise. I might suggest increasing the precache timing since the service worker might not wait long enough before going offline. It could be beneficial to show synchronization status on the index. The user asked if there's another way to improve, so I think a quick improvement would be adding a visible warmup status and even prefetching local articles on load. Implementing a chunked warmup from page JavaScript using fetch sounds good but requires some modification in build.py.\"}]}"},{"type":"toolCall","id":"call_25RIrmwuuQ5yEJPNOsTAqxAN|fc_0532075662ec7a6e016a06e9c95554819085c134f206e462f7","name":"todo","arguments":{"action":"create","subject":"Improve rss-offline local article warmup hit rate","description":"Add client-side warmup fetch for local article copies so more articles are available offline without first opening them manually.","activeForm":"improving rss-offline warmup","status":"in_progress","owner":"agent"}}],"api":"openai-codex-responses","provider":"openai-codex","model":"gpt-5.4","usage":{"input":220175,"output":269,"cacheRead":0,"cacheWrite":0,"totalTokens":220444,"cost":{"input":0.5504375,"output":0.004035,"cacheRead":0,"cacheWrite":0,"total":0.5544725}},"stopReason":"toolUse","timestamp":1778837951013,"responseId":"resp_0532075662ec7a6e016a06e9bfa2248190ad51e1b589eec53d"}}
/home/sebas/pi-config/sessions/--home-sebas--/2026-05-12T11-39-30-483Z_019e1bfc-2732-7351-b63a-429fd2c3a363.jsonl:525:{"type":"message","id":"35f31277","parentId":"8ef0ea60","timestamp":"2026-05-15T09:39:22.246Z","message":{"role":"toolResult","toolCallId":"call_25RIrmwuuQ5yEJPNOsTAqxAN|fc_0532075662ec7a6e016a06e9c95554819085c134f206e462f7","toolName":"todo","content":[{"type":"text","text":"Created #17: Improve rss-offline local article warmup hit rate (pending)"}],"details":{"action":"create","params":{"action":"create","subject":"Improve rss-offline local article warmup hit rate","description":"Add client-side warmup fetch for local article copies so more articles are available offline without first opening them manually.","activeForm":"improving rss-offline warmup","status":"in_progress","owner":"agent"},"tasks":[{"id":1,"subject":"BB-002 audit gomakethings patterns for Ballbox","status":"completed","description":"Research concrete frontend/offline/navigation patterns from gomakethings.com and related posts. Goal: extract practices applicable to Ballbox.","activeForm":"researching gomakethings patterns","owner":"agent"},{"id":2,"subject":"BB-003 inventory nginx-served surfaces for offline/navigation improvements","status":"completed","description":"Inventory current nginx-served web surfaces on ballbox-first and classify candidates for static caching, offline navigation, and resilience improvements.","activeForm":"inventorying nginx surfaces","owner":"agent"},{"id":3,"subject":"BB-004 define self-hosted RSS/offline reader product bar","status":"completed","description":"Define the minimal feature/product bar for a self-hosted feed reader that supports offline reading and beats the current app.","activeForm":"defining feed reader product bar","owner":"agent"},{"id":4,"subject":"BB-002 prepare reviewable Ballbox offline prototype plan","status":"completed","description":"Turn BB-002 research into concrete reviewable coding/testing slices for Ballbox.","activeForm":"preparing Ballbox review slices","owner":"agent"},{"id":5,"subject":"BB-003 prepare reviewable Pi web offline rollout plan","status":"completed","description":"Turn BB-003 research into concrete reviewable coding/testing slices for nginx-served surfaces.","activeForm":"preparing Pi web review slices","owner":"agent"},{"id":6,"subject":"BB-004 prepare OSS RSS reader shortlist and review plan","status":"completed","description":"Turn BB-004 research into concrete self-hosted OSS options and a test plan. No paid options.","activeForm":"preparing RSS shortlist","owner":"agent"},{"id":7,"subject":"Make copyparty file URLs more direct and preserve old /files/home links","status":"completed","description":"Change copyparty root mapping so /home/sebas content lives directly under /files/, and add nginx redirects for legacy /files/home/... URLs.","activeForm":"updating copyparty paths","owner":"agent"},{"id":8,"subject":"Re-focus on Ballbox QR testing","status":"completed","description":"Pause backlog follow-up work and switch active focus to QR testing inside Ballbox. Determine what Sebas should do next.","activeForm":"re-focusing on QR testing","owner":"agent"},{"id":9,"subject":"Fix Mercado Pago QR webhook product id mapping","status":"completed","description":"Accept Mercado Pago order item external_code as fallback product id so paid QR orders persist in Ballbox.","activeForm":"fixing QR webhook mapping","owner":"agent"},{"id":10,"subject":"Fix Mercado Pago QR webhook amount parsing","status":"completed","description":"Accept Mercado Pago order total_amount as string or number so approved QR orders persist.","activeForm":"fixing QR amount parsing","owner":"agent"},{"id":11,"subject":"Reprioritize Ballbox work away from OPS-2 and machine-dependent payments","status":"in_progress","description":"Skip OPS-2 and machine-dependent payment work for now. Find and run as many non-machine Ballbox tasks in parallel as possible from this machine.","activeForm":"reprioritizing Ballbox work","owner":"agent"},{"id":12,"subject":"BB-003 implement portal offline slice 1","status":"in_progress","description":"Harden portal service worker with explicit offline fallback and bounded caches.","activeForm":"implementing portal offline slice","owner":"agent"},{"id":13,"subject":"BB-002 prepare Ballbox public offline slice 1 for implementation","status":"completed","description":"Prepare exact implementation-ready slice for Ballbox public offline shell and visited-page caching.","activeForm":"preparing Ballbox offline slice","owner":"agent"},{"id":14,"subject":"BB-004 prepare FreshRSS local trial setup","status":"in_progress","description":"Prepare or implement a local FreshRSS trial setup path for offline-friendly feed evaluation.","activeForm":"preparing FreshRSS trial","owner":"agent"},{"id":15,"subject":"Build minimal rss-offline reader","status":"in_progress","description":"Replace FreshRSS as primary offline reader path with a minimal local feed reader focused on date-sorted entries and preloaded real blog links, served at /rss-offline/.","activeForm":"building rss-offline","owner":"agent"},{"id":16,"subject":"Make rss-offline local copies default and precached","status":"completed","description":"Update rss-offline so primary click opens local copies and service worker precaches generated local article pages for offline use without manual first visit.","activeForm":"hardening rss-offline offline behavior","owner":"agent"},{"id":17,"subject":"Improve rss-offline local article warmup hit rate","status":"pending","description":"Add client-side warmup fetch for local article copies so more articles are available offline without first opening them manually.","activeForm":"improving rss-offline warmup","owner":"agent"}],"nextId":18},"isError":false,"timestamp":1778837962246}}
/home/sebas/pi-config/sessions/--home-sebas--/2026-05-12T11-39-30-483Z_019e1bfc-2732-7351-b63a-429fd2c3a363.jsonl:527:{"type":"message","id":"d6d6d369","parentId":"7a358f30","timestamp":"2026-05-15T09:39:28.747Z","message":{"role":"toolResult","toolCallId":"call_bebenDTT5lviabTT75AcUba4|fc_0532075662ec7a6e016a06e9cf3d24819090df420e75907f89","toolName":"todo","content":[{"type":"text","text":"Updated #17 (pending → in_progress)"}],"details":{"action":"update","params":{"action":"update","id":17,"status":"in_progress"},"tasks":[{"id":1,"subject":"BB-002 audit gomakethings patterns for Ballbox","status":"completed","description":"Research concrete frontend/offline/navigation patterns from gomakethings.com and related posts. Goal: extract practices applicable to Ballbox.","activeForm":"researching gomakethings patterns","owner":"agent"},{"id":2,"subject":"BB-003 inventory nginx-served surfaces for offline/navigation improvements","status":"completed","description":"Inventory current nginx-served web surfaces on ballbox-first and classify candidates for static caching, offline navigation, and resilience improvements.","activeForm":"inventorying nginx surfaces","owner":"agent"},{"id":3,"subject":"BB-004 define self-hosted RSS/offline reader product bar","status":"completed","description":"Define the minimal feature/product bar for a self-hosted feed reader that supports offline reading and beats the current app.","activeForm":"defining feed reader product bar","owner":"agent"},{"id":4,"subject":"BB-002 prepare reviewable Ballbox offline prototype plan","status":"completed","description":"Turn BB-002 research into concrete reviewable coding/testing slices for Ballbox.","activeForm":"preparing Ballbox review slices","owner":"agent"},{"id":5,"subject":"BB-003 prepare reviewable Pi web offline rollout plan","status":"completed","description":"Turn BB-003 research into concrete reviewable coding/testing slices for nginx-served surfaces.","activeForm":"preparing Pi web review slices","owner":"agent"},{"id":6,"subject":"BB-004 prepare OSS RSS reader shortlist and review plan","status":"completed","description":"Turn BB-004 research into concrete self-hosted OSS options and a test plan. No paid options.","activeForm":"preparing RSS shortlist","owner":"agent"},{"id":7,"subject":"Make copyparty file URLs more direct and preserve old /files/home links","status":"completed","description":"Change copyparty root mapping so /home/sebas content lives directly under /files/, and add nginx redirects for legacy /files/home/... URLs.","activeForm":"updating copyparty paths","owner":"agent"},{"id":8,"subject":"Re-focus on Ballbox QR testing","status":"completed","description":"Pause backlog follow-up work and switch active focus to QR testing inside Ballbox. Determine what Sebas should do next.","activeForm":"re-focusing on QR testing","owner":"agent"},{"id":9,"subject":"Fix Mercado Pago QR webhook product id mapping","status":"completed","description":"Accept Mercado Pago order item external_code as fallback product id so paid QR orders persist in Ballbox.","activeForm":"fixing QR webhook mapping","owner":"agent"},{"id":10,"subject":"Fix Mercado Pago QR webhook amount parsing","status":"completed","description":"Accept Mercado Pago order total_amount as string or number so approved QR orders persist.","activeForm":"fixing QR amount parsing","owner":"agent"},{"id":11,"subject":"Reprioritize Ballbox work away from OPS-2 and machine-dependent payments","status":"in_progress","description":"Skip OPS-2 and machine-dependent payment work for now. Find and run as many non-machine Ballbox tasks in parallel as possible from this machine.","activeForm":"reprioritizing Ballbox work","owner":"agent"},{"id":12,"subject":"BB-003 implement portal offline slice 1","status":"in_progress","description":"Harden portal service worker with explicit offline fallback and bounded caches.","activeForm":"implementing portal offline slice","owner":"agent"},{"id":13,"subject":"BB-002 prepare Ballbox public offline slice 1 for implementation","status":"completed","description":"Prepare exact implementation-ready slice for Ballbox public offline shell and visited-page caching.","activeForm":"preparing Ballbox offline slice","owner":"agent"},{"id":14,"subject":"BB-004 prepare FreshRSS local trial setup","status":"in_progress","description":"Prepare or implement a local FreshRSS trial setup path for offline-friendly feed evaluation.","activeForm":"preparing FreshRSS trial","owner":"agent"},{"id":15,"subject":"Build minimal rss-offline reader","status":"in_progress","description":"Replace FreshRSS as primary offline reader path with a minimal local feed reader focused on date-sorted entries and preloaded real blog links, served at /rss-offline/.","activeForm":"building rss-offline","owner":"agent"},{"id":16,"subject":"Make rss-offline local copies default and precached","status":"completed","description":"Update rss-offline so primary click opens local copies and service worker precaches generated local article pages for offline use without manual first visit.","activeForm":"hardening rss-offline offline behavior","owner":"agent"},{"id":17,"subject":"Improve rss-offline local article warmup hit rate","status":"in_progress","description":"Add client-side warmup fetch for local article copies so more articles are available offline without first opening them manually.","activeForm":"improving rss-offline warmup","owner":"agent"}],"nextId":18},"isError":false,"timestamp":1778837968747}}
/home/sebas/pi-config/sessions/--home-sebas--/2026-05-12T11-39-30-483Z_019e1bfc-2732-7351-b63a-429fd2c3a363.jsonl:528:{"type":"message","id":"5406553d","parentId":"d6d6d369","timestamp":"2026-05-15T09:39:52.762Z","message":{"role":"assistant","content":[{"type":"toolCall","id":"call_nWaUIV9VL4H69l2pZPtbI2V7|fc_0532075662ec7a6e016a06e9d427488190897ebf6c01e95cc4","name":"edit","arguments":{"path":"/home/sebas/work/projects/rss-offline/build.py","edits":[{"oldText":"    index.write_text(f'''<!doctype html><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"><meta name=\"theme-color\" content=\"#07111f\"><title>rss-offline</title>

rss-offline

Artículos por fecha con copia local y link real precargado.

{len(archived)} artículos · últimos {DAYS} días

{''.join(cards)}''')\n","newText":"    local_paths = [entry['local_path'] for entry in archived]\n    index.write_text(f'''<!doctype html><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"><meta name=\"theme-color\" content=\"#07111f\"><title>rss-offline</title>

rss-offline

Artículos por fecha con copia local y link real precargado.

{len(archived)} artículos · últimos {DAYS} días

{''.join(cards)}''')\n"}]}}],"api":"openai-codex-responses","provider":"openai-codex","model":"gpt-5.4","usage":{"input":1380,"output":1074,"cacheRead":219136,"cacheWrite":0,"totalTokens":221590,"cost":{"input":0.0034500000000000004,"output":0.01611,"cacheRead":0.054784,"cacheWrite":0,"total":0.074344}},"stopReason":"toolUse","timestamp":1778837968757,"responseId":"resp_0532075662ec7a6e016a06e9d15fb8819085533c8e6c1c4920"}}
/home/sebas/pi-config/sessions/--home-sebas--/2026-05-12T11-39-30-483Z_019e1bfc-2732-7351-b63a-429fd2c3a363.jsonl:529:{"type":"message","id":"0669fc39","parentId":"5406553d","timestamp":"2026-05-15T09:39:52.865Z","message":{"role":"toolResult","toolCallId":"call_nWaUIV9VL4H69l2pZPtbI2V7|fc_0532075662ec7a6e016a06e9d427488190897ebf6c01e95cc4","toolName":"edit","content":[{"type":"text","text":"Successfully replaced 1 block(s) in /home/sebas/work/projects/rss-offline/build.py."}],"details":{"diff":"     ...\n 150     cards = []\n 151     for entry in archived:\n 152         cards.append(f'''

{escape(entry['title'])}

{escape(entry['feed'])} · {escape(entry['date_label'])}

copia local · blog real

''')\n 153 \n-154     index.write_text(f'''<!doctype html><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"><meta name=\"theme-color\" content=\"#07111f\"><title>rss-offline</title>

rss-offline

Artículos por fecha con copia local y link real precargado.

{len(archived)} artículos · últimos {DAYS} días

{''.join(cards)}''')\n+154     local_paths = [entry['local_path'] for entry in archived]\n+155     index.write_text(f'''<!doctype html><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"><meta name=\"theme-color\" content=\"#07111f\"><title>rss-offline</title>

rss-offline

Artículos por fecha con copia local y link real precargado.

{len(archived)} artículos · últimos {DAYS} días

{''.join(cards)}''')\n 155 \n 156     offline.write_text('<!doctype html><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"><title>rss-offline sin conexión</title>
Sin conexión

La lista y copias locales visitadas deberían seguir disponibles.

Volver a rss-offline
')\n 157 \n 158     precache_paths = ['/rss-offline/', '/rss-offline/offline.html', '/rss-offline/entries.json'] + [entry['local_path'] for entry in archived]\n     ...","firstChangedLine":154},"isError":false,"timestamp":1778837992863}}

[Showing lines 40-50 of 50 (50.0KB limit). Full output: /tmp/pi-bash-ec64b4c83565caf5.log]


cd /home/sebas/work/projects/rss-offline && ls -1 && find . -maxdepth 2 -type f | sed 's#^./##' | sort | head -100


#!/usr/bin/env python3
import email.utils
import hashlib
import json
import os
import re
import subprocess
import time
import xml.etree.ElementTree as ET
from datetime import datetime, timedelta, timezone
from html import escape
from pathlib import Path
from urllib.parse import urljoin, urlparse

import requests
from bs4 import BeautifulSoup

BASE_DIR = Path(__file__).resolve().parent
SOURCES_PATH = BASE_DIR / 'feed-sources.json'
DATA_DIR = BASE_DIR / 'data'
OUTPUT_DIR = Path('/var/www/rss-offline')
MAX_ENTRIES = int(os.environ.get('RSS_OFFLINE_MAX_ENTRIES', '250'))
DAYS = int(os.environ.get('RSS_OFFLINE_DAYS', '14'))
TIMEOUT = 20
HEADERS = {'user-agent': 'Mozilla/5.0 (rss-offline builder)'}
DEFAULT_CAPTURE_MODE = os.environ.get('RSS_OFFLINE_CAPTURE_MODE', 'rendered').strip().lower() or 'rendered'
RENDER_BUDGET_MS = int(os.environ.get('RSS_OFFLINE_RENDER_BUDGET_MS', '8000'))
MAX_RENDERED_ENTRIES = int(os.environ.get('RSS_OFFLINE_MAX_RENDERED_ENTRIES', '8'))
MAX_ASSETS_PER_PAGE = int(os.environ.get('RSS_OFFLINE_MAX_ASSETS_PER_PAGE', '24'))
MAX_ASSET_BYTES = int(os.environ.get('RSS_OFFLINE_MAX_ASSET_BYTES', '1500000'))
ALLOWED_ASSET_EXTENSIONS = ('.css', '.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.ico')
CHROMIUM_BIN = os.environ.get('RSS_OFFLINE_CHROMIUM_BIN', '/usr/bin/chromium')
PDF_DIRNAME = os.environ.get('RSS_OFFLINE_PDF_DIRNAME', 'pdf')
PDF_MAX_ARTICLES = int(os.environ.get('RSS_OFFLINE_PDF_MAX_ARTICLES', '8'))
PDF_BASENAME = os.environ.get('RSS_OFFLINE_PDF_BASENAME', 'rss-offline')
PDF_PAGE_TIMEOUT = int(os.environ.get('RSS_OFFLINE_PDF_PAGE_TIMEOUT', '90'))
PDF_MAX_SOURCE_BYTES = int(os.environ.get('RSS_OFFLINE_PDF_MAX_SOURCE_BYTES', '2500000'))

def fetch(url: str) -> requests.Response:
    response = requests.get(url, timeout=TIMEOUT, headers=HEADERS)
    response.raise_for_status()
    return response

def text(node, path_options):
    for path in path_options:
        found = node.find(path)
        if found is not None and found.text and found.text.strip():
            return found.text.strip()
    return ''

def entry_links(item):
    for tag in ['link', '{http://www.w3.org/2005/Atom}link']:
        for node in item.findall(tag):
            href = node.get('href') or (node.text or '').strip()
            if href:
                rel = node.get('rel', 'alternate')
                if rel in ('alternate', ''):
                    return href
    return ''

def parse_timestamp(raw: str) -> int:
    raw = (raw or '').strip()
    if not raw:
        return 0
    try:
        return int(email.utils.parsedate_to_datetime(raw).timestamp())
    except Exception:
        pass
    try:
        return int(datetime.fromisoformat(raw.replace('Z', '+00:00')).timestamp())
    except Exception:
        return 0

def slug(url: str) -> str:
    return hashlib.sha256(url.encode()).hexdigest()[:16]

def asset_rel_path(entry_key: str, asset_url: str, content_type: str = '') -> str:
    parsed = urlparse(asset_url)
    raw = (parsed.path or '').lower()
    ext = Path(raw).suffix
    if not ext:
        if 'css' in content_type:
            ext = '.css'
        elif 'javascript' in content_type:
            ext = '.js'
        elif 'svg' in content_type:
            ext = '.svg'
        elif 'png' in content_type:
            ext = '.png'
        elif 'jpeg' in content_type:
            ext = '.jpg'
        elif 'webp' in content_type:
            ext = '.webp'
        elif 'gif' in content_type:
            ext = '.gif'
        elif 'woff2' in content_type:
            ext = '.woff2'
        elif 'woff' in content_type:
            ext = '.woff'
        elif 'html' in content_type:
            ext = '.html'
        else:
            ext = '.bin'
    name = hashlib.sha256(asset_url.encode()).hexdigest()[:16] + ext
    return f'assets/{entry_key}/{name}'

def fetch_asset(url: str) -> requests.Response:
    response = requests.get(url, timeout=TIMEOUT, headers=HEADERS, stream=True)
    response.raise_for_status()
    return response

def should_keep_asset(node, parsed):
    path_lower = parsed.path.lower()
    if node.name == 'link' and 'stylesheet' in ' '.join(node.get('rel', [])).lower():
        return True
    if node.name != 'img':
        return False
    return any(path_lower.endswith(ext) for ext in ALLOWED_ASSET_EXTENSIONS)

def mirror_assets(entry, soup, entry_key: str):
    page_origin = urlparse(entry['link']).netloc
    mirrored = {}
    total_bytes = 0
    count = 0

    def candidates():
        for tag, attr in [('link', 'href'), ('img', 'src')]:
            for node in soup.find_all(tag):
                ref = node.get(attr)
                if ref:
                    yield node, attr, ref

    for node, attr, ref in candidates():
        if count >= MAX_ASSETS_PER_PAGE or total_bytes >= MAX_ASSET_BYTES:
            break
        asset_url = urljoin(entry['link'], ref)
        parsed = urlparse(asset_url)
        if parsed.scheme not in ('http', 'https'):
            continue
        if parsed.netloc != page_origin:
            continue
        if not should_keep_asset(node, parsed):
            continue
        try:
            rel = mirrored.get(asset_url)
            if rel is None:
                res = fetch_asset(asset_url)
                content_type = res.headers.get('content-type', '').lower()
                rel = asset_rel_path(entry_key, asset_url, content_type)
                data = b''
                limit_hit = False
                for chunk in res.iter_content(65536):
                    if not chunk:
                        continue
                    data += chunk
                    if len(data) + total_bytes > MAX_ASSET_BYTES:
                        limit_hit = True
                        break
                res.close()
                if limit_hit or not data:
                    continue
                out = OUTPUT_DIR / rel
                out.parent.mkdir(parents=True, exist_ok=True)
                out.write_bytes(data)
                mirrored[asset_url] = rel
                total_bytes += len(data)
                count += 1
            node[attr] = '/rss-offline/' + rel
        except Exception:
            continue

def render_page(url: str) -> str:
    cmd = [
        CHROMIUM_BIN,
        '--headless=new',
        '--disable-gpu',
        '--no-sandbox',
        '--run-all-compositor-stages-before-draw',
        f'--virtual-time-budget={RENDER_BUDGET_MS}',
        '--dump-dom',
        url,
    ]
    result = subprocess.run(cmd, capture_output=True, text=True, timeout=max(TIMEOUT, (RENDER_BUDGET_MS // 1000) + 10), check=True)
    html = result.stdout.strip()
    if not html:
        raise RuntimeError('empty rendered DOM')
    return html

def capture_page(entry, rendered_allowed: bool):
    preferred = (entry.get('capture_mode') or DEFAULT_CAPTURE_MODE).lower()
    if preferred == 'rendered' and not rendered_allowed:
        preferred = 'simple'
    modes = [preferred] + [mode for mode in ('rendered', 'simple') if mode != preferred and (mode != 'rendered' or rendered_allowed)]
    errors = []
    for mode in modes:
        try:
            if mode == 'rendered':
                return render_page(entry['link']), 'rendered'
            if mode == 'simple':
                return fetch(entry['link']).text, 'simple'
        except Exception as e:
            errors.append(f'{mode}:{e}')
    raise RuntimeError(' ; '.join(errors))

def make_local_copy(entry, page_html: str, entry_key: str) -> str:
    soup = BeautifulSoup(page_html, 'html.parser')

    if soup.head is None:
        head = soup.new_tag('head')
        if soup.html is None:
            html = soup.new_tag('html')
            html.append(head)
            body = soup.new_tag('body')
            for child in list(soup.contents):
                body.append(child.extract())
            html.append(body)
            soup.append(html)
        else:
            soup.html.insert(0, head)

    if soup.head.find('meta', attrs={'charset': True}) is None:
        soup.head.insert(0, soup.new_tag('meta', charset='utf-8'))
    if soup.head.find('meta', attrs={'name': 'viewport'}) is None:
        soup.head.append(soup.new_tag('meta', attrs={'name': 'viewport', 'content': 'width=device-width, initial-scale=1'}))

    for base in soup.head.find_all('base'):
        base.decompose()
    soup.head.insert(0, soup.new_tag('base', href=entry['link']))

    banner_css = (
        'body{margin-top:72px!important}'
        '.rss-offline-banner{position:fixed;top:0;left:0;right:0;z-index:2147483647;'
        'background:#07111f;color:#fff;padding:12px 16px;font:14px/1.4 system-ui,sans-serif;'
        'box-shadow:0 1px 0 rgba(255,255,255,.08)}'
        '.rss-offline-banner a{color:#8dc3ff;text-decoration:none}'
        '.rss-offline-banner strong{color:#fff}'
        '.rss-offline-banner .muted{opacity:.78}'
    )
    style = soup.new_tag('style')
    style.string = banner_css
    soup.head.append(style)

    mirror_assets(entry, soup, entry_key)

    banner = BeautifulSoup(
        f'
← rss-offline · '
        f'abrir original · '
        f'{escape(entry["title"])} [{escape(entry["captured_mode"])}] · '

[247 more lines in file. Use offset=261 to continue.]


build.py
data
feed-sources.json
__pycache__
build.py
feed-sources.json
__pycache__/build.cpython-313.pyc


f'{escape(entry["feed"])} · {escape(entry["date_label"])}
',
        'html.parser'
    )
    body = soup.body or soup
    body.insert(0, banner)

    return '<!doctype html>\n' + str(soup)

def load_sources():
    sources = json.loads(SOURCES_PATH.read_text())
    for source in sources:
        source['captureMode'] = (source.get('captureMode') or DEFAULT_CAPTURE_MODE).lower()
    return sources

def isolate_anchor_section(soup: BeautifulSoup, entry: dict):
    parsed = urlparse(entry['link'])
    fragment = (parsed.fragment or '').strip()
    if not fragment:
        return soup
    target = soup.find(id=fragment)
    if target is None:
        target = soup.find(attrs={'name': fragment})
    if target is None:
        return soup
    container = target
    for _ in range(6):
        parent = container.parent
        if parent is None or not getattr(parent, 'name', None):
            break
        text_len = len(parent.get_text(' ', strip=True))
        if 200 < text_len < 20000:
            container = parent
            break
        container = parent
    wrapper = BeautifulSoup('<!doctype html>', 'html.parser')
    wrapper.body.append(BeautifulSoup(str(container), 'html.parser'))
    return wrapper

def clean_pdf_soup(soup: BeautifulSoup):
    selectors = [
        'script', 'noscript', 'iframe', 'video', 'audio',
        '[aria-modal="true"]', '[role="dialog"]',
        '.modal', '.overlay', '.backdrop', '.drawer', '.popup',
        '[class*="overlay"]', '[class*="backdrop"]',
        '[style*="backdrop-filter"]'
    ]
    for selector in selectors:
        for tag in soup.select(selector):
            tag.decompose()
    for tag in soup.find_all(True):
        style = (tag.get('style') or '').lower().replace(' ', '')
        klass = ' '.join(tag.get('class', [])).lower()
        if ('position:fixed' in style or 'position:sticky' in style) and (
            'overlay' in klass or 'backdrop' in klass or 'modal' in klass or
            'inset:0' in style or ('top:0' in style and 'left:0' in style and 'right:0' in style and 'bottom:0' in style)
        ):
            tag.decompose()
    return soup

def make_pdf_printable_copy(entry: dict):
    src_path = OUTPUT_DIR / f'article-{slug(entry["link"])}.html'
    pdf_dir = OUTPUT_DIR / PDF_DIRNAME
    pdf_dir.mkdir(parents=True, exist_ok=True)
    printable_path = pdf_dir / f'article-{slug(entry["link"])}.print.html'
    soup = BeautifulSoup(src_path.read_text(), 'html.parser')
    soup = isolate_anchor_section(soup, entry)
    soup = clean_pdf_soup(soup)
    html = str(soup)
    html = html.replace('/rss-offline/', 'file:///var/www/rss-offline/')
    html = html.replace('position:fixed;top:0;left:0;right:0;', 'position:static;')
    html = html.replace('body{margin-top:72px!important}', 'body{margin-top:0!important}')
    html = html.replace('', '')
    if len(html.encode()) > PDF_MAX_SOURCE_BYTES:
        raise ValueError('print source too large')
    printable_path.write_text(html)
    return printable_path

def print_pdf(input_html: Path, output_pdf: Path):
    cmd = [
        CHROMIUM_BIN,
        '--headless=new',
        '--disable-gpu',
        '--no-sandbox',
        '--print-to-pdf=' + str(output_pdf),
        '--allow-file-access-from-files',
        '--disable-features=HttpsUpgrades,BlockInsecurePrivateNetworkRequests',
        input_html.as_uri(),
    ]
    subprocess.run(cmd, capture_output=True, text=True, timeout=PDF_PAGE_TIMEOUT, check=True)

def build_pdf_pack(entries, build_stamp: str):
    selected = entries[:PDF_MAX_ARTICLES]
    if not selected:
        return None
    pdf_dir = OUTPUT_DIR / PDF_DIRNAME
    pdf_dir.mkdir(parents=True, exist_ok=True)
    article_pdfs = []
    built = []
    for idx, entry in enumerate(selected, start=1):
        try:
            printable = make_pdf_printable_copy(entry)
            pdf_name = f'article-{idx:03d}-{slug(entry["link"])}.pdf'
            pdf_path = pdf_dir / pdf_name
            print_pdf(printable, pdf_path)
            article_pdfs.append(str(pdf_path))
            built.append({
                'title': entry['title'],
                'path': f'/rss-offline/{PDF_DIRNAME}/{pdf_name}',
            })
            print('PDF_OK', entry['link'])
        except Exception as e:
            print('PDF_ERR', entry['link'], e)
    if not article_pdfs:
        return None
    merged_filename = f'{PDF_BASENAME}-{build_stamp}.pdf'
    merged_path = OUTPUT_DIR / merged_filename
    subprocess.run(['pdfunite', *article_pdfs, str(merged_path)], check=True)
    return {
        'path': f'/rss-offline/{merged_filename}',
        'count': len(built),
        'articles': built,
    }

def list_pdf_archive():
    pdfs = []
    pattern = re.compile(rf'^{re.escape(PDF_BASENAME)}-\d{{4}}-\d{{2}}-\d{{2}}(?:_\d{{2}}-\d{{2}})?\.pdf$')
    for path in sorted(OUTPUT_DIR.glob(f'{PDF_BASENAME}-*.pdf'), reverse=True):
        if not pattern.match(path.name):
            continue
        stat = path.stat()
        pdfs.append({
            'name': path.name,
            'path': f'/rss-offline/{path.name}',
            'size_mb': round(stat.st_size / (1024 * 1024), 1),
            'date_label': datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).astimezone().strftime('%Y-%m-%d %H:%M'),
        })
    return pdfs

def build():
    DATA_DIR.mkdir(parents=True, exist_ok=True)
    OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
    build_stamp = datetime.now().astimezone().strftime('%Y-%m-%d')
    sources = load_sources()
    cutoff = int((datetime.now(timezone.utc) - timedelta(days=DAYS)).timestamp())
    entries = []

    for source in sources:
        try:
            response = fetch(source['feedUrl'])
            xml_text = response.text
            root = ET.fromstring(xml_text)
        except Exception as e:
            print('FEED_ERR', source['name'], e)
            continue

        items = []
        if root.tag.endswith('rss'):
            channel = root.find('channel')
            items = channel.findall('item') if channel is not None else []
        else:
            items = root.findall('{http://www.w3.org/2005/Atom}entry')

        for item in items:
            title = text(item, ['title', '{http://www.w3.org/2005/Atom}title']) or source['name']
            link = entry_links(item)
            if not link:
                continue
            link = urljoin(source['siteUrl'], link)
            ts = parse_timestamp(text(item, ['pubDate', '{http://www.w3.org/2005/Atom}updated', '{http://www.w3.org/2005/Atom}published']))
            if ts and ts < cutoff:
                continue
            entries.append({
                'feed': source['name'],
                'feed_url': source['feedUrl'],
                'title': title,
                'link': link,
                'timestamp': ts,
                'capture_mode': source['captureMode'],
            })

    entries.sort(key=lambda item: item['timestamp'], reverse=True)
    entries = entries[:MAX_ENTRIES]

    deduped = []
    seen_links = set()
    for entry in entries:
        key = entry['link']
        if key in seen_links:
            continue
        seen_links.add(key)
        deduped.append(entry)
    entries = deduped

    archived = []
    rendered_used = 0
    for entry in entries:
        try:
            wants_rendered = (entry.get('capture_mode') or DEFAULT_CAPTURE_MODE) == 'rendered'
            page_html, used_mode = capture_page(entry, rendered_allowed=(rendered_used < MAX_RENDERED_ENTRIES or not wants_rendered))
            key = slug(entry['link'])
            article_file = OUTPUT_DIR / f'article-{key}.html'
            entry['local_path'] = f'/rss-offline/article-{key}.html'
            entry['date_label'] = datetime.fromtimestamp(entry['timestamp'] or time.time(), tz=timezone.utc).astimezone().strftime('%Y-%m-%d %H:%M')
            entry['captured_mode'] = used_mode
            article_file.write_text(make_local_copy(entry, page_html, key))
            if used_mode == 'rendered':
                rendered_used += 1
            archived.append(entry)
            print('OK', used_mode, entry['link'])
        except Exception as e:
            print('ARTICLE_ERR', entry['link'], e)

[27 more lines in file. Use offset=481 to continue.]


pdf_info = build_pdf_pack(archived, build_stamp)

    index = OUTPUT_DIR / 'index.html'
    sw = OUTPUT_DIR / 'sw.js'
    offline = OUTPUT_DIR / 'offline.html'
    json_index = OUTPUT_DIR / 'entries.json'

    json_index.write_text(json.dumps(archived, ensure_ascii=False, indent=2))

    pdf_archive = list_pdf_archive()
    pdf_cards = []
    for item in pdf_archive:
        pdf_cards.append(f'''

{escape(item['name'])}

{escape(item['date_label'])} · {item['size_mb']} MB

''')

    local_paths = [entry['local_path'] for entry in archived]
    index.write_text(f'''<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="theme-color" content="#07111f"><title>rss-offline</title>

rss-offline

PDF diario con artículos nuevos.

{len(archived)} artículos · últimos {DAYS} días

Archivo de PDFs
{''.join(pdf_cards) or '
Todavía no hay PDFs.
'}
''')

    offline.write_text('<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>rss-offline sin conexión</title>
Sin conexión

La lista y copias locales visitadas deberían seguir disponibles.

Volver a rss-offline
')

    precache_paths = ['/rss-offline/', '/rss-offline/offline.html', '/rss-offline/entries.json'] + ([pdf_info['path']] if pdf_info else []) + [entry['local_path'] for entry in archived]
    sw_version = f"rss-offline-v{int(time.time())}"
    sw.write_text(f"""const V='{sw_version}';const CORE=V+'-core';const PAGES=V+'-pages';const PRECACHE={json.dumps(precache_paths, ensure_ascii=False)};self.addEventListener('install',e=>{{e.waitUntil(caches.open(CORE).then(c=>c.addAll(PRECACHE)));self.skipWaiting();}});self.addEventListener('activate',e=>{{e.waitUntil(caches.keys().then(keys=>Promise.all(keys.filter(k=>k!==CORE&&k!==PAGES).map(k=>caches.delete(k)))).then(()=>self.clients.claim()));}});self.addEventListener('fetch',e=>{{const r=e.request;const u=new URL(r.url);if(r.method!=='GET'||u.origin!==self.location.origin||!u.pathname.startsWith('/rss-offline/'))return;e.respondWith((async()=>{{const core=await caches.open(CORE);const pages=await caches.open(PAGES);const cached=await core.match(r)||await pages.match(r);try{{const res=await fetch(r);if(res.ok) await pages.put(r,res.clone());return res;}}catch{{return cached||await core.match('/rss-offline/offline.html')||Response.error();}}}})());}});""")

if __name__ == '__main__':
    build()


python - <<'PY'
import requests
u='https://ballbox-first.emperor-ratio.ts.net/rss-offline/'
text=requests.get(u,timeout=20).text
print(text[:1200])
PY

python - <<'PY'
import requests,re
idx=requests.get('https://ballbox-first.emperor-ratio.ts.net/rss-offline/',timeout=20).text
m=re.search(r'href="([^"]+\.pdf)"',idx)
print('pdf',m.group(1) if m else 'none')
if m:
    r=requests.get('https://ballbox-first.emperor-ratio.ts.net'+m.group(1),timeout=20,allow_redirects=True)
    print('status',r.status_code)
    for k,v in r.headers.items():
        if k.lower() in ('content-type','content-disposition','x-content-type-options','content-security-policy'):
            print(k+':',v)
PY


<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="theme-color" content="#07111f"><title>rss-offline</title>

rss-offline

PDF diario con artÃculos nuevos.

37 artÃculos Â· Ãºltimos 14 dÃas

Archivo de PDFs

rss-offline-2026-05-30.pdf

2026-05-30 06:02 Â· 11.2 MB

<a h


pdf /rss-offline/rss-offline-2026-05-30.pdf
status 200
Content-Type: application/pdf


**Revising implementation**

I need to implement a change. I'm thinking about removing the download feature and adding a more robust visited style instead. Maybe I should consider whether I need a target? Actually, I think I don’t. It seems like I can just focus on editing and rebuilding. Let's get started on that!

edit

for item in pdf_archive:
        pdf_cards.append(f'''

{escape(item['name'])}

{escape(item['date_label'])} · {item['size_mb']} MB

''')

index.write_text(f'''<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="theme-color" content="#07111f"><title>rss-offline</title>

rss-offline

PDF diario con artículos nuevos.

{len(archived)} artículos · últimos {DAYS} días

Archivo de PDFs
{''.join(pdf_cards) or '
Todavía no hay PDFs.
'}
''')


Successfully replaced 2 block(s) in /home/sebas/work/projects/rss-offline/build.py.

...
 489
 490     pdf_archive = list_pdf_archive()
 491     pdf_cards = []
 492     for item in pdf_archive:
-493         pdf_cards.append(f'''

{escape(item['name'])}

{escape(item['date_label'])} · {item['size_mb']} MB

''')
+493         pdf_cards.append(f'''

{escape(item['name'])}

{escape(item['date_label'])} · {item['size_mb']} MB

''')
 494
 495     local_paths = [entry['local_path'] for entry in archived]
-496     index.write_text(f'''<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="theme-color" content="#07111f"><title>rss-offline</title>

rss-offline

PDF diario con artículos nuevos.

{len(archived)} artículos · últimos {DAYS} días

Archivo de PDFs
{''.join(pdf_cards) or '
Todavía no hay PDFs.
'}
''')
+496     index.write_text(f'''<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="theme-color" content="#07111f"><title>rss-offline</title>

rss-offline

PDF diario con artículos nuevos.

{len(archived)} artículos · últimos {DAYS} días

Archivo de PDFs
{''.join(pdf_cards) or '
Todavía no hay PDFs.
'}
''')
 497
 498     offline.write_text('<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>rss-offline sin conexión</title>
Sin conexión

La lista y copias locales visitadas deberían seguir disponibles.

Volver a rss-offline
')
 499
 500     precache_paths = ['/rss-offline/', '/rss-offline/offline.html', '/rss-offline/entries.json'] + ([pdf_info['path']] if pdf_info else []) + [entry['local_path'] for entry in archived]
     ...

--- /home/sebas/work/projects/rss-offline/build.py
+++ /home/sebas/work/projects/rss-offline/build.py
@@ -489,12 +489,12 @@

     pdf_archive = list_pdf_archive()
     pdf_cards = []
     for item in pdf_archive:
-        pdf_cards.append(f'''

{escape(item['name'])}

{escape(item['date_label'])} · {item['size_mb']} MB

''')
+        pdf_cards.append(f'''

{escape(item['name'])}

{escape(item['date_label'])} · {item['size_mb']} MB

''')

     local_paths = [entry['local_path'] for entry in archived]
-    index.write_text(f'''<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="theme-color" content="#07111f"><title>rss-offline</title>

rss-offline

PDF diario con artículos nuevos.

{len(archived)} artículos · últimos {DAYS} días

Archivo de PDFs
{''.join(pdf_cards) or '
Todavía no hay PDFs.
'}
''')
+    index.write_text(f'''<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="theme-color" content="#07111f"><title>rss-offline</title>

rss-offline

PDF diario con artículos nuevos.

{len(archived)} artículos · últimos {DAYS} días

Archivo de PDFs
{''.join(pdf_cards) or '
Todavía no hay PDFs.
'}
''')

     offline.write_text('<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>rss-offline sin conexión</title>
Sin conexión

La lista y copias locales visitadas deberían seguir disponibles.

Volver a rss-offline
')

     precache_paths = ['/rss-offline/', '/rss-offline/offline.html', '/rss-offline/entries.json'] + ([pdf_info['path']] if pdf_info else []) + [entry['local_path'] for entry in archived]


bash

cd /home/sebas/work/projects/rss-offline && ./build.py >/tmp/rss-offline-build.out && python - <<'PY'
import requests,re
idx=requests.get('https://ballbox-first.emperor-ratio.ts.net/rss-offline/',timeout=20).text
print('has download attr', 'download' in idx)
print('has visited css', 'a:visited' in idx and '.pdf-link:visited' in idx)
m=re.search(r'<a class="pdf-link" href="([^"]+\.pdf)"', idx)
print('pdf-link', bool(m), m.group(1) if m else '')
PY


Command timed out after 300 seconds


**Considering build inspection**

I think I might need to build slowly and check the output, maybe using a temporary file? It seems like a shorter inspection could be helpful, especially if the build is still running. Perhaps I should try rerunning the process with a larger timeout to give it some extra time. That way, I can get a clearer picture of what’s happening. It's always good to be thorough in this kind of situation!

cd /home/sebas/work/projects/rss-offline && ./build.py >/tmp/rss-offline-build.out && tail -40 /tmp/rss-offline-build.out && python - <<'PY'
import requests,re
idx=requests.get('https://ballbox-first.emperor-ratio.ts.net/rss-offline/',timeout=20).text
print('has download attr', 'download' in idx)
print('has visited css', 'a:visited' in idx and '.pdf-link:visited' in idx)
m=re.search(r'<a class="pdf-link" href="([^"]+\.pdf)"', idx)
print('pdf-link', bool(m), m.group(1) if m else '')
PY


Traceback (most recent call last):
  File "/home/sebas/work/projects/rss-offline/./build.py", line 506, in <module>
    build()
    ~~~~~^^
  File "/home/sebas/work/projects/rss-offline/./build.py", line 488, in build
    json_index.write_text(json.dumps(archived, ensure_ascii=False, indent=2))
    ~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/lib/python3.13/pathlib/_local.py", line 557, in write_text
    return PathBase.write_text(self, data, encoding, errors, newline)
           ~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/lib/python3.13/pathlib/_abc.py", line 651, in write_text
    with self.open(mode='w', encoding=encoding, errors=errors, newline=newline) as f:
         ~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/lib/python3.13/pathlib/_local.py", line 539, in open
    return io.open(self, mode, buffering, encoding, errors, newline)
           ~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
PermissionError: [Errno 13] Permission denied: '/var/www/rss-offline/entries.json'

Command exited with code 1


cd /home/sebas/work/projects/rss-offline && sudo ./build.py >/tmp/rss-offline-build.out && tail -20 /tmp/rss-offline-build.out && python - <<'PY'
import requests,re
idx=requests.get('https://ballbox-first.emperor-ratio.ts.net/rss-offline/',timeout=20).text
print('has download attr', 'download' in idx)
print('has visited css', 'a:visited' in idx and '.pdf-link:visited' in idx)
m=re.search(r'<a class="pdf-link" href="([^"]+\.pdf)"', idx)
print('pdf-link', bool(m), m.group(1) if m else '')
PY


OK simple https://github.blog/open-source/building-githubs-next-chapter-in-accessibility/
OK simple https://leadershipintech.com/newsletters/2282-recognize-your-management-wins
OK simple https://tailscale.com/changelog/#2026-05-21-client
OK simple https://tailscale.com/changelog/#2026-05-21-service
OK simple https://github.blog/security/investigating-unauthorized-access-to-githubs-internal-repositories/
OK simple https://supernuclear.substack.com/p/case-study-tamera
OK simple https://www.digitalocean.com/blog/inference-router-architecture
OK simple https://tailscale.com/changelog/#2026-05-20-service
OK simple https://simonw.substack.com/p/the-last-six-months-in-llms-in-five
OK simple https://tailscale.com/changelog/#2026-05-19-service
OK simple https://github.blog/news-insights/product-news/take-your-local-github-sessions-anywhere/
OK simple https://leadershipintech.com/newsletters/2281-how-to-stay-resilient-in-a-difficult-job
PDF_OK https://mariozechner.at/posts/2026-05-30-shitty-robot
PDF_OK https://www.digitalocean.com/blog/ai-disruptors
PDF_OK https://supernuclear.substack.com/p/the-half-assed-potluck-and-other
PDF_OK https://gomakethings.com/articles/how-to-redirect-if-a-page-doesnt-exist-with-apache/
PDF_OK https://simonw.substack.com/p/i-think-anthropic-and-openai-have
PDF_OK https://tailscale.com/changelog/#2026-05-29-service
PDF_OK https://martinalderson.com/posts/whats-going-on-with-gemini/?utm_source=rss&utm_medium=rss&utm_campaign=feed
PDF_OK https://www.digitalocean.com/blog/digitalocean-opencode-inference-routers
has download attr False
has visited css True
pdf-link True /rss-offline/rss-offline-2026-05-31.pdf


Updated #1 (pending → completed)


Sí. Ya quedó.

Cambio:
- saqué `download` de los links a PDF
- agregué estilo `a:visited` visible
- rebuild del sitio hecho

Resultado:
- al abrir un PDF desde
  https://ballbox-first.emperor-ratio.ts.net/rss-offline/
  el link ahora puede quedar marcado como leído con el `:visited` nativo del browser

Probá refrescando la página después de abrir uno.
Si querés, también le meto un color más obvio para “leído”.


[telegram] dos nth mas:
un boton al lado para descargar directo el pdf
mantener el scroll position de los pdfs abiertos en la pagina, se puede?


**Considering PDF scroll functionality**

I need to figure out how to maintain the scroll position when dealing with PDFs. It seems I might be able to add a button and use localStorage to save the scroll position when returning from a PDF. Generally, opening the same tab keeps the scroll, but PDFs could replace the document. I wonder if it’s better to open the reader link in a new tab instead. I should consider implementing scroll storage on click and restoring it on load. Time to edit the build.py.

todo

Add direct PDF download button and preserve rss-offline scroll position

Update rss-offline PDF archive so each item has a separate direct download button while main link remains readable/visited, and restore page scroll position after opening PDFs.


Created #2: Add direct PDF download button and preserve rss-offline scroll position (pending)


edit

for item in pdf_archive:
        pdf_cards.append(f'''

{escape(item['name'])} descargar

{escape(item['date_label'])} · {item['size_mb']} MB

''')


...
 489
 490     pdf_archive = list_pdf_archive()
 491     pdf_cards = []
 492     for item in pdf_archive:
-493         pdf_cards.append(f'''

{escape(item['name'])}

{escape(item['date_label'])} · {item['size_mb']} MB

''')
+493         pdf_cards.append(f'''

{escape(item['name'])} descargar

{escape(item['date_label'])} · {item['size_mb']} MB

''')
 494
 495     local_paths = [entry['local_path'] for entry in archived]
-496     index.write_text(f'''<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="theme-color" content="#07111f"><title>rss-offline</title>

rss-offline

PDF diario con artículos nuevos.

{len(archived)} artículos · últimos {DAYS} días

Archivo de PDFs
{''.join(pdf_cards) or '
Todavía no hay PDFs.
'}
''')
+496     index.write_text(f'''<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="theme-color" content="#07111f"><title>rss-offline</title>

rss-offline

PDF diario con artículos nuevos.

{len(archived)} artículos · últimos {DAYS} días

Archivo de PDFs
{''.join(pdf_cards) or '
Todavía no hay PDFs.
'}
''')
 497
 498     offline.write_text('<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>rss-offline sin conexión</title>
Sin conexión

La lista y copias locales visitadas deberían seguir disponibles.

Volver a rss-offline
')
 499
 500     precache_paths = ['/rss-offline/', '/rss-offline/offline.html', '/rss-offline/entries.json'] + ([pdf_info['path']] if pdf_info else []) + [entry['local_path'] for entry in archived]
     ...

--- /home/sebas/work/projects/rss-offline/build.py
+++ /home/sebas/work/projects/rss-offline/build.py
@@ -489,12 +489,12 @@

     pdf_archive = list_pdf_archive()
     pdf_cards = []
     for item in pdf_archive:
-        pdf_cards.append(f'''

{escape(item['name'])}

{escape(item['date_label'])} · {item['size_mb']} MB

''')
+        pdf_cards.append(f'''

{escape(item['name'])} descargar

{escape(item['date_label'])} · {item['size_mb']} MB

''')

     local_paths = [entry['local_path'] for entry in archived]
-    index.write_text(f'''<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="theme-color" content="#07111f"><title>rss-offline</title>

rss-offline

PDF diario con artículos nuevos.

{len(archived)} artículos · últimos {DAYS} días

Archivo de PDFs
{''.join(pdf_cards) or '
Todavía no hay PDFs.
'}
''')
+    index.write_text(f'''<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="theme-color" content="#07111f"><title>rss-offline</title>

rss-offline

PDF diario con artículos nuevos.

{len(archived)} artículos · últimos {DAYS} días

Archivo de PDFs
{''.join(pdf_cards) or '
Todavía no hay PDFs.
'}
''')

     offline.write_text('<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>rss-offline sin conexión</title>
Sin conexión

La lista y copias locales visitadas deberían seguir disponibles.

Volver a rss-offline
')

     precache_paths = ['/rss-offline/', '/rss-offline/offline.html', '/rss-offline/entries.json'] + ([pdf_info['path']] if pdf_info else []) + [entry['local_path'] for entry in archived]


bash

cd /home/sebas/work/projects/rss-offline && sudo ./build.py >/tmp/rss-offline-build.out && python - <<'PY'
import requests
idx=requests.get('https://ballbox-first.emperor-ratio.ts.net/rss-offline/',timeout=20).text
checks={
 'download_button':'pdf-download' in idx and '>descargar<' in idx,
 'scroll_key':'rss-offline:index-scroll' in idx,
 'manual_scroll_restoration':'history.scrollRestoration' in idx,
}
print(checks)
PY


{'download_button': True, 'scroll_key': True, 'manual_scroll_restoration': True}


Updated #2 (pending → completed)


Sí.

Hecho:
- link principal del PDF:
  - abre normal
  - mantiene `:visited`
- botón al lado:
  - `descargar`
  - baja directo el PDF
- scroll position:
  - guardado en `sessionStorage`
  - se restaura al volver a la lista
  - forzado también en `pageshow`

URL:
- https://ballbox-first.emperor-ratio.ts.net/rss-offline/

Sobre “se puede?”:
- sí, para volver a la lista en la misma pestaña
- depende un poco del visor PDF/browser, pero del lado HTML ya quedó cubierto bastante bien

Si querés, siguiente mejora:
- que el PDF se abra en pestaña nueva
- y así la lista ni se mueve ni se recarga


[telegram] ahora indaga los ultimos pdfs e intenta en el builder targetear mejor los blogs y nada mas (github y digitalocean en particular tienen mucho extra)
no hace falta que rebuildees nada


**Inspecting build rules**

I need to check the build rules and consider proposing edits. The user has asked me to look into the latest PDFs and try to improve the blogs without needing a rebuild. I think I should check the generated HTML from specific sources and create a to-do list for that. I can edit build.py without triggering a rebuild, so I might also look at the locally generated article print HTML for GitHub or DigitalOcean.

todo

Tighten rss-offline PDF extraction for noisy blogs

Inspect recent PDF inputs, especially GitHub Blog and DigitalOcean, and improve builder targeting/cleanup so printable copies focus on article content with less extra chrome. Do not rebuild.


Created #3: Tighten rss-offline PDF extraction for noisy blogs (pending)


bash

find /var/www/rss-offline/pdf -maxdepth 1 -type f | sort | tail -20

ls -1 /var/www/rss-offline/article-*.html | tail -20


/var/www/rss-offline/article-baf0560bfe83e07a.html
/var/www/rss-offline/article-bb51898f223204ef.html
/var/www/rss-offline/article-bc93d7b33addd1e2.html
/var/www/rss-offline/article-be577c6425356c70.html
/var/www/rss-offline/article-c05e51dd412952e4.html
/var/www/rss-offline/article-c1ac4d327109cf09.html
/var/www/rss-offline/article-c22ede05fdcde865.html
/var/www/rss-offline/article-c49c56b7ed458190.html
/var/www/rss-offline/article-c6f41170288b1b07.html
/var/www/rss-offline/article-c858592a149e5a01.html
/var/www/rss-offline/article-c9dde27b9de5a145.html
/var/www/rss-offline/article-cafc54395b674132.html
/var/www/rss-offline/article-cbebf207f4772426.html
/var/www/rss-offline/article-d44db85543e860b6.html
/var/www/rss-offline/article-e730d9b21ea1c9bb.html
/var/www/rss-offline/article-ee31b1556857165d.html
/var/www/rss-offline/article-f0c570917d1b9416.html
/var/www/rss-offline/article-f419e05b4804e437.html
/var/www/rss-offline/article-f8d49f800ade15be.html
/var/www/rss-offline/article-fc2d142f0379dac3.html


**Mapping slugs to links**

Alright, I need to think about mapping slugs to links. It seems like I might need to reference something like entries.json for that. This could help me organize and connect the slugs properly to their corresponding links. I imagine simplifying the process if I get that mapping down. So my first step will be checking entries.json to see what I can find for this task. Let's figure this out!

python - <<'PY'
import json
from pathlib import Path
entries=json.loads(Path('/var/www/rss-offline/entries.json').read_text())
for e in entries[:20]:
    if 'github.blog' in e['link'] or 'digitalocean.com/blog' in e['link']:
        print(e['local_path'].split('article-')[-1].split('.html')[0], e['link'])
PY


b5ddc6629f6a7c2d https://www.digitalocean.com/blog/ai-disruptors
c22ede05fdcde865 https://www.digitalocean.com/blog/digitalocean-opencode-inference-routers
c05e51dd412952e4 https://github.blog/news-insights/company-news/still-a-developer-just-outside-our-latest-github-shop-collection-is-here/
c6f41170288b1b07 https://www.digitalocean.com/blog/introducing-batch-inference
98558fbdd89c6b67 https://github.blog/developer-skills/github/github-for-beginners-getting-started-with-git-and-github-in-vs-code/