Tutorials

Журналы аудита решения CAPTCHA: отслеживание запросов на соответствие требованиям

Сколько стоил вчерашний трафик CAPTCHA и кто его запустил — скрапер, воркер очереди или разработчик, тестирующий интеграцию? Без отдельного журнала аудита ответ теряется уже через сутки. Если команда решает через CaptchaAI тысячи CAPTCHA в день, структурированный журнал — базовое требование для комплаенса, разбора инцидентов и сверки биллинга, а не опция.

Дальше — обязательные поля, код на Python и Node.js и разбор накопленных логов.

Зачем вести журнал аудита отдельно от лога приложения

Обычный лог приложения фиксирует ошибки и отладочные сообщения, но редко хранит контекст, нужный для комплаенса или разбора биллинга. Отдельный журнал аудита решает три задачи, с которыми обычный лог не справляется:

  • Атрибуция запроса. Каждая запись привязана к источнику — scraper-job-42, qa-checkout-suite или конкретному сервису, а не просто к строке в общем логе.
  • Сверка стоимости. Поле cost в каждой записи позволяет сопоставить журнал с расходом потоков в панели управления CaptchaAI, не дожидаясь конца расчётного периода.
  • Разбор инцидентов. Когда решение падает по таймауту или возвращает ERROR_CAPTCHA_UNSOLVABLE, журнал восстанавливает хронологию без обращения в поддержку.

Для агентств и интеграторов, которые обслуживают несколько проектов через один API-ключ, отдельный журнал — ещё и способ показать клиенту фактическое потребление по его задачам, а не просто общий счёт за поток.

Обязательные поля журнала аудита

Каждое решение фиксирует:

Поле Назначение Пример
timestamp Время запроса 2026-04-04T14:30:00Z
request_id ID решения uuid4()
captcha_type Тип CAPTCHA userrecaptcha
target_site Адрес страницы проверки https://staging.example.com/qa-login
task_id ID задачи CaptchaAI 73829451
status Итог solved, failed, timeout
solve_time_ms Время решения 18432
error_code Код ошибки ERROR_CAPTCHA_UNSOLVABLE
initiator Источник запроса scraper-job-42
cost Стоимость 0.003

Не логируйте: API-ключи, токены CAPTCHA и персональные данные с целевых сайтов.

Python: аудит-логирование решения

Обёртка над in.php/res.php: пишет JSON-строку на каждом этапе — отправке, опросе, итоге:

Совет: выносите запись аудит-лога в отдельный поток, очередь или буфер. Синхронная запись на диск в основном обработчике заметно замедляет решение при высокой нагрузке — подробнее в разделе «Типичные проблемы» ниже.

# audit_solver.py
import os
import uuid
import time
import json
import logging
from datetime import datetime, timezone
import requests

API_KEY = os.environ.get("CAPTCHAAI_KEY", "YOUR_API_KEY")

# Configure audit logger — separate from application logs
audit_logger = logging.getLogger("captcha_audit")
audit_logger.setLevel(logging.INFO)

# File handler with rotation
from logging.handlers import RotatingFileHandler
handler = RotatingFileHandler(
    "captcha_audit.jsonl",
    maxBytes=50_000_000,  # 50 MB per file
    backupCount=10,
)
handler.setFormatter(logging.Formatter("%(message)s"))
audit_logger.addHandler(handler)

def log_audit(record):
    """Write a structured audit record."""
    audit_logger.info(json.dumps(record, default=str))

