5:import mimetypes
11:import zipfile
40:PUBLIC_BASE_URL = os.environ.get('RSS_OFFLINE_PUBLIC_BASE_URL', 'https://ballbox-first.emperor-ratio.ts.net/rss-offline')
282:def pick_article_root(soup: BeautifulSoup):
306:def sanitize_article_html(page_html: str, entry: dict):
308:    root = pick_article_root(soup)
347:def fetch_binary(url: str, max_bytes: int):
364:def make_epub_xhtml(title: str, body_html: str):


**Editing build.py**

I need to edit build.py to clean it up by removing any dead functions and imports, as well as the top CTA variable. I’m thinking that maybe the PUBLIC_BASE_URL is unused, so that should go too. I should also consider removing the current index, since it seems unnecessary with no top CTA. It feels like a good idea to streamline things, so let’s get started on those edits!

edit

import mimetypes
import os
import re
import subprocess
import time
import xml.etree.ElementTree as ET
import zipfile

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'))
PUBLIC_BASE_URL = os.environ.get('RSS_OFFLINE_PUBLIC_BASE_URL', 'https://ballbox-first.emperor-ratio.ts.net/rss-offline')

def pick_article_root(soup: BeautifulSoup):
    selectors = [
        'article',
        'main',
        '[role="main"]',
        '.post',
        '.entry-content',
        '.post-content',
        '.article-content',
        '.content',
    ]
    best = None
    best_len = 0
    for selector in selectors:
        for node in soup.select(selector):
            text_len = len(node.get_text(' ', strip=True))
            if text_len > best_len:
                best = node
                best_len = text_len
    if best is not None:
        return best
    return soup.body or soup

def sanitize_article_html(page_html: str, entry: dict):
    soup = BeautifulSoup(page_html, 'html.parser')
    root = pick_article_root(soup)
    content = BeautifulSoup(str(root), 'html.parser')

    for tag in content.select('script, style, noscript, iframe, form, nav, footer, header, aside, button, input, select, textarea'):
        tag.decompose()

    for node in content.find_all(True):
        for attr in list(node.attrs):
            if attr.lower().startswith('on'):
                del node.attrs[attr]
        if node.name == 'a':
            href = node.get('href')
            if href:
                node['href'] = urljoin(entry['link'], href)
        if node.name == 'img':
            src = node.get('src') or node.get('data-src') or node.get('data-lazy-src')
            if src:
                node['src'] = urljoin(entry['link'], src)
            elif node.get('srcset'):
                first = node.get('srcset', '').split(',')[0].strip().split(' ')[0]
                if first:
                    node['src'] = urljoin(entry['link'], first)
            for noisy in ('srcset', 'sizes', 'loading', 'decoding', 'fetchpriority'):
                if noisy in node.attrs:
                    del node.attrs[noisy]

    title_html = f'
{escape(entry["title"])}
'
    meta_html = (
        '
'
        f'{escape(entry["feed"])} · {escape(entry["date_label"])} · '
        f'original'
        '
'
    )
    body_html = ''.join(str(child) for child in (content.body or content).contents).strip()
    if not body_html:
        body_html = f'
Open original article
'
    return title_html + meta_html + body_html

def fetch_binary(url: str, max_bytes: int):
    response = requests.get(url, timeout=TIMEOUT, headers=HEADERS, stream=True)
    response.raise_for_status()
    chunks = []
    size = 0
    for chunk in response.iter_content(65536):
        if not chunk:
            continue
        size += len(chunk)
        if size > max_bytes:
            response.close()
            raise ValueError('asset too large')
        chunks.append(chunk)
    response.close()
    return b''.join(chunks), response.headers.get('content-type', '').split(';')[0].strip().lower()

def make_epub_xhtml(title: str, body_html: str):
    return (
        '<?xml version="1.0" encoding="utf-8"?>\n'
        '<!DOCTYPE html>\n'
        '\n'
        '\n'
        '  <meta charset="utf-8" />\n'
        f'  <title>{escape(title)}</title>\n'
        '  <link rel="stylesheet" type="text/css" href="styles.css" />\n'
        '\n'
        '\n'
        f'{body_html}\n'
        '\n'
        '\n'
    )

pdf_cta = ''
    if pdf_info:
        pdf_cta = f'
Descargar PDF de hoy · {pdf_info["count"]} artículos
'
    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.
{pdf_cta}

{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.
'}
''')


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

1 #!/usr/bin/env python3
   2 import email.utils
   3 import hashlib
   4 import json
-  5 import mimetypes
   6 import os
   7 import re
   8 import subprocess
   9 import time
  10 import xml.etree.ElementTree as ET
- 11 import zipfile
  12 from datetime import datetime, timedelta, timezone
  13 from html import escape
  14 from pathlib import Path
  15 from urllib.parse import urljoin, urlparse
     ...
  36 PDF_MAX_ARTICLES = int(os.environ.get('RSS_OFFLINE_PDF_MAX_ARTICLES', '8'))
  37 PDF_BASENAME = os.environ.get('RSS_OFFLINE_PDF_BASENAME', 'rss-offline')
  38 PDF_PAGE_TIMEOUT = int(os.environ.get('RSS_OFFLINE_PDF_PAGE_TIMEOUT', '90'))
  39 PDF_MAX_SOURCE_BYTES = int(os.environ.get('RSS_OFFLINE_PDF_MAX_SOURCE_BYTES', '2500000'))
- 40 PUBLIC_BASE_URL = os.environ.get('RSS_OFFLINE_PUBLIC_BASE_URL', 'https://ballbox-first.emperor-ratio.ts.net/rss-offline')
  41
  42
  43 def fetch(url: str) -> requests.Response:
  44     response = requests.get(url, timeout=TIMEOUT, headers=HEADERS)
     ...
 278         source['captureMode'] = (source.get('captureMode') or DEFAULT_CAPTURE_MODE).lower()
 279     return sources
 280
 281
-282 def pick_article_root(soup: BeautifulSoup):
-283     selectors = [
-284         'article',
-285         'main',
-286         '[role="main"]',
-287         '.post',
-288         '.entry-content',
-289         '.post-content',
-290         '.article-content',
-291         '.content',
-292     ]
-293     best = None
-294     best_len = 0
-295     for selector in selectors:
-296         for node in soup.select(selector):
-297             text_len = len(node.get_text(' ', strip=True))
-298             if text_len > best_len:
-299                 best = node
-300                 best_len = text_len
-301     if best is not None:
-302         return best
-303     return soup.body or soup
-304
-305
-306 def sanitize_article_html(page_html: str, entry: dict):
-307     soup = BeautifulSoup(page_html, 'html.parser')
-308     root = pick_article_root(soup)
-309     content = BeautifulSoup(str(root), 'html.parser')
-310
-311     for tag in content.select('script, style, noscript, iframe, form, nav, footer, header, aside, button, input, select, textarea'):
-312         tag.decompose()
-313
-314     for node in content.find_all(True):
-315         for attr in list(node.attrs):
-316             if attr.lower().startswith('on'):
-317                 del node.attrs[attr]
-318         if node.name == 'a':
-319             href = node.get('href')
-320             if href:
-321                 node['href'] = urljoin(entry['link'], href)
-322         if node.name == 'img':
-323             src = node.get('src') or node.get('data-src') or node.get('data-lazy-src')
-324             if src:
-325                 node['src'] = urljoin(entry['link'], src)
-326             elif node.get('srcset'):
-327                 first = node.get('srcset', '').split(',')[0].strip().split(' ')[0]
-328                 if first:
-329                     node['src'] = urljoin(entry['link'], first)
-330             for noisy in ('srcset', 'sizes', 'loading', 'decoding', 'fetchpriority'):
-331                 if noisy in node.attrs:
-332                     del node.attrs[noisy]
-333
-334     title_html = f'
{escape(entry["title"])}
'
-335     meta_html = (
-336         '
'
-337         f'{escape(entry["feed"])} · {escape(entry["date_label"])} · '
-338         f'original'
-339         '
'
-340     )
-341     body_html = ''.join(str(child) for child in (content.body or content).contents).strip()
-342     if not body_html:
-343         body_html = f'
Open original article
'
-344     return title_html + meta_html + body_html
-345
-346
-347 def fetch_binary(url: str, max_bytes: int):
-348     response = requests.get(url, timeout=TIMEOUT, headers=HEADERS, stream=True)
-349     response.raise_for_status()
-350     chunks = []
-351     size = 0
-352     for chunk in response.iter_content(65536):
-353         if not chunk:
-354             continue
-355         size += len(chunk)
-356         if size > max_bytes:
-357             response.close()
-358             raise ValueError('asset too large')
-359         chunks.append(chunk)
-360     response.close()
-361     return b''.join(chunks), response.headers.get('content-type', '').split(';')[0].strip().lower()
-362
-363
-364 def make_epub_xhtml(title: str, body_html: str):
-365     return (
-366         '<?xml version="1.0" encoding="utf-8"?>\n'
-367         '<!DOCTYPE html>\n'
-368         '\n'
-369         '\n'
-370         '  <meta charset="utf-8" />\n'
-371         f'  <title>{escape(title)}</title>\n'
-372         '  <link rel="stylesheet" type="text/css" href="styles.css" />\n'
-373         '\n'
-374         '\n'
-375         f'{body_html}\n'
-376         '\n'
-377         '\n'
-378     )
-379
-380
 381 def isolate_anchor_section(soup: BeautifulSoup, entry: dict):
 382     parsed = urlparse(entry['link'])
 383     fragment = (parsed.fragment or '').strip()
 384     if not fragment:
     ...
 596     for item in pdf_archive:
 597         pdf_cards.append(f'''

{escape(item['name'])}

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

''')
 598
 599     local_paths = [entry['local_path'] for entry in archived]
-600     pdf_cta = ''
-601     if pdf_info:
-602         pdf_cta = f'
Descargar PDF de hoy · {pdf_info["count"]} artículos
'
-603     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.
{pdf_cta}

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

Archivo de PDFs
{''.join(pdf_cards) or '
Todavía no hay PDFs.
'}
''')
+498     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.
'}
''')
 604
 605     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
')
 606
 607     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

python3 -m py_compile /home/sebas/work/projects/rss-offline/build.py && grep -nE 'epub|mimetypes|zipfile|PUBLIC_BASE_URL|Descargar PDF de hoy|pick_article_root|sanitize_article_html|fetch_binary|make_epub_xhtml' /home/sebas/work/projects/rss-offline/build.py || true


(no output)


python3 - <<'PY' >/tmp/rss-offline-index-new.html
import json, re
from datetime import datetime, timezone
from html import escape
from pathlib import Path
OUTPUT_DIR = Path('/var/www/rss-offline')
entries = json.loads((OUTPUT_DIR / 'entries.json').read_text())
pattern = re.compile(r'^rss-offline-\d{4}-\d{2}-\d{2}(?:_\d{2}-\d{2})?\.pdf$')
pdfs = []
for path in sorted(OUTPUT_DIR.glob('rss-offline-*.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')})
pdf_cards = [f'''

