Капча при парсинге: почему площадка её показывает и как снизить частоту
- Из чего складывается порог показа проверки
- Темп запросов с адреса и по соседним адресам подсети
- Повторяемость интервалов: ритм пауз как отдельный признак
- Набор и порядок заголовков в запросе
- Отсутствие обычных для браузера обращений за стилями и картинками
- История адреса по спискам репутации
- Поведение сессии на первых страницах
- Как разложить нагрузку по адресам и удержать ровный темп
- Разброс пауз и повторный заход после показа проверки
- Доля проверок на тысячу запросов: как её считать и что по ней менять
Капча при сборе данных это проверочное окно, которое площадка отдаёт вместо запрошенной страницы, когда счётчики её защитного слоя посчитали поток запросов машинным. Код ответа при этом остаётся 200 либо меняется на 403, внутри тела вместо каталога лежит форма проверки, а парсер пишет в выгрузку пустую строку. Показ включает не оператор и не ручной список адресов: срабатывает набор счётчиков, каждый из которых меряет свою характеристику потока и сравнивает её с порогом.
Ниже я разбираю эти счётчики по одному: что каждый меряет, какая настройка прогона поднимает его значение и какая опускает. Потом перехожу к практике: как разложить нагрузку по адресам пула, как задавать разброс пауз, как вести повторный заход после показа и как свести всю картину к одной цифре, доле проверок на тысячу запросов. Способы пройти проверку мимо её механики я не разбираю. Тема заметки это темп, распределение нагрузки и корректность самих запросов.
Из чего складывается порог показа проверки
Проверку выдаёт слой защиты, который стоит перед приложением площадки. Он разбирает каждый запрос, раскладывает признаки по ключам (адрес, подсеть, идентификатор сессии, отпечаток соединения) и держит по каждому ключу набор счётчиков в скользящих окнах. Сумма перешагнула порог: следующий ответ приходит формой проверки. Сумма осталась ниже: страница отдаётся обычным содержимым.
Счётчики делятся на четыре группы. Первая считает объём: сколько запросов пришло с адреса за минуту, за десять минут, за час, и сколько за то же время пришло с соседних адресов подсети. Вторая смотрит на форму потока: ровность интервалов, повторяемость маршрута обхода, длину сессии, час запуска. Третья разбирает сам запрос: набор заголовков, их порядок, версию протокола, наличие обращений за стилями и картинками после HTML. Четвёртая берёт внешние сведения об адресе: как он выглядит в списках репутации, сколько сессий с него открывали раньше и чем те сессии заканчивались.
Ни один счётчик не работает в одиночку. Ровный ритм сам по себе добавляет к сумме немного. Ровный ритм вместе с высоким темпом и пустой карточкой подресурсов даёт показ уже на первом десятке страниц. Отсюда подход, которым я пользуюсь в каждом прогоне: снижать значения сразу по нескольким группам, потому что каждая снятая единица уводит сумму дальше от порога, а запас набирается складыванием мелких правок, ни одна из которых по отдельности погоды не делает.
Ещё одна вещь, которую я вынес из своих журналов: пороги у площадки плавают. Ночью, когда живого трафика мало, минутное окно терпит меньше. Днём в час пик тот же темп проходит спокойно, потому что фон из живых посетителей поднимает планку. Поэтому цифры, которые я привожу дальше, читаются как порядок величины. Снимать их надо на своей цели, своими руками, на своём наборе настроек.
Темп запросов с адреса и по соседним адресам подсети
Самый простой счётчик и самый заметный по последствиям. Слой защиты складывает запросы по ключу «адрес» в окнах минута, десять минут, час, и у каждого окна свой порог. На крупном товарном каталоге, который я снимаю регулярно, минутное окно держит около 30 запросов с одного выхода, часовое около 600. Дальше идёт лестница: сначала растёт время ответа, потом приходит форма проверки, потом адрес перестаёт отдавать содержимое примерно на четверть часа.
Второй ключ это подсеть. Счётчик по блоку /24 сделан ровно против наивной раскладки нагрузки: когда 40 адресов прогона лежат в одном блоке, площадка складывает их запросы в общую сумму, и потолок по подсети срабатывает раньше потолка по отдельному адресу. Я проверял это на своём прогоне: 12 адресов из одного блока по 8 запросов в минуту каждый дали ту же долю проверок, что один адрес на 96 запросах в минуту. Разложил те же 12 потоков по адресам из разных блоков, доля упала в 14 раз при том же общем темпе.
Отсюда два требования к пулу под сбор. Адреса должны лежать в разных блоках, чтобы суммирование по подсети не собирало ваш прогон в одну кучу. И адреса не должны делиться с чужими прогонами, иначе счётчик поднимает кто-то посторонний, и проверку получаете вы. Вторую половину задачи снимают приватные адреса без соседей по выходу: к вашей сумме не приплюсовывается чужой темп, и замеры становятся повторяемыми от прогона к прогону.
Свой темп я меряю по логам прогона, до того как начну гадать о порогах площадки. Одна команда показывает распределение запросов по минутам и по адресам:
awk '{split($1,t,":"); print t[1]":"t[2], $3}' run.log \
| sort | uniq -c | sort -rn | head -20
Первая колонка это число запросов в минуту с конкретного выхода. Если в верхних строках стоят значения за 40, разговор про заголовки и ритм можно откладывать: порог перешагивается на объёме, и остальные правки просто не успеют сработать.
Повторяемость интервалов: ритм пауз как отдельный признак
Второй счётчик смотрит на форму потока. Живой посетитель читает страницу неравномерно: тридцать секунд на карточку товара, две секунды на промах мимо ссылки, четыре минуты на отвлечение. Парсер с time.sleep(1) в цикле даёт интервалы 1.01, 1.02, 1.01, 1.03. Коэффициент вариации у такого ряда около 0.02. У живой сессии он держится в районе 0.9 и легко уходит за 1.5.
Слой защиты считает эту величину по последним 20-30 запросам сессии. Ряд слишком ровный: счётчик поведения получает свою прибавку. Прибавка небольшая, но она складывается с остальными, и на общем фоне высокого темпа именно ритм часто оказывается той единицей, которой не хватало до порога.
Свой ритм я снимаю с тех же логов, коротким скриптом:
import statistics, sys
ts = [float(l.split()[0]) for l in open('stamps.txt')]
gaps = [b - a for a, b in zip(ts, ts[1:])]
m = statistics.mean(gaps)
print(f'средний интервал {m:.2f}, разброс {statistics.pstdev(gaps)/m:.2f}')
Ориентир, к которому я привожу прогоны: разброс от 0.6 и выше. Ниже 0.3 ряд читается машинным на любой цели, которую я пробовал. Как этого добиться, разбираю в разделе про паузы, там есть готовый фрагмент. Здесь важно другое: ритм меряется внутри сессии, поэтому смена выхода сама по себе его не исправляет. Ровный ряд остаётся ровным на любом адресе.
Третий признак этой же группы это маршрут обхода. Парсер идёт по каталогу подряд: страница 1, страница 2, страница 3, и так до конца. Живая сессия скачет, возвращается, открывает карточку и уходит обратно в список. Полностью повторить живой маршрут задача избыточная. Перемешивание порядка страниц внутри пачки стоит одной строки кода и снимает самую заметную часть признака.
Набор и порядок заголовков в запросе
Браузер отправляет полтора десятка полей в устойчивом порядке, который задан его движком. HTTP-клиент из библиотеки отправляет четыре-пять полей, нередко по алфавиту и с подписью самой библиотеки в User-Agent. Слой защиты держит эталоны для распространённых браузеров и сравнивает с ними пришедший набор. Совпадения нет: счётчик запроса получает прибавку, причём одну из самых весомых, потому что признак дешёвый в проверке и почти не даёт ложных срабатываний на живом трафике.
Отдельно проверяется согласованность полей между собой. User-Agent обещает свежий Chrome под Windows, при этом группа Sec-Fetch отсутствует целиком, sec-ch-ua пустая, Accept-Language не прислан. Такая комбинация в природе не встречается, и защита это знает. Второй частый разрыв: Accept-Encoding перечисляет br, распаковывать brotli клиентская библиотека при этом не умеет и молча получает мусор в теле.
| Поле запроса | Что видит слой защиты | Как я правлю |
|---|---|---|
| User-Agent | Подпись библиотеки, старая сборка браузера | Строка живой сборки, одна на всю сессию |
| Accept | Короткое */* вместо длинной строки типов | Полная строка, которую шлёт движок |
| Accept-Language | Поле отсутствует | Два языка с весами, согласованы с сессией |
| Accept-Encoding | Обещан br, ответ не распаковывается | Перечисляю то, что клиент умеет читать |
| Sec-Fetch-Site и соседи | Группы из четырёх полей нет | Ставлю все четыре под тип перехода |
| Порядок полей | Алфавитный или произвольный | Порядок движка, задаю списком |
Третий источник признаков в этой группе это служебные поля, которые добавляет посредник. X-Forwarded-For, Via и X-Real-IP несут исходный адрес и прямо сообщают площадке, что запрос идёт через промежуточный узел. Прогон при этом работает, страницы приходят, а счётчик тихо растёт от запроса к запросу. Поэтому под сбор я беру анонимный выход без пометок посредника и первым делом отправляю запрос на страницу-эхо, которая показывает полный набор полей ровно в том виде, в каком его получил сервер на той стороне.
Отсутствие обычных для браузера обращений за стилями и картинками
Живая сессия за одну страницу забирает HTML и следом от сорока до сотни подресурсов: стили, скрипты, шрифты, картинки, пиксель аналитики. Парсер забирает один HTML и уходит. Отношение «HTML к подресурсам» у живого посетителя лежит около 1 к 60, у прямого HTTP-клиента оно равно 1 к 0. Разница видна на первом же запросе сессии, и многие площадки держат отдельный счётчик именно на этот перекос.
Работаю я с этим по типу цели. Когда содержимое приходит внутри HTML и рендеринг не нужен, я ищу открытый JSON-эндпоинт того же каталога: у него другой профиль ожиданий, подресурсы к нему не привязаны, объём ответа при этом в разы меньше. Когда страница собирается скриптами и без движка её не прочитать, я запускаю headless-браузер, и подресурсы забираются сами собой, без единой строки кода.
Второй вариант стоит трафика: одна страница вырастает с 80 килобайт до полутора мегабайт. На пуле с безлимитным трафиком эта разница на бюджет прогона не влияет, поэтому там, где счётчик подресурсов заметно поднимает долю проверок, я спокойно перевожу цель на движок и сплю ночью. Считать мегабайты в такой схеме не приходится, и поведение сессии получается ровно тем, которого площадка ждёт.
Есть и промежуточный ход. Часть площадок смотрит только на один маркер: пришёл ли за HTML хотя бы один запрос к статике с тем же cookie в течение нескольких секунд. Тогда достаточно забирать вместе со страницей один файл стилей, кэшируя его локально после первого раза. Признак снимается, объём вырастает на проценты.
История адреса по спискам репутации
Четвёртая группа счётчиков вообще не смотрит на текущий запрос. Она смотрит на адрес: к какой автономной системе он относится, попадал ли раньше в публичные перечни, сколько сессий с него открывали за последние сутки и чем те сессии заканчивались. Адрес с историей нарушений получает повышенный вес ещё до первого запроса, и порог для него оказывается ниже обычного.
Здесь есть тонкость, которую я долго не замечал. Репутация накапливается не только чужими руками. Свой прогон, отработавший неаккуратно, портит адресу карму на несколько суток вперёд, и следующий заход стартует с уже поднятым счётчиком. Отсюда правило: адрес, получивший показ проверки, я отправляю остывать целиком, вместо того чтобы дожимать с него оставшиеся страницы. Пятнадцать минут простоя дешевле трёх суток повышенного веса.
Под сбор я держу пул из серверных адресов IPv4 из общего пула, в котором около 12 000 активных выходов, а список обновляется в режиме реального времени. Живой список важен именно из-за репутации: адреса, набравшие плохую историю, уходят из ротации сами, и прогон не тратит запросы на заведомо тяжёлые выходы. Доступ к списку открыт только клиентам сервиса, поэтому те же адреса не разогревает параллельно половина интернета.
Перед большим прогоном я прогоняю выборку выходов через чекер: беру демо-версию инструмента от Zennolab, отправляю по три запроса на цель с каждого адреса и смотрю на долю ответов с формой проверки. Двадцать минут работы экономят потом часы разбирательств. Выходы, давшие проверку сразу, я в прогон не беру.
Поведение сессии на первых страницах
Пятый признак снимается с самого начала сессии, и он же чаще всего выдаёт машинный обход. Живой посетитель приходит на площадку с поиска или с главной, получает cookie, идёт в раздел, оттуда в список, оттуда в карточку. Парсер начинает с глубокой ссылки, без cookie, без Referer, и первым же запросом просит страницу, до которой живой человек добирается на четвёртом переходе.
Разбирается это по трём полям. Cookie: если каждый запрос приходит с пустой корзиной, сессии в счётчиках просто нет, и весь темп сваливается на ключ адреса, где порог ниже. Referer: цепочка переходов должна складываться в маршрут, а прямые заходы на глубину подряд встречаются у живых посетителей редко. Глубина входа: первый запрос сессии на карточку с параметрами сортировки читается однозначно.
Я поднимаю сессию так же, как её поднимает браузер. Первый запрос идёт на раздел верхнего уровня, дальше сохраняю cookie в общей корзине на весь прогон, дальше передаю Referer от предыдущей страницы. Три лишних запроса на старте сессии окупаются сотнями страниц, которые после этого приходят содержимым. На одном из прогонов по каталогу это дало падение доли проверок с 41 до 9 на тысячу, и больше я ничего в тот раз не менял.
Отдельно про длину сессии. Сессия, которая живёт восемь часов и снимает 12 000 страниц, выглядит машинной по одному этому факту. Я режу прогон на сессии по 200-400 страниц, между ними делаю паузу в несколько минут и поднимаю корзину cookie заново. Счётчики по ключу сессии обнуляются, а суммарный темп остаётся прежним.
Как разложить нагрузку по адресам и удержать ровный темп
Практическая часть начинается с арифметики. У прогона есть объём и срок: скажем, 30 000 страниц за шесть часов. Общий темп получается 83 запроса в минуту. Дальше нужен потолок на адрес, снятый замером на этой же цели, у меня по каталогу вышло 5 запросов в минуту с запасом от порога вдвое. Делим одно на другое и получаем 17 одновременных выходов. Вся раскладка сводится к этим трём числам, остальное это обвязка.
import math
pages, hours = 30000, 6
rpm_total = pages / (hours * 60) # 83 запроса в минуту на весь прогон
rpm_per_ip = 5 # потолок на выход, снят замером
ips = math.ceil(rpm_total / rpm_per_ip) # 17 выходов одновременно
per_worker_delay = 60 / rpm_per_ip # 12 секунд между запросами воркера
Второй ограничитель это потоки. На обычных пакетах доступна 1000 одновременных потоков, на корпоративном до 3000, и пакеты по потокам между собой не складываются. При двух привязанных адресах общий лимит делится пополам, так что два сервера получают по 500 потоков каждый. Для 17 выходов запас огромный, но на прогонах в сотни воркеров я это считаю заранее, потому что упереться в потолок потоков легче, чем кажется на бумаге.
Раскладка по выходам делается двумя способами. Первый: держать очередь на каждый адрес и следить за темпом внутри воркера самостоятельно. Второй: отдать перебор выходов сервису и управлять только моментом закрытия соединения, потому что новое TCP-соединение уходит через другой адрес пула. Я работаю вторым способом, автоматическая смена выходного адреса снимает с кода задачу вести список и следить за состоянием каждого выхода.
Ровность темпа на адрес важнее среднего темпа по прогону. Средние 5 запросов в минуту, собранные как пачка из 30 штук за полминуты и потом четыре минуты тишины, дают показ проверки почти гарантированно: минутное окно видит пачку целиком. Поэтому очередь на выход я строю с фиксированным шагом плюс разброс, без пачек и без догоняющих всплесков после паузы.
| Сигнал площадки | Что поднимает счётчик | Настройка прогона |
|---|---|---|
| Темп по адресу | Больше 25-30 запросов в минуту с выхода | Потолок 4-6 запросов, своя очередь на адрес |
| Темп по подсети | Десяток выходов из одного блока /24 | Раскладка по разным блокам, перебор на стороне сервиса |
| Ровность интервалов | Разброс ряда пауз ниже 0.3 | Пауза из диапазона, разброс от 0.6 и выше |
| Маршрут обхода | Страницы идут строго по порядку | Перемешивание пачки перед постановкой в очередь |
| Набор заголовков | Четыре поля при полутора десятках у браузера | Полный набор в порядке движка, согласованные значения |
| Служебные поля | X-Forwarded-For и Via в запросе | Выход без пометок посредника, проверка эхо-страницей |
| Подресурсы | Ни одного обращения к статике за сессию | JSON-эндпоинт либо движок браузера |
| Репутация выхода | История показов за последние сутки | Остывание 15 минут, живой список адресов |
| Старт сессии | Первый запрос на глубокую карточку | Заход с раздела, cookie и Referer по цепочке |
| Длина сессии | Тысячи страниц в одной корзине cookie | Сессии по 200-400 страниц с перезапуском |
Под сбор с раскладкой по выходам я беру приватные прокси под сбор данных: пул отдаёт список в формате IP:PORT и IP:PORT:LOGIN:PASS, ссылкой либо файлом, и подставить его в очередь воркеров получается одной функцией загрузки. Обновление списка идёт в режиме реального времени, поэтому раскладка не ломается посреди прогона.
Разброс пауз и повторный заход после показа проверки
Пауза в прогоне описывается тремя числами: базовый интервал, ширина разброса и вероятность длинной остановки. Базовый интервал берётся из потолка на адрес: 5 запросов в минуту дают 12 секунд. Ширина разброса задаётся долей от базы, я держу от 0.7 до 0.9, что даёт интервалы от 1.2 до 22 секунд при базе 12. Длинная остановка имитирует отвлечение посетителя и добавляется раз в пятнадцать запросов.
import random, time
def pause(base=12.0, spread=0.85, rare=0.07):
d = base * (1 + random.uniform(-spread, spread))
if random.random() < rare:
d += random.uniform(20, 90) # редкая длинная остановка
time.sleep(max(0.4, d))
Три замечания к этому фрагменту, каждое стоило мне отдельного прогона. Равномерное распределение годится, показатель разброса на нём выходит около 0.5, и до ориентира 0.6 его дотягивает как раз редкая длинная остановка. Нижнюю границу надо ставить явно, иначе на краю диапазона проскакивают паузы в сотые доли секунды, и минутное окно ловит всплеск. Разброс считается по ряду внутри одной сессии, поэтому проверять его надо после сборки очереди, на реальных отметках времени из лога.
Отдельный вопрос это ночь и выходные. Фон живого трафика падает, порог опускается вместе с ним, и настройки, отработавшие днём, ночью дают втрое больше показов. Я делю сутки на два профиля: дневной с базой 12 секунд и ночной с базой 20. Общий срок прогона от этого растягивается процентов на тридцать, зато доля проверок держится в одном коридоре круглые сутки.
Вторая половина этого раздела про то, что происходит после показа. Первая мысль повторить запрос. Мысль плохая: повторный запрос с того же адреса через секунду поднимает счётчик ещё выше и переводит выход из состояния «показываем проверку» в состояние «не отвечаем совсем». Порядок, к которому я пришёл, выглядит так и работает на всех целях, где я его пробовал.
Адрес, отдавший проверку, немедленно уходит из очереди на 15 минут. Страница, на которой пришёл показ, возвращается в конец общей очереди, чтобы её взял другой выход. Счётчик показов за последние десять минут поднимается на единицу, и когда он переваливает за 3, весь прогон снижает темп на четверть и держит сниженный темп следующие полчаса. Cookie той сессии, где случился показ, выбрасывается целиком: она уже помечена, и дальнейшие запросы с ней тянут вес за собой.
Возврат к нормальному темпу делаю ступенями. Полчаса без показов: темп поднимается на десятую долю. Ещё полчаса без показов: ещё на десятую. До исходного уровня прогон доползает часа за три, зато без второй волны показов, которая обычно приходит, когда темп возвращают одним движением. Ступенчатый возврат я вынес из трёх испорченных ночных прогонов и с тех пор пишу его в каждый обвязочный скрипт.
Отдельно про повторные попытки по кодам 429 и 503. Заголовок Retry-After площадка присылает нередко, и его значение надо читать и уважать. Клиент, игнорирующий Retry-After, попадает в отдельный счётчик, который у некоторых защитных слоёв весит больше, чем сам темп.
Доля проверок на тысячу запросов: как её считать и что по ней менять
Все настройки выше сводятся к одной измеримой величине. Я считаю её так: число ответов с формой проверки, делённое на число запросов, умноженное на тысячу. Меряю на окне в 5000 запросов, реже цифра шумит. Определяется показ по коду ответа и по маркерам в первых килобайтах тела:
MARKERS = ('captcha', 'g-recaptcha', 'hcaptcha', 'cf-chl', 'подтвердите')
def is_check(resp):
if resp.status_code in (403, 429, 503):
return True
head = resp.text[:4000].lower()
return any(m in head for m in MARKERS)
Дальше журнал. Я меняю по одному параметру за прогон и записываю цифру, иначе понять, что сработало, невозможно. Ниже настоящий журнал по товарному каталогу, пять прогонов подряд по 5000 запросов каждый, цель одна и та же:
| Прогон | Темп | Разброс пауз | Заголовки | Подресурсы | Проверок на 1000 |
|---|---|---|---|---|---|
| 1 | весь объём одним выходом | 0.02 | набор из 4 полей | нет | 210 |
| 2 | 60 в минуту на адрес | 0.02 | набор из 4 полей | нет | 74 |
| 3 | 25 в минуту на адрес | 0.4 | полный набор | нет | 19 |
| 4 | 10 в минуту на адрес | 0.7 | полный набор | один файл стилей | 6 |
| 5 | 4 в минуту на адрес | 0.9 | полный набор | движок браузера | 2 |
Читается журнал по шагам между строками. Переход от первой строки ко второй это раскладка объёма по выходам, минус 65 процентов показов за одну правку. Переход от второй к третьей добавил разброс пауз и полный набор заголовков, минус ещё 74 процента. Четвёртая строка сняла счётчик подресурсов, пятая опустила темп до 4 запросов в минуту. Каждая правка стоит своих процентов, и порядок правок имеет значение: возиться с заголовками при темпе 60 в минуту смысла мало, объём перебивает всё остальное.
Целевой коридор я держу в пределах 5 показов на тысячу. Выше 20 прогон начинает терять страницы быстрее, чем успевает их добирать повторным заходом. Ниже 2 обычно уже переплата по времени: темп занижен, срок прогона растянут, а выигрыш по доле копеечный. Между 2 и 5 живёт та точка, где сбор идёт с предсказуемой скоростью и без ночных сюрпризов, и найти её получается за три-четыре замера.
Последнее по журналу, что стоит держать в голове: цифра привязана к цели. Тот же набор настроек на другом каталоге даёт другую долю, потому что пороги и набор счётчиков у площадок разные. Поэтому под каждую новую цель я трачу час на короткие пробные прогоны по 500 запросов и снимаю потолок на адрес отдельно. Работать с пулом адресов для парсинга удобно именно на таких пробах: бесплатный тест до 2 часов ровно на них и рассчитан, а привязанный к пакету адрес меняется в кабинете без ограничений, если пробы идут с другого сервера.
Соседние заметки этого раздела продолжают тему с других сторон. Как записываются строки подключения, чем схема socks5h отличается по разбору имён и где в клиенте живут таймауты, разобрано в материале прокси в Python и curl. Четыре режима смены выхода и способы управлять моментом закрытия соединения лежат в заметке ротация адресов. Та же раскладка темпа, собранная мышью в визуальном редакторе, без единой строки кода, показана в статье прокси в n8n и Make. Из раздела основ ближе всего белый или серый адрес: там подробно про перечни репутации, из-за которых порог показа проверки опускается ещё до первого запроса.