def solve_with_audit(sitekey, pageurl, captcha_type="userrecaptcha",
                      initiator="unknown"):
    """Solve a CAPTCHA with full audit logging."""
    request_id = str(uuid.uuid4())
    start = time.time()

    audit_record = {
        "request_id": request_id,
        "timestamp": datetime.now(timezone.utc).isoformat(),
        "captcha_type": captcha_type,
        "target_site": pageurl,
        "initiator": initiator,
        "status": "submitted",
    }

    session = requests.Session()

    try:
        # Submit
        resp = session.get("https://ocr.captchaai.com/in.php", params={
            "key": API_KEY,
            "method": captcha_type,
            "googlekey": sitekey,
            "pageurl": pageurl,
            "json": "1",
        })
        result = resp.json()

        if result.get("status") != 1:
            audit_record.update({
                "status": "submit_failed",
                "error_code": result.get("request"),
                "solve_time_ms": int((time.time() - start) * 1000),
            })
            log_audit(audit_record)
            return None

        task_id = result["request"]
        audit_record["task_id"] = task_id

        # Poll
        time.sleep(15)
        for _ in range(25):
            poll = session.get("https://ocr.captchaai.com/res.php", params={
                "key": API_KEY, "action": "get",
                "id": task_id, "json": "1",
            })
            poll_result = poll.json()

            if poll_result.get("status") == 1:
                solve_time = int((time.time() - start) * 1000)
                audit_record.update({
                    "status": "solved",
                    "solve_time_ms": solve_time,
                    "cost_estimate": 0.003,  # Adjust per your rate
                })
                log_audit(audit_record)
                return poll_result["request"]

            if poll_result.get("request") != "CAPCHA_NOT_READY":
                audit_record.update({
                    "status": "failed",
                    "error_code": poll_result.get("request"),
                    "solve_time_ms": int((time.time() - start) * 1000),
                })
                log_audit(audit_record)
                return None

            time.sleep(5)

        audit_record.update({
            "status": "timeout",
            "solve_time_ms": int((time.time() - start) * 1000),
        })
        log_audit(audit_record)
        return None

    except Exception as e:
        audit_record.update({
            "status": "error",
            "error_code": str(e)[:200],
            "solve_time_ms": int((time.time() - start) * 1000),
        })
        log_audit(audit_record)
        raise

# Usage
token = solve_with_audit(
    sitekey="6Le-wvkSAAAAAPBMRTvw0Q4Muexq9bi0DJwx_mJ-",
    pageurl="https://www.google.com/recaptcha/api2/demo",
    initiator="price-scraper-v2",
)

Формат записи (JSONL)

Каждая строка — самостоятельный JSON-объект, его проще разбирать построчно, чем массив:

{"request_id":"a1b2c3d4-...","timestamp":"2026-04-04T14:30:00+00:00","captcha_type":"userrecaptcha","target_site":"https://www.google.com/recaptcha/api2/demo","initiator":"price-scraper-v2","status":"solved","task_id":"73829451","solve_time_ms":18432,"cost_estimate":0.003}

Node.js-версия

Та же логика на JavaScript — те же поля и шаги:

// audit_solver.js
const fs = require('fs');
const { v4: uuidv4 } = require('uuid');
const axios = require('axios');

const API_KEY = process.env.CAPTCHAAI_KEY || 'YOUR_API_KEY';
const AUDIT_FILE = 'captcha_audit.jsonl';

function logAudit(record) {
  fs.appendFileSync(AUDIT_FILE, JSON.stringify(record) + '\n');
}

async function solveWithAudit(sitekey, pageurl, initiator = 'unknown') {
  const requestId = uuidv4();
  const start = Date.now();
  const record = {
    request_id: requestId,
    timestamp: new Date().toISOString(),
    captcha_type: 'userrecaptcha',
    target_site: pageurl,
    initiator,
    status: 'submitted',
  };

  try {
    const submit = await axios.get('https://ocr.captchaai.com/in.php', {
      params: {
        key: API_KEY, method: 'userrecaptcha',
        googlekey: sitekey, pageurl, json: '1',
      },
    });

    if (submit.data.status !== 1) {
      record.status = 'submit_failed';
      record.error_code = submit.data.request;
      record.solve_time_ms = Date.now() - start;
      logAudit(record);
      return null;
    }

    record.task_id = submit.data.request;
    await new Promise(r => setTimeout(r, 15000));

    for (let i = 0; i < 25; i++) {
      const poll = await axios.get('https://ocr.captchaai.com/res.php', {
        params: { key: API_KEY, action: 'get', id: submit.data.request, json: '1' },
      });

      if (poll.data.status === 1) {
        record.status = 'solved';
        record.solve_time_ms = Date.now() - start;
        record.cost_estimate = 0.003;
        logAudit(record);
        return poll.data.request;
      }
      if (poll.data.request !== 'CAPCHA_NOT_READY') {
        record.status = 'failed';
        record.error_code = poll.data.request;
        record.solve_time_ms = Date.now() - start;
        logAudit(record);
        return null;
      }
      await new Promise(r => setTimeout(r, 5000));
    }

    record.status = 'timeout';
    record.solve_time_ms = Date.now() - start;
    logAudit(record);
    return null;
  } catch (e) {
    record.status = 'error';
    record.error_code = e.message.slice(0, 200);
    record.solve_time_ms = Date.now() - start;
    logAudit(record);
    throw e;
  }
}

