Зарплатный портал почти никогда не блокирует первый запрос — проблема появляется на двадцатом. Как только скрипт начинает перебирать должности и города подряд, между ним и цифрами встаёт Cloudflare Turnstile или reCAPTCHA v2. Рабочая схема состоит из трёх шагов: распознать, что вернулась проверка, а не выдача; отправить sitekey и pageurl в API CaptchaAI; повторить запрос уже с полученным токеном. Ниже — готовый сборщик на Python, агрегатор нескольких источников на JavaScript и расчёт потоков под реальный объём.
Где именно появляется проверка
Первое, что стоит сделать до написания парсера, — понять, какой тип проверки стоит на конкретном источнике. От этого зависит и время решения, и то, сколько задач успеет закрыть один поток.
| Тип источника | Тип CAPTCHA | Что её вызывает |
|---|---|---|
| Агрегаторы зарплат | Cloudflare Turnstile | Серия однотипных поисковых запросов |
| Фильтры зарплат на джоб-бордах | reCAPTCHA v2 | Несколько уточнений подряд |
| Государственная статистика труда | Image CAPTCHA | Запрос выгрузки набора данных |
| Корпоративные страницы компенсаций | Cloudflare Challenge | Массовый просмотр страниц |
| Платформы HR-опросов | reCAPTCHA v3 | Отправка формы |
Все пять типов входят в число поддерживаемых. Ориентировочное время решения: Turnstile — менее 10 с, reCAPTCHA v2 — менее 60 с, Image CAPTCHA — менее 0,5 с. Эти цифры удобно закладывать прямо в тайм-ауты сборщика: если вы ждёте ответ три минуты, вы почти наверняка ждёте не решение, а зависший запрос.
Сколько потоков нужно под ваш объём
CaptchaAI тарифицируется по потокам, а не по числу решений. Поток — это одна CAPTCHA, находящаяся в работе; как только она решена, поток берёт следующую. Внутри тарифа число решений не ограничено, отдельной доплаты за конкретный тип CAPTCHA нет.
| Тариф | Цена | Потоки |
|---|---|---|
| BASIC | $15/мес | 5 |
| STANDARD | $30/мес | 15 |
| ADVANCE | $90/мес | 50 |
Практический ориентир: при времени решения Turnstile менее 10 с один поток закрывает порядка нескольких сотен задач в час. Если ночной прогон собирает 3000 комбинаций «должность + город», а проверка выпадает примерно на каждой десятой странице, речь идёт о сотнях решений — такой объём укладывается в младшие тарифы, и узким местом остаются лимиты самого портала, а не количество потоков.
Отдельный аргумент для команд, которые считают бюджет в тенге, рублях или гривне: фиксированная месячная цена в USD предсказуема, тогда как оплата за каждое решение при кампании на десятки тысяч запросов заранее непросчитываема. Аналитическому агентству в Алматы, которое раз в квартал строит срез зарплат по IT-рынку Казахстана и Центральной Азии, проще заложить один тариф на месяц активного сбора, чем объяснять заказчику плавающий счёт.
| Режим сбора | Объём в сутки | Частота проверок | Когда подходит |
|---|---|---|---|
| Последовательно с паузами | 100–500 запросов | низкая | точечный срез по 2–3 ролям |
| Несколько сессий с распределением по IP | 500–2000 запросов | средняя | региональные сравнения |
| Параллельная работа в несколько потоков | 2000–10 000 запросов | высокая | полные наборы данных по рынку |
Прокси здесь — обычная инфраструктура распределения нагрузки: они помогают не упереться в ограничение частоты запросов с одного адреса, и не более того.
Сборщик зарплатных данных на Python
Логика класса ниже простая. Метод collect_salary_data делает обычный GET к поиску портала, проверяет ответ на признаки Turnstile (код 403, наличие cf-turnstile или домена challenges.cloudflare.com), при необходимости решает проверку и разбирает HTML в структуру SalaryRecord. Метод collect_bulk перебирает пары «должность + локация» с паузой в две секунды и не роняет весь прогон из-за одной неудачной комбинации.
Обратите внимание на _solve_turnstile_and_retry: sitekey извлекается из разметки регулярным выражением, задача отправляется в in.php, затем res.php опрашивается с интервалом три секунды, а готовый токен уходит обратно на портал в поле cf-turnstile-response.
import requests
import time
import re
from dataclasses import dataclass
@dataclass
class SalaryRecord:
title: str
location: str
min_salary: float
max_salary: float
median_salary: float
sample_size: int
source: str
class SalaryCollector:
def __init__(self, api_key):
self.api_key = api_key
self.session = requests.Session()
self.session.headers.update({
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
})
def collect_salary_data(self, portal_url, job_title, location):
"""Search for salary data, solving CAPTCHAs as needed."""
response = self.session.get(portal_url, params={
"title": job_title,
"location": location
})
if self._is_turnstile_challenge(response):
response = self._solve_turnstile_and_retry(response, portal_url)
return self._parse_salary_data(response.text, portal_url)
def collect_bulk(self, portal_url, job_titles, locations):
"""Collect salary data for multiple job title + location combos."""
results = []
for title in job_titles:
for location in locations:
try:
data = self.collect_salary_data(
portal_url, title, location
)
results.extend(data)
# Respectful delay between requests
time.sleep(2)
except Exception as e:
print(f"Failed for {title} in {location}: {e}")
return results
def _is_turnstile_challenge(self, response):
return (
response.status_code == 403 or
"cf-turnstile" in response.text or
"challenges.cloudflare.com" in response.text
)
def _solve_turnstile_and_retry(self, response, url):
match = re.search(r'data-sitekey="(0x[^"]+)"', response.text)
if not match:
raise ValueError("Turnstile sitekey not found")
resp = requests.post("https://ocr.captchaai.com/in.php", data={
"key": self.api_key,
"method": "turnstile",
"sitekey": match.group(1),
"pageurl": url,
"json": 1
})
task_id = resp.json()["request"]
for _ in range(60):
time.sleep(3)
result = requests.get("https://ocr.captchaai.com/res.php", params={
"key": self.api_key,
"action": "get",
"id": task_id,
"json": 1
})
data = result.json()
if data["status"] == 1:
return self.session.post(url, data={
"cf-turnstile-response": data["request"]
})
raise TimeoutError("Turnstile solve timed out")
def _parse_salary_data(self, html, source):
from bs4 import BeautifulSoup
soup = BeautifulSoup(html, "html.parser")
records = []
def text_or_empty(node):
return node.text.strip() if node and node.text else ""
for row in soup.select(".salary-row, .compensation-entry, tr[data-salary]"):
try:
records.append(SalaryRecord(
title=text_or_empty(row.select_one(".job-title, .title")),
location=text_or_empty(row.select_one(".location")),
min_salary=self._parse_amount(
text_or_empty(row.select_one(".min-salary, .low"))
),
max_salary=self._parse_amount(
text_or_empty(row.select_one(".max-salary, .high"))
),
median_salary=self._parse_amount(
text_or_empty(row.select_one(".median, .mid"))
),
sample_size=int(
text_or_empty(row.select_one(".count, .sample")).replace(",", "") or 0
),
source=source
))
except (AttributeError, ValueError):
continue
return records
def _parse_amount(self, text):
if not text:
return 0.0
cleaned = re.sub(r'[^\d.]', '', text)
return float(cleaned) if cleaned else 0.0
# Usage
collector = SalaryCollector("YOUR_API_KEY")
data = collector.collect_bulk(
"https://salary.example.com/search",
job_titles=["Software Engineer", "Data Analyst", "Product Manager"],
locations=["San Francisco", "New York", "Austin"]
)
for record in data:
print(f"{record.title} in {record.location}: "
f"${record.min_salary:,.0f}–${record.max_salary:,.0f} "
f"(median: ${record.median_salary:,.0f})")
Одна requests.Session() на весь прогон — не мелочь: она сохраняет заголовки и cookie между запросами, поэтому проверка не выпадает заново на каждом поиске подряд.
Агрегация нескольких источников на JavaScript
Один портал редко даёт полную картину: медианы по одной и той же роли расходятся на десятки процентов. Агрегатор ниже опрашивает несколько источников по очереди, для каждого при необходимости решает Turnstile и сводит результат в один объект — среднюю медиану, число сработавших источников и разброс между ними. Ошибка на одном источнике не прерывает цикл, а остаётся в результатах в поле error.
class SalaryAggregator {
constructor(apiKey) {
this.apiKey = apiKey;
this.sources = [];
}
addSource(name, searchUrl) {
this.sources.push({ name, searchUrl });
}
async collectForRole(jobTitle, location) {
const results = [];
for (const source of this.sources) {
try {
const data = await this.querySource(source, jobTitle, location);
results.push({ source: source.name, ...data });
} catch (error) {
results.push({ source: source.name, error: error.message });
}
}
return this.aggregateResults(results, jobTitle, location);
}
async querySource(source, jobTitle, location) {
const url = `${source.searchUrl}?title=${encodeURIComponent(jobTitle)}&location=${encodeURIComponent(location)}`;
const response = await fetch(url);
const html = await response.text();
if (html.includes('cf-turnstile') || response.status === 403) {
return this.solveAndRetry(source.searchUrl, html, jobTitle, location);
}
return this.parseSalaryData(html);
}
async solveAndRetry(baseUrl, html, jobTitle, location) {
const match = html.match(/data-sitekey="(0x[^"]+)"/);
if (!match) throw new Error('Turnstile sitekey not found');
const submitResp = await fetch('https://ocr.captchaai.com/in.php', {
method: 'POST',
body: new URLSearchParams({
key: this.apiKey,
method: 'turnstile',
sitekey: match[1],
pageurl: baseUrl,
json: '1'
})
});
const { request: taskId } = await submitResp.json();
for (let i = 0; i < 60; i++) {
await new Promise(r => setTimeout(r, 3000));
const result = await fetch(
`https://ocr.captchaai.com/res.php?key=${this.apiKey}&action=get&id=${taskId}&json=1`
);
const data = await result.json();
if (data.status === 1) {
const response = await fetch(baseUrl, {
method: 'POST',
body: new URLSearchParams({
'cf-turnstile-response': data.request,
title: jobTitle,
location: location
})
});
return this.parseSalaryData(await response.text());
}
}
throw new Error('Turnstile solve timed out');
}
aggregateResults(results, jobTitle, location) {
const valid = results.filter(r => !r.error && r.median);
if (valid.length === 0) return null;
const medians = valid.map(r => r.median);
return {
jobTitle,
location,
avgMedian: medians.reduce((a, b) => a + b, 0) / medians.length,
sources: valid.length,
range: { min: Math.min(...medians), max: Math.max(...medians) }
};
}
}
// Usage
const aggregator = new SalaryAggregator('YOUR_API_KEY');
aggregator.addSource('SalaryDB', 'https://salarydb.example.com/search');
aggregator.addSource('PayScale', 'https://payscale.example.com/lookup');
const result = await aggregator.collectForRole('Software Engineer', 'San Francisco');
console.log(`Median salary: $${result.avgMedian.toLocaleString()} (${result.sources} sources)`);
Разброс range.min — range.max полезнее среднего: если два источника расходятся вдвое, дело обычно не в рынке, а в разной методике выборки.
Что ломается на практике
| Симптом | Причина | Что делать |
|---|---|---|
| Проверка выпадает на каждом запросе | Каждый запрос уходит новым соединением | Использовать одну сессию на весь цикл сбора |
| В ответе «Login required» | Раздел доступен только авторизованным | Авторизоваться до начала поиска |
| После решения приходит пустая выдача | В POST не хватает скрытых полей формы | Отправлять все поля формы вместе с токеном |
| Цифры расходятся между прогонами | Портал считает диапазон по другой выборке | Фиксировать параметры запроса и дату сбора |
res.php долго возвращает статус ожидания |
Тайм-аут задан без учёта типа CAPTCHA | Сверить ожидание со временем решения нужного типа |
Данные о зарплатах — это чувствительные данные
Агрегированные диапазоны по должностям обезличены, но выгрузка с портала нередко тянет за собой лишнее: имена рекрутеров, идентификаторы вакансий, контакты. Для читателей в РФ ориентир — 152-ФЗ «О персональных данных», для трансграничных проектов — привычная GDPR-дисциплина. Практическое правило простое: сохранять только те поля, которые действительно нужны для расчёта, и отбрасывать остальное на этапе парсинга, а не «когда-нибудь потом». Это вопрос вашей собственной проверки, а не обязательство со стороны сервиса решения CAPTCHA.
Частые вопросы
Токен получен, но портал всё равно возвращает 403 — что проверить?
Чаще всего токен ушёл не туда или не вовремя. Проверьте три вещи: pageurl при отправке задачи совпадает с реальным адресом страницы; токен уходит в поле cf-turnstile-response тем же POST, что и параметры поиска; между решением и отправкой прошло меньше минуты — токен живёт недолго.
Поддерживаются ли hCaptcha и FunCaptcha на зарплатных порталах?
Нет. hCaptcha и FunCaptcha сейчас не поддерживаются, GeeTest v4 — в разработке. Поддерживаются reCAPTCHA v2 и v3, Cloudflare Turnstile и Cloudflare Challenge, GeeTest v3, текстовые и графические CAPTCHA. Типы CaptchaFox, Friendly Captcha и Lemin доступны в статусе beta. Если ключевой источник закрыт неподдерживаемым типом, закладывайте по нему ручную выгрузку.
Что делать, если разметка портала поменялась и парсер собирает пустые записи?
Селекторы в _parse_salary_data перечислены через запятую именно для этого — это несколько вариантов вёрстки на один прогон. Добавьте счётчик пустых записей и порог: если не распарсилось больше 20 % строк, прогон должен падать с ошибкой, а не молча писать нули в набор данных.
Нужно ли платить отдельно за каждую решённую CAPTCHA?
Нет. Оплата идёт за число одновременных потоков в месяц, количество решений внутри тарифа не ограничено. Поэтому при планировании кампании считают не «сколько решений мы купим», а «сколько проверок должно решаться параллельно, чтобы прогон уложился в окно».