ARTICLE_ERR https://github.blog/news-insights/company-news/github-availability-report-april-2026/ [Errno 13] Permission denied: '/var/www/rss-offline/article-76e67a998600e970.html'
ARTICLE_ERR https://github.blog/engineering/architecture-optimization/from-latency-to-instant-modernizing-github-issues-navigation-performance/ [Errno 13] Permission denied: '/var/www/rss-offline/article-9ec345ce998e8b2b.html'
ARTICLE_ERR https://gomakethings.com/we-are-miracles/ [Errno 13] Permission denied: '/var/www/rss-offline/article-22c38e85613911ce.html'
ARTICLE_ERR https://leadershipintech.com/newsletters/2277-before-you-fire-all-your-glue-people-because-of-ai [Errno 13] Permission denied: '/var/www/rss-offline/article-0409e49d86e8a171.html'
ARTICLE_ERR https://tailscale.com/changelog/#2026-05-14-service [Errno 13] Permission denied: '/var/www/rss-offline/article-0b08cc8daf2cd6f8.html'
ARTICLE_ERR https://martinalderson.com/posts/managed-agents-are-the-new-lambda/?utm_source=rss&utm_medium=rss&utm_campaign=feed [Errno 13] Permission denied: '/var/www/rss-offline/article-ee31b1556857165d.html'
ARTICLE_ERR https://openclaw.ai/blog/openai-models-in-openclaw-done-right/ [Errno 13] Permission denied: '/var/www/rss-offline/article-9084e1d1eaaaa4a1.html'
ARTICLE_ERR https://www.digitalocean.com/blog/your-infrastructure-matters [Errno 13] Permission denied: '/var/www/rss-offline/article-01abb99c09e69454.html'
ARTICLE_ERR https://github.blog/open-source/gaming/dungeons-desktops-10-roguelikes-that-never-die-because-their-communities-wont-let-them/ [Errno 13] Permission denied: '/var/www/rss-offline/article-a5c352e10c62e36a.html'
ARTICLE_ERR https://tailscale.com/changelog/#2026-05-13-client [Errno 13] Permission denied: '/var/www/rss-offline/article-f8d49f800ade15be.html'
ARTICLE_ERR https://tailscale.com/changelog/#2026-05-13-service [Errno 13] Permission denied: '/var/www/rss-offline/article-fc2d142f0379dac3.html'
ARTICLE_ERR https://tailscale.com/changelog/#2026-05-13-service [Errno 13] Permission denied: '/var/www/rss-offline/article-fc2d142f0379dac3.html'
ARTICLE_ERR https://tailscale.com/changelog/#2026-05-13-service [Errno 13] Permission denied: '/var/www/rss-offline/article-fc2d142f0379dac3.html'
ARTICLE_ERR https://github.blog/news-insights/company-news/github-copilot-individual-plans-introducing-flex-allotments-in-pro-and-pro-and-a-new-max-plan/ [Errno 13] Permission denied: '/var/www/rss-offline/article-be577c6425356c70.html'
ARTICLE_ERR https://github.blog/ai-and-ml/github-copilot/dungeons-desktops-building-a-procedurally-generated-roguelike-with-github-copilot-cli/ [Errno 13] Permission denied: '/var/www/rss-offline/article-763d0e7f1d9c5469.html'
ARTICLE_ERR https://github.blog/developer-skills/github/github-for-beginners-getting-started-with-oss-contributions/ [Errno 13] Permission denied: '/var/www/rss-offline/article-66d66f99fd29fb39.html'
ARTICLE_ERR https://gomakethings.com/pruning-harvesting-replanting-and-other-forced-metaphors/ [Errno 13] Permission denied: '/var/www/rss-offline/article-07fe8783d6f3ac08.html'
ARTICLE_ERR https://leadershipintech.com/newsletters/2276-when-everyone-has-ai-and-the-company-still-learns-nothing [Errno 13] Permission denied: '/var/www/rss-offline/article-30d6e4faa988619a.html'
ARTICLE_ERR https://simonw.substack.com/p/vibe-coding-and-agentic-engineering [Errno 13] Permission denied: '/var/www/rss-offline/article-c858592a149e5a01.html'
ARTICLE_ERR https://github.blog/news-insights/policy-news-and-insights/why-age-assurance-laws-matter-for-developers/ [Errno 13] Permission denied: '/var/www/rss-offline/article-a4139cbabb03f50a.html'
ARTICLE_ERR https://github.blog/news-insights/policy-news-and-insights/how-researchers-are-using-github-innovation-graph-data-to-reveal-the-digital-complexity-of-nations/ [Errno 13] Permission denied: '/var/www/rss-offline/article-57c425cc2a4fe9bb.html'
ARTICLE_ERR https://lucumr.pocoo.org/2026/5/8/local-models/ [Errno 13] Permission denied: '/var/www/rss-offline/article-4d443773127eb5bb.html'
ARTICLE_ERR https://github.blog/ai-and-ml/github-copilot/improving-token-efficiency-in-github-agentic-workflows/ [Errno 13] Permission denied: '/var/www/rss-offline/article-74f95f5ce84e2847.html'
ARTICLE_ERR https://github.blog/ai-and-ml/generative-ai/agent-pull-requests-are-everywhere-heres-how-to-review-them/ [Errno 13] Permission denied: '/var/www/rss-offline/article-00517e9920463688.html'
ARTICLE_ERR https://supernuclear.substack.com/p/what-popup-villages-teach-us-about [Errno 13] Permission denied: '/var/www/rss-offline/article-cbebf207f4772426.html'
ARTICLE_ERR https://leadershipintech.com/newsletters/2272-agile-in-the-age-of-ai [Errno 13] Permission denied: '/var/www/rss-offline/article-a7a71f6bda7d944f.html'
ARTICLE_ERR https://martinalderson.com/posts/open-weights-are-quietly-closing-up/?utm_source=rss&utm_medium=rss&utm_campaign=feed [Errno 13] Permission denied: '/var/www/rss-offline/article-cafc54395b674132.html'
ARTICLE_ERR https://openclaw.ai/blog/openclaw-rough-week/ [Errno 13] Permission denied: '/var/www/rss-offline/article-30626cf6c11f2a57.html'
ARTICLE_ERR https://www.digitalocean.com/blog/powering-the-inference-era [Errno 13] Permission denied: '/var/www/rss-offline/article-60a3bacc8d20f732.html'
ARTICLE_ERR https://gomakethings.com/which-side-are-you-on/ [Errno 13] Permission denied: '/var/www/rss-offline/article-a811a16760980904.html'
ARTICLE_ERR https://lucumr.pocoo.org/2026/5/4/content-for-contents-sake/ [Errno 13] Permission denied: '/var/www/rss-offline/article-baf0560bfe83e07a.html'
ARTICLE_ERR https://martinalderson.com/posts/august-29-2026-a-scenario/?utm_source=rss&utm_medium=rss&utm_campaign=feed [Errno 13] Permission denied: '/var/www/rss-offline/article-779e8c793e4c6f70.html'
ARTICLE_ERR https://leadershipintech.com/newsletters/2271-measure-less-to-learn-more [Errno 13] Permission denied: '/var/www/rss-offline/article-5de306c8f07b39cf.html'
ARTICLE_ERR https://simonw.substack.com/p/deepseek-v4-and-the-end-of-the-openaimicrosoft [Errno 13] Permission denied: '/var/www/rss-offline/article-8ebb1572829e77ef.html'
Traceback (most recent call last):
  File "/home/sebas/work/projects/rss-offline/build.py", line 199, in <module>
    build()
    ~~~~~^^
  File "/home/sebas/work/projects/rss-offline/build.py", line 183, 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'
