Как решить капчу в Pydoll с помощью CapSkip на асинхронном Python

pydoll captcha - How to Solve a Pydoll CAPTCHA With CapSkip in Async Python

Чтобы решить капчу в Pydoll, считайте sitekey со страницы, решите её клиентом AsyncCapSkip из CapSkip в том же цикле событий asyncio, запишите токен в поле g-recaptcha-response и запустите коллбэк страницы или отправьте форму. В самом Pydoll 3.0 кода для reCAPTCHA нет, поэтому токен должен прийти от решателя. CapSkip работает на вашем собственном ПК с Windows, так что у обхода, который открывает много страниц, нет платы за каждое решение. В этом руководстве весь процесс решения капчи в Pydoll собирается примерно в 80 строк, а ещё разбираются ловушки, на которых в Windows уходит время.

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

  • CapSkip, запущенный на ПК с Windows, с включённым решателем reCAPTCHA. Пошаговое руководство по настройке проведёт вас через первый запуск.
  • Python 3.10 или новее. Он нужен и Pydoll 3.0.0, и пакету capskip 1.3.0. Всё ниже запускалось на Windows 11 с Python 3.14.0, а полный скрипт запускался ещё и на Python 3.11.7. Python 3.10, 3.12 и 3.13 не запускались.
  • Google Chrome, установленный в стандартную папку Program Files. В руководстве использовался Chrome 155, а полный скрипт запускался ещё и с Chrome for Testing 146.
  • Окно PowerShell. Команды написаны в синтаксисе PowerShell.
  • Ваша собственная страница с чекбоксом reCAPTCHA v2. Пример формы представляет собой обычную форму регистрации. Поменяйте селекторы под свою страницу.
  • Адрес решателя. В режиме Local CapSkip отвечает на 127.0.0.1 и только для этого устройства. В режиме Server он слушает ваш сетевой адрес или публичный IP, поэтому Pydoll на другом ПК, VPS или хостинговой платформе может обращаться к нему через API. Оба режима задаются в разделе Настройки подключения.

Замеры ниже получены на имитации CapSkip и имитации страницы регистрации на том же ПК, с настоящим Pydoll, настоящим SDK, настоящим Chrome и настоящим скриптом api.js от Google. Поведение браузера и SDK верно и для вашего собственного сайта. Время решения и правила приёма и отклонения на странице относятся только к самой имитации. Одно решение на работающем CapSkip с публичным тестовым sitekey от Google и настройками по умолчанию заняло 9.0 секунды и вернуло токен из 1764 символов. Время решения меняется от запуска к запуску. Этот токен нигде не использовался, и ни один живой сайт не проверялся.

Где заканчивается Pydoll и начинается CapSkip

Pydoll управляет Chrome по протоколу DevTools, без WebDriver посередине. Он даёт вам браузер, элементы страницы и способ выполнять JavaScript. Единственный код для капчи в Pydoll 3.0.0 относится к помощнику для чекбокса Cloudflare Turnstile. Он находит чекбокс и кликает по нему. Токен он не считывает и не выбрасывает ошибку, когда ничего не происходит. Обработки reCAPTCHA нет вообще. Чекбокс reCAPTCHA v2 решает сервис, который возвращает токен, а затем этот токен нужно положить на странице в нужное место. Именно это здесь делает CapSkip, а остальная часть руководства служит связующим кодом.

Шаг 1: создаём проект

Используйте виртуальное окружение в пустой папке. Команды запускают Python из этого окружения по его пути, так что активировать окружение не нужно, а ограничивающая политика скриптов PowerShell не мешает. Каждый скрипт ниже запускайте Python из окружения, например .\.venv\Scripts\python.exe solve.py. Старая установка Pydoll, например 2.27.0, упала бы на первом импорте скрипта ниже с ошибкой ImportError: cannot import name ‘Chrome’ from ‘pydoll’. В Pydoll 3.0 Chrome экспортируется с верхнего уровня.