{escape(item['name'])}

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

''' for item in pdfs]
local_paths = [entry['local_path'] for entry in entries]
print(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(entries)} artículos · últimos 14 días

Archivo de PDFs
{''.join(pdf_cards) or '
Todavía no hay PDFs.
'}
''')
PY
sudo cp /tmp/rss-offline-index-new.html /var/www/rss-offline/index.html
grep -n 'Descargar PDF' /var/www/rss-offline/index.html || true


**Considering documentation updates**

I need to search the documentation again, but it seems like excluding sessions didn't yield any results. Maybe there actually are no docs on that? I should mention that I've cleaned up any existing code or docs. It might be a good idea to look into updating the project documentation as well. I’ll search specifically for references to RSS offline to see what I can find. Let's keep moving forward!

grep -Rni 'rss-offline' /home/sebas/pi-config/docs /home/sebas/work/projects/rss-offline 2>/dev/null | sed -n '1,220p'


/home/sebas/pi-config/docs/bb-004-rss-offline-status-2026-05-14.md:1:# BB-004 rss-offline status — 2026-05-14
/home/sebas/pi-config/docs/bb-004-rss-offline-status-2026-05-14.md:9:- `https://ballbox-first.emperor-ratio.ts.net/rss-offline/`
/home/sebas/pi-config/docs/bb-004-rss-offline-status-2026-05-14.md:22:## What `rss-offline` does
/home/sebas/pi-config/docs/bb-004-rss-offline-status-2026-05-14.md:29:- serves a static web UI at `/rss-offline/`
/home/sebas/pi-config/docs/bb-004-rss-offline-status-2026-05-14.md:32:- registers a service worker under `/rss-offline/`
/home/sebas/pi-config/docs/bb-004-rss-offline-status-2026-05-14.md:40:- `/home/sebas/work/projects/rss-offline/`
/home/sebas/pi-config/docs/bb-004-rss-offline-status-2026-05-14.md:41:- `/home/sebas/work/projects/rss-offline/feed-sources.json`
/home/sebas/pi-config/docs/bb-004-rss-offline-status-2026-05-14.md:42:- `/home/sebas/work/projects/rss-offline/build.py`
/home/sebas/pi-config/docs/bb-004-rss-offline-status-2026-05-14.md:46:- `/var/www/rss-offline/`
/home/sebas/pi-config/docs/bb-004-rss-offline-status-2026-05-14.md:47:- `/var/www/rss-offline/index.html`
/home/sebas/pi-config/docs/bb-004-rss-offline-status-2026-05-14.md:48:- `/var/www/rss-offline/entries.json`
/home/sebas/pi-config/docs/bb-004-rss-offline-status-2026-05-14.md:49:- `/var/www/rss-offline/sw.js`
/home/sebas/pi-config/docs/bb-004-rss-offline-status-2026-05-14.md:50:- `/var/www/rss-offline/offline.html`
/home/sebas/pi-config/docs/bb-004-rss-offline-status-2026-05-14.md:51:- `/var/www/rss-offline/article-*.html`
/home/sebas/pi-config/docs/bb-004-rss-offline-status-2026-05-14.md:55:- `/home/sebas/runtime/rss-offline-build.log`
/home/sebas/pi-config/docs/bb-004-rss-offline-status-2026-05-14.md:76:Treat `rss-offline` as the current best offline reading path.
/home/sebas/pi-config/docs/bb-004-rss-offline-status-2026-05-14.md:77:Keep investing in `rss-offline` only if a future need appears for:
/home/sebas/pi-config/docs/BACKLOG.md:58:  - is `rss-offline` already good enough as the primary phone/offline reader, or does it still need a stronger local archive hit rate?
/home/sebas/pi-config/docs/BACKLOG.md:61:  - treat `rss-offline` as the primary experiment, measure whether the current local-copy workflow is sufficient, and only then decide whether to keep evolving it or fold feed management into it
/home/sebas/pi-config/docs/BACKLOG.md:65:  - current primary path is `/rss-offline/`: static date-sorted recent entries, server-fetched local article copies, service worker under the same prefix, and local copy as the primary click target
/home/sebas/pi-config/docs/BACKLOG.md:67:  - current generated artifacts and limits are documented in `pi-config/docs/bb-004-rss-offline-status-2026-05-14.md`
/home/sebas/work/projects/rss-offline/build.py:21:OUTPUT_DIR = Path('/var/www/rss-offline')
/home/sebas/work/projects/rss-offline/build.py:25:HEADERS = {'user-agent': 'Mozilla/5.0 (rss-offline builder)'}
/home/sebas/work/projects/rss-offline/build.py:35:PDF_BASENAME = os.environ.get('RSS_OFFLINE_PDF_BASENAME', 'rss-offline')
/home/sebas/work/projects/rss-offline/build.py:179:            node[attr] = '/rss-offline/' + rel
/home/sebas/work/projects/rss-offline/build.py:246:        '.rss-offline-banner{position:fixed;top:0;left:0;right:0;z-index:2147483647;'
/home/sebas/work/projects/rss-offline/build.py:249:        '.rss-offline-banner a{color:#8dc3ff;text-decoration:none}'
/home/sebas/work/projects/rss-offline/build.py:250:        '.rss-offline-banner strong{color:#fff}'
/home/sebas/work/projects/rss-offline/build.py:251:        '.rss-offline-banner .muted{opacity:.78}'
/home/sebas/work/projects/rss-offline/build.py:260:        f'
← rss-offline · '
/home/sebas/work/projects/rss-offline/build.py:335:    html = html.replace('/rss-offline/', 'file:///var/www/rss-offline/')
/home/sebas/work/projects/rss-offline/build.py:376:                'path': f'/rss-offline/{PDF_DIRNAME}/{pdf_name}',
/home/sebas/work/projects/rss-offline/build.py:387:        'path': f'/rss-offline/{merged_filename}',
/home/sebas/work/projects/rss-offline/build.py:402:            'path': f'/rss-offline/{path.name}',
/home/sebas/work/projects/rss-offline/build.py:472:            entry['local_path'] = f'/rss-offline/article-{key}.html'
/home/sebas/work/projects/rss-offline/build.py:498:    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.
'}
''')
/home/sebas/work/projects/rss-offline/build.py:500:    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
')
/home/sebas/work/projects/rss-offline/build.py:502:    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:503:    sw_version = f"rss-offline-v{int(time.time())}"
/home/sebas/work/projects/rss-offline/build.py:504:    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();}}}})());}});""")


**Updating documentation**

I think I need to update the doc file to align it with the current state. The user asked for clean code and documentation, not just a visual review. It seems like I should update the docs to reflect the new daily PDF archive instead of the old description. Maybe editing the bb-004 status document and the backlog bullets will be enough. I'll inspect the relevant sections to see what needs to be changed.


# BB-004 rss-offline status — 2026-05-14

Current state for offline feed reading on `ballbox-first`.

## Outcome

Primary path now:

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

## Why the pivot happened

Previous cross-origin browser caching was not reliable enough for the main user goal:

- read recent content offline on phone
- avoid opening blogs one by one first

The weak point was cross-origin caching of external blog pages from mobile browsers.
That made offline hit rate inconsistent even after prefetch attempts.
The current builder now saves the fetched blog page HTML locally instead of a text-only extraction.

## What `rss-offline` does

- reads a curated feed list from local config
- fetches recent entries directly from feeds
- sorts entries by date descending
- downloads recent linked articles server-side
- stores a full local HTML copy of each fetched article page
- serves a static web UI at `/rss-offline/`
- makes the **local copy** the primary click target
- keeps a secondary link to the real/original blog
- registers a service worker under `/rss-offline/`
- precaches generated local article pages for better offline behavior
- runs a rebuild cron every 30 minutes

## Current files and paths

Project source:

- `/home/sebas/work/projects/rss-offline/`
- `/home/sebas/work/projects/rss-offline/feed-sources.json`
- `/home/sebas/work/projects/rss-offline/build.py`

Generated site:

- `/var/www/rss-offline/`
- `/var/www/rss-offline/index.html`
- `/var/www/rss-offline/entries.json`
- `/var/www/rss-offline/sw.js`
- `/var/www/rss-offline/offline.html`
- `/var/www/rss-offline/article-*.html`

Logs:

- `/home/sebas/runtime/rss-offline-build.log`

## Current behavior limits

- offline reliability is better for local copies than for real blog links
- Safari/iPhone caching still may not be perfect for every generated page every time
- some sites will still depend on remote assets for full visual fidelity, but the saved local page is much closer to the original than the old text-only copy
- no category/tag UX yet; the current product is a date-sorted list only
- local copies are raw saved pages with a small local banner, not per-site cleaned reader views

## Operational notes

Cron currently rebuilds every 30 minutes.
That means:

- new recent articles should appear automatically
- local copies should refresh automatically
- service worker cache version only changes when the generated file content changes

## Product direction for now

Treat `rss-offline` as the current best offline reading path.
Keep investing in `rss-offline` only if a future need appears for:

- better feed management UX
- multi-user reader features
- categorization/search heavier than the current simple offline list


### BB-004 - Self-hosted RSS/feed system for offline reading
- status: `researching`
- goal: build a feed/RSS reading system on this server to replace the current app and support preloaded offline reading
- why: better control, better offline use on flights, and integration with local portal/nginx improvements
- open_questions:
  - is `rss-offline` already good enough as the primary phone/offline reader, or does it still need a stronger local archive hit rate?
  - how much richer UX is worth adding beyond a date-sorted list plus local-copy/original-link split?
- next_step:
  - treat `rss-offline` as the primary experiment, measure whether the current local-copy workflow is sufficient, and only then decide whether to keep evolving it or fold feed management into it
- notes:
  - should be evaluated together with BB-003 service-worker / offline-cache strategy
  - constraint: no paid options
  - current primary path is `/rss-offline/`: static date-sorted recent entries, server-fetched local article copies, service worker under the same prefix, and local copy as the primary click target
  - reason for pivot: cross-origin mobile browser caching of external blog pages was too unreliable, even with prefetch attempts
  - current generated artifacts and limits are documented in `pi-config/docs/bb-004-rss-offline-status-2026-05-14.md`
  - minimal bar that already beats the current app: feed subscriptions/import, background refresh, unread/read state, folders/tags optional, article list + readable article view, save-for-offline/preload, cached visited articles, mobile-friendly web UI, multi-device sync through server state
  - nice-to-haves later: full-text extraction, search, saved highlights, send-to-reader/share target, email/newsletter ingestion


edit

The weak point was cross-origin caching of external blog pages from mobile browsers.
That made offline hit rate inconsistent even after prefetch attempts.
The current builder now saves the fetched blog page HTML locally instead of a text-only extraction.

The weak point was cross-origin caching of external blog pages from mobile browsers.
That made offline hit rate inconsistent even after prefetch attempts.
The current builder saves fetched blog pages locally and produces a daily stitched PDF archive.

- sorts entries by date descending
- downloads recent linked articles server-side
- stores a full local HTML copy of each fetched article page
- serves a static web UI at `/rss-offline/`
- makes the **local copy** the primary click target
- keeps a secondary link to the real/original blog
- registers a service worker under `/rss-offline/`
- precaches generated local article pages for better offline behavior
- runs a rebuild cron every 30 minutes

- sorts entries by date descending
- downloads recent linked articles server-side
- stores a full local HTML copy of each fetched article page
- builds one stitched PDF per day with a dated filename
- serves a static web UI at `/rss-offline/`
- shows a simple archive of generated PDFs
- registers a service worker under `/rss-offline/`
- precaches generated local article pages for better offline behavior
- runs a rebuild cron once per day

## Current behavior limits

- offline reliability is better for local copies than for real blog links
- Safari/iPhone caching still may not be perfect for every generated page every time
- some sites will still depend on remote assets for full visual fidelity, but the saved local page is much closer to the original than the old text-only copy
- no category/tag UX yet; the current product is a date-sorted list only
- local copies are raw saved pages with a small local banner, not per-site cleaned reader views

## Operational notes

Cron currently rebuilds every 30 minutes.
That means:

- new recent articles should appear automatically
- local copies should refresh automatically
- service worker cache version only changes when the generated file content changes

## Current behavior limits

- offline reliability is now centered on the generated PDFs
- Safari/iPhone caching still may not be perfect for every generated local HTML page every time
- some sites will still depend on remote assets for full visual fidelity, but the saved local page is much closer to the original than the old text-only copy
- no category/tag UX yet; the current product is a simple PDF archive
- local copies are raw saved pages with a small local banner, used as build inputs and fallback browsing

## Operational notes

Cron currently rebuilds once per day at 06:00.
That means:

- a new dated PDF should appear automatically each day when there is fresh content
- local article copies refresh during the daily build
- service worker cache version only changes when the generated file content changes


Successfully replaced 3 block(s) in /home/sebas/pi-config/docs/bb-004-rss-offline-status-2026-05-14.md.

    ...
 16 - avoid opening blogs one by one first
 17
 18 The weak point was cross-origin caching of external blog pages from mobile browsers.
 19 That made offline hit rate inconsistent even after prefetch attempts.
-20 The current builder now saves the fetched blog page HTML locally instead of a text-only extraction.
+20 The current builder saves fetched blog pages locally and produces a daily stitched PDF archive.
 21
 22 ## What `rss-offline` does
 23
 24 - reads a curated feed list from local config
 25 - fetches recent entries directly from feeds
 26 - sorts entries by date descending
 27 - downloads recent linked articles server-side
 28 - stores a full local HTML copy of each fetched article page
+29 - builds one stitched PDF per day with a dated filename
 29 - serves a static web UI at `/rss-offline/`
-30 - makes the **local copy** the primary click target
-31 - keeps a secondary link to the real/original blog
+31 - shows a simple archive of generated PDFs
 32 - registers a service worker under `/rss-offline/`
 33 - precaches generated local article pages for better offline behavior
-34 - runs a rebuild cron every 30 minutes
+34 - runs a rebuild cron once per day
 35
 36 ## Current files and paths
 37
 38 Project source:
    ...
 55 - `/home/sebas/runtime/rss-offline-build.log`
 56
 57 ## Current behavior limits
 58
-59 - offline reliability is better for local copies than for real blog links
-60 - Safari/iPhone caching still may not be perfect for every generated page every time
+59 - offline reliability is now centered on the generated PDFs
+60 - Safari/iPhone caching still may not be perfect for every generated local HTML page every time
 61 - some sites will still depend on remote assets for full visual fidelity, but the saved local page is much closer to the original than the old text-only copy
-62 - no category/tag UX yet; the current product is a date-sorted list only
-63 - local copies are raw saved pages with a small local banner, not per-site cleaned reader views
+62 - no category/tag UX yet; the current product is a simple PDF archive
+63 - local copies are raw saved pages with a small local banner, used as build inputs and fallback browsing
 64
 65 ## Operational notes
 66
-67 Cron currently rebuilds every 30 minutes.
+67 Cron currently rebuilds once per day at 06:00.
 68 That means:
 69
-70 - new recent articles should appear automatically
-71 - local copies should refresh automatically
+70 - a new dated PDF should appear automatically each day when there is fresh content
+71 - local article copies refresh during the daily build
 72 - service worker cache version only changes when the generated file content changes
 73
 74 ## Product direction for now
 75
    ...


- open_questions:
  - is `rss-offline` already good enough as the primary phone/offline reader, or does it still need a stronger local archive hit rate?
  - how much richer UX is worth adding beyond a date-sorted list plus local-copy/original-link split?
- next_step:
  - treat `rss-offline` as the primary experiment, measure whether the current local-copy workflow is sufficient, and only then decide whether keep evolving it or fold feed management into it
- notes:
  - should be evaluated together with BB-003 service-worker / offline-cache strategy
  - constraint: no paid options
  - current primary path is `/rss-offline/`: static date-sorted recent entries, server-fetched local article copies, service worker under the same prefix, and local copy as the primary click target
  - reason for pivot: cross-origin mobile browser caching of external blog pages was too unreliable, even with prefetch attempts
  - current generated artifacts and limits are documented in `pi-config/docs/bb-004-rss-offline-status-2026-05-14.md`

- open_questions:
  - is the current daily PDF archive enough as the main phone/offline workflow?
  - is any richer UX worth adding beyond a simple dated PDF archive?
- next_step:
  - treat `rss-offline` as the primary experiment, measure whether the daily PDF workflow is sufficient, and only then decide whether to keep evolving it or fold feed management into it
- notes:
  - should be evaluated together with BB-003 service-worker / offline-cache strategy
  - constraint: no paid options
  - current primary path is `/rss-offline/`: static PDF archive built from server-fetched local article copies, with a service worker under the same prefix
  - reason for pivot: cross-origin mobile browser caching of external blog pages was too unreliable, even with prefetch attempts
  - current generated artifacts and limits are documented in `pi-config/docs/bb-004-rss-offline-status-2026-05-14.md`


Could not find the exact text in /home/sebas/pi-config/docs/BACKLOG.md. The old text must match exactly including all whitespace and newlines.


- status: `researching`
- goal: build a feed/RSS reading system on this server to replace the current app and support preloaded offline reading
- why: better control, better offline use on flights, and integration with local portal/nginx improvements
- open_questions:
  - is `rss-offline` already good enough as the primary phone/offline reader, or does it still need a stronger local archive hit rate?
  - how much richer UX is worth adding beyond a date-sorted list plus local-copy/original-link split?
- next_step:
  - treat `rss-offline` as the primary experiment, measure whether the current local-copy workflow is sufficient, and only then decide whether to keep evolving it or fold feed management into it
- notes:
  - should be evaluated together with BB-003 service-worker / offline-cache strategy
  - constraint: no paid options
  - current primary path is `/rss-offline/`: static date-sorted recent entries, server-fetched local article copies, service worker under the same prefix, and local copy as the primary click target
  - reason for pivot: cross-origin mobile browser caching of external blog pages was too unreliable, even with prefetch attempts
  - current generated artifacts and limits are documented in `pi-config/docs/bb-004-rss-offline-status-2026-05-14.md`
  - minimal bar that already beats the current app: feed subscriptions/import, background refresh, unread/read state, folders/tags optional, article list + readable article view, save-for-offline/preload, cached visited articles, mobile-friendly web UI, multi-device sync through server state
  - nice-to-haves later: full-text extraction, search, saved highlights, send-to-reader/share target, email/newsletter ingestion


- open_questions:
  - is `rss-offline` already good enough as the primary phone/offline reader, or does it still need a stronger local archive hit rate?
  - how much richer UX is worth adding beyond a date-sorted list plus local-copy/original-link split?
- next_step:
  - treat `rss-offline` as the primary experiment, measure whether the current local-copy workflow is sufficient, and only then decide whether to keep evolving it or fold feed management into it
- notes:
  - should be evaluated together with BB-003 service-worker / offline-cache strategy
  - constraint: no paid options
  - current primary path is `/rss-offline/`: static date-sorted recent entries, server-fetched local article copies, service worker under the same prefix, and local copy as the primary click target
  - reason for pivot: cross-origin mobile browser caching of external blog pages was too unreliable, even with prefetch attempts
  - current generated artifacts and limits are documented in `pi-config/docs/bb-004-rss-offline-status-2026-05-14.md`

- open_questions:
  - is the current daily PDF archive enough as the primary phone/offline reader?
  - is any richer UX worth adding beyond a simple dated PDF archive?
- next_step:
  - treat `rss-offline` as the primary experiment, measure whether the daily PDF workflow is sufficient, and only then decide whether to keep evolving it or fold feed management into it
- notes:
  - should be evaluated together with BB-003 service-worker / offline-cache strategy
  - constraint: no paid options
  - current primary path is `/rss-offline/`: static PDF archive built from server-fetched local article copies, with a service worker under the same prefix
  - reason for pivot: cross-origin mobile browser caching of external blog pages was too unreliable, even with prefetch attempts
  - current generated artifacts and limits are documented in `pi-config/docs/bb-004-rss-offline-status-2026-05-14.md`


Successfully replaced 1 block(s) in /home/sebas/pi-config/docs/BACKLOG.md.

    ...
 54 - status: `researching`
 55 - goal: build a feed/RSS reading system on this server to replace the current app and support preloaded offline reading
 56 - why: better control, better offline use on flights, and integration with local portal/nginx improvements
 57 - open_questions:
-58   - is `rss-offline` already good enough as the primary phone/offline reader, or does it still need a stronger local archive hit rate?
-59   - how much richer UX is worth adding beyond a date-sorted list plus local-copy/original-link split?
+58   - is the current daily PDF archive enough as the primary phone/offline reader?
+59   - is any richer UX worth adding beyond a simple dated PDF archive?
 60 - next_step:
-61   - treat `rss-offline` as the primary experiment, measure whether the current local-copy workflow is sufficient, and only then decide whether to keep evolving it or fold feed management into it
+61   - treat `rss-offline` as the primary experiment, measure whether the daily PDF workflow is sufficient, and only then decide whether to keep evolving it or fold feed management into it
 62 - notes:
 63   - should be evaluated together with BB-003 service-worker / offline-cache strategy
 64   - constraint: no paid options
-65   - current primary path is `/rss-offline/`: static date-sorted recent entries, server-fetched local article copies, service worker under the same prefix, and local copy as the primary click target
+65   - current primary path is `/rss-offline/`: static PDF archive built from server-fetched local article copies, with a service worker under the same prefix
 66   - reason for pivot: cross-origin mobile browser caching of external blog pages was too unreliable, even with prefetch attempts
 67   - current generated artifacts and limits are documented in `pi-config/docs/bb-004-rss-offline-status-2026-05-14.md`
 68   - minimal bar that already beats the current app: feed subscriptions/import, background refresh, unread/read state, folders/tags optional, article list + readable article view, save-for-offline/preload, cached visited articles, mobile-friendly web UI, multi-device sync through server state
 69   - nice-to-haves later: full-text extraction, search, saved highlights, send-to-reader/share target, email/newsletter ingestion


bash

grep -RniE 'Descargar PDF|epub|latest\.pdf|local-copy/original-link split|local copy as the primary click target|real/original blog' /home/sebas/pi-config/docs /home/sebas/work/projects/rss-offline /var/www/portal /var/www/rss-offline 2>/dev/null | sed -n '1,220p'


/var/www/rss-offline/article-763d0e7f1d9c5469.html:34:<script type="application/ld+json">{"@context":"https:\/\/schema.org","headline":"Dungeons & Desktops: Building a procedurally generated roguelike with GitHub Copilot CLI","author":{"@type":"Person","name":"Lee Reilly"},"datePublished":"2026-05-12T08:00:00-07:00","abstract":"Learn how one Hubber used GitHub Copilot CLI to build an extension that turns any codebase into a unique, roguelike dungeon.  ","mainEntityOfPage":{"@type":"WebPage","@id":"https:\/\/github.blog\/ai-and-ml\/github-copilot\/dungeons-desktops-building-a-procedurally-generated-roguelike-with-github-copilot-cli\/"},"image":"https:\/\/github.blog\/wp-content\/uploads\/2026\/05\/586361988-07b052aa-cf75-4102-acfd-bc157c18ba32.gif?resize=150%2C150","@type":"TechArticle"}</script>
/var/www/rss-offline/article-763d0e7f1d9c5469.html:35:<script type="application/ld+json">{"@context":"https:\/\/schema.org","headline":"Dungeons & Desktops: Building a procedurally generated roguelike with GitHub Copilot CLI","author":{"@type":"Person","name":"Lee Reilly"},"datePublished":"2026-05-12T08:00:00-07:00","abstract":"Learn how one Hubber used GitHub Copilot CLI to build an extension that turns any codebase into a unique, roguelike dungeon.  ","mainEntityOfPage":{"@type":"WebPage","@id":"https:\/\/github.blog\/ai-and-ml\/github-copilot\/dungeons-desktops-building-a-procedurally-generated-roguelike-with-github-copilot-cli\/"},"image":"https:\/\/github.blog\/wp-content\/uploads\/2026\/05\/586361988-07b052aa-cf75-4102-acfd-bc157c18ba32.gif?resize=150%2C150","@type":"BlogPosting"}</script>
/var/www/rss-offline/article-763d0e7f1d9c5469.html:36:<script class="yoast-schema-graph" type="application/ld+json">{"@context":"https:\/\/schema.org","@graph":[{"@type":"Article","@id":"https:\/\/github.blog\/ai-and-ml\/github-copilot\/dungeons-desktops-building-a-procedurally-generated-roguelike-with-github-copilot-cli\/#article","isPartOf":{"@id":"https:\/\/github.blog\/ai-and-ml\/github-copilot\/dungeons-desktops-building-a-procedurally-generated-roguelike-with-github-copilot-cli\/"},"author":[{"@id":"https:\/\/github.blog\/#\/schema\/person\/21fa3ae5aac7d4dd50baf74154006d86"}],"headline":"Dungeons & Desktops: Building a procedurally generated roguelike with GitHub Copilot CLI","datePublished":"2026-05-12T15:00:00+00:00","mainEntityOfPage":{"@id":"https:\/\/github.blog\/ai-and-ml\/github-copilot\/dungeons-desktops-building-a-procedurally-generated-roguelike-with-github-copilot-cli\/"},"wordCount":1292,"image":{"@id":"https:\/\/github.blog\/ai-and-ml\/github-copilot\/dungeons-desktops-building-a-procedurally-generated-roguelike-with-github-copilot-cli\/#primaryimage"},"thumbnailUrl":"https:\/\/github.blog\/wp-content\/uploads\/2026\/05\/586361988-07b052aa-cf75-4102-acfd-bc157c18ba32.gif?fit=1920%2C1080","keywords":["gaming","GitHub Copilot","GitHub Copilot CLI"],"articleSection":["AI & ML","Application development","Gaming","GitHub Copilot"],"inLanguage":"en-US"},{"@type":"WebPage","@id":"https:\/\/github.blog\/ai-and-ml\/github-copilot\/dungeons-desktops-building-a-procedurally-generated-roguelike-with-github-copilot-cli\/","url":"https:\/\/github.blog\/ai-and-ml\/github-copilot\/dungeons-desktops-building-a-procedurally-generated-roguelike-with-github-copilot-cli\/","name":"Dungeons & Desktops: Building a procedurally generated roguelike with GitHub Copilot CLI - The GitHub Blog","isPartOf":{"@id":"https:\/\/github.blog\/#website"},"primaryImageOfPage":{"@id":"https:\/\/github.blog\/ai-and-ml\/github-copilot\/dungeons-desktops-building-a-procedurally-generated-roguelike-with-github-copilot-cli\/#primaryimage"},"image":{"@id":"https:\/\/github.blog\/ai-and-ml\/github-copilot\/dungeons-desktops-building-a-procedurally-generated-roguelike-with-github-copilot-cli\/#primaryimage"},"thumbnailUrl":"https:\/\/github.blog\/wp-content\/uploads\/2026\/05\/586361988-07b052aa-cf75-4102-acfd-bc157c18ba32.gif?fit=1920%2C1080","datePublished":"2026-05-12T15:00:00+00:00","author":{"@id":"https:\/\/github.blog\/#\/schema\/person\/21fa3ae5aac7d4dd50baf74154006d86"},"description":"Learn how one Hubber used GitHub Copilot CLI to build an extension that turns any codebase into a unique, roguelike dungeon.","breadcrumb":{"@id":"https:\/\/github.blog\/ai-and-ml\/github-copilot\/dungeons-desktops-building-a-procedurally-generated-roguelike-with-github-copilot-cli\/#breadcrumb"},"inLanguage":"en-US","potentialAction":[{"@type":"ReadAction","target":["https:\/\/github.blog\/ai-and-ml\/github-copilot\/dungeons-desktops-building-a-procedurally-generated-roguelike-with-github-copilot-cli\/"]}]},{"@type":"ImageObject","inLanguage":"en-US","@id":"https:\/\/github.blog\/ai-and-ml\/github-copilot\/dungeons-desktops-building-a-procedurally-generated-roguelike-with-github-copilot-cli\/#primaryimage","url":"https:\/\/github.blog\/wp-content\/uploads\/2026\/05\/586361988-07b052aa-cf75-4102-acfd-bc157c18ba32.gif?fit=1920%2C1080","contentUrl":"https:\/\/github.blog\/wp-content\/uploads\/2026\/05\/586361988-07b052aa-cf75-4102-acfd-bc157c18ba32.gif?fit=1920%2C1080","width":1920,"height":1080,"caption":"Decorative header image that says 'Dungeons & Desktops: Building a procedurally generated Roguelike with GitHub Copilot CLI'"},{"@type":"BreadcrumbList","@id":"https:\/\/github.blog\/ai-and-ml\/github-copilot\/dungeons-desktops-building-a-procedurally-generated-roguelike-with-github-copilot-cli\/#breadcrumb","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"https:\/\/github.blog\/"},{"@type":"ListItem","position":2,"name":"AI & ML","item":"https:\/\/github.blog\/ai-and-ml\/"},{"@type":"ListItem","position":3,"name":"GitHub Copilot","item":"https:\/\/github.blog\/ai-and-ml\/github-copilot\/"},{"@type":"ListItem","position":4,"name":"Dungeons & Desktops: Building a procedurally generated roguelike with GitHub Copilot CLI"}]},{"@type":"WebSite","@id":"https:\/\/github.blog\/#website","url":"https:\/\/github.blog\/","name":"The GitHub Blog","description":"Updates, ideas, and inspiration from GitHub to help developers build and design software.","potentialAction":[{"@type":"SearchAction","target":{"@type":"EntryPoint","urlTemplate":"https:\/\/github.blog\/?s={search_term_string}"},"query-input":{"@type":"PropertyValueSpecification","valueRequired":true,"valueName":"search_term_string"}}],"inLanguage":"en-US"},{"@type":"Person","@id":"https:\/\/github.blog\/#\/schema\/person\/21fa3ae5aac7d4dd50baf74154006d86","name":"Lee Reilly","image":{"@type":"ImageObject","inLanguage":"en-US","@id":"https:\/\/secure.gravatar.com\/avatar\/66ce0fc5d26ebbe4627e038131204dd34a1db6f021aafdb6d699b9e6c65ca883?s=96&d=mm&r=g828d591da5cdb8199f87211a7731d857","url":"https:\/\/secure.gravatar.com\/avatar\/66ce0fc5d26ebbe4627e038131204dd34a1db6f021aafdb6d699b9e6c65ca883?s=96&d=mm&r=g","contentUrl":"https:\/\/secure.gravatar.com\/avatar\/66ce0fc5d26ebbe4627e038131204dd34a1db6f021aafdb6d699b9e6c65ca883?s=96&d=mm&r=g","caption":"Lee Reilly"},"description":"Senior Program Manager, GitHub Developer Relations. Open source hype man, AI whisperer, hackathon and game jam wrangler. I write && manage programs, support dev communities, and occasionally ship something.","sameAs":["https:\/\/leereilly.net","https:\/\/www.facebook.com\/lee337reilly","https:\/\/x.com\/leereilly"],"url":"https:\/\/github.blog\/author\/leereilly\/"}]}</script>

[Showing lines 90-93 of 93 (50.0KB limit). Full output: /tmp/pi-bash-060e7138a3e38bea.log]


**Searching and validating content**

I’m looking into a noisy search that includes the term "Building," which is fine. I might need to regenerate the index, but it seems like there’s currently no clear call to action. Should I run a build? Maybe not; I’ll check the documentation to validate my assumptions, but it’s worth noting there’s no grep in the source or documentation. I need to ensure everything is clear and functional.

grep -RniE 'epub|latest\.pdf|Descargar PDF|local-copy/original-link split|local copy as the primary click target|real/original blog' /home/sebas/pi-config/docs /home/sebas/work/projects/rss-offline /var/www/portal 2>/dev/null | sed -n '1,220p'

grep -nE 'Descargar PDF' /var/www/rss-offline/index.html || true; crontab -l | grep 'rss-offline/build.py'; ls -1 /var/www/rss-offline/*.pdf 2>/dev/null


/home/sebas/pi-config/docs/PORTAL-SYSTEM-RUNBOOK.md:63:Si hay cambios en el Svelte y hace falta republicar el build:
/home/sebas/pi-config/docs/PORTAL-SYSTEM-RUNBOOK.md:87:## Cómo republicar por Tailscale


0 6 * * * sudo /home/sebas/work/projects/rss-offline/build.py >> /home/sebas/runtime/rss-offline-build.log 2>&1
/var/www/rss-offline/rss-offline-2026-05-26_05-00.pdf


todo

top PDF CTA removed from deployed index; build.py dead EPUB code/imports removed and compiles; rss-offline docs updated to daily PDF archive model; cron still daily 06:00; only dated PDF remains


Updated #3 (in_progress → completed)

Make rss-offline exports use dated filenames instead of overwriting latest

Update rss-offline builder so generated PDF/EPUB artifacts are named with current date/time and previous exports are preserved. Keep behavior truthful and verify output paths.

py_compile ok; helper validation confirmed dated EPUB/PDF output paths like /rss-offline/rss-offline-2026-05-26_10-15.{epub,pdf}

Switch rss-offline to daily dated PDFs with nginx home navigation; remove EPUB and per-feed links

Change cron from every 30 minutes to daily, keep dated PDF generation with navigation from nginx home, remove EPUB generation/output, and remove feed/article listing links as requested.

updating rss-offline delivery and navigation

cron changed to daily 06:00; portal home card updated; rss-offline index now lists dated PDFs only; EPUB files removed; old same-day duplicate PDFs pruned; build.py compiles

Remove top PDF CTA and clean rss-offline code/docs fully

Remove the initial download CTA from rss-offline index and clean remaining code/docs/config references so the system is truly PDF-archive-only, not just visually adjusted.


Hecho.

Limpié de verdad, no solo visual:

- saqué el bloque inicial de
  “descargar pdf”
- limpié `build.py`:
  - fuera código EPUB muerto
  - fuera imports muertos
  - fuera refs viejas
- actualicé docs:
  - `bb-004-rss-offline-status-2026-05-14.md`
  - `BACKLOG.md`
- validé:
  - `build.py` compila
  - cron sigue diario `06:00`
  - queda solo:
    `/var/www/rss-offline/rss-offline-2026-05-26_05-00.pdf`

Ahora `/rss-offline/` queda solo
como archivo limpio de PDFs.


[telegram] docs son mas grandes de lo deseado todavia.
lee alguno para encontrar low hanging fruit de cosas que NO extraer de los feed
o explorar otro formato que sea tan visualmente fiel como el pdf


**Exploring document options**

I need to come up with a way to review the documents the user mentioned, possibly focusing on things we shouldn't extract from the feeds. It might be helpful to inspect both online and local docs, particularly looking at PDF formats. I should probably do some research and check the build logic to identify any simplifications. Let's quickly read through relevant documents or the repo for insights on low-hanging fruit.

Find low-hanging simplifications for rss-offline fidelity/size

Review current rss-offline docs/code to identify easy wins: content/assets that should not be extracted from feeds, and alternative output formats with PDF-like visual fidelity.


Created #4: Find low-hanging simplifications for rss-offline fidelity/size (pending)


Updated #4 (pending → in_progress)


# BB-004 rss-offline status — 2026-05-14

Current state for offline feed reading on `ballbox-first`.

## Outcome

Primary path now:

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

## Why the pivot happened

Previous cross-origin browser caching was not reliable enough for the main user goal:

- read recent content offline on phone
- avoid opening blogs one by one first

The weak point was cross-origin caching of external blog pages from mobile browsers.
That made offline hit rate inconsistent even after prefetch attempts.
The current builder saves fetched blog pages locally and produces a daily stitched PDF archive.

## What `rss-offline` does

- reads a curated feed list from local config
- fetches recent entries directly from feeds
- sorts entries by date descending
- downloads recent linked articles server-side
- stores a full local HTML copy of each fetched article page
- builds one stitched PDF per day with a dated filename
- serves a static web UI at `/rss-offline/`
- shows a simple archive of generated PDFs
- registers a service worker under `/rss-offline/`
- precaches generated local article pages for better offline behavior
- runs a rebuild cron once per day

## Current files and paths

Project source:

- `/home/sebas/work/projects/rss-offline/`
- `/home/sebas/work/projects/rss-offline/feed-sources.json`
- `/home/sebas/work/projects/rss-offline/build.py`

Generated site:

- `/var/www/rss-offline/`
- `/var/www/rss-offline/index.html`
- `/var/www/rss-offline/entries.json`
- `/var/www/rss-offline/sw.js`
- `/var/www/rss-offline/offline.html`
- `/var/www/rss-offline/article-*.html`

Logs:

- `/home/sebas/runtime/rss-offline-build.log`

## Current behavior limits

- offline reliability is now centered on the generated PDFs
- Safari/iPhone caching still may not be perfect for every generated local HTML page every time
- some sites will still depend on remote assets for full visual fidelity, but the saved local page is much closer to the original than the old text-only copy
- no category/tag UX yet; the current product is a simple PDF archive
- local copies are raw saved pages with a small local banner, used as build inputs and fallback browsing

## Operational notes

Cron currently rebuilds once per day at 06:00.
That means:

- a new dated PDF should appear automatically each day when there is fresh content
- local article copies refresh during the daily build
- service worker cache version only changes when the generated file content changes

## Product direction for now

Treat `rss-offline` as the current best offline reading path.
Keep investing in `rss-offline` only if a future need appears for:

- better feed management UX
- multi-user reader features
- categorization/search heavier than the current simple offline list


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, 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"])}] · '
        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:

[130 more lines in file. Use offset=380 to continue.]


Low-hanging cuts: stop mirroring scripts/fonts/source tags; drop local HTML/service-worker path if product is PDF-only; cap hero images; prefer MHTML/saved-page bundle only if exploring a PDF-alternative with high visual fidelity.


Updated #4 (in_progress → completed)


Sí. Leí estado + builder.
Low hanging fruit claros:

No extraer:
- `script`
- `source`
- `video`
- `audio`
- fuentes:
  `woff`, `woff2`, `ttf`
- assets grandes no visibles
- overlays/modals ya se limpian;
  ampliar eso a trackers/embeds

Motivo:
- hoy el producto final es PDF
- esos assets pesan mucho
- suben fragilidad
- casi no ayudan al PDF final

Más fuerte:
- si el producto ya es PDF-only,
  `article-*.html` + `sw.js` +
  precache son casi deuda
- hoy se siguen generando
  solo para llegar al PDF
- mayor low hanging fruit real:
  pipeline más corto hacia PDF

Formato alternativo tan fiel como PDF:
- mejor candidato: `MHTML`
- guarda la página casi entera
- mucho más fiel que extraer feed
- más “web real” que PDF

Pero:
- peor compatibilidad/UX que PDF
- en iPhone no confío tanto
- PDF sigue siendo mejor entrega final

Mi take:
1. no extraer scripts/fuentes/media
2. recortar assets a CSS + imágenes
3. evaluar luego si matar por completo
   la capa `article-*.html` pública
4. si querés explorar alternativa,
   probar `MHTML`, no EPUB

Si querés, hago el pass ahora:
- poda de assets
- menos peso
- menos ruido
- sin cambiar el output final PDF.


[telegram] dale. y mhtml despues, y comparamos tamaños (vos) y fidelidad (yo) de ambos


Prune rss-offline asset extraction and prep later MHTML comparison

Reduce rss-offline builder weight by stopping extraction of non-essential assets for PDF workflow. Leave MHTML exploration for later, then compare sizes here and fidelity by user review.


Created #5: Prune rss-offline asset extraction and prep later MHTML comparison (pending)


Updated #5 (pending → in_progress)


edit

ALLOWED_ASSET_EXTENSIONS = ('.css', '.js', '.mjs', '.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.ico', '.woff', '.woff2', '.ttf')

ALLOWED_ASSET_EXTENSIONS = ('.css', '.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.ico')

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

    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


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

     ...
  27 RENDER_BUDGET_MS = int(os.environ.get('RSS_OFFLINE_RENDER_BUDGET_MS', '8000'))
  28 MAX_RENDERED_ENTRIES = int(os.environ.get('RSS_OFFLINE_MAX_RENDERED_ENTRIES', '8'))
  29 MAX_ASSETS_PER_PAGE = int(os.environ.get('RSS_OFFLINE_MAX_ASSETS_PER_PAGE', '24'))
  30 MAX_ASSET_BYTES = int(os.environ.get('RSS_OFFLINE_MAX_ASSET_BYTES', '1500000'))
- 31 ALLOWED_ASSET_EXTENSIONS = ('.css', '.js', '.mjs', '.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.ico', '.woff', '.woff2', '.ttf')
+ 31 ALLOWED_ASSET_EXTENSIONS = ('.css', '.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.ico')
  32 CHROMIUM_BIN = os.environ.get('RSS_OFFLINE_CHROMIUM_BIN', '/usr/bin/chromium')
  33 PDF_DIRNAME = os.environ.get('RSS_OFFLINE_PDF_DIRNAME', 'pdf')
  34 PDF_MAX_ARTICLES = int(os.environ.get('RSS_OFFLINE_PDF_MAX_ARTICLES', '8'))
  35 PDF_BASENAME = os.environ.get('RSS_OFFLINE_PDF_BASENAME', 'rss-offline')
     ...
 118
 119
 120 def should_keep_asset(node, parsed):
 121     path_lower = parsed.path.lower()
-122     if any(path_lower.endswith(ext) for ext in ALLOWED_ASSET_EXTENSIONS):
-123         return True
 124     if node.name == 'link' and 'stylesheet' in ' '.join(node.get('rel', [])).lower():
 125         return True
-126     if node.name == 'script' and parsed.path:
-127         return True
-128     return False
+124     if node.name != 'img':
+125         return False
+126     return any(path_lower.endswith(ext) for ext in ALLOWED_ASSET_EXTENSIONS)
 129
 130
 131 def mirror_assets(entry, soup, entry_key: str):
 132     page_origin = urlparse(entry['link']).netloc
     ...
 134     total_bytes = 0
 135     count = 0
 136
 137     def candidates():
-138         for tag, attr in [('link', 'href'), ('script', 'src'), ('img', 'src'), ('source', 'src')]:
+136         for tag, attr in [('link', 'href'), ('img', 'src')]:
 139             for node in soup.find_all(tag):
 140                 ref = node.get(attr)
 141                 if ref:
 142                     yield node, attr, ref
     ...


bash

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

python3 - <<'PY'
import importlib.util
spec=importlib.util.spec_from_file_location('rss_build','/home/sebas/work/projects/rss-offline/build.py')
mod=importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
print(mod.ALLOWED_ASSET_EXTENSIONS)
PY


(no output)


('.css', '.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.ico')


sudo /home/sebas/work/projects/rss-offline/build.py >> /home/sebas/runtime/rss-offline-build.log 2>&1 && tail -n 40 /home/sebas/runtime/rss-offline-build.log && ls -lh /var/www/rss-offline/rss-offline-*.pdf | tail -n 5


OK rendered https://www.digitalocean.com/blog/request-based-autoscaling-app-platform
OK rendered https://github.blog/ai-and-ml/github-copilot/github-recognized-as-a-leader-in-the-gartner-magic-quadrant-for-enterprise-ai-coding-agents-for-the-third-year-in-a-row/
OK rendered https://simonw.substack.com/p/datasette-agent-an-ai-assistant-for
OK rendered https://tailscale.com/changelog/#2026-05-22-service
OK simple https://github.blog/open-source/gaming/beyond-the-engine-10-open-source-projects-shaping-how-games-actually-get-made/
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
OK simple https://github.blog/ai-and-ml/github-copilot/building-a-general-purpose-accessibility-agent-and-what-we-learned-in-the-process/
OK simple https://github.blog/security/raising-the-bar-quality-shared-responsibility-and-the-future-of-githubs-bug-bounty-program/
OK simple https://supernuclear.substack.com/p/repost-meet-cheryl-your-coliving
OK simple https://tailscale.com/changelog/#2026-05-15-client
OK simple https://tailscale.com/changelog/#2026-05-15-service
OK simple https://openclaw.ai/blog/where-openclaw-security-is-heading/
OK simple https://github.blog/news-insights/company-news/github-availability-report-april-2026/
OK simple https://github.blog/engineering/architecture-optimization/from-latency-to-instant-modernizing-github-issues-navigation-performance/
OK simple https://gomakethings.com/we-are-miracles/
OK simple https://leadershipintech.com/newsletters/2277-before-you-fire-all-your-glue-people-because-of-ai
OK simple https://tailscale.com/changelog/#2026-05-14-service
OK simple https://martinalderson.com/posts/managed-agents-are-the-new-lambda/?utm_source=rss&utm_medium=rss&utm_campaign=feed
OK simple https://openclaw.ai/blog/openai-models-in-openclaw-done-right/
OK simple https://www.digitalocean.com/blog/your-infrastructure-matters
OK simple https://tailscale.com/changelog/#2026-05-13-client
PDF_OK https://world.hey.com/dhh/basecamp-five-8fcfd2ef
PDF_OK https://github.blog/developer-skills/github/github-for-beginners-getting-started-with-git-and-github-in-vs-code/
PDF_OK https://leadershipintech.com/newsletters/2286-barely-treading-water
PDF_OK https://lucumr.pocoo.org/2026/5/24/pi-oss/
PDF_OK https://www.digitalocean.com/blog/request-based-autoscaling-app-platform
PDF_OK https://github.blog/ai-and-ml/github-copilot/github-recognized-as-a-leader-in-the-gartner-magic-quadrant-for-enterprise-ai-coding-agents-for-the-third-year-in-a-row/
PDF_OK https://simonw.substack.com/p/datasette-agent-an-ai-assistant-for
PDF_OK https://tailscale.com/changelog/#2026-05-22-service
-rw-r--r-- 1 root root  12M May 26 05:03 /var/www/rss-offline/rss-offline-2026-05-26_05-00.pdf
-rw-r--r-- 1 root root 3.8M May 26 09:05 /var/www/rss-offline/rss-offline-2026-05-26.pdf


todo

asset mirroring pruned to stylesheets + img only; scripts/fonts/source tags no longer mirrored; build.py compiles; fresh rebuild produced /var/www/rss-offline/rss-offline-2026-05-26.pdf at 3.8M vs older 12M same-day PDF


Updated #5 (in_progress → completed)


Hecho.

Podé la extracción:
- no más `script`
- no más `source`
- no más fuentes
- quedan:
  - CSS
  - imágenes

Validado con rebuild real.

Resultado:
- PDF viejo de hoy:
  `rss-offline-2026-05-26_05-00.pdf`
  ≈ `12M`
- PDF nuevo podado:
  `rss-offline-2026-05-26.pdf`
  ≈ `3.8M`

Baja fuerte de peso.

Próximo después:
- hago variante `MHTML`
- yo comparo tamaños
- vos comparás fidelidad visual.


[telegram] si


**Considering user confirmation**

I see that the user said "yes," so I might proceed with the task. It seems like I don’t necessarily need tools for this. I should think about creating a to-do and potentially getting started. However, I wonder if the user is just giving consent without needing immediate action. It’s important for me to clarify this before going ahead. Keeping communication open could help!

Build MHTML variant for rss-offline and compare size vs PDF

Add an MHTML-based export path for rss-offline so we can compare output size here and visual fidelity by user review later.


Created #6: Build MHTML variant for rss-offline and compare size vs PDF (pending)


Updated #6 (pending → in_progress)


Voy con MHTML ahora.


[telegram] pudiste?


**Checking answer status**

I need to check the status of the task. It seems like nothing may have been done yet, so I should be honest about that. I wonder if I can work on it now. The user is asking if it's done, but I might need to execute some steps first. I think I can begin by inspecting the tools and checking if there's support for saving the page as MHTML. Let's get started on this!

bash

chromium --help 2>/dev/null | grep -i mhtml | sed -n '1,40p'

grep -n 'build_pdf_pack\|list_pdf_archive\|if __name__' -n /home/sebas/work/projects/rss-offline/build.py | sed -n '1,80p'


357:def build_pdf_pack(entries, build_stamp: str):
391:def list_pdf_archive():
481:    pdf_info = build_pdf_pack(archived, build_stamp)
490:    pdf_archive = list_pdf_archive()
505:if __name__ == '__main__':


python3 - <<'PY'
import importlib.util
mods=['websockets','websocket','requests']
for m in mods:
    print(m, importlib.util.find_spec(m) is not None)
PY

node -e "try{require('playwright'); console.log('playwright yes')}catch(e){console.log('playwright no')}"


websockets True
websocket False
requests True


playwright no


**Prototyping with websockets**

I'm considering using Python with websockets alongside Chrome's remote debugging. It seems like implementing a helper script could be a one-off task. Maybe I should start by prototyping it manually, focusing on just one page to see how it works out. This way, I can test the functionality before scaling it up. Let’s explore if a smaller approach will yield good results!

python3 - <<'PY'
import asyncio, json, subprocess, tempfile, time, urllib.request
from pathlib import Path
import websockets

url='https://example.com'
out=Path('/tmp/test-page.mhtml')
port=9223
proc=subprocess.Popen(['/usr/bin/chromium','--headless=new','--disable-gpu','--no-sandbox',f'--remote-debugging-port={port}','about:blank'],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
try:
    for _ in range(50):
        try:
            with urllib.request.urlopen(f'http://127.0.0.1:{port}/json/version', timeout=1) as r:
                info=json.load(r)
            break
        except Exception:
            time.sleep(0.2)
    else:
        raise RuntimeError('chrome not up')
    ws_url=info['webSocketDebuggerUrl']
    async def run():
        async with websockets.connect(ws_url, max_size=None) as ws:
            msg_id=0
            async def send(method, params=None):
                nonlocal msg_id
                msg_id+=1
                await ws.send(json.dumps({'id':msg_id,'method':method,'params':params or {}}))
                while True:
                    raw=await ws.recv()
                    data=json.loads(raw)
                    if data.get('id')==msg_id:
                        return data
            print(await send('Target.createTarget', {'url':'about:blank'}))
    asyncio.run(run())
finally:
    proc.terminate(); proc.wait()
PY


{'id': 1, 'result': {'targetId': '667F881CD1E4C7C33FFB7C16298C15CE'}}


**Crafting websocket connection**

I need to attach to a target websocket using the target ID. I'll consider using /json/list, but it might be easier to use Browser.getVersion. Actually, I think the websocket is meant for the browser target. So, I should use Target.attachToTarget and then send messages through Target.sendMessageToTarget? This might be simpler if I use flatten. Let’s get to it!

python3 - <<'PY'
import asyncio, json, subprocess, time, urllib.request
from pathlib import Path
import websockets

port=9224
proc=subprocess.Popen(['/usr/bin/chromium','--headless=new','--disable-gpu','--no-sandbox',f'--remote-debugging-port={port}','about:blank'],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
try:
    for _ in range(50):
        try:
            with urllib.request.urlopen(f'http://127.0.0.1:{port}/json/version', timeout=1) as r:
                info=json.load(r)
            break
        except Exception:
            time.sleep(0.2)
    async def run():
        async with websockets.connect(info['webSocketDebuggerUrl'], max_size=None) as ws:
            next_id=0
            async def cmd(method, params=None, sessionId=None):
                nonlocal next_id
                next_id+=1
                payload={'id':next_id,'method':method,'params':params or {}}
                if sessionId:
                    payload['sessionId']=sessionId
                await ws.send(json.dumps(payload))
                while True:
                    data=json.loads(await ws.recv())
                    if data.get('id')==next_id:
                        return data
            res=await cmd('Target.createTarget', {'url':'about:blank'})
            tid=res['result']['targetId']
            res=await cmd('Target.attachToTarget', {'targetId':tid,'flatten':True})
            sid=res['result']['sessionId']
            print('sid',sid)
            print(await cmd('Page.enable', sessionId=sid))
            print(await cmd('Page.navigate', {'url':'https://example.com'}, sessionId=sid))
            # wait a bit
            for _ in range(20):
                data=json.loads(await ws.recv())
                if data.get('method')=='Page.loadEventFired':
                    print('loaded'); break
            snap=await cmd('Page.captureSnapshot', {'format':'mhtml'}, sessionId=sid)
            print('keys', snap.keys(), snap.get('result',{}).keys())
            print(str(snap['result']['data'])[:80])
    asyncio.run(run())
finally:
    proc.terminate(); proc.wait()
PY


Command timed out after 120 seconds


**Improving event handling**

I realize I need to enhance my event handling since `ws.recv` is blocking. I’m thinking of using polling with a timeout instead. Maybe I can simplify the process by applying a fixed sleep time after navigating, then capturing subsequent events. This could streamline things and reduce any complications caused by blocking calls. Let’s give it a try and see how that works out!

python3 - <<'PY'
import asyncio, json, subprocess, time, urllib.request
import websockets

port=9225
proc=subprocess.Popen(['/usr/bin/chromium','--headless=new','--disable-gpu','--no-sandbox',f'--remote-debugging-port={port}','about:blank'],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
try:
    for _ in range(50):
        try:
            with urllib.request.urlopen(f'http://127.0.0.1:{port}/json/version', timeout=1) as r:
                info=json.load(r)
            break
        except Exception:
            time.sleep(0.2)
    async def run():
        async with websockets.connect(info['webSocketDebuggerUrl'], max_size=None) as ws:
            next_id=0
            pending={}
            async def receiver():
                while True:
                    data=json.loads(await ws.recv())
                    msg_id=data.get('id')
                    if msg_id in pending:
                        pending[msg_id].set_result(data)
            recv_task=asyncio.create_task(receiver())
            async def cmd(method, params=None, sessionId=None):
                nonlocal next_id
                next_id+=1
                fut=asyncio.get_running_loop().create_future()
                pending[next_id]=fut
                payload={'id':next_id,'method':method,'params':params or {}}
                if sessionId:
                    payload['sessionId']=sessionId
                await ws.send(json.dumps(payload))
                res=await asyncio.wait_for(fut, 30)
                pending.pop(next_id, None)
                return res
            tid=(await cmd('Target.createTarget', {'url':'about:blank'}))['result']['targetId']
            sid=(await cmd('Target.attachToTarget', {'targetId':tid,'flatten':True}))['result']['sessionId']
            await cmd('Page.enable', sessionId=sid)
            await cmd('Page.navigate', {'url':'https://example.com'}, sessionId=sid)
            await asyncio.sleep(5)
            snap=await cmd('Page.captureSnapshot', {'format':'mhtml'}, sessionId=sid)
            print(snap['result']['data'][:120])
            recv_task.cancel()
    asyncio.run(run())
finally:
    proc.terminate(); proc.wait()
PY


Traceback (most recent call last):
  File "/usr/lib/python3.13/asyncio/tasks.py", line 507, in wait_for
    return await fut
           ^^^^^^^^^
asyncio.exceptions.CancelledError

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "<stdin>", line 45, in <module>
  File "/usr/lib/python3.13/asyncio/runners.py", line 195, in run
    return runner.run(main)
           ~~~~~~~~~~^^^^^^
  File "/usr/lib/python3.13/asyncio/runners.py", line 118, in run
    return self._loop.run_until_complete(task)
           ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^
  File "/usr/lib/python3.13/asyncio/base_events.py", line 725, in run_until_complete
    return future.result()
           ~~~~~~~~~~~~~^^
  File "<stdin>", line 40, in run
  File "<stdin>", line 34, in cmd
  File "/usr/lib/python3.13/asyncio/tasks.py", line 506, in wait_for
    async with timeouts.timeout(timeout):
               ~~~~~~~~~~~~~~~~^^^^^^^^^
  File "/usr/lib/python3.13/asyncio/timeouts.py", line 116, in __aexit__
    raise TimeoutError from exc_val
TimeoutError
Task exception was never retrieved
future: <Task finished name='Task-3' coro=<run.<locals>.receiver() done, defined at <stdin>:18> exception=ConnectionClosedOK(Close(code=1000, reason=''), Close(code=1000, reason=''), False)>
Traceback (most recent call last):
  File "<stdin>", line 20, in receiver
  File "/usr/lib/python3/dist-packages/websockets/asyncio/connection.py", line 322, in recv
websockets.exceptions.ConnectionClosedOK: sent 1000 (OK); then received 1000 (OK)

Command exited with code 1


**Exploring browser behavior**

I’m considering whether the browser WebSocket closes, especially if Chromium exits without a target. It’s possible that using remote debugging with a headless instance and about:blank can get a bit strange. I think it might be easier to use /json/new to go about this. By focusing on HTTP endpoints, I can create a target and get the page WebSocket. This could simplify our approach.

python3 - <<'PY'
import asyncio, json, subprocess, time, urllib.request, urllib.parse
import websockets

port=9226
proc=subprocess.Popen(['/usr/bin/chromium','--headless=new','--disable-gpu','--no-sandbox',f'--remote-debugging-port={port}'],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
try:
    for _ in range(50):
        try:
            with urllib.request.urlopen(f'http://127.0.0.1:{port}/json/version', timeout=1) as r:
                _=json.load(r)
            break
        except Exception:
            time.sleep(0.2)
    with urllib.request.urlopen(f'http://127.0.0.1:{port}/json/new?{urllib.parse.quote("https://example.com", safe="")}', timeout=5) as r:
        target=json.load(r)
    wsurl=target['webSocketDebuggerUrl']
    async def run():
        async with websockets.connect(wsurl, max_size=None) as ws:
            next_id=0
            async def cmd(method, params=None):
                nonlocal next_id
                next_id += 1
                await ws.send(json.dumps({'id':next_id,'method':method,'params':params or {}}))
                while True:
                    data=json.loads(await ws.recv())
                    if data.get('id') == next_id:
                        return data
            await cmd('Page.enable')
            await asyncio.sleep(5)
            snap=await cmd('Page.captureSnapshot', {'format':'mhtml'})
            print(snap['result']['data'][:120])
    asyncio.run(run())
finally:
    proc.terminate(); proc.wait()
PY


Traceback (most recent call last):
  File "<stdin>", line 14, in <module>
  File "/usr/lib/python3.13/urllib/request.py", line 189, in urlopen
    return opener.open(url, data, timeout)
           ~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^
  File "/usr/lib/python3.13/urllib/request.py", line 495, in open
    response = meth(req, response)
  File "/usr/lib/python3.13/urllib/request.py", line 604, in http_response
    response = self.parent.error(
        'http', request, response, code, msg, hdrs)
  File "/usr/lib/python3.13/urllib/request.py", line 533, in error
    return self._call_chain(*args)
           ~~~~~~~~~~~~~~~~^^^^^^^
  File "/usr/lib/python3.13/urllib/request.py", line 466, in _call_chain
    result = func(*args)
  File "/usr/lib/python3.13/urllib/request.py", line 613, in http_error_default
    raise HTTPError(req.full_url, code, msg, hdrs, fp)
urllib.error.HTTPError: HTTP Error 405: Method Not Allowed

Command exited with code 1


python3 - <<'PY'
import asyncio, json, subprocess, time, urllib.request
import websockets

port=9227
proc=subprocess.Popen(['/usr/bin/chromium','--headless=new','--disable-gpu','--no-sandbox',f'--remote-debugging-port={port}','about:blank'],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
try:
    for _ in range(50):
        try:
            with urllib.request.urlopen(f'http://127.0.0.1:{port}/json/list', timeout=1) as r:
                targets=json.load(r)
            if targets: break
        except Exception:
            time.sleep(0.2)
    wsurl=targets[0]['webSocketDebuggerUrl']
    async def run():
        async with websockets.connect(wsurl, max_size=None) as ws:
            next_id=0
            async def cmd(method, params=None):
                nonlocal next_id
                next_id += 1
                myid = next_id
                await ws.send(json.dumps({'id':myid,'method':method,'params':params or {}}))
                while True:
                    data=json.loads(await ws.recv())
                    if data.get('id') == myid:
                        return data
            print(await cmd('Page.enable'))
            print(await cmd('Page.navigate', {'url':'https://example.com'}))
            await asyncio.sleep(5)
            snap=await cmd('Page.captureSnapshot', {'format':'mhtml'})
            print(snap['result']['data'][:120])
    asyncio.run(run())
finally:
    proc.terminate(); proc.wait()
PY


{'id': 1, 'result': {}}
{'id': 2, 'result': {'frameId': '21279730A2F939C20FC5BFCC7F265858', 'loaderId': '641CE365CD1176343FB1072C2DB49C48', 'errorText': 'net::ERR_ABORTED', 'isDownload': False}}
From: <Saved by Blink>
Snapshot-Content-Location: chrome-extension://nkeimhogjdpnpccoofpliimaahmaaome/background.html


**Exploring MHTML capture**

I can implement MHTML capture for local HTML pages or original links. Since the goal is to compare fidelity and sizes with PDFs, I’m thinking about whether to produce stitched files. MHTML is typically one page per article, so zipping multiple MHTMLs could work. I might create a daily folder and zip them into an archive or store MHTML files in a folder. The user asked if I managed to do this, so I should create a quick prototype now for the current selected articles.

**Considering MHTML fidelity**

I’m exploring how to achieve fidelity similar to PDFs by using a local printable page. MHTML should reflect the source style from either a local article or an original URL. For better fidelity, capturing the original link as MHTML might be helpful to preserve visuals, though it could be tricky to open on a phone. I could create zipped sets of MHTML for top selected articles from entries.json, then compare total sizes to evaluate effectiveness. Maybe I should attach links for sharing later as well.

**Planning file generation**

I think I can simply respond with "yes" and provide sizes, then suggest inspecting fidelity through the files. However, it might be better to generate publicly accessible files and perhaps create a portal index, even if the user didn't specifically ask for that. I’m considering making a script rather than integrating it right now. I can create a directory like `/var/www/rss-offline/mhtml/` and zip the files. Using Python for a one-off process with websockets sounds like a good plan since the user requested it.

python3 - <<'PY'
import asyncio, json, os, subprocess, time, urllib.request, zipfile
from pathlib import Path
import websockets

CHROME='/usr/bin/chromium'
PORT=9231
ENTRIES=json.loads(Path('/var/www/rss-offline/entries.json').read_text())[:8]
OUT_DIR=Path('/var/www/rss-offline/mhtml-2026-05-26')
OUT_DIR.mkdir(parents=True, exist_ok=True)
ZIP_PATH=Path('/var/www/rss-offline/rss-offline-2026-05-26-mhtml.zip')

def slug(url):
    import hashlib
    return hashlib.sha256(url.encode()).hexdigest()[:16]

proc=subprocess.Popen([CHROME,'--headless=new','--disable-gpu','--no-sandbox',f'--remote-debugging-port={PORT}','about:blank'],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
try:
    for _ in range(50):
        try:
            with urllib.request.urlopen(f'http://127.0.0.1:{PORT}/json/list', timeout=1) as r:
                targets=json.load(r)
            if targets:
                break
        except Exception:
            time.sleep(0.2)
    wsurl=targets[0]['webSocketDebuggerUrl']

    async def run():
        async with websockets.connect(wsurl, max_size=None) as ws:
            next_id=0
            async def cmd(method, params=None):
                nonlocal next_id
                next_id += 1
                myid=next_id
                await ws.send(json.dumps({'id':myid,'method':method,'params':params or {}}))
                while True:
                    data=json.loads(await ws.recv())
                    if data.get('id') == myid:
                        return data
            await cmd('Page.enable')
            await cmd('Emulation.setDeviceMetricsOverride', {'width':1280,'height':900,'deviceScaleFactor':1,'mobile':False})
            for idx, entry in enumerate(ENTRIES, start=1):
                url=entry['link']
                name=f'{idx:03d}-{slug(url)}.mhtml'
                path=OUT_DIR / name
                print('MHTML', url)
                await cmd('Page.navigate', {'url':url})
                await asyncio.sleep(8)
                snap=await cmd('Page.captureSnapshot', {'format':'mhtml'})
                path.write_text(snap['result']['data'])
    asyncio.run(run())
finally:
    proc.terminate(); proc.wait()

with zipfile.ZipFile(ZIP_PATH, 'w', compression=zipfile.ZIP_DEFLATED) as zf:
    for p in sorted(OUT_DIR.glob('*.mhtml')):
        zf.write(p, arcname=p.name)

pdf=Path('/var/www/rss-offline/rss-offline-2026-05-26.pdf')
print('PDF_BYTES', pdf.stat().st_size if pdf.exists() else -1)
print('MHTML_ZIP_BYTES', ZIP_PATH.stat().st_size)
for p in sorted(OUT_DIR.glob('*.mhtml')):
    print(p.name, p.stat().st_size)
PY


Traceback (most recent call last):
  File "<stdin>", line 9, in <module>
  File "/usr/lib/python3.13/pathlib/_local.py", line 724, in mkdir
    os.mkdir(self, mode)
    ~~~~~~~~^^^^^^^^^^^^
PermissionError: [Errno 13] Permission denied: '/var/www/rss-offline/mhtml-2026-05-26'

Command exited with code 1


tmpdir=$(mktemp -d)
python3 - <<'PY' "$tmpdir"
import asyncio, json, subprocess, time, urllib.request, zipfile, sys
from pathlib import Path
import websockets

TMP=Path(sys.argv[1])
CHROME='/usr/bin/chromium'
PORT=9231
ENTRIES=json.loads(Path('/var/www/rss-offline/entries.json').read_text())[:8]
OUT_DIR=TMP / 'mhtml-2026-05-26'
OUT_DIR.mkdir(parents=True, exist_ok=True)
ZIP_PATH=TMP / 'rss-offline-2026-05-26-mhtml.zip'

def slug(url):
    import hashlib
    return hashlib.sha256(url.encode()).hexdigest()[:16]

proc=subprocess.Popen([CHROME,'--headless=new','--disable-gpu','--no-sandbox',f'--remote-debugging-port={PORT}','about:blank'],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
try:
    for _ in range(50):
        try:
            with urllib.request.urlopen(f'http://127.0.0.1:{PORT}/json/list', timeout=1) as r:
                targets=json.load(r)
            if targets:
                break
        except Exception:
            time.sleep(0.2)
    wsurl=targets[0]['webSocketDebuggerUrl']

    async def run():
        async with websockets.connect(wsurl, max_size=None) as ws:
            next_id=0
            async def cmd(method, params=None):
                nonlocal next_id
                next_id += 1
                myid=next_id
                await ws.send(json.dumps({'id':myid,'method':method,'params':params or {}}))
                while True:
                    data=json.loads(await ws.recv())
                    if data.get('id') == myid:
                        return data
            await cmd('Page.enable')
            await cmd('Emulation.setDeviceMetricsOverride', {'width':1280,'height':900,'deviceScaleFactor':1,'mobile':False})
            for idx, entry in enumerate(ENTRIES, start=1):
                url=entry['link']
                name=f'{idx:03d}-{slug(url)}.mhtml'
                path=OUT_DIR / name
                print('MHTML', url)
                await cmd('Page.navigate', {'url':url})
                await asyncio.sleep(8)
                snap=await cmd('Page.captureSnapshot', {'format':'mhtml'})
                path.write_text(snap['result']['data'])
    asyncio.run(run())
finally:
    proc.terminate(); proc.wait()

with zipfile.ZipFile(ZIP_PATH, 'w', compression=zipfile.ZIP_DEFLATED) as zf:
    for p in sorted(OUT_DIR.glob('*.mhtml')):
        zf.write(p, arcname=p.name)

pdf=Path('/var/www/rss-offline/rss-offline-2026-05-26.pdf')
print('PDF_BYTES', pdf.stat().st_size if pdf.exists() else -1)
print('MHTML_ZIP_BYTES', ZIP_PATH.stat().st_size)
for p in sorted(OUT_DIR.glob('*.mhtml')):
    print(p.name, p.stat().st_size)
PY
sudo rm -rf /var/www/rss-offline/mhtml-2026-05-26 /var/www/rss-offline/rss-offline-2026-05-26-mhtml.zip
sudo cp -r "$tmpdir/mhtml-2026-05-26" /var/www/rss-offline/
sudo cp "$tmpdir/rss-offline-2026-05-26-mhtml.zip" /var/www/rss-offline/
ls -lh /var/www/rss-offline/rss-offline-2026-05-26.pdf /var/www/rss-offline/rss-offline-2026-05-26-mhtml.zip
ls -lh /var/www/rss-offline/mhtml-2026-05-26 | sed -n '1,20p'


MHTML https://world.hey.com/dhh/basecamp-five-8fcfd2ef
MHTML https://github.blog/developer-skills/github/github-for-beginners-getting-started-with-git-and-github-in-vs-code/
MHTML https://leadershipintech.com/newsletters/2286-barely-treading-water
MHTML https://lucumr.pocoo.org/2026/5/24/pi-oss/
MHTML https://www.digitalocean.com/blog/request-based-autoscaling-app-platform
MHTML https://github.blog/ai-and-ml/github-copilot/github-recognized-as-a-leader-in-the-gartner-magic-quadrant-for-enterprise-ai-coding-agents-for-the-third-year-in-a-row/
MHTML https://simonw.substack.com/p/datasette-agent-an-ai-assistant-for
MHTML https://tailscale.com/changelog/#2026-05-22-service
PDF_BYTES 3904958
MHTML_ZIP_BYTES 6204
001-8ce8fe6b9afb1b1c.mhtml 1188
002-98558fbdd89c6b67.mhtml 1188
003-2231f609093b63d6.mhtml 1188
004-b88b0610b56ab9fb.mhtml 1188
005-5cae64475c972bc8.mhtml 1188
006-e730d9b21ea1c9bb.mhtml 1188
007-bc93d7b33addd1e2.mhtml 1188
008-10a4ae2b78c0be2c.mhtml 1188
-rw-r--r-- 1 root root 6.1K May 26 09:59 /var/www/rss-offline/rss-offline-2026-05-26-mhtml.zip
-rw-r--r-- 1 root root 3.8M May 26 09:05 /var/www/rss-offline/rss-offline-2026-05-26.pdf
total 32K
-rw-r--r-- 1 root root 1.2K May 26 09:59 001-8ce8fe6b9afb1b1c.mhtml
-rw-r--r-- 1 root root 1.2K May 26 09:59 002-98558fbdd89c6b67.mhtml
-rw-r--r-- 1 root root 1.2K May 26 09:59 003-2231f609093b63d6.mhtml
-rw-r--r-- 1 root root 1.2K May 26 09:59 004-b88b0610b56ab9fb.mhtml
-rw-r--r-- 1 root root 1.2K May 26 09:59 005-5cae64475c972bc8.mhtml
-rw-r--r-- 1 root root 1.2K May 26 09:59 006-e730d9b21ea1c9bb.mhtml
-rw-r--r-- 1 root root 1.2K May 26 09:59 007-bc93d7b33addd1e2.mhtml
-rw-r--r-- 1 root root 1.2K May 26 09:59 008-10a4ae2b78c0be2c.mhtml


**Investigating chrome issues**

I’m seeing a suspicious MHTML error related to a tiny Chrome extension, maybe because the navigation was aborted? I’ll need to inspect one file to figure out what’s going on. It’s a bit confusing, and I want to ensure I’m addressing the right issue here. Let’s go step by step to identify the problem and resolve it effectively. It feels a bit tricky, but I’m on it!

sed -n '1,40p' /var/www/rss-offline/mhtml-2026-05-26/001-8ce8fe6b9afb1b1c.mhtml