How to Solve CAPTCHAs in Windows Task Scheduler Jobs

task scheduler captcha - How to Solve CAPTCHAs in Windows Task Scheduler Jobs

To solve CAPTCHAs from Windows Task Scheduler, register a task that runs your virtual environment’s python.exe directly, give the script its own retries and HTTP timeouts, and let the task’s time limit catch whatever else goes wrong. That is the whole task scheduler captcha setup, and CapSkip runs on the same PC, so there is no queue to host and no per-solve cost. The defaults hide four traps, and this guide covers each one. The working folder is System32, so a relative file name fails. One hung run silently ends the schedule. A cmd wrapper leaves the real Python process running when the scheduler kills the task. And the restart-on-failure setting does not restart a script that exits with an error.

What you need

  • CapSkip running on a Windows PC, with the reCAPTCHA solver switched on. CapSkip is a desktop app, so the task only works while it is open.
  • Python 3.10 or later, the floor of the CapSkip SDK. Everything below was run on Windows 11 with Python 3.14.0 and capskip 1.3.0, in Windows PowerShell 5.1.
  • An ordinary PowerShell prompt, not an elevated one. Registering and running the task needed no administrator prompt in testing.
  • An address for the solver. In Local mode CapSkip answers on 127.0.0.1 for that device only. In Server mode it listens on your network address or public IP, so a task on another machine can call it over the API. Both are set under connection settings.

Step 1: create a job folder and a Python environment

Put the job in one folder with its own virtual environment. The task will run the python.exe inside that environment by its full path, so it needs nothing from your PATH and no activated shell.

# PowerShell: make a job folder with its own Python environment
mkdir C:\CapSkipJobs
cd C:\CapSkipJobs
python -m venv venv
.\venv\Scripts\python.exe -m pip install capskip==1.3.0 requests

The SDK version is pinned because the timeout patch in Step 2 reaches into the SDK’s internals. Test it again before you move to a newer version. Task Manager shows two python.exe processes for every run: the environment’s launcher and the real interpreter it starts. That is normal.

Step 2: read settings, log to a file and give the SDK a timeout

Save the code from Steps 2 to 4 in one file, C:\CapSkipJobs\job.py, in the order shown. The whole file is repeated at the end of this guide. The job reads its settings from environment variables and passes them to the client itself, because the SDK does not read them on its own. It also has defaults, so the file runs without any setup. CAPSKIP_API_KEY is only needed if you switch on API key validation in CapSkip.

import functools
import logging
import os
import re
import sys
import time
import types

import requests
import capskip.api
from capskip import ApiException, CapSkip, NetworkException, TimeoutException

HOST = os.getenv('CAPSKIP_HOST', '127.0.0.1')
PORT = int(os.getenv('CAPSKIP_PORT', '8080'))
API_KEY = os.getenv('CAPSKIP_API_KEY', 'YOUR_API_KEY')
SITE = os.getenv('SITE_URL', 'https://example.com').rstrip('/')
USER = os.getenv('SITE_USER', 'YOUR_USERNAME')
PASSWORD = os.getenv('SITE_PASSWORD', 'YOUR_PASSWORD')
LOG_FILE = os.getenv('JOB_LOG', r'C:\CapSkipJobs\job.log')
ATTEMPTS = int(os.getenv('JOB_ATTEMPTS', '3'))
PAUSE = int(os.getenv('JOB_PAUSE', '30'))

Task Scheduler has no field for environment variables, and a task does not see a variable you set only in a terminal. A user variable set with setx does reach it, even one set after the task was registered: the next run picks it up, with no sign-out. The values sit in your account’s registry as plain text, about as private as a .env file.

# Task Scheduler has no environment field. User variables work:
# the next run of the task sees them, with no sign-out needed.
setx SITE_URL "https://example.com"
setx SITE_USER "YOUR_USERNAME"
setx SITE_PASSWORD "YOUR_PASSWORD"

Next come the log file and a timeout for the SDK. Task Scheduler throws away a program’s output, so print() shows nowhere, and the job writes a log file at an absolute path instead. With no working folder set, the task runs in C:\Windows\System32, where a relative file name such as job.log fails with a PermissionError.