# Windows PowerShell, in an empty folder
python -m venv .venv
.\.venv\Scripts\python.exe -m pip install pydoll-python capskip

Шаг 2: запустите Chrome и откройте страницу

Сохраните это как solve.py. Это самый маленький скрипт Pydoll, который открывает вашу страницу и печатает её заголовок. Браузер запускается внутри блока async with, поэтому по окончании блока он закрывается.

# pip install pydoll-python capskip
import asyncio

from pydoll import Chrome
from pydoll.browser.options import ChromiumOptions

PAGE = 'https://example.com/page-with-recaptcha'


async def main():
    options = ChromiumOptions()
    options.headless = True  # set to False to watch the window
    async with Chrome(options=options) as browser:
        tab = await browser.start()
        await tab.go_to(PAGE)
        print(await tab.title())


asyncio.run(main())

Pydoll ищет Chrome только в двух местах: в Program Files и Program Files (x86), в папке Google, Chrome, Application. Chrome, установленный для одного пользователя, в AppData, не находится, и скрипт останавливается с InvalidBrowserPath. Чтобы это исправить, задайте в options.binary_location полный путь к chrome.exe. Если указать несуществующий путь, вместо этого получится FailedToStartBrowser. Та же строка направляет Pydoll на Chrome for Testing, и это тоже сработало. Pydoll не сверяет версии браузера ни с чем, поэтому запустились и 146, и 155.

В Windows запуск медленнее, чем вы ожидаете. На тестовом ПК browser.start() занял 2.8 секунды с настройками по умолчанию и headless Chrome 155. Первый go_to занял ещё 2.1 секунды на странице без внешнего скрипта и около 3.1 секунды на странице регистрации, которая загружает скрипт Google. Pydoll подключается к localhost, а localhost сначала пробует адрес IPv6. Порт отладки Chrome не слушает IPv6, и Windows около 2 секунд отказывает в соединении с петлёй, поэтому каждое новое соединение с Chrome платит около 2 секунд, прежде чем сработает IPv4. Это касается самого браузера и каждой дополнительной вкладки, которую вы открываете. В Pydoll для этого нет настройки. Если заставить localhost разрешаться в 127.0.0.1 внутри процесса Python, в тесте задержка исчезает, но это патчит стандартную библиотеку, поэтому в руководстве так не делается. Учитывайте эту задержку, когда замеряете время запуска.

Блок with закрывает Chrome при обычном выходе, после исключения внутри него и после отменённой задачи, а также удаляет временную папку профиля. Если завершить процесс Python командой taskkill /F, этого не происходит. В тесте остались работать 13 процессов chrome.exe, а папка профиля осталась на диске. Ctrl+C в консоли не проверялся. Если вы прервали запуск, перед следующим запуском поищите оставшиеся процессы chrome.exe в Task Manager.

Шаг 3: проверьте, что CapSkip отвечает

Прежде чем добавлять браузер, убедитесь, что вызов решателя работает сам по себе. Сохраните это как first_solve.py. AsyncCapSkip является асинхронным клиентом из того же пакета. SDK не читает переменные окружения, поэтому хост и порт записаны прямо в клиент. Значения-заглушки в коде написаны буквально. YOUR_SITEKEY и адрес страницы должны относиться к странице, на которой действительно есть виджет reCAPTCHA v2, иначе CapSkip ответит ошибкой. Скопируйте sitekey из исходного кода страницы, это значение data-sitekey. Шаг 4 считывает его со страницы за вас. Запустите файл командой .\.venv\Scripts\python.exe first_solve.py.

# pip install capskip
import asyncio

from capskip import AsyncCapSkip


async def main():
    solver = AsyncCapSkip(host='127.0.0.1', port=8080)
    result = await solver.recaptcha(
        sitekey='YOUR_SITEKEY',
        url='https://example.com/page-with-recaptcha',
    )
    print(result['code'][:30])


