Use Cases

Обработка CAPTCHA для сбора данных о зарплате и компенсациях

Зарплатный портал почти никогда не блокирует первый запрос — проблема появляется на двадцатом. Как только скрипт начинает перебирать должности и города подряд, между ним и цифрами встаёт 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.minrange.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?

Нет. Оплата идёт за число одновременных потоков в месяц, количество решений внутри тарифа не ограничено. Поэтому при планировании кампании считают не «сколько решений мы купим», а «сколько проверок должно решаться параллельно, чтобы прогон уложился в окно».


Следующие шаги

Комментарии для этой статьи отключены.