← Назад в базу знаний

Medium через IPv6 прокси: публикация статей и парсинг контента

Medium через 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 терпит медленных, но предсказуемых клиентов. Быстрых и хаотичных — банит.

✔️Купить прокси