0
0

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でカクヨム小説を自動バックアップし、各話の変更履歴をSHA-256で保存する

0
Last updated at Posted at 2026-08-27

以前、カクヨムの小説バックアップZIPをPowerShellで自動取得し、Google Driveなどの指定フォルダへ保存する方法を書いた。

その後、実際に小説を書き続ける中で、単にZIPを保存するだけでは不便になった。

欲しくなったのは次の2種類のデータだった。

  • 現在の各話本文:AIや他のツールから常に最新版を参照するための正本
  • 過去の各話本文:どのように推敲・修正されたか後から追える履歴

そこで、バックアップZIP取得処理を拡張し、

  1. カクヨムへログイン
  2. 複数作品のバックアップZIPを取得
  3. ZIP自体を日時付きで保存
  4. ZIPから各話本文を抽出して episodes を最新版へ更新
  5. 各話のSHA-256を計算
  6. 過去に保存した本文と同一なら履歴追加をスキップ
  7. 内容が変わっている場合だけ history/episodes に新しい版を追加
  8. CSVのmanifestも保存

までを1回で行うようにした。

PowerShell 5.1互換を想定している。

注意

この記事の処理はカクヨムの公開APIではなく、Web画面のログイン・バックアップ機能を利用している。
カクヨム側のHTMLや認証・バックアップ仕様が変更された場合は動作しなくなる可能性がある。
また、自分が管理している作品のバックアップ用途を前提としている。

以前の版との違い

以前の版は、基本的に

カクヨム
  ↓
バックアップZIP取得
  ↓
指定フォルダへ保存

だけだった。

現在は次の構成になっている。

カクヨム
  ↓
バックアップZIP取得
  ↓
history/kakuyomu_zip に保存
  ↓
ZIPを1回だけ解析
  ├─ episodes
  │    └─ 現在の各話本文
  │
  └─ history/episodes
       └─ 変更された版だけ蓄積

同じZIPを何度も展開・解析するのではなく、ZIPを一度読み込んで EpisodeData[] を作り、そこから「現行版」と「履歴版」の2系統へ分岐させている。

フォルダ構成

作品ごとに、次のようなフォルダを用意する。

作品ルート
├─ episodes
│  ├─ episode_0001.txt
│  ├─ episode_0002.txt
│  └─ manifest.csv
│
└─ history
   ├─ episodes
   │  ├─ episode_0001
   │  │  ├─ episode_0001_rev_20260614_193000.txt
   │  │  └─ episode_0001_rev_20260816_205817.txt
   │  ├─ episode_0002
   │  └─ ...
   │
   ├─ kakuyomu_zip
   │  └─ 取得したバックアップZIP
   │
   └─ manifests
      └─ episode_history.csv

episodes は常に現在の状態へ更新する。

一方、history/episodes は積み上げ専用で、過去ファイルを削除しない。

なぜファイル名だけで履歴判定しないのか

カクヨムのバックアップ内には更新日時が入っているため、履歴ファイル名にもその日時を使える。

ただし、履歴の重複判定を更新日時やmanifestだけに依存させると、何らかの理由でmanifestと実ファイルの状態がずれた場合に扱いづらい。

そこで最新版では、履歴フォルダに実際に存在する本文ファイルのSHA-256を直接計算して比較する

Get-FileHash -Algorithm SHA256

新しく取得した本文のSHA-256と、既存履歴のいずれかが一致すれば、

history重複スキップ

として新しい履歴ファイルは作らない。

本文が1文字でも変わっていればハッシュ値が変わるので、新しい版として保存する。

同じ更新日時なのに本文が違う場合

通常は、更新日時を使って次のような名前にする。

episode_0001_rev_20260816_205817.txt

しかし、同じファイル名がすでに存在するのにSHA-256が異なるケースも考慮した。

その場合は短縮したSHA-256を付加する。

episode_0001_rev_20260816_205817_48f0ed86.txt

さらに同名が存在する場合は連番を付ける。

このため、ファイル名の衝突で古い履歴を上書きしない。

ZIPそのものも残す

各話本文だけでなく、カクヨムから取得した元ZIPも

history/kakuyomu_zip

へ保存する。

本文抽出処理にバグが見つかった場合でも、元ZIPが残っていれば後から再解析できるためである。

episodeshistory の役割を分ける

この構成にした一番大きな理由は、AIから小説を参照するときに扱いやすくするためだった。

episodes だけ見れば常に現在の本文がある。

episodes/episode_0001.txt
episodes/episode_0002.txt
...

一方で、「第1話が以前どう書かれていたか」を調べたい場合だけ

history/episodes/episode_0001

を見る。

最新版と変更履歴を同じフォルダへ混在させないことで、通常利用と履歴調査を分離できる。

設定ファイル

kakuyomu_credentials.txt

email=your_email@example.com

パスワードは平文では保存しない。

従来版と同じく kakuyomu_password.dat にWindowsのDPAPIを利用して暗号化した状態で保存する。

kakuyomu_config.txt

以前の版では、

work=作品ID|ZIP保存先

だった。

最新版では、作品単位のルートフォルダを基準にするため、

work=作品ID|識別名|作品ルート

へ変更した。

例:

# work=作品ID|識別名|作品ルート

work=YOUR_WORK_ID_1|novel1|G:\My Drive\stories\novel1
work=YOUR_WORK_ID_2|novel2|G:\My Drive\stories\novel2

識別名には半角英数字、_-のみ使用できる。

スクリプトが作品ルート配下に必要なフォルダを作成する。