asyncio.run(main())

Шаг 4: считайте sitekey со страницы

Sitekey лежит на странице, и Pydoll может его прочитать. Обычный виджет представляет собой div с классом g-recaptcha и атрибутом data-sitekey. У виджета, который страница рисует через grecaptcha.render(), такого div нет. Он оставляет iframe, в адресе которого sitekey передаётся в параметре k. Эта функция обрабатывает оба случая одним селектором, а имя тега подсказывает, какой из них получен. Добавьте её в solve.py выше async def main().

from urllib.parse import parse_qs, urlparse


async def find_sitekey(tab):
    # A normal widget carries data-sitekey. A widget drawn by
    # grecaptcha.render() only leaves its iframe, with k=<sitekey>.
    found = await tab.query(
        '.g-recaptcha[data-sitekey], '
        'iframe[src*="recaptcha/api2/anchor"]',
        timeout=10,
    )
    if found.tag_name == 'iframe':
        src = found.get_attribute('src')
        return parse_qs(urlparse(src).query)['k'][0]
    return found.get_attribute('data-sitekey')

Здесь важны две детали Pydoll. get_attribute является обычной функцией, а не корутиной, поэтому не вызывайте её через await, а для отсутствующего атрибута она возвращает None. А query выбрасывает ElementNotFound, когда ничего не найдено. С таймаутом он ждёт и вместо этого выбрасывает WaitElementTimeout, и это правильный выбор для виджета, который появляется через мгновение после загрузки страницы. Виджет, добавленный через 3 секунды после загрузки, нашёлся примерно через 3 секунды, при таймауте в 10 секунд в find_sitekey.

Шаг 5: решайте в том же цикле событий

Pydoll работает на asyncio, и ваш вызов решения выполняется в том же цикле. Поэтому важен выбор клиента. Добавьте это тоже выше async def main() и сделайте то же самое с функцией из шага 6.

from capskip import AsyncCapSkip

solver = AsyncCapSkip(host='127.0.0.1', port=8080)


async def solve(sitekey, url):
    result = await solver.recaptcha(sitekey, url)
    return result['code']

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

КлиентВремя решенияТики счётчикаСамая длинная пауза
Синхронный клиент CapSkip, вызванный прямо в асинхронной функции3.76 s53.78 s
CapSkip в asyncio.to_thread3.76 s650.06 s
AsyncCapSkip5.0 s680.3 s

В первом случае сама страница продолжала работать. Замирала только сторона Python: Python не может реагировать ни на что из происходящего на странице, пока не закончится решение, поэтому события из второй вкладки обрабатываются с опозданием. AsyncCapSkip здесь занял примерно на секунду больше, но это зависит от того, куда попадёт его следующий опрос после решения, и при решениях на имитации в 2, 4 и 8 секунд он закончил раньше, чем вариант с потоком. Всегда он стоит около 0.15 секунды заблокированного цикла на каждый запрос, потому что каждый раз создаёт новый HTTP-клиент. Это всё равно лучше замороженного цикла.

Шаг 6: запишите токен в страницу

Решённая reCAPTCHA представляет собой токен. Страница ожидает его в textarea с именем g-recaptcha-response, а если у виджета есть коллбэк, страница ожидает, что коллбэк выполнится с этим токеном. Эта функция делает и то и другое. Она использует execute_script из Pydoll и возвращает True, если нашла и запустила коллбэк. Добавьте её тоже выше async def main().

import json

