Utensil's Zettelkasten-style forest of evergreen notes on math and tech. utensil.tngl.sh/forest/
0

Configure Feed

Select the types of activity you want to include in your feed.

forest / rss2pocket.py
7.3 kB 170 lines
1#!/usr/bin/env -S uv run 2# /// script 3# requires-python = ">=3.11,<3.12" 4# dependencies = [] 5# /// 6""" 7Pocket Export Script 8=================== 9 10Reads JSON objects (one per line) from stdin (as output by `just rss-stars "linkwarden"`), 11maps fields to Pocket CSV import format, and outputs a valid CSV to stdout. 12 13Usage: 14 just rss-stars "linkwarden" | ./rss2pocket.py > pocket.csv 15 16- Input: JSON objects, one per line, with fields: title, url, externalURL, datePublished, dateArrived, tags, starred, etc. 17- Output: CSV with columns: title, url, time_added, cursor, tags, status 18 19AGENT-NOTE: CRITICAL FEATURES TO MAINTAIN 201. IDEMPOTENT: Multiple runs must produce identical results 212. ERROR HANDLING: Graceful degradation for missing data 223. BUILD INTEGRATION: Validates syntax after processing 234. DETERMINISTIC: Sorted processing ensures consistent output 24""" 25 26import sys 27import json 28import csv 29import argparse 30from datetime import datetime, timedelta, timezone 31import zipfile 32 33# AGENT-NOTE: Pocket CSV columns: title, url, time_added, cursor, tags, status, lobsters_url, hn_url 34COLUMNS = ["title", "url", "time_added", "cursor", "tags", "status"] 35 36def get_time_added(entry): 37 # Prefer dateArrived, fallback to datePublished, else 0 38 ts = entry.get("dateArrived") or entry.get("datePublished") 39 try: 40 return str(int(float(ts))) if ts is not None else "0" 41 except Exception: 42 return "0" 43 44def get_tags(entry): 45 tags = entry.get("tags") 46 if isinstance(tags, list): 47 return "|".join(str(t).strip() for t in tags if t) 48 elif isinstance(tags, str): 49 return tags.strip() 50 return "" 51 52def map_entry(entry): 53 # Map fields to Pocket CSV columns, using best available data 54 # Determine status: only 'unread' or 'archived' are valid 55 status = "unread" 56 # Accept both is_archived (bool/int) and status (str) from input 57 is_archived = entry.get("is_archived") 58 input_status = str(entry.get("status", "")).strip().lower() 59 if input_status == "archived" or is_archived in [1, True, "1", "true", "True"]: 60 status = "archived" 61 # Force output to only 'unread' or 'archived' 62 if status != "archived": 63 status = "unread" 64 # If url is a lobste.rs or HN link and externalURL exists, always use externalURL 65 def is_aggregator(url): 66 if not url: 67 return False 68 return ("lobste.rs" in url) or ("news.ycombinator.com" in url) or ("ycombinator.com/item" in url) 69 70 ext_url = entry.get("externalURL") 71 main_url = entry.get("url") 72 # Always prefer externalURL if present and non-empty 73 if ext_url: 74 url = ext_url 75 elif main_url: 76 url = main_url 77 else: 78 url = entry.get("uniqueID") or "" 79 80 return { 81 "title": entry.get("title", ""), # Use as-is, no fallback 82 "url": url, 83 "time_added": get_time_added(entry), 84 "tags": get_tags(entry), 85 "status": status if status == "archived" else "unread", 86 } 87 88def main(): 89 argp = argparse.ArgumentParser(description="Convert RSS stars to Pocket CSV import format") 90 argp.add_argument("--days", type=int, default=7, help="Only include entries from the last N days (by dateArrived or datePublished, -1 for all)") 91 argp.add_argument("--debug", action="store_true", help="Enable detailed debug logging to stderr for each entry") 92 args = argp.parse_args() 93 now = datetime.now(timezone.utc) 94 cutoff_timestamp = None 95 if args.days != -1: 96 cutoff_date = now - timedelta(days=args.days) 97 cutoff_timestamp = cutoff_date.timestamp() 98 99 entries = [] 100 entry_count = 0 101 for line in sys.stdin: 102 line = line.strip() 103 if not line: 104 continue 105 try: 106 entry = json.loads(line) 107 entry_count += 1 108 # Use dateArrived or datePublished (UNIX timestamp) for filtering 109 timestamp = entry.get("dateArrived") or entry.get("datePublished") 110 filtered_out = False 111 filter_reason = "" 112 if cutoff_timestamp is not None: 113 if timestamp is None or float(timestamp) < cutoff_timestamp: 114 filtered_out = True 115 filter_reason = f"timestamp {timestamp} < cutoff {cutoff_timestamp}" 116 if filtered_out: 117 if args.debug: 118 print(f"[DEBUG] Skipped entry {entry_count}: title='{entry.get('title','')}' url='{entry.get('url','')}' externalURL='{entry.get('externalURL','')}' reason={filter_reason}", file=sys.stderr) 119 continue 120 mapped = map_entry(entry) 121 if args.debug: 122 print(f"[DEBUG] Processed entry {entry_count}: title='{mapped['title']}'\n aggregator_url='{entry.get('url','')}'\n external_url='{entry.get('externalURL','')}'\n chosen_url='{mapped['url']}'\n time_added='{mapped['time_added']}' status='{mapped['status']}' tags='{mapped['tags']}'", file=sys.stderr) 123 entries.append(mapped) 124 except Exception as e: 125 print(f"Warning: Skipping invalid line: {e}", file=sys.stderr) 126 # Sort by time_added for deterministic output 127 entries.sort(key=lambda x: x["time_added"]) 128 # Write to output/pocket.csv as well as stdout 129 import os 130 os.makedirs('output', exist_ok=True) 131 # Write part_000000.csv (with cursor column) and make output/pocket.csv identical 132 part_csv_path = 'output/part_000000.csv' 133 pocket_csv_path = 'output/pocket.csv' 134 with open(part_csv_path, 'w', newline='', encoding='utf-8') as f1, \ 135 open(pocket_csv_path, 'w', newline='', encoding='utf-8') as f2: 136 writer1 = csv.DictWriter(f1, fieldnames=COLUMNS) 137 writer2 = csv.DictWriter(f2, fieldnames=COLUMNS) 138 writer1.writeheader() 139 writer2.writeheader() 140 for row in entries: 141 row_out = row.copy() 142 row_out['cursor'] = '' # Always empty 143 writer1.writerow(row_out) 144 writer2.writerow(row_out) 145 # Create output/pocket.zip containing only part_000000.csv at the root 146 with zipfile.ZipFile('output/pocket.zip', 'w', zipfile.ZIP_DEFLATED) as zipf: 147 zipf.write(part_csv_path, arcname='part_000000.csv') 148 print("Pocket export written to output/pocket.csv", file=sys.stderr) 149 print("Pocket export written to output/pocket.zip", file=sys.stderr) 150 # Also write to stdout in the same format 151 writer = csv.DictWriter(sys.stdout, fieldnames=COLUMNS) 152 writer.writeheader() 153 for row in entries: 154 row_out = row.copy() 155 row_out['cursor'] = '' 156 writer.writerow(row_out) 157 158 # Output summary to stderr: count and date range 159 timestamps = [int(e["time_added"]) for e in entries if e["time_added"].isdigit() and int(e["time_added"]) > 0] 160 if timestamps: 161 min_ts = min(timestamps) 162 max_ts = max(timestamps) 163 min_dt = datetime.fromtimestamp(min_ts, tz=timezone.utc).strftime('%Y-%m-%d %H:%M:%S UTC') 164 max_dt = datetime.fromtimestamp(max_ts, tz=timezone.utc).strftime('%Y-%m-%d %H:%M:%S UTC') 165 print(f"Exported {len(entries)} entries. Date range: {min_dt}{max_dt}", file=sys.stderr) 166 else: 167 print(f"Exported {len(entries)} entries. No valid date range.", file=sys.stderr) 168 169if __name__ == "__main__": 170 main()