os.makedirs(os.path.dirname(LOG_FILE), exist_ok=True)
logging.basicConfig(filename=LOG_FILE, level=logging.INFO,
                    format='%(asctime)s pid=%(process)d %(levelname)s %(message)s')
log = logging.getLogger('job')

# The SDK sets no HTTP timeouts, so give every CapSkip call one: connect 5 s, read 30 s.
# This reaches into the SDK, so the guard fails loudly if a newer version changes it.
if not hasattr(capskip.api, 'requests'):
    log.error('capskip.api.requests is gone: check the timeout patch')
    sys.exit(1)
capskip.api.requests = types.SimpleNamespace(
    RequestException=requests.RequestException,
    post=functools.partial(requests.post, timeout=(5, 30)),
    get=functools.partial(requests.get, timeout=(5, 30)))

The last lines fix a problem the scheduler cannot. The CapSkip Python SDK sets no HTTP timeout, so if CapSkip accepts a connection and then never answers, the call waits forever, and the recaptchaTimeout setting only counts between polls. The patch gives every SDK call a 5 second connect timeout and a 30 second read timeout. A submit that never gets an answer then fails as a NetworkException after 30 seconds. A result poll that never gets an answer ends as a TimeoutException shortly after recaptchaTimeout is over: 333 seconds with its default of 300. The guard above the patch stops the job with a line in the log if a newer SDK no longer has that attribute, instead of letting it run without timeouts. The guide to solving CAPTCHAs in APScheduler explains the same patch in more detail.

Step 3: fetch the form, solve it and post it

The job loads the login page in a requests Session, reads the CSRF token and the sitekey from it, and asks CapSkip for a token. One Session carries the cookie that goes with the CSRF token. The post sends the Referer too, because some frameworks, such as Django over HTTPS, reject a form post without one. Replace the paths and field names with the ones your site uses.

def fetch_login(session):
    """Load the login page: the session cookie, the CSRF token and the sitekey."""
    page = session.get(SITE + '/login', timeout=(5, 30))
    if page.status_code != 200:
        raise RuntimeError('login page returned HTTP %d' % page.status_code)
    csrf = re.search(r'name="csrfmiddlewaretoken" value="([^"]+)"', page.text)
    key = re.search(r'data-sitekey="([^"]+)"', page.text)
    if not csrf or not key:
        raise RuntimeError('login page has no CSRF token or no sitekey')
    return csrf.group(1), key.group(1)


def solve(sitekey):
    solver = CapSkip(apiKey=API_KEY, host=HOST, port=PORT)
    return solver.recaptcha(sitekey=sitekey, url=SITE + '/login')['code']

solver.recaptcha returns a result whose code field is the token. The post below puts it in the g-recaptcha-response field next to the other form fields. A failed login often answers with status 200 and the login form again, so the job also treats a page that still has a password field as a rejection. Change that test to fit your site, because a job that reports success for a rejected login is worse than one that fails.

def submit(session, csrf, token):
    """Post the login form, the way the browser would, Referer included."""
    resp = session.post(SITE + '/login', timeout=(5, 30),
                        headers={'Referer': SITE + '/login'},
                        data={'csrfmiddlewaretoken': csrf, 'username': USER,
                              'password': PASSWORD, 'g-recaptcha-response': token})
    # Many sites answer a failed login with 200 and the form again.
    if resp.status_code != 200 or 'type="password"' in resp.text:
        raise RuntimeError('login rejected: HTTP %d %s' % (resp.status_code, resp.text[:120]))
    log.info('login accepted: %s', resp.text[:40])

For other CAPTCHA types, call the matching SDK method and change the form field name in the post. The guide to solving reCAPTCHA v2 in Python covers the same call in a plain script.

Step 4: retry in the script and exit with a code

A scheduled job needs a plan for failure, and Task Scheduler’s own restart setting is not one. RestartCount and RestartInterval restarted a task only when it failed to start: a missing program was started again about one and two minutes later. A script that exited with code 1 or 3, or died with an uncaught exception, ran once and was never restarted. So the retry lives in the script.