FILL_JS = """
(function (token) {
  var seen = new Set(), fn = null;
  document.querySelectorAll('textarea[name="g-recaptcha-response"]')
    .forEach(function (box) { box.value = token; });
  (function walk(o, depth) {
    if (!o || typeof o !== 'object' || seen.has(o) || depth > 6) return;
    seen.add(o);
    for (var k in o) {
      var v; try { v = o[k]; } catch (e) { continue; }
      if (k === 'callback' && !fn) {
        fn = typeof v === 'string' ? window[v] : v;
      }
      if (v && typeof v === 'object' && !(v instanceof Node)) {
        walk(v, depth + 1);
      }
    }
  })(window.___grecaptcha_cfg, 0);
  if (typeof fn === 'function') fn(token);
  return typeof fn === 'function';
})(%s)
"""


async def fill_token(tab, token):
    reply = await tab.execute_script(
        FILL_JS % json.dumps(token), return_by_value=True)
    outcome = reply['result']
    if 'exceptionDetails' in outcome:
        error = outcome['exceptionDetails']['exception']
        raise RuntimeError(error['description'])
    return outcome['result']['value']  # True if a callback ran

JavaScript обходит собственный объект конфигурации Google, чтобы найти коллбэк, потому что ключи внутри него являются короткими именами, которые Google может изменить. Атрибут data-callback хранится там как имя функции, поэтому обход ищет это имя в window. Коллбэк, переданный в grecaptcha.render(), хранится как сама функция. Оба случая проверены на настоящем api.js от Google.

Pydoll возвращает из execute_script необработанный ответ DevTools, поэтому значение лежит в reply[‘result’][‘result’][‘value’], и вызов нужно ждать через await. Объект возвращается как ссылка, если не передать return_by_value=True. Ошибка JavaScript не выбрасывается в Python. Она добавляет в ответ exceptionDetails, поэтому функция проверяет его наличие и сама выбрасывает RuntimeError.

Шаг 7: соберите всё вместе

Удалите async def main() и строку asyncio.run(main()) внизу solve.py, оставьте функции выше них и вставьте вот это на их место. Решение запускается после загрузки страницы, потому что sitekey берётся со страницы, и прямо перед использованием токена. Если коллбэк выполнился, дальше всё делает страница. Если нет, скрипт нажимает кнопку отправки формы и ждёт перехода.

async def main():
    options = ChromiumOptions()
    options.headless = True  # set to False to watch the window
    async with Chrome(options=options) as browser:
        tab = await browser.start()
        await tab.go_to(PAGE)
        sitekey = await find_sitekey(tab)
        token = await solve(sitekey, await tab.current_url())
        if not await fill_token(tab, token):
            button = await tab.query('form button[type="submit"]')
            async with tab.expect_navigation():
                await button.click()
        print(await tab.title())


asyncio.run(main())

На странице, где отправку делает коллбэк, нажимать нечего. Эта версия заменяет main() тем же способом. Что произойдёт дальше, зависит от страницы, поэтому ждите того, что меняет сама страница. Эта версия ждёт, пока элемент с результатом на тестовой странице сменит значение waiting на другое. Поменяйте селектор на тот элемент, который обновляет ваша страница.

async def main():
    options = ChromiumOptions()
    options.headless = True  # set to False to watch the window
    async with Chrome(options=options) as browser:
        tab = await browser.start()
        await tab.go_to(PAGE)
        sitekey = await find_sitekey(tab)
        token = await solve(sitekey, await tab.current_url())
        if await fill_token(tab, token):
            # The page's own callback takes over. What it does next is
            # page specific: this page writes its answer into #result.
            await tab.wait_for_script(
                "var r = document.querySelector('#result');"
                "return r && r.textContent !== 'waiting';"
            )
        print(await (await tab.query('#result')).text())


asyncio.run(main())

Весь скрипт

Вот solve.py целиком, со всеми импортами вверху. Он заменяет блоки, которые вы добавляли по одному, поэтому сначала удалите их. На странице с обычной формой он напечатал Welcome, и страница один раз приняла токен. На странице, где отправку делает коллбэк, эта версия может напечатать старый заголовок до смены страницы, поэтому там используйте вариант с коллбэком выше.

# pip install pydoll-python capskip
import asyncio
import json
from urllib.parse import parse_qs, urlparse