パスワード登録

以前の記事と同じ方式で、SecureString とWindows DPAPIを利用する。

例:

$ErrorActionPreference = "Stop"

$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$PasswordFile = Join-Path $ScriptDir "kakuyomu_password.dat"

$securePassword = Read-Host "カクヨムのパスワードを入力してください" -AsSecureString
$encryptedPassword = $securePassword | ConvertFrom-SecureString

Set-Content `
    -Path $PasswordFile `
    -Value $encryptedPassword `
    -Encoding UTF8

Write-Host "パスワードを暗号化して保存しました。"
Write-Host $PasswordFile

原則として、このファイルを作成したWindowsユーザーと同じユーザーで実行する。

実行後

例えば4作品を登録している場合、最後に次のような結果を表示する。

========================================
全作品処理完了
========================================

[OK] novel1 (作品ID)
     作品名: 作品名
     ZIP: ...
     episodes: 109話
     history追加: 2
     history重複: 107

成功: 1
失敗: 0

本文が変更されていない話については履歴ファイルを増やさず、変更された話だけが追加される。

完全版 kakuyomu_backup.ps1

以下が現在使用している完全版。

# ============================================================
# カクヨム複数作品 バックアップ + episodes生成 + history蓄積
#
# kakuyomu_credentials.txt:
#   email=your_email@example.com
#
# kakuyomu_password.dat:
#   kakuyomu_password_setup.ps1 で作成した暗号化パスワード
#
# kakuyomu_config.txt:
#   work=作品ID|識別名|作品ルート
#
# 標準フォルダ構成:
#   <作品ルート>\episodes
#   <作品ルート>\history\episodes
#   <作品ルート>\history\kakuyomu_zip
#   <作品ルート>\history\manifests
#
# 処理:
#   1. カクヨムからZIP取得
#   2. ZIPを history\kakuyomu_zip に取得日時付きで保存
#   3. ZIPを1回だけ解析して EpisodeData[] を作成
#   4. episodes を最新状態へ再生成
#   5. history は既存実ファイルのSHA-256と比較し、
#      未保存の版だけ追加
#
# PowerShell 5.1 互換を想定
# ============================================================

$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"

$CharCountWarningThreshold = 0.20
$Utf8NoBom = [System.Text.UTF8Encoding]::new($false)

Add-Type -AssemblyName System.IO.Compression.FileSystem

# ============================================================
# 共通ヘルパー
# ============================================================

function Ensure-Directory {
    param(
        [Parameter(Mandatory = $true)]
        [string]$Path
    )

    if (-not (Test-Path -LiteralPath $Path)) {
        New-Item -ItemType Directory -Path $Path -Force | Out-Null
    }
}

function Write-Utf8NoBomText {
    param(
        [Parameter(Mandatory = $true)]
        [string]$Path,

        [Parameter(Mandatory = $true)]
        [AllowEmptyString()]
        [string]$Text
    )

    [System.IO.File]::WriteAllText($Path, $Text, $Utf8NoBom)
}

function Normalize-LineEndings {
    param(
        [Parameter(Mandatory = $true)]
        [AllowEmptyString()]
        [string]$Text
    )

    return $Text.Replace("`r`n", "`n").Replace("`r", "`n")
}

function Convert-FullWidthInteger {
    param(
        [Parameter(Mandatory = $true)]
        [string]$Value
    )

    $translated = $Value
    $translated = $translated.Replace("0", "0")
    $translated = $translated.Replace("1", "1")
    $translated = $translated.Replace("2", "2")
    $translated = $translated.Replace("3", "3")
    $translated = $translated.Replace("4", "4")
    $translated = $translated.Replace("5", "5")
    $translated = $translated.Replace("6", "6")
    $translated = $translated.Replace("7", "7")
    $translated = $translated.Replace("8", "8")
    $translated = $translated.Replace("9", "9")
    $translated = $translated.Replace(",", ",")
    $translated = $translated.Replace(",", "")

    return [int]$translated
}

function Get-UnicodeCodePointCount {
    param(
        [Parameter(Mandatory = $true)]
        [AllowEmptyString()]
        [string]$Text
    )

    $count = 0
    $i = 0

    while ($i -lt $Text.Length) {
        $ch = $Text[$i]

        if (
            [char]::IsHighSurrogate($ch) -and
            ($i + 1) -lt $Text.Length -and
            [char]::IsLowSurrogate($Text[$i + 1])
        ) {
            $i += 2
        }
        else {
            $i += 1
        }

        $count++
    }

    return $count
}

function Get-StringSha256 {
    param(
        [Parameter(Mandatory = $true)]
        [AllowEmptyString()]
        [string]$Text
    )

    $bytes = $Utf8NoBom.GetBytes($Text)
    $sha = [System.Security.Cryptography.SHA256]::Create()

    try {
        $hashBytes = $sha.ComputeHash($bytes)
    }
    finally {
        $sha.Dispose()
    }

    return (($hashBytes | ForEach-Object { $_.ToString("x2") }) -join "")
}

function Get-UniquePath {
    param(
        [Parameter(Mandatory = $true)]
        [string]$DesiredPath
    )

    if (-not (Test-Path -LiteralPath $DesiredPath)) {
        return $DesiredPath
    }

    $dir  = Split-Path -Parent $DesiredPath
    $base = [System.IO.Path]::GetFileNameWithoutExtension($DesiredPath)
    $ext  = [System.IO.Path]::GetExtension($DesiredPath)

    $index = 2

    while ($true) {
        $candidate = Join-Path $dir ("{0}_{1}{2}" -f $base, $index, $ext)

        if (-not (Test-Path -LiteralPath $candidate)) {
            return $candidate
        }

        $index++
    }
}