def run():
    session = requests.Session()  # a fresh session means a fresh CSRF token
    csrf, sitekey = fetch_login(session)
    log.info('solving reCAPTCHA v2, sitekey %s', sitekey)
    submit(session, csrf, solve(sitekey))


def is_retryable(exc):
    if isinstance(exc, (NetworkException, TimeoutException, requests.RequestException)):
        return True
    return isinstance(exc, ApiException) and 'UNSOLVABLE' in str(exc)

Four failures are worth another attempt: CapSkip not answering, a TimeoutException, ERROR_CAPTCHA_UNSOLVABLE (CapSkip could not solve this task, and a fresh try can), and the site itself not answering. Anything else, such as a rejected login or a page with no CSRF token, is fatal, because a retry would repeat it.

def main():
    log.info('start, CapSkip at %s:%s, site %s', HOST, PORT, SITE)
    for attempt in range(1, ATTEMPTS + 1):
        try:
            run()
            log.info('done, exit 0')
            return 0
        except Exception as exc:
            retry = is_retryable(exc)
            log.warning('attempt %d/%d failed, %s: %s: %s', attempt, ATTEMPTS,
                        'retryable' if retry else 'fatal', type(exc).__name__, exc,
                        exc_info=not retry)
            if not retry:
                return 1
            if attempt < ATTEMPTS:
                time.sleep(PAUSE)
    log.error('giving up after %d attempts, exit 3', ATTEMPTS)
    return 3


if __name__ == '__main__':
    sys.exit(main())

The job makes up to three attempts, 30 seconds apart, and then exits with a code that Task Scheduler shows as the Last Run Result.

Exit codeMeaning
0The login went through
1A failure a retry cannot fix; the log has the traceback
3Every attempt hit a failure that may clear up on its own

Code 2 is left out on purpose. Python itself exits with 2 when it cannot open the script file, so a 2 in the Last Run Result would be ambiguous.

The same loop covers a CapSkip that is not open yet. With CapSkip closed, one attempt fails in about 2 seconds. In a test where the solver and the site both came up late, the first attempt failed and the second, 30 seconds later, succeeded. If you want the job to start at sign-in instead of on a clock, a log-on trigger for your own account registers without administrator rights and accepts a delay; a one-minute delay registered fine.

Step 5: register the task

This PowerShell script registers the task for the current user. Nothing in it needs an elevated prompt.

$python = 'C:\CapSkipJobs\venv\Scripts\python.exe'
$jobDir = 'C:\CapSkipJobs'

$action = New-ScheduledTaskAction -Execute $python `
    -Argument ('"{0}\job.py"' -f $jobDir) -WorkingDirectory $jobDir
$trigger = New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(1) `
    -RepetitionInterval (New-TimeSpan -Hours 1)
$settings = New-ScheduledTaskSettingsSet `
    -ExecutionTimeLimit (New-TimeSpan -Minutes 30) `
    -MultipleInstances IgnoreNew `
    -StartWhenAvailable -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries

Register-ScheduledTask -TaskName 'CapSkip login job' -Action $action `
    -Trigger $trigger -Settings $settings -Force

Each part earns its place.

  • The action runs the environment’s python.exe directly, with the script as a quoted absolute path and the job folder as the working directory. Do not put cmd /c or a .bat file in front of it. The section on what the time limit really stops shows why.
  • The trigger fires one minute from now and then every hour, with no end date.
  • The time limit is 30 minutes. The default is 72 hours, three days. The longest a run can take works out to about 18 minutes: three attempts that each wait out a silent CapSkip for 333 seconds, plus two 30 second pauses. Thirty minutes leaves room for that and still frees a stuck run the same morning.
  • IgnoreNew is already the default for multiple instances. It is written out so the choice is visible.
  • By default a task does not start on battery power and stops when the PC switches to it. AllowStartIfOnBatteries and DontStopIfGoingOnBatteries turn both off, which matters on a laptop. StartWhenAvailable lets the scheduler start a run it missed, for example while the PC slept, as soon as it can. Microsoft documents every option in the New-ScheduledTaskSettingsSet reference.
  • The task runs as you, only while you are signed in. That suits CapSkip, which is a desktop app running in your session.