Traceback (most recent call last):
  File "<stdin>", line 4, in <module>
  File "/usr/lib/python3.13/pathlib/_local.py", line 548, in read_text
    return PathBase.read_text(self, encoding, errors, newline)
           ~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/lib/python3.13/pathlib/_abc.py", line 632, in read_text
    with self.open(mode='r', 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)
           ~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
FileNotFoundError: [Errno 2] No such file or directory: '/var/www/rss-offline/entries.json'

Command exited with code 1


bash

cd /home/sebas/work/projects/rss-offline && sudo python3 build.py | sudo tee /home/sebas/runtime/rss-offline-build.log >/dev/null
python3 - <<'PY'
import json
from pathlib import Path
p=Path('/var/www/rss-offline/entries.json')
entries=json.loads(p.read_text())
print('entries', len(entries))
print('freshrss', sum(1 for e in entries if 'freshrss' in json.dumps(e).lower()))
PY


entries 34
freshrss 0


cd /home/sebas && rg -n --hidden -S "FreshRSS|freshrss|/rss/|rss-sw.js|rss-offline.html" /home/sebas/pi-config /home/sebas/work/projects /var/www/portal /etc/nginx/sites-available/kiosk 2>/dev/null


/home/sebas/work/projects/rss-offline/feed-sources.json:24:    "feedUrl": "https://www.digitalocean.com/rss/blog.atom",


**Verifying routes and tests**

It seems like there are no remaining references, which is great! I might want to verify if the /rss/ route is indeed removed since the search didn't show anything. It wouldn't hurt to check if there’s a mention of it on the portal page. Then, I should reload it. I'll also keep an eye on those backfiles in the docs that seem a bit odd from that replacement. Lastly, I think I should run some syntax tests for build.py.

python3 -m py_compile /home/sebas/work/projects/rss-offline/build.py && echo OK


OK


Updated #1 (in_progress → completed)

Remove FreshRSS project references and assets

Find and remove FreshRSS project code, docs, links, and todo items from local workspace/config.

Change offline RSS capture to save full local pages

Update RSS offline flow to store full blog pages locally instead of text-only captures.


Updated #2 (in_progress → completed)


Hecho.

Cambios:
- saqué FreshRSS de:
  - código
  - docs
  - backlog/todo
  - portal
  - nginx
  - feeds de `rss-offline`
- borré:
  - `/home/sebas/work/projects/bb-004/freshrss-trial`
  - doc/snippet de FreshRSS
  - archivo runtime `freshrss-recent-archive`
- paré y removí el contenedor `bb004-freshrss-trial`

RSS offline:
- cambié el builder para guardar la página HTML local completa
- ya no guarda extracción texto-only
- regeneré `/var/www/rss-offline/`
- resultado actual: 34 entradas, 0 refs a FreshRSS

Impacto visible:
- portal ya no muestra `RSS`
- queda solo `RSS offline`
- `/rss/` dejó de existir
- `/rss-offline/` sigue activo con copias locales completas

Si querés, siguiente paso:
- probar 2 o 3 blogs desde el celu
- ver qué tan bien se ven offline real en Safari/Chrome móvil


[telegram] anda muy bien en safari! y bajr el js tambien es posible? la mayoria de las paginas sin js son muy crudas (super usable igual, seria por ux)


**Clarifying Asset Mirroring**

I should keep my answer brief, confirming that yes, local full asset mirroring is possible but with some caveats. If the scope changes, the user might need to ask again. I could explain how asset mirroring involves JS and CSP issues but could also highlight saving same-origin assets. Mentioning that a service worker already caches local HTML might be useful. However, I’ll note that dynamic APIs won't work offline. No tools are needed for this action.

