以前、カクヨムの小説バックアップZIPをPowerShellで自動取得し、Google Driveなどの指定フォルダへ保存する方法を書いた。
- Google Colabでカクヨムの小説バックアップZIPから各話本文を抽出する
- PowerShellでカクヨムの小説バックアップZIPを自動取得してGoogle Driveなどの指定フォルダに保存する
その後、実際に小説を書き続ける中で、単にZIPを保存するだけでは不便になった。
欲しくなったのは次の2種類のデータだった。
- 現在の各話本文:AIや他のツールから常に最新版を参照するための正本
- 過去の各話本文:どのように推敲・修正されたか後から追える履歴
そこで、バックアップZIP取得処理を拡張し、
- カクヨムへログイン
- 複数作品のバックアップZIPを取得
- ZIP自体を日時付きで保存
- ZIPから各話本文を抽出して
episodesを最新版へ更新 - 各話のSHA-256を計算
- 過去に保存した本文と同一なら履歴追加をスキップ
- 内容が変わっている場合だけ
history/episodesに新しい版を追加 - 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が残っていれば後から再解析できるためである。
episodes と history の役割を分ける
この構成にした一番大きな理由は、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

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