# ============================================================
# key=value ファイル読み込み
# ============================================================

function Read-KeyValueFile {
    param(
        [Parameter(Mandatory = $true)]
        [string]$Path
    )

    if (-not (Test-Path -LiteralPath $Path)) {
        throw "設定ファイルが見つかりません: $Path"
    }

    $result = @{}

    Get-Content -LiteralPath $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
}

# ============================================================
# config読み込み
# work=作品ID|識別名|作品ルート
# ============================================================

function Read-WorkConfigs {
    param(
        [Parameter(Mandatory = $true)]
        [string]$Path
    )

    if (-not (Test-Path -LiteralPath $Path)) {
        throw "設定ファイルが見つかりません: $Path"
    }

    $configs = @()

    Get-Content -LiteralPath $Path -Encoding UTF8 | ForEach-Object {
        $line = $_.Trim()

        if (
            [string]::IsNullOrWhiteSpace($line) -or
            $line.StartsWith("#") -or
            -not $line.StartsWith("work=")
        ) {
            return
        }

        $value = $line.Substring(5)
        $parts = $value -split "\|", 3

        if ($parts.Count -ne 3) {
            throw (
                "work設定の形式が不正です: $line" +
                "`n正しい形式: work=作品ID|識別名|作品ルート"
            )
        }

        $workId = $parts[0].Trim()
        $workKey = $parts[1].Trim()
        $rootDir = $parts[2].Trim()

        if ([string]::IsNullOrWhiteSpace($workId)) {
            throw "作品IDが空です: $line"
        }

        if ([string]::IsNullOrWhiteSpace($workKey)) {
            throw "識別名が空です: $line"
        }

        if ($workKey -notmatch '^[A-Za-z0-9_-]+$') {
            throw (
                "識別名には半角英数字・_・-のみ使用してください: $workKey"
            )
        }

        if ([string]::IsNullOrWhiteSpace($rootDir)) {
            throw "作品ルートが空です: $line"
        }

        if (-not (Test-Path -LiteralPath $rootDir)) {
            throw (
                "作品ルートが見つかりません。" +
                "`n作品ID: $workId" +
                "`n作品ルート: $rootDir"
            )
        }

        $episodesDir = Join-Path $rootDir "episodes"
        $historyDir = Join-Path $rootDir "history"
        $historyEpisodesDir = Join-Path $historyDir "episodes"
        $historyZipDir = Join-Path $historyDir "kakuyomu_zip"
        $historyManifestsDir = Join-Path $historyDir "manifests"

        $configs += [PSCustomObject]@{
            WorkId              = $workId
            WorkKey             = $workKey
            RootDir             = $rootDir
            EpisodesDir         = $episodesDir
            HistoryDir          = $historyDir
            HistoryEpisodesDir  = $historyEpisodesDir
            HistoryZipDir       = $historyZipDir
            HistoryManifestsDir = $historyManifestsDir
        }
    }

    if ($configs.Count -eq 0) {
        throw "kakuyomu_config.txt に work 設定がありません。"
    }

    return $configs
}

# ============================================================
# 認証情報読み込み
# ============================================================

$credentials = Read-KeyValueFile $CredentialFile
$email = $credentials["email"]

if ([string]::IsNullOrWhiteSpace($email)) {
    throw "kakuyomu_credentials.txt に email がありません。"
}

if (-not (Test-Path -LiteralPath $PasswordFile)) {
    throw (
        "暗号化パスワードファイルが見つかりません。" +
        "`n$PasswordFile" +
        "`n先に kakuyomu_password_setup.ps1 を実行してください。"
    )
}

