Use Cases

Обработка CAPTCHA для сбора данных фондового рынка

Проверка появляется не там, где её ждут: не на первой странице портала, а на двадцатом тикере подряд, на выгрузке истории за год и на сложном запросе к скринеру. Финансовые порталы закрывают котировки, отчётность и рейтинги аналитиков через Cloudflare Turnstile и reCAPTCHA, поэтому сборщик данных должен уметь три вещи: распознать страницу проверки, отправить её в решатель и повторить исходный запрос с готовым токеном. Ниже — рабочая схема: где именно срабатывает проверка, код на Python и JavaScript, разумные интервалы опроса и разбор ошибок, которые чаще всего ломают ночной сбор.

Где именно срабатывает проверка CAPTCHA на финансовых порталах

Полезно заранее знать, какой тип проверки ждёт на каждом маршруте: от этого зависит и значение параметра method, и то, в какое поле возвращать токен.

Тип данных Примеры порталов Тип CAPTCHA Что вызывает проверку
Котировки в реальном времени Финансовые порталы Cloudflare Turnstile Быстрый перебор тикеров
Исторические цены Поставщики данных reCAPTCHA v2 Массовая выгрузка CSV
Финансовая отчётность Сайты раскрытия (SEC) Image CAPTCHA Повторяющиеся запросы к EDGAR
Результаты скрининга Скринеры акций Cloudflare Challenge Сложные фильтры
Рейтинги аналитиков Исследовательские порталы reCAPTCHA v3 Много просмотров страниц подряд

CaptchaAI решает все перечисленные типы: reCAPTCHA v2 и v3 (включая Enterprise-варианты), Cloudflare Turnstile и Cloudflare Challenge, GeeTest v3, image/OCR и grid-капчи. Типы hCaptcha и FunCaptcha сервис не поддерживает, GeeTest v4 — в разработке; если портал закрыт одним из них, сразу планируйте другой источник данных.

Сценарий: ночная выгрузка портфеля из 300 тикеров

Типичная задача аналитической команды в Алматы или Минске выглядит так: раз в сутки после закрытия рынка нужно снять цены закрытия по 300 бумагам и обновить витрину в ClickHouse. Проверка появляется примерно на каждом десятом запросе, и последовательный сборщик с паузой в 2 секунды укладывается в окно. При трёх параллельных потоках проверка срабатывает чаще, и решения начинают выстраиваться в очередь.

Здесь важна тарификация по потокам: CaptchaAI считает не решения, а одновременные задачи, и внутри тарифа число решений на поток не ограничено. Тариф BASIC ($15/мес, 5 потоков) закрывает такую ночную выгрузку с запасом, потому что решения расходуются короткими всплесками. Если же вы снимаете внутридневные котировки по нескольким сотням бумаг в течение всей сессии, имеет смысл смотреть на STANDARD ($30/мес, 15 потоков) или ADVANCE ($90/мес, 50 потоков) — предсказуемая месячная сумма в долларах удобнее для планирования бюджета, чем оплата за каждое решение.

Сборщик котировок на Python

Класс ниже делает три вещи: ходит за котировкой или историей в рамках одной сессии, распознаёт страницу проверки по коду 403 и маркерам Cloudflare, а затем отправляет sitekey в CaptchaAI и повторяет запрос с полученным токеном. Обратите внимание на два разных поля ответа: cf-turnstile-response для Turnstile и g-recaptcha-response для reCAPTCHA — перепутать их нельзя, форма просто не примет отправку.

import requests
import time
import re
from datetime import datetime, timedelta

