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

...
  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')
  36 PDF_PAGE_TIMEOUT = int(os.environ.get('RSS_OFFLINE_PDF_PAGE_TIMEOUT', '90'))
  37 PDF_MAX_SOURCE_BYTES = int(os.environ.get('RSS_OFFLINE_PDF_MAX_SOURCE_BYTES', '2500000'))
+ 38 STATE_PATH = DATA_DIR / 'build-state.json'
+ 39 STATE_KEEP_DAYS = int(os.environ.get('RSS_OFFLINE_STATE_KEEP_DAYS', '45'))
  38
  39
  40 def fetch(url: str) -> requests.Response:
  41     response = requests.get(url, timeout=TIMEOUT, headers=HEADERS)
     ...
 459     ]
 460     subprocess.run(cmd, capture_output=True, text=True, timeout=PDF_PAGE_TIMEOUT, check=True)
 461
 462
+465 def load_state():
+466     if not STATE_PATH.exists():
+467         return {'seen_links': {}}
+468     try:
+469         raw = json.loads(STATE_PATH.read_text())
+470     except Exception:
+471         return {'seen_links': {}}
+472     seen_links = raw.get('seen_links') or {}
+473     if not isinstance(seen_links, dict):
+474         seen_links = {}
+475     return {'seen_links': seen_links}
+476
+477
+478
+479 def save_state(state: dict):
+480     cutoff = int((datetime.now(timezone.utc) - timedelta(days=STATE_KEEP_DAYS)).timestamp())
+481     seen_links = {
+482         link: ts for link, ts in (state.get('seen_links') or {}).items()
+483         if isinstance(link, str) and isinstance(ts, int) and ts >= cutoff
+484     }
+485     STATE_PATH.write_text(json.dumps({'seen_links': seen_links}, ensure_ascii=False, indent=2))
+486
+487
+488
 463 def build_pdf_pack(entries, build_stamp: str):
 464     selected = entries[:PDF_MAX_ARTICLES]
 465     if not selected:
 466         return None
     ...
 513 def build():
 514     DATA_DIR.mkdir(parents=True, exist_ok=True)
 515     OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
 516     build_stamp = datetime.now().astimezone().strftime('%Y-%m-%d')
+543     state = load_state()
+544     seen_links = set((state.get('seen_links') or {}).keys())
 517     sources = load_sources()
 518     cutoff = int((datetime.now(timezone.utc) - timedelta(days=DAYS)).timestamp())
 519     entries = []
 520
     ...
 565         deduped.append(entry)
 566     entries = deduped
 567
 568     archived = []
+597     new_entries = []
 569     rendered_used = 0
 570     for entry in entries:
 571         try:
 572             wants_rendered = (entry.get('capture_mode') or DEFAULT_CAPTURE_MODE) == 'rendered'
     ...
 579             article_file.write_text(make_local_copy(entry, page_html, key))
 580             if used_mode == 'rendered':
 581                 rendered_used += 1
 582             archived.append(entry)
+612             if entry['link'] not in seen_links:
+613                 new_entries.append(entry)
 583             print('OK', used_mode, entry['link'])
 584         except Exception as e:
 585             print('ARTICLE_ERR', entry['link'], e)
 586
-587     pdf_info = build_pdf_pack(archived, build_stamp)
+618     pdf_info = build_pdf_pack(new_entries, build_stamp)
 588
 589     index = OUTPUT_DIR / 'index.html'
 590     sw = OUTPUT_DIR / 'sw.js'
 591     offline = OUTPUT_DIR / 'offline.html'
 592     json_index = OUTPUT_DIR / 'entries.json'
 593
 594     json_index.write_text(json.dumps(archived, ensure_ascii=False, indent=2))
 595
+627     for entry in archived:
+628         state['seen_links'][entry['link']] = entry['timestamp'] or int(time.time())
+629     save_state(state)
+630
 596     pdf_archive = list_pdf_archive()
 597     pdf_cards = []
 598     for item in pdf_archive:
 599         pdf_cards.append(f'''

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

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

''')
 600
 601     local_paths = [entry['local_path'] for entry in archived]
-602     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.
'}
''')
+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

PDF diario con artículos nuevos.

{len(new_entries)} nuevos en PDF · {len(archived)} artículos cacheados · últimos {DAYS} días

Archivo de PDFs
{''.join(pdf_cards) or '
Todavía no hay PDFs.
'}
''')
 603
 604     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
