カクヨムの「小説をバックアップ」を毎回手動で実行する代わりに、PowerShellからバックアップZIPを取得して指定フォルダへ保存するスクリプトです。
複数作品に対応し、作品ごとに保存先を設定できます。パスワードはスクリプトへ直接書かず、WindowsのDPAPIを使って暗号化したファイルとして保存します。
自分のアカウント・自分の作品を低頻度でバックアップする用途で作成しています。サイト側の仕様変更によって動作しなくなる可能性があります。
ファイル構成
同じフォルダに次のファイルを置きます。
kakuyomu_backup.cmd
kakuyomu_backup.ps1
kakuyomu_config.txt
kakuyomu_credentials.txt
kakuyomu_password_setup.ps1
kakuyomu_password.dat # 初回設定時に生成。公開・共有しない
1. kakuyomu_credentials.txt
メールアドレスを設定します。
email=your_email@example.com
2. kakuyomu_config.txt
バックアップしたい作品IDと保存先フォルダを設定します。複数作品を指定できます。
# 形式
# work=作品ID|ZIP保存先
work=YOUR_WORK_ID_1|YOUR_BACKUP_FOLDER_1
work=YOUR_WORK_ID_2|YOUR_BACKUP_FOLDER_2
Google Drive for desktopを使っている場合は、Windowsから見えるGoogle Drive内のフォルダを保存先に指定できます。
3. kakuyomu_password_setup.ps1
初回だけ実行します。
$ErrorActionPreference = "Stop"
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$PasswordFile = Join-Path $ScriptDir "kakuyomu_password.dat"
Write-Host ""
Write-Host "========================================"
Write-Host "カクヨム パスワード登録"
Write-Host "========================================"
Write-Host ""
$securePassword = Read-Host "カクヨムのパスワードを入力してください" -AsSecureString
if ($null -eq $securePassword) {
throw "パスワードが入力されていません。"
}
$encryptedPassword = $securePassword | ConvertFrom-SecureString
Set-Content `
-Path $PasswordFile `
-Value $encryptedPassword `
-Encoding UTF8
Write-Host ""
Write-Host "パスワードを暗号化して保存しました。"
Write-Host ""
Write-Host "保存先:"
Write-Host $PasswordFile
Write-Host ""
Write-Host "このファイルは、原則として現在のWindowsユーザーでのみ復号できます。"
Write-Host ""
実行コマンド。
powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".\kakuyomu_password_setup.ps1"
実行するとパスワード入力を求められ、kakuyomu_password.dat が作成されます。このファイルは公開・共有しません。
4. kakuyomu_backup.ps1
バックアップ本体です。
# ============================================================
# カクヨム複数作品 自動バックアップ
# DPAPI対応版
#
# kakuyomu_credentials.txt:
# email=your_email@example.com
#
# kakuyomu_password.dat:
# kakuyomu_password_setup.ps1 で作成した暗号化パスワード
#
# kakuyomu_config.txt:
# work=作品ID|ZIP保存先
#
# ============================================================
$ErrorActionPreference = "Stop"
# ============================================================
# 基本設定
# ============================================================
$BaseUrl = "https://kakuyomu.jp"
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$CredentialFile = Join-Path $ScriptDir "kakuyomu_credentials.txt"
$PasswordFile = Join-Path $ScriptDir "kakuyomu_password.dat"
$ConfigFile = Join-Path $ScriptDir "kakuyomu_config.txt"
$UserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.0.0 Safari/537.36"
# ============================================================
# key=value ファイル読み込み
# ============================================================
function Read-KeyValueFile {
param(
[Parameter(Mandatory = $true)]
[string]$Path
)
if (-not (Test-Path $Path)) {
throw "設定ファイルが見つかりません: $Path"
}
$result = @{}
Get-Content $Path -Encoding UTF8 | ForEach-Object {
$line = $_.Trim()
if ([string]::IsNullOrWhiteSpace($line)) {
return
}
if ($line.StartsWith("#")) {
return
}
$parts = $line -split "=", 2
if ($parts.Count -ne 2) {
return
}
$key = $parts[0].Trim()
$value = $parts[1].Trim()
if ($result.ContainsKey($key)) {
if ($result[$key] -is [System.Array]) {
$result[$key] += $value
}
else {
$result[$key] = @(
$result[$key],
$value
)
}
}
else {
$result[$key] = $value
}
}
return $result
}
# ============================================================
# 認証情報読み込み
# ============================================================
$credentials = Read-KeyValueFile $CredentialFile
$email = $credentials["email"]
if ([string]::IsNullOrWhiteSpace($email)) {
throw "kakuyomu_credentials.txt に email がありません。"
}
# ============================================================
# DPAPI暗号化パスワード読み込み
# ============================================================
if (-not (Test-Path $PasswordFile)) {
throw (
"暗号化パスワードファイルが見つかりません。" +
"`n$PasswordFile" +
"`n先に kakuyomu_password_setup.ps1 を実行してください。"
)
}
$encryptedPassword = Get-Content `
-Path $PasswordFile `
-Raw `
-Encoding UTF8
if ([string]::IsNullOrWhiteSpace($encryptedPassword)) {
throw "kakuyomu_password.dat が空です。"
}
try {
$securePassword = $encryptedPassword.Trim() |
ConvertTo-SecureString
}
catch {
throw (
"kakuyomu_password.dat を復号できませんでした。" +
"`nこのファイルを作成したWindowsユーザーと" +
"現在の実行ユーザーが同じか確認してください。" +
"`n必要なら kakuyomu_password_setup.ps1 を再実行してください。"
)
}
# ============================================================
# 作品設定読み込み
# ============================================================
if (-not (Test-Path $ConfigFile)) {
throw "設定ファイルが見つかりません: $ConfigFile"
}
$WorkConfigs = @()
Get-Content $ConfigFile -Encoding UTF8 | ForEach-Object {
$line = $_.Trim()
if ([string]::IsNullOrWhiteSpace($line)) {
return
}
if ($line.StartsWith("#")) {
return
}
if (-not $line.StartsWith("work=")) {
return
}
$value = $line.Substring(5)
$parts = $value -split "\|", 2
if ($parts.Count -ne 2) {
throw "work設定の形式が不正です: $line"
}
$workId = $parts[0].Trim()
$backupDir = $parts[1].Trim()
if ([string]::IsNullOrWhiteSpace($workId)) {
throw "作品IDが空です: $line"
}
if ([string]::IsNullOrWhiteSpace($backupDir)) {
throw "保存先が空です: $line"
}
$WorkConfigs += [PSCustomObject]@{
WorkId = $workId
BackupDir = $backupDir
}
}
if ($WorkConfigs.Count -eq 0) {
throw "kakuyomu_config.txt に work 設定がありません。"
}
# ============================================================
# 保存先事前確認
# ============================================================
foreach ($workConfig in $WorkConfigs) {
if (-not (Test-Path $workConfig.BackupDir)) {
throw (
"バックアップ保存先が見つかりません。" +
"`n作品ID: $($workConfig.WorkId)" +
"`n保存先: $($workConfig.BackupDir)"
)
}
}
# ============================================================
# URL生成
# ============================================================
$LoginApiUrl = "$BaseUrl/api/v2/auth/login"
$LoginPageUrl = (
"$BaseUrl/auth/login/email" +
"?location=%2F" +
"&auth_platform=web"
)
# ============================================================
# Webセッション作成
# ============================================================
$session = New-Object Microsoft.PowerShell.Commands.WebRequestSession
$commonHeaders = @{
"User-Agent" = $UserAgent
"Accept-Language" = "ja,en-US;q=0.9,en;q=0.8"
}
# ============================================================
# SecureStringを一時的に平文へ変換してログイン
# ============================================================
$ptr = [IntPtr]::Zero
$password = $null
$loginBody = $null
try {
$ptr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR(
$securePassword
)
$password = [Runtime.InteropServices.Marshal]::PtrToStringBSTR(
$ptr
)
# ========================================================
# STEP 1: ログイン
# ========================================================
Write-Host ""
Write-Host "========================================"
Write-Host "カクヨムへログイン"
Write-Host "========================================"
$loginHeaders = @{
"User-Agent" = $UserAgent
"Accept-Language" = "ja,en-US;q=0.9,en;q=0.8"
"Accept" = "*/*"
"Origin" = $BaseUrl
"Referer" = $LoginPageUrl
}
$loginBody = @{
email = $email
location = "/"
password = $password
} | ConvertTo-Json -Compress
$loginResponse = Invoke-WebRequest `
-Uri $LoginApiUrl `
-Method POST `
-Headers $loginHeaders `
-WebSession $session `
-ContentType "application/json" `
-Body $loginBody `
-UseBasicParsing
if ($loginResponse.StatusCode -ne 200) {
throw "ログインに失敗しました。Status: $($loginResponse.StatusCode)"
}
$loginJson = $loginResponse.Content | ConvertFrom-Json
if ($loginJson.redirectLocation -ne "/") {
throw "ログイン成功を確認できませんでした。"
}
Write-Host "ログイン: OK"
}
finally {
if ($ptr -ne [IntPtr]::Zero) {
[Runtime.InteropServices.Marshal]::ZeroFreeBSTR($ptr)
}
$password = $null
$loginBody = $null
}
# ============================================================
# 作品バックアップ処理
# ============================================================
function Backup-KakuyomuWork {
param(
[Parameter(Mandatory = $true)]
[string]$WorkId,
[Parameter(Mandatory = $true)]
[string]$BackupDir
)
$WorkUrl = "$BaseUrl/my/works/$WorkId"
$ArchiveUrl = "$WorkUrl/archive"
Write-Host ""
Write-Host "========================================"
Write-Host "作品ID: $WorkId"
Write-Host "========================================"
# ========================================================
# 作品管理ページ取得
# ========================================================
Write-Host ""
Write-Host "作品管理ページを取得..."
$workResponse = Invoke-WebRequest `
-Uri $WorkUrl `
-Method GET `
-Headers $commonHeaders `
-WebSession $session `
-UseBasicParsing
if ($workResponse.StatusCode -ne 200) {
throw (
"作品管理ページ取得失敗。" +
"作品ID: $WorkId " +
"Status: $($workResponse.StatusCode)"
)
}
if ($workResponse.Content -notmatch "小説をバックアップ") {
throw (
"ログイン済み作品管理ページを取得できませんでした。" +
"作品ID: $WorkId"
)
}
Write-Host "作品管理ページ: OK"
# ========================================================
# 作品タイトル取得
# ========================================================
$workTitle = $null
$titleMatch = [regex]::Match(
$workResponse.Content,
'<h1>(.*?)</h1>',
[System.Text.RegularExpressions.RegexOptions]::IgnoreCase
)
if ($titleMatch.Success) {
$workTitle = [System.Net.WebUtility]::HtmlDecode(
$titleMatch.Groups[1].Value
)
$workTitle = [regex]::Replace(
$workTitle,
'<[^>]+>',
''
).Trim()
}
if ($workTitle) {
Write-Host "作品名: $workTitle"
}
# ========================================================
# csrf_token取得
# ========================================================
Write-Host ""
Write-Host "csrf_token を取得..."
$csrfPattern = 'name="csrf_token"\s+value="([^"]+)"'
$csrfMatch = [regex]::Match(
$workResponse.Content,
$csrfPattern,
[System.Text.RegularExpressions.RegexOptions]::IgnoreCase
)
if (-not $csrfMatch.Success) {
throw "csrf_token を取得できませんでした。作品ID: $WorkId"
}
$csrfToken = $csrfMatch.Groups[1].Value
Write-Host "csrf_token: OK"
# ========================================================
# ZIP取得
# ========================================================
Write-Host ""
Write-Host "バックアップZIPを取得..."
$tempZip = Join-Path `
$env:TEMP `
"kakuyomu_backup_$WorkId.zip"
if (Test-Path $tempZip) {
Remove-Item $tempZip -Force
}
$archiveHeaders = @{
"User-Agent" = $UserAgent
"Accept-Language" = "ja,en-US;q=0.9,en;q=0.8"
"Origin" = $BaseUrl
"Referer" = $WorkUrl
}
$archiveResponse = Invoke-WebRequest `
-Uri $ArchiveUrl `
-Method POST `
-Headers $archiveHeaders `
-WebSession $session `
-ContentType "application/x-www-form-urlencoded" `
-Body @{
csrf_token = $csrfToken
} `
-OutFile $tempZip `
-PassThru `
-UseBasicParsing
if ($archiveResponse.StatusCode -ne 200) {
if (Test-Path $tempZip) {
Remove-Item $tempZip -Force
}
throw (
"バックアップ取得失敗。" +
"作品ID: $WorkId " +
"Status: $($archiveResponse.StatusCode)"
)
}
# ========================================================
# ZIPレスポンス確認
# ========================================================
$contentType = $archiveResponse.Headers["Content-Type"]
if ($contentType -notlike "application/zip*") {
if (Test-Path $tempZip) {
Remove-Item $tempZip -Force
}
throw (
"ZIPではないレスポンスが返されました。" +
"作品ID: $WorkId " +
"Content-Type: $contentType"
)
}
# ========================================================
# ファイル名取得
# ========================================================
$contentDisposition = `
$archiveResponse.Headers["Content-Disposition"]
$filename = $null
if ($contentDisposition) {
$match = [regex]::Match(
$contentDisposition,
"filename\*=UTF-8''([^;]+)",
[System.Text.RegularExpressions.RegexOptions]::IgnoreCase
)
if ($match.Success) {
$filename = [System.Uri]::UnescapeDataString(
$match.Groups[1].Value
)
}
if (-not $filename) {
$match = [regex]::Match(
$contentDisposition,
'filename="([^"]+)"',
[System.Text.RegularExpressions.RegexOptions]::IgnoreCase
)
if ($match.Success) {
$filename = $match.Groups[1].Value
}
}
}
if (-not $filename) {
$date = Get-Date -Format "yyyyMMdd"
$filename = "kakuyomu_$WorkId`_$date.zip"
}
# ========================================================
# 指定された保存先へ保存
# ========================================================
$backupPath = Join-Path $BackupDir $filename
Move-Item `
-Path $tempZip `
-Destination $backupPath `
-Force
# ========================================================
# 保存確認
# ========================================================
if (-not (Test-Path $backupPath)) {
throw "ZIP保存後の確認に失敗しました: $backupPath"
}
$fileInfo = Get-Item $backupPath
if ($fileInfo.Length -le 0) {
throw "保存されたZIPのサイズが0バイトです: $backupPath"
}
# ========================================================
# 作品ごとの完了表示
# ========================================================
Write-Host ""
Write-Host "バックアップ成功"
if ($workTitle) {
Write-Host "作品名 : $workTitle"
}
Write-Host "作品ID : $WorkId"
Write-Host "保存先DIR : $BackupDir"
Write-Host "ファイル名: $filename"
Write-Host "保存先 : $backupPath"
Write-Host ("サイズ : {0:N0} bytes" -f $fileInfo.Length)
return $backupPath
}
# ============================================================
# 全作品バックアップ
# ============================================================
$successCount = 0
$failureCount = 0
$backupResults = @()
foreach ($workConfig in $WorkConfigs) {
try {
$backupPath = Backup-KakuyomuWork `
-WorkId $workConfig.WorkId `
-BackupDir $workConfig.BackupDir
$backupResults += [PSCustomObject]@{
WorkId = $workConfig.WorkId
Success = $true
Path = $backupPath
Error = ""
}
$successCount++
}
catch {
Write-Host ""
Write-Host "バックアップ失敗"
Write-Host "作品ID: $($workConfig.WorkId)"
Write-Host "保存先: $($workConfig.BackupDir)"
Write-Host "エラー: $($_.Exception.Message)"
$backupResults += [PSCustomObject]@{
WorkId = $workConfig.WorkId
Success = $false
Path = ""
Error = $_.Exception.Message
}
$failureCount++
}
}
# ============================================================
# 最終結果
# ============================================================
Write-Host ""
Write-Host "========================================"
Write-Host "全バックアップ処理完了"
Write-Host "========================================"
Write-Host ""
foreach ($result in $backupResults) {
if ($result.Success) {
Write-Host "[OK] $($result.WorkId)"
Write-Host " $($result.Path)"
}
else {
Write-Host "[NG] $($result.WorkId)"
Write-Host " $($result.Error)"
}
}
Write-Host ""
Write-Host "成功: $successCount"
Write-Host "失敗: $failureCount"
Write-Host ""
# ============================================================
# 認証情報を変数から消去
# ============================================================
$securePassword = $null
$encryptedPassword = $null
$email = $null
$credentials = $null
5. kakuyomu_backup.cmd
普段はこちらを起動します。
@echo off
cd /d "%~dp0"
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0kakuyomu_backup.ps1"
echo.
echo ----------------------------------------
echo 処理が終了しました。
echo ----------------------------------------
pause
使い方
-
kakuyomu_credentials.txtにメールアドレスを設定する -
kakuyomu_config.txtに作品IDと保存先を設定する -
kakuyomu_password_setup.ps1を一度実行してパスワードを登録する - 以後は
kakuyomu_backup.cmdを実行する
同じ日に再実行して同名のZIPが返った場合は、Move-Item -Force により上書きします。
補足
このスクリプトを作るまでの調査や試行錯誤は、カクヨムの『AIで小説を書くにはどうすればいい?』で記録しています。
記録8:Chrome DevToolsとGoogle ColabでカクヨムのバックアップZIP自動取得を試した
https://kakuyomu.jp/works/2912051605768714980/episodes/2912051606405487283
この仕組みを実際に使っている作品
今回紹介した仕組みは、以下のカクヨム作品の制作・分析で実際に使用しています。
アークリーチャーズ
頭のない異形が歩く世界で、頭部を外して戦う汎用人型兵器カイを中心に描くSF小説です。
AIを使いながら、設定管理・推敲・バックアップ・読者動向の分析まで含めて制作しています。
https://kakuyomu.jp/works/822139842645600859

AIで小説を書くにはどうすればいい?
『アークリーチャーズ』をAIで制作する中で、実際に試した方法、失敗、ツールの変更、制作環境の改善などを記録しているドキュメンタリーです。