$encryptedPassword = Get-Content `
    -LiteralPath $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 を再実行してください。"
    )
}

$WorkConfigs = Read-WorkConfigs $ConfigFile

foreach ($workConfig in $WorkConfigs) {
    Ensure-Directory $workConfig.EpisodesDir
    Ensure-Directory $workConfig.HistoryEpisodesDir
    Ensure-Directory $workConfig.HistoryZipDir
    Ensure-Directory $workConfig.HistoryManifestsDir
}

# ============================================================
# URL・Webセッション
# ============================================================

$LoginApiUrl = "$BaseUrl/api/v2/auth/login"
$LoginPageUrl = (
    "$BaseUrl/auth/login/email" +
    "?location=%2F" +
    "&auth_platform=web"
)

$session = New-Object Microsoft.PowerShell.Commands.WebRequestSession

$commonHeaders = @{
    "User-Agent"      = $UserAgent
    "Accept-Language" = "ja,en-US;q=0.9,en;q=0.8"
}

# ============================================================
# ログイン
# ============================================================

function Invoke-KakuyomuLogin {
    $ptr = [IntPtr]::Zero
    $password = $null
    $loginBody = $null

    try {
        $ptr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR(
            $securePassword
        )

        $password = [Runtime.InteropServices.Marshal]::PtrToStringBSTR(
            $ptr
        )

        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
    }
}

# ============================================================
# ZIP取得
# 戻り値:
#   TempZipPath
#   OriginalFileName
#   WorkTitle
# ============================================================

function Get-KakuyomuBackupZip {
    param(
        [Parameter(Mandatory = $true)]
        [string]$WorkId
    )

    $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"
    }

    Write-Host ""
    Write-Host "csrf_token を取得..."

    $csrfMatch = [regex]::Match(
        $workResponse.Content,
        'name="csrf_token"\s+value="([^"]+)"',
        [System.Text.RegularExpressions.RegexOptions]::IgnoreCase
    )

    if (-not $csrfMatch.Success) {
        throw "csrf_token を取得できませんでした。作品ID: $WorkId"
    }

    $csrfToken = $csrfMatch.Groups[1].Value
    Write-Host "csrf_token: OK"

    Write-Host ""
    Write-Host "バックアップZIPを取得..."

    $tempZip = Join-Path `
        $env:TEMP `
        ("kakuyomu_backup_{0}_{1}.zip" -f $WorkId, [guid]::NewGuid().ToString("N"))

    $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 -LiteralPath $tempZip) {
            Remove-Item -LiteralPath $tempZip -Force
        }

        throw (
            "バックアップ取得失敗。" +
            "作品ID: $WorkId " +
            "Status: $($archiveResponse.StatusCode)"
        )
    }

    $contentType = $archiveResponse.Headers["Content-Type"]

    if ($contentType -notlike "application/zip*") {
        if (Test-Path -LiteralPath $tempZip) {
            Remove-Item -LiteralPath $tempZip -Force
        }

        throw (
            "ZIPではないレスポンスが返されました。" +
            "作品ID: $WorkId " +
            "Content-Type: $contentType"
        )
    }

    $originalFileName = $null
    $contentDisposition = $archiveResponse.Headers["Content-Disposition"]

    if ($contentDisposition) {
        $match = [regex]::Match(
            $contentDisposition,
            "filename\*=UTF-8''([^;]+)",
            [System.Text.RegularExpressions.RegexOptions]::IgnoreCase
        )

        if ($match.Success) {
            $originalFileName = [System.Uri]::UnescapeDataString(
                $match.Groups[1].Value
            )
        }

        if (-not $originalFileName) {
            $match = [regex]::Match(
                $contentDisposition,
                'filename="([^"]+)"',
                [System.Text.RegularExpressions.RegexOptions]::IgnoreCase
            )

            if ($match.Success) {
                $originalFileName = $match.Groups[1].Value
            }
        }
    }

    if (-not $originalFileName) {
        $originalFileName = "kakuyomu_$WorkId.zip"
    }

    if (-not (Test-Path -LiteralPath $tempZip)) {
        throw "一時ZIPが見つかりません: $tempZip"
    }

    $fileInfo = Get-Item -LiteralPath $tempZip

    if ($fileInfo.Length -le 0) {
        throw "取得したZIPのサイズが0バイトです: $tempZip"
    }

    Write-Host ("ZIP取得: OK ({0:N0} bytes)" -f $fileInfo.Length)

    return [PSCustomObject]@{
        TempZipPath      = $tempZip
        OriginalFileName = $originalFileName
        WorkTitle        = $workTitle
    }
}

# ============================================================
# ZIPをhistoryへ保存
# 毎回保存し、重複排除しない
# ============================================================

function Save-KakuyomuZipHistory {
    param(
        [Parameter(Mandatory = $true)]
        [string]$TempZipPath,

        [Parameter(Mandatory = $true)]
        [string]$WorkKey,

        [Parameter(Mandatory = $true)]
        [string]$HistoryZipDir
    )

    Ensure-Directory $HistoryZipDir

    $timestamp = Get-Date -Format "yyyyMMdd_HHmmss"
    $fileName = "{0}_kakuyomu_{1}.zip" -f $WorkKey, $timestamp
    $desiredPath = Join-Path $HistoryZipDir $fileName
    $historyPath = Get-UniquePath $desiredPath

    Move-Item `
        -LiteralPath $TempZipPath `
        -Destination $historyPath

    if (-not (Test-Path -LiteralPath $historyPath)) {
        throw "ZIP履歴保存後の確認に失敗しました: $historyPath"
    }

    $fileInfo = Get-Item -LiteralPath $historyPath

    if ($fileInfo.Length -le 0) {
        throw "保存されたZIPのサイズが0バイトです: $historyPath"
    }

    Write-Host ""
    Write-Host "ZIP履歴保存: OK"
    Write-Host "  $historyPath"

    return $historyPath
}

# ============================================================
# ZIP内のepisodeを1回だけ解析
# ============================================================

function Read-KakuyomuEpisodesFromZip {
    param(
        [Parameter(Mandatory = $true)]
        [string]$ZipPath
    )

    Write-Host ""
    Write-Host "ZIP内エピソードを解析..."

    $episodes = @()
    $zip = [System.IO.Compression.ZipFile]::OpenRead($ZipPath)

    try {
        foreach ($entry in $zip.Entries) {
            $fileName = [System.IO.Path]::GetFileName($entry.FullName)

            $numberMatch = [regex]::Match(
                $fileName,
                '^episode_(\d{4})\.txt$',
                [System.Text.RegularExpressions.RegexOptions]::IgnoreCase
            )

            if (-not $numberMatch.Success) {
                continue
            }

            $number = [int]$numberMatch.Groups[1].Value

            $stream = $entry.Open()
            $strictUtf8 = [System.Text.UTF8Encoding]::new($false, $true)
            $reader = [System.IO.StreamReader]::new(
                $stream,
                $strictUtf8,
                $true
            )

            try {
                $sourceText = $reader.ReadToEnd()
            }
            finally {
                $reader.Dispose()
                $stream.Dispose()
            }

            $normalized = Normalize-LineEndings $sourceText

            # タイトル
            $title = $null
            $titleMatch = [regex]::Match(
                $normalized,
                '【タイトル】[ \t]*\n([^\n]+)'
            )

            if ($titleMatch.Success) {
                $title = $titleMatch.Groups[1].Value.Trim()
            }
            else {
                $firstNonEmpty = (
                    $normalized -split "`n" |
                    Where-Object { -not [string]::IsNullOrWhiteSpace($_) } |
                    Select-Object -First 1
                )

                if ($firstNonEmpty) {
                    $title = $firstNonEmpty.Trim()
                }
                else {
                    $title = "第${number}話"
                }
            }

            # 更新日時
            $updatedAtRaw = $null
            $updatedAtFilePart = $null

            $updatedMatch = [regex]::Match(
                $normalized,
                '【更新日時】[ \t]*\n[ \t]*(\d{4})-(\d{2})-(\d{2})[ \t]+(\d{2}):(\d{2}):(\d{2})'
            )

            if ($updatedMatch.Success) {
                $updatedAtRaw = $updatedMatch.Groups[0].Value -replace '^【更新日時】[ \t]*\n[ \t]*', ''
                $updatedAtFilePart = (
                    "{0}{1}{2}_{3}{4}{5}" -f
                    $updatedMatch.Groups[1].Value,
                    $updatedMatch.Groups[2].Value,
                    $updatedMatch.Groups[3].Value,
                    $updatedMatch.Groups[4].Value,
                    $updatedMatch.Groups[5].Value,
                    $updatedMatch.Groups[6].Value
                )
            }

            # 本文ブロック
            $bodyHeaderMatch = [regex]::Match(
                $normalized,
                '(?m)^【本文(([0-90-9,,]+)行)】[ \t]*\n'
            )

            if (-not $bodyHeaderMatch.Success) {
                throw (
                    "${fileName}: 【本文(○行)】が見つかりません。" +
                    "本文の終端を行数で判定できません。"
                )
            }

            $declaredLinesText = $bodyHeaderMatch.Groups[1].Value `
                -replace ',', '' `
                -replace ',', ''

            $declaredLines = Convert-FullWidthInteger `
                $declaredLinesText

            $remainingText = $normalized.Substring(
                $bodyHeaderMatch.Index + $bodyHeaderMatch.Length
            )

            $remainingLines = @($remainingText -split "`n")

            if ($remainingLines.Count -lt $declaredLines) {
                throw (
                    "${fileName}: 【本文($declaredLines" + "行)】とありますが、" +
                    "その後には$($remainingLines.Count)行しかありません。"
                )
            }

            if ($declaredLines -eq 0) {
                $rawBody = ""
            }
            else {
                $rawBody = ($remainingLines[0..($declaredLines - 1)] -join "`n")
            }

            $rawBody = $rawBody.Trim()

            # 【文字数】
            $declaredChars = $null

            $charPatterns = @(
                '【文字数】[ \t]*\n[ \t]*([0-90-9,,]+)[ \t]*文字?',
                '【文字数】[ \t]*([0-90-9,,]+)[ \t]*文字?',
                '【文字数([ \t]*([0-90-9,,]+)[ \t]*文字?[ \t]*)】'
            )

            foreach ($pattern in $charPatterns) {
                $charMatch = [regex]::Match($normalized, $pattern)

                if ($charMatch.Success) {
                    $declaredChars = Convert-FullWidthInteger `
                        $charMatch.Groups[1].Value
                    break
                }
            }

            $withoutNewlines = $rawBody.Replace("`n", "")
            $actualCharsMax = Get-UnicodeCodePointCount $withoutNewlines

            $withoutWhitespace = [regex]::Replace(
                $rawBody,
                '[ \t \n]+',
                ''
            )
            $actualCharsMin = Get-UnicodeCodePointCount $withoutWhitespace

            if ($null -eq $declaredChars) {
                Write-Warning (
                    "${fileName}: 【文字数】を取得できないため、" +
                    "文字数照合をスキップします。"
                )
            }
            elseif (
                -not (
                    $actualCharsMin -le $declaredChars -and
                    $declaredChars -le $actualCharsMax
                )
            ) {
                if ($declaredChars -lt $actualCharsMin) {
                    $nearestActual = $actualCharsMin
                }
                else {
                    $nearestActual = $actualCharsMax
                }

                if ($declaredChars -eq 0) {
                    if ($nearestActual -eq 0) {
                        $diffRatio = 0.0
                    }
                    else {
                        $diffRatio = 1.0
                    }
                }
                else {
                    $diffRatio = (
                        [math]::Abs($nearestActual - $declaredChars) /
                        $declaredChars
                    )
                }

                if ($diffRatio -ge $CharCountWarningThreshold) {
                    Write-Warning (
                        "${fileName}: 文字数が大きくずれています。" +
                        "【文字数】=$declaredChars, " +
                        "抽出本文の妥当範囲=$actualCharsMin$actualCharsMax, " +
                        ("差={0:P1}" -f $diffRatio)
                    )
                }
            }

            # 本文先頭の重複タイトル除去
            $body = $rawBody
            $bodyLines = @($body -split "`n")

            while (
                $bodyLines.Count -gt 0 -and
                [string]::IsNullOrWhiteSpace($bodyLines[0])
            ) {
                if ($bodyLines.Count -eq 1) {
                    $bodyLines = @()
                }
                else {
                    $bodyLines = @($bodyLines[1..($bodyLines.Count - 1)])
                }
            }

            if ($bodyLines.Count -gt 0) {
                $first = $bodyLines[0].Trim()

                if (
                    $first -eq $title -or
                    $first -match '^第\d+話[ \s].+'
                ) {
                    if ($bodyLines.Count -eq 1) {
                        $bodyLines = @()
                    }
                    else {
                        $bodyLines = @($bodyLines[1..($bodyLines.Count - 1)])
                    }
                }
            }

            $body = ($bodyLines -join "`n").Trim()

            if ([string]::IsNullOrEmpty($body)) {
                $bodyLineCount = 0
            }
            else {
                $bodyLineCount = @($body -split "`n").Count
            }

            # Colab版と同じepisodes出力形式
            $processedText = (
                "【タイトル】`n" +
                "$title`n`n" +
                "【本文($bodyLineCount" + "行)】`n`n" +
                "$body`n"
            )

            $processedSha256 = Get-StringSha256 $processedText

            $episodes += [PSCustomObject]@{
                FileName          = $fileName
                Number            = $number
                Title             = $title
                UpdatedAtRaw      = $updatedAtRaw
                UpdatedAtFilePart = $updatedAtFilePart
                SourceChars       = Get-UnicodeCodePointCount $normalized
                BodyChars         = Get-UnicodeCodePointCount $body
                BodyLines         = $bodyLineCount
                DeclaredLines     = $declaredLines
                DeclaredChars     = $declaredChars
                ProcessedText     = $processedText
                ProcessedSha256   = $processedSha256
            }
        }
    }
    finally {
        $zip.Dispose()
    }

    $episodes = @($episodes | Sort-Object Number)

    if ($episodes.Count -eq 0) {
        throw "ZIP内に episode_XXXX.txt 形式の対象ファイルがありません。"
    }

    Write-Host "ZIP解析: OK"
    Write-Host "対象話数: $($episodes.Count)"

    return $episodes
}

