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

import requests
from bs4 import BeautifulSoup

BASE_DIR = Path(__file__).resolve().parent
SOURCES_PATH = BASE_DIR / 'feed-sources.json'
DATA_DIR = BASE_DIR / 'data'
OUTPUT_DIR = Path('/var/www/rss-offline')
MAX_ENTRIES = int(os.environ.get('RSS_OFFLINE_MAX_ENTRIES', '250'))
DAYS = int(os.environ.get('RSS_OFFLINE_DAYS', '14'))
TIMEOUT = 20
HEADERS = {'user-agent': 'Mozilla/5.0 (rss-offline builder)'}
DEFAULT_CAPTURE_MODE = os.environ.get('RSS_OFFLINE_CAPTURE_MODE', 'rendered').strip().lower() or 'rendered'
RENDER_BUDGET_MS = int(os.environ.get('RSS_OFFLINE_RENDER_BUDGET_MS', '8000'))
MAX_RENDERED_ENTRIES = int(os.environ.get('RSS_OFFLINE_MAX_RENDERED_ENTRIES', '8'))
MAX_ASSETS_PER_PAGE = int(os.environ.get('RSS_OFFLINE_MAX_ASSETS_PER_PAGE', '24'))
MAX_ASSET_BYTES = int(os.environ.get('RSS_OFFLINE_MAX_ASSET_BYTES', '1500000'))
ALLOWED_ASSET_EXTENSIONS = ('.css', '.js', '.mjs', '.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.ico', '.woff', '.woff2', '.ttf')
CHROMIUM_BIN = os.environ.get('RSS_OFFLINE_CHROMIUM_BIN', '/usr/bin/chromium')
EPUB_TITLE = os.environ.get('RSS_OFFLINE_EPUB_TITLE', 'rss-offline latest')
EPUB_FILENAME = os.environ.get('RSS_OFFLINE_EPUB_FILENAME', 'rss-offline-latest.epub')
EPUB_MAX_ARTICLES = int(os.environ.get('RSS_OFFLINE_EPUB_MAX_ARTICLES', '40'))
EPUB_MAX_IMAGES_PER_ARTICLE = int(os.environ.get('RSS_OFFLINE_EPUB_MAX_IMAGES_PER_ARTICLE', '12'))
EPUB_MAX_IMAGE_BYTES = int(os.environ.get('RSS_OFFLINE_EPUB_MAX_IMAGE_BYTES', '2000000'))

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

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

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

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

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

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

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

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

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

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

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

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

def capture_page(entry, 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)

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


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

def build_epub(entries):
    selected = entries[:EPUB_MAX_ARTICLES]
    if not selected:
        return None

    epub_path = OUTPUT_DIR / EPUB_FILENAME
    articles = []
    assets = {}

    for idx, entry in enumerate(selected, start=1):
        article_html = sanitize_article_html((OUTPUT_DIR / f'article-{slug(entry["link"])}.html').read_text(), entry)
        soup = BeautifulSoup(article_html, 'html.parser')
        image_count = 0
        for img in soup.find_all('img'):
            src = img.get('src')
            if not src or image_count >= EPUB_MAX_IMAGES_PER_ARTICLE:
                img.decompose()
                continue
            try:
                data, content_type = fetch_binary(src, EPUB_MAX_IMAGE_BYTES)
                ext = mimetypes.guess_extension(content_type) or Path(urlparse(src).path).suffix or '.bin'
                if ext == '.jpe':
                    ext = '.jpg'
                asset_name = f'images/{slug(src)}{ext}'
                assets.setdefault(asset_name, (data, content_type or mimetypes.guess_type(asset_name)[0] or 'application/octet-stream'))
                img['src'] = asset_name
                image_count += 1
            except Exception:
                img.decompose()
        chapter_name = f'article-{idx:03d}.xhtml'
        articles.append({
            'title': entry['title'],
            'file': chapter_name,
            'body': make_epub_xhtml(entry['title'], str(soup)),
            'entry': entry,
        })

    nav_items = ''.join(
        f'
{escape(article["title"])}
'
        for article in articles
    )
    nav_xhtml = make_epub_xhtml('Contents', f'
Contents

{nav_items}
')
    intro_body = '
{}

{} articles · generated {}

Server pack for reliable iPhone offline reading.
'.format(
        escape(EPUB_TITLE),
        len(articles),
        escape(datetime.now().astimezone().strftime('%Y-%m-%d %H:%M')),
    )
    intro_xhtml = make_epub_xhtml(EPUB_TITLE, intro_body)
    stylesheet = (
        'body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;line-height:1.55;margin:0 auto;max-width:44em;padding:1em;color:#111;}'
        'img{max-width:100%;height:auto;display:block;margin:1em auto;}'
        'pre,code{white-space:pre-wrap;word-break:break-word;}'
        'a{color:#0b57d0;}'
        'h1,h2,h3{line-height:1.2;}'
    )

    manifest_items = [
        '<item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/>',
        '<item id="intro" href="intro.xhtml" media-type="application/xhtml+xml"/>',
        '<item id="css" href="styles.css" media-type="text/css"/>',
    ]
    spine_items = ['<itemref idref="intro"/>']
    for idx, article in enumerate(articles, start=1):
        manifest_items.append(f'<item id="chap{idx}" href="{article["file"]}" media-type="application/xhtml+xml"/>')
        spine_items.append(f'<itemref idref="chap{idx}"/>')
    for idx, (asset_name, (_data, media_type)) in enumerate(sorted(assets.items()), start=1):
        manifest_items.append(f'<item id="img{idx}" href="{escape(asset_name, quote=True)}" media-type="{escape(media_type, quote=True)}"/>')

    opf = f'''<?xml version="1.0" encoding="utf-8"?>
<package xmlns="http://www.idpf.org/2007/opf" unique-identifier="bookid" version="3.0">
  <metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
    <dc:identifier id="bookid">urn:rss-offline:{int(time.time())}</dc:identifier>
    <dc:title>{escape(EPUB_TITLE)}</dc:title>
    <dc:language>en</dc:language>
    <dc:creator>rss-offline</dc:creator>
    <meta property="dcterms:modified">{datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')}</meta>
  </metadata>
  <manifest>
    {' '.join(manifest_items)}
  </manifest>
  <spine>
    {' '.join(spine_items)}
  </spine>
</package>
'''

    with zipfile.ZipFile(epub_path, 'w') as zf:
        zf.writestr('mimetype', 'application/epub+zip', compress_type=zipfile.ZIP_STORED)
        zf.writestr('META-INF/container.xml', '''<?xml version="1.0" encoding="utf-8"?>
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
  <rootfiles>
    <rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/>
  </rootfiles>
</container>
''')
        zf.writestr('OEBPS/content.opf', opf)
        zf.writestr('OEBPS/nav.xhtml', nav_xhtml)
        zf.writestr('OEBPS/intro.xhtml', intro_xhtml)
        zf.writestr('OEBPS/styles.css', stylesheet)
        for article in articles:
            zf.writestr(f'OEBPS/{article["file"]}', article['body'])
        for asset_name, (data, _media_type) in assets.items():
            zf.writestr(f'OEBPS/{asset_name}', data)

    return {'path': f'/rss-offline/{EPUB_FILENAME}', 'count': len(articles)}