One more choice is python.exe or pythonw.exe. On the Windows 11 PC used for these tests, python.exe opened no visible window, only a hidden console host. pythonw.exe has no console at all, so its sys.stdout and sys.stderr are None: print() does nothing, sys.stderr.write raises an AttributeError, and an uncaught exception leaves no trace anywhere. The job logs to a file, so it works under either. Switch to pythonw.exe only if a console window appears on your PC.

Step 6: run it and read the result

The block below starts the task by hand, waits until it is no longer running, then shows how it ended and what the job logged.

# Run the task now instead of waiting for the trigger
Start-ScheduledTask -TaskName 'CapSkip login job'
Start-Sleep -Seconds 2

# Wait until it is no longer running
while ((Get-ScheduledTask -TaskName 'CapSkip login job').State -eq 'Running') {
    Start-Sleep -Seconds 2
}

# How did it end, and what did the job write?
Get-ScheduledTaskInfo -TaskName 'CapSkip login job' |
    Select-Object LastRunTime, LastTaskResult, NextRunTime
Get-Content C:\CapSkipJobs\job.log -Tail 6

The Last Run Result is a number. These are the ones you will meet.

LastTaskResultHexMeaning
00x0The job exited 0
10x1The job exited 1, or died with an uncaught exception
30x3The job exited 3 after its attempts
2670090x41301The task is running now
2670110x41303The task has not run yet
2670140x41306The scheduler stopped it, at the time limit or with Stop-ScheduledTask
21479467200x800710E0A trigger was ignored because a run was still going
21479424020x80070002The program path does not exist

Why one hung run stops the whole schedule

This is the failure the defaults make worst. The default policy for multiple instances is IgnoreNew, so a trigger that fires while the previous run is still going is dropped. The default time limit is 72 hours. Together they mean that one run that never ends blocks the schedule for three days.

In a test, the job ran without the timeout patch against a CapSkip that accepted the connection and never answered, with a trigger every minute. The task stayed in the Running state for the five minutes watched. The job log held one start line and nothing after it. Meanwhile LastRunTime moved forward every minute and LastTaskResult changed to 0x800710E0, the code for a trigger ignored because an instance was still running. The task looked alive, and the number pointed at the skipped trigger rather than at the stuck run. Task Scheduler’s own history log is switched off by default, so your job log is the only record you will have.

Three layers prevent it, and each catches something the others miss. The SDK timeout ends a hung CapSkip call in 30 to 333 seconds. The retry loop turns that into another attempt or a clean exit 3. The time limit is the backstop for the hang you did not think of. With all three in place, a CapSkip that never answered the submit ended the job with exit 3 after about 152 seconds: three 30 second timeouts and two pauses. One that never answered the result polls costs up to 333 seconds per attempt, which the 30 minute limit still covers.

What the time limit really stops

The time limit is precise. A limit of one minute killed the run at 60.2 seconds, and Task Scheduler accepted a 30 second limit and killed at 30.5. What it kills depends on the shape of the action.

ActionKilled at the limitStill running afterwards
The environment’s python.exeThe launcher and the interpreterNothing
cmd /c python job.pycmd.exeThe launcher and the interpreter, still holding their connection to CapSkip
A .bat file that runs pythoncmd.exeThe launcher and the interpreter, still holding their connection to CapSkip

The same split showed up with Stop-ScheduledTask. It matters for a CAPTCHA job because an orphaned process keeps its connection to CapSkip open. Wrappers have smaller traps too. A cmd /c line with a redirect needs an extra pair of outer quotes, or the job exits 1 with nothing logged, and the redirect target must be an absolute path. A .bat file whose last line is echo reports 0 whatever Python returned, so end it with exit /b %ERRORLEVEL%. And Unregister-ScheduledTask does not stop a run that is in progress, so call Stop-ScheduledTask first.

The full job

Here are the six blocks of job.py together. Save it as C:\CapSkipJobs\job.py, which is the path the task points at.

import functools
import logging
import os
import re
import sys
import time
import types

import requests
import capskip.api
from capskip import ApiException, CapSkip, NetworkException, TimeoutException

