如何在 Windows Task Scheduler 作业中识别验证码

要在 Windows Task Scheduler 中识别验证码,就注册一个直接运行你的虚拟环境中 python.exe 的任务,让脚本自带重试和 HTTP 超时,再让任务的时限兜住其余所有可能出错的情况。这就是 Task Scheduler 验证码识别的完整方案,而且 CapSkip 就运行在同一台电脑上,所以不用托管队列,也不会按次收费。默认设置里藏着四个陷阱,本指南会逐一讲到。工作目录是 System32,所以相对文件名会失败。一次挂起的运行会悄无声息地让整个计划停摆。cmd 包装器会在调度器终止任务时,让真正的 Python 进程继续运行。而失败后重启的设置,不会重启以错误退出的脚本。
你需要什么
- 在 Windows 电脑上运行的 CapSkip,并已开启 reCAPTCHA 识别功能。CapSkip 是桌面应用,所以任务只有在它打开时才能正常工作。
- Python 3.10 或更高版本,这是 CapSkip SDK 的最低要求。下面的所有内容都在 Windows 11 上,配合 Python 3.14.0 和 capskip 1.3.0,在 Windows PowerShell 5.1 中运行过。
- 一个普通的 PowerShell 窗口,不是以管理员身份运行的。测试中,注册和运行这个任务都没有出现管理员权限提示。
- 识别工具的地址。在 Local 模式下,CapSkip 只在 127.0.0.1 上响应,仅供本机使用。在 Server 模式下,它监听你的网络地址或公网 IP,这样另一台机器上的任务就能通过 API 调用它。两种模式的设置位置都是 连接设置.
第 1 步:创建作业文件夹和 Python 环境
把作业放在一个文件夹里,并给它配一个自己的虚拟环境。任务会按完整路径运行该环境里的 python.exe,所以它既不依赖你的 PATH,也不需要一个已激活虚拟环境的 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
SDK 的版本被固定下来,是因为第 2 步的超时补丁会深入 SDK 的内部。换用更新的版本之前,要再测试一遍。每次运行时,Task Manager 里都会显示两个 python.exe 进程:虚拟环境自带的启动器,以及它启动的真正的解释器。这是正常现象。
第 2 步:读取设置、写日志文件,并给 SDK 设置超时
把第 2 步到第 4 步的代码按所示顺序保存在同一个文件里,文件路径是 C:\CapSkipJobs\job.py。整个文件会在本指南末尾再完整给出一遍。作业从环境变量中读取设置,并自己把它们传给客户端,因为 SDK 不会自己读取这些变量。作业里也有默认值,所以不做任何设置,这个文件也能运行。只有在 CapSkip 中开启了 API key validation 时,才需要 CAPSKIP_API_KEY。
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 没有用于设置环境变量的字段,而且任务看不到你只在终端里设置的变量。用 setx 设置的用户变量则能传到任务里,即使是在任务注册之后才设置的也一样:下一次运行就会读到它,不需要注销。这些值以明文形式保存在你账户的注册表里,私密程度和 .env 文件差不多。
# 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"
接下来是日志文件,以及给 SDK 设置的超时。Task Scheduler 会丢弃程序的输出,所以 print() 不会在任何地方显示内容,作业改为把日志写到一个绝对路径的文件里。如果没有设置工作目录,任务就会在 C:\Windows\System32 中运行,而在那里,像 job.log 这样的相对文件名会因 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)))最后几行解决的是调度器解决不了的问题。CapSkip Python SDK 不设置任何 HTTP 超时,所以如果 CapSkip 接受了连接却始终不应答,这次调用就会永远等下去,而 recaptchaTimeout 设置只在两次轮询之间计时。这个补丁给 SDK 的每次调用都加上 5 秒的连接超时和 30 秒的读取超时。这样,一次得不到应答的提交,会在 30 秒后以 NetworkException 失败。一次得不到应答的结果轮询,会在 recaptchaTimeout 到期后不久以 TimeoutException 结束:默认值为 300 时,是 333 秒。补丁上方的防护检查,会在较新的 SDK 不再有那个属性时,让作业停下并在日志里留下一行记录,而不是任由它在没有超时的情况下运行。 用 APScheduler 识别验证码的指南 对同一个补丁有更详细的讲解。
第 3 步:获取表单、识别并提交
作业在一个 requests Session 中加载登录页面,从中读取 CSRF token 和 sitekey,再向 CapSkip 请求一个 token。同一个 Session 会带着与 CSRF token 配套的 cookie。提交时也会发送 Referer,因为有些框架(比如 HTTPS 下的 Django)会拒绝没有 Referer 的表单提交。请把路径和字段名换成你自己站点使用的。
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 返回的结果里,code 字段就是 token。下面的提交会把它放进 g-recaptcha-response 字段,与其他表单字段放在一起。登录失败时,站点经常返回状态 200 并再次显示登录表单,所以作业也会把仍然带有密码字段的页面视为被拒绝。请按你的站点修改这个判断,因为对一次被拒绝的登录报告成功的作业,比直接失败的作业更糟糕。
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])对于其他验证码类型,调用对应的 SDK 方法,并修改提交中的表单字段名。 用 Python 识别 reCAPTCHA v2 的指南 介绍了同一个调用在普通脚本中的写法。
第 4 步:在脚本中重试,并以退出码结束
定时作业需要一套应对失败的办法,而 Task Scheduler 自带的重启设置算不上。RestartCount 和 RestartInterval 只在任务启动失败时才会重启它:一个不存在的程序,在大约一分钟和两分钟后各被重新启动了一次。一个以退出码 1 或 3 结束、或因未捕获的异常而终止的脚本,只运行了一次,从未被重启。所以重试要放在脚本里。
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)有四种失败值得再试一次:CapSkip 没有应答、TimeoutException、ERROR_CAPTCHA_UNSOLVABLE(CapSkip 无法识别这个任务,而重新尝试可能成功),以及站点本身没有应答。其他任何情况,比如登录被拒绝或页面里没有 CSRF token,都是致命的,因为重试只会重复同样的结果。
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())作业最多尝试三次,每次间隔 30 秒,然后以一个退出码结束,Task Scheduler 会把这个退出码显示为 Last Run Result。
| 退出码 | 含义 |
|---|---|
| 0 | 登录成功 |
| 1 | 重试解决不了的失败;日志里有 traceback |
| 3 | 每次尝试都遇到了可能会自行恢复的失败 |
退出码 2 是有意省略的。Python 自己在打不开脚本文件时就会以 2 退出,所以 Last Run Result 里出现 2 会有歧义。
同一个循环也能应对 CapSkip 还没打开的情况。CapSkip 关闭时,一次尝试大约 2 秒就会失败。在一次识别工具和站点都启动得很晚的测试中,第一次尝试失败了,30 秒后的第二次成功了。如果你想让作业在登录 Windows 时启动,而不是按时间表启动,针对你自己账户的登录触发器无需管理员权限就能注册,而且支持延迟;设置一分钟的延迟,注册没有问题。
第 5 步:注册任务
这个 PowerShell 脚本为当前用户注册任务。其中没有任何内容需要以管理员身份运行。
$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每一部分都有它存在的理由。
- 操作直接运行虚拟环境中的 python.exe,脚本用带引号的绝对路径,作业文件夹作为工作目录。不要在它前面加 cmd /c 或 .bat 文件。讲时限究竟能停掉什么的那一节会说明原因。
- 触发器从现在起一分钟后触发,之后每小时触发一次,没有结束日期。
- 时限是 30 分钟。默认值是 72 小时,也就是三天。一次运行最长可能要花大约 18 分钟:三次尝试,每次都要对一个始终不应答的 CapSkip 等满 333 秒,再加上两次 30 秒的停顿。30 分钟既留出了这个余量,又能在当天上午就清掉卡住的运行。
- IgnoreNew 本来就是多实例策略的默认值。这里把它写出来,是为了让这个选择一目了然。
- 默认情况下,任务不会在电池供电时启动,电脑切换到电池供电时还会把它停掉。AllowStartIfOnBatteries 和 DontStopIfGoingOnBatteries 会把这两种行为都关掉,这在笔记本电脑上很重要。StartWhenAvailable 会让调度器在错过某次运行(比如电脑睡眠期间)之后,尽快补启动它。每个选项的完整说明见 Microsoft 的 New-ScheduledTaskSettingsSet 参考文档.
- 任务以你的身份运行,而且只在你已登录时运行。这正适合 CapSkip,因为它是运行在你的会话中的桌面应用。
还有一个选择:用 python.exe 还是 pythonw.exe。在这些测试所用的 Windows 11 电脑上,python.exe 没有打开任何可见的窗口,只有一个隐藏的控制台宿主进程。pythonw.exe 则完全没有控制台,所以它的 sys.stdout 和 sys.stderr 都是 None:print() 什么也不做,sys.stderr.write 会引发 AttributeError,未捕获的异常也不会在任何地方留下痕迹。作业把日志写进文件,所以用哪个都能正常工作。只有当你的电脑上出现了控制台窗口时,才需要改用 pythonw.exe。
第 6 步:运行任务并读取结果
下面这段代码手动启动任务,等它不再处于运行状态,然后显示它是如何结束的,以及作业记录了什么日志。
# 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 6Last Run Result 是一个数字。你会遇到的有这些。
| LastTaskResult | 十六进制 | 含义 |
|---|---|---|
| 0 | 0x0 | 作业以 0 退出 |
| 1 | 0x1 | 作业以 1 退出,或因未捕获的异常而终止 |
| 3 | 0x3 | 作业在用完全部尝试后以 3 退出 |
| 267009 | 0x41301 | 任务正在运行 |
| 267011 | 0x41303 | 任务尚未运行过 |
| 267014 | 0x41306 | 调度器停止了它,原因是到了时限,或是用了 Stop-ScheduledTask |
| 2147946720 | 0x800710E0 | 触发被忽略,因为上一次运行仍在进行 |
| 2147942402 | 0x80070002 | 程序路径不存在 |
为什么一次挂起的运行会让整个计划停摆
默认设置让这种故障的后果最为严重。多实例的默认策略是 IgnoreNew,所以在上一次运行仍在进行时触发的那次触发会被丢弃。默认时限是 72 小时。两者合在一起,就意味着一次永远不会结束的运行,会让整个计划被阻塞三天。
在一次测试中,作业不带超时补丁,针对一个接受了连接却始终不应答的 CapSkip 运行,触发器每分钟触发一次。在观察的五分钟里,任务一直处于 Running 状态。作业日志里只有一行启动记录,之后什么也没有。与此同时,LastRunTime 每分钟都在向前推进,LastTaskResult 变成了 0x800710E0,这个代码表示触发因实例仍在运行而被忽略。任务看上去还活着,而这个数字指向的是被跳过的触发,而不是卡住的那次运行。Task Scheduler 自己的历史日志默认是关闭的,所以你的作业日志是你唯一的记录。
有三层防护可以避免这种情况,而且每一层都能抓住其他层漏掉的问题。SDK 超时会在 30 到 333 秒内结束一次挂起的 CapSkip 调用。重试循环把这变成又一次尝试,或者一次干净的退出码 3。时限则是最后的兜底手段,用来应对你没想到的那种挂起。三层都到位时,一个始终不应答提交的 CapSkip,让作业在大约 152 秒后以退出码 3 结束:三次 30 秒的超时,加两次停顿。一个始终不应答结果轮询的 CapSkip,每次尝试最多花 333 秒,30 分钟的时限仍然能覆盖。
时限究竟能停掉什么
时限是很精确的。一分钟的时限在 60.2 秒时终止了运行,Task Scheduler 还接受了 30 秒的时限,并在 30.5 秒时终止。它会终止什么,取决于操作的构成方式。
| 动作 | 在时限处被终止 | 之后仍在运行 |
|---|---|---|
| 虚拟环境中的 python.exe | 启动器和解释器 | 无 |
| cmd /c python job.py | cmd.exe | 启动器和解释器,仍然保持着与 CapSkip 的连接 |
| 运行 python 的 .bat 文件 | cmd.exe | 启动器和解释器,仍然保持着与 CapSkip 的连接 |
用 Stop-ScheduledTask 停止任务时,也出现了同样的分化。这对验证码作业很重要,因为孤儿进程会一直保持着它与 CapSkip 的连接。包装器还有一些较小的陷阱。带重定向的 cmd /c 命令行需要多加一对最外层的引号,否则作业会以 1 退出,日志里什么也没有,而且重定向的目标必须是绝对路径。最后一行是 echo 的 .bat 文件,无论 Python 返回什么,都会报告 0,所以要用 exit /b %ERRORLEVEL% 结尾。另外,Unregister-ScheduledTask 不会停止正在进行的运行,所以要先调用 Stop-ScheduledTask。
完整的作业
下面是 job.py 的六段代码合在一起的样子。把它保存为 C:\CapSkipJobs\job.py,这正是任务所指向的路径。
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())在另一台机器上运行 CapSkip
任务和识别工具不必在同一台电脑上。在连接设置里把 CapSkip 切换到 Server 模式,它就会监听你的网络地址或公网 IP。然后让作业指向那里。下面的 setx 这一行会在下一次运行时生效。
# 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"
如果链路要经过公网,请使用静态公网 IP,并打开 API key validation,在 CAPSKIP_API_KEY 中使用同一个密钥。Server 模式启动时,CapSkip 会向所有地址开放端口,所以要想只让你预期的机器访问,就为其他地址添加一条 Windows Firewall 阻止规则。识别工具仍然是你自己的 Windows 机器,识别也仍然不计量。
Task Scheduler 验证码识别的常见错误
| 你所看到的 | 原因 | 修复 |
|---|---|---|
| 任务状态连续几个小时都是 Running | 某次调用卡住了,而默认的 72 小时时限还很遥远 | 加上 SDK 超时,并设置一个以分钟计的时限 |
| 每次触发都是 2147946720 (0x800710E0) | 有一次运行仍在进行,触发都被忽略了 | 查看作业日志的最后一行,然后修复挂起问题 |
| 2147942402 (0x80070002) | 程序路径不存在 | 使用虚拟环境中 python.exe 的完整路径 |
| Last Run Result 为 2,且日志是空的 | Python 打不开 job.py,通常是因为用了相对路径又没有设置工作目录 | 给操作设置脚本的绝对路径和工作目录 |
| job.log 报 PermissionError | 相对文件名被解析到了 C:\Windows\System32 里 | 使用绝对路径 |
| 每次运行都以退出码 3 结束 | CapSkip 没有运行,主机或端口不对,或者每次识别都失败了 | 检查日志的第一行,以及连接设置里的地址 |
| 267014 (0x41306) | 调度器在时限处停止了这次运行 | 找到日志在哪里中断,并修复那次调用 |
| 作业内部读不到设置 | 这些变量只在终端里设置过 | 用 setx 设置它们,或者把默认值写在文件里 |
| 停止之后仍残留两个 python.exe 进程 | 操作是 cmd /c 或 .bat 文件 | 把 python.exe 作为操作来运行 |
常见问题
我注销之后,任务还能运行吗?
在未以管理员身份运行的 PowerShell 窗口中,注册一个无论是否登录都会运行、且不保存密码的任务,在测试中失败了,报错为 Access is denied (0x80070005)。保存密码的注册方式没有测试,标准用户账户也没有测试。无论哪种方式,CapSkip 都必须处于打开状态才能应答,而它是桌面应用,所以通常的做法是用一台你一直保持登录的电脑。如果你需要把识别工具放在一台没人使用的机器上,就在那台机器上以 Server 模式运行 CapSkip,并让作业指向它。
下一次触发能否替换掉挂起的运行,而不是被忽略?
可以,用 StopExisting 策略。New-ScheduledTaskSettingsSet 只提供 Parallel、Queue 和 IgnoreNew,所以要在设置对象上手动设置这个值:
# 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
导出的任务随后会显示 StopExisting。以 python.exe 作为操作时,每次触发都会终止旧的运行并启动一次新的运行。以 cmd /c 作为操作时,每次终止都会留下一个孤儿进程。Parallel 每分钟启动一次新的运行,而挂起的那次一直留着,Queue 则一直在它后面排队等待。
这对编译好的 C# 程序也适用吗?
调度器这一侧是一样的。把操作指向你的 exe,设置工作目录,保留时限,并使用绝对的日志路径。重试和退出码则要写在你自己的代码里。如果程序应该全天运行,而不是按时间表启动,那么 用 .NET Worker Service 识别验证码的指南 讲的就是这种情形。
什么时候队列比 Task Scheduler 更合适?
当工作是由其他代码创建的,或者每一项工作都必须能撑过崩溃并单独重试的时候。Task Scheduler 按时间表启动一个全新的进程,对各个作业一无所知。Huey 这样的队列会把每个任务保存在文件里并负责重试,而 用 Huey 任务识别验证码的指南 展示了一种在 Windows 上只用一个 SQLite 文件就能运行的方案。
简短版结论
Task Scheduler 验证码作业需要的不只是识别本身。直接运行虚拟环境中的 python.exe,并设置工作目录、绝对的日志路径和绝对的脚本路径。给 SDK 的 HTTP 调用设置超时,并在脚本内部重试,因为重启设置会忽略退出码。把执行时限设为几分钟,因为 72 小时的默认值会让一次挂起的运行阻塞之后的每一次触发。
- Python 包能处理的所有验证码类型: Python 验证码识别页面.
- 复选框 reCAPTCHA 如何运作、识别工具覆盖哪些内容: reCAPTCHA v2 识别页面.
一个每小时运行一次的作业,一个月会做大约 720 次识别。用在你自己的 Windows 机器上运行的 本地验证码识别工具 来完成这些识别,这个数字就永远不会变成账单。