Как анализировать накопленные логи

Дневная сводка

Скрипт считает решения по статусам и стоимость — этого хватит для быстрой проверки:

import json
from collections import Counter
from datetime import date

def daily_summary(log_file, target_date=None):
    """Generate a daily summary from audit logs."""
    target = target_date or date.today().isoformat()
    statuses = Counter()
    total_cost = 0
    solve_times = []

    with open(log_file) as f:
        for line in f:
            record = json.loads(line)
            if record["timestamp"].startswith(target):
                statuses[record["status"]] += 1
                total_cost += record.get("cost_estimate", 0)
                if record.get("solve_time_ms"):
                    solve_times.append(record["solve_time_ms"])

    print(f"Date: {target}")
    print(f"Total requests: {sum(statuses.values())}")
    print(f"Statuses: {dict(statuses)}")
    print(f"Estimated cost: ${total_cost:.2f}")
    if solve_times:
        print(f"Median solve time: {sorted(solve_times)[len(solve_times)//2]}ms")

daily_summary("captcha_audit.jsonl")

На что обращать внимание в сводке

  • Рост доли failed/timeout относительно solved — сигнал деградации апстрима, смены разметки целевой страницы или проблем с прокси.
  • Медиана solve_time_ms, растущая день ко дню, — повод проверить нагрузку на очередь или сменившийся тип CAPTCHA.
  • Всплеск cost без роста числа solved — обычно значит, что растёт доля неудачных попыток, за которые вы всё равно платите.

Объём журналов и хранение

Размер растёт линейно с объёмом решений:

Объём В день В месяц Рекомендация
100 решений/день ~30 КБ ~1 МБ Локальный файл
1000 решений/день ~300 КБ ~10 МБ Файл + ротация
10 000 решений/день ~3 МБ ~100 МБ Агрегатор логов
100 000 решений/день ~30 МБ ~1 ГБ ELK / Datadog

Типичные проблемы

Проблема Причина Исправление
Файл журнала разрастается Нет ротации RotatingFileHandler или logrotate
Записи аудита пропадают Исключение до записи Пишите запись в блоке finally
Медленная запись при нагрузке Синхронный файловый I/O Асинхронная запись или буферизация
Метки времени не совпадают Рассинхронизация часов NTP; время только в UTC

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

Нужно ли учитывать 152-ФЗ или GDPR при хранении журналов аудита?

Обычно нет — журнал не содержит персональных данных, если вы не логируете токены и содержимое страниц. Если target_site или initiator связаны с данными пользователей, сверьтесь с 152-ФЗ или GDPR.

Стоит ли сохранять сам токен CAPTCHA в аудит-логе?

Нет. Токен живёт 60–300 секунд и не имеет ценности постфактум.

Можно ли сверять журнал аудита с балансом CaptchaAI?

Да. Сравнивайте стоимость из логов с расходом потоков в панели управления — это ловит расхождения в биллинге раньше.

Что делать, если аудит нужен нескольким командам?

Используйте initiator как идентификатор источника (scraper-job-42, qa-checkout-suite) и фильтруйте сводки по нему.

Нужно ли шифровать файл журнала аудита на диске?

Если запись не содержит токенов и персональных данных, шифрование не обязательно. Но ограничьте доступ к файлу правами ОС, держите ротацию включённой и не копируйте captcha_audit.jsonl в общедоступные бэкапы без проверки прав доступа — это снижает риск при утечке снапшота диска.

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

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