rss2podcast
Turn an RSS feed into a listenable podcast. Fetches each entry, extracts the article body, sends it to gopipertts for Piper TTS synthesis, and publishes the resulting MP3s as a standards-compliant podcast RSS feed.
How it works
- Parse the source RSS feed.
- For each entry not yet processed (tracked in
state.json):- Fetch the linked article and extract the body with
trafilatura(falls back to RSS summary). - Strip HTML to plain speakable text.
- POST to gopipertts
/api/tts, save the MP3 to disk. - Append the entry to
state.jsonimmediately (crash-safe).
- Fetch the linked article and extract the body with
- Rewrite
feed.xmlfrom the full state withfeedgen(iTunes namespace). - Write
style.xslto the output root so browsers render feeds as a readable HTML page.
Install
uv sync
Or install as a tool:
uv tool install .
Usage
Single feed (CLI)
EFF Updates (equivalent to the config.sample.yaml entry):
uv run rss2podcast --feed-url https://www.eff.org/rss/updates.xml --feed-name "EFF Updates" --output-dir podcasts --url-root https://podcasts.example.com --tts-endpoint http://localhost:8080/ --voice en_US-amy-medium --description "EFF updates, narrated by Piper TTS" --author EFF --limit 5
Ars Technica:
uv run rss2podcast --feed-url https://feeds.arstechnica.com/arstechnica/index --feed-name "Ars Technica" --output-dir podcasts --url-root https://podcasts.example.com --tts-endpoint http://localhost:8080/ --voice en_US-amy-medium --description "Ars Technica articles, narrated by Piper TTS" --author "Ars Technica" --limit 1 --prune-xpath '//div[contains(@class,"author-bio")]' --merge-xpath '//div[contains(@class,"post-content")]'
Hackaday:
uv run rss2podcast --feed-url https://hackaday.com/blog/feed/ --feed-name Hackaday --output-dir podcasts --url-root https://podcasts.example.com --tts-endpoint http://localhost:8080/ --voice en_US-amy-medium --description "Hackaday articles, narrated by Piper TTS" --author Hackaday --prune-xpath '//div[contains(@class,"author-bio")]' --prune-xpath '//section[contains(@class,"related")]'
Multi-feed (YAML)
uv run rss2podcast --config config.yaml
See config.sample.yaml for a fully annotated example.
Output layout
{output_dir}/
style.xsl
{feed-slug}/
state.json
feed.xml
2026-04-16-some-post-title-abc12345.mp3
...
Serve {output_dir} over HTTP at {url_root} and subscribe to {url_root}/{feed-slug}/feed.xml in a podcast app. When style_rss_feed is enabled (the default), opening feed.xml directly in a browser renders it as a human-readable HTML page with an inline audio player.
Scheduling
Designed to run as a cron / scheduled job. Re-runs are idempotent — entries already in state.json are skipped. Long runs are fine (no time limits, state is committed after each entry).
CLI reference
Feed selection
| Flag | Default | Description |
|---|---|---|
--config PATH |
— | YAML config file; enables multi-feed mode (--limit may still be used to override the YAML value) |
--feed-url URL |
— | Source RSS feed URL (single-feed mode) |
--feed-name NAME |
— | Feed display name; also determines the output subdirectory slug |
--output-dir PATH |
— | Directory to write state.json, feed.xml, and MP3s |
--url-root URL |
— | Public base URL where output-dir is served |
TTS
| Flag | Default | Description |
|---|---|---|
--tts-endpoint URL |
http://localhost:8080 |
gopipertts base URL |
--voice MODEL |
en_US-amy-medium |
Piper voice model name |
Feed metadata
| Flag | Default | Description |
|---|---|---|
--description TEXT |
— | Channel description |
--author TEXT |
— | Channel author |
--image-url URL |
— | Channel artwork URL |
Processing
| Flag | Default | Description |
|---|---|---|
--limit N |
— | Keep only the N newest articles per feed; entries that roll out of the window are evicted from state and removed from the podcast feed |
--save-text |
off | Persist raw/clean text in state.json (useful for debugging) |
--no-fetch |
off | Skip external crawling; use only RSS content/description |
--no-style-rss-feed |
style on | Disable XSLT styling; skip style.xsl and omit the processing instruction from feed.xml |
--no-github-ribbon |
ribbon on | Hide the "Fork me on GitHub" ribbon in the styled feed page |
Extraction tuning
These control how trafilatura extracts article text from fetched pages. Defaults are tuned for broad recall; tighten them for noisy feeds.
| Flag | Default | Description |
|---|---|---|
--no-favor-recall |
recall on | Disable recall-biased extraction; fall back to trafilatura's default balanced mode |
--favor-precision |
off | Prefer fewer, higher-confidence text blocks; reduces sidebar/bio bleed-through at the cost of occasionally truncating real content |
--include-comments |
off | Include comment sections in extracted text |
--include-tables |
off | Include table content |
--deduplicate |
off | Remove duplicate text blocks (useful for feeds that repeat headlines or teasers) |
--fast-extraction |
off | Skip fallback extractors; faster but may miss content on harder pages |
--prune-xpath XPATH |
— | XPath expression to remove from the DOM before extraction; repeatable. Use this to surgically excise author bios, related-article widgets, cookie banners, etc. |
--merge-xpath XPATH |
— | XPath matching split article containers; children of all matches are concatenated into the first match before extraction. Repeatable. Use this when a site breaks the article body across sibling containers around mid-article ads (e.g. Ars Technica's split post-content divs), which otherwise causes trafilatura to truncate at the first ad break. |
--prune-xpath examples:
# Remove Ars Technica author bio
--prune-xpath '//div[contains(@class,"author-bio")]'
# Remove multiple sections
--prune-xpath '//aside' --prune-xpath '//div[@id="related"]'
--merge-xpath example:
# Ars Technica splits the article body across multiple <div class="post-content">
# siblings separated by ad wrappers; merging them avoids mid-article truncation.
--merge-xpath '//div[contains(@class,"post-content")]'
YAML config reference
Top-level keys:
output_dir: /var/www/podcasts # required
url_root: https://podcasts.example.com # required
tts_endpoint: http://gopipertts:8080 # default: http://localhost:8080
limit: 5 # optional: process only N newest per feed
save_text: false # optional: persist text in state.json
no_fetch: false # optional: skip external crawling globally
style_rss_feed: true # default: true; set to false to disable XSLT browser rendering
show_github_ribbon: true # default: true; set to false to hide the "Fork me on GitHub" ribbon
Per-feed keys:
feeds:
- name: My Feed # required
url: https://... # required
# TTS
voice: en_US-amy-medium # default: en_US-amy-medium
# Feed metadata
description: "..."
author: "..."
image_url: "https://..."
# Processing (override top-level defaults)
limit: 5 # optional: overrides top-level limit for this feed
# Extraction tuning
favor_recall: true # default: true
favor_precision: false # default: false
include_comments: false # default: false
include_tables: false # default: false
deduplicate: false # default: false
fast_extraction: false # default: false
prune_xpath: # default: null
- '//div[contains(@class,"author-bio")]'
- '//section[@id="related"]'
merge_xpath: # default: null
- '//div[contains(@class,"post-content")]'
favor_recall and favor_precision are independent trafilatura flags. Setting both to true is valid; trafilatura will apply both biases simultaneously.
Tests
uv sync --extra test
uv run pytest
Metadata
Release files for rss2podcast 0.19.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| rss2podcast-0.19.0.tar.gz | 13.3 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| rss2podcast-0.19.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 30.8 kB
Release files / rss2podcast-0.19.0.tar.gz
| Download URL | rss2podcast-0.19.0.tar.gz |
|---|---|
| Size | 13.3 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
89dac6c2bcce9b9843eb41a3cbe2838d7fecbfbe4a744900a4239260500c1283
|
|
BLAKE2b-256 checksum How to use checksums |
21ca5b7e1a16912034bbd21443c532c43d50c50ecf895b2e7dc4ea2c0241f7d2
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/6.1.0 CPython/3.13.12
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Apr 21, 2026.
Transparency logRelease files / rss2podcast-0.19.0-py3-none-any.whl
| Download URL | rss2podcast-0.19.0-py3-none-any.whl |
|---|---|
| Size | 17.5 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
1916e502af8ebdf716712ad9e230aff55f685c9486b9d5a9057bd00e727693ab
|
|
BLAKE2b-256 checksum How to use checksums |
6544adadd33294d9d45384db14c617d9e8047dd5d103162a31e0540bb2ecb6e0
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/6.1.0 CPython/3.13.12
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Apr 21, 2026.
Transparency log