from capskip import AsyncCapSkip
from pydoll import Chrome
from pydoll.browser.options import ChromiumOptions

PAGE = 'https://example.com/page-with-recaptcha'

solver = AsyncCapSkip(host='127.0.0.1', port=8080)

# Put the token in every response box, then run the page's callback.
FILL_JS = """
(function (token) {
  var seen = new Set(), fn = null;
  document.querySelectorAll('textarea[name="g-recaptcha-response"]')
    .forEach(function (box) { box.value = token; });
  (function walk(o, depth) {
    if (!o || typeof o !== 'object' || seen.has(o) || depth > 6) return;
    seen.add(o);
    for (var k in o) {
      var v; try { v = o[k]; } catch (e) { continue; }
      if (k === 'callback' && !fn) {
        fn = typeof v === 'string' ? window[v] : v;
      }
      if (v && typeof v === 'object' && !(v instanceof Node)) {
        walk(v, depth + 1);
      }
    }
  })(window.___grecaptcha_cfg, 0);
  if (typeof fn === 'function') fn(token);
  return typeof fn === 'function';
})(%s)
"""


async def find_sitekey(tab):
    # A normal widget carries data-sitekey. A widget drawn by
    # grecaptcha.render() only leaves its iframe, with k=<sitekey>.
    found = await tab.query(
        '.g-recaptcha[data-sitekey], '
        'iframe[src*="recaptcha/api2/anchor"]',
        timeout=10,
    )
    if found.tag_name == 'iframe':
        src = found.get_attribute('src')
        return parse_qs(urlparse(src).query)['k'][0]
    return found.get_attribute('data-sitekey')


async def solve(sitekey, url):
    result = await solver.recaptcha(sitekey, url)
    return result['code']


async def fill_token(tab, token):
    reply = await tab.execute_script(
        FILL_JS % json.dumps(token), return_by_value=True)
    outcome = reply['result']
    if 'exceptionDetails' in outcome:
        error = outcome['exceptionDetails']['exception']
        raise RuntimeError(error['description'])
    return outcome['result']['value']  # True if a callback ran


async def main():
    options = ChromiumOptions()
    options.headless = True  # set to False to watch the window
    async with Chrome(options=options) as browser:
        tab = await browser.start()
        await tab.go_to(PAGE)
        sitekey = await find_sitekey(tab)
        token = await solve(sitekey, await tab.current_url())
        if not await fill_token(tab, token):
            button = await tab.query('form button[type="submit"]')
            async with tab.expect_navigation():
                await button.click()
        print(await tab.title())


asyncio.run(main())

Решение в двух вкладках одновременно

Две вкладки могут решать капчу одновременно с помощью asyncio.gather. Два решения по 3 секунды, запущенные вместе, закончились вместе меньше чем за 4 секунды и с разными токенами. Ловушка в cookie. Вкладки в контексте браузера по умолчанию делят одно хранилище cookie, поэтому вторая загрузка страницы может заменить cookie сессии или CSRF, с которым первая вкладка вот-вот отправит форму. Имитация сайта ставит новый cookie сессии при каждой загрузке страницы, как это делают некоторые настоящие сайты, поэтому вторая загрузка заменила cookie первой вкладки, и одна из двух одновременных отправок была отклонена. Когда каждой вкладке дали собственный контекст браузера, это исправилось, и обе отправки приняли. Эта версия заменяет main() тем же способом и нажимает кнопку отправки, поэтому подходит для обычной формы.

async def solve_page(tab):
    sitekey = await find_sitekey(tab)
    token = await solve(sitekey, await tab.current_url())
    await fill_token(tab, token)
    button = await tab.query('form button[type="submit"]')
    async with tab.expect_navigation():
        await button.click()
    return await tab.title()


