1. 2scraper
  2. Publishing & community
  3. Medium
Open source · MITRuns with no key, proxy or account

Medium Scraper

Tag feeds, day archives, author pages and full story text from Medium — no key, no proxy.

  • Playwright
  • Selenium
  • Puppeteer
  • Scraping Browser API
  • JSON
  • CSV
medium-scraper
# Tag feed
$ python3 playwright_scraper.py --url "https://medium.com/tag/python"
✓ 6 sample rows · 28 columns in sample_output.json
4modes: tag, archive, author, post
128stories from one archive day, measured Sep 2026
28output columns
0keys or proxies used for the README's measurements
Available Medium scrapers

What the Medium scraper collects

Reads a Medium tag feed, a tag's day archive (up to 128 stories per day), an author's profile or a single story, and writes one row per story: title, subtitle, author, publication, dates, claps, responses, reading time, word count, paywall flag, tags and — for a single story — the full body text. Output is JSON and CSV with a run-metadata sidecar.

Tag feed

Input: A /tag/{slug} URL

The stories a tag feed ships in one fetch (53 on /tag/python, Sep 2026), with title, author, claps and tags.

Tag day archive

Input: A /tag/{slug}/archive/{yyyy}/{mm}/{dd} URL

Up to 128 stories per day with reading time, word count and language; walk several days with --pages, in parallel with --concurrency.

Author page

Input: A /@{username} URL

One row per story on the writer's profile with claps, responses and reading time.

Single story

Input: A /p/{id} URL or any story URL

One row for the story including its full text in content and the character count.

Quick start

From zero to Medium data in 3 steps

  1. Install

    Clone the repository and install one browser engine. Python 3.9+.

    $ git clone https://github.com/2scraper/medium-scraper.git && cd medium-scraper
    $ python3 -m venv .venv && . .venv/bin/activate
    $ pip install -r requirements.txt -r requirements-playwright.txt
    $ playwright install chromium
  2. Run a scraper

    Pick what you need and run it. Every flag is documented in the README.

    $ python3 playwright_scraper.py --url "https://medium.com/tag/python"
  3. Get structured data

    Rows land in JSON or CSV (--format), with a .meta.json sidecar recording status, pages fetched and why the run stopped.

Output

Medium data fields

28 columns per row in a predictable schema. The most useful ones:

FieldWhat it holds
skuMedium's own 12-hex post id — survives renames and moves
titleStory title
subtitleStory subtitle
authorAuthor display name
author_usernameAuthor handle
publicationPublication the story appears in, if any
published_atFirst publication time
clapsClap count
responsesNumber of responses
reading_time_minEstimated reading time in minutes
word_countWord count
is_paywalledMember-only story flag
languageStory language
tagsList of story tags
contentFull story text (single-story mode)
data_sourceWhich view built the row: obvinit, apollo, jsonld or dom
[
  {
    "source": "medium.com",
    "scraped_at": "2026-09-16T11:38:11.223347+00:00",
    "url": "https://medium.com/@dailypythonpro/i-tried-python-3-15s-lazy-imports-here-s-what-happen…",
    "sku": "bc8b23e21c66",
    "title": "I Tried Python 3.15’s Lazy Imports. Here’s What Happened",
    "subtitle": "What I learned about startup time, deferred work, and the traps you shouldn’t ignore.",
    "canonical_url": null,
    "author": "Daily Python",
    "author_username": "dailypythonpro",
    "author_url": "https://medium.com/@dailypythonpro",
    "publication": null,
    "publication_url": null,
    "published_at": "2026-09-12T19:37:42.037000+00:00",
    "updated_at": "2026-09-12T19:37:42.037000+00:00",
    "claps": 73,
    "responses": 1,
    "reading_time_min": 13.93,
    "word_count": 3333,
    "is_paywalled": true,
    "is_series": false,
    "language": "en",
    "tags": [
      "python",
      "python-programming",
      "python3",
      "… 2 more"
    ],
    "preview_image_url": "https://miro.medium.com/v2/resize:fit:1200/1*mj0tV8VSIYZ0ZnykVXGVlA.png",
    "content": null,
    "content_chars": null,
    "data_source": "obvinit",
    "page": 1,
    "position": 1
  },
  {
    "source": "medium.com",
    "scraped_at": "2026-09-16T11:38:11.223371+00:00",
    "url": "https://medium.com/@mayursurani/building-a-production-grade-end-to-end-data-engineering…",
    "sku": "a268c4aefacc",
    "title": "Building a Production-Grade End-to-End Data Engineering Project: Agentic Databases, Dat…",
    "subtitle": "End to End Production grade databrick + DBT + Agentic Database + Airflow Project",
    "canonical_url": null,
    "author": "Mayurkumar Surani",
    "author_username": "mayursurani",
    "author_url": "https://medium.com/@mayursurani",
    "publication": null,
    "publication_url": null,
    "published_at": "2026-09-12T08:47:46.149000+00:00",
    "updated_at": "2026-09-12T08:47:46.149000+00:00",
    "claps": 21,
    "responses": 0,
    "reading_time_min": 12.95,
    "word_count": 3212,
    "is_paywalled": true,
    "is_series": false,
    "language": "en",
    "tags": [
      "databricks",
      "airflow",
      "agentic-ai",
      "… 2 more"
    ],
    "preview_image_url": "https://miro.medium.com/v2/resize:fit:1200/1*dCzIMpQo_yqAUINUHfhugg.png",
    "content": null,
    "content_chars": null,
    "data_source": "obvinit",
    "page": 1,
    "position": 2
  }
]

