Как решать капчу в скрипте Windmill (Python)

windmill captcha - How to Solve CAPTCHAs in a Windmill Script (Python)

Компактнее, чем решение капчи в Windmill, такая интеграция почти не бывает. Windmill читает импорты в начале вашего скрипта, сопоставляет их с пакетами PyPI и фиксирует версии в lockfile, поэтому CapSkip SDK появляется без шага установки и без файла зависимостей. Остаётся функция main, которая принимает sitekey и возвращает вам токен. Думать стоит не о коде, а о том, на какой машине работает воркер, потому что это решает, останется ли решатель на адресе loopback или ему придётся слушать вашу сеть.

Что понадобится

  • Инстанс Windmill, свой или в их облаке, и workspace, куда вы можете задеплоить скрипт.
  • CapSkip, запущенный на машине с Windows. Local mode подойдёт, если воркер работает на той же машине. Если воркеры живут в контейнерах или на другом хосте, переключитесь в Server mode.
  • Sitekey и URL страницы сайта, который вы автоматизируете.
  • Переменная Windmill с ключом решателя и переменная окружения воркера с его адресом.

Почему строка импорта и есть вся установка

Windmill разбирает импорты верхнего уровня, когда вы сохраняете скрипт, определяет, каким пакетам PyPI они соответствуют, и запускает задание зависимостей, которое пишет lockfile. Этот lockfile привязан к версии скрипта, поэтому та развёрнутая версия, которую вы протестировали, и будет работать через полгода. Нет файла зависимостей, который надо поддерживать, и нечего устанавливать на воркере руками.

Там же можно зафиксировать интерпретатор, комментарием в шапке скрипта. Развёрнутый скрипт, который не запрашивает версию, работает на Python 3.11.

# py312
# pip install capskip - Windmill resolves this import itself
# and locks the version when the script is deployed.
from capskip import CapSkip

Шаг 1: скрипт решения

Скрипт Windmill представляет собой функцию main. Её аргументы становятся схемой входных данных и формой, которую рисует Windmill, поэтому указывайте типы. Всё, что вы возвращаете, становится результатом скрипта, и следующий шаг flow читает его оттуда.

# py312
# pip install capskip - resolved from this import on save.
import wmill
from capskip import CapSkip

def main(sitekey: str, page_url: str) -> str:
    # Host and port come from the worker environment. The key is
    # a Windmill variable, so it is stored encrypted and never
    # appears in the script body or in the run logs.
    solver = CapSkip(
        host=wmill.get_variable("u/admin/capskip_host"),
        port=8080,
        apiKey=wmill.get_variable("u/admin/capskip_key"),
    )

    result = solver.recaptcha(sitekey=sitekey, url=page_url)

    return result["code"]   # the token, for the next step

Это вся интеграция для reCAPTCHA v2. Любой другой вариант сводится к тому же методу с дополнительным ключевым аргументом: invisible со значением 1, enterprise со значением 1 или version со значением v3 и именем действия. У Turnstile и GeeTest свои методы такой же формы, а полный список параметров лежит в документации CapSkip API.

Две вещи про SDK стоит знать, прежде чем браться за самописный цикл опроса. Он опрашивает за вас, начиная с 250 миллисекунд и увеличивая паузу, а не выжидая постоянный интервал, и это обычно быстрее опубликованных сроков ожидания сырого API. И его верхний предел для reCAPTCHA, Turnstile и GeeTest равен 300 секундам, он задан в recaptchaTimeout. Это число важно, когда вы выставляете таймаут скрипта на шаге 4.

Шаг 2: держите ключ в переменной Windmill

В Windmill есть полноценные переменные и секреты, и скрипт выше читает переменную напрямую. Есть второй способ, который лучше подходит для flow: передайте переменную в шаг аргументом, используя синтаксис ссылки, и Windmill разрешит её во время выполнения с правами вызывающего.

