ZMediumToMarkdown

Download Medium posts as clean Markdown, preserving structure, images, links, code blocks, and common embeds for plain Markdown or Jekyll workflows.
Try it in 30 seconds
gem install ZMediumToMarkdown
ZMediumToMarkdown -p "https://medium.com/<USER>/<POST>"
The converted Markdown is written to ./Output/zmediumtomarkdown/. Public posts usually work without cookies.
For paywalled posts, bulk downloads, or CI / GitHub Actions, you'll need Medium login cookies. On a local TTY the tool can auto-capture them by opening Chrome the first time Cloudflare blocks; CI runs need a Cloudflare Worker proxy. See Cookies & Cloudflare setup.
π Setting Up Medium Cookies and a Cloudflare Worker Proxy β
Features
- Convert one Medium post or download every post from a Medium username.
- Preserve headings, blockquotes, lists, inline code, fenced code blocks, images, links, and front matter.
- Render common embeds: GitHub Gists, Twitter / X, YouTube, Vimeo, SoundCloud, Spotify, and generic OG-image cards.
- Download images locally and emit paths for either plain Markdown output or Jekyll projects.
- Read paywalled posts when valid Medium
sid/uidcookies (Membership account) are provided. - Auto-capture login cookies via Chrome on a local TTY when Cloudflare blocks, into an encrypted on-disk cache reused on subsequent runs.
- Skip unchanged posts by comparing
last_modified_at, making scheduled backups practical. - Keep multilingual text stable, including CJK, Arabic, Hebrew, Cyrillic, and emoji.
- Stream rendered Markdown to stdout for embedding callers (e.g. mcp-medium-reader) via
--stdout/--list, no filesystem writes. - Run as a Ruby gem, local CLI tool, or GitHub Action.
Cookies & Cloudflare setup
Medium's GraphQL endpoint is protected by Cloudflare. Two failure modes interrupt a run:
- Cloudflare bot challenge β HTTP 403 / "Just a momentβ¦". Empirically: after ~10 posts without cookies, or ~25 posts from CI / datacenter IPs without a Worker proxy.
- Paywalled posts β return only the public preview unless
sid/uidcome from a logged-in Medium Member account.
What you need by scenario
| Scenario | sid / uid cookies |
Cloudflare Worker proxy |
|---|---|---|
| CI / CD (GitHub Actions, cloud runners) | Strongly recommended | Strongly recommended |
| Local machine (laptop / desktop) | Recommended for paywalled posts | Optional |
| Paywalled posts (anywhere) | Required (Membership account) | Independent |
Three ways to clear a Cloudflare block
- Auto-login on a TTY (local). When Cloudflare blocks an interactive run and Google Chrome is installed, the tool opens Chrome at https://medium.com; sign in / clear the challenge, and
sid/uid/cf_clearance/_cfuvidare captured into an AES-256-GCM-encrypted cache at~/.zmediumtomarkdown(chmod 0600). Cached cookies are reused on subsequent runs and refreshed on every new block, so you rarely repeat the flow. RunZMediumToMarkdown --authonce to trigger this flow on demand and seed the cache before any real run. Pass--non-interactive(or setMEDIUM_NO_AUTO_BROWSER=1) to suppress the prompt and fail fast. - Cloudflare Worker proxy. Permanent fix, recommended for CI. Point the GraphQL endpoint (and optionally the image CDN) at your own Worker so requests originate from inside Cloudflare's network instead of a flagged datacenter IP.
- Manual
cf_clearance/_cfuvidcookies. Short-term unblocking (~30 min). Useful when you can't run Chrome and don't want to set up a Worker proxy yet.
Inputs
CLI flag wins over env var, env var wins over the on-disk cache.
| Cookie / variable | CLI flag | Env var |
|---|---|---|
sid (Medium login) |
-s, --cookie_sid |
MEDIUM_COOKIE_SID |
uid (Medium login) |
-d, --cookie_uid |
MEDIUM_COOKIE_UID |
cf_clearance |
--cookie_cf_clearance |
MEDIUM_COOKIE_CF_CLEARANCE |
_cfuvid |
--cookie_cfuvid |
MEDIUM_COOKIE_CFUVID |
| Worker proxy host | -x, --medium_host |
MEDIUM_HOST β set to your Worker URL with or without a /_/graphql suffix; the gem only uses the origin and rebuilds paths. Covers both medium.com and miro.medium.com via path dispatch. |
| Worker shared secret | β | MEDIUM_HOST_SECRET (sent as X-Medium-Proxy-Secret header on proxy requests; matches the SECRET constant in the Worker script) |
# Env-var form (preferred β keeps secrets out of shell history)
export MEDIUM_COOKIE_SID="<your sid>"
export MEDIUM_COOKIE_UID="<your uid>"
ZMediumToMarkdown -p "https://medium.com/..."
# Or as flags for one-off runs
ZMediumToMarkdown -p "https://medium.com/..." -s "<sid>" -d "<uid>"
# Behind a single Cloudflare Worker that handles both medium.com and miro.medium.com.
# Either form of MEDIUM_HOST works β the gem only uses the origin.
export MEDIUM_HOST="https://my-worker.my-account.workers.dev/"
export MEDIUM_HOST_SECRET="<your-secret>"
ZMediumToMarkdown -u zhgchgli
Full setup guide
The setup guide covers cookie extraction, Cloudflare Worker deployment, security notes, and GitHub Actions wiring:
Installation
Gem (recommended)
gem install ZMediumToMarkdown
On macOS, prefer a managed Ruby (rbenv / rvm / asdf) over the system Ruby. Installing gems against /usr/bin/ruby usually requires sudo and modifies the OS Ruby environment.
From source
git clone https://github.com/ZhgChgLi/ZMediumToMarkdown
cd ZMediumToMarkdown
bundle install
bundle exec ruby bin/ZMediumToMarkdown -p "https://medium.com/..."
Usage
ZMediumToMarkdown [options]
-s, --cookie_sid SID Medium logged-in cookie sid (or $MEDIUM_COOKIE_SID)
-d, --cookie_uid UID Medium logged-in cookie uid (or $MEDIUM_COOKIE_UID)
--cookie_cf_clearance VALUE Cloudflare cf_clearance cookie (or $MEDIUM_COOKIE_CF_CLEARANCE).
Short-term Cloudflare unblocking; expires ~30 min.
--cookie_cfuvid VALUE Cloudflare _cfuvid cookie (or $MEDIUM_COOKIE_CFUVID).
Companion to cf_clearance.
-x, --medium_host URL Cloudflare Worker proxy URL (or $MEDIUM_HOST). One Worker
covers both medium.com and miro.medium.com via path
dispatch. Set $MEDIUM_HOST_SECRET to the same secret used
in the Worker script. Strongly recommended for CI / bulk
runs β see the wiki setup guide.
--non-interactive Never prompt or open Chrome on a Cloudflare block. CI runners
auto-detect this; use the flag to force the same behavior on a TTY.
--auth Open Chrome to sign in, capture cookies into the encrypted
cache (~/.zmediumtomarkdown), and exit. Run once before bulk /
scheduled jobs to seed the cache up front.
-u, --username USERNAME Download every post by a Medium username
-p, --postURL POST_URL Download a single post URL
--jekyll Emit Jekyll-friendly output (combine with -u or -p)
--stdout Render Markdown to stdout; skip all image/asset downloads.
Use with -p or -u. Logs and banners go to stderr.
--list With -u, emit one NDJSON line per post (title, url, dates,
tags, etc.) to stdout. Skips bodies and image downloads.
--limit N Cap the number of posts processed by -u in --stdout / --list.
-n, --new Update to the latest version (gem install only)
-c, --clean Remove every downloaded post under cwd
-v, --version Print the current version
-h, --help Show this message
Examples
# Single post into ./Output/zmediumtomarkdown/
ZMediumToMarkdown -p "https://medium.com/<user>/<slug>-<id>"
# Every post by a user, Jekyll-friendly into ./_posts/zmediumtomarkdown/ + ./assets/
ZMediumToMarkdown -u zhgchgli --jekyll
For paywalled / bulk / CI runs, also pass cookies and (optionally) a Worker proxy β see Cookies & Cloudflare setup.
Deprecated flags.
-j USERNAMEand-k POST_URLstill work for backwards compatibility but emit a warning. Use--jekyll -u β¦/--jekyll -p β¦instead.
Output layout
| Mode | Markdown destination | Image destination |
|---|---|---|
Plain (-p / -u) |
./Output/zmediumtomarkdown/<date>-<slug>.md |
./Output/zmediumtomarkdown/assets/<post_id>/ |
Jekyll (--jekyll) |
./_posts/zmediumtomarkdown/<date>-<post_id>.md |
./assets/<post_id>/ |
When run with -u, plain mode additionally nests under ./Output/users/<username>/.
Reruns are cheap β posts whose last_modified_at matches the existing front matter are skipped.
Embedding callers β --stdout / --list
The gem can also be invoked as a backend for tools that need rendered Markdown without filesystem side effects β most notably the mcp-medium-reader MCP server, which exposes Medium reading to LLMs.
In --stdout / --list mode:
- Markdown / NDJSON is written to stdout; banners, progress, and warnings go to stderr.
- No filesystem writes, no
Output/directory, noassets/directory. - No image downloads β image references stay as remote URLs on
miro.medium.com(or yourMEDIUM_HOSTproxy origin when configured). - Skip-already-downloaded checks are bypassed; the post is rendered fresh every time.
# Stream a single post's Markdown to stdout.
ZMediumToMarkdown --stdout -p "https://medium.com/<user>/<slug>-<id>"
# Stream every post by a user, separated by `\n\n---\n\n`. --limit caps the count.
ZMediumToMarkdown --stdout -u zhgchgli --limit 5
# List a user's posts as NDJSON (one JSON object per line). No bodies.
ZMediumToMarkdown --list -u zhgchgli --limit 20
# {"title":"β¦","url":"β¦","creator":"β¦","firstPublishedAt":"β¦","latestPublishedAt":"β¦","tags":["β¦"],"description":"β¦","pin":false}
Cookies and Worker-proxy env vars apply the same way as in normal mode.
Quick-start templates
- GitHub Actions backup, no code: How-to walkthrough
- Working Action repo example: https://github.com/ZhgChgLi/ZMediumToMarkdown-github-action
Minimal GitHub Action
name: ZMediumToMarkdown
on:
workflow_dispatch:
schedule:
- cron: "10 1 15 * *" # 01:10 on day-of-month 15
jobs:
backup:
runs-on: ubuntu-latest
steps:
- uses: ZhgChgLi/ZMediumToMarkdown@main
env:
MEDIUM_COOKIE_SID: ${{ secrets.MEDIUM_COOKIE_SID }}
MEDIUM_COOKIE_UID: ${{ secrets.MEDIUM_COOKIE_UID }}
with:
command: "-u zhgchgli"
Store MEDIUM_COOKIE_SID / MEDIUM_COOKIE_UID as repository secrets, not repository variables, and never hard-code them in YAML. Pass them through the step's env: block instead of the command: string so they stay out of logs. For CI, also point MEDIUM_HOST at a Cloudflare Worker proxy; see the setup guide.
Example output
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
Blocked by Medium's Cloudflare layer (HTTP 403) |
Cloudflare bot challenge; common after about 10 posts without cookies, or about 25 posts from CI / datacenter IPs without a Worker proxy | Local: on a TTY the tool auto-opens Chrome to clear the challenge and refresh cookies (cached at ~/.zmediumtomarkdown). If Chrome is not installed, open https://medium.com in any browser, clear the challenge, then rerun. CI / datacenter: set up cookies and a Cloudflare Worker proxy β see the setup guide. |
This post is behind Medium's paywall⦠even though I set cookies |
Cookies do not belong to a Medium Member account that can read this post, or they have expired after inactivity | Refresh sid / uid from a logged-in browser and verify the account has access to the post. Cookies stay valid as long as they keep being used. |
Error: Too Many Requests, blocked by Medium |
Hit Mediumβs rate limit | Slow the schedule down or split the run; the tool already retries up to 10 times. |
| Markdown looks fine but CJK / emoji is mojibaked | Older release β encoding regression | Upgrade to β₯ 2.6.7 (this release force-encodes all responses to UTF-8). |
An iframe came back blank |
Generic embed (non-Twitter, non-gist, non-YouTube, non-widgetic) without an OG image | Expected β the source has no image to embed. The tool emits an empty line so paragraph spacing is preserved. |
Development
bundle install
bundle exec rake test # run the minitest suite
The suite includes a 174-paragraph end-to-end fixture under test/fixtures/. To regenerate the golden Markdown file after intentional output changes:
UPDATE_FIXTURES=1 bundle exec rake test
CI runs the same rake test against Ruby 3.2 / 3.3 / 3.4.
Disclaimer
Medium's Terms of Service
Medium's official Terms of Service forbid using "any software, script, robot, spider or other automatic device, process or means (including crawlers, browser plugins and add-ons or any other technology) to access the Services." β Medium Rules.
ZMediumToMarkdown is exactly the kind of tool that statement contemplates. Use of this gem may conflict with Medium's Terms of Service. The author makes no claim that this is permitted use; you accept all risk, including account suspension, IP-address blocks, or legal action by Medium. On its first invocation the CLI prints a one-time consent prompt and requires you to type yes before any network call is made; in non-interactive environments set ZMTM_TOS_ACCEPTED=1 or pass --accept-terms once.
Copyright
All content downloaded using ZMediumToMarkdown β articles, images, video β is subject to copyright and belongs to its respective owner. This tool does not claim ownership of any downloaded content.
Downloading and using copyrighted content without the owner's permission may be illegal. ZMediumToMarkdown does not condone copyright infringement and will not be held responsible for misuse of this tool. Users are solely responsible for ensuring they have the necessary permissions and rights for any content they download.
By using ZMediumToMarkdown you acknowledge and agree to comply with all applicable copyright laws and regulations.
Full Terms
The complete Terms of Use are in TERMS.md; the privacy posture is in PRIVACY.md. Both are versioned β when they change in a backwards-incompatible way, the CLI invalidates the existing consent marker and re-prompts on the next run.
Other works
Ruby libraries
- RubyRangeable β generic interval-set container, published as the
rangeablegem; powers ZMediumToMarkdown's markup rendering since 3.6.0. Spec: RangeableRFC.
Swift libraries
- ZMarkupParser β pure-Swift HTML β
NSAttributedStringwith customizable style/tag mapping. - ZPlayerCacher β lightweight
AVAssetResourceLoaderDelegatecache forAVPlayerItemstreaming. - SwiftRangeable β Swift reference implementation of Rangeable.
Integration tools
- mcp-medium-reader β macOS MCP server that wraps this gem so LLMs (Claude Desktop, etc.) can read Medium posts.
- XCFolder β convert Xcode virtual groups to real directories (Tuist / XcodeGen friendly).
- ZReviewTender β fetch App Store / Google Play reviews into your workflow.
- linkyee β open-source LinkTree alternative on GitHub Pages.
About
Donate
If this project helped you, please star the repo or buy me a beer. PRs and issue reports welcome.