Sí. Posible.

Mejor versión:
- bajar assets de la página:
  - JS
  - CSS
  - imágenes
  - fuentes
- reescribir links para que apunten local
- abrir la copia local con esos assets locales

Límite real:
- mucho JS moderno depende de:
  - requests extra
  - APIs externas
  - hydration
  - CSP rara
  - bundles con rutas dinámicas
- eso no siempre revive offline 100%
- pero para UX visual suele mejorar bastante

Plan bueno:
1. guardar HTML completo
2. bajar assets same-origin referenciados
3. reescribir `src` / `href`
4. dejar fallback al original si algo falta
5. medir en 3-5 blogs reales

Eso da:
- mejor look
- mejor layout
- más chance de artículos “como el sitio”
- sin prometer app dinámica completa offline

Si querés, lo implemento en `rss-offline` como:
- `modo simple`: HTML local
- `modo assets`: HTML + CSS/JS/img local

Yo haría directo `modo assets` con límite de tamaño por página.


[telegram] si no requiere proceso ai intermedio a futuro probarlo


todo

Add asset mirroring to rss-offline local captures

Extend rss-offline builder to download same-origin page assets like CSS, JS, images, and fonts, rewrite captured HTML to local asset paths, and keep size/count limits for pragmatic offline UX.


Created #3: Add asset mirroring to rss-offline local captures (pending)


Updated #3 (pending → in_progress)


edit

from urllib.parse import urljoin, urlparse