class StockDataCollector:
    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 get_quote(self, portal_url, symbol):
        """Get current stock quote, solving CAPTCHAs if needed."""
        url = f"{portal_url}/quote/{symbol}"
        response = self.session.get(url)

        if self._is_captcha_page(response):
            response = self._solve_and_retry(response, url)

        return self._parse_quote(response.text, symbol)

    def get_historical(self, portal_url, symbol, days=365):
        """Download historical price data."""
        url = f"{portal_url}/history/{symbol}"
        params = {
            "period": f"{days}d",
            "interval": "1d"
        }
        response = self.session.get(url, params=params)

        if self._is_captcha_page(response):
            response = self._solve_and_retry(response, url)

        return self._parse_historical(response.text)

    def scan_symbols(self, portal_url, symbols, delay=2):
        """Collect quotes for multiple symbols."""
        results = {}

        for symbol in symbols:
            try:
                results[symbol] = self.get_quote(portal_url, symbol)
                time.sleep(delay)
            except Exception as e:
                results[symbol] = {"error": str(e)}

        return results

    def _is_captcha_page(self, response):
        return (
            response.status_code == 403 or
            "cf-turnstile" in response.text or
            "challenges.cloudflare.com" in response.text
        )

    def _solve_and_retry(self, response, url):
        match = re.search(r'data-sitekey="(0x[^"]+)"', response.text)
        if not match:
            # Fall back to reCAPTCHA detection
            match = re.search(r'data-sitekey="([^"]+)"', response.text)
            if match:
                return self._solve_recaptcha_and_retry(match.group(1), url)
            raise ValueError("No CAPTCHA sitekey 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("CAPTCHA solve timed out")

    def _solve_recaptcha_and_retry(self, site_key, url):
        resp = requests.post("https://ocr.captchaai.com/in.php", data={
            "key": self.api_key,
            "method": "userrecaptcha",
            "googlekey": site_key,
            "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={
                    "g-recaptcha-response": data["request"]
                })

        raise TimeoutError("reCAPTCHA solve timed out")

    def _parse_quote(self, html, symbol):
        from bs4 import BeautifulSoup
        soup = BeautifulSoup(html, "html.parser")

        def text_or_none(node):
            return node.text.strip() if node and node.text else None

        return {
            "symbol": symbol,
            "price": text_or_none(soup.select_one("[data-field='regularMarketPrice'], .price")),
            "change": text_or_none(soup.select_one("[data-field='regularMarketChange'], .change")),
            "volume": text_or_none(soup.select_one("[data-field='regularMarketVolume'], .volume")),
            "market_cap": text_or_none(soup.select_one("[data-field='marketCap'], .market-cap")),
            "timestamp": datetime.now().isoformat()
        }

    def _parse_historical(self, html):
        from bs4 import BeautifulSoup
        soup = BeautifulSoup(html, "html.parser")
        rows = []

        for row in soup.select("table tr")[1:]:  # Skip header
            cells = [td.text.strip() for td in row.select("td")]
            if len(cells) >= 6:
                rows.append({
                    "date": cells[0],
                    "open": cells[1],
                    "high": cells[2],
                    "low": cells[3],
                    "close": cells[4],
                    "volume": cells[5]
                })

        return rows


# Usage
collector = StockDataCollector("YOUR_API_KEY")

# Single quote
quote = collector.get_quote("https://finance.example.com", "AAPL")
print(f"AAPL: ${quote['price']} ({quote['change']})")

# Scan multiple symbols
portfolio = collector.scan_symbols(
    "https://finance.example.com",
    ["AAPL", "GOOGL", "MSFT", "AMZN", "TSLA"]
)

Ключевая деталь — общий requests.Session(). Куки, выданные после успешного прохождения проверки, живут в сессии и позволяют сделать следующие запросы уже без решения. Если создавать новую сессию на каждый тикер, проверка будет срабатывать буквально на каждом шаге, а число задач вырастет на порядок.

Скринер рынка на JavaScript

Тот же цикл в Node-варианте: запрос к скринеру, проверка HTML на маркер cf-turnstile, отправка задачи, опрос res.php и повторная отправка фильтров вместе с токеном.

class MarketScreener {
  constructor(apiKey) {
    this.apiKey = apiKey;
  }

  async screenStocks(portalUrl, filters) {
    const params = new URLSearchParams(filters);
    const response = await fetch(`${portalUrl}/screener?${params}`);
    const html = await response.text();

    if (html.includes('cf-turnstile') || response.status === 403) {
      return this.solveAndScreen(portalUrl, filters, html);
    }

    return this.parseScreenerResults(html);
  }

  async solveAndScreen(portalUrl, filters, html) {
    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: portalUrl,
        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(`${portalUrl}/screener`, {
          method: 'POST',
          body: new URLSearchParams({
            ...filters,
            'cf-turnstile-response': data.request
          })
        });
        return this.parseScreenerResults(await response.text());
      }
    }
    throw new Error('Turnstile solve timed out');
  }

  parseScreenerResults(html) {
    const rows = [];
    const tableMatch = html.match(/<table[^>]*>[\s\S]*?<\/table>/i);
    if (!tableMatch) return rows;

    const rowMatches = tableMatch[0].matchAll(/<tr[^>]*>([\s\S]*?)<\/tr>/gi);
    for (const row of rowMatches) {
      const cells = [...row[1].matchAll(/<td[^>]*>([\s\S]*?)<\/td>/gi)]
        .map(m => m[1].replace(/<[^>]+>/g, '').trim());
      if (cells.length >= 4) {
        rows.push({
          symbol: cells[0],
          price: cells[1],
          change: cells[2],
          volume: cells[3]
        });
      }
    }
    return rows;
  }
}