Где лежит значениеКак скрипт его получает
Секретная переменная WindmillПрочитать её в теле скрипта клиентом wmill, как выше
Переменная Windmill, переданная аргументом шагаЗадайте аргументу значение dollar-var, а за ним путь к переменной
Ресурс Windmill, который держит несколько полей сразуЗадайте аргументу значение dollar-res, а за ним путь к ресурсу
Переменная окружения на хосте воркераПрочитать её из окружения процесса, после того как воркеру разрешено её пробрасывать

Эти ссылки разрешаются рекурсивно, в том числе внутри списков и вложенных объектов, поэтому шаг, который принимает список ключей, может держать ссылку в каждом элементе. Заведите этому workspace собственный ключ решателя вместо того, чтобы делить один на всё, что вы запускаете.

Шаг 3: место, где работает воркер, определяет режим подключения

Это тот вопрос, который на самом деле формирует всю настройку, и ошибиться в нём легко, потому что скрипт в обоих случаях выглядит одинаково. Воркер Windmill является самостоятельным процессом, который выполняет один скрипт за раз. Это может быть контейнер рядом с базой данных, процесс на виртуальной машине или процесс на вашем рабочем компьютере. Что бы это ни было, вызов SDK открывает сокет из воркера, поэтому решатель должен быть доступен именно оттуда и больше ниоткуда.

Именно для этого у CapSkip есть два режима подключения. Local слушает 127.0.0.1 и обслуживает только это устройство. Server слушает ваш сетевой адрес или публичный IP, поэтому другая машина, хост контейнеров или облачная платформа могут обратиться к той же машине Windows по API. Оба настраиваются в разделе Настройки подключения, а Server mode меняет только то, где работает решатель. Это по-прежнему ваше железо и по-прежнему без счётчика.

Где работает ваш воркерКакой режим и значение хоста
На той же машине с Windows, что и CapSkipLocal mode. Значение хоста остаётся 127.0.0.1
В контейнере или на другой машине в вашей сетиServer mode. В значении хоста указан LAN-адрес машины с решателем
В облаке Windmill или на виртуальной машине вне вашей сетиServer mode со статическим публичным IP плюс правило фаервола

Воркеры Windmill действительно работают на Windows, и это тот случай, который оставляет вас на адресе loopback. Там важна одна настройка. Изоляция PID-пространства имён на Linux по умолчанию имеет значение true, а собственная документация Windmill предписывает выставлять для воркеров на Windows значение false. Задайте переменной с именем ENABLE_UNSHARE_PID значение false на воркере с Windows, и он запустится нормально.

Для двух других строк адрес должен лежать в окружении воркера, а не в скрипте. Windmill по умолчанию отдаёт заданию не все переменные хоста, поэтому перечислите нужные через запятую в переменной с именем WHITELIST_ENVS на воркере. Группа воркеров также может нести собственные статические и динамические переменные окружения, задаваемые в интерфейсе, и это более аккуратный вариант, когда рядом с решателем стоит только часть ваших воркеров.

Шаг 4: таймаут и повтор

Windmill кладёт поле Timeout в настройки выполнения скрипта, рядом с ограничениями Cache и Concurrency. Ставьте его выше вашего самого медленного решения, а не ниже. Чекбокс reCAPTCHA v2 обычно укладывается заметно меньше чем в минуту, но страницы проверки Turnstile и GeeTest занимают больше, и SDK будет опрашивать до 300 секунд, прежде чем сдастся с TimeoutException. Таймаут скрипта ниже этого значения превращает медленное решение в убитое задание, в логе которого нет ничего полезного.

Как только скрипт становится шагом flow, появляется второй слой. Шаги flow в Windmill повторяются в двух формах, и экспоненциальная подходит решателю, который ненадолго занят.