First 2 of 6 rows from a real run — sample_output.json. Long values shortened here.

Built for Medium

Handles what breaks naive Medium scrapers

Gets past the HeadlessChrome block

Medium's Cloudflare hard-blocks the HeadlessChrome token in the User-Agent, not headless browsing. These engines build their UA from the browser's own version without that token (11 of 11 blocked vs 0 of 11, measured Sep 2026).

Two renderers, both read

Tag feeds and author pages ship Apollo state; day archives ship a legacy payload with 84 fields per story. data_source records which view built each row, so a null reading time on a tag feed is the site, not a failed read.

Managed challenge retried in a fresh context

Cloudflare's transient 'Just a moment' page cleared on the next attempt in a new browser context in 9 of 9 cases, so --retries opens a fresh context and no solve is ever paid for.

Traps refused with the reason

Publication home pages ship post stubs with no data, ?page=2 on a tag feed is ignored, and an empty archive day redirects to its month — each is detected rather than written as plausible rows.

Everything built in

Scraping infrastructure included

The same tested core runs every 2scraper repository, so the hard parts work the same way on every site.

CAPTCHA handling

Detects the challenge a page really serves and, with a 2Captcha key, solves it only when it blocks the data — a capped number of solves per page.

Proxy rotation

Single proxy or a pool from a file, rotated on blocks with a fresh browser per exit. Credentials stay out of argv and logs.

Fingerprints

Optional complete device identity from the 2Captcha Fingerprint API — user agent, client hints, locale and timezone that agree with each other.

Scraping Browser API

Point --cdp-endpoint at a managed Chromium with its own exit country — no browser infrastructure to run yourself.

Honest run metadata

Every run writes a .meta.json sidecar and distinct exit codes: complete, blocked, empty and partial are never confused, and a failed run never overwrites good data.

Diff two runs

diff_runs.py compares two complete Medium snapshots by id — new, removed and changed rows for monitoring.

Use cases

What teams do with Medium data

Topic and trend research

Walk a tag's day archives to collect every story published on a topic, with claps and responses as engagement signals.

Author and competitor tracking

Follow writers' output, reading times and engagement over time and diff runs by post id.

Content datasets

Collect full story text with tags and language for NLP corpora or content analysis.

Paywall and publication analysis

Measure the share of member-only stories per tag or publication using is_paywalled and publication.

Access & pricing

Free to run. Pay only for what the site requires.

Runs with no key, proxy or account

No key and no proxy needed: every number in the README was measured with Playwright's bundled Chromium from an ordinary datacentre address. What 2Captcha's products add here is not getting in: the Scraper API returns identical rows with no browser at $0.0005 a request, and the Scraping Browser API let an author page scroll to 55 rows against 10 locally (Sep 2026). For long archive walks the limit is rate — --delay first, rotating proxies to scale.

Optional 2Captcha services — captcha solving, the Scraping Browser API, residential proxies and fingerprints — work behind one key.

Good to know

  • --pages and --concurrency only apply to day archives; tag feeds and author pages are one fetch.
  • Concurrency is implemented in the Playwright engine only.
  • towardsdatascience.com moved off Medium and is refused.

2scraper vs. hosted scraper APIs

2scraperTypical hosted API
CodeOpen source, MIT — read and patch itClosed
CostFree; optional services billed only when usedSubscription or per request / per result
Runs onYour machine, server or CIThe vendor's infrastructure
OutputJSON/CSV files + run metadataAPI responses
When the site changesFix it yourself or open an issue / PRWait for the vendor
FAQ

Medium scraper FAQ

Do I need a proxy or a 2Captcha key?

No. All README measurements were taken with no key and no proxy. Proxies help only when a long archive walk hits Medium's rate limit.

How do I get many stories?

Use --mode archive on a tag's day archive: up to 128 stories per day, walkable over several days with --pages and --concurrency.

Why are reading time and word count null on some rows?

A tag feed's payload does not contain them. Archive, author and single-story rows carry them; data_source shows which view built the row.

Can it scrape a publication's home page?

No — it ships post ids with no data. The scraper refuses it and suggests archive mode on a tag the publication uses, or post mode on story URLs.

Does it get the full article text?

Yes, in single-story mode: content holds the body and content_chars its length.

Is scraping Medium legal?

It reads public pages. Follow Medium's terms and the law where you operate, and respect authors' rights over their text.