HEADERS = {'user-agent': 'Mozilla/5.0 (rss-offline builder)'}
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', '.js', '.mjs', '.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.ico', '.woff', '.woff2', '.ttf')

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

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'
        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 mirror_assets(entry, soup, entry_key: str):
    page_origin = urlparse(entry['link']).netloc
    asset_dir = OUTPUT_DIR / 'assets' / entry_key
    asset_dir.mkdir(parents=True, exist_ok=True)
    mirrored = {}
    total_bytes = 0
    count = 0

    def candidates():
        for tag, attr in [('link', 'href'), ('script', 'src'), ('img', 'src'), ('source', '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
        path_lower = parsed.path.lower()
        if not any(path_lower.endswith(ext) for ext in ALLOWED_ASSET_EXTENSIONS):
            if node.name == 'link' and 'stylesheet' not in ' '.join(node.get('rel', [])):
                continue
            if node.name == 'script' and not path_lower:
                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 make_local_copy(entry, page_html: str, entry_key: str) -> str:
    soup = BeautifulSoup(page_html, 'html.parser')

    mirror_assets(entry, soup, entry_key)

    banner = BeautifulSoup(


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

...
   8 import xml.etree.ElementTree as ET
   9 from datetime import datetime, timedelta, timezone
  10 from html import escape
  11 from pathlib import Path
- 12 from urllib.parse import urljoin
+ 12 from urllib.parse import urljoin, urlparse
  13
  14 import requests
  15 from bs4 import BeautifulSoup
  16
     ...
  21 MAX_ENTRIES = int(os.environ.get('RSS_OFFLINE_MAX_ENTRIES', '250'))
  22 DAYS = int(os.environ.get('RSS_OFFLINE_DAYS', '14'))
  23 TIMEOUT = 20
  24 HEADERS = {'user-agent': 'Mozilla/5.0 (rss-offline builder)'}
+ 25 MAX_ASSETS_PER_PAGE = int(os.environ.get('RSS_OFFLINE_MAX_ASSETS_PER_PAGE', '24'))
+ 26 MAX_ASSET_BYTES = int(os.environ.get('RSS_OFFLINE_MAX_ASSET_BYTES', '1500000'))
+ 27 ALLOWED_ASSET_EXTENSIONS = ('.css', '.js', '.mjs', '.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.ico', '.woff', '.woff2', '.ttf')
  25
  26
  27 def fetch(url: str) -> requests.Response:
  28     response = requests.get(url, timeout=TIMEOUT, headers=HEADERS)
     ...
  66 def slug(url: str) -> str:
  67     return hashlib.sha256(url.encode()).hexdigest()[:16]
  68
  69
- 70 def make_local_copy(entry, page_html: str) -> str:
+ 73 def asset_rel_path(entry_key: str, asset_url: str, content_type: str = '') -> str:
+ 74     parsed = urlparse(asset_url)
+ 75     raw = (parsed.path or '').lower()
+ 76     ext = Path(raw).suffix
+ 77     if not ext:
+ 78         if 'css' in content_type:
+ 79             ext = '.css'
+ 80         elif 'javascript' in content_type:
+ 81             ext = '.js'
+ 82         elif 'svg' in content_type:
+ 83             ext = '.svg'
+ 84         elif 'png' in content_type:
+ 85             ext = '.png'
+ 86         elif 'jpeg' in content_type:
+ 87             ext = '.jpg'
+ 88         elif 'webp' in content_type:
+ 89             ext = '.webp'
+ 90         elif 'gif' in content_type:
+ 91             ext = '.gif'
+ 92         elif 'woff2' in content_type:
+ 93             ext = '.woff2'
+ 94         elif 'woff' in content_type:
+ 95             ext = '.woff'
+ 96         else:
+ 97             ext = '.bin'
+ 98     name = hashlib.sha256(asset_url.encode()).hexdigest()[:16] + ext
+ 99     return f'assets/{entry_key}/{name}'
+100
+101
+102 def fetch_asset(url: str) -> requests.Response:
+103     response = requests.get(url, timeout=TIMEOUT, headers=HEADERS, stream=True)
+104     response.raise_for_status()
+105     return response
+106
+107
+108 def mirror_assets(entry, soup, entry_key: str):
+109     page_origin = urlparse(entry['link']).netloc
+110     asset_dir = OUTPUT_DIR / 'assets' / entry_key
+111     asset_dir.mkdir(parents=True, exist_ok=True)
+112     mirrored = {}
+113     total_bytes = 0
+114     count = 0
+115
+116     def candidates():
+117         for tag, attr in [('link', 'href'), ('script', 'src'), ('img', 'src'), ('source', 'src')]:
+118             for node in soup.find_all(tag):
+119                 ref = node.get(attr)
+120                 if ref:
+121                     yield node, attr, ref
+122
+123     for node, attr, ref in candidates():
+124         if count >= MAX_ASSETS_PER_PAGE or total_bytes >= MAX_ASSET_BYTES:
+125             break
+126         asset_url = urljoin(entry['link'], ref)
+127         parsed = urlparse(asset_url)
+128         if parsed.scheme not in ('http', 'https'):
+129             continue
+130         if parsed.netloc != page_origin:
+131             continue
+132         path_lower = parsed.path.lower()
+133         if not any(path_lower.endswith(ext) for ext in ALLOWED_ASSET_EXTENSIONS):
+134             if node.name == 'link' and 'stylesheet' not in ' '.join(node.get('rel', [])):
+135                 continue
+136             if node.name == 'script' and not path_lower:
+137                 continue
+138         try:
+139             rel = mirrored.get(asset_url)
+140             if rel is None:
+141                 res = fetch_asset(asset_url)
+142                 content_type = res.headers.get('content-type', '').lower()
+143                 rel = asset_rel_path(entry_key, asset_url, content_type)
+144                 data = b''
+145                 limit_hit = False
+146                 for chunk in res.iter_content(65536):
+147                     if not chunk:
+148                         continue
+149                     data += chunk
+150                     if len(data) + total_bytes > MAX_ASSET_BYTES:
+151                         limit_hit = True
+152                         break
+153                 res.close()
+154                 if limit_hit or not data:
+155                     continue
+156                 out = OUTPUT_DIR / rel
+157                 out.parent.mkdir(parents=True, exist_ok=True)
+158                 out.write_bytes(data)
+159                 mirrored[asset_url] = rel
+160                 total_bytes += len(data)
+161                 count += 1
+162             node[attr] = '/rss-offline/' + rel
+163         except Exception:
+164             continue
+165
+166
+167 def make_local_copy(entry, page_html: str, entry_key: str) -> str:
  71     soup = BeautifulSoup(page_html, 'html.parser')
  72
  73     if soup.head is None:
  74         head = soup.new_tag('head')
     ...
 104     style = soup.new_tag('style')
 105     style.string = banner_css
 106     soup.head.append(style)
 107
+205     mirror_assets(entry, soup, entry_key)
+206
 108     banner = BeautifulSoup(
 109         f'
← rss-offline · '
 110         f'abrir original · '
 111         f'{escape(entry["title"])} · '
     ...
 168             key = slug(entry['link'])
 169             article_file = OUTPUT_DIR / f'article-{key}.html'
 170             entry['local_path'] = f'/rss-offline/article-{key}.html'
 171             entry['date_label'] = datetime.fromtimestamp(entry['timestamp'] or time.time(), tz=timezone.utc).astimezone().strftime('%Y-%m-%d %H:%M')
-172             article_file.write_text(make_local_copy(entry, response.text))
+271             article_file.write_text(make_local_copy(entry, response.text, key))
 173             archived.append(entry)
 174             print('OK', entry['link'])
 175         except Exception as e:
 176             print('ARTICLE_ERR', entry['link'], e)
     ...


bash

python3 -m py_compile /home/sebas/work/projects/rss-offline/build.py && echo OK && cd /home/sebas/work/projects/rss-offline && sudo python3 build.py | sudo tee /home/sebas/runtime/rss-offline-build.log >/dev/null && sudo find /var/www/rss-offline/assets -type f | head -n 20 && echo 'asset_count='$(sudo find /var/www/rss-offline/assets -type f | wc -l)


**Updating considerations**

I should probably mention that I might need to refrain from discussing AI in the future. It's an interesting thought, and it feels important to clarify that. I think it would help to provide a caveat so that things are clearer! I'll make sure to keep this in mind and update my to-do list to reflect this change. It seems like a good idea to ensure transparency going forward!


Updated #3 (in_progress → completed)


Sí. No requiere AI.

Ya lo probé:
- `rss-offline` ahora baja assets same-origin
- incluye:
  - JS
  - CSS
  - imágenes
  - fuentes
- reescribe links al mirror local
- regeneré el sitio

Estado:
- builder OK
- generó assets locales
- count actual: 393 archivos

Límite puesto:
- máx 24 assets por página
- máx ~1.5 MB por página en assets
- solo same-origin
- nada de proceso AI intermedio

Eso debería mejorar bastante la UX visual.
No garantiza que todo JS dinámico viva offline, pero sí mejor pinta/layout en muchos sitios.

Probalo en Safari otra vez.
Si ves un blog puntual que sigue feo, lo ajusto.


[telegram] Anda bien, pero podría mejorar
Y si en vez se renderizar el HTML completo en el servidor y se guarda el resultante después de hacer todas las llamadas fuentes externas, y eso complicado que mencionaste


**Explaining rendering approaches**

I think I need to answer this conceptually. The user is asking about rendering full HTML server-side after external calls, considering the complexity involved. I should explain that, yes, we can use a headless browser snapshot instead of relying on AI for this. There are tradeoffs to consider like size, time, authentication, and handling infinite scroll or JavaScript state. I can suggest two modes: the current lightweight approach versus a rendered snapshot for specific sites.

Sí. Eso existe.

Sería otro enfoque:
- abrir la página en headless Chromium
- dejar que cargue JS
- esperar red quieta
- capturar el DOM final
- bajar assets usados
- guardar snapshot resultante

Eso te acerca mucho más a:
- “lo que vio el navegador”
- no “HTML crudo + assets”

Nombre práctico:
- snapshot renderizado
- o prerender local

Ventajas:
- mejor UX
- mejor layout
- contenido que aparece solo con JS
- menos páginas “crudas”

Lo complicado:
- más lento
- más pesado
- más frágil
- algunas páginas siguen dependiendo de:
  - XHR/fetch posteriores
  - lazy load al scrollear
  - estado de sesión/cookies
  - scripts que recalculan cosas al abrir
  - CSP / anti-bot / rate limits
- guardar “todo lo que usó” no siempre es trivial
  - fuentes
  - imágenes lazy
  - llamadas API
  - chunks JS dinámicos
  - iframes embebidos

Pero sí: es bastante viable.
Y no requiere AI.

Yo haría esto:
1. mantener modo actual como baseline rápido
2. agregar modo `rendered`
3. usar Playwright/Chromium
4. esperar carga + autoscroll corto
5. guardar HTML final
6. mirror de assets y quizá algunas respuestas XHR same-origin
7. probar en 3-5 sitios

Eso probablemente mejore mucho más que seguir puliendo el modo actual.

Si querés, lo implemento como segundo pipeline:
- `simple`: fetch HTML
- `rendered`: Chromium snapshot

Y dejamos flag por feed o por dominio.


[telegram] dale y que diga en la lista que modo tiene disponible (simple/rendered)


todo

Add rendered snapshot mode to rss-offline

Add a Playwright/Chromium rendered capture mode to rss-offline, support per-feed/domain mode selection, and show available mode in the list UI.


Created #4: Add rendered snapshot mode to rss-offline (pending)


Updated #4 (pending → in_progress)


**Investigating current feed**

I need to inspect the current feed JSON and think about adding a mode field. It seems like I might need to use Playwright if that's installed. It sounds like editing `build.py` significantly could be necessary, but I wonder if it might be easier to use a Python script for replacement instead. That could streamline things a bit. Let's keep exploring these options to find the best approach!


#!/usr/bin/env python3
import email.utils
import hashlib
import json
import os
import re
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)'}
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', '.js', '.mjs', '.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.ico', '.woff', '.woff2', '.ttf')

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'
        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 mirror_assets(entry, soup, entry_key: str):
    page_origin = urlparse(entry['link']).netloc
    asset_dir = OUTPUT_DIR / 'assets' / entry_key
    asset_dir.mkdir(parents=True, exist_ok=True)
    mirrored = {}
    total_bytes = 0
    count = 0

    def candidates():
        for tag, attr in [('link', 'href'), ('script', 'src'), ('img', 'src'), ('source', '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
        path_lower = parsed.path.lower()
        if not any(path_lower.endswith(ext) for ext in ALLOWED_ASSET_EXTENSIONS):
            if node.name == 'link' and 'stylesheet' not in ' '.join(node.get('rel', [])):
                continue
            if node.name == 'script' and not path_lower:
                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 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"])} · '
        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 build():
    DATA_DIR.mkdir(parents=True, exist_ok=True)
    OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
    sources = json.loads(SOURCES_PATH.read_text())
    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,
            })

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

    archived = []
    for entry in entries:
        try:
            response = fetch(entry['link'])
            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')
            article_file.write_text(make_local_copy(entry, response.text, key))
            archived.append(entry)
            print('OK', entry['link'])
        except Exception as e:
            print('ARTICLE_ERR', entry['link'], e)

    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))

    cards = []
    for entry in archived:
        cards.append(f'''

{escape(entry['title'])}

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

página local completa · blog real

''')

    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

Artículos por fecha con página local completa y link real.

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

{''.join(cards)}''')

    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'] + [entry['local_path'] for entry in archived]
    sw.write_text(f"""const V='rss-offline-v3';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();}}}})());}});""")

if __name__ == '__main__':
    build()


**Exploring snapshot implementation**

I’m considering how to implement a playwright snapshot. It seems that the easiest approach might be a separate Node script. However, I could also use Python subprocesses with a playwright command. I should check if the necessary libraries are installed. Another option would be to utilize the playwright CLI for screenshots. Ultimately, I think I can use a Node script to access the DOM content, so I will inspect whether the Playwright module is installed in Python.

bash

python3 - <<'PY'
import importlib.util
print(importlib.util.find_spec('playwright'))
PY


node-playwright-missing


(no output)


which chromium || which chromium-browser || which google-chrome || which google-chrome-stable || true


**Implementing rendered mode**

I’m thinking about implementing rendered mode with various settings to ensure everything runs smoothly. It might involve using a headless browser with specific flags and a timeout. The user hinted at wanting different modes, like simple and rendered, so maybe I'll set the default to rendered for simplicity. I'd also like to check if we can generate both modes per article when the source allows it. I'll make sure to adjust the list based on what's available.

**Setting capture modes**

I’m considering how to handle capture modes, starting with rendered if the source supports it; otherwise, I'll fall back to simple. I could set the default to rendered for all sources, but that could be risky. Instead, I’m thinking of adding a specific field for capture modes and ensuring we can define it per source. I also want to manage this through environment variables. It's clear I'll need to rewrite the build.py file for clarity and efficiency.

write

#!/usr/bin/env python3
import email.utils
import hashlib
import json
import os
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_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', '.js', '.mjs', '.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.ico', '.woff', '.woff2', '.ttf')
CHROMIUM_BIN = os.environ.get('RSS_OFFLINE_CHROMIUM_BIN', '/usr/bin/chromium')

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 any(path_lower.endswith(ext) for ext in ALLOWED_ASSET_EXTENSIONS):
        return True
    if node.name == 'link' and 'stylesheet' in ' '.join(node.get('rel', [])).lower():
        return True
    if node.name == 'script' and parsed.path:
        return True
    return False

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'), ('script', 'src'), ('img', 'src'), ('source', '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):
    preferred = (entry.get('capture_mode') or DEFAULT_CAPTURE_MODE).lower()
    modes = [preferred] + [mode for mode in ('rendered', 'simple') if mode != preferred]
    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"])}] · '
        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 build():
    DATA_DIR.mkdir(parents=True, exist_ok=True)
    OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
    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]

    archived = []
    for entry in entries:
        try:
            page_html, used_mode = capture_page(entry)
            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))
            archived.append(entry)
            print('OK', used_mode, entry['link'])
        except Exception as e:
            print('ARTICLE_ERR', entry['link'], e)

    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))

    cards = []
    for entry in archived:
        cards.append(f'''

{escape(entry['title'])}

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

modo: {escape(entry['captured_mode'])} · blog real

''')

    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

Artículos por fecha con página local completa, modo visible y link real.

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

{''.join(cards)}''')

    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'] + [entry['local_path'] for entry in archived]
    sw.write_text(f"""const V='rss-offline-v4';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();}}}})());}});""")