HOST = os.getenv('CAPSKIP_HOST', '127.0.0.1')
PORT = int(os.getenv('CAPSKIP_PORT', '8080'))
API_KEY = os.getenv('CAPSKIP_API_KEY', 'YOUR_API_KEY')
SITE = os.getenv('SITE_URL', 'https://example.com').rstrip('/')
USER = os.getenv('SITE_USER', 'YOUR_USERNAME')
PASSWORD = os.getenv('SITE_PASSWORD', 'YOUR_PASSWORD')
LOG_FILE = os.getenv('JOB_LOG', r'C:\CapSkipJobs\job.log')
ATTEMPTS = int(os.getenv('JOB_ATTEMPTS', '3'))
PAUSE = int(os.getenv('JOB_PAUSE', '30'))


os.makedirs(os.path.dirname(LOG_FILE), exist_ok=True)
logging.basicConfig(filename=LOG_FILE, level=logging.INFO,
                    format='%(asctime)s pid=%(process)d %(levelname)s %(message)s')
log = logging.getLogger('job')

# The SDK sets no HTTP timeouts, so give every CapSkip call one: connect 5 s, read 30 s.
# This reaches into the SDK, so the guard fails loudly if a newer version changes it.
if not hasattr(capskip.api, 'requests'):
    log.error('capskip.api.requests is gone: check the timeout patch')
    sys.exit(1)
capskip.api.requests = types.SimpleNamespace(
    RequestException=requests.RequestException,
    post=functools.partial(requests.post, timeout=(5, 30)),
    get=functools.partial(requests.get, timeout=(5, 30)))


def fetch_login(session):
    """Load the login page: the session cookie, the CSRF token and the sitekey."""
    page = session.get(SITE + '/login', timeout=(5, 30))
    if page.status_code != 200:
        raise RuntimeError('login page returned HTTP %d' % page.status_code)
    csrf = re.search(r'name="csrfmiddlewaretoken" value="([^"]+)"', page.text)
    key = re.search(r'data-sitekey="([^"]+)"', page.text)
    if not csrf or not key:
        raise RuntimeError('login page has no CSRF token or no sitekey')
    return csrf.group(1), key.group(1)


def solve(sitekey):
    solver = CapSkip(apiKey=API_KEY, host=HOST, port=PORT)
    return solver.recaptcha(sitekey=sitekey, url=SITE + '/login')['code']


def submit(session, csrf, token):
    """Post the login form, the way the browser would, Referer included."""
    resp = session.post(SITE + '/login', timeout=(5, 30),
                        headers={'Referer': SITE + '/login'},
                        data={'csrfmiddlewaretoken': csrf, 'username': USER,
                              'password': PASSWORD, 'g-recaptcha-response': token})
    # Many sites answer a failed login with 200 and the form again.
    if resp.status_code != 200 or 'type="password"' in resp.text:
        raise RuntimeError('login rejected: HTTP %d %s' % (resp.status_code, resp.text[:120]))
    log.info('login accepted: %s', resp.text[:40])


def run():
    session = requests.Session()  # a fresh session means a fresh CSRF token
    csrf, sitekey = fetch_login(session)
    log.info('solving reCAPTCHA v2, sitekey %s', sitekey)
    submit(session, csrf, solve(sitekey))


def is_retryable(exc):
    if isinstance(exc, (NetworkException, TimeoutException, requests.RequestException)):
        return True
    return isinstance(exc, ApiException) and 'UNSOLVABLE' in str(exc)


def main():
    log.info('start, CapSkip at %s:%s, site %s', HOST, PORT, SITE)
    for attempt in range(1, ATTEMPTS + 1):
        try:
            run()
            log.info('done, exit 0')
            return 0
        except Exception as exc:
            retry = is_retryable(exc)
            log.warning('attempt %d/%d failed, %s: %s: %s', attempt, ATTEMPTS,
                        'retryable' if retry else 'fatal', type(exc).__name__, exc,
                        exc_info=not retry)
            if not retry:
                return 1
            if attempt < ATTEMPTS:
                time.sleep(PAUSE)
    log.error('giving up after %d attempts, exit 3', ATTEMPTS)
    return 3


if __name__ == '__main__':
    sys.exit(main())

