Как отличить капчу от блокировки в парсерах на Python

Когда парсер на Python получает плохой ответ, первый вопрос такой: капча или блокировка, потому что от ответа зависит, что делать дальше. Капчу можно решить. Ограничение частоты запросов нужно переждать. Страница блокировки означает, что ваш адрес или отпечаток отклонён, и никакой токен этого не изменит. В этом руководстве собирается классификатор примерно в 40 строк, который читает статус, заголовки и разметку и возвращает одну из шести меток, а затем цикл запросов, который вызывает CapSkip только для той метки, при которой решение может помочь. CapSkip работает на вашем собственном ПК с Windows без платы за каждое решение, поэтому пропуск решения, которое не сработает, экономит время, а не деньги.
Что понадобится
- CapSkip, запущенный на ПК с Windows, с включённым решателем reCAPTCHA. Пошаговое руководство по настройке проведёт вас через первый запуск.
- Python 3.10 или новее. Код ниже запускался на Python 3.14.0 с requests 2.33.0 и capskip 1.3.0.
- Окно PowerShell. Команды написаны в синтаксисе PowerShell.
- Страницы, которыми вы владеете или которые вам разрешено автоматизировать.
- Адрес решателя. В режиме Local CapSkip отвечает на 127.0.0.1 и только для этого устройства. В режиме Server он слушает ваш сетевой адрес или публичный IP, чтобы парсер на другом ПК, VPS или размещённой платформе мог обращаться к нему через API. Оба режима задаются в разделе Настройки подключения.
Как это проверялось: классификатор и цикл запросов прошли 17 проверок на Python 3.14.0, и в каждой ответ отдавал локальный тестовый сайт. Решения шли через настоящий пакет capskip на имитацию CapSkip, а не на приложение. Две публичные страницы за полностраничной проверкой Cloudflare тоже были загружены 29 октября 2026 года, и обе вернули 403 с заголовком cf-mitigated. Ни одно число ниже не является временем решения.
Почему сначала нужно спросить: капча или блокировка
Две ошибки стоят по-разному. Если отправить страницу блокировки решателю, решение будет потрачено впустую, а вы останетесь заблокированы. Если менять прокси при каждом появлении капчи, будут сожжены адреса, которые жечь было не нужно. Страница с капчей обычно просит что-то подтвердить, и решённый токен вас пропускает. Страница с блокировкой просто отказывает вам. Сторону ожидания разбирает руководство по HTTP 429 для одного кода статуса. Это руководство посвящено решению, которое принимается раньше.
Всё покрывают шесть меток: ok, rate_limited, captcha, cloudflare_challenge, blocked и unknown. Метка unknown важна. Классификатор, вынужденный выбирать среди остальных пяти, иногда ошибётся, а цикл, который выбрасывает исключение на unknown, покажет вам случай, который вы ещё не обработали.
Шаг 1: установка
Создайте виртуальное окружение в пустой папке и установите два пакета. Команды вызывают Python самого окружения по его пути, поэтому активировать окружение не нужно.
py -m venv .venv .\.venv\Scripts\python.exe -m pip install capskip requests
Шаг 2: сигналы
Сохраните этот блок и следующий как classify.py. Первый блок содержит шаблоны. Шаблон виджета ищет тег, в атрибуте class которого назван виджет, или тег altcha-widget. Пост в блоге, который показывает разметку в экранированном коде, ссылка, в адресе которой есть имя виджета, и скрытое поле g-recaptcha-response не дают совпадения.
Последняя строка содержит фразы, по которым определяется мягкая блокировка: страница возвращает обычный статус, но сообщает, что вам отказано. Будьте осторожны с ней. В документации Cloudflare по ошибкам есть Error 1020: Access denied, откуда взяты первые две фразы. Остальные две являются догадками по страницам блокировки, встречавшимся в сети, и у вашей цели будет своя формулировка. Замените их, когда увидите несколько настоящих.
import re
# A tag whose class attribute names a widget, or an altcha-widget tag. A link, a hidden
# input or a sentence that merely contains the name does not match.
CLS = r"<[^>]*\bclass\s*=\s*[\"'][^\"']*(?<![\w-])%s(?![\w-])"
WIDGET = re.compile(CLS % "(?:g-recaptcha|h-captcha|cf-turnstile|frc-captcha|captchafox)"
+ r"|<altcha-widget\b", re.I)
TITLE = re.compile(r"<title[^>]*>(.*?)</title>", re.I | re.S)
BLOCK = ("access denied", "error 1020", "unusual traffic", "you have been blocked")Шаг 3: решаем, капча это или блокировка
Порядок проверок и есть логика. Каждое правило срабатывает, только если не совпали вышестоящие.
- Заголовок cf-mitigated идёт первым. Cloudflare документирует значение challenge как то, которое он отправляет на страницах проверки, и это самый сильный сигнал в функции.
- Затем проверяется перенаправление, для вызывающего кода, который отключает следование перенаправлениям. Оно помечается как unknown, потому что по одному адресу нельзя сказать, ведёт ли оно на капчу или на статью со словом в названии.
- Переменная скрипта _cf_chl_opt и заголовок Just a moment идут после этого. Они ловят страницу проверки, когда прокси или CDN убрали заголовок.
- Аргумент expect позволяет указать строку, которая есть только на настоящей странице. Если он передан, страница считается ok при любом статусе. Без него страница с контентом и виджетом капчи в подвале считалась бы капчей.
- Виджет в разметке означает captcha, даже при 429 или 403. Это проверяется раньше правил по статусу, потому что некоторые сайты показывают капчу с кодом ошибки. Список виджетов охватывает reCAPTCHA, hCaptcha, Turnstile, Friendly Captcha, CaptchaFox и ALTCHA.
- 429 или 503 с Retry-After означает rate_limited. 403 с телом HTML означает blocked, если только текст страницы не содержит csrf. JSON 403 остаётся unknown, потому что API, который сообщает, что у вашего токена нет нужной области, не предлагает вам менять прокси.
- Ответ со статусом ниже 400 считается мягкой блокировкой, если его заголовок содержит одну из фраз или если вся страница короче 3000 символов и содержит одну из них.
def classify(resp, expect=None):
"""expect: a string only the real page contains, for pages that carry a widget anyway."""
code, h = resp.status_code, resp.headers
if h.get("cf-mitigated", "").lower() == "challenge":
return "cloudflare_challenge" # Cloudflare docs
if 300 <= code < 400: # allow_redirects=False callers
return "unknown"
html = "html" in h.get("content-type", "").lower()
text = resp.text[:200000] if html else ""
low = text.lower()
if "_cf_chl_opt" in text or "<title>just a moment" in low:
return "cloudflare_challenge" # header stripped by a proxy
if expect and expect in text:
return "ok"
if WIDGET.search(text):
return "captcha" # even on a 429 or a 403
if code == 429 or (code == 503 and "retry-after" in h):
return "rate_limited"
if code == 403 and html and "csrf" not in low:
return "blocked" # JSON 403s fall through
m = TITLE.search(text)
head = m.group(1).lower() if m else ""
small = len(text) < 3000 # block pages are short
if code < 400 and html and any(b in head or (small and b in low) for b in BLOCK):
return "blocked" # soft block: 200 with a notice
return "ok" if code < 400 else "unknown"Шаг 4: читаем Retry-After
Сохраните этот блок, следующий и ещё один за ним как policy.py. Ответ 429 может нести заголовок Retry-After, и в нём могут быть секунды или дата HTTP. MDN перечисляет обе формы и сообщает, что для 429 заголовок необязателен. Вспомогательная функция обрабатывает оба варианта и возвращает выбранное вами число, если заголовок отсутствует или нечитаем.
import re
import time
from datetime import datetime, timezone
from email.utils import parsedate_to_datetime
from urllib.parse import urljoin
from classify import classify
def retry_delay(resp, fallback):
raw = resp.headers.get("Retry-After")
if not raw:
return fallback
try:
return max(1.0, float(raw)) # delay-seconds
except ValueError:
pass
try:
when = parsedate_to_datetime(raw) # HTTP-date
return max(1.0, (when - datetime.now(timezone.utc)).total_seconds())
except (TypeError, ValueError):
return fallbackШаг 5: решаем капчу
Это единственное место, где вызывается CapSkip. Оно читает со страницы sitekey и action формы, просит у CapSkip токен и отправляет его обратно в поле g-recaptcha-response. Оно обрабатывает только reCAPTCHA v2, и это сделано намеренно, и отправляет только это одно поле, поэтому для формы с другими обязательными полями их нужно добавить в данные. Каждый виджет из списка получает метку captcha, поэтому на эту функцию попадает и страница с Turnstile, hCaptcha, Friendly Captcha, CaptchaFox или ALTCHA. Она останавливает их с RuntimeError, где сказано запустить детектор типа капчи заранее. Если вместо этого отправить ключ Turnstile в метод reCAPTCHA, всё закончится ошибкой ERROR_GOOGLEKEY: CapSkip отклоняет ключ, не похожий на ключ reCAPTCHA, что я подтвердил на имитации, копирующей проверку ключа в приложении, а не на самом приложении. Сам вызов reCAPTCHA описан на странице решателя reCAPTCHA v2.
def solve_recaptcha_form(session, resp, solver):
"""reCAPTCHA v2 only. For other types, run the detector first."""
key = re.search(r"data-sitekey=[\"']([^\"']+)", resp.text)
form = re.search(r"<form[^>]*action=[\"']([^\"']*)", resp.text)
if "g-recaptcha" not in resp.text or not (key and form):
raise RuntimeError("not a plain reCAPTCHA v2 form: run the detector first")
token = solver.recaptcha(sitekey=key.group(1), url=resp.url)["code"]
return session.post(urljoin(resp.url, form.group(1)),
data={"g-recaptcha-response": token}, timeout=30)Шаг 6: цикл запросов
Цикл действует по решению «капча или блокировка». Он превращает каждую метку в одно действие и делает до четырёх попыток. При ограничении частоты он ждёт столько, сколько указано в заголовке, с нижней границей в 1 секунду и верхней в 60. Из-за верхней границы Retry-After в сутки повторяется через минуту, что может продлить ограничение. Голый 429, то есть без Retry-After, получает два ожидания по 5 секунд, затем смену прокси, и для нового адреса счёт начинается заново. При четырёх попытках это два ожидания, смена и ещё одно ожидание. 429 с Retry-After никогда не меняет прокси. Блокировка вызывает вашу функцию rotate и никогда не доходит до решателя. Ваша функция rotate должна изменить то, как выглядит следующий запрос, например задав session.proxies, или выбросить исключение, чтобы остановить запуск. Страница проверки вызывает вашу функцию on_challenge, которая должна оставить в session.cookies действительную cookie cf_clearance, потому что значений, которые методу Turnstile нужны для полностраничной проверки, нет в HTML, который получает обычный запрос. После возврата любой из функций цикл просто запрашивает URL снова. При успехе функция возвращает ответ на отправку формы, поэтому, если ваша целевая страница другая, запросите её ещё раз. Каждая попытка с капчей стоит одного решения и может ждать до пяти минут, поэтому четыре неудачные попытки могут израсходовать четыре решения. NetworkException означает, что CapSkip недоступен, и здесь не перехватывается. Что делать с cookie после этого, рассказывает руководство по cf_clearance подробно.
def fetch(session, url, solver, rotate, on_challenge,
tries=4, sleep=time.sleep, expect=None):
limited = 0
for _ in range(tries):
resp = session.get(url, timeout=30)
kind = classify(resp, expect)
if kind == "ok":
return resp
if kind == "rate_limited":
limited += 1
if limited > 2 and "Retry-After" not in resp.headers:
rotate(session) # a bare 429 that outlasts two waits
limited = 0 # the new address starts fresh
else:
sleep(min(retry_delay(resp, 5.0), 60.0))
elif kind == "captcha":
resp = solve_recaptcha_form(session, resp, solver)
if classify(resp, expect) == "ok":
return resp
elif kind == "cloudflare_challenge":
on_challenge(session, resp) # yours: a browser, or the Turnstile guide
elif kind == "blocked":
rotate(session) # never solve a block
else:
raise RuntimeError(f"unknown response {resp.status_code} from {url}")
raise RuntimeError(f"gave up on {url} after {tries} tries")Запуск
Сохраните это как use.py рядом с двумя другими файлами. Замените адрес и вставьте свою логику прокси в rotate. Пример rotate выбрасывает исключение, поэтому запуск с блокировкой останавливается, а не спрашивает один и тот же адрес четыре раза. Запустите его Python самого окружения, как в .\.venv\Scripts\python.exe use.py. Порт 8080 используется по умолчанию; если у вас другой, возьмите порт из настроек подключения CapSkip. С адресом-заглушкой скрипт заканчивается ошибкой unknown response, и это ожидаемый результат.
import requests
from capskip import CapSkip
from policy import fetch
solver = CapSkip(host="127.0.0.1", port=8080)
def rotate(session):
raise RuntimeError("blocked: change the proxy here, or stop the run")
def on_challenge(session, resp):
raise RuntimeError("Cloudflare challenge page: needs a browser")
session = requests.Session()
page = fetch(session, "https://example.com/data", solver, rotate, on_challenge)
print(page.status_code, len(page.text))Чтобы увидеть, как классификатор оценивает настоящие ответы, прежде чем ему доверять, запустите эту проверку на нескольких своих URL. В запуске 29 октября 2026 года он вывел ok для обычной страницы, rate_limited для 429 и blocked для 403 от тестового сервиса.
import requests
from classify import classify
URLS = [
"https://example.com/",
"https://httpbin.org/status/429",
"https://httpbin.org/status/403",
]
for url in URLS:
resp = requests.get(url, timeout=30)
print(classify(resp), resp.status_code, url)Где классификатор ошибается
Это набор эвристик, и вот известные случаи.
| Случай | Результат | Исправить |
|---|---|---|
| Страница с контентом и виджетом капчи в подвале | captcha | Передайте expect в classify или в fetch со строкой, которая есть только на настоящей странице |
| Пост в блоге с фразой unusual traffic в заголовке | blocked | Замените фразы в BLOCK фразами вашей цели |
| HTML 403 от самого приложения, например страница входа | blocked | Добавьте правило для этой страницы перед правилом для 403 |
| Прямой заход или перенаправление на страницу /captcha, где только изображение | ok | Добавьте правило для этого сайта |
| Кнопка с классом g-recaptcha и атрибутом data-action (привязка reCAPTCHA v3) | captcha | Этот виджет никогда не закрывает страницу: передайте expect или запустите детектор |
| Капча, добавленная через JavaScript после загрузки | ok | Классификатор видит только первый ответ |
| Скрипт reCAPTCHA v3 на всём сайте | ok | Так задумано: v3 никогда не закрывает страницу |
Частые ошибки
| Ошибка | Причина | Исправить |
|---|---|---|
| RuntimeError: unknown response | Статус, который правила не покрывают, например JSON 403 или 404 | Выведите тело ответа и добавьте правило или позвольте остановить запуск |
| RuntimeError: gave up | Четыре попытки без ok | Проверьте, какая метка повторялась, и увеличивайте tries, только если это была rate_limited |
| RuntimeError: not a plain reCAPTCHA v2 form | Метка captcha пришла от другого виджета, или на странице нет sitekey или формы | Запустите детектор и вызовите подходящий метод |
| ERROR_GOOGLEKEY | Ключ не похож на ключ reCAPTCHA, например ключ Turnstile или слово explicit | Прочитайте ключ из самого виджета или запустите детектор |
| ApiException или TimeoutException | Решение не удалось или превысило лимит SDK | Проверьте метку, которая была перед этим, и держите tries небольшим, чтобы неудачная капча не тратила много решений |
| NetworkException | CapSkip не запущен либо неверно указаны хост или порт | Запустите CapSkip и проверьте настройки подключения, затем откройте раздел Документация API |
Запуск решателя на сервере
Циклу от CapSkip нужны только хост и порт. В режиме Local это 127.0.0.1 на том же ПК. В режиме Server CapSkip слушает ваш сетевой адрес или публичный IP, поэтому парсер на другом ПК, VPS или размещённой платформе обращается к тому же API. Смените хост и порт в конструкторе CapSkip, и больше ничего не меняется. Для стабильного удалённого доступа рекомендуется статический публичный IP. Режим Server всё равно работает на вашем оборудовании и всё равно без счётчика. Он открывает порт для любого адреса, поэтому добавьте в Windows Firewall блокирующее правило для адресов, которым доступ к нему не нужен. В разделе Настройки подключения объясняется, как переключать режимы.
FAQ
Как понять, что меня заблокировали, а не просто ограничили по частоте?
Ответ 429, особенно с заголовком Retry-After, означает ограничение частоты, и ожидание помогает. 403 с телом HTML или страница 200, где сказано, что доступ запрещён, означает блокировку. Голый 429, который не проходит после двух ожиданий, по практическому правилу считается блокировкой, поэтому цикл меняет на нём прокси.
Может ли решённая капча исправить блокировку?
Нет. Блокировка отказывает вашему адресу или вашему отпечатку, а токен ничего не доказывает ни о том, ни о другом. При блокировке цикл пропускает решатель и меняет прокси.
Почему цикл не решает страницу проверки Cloudflare?
Методу Turnstile нужны два значения из проверки, а обычный запрос их не получает. Цикл передаёт этот случай функции, которую вы предоставляете, например такой, что управляет настоящим браузером, и честно показывает, что классификатор способен увидеть.
Можно ли запускать это с CapSkip на другой машине?
Да. Используйте режим Server, направьте конструктор CapSkip на этот хост и порт, и цикл работает так же, как на одном ПК. Решатель остаётся на вашем оборудовании.
Коротко
Сначала решите, капча это или блокировка, потом действуйте: решайте капчу, пережидайте ограничение частоты, меняйте прокси при блокировке и останавливайтесь на всём неизвестном. Пока CapSkip работает на вашей машине, выигрыш от пропуска заведомо бесполезного решения измеряется временем, а выигрыш от отказа от лишней смены прокси адресами. Если взглянуть на прохождение страниц с капчей шире, то обход капчи сводится к тому, чтобы решать то, что можно решить, и обходить остальное. Сам SDK для Python описан на странице сервиса распознавания капчи для Python.
