Private Patreon feed
  • Python 95.2%
  • Dockerfile 3.2%
  • Shell 1.6%
Find a file
Aleksei Besogonov 9b467c2ad6 Add static HTTP server for the feed and post content
- serve.py: stdlib http.server over the mirror dir. / -> index.html,
  /feed.xml -> RSS (served as application/rss+xml), posts and their
  images/files under their folders. Binds 127.0.0.1 by default; no-cache
  headers so regenerated content is picked up.
- docker-compose.yml: a `web` service serving the ./data volume read-only
  on port 8080 (WEB_PORT override); Dockerfile now ships serve.py.
- README: "Serving the feed and posts" section, including the --base-url
  note for RSS images and a security warning (unauthenticated, paid content).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-20 19:29:24 -07:00
.dockerignore Add Docker packaging with persistent volumes 2026-09-20 19:24:37 -07:00
.gitignore Add Docker packaging with persistent volumes 2026-09-20 19:24:37 -07:00
docker-compose.yml Add static HTTP server for the feed and post content 2026-09-20 19:29:24 -07:00
Dockerfile Add static HTTP server for the feed and post content 2026-09-20 19:29:24 -07:00
entrypoint.sh Add Docker packaging with persistent volumes 2026-09-20 19:24:37 -07:00
patreon_mobile.py Add Docker packaging with persistent volumes 2026-09-20 19:24:37 -07:00
README.md Add static HTTP server for the feed and post content 2026-09-20 19:29:24 -07:00
requirements.txt Add Docker packaging with persistent volumes 2026-09-20 19:24:37 -07:00
serve.py Add static HTTP server for the feed and post content 2026-09-20 19:29:24 -07:00

patrss-mobile

Mirroring your Patreon home feed by talking to the private JSON:API that the Patreon Android app uses, instead of scraping the website with a headless browser. Produces a local per-post archive (full text + downloaded images and files) and, optionally, an RSS feed.

This is the "pretend to be the mobile app" experiment. It works, and it is simpler and more robust than browser scraping.

Why this beats scraping

Browser scraping (Playwright) Mobile API (this)
Content source Rendered HTML, must be parsed Structured JSON:API
Full post body Reconstruct from DOM content_json_string (ProseMirror doc)
Images Scrape <img>, lazy-load hassles Signed URLs / download_url in the JSON
Pagination Scroll simulation links.next cursor
Runtime cost Full Chromium Plain requests (curl_cffi only to log in)
Fragility Breaks on markup changes Stable API contract

Authentication — two options

  1. session_id (recommended, no curl_cffi): grab the session_id cookie from a browser/app already logged into Patreon and pass it via --session-id, PATREON_SESSION_ID, or a Session: line in creds.txt. All reads work over plain requests; Cloudflare does not challenge GETs.
  2. email + password (+ TOTP): full login. The login endpoint is Cloudflare-fingerprinted, so this path needs curl_cffi browser impersonation. On success the session is cached in .session.json and later runs reuse it (no re-login, no TOTP).

creds.txt format:

Login: you@example.com
Pass: your-password
OTP: AAAA BBBB CCCC ...        # base32 TOTP secret (omit if no 2FA)
Session: <session_id cookie>   # optional; if set, curl_cffi is not needed

Quick start

# option 1: session cookie only, no curl_cffi needed
python patreon_mobile.py --session-id "<session_id>" --pages 3 --out mirror --rss

# option 2: full login (needs curl_cffi, see note below)
pip install -r requirements.txt
python patreon_mobile.py --pages 3 --out mirror --rss

Output: one directory per post

