Gmail ingestion pipeline that extracts newsletter links and (optionally) fetches article pages.
I found that I subscribed to a number of tech newsletters (e.g. TLDR, MarksTechPost). Individually they were great, but as a whole they are just overwhelming. Oftentimes links get re-used between newsletters, and packaged slightly differently depending on the audience. If you actually want to read the articles you can spend hours each day just trying to figure out what other people are doing.
Create a pipeline that pulls from Gmail, where I have a "Tech Newsletters" label. De-duplicate links between newsletters, and add minimal persistence so that if you re-run the pipeline you don't see links added twice.
Feed the cleaned article to a cheap LLM, get a summary and tags categorizing it. Store simple entries in an Obsidian vault in an organized fashion.
Down the road, I'd like to add some rebalancing to re-consider the broad themes, but I haven't dug into that, and nowadays it seems like throwing an Agent at the task might offer the same benefits. I'd also like to add in some rating for personal preferences (e.g. "this topic is of particular interest for me"), but I haven't thought through that thoroughly.
Create a local vault in this repo (gitignored):
mkdir -p vaultIngest recent newsletters (creates newsletter.db):
python run.py ingest --label "Tech Newsletters" --since-days 7 --max 20 --db newsletter.db --vault "C:\\Path\\To\\Repo\\vault"Write structured ingest logs (JSONL):
python run.py ingest --label "Tech Newsletters" --since-days 7 --max 20 --db newsletter.db --vault "C:\\Path\\To\\Repo\\vault" --log-jsonl logs\\ingest.jsonlFetch article pages and write article notes:
python run.py process-links --db newsletter.db --vault "C:\\Path\\To\\Repo\\vault" --max-links 25Preview process-links candidates without changing anything:
python run.py process-links --db newsletter.db --max-links 25 --dry-runWrite structured process logs (JSONL):
python run.py process-links --db newsletter.db --vault "C:\\Path\\To\\Repo\\vault" --max-links 25 --log-jsonl logs\\process-links.jsonlBackfill redirect URLs (optional):
python run.py backfill-redirects --db newsletter.db --max-links 200 --redirect-rate-limit 0.2Refresh old processed links (optional):
python run.py refresh --db newsletter.db --vault "C:\\Path\\To\\Repo\\vault" --older-than-days 30 --max-links 25Refresh only previously failed links:
python run.py refresh --db newsletter.db --vault "C:\\Path\\To\\Repo\\vault" --older-than-days 30 --failed-onlyRefresh only previously successful links:
python run.py refresh --db newsletter.db --vault "C:\\Path\\To\\Repo\\vault" --older-than-days 30 --ok-onlyRefresh stale successful links (preset):
python run.py refresh --db newsletter.db --vault "C:\\Path\\To\\Repo\\vault" --older-than-days 30 --stale-okPreview refresh candidates without changing anything:
python run.py refresh --db newsletter.db --vault "C:\\Path\\To\\Repo\\vault" --older-than-days 30 --statuses "ok,fail" --dry-runRefresh only specific sources/categories:
python run.py refresh --db newsletter.db --vault "C:\\Path\\To\\Repo\\vault" --older-than-days 30 --domains "example.com,news.ycombinator.com" --categories "Dev Tools,Security"Write structured refresh logs (JSONL):
python run.py refresh --db newsletter.db --vault "C:\\Path\\To\\Repo\\vault" --older-than-days 30 --log-jsonl logs\\refresh.jsonlrun.py: entrypoint CLI (will remain as thin wrapper).newsletter/: package for core pipeline modules (refactor in progress).newsletter/cli.py: CLI orchestration and command dispatch.observability/: notebooks and scripts for inspecting outputs.tests/: unit tests.
- Purpose: Pull labeled Gmail messages, extract/store links, and optionally fetch articles.
- Common flags:
--label,--since-days,--max,--db,--vault,--resolve-redirects. - Optional outputs:
--fetch-summary-json <path>,--log-jsonl <path>.
- Purpose: Process unprocessed links into article notes.
- Common flags:
--db,--vault,--articles-dir,--max-links. - Safe preview:
--dry-run. - Optional outputs:
--fetch-summary-json <path>,--log-jsonl <path>.
- Purpose: Reprocess already processed links using age/status/domain/category filters.
- Common flags:
--db,--vault,--older-than-days,--max-links. - Filters:
--statuses,--domains,--categories. - Presets:
--failed-only,--ok-only,--stale-ok. - Safe preview:
--dry-run. - Optional outputs:
--fetch-summary-json <path>,--log-jsonl <path>.
- Purpose: Resolve and update canonical URLs for already-stored links.
- Common flags:
--db,--max-links,--redirect-timeout,--redirect-retries,--redirect-rate-limit.
When --log-jsonl is set, commands append one JSON object per line.
- Common event types:
run_startcandidate(dry-run candidate URL)message_processed(ingest only)url_processed(fetch/process result per URL)ingest_summary(ingest only)run_summary
- Common fields:
timestamp,event,command- URL-level events include fields like
url,domain,status,title,note_path. - Failure diagnostics on URL-level events:
error_class,http_status,retry_count. - Summarization events include
llm_mode,fallback_used,model,prompt_version,llm_latency_mswhen applicable.
- Example:
{"timestamp":1738675200,"event":"url_processed","command":"process-links","url":"https://example.com/a","domain":"example.com","status":"ok","title":"Example","note_path":"C:\\vault\\Newsletters\\Articles\\Other\\2026\\Example.md"}Summarize one or more JSONL run logs — success rate, top failing domains, LLM mode mix, latency percentiles, and daily trend.
python -m observability.log_report --logs "logs/*.jsonl" --since-days 7
python -m observability.log_report --logs logs/process.jsonl --command process-links --top-domains 20
python -m observability.log_report --logs "logs/*.jsonl" --json > report.jsonAnswers "what broke this week?" without launching a notebook. The same
aggregations (observability.log_stats) are reused in the companion notebook
observability/observability_examples.ipynb for exploratory analysis and
charts.
Install deps:
python -m pip install -r requirements.txtRun tests:
python -m pytestNotes:
- Tests create a temporary
.test_tmpdirectory in the repo (gitignored).
Fixture-based summarizer evaluation is available via:
python -m observability.eval_summarizer --mode stub --fixtures eval\\fixtures.jsonlTo evaluate the OpenAI path directly:
python -m observability.eval_summarizer --mode openai --fixtures eval\\fixtures.jsonl --model gpt-4o-miniSet OPENAI_API_KEY to enable OpenAI-backed summaries. Optionally set OPENAI_MODEL
(defaults to gpt-4o-mini). If no key is set, a local stub summarizer is used.