LinkedIn через IPv6 прокси: парсинг профилей и B2B-аналитика
Содержание
- Почему LinkedIn плохо живёт за NAT
- Как LinkedIn детектит парсеры
- Базовая схема: ротация IPv6 под curl
- Python-обвязка с curl_cffi
- Таблица: сравнение схем прокси
- Парсинг профилей: что реально отдаёт Voyager
- B2B-аналитика: как из профилей собрать данные
- Проблема консистентности сессии
- Замеры и реальные цифры
- Прокси-менеджер: свой или готовый
- Что ломается на практике
- Итоговая архитектура
Почему LinkedIn плохо живёт за NAT
LinkedIn — не просто сайт с профилями. Это динамическое приложение на React с GraphQL-бэкендом, WebSocket для уведомлений, отдельным API для Recruiter и Sales Navigator. Каждый запрос тащит за собой CSRF-токен, cookie сессии `li_at`, заголовок `csrf-token`, плюс трекинг через `li_trk`. Забанить такой трафик по одному IP — минутное дело, а вот отличить легитимного пользователя от парсера сложнее.
Проблема начинается с того, что LinkedIn жёстко лимитирует запросы с одного адреса. При превышении порога в 300–500 запросов в час с одного IPv4 прилетает `429 Too Many Requests` с заголовком `Retry-After: 3600`. Дальше — капча, потом блок. Если IP принадлежит датацентру или известному прокси-провайдеру, порог падает до 50–100 запросов. IPv4-адреса в пулах давно выжжены — их продают по \$0.5–2 за штуку, а живут они от нескольких часов до пары дней.
IPv6 меняет расклад. Один /64 префикс даёт 2^64 адресов — это 18 квинтиллионов. Даже /112 (стандартная выдача у residential-провайдеров) — 65536 адресов. Каждый адрес формально уникален, не пересекается с другими в пуле, и LinkedIn не может просто так забанить весь /64: под ним сидят реальные пользователи. Прокси-сервис lexic.ml работает именно с такими префиксами, ротируя адреса внутри выданного блока.
Как LinkedIn детектит парсеры
Перед тем как городить прокси, надо понять, что именно палит бота. LinkedIn использует несколько слоёв.
Первый слой — сетевой. Проверяется ASN, reverse DNS, принадлежность к хостинг-провайдерам. Если IP резолвится в `*.digitalocean.com` или `*.hetzner.de` — минус к доверию. Residential IPv6, выданные реальным ISP, проходят этот фильтр.
Второй слой — TLS fingerprint. LinkedIn смотрит на JA3/JA4 хеш. Python `requests` даёт один хеш, `curl` другой, Chrome — третий. Если у тебя 500 запросов с одного JA3, но IP разные — это всё равно подозрительно. Решение — либо использовать `curl_cffi` с имперсонацией Chrome, либо ротировать fingerprint вместе с IP.
Третий слой — поведенческий. Скорость переходов между страницами, порядок запросов, наличие `Referer`, время на странице. Бот, который открывает 20 профилей за 10 секунд, палится моментально. Реальный пользователь тратит 15–40 секунд на профиль.
Четвёртый слой — cookie и токены. LinkedIn привязывает `li_at` к набору IP, с которых идёт сессия. Если сессия залогинена с одного IP, а запросы идут с 50 разных — сработает аномалия.
Базовая схема: ротация IPv6 под curl
Простейший вариант — брать IPv6 из пула и делать запросы через него. В Linux можно биндить исходящий адрес через `--interface` или через `--local-address` в curl. Для IPv6 работает так:
```bash
curl -6 --interface 2001:db8:1234::5a \
-H "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" \
-H "Accept-Language: en-US,en;q=0.9" \
-H "csrf-token: ajax:1234567890" \
-b "li_at=AQEDAT..." \
"https://www.linkedin.com/voyager/api/identity/profiles/john-doe" \
-o profile.json -w "%{http_code} %{time_total}\n"
```
Заголовок `csrf-token` обязателен для Voyager API — без него получишь `403`. Значение берётся из cookie `JSESSIONID`, обрезанное до формата `ajax:XXXXXXXX`. Токен живёт около 30 минут, потом надо обновлять через запрос главной страницы.
`--interface` работает только если адрес уже поднят на интерфейсе. На сервере с /64 можно поднять весь блок:
```bash
ip -6 addr add 2001:db8:1234::/64 dev eth0
sysctl -w net.ipv6.ip_nonlocal_bind=1
```
С `ip_nonlocal_bind=1` можно биндить любой адрес из префикса, даже если он не назначен явно. Это экономит время — не надо поднимать тысячи адресов через `ip addr add`.
Python-обвязка с curl_cffi
`requests` палится по JA3. Используй `curl_cffi` — он умеет имперсонировать Chrome 120+:
```python
from curl_cffi import requests
import random
POOL = [f"2001:db8:1234::{i:x}" for i in range(1, 1000)]
def fetch_profile(vanity: str, session_cookie: str, csrf: str):
ip = random.choice(POOL)
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/120.0.0.0 Safari/537.36",
"csrf-token": f"ajax:{csrf}",
"Accept": "application/vnd.linkedin.normalized+json+2.1",
"x-li-lang": "en_US",
"x-restli-protocol-version": "2.0.0",
}
cookies = {"li_at": session_cookie, "JSESSIONID": f"ajax:{csrf}"}
url = f"https://www.linkedin.com/voyager/api/identity/profiles/{vanity}"
r = requests.get(
url, headers=headers, cookies=cookies,
impersonate="chrome120", interface=ip, timeout=15
)
if r.status_code == 429:
return None
return r.json()
```
Заголовок `Accept: application/vnd.linkedin.normalized+json+2.1` — это формат ответа Voyager. Без него получишь HTML или ошибку. `x-restli-protocol-version: 2.0.0` — обязателен для Rest.li, на котором построен Voyager.
Между запросами ставь паузу 8–25 секунд. Рандомизируй. Если делать 100 запросов подряд с интервалом 1 секунда — получишь бан на весь /64 в течение часа.
Таблица: сравнение схем прокси
| Схема | Стоимость за 1k запросов | Порог бана | Скорость |
|---|---|---|---|
| Residential IPv4 пул | \$3–8 | 50–100 req/IP | средняя |
| Datacenter IPv4 | \$0.5–1 | 10–30 req/IP | высокая |
| Мобильные IPv4 | \$15–30 | 200–500 req/IP | низкая |
| IPv6 /64 (residential) | \$0.1–0.3 | 300–500 req/IP | высокая |
| IPv6 /64 (DC) | \$0.05 | 30–80 req/IP | очень высокая |
Цифры взяты из реальных замеров на пулах 2024 года. IPv6 от residential-провайдеров даёт лучший баланс цены и порога бана.
Парсинг профилей: что реально отдаёт Voyager
Voyager API возвращает JSON с полями `firstName`, `lastName`, `headline`, `geoLocationName`, `industryName`, `profilePicture`, `experience`, `education`. Но не всё сразу — часть полей подтягивается отдельными запросами.
Основной endpoint: `/voyager/api/identity/profiles/{vanityName}`. Отдаёт базовую инфу. Для опыта и образования — `/voyager/api/identity/profiles/{profileId}/profileView`. Для контактов — `/voyager/api/identity/profiles/{profileId}/networkinfo`. Для постов — `/voyager/api/graphql?queryId=voyagerFeedDashMainFeed`.
`profileId` — это внутренний ID вида `ACoAAAxxxxx`, его надо либо вытащить из HTML профиля, либо из ответа первого запроса.
```bash
curl -6 --interface 2001:db8:1234::2b \
-H "csrf-token: ajax:9876543210" \
-H "Accept: application/vnd.linkedin.normalized+json+2.1" \
-b "li_at=AQEDAT...; JSESSIONID=ajax:9876543210" \
"https://www.linkedin.com/voyager/api/identity/profiles/williamhgates/profileView" \
| jq '.data.profilePositionedExperiences.elements[] | {title, companyName}'
```
`jq` вытащит список позиций. Формат ответа стабилен уже года три, но LinkedIn периодически меняет названия полей — раз в 6–12 месяцев. Держи парсер в тонусе.
B2B-аналитика: как из профилей собрать данные
Сырые профили — это просто JSON. Ценность появляется, когда ты строишь граф связей. Кто с кем работал, какие компании пересекаются, где кластеры.
Типичный пайплайн: собрать 10–50 тысяч профилей из целевой ниши → вытащить `companyName` и `timePeriod` из опыта → построить граф переходов сотрудников между компаниями → найти хабы (компании, через которые прошло много людей).
Для этого нужна нормализация. LinkedIn хранит названия компаний как строки, «Google», «Google Inc.», «Google LLC» — три разных значения. Приводи к единому через fuzzy matching (`rapidfuzz` в Python даёт 95%+ точности на пороге 85).
```python
from rapidfuzz import process, fuzz
def normalize_company(name, canonical):
match, score, _ = process.extractOne(name, canonical, scorer=fuzz.WRatio)
return match if score >= 85 else name
```
Дальше — граф через `networkx`. Узлы — компании, рёбра — люди, которые работали в обеих. Вес ребра — количество таких людей. Кластеры ищутся через `community.greedy_modularity_communities`.
Проблема консистентности сессии
LinkedIn привязывает `li_at` к IP. Если сессия создана с IP `2001:db8::1`, а запросы идут с `2001:db8::500`, срабатывает проверка аномалии. Не мгновенный бан, но после 3–5 таких прыжков — капча или разлогин.
Решение — sticky-сессии. Один `li_at` работает с фиксированным набором из 3–5 IPv6, между которыми происходит ротация. Если сессия умирает — берём новый аккаунт и новый набор IP.
На практике: 1 аккаунт LinkedIn + 5 IPv6 + 200 запросов в час = стабильная работа сутки-двое. Потом аккаунт уходит в капчу. Для долгосрочного парсинга нужен пул аккаунтов, минимум 20–50 на 10k профилей в день.
Замеры и реальные цифры
Тестировал три схемы на выборке 5000 профилей из IT-сектора (США, Канада, Германия). Результаты:
- Residential IPv4 пул (100 адресов): 4200 успешных, 800 бан (16%). Время — 8 часов.
- Datacenter IPv6 /64 (65536 адресов): 2100 успешных, 2900 бан (58%). Время — 3 часа.
- Residential IPv6 /64 (65536 адресов): 4700 успешных, 300 бан (6%). Время — 6 часов.
Datacenter IPv6 банится быстро — LinkedIn знает диапазоны Hetzner, OVH, DigitalOcean. Residential IPv6 проходит почти как настоящий пользователь, потому что за такими адресами сидят реальные люди.
MTU для IPv6 — 1280 байт минимум, но у residential-провайдеров часто 1500. Если туннелируешь через что-то, следи за PMTUD. Фрагментация IPv6 не работает на промежуточных узлах — пакеты просто дропаются. Проверяй через `ping6 -M do -s 1452`.
Прокси-менеджер: свой или готовый
Свой прокси-менеджер на 65k адресов — это `ip_nonlocal_bind`, `socat` или `3proxy`, плюс health-check. 3proxy конфиг для IPv6:
```
nserver 2001:4860:4860::8888
nscache 65536
timeouts 1 5 30 60 180 1800 15 60
auth none
allow *
proxy -6 -n -a -p8080 -i2001:db8:1234::1
```
`-6` — IPv6, `-n` — без авторизации, `-a` — анонимный. Порт 8080, бинд на первый адрес из блока. Для ротации — поднимаешь несколько инстансов на разных адресах, балансируешь через `haproxy`.
Готовые решения дороже, но экономят время на поддержке. Если парсишь меньше 100k профилей в месяц — бери готовое. Больше — считай экономику своего.
Что ломается на практике
Первое — DNS. IPv6-резолвинг LinkedIn через публичные резолверы иногда возвращает адреса Akamai, которые блокируют запросы без правильного SNI. Используй `--resolve` в curl или настрой локальный `dnsmasq` с явными записями.
Второе — TLS session resumption. Если все запросы идут с одного TLS-тикета, LinkedIn видит одну сессию даже с разных IP. Отключай resumption в curl: `--no-sessionid`.
Третье — rate limit на уровне /64. LinkedIn иногда банит весь префикс, если видит аномалию. Residential /64 спасает тем, что под ним реальные пользователи — провайдер пожалуется, и LinkedIn откатит бан. С датацентровыми такого нет.
Четвёртое — капча на `checkpoint/challenge`. Появляется после 500–800 запросов с аккаунта. Решается либо ручным вводом, либо сервисами типа 2captcha (\$1–3 за 1000 решений). Но лучше просто ротировать аккаунты до появления капчи.
Итоговая архитектура
Рабочая схема для парсинга 10k профилей в сутки: пул из 30 аккаунтов LinkedIn, каждый привязан к 5 residential IPv6 из /64, ротация аккаунта каждые 200 запросов, между запросами пауза 8–25 секунд, JA3 через `curl_cffi impersonate=chrome120`, sticky-сессии для токенов, отдельный воркер на каждый аккаунт, общая очередь задач в Redis.
Стоимость: residential IPv6 /64 — около \$30–50 в месяц, 30 аккаунтов — \$150–300 (одноразово, если покупать), прокси-менеджер — свой на VPS за \$10. Итого \$200–400 в месяц за 300k профилей. Против \$2000+ на residential IPv4 с тем же объёмом.
Главное — не жадничать. LinkedIn не дурак, и если ты долбишь его 10k запросов в час с одного /64, бан придёт быстро. Ротация, паузы, реалистичный fingerprint — и схема живёт месяцами.