How to Scrape Medium Articles: RSS, JSON, and Claps
Scrape Medium articles cleanly: pull stories from RSS feeds, read the ?format=json payload past its XSSI prefix, and collect tags, authors, and clap counts.

To scrape Medium articles you almost never need to fight Medium's React front end or its Cloudflare edge. Medium hands you three quieter interfaces: an RSS feed for every profile, publication, and tag; a ?format=json payload sitting behind almost every URL; and a block of JSON-LD baked into each article page. This guide shows how to read all three, how to pull the fields that only exist in one of them (clap counts, the member-only flag, per-tag post counts), how to get past the RSS 10-item cap, and the one parsing gotcha that makes json.loads fail on Medium every single time. Every request routes through SparkProxy's Scraping API, so IP rotation and geo-targeting are one parameter instead of an infrastructure project.
Key Takeaways
- Medium exposes RSS feeds at
medium.com/feed/@user,/feed/tag/, and/feed/, and each returns the full HTML body of public posts incontent:encoded. No browser needed.- The
?format=jsonendpoint carries the fields RSS omits:virtuals.totalClapCountfor claps,virtuals.tagswith per-tag post counts, andisLockedfor the paywall. Its response is prefixed with])}while(1);, which you must strip before parsing.- Member-only stories return
isLocked: trueand only a preview in RSS. Scrape public content, skip the locked bodies, and never republish full article text.
Is scraping Medium legal?
Medium publishes RSS feeds and a JSON view on purpose, so reading public stories is not the same as breaking into a locked endpoint. The line that matters is public versus member-only, and facts versus expression.
In the United States, the Ninth Circuit's ruling in hiQ Labs v. LinkedIn (2022) held that scraping publicly accessible data, meaning content with no login wall, generally does not violate the Computer Fraud and Abuse Act. Separately, under Feist Publications v. Rural Telephone (1991), facts are not copyrightable. A story's title, its author, its tags, its publish date, and its clap count are facts about the post. The article body is creative expression owned by the author, so it is protected. That split gives you a clean rule: collect metadata freely, keep only short snippets of body text under fair use, and never republish someone's full article.
Two checks belong in any serious build. Read Medium's Terms of Service and its robots.txt before you crawl at scale, and rate-limit yourself so you are not degrading the service for anyone else. If the data feeds a commercial product, run it past a lawyer. This is engineering guidance, not legal advice. The general pattern for polite collection is worth reading in full in our guide on ethical scraping and rate limiting.
Scraping at scale? Skip the blocks.
Fast, unblockable datacentre proxies with unlimited bandwidth.
The Medium RSS feeds
Every Medium profile, publication, and tag has an RSS feed. The feeds return clean RSS 2.0 XML, they include the full post body for public stories, and they never require rendering. This is the same feed-first approach that makes an aggregator like Google News scriptable, covered in How to Scrape Google News.
| Feed | URL pattern |
|---|---|
| User profile | `https://medium.com/feed/@username` |
| Publication (on medium.com) | `https://medium.com/feed/publication-slug` |
| Publication (custom domain) | `https://blog.example.com/feed` |
| Tag | `https://medium.com/feed/tag/python` |
| Sitewide latest | `https://medium.com/feed/latest` |
Each carries title, link, a guid of the form https://medium.com/p/, one per tag, for the author, pubDate, and with the body HTML. The @ in a profile feed is required; medium.com/feed/username without it will not resolve.
One hard limit shapes everything downstream: a Medium RSS feed returns only the latest 10 items and does not paginate. For a low-frequency monitor that is fine. For a backfill you need the archive pages in the pagination section below.
The ?format=json endpoint and the )}while(1) prefix
Append ?format=json to almost any Medium URL and you get that page's underlying data as JSON: a profile, a single post, a tag page, a publication. This is where claps, the isLocked paywall flag, reading time, and richly-typed tags live. It is the same "hidden JSON behind the HTML" technique described in How to Scrape Hidden JSON API Endpoints, with one Medium-specific twist that trips up everyone the first time.
Medium does not return clean JSON. It prefixes every response with an anti-hijacking guard:
])}while(1);</x>{"success":true,"payload":{ ... }}
That leading ])}while(1); is deliberate. It is XSSI (cross-site script inclusion) protection: if some other site tries to load the URL as a , the while(1) traps it in an infinite loop instead of leaking your data. For you as a scraper it means one thing. Pass the raw body to json.loads and you get json.decoder.JSONDecodeError: Extra data or an unexpected-token error. Strip the exact prefix first, then parse. The payload shape you get back is:
payload
โโโ value # the post object (for a post URL)
โ โโโ title, creatorId, isLocked
โ โโโ virtuals # totalClapCount, tags[], readingTime, responsesCreatedCount
โโโ references
โโโ User # { userId: { name, username, bio, ... } }
โโโ Post # { postId: postObject } for profile/tag URLs
Set up the SparkProxy Scraping API
You can hit an RSS feed with a plain HTTP client, and for a handful of pulls that works. Once you poll many feeds on a schedule or fan out across the ?format=json endpoints, Medium's Cloudflare layer starts rate-limiting a repeat IP and occasionally serving an interstitial. Routing through the SparkProxy Scraping API turns IP rotation, geo-targeting, and optional rendering into request parameters.
The base URL is https://scrape.sparkproxy.io/api/v1, and it authenticates with an X-API-Key header. RSS and ?format=json are both static text, so keep render_js=false to stay on the 1-credit tier instead of paying for a browser you don't need.
curl -G "https://scrape.sparkproxy.io/api/v1" \
--data-urlencode "url=https://medium.com/feed/@username" \
--data-urlencode "render_js=false" \
-H "X-API-Key: YOUR_API_KEY"
A thin Python wrapper keeps the rest of the guide readable:
import requests
API = "https://scrape.sparkproxy.io/api/v1"
KEY = "YOUR_API_KEY"
def fetch(url, **params):
"""GET a URL through the SparkProxy Scraping API, return the raw body."""
params["url"] = url
r = requests.get(API, params=params,
headers={"X-API-Key": KEY}, timeout=120)
r.raise_for_status()
return r.text
One thing to get right: do not set SparkProxy's own format=json parameter here. That parameter wraps the response in a SparkProxy JSON envelope, and since Medium already returns JSON you would be double-wrapping. Leave the format at its html default so fetch hands back Medium's raw body, and you strip the XSSI prefix yourself.
Pull claps and full metadata from JSON
Claps, reading time, response counts, and per-tag post counts only come from the JSON payload. Here is the helper that fetches a Medium URL and strips the XSSI guard before parsing.
import json
XSSI_PREFIX = "])}while(1);</x>"
def medium_json(url):
body = fetch(url, render_js="false",
premium_proxy="true", country_code="US")
if body.startswith(XSSI_PREFIX):
body = body[len(XSSI_PREFIX):] # the fix for JSONDecodeError: Extra data
return json.loads(body)
For a single story, read the post value and its author out of references.User:
def post_meta(post_url):
data = medium_json(post_url + "?format=json")
value = data["payload"]["value"]
v = value["virtuals"]
user = data["payload"]["references"]["User"][value["creatorId"]]
return {
"title": value["title"],
"author": user["name"],
"username": user["username"],
"claps": v["totalClapCount"],
"responses": v["responsesCreatedCount"],
"reading_min": round(v["readingTime"], 1),
"tags": [t["slug"] for t in v["tags"]],
"member_only": value["isLocked"],
}
print(post_meta("https://medium.com/@username/a-story-title-1a2b3c4d5e6f"))
Here is the tip that saves the most requests. A profile's ?format=json payload already contains a references.Post map, and every post in it carries its own virtuals. So one call to a profile returns the clap count and tags for all of that author's recent stories, with no per-post requests at all.
def profile_posts_with_claps(username):
data = medium_json(f"https://medium.com/@{username}?format=json")
out = []
for post in data["payload"]["references"]["Post"].values():
v = post["virtuals"]
out.append({
"title": post["title"],
"claps": v["totalClapCount"],
"tags": [t["slug"] for t in v.get("tags", [])],
"locked": post["isLocked"],
})
return sorted(out, key=lambda p: p["claps"], reverse=True)
for p in profile_posts_with_claps("username")[:5]:
print(p["claps"], "claps -", p["title"])
Read the article JSON-LD
Every Medium article page embeds a block of Schema.org JSON-LD in its server HTML. It is a stable, standardized fallback for the basics: headline, author, datePublished, dateModified, and the cover image. It does not carry claps, so treat it as a complement to the JSON payload, not a replacement.
Because the JSON-LD sits in the initial server-rendered HTML, you can read it with render_js=false. Render only when you need the client-hydrated state.
import json, re
def article_jsonld(post_url):
html = fetch(post_url, render_js="false",
premium_proxy="true", country_code="US")
blocks = re.findall(
r'<script[^>]+type="application/ld\+json"[^>]*>(.*?)</script>',
html, re.S)
for raw in blocks:
obj = json.loads(raw)
if obj.get("@type") in ("NewsArticle", "Article", "BlogPosting"):
return {
"headline": obj.get("headline"),
"author": (obj.get("author") or {}).get("name"),
"published": obj.get("datePublished"),
"modified": obj.get("dateModified"),
"image": obj.get("image"),
}
return None
Why keep this around when ?format=json is richer? Two reasons. The JSON-LD schema is durable across front-end rewrites, while Medium's internal payload shape can shift. And if the ?format=json view is ever unavailable for a given URL, the JSON-LD is still right there in the page you already fetched.
The metered paywall: only scrape public
Medium runs a metered paywall. Non-members get a small number of free member-story reads per month, then member-only posts collapse to a preview. In the data, a member-only story is flagged isLocked: true, and its RSS content:encoded contains only the abstract that a metered-out reader would see.
The ethical and legal position from the first section applies directly here. Metadata about a locked post (its title, author, tags, clap count) is fine to collect. The full member-only body is not yours to extract or republish, and defeating the paywall to get it breaks both Medium's terms and the author's copyright. So filter locked stories out of any body-text pipeline:
def public_only(posts):
"""Keep public stories, skip member-only (isLocked) content."""
return [p for p in posts if not p["locked"]]
For a single post, the guard is value["isLocked"] from the JSON payload; for an RSS-first pipeline, a content:encoded block that ends in a "Read the full story" style teaser is the same signal. Collect the facts, drop the locked bodies.
Go past the latest 10: tag and archive pagination
The RSS 10-item cap is the wall most Medium scrapers hit. The way through is not pagination on the feed, which does not exist, but Medium's dated archive pages. Every tag and every publication exposes an archive addressable by year, month, and day:
https://medium.com/tag/python/archive/2026(a full year)https://medium.com/tag/python/archive/2026/07(one month)https://medium.com/tag/python/archive/2026/07/15(one day)
Walk the archive one day at a time and you can reconstruct a tag's history well beyond the last 10 posts. These pages hydrate client-side, so this is the one place render_js=true earns its cost.
from bs4 import BeautifulSoup
def tag_archive(tag, year, month=None, day=None):
url = f"https://medium.com/tag/{tag}/archive/{year}"
if month: url += f"/{month:02d}"
if day: url += f"/{day:02d}"
html = fetch(url, render_js="true", wait="2",
premium_proxy="true", country_code="US")
soup = BeautifulSoup(html, "html.parser")
links = set()
for a in soup.select('a[href*="/@"]'):
href = a.get("href", "").split("?")[0]
if "/@" in href and href.count("/") >= 4: # /@user/slug-hash
links.add(href)
return sorted(links)
import calendar
def crawl_month(tag, year, month):
urls = set()
for day in range(1, calendar.monthrange(year, month)[1] + 1):
urls.update(tag_archive(tag, year, month, day))
return sorted(urls)
print(len(crawl_month("machine-learning", 2026, 7)), "story URLs in July")
Feed each recovered URL back into post_meta from the claps section to attach clap counts and tags. Publications work the same way with https://.
Scale without getting blocked
A single feed pull is trivial. A collector polling many profiles and tags, then enriching each story with a JSON call, is a different animal, and it fails in predictable ways: 429s from over-polling one IP, Cloudflare challenges on repeat requests, and duplicate stories that appear in both a tag feed and an author feed.
Dedupe on the post_id from the RSS guid (or the /p/ in any URL), because the same story shows up across feeds under different tracking suffixes. Pace your requests, and back off on a 429 rather than re-hitting immediately.
import time, random
seen = set()
def collect(feed_urls, delay=1.5):
for feed_url in feed_urls:
for story in get_stories(feed_url):
if story["post_id"] in seen:
continue # already collected via another feed
seen.add(story["post_id"])
yield story
time.sleep(delay + random.random()) # jittered pacing between feeds
def with_retry(fn, *args, tries=4):
for n in range(tries):
try:
return fn(*args)
except requests.HTTPError as exc:
if exc.response is not None and exc.response.status_code == 429:
time.sleep(2 ** n + random.random()) # exponential backoff on 429
continue
raise
raise RuntimeError("retries exhausted")
Three rules keep this alive in production. Poll each feed no faster than the author actually publishes; most profiles do not need a check more than a few times a day. Let the proxy layer rotate IPs so no single address carries the whole load, which is exactly what premium_proxy=true plus a country_code exit gives you. And if a request does get challenged, the playbook in How to Bypass Cloudflare When Web Scraping and the broader How to Scrape High-Volume Data Without Rate Limiting both apply directly.
Frequently asked questions
FAQ
Medium closed its writer-facing API to new integrations and never offered an official read API for arbitrary stories. The practical public interfaces are the RSS feeds (medium.com/feed/@user, /feed/tag/, /feed/), the ?format=json payload behind most URLs, and the JSON-LD embedded in every article page.
Medium prefixes every JSON response with the XSSI guard ])}while(1);, and json.loads chokes on it with a JSONDecodeError: Extra data. Strip that exact prefix from the start of the body before parsing, then load the rest. It is anti-hijacking protection, not corruption.
Clap counts live only in the JSON payload at virtuals.totalClapCount, not in RSS or JSON-LD. Fetch a story with and read that field, or pull an author's ?format=json profile once and read totalClapCount for every post in its references.Post map.
Scrape only the public ones. Member-only stories carry isLocked: true and return just a preview in RSS. Their metadata (title, author, tags, claps) is fair to collect, but extracting or republishing the full member body defeats the metered paywall and infringes the author's copyright, so filter locked posts out of any body-text pipeline.
Medium caps every RSS feed at the latest 10 items and does not paginate. To reach older stories, crawl the dated tag or publication archive pages such as medium.com/tag/python/archive/2026/07/15, or read a profile's ?format=json references.Post map, then enrich each URL from there.
For a few feeds a datacenter IP is usually enough, since RSS and ?format=json are lightweight static responses. At volume Medium sits behind Cloudflare and rate-limits repeat IPs, so rotating residential exits with a matching country_code keep both the feeds and the JSON endpoints answering.
Get 20% off your first month
Premium datacentre proxies with unlimited bandwidth. Use the code at checkout.
Save up to 15% more on quarterly, half-yearly and yearly plans
Related articles

XPath and CSS Selectors: Scrapers That Don't Break
Scrape data with XPath and CSS selectors that survive redesigns: label-anchored XPath, stable attributes, namespace fixes, and a selector-drift detector.

Stealth Plugins for Puppeteer and Playwright: What Works
Stealth plugins patch the known automation tells in Puppeteer and Playwright. Which ones still matter, why the patch set is a signature, and how to test yours.

How to Scrape Zomato and Swiggy Data (Menus and Prices)
Scrape Zomato and Swiggy data that means something: pin the delivery coordinates, key every row by restaurant, pin and timestamp, and split the fee stack.