# ============================================================
# episodes系
# 最新正本だけを保持
# ============================================================

function Update-CurrentEpisodes {
    param(
        [Parameter(Mandatory = $true)]
        [array]$Episodes,

        [Parameter(Mandatory = $true)]
        [string]$EpisodesDir,

        [Parameter(Mandatory = $true)]
        [string]$SourceZipPath
    )

    Ensure-Directory $EpisodesDir

    Write-Host ""
    Write-Host "episodes を最新状態へ更新..."

    # 先に一時出力へ全話書き出し、成功してから入れ替える
    $stagingDir = Join-Path `
        $env:TEMP `
        ("kakuyomu_episodes_" + [guid]::NewGuid().ToString("N"))

    Ensure-Directory $stagingDir

    try {
        foreach ($episode in $Episodes) {
            $outputPath = Join-Path `
                $stagingDir `
                ("episode_{0:D4}.txt" -f $episode.Number)

            Write-Utf8NoBomText `
                -Path $outputPath `
                -Text $episode.ProcessedText
        }

        $stagedFiles = @(
            Get-ChildItem `
                -LiteralPath $stagingDir `
                -File |
            Where-Object { $_.Name -match '^episode_\d{4}\.txt$' }
        )

        if ($stagedFiles.Count -ne $Episodes.Count) {
            throw (
                "episodes一時生成数が一致しません。" +
                "期待=$($Episodes.Count), 実際=$($stagedFiles.Count)"
            )
        }

        # 既存正本を削除
        $oldFiles = @(
            Get-ChildItem `
                -LiteralPath $EpisodesDir `
                -File |
            Where-Object { $_.Name -match '^episode_\d{4}\.txt$' }
        )

        foreach ($oldFile in $oldFiles) {
            Remove-Item -LiteralPath $oldFile.FullName -Force
        }

        # 最新版を配置
        foreach ($stagedFile in $stagedFiles) {
            $destination = Join-Path $EpisodesDir $stagedFile.Name

            Move-Item `
                -LiteralPath $stagedFile.FullName `
                -Destination $destination
        }

        Write-CurrentEpisodesManifest `
            -Episodes $Episodes `
            -EpisodesDir $EpisodesDir `
            -SourceZipPath $SourceZipPath

        Write-Host "episodes更新: OK"
        Write-Host "更新話数: $($Episodes.Count)"
    }
    finally {
        if (Test-Path -LiteralPath $stagingDir) {
            Remove-Item -LiteralPath $stagingDir -Recurse -Force
        }
    }
}

function Write-CurrentEpisodesManifest {
    param(
        [Parameter(Mandatory = $true)]
        [array]$Episodes,

        [Parameter(Mandatory = $true)]
        [string]$EpisodesDir,

        [Parameter(Mandatory = $true)]
        [string]$SourceZipPath
    )

    $manifestPath = Join-Path $EpisodesDir "manifest.csv"
    $tempManifest = "$manifestPath.tmp"
    $processedAt = Get-Date
    $sourceZipInfo = Get-Item -LiteralPath $SourceZipPath

    $rows = foreach ($episode in $Episodes) {
        [PSCustomObject]@{
            number                 = $episode.Number
            title                  = $episode.Title
            file_name              = $episode.FileName
            source_chars           = $episode.SourceChars
            body_chars             = $episode.BodyChars
            removed_chars          = ($episode.SourceChars - $episode.BodyChars)
            body_lines             = $episode.BodyLines
            source_zip_name        = $sourceZipInfo.Name
            source_zip_modified_at = $sourceZipInfo.LastWriteTime.ToString("yyyy-MM-ddTHH:mm:sszzz")
            processed_at           = $processedAt.ToString("yyyy-MM-ddTHH:mm:sszzz")
        }
    }

    $rows |
        Export-Csv `
            -LiteralPath $tempManifest `
            -NoTypeInformation `
            -Encoding UTF8

    Move-Item `
        -LiteralPath $tempManifest `
        -Destination $manifestPath `
        -Force
}

# ============================================================
# history系
# 積み上げ。削除しない。
# 重複判定はmanifestではなく既存実ファイルのSHA-256。
# ============================================================

function Test-HistoryContainsHash {
    param(
        [Parameter(Mandatory = $true)]
        [string]$EpisodeHistoryDir,

        [Parameter(Mandatory = $true)]
        [string]$TargetHash
    )

    if (-not (Test-Path -LiteralPath $EpisodeHistoryDir)) {
        return $false
    }

    $historyFiles = @(
        Get-ChildItem `
            -LiteralPath $EpisodeHistoryDir `
            -File |
        Where-Object { $_.Extension -ieq ".txt" }
    )

    foreach ($historyFile in $historyFiles) {
        $existingHash = (
            Get-FileHash `
                -LiteralPath $historyFile.FullName `
                -Algorithm SHA256
        ).Hash.ToLowerInvariant()

        if ($existingHash -eq $TargetHash.ToLowerInvariant()) {
            return $true
        }
    }

    return $false
}

function Save-EpisodeHistory {
    param(
        [Parameter(Mandatory = $true)]
        [array]$Episodes,

        [Parameter(Mandatory = $true)]
        [string]$HistoryEpisodesDir,

        [Parameter(Mandatory = $true)]
        [string]$HistoryManifestsDir,

        [Parameter(Mandatory = $true)]
        [string]$SourceZipPath
    )

    Ensure-Directory $HistoryEpisodesDir
    Ensure-Directory $HistoryManifestsDir

    Write-Host ""
    Write-Host "history/episodes を更新..."

    $addedCount = 0
    $skippedCount = 0
    $newManifestRows = @()
    $observedAt = Get-Date
    $sourceZipName = (Get-Item -LiteralPath $SourceZipPath).Name

    foreach ($episode in $Episodes) {
        $episodeDirName = "episode_{0:D4}" -f $episode.Number
        $episodeHistoryDir = Join-Path `
            $HistoryEpisodesDir `
            $episodeDirName

        Ensure-Directory $episodeHistoryDir

        $alreadyExists = Test-HistoryContainsHash `
            -EpisodeHistoryDir $episodeHistoryDir `
            -TargetHash $episode.ProcessedSha256

        if ($alreadyExists) {
            $skippedCount++
            continue
        }

        if ($episode.UpdatedAtFilePart) {
            $fileBase = (
                "episode_{0:D4}_rev_{1}" -f
                $episode.Number,
                $episode.UpdatedAtFilePart
            )
        }
        else {
            $fallbackTimestamp = $observedAt.ToString("yyyyMMdd_HHmmss")
            $fileBase = (
                "episode_{0:D4}_rev_observed_{1}" -f
                $episode.Number,
                $fallbackTimestamp
            )

            Write-Warning (
                "$($episode.FileName): 【更新日時】を取得できなかったため、" +
                "履歴ファイル名に観測日時を使用します。"
            )
        }

        $desiredPath = Join-Path `
            $episodeHistoryDir `
            ($fileBase + ".txt")

        $historyPath = $desiredPath

        if (Test-Path -LiteralPath $historyPath) {
            # 同名だが別ハッシュなら短縮SHAを付ける
            $historyPath = Join-Path `
                $episodeHistoryDir `
                (
                    "{0}_{1}.txt" -f
                    $fileBase,
                    $episode.ProcessedSha256.Substring(0, 8)
                )

            $historyPath = Get-UniquePath $historyPath
        }

        Write-Utf8NoBomText `
            -Path $historyPath `
            -Text $episode.ProcessedText

        $savedHash = (
            Get-FileHash `
                -LiteralPath $historyPath `
                -Algorithm SHA256
        ).Hash.ToLowerInvariant()

        if ($savedHash -ne $episode.ProcessedSha256.ToLowerInvariant()) {
            Remove-Item -LiteralPath $historyPath -Force

            throw (
                "history保存後のSHA-256確認に失敗しました。" +
                "`n$historyPath"
            )
        }

        $newManifestRows += [PSCustomObject]@{
            episode_number = $episode.Number
            title          = $episode.Title
            updated_at     = $episode.UpdatedAtRaw
            sha256         = $episode.ProcessedSha256
            history_file   = (
                Join-Path `
                    $episodeDirName `
                    ([System.IO.Path]::GetFileName($historyPath))
            )
            source_zip     = $sourceZipName
            observed_at    = $observedAt.ToString("yyyy-MM-ddTHH:mm:sszzz")
        }

        $addedCount++
    }

    if ($newManifestRows.Count -gt 0) {
        $historyManifestPath = Join-Path `
            $HistoryManifestsDir `
            "episode_history.csv"

        if (Test-Path -LiteralPath $historyManifestPath) {
            $newManifestRows |
                Export-Csv `
                    -LiteralPath $historyManifestPath `
                    -NoTypeInformation `
                    -Encoding UTF8 `
                    -Append
        }
        else {
            $newManifestRows |
                Export-Csv `
                    -LiteralPath $historyManifestPath `
                    -NoTypeInformation `
                    -Encoding UTF8
        }
    }

    Write-Host "history追加: $addedCount"
    Write-Host "history重複スキップ: $skippedCount"

    return [PSCustomObject]@{
        Added   = $addedCount
        Skipped = $skippedCount
    }
}

# ============================================================
# 作品単位の完全処理
# ============================================================

function Invoke-KakuyomuWorkProcess {
    param(
        [Parameter(Mandatory = $true)]
        $WorkConfig
    )

    $download = $null
    $zipHistoryPath = $null

    try {
        $download = Get-KakuyomuBackupZip `
            -WorkId $WorkConfig.WorkId

        $zipHistoryPath = Save-KakuyomuZipHistory `
            -TempZipPath $download.TempZipPath `
            -WorkKey $WorkConfig.WorkKey `
            -HistoryZipDir $WorkConfig.HistoryZipDir

        # 元ZIP解析はここで1回だけ
        $episodes = Read-KakuyomuEpisodesFromZip `
            -ZipPath $zipHistoryPath

        # 分岐1: 現行正本
        Update-CurrentEpisodes `
            -Episodes $episodes `
            -EpisodesDir $WorkConfig.EpisodesDir `
            -SourceZipPath $zipHistoryPath

        # 分岐2: 履歴分析データ
        $historyResult = Save-EpisodeHistory `
            -Episodes $episodes `
            -HistoryEpisodesDir $WorkConfig.HistoryEpisodesDir `
            -HistoryManifestsDir $WorkConfig.HistoryManifestsDir `
            -SourceZipPath $zipHistoryPath

        return [PSCustomObject]@{
            WorkId       = $WorkConfig.WorkId
            WorkKey      = $WorkConfig.WorkKey
            WorkTitle    = $download.WorkTitle
            Success      = $true
            ZipPath      = $zipHistoryPath
            EpisodeCount = $episodes.Count
            HistoryAdded = $historyResult.Added
            HistorySkip  = $historyResult.Skipped
            Error        = ""
        }
    }
    finally {
        if (
            $download -and
            $download.TempZipPath -and
            (Test-Path -LiteralPath $download.TempZipPath)
        ) {
            Remove-Item -LiteralPath $download.TempZipPath -Force
        }
    }
}

# ============================================================
# 実行
# ============================================================

Invoke-KakuyomuLogin

$successCount = 0
$failureCount = 0
$results = @()

foreach ($workConfig in $WorkConfigs) {
    try {
        $result = Invoke-KakuyomuWorkProcess `
            -WorkConfig $workConfig

        $results += $result
        $successCount++
    }
    catch {
        Write-Host ""
        Write-Host "========================================"
        Write-Host "処理失敗"
        Write-Host "========================================"
        Write-Host "作品ID: $($workConfig.WorkId)"
        Write-Host "識別名: $($workConfig.WorkKey)"
        Write-Host "エラー: $($_.Exception.Message)"

        $results += [PSCustomObject]@{
            WorkId       = $workConfig.WorkId
            WorkKey      = $workConfig.WorkKey
            WorkTitle    = ""
            Success      = $false
            ZipPath      = ""
            EpisodeCount = 0
            HistoryAdded = 0
            HistorySkip  = 0
            Error        = $_.Exception.Message
        }

        $failureCount++
    }
}

