はじめに
前回の記事では、LoLをやりすぎないためにソロキューだけマッチングを自動キャンセルする仕組みを作った話を書きました。
今回はその実装編です。
この記事では、以下のあたりを中心にまとめます。
- LoLクライアントのローカルAPI、LCU APIへの接続
- ソロロビー判定
- マッチング検索のキャンセル
- Ready Checkの辞退
- Windowsサービスとしての常駐化
- 落ちても復旧するためのwatchdog構成
- インストール用PowerShellスクリプトでのパッケージ化
前回は「なぜ作ったか」が中心でしたが、今回は「どう作ったか」が中心です。
なお、記事中のパスは個人環境に依存しないように以下のように置き換えています。
| 表記 | 意味 |
|---|---|
<project-root> |
開発用フォルダ |
<install-root> |
実行ファイルやスクリプトのインストール先 |
<state-root> |
ログ、PID、heartbeatなどの状態ファイル置き場 |
免責事項
この記事の内容は、LoLクライアントでローカルに利用できるLCU APIを、個人PC内で個人利用する前提の実装例です。
外部サーバーへの送信、アカウント情報の収集、ゲームクライアント本体の改変や通信内容の改ざんは行っていません。
利用する場合は、各サービスの利用規約や運営ポリシーを確認したうえで、自己責任で扱ってください。
全体構成
最終的な構成はこうしました。
<project-root>
├─ lolsq_guard
│ ├─ controller.py # LCUを監視してキャンセルする本体
│ ├─ watchdog.py # controllerの死活監視
│ ├─ service_worker.py # Windowsサービス用worker wrapper
│ ├─ service_guardian.py # Windowsサービス用guardian wrapper
│ └─ common.py # 共通処理
├─ launcher.py # 復旧用ランチャー
├─ Install-LoLSoloQueueGuard.ps1
├─ Check-LoLSoloQueueGuardStatus.ps1
└─ Emergency-Stop-LoLSoloQueueGuard.ps1
役割としては、次のように分けています。
| コンポーネント | 役割 |
|---|---|
controller.py |
LoLクライアントを監視し、ソロ検索ならキャンセルする |
watchdog.py |
controllerが落ちていないか監視する |
service_worker.py |
controllerをWindowsサービスとして動かす |
service_guardian.py |
watchdogをWindowsサービスとして動かす |
launcher.py |
タスクスケジューラから呼ばれる復旧用入口 |
Install-*.ps1 |
ファイル配置、サービス登録、タスク登録を行う |
実際に動くサービスは2つです。
| サービス名 | 役割 |
|---|---|
LoLSQGWorkerSvc |
LCU監視とキャンセル処理 |
LoLSQGuardianSvc |
workerサービスの監視と復旧 |
さらに保険として、タスクスケジューラにも LoLSoloQueueGuard_Bootstrap を登録しています。
LCU APIに接続する
LoLクライアントは起動中、ローカルにAPIサーバーを立てています。
このAPIに接続するために、クライアントが生成するlockfileを読みます。
※lockfileには接続に必要な情報が入っていますが、 passwordなどの認証情報も含まれるので、取り扱いには注意してください。この記事でもlockfileの実値は掲載しません。
実装では、lockfile候補を順番に見て、存在するものから接続情報を取り出すようにしました。
class LcuClient:
def __init__(self, lockfile_candidates):
self.lockfile_candidates = [pathlib.Path(p) for p in lockfile_candidates]
self.port = None
self.password = None
self.protocol = None
self.process = None
self._ssl_context = ssl.create_default_context()
self._ssl_context.check_hostname = False
self._ssl_context.verify_mode = ssl.CERT_NONE
def refresh_credentials(self) -> bool:
for lockfile in self.lockfile_candidates:
if not lockfile.exists():
continue
raw = lockfile.read_text(encoding="utf-8").strip()
parts = raw.split(":")
if len(parts) < 5:
continue
self.process = parts[0]
self.port = int(parts[2])
self.password = parts[3]
self.protocol = parts[4]
return True
return False
def _auth_header(self) -> str:
token = base64.b64encode(f"riot:{self.password}".encode("utf-8")).decode("ascii")
return f"Basic {token}"
LCU APIはローカルの自己署名証明書で動くため、この用途では証明書検証を無効化しています。
(※この設定は外部APIや通常のHTTPS通信では使用しないように注意してください。)
API呼び出し部分
API呼び出しは、メソッド、パス、payloadを渡せる薄いラッパーにしました。
def request(self, method: str, path: str, payload: Optional[dict] = None):
if not self.port or not self.password:
ok = self.refresh_credentials()
if not ok:
return 0, None, "lockfile_not_found"
url = f"{self.protocol or 'https'}://127.0.0.1:{self.port}{path}"
headers = {
"Authorization": self._auth_header(),
"Accept": "application/json",
}
data = None
if payload is not None:
data = json.dumps(payload).encode("utf-8")
headers["Content-Type"] = "application/json"
req = urllib.request.Request(
url,
data=data,
method=method.upper(),
headers=headers,
)
try:
with urllib.request.urlopen(req, timeout=2.0, context=self._ssl_context) as resp:
body_bytes = resp.read()
body_str = body_bytes.decode("utf-8", errors="ignore") if body_bytes else ""
if body_str:
try:
return resp.status, json.loads(body_str), ""
except json.JSONDecodeError:
return resp.status, None, body_str
return resp.status, None, ""
except urllib.error.HTTPError as e:
body = e.read().decode("utf-8", errors="ignore") if e.fp else ""
return e.code, None, body
except Exception as e:
return 0, None, str(e)
ここでは例外をそのまま外に投げず、status, body, error の形で返すようにしています。
常駐プロセスなので、一時的にLoLクライアントが閉じている、lockfileがまだない、APIが一瞬応答しない、といった状態は普通に起きます。
そのたびに落ちるより、次のpollで復帰できるようにしたかったためです。
ロビー人数を取得する
ソロキューを止めるために、一番大事なのはロビー人数です。
まず /lol-lobby/v2/lobby を見て、members が取れればその数を使います。
def get_lobby_member_count(client: LcuClient) -> Optional[int]:
status, body, _ = client.request("GET", "/lol-lobby/v2/lobby")
if status != 200 or not isinstance(body, dict):
status2, body2, _ = client.request("GET", "/lol-lobby/v2/lobby/members")
if status2 == 200 and isinstance(body2, list):
return len(body2)
return None
members = body.get("members")
if isinstance(members, list):
return len(members)
return None
ポイントは、人数が分からない場合に None を返すことです。
人数不明をキャンセル対象にはせず、何もしないことで意図しない動作を防いでいます。
実務においてもある話ですが、こういった処理の境界線は個人的に重要視しています。
マッチング検索中か判定する
次に、現在マッチング検索中かどうかを見ます。
def is_matchmaking_searching(client: LcuClient) -> bool:
status, body, _ = client.request(
"GET",
"/lol-lobby/v2/lobby/matchmaking/search-state",
)
if status == 200 and isinstance(body, dict):
in_queue = body.get("isCurrentlyInQueue")
if isinstance(in_queue, bool):
return in_queue
search_state = str(body.get("searchState", "")).lower()
if search_state and search_state not in {
"none",
"invalid",
"canceled",
"cancelled",
"notsearching",
}:
return True
status2, body2, _ = client.request("GET", "/lol-gameflow/v1/gameflow-phase")
if status2 == 200 and isinstance(body2, str):
return body2.lower() == "matchmaking"
return False
search-state で取れる情報を優先しつつ、クライアントの状態差分に備えて gameflow-phase もfallbackとして見ています。
検索キャンセルとReady Check辞退
検索キャンセルはこのAPIです。
def cancel_matchmaking(client: LcuClient) -> bool:
status, _, _ = client.request(
"DELETE",
"/lol-lobby/v2/lobby/matchmaking/search",
)
return status in (200, 202, 204)
検索キャンセル前に即マッチした場合は、Ready Checkが出ます。
そのため、Ready Checkが出ている場合も、ソロロビーであれば辞退します。
def should_decline_ready_check(ready_check: Optional[Dict[str, Any]]) -> bool:
if not isinstance(ready_check, dict):
return False
state = str(ready_check.get("state", "")).strip().lower()
player_response = str(ready_check.get("playerResponse", "")).strip().lower()
if state not in {"inprogress", "pending", "active"}:
return False
if player_response in {"accepted", "declined"}:
return False
return True
def decline_ready_check(client: LcuClient) -> bool:
status, _, _ = client.request(
"POST",
"/lol-matchmaking/v1/ready-check/decline",
)
return status in (200, 202, 204)
すでに自分が承認済み・辞退済みの場合は触らないようにしています。
メインループ
controllerのメインループでは、以下を繰り返します。
- heartbeatを書く
- guardian/watchdogの状態を確認する
- LoLプロセスが起動しているか見る
- lockfileからLCU接続情報を読む
- ロビー人数を取得する
- マッチング検索中か確認する
- ソロ検索中ならキャンセルする
- ソロReady Check中なら辞退する
重要な部分だけ抜き出すとこうです。
member_count = get_lobby_member_count(lcu)
searching = is_matchmaking_searching(lcu)
is_solo_lobby = member_count is not None and member_count < solo_threshold
if is_solo_lobby and searching:
if time.time() - last_cancel_ts >= 2.0:
cancel_matchmaking(lcu)
last_cancel_ts = time.time()
if is_solo_lobby:
ready_check = get_ready_check_info(lcu)
if should_decline_ready_check(ready_check):
if time.time() - last_ready_decline_ts >= 1.0:
decline_ready_check(lcu)
last_ready_decline_ts = time.time()
last_cancel_ts と last_ready_decline_ts を持っているのは、短時間に同じAPIを叩き続けないためです。
常駐監視では、条件がtrueの間ずっと処理が走り続けます。
なので、キャンセルや辞退のAPIは軽く間隔を空けるようにしています。
二重起動を防ぐ
常駐プロセスでは、同じcontrollerが複数立ち上がると困ります。
そこで、Windowsのmutexを使って二重起動を防いでいます。
def create_single_instance_mutex(name: str):
kernel32 = ctypes.windll.kernel32
mutex = kernel32.CreateMutexW(None, False, name)
ERROR_ALREADY_EXISTS = 183
already_exists = kernel32.GetLastError() == ERROR_ALREADY_EXISTS
return mutex, already_exists
controller側では、すでに起動済みなら終了します。
mutex, already_exists = create_single_instance_mutex(MUTEX_CONTROLLER)
if already_exists:
logger.info("another controller instance is already running, exiting.")
return
タスクスケジューラ、サービス、手動起動など、複数の入口がある構成では、この保険があると安心です。
heartbeatとPID、サービス状態で死活監視する
プロセス直起動モードでは、controllerとwatchdogがそれぞれPIDファイルとheartbeatファイルを書きます。
def write_pid(path: pathlib.Path, pid: int) -> None:
path.write_text(str(pid), encoding="ascii")
def write_heartbeat(path: pathlib.Path) -> None:
path.write_text(str(time.time()), encoding="ascii")
watchdog側は、controllerのPIDが生きているか、heartbeatが古くないかを確認します。
def ensure_controller_alive(paths, logger, stale_seconds: float):
controller_pid = read_pid(pid_file(paths, CONTROLLER_NAME))
pid_alive = is_pid_running(controller_pid)
stale = peer_is_stale(paths, CONTROLLER_NAME, stale_seconds)
if pid_alive and not stale:
return
controller_script = paths.scripts / "controller.py"
started = start_script_detached(controller_script)
if started:
logger.warning("controller was missing/stale and restart was attempted.")
else:
logger.error("failed to start controller.")
最終的なサービスモードでは、guardianはworkerサービスの状態も見ます。workerサービスが動いていなければ、サービスとして起動し直します。
def ensure_worker_service_alive(logger, worker_service_name: str):
if is_windows_service_running(worker_service_name):
return
start_windows_service(worker_service_name)
Windowsサービス化する
Windowsサービス化には pywin32 を使いました。
workerサービスは、サービス開始時に controller.run() を別スレッドで起動します。
class LoLSQGWorkerService(win32serviceutil.ServiceFramework):
_svc_name_ = "LoLSQGWorkerSvc"
_svc_display_name_ = "LoL Solo Queue Guard Worker"
def __init__(self, args):
super().__init__(args)
self.hWaitStop = win32event.CreateEvent(None, 0, 0, None)
self.stop_event = threading.Event()
self.worker_thread = None
def SvcStop(self):
self.ReportServiceStatus(win32service.SERVICE_STOP_PENDING)
self.stop_event.set()
win32event.SetEvent(self.hWaitStop)
def SvcDoRun(self):
self.worker_thread = threading.Thread(
target=controller.run,
kwargs={"stop_event": self.stop_event},
daemon=True,
)
self.worker_thread.start()
win32event.WaitForSingleObject(self.hWaitStop, win32event.INFINITE)
self.stop_event.set()
if self.worker_thread:
self.worker_thread.join(timeout=10)
サービス停止時に stop_event を立て、controller側のループもそれを見て抜けるようにしています。
常駐スクリプトをWindowsサービスにするとき、停止処理を雑にすると、プロセスだけ残ったり、次回起動時に二重起動したりします。
なので、停止要求を受け取ってループを抜ける形にしました。
guardianサービスも同じ考え方で、watchdog.run() をサービスとして起動します。
処理中のウィンドウを出さない
地味ですが大事だったのが、バックグラウンド実行時にPowerShellやpythonなどのコンソールウインドウが出ないようにすることです。
タスクスケジューラやPowerShellから定期実行すると、設定によっては一瞬ウィンドウが出ることがあります。
常駐サービスなので、これはかなり気になりました。
これを回避するために、タスクスケジューラからの入口では pythonw.exe を使い、Python側で子プロセスを起動する場合も CREATE_NO_WINDOW を指定しています。
def start_script_detached(script_path: pathlib.Path) -> bool:
python_exe = sys.executable
creation_flags = subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.CREATE_NO_WINDOW
startupinfo = subprocess.STARTUPINFO()
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
startupinfo.wShowWindow = subprocess.SW_HIDE
subprocess.Popen(
[python_exe, str(script_path)],
cwd=str(script_path.parent.parent),
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
creationflags=creation_flags,
startupinfo=startupinfo,
close_fds=True,
)
return True
常駐前提のツールでは、こういったノイズがない状態で使えるのも大事でした。
インストーラをPowerShellで作る
個人PC向けなので、今回はPowerShellでインストールスクリプトを作りました。
やっていることは以下です。
- 管理者権限チェック
- インストール先と状態ファイル用フォルダを作成
- Pythonファイルを
<install-root>にコピー -
config.jsonを生成 - Windowsサービスを登録
- サービス失敗時の再起動設定を入れる
- タスクスケジューラに復旧用タスクを登録
- 初回起動する
簡略化するとこんな感じです。
param(
[string]$InstallRoot = "$env:ProgramData\LoLSoloQueueGuard",
[string]$StateRoot = "$env:LOCALAPPDATA\LoLSoloQueueGuard",
[string]$LeagueClientPath = "<league-client-path>",
[string]$RiotClientPath = "<riot-client-path>"
)
$ErrorActionPreference = "Stop"
if (-not (Test-IsAdmin)) {
throw "Run this installer as Administrator."
}
New-Item -ItemType Directory -Path $InstallRoot -Force | Out-Null
New-Item -ItemType Directory -Path $StateRoot -Force | Out-Null
New-Item -ItemType Directory -Path (Join-Path $StateRoot "runtime") -Force | Out-Null
New-Item -ItemType Directory -Path (Join-Path $StateRoot "logs") -Force | Out-Null
Copy-Item -Path "$sourceRoot\lolsq_guard" -Destination $InstallRoot -Recurse -Force
Copy-Item -Path "$sourceRoot\launcher.py" -Destination $InstallRoot -Force
設定ファイルはJSONで書き出しています。
$config = [ordered]@{
pythonPath = $pythonPath
pythonwPath = $pythonwPath
stateRoot = $StateRoot
pollIntervalSeconds = 2.0
peerCheckIntervalSeconds = 5.0
peerStaleSeconds = 30.0
heartbeatIntervalSeconds = 2.0
lolProcessNames = @(
"LeagueClient.exe",
"LeagueClientUx.exe",
"RiotClientServices.exe"
)
lcuLockfileCandidates = @(
(Join-Path $LeagueClientPath "lockfile"),
(Join-Path $RiotClientPath "lockfile")
)
cancelWhenLobbyMembersLessThan = 2
logLevel = "INFO"
supervisorMode = "service"
}
config.json はPowerShellからUTF-8で書きます。
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
[System.IO.File]::WriteAllText($configPath, $configJson, $utf8NoBom)
Python側では utf-8-sig でも読めるようにしています。
with paths.config.open("r", encoding="utf-8-sig") as f:
loaded = json.load(f)
Windows上でPowerShellとPythonを行き来すると、文字コードやBOMで地味にハマることがあります。
ここは最初から揃えておいたほうが楽です。
サービス登録と自動復旧
サービス登録は、pywin32 のサービスwrapperを使って行います。
$workerSvcScript = Join-Path $InstallRoot "lolsq_guard\service_worker.py"
$guardianSvcScript = Join-Path $InstallRoot "lolsq_guard\service_guardian.py"
& $pythonPath $workerSvcScript --startup auto install | Out-Null
& $pythonPath $guardianSvcScript --startup auto install | Out-Null
さらに、Windowsサービス側にも失敗時の再起動設定を入れます。
foreach ($svc in @($workerService, $guardianService)) {
& sc.exe failure $svc "reset= 86400" "actions= restart/5000/restart/10000/restart/20000" | Out-Null
& sc.exe failureflag $svc 1 | Out-Null
}
ここでは、サービスが落ちた場合に段階的に再起動するようにしています。
- 1回目: 5秒後に再起動
- 2回目: 10秒後に再起動
- それ以降: 20秒後に再起動
タスクスケジューラを復旧保険にする
サービスだけでも動きますが、さらに保険としてタスクスケジューラも登録しました。
登録しているトリガーは以下です。
- Windows起動時
- ユーザーログオン時
- 定期実行
起動するのは launcher.py です。
$launcherPath = Join-Path $InstallRoot "launcher.py"
$action = New-ScheduledTaskAction `
-Execute $pythonwPath `
-Argument "`"$launcherPath`"" `
-WorkingDirectory $InstallRoot
$triggerAtStartup = New-ScheduledTaskTrigger -AtStartup
$triggerAtLogon = New-ScheduledTaskTrigger -AtLogOn -User $currentUser
$triggerRepeat = New-ScheduledTaskTrigger `
-Once `
-At (Get-Date).AddMinutes(1) `
-RepetitionInterval (New-TimeSpan -Minutes 2) `
-RepetitionDuration (New-TimeSpan -Days 3650)
launcher側では、サービスモードならguardianとworkerを起動確認します。
def ensure_service_started(service_name: str) -> None:
state = query_service_state(service_name)
if state in {"RUNNING", "START_PENDING", "CONTINUE_PENDING"}:
return
run_hidden_command(["sc.exe", "start", service_name], timeout_seconds=10.0)
これで、PC再起動後やログオン時、何かの拍子にサービスが落ちていた場合でも復旧しやすくしています。
状態確認スクリプト
常駐ツールで怖いのは、動いているのか分からないことです。
そこで、状態確認用のPowerShellも用意しました。
確認しているのは以下です。
- インストール先があるか
- 設定ファイルがあるか
- サービスが登録されているか
- サービス状態がどうなっているか
- タスクスケジューラが登録されているか
- runtimeファイルが更新されているか
- PIDが生きているか
- ログが出ているか
cd <project-root>
.\Check-LoLSoloQueueGuardStatus.ps1
中では、まずサービスの状態を見ています。
$svcs = @($GuardianService, $WorkerService) | ForEach-Object {
$s = Get-Service -Name $_ -ErrorAction SilentlyContinue
if ($s) {
[pscustomobject]@{
Name = $s.Name
Status = $s.Status
StartType = $s.StartType
}
} else {
[pscustomobject]@{
Name = $_
Status = "NotInstalled"
StartType = "-"
}
}
}
$svcs | Format-Table -AutoSize
タスクスケジューラに復旧用タスクが登録されているかも確認します。
$task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
if ($task) {
$task | Select-Object TaskName, State, TaskPath | Format-Table -AutoSize
$task.Actions |
Select-Object Execute, Arguments, WorkingDirectory |
Format-Table -AutoSize
} else {
Write-Host "Task not found: $TaskName"
}
さらに、runtime配下のPIDファイルからプロセスが生きているかも見ます。
$pidFiles = @(
@{ Name = "controller"; Path = (Join-Path $runtimeDir "controller.pid") },
@{ Name = "watchdog"; Path = (Join-Path $runtimeDir "watchdog.pid") }
)
$rows = @()
foreach ($entry in $pidFiles) {
$pidValue = $null
$alive = $false
if (Test-Path -LiteralPath $entry.Path) {
$pidValue = [int](Get-Content -LiteralPath $entry.Path -Raw).Trim()
$p = Get-Process -Id $pidValue -ErrorAction SilentlyContinue
if ($p) {
$alive = $true
}
}
$rows += [pscustomobject]@{
Name = $entry.Name
Pid = $pidValue
Alive = $alive
}
}
$rows | Format-Table -AutoSize
最後に、ログファイルの末尾を出して、直近で何が起きたか確認できるようにしています。
$logDir = Join-Path $stateRoot "logs"
if (Test-Path -LiteralPath $logDir) {
Get-ChildItem -LiteralPath $logDir -File |
Select-Object Name, Length, LastWriteTime |
Format-Table -AutoSize
Get-ChildItem -LiteralPath $logDir -File | ForEach-Object {
Write-Host ""
Write-Host ("--- tail: {0} ---" -f $_.Name)
Get-Content -LiteralPath $_.FullName -Tail 20
}
}
出力として、サービス、タスク、runtime、ログの状態をまとめて見られるようにしています。
特に検証中・作成途中の段階では、この確認スクリプトがかなり役に立ちました。
常駐系は「そもそも動いてる?」の確認から始まりがちなので、状態確認コマンドを先に用意しておくと、検証と原因切り分けがかなり楽になると感じました。
緊急停止スクリプト
止めたいときにすぐ止められることも大事です。
緊急停止スクリプトでは、以下を行います。
- タスクスケジューラのタスク停止
- タスクの無効化
- controller/watchdogプロセスの停止
- Windowsサービスの停止
Stop-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
Disable-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue | Out-Null
Get-CimInstance Win32_Process |
Where-Object {
$_.Name -match "^python(\.exe)?$" -and (
$_.CommandLine -match "lolsq_guard\\controller.py" -or
$_.CommandLine -match "lolsq_guard\\watchdog.py"
)
} |
ForEach-Object {
Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue
}
foreach ($svc in @($GuardianService, $WorkerService)) {
Stop-Service -Name $svc -Force -ErrorAction SilentlyContinue
}
常駐化や自動復旧を強くするほど、止めるための導線も必要になります。
ここはセットで作るべきところでした。
実装してみて気をつけたこと
実装で特に気をつけたのは、次の点です。
- ロビー人数が分からない場合は何もしない
- APIを短時間に連打しない
- controller/watchdogの二重起動を防ぐ
- サービス停止時にスレッドも止める
- 処理中にコンソールウィンドウを出さない
- 状態確認と緊急停止を必ず用意する
今回のような自動化では、動くこと自体よりも「余計な場面で動かないこと」を重視しました。
また、常駐前提であるがゆえに、工夫するところも多かった印象です。
まとめ
今回は、LoLのソロキュー自動キャンセルをLCU APIとWindowsサービスで実装した話を書きました。
構成としては、以下のようになっています。
- LCU APIからロビー人数と検索状態を取得する
- ソロロビーかつ検索中なら検索キャンセルAPIを呼ぶ
- ソロロビーでReady Checkが出たら辞退する
- controllerをWindowsサービスとして常駐させる
- guardian/watchdogで復旧できるようにする
- タスクスケジューラを復旧保険にする
- 状態確認と緊急停止のスクリプトを用意する
やっていることは小さな自動化ですが、常駐ツールとしてちゃんと使うには、意外と周辺実装が大事でした。
APIを叩く本体よりも、二重起動防止、ログ、停止処理、サービス復旧、ステータス確認のほうが地味に効いてきます。
前回の記事では思想や設計に近い話でしたが、今回はより実装やコーディングに寄せたより技術色の濃い記事を書かせていただきました。
タイトルだけ見ると、全くもって役に立たなそうな内容ですが、「PythonでWindows常駐サービスを仕込む」という視点で読んでいただけると、案外楽しめるのではないかと思っています。
ここまで読んでいただき、ありがとうございました。
次回は、AWSに関する記事を書いていく予定です。