// Usage
const screener = new MarketScreener('YOUR_API_KEY');
const results = await screener.screenStocks('https://finance.example.com', {
  sector: 'technology',
  marketCap: 'large',
  peRatio: '<25'
});

Опрос здесь ограничен 60 итерациями по 3 с — это осознанный верхний предел, а не ожидаемое время решения. Turnstile обычно решается заметно быстрее, а длинный лимит нужен для редких пиков нагрузки; при исчерпании лимита корректнее отдать ошибку наверх и повторить попытку позже, чем висеть в ожидании бесконечно.

Интервалы сбора: сколько данных нужно на самом деле

Половина проблем со сбором биржевых данных решается не кодом, а расписанием. Чем реже вы обращаетесь к порталу, тем реже видите проверку и тем меньше потоков вам нужно.

Тип данных Рекомендуемый интервал Как часто встречается CAPTCHA
Котировки в реальном времени 1–5 минут Часто — по возможности используйте официальный API
Цены закрытия Раз в сутки после закрытия торгов Редко
Финансовая отчётность Раз в квартал Почти никогда
Результаты скрининга Раз в сутки Умеренно
Рейтинги аналитиков Раз в неделю Редко

Отдельно про сами данные: собирайте только ту информацию, которую вы вправе обрабатывать, и сверяйтесь с условиями использования портала. Если в выгрузке появляются персональные данные — например, имена аналитиков или клиентские идентификаторы, — к ним применяются требования 152-ФЗ «О персональных данных» и аналогичных норм в вашей юрисдикции. Это зона ответственности вашей команды, а не сервиса решения CAPTCHA.

Разбор типичных сбоев

Симптом Причина Что делать
Turnstile появляется на каждом запросе Для каждого запроса создаётся новая сессия Сохраняйте куки между запросами в одном объекте сессии
История цен приходит неполной Пагинация закрыта проверкой Решайте проверку постранично и идите по ссылкам пагинации
Котировка устарела Отдаётся закешированный ответ Добавьте параметр для сброса кеша в строку запроса
Ответ 429 Слишком высокая частота запросов Увеличьте задержку и распределите нагрузку между прокси
Ошибка баланса в ответе res.php Закончился баланс аккаунта Пополните баланс и добавьте его проверку в мониторинг сборщика
Токен получен, но портал отвечает 403 Токен ушёл не в то поле или с другого URL Сверьте pageurl с реальным адресом страницы и имя поля токена с типом проверки

Часто задаваемые вопросы

Сколько потоков нужно для сбора по 300 тикерам?

Считайте не по числу тикеров, а по числу одновременных решений. При последовательном обходе с паузой достаточно одного-двух потоков, и тариф BASIC ($15/мес, 5 потоков) даёт запас. Параллельный сбор сразу по нескольким порталам — повод смотреть на STANDARD ($30/мес, 15 потоков).

Почему проверка возвращается сразу после успешного решения?

Чаще всего токен приняли, но куки не сохранились: следующий запрос уходит из новой сессии и выглядит для портала как первый визит. Держите одну сессию на портал и проверьте, что заголовки и User-Agent не меняются между запросами.

Что делать, если портал закрыт hCaptcha?

CaptchaAI не решает hCaptcha и FunCaptcha. В этом случае ищите альтернативный источник тех же данных — официальный API портала, агрегатора или регуляторную выгрузку — либо согласуйте доступ напрямую с площадкой.

Можно ли собирать данные с нескольких порталов одновременно?

Да. Заведите отдельную сессию на каждый портал и отправляйте задачи в CaptchaAI независимо — параллельные задачи ограничены только числом потоков вашего тарифа.

Чем решение CAPTCHA отличается от официального API портала?

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


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

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