Форма повтораЧто вы настраиваетеКогда её использовать
Постоянная задержкаМаксимальное число попыток и фиксированная задержкаРешатель, который иногда перезапускается, когда постоянного ожидания достаточно
Экспоненциальная задержкаМаксимальное число попыток, основание в секундах и множительВсё, что может быть действительно занято, чтобы вы увеличивали паузу, а не долбили запросами

Задержка для экспоненциальной формы рассчитывается как множитель, умноженный на основание в степени номера попытки, поэтому основание 3 с множителем 2 на пяти попытках растягивает ожидания с 6 секунд до 486. Есть ещё настройка Continue on error, которая позволяет flow идти дальше после исчерпания повторов и передаёт ошибку как результат шага: так и строится ветка, которая откатывается на запасной вариант, а не роняет весь запуск.

Полный рабочий пример

Один скрипт, который решает капчу и отправляет форму, чтобы токен не лежал в ожидании следующего шага. Последний пункт не про стиль. Токен reCAPTCHA живёт около двух минут, и flow, который решает капчу на одном шаге, ждёт согласования, а потом отправляет на другом, гарантированно его теряет. Подробнее об этом в руководстве по истечению срока действия токена reCAPTCHA.

# py312
# pip install capskip requests - both resolved from these imports.
import os
import requests
import wmill
from capskip import CapSkip
from capskip.exceptions import TimeoutException, NetworkException

SITE = "https://example.com/page-with-recaptcha"

def main(sitekey: str, username: str) -> dict:
    # CAPSKIP_HOST is set on the worker and allowed through by
    # WHITELIST_ENVS. It falls back to the loopback address so the
    # same script still runs on a worker that sits next to CapSkip.
    solver = CapSkip(
        host=os.environ.get("CAPSKIP_HOST", "127.0.0.1"),
        port=8080,
        apiKey=wmill.get_variable("u/admin/capskip_key"),
    )

    try:
        result = solver.recaptcha(sitekey=sitekey, url=SITE)
    except TimeoutException:
        # Let the flow's retry policy decide what happens next.
        raise
    except NetworkException:
        raise RuntimeError("CapSkip is unreachable from this worker")

    # Submit immediately. The token is short lived, and the field
    # name below is the one the page's own form posts.
    posted = requests.post(
        SITE,
        data={
            "username": username,
            "g-recaptcha-response": result["code"],
        },
        timeout=30,
    )

    return {"status": posted.status_code, "captcha_id": result["captchaId"]}

Последний скрипт возвращает id капчи, а не токен, и сделано это намеренно. Id пригодится, когда вы позже разбираете историю запусков Windmill, а токен бесполезен: к тому моменту он уже истёк, а если положить его в сохранённый результат задания, он попадёт в ваши логи.

Решение нескольких сразу

Воркер Windmill выполняет один скрипт за раз и использует всю машину, которая у него есть. Поэтому параллельность здесь определяется тем, сколько воркеров вы запускаете, а не тем, что делает ваш скрипт. Есть два способа её получить, и они сочетаются.

  • Запустить больше воркеров. Группу воркеров можно масштабировать независимо, а задания забирает тот воркер, который свободен.
  • Решать капчи пачкой внутри одного скрипта. Python SDK поставляется с настоящим асинхронным клиентом, поэтому в одном задании может выполняться сразу несколько решений. Это имеет смысл, когда запуску нужно десять токенов, а не один, и этот приём разобран в руководстве по параллельному решению капчи на Python.

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

Частые ошибки и что они означают