async def main():
    options = ChromiumOptions()
    options.headless = True
    async with Chrome(options=options) as browser:
        await browser.start()
        # One browser context per tab: tabs in the default context
        # share cookies, and a second page load can replace the first
        # tab's session or CSRF cookie.
        contexts = [await browser.create_browser_context() for _ in range(2)]
        tabs = [
            await browser.new_tab(PAGE, browser_context_id=context)
            for context in contexts
        ]
        print(await asyncio.gather(*[solve_page(tab) for tab in tabs]))
        for context in contexts:
            await browser.delete_browser_context(context)


asyncio.run(main())

Запуск Pydoll с CapSkip на другой машине

Pydoll и решателю не обязательно работать на одном ПК. Переключите CapSkip в режим Server в настройках подключения, и он будет слушать ваш сетевой адрес или публичный IP. Для стабильного удалённого доступа рекомендуется статический публичный IP. Тогда меняется только строка с клиентом. Решателем по-прежнему остаётся ваша собственная машина с Windows, и решения по-прежнему не тарифицируются.

# CapSkip on another Windows PC, in Server mode
solver = AsyncCapSkip(
    host='192.168.1.50',  # that PC's network address
    port=8080,
    apiKey='YOUR_API_KEY',  # only if API key validation is on
)

Обратите внимание на две вещи. SDK обращается к CapSkip по обычному HTTP, поэтому API-ключ передаётся открытым текстом. Держите это соединение в частной сети или VPN и включите проверку API-ключа. А ещё CapSkip открывает свой порт для всех адресов при запуске режима Server, поэтому сузить доступ можно правилом Windows Firewall, блокирующим адреса, которых вы не ожидаете. С AsyncCapSkip недоступный адрес приводит примерно через 5 секунд к NetworkException с пустым сообщением. Удалённая настройка для этого руководства не запускалась, читался только конструктор.

Таймауты и как выглядит сбой

Решайте и отправляйте быстро. Токен reCAPTCHA работает один раз и истекает, поэтому решайте после загрузки страницы и прямо перед отправкой. Строгая имитация сайта отклонила вторую отправку того же токена с кодом 403, и это правило самой имитации. Страница про срок действия токена reCAPTCHA описывает тайминги. Ниже показано, как выглядел каждый сбой с AsyncCapSkip и настройками по умолчанию на имитации:

СлучайЧто вы видитеЧерез
CapSkip не запущенNetworkException: All connection attempts failed2.2 s
CapSkip принимает вызов и замолкаетNetworkException с пустым сообщением5.3 s
Решить капчу не получаетсяApiException: ERROR_CAPTCHA_UNSOLVABLE0.7 s
Решение в 30 с при recaptchaTimeout, равном 6TimeoutException: timeout 6.0 exceeded8.7 s
То же решение внутри asyncio.wait_for с лимитом 4 сСобственный TimeoutError Python (asyncio.TimeoutError в Python 3.10)4.0 s

Задавайте recaptchaTimeout у клиента, а не у вызова. Аргумент timeout в вызове приводит к TypeError с сообщением got multiple values for keyword argument ‘timeout’. SDK проверяет свой лимит только между опросами, поэтому лимит в 6 секунд сработал на 8.7 секунде. Если важен жёсткий потолок, оберните вызов в asyncio.wait_for. Он отменяет только вашу сторону. CapSkip держит задание до своих собственных лимитов, и это не проверялось на настоящем приложении. У Pydoll есть свои ошибки. Навигация, которая занимает больше времени, чем разрешает go_to, вызывает PageLoadTimeout, а отказ в соединении вызывает NavigationError с net::ERR_CONNECTION_REFUSED в сообщении. В Pydoll есть и класс TimeoutException, и в CapSkip есть класс с таким же именем. Это разные классы, поэтому импортируйте модули целиком и пишите capskip.TimeoutException, когда перехватываете исключение решателя.

Частые ошибки при решении капчи в Pydoll

