How to Solve CAPTCHAs in Jupyter Without asyncio.run Errors

jupyter notebook captcha - How to Solve CAPTCHAs in Jupyter Without asyncio.run Errors

To solve CAPTCHAs in a Jupyter notebook, install the capskip package with %pip so it lands in the kernel’s own Python. Then call CapSkip from a cell as you would in a script, or await its AsyncCapSkip client right at the top level of a cell. What you must not do is wrap the async call in asyncio.run(). The kernel already runs an event loop, so asyncio.run raises RuntimeError, and Playwright’s sync API refuses to start for the same reason. Jupyter notebook CAPTCHA work has two more traps of its own: the stop button cannot always stop a solve, and on Windows an older ipykernel stops Playwright from launching a browser. This guide covers the install, both clients, browsers and remote kernels, tested on ipykernel 7.4 and 7.3.

What you need

  • CapSkip running on a Windows machine. The examples solve reCAPTCHA v2, and every other type the SDK handles works the same way in a cell.
  • Python 3.10 or later and a current capskip package (1.3.0 when this was written), plus requests and Beautiful Soup for the page. Playwright only if you need a browser.
  • Jupyter in any form: Notebook 7, JupyterLab or the notebook editor in VS Code. All three run your cells on ipykernel, which is where every behaviour below comes from.
  • An address for the solver. Local mode answers on 127.0.0.1 for that device only; Server mode listens on your network address or public IP so a kernel on another machine can call it over the API. Both are under connection settings, and the last section covers when you need Server mode.

Step 1: install into the kernel’s Python with %pip

Run the install from a notebook cell, with a percent sign:

# pip install capskip requests beautifulsoup4, run inside the notebook:
%pip install capskip requests beautifulsoup4

The percent sign matters. IPython’s %pip magic runs pip with the kernel’s own interpreter, sys.executable, so the package lands where your cells import from. An exclamation mark hands the line to a shell instead, and the shell runs whichever pip comes first on PATH. On a machine with more than one Python those are different installs. In one test, %pip in the kernel reported capskip 1.3.0 while the shell’s pip, from a global Python 3.14, reported an old 1.0.1 without the newer CAPTCHA types. Check which Python and which version you got before going further:

import sys
import capskip

print(sys.executable)        # the Python this kernel runs on
print(capskip.__version__)   # 1.3.0 or later

If a package was already imported when you upgraded it, restart the kernel so the new version loads. IPython prints the same reminder after %pip.

Step 2: solve in a cell with the sync client

The sync client needs nothing special in a notebook. Fetch the page with a requests.Session, find the widget, and solve:

import requests
from bs4 import BeautifulSoup
from capskip import CapSkip

PAGE_URL = "https://example.com/login"

# pollingInterval caps the wait between polls. At 1 instead of the
# default 5, the stop button takes effect within about a second.
solver = CapSkip(host="127.0.0.1", port=8080, pollingInterval=1)

session = requests.Session()
page = session.get(PAGE_URL, timeout=30)
soup = BeautifulSoup(page.text, "html.parser")
widget = soup.select_one(".g-recaptcha[data-sitekey]")
form = widget.find_parent("form")

token = solver.recaptcha(sitekey=widget["data-sitekey"], url=PAGE_URL)["code"]
len(token)

Then post it in the next cell, with the form’s own fields and the same session:

from urllib.parse import urljoin

def form_fields(form):
    """The fields a browser would send: hidden, text and checked boxes."""
    fields = {}
    for tag in form.select("input[name]"):
        kind = (tag.get("type") or "text").lower()
        if kind in ("submit", "button", "image", "file", "reset"):
            continue
        if kind in ("checkbox", "radio") and not tag.has_attr("checked"):
            continue
        fields[tag["name"]] = tag.get("value", "on" if kind in ("checkbox", "radio") else "")
    return fields

data = form_fields(form)
data["username"] = "YOUR_USERNAME"
data["g-recaptcha-response"] = token
res = session.post(urljoin(PAGE_URL, form.get("action") or PAGE_URL), data=data,
                   headers={"Referer": PAGE_URL}, timeout=30)
res.status_code

The session carries the page’s cookies to the post, which is what ties the hidden CSRF field to your visit. The Referer header stands in for the one a browser sends: requests sends none, and some frameworks, Django among them, refuse an HTTPS form post without a Referer or Origin. A reCAPTCHA token is good for one submit and about two minutes, so run the post cell straight after the solve cell rather than coming back to it later.

