Reddit через IPv6 прокси: парсинг сабреддитов и обход лимитов
Содержание
Почему Reddit душит парсеров
Reddit отдаёт публичный JSON по адресу `https://www.reddit.com/r//new.json`. Выглядит как подарок. На деле — ловушка для наивных. Без авторизации лимит примерно 10 запросов в минуту на IP. С OAuth-токеном — 100 запросов в минуту на client_id. Превысил — получил `429 Too Many Requests` и бан IP на 10-15 минут. Иногда дольше.
Проблема в том, что лимит считается по IP-адресу. Один сервер — один IP — один счётчик. Параллельный парсинг сотни сабреддитов с одной машины упирается в стену за минуту. Прокси-пулы на IPv4 дорогие: датацентр-адреса Reddit режет агрессивно, резиденциальные стоят от \$3 за гигабайт. IPv6 меняет расклад.
У IPv6 адресное пространство /64 — это 18 квинтиллионов адресов. Один провайдер выдаёт клиенту /64 или /48. Каждый адрес из подсети — отдельный счётчик лимитов для Reddit. Даже если Reddit склеивает по /64 (а он не всегда это делает), у вас сотни /64 в /48. На практике ротация IPv6 даёт в 10-50 раз больше «бесплатных» запросов, чем один IPv4.
Как Reddit на самом деле считает лимиты
Заголовки ответа говорят больше, чем документация. Смотрите на `X-Ratelimit-Used`, `X-Ratelimit-Remaining`, `X-Ratelimit-Reset`. Reddit использует эти заголовки для OAuth-запросов. Для анонимных JSON-эндпоинтов они тоже приходят, но с другими значениями.
```bash
curl -sI -A "Mozilla/5.0 (X11; Linux x86_64) parser/1.0" \
"https://www.reddit.com/r/python/new.json?limit=100" | grep -i ratelimit
```
Ответ примерно такой:
```
x-ratelimit-used: 1
x-ratelimit-remaining: 9.0
x-ratelimit-reset: 60
```
`remaining: 9.0` — это не «9 запросов осталось навсегда», это скользящее окно. Сброс через 60 секунд. Если гнать быстрее — `remaining` уйдёт в ноль и придёт 429 с `Retry-After: 600`. Вот тут и начинается боль: 600 секунд простоя на одном IP.
Есть нюанс с User-Agent. Reddit требует уникальный UA с контактом разработчика. Дефолтный `python-requests/2.31.0` ловит 429 почти мгновенно. UA вида `myapp/1.0 (by /u/username)` живёт дольше. Но и он не спасает от лимита по IP.
Схема с IPv6-ротацией
Идея простая: каждый воркер ходит через свой IPv6-адрес из выданной подсети. Reddit видит разные IP — разные счётчики. Через прокси-провайдера с пулом IPv6 (например, lexic.ml даёт /64-подсети на аккаунт) вы получаете сотни адресов в минуту без затрат на резиденциальные IPv4.
Архитектура такая. Пул из N воркеров. Каждый берёт задачу из очереди (сабреддит + тип листинга: new/hot/top). Прокси-менеджер выдаёт свободный IPv6 из пула. Воркер делает запрос, парсит JSON, кладёт результат в БД, возвращает IP в пул через cooldown (например, 65 секунд — чтобы окно лимита точно сбросилось).
Cooldown критичен. Если вернуть IP в пул сразу, второй запрос с того же адреса попадёт в то же окно и сожжёт остаток лимита. 65 секунд — безопасный зазор с учётом сетевой задержки и рассинхрона часов.
Рабочий пример на Python
Берём `aiohttp` и `asyncio`. Синхронный `requests` с ротацией прокси работает, но упирается в GIL при большом числе воркеров.
```python
import asyncio
import aiohttp
import time
from collections import deque
PROXY_HOST = "gate.lexic.ml"
PROXY_PORT = 8080
POOL_SIZE = 50
COOLDOWN = 65
class IPv6Pool:
def __init__(self, size):
self.free = asyncio.Queue()
self.cooldown = COOLDOWN
for i in range(size):
self.free.put_nowait(f"user-session-{i}")
async def acquire(self):
session_id = await self.free.get()
return session_id
async def release(self, session_id):
await asyncio.sleep(self.cooldown)
await self.free.put(session_id)
async def fetch_subreddit(session, pool, sub, listing="new"):
sid = await pool.acquire()
proxy = f"http://{sid}:{sid}@{PROXY_HOST}:{PROXY_PORT}"
url = f"https://www.reddit.com/r/{sub}/{listing}.json?limit=100"
headers = {"User-Agent": "parser/1.0 (by /u/yourname)"}
try:
async with session.get(url, proxy=proxy, headers=headers, timeout=20) as resp:
if resp.status == 429:
retry = int(resp.headers.get("Retry-After", 600))
await asyncio.sleep(retry)
return None
data = await resp.json()
return data["data"]["children"]
except aiohttp.ClientError:
return None
finally:
asyncio.create_task(pool.release(sid))
async def main(subs):
pool = IPv6Pool(POOL_SIZE)
async with aiohttp.ClientSession() as session:
tasks = [fetch_subreddit(session, pool, s) for s in subs]
results = await asyncio.gather(*tasks)
return results
if __name__ == "__main__":
subs = [f"sub{i}" for i in range(1, 101)]
asyncio.run(main(subs))
```
Прокси-провайдер обычно даёт sticky-сессии по логину: `user-session-N`. Каждая сессия прибита к своему IPv6 на время жизни. Смена сессии — новый адрес. Это дешевле, чем крутить SOCKS5-туннели вручную.
Reddit API против JSON-эндпоинтов
Многие лезут в OAuth сразу. Не всегда оправдано. Сравним.
| Параметр | Публичный JSON | OAuth API |
|---|---|---|
| Лимит без токена | ~10 req/min на IP | — |
| Лимит с токеном | — | 100 req/min на client_id |
| Авторизация | не нужна | OAuth2, client_id + secret |
| Данные | посты, комменты, листинги | всё то же + модерация, личные сообщения |
| Бан за превышение | IP на 10-15 мин | client_id навсегда |
| Скорость старта | 5 минут | регистрация приложения, 1-2 дня на модерацию |
OAuth даёт стабильные 100 req/min. Но client_id банится насовсем при злоупотреблении — восстановить сложно. Публичный JSON с IPv6-ротацией даёт больше суммарной пропускной способности при меньшем риске: сгорел IP — взял следующий. Для сбора публичных постов это выигрышная стратегия.
OAuth нужен, когда требуются приватные данные или запись (постинг, голосование). Для read-only парсинга сабреддитов публичный JSON + ротация — рабочий вариант.
Пагинация и курсоры
Reddit отдаёт по 100 постов на страницу максимум (параметр `limit`, hard cap 100). Дальше — `after` из `data.after`. Без курсора вы получите первые 100 постов и всё.
```python
async def paginate(session, pool, sub, pages=10):
after = None
all_posts = []
for _ in range(pages):
sid = await pool.acquire()
proxy = f"http://{sid}:{sid}@gate.lexic.ml:8080"
params = {"limit": 100}
if after:
params["after"] = after
url = f"https://www.reddit.com/r/{sub}/new.json"
async with session.get(url, params=params, proxy=proxy,
headers={"User-Agent": "parser/1.0"}) as r:
if r.status != 200:
break
data = await r.json()
posts = data["data"]["children"]
if not posts:
break
all_posts.extend(posts)
after = data["data"].get("after")
if not after:
break
await pool.release(sid)
return all_posts
```
Тонкость: `after` живёт ограниченное время. Если между страницами прошло больше пары минут (а с cooldown 65 секунд это норма), курсор может протухнуть. Reddit вернёт пустой `children` или 400. Решение — не разносить страницы одного сабреддита по разным IP с большими паузами, а брать 3-5 страниц подряд с одного адреса (укладываясь в лимит), потом менять IP.
Что ломается на практике
Первый грабли-кейс: сервер на Ubuntu 22.04, `aiohttp 3.9`, пул из 100 IPv6. Через час работы — 40% запросов с 403. Причина: провайдер выдал /64, а Reddit начал склеивать адреса по /64-префиксу. Все 100 адресов попали в один счётчик. Решение: запросить у провайдера /48 или несколько /64 и ротировать между подсетями, а не внутри одной.
Второй кейс: тот же парсер, но с `requests` в 20 потоках. Латентность выросла с 200 мс до 3-4 секунд. Причина — TCP-хендшейк на каждый запрос через новый прокси-туннель плюс DNS-резолв. С `aiohttp` и keep-alive соединениями на сессию латентность упала до 250-350 мс. Разница в 10 раз на объёме в 50k запросов — это часы против минут.
Третий кейс: парсинг r/all с `limit=100` и пагинацией на 50 страниц. На 30-й странице стабильно 429, хотя `remaining` показывал 5. Причина: r/all — самый нагруженный эндпоинт, у него отдельный, более жёсткий лимит (примерно 5 req/min на IP против 10 для конкретных сабреддитов). Решение: для r/all увеличить cooldown до 120 секунд и снизить `limit` до 50.
Мониторинг и алерты
Без метрик парсер тихо деградирует. Минимум, что нужно собирать: доля 429/403 в минуту, средняя латентность, число активных IP в пуле, глубина очереди задач. Prometheus + Grafana закрывают это за вечер.
Ключевая метрика — `ratelimit_remaining` из заголовков. Если среднее по пулу падает ниже 3, пора расширять пул или снижать частоту. Второй индикатор — `Retry-After` в 429-ответах. Рост с 60 до 600 секунд означает, что Reddit ужесточил политику для вашей подсети.
Логирование IP в каждом запросе обязательно. Без него не поймёте, какой адрес сгорел и почему. Формат: `timestamp ip subreddit status latency ratelimit_remaining`. Парсится в ELK или Loki за минуты.
Юридическая сторона и ToS
Reddit ToS запрещает автоматизированный сбор без разрешения. На практике публичный JSON отдают открыто, а банят за агрессивность, не за сам факт парсинга. Границы размыты. Если собираетесь публиковать данные или коммерчески использовать — читайте Reddit API Terms и Data Use Agreement. Для внутренней аналитики и исследований прецеденты мягче.
Технически безопасная стратегия: не превышать 10 req/min на IP, ставить честный User-Agent с контактом, уважать `Retry-After`, не парсить приватные сабреддиты. Это снижает шанс попасть в чёрный список провайдера и получить жалобу.
IPv6-ротация — инструмент, не индульгенция. Она решает проблему лимитов, но не отменяет правила. Парсер, который ведёт себя как десять тысяч вежливых пользователей, живёт долго. Парсер, который долбит Reddit с 50k адресов в минуту, ловит бан подсети за сутки.