- Python 95.2%
- Dockerfile 3.2%
- Shell 1.6%
- 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> |
||
|---|---|---|
| .dockerignore | ||
| .gitignore | ||
| docker-compose.yml | ||
| Dockerfile | ||
| entrypoint.sh | ||
| patreon_mobile.py | ||
| README.md | ||
| requirements.txt | ||
| serve.py | ||
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
- session_id (recommended, no curl_cffi): grab the
session_idcookie from a browser/app already logged into Patreon and pass it via--session-id,PATREON_SESSION_ID, or aSession:line increds.txt. All reads work over plainrequests; Cloudflare does not challenge GETs. - email + password (+ TOTP): full login. The login endpoint is
Cloudflare-fingerprinted, so this path needs
curl_cffibrowser impersonation. On success the session is cached in.session.jsonand 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) andcontent.htmlpoints at the local copies, so the archive is self-contained offline. - Downloadable attachments and audio land in
files/and are linked at the bottom ofcontent.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.jsonafter every page. Stop it any time (Ctrl-C, kill, reboot) and re-run the same--backfillcommand to continue where it left off.--restartstarts over from the top;--max-pages Nstops 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:
--backfillreaches into the past; a normal--pagesrun 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.jsonlogin cache and, optionally, acreds.txt.
docker compose (recommended)
# 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
./dataand./configmust be writable by it (they are if you own them). - Credentials come from env vars (
PATREON_SESSION_ID, orPATREON_EMAIL/PATREON_PASSWORD/PATREON_TOTP_SECRET) or acreds.txtmounted 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/loginendpoint is behind Cloudflare bot management, which fingerprints the TLS/HTTP2 handshake. Plainrequests/urllibgets a403 Attention Required.curl_cffiwith browser impersonation (chrome) passes. This is the one hard dependency. - CSRF: an anonymous
GET /api/current_usersets ana_csrfcookie; echo its value in theX-CSRF-TOKENheader on POSTs. - Login (cookie session, not OAuth bearer):
POST /api/login?json-api-version=1.0body{"data":{"type":"user","attributes":{"email","password"}}}. With 2FA on, this returns401 TOTPTwoFactorRequired(code 109). Resubmit the same call withtwo_factor_code(6-digit TOTP) andtwo_factor_method:"totp"added toattributes. Success sets asession_idcookie, which is the durable credential (cached in.session.jsonso later runs skip the login + TOTP). - Feed:
GET /api/stream. The app sendsjson-api-use-default-includes: trueandjson-api-use-default-fields: trueheaders, so the server returns full posts plusincludedcampaigns / users / tags without us enumeratingfields[...]/include=.... Pagination vialinks.next(page[cursor]=<timestamp>). - Post content: lives in
attributes.content_json_string, a ProseMirror / TipTap document (nodes:heading,paragraph,textwithbold/italic/linkmarks,image, lists, blockquote). The legacy HTMLcontentfield is empty.patreon_mobile.pyconverts the doc to HTML. - Images & files:
GET /api/posts/{id}?include=media,images,audio, attachments_media,...returnsmediaobjects withfile_name,mimetype,size_bytes, a signeddownload_url(original quality), and animage_urlsmap of display variants. We downloaddownload_url(or animage_urlsvariant) and rewritecontent.htmlto the local copy. Inlineimagenodes in the doc also carry a signedsrc, but it is a width-limited variant; the media object'sdownload_urlis the full-resolution file. Signed URLs expire (token-time), which is why we download rather than hotlink. - Videos:
attributes.embed.htmlholds 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_viewisfalse; we emit a placeholder instead of the (absent) body.
App identifiers (from the APK)
- Package
com.patreon.android, versionName126.38.0.14. - HTTP stack OkHttp
5.3.2. Login screenUnifiedLoginActivity.
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.