Что вы видитеПричинаИсправить
ImportError: cannot import name ‘Chrome’ from ‘pydoll’Установлен Pydoll 2.xОбновитесь до pydoll-python 3
InvalidBrowserPath: No valid browser path foundChrome установлен для одного пользователя, а не в Program FilesЗадайте options.binary_location
FailedToStartBrowser: The browser exited with code 21Фиксированный user-data-dir уже используется другим браузеромИспользуйте другую папку профиля или не задавайте её вовсе
ArgumentAlreadyExistsInOptionsОдин и тот же объект ChromiumOptions использован для второго браузера, или вы добавили флаг, который Pydoll добавляет самСоздавайте новый объект options для каждого браузера
WaitElementTimeout при запросе sitekeyНа странице пока нет виджета, или селектор ему не соответствуетПроверьте страницу в видимом окне, затем увеличьте таймаут
NetworkException: All connection attempts failedCapSkip не запущен либо неверно указан портЗапустите CapSkip и проверьте настройки подключения
ApiException: ERROR_CAPTCHA_UNSOLVABLECapSkip не смог завершить решениеПроверьте sitekey и адрес страницы, затем повторите попытку один раз. Смотрите ERROR_CAPTCHA_UNSOLVABLE
RuntimeError: ReferenceError, имя не определеноJavaScript в fill_token выбросил исключение внутри страницыЗапустите тот же скрипт в консоли браузера
Страница сообщает, что капча не пройдена, а ошибки в Python нетТокен использовали дважды, или коллбэк не выполнилсяРешайте заново прямо перед каждой отправкой
FailedToStartBrowser: Could not launchoptions.binary_location указывает на файл, которого нетПроверьте путь к chrome.exe
Каждый запуск идёт примерно на 4 секунды дольше самой работыКаждое новое соединение с Chrome платит задержку localhost около 2 секундИспользуйте один браузер и одну вкладку: второй go_to на той же вкладке занял меньше 0.2 секунды

FAQ

Решает ли Pydoll reCAPTCHA сам по себе?

Нет. В Pydoll 3.0.0 нет кода для reCAPTCHA. Его единственный помощник для капчи ищет чекбокс Cloudflare Turnstile и кликает по нему, а это не решение и токена не возвращает.

Работает ли это с headless Chrome?

Полный скрипт прошёл и в headless, и в headed режиме на странице-имитации, на Chrome 155 и Chrome for Testing 146. Как живой сайт относится к headless-браузеру, не проверялось, и это отдельный вопрос от токена.

Можно ли запускать Pydoll на другом ПК, а CapSkip держать дома?

Да, если этот ПК может достучаться до CapSkip. Переведите CapSkip в режим Server и задайте хост и порт в клиенте. Для стабильного удалённого доступа через интернет рекомендуется статический публичный IP. Ключ и адрес страницы передаются открытым текстом по обычному HTTP, поэтому лучше использовать VPN или частную сеть и добавить правило Windows Firewall, блокирующее другие адреса. Удалённая настройка для этого руководства не проверялась.

А как насчёт невидимой reCAPTCHA, v3 и Enterprise?

Вызов recaptcha в SDK принимает для них опции: invisible со значением 1, enterprise со значением 1 и version со значением v3. Для Pydoll страницу нужно читать иначе, например брать имя действия для v3, и ни один из этих вариантов для этого руководства не запускался. Сам вызов решателя для v3 описан в руководстве по решению reCAPTCHA v3 в Python.

Коротко

Решение капчи в Pydoll состоит из пяти частей: считать sitekey из data-sitekey или из адреса iframe, решать через AsyncCapSkip, чтобы цикл событий оставался живым, записать токен в g-recaptcha-response, запустить коллбэк страницы или отправить форму и дать каждой параллельной вкладке собственный контекст браузера. Решайте прямо перед отправкой, задавайте recaptchaTimeout у клиента и оборачивайте вызов в asyncio.wait_for как жёсткий потолок.

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