mirror/
  index.html                      # browsable list of all posts, newest first
  feed.xml                        # (with --rss) RSS 2.0, content:encoded
  <Creator>/
    2026-09-20_170039083_Chapter-Eleven/
      content.html                # full post, <img> rewritten to local files
      post.json                   # raw JSON:API post + relationships
      images/                     # every image, best quality (download_url)
      files/                      # audio / PDFs / other downloadable attachments
  • Images are downloaded at original quality (download_url) and content.html points at the local copies, so the archive is self-contained offline.
  • Downloadable attachments and audio land in files/ and are linked at the bottom of content.html.
  • HLS streaming video (Mux) is not downloaded; its stream URL is recorded in the post instead. External embeds (YouTube etc.) are kept as iframes.
  • Re-running is incremental: already-downloaded files are skipped.

Flags: --pages N (feed pages, ~12 posts each), --limit N (first N posts), --out DIR, --rss [FILE], --base-url URL, --session-id.

Backfilling the whole history (slow, resumable)

Normal runs grab the most recent pages. To archive the entire feed history, use --backfill, which walks the feed backwards page by page at a deliberately low request rate:

python patreon_mobile.py --session-id "<sid>" --backfill --rss
  • Low rate: defaults to ~1.5 API requests/min (one every ~40s). Set it with --rate N (requests/min). Image/file downloads hit the CDN, not the API, so they run a bit faster and don't count against that budget.
  • Resumable: the next cursor is saved to mirror/.backfill_state.json after every page. Stop it any time (Ctrl-C, kill, reboot) and re-run the same --backfill command to continue where it left off. --restart starts over from the top; --max-pages N stops after N pages this run (chunked backfill).
  • No wasted requests: posts already mirrored are detected from disk and skipped without fetching them again.
  • Division of labour: --backfill reaches into the past; a normal --pages run picks up new posts at the top. Run the normal one on a schedule and let the backfill grind through history in the background.

Because it is slow by design, run it detached, e.g.:

nohup python patreon_mobile.py --session-id "<sid>" --backfill --rss > backfill.log 2>&1 &

Running with Docker

The image bundles Python, curl_cffi, and the script. Two volumes hold the persistent state:

  • /data — the mirror output (posts, images, files, index.html, feed.xml).
  • /config — the .session.json login cache and, optionally, a creds.txt.
# put your session cookie in the environment (or a creds.txt in ./config)
export PATREON_SESSION_ID="<session_id cookie>"

docker compose up --build           # runs continuously, syncing every 30 min

./data and ./config are created next to the compose file and survive restarts. Tune the cadence with LOOP_SECONDS (unset it to run once and exit). One-off commands reuse the same volumes and credentials:

docker compose run --rm patrss --pages 5 --rss        # sync 5 recent pages
docker compose run --rm patrss --backfill             # walk history (resumable)

plain docker

docker build -t patrss-mobile .

docker run --rm \
  -e PATREON_SESSION_ID="<session_id cookie>" \
  -v "$PWD/data:/data" -v "$PWD/config:/config" \
  patrss-mobile --pages 3 --rss

Notes:

  • The container runs as uid 1000; the mounted ./data and ./config must be writable by it (they are if you own them).
  • Credentials come from env vars (PATREON_SESSION_ID, or PATREON_EMAIL/PATREON_PASSWORD/PATREON_TOTP_SECRET) or a creds.txt mounted at /config/creds.txt. Nothing secret is baked into the image.

Serving the feed and posts

serve.py is a small static HTTP server over the mirror directory:

/            -> index.html (browsable post list)
/feed.xml    -> the RSS feed (served as application/rss+xml)
/<Creator>/<post>/content.html, images/, files/   -> the posts

Locally:

python serve.py --dir mirror --port 8080          # http://127.0.0.1:8080/

With Docker, the web service in docker-compose.yml serves the same ./data volume the mirror writes to:

docker compose up -d            # runs both: patrss (sync) and web (serve)
# browse http://localhost:8080/ , subscribe to http://localhost:8080/feed.xml

For the images embedded in the RSS to load in a feed reader, generate the feed with a --base-url that matches where you serve it, so the links are absolute:

python patreon_mobile.py ... --rss --base-url http://your-host:8080

