1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

PowerShellでカクヨムの小説バックアップZIPを自動取得してGoogle Driveなどの指定フォルダに保存する

1
Last updated at Posted at 2026-08-16

カクヨムの「小説をバックアップ」を毎回手動で実行する代わりに、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

使い方

  1. kakuyomu_credentials.txt にメールアドレスを設定する
  2. kakuyomu_config.txt に作品IDと保存先を設定する
  3. kakuyomu_password_setup.ps1 を一度実行してパスワードを登録する
  4. 以後は 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
コンセプトアート.png

AIで小説を書くにはどうすればいい?

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

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?