Дисклеймер. Материал ниже описывает исследование защиты Akamai Bot Manager на примере публичного сайта businesswire.com. Цель образовательная: показать, как устроена защита и как её слои выглядят со стороны клиента. Все источники данных, которые мы используем (MRSS feed и sitemap на S3), businesswire сам публикует в robots.txt для краулеров и поисковиков; работа идёт только с публично доступным контентом. Если вы применяете описанные техники в своих задачах, соблюдайте robots.txt, Terms of Service сайта и применимое законодательство (для США это CFAA, для ЕС — GDPR и Database Directive).
В предыдущей статье мы разбирали, что Akamai Bot Manager в 2026 проверяет на пяти параллельных слоях и какими инструментами эти слои закрываются. Сегодня — рабочий кейс под живую задачу: каждые 15 минут собирать новые пресс-релизы с businesswire.com по списку поисковых запросов и сохранять полный HTML вместе с метаданными.
businesswire.com — это площадка для распространения корпоративных пресс-релизов. Сайт защищён Akamai. На первый взгляд он выглядит подходящим для парсинга: есть поисковая модалка в шапке, есть листинги релизов, есть открытые URL. На практике ни одна из этих трёх вещей не работает так, как ожидаешь.
Готовое решение — связка Patchright и curl_cffi через резидентский US-прокси с закреплённой сессией, плюс два источника данных, которые businesswire сам отдаёт через robots.txt: MRSS feed и sitemap на S3. Один проход с пятью запросами укладывается в 47 секунд, без падений. Код целиком открыт на github.com/geekproxy/businesswire-scraper.
Дальше — путь снизу: что не работает и почему, потом что работает.
Карта эндпоинтов: что отдаётся, что блокируется
Все запросы ниже — через резидентский US-прокси с библиотекой curl_cffi и имитацией TLS-отпечатка Chrome 131. Это закрывает фингерпринты TLS и HTTP/2 (первый слой защиты Akamai). Дата-центровый IP не дошёл бы даже до TLS — его срежет четвёртый слой, IP reputation.
GET / → 200, 446 KB ok
GET /newsroom → 200, 715 b Page Unavailable
GET /newsroom/industry → 200, 442 KB ok
GET /newsroom/subject → 200, 396 KB ok
GET /newsroom/language → 200, 358 KB ok
GET /news/home/{id}/en/... → 200, 2.4 KB Akamai-проверка через JS
GET /sitemap.xml → 200, 46 KB ok (только статические страницы)
GET /sitemap-index.xml → 200, 1.5 KB ok
GET /robots.txt → 200, 6 KB ok
Два неинтуитивных момента сразу:
Корневая /newsroom отвечает 715 байтами «Page Unavailable» с обфусцированным JS-челленджем. При этом /newsroom/industry, /subject, /language (фактически листинги той же страницы по фильтрам) отдают полноценную одностраничную сборку на 350–450 KB. Akamai тут различает рутовый поисковый маршрут и листинги вокруг него: рут защищён жёстче.
Сами страницы релизов /news/home/{id}/... возвращают 2.4 KB. Это шелл с проверочным JS-скриптом, который ставит cookie и редиректит. Без валидного _abck (Akamai-кука, которую выдаёт сервер только после правильного POST с sensor_data из браузера) реального HTML не получить.
Итого: главная страница поиска /newsroom закрыта полностью. Доступны только подразделы листинга и, при наличии правильной куки, сами релизы.
Что не работает
Чистый requests со спуфом User-Agent
import requests
r = requests.get("https://www.businesswire.com/",
headers={"User-Agent": "Mozilla/5.0 ..."})
# HTTP 403
TLS-отпечаток urllib3 от CPython не имеет ничего общего с Chrome. Akamai определяет это в первом слое до того, как сервер прочитает HTTP-заголовки. Спуф User-Agent бесполезен.
Голый Playwright с headless Chromium
await page.goto("https://www.businesswire.com/")
# net::ERR_HTTP2_PROTOCOL_ERROR
Падает на TLS- и HTTP/2-рукопожатии на самой главной. Headless Chromium имеет узнаваемые отпечатки во втором слое (параметры SETTINGS-кадра, ALPN), Akamai реагирует не 403-м, а сбросом потока на уровне HTTP/2 — ERR_HTTP2_PROTOCOL_ERROR. Флаги --disable-features=Http2, --headless=new и плагин playwright-stealth не помогают: стелс-плагин патчит третий слой (свойства DOM и JS-среды), а до него мы просто не доходим.
--- default headless | args=[]
FAIL: net::ERR_HTTP2_PROTOCOL_ERROR
--- headless=new
FAIL: net::ERR_HTTP2_PROTOCOL_ERROR
--- disable-h2-features
FAIL: net::ERR_HTTP2_PROTOCOL_ERROR
--- disable-blink-auto
FAIL: net::ERR_HTTP2_PROTOCOL_ERROR
--- combo
FAIL: net::ERR_HTTP2_PROTOCOL_ERROR
Воспроизводится скриптом probes/01_baseline_chromium.py в репозитории.
Прямой парсинг страницы поиска /newsroom
Даже после полного прогрева на главной (с движениями мышью и переходом на одну страницу релиза) /newsroom отвечает 715-байтовой заглушкой. Akamai требует на этом маршруте более строгого sensor_data, плюс при повторных попытках с того же IP прилетает 429. Рутовый поиск действительно защищён жёстче подразделов.
Поисковая модалка в шапке — это не настоящий поиск
На каждой странице businesswire в шапке есть иконка-лупа. Кликаешь, открывается модальное окно, набираешь «nasdaq» — выпадает список из пяти свежих релизов. Выглядит как нормальная функция, которую достаточно реверс-инженерить.
Открываем главную в Patchright, вешаем обработчик context.on("response", ...) на все ответы, кликаем кнопку, набираем запрос, ждём 15 секунд после Enter. Получаем 92 захваченных сетевых запроса. Из них на поиск тянут только три:
POST 200 https://1dfqiezxuz-1.algolianet.com/1/indexes/*/queries
POST 200 https://1dfqiezxuz-2.algolianet.com/1/indexes/*/queries
POST 200 https://1dfqiezxuz-3.algolianet.com/1/indexes/*/queries
Это Algolia. App ID 1DFQIEZXUZ, публичный поисковый ключ 3ef401672a314ef0434873a57f64bfd3 лежит прямо в HTML главной страницы. Тело запроса — пакетный поиск по нескольким индексам:
{"requests": [
{"indexName": "prod_api::home.home", "query": "nasdaq"},
{"indexName": "prod_api::contacts.contacts", "query": "nasdaq"},
{"indexName": "prod_api::distribution.distribution", "query": "nasdaq"},
{"indexName": "prod_api::help.help", "query": "nasdaq"},
...
]}
В ответе по каждому индексу пустые hits: []. Algolia ничего не нашла. А в DOM модалки при этом — 45 ссылок вида /news/home/{id}/... с заголовками. Откуда они?
Это фильтр на клиенте. Next.js встроил в свойства страницы список из ~10 свежих релизов (для бокового блока «Latest News»), а модалка поиска просто прогоняет введённую подстроку через этот список. Никакого бэкенд-поиска по пресс-релизам нет — Algolia покрывает контентные страницы сайта (help, distribution, contacts), но не релизы.
Вывод: реверс-инженерить нечего. Полноценного публичного API для поиска по релизам у businesswire нет. Если задача — найти релиз по подстроке, нужно либо подключать внешний поиск (Google News с фильтром site:businesswire.com, или платный сервис обхода), либо искать альтернативный источник.
Скрипт: probes/04_reverse_search_modal.py.
Поворот через robots.txt
Стандартный приём, который применяет каждый, кто хоть раз скрейпил продовый сайт, и о котором почему-то редко пишут в гайдах по обходу: открыть /robots.txt и прочитать его глазами до того, как ломать защиту.
$ curl https://www.businesswire.com/robots.txt | grep -i sitemap
Sitemap: https://www.businesswire.com/sitemap-index.xml
Sitemap: https://bw-prod-sitemap.s3.us-east-1.amazonaws.com/webdmz1.vaprod.businesswire.com/home/smap-bw-mod.xml
Sitemap: https://bw-prod-sitemap.s3.us-east-1.amazonaws.com/webdmz1.vaprod.businesswire.com/gn-home/gn-bw-mod.xml
Sitemap: https://feed.businesswire.com/mrss/home/?rss=G1QFDERJXkJcFVJYWQ==
Тут лежит вся задача. businesswire сам опубликовал три альтернативных источника для краулеров, поисковиков и SEO-индексаторов:
feed.businesswire.com/mrss/home/?rss=G1QFDERJXkJcFVJYWQ==— MRSS-фид на отдельном поддомене. Возвращает последние ~50 релизов с полным заголовком, описанием, ссылкой и датой публикации. Поддомен тоже под Akamai, ноcurl_cffiс имитацией Chrome 131 через резидентский прокси проходит сюда стабильнее, чем через главный домен.bw-prod-sitemap.s3.us-east-1.amazonaws.com/.../smap-bw-mod.xml— индекс сайтмапов на публичном AWS S3-бакете. На S3 Akamai не стоит вообще, любой HTTP-клиент читает без всяких танцев. Индекс ссылается на дневные сайтмапы вида2026-05-01.xml.gz(gzip-XML), в каждом — около 450 релизов за сутки.bw-prod-sitemap.s3.us-east-1.amazonaws.com/.../gn-home/gn-bw-mod.xml— то же самое в формате Google News.
Это публичный интерфейс для краулеров. Никто его не прячет. Просто никто не смотрит в robots.txt перед тем, как рисовать диаграмму обхода TLS-отпечатка.
Финальный конвейер
┌──────────────────────────────────────────────────┐
│ ШАГ 1: MRSS feed │
│ GET https://feed.businesswire.com/mrss/home/?rss=│
│ через rotating residential US + curl_cffi │
│ с имитацией Chrome 131 │
│ → XML с ~50 свежими релизами │
│ (title, description, link, pubDate) │
└────────────┬─────────────────────────────────────┘
▼
┌──────────────────────────────────────────────────┐
│ ШАГ 2: фильтр по подстрокам из конфига │
│ если запрос (case-insensitive) есть в title или │
│ description — релиз попадает дальше │
└────────────┬─────────────────────────────────────┘
▼
┌──────────────────────────────────────────────────┐
│ ШАГ 3: дедуп по release_id через SQLite │
│ оставляем только новые │
└────────────┬─────────────────────────────────────┘
▼
┌──────────────────────────────────────────────────┐
│ ШАГ 3.5: проверка живых сессий прокси │
│ для каждого порта из пула — короткий GET на │
│ главную businesswire; берём первый порт, ответ- │
│ ный за <8 сек с кодом 200 и >50 KB │
└────────────┬─────────────────────────────────────┘
▼
┌──────────────────────────────────────────────────┐
│ ШАГ 4: прогрев Patchright на выбранном порту │
│ открыли главную → симуляция мыши → один релиз │
│ собрали Akamai-куки (_abck, ak_bmsc, bm_sv, bm_so│
└────────────┬─────────────────────────────────────┘
▼
┌──────────────────────────────────────────────────┐
│ ШАГ 5: curl_cffi (chrome131) с куками и тем же │
│ закреплённым портом. Параллельно (5 потоков) │
│ скачивает страницы релизов. │
│ Если >50% запросов начинают тайм-аутить — повтор │
│ шагов 3.5 и 4 с новой сессией. │
└────────────┬─────────────────────────────────────┘
▼
┌──────────────────────────────────────────────────┐
│ ШАГ 6: HTML + JSON с метаданными на диск, │
│ запись в SQLite │
└──────────────────────────────────────────────────┘
Отдельно, раз в сутки или после долгого простоя, можно запустить scraper.py --backfill 2026-05-15: скрипт скачает дневной сайтмап с S3 за указанную дату, отфильтрует URL по подстрокам в slug, и тем же путём дойдёт до пропущенных релизов.
Двухстадийный обход для страниц релизов
MRSS-фид под curl_cffi с имитацией Chrome 131 — это просто XML, он открывается чисто. А вот страницы релизов (/news/home/{id}/...) — уже первая категория сайтов из предыдущей статьи: нужна валидная _abck, которую сервер выдаёт только после успешной отправки sensor_data из настоящего браузера.
Решение оттуда же — двухстадийный конвейер.
Стадия 1: прогрев в Patchright. Patchright это форк Playwright с патчами Chromium на двоичном уровне, которые скрывают признаки автоматизации. Открываем главную через закреплённую US-сессию резидентского прокси, симулируем движение мыши и прокрутку (это для четвёртого слоя Akamai, поведенческого), переходим на одну страницу релиза, собираем все куки.
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True, channel="chromium",
args=["--no-sandbox"])
context = await browser.new_context(
proxy={"server": "http://rs.geekproxy.io:10000",
"username": "USER__cr.us", "password": "PASS"},
user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/131.0.0.0 Safari/537.36",
viewport={"width": 1366, "height": 900},
locale="en-US",
timezone_id="America/New_York",
)
page = await context.new_page()
await page.goto("https://www.businesswire.com/", wait_until="domcontentloaded")
await page.wait_for_timeout(12000) # даём sensor отработать
await page.mouse.move(120, 200)
await page.mouse.move(400, 350, steps=12)
await page.mouse.wheel(0, 600)
await page.wait_for_timeout(1500)
await page.goto(SAMPLE_RELEASE_URL, wait_until="domcontentloaded")
await page.wait_for_timeout(5000)
cookies = await context.cookies()
Из собранных кук нам нужны _abck, ak_bmsc, bm_sv, bm_so — все четыре Akamai ставит после правильного sensor_data.
Стадия 2: пакетное скачивание через curl_cffi. Создаём Session с имитацией Chrome 131, переносим в неё куки и ходим через тот же закреплённый прокси (важно: IP не должен меняться внутри одного потока действий).
from curl_cffi import requests as ccffi
proxy = "http://USER__cr.us:[email protected]:10000"
s = ccffi.Session(impersonate="chrome131", timeout=30,
proxies={"http": proxy, "https": proxy})
for c in cookies:
if c["domain"].endswith("businesswire.com"):
s.cookies.set(c["name"], c["value"], domain=c["domain"])
r = s.get(release_url, headers={
"Accept-Language": "en-US,en;q=0.9",
"Referer": "https://www.businesswire.com/",
"Sec-Fetch-Site": "same-origin",
"Sec-Fetch-Mode": "navigate",
...
})
# HTTP 200, ~400 KB реальной статьи
Один важный момент про соответствие браузеров между двумя стадиями. Библиотека curl_cffi умеет имитировать TLS-отпечатки разных браузеров: Chrome, Firefox, Safari, Edge — и разных их версий. Если на первой стадии прогрев делает Chromium-форк (Patchright), на второй curl_cffi должен имитировать именно Chrome — например chrome131. Если на первой стадии используется Firefox-форк Camoufox, на второй curl_cffi должен имитировать Firefox (firefox-NNN). Akamai при выдаче _abck запоминает, под каким браузером прошёл sensor_data, и на следующих запросах сравнивает заявленный браузер. Если на стадии 1 был Chrome, а на стадии 2 curl_cffi выдаёт Firefox-отпечаток, Akamai видит несоответствие и блокирует.
Пять параллельных потоков через ThreadPoolExecutor — рабочий потолок для одного закреплённого IP. Больше — и поведенческий слой Akamai начинает помечать сессию как подозрительную.
Воспроизводится скриптом probes/03_two_stage_bypass.py.
Сессия прокси — это переменная, а не константа
В теории резидентская сессия с закреплением — это «один IP на 5–120 минут». На практике у любого провайдера резидентских прокси периодически случаются такие IP:
- холодный — домашний роутер давно не в сети, но его адрес ещё в пуле;
- замедленный — Akamai специально снижает скорость ответа конкретному IP, который засветился ранее;
- загруженный канал.
Если ваша сессия попала на такой IP, все запросы через неё ползут или зависают до конца сессии. Один тайм-аут — шум, цепочка — сигнал.
На Geekproxy.io есть два независимых способа держать пул закреплённых сессий.
Первый способ — порты. Каждый порт в диапазоне 10000–10010 это отдельная сессия со своим закреплённым IP. Получается 11 параллельных «слотов»: пишем в конфиге диапазон, перед прогревом скрипт пробегает по портам.
Второй способ — параметр sessid в логине. К имени пользователя добавляется суффикс ;sessid.<любая строка или число>, и сессия закрепляется на IP, который этот идентификатор сейчас представляет, в среднем на 30 минут. Меняя идентификатор, получаем произвольное количество параллельных сессий: sessid.a1, sessid.a2 и так далее. Этот способ полезен, когда нужно больше слотов, чем количество доступных портов, или когда хочется создавать «сессию под задачу» с заранее известным именем (удобно для логирования).
Для нашего скрейпера я выбрал пул портов как основной механизм: он стабильнее в логах (видно, какой именно порт обслужил какой релиз), и 11 слотов в среднем перекрывают потребности 15-минутного интервала. Параметр sessid оставлен опциональным — конфигурация в config.yaml это допускает, если порты вдруг кончатся.
Перед прогревом скрипт пробегает по пулу, на каждом порту делает короткий GET на главную businesswire и берёт первый порт, который ответил кодом 200, отдал больше 50 KB и уложился в 8 секунд.
def pick_healthy_sticky_port(cfg, log):
for port in cfg.proxy_sticky_ports:
proxy = f"http://...:{port}"
t0 = time.time()
try:
r = ccffi.get(cfg.proxy_health_url,
impersonate="chrome131",
proxies={"http": proxy, "https": proxy},
timeout=cfg.proxy_health_timeout)
except Exception:
continue
if r.status_code == 200 and len(r.text) > 50000:
return port
return None
Из реального лога одного прогона:
sticky probe port 10000: Timeout (8.0s)
sticky probe port 10001: HTTP 200 447924 bytes (2.4s) — picked
warmup: launching Patchright on sticky port 10001
warmup: 32 cookies (Akamai: ['ak_bmsc', 'bm_s', 'bm_so', 'bm_sv'])
saved 2/2 ... pass elapsed 47.4s
Порт 10000 завис на тайм-ауте, скрипт за 2.4 секунды выбрал 10001, дальше всё прошло чисто. Без пула прогон ждал бы по 30 секунд на каждом скачивании.
Если посреди прогона больше половины скачиваний начинают тайм-аутить, скрипт повторяет шаги проверки и прогрева на новой сессии, и ретраит только провалившиеся. Это аварийный обход прямо в процессе.
Цифры
| Сценарий | Время |
|---|---|
| Один прогон, MRSS + 5 запросов, 0–2 новых релиза | 30–60 сек |
| Один прогон, MRSS + 20 запросов, 2–6 новых релизов | 45–110 сек |
| Backfill за сутки, ~450 URL в сайтмапе, 10–30 матчей | 5–10 мин |
| Сам по себе прогрев | 30–35 сек |
| Одна страница релиза через curl_cffi на стадии 2 | 0.5–1.5 сек |
На тестовом стенде 5 из 5 и 2 из 2 без падений. Куки от одного прогрева работают на десятках последовательных запросов в течение прогона. Akamai не помечает сессию.
Когда это сломается
Прогноз — какие компоненты «уедут» первыми и что с ними делать:
- Patchright перестаёт получать
_abck. Akamai обновил sensor или научился детектить Chromium-стелс по новому параметру. Сначала обновитеpatchright. Если не помогает — переходите на Camoufox (Firefox-форк со стелс-патчами) и парную имитацию Firefox вcurl_cffi(firefox-NNN). Правило соответствия движков остаётся в силе. - MRSS-фид меняет ключ. В
robots.txtвсегда лежит актуальная строкаSitemap: https://feed.businesswire.com/mrss/home/?rss=.... В скрипте URL вынесен вsource.mrss_url— нужно просто подправить. feed.businesswire.comначинает отвечать 403 или 429. Переключитеproxy.rotating_portна порт из пула закреплённых, чтобы fetch шёл через стабильный IP. Если совсем перестанет работать — поменяйте основной источник на S3-сайтмап. Заголовка и описания там нет (только URL), но slug в URL обычно содержит заголовок, и для фильтра по подстроке этого хватает.- Пул сессий деградирует. Расширьте
sticky_port_range(попросите у провайдера больше слотов) или поменяйте страну вproxy.country.
Что универсального для других Akamai-сайтов
Метод этого поста переносится почти один в один на любой сайт под Akamai с публичным контентом:
robots.txt— это первый шаг, а не последний. Очень часто там лежит ссылка на фид или сайтмап на отдельном поддомене или CDN, фактически вне основной защиты. Стоит 5 секунд, экономит часы.- Карта защиты по эндпоинтам. Защита Akamai неоднородная: рутовый поисковый маршрут обычно строже, чем листинги вокруг него, а sitemap и RSS часто открыты. Проверяйте каждый эндпоинт отдельно простым
curl_cffiдо того, как тянуться за Patchright. - Перехват сети в браузере за пять минут показывает, у вас бэкенд-поиск или клиентский фильтр. Эта проверка часто экономит недели реверс-инженеринга того, чего нет.
- Двухстадийный обход «прогрев в Patchright плюс
curl_cffiс куками» — универсальный паттерн для всех строгих Akamai-сайтов с релиз-контентом (новости, статьи, каталоги). - Сессию прокси держите как пул, а не как одну точку отказа. Независимо от провайдера, резидентские адреса периодически деградируют. План Б (другая сессия из пула) должен быть в коде с самого первого коммита.
Репозиторий
github.com/geekproxy/businesswire-scraper — полный код, конфиги, четыре probe-скрипта, которыми мы шли по этой истории. Запуск:
git clone https://github.com/geekproxy/businesswire-scraper.git
cd businesswire-scraper
python3 -m venv venv && ./venv/bin/pip install -r requirements.txt
./venv/bin/patchright install chromium
cp config.yaml.example config.yaml
cp .env.example .env
# заполнить BW_PROXY_USERNAME, BW_PROXY_PASSWORD в .env
./venv/bin/python3 scraper.py
Резидентские прокси для этого кейса — наши, geekproxy.io. Закреплённые сессии на отдельных портах, гео-привязка US/EU/AS, IP из реальных ISP. Сразу подходят для описанного конвейера.