')
 605
 606     precache_paths = ['/rss-offline/', '/rss-offline/offline.html', '/rss-offline/entries.json'] + ([pdf_info['path']] if pdf_info else []) + [entry['local_path'] for entry in archived]
     ...

--- /home/sebas/work/projects/rss-offline/build.py
+++ /home/sebas/work/projects/rss-offline/build.py
@@ -34,8 +34,10 @@
 PDF_MAX_ARTICLES = int(os.environ.get('RSS_OFFLINE_PDF_MAX_ARTICLES', '8'))
 PDF_BASENAME = os.environ.get('RSS_OFFLINE_PDF_BASENAME', 'rss-offline')
 PDF_PAGE_TIMEOUT = int(os.environ.get('RSS_OFFLINE_PDF_PAGE_TIMEOUT', '90'))
 PDF_MAX_SOURCE_BYTES = int(os.environ.get('RSS_OFFLINE_PDF_MAX_SOURCE_BYTES', '2500000'))
+STATE_PATH = DATA_DIR / 'build-state.json'
+STATE_KEEP_DAYS = int(os.environ.get('RSS_OFFLINE_STATE_KEEP_DAYS', '45'))

 def fetch(url: str) -> requests.Response:
     response = requests.get(url, timeout=TIMEOUT, headers=HEADERS)
@@ -459,8 +461,32 @@
     ]
     subprocess.run(cmd, capture_output=True, text=True, timeout=PDF_PAGE_TIMEOUT, check=True)

+def load_state():
+    if not STATE_PATH.exists():
+        return {'seen_links': {}}
+    try:
+        raw = json.loads(STATE_PATH.read_text())
+    except Exception:
+        return {'seen_links': {}}
+    seen_links = raw.get('seen_links') or {}
+    if not isinstance(seen_links, dict):
+        seen_links = {}
+    return {'seen_links': seen_links}
+
+
+
+def save_state(state: dict):
+    cutoff = int((datetime.now(timezone.utc) - timedelta(days=STATE_KEEP_DAYS)).timestamp())
+    seen_links = {
+        link: ts for link, ts in (state.get('seen_links') or {}).items()
+        if isinstance(link, str) and isinstance(ts, int) and ts >= cutoff
+    }
+    STATE_PATH.write_text(json.dumps({'seen_links': seen_links}, ensure_ascii=False, indent=2))
+
+
+
 def build_pdf_pack(entries, build_stamp: str):
     selected = entries[:PDF_MAX_ARTICLES]
     if not selected:
         return None
@@ -513,8 +539,10 @@
 def build():
     DATA_DIR.mkdir(parents=True, exist_ok=True)
     OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
     build_stamp = datetime.now().astimezone().strftime('%Y-%m-%d')
+    state = load_state()
+    seen_links = set((state.get('seen_links') or {}).keys())
     sources = load_sources()
     cutoff = int((datetime.now(timezone.utc) - timedelta(days=DAYS)).timestamp())
     entries = []

@@ -565,8 +593,9 @@
         deduped.append(entry)
     entries = deduped

     archived = []
+    new_entries = []
     rendered_used = 0
     for entry in entries:
         try:
             wants_rendered = (entry.get('capture_mode') or DEFAULT_CAPTURE_MODE) == 'rendered'
@@ -579,28 +608,34 @@
             article_file.write_text(make_local_copy(entry, page_html, key))
             if used_mode == 'rendered':
                 rendered_used += 1
             archived.append(entry)
+            if entry['link'] not in seen_links:
+                new_entries.append(entry)
             print('OK', used_mode, entry['link'])
         except Exception as e:
             print('ARTICLE_ERR', entry['link'], e)

-    pdf_info = build_pdf_pack(archived, build_stamp)
+    pdf_info = build_pdf_pack(new_entries, build_stamp)

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

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

+    for entry in archived:
+        state['seen_links'][entry['link']] = entry['timestamp'] or int(time.time())
+    save_state(state)
+
     pdf_archive = list_pdf_archive()
     pdf_cards = []
     for item in pdf_archive:
         pdf_cards.append(f'''

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

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

''')

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

rss-offline