def build():
    DATA_DIR.mkdir(parents=True, exist_ok=True)
    OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
    sources = load_sources()
    cutoff = int((datetime.now(timezone.utc) - timedelta(days=DAYS)).timestamp())
    entries = []

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

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

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

[57 more lines in file. Use offset=521 to continue.]


'feed_url': source['feedUrl'],
                'title': title,
                'link': link,
                'timestamp': ts,
                'capture_mode': source['captureMode'],
            })

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

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

    epub_info = build_epub(archived)

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

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

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

{escape(entry['title'])}

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

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

''')

    local_paths = [entry['local_path'] for entry in archived]
    epub_cta = ''
    if epub_info:
        epub_cta = f'
Descargar EPUB offline · {epub_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

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

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

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

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

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

Volver a rss-offline
')

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

if __name__ == '__main__':
    build()


**Exploring PDF generation options**

I'm thinking about adding PDF generation using the Chromium local file option. The easiest way might be to print an article from a local path URL while maintaining its original style. I need this to be accessible through Chromium, possibly using a local URL instead of a network one. It seems like using --print-to-pdf with proper asset links could help. I should consider testing with a known local URL to see how it works.

bash

python3 - <<'PY'
from pathlib import Path
p=Path('/tmp/chrome-test.html')
p.write_text('
hi
')
print(p)
PY
chromium --headless=new --disable-gpu --no-sandbox --print-to-pdf=/tmp/chrome-test.pdf file:///tmp/chrome-test.html >/tmp/chrome_pdf_out 2>&1; echo $?; tail -n 20 /tmp/chrome_pdf_out; ls -lh /tmp/chrome-test.pdf


/tmp/chrome-test.html
0
Error: unrecognized flag --no-decommit-pooled-pages
Try --help for options
Error: unrecognized flag --no-decommit-pooled-pages
Try --help for options
Error: unrecognized flag --no-decommit-pooled-pages
Try --help for options
Error: unrecognized flag --no-decommit-pooled-pages
Try --help for options
Error: unrecognized flag --no-decommit-pooled-pages
Try --help for options
Error: unrecognized flag --no-decommit-pooled-pages
Try --help for options
Error: unrecognized flag --no-decommit-pooled-pages
Try --help for options
14069 bytes written to file /tmp/chrome-test.pdf
-rw-rw-r-- 1 sebas sebas 14K May 20 06:48 /tmp/chrome-test.pdf


**Testing implementation options**

I need to implement something, and I'm considering using a local file generated from article HTML with assets that have a local absolute path. However, the article HTML includes /rss-offline/assets, and using file:// won't work as expected. Alternatively, I could print out the URL with HTTPS format and possibly add --ignore-certificate-errors. It's time to test these approaches to see which one works best!

chromium --headless=new --disable-gpu --no-sandbox --ignore-certificate-errors --print-to-pdf=/tmp/rss-test.pdf 'https://ballbox-first.emperor-ratio.ts.net/rss-offline/article-0fdf7c0575324c55.html' >/tmp/rss_test_out 2>&1; echo $?; tail -n 20 /tmp/rss_test_out; ls -lh /tmp/rss-test.pdf


Command timed out after 180 seconds


edit

EPUB_MAX_IMAGE_BYTES = int(os.environ.get('RSS_OFFLINE_EPUB_MAX_IMAGE_BYTES', '2000000'))
PDF_DIRNAME = os.environ.get('RSS_OFFLINE_PDF_DIRNAME', 'pdf')
PDF_MAX_ARTICLES = int(os.environ.get('RSS_OFFLINE_PDF_MAX_ARTICLES', '12'))
PDF_MERGED_FILENAME = os.environ.get('RSS_OFFLINE_PDF_MERGED_FILENAME', 'rss-offline-latest.pdf')
PDF_PAGE_TIMEOUT = int(os.environ.get('RSS_OFFLINE_PDF_PAGE_TIMEOUT', '90'))
PUBLIC_BASE_URL = os.environ.get('RSS_OFFLINE_PUBLIC_BASE_URL', 'https://ballbox-first.emperor-ratio.ts.net/rss-offline')


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

     ...
  36 EPUB_FILENAME = os.environ.get('RSS_OFFLINE_EPUB_FILENAME', 'rss-offline-latest.epub')
  37 EPUB_MAX_ARTICLES = int(os.environ.get('RSS_OFFLINE_EPUB_MAX_ARTICLES', '40'))
  38 EPUB_MAX_IMAGES_PER_ARTICLE = int(os.environ.get('RSS_OFFLINE_EPUB_MAX_IMAGES_PER_ARTICLE', '12'))
  39 EPUB_MAX_IMAGE_BYTES = int(os.environ.get('RSS_OFFLINE_EPUB_MAX_IMAGE_BYTES', '2000000'))
+ 40 PDF_DIRNAME = os.environ.get('RSS_OFFLINE_PDF_DIRNAME', 'pdf')
+ 41 PDF_MAX_ARTICLES = int(os.environ.get('RSS_OFFLINE_PDF_MAX_ARTICLES', '12'))
+ 42 PDF_MERGED_FILENAME = os.environ.get('RSS_OFFLINE_PDF_MERGED_FILENAME', 'rss-offline-latest.pdf')
+ 43 PDF_PAGE_TIMEOUT = int(os.environ.get('RSS_OFFLINE_PDF_PAGE_TIMEOUT', '90'))
+ 44 PUBLIC_BASE_URL = os.environ.get('RSS_OFFLINE_PUBLIC_BASE_URL', 'https://ballbox-first.emperor-ratio.ts.net/rss-offline')
  40
  41
  42 def fetch(url: str) -> requests.Response:
  43     response = requests.get(url, timeout=TIMEOUT, headers=HEADERS)
     ...


def build_epub(entries):
    selected = entries[:EPUB_MAX_ARTICLES]
    if not selected:
        return None

    epub_path = OUTPUT_DIR / EPUB_FILENAME

    return {'path': f'/rss-offline/{EPUB_FILENAME}', 'count': len(articles)}

def build():

return {'path': f'/rss-offline/{EPUB_FILENAME}', 'count': len(articles)}

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

def build():


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

...
 488
 489     return {'path': f'/rss-offline/{EPUB_FILENAME}', 'count': len(articles)}
 490
 491
+492 def make_pdf_printable_copy(entry: dict):
+493     src_path = OUTPUT_DIR / f'article-{slug(entry["link"])}.html'
+494     pdf_dir = OUTPUT_DIR / PDF_DIRNAME
+495     pdf_dir.mkdir(parents=True, exist_ok=True)
+496     printable_path = pdf_dir / f'article-{slug(entry["link"])}.print.html'
+497     html = src_path.read_text()
+498     html = html.replace('/rss-offline/', 'file:///var/www/rss-offline/')
+499     html = html.replace('position:fixed;top:0;left:0;right:0;', 'position:static;')
+500     html = html.replace('body{margin-top:72px!important}', 'body{margin-top:0!important}')
+501     html = html.replace('', '')
+502     printable_path.write_text(html)
+503     return printable_path
+504
+505
+506 def print_pdf(input_html: Path, output_pdf: Path):
+507     cmd = [
+508         CHROMIUM_BIN,
+509         '--headless=new',
+510         '--disable-gpu',
+511         '--no-sandbox',
+512         '--print-to-pdf=' + str(output_pdf),
+513         '--allow-file-access-from-files',
+514         '--disable-features=HttpsUpgrades,BlockInsecurePrivateNetworkRequests',
+515         input_html.as_uri(),
+516     ]
+517     subprocess.run(cmd, capture_output=True, text=True, timeout=PDF_PAGE_TIMEOUT, check=True)
+518
+519
+520 def build_pdf_pack(entries):
+521     selected = entries[:PDF_MAX_ARTICLES]
+522     if not selected:
+523         return None
+524     pdf_dir = OUTPUT_DIR / PDF_DIRNAME
+525     pdf_dir.mkdir(parents=True, exist_ok=True)
+526     article_pdfs = []
+527     built = []
+528     for idx, entry in enumerate(selected, start=1):
+529         try:
+530             printable = make_pdf_printable_copy(entry)
+531             pdf_name = f'article-{idx:03d}-{slug(entry["link"])}.pdf'
+532             pdf_path = pdf_dir / pdf_name
+533             print_pdf(printable, pdf_path)
+534             article_pdfs.append(str(pdf_path))
+535             built.append({
+536                 'title': entry['title'],
+537                 'path': f'/rss-offline/{PDF_DIRNAME}/{pdf_name}',
+538             })
+539             print('PDF_OK', entry['link'])
+540         except Exception as e:
+541             print('PDF_ERR', entry['link'], e)
+542     if not article_pdfs:
+543         return None
+544     merged_path = OUTPUT_DIR / PDF_MERGED_FILENAME
+545     subprocess.run(['pdfunite', *article_pdfs, str(merged_path)], check=True)
+546     return {
+547         'path': f'/rss-offline/{PDF_MERGED_FILENAME}',
+548         'count': len(built),
+549         'articles': built,
+550     }
+551
+552
 492 def build():
 493     DATA_DIR.mkdir(parents=True, exist_ok=True)
 494     OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
 495     sources = load_sources()
     ...


    epub_info = build_epub(archived)

    index = OUTPUT_DIR / 'index.html'

    epub_info = build_epub(archived)
    pdf_info = build_pdf_pack(archived)

    index = OUTPUT_DIR / 'index.html'

local_paths = [entry['local_path'] for entry in archived]
    epub_cta = ''
    if epub_info:
        epub_cta = f'
Descargar EPUB offline · {epub_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

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

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

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

local_paths = [entry['local_path'] for entry in archived]
    epub_cta = ''
    if epub_info:
        epub_cta = f'
Descargar EPUB offline · {epub_info["count"]} artículos
'
    pdf_cta = ''
    if pdf_info:
        pdf_cta = f'
Descargar PDF stitched · {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

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

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

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

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

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


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

...
 613         except Exception as e:
 614             print('ARTICLE_ERR', entry['link'], e)
 615
 616     epub_info = build_epub(archived)
+617     pdf_info = build_pdf_pack(archived)
 617
 618     index = OUTPUT_DIR / 'index.html'
 619     sw = OUTPUT_DIR / 'sw.js'
 620     offline = OUTPUT_DIR / 'offline.html'
     ...
 629     local_paths = [entry['local_path'] for entry in archived]
 630     epub_cta = ''
 631     if epub_info:
 632         epub_cta = f'
Descargar EPUB offline · {epub_info["count"]} artículos
'
-633     index.write_text(f'''<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="theme-color" content="#07111f"><title>rss-offline</title>