# ============================================================
# 最終結果
# ============================================================

Write-Host ""
Write-Host "========================================"
Write-Host "全作品処理完了"
Write-Host "========================================"
Write-Host ""

foreach ($result in $results) {
    if ($result.Success) {
        Write-Host "[OK] $($result.WorkKey) ($($result.WorkId))"

        if ($result.WorkTitle) {
            Write-Host "     作品名: $($result.WorkTitle)"
        }

        Write-Host "     ZIP: $($result.ZipPath)"
        Write-Host "     episodes: $($result.EpisodeCount)話"
        Write-Host "     history追加: $($result.HistoryAdded)"
        Write-Host "     history重複: $($result.HistorySkip)"
    }
    else {
        Write-Host "[NG] $($result.WorkKey) ($($result.WorkId))"
        Write-Host "     $($result.Error)"
    }

    Write-Host ""
}

Write-Host "成功: $successCount"
Write-Host "失敗: $failureCount"
Write-Host ""

# ============================================================
# 認証情報を変数から消去
# ============================================================

$securePassword = $null
$encryptedPassword = $null
$email = $null
$credentials = $null

まとめ

最初は「カクヨムのバックアップZIPを自動でGoogle Driveへ保存できればよい」と考えていた。

しかし実際にAIを使いながら小説を継続的に書いていると、

  • 現在の本文を機械的に参照したい
  • 昔の本文も残したい
  • 毎回全話をコピーして履歴を増やしたくない
  • どの版が変わったのか後から追いたい

という要求が出てきた。

その結果、単なるバックアップ取得スクリプトから、

「カクヨムを原本にしたまま、現在の本文と推敲履歴をローカル/Google Drive側へ同期する仕組み」

に近いものになった。

Gitで小説本文を管理する方法も考えたが、カクヨム側を公開原本として扱いたかったため、今回はファイル単位のSHA-256履歴方式にしている。

この仕組みを実際に使っている作品

今回紹介した仕組みは、以下のカクヨム作品の制作・分析で実際に使用しています。

アークリーチャーズ

頭のない異形が歩く世界で、頭部を外して戦う汎用人型兵器カイを中心に描くSF小説です。
AIを使いながら、設定管理・推敲・バックアップ・読者動向の分析まで含めて制作しています。

https://kakuyomu.jp/works/822139842645600859
コンセプトアート.png

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

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

0
0
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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?