Running CapSkip on another machine

The task and the solver do not have to share a PC. Switch CapSkip to Server mode under connection settings, and it listens on your network address or public IP. Then point the job at it. The setx line below takes effect on the next run.

# Only if CapSkip runs in Server mode on another Windows PC
setx CAPSKIP_HOST "192.168.1.50"
# Only if API key validation is switched on in CapSkip
setx CAPSKIP_API_KEY "YOUR_API_KEY"

Use a static public IP if the route crosses the internet, and turn on API key validation, with the same key in CAPSKIP_API_KEY. CapSkip opens its port to every address when Server mode starts, so to limit access to the machines you expect, add a Windows Firewall block rule for the others. The solver is still your own Windows machine, and solves are still unmetered.

Common Task Scheduler CAPTCHA errors

What you seeCauseFix
The task state stays Running for hoursA call is stuck and the 72 hour default limit is far awayAdd the SDK timeout and a time limit of minutes
2147946720 (0x800710E0) at every triggerTriggers are being ignored while one run is still goingRead the last line of the job log, then fix the hang
2147942402 (0x80070002)The program path does not existUse the full path to the environment’s python.exe
Last Run Result 2 and an empty logPython could not open job.py, usually a relative path with no working folderGive the action an absolute script path and a working folder
PermissionError on job.logA relative file name resolved inside C:\Windows\System32Use absolute paths
Exit 3 on every runCapSkip is not running, the host or port is wrong, or every solve failedCheck the first line of the log and the address under connection settings
267014 (0x41306)The scheduler stopped the run at the time limitFind where the log ends, and fix that call
Settings are missing inside the jobThe variables were set only in a terminalSet them with setx, or keep defaults in the file
Two python.exe processes remain after a stopThe action is cmd /c or a .bat fileRun python.exe as the action

FAQ

Can the task run when I am signed out?

Registering a task that runs whether you are signed in or not, with no stored password, failed in testing with Access is denied (0x80070005) from a PowerShell prompt that was not elevated. Registering one with a stored password was not tested, and neither was a standard user account. Either way CapSkip has to be open to answer, and it is a desktop app, so the usual setup is a PC where you stay signed in. If you need the solver on a machine nobody uses, run CapSkip there in Server mode and point the job at it.

Can the next trigger replace a hung run instead of being ignored?

Yes, with the StopExisting policy. New-ScheduledTaskSettingsSet only offers Parallel, Queue and IgnoreNew, so set the value on the settings object by hand:

# New-ScheduledTaskSettingsSet cannot set this policy, so set it on the
# $settings object from Step 5 by hand, then update the task.
$settings.CimInstanceProperties['MultipleInstances'].Value = 3
Set-ScheduledTask -TaskName 'CapSkip login job' -Settings $settings

The exported task then reads StopExisting. With python.exe as the action, each trigger killed the old run and started a fresh one. With cmd /c as the action, every kill left an orphan behind. Parallel started a new run every minute while the hung one stayed, and Queue kept waiting behind it.

Does this work for a compiled C# program?

The scheduler side is the same. Point the action at your exe, set the working folder, keep the time limit and use an absolute log path. The retries and exit codes then belong in your own code. If the program should run all day instead of being started on a clock, the guide to solving CAPTCHAs in a .NET Worker Service covers that shape.

When is a queue a better fit than Task Scheduler?

When other code creates the work, or when each piece of work must survive a crash and be retried on its own. Task Scheduler starts a fresh process on a clock and knows nothing about individual jobs. A queue such as Huey keeps every task in a file and retries it, and the guide to solving CAPTCHAs in Huey tasks shows a setup that runs on Windows with a single SQLite file.

The short version

A Task Scheduler CAPTCHA job needs more than the solve. Run the environment’s python.exe directly, with a working folder, an absolute log path and an absolute script path. Give the SDK’s HTTP calls a timeout and retry inside the script, because the restart setting ignores exit codes. Set an execution time limit of minutes, since the default of 72 hours lets one hung run block every later trigger.

A job that runs every hour makes about 720 solves a month. Running them through a local captcha solver on your own Windows machine means that number never turns into a bill.