PDF diario con artículos nuevos.

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

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

rss-offline

PDF diario con artículos nuevos.

{len(new_entries)} nuevos en PDF · {len(archived)} artículos cacheados · últimos {DAYS} días

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

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

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

Volver a rss-offline
')

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


edit

def load_state():
    if not STATE_PATH.exists():
        return {'seen_links': {}}
    try:
        raw = json.loads(STATE_PATH.read_text())
    except Exception:
        return {'seen_links': {}}
    seen_links = raw.get('seen_links') or {}
    if not isinstance(seen_links, dict):
        seen_links = {}
    return {'seen_links': seen_links}

def load_state():
    if not STATE_PATH.exists():
        seeded = {}
        json_index = OUTPUT_DIR / 'entries.json'
        if json_index.exists():
            try:
                for entry in json.loads(json_index.read_text()):
                    link = entry.get('link')
                    ts = entry.get('timestamp') or int(time.time())
                    if isinstance(link, str) and isinstance(ts, int):
                        seeded[link] = ts
            except Exception:
                seeded = {}
        return {'seen_links': seeded}
    try:
        raw = json.loads(STATE_PATH.read_text())
    except Exception:
        return {'seen_links': {}}
    seen_links = raw.get('seen_links') or {}
    if not isinstance(seen_links, dict):
        seen_links = {}
    return {'seen_links': seen_links}


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

     ...
 463
 464
 465 def load_state():
 466     if not STATE_PATH.exists():
-467         return {'seen_links': {}}
+467         seeded = {}
+468         json_index = OUTPUT_DIR / 'entries.json'
+469         if json_index.exists():
+470             try:
+471                 for entry in json.loads(json_index.read_text()):
+472                     link = entry.get('link')
+473                     ts = entry.get('timestamp') or int(time.time())
+474                     if isinstance(link, str) and isinstance(ts, int):
+475                         seeded[link] = ts
+476             except Exception:
+477                 seeded = {}
+478         return {'seen_links': seeded}
 468     try:
 469         raw = json.loads(STATE_PATH.read_text())
 470     except Exception:
 471         return {'seen_links': {}}
     ...

--- /home/sebas/work/projects/rss-offline/build.py
+++ /home/sebas/work/projects/rss-offline/build.py
@@ -463,9 +463,20 @@

 def load_state():
     if not STATE_PATH.exists():
-        return {'seen_links': {}}
+        seeded = {}
+        json_index = OUTPUT_DIR / 'entries.json'
+        if json_index.exists():
+            try:
+                for entry in json.loads(json_index.read_text()):
+                    link = entry.get('link')
+                    ts = entry.get('timestamp') or int(time.time())
+                    if isinstance(link, str) and isinstance(ts, int):
+                        seeded[link] = ts
+            except Exception:
+                seeded = {}
+        return {'seen_links': seeded}
     try:
         raw = json.loads(STATE_PATH.read_text())
     except Exception:
         return {'seen_links': {}}


**Inspecting the build process**

I'm thinking I might need to remove the state if it's absent since I'm not sure if it's been created earlier. It seems likely that it hasn't been, but I want to confirm this by running the build process. Once that's done, I should inspect the resulting PDF to see if there's anything new. It's a bit of a guessing game, but I want to make sure everything's in order before moving forward!

bash

cd /home/sebas/work/projects/rss-offline && rm -f data/build-state.json && python3 -m py_compile build.py && sudo -n python3 build.py > /tmp/rss-offline-incremental.log 2>&1 && tail -n 30 /tmp/rss-offline-incremental.log && echo '---STATE---' && sudo -n cat /home/sebas/work/projects/rss-offline/data/build-state.json | sed -n '1,40p' && echo '---PDFS---' && ls -lt /var/www/rss-offline/rss-offline-*.pdf | head -5