if __name__ == '__main__':
    build()


Successfully wrote 15660 bytes to /home/sebas/work/projects/rss-offline/build.py


**Considering update options**

I’m thinking about updating the feed sources to include captureMode. But, hmm, it might not be necessary if the default is already rendered. Showing the available mode should reflect the used mode, which may be enough. I wonder if explicitly adding captureMode to some would help, but probably not. I think I need to just run the compile and build process to see how everything functions as is.


OK


bash

cd /home/sebas/work/projects/rss-offline && sudo python3 build.py | sudo tee /home/sebas/runtime/rss-offline-build.log >/tmp/rss-offline-build-last.log
python3 - <<'PY'
import json
from pathlib import Path
entries=json.loads(Path('/var/www/rss-offline/entries.json').read_text())
print('entries', len(entries))
from collections import Counter
print('modes', dict(Counter(e.get('captured_mode') for e in entries)))
PY


Command timed out after 240 seconds


**Examining temperature logs**

I want to check if the temperature-related process is still running. It seems important to inspect the temperature to make sure everything is functioning correctly. I think the best way to figure this out is to tail the logs, so I can see live updates on what's happening. This should give me a clearer picture of any potential issues or confirm that everything's okay! Let's proceed with that.

tail -n 40 /home/sebas/runtime/rss-offline-build.log 2>/dev/null || tail -n 40 /tmp/rss-offline-build-last.log 2>/dev/null || true