The stop button is where a notebook differs from a script. An interrupt raises KeyboardInterrupt in your cell, but on a Windows kernel it lands only when the current call returns. The SDK spends most of a solve sleeping between polls, so the cell stops when that sleep ends: up to five seconds at the default pollingInterval, about one second at the setting above. The traceback looks odd, with a NetworkException first and the KeyboardInterrupt raised while handling it. That NetworkException is just the SDK’s not-ready signal.

The bad case is a CapSkip that accepts the connection and never answers. The sync client sets no timeout on its HTTP calls, so on Windows the cell sits in a socket read that no interrupt reaches, and recaptchaTimeout does not help because the SDK only checks it between polls. Restarting the kernel frees it but loses every variable. Closing CapSkip frees it too and keeps them, because the dropped connection ends the read and the interrupt you already sent lands at once. Interrupting also stops only your cell, never CapSkip: the SDK has no call to cancel a job, so the abandoned solve carries on and its token is never read.

Step 3: async in a cell: await at the top level, never asyncio.run

The usual way to run async code from a script fails in a notebook:

import asyncio
from capskip import AsyncCapSkip

asolver = AsyncCapSkip(host="127.0.0.1", port=8080, pollingInterval=1)

asyncio.run(asolver.recaptcha(sitekey=widget["data-sitekey"], url=PAGE_URL))
# RuntimeError: asyncio.run() cannot be called from a running event loop

ipykernel runs every cell inside an event loop it already started, and asyncio.run insists on starting a new one. The older idiom, asyncio.get_event_loop().run_until_complete(), fails too, with RuntimeError: This event loop is already running. Neither call ever reaches CapSkip, so no job is submitted. The coroutine you passed never runs, so a later cell may print a RuntimeWarning that it was never awaited; it is harmless. The fix is simpler than the problem. IPython lets you await at the top level of a cell, outside any function:

# No asyncio.run: the kernel's loop runs this await.
from capskip import AsyncCapSkip

asolver = AsyncCapSkip(host="127.0.0.1", port=8080, pollingInterval=1)
result = await asolver.recaptcha(sitekey=widget["data-sitekey"], url=PAGE_URL)
token = result["code"]

That makes AsyncCapSkip the better client for a notebook, for two reasons beyond style. An interrupt cancels the await straight away, so the cell ends at once instead of after the next poll. And its HTTP calls go through httpx, which gives up on a silent connection after five seconds by default. A CapSkip that stops answering then ends the call with an SDK error instead of a hang: TimeoutException shortly after recaptchaTimeout passes if a poll stalls, or NetworkException after five seconds if the submit does. The stop button keeps working throughout. Set recaptchaTimeout on the constructor to shorten that wait; recaptcha() takes no timeout argument in the Python SDK, and passing one raises TypeError.

Several solves at once are a gather away. CapSkip solves reCAPTCHA on 10 threads by default, Max. Threads in its settings, and a task queued behind them waits up to 250 seconds before CapSkip fails it, so cap the batch:

import asyncio

# Max. Threads for reCAPTCHA is 10 by default; more than that queues.
limit = asyncio.Semaphore(10)

async def solve(sitekey, url):
    async with limit:
        return (await asolver.recaptcha(sitekey=sitekey, url=url))["code"]

# jobs: (sitekey, url) pairs; here the Step 2 page three times
jobs = [(widget["data-sitekey"], PAGE_URL)] * 3
tokens = await asyncio.gather(*(solve(k, u) for k, u in jobs), return_exceptions=True)

gather hands back every token at the end, so post them promptly or switch to asyncio.as_completed and post each as it arrives. With return_exceptions=True a failed solve comes back as its exception in the list instead of raising out of the cell, so post only the entries that are strings. Solves in a batch share one CapSkip machine, so expect each to run slower than a lone solve. The client also builds a new HTTP client for every poll, about a tenth of a second of kernel CPU each on Windows, so a big batch keeps a core busy. The patterns for bigger batches are in the guide to solving CAPTCHAs in parallel in Python.

Older answers to the asyncio.run error recommend nest_asyncio. Skip it. The project was archived in March 2024. On Python 3.14 applying it breaks the kernel: from the next cell on, every httpx call fails with a TypeError about a weak reference to a NoneType object, AsyncCapSkip included, until you restart. Top-level await needs no patch at all.

Step 4: a browser in a notebook

Selenium has no event loop of its own, so a webdriver session works in a cell exactly as it does in a script: solve with the sync client from Step 2 and write the token into the page. Playwright’s sync API checks for a running loop and refuses with Error: It looks like you are using Playwright Sync API inside the asyncio loop. Please use the Async API instead. So in a notebook you drive Playwright through its async API, with await at the top level:

# pip install playwright into the kernel, then download a browser once:
%pip install playwright
import sys
!"{sys.executable}" -m playwright install chromium
from playwright.async_api import async_playwright

# start() instead of "async with", so the browser outlives this cell.
pw = await async_playwright().start()
browser = await pw.chromium.launch()
page = await browser.new_page()
await page.goto(PAGE_URL)

sitekey = await page.locator(".g-recaptcha").get_attribute("data-sitekey")
token = (await asolver.recaptcha(sitekey=sitekey, url=page.url))["code"]

# The widget's hidden textarea is the field the form submits.
await page.locator("#g-recaptcha-response").evaluate("(el, t) => { el.value = t; }", token)
await page.locator("form button[type=submit]").click()

Keeping pw and browser in variables lets you poke at the page from the next cells, which is most of the point of a notebook. Close both when you are done with await browser.close() and await pw.stop(). Calling the kernel’s own sys.executable matches the browser to the Playwright your cells import, for the same reason %pip does. The quotes keep a path with spaces intact, and the exclamation-mark line shows download progress in the cell. More on driving the browser itself is in the Playwright CAPTCHA solver guide.

On Windows, check your ipykernel version before you debug Playwright. Up to 7.3, ipykernel ran cells on a selector event loop there, which cannot start asyncio subprocesses, so chromium.launch() fails with a bare NotImplementedError. Version 7.4.0, released at the end of September 2026, moved Windows cells to the Proactor loop, and the same cell works. Print ipykernel.__version__ to see which you have. To upgrade, run %pip install -U ipykernel in the notebook and restart the kernel. If you cannot upgrade, a worker thread does not help, because the selector policy covers the whole kernel process. Drive the browser with Selenium instead, which starts chromedriver without asyncio.

When the kernel is not on your PC: Server mode

Everything above assumes the kernel runs on the machine with CapSkip, so 127.0.0.1 reaches it. That is the usual Jupyter notebook CAPTCHA setup, but not the only one. A notebook served by JupyterHub on a server, a remote kernel in VS Code or a hosted notebook such as Google Colab runs your cells somewhere else, and there 127.0.0.1 means that machine. The first call then fails with NetworkException and a refused connection. Switch CapSkip to Server mode under connection settings and it listens on your network address or public IP, so the remote kernel calls it over the same API with that address as host.

Use a static public IP when the route crosses the internet. CapSkip opens its port to every address when Server mode starts, so to keep it to the machines you expect, add a Windows Firewall block rule for the others. That works when the kernel’s address is fixed, as on your own JupyterHub server. A hosted notebook’s outgoing address can change between sessions, so there turn on API key validation in CapSkip’s settings and pass the key as apiKey. It is still your own hardware and still unmetered. The client reads no environment variables by itself, so read CAPSKIP_HOST with os.getenv in a cell and pass it in, as the full example does.

Full working example

Three cells, async throughout: the shortest complete Jupyter notebook CAPTCHA workflow that survives the stop button.

# Cell 1: pip install capskip requests beautifulsoup4, then restart the kernel.
%pip install capskip requests beautifulsoup4
# Cell 2: the client and a helper. Run once per kernel.
import os
from urllib.parse import urljoin

import requests
from bs4 import BeautifulSoup
from capskip import AsyncCapSkip, CapSkipError

PAGE_URL = "https://example.com/login"
solver = AsyncCapSkip(
    apiKey=os.getenv("CAPSKIP_API_KEY", "capskip"),
    host=os.getenv("CAPSKIP_HOST", "127.0.0.1"),
    port=int(os.getenv("CAPSKIP_PORT", "8080")),
    pollingInterval=1,
)

def form_fields(form):
    fields = {}
    for tag in form.select("input[name]"):
        kind = (tag.get("type") or "text").lower()
        if kind in ("submit", "button", "image", "file", "reset"):
            continue
        if kind in ("checkbox", "radio") and not tag.has_attr("checked"):
            continue
        fields[tag["name"]] = tag.get("value", "on" if kind in ("checkbox", "radio") else "")
    return fields
# Cell 3: fetch, solve, post. Run it again for a fresh token.
session = requests.Session()
page = session.get(PAGE_URL, timeout=30)
soup = BeautifulSoup(page.text, "html.parser")
widget = soup.select_one(".g-recaptcha[data-sitekey]")
if widget is None:
    raise RuntimeError("No reCAPTCHA widget in the HTML; the page may build it with JavaScript.")
form = widget.find_parent("form")

try:
    result = await solver.recaptcha(sitekey=widget["data-sitekey"], url=PAGE_URL)
except CapSkipError as err:
    raise RuntimeError(f"solve failed: {type(err).__name__}: {err}") from err