OK simple https://simonw.substack.com/p/i-think-anthropic-and-openai-have
OK simple https://tailscale.com/changelog/#2026-05-29-service
OK simple https://martinalderson.com/posts/whats-going-on-with-gemini/?utm_source=rss&utm_medium=rss&utm_campaign=feed
OK simple https://www.digitalocean.com/blog/digitalocean-opencode-inference-routers
OK simple https://github.blog/news-insights/company-news/still-a-developer-just-outside-our-latest-github-shop-collection-is-here/
OK simple https://gomakethings.com/articles/how-to-add-icons-to-external-links-with-css/
OK simple https://leadershipintech.com/newsletters/2287-why-japanese-companies-do-so-many-different-things
OK simple https://tailscale.com/changelog/#2026-05-28-client
OK simple https://openclaw.ai/blog/lighter-core-sharper-claws/
OK simple https://www.digitalocean.com/blog/introducing-batch-inference
OK simple https://gomakethings.com/articles/crying-to-punk/
OK simple https://world.hey.com/dhh/basecamp-five-8fcfd2ef
OK simple https://lucumr.pocoo.org/2026/5/26/clankers/
OK simple https://tailscale.com/changelog/#2026-05-26-service
OK simple https://github.blog/developer-skills/github/github-for-beginners-getting-started-with-git-and-github-in-vs-code/
OK simple https://leadershipintech.com/newsletters/2286-barely-treading-water
OK simple https://lucumr.pocoo.org/2026/5/24/pi-oss/
OK simple https://www.digitalocean.com/blog/request-based-autoscaling-app-platform
OK simple 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 simple https://simonw.substack.com/p/datasette-agent-an-ai-assistant-for
OK simple 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
---STATE---
{
  "seen_links": {
    "https://martinalderson.com/posts/built-for-turbulence-podcast/?utm_source=rss&utm_medium=rss&utm_campaign=feed": 1780358400,
    "https://www.digitalocean.com/blog/reduce-llm-inference-costs-prefix-caching": 1780342200,
    "https://www.digitalocean.com/blog/serverless-inference-deep-dive": 1780339448,
    "https://gomakethings.com/articles/overflow-bug-with-.visually-hidden-class/": 1780324200,
    "https://gomakethings.com/articles/reading-more.-watching-less./": 1780324200,
    "https://world.hey.com/dhh/let-the-agents-democratize-open-source-9fd630a9": 1780323654,
    "https://openclaw.ai/blog/openclaw-nvidia-skill-security/": 1780291800,
    "https://tailscale.com/changelog/#2026-06-01-client": 1780272000,
    "https://leadershipintech.com/newsletters/2291-the-secret-to-skip-level-one-on-ones": 1780250700,
    "https://openclaw.ai/blog/safer-than-yolo-auto-mode-for-exec-approvals/": 1780185600,
    "https://mariozechner.at/posts/2026-05-30-shitty-robot": 1780099200,
    "https://www.digitalocean.com/blog/ai-disruptors": 1780090204,
    "https://supernuclear.substack.com/p/the-half-assed-potluck-and-other": 1780076658,
    "https://gomakethings.com/articles/how-to-redirect-if-a-page-doesnt-exist-with-apache/": 1780065000,
    "https://simonw.substack.com/p/i-think-anthropic-and-openai-have": 1780021196,
    "https://tailscale.com/changelog/#2026-05-29-service": 1780012800,
    "https://martinalderson.com/posts/whats-going-on-with-gemini/?utm_source=rss&utm_medium=rss&utm_campaign=feed": 1780012800,
    "https://www.digitalocean.com/blog/digitalocean-opencode-inference-routers": 1780002162,
    "https://github.blog/news-insights/company-news/still-a-developer-just-outside-our-latest-github-shop-collection-is-here/": 1779992323,
    "https://gomakethings.com/articles/how-to-add-icons-to-external-links-with-css/": 1779978600,
    "https://leadershipintech.com/newsletters/2287-why-japanese-companies-do-so-many-different-things": 1779948300,
    "https://tailscale.com/changelog/#2026-05-28-client": 1779926400,
    "https://openclaw.ai/blog/lighter-core-sharper-claws/": 1779926400,
    "https://www.digitalocean.com/blog/introducing-batch-inference": 1779903820,
    "https://gomakethings.com/articles/crying-to-punk/": 1779892200,
    "https://world.hey.com/dhh/basecamp-five-8fcfd2ef": 1779792750,
    "https://lucumr.pocoo.org/2026/5/26/clankers/": 1779753600,
    "https://tailscale.com/changelog/#2026-05-26-service": 1779753600,
    "https://github.blog/developer-skills/github/github-for-beginners-getting-started-with-git-and-github-in-vs-code/": 1779724800,
    "https://leadershipintech.com/newsletters/2286-barely-treading-water": 1779645900,
    "https://lucumr.pocoo.org/2026/5/24/pi-oss/": 1779580800,
    "https://www.digitalocean.com/blog/request-based-autoscaling-app-platform": 1779472946,
    "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/": 1779466221,
    "https://simonw.substack.com/p/datasette-agent-an-ai-assistant-for": 1779432347,
    "https://tailscale.com/changelog/#2026-05-22-service": 1779408000,
    "https://github.blog/open-source/gaming/beyond-the-engine-10-open-source-projects-shaping-how-games-actually-get-made/": 1779386400,
    "https://github.blog/open-source/building-githubs-next-chapter-in-accessibility/": 1779379200,
    "https://leadershipintech.com/newsletters/2282-recognize-your-management-wins": 1779343500,
---PDFS---
-rw-r--r-- 1 root root  1213011 Jun  2 13:01 /var/www/rss-offline/rss-offline-2026-06-02.pdf
-rw-r--r-- 1 root root  6828805 Jun  1 06:02 /var/www/rss-offline/rss-offline-2026-06-01.pdf
-rw-r--r-- 1 root root  6840244 May 31 06:41 /var/www/rss-offline/rss-offline-2026-05-31.pdf
-rw-r--r-- 1 root root 11723506 May 30 06:02 /var/www/rss-offline/rss-offline-2026-05-30.pdf
-rw-r--r-- 1 root root  8272915 May 29 06:02 /var/www/rss-offline/rss-offline-2026-05-29.pdf


**Analyzing PDF status**

I'm trying to figure out if today's PDF was overwritten. It seems like there might not be any new entries since I didn't see a PDF_OK. But there's an existing file with a modified time from earlier, maybe from 13:01. I'm wondering if it was built from current entries or if it was overwritten. Inspecting the PDF pages might help clarify this. If there are no new entries, maybe we shouldn't create a new PDF to avoid stale files. I'll need to check the logs for any omitted PDF_OK.

rg -n "PDF_" /tmp/rss-offline-incremental.log | sed -n '1,60p'


(no output)


**Considering PDF updates**

I see there’s no new PDF created, but the existing file has a modified time from earlier. It might be from an old file listing. The user needs to know that the current PDF might contain repeated content since there haven't been new articles added.

To avoid any confusion, I should probably delete any stale PDFs from today if no new entries are found. I’ll figure out whether to remove the outdated merged PDF altogether or create a small one with no new content.

**Deciding on PDF management**

It seems like I need to ensure that if there are no new entries, I shouldn't create a new PDF for that day. However, I do want to remove any stale dated files from earlier to avoid misleading information. So, my plan is to check if the PDF information is none and, if so, delete the merged path for the current build stamp if it exists. I think this approach makes sense!

edit

    pdf_info = build_pdf_pack(new_entries, build_stamp)

    index = OUTPUT_DIR / 'index.html'

    pdf_info = build_pdf_pack(new_entries, build_stamp)
    if pdf_info is None:
        stale_merged = OUTPUT_DIR / f'{PDF_BASENAME}-{build_stamp}.pdf'
        if stale_merged.exists():
            stale_merged.unlink()

    index = OUTPUT_DIR / 'index.html'


     ...
 626         except Exception as e:
 627             print('ARTICLE_ERR', entry['link'], e)
 628
 629     pdf_info = build_pdf_pack(new_entries, build_stamp)
+630     if pdf_info is None:
+631         stale_merged = OUTPUT_DIR / f'{PDF_BASENAME}-{build_stamp}.pdf'
+632         if stale_merged.exists():
+633             stale_merged.unlink()
 630
 631     index = OUTPUT_DIR / 'index.html'
 632     sw = OUTPUT_DIR / 'sw.js'
 633     offline = OUTPUT_DIR / 'offline.html'
     ...

--- /home/sebas/work/projects/rss-offline/build.py
+++ /home/sebas/work/projects/rss-offline/build.py
@@ -626,8 +626,12 @@
         except Exception as e:
             print('ARTICLE_ERR', entry['link'], e)

     pdf_info = build_pdf_pack(new_entries, build_stamp)
+    if pdf_info is None:
+        stale_merged = OUTPUT_DIR / f'{PDF_BASENAME}-{build_stamp}.pdf'
+        if stale_merged.exists():
+            stale_merged.unlink()

     index = OUTPUT_DIR / 'index.html'
     sw = OUTPUT_DIR / 'sw.js'
     offline = OUTPUT_DIR / 'offline.html'


bash

cd /home/sebas/work/projects/rss-offline && python3 -m py_compile build.py && sudo -n python3 build.py > /tmp/rss-offline-incremental2.log 2>&1 && tail -n 10 /tmp/rss-offline-incremental2.log && echo '---PDFS---' && ls -lt /var/www/rss-offline/rss-offline-*.pdf | head -5


OK simple 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
---PDFS---
-rw-r--r-- 1 root root  6828805 Jun  1 06:02 /var/www/rss-offline/rss-offline-2026-06-01.pdf
-rw-r--r-- 1 root root  6840244 May 31 06:41 /var/www/rss-offline/rss-offline-2026-05-31.pdf
-rw-r--r-- 1 root root 11723506 May 30 06:02 /var/www/rss-offline/rss-offline-2026-05-30.pdf
-rw-r--r-- 1 root root  8272915 May 29 06:02 /var/www/rss-offline/rss-offline-2026-05-29.pdf
-rw-r--r-- 1 root root  3497150 May 28 06:02 /var/www/rss-offline/rss-offline-2026-05-28.pdf


todo

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

Daily merged PDF now includes only unseen links since last successful build; if there are no new entries, today's merged PDF is not kept/generated.


Updated #9 (in_progress → completed)

Add feed title headers to daily RSS PDF output

Find RSS daily PDF generator and include feed title before item content so sections are clearer.

Audit last 5 RSS PDFs for UX improvements

Inspect the 5 most recent RSS offline PDFs, identify layout/content issues, and improve generator toward Safari Reader-like output: more consistent typography, fewer distractions, bounded icon/media sizing.

pdftoppm first-page screenshots for DigitalOcean samples

Fix Tailscale changelog PDF and reduce image prominence

Handle Tailscale changelog PDF generation failure and make reader mode more text-first by reducing image prominence further.

fixing changelog PDF and tightening reader mode

visual check first-page PNGs for Tailscale and DigitalOcean

Increase RSS PDF reader font size for small-screen reading

Make RSS offline PDF typography about two steps larger to improve iPhone reading comfort.

visual first-page check /var/www/rss-offline/pdf/article-002-a5d7a20ccc09ad8c.pdf

Increase RSS PDF body font again

Make RSS PDF body text another notch larger, roughly one step below the header title feel for phone reading.

Tune RSS PDF toward iPhone Reader legibility

Adjust PDF layout to better match iPhone Reader readability: narrower page/column, larger type, cleaner spacing.

visual comparison against provided iPhone Reader screenshots using regenerated first-page PNGs

Tighten line spacing in latest RSS PDF only

Reduce line-height slightly toward iPhone Reader and regenerate only the latest/current PDF outputs, leaving older dated PDFs untouched.

Older dated merged PDFs remain untouched; regenerated current/latest outputs only.

Validate duplicate/repeated content in RSS PDFs

Check whether daily RSS PDFs are repeating feed entries across days or within a pack, and identify cause in generator logic/data.

No duplicate links inside current entries/PDF; repetition across days is expected because the daily merged PDF is rebuilt from a rolling 14-day window, not only today's new entries.

Make daily RSS PDF include only new entries since last run

Change rss-offline so the daily merged PDF contains only entries newer than the previous successful build, while keeping the wider archive/list behavior.


Sí. Ya lo cambié a opción 1.

Nuevo comportamiento:
- el PDF diario usa solo links nuevos
  desde la última corrida exitosa
- si no hay nada nuevo,
  no deja un PDF “de hoy” viejo/mintiendo

Validé:
- había 44 links únicos,
  sin duplicados internos
- sembré estado desde lo ya visto
- hoy, como no había nuevos,
  desapareció `rss-offline-2026-06-02.pdf`
  para no repetir lectura vieja

Queda así:
- archivo/lista web sigue mostrando
  el histórico cacheado
- PDF diario = solo novedades reales

Si querés, siguiente mejora:
- mostrar en la home
  “0 nuevos desde la última corrida”


/new


queued new Telegram Pi session: pi-telegram