ps -ef | rg 'python3 build.py|chromium.*dump-dom' || true


root     1989466    1289  6 13:06 ?        00:00:15 python3 build.py
root     1995807 1989466 45 13:10 ?        00:00:00 /usr/lib/chromium/chromium --js-flags=--no-decommit-pooled-pages --force-renderer-accessibility --enable-remote-extensions --show-component-extension-options --enable-gpu-rasterization --no-default-browser-check --disable-pings --media-router=0 --enable-remote-extensions --load-extension= --use-angle=gles https://github.blog/ai-and-ml/github-copilot/dungeons-desktops-building-a-procedurally-generated-roguelike-with-github-copilot-cli/ --headless=new --disable-gpu --no-sandbox --run-all-compositor-stages-before-draw --virtual-time-budget=8000 --dump-dom
sebas    1995993 1870186  0 13:10 ?        00:00:00 /bin/bash -c ps -ef | rg 'python3 build.py|chromium.*dump-dom' || true
sebas    1995995 1995993  0 13:10 ?        00:00:00 rg python3 build.py|chromium.*dump-dom


while ps -ef | rg -q 'python3 build.py'; do sleep 5; echo waiting; done; echo done; python3 - <<'PY'
import json
from pathlib import Path
p=Path('/var/www/rss-offline/entries.json')
print('exists', p.exists())
if p.exists():
  entries=json.loads(p.read_text())
  from collections import Counter
  print('entries', len(entries))
  print('modes', dict(Counter(e.get('captured_mode') for e in entries)))
PY


waiting
waiting
waiting
waiting
waiting
waiting
waiting
waiting
waiting
waiting
waiting
waiting
waiting
waiting
waiting
waiting
waiting
waiting
waiting
waiting
waiting
waiting
waiting
waiting
waiting
waiting
waiting
waiting
waiting
waiting
waiting
waiting
waiting
waiting
waiting
waiting
waiting
waiting
waiting
waiting
waiting
waiting
waiting
waiting
waiting
waiting
waiting
waiting
waiting
waiting
waiting
waiting
waiting
waiting
waiting
waiting
waiting
waiting
waiting
waiting
waiting
waiting
waiting
waiting
waiting
waiting
waiting
waiting
waiting
waiting
waiting
waiting
waiting
waiting
waiting
waiting
waiting
waiting
waiting
waiting
waiting
waiting
waiting

Command timed out after 420 seconds


**Optimizing build performance**

It seems the build process is either too slow or hanging. I’m thinking maybe I need to optimize by not rendering all entries at once. A fallback to default rendering could help, along with reducing the maximum entries. For production, running a cron job every 30 minutes seems difficult. Perhaps I could render only selected domains? The user wants to try this, so I could set rendering for specific feeds instead of all at once. I'll need to ensure the build remains functional and find out which sites might be causing the hang.