data = form_fields(form)
data["username"] = "YOUR_USERNAME"
data["g-recaptcha-response"] = result["code"]
res = session.post(urljoin(PAGE_URL, form.get("action") or PAGE_URL), data=data,
                   headers={"Referer": PAGE_URL}, timeout=30)
print(res.status_code)

Cell 3 is the one you rerun, and each run is a new solve and a new token, because a reCAPTCHA token works once. If the page builds its widget from JavaScript, the fetched HTML has no sitekey; use the Playwright cells from Step 4 instead, or read the sitekey from the script that renders it.

Common errors and what they mean

What you seeCauseFix
RuntimeError: asyncio.run() cannot be called from a running event loopThe kernel already runs an event loopAwait the call at the top level of the cell
RuntimeError: This event loop is already runningrun_until_complete was called on the kernel’s own loopAwait the call at the top level of the cell
Playwright says you are using the Sync API inside the asyncio loopThe sync API refuses to run while a loop is runningUse playwright.async_api with await; on ipykernel 7.4 or later, running the sync code in a worker thread also works
NotImplementedError from chromium.launch() on Windowsipykernel before 7.4.0 runs cells on a selector loop, which cannot start the browser processRun %pip install -U ipykernel, then restart the kernel
ModuleNotFoundError for capskip, or an older version than you installedThe shell’s pip installed into a different Python from the kernel’sInstall with %pip and check sys.executable
The stop button does nothing and the cell keeps runningThe sync client is waiting on a CapSkip that accepted the connection and never answered, and it sets no HTTP timeoutClose CapSkip or restart the kernel, and prefer AsyncCapSkip in notebooks
A TypeError about a weak reference on every httpx or AsyncCapSkip call, Python 3.14nest_asyncio was applied in an earlier cellRestart the kernel and drop nest_asyncio; top-level await needs no patch
NetworkException with a refused connectionCapSkip is not running, or the kernel runs on another machineStart CapSkip, or switch it to Server mode and pass its address
ApiException with ERROR_CAPTCHA_UNSOLVABLECapSkip failed the solve, for example after waiting 250 seconds for a free threadKeep batches at Max. Threads, add proxies, then retry
The site rejects a token CapSkip returnedThe post went out without the page’s cookies, its hidden fields or a Referer, or the token was more than two minutes oldPost on the same requests.Session with form_fields and a Referer, right after the solve

FAQ

Does this work in JupyterLab and VS Code notebooks?

Yes. JupyterLab, Notebook 7 and the notebook editor in VS Code all hand your cells to ipykernel, so top-level await, the asyncio.run error and the Windows event loop behave the same in each. What differs is which Python the kernel uses, so check sys.executable in whichever editor you open.

Can a hosted notebook such as Google Colab use the solver on my PC?

Yes, through Server mode. A hosted notebook runs its kernel on the provider’s machines, so 127.0.0.1 there is not your PC. Put CapSkip in Server mode under connection settings, give the PC a static public IP, turn on API key validation, and pass your address and key to the client. A hosted notebook’s outgoing address can change, so the key is what keeps strangers out; a Windows Firewall block rule can still close the port to address ranges you never use. The solver stays on hardware you own, so a long session of reruns never changes what you pay.

Should I use nest_asyncio to make asyncio.run work?

No. It patches the running loop so asyncio.run can nest inside it, which was a common fix for this error. The project is archived, and on Python 3.14 the patch breaks every later httpx call in the kernel, AsyncCapSkip included. Top-level await already does what you wanted from asyncio.run, without changing anything about the loop.

What happens to the solve when I interrupt a cell?

Your cell stops and CapSkip carries on. The SDK has no call to cancel a job, so CapSkip finishes the solve and nobody reads the token. Running the cell again submits a new job. If you interrupt a large batch, the abandoned solves keep CapSkip’s threads busy until each one finishes or runs into CapSkip’s own timeouts, which can take minutes, so a new batch started straight away waits behind them.

The short version

Jupyter notebook CAPTCHA solving comes down to five habits. Install with %pip, never through the shell. The sync CapSkip client works in a cell as it does in a script; set pollingInterval to 1 so interrupts land quickly. For async, await AsyncCapSkip at the top level of the cell and never wrap it in asyncio.run or reach for nest_asyncio. For a browser, use Selenium as is or Playwright’s async API, and on Windows run ipykernel 7.4 or later. When the kernel lives on another machine, switch CapSkip to Server mode and pass its address.

A notebook is where a scraper gets built one rerun at a time. With a captcha bypass running on your own machine, every one of those reruns is free.