rss-offline

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

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

{''.join(cards)}''')
+634     pdf_cta = ''
+635     if pdf_info:
+636         pdf_cta = f'
Descargar PDF stitched · {pdf_info["count"]} artículos
'
+637     index.write_text(f'''<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="theme-color" content="#07111f"><title>rss-offline</title>

rss-offline

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

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

{''.join(cards)}''')
 634
 635     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
')
 636
-637     precache_paths = ['/rss-offline/', '/rss-offline/offline.html', '/rss-offline/entries.json'] + ([epub_info['path']] if epub_info else []) + [entry['local_path'] for entry in archived]
+641     precache_paths = ['/rss-offline/', '/rss-offline/offline.html', '/rss-offline/entries.json'] + ([pdf_info['path']] if pdf_info else []) + ([epub_info['path']] if epub_info else []) + [entry['local_path'] for entry in archived]
 638     sw.write_text(f"""const V='rss-offline-v4';const CORE=V+'-core';const PAGES=V+'-pages';const PRECACHE={json.dumps(precache_paths, ensure_ascii=False)};self.addEventListener('install',e=>{{e.waitUntil(caches.open(CORE).then(c=>c.addAll(PRECACHE)));self.skipWaiting();}});self.addEventListener('activate',e=>{{e.waitUntil(caches.keys().then(keys=>Promise.all(keys.filter(k=>!k.startsWith(V)).map(k=>caches.delete(k)))).then(()=>self.clients.claim()));}});self.addEventListener('fetch',e=>{{const r=e.request;const u=new URL(r.url);if(r.method!=='GET'||u.origin!==self.location.origin||!u.pathname.startsWith('/rss-offline/'))return;e.respondWith((async()=>{{const core=await caches.open(CORE);const pages=await caches.open(PAGES);const cached=await core.match(r)||await pages.match(r);try{{const res=await fetch(r);if(res.ok) await pages.put(r,res.clone());return res;}}catch{{return cached||await core.match('/rss-offline/offline.html')||Response.error();}}}})());}});""")
 639
 640
 641 if __name__ == '__main__':
     ...


bash

python3 -m py_compile /home/sebas/work/projects/rss-offline/build.py && echo OK && cd /home/sebas/work/projects/rss-offline && sudo -n env RSS_OFFLINE_PDF_MAX_ARTICLES=6 python3 build.py > /tmp/rss_pdf_build.log 2>&1; echo $?; tail -n 120 /tmp/rss_pdf_build.log


OK
0
OK rendered https://simonw.substack.com/p/the-last-six-months-in-llms-in-five
OK simple https://tailscale.com/changelog/#2026-05-19-service
OK rendered https://tailscale.com/changelog/#2026-05-19-service
OK rendered https://github.blog/news-insights/product-news/take-your-local-github-sessions-anywhere/
OK rendered https://leadershipintech.com/newsletters/2281-how-to-stay-resilient-in-a-difficult-job
OK rendered https://github.blog/ai-and-ml/github-copilot/building-a-general-purpose-accessibility-agent-and-what-we-learned-in-the-process/
OK rendered https://github.blog/security/raising-the-bar-quality-shared-responsibility-and-the-future-of-githubs-bug-bounty-program/
OK rendered https://supernuclear.substack.com/p/repost-meet-cheryl-your-coliving
OK rendered 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://github.blog/open-source/gaming/dungeons-desktops-10-roguelikes-that-never-die-because-their-communities-wont-let-them/
OK simple https://tailscale.com/changelog/#2026-05-13-client
OK simple https://github.blog/news-insights/company-news/github-copilot-individual-plans-introducing-flex-allotments-in-pro-and-pro-and-a-new-max-plan/
OK simple https://github.blog/ai-and-ml/github-copilot/dungeons-desktops-building-a-procedurally-generated-roguelike-with-github-copilot-cli/
OK simple https://github.blog/developer-skills/github/github-for-beginners-getting-started-with-oss-contributions/
OK simple https://gomakethings.com/pruning-harvesting-replanting-and-other-forced-metaphors/
OK simple https://leadershipintech.com/newsletters/2276-when-everyone-has-ai-and-the-company-still-learns-nothing
OK simple https://simonw.substack.com/p/vibe-coding-and-agentic-engineering
OK simple https://github.blog/news-insights/policy-news-and-insights/why-age-assurance-laws-matter-for-developers/
OK simple https://lucumr.pocoo.org/2026/5/8/local-models/
OK simple https://supernuclear.substack.com/p/what-popup-villages-teach-us-about
OK simple https://leadershipintech.com/newsletters/2272-agile-in-the-age-of-ai
PDF_OK https://simonw.substack.com/p/the-last-six-months-in-llms-in-five
PDF_OK https://tailscale.com/changelog/#2026-05-19-service
PDF_OK https://tailscale.com/changelog/#2026-05-19-service
PDF_OK https://github.blog/news-insights/product-news/take-your-local-github-sessions-anywhere/
PDF_OK https://leadershipintech.com/newsletters/2281-how-to-stay-resilient-in-a-difficult-job
PDF_OK https://github.blog/ai-and-ml/github-copilot/building-a-general-purpose-accessibility-agent-and-what-we-learned-in-the-process/


**Inspecting files for duplicates**

I need to check the files' sizes—I’m wondering if there’s a chance of duplicates or some weird link issue going on. It seems a little odd, but I want to make sure everything is organized and not cluttered with the same information. I guess verifying this will help keep things tidy! It's just good practice, right? Let’s get in there and see what’s happening with those files.

ls -lh /var/www/rss-offline/rss-offline-latest.pdf /var/www/rss-offline/pdf | sed -n '1,20p'

grep -o 'Descargar PDF stitched[^<]*\|rss-offline-latest.pdf' /var/www/rss-offline/index.html | head -n 10

pdfinfo /var/www/rss-offline/rss-offline-latest.pdf | sed -n '1,20p'


-rw-r--r-- 1 root root 7.5M May 20 06:56 /var/www/rss-offline/rss-offline-latest.pdf

/var/www/rss-offline/pdf:
total 14M
-rw-r--r-- 1 root root  61K May 20 06:56 article-001-0fdf7c0575324c55.pdf
-rw-r--r-- 1 root root 4.6M May 20 06:56 article-002-c49c56b7ed458190.pdf
-rw-r--r-- 1 root root 4.6M May 20 06:56 article-003-c49c56b7ed458190.pdf
-rw-r--r-- 1 root root 722K May 20 06:56 article-004-4c9caf07a88326ae.pdf
-rw-r--r-- 1 root root 131K May 20 06:56 article-005-37e4cd2922d14c57.pdf
-rw-r--r-- 1 root root 926K May 20 06:56 article-006-1097ba82e0f71b98.pdf
-rw-r--r-- 1 root root 562K May 20 06:56 article-0fdf7c0575324c55.print.html
-rw-r--r-- 1 root root 212K May 20 06:56 article-1097ba82e0f71b98.print.html
-rw-r--r-- 1 root root  15K May 20 06:56 article-37e4cd2922d14c57.print.html
-rw-r--r-- 1 root root 213K May 20 06:56 article-4c9caf07a88326ae.print.html
-rw-r--r-- 1 root root 2.1M May 20 06:56 article-c49c56b7ed458190.print.html


rss-offline-latest.pdf
Descargar PDF stitched


Custom Metadata: no
Metadata Stream: no
Tagged:          no
UserProperties:  no
Suspects:        no
Form:            none
JavaScript:      no
Pages:           365
Encrypted:       no
Page size:       612 x 792 pts (letter)
Page rot:        0
File size:       7790611 bytes
Optimized:       no
PDF version:     1.4


**Completing the task**

I think I need to set this task as complete. I should mention that there are likely huge pages and duplicates involved in the process. It's important to keep things organized, so I want to make sure that I’m addressing any duplication issues and simplifying things as much as possible. I want to ensure everything is tidy before officially wrapping it up. Let’s do this the right way!

todo

Implemented first PDF export path in rss-offline build with per-article Chromium print output and merged PDF pack at /rss-offline/rss-offline-latest.pdf.


Updated #2 (in_progress → completed)

Prototype EPUB output for rss-offline

Inspect rss-offline builder, assess EPUB feasibility, implement a first working EPUB export path if practical, and report feasibility + implementation plan + working usage in one pass.

Implemented first EPUB export in rss-offline build, rebuilt site, and published downloadable EPUB at /rss-offline/rss-offline-latest.epub.

Prototype PDF output for rss-offline

Replace or complement the broken EPUB experiment with a PDF-based offline pack path for iPhone testing, aiming for better fidelity of text, images, and page styling.


Done.

Live
- PDF pack:
  `https://ballbox-first.emperor-ratio.ts.net/rss-offline/rss-offline-latest.pdf`

Current size
- **7.8 MB**
- 6 articles in this first cut
- merged from per-article PDFs

What I changed
- added PDF export to:
  `/home/sebas/work/projects/rss-offline/build.py`
- Chromium prints per-article local snapshots
- `pdfunite` merges them
- index now shows:
  `Descargar PDF stitched`

Notes
- this should preserve style/images better than EPUB
- current pack is **365 pages**, so some pages are long/heavy
- there is still one ugly issue:
  Tailscale feed produced duplicate same-link entries,
  so same page got printed twice

Next best move
- you test the PDF on iPhone
- if closer, I tighten:
  - dedupe same-link items
  - cap article count/page count
  - add “top 3 / top 5” smaller PDF packs
  - maybe per-article PDF links only

If you want, I can do the cleanup pass now without waiting.


[telegram] 1 small issue: openclaw blog renders with a hazy overlay

1 big issue: pdf ends up being 700+ pages because tailscale blog loads all posts ever. some limit strategy is needed


**Planning a response**

I need to respond concisely to the user and consider updating the to-do list. It seems like they’re reporting issues, so I should acknowledge that and propose clear fixes. Maybe I should ask if they want to start right away, but only if it changes the scope of the task. I can outline the fix, which is to strip overlays in a printable copy and clip long pages using content selectors for Tailscale.

Yes.

Cause
- **OpenClaw haze**:
  page-level overlay/backdrop survives print
- **Tailscale explosion**:
  changelog anchor points into a long index page,
  so print grabs the whole changelog

Fix plan
1. **strip overlays/modals**
   - remove fixed/sticky full-screen layers
   - hide backdrop/filter/mask elements in print copy

2. **dedupe by final link**
   - same URL once only

3. **special handling for anchor feeds**
   - if URL has `#...`, isolate just that section
   - for Tailscale, print only the matching changelog item,
     not the whole page

4. **hard limits**
   - max pages per article
   - max articles per pack
   - skip pathological pages

Best strategy
- make PDF path **article-snippet-first**
- not “print the whole loaded page” blindly

If you want, I apply that now:
- overlay cleanup
- Tailscale section isolation
- dedupe
- smaller safer pack defaults


[telegram] y


Harden rss-offline PDF pack generation

Fix overlay artifacts and runaway long-page PDFs by stripping modal/backdrop overlays, deduping same-link entries, isolating anchor-target sections like Tailscale changelog items, and applying safer pack limits.


Created #3: Harden rss-offline PDF pack generation (pending)

Harden rss-offline PDF pack generation

Prototype EPUB output for rss-offline

Prototype PDF output for rss-offline

Harden rss-offline PDF pack generation


Updated #3 (pending → in_progress)

Prototype EPUB output for rss-offline

Prototype PDF output for rss-offline

Harden rss-offline PDF pack generation


edit

PDF_DIRNAME = os.environ.get('RSS_OFFLINE_PDF_DIRNAME', 'pdf')
PDF_MAX_ARTICLES = int(os.environ.get('RSS_OFFLINE_PDF_MAX_ARTICLES', '12'))
PDF_MERGED_FILENAME = os.environ.get('RSS_OFFLINE_PDF_MERGED_FILENAME', 'rss-offline-latest.pdf')
PDF_PAGE_TIMEOUT = int(os.environ.get('RSS_OFFLINE_PDF_PAGE_TIMEOUT', '90'))
PUBLIC_BASE_URL = os.environ.get('RSS_OFFLINE_PUBLIC_BASE_URL', 'https://ballbox-first.emperor-ratio.ts.net/rss-offline')

PDF_DIRNAME = os.environ.get('RSS_OFFLINE_PDF_DIRNAME', 'pdf')
PDF_MAX_ARTICLES = int(os.environ.get('RSS_OFFLINE_PDF_MAX_ARTICLES', '8'))
PDF_MERGED_FILENAME = os.environ.get('RSS_OFFLINE_PDF_MERGED_FILENAME', 'rss-offline-latest.pdf')
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')


     ...
  37 EPUB_MAX_ARTICLES = int(os.environ.get('RSS_OFFLINE_EPUB_MAX_ARTICLES', '40'))
  38 EPUB_MAX_IMAGES_PER_ARTICLE = int(os.environ.get('RSS_OFFLINE_EPUB_MAX_IMAGES_PER_ARTICLE', '12'))
  39 EPUB_MAX_IMAGE_BYTES = int(os.environ.get('RSS_OFFLINE_EPUB_MAX_IMAGE_BYTES', '2000000'))
  40 PDF_DIRNAME = os.environ.get('RSS_OFFLINE_PDF_DIRNAME', 'pdf')
- 41 PDF_MAX_ARTICLES = int(os.environ.get('RSS_OFFLINE_PDF_MAX_ARTICLES', '12'))
+ 41 PDF_MAX_ARTICLES = int(os.environ.get('RSS_OFFLINE_PDF_MAX_ARTICLES', '8'))
  42 PDF_MERGED_FILENAME = os.environ.get('RSS_OFFLINE_PDF_MERGED_FILENAME', 'rss-offline-latest.pdf')
  43 PDF_PAGE_TIMEOUT = int(os.environ.get('RSS_OFFLINE_PDF_PAGE_TIMEOUT', '90'))
+ 44 PDF_MAX_SOURCE_BYTES = int(os.environ.get('RSS_OFFLINE_PDF_MAX_SOURCE_BYTES', '2500000'))
  44 PUBLIC_BASE_URL = os.environ.get('RSS_OFFLINE_PUBLIC_BASE_URL', 'https://ballbox-first.emperor-ratio.ts.net/rss-offline')
  45
  46
  47 def fetch(url: str) -> requests.Response:
     ...


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'
    html = src_path.read_text()
    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('', '')
    printable_path.write_text(html)
    return printable_path

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


...
 489
 490     return {'path': f'/rss-offline/{EPUB_FILENAME}', 'count': len(articles)}
 491
 492
+493 def isolate_anchor_section(soup: BeautifulSoup, entry: dict):
+494     parsed = urlparse(entry['link'])
+495     fragment = (parsed.fragment or '').strip()
+496     if not fragment:
+497         return soup
+498     target = soup.find(id=fragment)
+499     if target is None:
+500         target = soup.find(attrs={'name': fragment})
+501     if target is None:
+502         return soup
+503     container = target
+504     for _ in range(6):
+505         parent = container.parent
+506         if parent is None or not getattr(parent, 'name', None):
+507             break
+508         text_len = len(parent.get_text(' ', strip=True))
+509         if 200 < text_len < 20000:
+510             container = parent
+511             break
+512         container = parent
+513     wrapper = BeautifulSoup('<!doctype html>', 'html.parser')
+514     wrapper.body.append(BeautifulSoup(str(container), 'html.parser'))
+515     return wrapper
+516
+517
+518 def clean_pdf_soup(soup: BeautifulSoup):
+519     selectors = [
+520         'script', 'noscript', 'iframe', 'video', 'audio',
+521         '[aria-modal="true"]', '[role="dialog"]',
+522         '.modal', '.overlay', '.backdrop', '.drawer', '.popup',
+523         '[class*="overlay"]', '[class*="backdrop"]',
+524         '[style*="backdrop-filter"]'
+525     ]
+526     for selector in selectors:
+527         for tag in soup.select(selector):
+528             tag.decompose()
+529     for tag in soup.find_all(True):
+530         style = (tag.get('style') or '').lower().replace(' ', '')
+531         klass = ' '.join(tag.get('class', [])).lower()
+532         if ('position:fixed' in style or 'position:sticky' in style) and (
+533             'overlay' in klass or 'backdrop' in klass or 'modal' in klass or
+534             'inset:0' in style or ('top:0' in style and 'left:0' in style and 'right:0' in style and 'bottom:0' in style)
+535         ):
+536             tag.decompose()
+537     return soup
+538
+539
 493 def make_pdf_printable_copy(entry: dict):
 494     src_path = OUTPUT_DIR / f'article-{slug(entry["link"])}.html'
 495     pdf_dir = OUTPUT_DIR / PDF_DIRNAME
 496     pdf_dir.mkdir(parents=True, exist_ok=True)
 497     printable_path = pdf_dir / f'article-{slug(entry["link"])}.print.html'
-498     html = src_path.read_text()
+545     soup = BeautifulSoup(src_path.read_text(), 'html.parser')
+546     soup = isolate_anchor_section(soup, entry)
+547     soup = clean_pdf_soup(soup)
+548     html = str(soup)
 499     html = html.replace('/rss-offline/', 'file:///var/www/rss-offline/')
 500     html = html.replace('position:fixed;top:0;left:0;right:0;', 'position:static;')
 501     html = html.replace('body{margin-top:72px!important}', 'body{margin-top:0!important}')
-502     html = html.replace('', '')
+552     html = html.replace('', '')
+553     if len(html.encode()) > PDF_MAX_SOURCE_BYTES:
+554         raise ValueError('print source too large')
 503     printable_path.write_text(html)
 504     return printable_path
 505
 506
     ...


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

    archived = []

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

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

    archived = []


     ...
 646
 647     entries.sort(key=lambda item: item['timestamp'], reverse=True)
 648     entries = entries[:MAX_ENTRIES]
 649
+650     deduped = []
+651     seen_links = set()
+652     for entry in entries:
+653         key = entry['link']
+654         if key in seen_links:
+655             continue
+656         seen_links.add(key)
+657         deduped.append(entry)
+658     entries = deduped
+659
 650     archived = []
 651     rendered_used = 0
 652     for entry in entries:
 653         try:
     ...


bash

python3 -m py_compile /home/sebas/work/projects/rss-offline/build.py && cd /home/sebas/work/projects/rss-offline && sudo -n env RSS_OFFLINE_PDF_MAX_ARTICLES=6 python3 build.py > /tmp/rss_pdf_harden.log 2>&1; echo $?; tail -n 120 /tmp/rss_pdf_harden.log


0
OK rendered https://simonw.substack.com/p/the-last-six-months-in-llms-in-five
OK rendered https://tailscale.com/changelog/#2026-05-19-service
OK rendered https://github.blog/news-insights/product-news/take-your-local-github-sessions-anywhere/
OK rendered https://leadershipintech.com/newsletters/2281-how-to-stay-resilient-in-a-difficult-job
OK rendered https://github.blog/ai-and-ml/github-copilot/building-a-general-purpose-accessibility-agent-and-what-we-learned-in-the-process/
OK rendered https://github.blog/security/raising-the-bar-quality-shared-responsibility-and-the-future-of-githubs-bug-bounty-program/
OK rendered 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 rendered 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://github.blog/open-source/gaming/dungeons-desktops-10-roguelikes-that-never-die-because-their-communities-wont-let-them/
OK simple https://tailscale.com/changelog/#2026-05-13-client
OK simple https://github.blog/news-insights/company-news/github-copilot-individual-plans-introducing-flex-allotments-in-pro-and-pro-and-a-new-max-plan/
OK simple https://github.blog/ai-and-ml/github-copilot/dungeons-desktops-building-a-procedurally-generated-roguelike-with-github-copilot-cli/
OK simple https://github.blog/developer-skills/github/github-for-beginners-getting-started-with-oss-contributions/
OK simple https://gomakethings.com/pruning-harvesting-replanting-and-other-forced-metaphors/
OK simple https://leadershipintech.com/newsletters/2276-when-everyone-has-ai-and-the-company-still-learns-nothing
OK simple https://simonw.substack.com/p/vibe-coding-and-agentic-engineering
OK simple https://github.blog/news-insights/policy-news-and-insights/why-age-assurance-laws-matter-for-developers/
OK simple https://lucumr.pocoo.org/2026/5/8/local-models/
OK simple https://supernuclear.substack.com/p/what-popup-villages-teach-us-about
OK simple https://leadershipintech.com/newsletters/2272-agile-in-the-age-of-ai
PDF_OK https://simonw.substack.com/p/the-last-six-months-in-llms-in-five
PDF_OK https://tailscale.com/changelog/#2026-05-19-service
PDF_OK https://github.blog/news-insights/product-news/take-your-local-github-sessions-anywhere/
PDF_OK https://leadershipintech.com/newsletters/2281-how-to-stay-resilient-in-a-difficult-job
PDF_OK https://github.blog/ai-and-ml/github-copilot/building-a-general-purpose-accessibility-agent-and-what-we-learned-in-the-process/
PDF_OK https://github.blog/security/raising-the-bar-quality-shared-responsibility-and-the-future-of-githubs-bug-bounty-program/


ls -lh /var/www/rss-offline/rss-offline-latest.pdf /var/www/rss-offline/pdf/article-*.pdf | sed -n '1,20p'

python3 - <<'PY'
import json
from pathlib import Path
entries=json.loads(Path('/var/www/rss-offline/entries.json').read_text())
print('entries',len(entries))
print('tailscale count',sum(1 for e in entries if 'tailscale.com/changelog/' in e['link']))
print('openclaw count',sum(1 for e in entries if 'openclaw.ai' in e['link']))
PY


Custom Metadata: no
Metadata Stream: no
Tagged:          no
UserProperties:  no
Suspects:        no
Form:            none
JavaScript:      no
Pages:           86
Encrypted:       no
Page size:       612 x 792 pts (letter)
Page rot:        0
File size:       6156108 bytes
Optimized:       no
PDF version:     1.4


-rw-r--r-- 1 root root 3.9M May 20 08:06 /var/www/rss-offline/pdf/article-001-0fdf7c0575324c55.pdf
-rw-r--r-- 1 root root  45K May 20 08:06 /var/www/rss-offline/pdf/article-002-c49c56b7ed458190.pdf
-rw-r--r-- 1 root root 721K May 20 08:06 /var/www/rss-offline/pdf/article-003-4c9caf07a88326ae.pdf
-rw-r--r-- 1 root root 4.6M May 20 08:04 /var/www/rss-offline/pdf/article-003-c49c56b7ed458190.pdf
-rw-r--r-- 1 root root 131K May 20 08:06 /var/www/rss-offline/pdf/article-004-37e4cd2922d14c57.pdf
-rw-r--r-- 1 root root 722K May 20 08:04 /var/www/rss-offline/pdf/article-004-4c9caf07a88326ae.pdf
-rw-r--r-- 1 root root 925K May 20 08:06 /var/www/rss-offline/pdf/article-005-1097ba82e0f71b98.pdf
-rw-r--r-- 1 root root 131K May 20 08:04 /var/www/rss-offline/pdf/article-005-37e4cd2922d14c57.pdf
-rw-r--r-- 1 root root 925K May 20 08:04 /var/www/rss-offline/pdf/article-006-1097ba82e0f71b98.pdf
-rw-r--r-- 1 root root 593K May 20 08:06 /var/www/rss-offline/pdf/article-006-b1de76efb323ff82.pdf
-rw-r--r-- 1 root root 593K May 20 08:04 /var/www/rss-offline/pdf/article-007-b1de76efb323ff82.pdf
-rw-r--r-- 1 root root  61K May 20 08:04 /var/www/rss-offline/pdf/article-008-286013b9db8727e4.pdf
-rw-r--r-- 1 root root 4.6M May 20 08:05 /var/www/rss-offline/pdf/article-009-bb51898f223204ef.pdf
-rw-r--r-- 1 root root 4.1M May 20 08:05 /var/www/rss-offline/pdf/article-010-12d956c293300f05.pdf
-rw-r--r-- 1 root root 2.5M May 20 08:05 /var/www/rss-offline/pdf/article-011-971e5ff677f0c0f4.pdf
-rw-r--r-- 1 root root 663K May 20 08:05 /var/www/rss-offline/pdf/article-012-76e67a998600e970.pdf
-rw-r--r-- 1 root root 5.9M May 20 08:06 /var/www/rss-offline/rss-offline-latest.pdf


entries 30
tailscale count 5
openclaw count 2


rm -f /var/www/rss-offline/pdf/article-0*.pdf /var/www/rss-offline/pdf/article-0*.print.html && echo cleaned


rm: cannot remove '/var/www/rss-offline/pdf/article-001-0fdf7c0575324c55.pdf': Permission denied
rm: cannot remove '/var/www/rss-offline/pdf/article-002-c49c56b7ed458190.pdf': Permission denied
rm: cannot remove '/var/www/rss-offline/pdf/article-003-4c9caf07a88326ae.pdf': Permission denied
rm: cannot remove '/var/www/rss-offline/pdf/article-003-c49c56b7ed458190.pdf': Permission denied
rm: cannot remove '/var/www/rss-offline/pdf/article-004-37e4cd2922d14c57.pdf': Permission denied
rm: cannot remove '/var/www/rss-offline/pdf/article-004-4c9caf07a88326ae.pdf': Permission denied
rm: cannot remove '/var/www/rss-offline/pdf/article-005-1097ba82e0f71b98.pdf': Permission denied
rm: cannot remove '/var/www/rss-offline/pdf/article-005-37e4cd2922d14c57.pdf': Permission denied
rm: cannot remove '/var/www/rss-offline/pdf/article-006-1097ba82e0f71b98.pdf': Permission denied
rm: cannot remove '/var/www/rss-offline/pdf/article-006-b1de76efb323ff82.pdf': Permission denied
rm: cannot remove '/var/www/rss-offline/pdf/article-007-b1de76efb323ff82.pdf': Permission denied
rm: cannot remove '/var/www/rss-offline/pdf/article-008-286013b9db8727e4.pdf': Permission denied
rm: cannot remove '/var/www/rss-offline/pdf/article-009-bb51898f223204ef.pdf': Permission denied
rm: cannot remove '/var/www/rss-offline/pdf/article-010-12d956c293300f05.pdf': Permission denied
rm: cannot remove '/var/www/rss-offline/pdf/article-011-971e5ff677f0c0f4.pdf': Permission denied
rm: cannot remove '/var/www/rss-offline/pdf/article-012-76e67a998600e970.pdf': Permission denied
rm: cannot remove '/var/www/rss-offline/pdf/article-0fdf7c0575324c55.print.html': Permission denied

Command exited with code 1


sudo -n rm -f /var/www/rss-offline/pdf/article-*.pdf /var/www/rss-offline/pdf/article-*.print.html && echo cleaned && ls -lh /var/www/rss-offline/pdf | sed -n '1,20p'


cleaned
total 0


cd /home/sebas/work/projects/rss-offline && sudo -n env RSS_OFFLINE_PDF_MAX_ARTICLES=6 python3 build.py > /tmp/rss_pdf_harden2.log 2>&1; echo $?; tail -n 80 /tmp/rss_pdf_harden2.log


0
OK rendered https://simonw.substack.com/p/the-last-six-months-in-llms-in-five
OK rendered https://tailscale.com/changelog/#2026-05-19-service
OK rendered https://github.blog/news-insights/product-news/take-your-local-github-sessions-anywhere/
OK rendered https://leadershipintech.com/newsletters/2281-how-to-stay-resilient-in-a-difficult-job
OK rendered https://github.blog/ai-and-ml/github-copilot/building-a-general-purpose-accessibility-agent-and-what-we-learned-in-the-process/
OK rendered https://github.blog/security/raising-the-bar-quality-shared-responsibility-and-the-future-of-githubs-bug-bounty-program/
OK rendered https://supernuclear.substack.com/p/repost-meet-cheryl-your-coliving
OK rendered 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://github.blog/open-source/gaming/dungeons-desktops-10-roguelikes-that-never-die-because-their-communities-wont-let-them/
OK simple https://tailscale.com/changelog/#2026-05-13-client
OK simple https://github.blog/news-insights/company-news/github-copilot-individual-plans-introducing-flex-allotments-in-pro-and-pro-and-a-new-max-plan/
OK simple https://github.blog/ai-and-ml/github-copilot/dungeons-desktops-building-a-procedurally-generated-roguelike-with-github-copilot-cli/
OK simple https://github.blog/developer-skills/github/github-for-beginners-getting-started-with-oss-contributions/
OK simple https://gomakethings.com/pruning-harvesting-replanting-and-other-forced-metaphors/
OK simple https://leadershipintech.com/newsletters/2276-when-everyone-has-ai-and-the-company-still-learns-nothing
OK simple https://simonw.substack.com/p/vibe-coding-and-agentic-engineering
OK simple https://github.blog/news-insights/policy-news-and-insights/why-age-assurance-laws-matter-for-developers/
OK simple https://lucumr.pocoo.org/2026/5/8/local-models/
OK simple https://supernuclear.substack.com/p/what-popup-villages-teach-us-about
OK simple https://leadershipintech.com/newsletters/2272-agile-in-the-age-of-ai
PDF_OK https://simonw.substack.com/p/the-last-six-months-in-llms-in-five
PDF_OK https://tailscale.com/changelog/#2026-05-19-service
PDF_OK https://github.blog/news-insights/product-news/take-your-local-github-sessions-anywhere/
PDF_OK https://leadershipintech.com/newsletters/2281-how-to-stay-resilient-in-a-difficult-job
PDF_OK https://github.blog/ai-and-ml/github-copilot/building-a-general-purpose-accessibility-agent-and-what-we-learned-in-the-process/
PDF_OK https://github.blog/security/raising-the-bar-quality-shared-responsibility-and-the-future-of-githubs-bug-bounty-program/


ls -lh /var/www/rss-offline/pdf/article-*.pdf | sed -n '1,20p'


Custom Metadata: no
Metadata Stream: no
Tagged:          no
UserProperties:  no
Suspects:        no
Form:            none
JavaScript:      no
Pages:           86
Encrypted:       no
Page size:       612 x 792 pts (letter)
Page rot:        0
File size:       6154775 bytes
Optimized:       no
PDF version:     1.4


-rw-r--r-- 1 root root 5.9M May 20 08:10 /var/www/rss-offline/rss-offline-latest.pdf

/var/www/rss-offline/pdf:
total 7.1M
-rw-r--r-- 1 root root 3.9M May 20 08:10 article-001-0fdf7c0575324c55.pdf
-rw-r--r-- 1 root root  45K May 20 08:10 article-002-c49c56b7ed458190.pdf
-rw-r--r-- 1 root root 721K May 20 08:10 article-003-4c9caf07a88326ae.pdf
-rw-r--r-- 1 root root 130K May 20 08:10 article-004-37e4cd2922d14c57.pdf
-rw-r--r-- 1 root root 925K May 20 08:10 article-005-1097ba82e0f71b98.pdf
-rw-r--r-- 1 root root 593K May 20 08:10 article-006-b1de76efb323ff82.pdf
-rw-r--r-- 1 root root 287K May 20 08:10 article-0fdf7c0575324c55.print.html
-rw-r--r-- 1 root root 199K May 20 08:10 article-1097ba82e0f71b98.print.html
-rw-r--r-- 1 root root  14K May 20 08:10 article-37e4cd2922d14c57.print.html
-rw-r--r-- 1 root root 173K May 20 08:10 article-4c9caf07a88326ae.print.html
-rw-r--r-- 1 root root 181K May 20 08:10 article-b1de76efb323ff82.print.html
-rw-r--r-- 1 root root 2.3K May 20 08:10 article-c49c56b7ed458190.print.html


-rw-r--r-- 1 root root 3.9M May 20 08:10 /var/www/rss-offline/pdf/article-001-0fdf7c0575324c55.pdf
-rw-r--r-- 1 root root  45K May 20 08:10 /var/www/rss-offline/pdf/article-002-c49c56b7ed458190.pdf
-rw-r--r-- 1 root root 721K May 20 08:10 /var/www/rss-offline/pdf/article-003-4c9caf07a88326ae.pdf
-rw-r--r-- 1 root root 130K May 20 08:10 /var/www/rss-offline/pdf/article-004-37e4cd2922d14c57.pdf
-rw-r--r-- 1 root root 925K May 20 08:10 /var/www/rss-offline/pdf/article-005-1097ba82e0f71b98.pdf
-rw-r--r-- 1 root root 593K May 20 08:10 /var/www/rss-offline/pdf/article-006-b1de76efb323ff82.pdf


todo

Hardened PDF generation: dedupe by link, isolate anchor sections for hash URLs like Tailscale changelog entries, strip overlay/modal-like elements, and rebuilt a smaller 6-article PDF pack.


Updated #3 (in_progress → completed)

Prototype EPUB output for rss-offline

Prototype PDF output for rss-offline

Harden rss-offline PDF pack generation


Fixed.

Changes
- deduped same-link entries
- isolated `#anchor` sections
  for long pages like Tailscale
- stripped overlay/backdrop/modal-like
  elements for print
- rebuilt smaller PDF pack

New result
- PDF:
  `https://ballbox-first.emperor-ratio.ts.net/rss-offline/rss-offline-latest.pdf`
- size:
  **5.9 MB**
- pages:
  **86**
- articles:
  **6**

This should solve:
- Tailscale runaway page count
- much of the OpenClaw haze risk

Not guaranteed perfect yet.
OpenClaw may still need a site-specific
print cleanup if their overlay is weird.

If you want, next I do either:
- add OpenClaw-specific cleanup
- make packs even smaller, like 3–4 items
- add per-article PDF links too


[telegram] still loaded a 700 page pdf at that url


**Explaining cache issues**

I need to clarify caching and stale content. There's a chance that an old PDF is being displayed due to the browser's cache or a service worker. I should consider updating the service worker version. Additionally, Safari might have cached the direct URL. I could either bust the URL or rename the file. It’s possible that the current file on the server has 86 pages. I should provide a new cache-busted URL and consider adding a versioned filename!

bash

pdfinfo /var/www/rss-offline/rss-offline-latest.pdf | sed -n '1,15p' && stat -c '%y %s' /var/www/rss-offline/rss-offline-latest.pdf

grep -o "const V='rss-offline-v[0-9]*'" /var/www/rss-offline/sw.js

python3 - <<'PY'
from pathlib import Path
import hashlib
p=Path('/var/www/rss-offline/rss-offline-latest.pdf')
print(hashlib.sha256(p.read_bytes()).hexdigest())
PY


Custom Metadata: no
Metadata Stream: no
Tagged:          no
UserProperties:  no
Suspects:        no
Form:            none
JavaScript:      no
Pages:           86
Encrypted:       no
Page size:       612 x 792 pts (letter)
Page rot:        0
File size:       6154775 bytes
Optimized:       no
PDF version:     1.4
2026-05-20 08:10:33.739733087 -0300 6154775


const V='rss-offline-v4'


**Considering service worker update**

I need to mention that the service worker version is unchanged at v4, which could lead to stale cache issues. To fix this, I might bump the version to v5 or generate a dynamic version. It might also help to provide a cache-busted URL with a query parameter. I’m thinking about whether Safari can bypass the service worker directly. It seems like the service worker does cache responses per request, including the query. So, I’ll definitely bump to v5.

edit

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


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

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


bash

cd /home/sebas/work/projects/rss-offline && sudo -n env RSS_OFFLINE_PDF_MAX_ARTICLES=6 python3 build.py >/tmp/rss_sw_bust.log 2>&1 && grep -o "const V='rss-offline-v[0-9]*'" /var/www/rss-offline/sw.js && pdfinfo /var/www/rss-offline/rss-offline-latest.pdf | sed -n '1,12p'


const V='rss-offline-v1779276165'
Custom Metadata: no
Metadata Stream: no
Tagged:          no
UserProperties:  no
Suspects:        no
Form:            none
JavaScript:      no
Pages:           86
Encrypted:       no
Page size:       612 x 792 pts (letter)
Page rot:        0
File size:       6155938 bytes


Server copy is **not** 700 pages now.

Current file at that path:
- **86 pages**
- **5.9 MB**

So this was stale cache.
Likely Safari + service worker held the old PDF.

I already forced a new service-worker version.

Try one of these now:

- reopen from:
  `https://ballbox-first.emperor-ratio.ts.net/rss-offline/rss-offline-latest.pdf?b=2`
- or hard-refresh `/rss-offline/`
- or open in a private tab
- or remove the old downloaded file in Files/Books first

If it still shows 700 pages after `?b=2`,
then Safari is serving a previously downloaded local copy, not the server one.


[telegram] lo veo muy bien! deja esa version y termina todo que cierro la sesion


~/.agents/skills/memory-write/memory-write --type episode --scope project --title "rss-offline PDF pack stabilized for iPhone test" --content "Closed the EPUB-first attempt after poor fidelity. Kept the PDF-based rss-offline pack version that tested well: Chromium-printed per-article local snapshots merged into /rss-offline/rss-offline-latest.pdf, with dedupe by link, anchor-section isolation for long hash URLs like Tailscale changelog entries, overlay/modal cleanup for print, smaller article cap, and cache-busting service worker versioning to avoid stale PDFs on iPhone." --source-ref "conversation:2026-05-20-rss-pdf-final" --origin-agent personal-agent


rss-offline PDF pack stabilized for iPhone test

Closed the EPUB-first attempt after poor fidelity. Kept the PDF-based rss-offline pack version that tested well: Chromium-printed per-article local snapshots merged into /rss-offline/rss-offline-latest.pdf, with dedupe by link, anchor-section isolation for long hash URLs like Tailscale changelog entries, overlay/modal cleanup for print, smaller article cap, and cache-busting service worker versioning to avoid stale PDFs on iPhone.

Summary: Closed the EPUB-first attempt after poor fidelity. Kept the PDF-based rss-offline pack version that tested well: Chromium-printed per-article local snapshots merged into /rss-offl…


Listo.

Queda así
- versión PDF buena conservada
- URL final:
  `https://ballbox-first.emperor-ratio.ts.net/rss-offline/rss-offline-latest.pdf`
- pack chico
- dedupe aplicado
- Tailscale recortado por sección
- overlay cleanup aplicado
- cache busting aplicado

Tareas
- PDF prototype: done
- PDF hardening: done

Memoria guardada
- `mem_2547c456e8d845ffad407ad5b606e24a`

Podés cerrar sesión.


/new


queued new Telegram Pi session: pi-telegram