**Improving configuration settings**

I’m considering implementing a simpler configuration by adding a capture mode for each source and setting some to rendered. I need to identify which feeds are working well, perhaps using rendered for static blogs or low counts. However, the user wants a more general approach. I’ve also noticed that the chromium calls may exceed the timeout, leading to slowdowns rather than hangs. For cron jobs every 30 minutes, I’ll need to cap rendered entries and adjust the default to simple, allowing rendered only when sources opt-in. Maybe choosing a few feeds the user reads would help, but it's unsure if that’s really needed.

**Establishing render limits**

I’m thinking I need to have visible results, so maybe I could set rendering for the first 10 newest entries. I could also consider a hybrid approach: rendering entries that are newer than 2 days, while older ones default to simple. I need to pick a few feeds with nice benefits and limited entries, like Substack or certain blogs. A better option might be to render only the first 8 entries overall and display the rest simply. This way, I can keep trials manageable for cron jobs—let’s set `RSS_OFFLINE_MAX_RENDERED_ENTRIES=8`. If the preferred mode is rendered, only the first 8 attempts will be rendered, while the rest will remain simple. Sounds good!

sudo pkill -f 'python3 build.py' || true
sudo pkill -f 'chromium.*dump-dom' || true
sleep 2
ps -ef | rg 'python3 build.py|chromium.*dump-dom' || true


edit

RENDER_BUDGET_MS = int(os.environ.get('RSS_OFFLINE_RENDER_BUDGET_MS', '8000'))
MAX_ASSETS_PER_PAGE = int(os.environ.get('RSS_OFFLINE_MAX_ASSETS_PER_PAGE', '24'))

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'))

def capture_page(entry):
    preferred = (entry.get('capture_mode') or DEFAULT_CAPTURE_MODE).lower()
    modes = [preferred] + [mode for mode in ('rendered', 'simple') if mode != preferred]

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)]

    archived = []
    for entry in entries:
        try:
            page_html, used_mode = capture_page(entry)

    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))

            article_file.write_text(make_local_copy(entry, page_html, key))
            archived.append(entry)
            print('OK', used_mode, entry['link'])

            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'])


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

     ...
  23 TIMEOUT = 20
  24 HEADERS = {'user-agent': 'Mozilla/5.0 (rss-offline builder)'}
  25 DEFAULT_CAPTURE_MODE = os.environ.get('RSS_OFFLINE_CAPTURE_MODE', 'rendered').strip().lower() or 'rendered'
  26 RENDER_BUDGET_MS = int(os.environ.get('RSS_OFFLINE_RENDER_BUDGET_MS', '8000'))
+ 27 MAX_RENDERED_ENTRIES = int(os.environ.get('RSS_OFFLINE_MAX_RENDERED_ENTRIES', '8'))
  27 MAX_ASSETS_PER_PAGE = int(os.environ.get('RSS_OFFLINE_MAX_ASSETS_PER_PAGE', '24'))
  28 MAX_ASSET_BYTES = int(os.environ.get('RSS_OFFLINE_MAX_ASSET_BYTES', '1500000'))
  29 ALLOWED_ASSET_EXTENSIONS = ('.css', '.js', '.mjs', '.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.ico', '.woff', '.woff2', '.ttf')
  30 CHROMIUM_BIN = os.environ.get('RSS_OFFLINE_CHROMIUM_BIN', '/usr/bin/chromium')
     ...
 191         raise RuntimeError('empty rendered DOM')
 192     return html
 193
 194
-195 def capture_page(entry):
+196 def capture_page(entry, rendered_allowed: bool):
 196     preferred = (entry.get('capture_mode') or DEFAULT_CAPTURE_MODE).lower()
-197     modes = [preferred] + [mode for mode in ('rendered', 'simple') if mode != preferred]
+198     if preferred == 'rendered' and not rendered_allowed:
+199         preferred = 'simple'
+200     modes = [preferred] + [mode for mode in ('rendered', 'simple') if mode != preferred and (mode != 'rendered' or rendered_allowed)]
 198     errors = []
 199     for mode in modes:
 200         try:
 201             if mode == 'rendered':
     ...
 311     entries.sort(key=lambda item: item['timestamp'], reverse=True)
 312     entries = entries[:MAX_ENTRIES]
 313
 314     archived = []
+318     rendered_used = 0
 315     for entry in entries:
 316         try:
-317             page_html, used_mode = capture_page(entry)
+321             wants_rendered = (entry.get('capture_mode') or DEFAULT_CAPTURE_MODE) == 'rendered'
+322             page_html, used_mode = capture_page(entry, rendered_allowed=(rendered_used < MAX_RENDERED_ENTRIES or not wants_rendered))
 318             key = slug(entry['link'])
 319             article_file = OUTPUT_DIR / f'article-{key}.html'
 320             entry['local_path'] = f'/rss-offline/article-{key}.html'
 321             entry['date_label'] = datetime.fromtimestamp(entry['timestamp'] or time.time(), tz=timezone.utc).astimezone().strftime('%Y-%m-%d %H:%M')
 322             entry['captured_mode'] = used_mode
 323             article_file.write_text(make_local_copy(entry, page_html, key))
+329             if used_mode == 'rendered':
+330                 rendered_used += 1
 324             archived.append(entry)
 325             print('OK', used_mode, entry['link'])
 326         except Exception as e:
 327             print('ARTICLE_ERR', entry['link'], e)
     ...


bash

