Medium через IPv6 прокси: публикация статей и парсинг контента
Содержание
- Почему Medium сопротивляется автоматизации
- Анатомия Medium API
- Парсинг: где ломается наивный подход
- Сравнение вариантов проксирования
- Публикация: обход XSRF и сессий
- Что ломается на практике
- Кейс: парсинг 5000 статей за ночь
- Публикация: черновик vs публичный пост
- Заголовки, которые реально важны
- Ограничения IPv6-подхода
- Итоговая схема
Почему Medium сопротивляется автоматизации
Medium с 2023 года ужесточил политику в отношении ботов. Cloudflare Turnstile, rate limiting по IP-подсети, fingerprinting TLS-отпечатков через JA3/JA4. Обычный `curl` с одного IPv4-адреса получает 403 уже на втором запросе к `/api/...`. Проблема не в заголовках User-Agent — их подделать легко. Проблема в том, что Medium видит один и тот же IP, один и тот же TLS ClientHello и одинаковые тайминги между запросами.
IPv6 здесь даёт неожиданное преимущество. Провайдеры выдают /64 или /48 префиксы — это миллиарды адресов на одного клиента. Каждый запрос можно отправлять с нового адреса. Для Medium это выглядит как трафик от тысяч разных пользователей из разных точек мира, даже если физически всё идёт с одной машины.
Но есть нюанс: не каждый IPv6-прокси умеет выдавать адреса пулом. Датацентровые /128 из одного блока /48 всё равно палятся по ASN. Нужны residential или мобильные пулы, либо ротация между несколькими ASN.
Анатомия Medium API
Публичного API у Medium нет с 2021 года. Официальный `api.medium.com/v1` закрыт для новых интеграций. Всё, что осталось — внутренние endpoint'ы, которые использует веб-клиент.
Основные точки:
- `POST https://medium.com/_/graphql` — GraphQL, основной канал
- `GET https://medium.com/@username` — публичный профиль
- `GET https://medium.com/feed/@username` — RSS, отдаёт последние 10 статей
- `POST https://medium.com/_/api/posts` — создание черновика
Публикация идёт через GraphQL mutation `createPost`. Заголовок, тело в Markdown, теги, canonical URL. Запрос требует `x-xsrf-token` и cookie `sid`. Без них — 401.
Пример запроса через IPv6-прокси:
```bash
curl -6 --proxy "http://[2001:db8::1]:8080" \
-X POST "https://medium.com/_/graphql" \
-H "content-type: application/json" \
-H "x-xsrf-token: \$TOKEN" \
-H "cookie: sid=\$SID; uid=\$UID" \
-d '{"operationName":"CreatePost","variables":{"title":"Test","content":"# Hello"},"query":"mutation CreatePost(\$title:String!,\$content:String!){createPost(title:\$title,content:\$content){id}}"}'
```
Ключ `-6` заставляет curl использовать IPv6. Прокси в квадратных скобках — обязательный синтаксис для IPv6-литералов в URL.
Парсинг: где ломается наивный подход
Парсинг Medium — задача с подвохом. HTML отдаётся server-side, но контент статьи подгружается через hydration JSON внутри ``. Регулярками его не взять. Нужен либо парсер JSON, либо headless-браузер.
Второй подвох — lazy loading. На странице профиля первые 10 статей в HTML, остальные тянутся через GraphQL с курсором. Без прокси-ротации курсор быстро упирается в rate limit.
Третий — Medium отдаёт разные версии страницы для разных регионов. Из Германии видна одна лента, из Сингапура — другая. Это влияет на рекомендации и на то, какие статьи попадут в выдачу профиля.
Пример парсера на Python с ротацией IPv6:
```python
import httpx
import asyncio
import json
import re
POOL = [
"http://[2001:db8:1::1]:8080",
"http://[2001:db8:2::1]:8080",
"http://[2001:db8:3::1]:8080",
]
async def fetch_post(url: str, proxy: str) -> dict:
async with httpx.AsyncClient(
proxies=proxy,
timeout=20.0,
headers={"user-agent": "Mozilla/5.0 (X11; Linux x86_64) Gecko/20100101 Firefox/121.0"}
) as client:
r = await client.get(url)
r.raise_for_status()
m = re.search(r'window\.__APOLLO_STATE__\s*=\s*({.+?});', r.text)
if not m:
return {"error": "no apollo state", "status": r.status_code}
state = json.loads(m.group(1))
return {"title": extract_title(state), "body": extract_body(state)}
async def main(urls):
tasks = []
for i, u in enumerate(urls):
tasks.append(fetch_post(u, POOL[i % len(POOL)]))
return await asyncio.gather(*tasks)
```
`httpx` умеет работать с IPv6-прокси без плясок. `requests` тоже, но хуже держит keep-alive через SOCKS6.
Сравнение вариантов проксирования
| Тип прокси | Скорость отклика | Вероятность бана | Стоимость за 1 ГБ |
|---|---|---|---|
| Datacenter IPv4 | 30-80 мс | высокая | \$0.5-2 |
| Datacenter IPv6 | 20-60 мс | средняя | \$0.2-1 |
| Residential IPv4 | 150-400 мс | низкая | \$3-15 |
| Residential IPv6 | 100-300 мс | низкая | \$2-10 |
| Mobile IPv6 | 200-600 мс | минимальная | \$10-30 |
Цифры усреднённые по рынку на конец 2024. IPv6 дешевле, потому что адресное пространство огромное и провайдеры не экономят. Но качество пулов сильно разнится: у некоторых провайдеров IPv6-адреса помечены как «datacenter» в базах MaxMind, даже если это residential.
Публикация: обход XSRF и сессий
Medium привязывает `x-xsrf-token` к cookie `sid`. Токен живёт около 24 часов, сессия — до 30 дней. Если публиковать с разных IP, Medium может инвалидировать сессию при резкой смене геолокации. Классический сценарий: логин из Москвы, через минуту POST из Амстердама — сессия убита.
Решение — sticky sessions. Один аккаунт, один IPv6-адрес на всю сессию. Пул адресов нужен для параллельных аккаунтов, а не для рандомизации внутри одного.
Пример на Python с привязкой аккаунт→прокси:
```python
import httpx, json
class MediumClient:
def __init__(self, sid: str, xsrf: str, proxy: str):
self.proxy = proxy
self.client = httpx.Client(
proxies=proxy,
headers={
"cookie": f"sid={sid}",
"x-xsrf-token": xsrf,
"content-type": "application/json",
"origin": "https://medium.com",
},
)
def create_draft(self, title: str, markdown: str, tags: list[str]) -> str:
query = """
mutation CreateDraft(\$input: CreatePostInput!) {
createPost(input: \$input) { id }
}
"""
payload = {
"operationName": "CreateDraft",
"query": query,
"variables": {"input": {"title": title, "content": markdown, "tags": tags}},
}
r = self.client.post("https://medium.com/_/graphql", json=payload)
r.raise_for_status()
return r.json()["data"]["createPost"]["id"]
```
Тег-лимит — 5 штук, минимум 1. Заголовок от 1 до 100 символов. Тело — до 1 МБ. Если превысить, GraphQL вернёт `errors[0].extensions.code = "CONTENT_TOO_LARGE"` без подробностей.
Что ломается на практике
Первое — TLS fingerprinting. Medium использует Cloudflare, а тот смотрит на JA3. `curl` и `httpx` имеют отпечатки, отличные от браузерных. Если запрос идёт с IPv6-адреса, но JA3 палится как Python-клиент, можно получить Turnstile-челлендж. Решение — `curl_cffi` с имперсонацией Chrome или Firefox.
Второе — DNS. Если прокси SOCKS6, DNS-резолв уходит через прокси. Если HTTP-прокси, часть клиентов резолвит локально. Для Medium разница критична: локальный DNS может вернуть IPv4-адрес, и запрос уйдёт мимо IPv6-пула.
Третье — MTU. IPv6 не фрагментирует пакеты на маршрутизаторах. Если MTU на пути меньше 1280 байт, соединение просто падает. Для прокси через туннели (6in4, Teredo) это регулярная история. Проверяется через `ping6 -M do -s 1452 medium.com`.
Кейс: парсинг 5000 статей за ночь
Задача — собрать корпус статей по тегу `machine-learning` за 2023-2024 годы. Объём — примерно 5 тысяч постов.
Наивный подход: один IPv4, `requests` с задержкой 1 секунда. Результат — 120 статей, потом 429 на 6 часов.
Попытка с datacenter IPv6 /64: скорость выросла до 3000 статей за ночь, но на 800-й Medium начал отдавать Turnstile-челлендж. Причина — все адреса из одного /48, ASN один, поведение одинаковое.
Решение — пул из 12 разных /48 от разных провайдеров плюс `curl_cffi` с имперсонацией Firefox 121. Плюс рандомизация таймингов: 200-800 мс между запросами, не фиксированный интервал. Итого 5000 статей за 7 часов, 0 банов.
Ключевой момент — не сам IPv6, а разнообразие. Один /64 хуже, чем 12 разных /48. Medium смотрит на префикс, не на отдельный адрес.
Публикация: черновик vs публичный пост
GraphQL mutation `createPost` создаёт черновик. Чтобы он стал публичным, нужен отдельный вызов `publishPost`. Между ними Medium делает проверку: сканирует контент на спам, проверяет ссылки, иногда ставит в очередь на ручную модерацию.
Если публиковать с нового IPv6-адреса каждый раз, Medium может пометить аккаунт как подозрительный и отправить все посты в ревью. Задержка публикации — от часа до суток. С одного стабильного адреса — публикация мгновенная.
Отсюда правило: для публикации sticky IPv6, для парсинга — ротация. Смешивать нельзя.
Заголовки, которые реально важны
Medium проверяет около 40 заголовков, но критичных — семь:
- `user-agent` — должен соответствовать TLS-отпечатку
- `accept-language` — влияет на региональную версию
- `origin` — обязательно `https://medium.com` для POST
- `referer` — для GraphQL не критичен, для REST важен
- `x-xsrf-token` — без него 401
- `cookie` — sid, uid, xsrf
- `content-type` — `application/json` для GraphQL
Чего НЕ надо делать: подделывать `x-forwarded-for`. Medium игнорирует его, но Cloudflare может забанить за несоответствие.
Ограничения IPv6-подхода
Не все сайты поддерживают IPv6. Medium — да, но его CDN (Fastly) иногда отдаёт контент только через IPv4. Если прокси форсит IPv6-only, часть запросов упадёт с `ENETUNREACH`.
Решение — dual-stack прокси с приоритетом IPv6. Happy Eyeballs (RFC 8305) справляется сам, но не все клиенты его реализуют. `curl` с `--happy-eyeballs-timeout-ms 200` работает корректно.
Второе ограничение — репутация пулов. Некоторые IPv6-диапазоны уже в чёрных списках. Перед покупкой пула стоит проверить через `https://api.abuseipdb.com/api/v2/check` или просто прогнать тестовые запросы.
Итоговая схема
Для парсинга: пул IPv6 из 10+ разных /48, `curl_cffi` с имперсонацией, ротация адреса каждые 50-100 запросов, тайминги 200-800 мс, retry с экспоненциальной задержкой на 429 и 403.
Для публикации: один sticky IPv6 на аккаунт, `httpx` или `requests`, стабильные заголовки, никакой ротации. XSRF-токен обновлять раз в сутки.
Прокси-сервис на lexic.ml даёт пулы IPv6 с ротацией по /64 и sticky-режимом на выбор — как раз под эту схему. Не панацея, но инфраструктурную часть закрывает.
Главное — не гнаться за скоростью. Medium терпит медленных, но предсказуемых клиентов. Быстрых и хаотичных — банит.