(The on-disk content.html pages always use local relative image paths and work through the server without a base URL; --base-url only affects the RSS.)

Security: the server is unauthenticated and serves your paid subscription content. serve.py binds to 127.0.0.1 by default. Only expose it (--host 0.0.0.0, or the Docker web service) on a trusted network, or put it behind a reverse proxy that adds authentication and TLS.

How it works (protocol notes)

Everything below was recovered from the app APK (string analysis of the dex) and confirmed against the live API.

  • Base URL: https://www.patreon.com/api/ (JSON:API, Content-Type: application/vnd.api+json).
  • Cloudflare: the /api/login endpoint is behind Cloudflare bot management, which fingerprints the TLS/HTTP2 handshake. Plain requests/urllib gets a 403 Attention Required. curl_cffi with browser impersonation (chrome) passes. This is the one hard dependency.
  • CSRF: an anonymous GET /api/current_user sets an a_csrf cookie; echo its value in the X-CSRF-TOKEN header on POSTs.
  • Login (cookie session, not OAuth bearer): POST /api/login?json-api-version=1.0 body {"data":{"type":"user","attributes":{"email","password"}}}. With 2FA on, this returns 401 TOTPTwoFactorRequired (code 109). Resubmit the same call with two_factor_code (6-digit TOTP) and two_factor_method:"totp" added to attributes. Success sets a session_id cookie, which is the durable credential (cached in .session.json so later runs skip the login + TOTP).
  • Feed: GET /api/stream. The app sends json-api-use-default-includes: true and json-api-use-default-fields: true headers, so the server returns full posts plus included campaigns / users / tags without us enumerating fields[...]/include=.... Pagination via links.next (page[cursor]=<timestamp>).
  • Post content: lives in attributes.content_json_string, a ProseMirror / TipTap document (nodes: heading, paragraph, text with bold/italic/ link marks, image, lists, blockquote). The legacy HTML content field is empty. patreon_mobile.py converts the doc to HTML.
  • Images & files: GET /api/posts/{id}?include=media,images,audio, attachments_media,... returns media objects with file_name, mimetype, size_bytes, a signed download_url (original quality), and an image_urls map of display variants. We download download_url (or an image_urls variant) and rewrite content.html to the local copy. Inline image nodes in the doc also carry a signed src, but it is a width-limited variant; the media object's download_url is the full-resolution file. Signed URLs expire (token-time), which is why we download rather than hotlink.
  • Videos: attributes.embed.html holds the provider iframe (YouTube etc.), passed through as-is. Native video is a Mux HLS stream (media.display.url = ...m3u8?token=...); we record the URL but do not fetch the segments (would need ffmpeg).
  • Locked posts: attributes.current_user_can_view is false; we emit a placeholder instead of the (absent) body.

App identifiers (from the APK)

  • Package com.patreon.android, versionName 126.38.0.14.
  • HTTP stack OkHttp 5.3.2. Login screen UnifiedLoginActivity.

Files

  • patreon_mobile.py — client (session_id or login auth, session cache), ProseMirror→HTML, per-post mirror + downloader, index/RSS, CLI.
  • serve.py — static HTTP server for the mirror (RSS feed + post content).
  • Dockerfile, docker-compose.yml, entrypoint.sh — container packaging.
  • creds.txt — your credentials (gitignored).
  • .session.json — cached cookies (gitignored).
  • mirror/ — generated output (gitignored).

Note on installing curl_cffi

This machine's Python is PEP 668 "externally managed" and has no python3-venv. Options: sudo apt install python3-venv then a venv, or pip install --target=./pylibs --break-system-packages curl_cffi and run with PYTHONPATH=./pylibs.

Caveat

This uses a private, unpublished API and a real login. It is for personal mirroring of your own subscriptions. Expect to fix things if Patreon changes the API, tightens Cloudflare, or the impersonation target drifts.