python3 -m py_compile /home/sebas/work/projects/rss-offline/build.py && cd /home/sebas/work/projects/rss-offline && sudo python3 build.py > /tmp/rss-build-run.log 2>&1 && tail -n 20 /tmp/rss-build-run.log && python3 - <<'PY'
import json
from pathlib import Path
entries=json.loads(Path('/var/www/rss-offline/entries.json').read_text())
from collections import Counter
print('entries', len(entries))
print('modes', dict(Counter(e.get('captured_mode') for e in entries)))
PY


OK simple https://tailscale.com/changelog/#2026-05-13-service
OK simple https://github.blog/news-insights/company-news/github-copilot-individual-plans-introducing-flex-allotments-in-pro-and-pro-and-a-new-max-plan/
OK simple https://github.blog/ai-and-ml/github-copilot/dungeons-desktops-building-a-procedurally-generated-roguelike-with-github-copilot-cli/
OK simple https://github.blog/developer-skills/github/github-for-beginners-getting-started-with-oss-contributions/
OK simple https://gomakethings.com/pruning-harvesting-replanting-and-other-forced-metaphors/
OK simple https://leadershipintech.com/newsletters/2276-when-everyone-has-ai-and-the-company-still-learns-nothing
OK simple https://simonw.substack.com/p/vibe-coding-and-agentic-engineering
OK simple https://github.blog/news-insights/policy-news-and-insights/why-age-assurance-laws-matter-for-developers/
OK simple https://github.blog/news-insights/policy-news-and-insights/how-researchers-are-using-github-innovation-graph-data-to-reveal-the-digital-complexity-of-nations/
OK simple https://lucumr.pocoo.org/2026/5/8/local-models/
OK simple https://supernuclear.substack.com/p/what-popup-villages-teach-us-about
OK simple https://leadershipintech.com/newsletters/2272-agile-in-the-age-of-ai
OK simple https://martinalderson.com/posts/open-weights-are-quietly-closing-up/?utm_source=rss&utm_medium=rss&utm_campaign=feed
OK simple https://openclaw.ai/blog/openclaw-rough-week/
OK simple https://www.digitalocean.com/blog/powering-the-inference-era
OK simple https://gomakethings.com/which-side-are-you-on/
OK simple https://lucumr.pocoo.org/2026/5/4/content-for-contents-sake/
OK simple https://martinalderson.com/posts/august-29-2026-a-scenario/?utm_source=rss&utm_medium=rss&utm_campaign=feed
OK simple https://leadershipintech.com/newsletters/2271-measure-less-to-learn-more
OK simple https://simonw.substack.com/p/deepseek-v4-and-the-end-of-the-openaimicrosoft
entries 36
modes {'rendered': 8, 'simple': 28}


**Inspecting index visibility**

I might need to check the index to confirm it's visible. It seems okay, but do I need to mention 36? Wait, the previous was 34, so maybe that’s fine. I could consider updating the feed sources, but I'm not sure that's necessary. I guess I should look at the first lines of the log to see if they’re rendered, though that might not be needed. I guess I’ll just ensure the mode is visible and complete my to-do list.


Updated #4 (in_progress → completed)


Listo.

Hice:
- agregué modo `rendered`
  - usa Chromium headless
  - espera render
  - guarda DOM renderizado
- mantuve fallback `simple`
- la lista ahora muestra:
  - `modo: rendered`
  - `modo: simple`

Estado actual:
- rebuild hecho
- 36 entradas
- 8 en `rendered`
- 28 en `simple`

Decisión práctica:
- no dejé `rendered` para todo
- era muy lento para el cron
- ahora prueba `rendered` en un lote acotado y el resto cae a `simple`

O sea:
- ya podés comparar UX real entre ambos en la misma lista
- sin romper tiempos de build

Si querés, siguiente ajuste útil:
- elegir qué feeds/dominios van siempre en `rendered`
- en vez de gastar esos 8 cupos en cualquier entrada nueva


[telegram] se siguen viendo asi

[attachments] /home/sebas/.pi/agent/tmp/telegram
- /44a74eb9-de3f-4832-ad4e-bc80fc32c4b8-photo-1409.jpg


**Evaluating image diagnosis**

I need to inspect an image thoroughly and provide a diagnosis along with a plan. It seems like the rendered DOM isn't complete, possibly due to Chrome not saving computed styles because of cross-origin issues. The screenshot shows unstyled elements, which might be caused by assets being on a different origin. I'll suggest allowing cross-origin static assets or using a readability mode. I might consider viewing the attachment and using it for better context.


Read image file [image/jpeg]


Sí. Ahí se ve el problema real:

- el DOM renderizado llegó
- pero faltan estilos/recursos clave
- entonces queda “estructura renderizada”
  pero no “página final linda”

Causa probable:
- muchos assets de GitHub Blog no están en el
  mismo origen
- yo solo estoy espejando same-origin
- entonces el HTML renderizado queda,
  pero parte del CSS/JS/CDN no

O sea:
- `rendered` ayudó al contenido
- no resolvió todavía el mirror completo

Siguiente mejora correcta:
1. permitir assets cross-origin estáticos
2. con whitelist de CDNs comunes
3. bajar:
   - css
   - js
   - fonts
   - imágenes
4. reescribir también esos links
5. opcional: bloquear trackers/ads

Ejemplo de dominios a permitir:
- `github.githubassets.com`
- `assets.ctfassets.net`
- `cdn.jsdelivr.net`
- etc, según feed

Eso sí debería mejorar mucho casos como este.

Mejor plan ahora:
- mantener `simple/rendered`
- agregar `rendered+cdn-assets`
- con whitelist, no “internet entero”

Si querés, hago esa versión.


/new


queued new Telegram Pi session: pi-telegram


Nueva sesión Telegram lanzada.