Что вы видитеПричинаИсправить
NetworkException, отказ в соединении на порту 8080Воркер не на той машине, к которой привязан CapSkipПереключитесь в Server mode и укажите в переменной с хостом адрес решателя
Переменная окружения с хостом внутри задания читается как пустаяОна есть на воркере, но её так и не разрешили проброситьДобавьте её имя в WHITELIST_ENVS или задайте её на группе воркеров
ModuleNotFoundError на импорте capskipЗадание зависимостей для этой версии ещё не отработалоСохраните и задеплойте скрипт, затем проверьте, что задание зависимостей завершилось
Задание убивают на середине решенияТаймаут скрипта короче, чем заняло решениеПоднимите Timeout в настройках выполнения скрипта выше 300 секунд
TimeoutException из SDKРешение действительно вышло за recaptchaTimeoutДайте flow повторить его и проверьте, что sitekey и URL страницы верные
ERROR_WRONG_USER_KEY в ответеПеременная Windmill пуста, поэтому был отправлен пустой ключПроверьте путь к переменной, включая префикс workspace
Воркер на Windows не запускаетсяИзоляция PID-пространства имён для этого воркера не выключенаЗадайте ENABLE_UNSHARE_PID значение false на этом воркере
Целевой сайт отклоняет действительный токенОн истёк между шагом решения и шагом отправкиРешайте и отправляйте в одном скрипте или в соседних шагах без ожидания

FAQ

Можно ли оставить CapSkip на адресе loopback вместе с Windmill?

Да, если воркер работает на той же машине с Windows, что и решатель. Это единственный вариант развёртывания, где Local mode выживает, и настроить его стоит осознанно: запустите на машине с решателем отдельный воркер, дайте ему собственный тег и направляйте скрипты с капчей на этот тег. Любая другая схема, включая облако Windmill и любой контейнер, требует Server mode, потому что воркер находится в другом месте.

Нужен ли файл зависимостей для SDK?

Нет. Windmill читает импорты верхнего уровня при сохранении скрипта, сопоставляет их с пакетами PyPI и генерирует lockfile для этой версии скрипта. Строка импорта и есть объявление зависимости. Если импорт падает во время выполнения, смотреть надо на то, завершилось ли задание зависимостей, а не на то, не потерялся ли файл.

Должен ли flow сам опрашивать результат?

Не для решения, которое умещается в одно задание. SDK уже опрашивает сам, и он увеличивает паузу начиная с 250 миллисекунд, а не спит фиксированный интервал, поэтому самодельный цикл из шагов ожидания медленнее и требует больше кода. Опрашивайте из flow только если вы намеренно разделили отправку и получение на два шага, и в этом случае проверяйте ответ об ожидании по имени. Он пишется CAPCHA_NOT_READY, без буквы T, и означает, что надо ждать дальше, а не что что-то пошло не так. Есть подробный разбор ответа CAPCHA_NOT_READY.

Чем это отличается от того же в Airflow, Dagster или n8n?

Windmill требует меньше всего кода из четырёх, потому что скрипт представляет собой обычную функцию, а его зависимости берутся из строки импорта. Airflow хочет задачу внутри DAG, и там надо думать про интервал планировщика, что разобрано в руководстве по DAG с капчей в Airflow. Dagster описывает ту же работу как asset, об этом в разборе Dagster. n8n работает как граф узлов, а не как среда выполнения кода, поэтому руководство по n8n строится вокруг его узла HTTP Request. Вопрос подключения во всех четырёх одинаковый.

Коротко

Импортируйте SDK в начале скрипта Windmill и дайте заданию зависимостей зафиксировать версию. Положите ключ решателя в переменную Windmill, а его адрес в переменную окружения воркера, которую пропускает WHITELIST_ENVS. Режим подключения выбирайте по тому, где работает воркер, а не по тому, где сидите вы: воркер на той же машине с Windows, где стоит решатель, сохраняет Local mode, а всему остальному нужен Server mode. Поставьте таймаут скрипта выше 300 секунд, добавьте экспоненциальную задержку на шаге flow и отправляйте токен в том же задании, которое его получило.

Одно соображение перед тем, как ставить это в расписание каждые пять минут: CapSkip работает как локальный сервис распознавания капчи на железе, которое у вас уже есть, поэтому постоянно срабатывающий flow стоит ровно столько же, сколько срабатывающий изредка.