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でカクヨムのPV・通知・フォロワーなどを定期取得して、AIで分析できるようにした

1
Last updated at Posted at 2026-08-28

カクヨムで作品を公開していると、PVが増えたときに「何が原因だったのか」を調べたくなることがあります。

最初は、カクヨムの作品管理画面をブラウザで開き、統計画面をPDFとして保存していました。

PDFをChatGPTに渡せば、

  • どの話が読まれているか
  • 前回からどれだけPVが増えたか
  • 第1話からどこまで読み進められているか

といった分析はできます。

ただ、この方法には問題がありました。

PDF保存では時系列データが揃わない

PDFを保存するのは、基本的に「気になったとき」です。

そのため、

「昨日と今日でどう変わったか」

「近況ノートを投稿した前後でPVが変わったか」

「作品フォロワーが増えた時期とPVの増加時期は一致しているか」

といったことを後から調べようとしても、必要な時点の資料が残っているとは限りません。

時系列で分析するなら、一定間隔で同じデータを取得しておいた方が扱いやすくなります。

そこで、PowerShellでカクヨムの情報を定期取得するスクリプトを作りました。

手動実行

普段の手動実行では、PowerShellスクリプトを直接入力するのではなく、同じフォルダに置いた kakuyomu_stats_manual.cmd を実行しています。

@echo off
setlocal
cd /d "%~dp0"

powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0kakuyomu_stats.ps1" -Mode Manual
set "EXITCODE=%ERRORLEVEL%"

echo.
echo ----------------------------------------
if "%EXITCODE%"=="0" (
    echo Completed successfully.
) else (
    echo Failed. exit code=%EXITCODE%
)
echo ----------------------------------------
pause
endlocal ^& exit /b %EXITCODE%

Manual モードでは、前回取得からの経過時間に関係なく、その場で取得します。

また、処理終了後にウィンドウを閉じず、正常終了したかエラーになったかを確認できるようにしています。

PowerShell本体を直接実行する場合は、次の形でも実行できます。

powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\kakuyomu_stats.ps1 -Mode Manual

Windows タスク スケジューラから定期実行する

定期実行では ModeScheduled を指定します。

PowerShell側では、

param(
    [ValidateSet("Manual", "Scheduled")]
    [string]$Mode = "Manual"
)

としており、Scheduled モードの場合だけ前回の正常取得時刻を確認します。

前回成功から interval_hours 未満なら、カクヨムへアクセスせず処理を終了します。

Windows タスク スケジューラでは、「プログラム/スクリプト」と「引数の追加」を分けて指定します。

プログラム/スクリプトには、

powershell.exe

を指定します。

引数の追加には、実際の kakuyomu_stats.ps1 のフルパスを使って、

-NoProfile -ExecutionPolicy Bypass -File "C:\実際のパス\kakuyomu_stats.ps1" -Mode Scheduled

のように指定します。

必要に応じて「開始(オプション)」には、スクリプトを置いているフォルダを指定します。

手動実行とタスク スケジューラでは、同じPowerShellスクリプトを使い、Mode だけを切り替えています。

手動実行
kakuyomu_stats_manual.cmd
        ↓
-Mode Manual
        ↓
取得間隔を無視して即時実行

タスク スケジューラ
        ↓
powershell.exe
        ↓
-Mode Scheduled
        ↓
前回成功時刻を確認
        ↓
interval_hours 未満ならスキップ

PVだけ取っても原因は分からない

最初の目的はPVの自動保存でした。

しかし、実際に分析することを考えると、PVだけでは情報が足りません。

たとえば、ある日にPVが増えたとしても、

  • 作者フォロワーが増えた
  • 作品フォロワーが増えた
  • レビューが付いた
  • 近況ノートに反応があった
  • 誰かから通知が来た
  • 自分が別のユーザーや作品をフォローした

といった情報が残っていなければ、その前後関係を確認できません。

そこで、取得対象を増やしました。

現在は主に以下を取得しています。

/my/works
/my/works/{workId}

/users/{user}/news
/notifications/notices_panel

/users/{user}/following_users
/users/{user}/following_works
/users/{user}/followers

/works/{workId}/followers
/works/{workId}/reviews

/my/reactions

また、近況ノートについては一覧だけでなく、直近のものについて個別ページも取得し、反応の情報を保存しています。

保存するデータ

作品管理画面からは、作品ごとに次のような情報を取得しています。

  • 総PV
  • 今日のPV
  • 今週のPV
  • 今月のPV
  • 公開話数
  • 各話PV
  • 日別PV
  • 作品の状態
  • 更新日時など

それ以外にも、

  • 近況ノート
  • 通知
  • フォローしているユーザー
  • フォローしている作品
  • 作者フォロワー
  • 作品フォロワー
  • レビュー
  • 読者からの反応
  • 近況ノートへの反応

を保存しています。

latestとhistoryを分ける

保存先は次のように分けました。

output_dir/
├─ latest/
├─ history/
├─ raw/
├─ state/
└─ logs/

latest

現在の状態です。

work_stats_latest.csv
episode_stats_latest.csv
daily_pv_latest.csv
my_works_latest.csv
news_latest.csv
notifications_latest.csv
following_users_latest.csv
following_works_latest.csv
followers_latest.csv
work_followers_latest.csv
work_reviews_latest.csv
reactions_latest.csv
news_reactions_latest.csv
relationship_events_latest.csv

AIに現在の状態を分析させる場合は、基本的にここだけ読ませます。

history

取得するたびに履歴を追加します。

たとえば、

work_stats.csv
episode_stats_YYYYMM.csv
daily_pv.csv
notifications.csv
following_users.csv
following_works.csv
followers.csv
work_followers.csv
work_reviews.csv
reactions.csv
news_reactions.csv
relationship_events.csv
runs.csv

などがあります。

時系列で変化を分析したい場合はこちらを使います。

raw

取得したHTMLそのものです。

raw/
└─ 20260829_080000/
   ├─ my_works.html
   ├─ work_xxx.html
   ├─ notices_panel.html
   ├─ following_users.html
   └─ ...

CSVに変換した後もHTMLを残しています。

後から、

「この情報も取得しておけばよかった」

となった場合でも、過去のHTMLを再解析できる可能性があるためです。

state

前回の正常取得日時を保存します。

Scheduledモードでは、この値を見て取得間隔を判定します。

logs

実行ログです。

タスク スケジューラから動かす場合、画面を見ていないので、実行結果を後から確認できるようにしています。

PowerShellには分析させない

スクリプト内で、

「この近況ノートが原因でPVが増えた」

「このフォロワー増加によってアクセスが増えた」

といった判断はしていません。

PowerShellが担当するのは、

取得
↓
解析
↓
整形
↓
整合性確認
↓
保存

までです。

分析はChatGPTなどのAI側に任せます。

たとえば私は、Google Driveへ同期した統計フォルダをChatGPTから読み、

latestを使って現在のPVを分析して。

時系列比較が必要ならhistoryを参照して。

PVの変化とフォロワー、レビュー、近況ノート、通知などの
時間的な関係も確認して。

数値から確認できる事実と、AIによる推測は分けて。

というような使い方をしています。

分析ロジックをPowerShellへ固定しないため、同じデータを使って別の観点から何度でも分析できます。

なぜHTMLも残すのか

WebページのHTML構造は変更される可能性があります。

このスクリプトもHTMLを正規表現などで解析しているため、カクヨム側のHTMLが変更されれば動かなくなる可能性があります。

一方、取得時点のHTMLが残っていれば、

  • パーサーの修正
  • 新しい項目の追加
  • 過去データの再解析

ができます。

CSVだけ保存するより容量は増えますが、今回は後からAIで分析するための資料を残すことを優先しました。

設定ファイル

認証情報と取得対象はスクリプト本体から分離しています。

設定イメージは次のような形です。

output_dir=C:\path\to\analytics\kakuyomu
interval_hours=11
news_user=ユーザーID

work=作品ID|識別名|表示名
work=作品ID|識別名|表示名

作品を追加する場合は work= を追加します。

パスワードは平文で直接スクリプトへ書かず、別ファイルに暗号化して保存しています。

注意点

このスクリプトは、カクヨムの公開APIを利用したものではなく、ブラウザで表示されるページを取得して解析しています。

そのため、HTML構造が変更された場合は修正が必要です。

また、自動取得を行う以上、短時間に大量のリクエストを送らないようにする必要があります。

今回のスクリプトでも取得間隔を設け、必要なページだけを取得するようにしています。

利用する場合は、サイト側の利用条件や現在の仕様を確認したうえで、自分のアカウント・自分の作品を対象に、サーバーへ過度な負荷をかけない範囲で使用してください。

まとめ

最初にやっていたのは、

カクヨムをブラウザで開く
↓
統計画面をPDF保存
↓
ChatGPTへ渡す

という方法でした。

現在は、

Windows タスク スケジューラ
↓
PowerShell
↓
カクヨムから一定間隔で情報取得
↓
CSV + HTMLとして保存
↓
Google Driveへ同期
↓
ChatGPTで分析

という形になっています。

PVだけでなく、その周辺で起きたことも一緒に保存するようにしたため、

「PVが増えた」

だけでなく、

「その前後に何が起きていたのか」

まで後から確認できるデータが残るようになりました。

AIに分析を任せる場合でも、過去のデータが存在しなければ分析できません。

そのため、分析方法を考える前に、まず時系列データを残せる仕組みを作ることにしました。

以下が現在使用しているPowerShellスクリプトです。

※カクヨム側のHTML変更などによって動作しなくなる可能性があります。

#requires -Version 5.1
<#[
.SYNOPSIS
    カクヨム統計の定点取得スクリプト。

.DESCRIPTION
    既存の kakuyomu_credentials.txt / kakuyomu_password.dat を流用してログインし、
    以下を取得します。
      - /my/works
      - /my/works/{workId} (設定された全作品)
      - /users/{news_user}/news
      - /notifications/notices_panel
      - /users/{news_user}/following_users
      - /users/{news_user}/following_works
      - /users/{news_user}/followers
      - /works/{workId}/followers
      - /works/{workId}/reviews
      - /my/reactions
      - 近況ノート個別ページ(一覧に出ている最新20件)

    Scheduled モードでは、前回「成功」から interval_hours 未満なら取得をスキップします。
    Manual モードでは、間隔に関係なく即時取得します。

    取得したHTMLは raw に保存し、AI分析用のCSVを history/latest に保存します。
    出力先がGoogle Drive for desktopなどのマウント待ちになる場合に備え、
    出力先ドライブを最大5分待ってから処理を開始します。
    全ページ取得・解析・保存に成功したときだけ last_success.txt を更新します。

.EXAMPLE
    # 手動実行(11時間制限を無視して実行)
    powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\kakuyomu_stats.ps1

.EXAMPLE
    # タスク スケジューラ用(前回成功から11時間未満ならスキップ)
    powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\kakuyomu_stats.ps1 -Mode Scheduled
#>

[CmdletBinding()]
param(
    [ValidateSet("Manual", "Scheduled")]
    [string]$Mode = "Manual"
)

$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_stats_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"
$Utf8NoBom = New-Object System.Text.UTF8Encoding($false)
$NewsDetailLimit = 20

# Google Drive for desktop 等のマウント待ち設定
$OutputMountWaitSeconds = 300
$OutputMountPollSeconds = 10

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

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 Get-OutputMountProbePath {
    param([Parameter(Mandatory = $true)][string]$OutputDir)

    # H:\マイドライブ\stories\... の場合は H:\マイドライブ を待つ。
    # これなら、Google Drive がマウント済みであれば、その下の出力フォルダ自体が
    # まだ存在しない初回実行でも Ensure-Directory で作成できる。
    $root = [System.IO.Path]::GetPathRoot($OutputDir)
    if ([string]::IsNullOrWhiteSpace($root)) {
        return $OutputDir
    }

    if ($root -match '^[A-Za-z]:\\$') {
        $relative = $OutputDir.Substring($root.Length).TrimStart('\', '/')
        if (-not [string]::IsNullOrWhiteSpace($relative)) {
            $firstSegment = ($relative -split '[\\/]', 2)[0]
            if (-not [string]::IsNullOrWhiteSpace($firstSegment)) {
                return (Join-Path $root $firstSegment)
            }
        }
    }

    # UNC パス等はルートが到達可能になるまで待つ。
    return $root
}

function Wait-ForOutputMount {
    param(
        [Parameter(Mandatory = $true)][string]$OutputDir,
        [int]$TimeoutSeconds = 300,
        [int]$PollSeconds = 10
    )

    if ($TimeoutSeconds -lt 1) { $TimeoutSeconds = 1 }
    if ($PollSeconds -lt 1) { $PollSeconds = 1 }

    $probePath = Get-OutputMountProbePath -OutputDir $OutputDir

    if (Test-Path -LiteralPath $probePath -ErrorAction SilentlyContinue) {
        return
    }

    $started = Get-Date
    Write-Host "出力先がまだ利用できません。マウントを待機します: $probePath"

    while ($true) {
        $elapsedSeconds = ((Get-Date) - $started).TotalSeconds
        if ($elapsedSeconds -ge $TimeoutSeconds) {
            throw "出力先ドライブを利用できません。$TimeoutSeconds 秒待機しましたが、Google Drive等がマウントされませんでした: $probePath"
        }

        $remaining = [math]::Max(0, [int][math]::Ceiling($TimeoutSeconds - $elapsedSeconds))
        $sleepSeconds = [math]::Min($PollSeconds, $remaining)

        Write-Host ("出力先待機中... {0}(残り最大 {1} 秒)" -f $probePath, $remaining)
        Start-Sleep -Seconds $sleepSeconds

        if (Test-Path -LiteralPath $probePath -ErrorAction SilentlyContinue) {
            $waited = [int][math]::Round(((Get-Date) - $started).TotalSeconds)
            Write-Host "出力先を確認しました。待機時間: $waited 秒"
            return
        }
    }
}

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) -or $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
}

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

    $raw = Read-KeyValueFile $Path

    $outputDir = [string]$raw["output_dir"]
    if ([string]::IsNullOrWhiteSpace($outputDir)) {
        throw "kakuyomu_stats_config.txt に output_dir がありません。"
    }

    $intervalHours = 11.0
    if ($raw.ContainsKey("interval_hours")) {
        if (-not [double]::TryParse([string]$raw["interval_hours"], [ref]$intervalHours)) {
            throw "interval_hours が数値ではありません: $($raw['interval_hours'])"
        }
    }
    if ($intervalHours -lt 1) {
        throw "interval_hours は1以上にしてください。"
    }

    $newsUser = [string]$raw["news_user"]
    if ([string]::IsNullOrWhiteSpace($newsUser)) {
        throw "kakuyomu_stats_config.txt に news_user がありません。"
    }

    $workValues = @()
    if ($raw.ContainsKey("work")) {
        if ($raw["work"] -is [System.Array]) { $workValues = @($raw["work"]) }
        else { $workValues = @([string]$raw["work"]) }
    }
    if ($workValues.Count -eq 0) {
        throw "kakuyomu_stats_config.txt に work 設定がありません。"
    }

    $works = @()
    foreach ($value in $workValues) {
        $parts = [string]$value -split "\|", 3
        if ($parts.Count -ne 3) {
            throw "work の形式が不正です: $value`n形式: work=作品ID|識別名|表示名"
        }

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

        if ($workId -notmatch '^\d+$') { throw "作品IDが不正です: $workId" }
        if ([string]::IsNullOrWhiteSpace($workKey)) { throw "作品識別名が空です: $value" }
        if ([string]::IsNullOrWhiteSpace($displayName)) { throw "作品表示名が空です: $value" }

        $works += [PSCustomObject]@{
            WorkId = $workId
            WorkKey = $workKey
            DisplayName = $displayName
        }
    }

    return [PSCustomObject]@{
        OutputDir = $outputDir
        IntervalHours = $intervalHours
        NewsUser = $newsUser
        Works = $works
    }
}

function Write-Utf8NoBomText {
    param(
        [Parameter(Mandatory = $true)][string]$Path,
        [Parameter(Mandatory = $true)][AllowEmptyString()][string]$Text
    )
    [System.IO.File]::WriteAllText($Path, $Text, $Utf8NoBom)
}

function Append-CsvRows {
    param(
        [Parameter(Mandatory = $true)][string]$Path,
        [Parameter(Mandatory = $true)][AllowEmptyCollection()][object[]]$Rows
    )

    if ($Rows.Count -eq 0) { return }
    $dir = Split-Path -Parent $Path
    Ensure-Directory $dir

    if (Test-Path -LiteralPath $Path) {
        $lines = @($Rows | ConvertTo-Csv -NoTypeInformation)
        if ($lines.Count -gt 1) {
            $writer = New-Object System.IO.StreamWriter($Path, $true, $Utf8NoBom)
            try {
                for ($i = 1; $i -lt $lines.Count; $i++) {
                    $writer.WriteLine([string]$lines[$i])
                }
            }
            finally {
                $writer.Dispose()
            }
        }
    }
    else {
        $text = ($Rows | ConvertTo-Csv -NoTypeInformation) -join "`r`n"
        Write-Utf8NoBomText -Path $Path -Text ($text + "`r`n")
    }
}

function Write-CsvFile {
    param(
        [Parameter(Mandatory = $true)][string]$Path,
        [Parameter(Mandatory = $true)][AllowEmptyCollection()][object[]]$Rows
    )
    $dir = Split-Path -Parent $Path
    Ensure-Directory $dir

    if ($Rows.Count -eq 0) {
        Write-Utf8NoBomText -Path $Path -Text ""
        return
    }

    $text = ($Rows | ConvertTo-Csv -NoTypeInformation) -join "`r`n"
    Write-Utf8NoBomText -Path $Path -Text ($text + "`r`n")
}

function Convert-ToInteger {
    param([Parameter(Mandatory = $true)][string]$Value)
    $normalized = $Value.Replace(",", "").Replace(",", "")
    return [int]$normalized
}

function Convert-HtmlToPlainText {
    param([Parameter(Mandatory = $true)][AllowEmptyString()][string]$Html)

    $text = $Html
    $text = [regex]::Replace($text, '(?is)<script\b[^>]*>.*?</script>', ' ')
    $text = [regex]::Replace($text, '(?is)<style\b[^>]*>.*?</style>', ' ')
    $text = [regex]::Replace($text, '(?i)<br\s*/?>', "`n")
    $text = [regex]::Replace($text, '(?i)</(?:div|p|li|tr|section|article|h[1-6])\s*>', "`n")
    $text = [regex]::Replace($text, '(?s)<[^>]+>', ' ')
    $text = [System.Net.WebUtility]::HtmlDecode($text)
    $text = $text.Replace([char]0x00A0, ' ')
    $text = [regex]::Replace($text, '[ \t\f\v]+', ' ')
    $text = [regex]::Replace($text, '(?:\r?\n\s*)+', "`n")
    return $text.Trim()
}

function Convert-JapaneseDateTextToIso {
    param([AllowNull()][string]$Text)
    if ([string]::IsNullOrWhiteSpace($Text)) { return "" }

    $m = [regex]::Match($Text, '(?<y>20\d{2})年\s*(?<m>\d{1,2})月\s*(?<d>\d{1,2})日\s*(?<hh>\d{1,2}):(?<mm>\d{2})')
    if (-not $m.Success) { return $Text.Trim() }

    $dt = Get-Date -Year ([int]$m.Groups['y'].Value) -Month ([int]$m.Groups['m'].Value) -Day ([int]$m.Groups['d'].Value) -Hour ([int]$m.Groups['hh'].Value) -Minute ([int]$m.Groups['mm'].Value) -Second 0
    return $dt.ToString("yyyy-MM-ddTHH:mm:sszzz")
}

# ============================================================
# 認証情報
# ============================================================

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

if (-not (Test-Path -LiteralPath $PasswordFile)) {
    throw "暗号化パスワードファイルが見つかりません: $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 を復号できません。作成したWindowsユーザーと現在の実行ユーザーが同じか確認してください。"
}

# ============================================================
# 設定・出力先
# ============================================================

$config = Read-StatsConfig $ConfigFile

$OutputDir = $config.OutputDir
$HistoryDir = Join-Path $OutputDir "history"
$LatestDir  = Join-Path $OutputDir "latest"
$RawDir     = Join-Path $OutputDir "raw"
$StateDir   = Join-Path $OutputDir "state"
$LogsDir    = Join-Path $OutputDir "logs"

# Windowsログオン直後など、Google Drive for desktop のマウントが
# タスク スケジューラより遅い場合に備えて待機する。
Wait-ForOutputMount `
    -OutputDir $OutputDir `
    -TimeoutSeconds $OutputMountWaitSeconds `
    -PollSeconds $OutputMountPollSeconds

Ensure-Directory $OutputDir
Ensure-Directory $HistoryDir
Ensure-Directory $LatestDir
Ensure-Directory $RawDir
Ensure-Directory $StateDir
Ensure-Directory $LogsDir

$LastSuccessFile = Join-Path $StateDir "last_success.txt"
$RunsFile = Join-Path $HistoryDir "runs.csv"

$runStarted = Get-Date
$runId = $runStarted.ToString("yyyyMMdd_HHmmss")
$runLogFile = Join-Path $LogsDir ("{0}.log" -f $runId)

function Write-RunLog {
    param([string]$Message)
    $line = "{0} {1}" -f (Get-Date).ToString("yyyy-MM-dd HH:mm:ss"), $Message
    Write-Host $Message
    [System.IO.File]::AppendAllText($runLogFile, $line + "`r`n", $Utf8NoBom)
}

function Add-RunRecord {
    param(
        [string]$Result,
        [int]$PageCount,
        [string]$Message
    )

    $row = [PSCustomObject]@{
        RunId       = $runId
        StartedAt   = $runStarted.ToString("yyyy-MM-ddTHH:mm:sszzz")
        FinishedAt  = (Get-Date).ToString("yyyy-MM-ddTHH:mm:sszzz")
        Mode        = $Mode
        Result      = $Result
        PageCount   = $PageCount
        WorkCount   = $config.Works.Count
        Message     = $Message
    }
    Append-CsvRows -Path $RunsFile -Rows @($row)
}

# ============================================================
# Scheduled モードの11時間制限
# ============================================================

if ($Mode -eq "Scheduled" -and (Test-Path -LiteralPath $LastSuccessFile)) {
    $lastText = (Get-Content -LiteralPath $LastSuccessFile -Raw -Encoding UTF8).Trim()
    $lastSuccess = [datetimeoffset]::MinValue

    if ([datetimeoffset]::TryParse($lastText, [ref]$lastSuccess)) {
        $elapsed = [datetimeoffset]::Now - $lastSuccess
        if ($elapsed.TotalHours -lt $config.IntervalHours) {
            $message = "前回成功から {0:N2} 時間のためスキップ(必要: {1}時間)。" -f $elapsed.TotalHours, $config.IntervalHours
            Write-RunLog $message
            Add-RunRecord -Result "SKIPPED_INTERVAL" -PageCount 0 -Message $message
            exit 0
        }
    }
    else {
        Write-RunLog "last_success.txt を解析できないため取得を実行します: $lastText"
    }
}

if ($Mode -eq "Manual") {
    Write-RunLog "手動モード: 前回成功時刻に関係なく取得します。"
}
else {
    Write-RunLog "スケジュールモード: 前回成功から $($config.IntervalHours) 時間以上経過しているため取得します。"
}

# ============================================================
# 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)

        $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

        $response = Invoke-WebRequest -Uri $LoginApiUrl -Method POST -Headers $loginHeaders -WebSession $session -ContentType "application/json" -Body $loginBody -UseBasicParsing

        if ($response.StatusCode -ne 200) {
            throw "ログインに失敗しました。Status: $($response.StatusCode)"
        }

        $loginJson = $response.Content | ConvertFrom-Json
        if ($loginJson.redirectLocation -ne "/") {
            throw "ログイン成功を確認できませんでした。"
        }

        Write-RunLog "ログイン: OK"
    }
    finally {
        if ($ptr -ne [IntPtr]::Zero) {
            [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($ptr)
        }
        $password = $null
        $loginBody = $null
    }
}

function Get-AuthenticatedPage {
    param(
        [Parameter(Mandatory = $true)][string]$Url,
        [Parameter(Mandatory = $true)][string]$SavePath,
        [switch]$RequireMyPage
    )

    Write-RunLog "GET $Url"
    $response = Invoke-WebRequest -Uri $Url -Method GET -Headers $commonHeaders -WebSession $session -UseBasicParsing

    if ($response.StatusCode -ne 200) {
        throw "ページ取得失敗: $Url Status=$($response.StatusCode)"
    }

    if ($RequireMyPage) {
        if ($response.Content -match '/auth/login' -and $response.Content -notmatch '/my/works') {
            throw "ログイン済みページを取得できませんでした: $Url"
        }
    }

    Write-Utf8NoBomText -Path $SavePath -Text $response.Content
    return $response.Content
}

# ============================================================
# /my/works 解析
# ============================================================

function Parse-MyWorks {
    param([Parameter(Mandatory = $true)][string]$Html)

    $ids = New-Object System.Collections.Generic.HashSet[string]
    $matches = [regex]::Matches($Html, '/my/works/(?<id>\d+)(?:["''/?#]|$)', [System.Text.RegularExpressions.RegexOptions]::IgnoreCase)
    foreach ($m in $matches) {
        [void]$ids.Add($m.Groups['id'].Value)
    }

    $rows = @()
    foreach ($id in ($ids | Sort-Object)) {
        $known = $config.Works | Where-Object { $_.WorkId -eq $id } | Select-Object -First 1
        $rows += [PSCustomObject]@{
            RetrievedAt = (Get-Date).ToString("yyyy-MM-ddTHH:mm:sszzz")
            WorkId      = $id
            Configured  = if ($null -ne $known) { "yes" } else { "no" }
            WorkKey     = if ($null -ne $known) { $known.WorkKey } else { "" }
            DisplayName = if ($null -ne $known) { $known.DisplayName } else { "" }
        }
    }
    return $rows
}

# ============================================================
# 作品管理ページ解析
# ============================================================

function Parse-WorkPage {
    param(
        [Parameter(Mandatory = $true)][string]$Html,
        [Parameter(Mandatory = $true)][object]$Work,
        [Parameter(Mandatory = $true)][datetimeoffset]$RetrievedAt
    )

    $singleline = [System.Text.RegularExpressions.RegexOptions]::Singleline
    $ignoreCaseSingleline = [System.Text.RegularExpressions.RegexOptions]::IgnoreCase -bor [System.Text.RegularExpressions.RegexOptions]::Singleline

    # ------------------------------------------------------------
    # 作品サマリー
    # ------------------------------------------------------------
    $state = ""
    $publicEpisodeCount = $null
    $totalPv = $null
    $todayPv = $null
    $weekPv = $null
    $monthPv = $null
    $charCount = $null
    $lastUpdated = ""
    $publishedAt = ""

    $m = [regex]::Match(
        $Html,
        '<span\s+class="widget-workStatusLabel-[^"]+">(?<state>連載中|完結済)</span>',
        $ignoreCaseSingleline
    )
    if ($m.Success) {
        $state = $m.Groups['state'].Value
    }

    $m = [regex]::Match(
        $Html,
        'title="公開済のエピソード数:(?<count>[\d,]+)"',
        $ignoreCaseSingleline
    )
    if ($m.Success) {
        $publicEpisodeCount = Convert-ToInteger $m.Groups['count'].Value
    }

    $m = [regex]::Match(
        $Html,
        'id="feedback-pv".*?data-ui-tooltip-label="PV数\s+(?<pv>[\d,]+)"',
        $ignoreCaseSingleline
    )
    if ($m.Success) {
        $totalPv = Convert-ToInteger $m.Groups['pv'].Value
    }

    $m = [regex]::Match($Html, 'data-ui-tooltip-label="今日\s+(?<pv>[\d,]+)\s*PV"', $ignoreCaseSingleline)
    if ($m.Success) { $todayPv = Convert-ToInteger $m.Groups['pv'].Value }

    $m = [regex]::Match($Html, 'data-ui-tooltip-label="今週\s+(?<pv>[\d,]+)\s*PV"', $ignoreCaseSingleline)
    if ($m.Success) { $weekPv = Convert-ToInteger $m.Groups['pv'].Value }

    $m = [regex]::Match($Html, 'data-ui-tooltip-label="今月\s+(?<pv>[\d,]+)\s*PV"', $ignoreCaseSingleline)
    if ($m.Success) { $monthPv = Convert-ToInteger $m.Groups['pv'].Value }

    $m = [regex]::Match(
        $Html,
        'id="summary-workInfo-characterCount-published-count">(?<chars>[\d,]+)</span>',
        $ignoreCaseSingleline
    )
    if ($m.Success) {
        $charCount = Convert-ToInteger $m.Groups['chars'].Value
    }

    $m = [regex]::Match(
        $Html,
        '<dt>最終更新日</dt>\s*<dd>\s*<time[^>]*>(?<date>[^<]+)</time>',
        $ignoreCaseSingleline
    )
    if ($m.Success) {
        $lastUpdated = Convert-JapaneseDateTextToIso ([System.Net.WebUtility]::HtmlDecode($m.Groups['date'].Value))
    }

    $m = [regex]::Match(
        $Html,
        '<dt>公開日</dt>\s*<dd>\s*<time[^>]*>(?<date>[^<]+)</time>',
        $ignoreCaseSingleline
    )
    if ($m.Success) {
        $publishedAt = Convert-JapaneseDateTextToIso ([System.Net.WebUtility]::HtmlDecode($m.Groups['date'].Value))
    }

    # ------------------------------------------------------------
    # エピソード一覧
    # 実HTMLでは1話ごとに <tr class="episode" ...>...</tr> となっている。
    # title 属性を使い、省略表示された画面上テキストは使わない。
    # ------------------------------------------------------------
    $episodeRows = @()
    $rowPattern = '<tr\s+class="episode"\s+id="toc-default-episode-(?<episodeId>\d+)"[^>]*>(?<body>.*?)</tr>'
    $rowMatches = [regex]::Matches($Html, $rowPattern, $ignoreCaseSingleline)

    $order = 0
    foreach ($row in $rowMatches) {
        $order++
        $body = $row.Groups['body'].Value

        $episodeId = $row.Groups['episodeId'].Value
        $status = ""
        $title = ""
        $chars = $null
        $pv = 0
        $episodeLastUpdated = ""

        $x = [regex]::Match(
            $body,
            'class="widget-episodeStatusLabel-[^"]+">(?<status>[^<]+)</span>',
            $ignoreCaseSingleline
        )
        if ($x.Success) {
            $status = ([System.Net.WebUtility]::HtmlDecode($x.Groups['status'].Value)).Trim()
        }

        $x = [regex]::Match(
            $body,
            '<td\s+class="episode-title">\s*<a[^>]*\stitle="(?<title>[^"]*)"',
            $ignoreCaseSingleline
        )
        if ($x.Success) {
            $title = ([System.Net.WebUtility]::HtmlDecode($x.Groups['title'].Value)).Trim()
        }
        else {
            # title 属性が無い場合だけリンク本文へフォールバック
            $x = [regex]::Match(
                $body,
                '<td\s+class="episode-title">\s*<a[^>]*>(?<title>.*?)</a>',
                $ignoreCaseSingleline
            )
            if ($x.Success) {
                $title = Convert-HtmlToPlainText $x.Groups['title'].Value
                $title = $title.Trim()
            }
        }

        $x = [regex]::Match(
            $body,
            '<td\s+class="episode-characterCount">(?<chars>[\d,]+)\s*文字</td>',
            $ignoreCaseSingleline
        )
        if ($x.Success) {
            $chars = Convert-ToInteger $x.Groups['chars'].Value
        }

        $x = [regex]::Match(
            $body,
            'class="episode-pv"[^>]*title="PV数"[^>]*>\s*(?<pv>[\d,]+)\s*PV\s*</a>',
            $ignoreCaseSingleline
        )
        if ($x.Success) {
            $pv = Convert-ToInteger $x.Groups['pv'].Value
        }

        $plainBody = Convert-HtmlToPlainText $body
        $x = [regex]::Match(
            $plainBody,
            '(?<date>20\d{2}年\s*\d{1,2}月\s*\d{1,2}日\s*\d{1,2}:\d{2})\s*最終更新'
        )
        if ($x.Success) {
            $episodeLastUpdated = Convert-JapaneseDateTextToIso $x.Groups['date'].Value
        }

        $episodeRows += [PSCustomObject]@{
            RetrievedAt    = $RetrievedAt.ToString("yyyy-MM-ddTHH:mm:sszzz")
            WorkId         = $Work.WorkId
            WorkKey        = $Work.WorkKey
            WorkName       = $Work.DisplayName
            EpisodeOrder   = $order
            EpisodeId      = $episodeId
            Status         = $status
            Title          = $title
            CharacterCount = if ($null -ne $chars) { $chars } else { "" }
            PV             = $pv
            LastUpdated    = $episodeLastUpdated
        }
    }

    if ($episodeRows.Count -eq 0) {
        throw "エピソード一覧を解析できませんでした: $($Work.DisplayName)"
    }

    $publicRows = @($episodeRows | Where-Object { $_.Status -eq "公開済" })
    $computedTotalPv = [int](($publicRows | Measure-Object -Property PV -Sum).Sum)

    if ($null -ne $publicEpisodeCount -and $publicEpisodeCount -ne $publicRows.Count) {
        throw "公開話数の整合性エラー: $($Work.DisplayName) dashboard=$publicEpisodeCount parsed=$($publicRows.Count)"
    }

    if ($null -eq $todayPv -or $null -eq $weekPv -or $null -eq $monthPv) {
        throw "今日/今週/今月PVを解析できませんでした: $($Work.DisplayName)"
    }

    if ($null -eq $totalPv) {
        throw "総PVを解析できませんでした: $($Work.DisplayName)"
    }

    if ($computedTotalPv -ne $totalPv) {
        throw "総PVの整合性エラー: $($Work.DisplayName) dashboard=$totalPv episode_sum=$computedTotalPv"
    }

    # ------------------------------------------------------------
    # カクヨム画面内の日別PVグラフ
    # data-ui-tooltip-label="2026年8月25日:5PV" の形で約30日分入っている。
    # ------------------------------------------------------------
    $dailyRows = @()
    $dailyPattern = 'data-ui-tooltip-label="(?<year>20\d{2})年(?<month>\d{1,2})月(?<day>\d{1,2})日:(?<pv>[\d,]+)PV"'
    $dailyMatches = [regex]::Matches($Html, $dailyPattern, $ignoreCaseSingleline)

    foreach ($d in $dailyMatches) {
        $dateText = "{0:D4}-{1:D2}-{2:D2}" -f `
            ([int]$d.Groups['year'].Value), `
            ([int]$d.Groups['month'].Value), `
            ([int]$d.Groups['day'].Value)

        $dailyRows += [PSCustomObject]@{
            RetrievedAt = $RetrievedAt.ToString("yyyy-MM-ddTHH:mm:sszzz")
            WorkId      = $Work.WorkId
            WorkKey     = $Work.WorkKey
            WorkName    = $Work.DisplayName
            Date        = $dateText
            PV          = Convert-ToInteger $d.Groups['pv'].Value
        }
    }

    $summary = [PSCustomObject]@{
        RetrievedAt       = $RetrievedAt.ToString("yyyy-MM-ddTHH:mm:sszzz")
        WorkId            = $Work.WorkId
        WorkKey           = $Work.WorkKey
        WorkName          = $Work.DisplayName
        State             = $state
        PublicEpisodes    = $publicRows.Count
        CharacterCount    = if ($null -ne $charCount) { $charCount } else { "" }
        TotalPV           = $totalPv
        TodayPV           = $todayPv
        WeekPV            = $weekPv
        MonthPV           = $monthPv
        LastUpdated       = $lastUpdated
        PublishedAt       = $publishedAt
        ParseMethod       = "dashboard+episode_crosscheck"
    }

    return [PSCustomObject]@{
        Summary    = $summary
        Episodes   = $episodeRows
        DailyStats = $dailyRows
    }
}

# ============================================================
# 近況ノート解析
# ============================================================

function Parse-NewsPage {
    param(
        [Parameter(Mandatory = $true)][string]$Html,
        [Parameter(Mandatory = $true)][string]$NewsUser,
        [Parameter(Mandatory = $true)][datetimeoffset]$RetrievedAt
    )

    # 一覧内の個別近況ノートURLを抽出し、その周辺ブロックから title/time/summary を読む。
    # HTML構造変更に備え、最低限 NewsId/URL が取れれば記録する。
    $rows = @()
    $seen = New-Object System.Collections.Generic.HashSet[string]

    $linkPattern = '/users/' + [regex]::Escape($NewsUser) + '/news/(?<id>\d+)'
    $links = [regex]::Matches($Html, $linkPattern, [System.Text.RegularExpressions.RegexOptions]::IgnoreCase)

    foreach ($link in $links) {
        $newsId = $link.Groups['id'].Value
        if (-not $seen.Add($newsId)) { continue }

        $index = $link.Index
        $start = [Math]::Max(0, $index - 2500)
        $length = [Math]::Min($Html.Length - $start, 6000)
        $context = $Html.Substring($start, $length)

        $url = "$BaseUrl/users/$NewsUser/news/$newsId"
        $title = ""
        $summary = ""
        $publishedAt = ""
        $displayedTime = ""

        # 対象URLを持つアンカー本文をタイトル候補にする。
        $anchorPattern = '<a\b[^>]*href=["'']?/users/' + [regex]::Escape($NewsUser) + '/news/' + [regex]::Escape($newsId) + '[^>]*>(?<title>.*?)</a>'
        $am = [regex]::Match($context, $anchorPattern, [System.Text.RegularExpressions.RegexOptions]::IgnoreCase -bor [System.Text.RegularExpressions.RegexOptions]::Singleline)
        if ($am.Success) {
            $title = Convert-HtmlToPlainText $am.Groups['title'].Value
            $title = [regex]::Replace($title, '\s+', ' ').Trim()
        }

        # time datetime="..." があれば正確な投稿日時として採る。
        $tm = [regex]::Match($context, '<time\b[^>]*datetime=["''](?<dt>[^"'']+)["''][^>]*>(?<display>.*?)</time>', [System.Text.RegularExpressions.RegexOptions]::IgnoreCase -bor [System.Text.RegularExpressions.RegexOptions]::Singleline)
        if ($tm.Success) {
            $displayedTime = (Convert-HtmlToPlainText $tm.Groups['display'].Value).Trim()
            $dto = [datetimeoffset]::MinValue
            if ([datetimeoffset]::TryParse($tm.Groups['dt'].Value, [ref]$dto)) {
                $publishedAt = $dto.ToLocalTime().ToString("yyyy-MM-ddTHH:mm:sszzz")
            }
            else {
                $publishedAt = $tm.Groups['dt'].Value
            }
        }

        # 周辺テキストを概要として保存。AI分析時の材料とする。
        $contextText = Convert-HtmlToPlainText $context
        $contextText = [regex]::Replace($contextText, '\s+', ' ').Trim()
        if ($contextText.Length -gt 500) { $contextText = $contextText.Substring(0, 500) }
        $summary = $contextText

        $rows += [PSCustomObject]@{
            RetrievedAt   = $RetrievedAt.ToString("yyyy-MM-ddTHH:mm:sszzz")
            NewsId        = $newsId
            PublishedAt   = $publishedAt
            DisplayedTime = $displayedTime
            Title         = $title
            Summary       = $summary
            Url           = $url
        }
    }

    return $rows | Sort-Object -Property NewsId -Descending
}

function Append-NewNewsOnly {
    param(
        [Parameter(Mandatory = $true)][string]$Path,
        [Parameter(Mandatory = $true)][AllowEmptyCollection()][object[]]$Rows
    )

    if ($Rows.Count -eq 0) { return @() }

    $known = New-Object System.Collections.Generic.HashSet[string]
    if (Test-Path -LiteralPath $Path) {
        try {
            foreach ($row in (Import-Csv -LiteralPath $Path -Encoding UTF8)) {
                if (-not [string]::IsNullOrWhiteSpace($row.NewsId)) { [void]$known.Add([string]$row.NewsId) }
            }
        }
        catch {
            throw "既存 news.csv を読み込めません: $Path`n$($_.Exception.Message)"
        }
    }

    $newRows = @($Rows | Where-Object { -not $known.Contains([string]$_.NewsId) })
    if ($newRows.Count -gt 0) {
        Append-CsvRows -Path $Path -Rows $newRows
    }
    return $newRows
}

# ============================================================
# 通知パネル解析
# ============================================================

function Convert-KakuyomuUrlToAbsolute {
    param([AllowNull()][string]$Url)

    if ([string]::IsNullOrWhiteSpace($Url)) { return "" }

    $decoded = [System.Net.WebUtility]::HtmlDecode($Url.Trim())

    if ($decoded -match '^https?://') {
        return $decoded
    }

    if ($decoded.StartsWith("/")) {
        return "$BaseUrl$decoded"
    }

    return $decoded
}

function Parse-NoticesPanel {
    param(
        [Parameter(Mandatory = $true)][string]$Html,
        [Parameter(Mandatory = $true)][datetimeoffset]$RetrievedAt
    )

    $rows = @()
    $ignoreCaseSingleline = [System.Text.RegularExpressions.RegexOptions]::IgnoreCase -bor [System.Text.RegularExpressions.RegexOptions]::Singleline

    $itemPattern = '<li\s+class="widget-notification-item"[^>]*>(?<body>.*?)</li>'
    $items = [regex]::Matches($Html, $itemPattern, $ignoreCaseSingleline)

    foreach ($item in $items) {
        $body = $item.Groups['body'].Value

        $url = ""
        $continueReadingUrl = ""
        $kind = ""
        $occurredAt = ""
        $displayedTime = ""
        $sender = ""
        $title = ""
        $subtitle = ""
        $notificationBody = ""
        $iconUrl = ""

        $m = [regex]::Match(
            $body,
            '<a\b[^>]*class="widget-notification-item-main"[^>]*href=["''](?<url>[^"'']+)["'']|<a\b[^>]*href=["''](?<url2>[^"'']+)["''][^>]*class="widget-notification-item-main"',
            $ignoreCaseSingleline
        )
        if ($m.Success) {
            if ($m.Groups['url'].Success) {
                $url = Convert-KakuyomuUrlToAbsolute $m.Groups['url'].Value
            }
            elseif ($m.Groups['url2'].Success) {
                $url = Convert-KakuyomuUrlToAbsolute $m.Groups['url2'].Value
            }
        }

        $m = [regex]::Match(
            $body,
            '<a\b[^>]*class="widget-notification-continueReading"[^>]*href=["''](?<url>[^"'']+)["'']|<a\b[^>]*href=["''](?<url2>[^"'']+)["''][^>]*class="widget-notification-continueReading"',
            $ignoreCaseSingleline
        )
        if ($m.Success) {
            if ($m.Groups['url'].Success) {
                $continueReadingUrl = Convert-KakuyomuUrlToAbsolute $m.Groups['url'].Value
            }
            elseif ($m.Groups['url2'].Success) {
                $continueReadingUrl = Convert-KakuyomuUrlToAbsolute $m.Groups['url2'].Value
            }
        }

        $m = [regex]::Match(
            $body,
            '<p\b[^>]*class="widget-notification-kind-noticeLabel"[^>]*>(?<value>.*?)</p>',
            $ignoreCaseSingleline
        )
        if ($m.Success) {
            $kind = (Convert-HtmlToPlainText $m.Groups['value'].Value).Trim()
        }

        $m = [regex]::Match(
            $body,
            '<p\b[^>]*class="widget-notification-occurredAt"[^>]*title=["''](?<exact>[^"'']+)["''][^>]*>(?<display>.*?)</p>|<p\b[^>]*title=["''](?<exact2>[^"'']+)["''][^>]*class="widget-notification-occurredAt"[^>]*>(?<display2>.*?)</p>',
            $ignoreCaseSingleline
        )
        if ($m.Success) {
            $exactText = ""
            $displayText = ""

            if ($m.Groups['exact'].Success) {
                $exactText = $m.Groups['exact'].Value
                $displayText = $m.Groups['display'].Value
            }
            else {
                $exactText = $m.Groups['exact2'].Value
                $displayText = $m.Groups['display2'].Value
            }

            $occurredAt = Convert-JapaneseDateTextToIso ([System.Net.WebUtility]::HtmlDecode($exactText))
            $displayedTime = (Convert-HtmlToPlainText $displayText).Trim()
        }

        $m = [regex]::Match(
            $body,
            '<p\b[^>]*class="widget-notification-sender"[^>]*title=["''](?<value>[^"'']*)["''][^>]*>.*?</p>|<p\b[^>]*title=["''](?<value2>[^"'']*)["''][^>]*class="widget-notification-sender"[^>]*>.*?</p>',
            $ignoreCaseSingleline
        )
        if ($m.Success) {
            if ($m.Groups['value'].Success) {
                $sender = [System.Net.WebUtility]::HtmlDecode($m.Groups['value'].Value).Trim()
            }
            else {
                $sender = [System.Net.WebUtility]::HtmlDecode($m.Groups['value2'].Value).Trim()
            }
        }

        $m = [regex]::Match(
            $body,
            '<p\b[^>]*class="widget-notification-title"[^>]*>(?<value>.*?)</p>',
            $ignoreCaseSingleline
        )
        if ($m.Success) {
            $title = (Convert-HtmlToPlainText $m.Groups['value'].Value).Trim()
        }

        $m = [regex]::Match(
            $body,
            '<p\b[^>]*class="widget-notification-subtitle"[^>]*>(?<value>.*?)</p>',
            $ignoreCaseSingleline
        )
        if ($m.Success) {
            $subtitle = (Convert-HtmlToPlainText $m.Groups['value'].Value).Trim()
        }

        $m = [regex]::Match(
            $body,
            '<p\b[^>]*class="widget-notification-body"[^>]*>(?<value>.*?)</p>',
            $ignoreCaseSingleline
        )
        if ($m.Success) {
            $notificationBody = (Convert-HtmlToPlainText $m.Groups['value'].Value).Trim()
        }

        $m = [regex]::Match(
            $body,
            '<p\b[^>]*class="widget-notification-kind-icon"[^>]*>.*?<img\b[^>]*src=["''](?<url>[^"'']+)["'']',
            $ignoreCaseSingleline
        )
        if ($m.Success) {
            $iconUrl = Convert-KakuyomuUrlToAbsolute $m.Groups['url'].Value
        }

        if (
            [string]::IsNullOrWhiteSpace($kind) -and
            [string]::IsNullOrWhiteSpace($title) -and
            [string]::IsNullOrWhiteSpace($url)
        ) {
            continue
        }

        $rows += [PSCustomObject]@{
            RetrievedAt       = $RetrievedAt.ToString("yyyy-MM-ddTHH:mm:sszzz")
            OccurredAt        = $occurredAt
            DisplayedTime     = $displayedTime
            Kind              = $kind
            Sender            = $sender
            Title             = $title
            Subtitle          = $subtitle
            Body              = $notificationBody
            Url               = $url
            ContinueReadingUrl = $continueReadingUrl
            IconUrl           = $iconUrl
        }
    }

    if ($rows.Count -eq 0) {
        throw "通知パネルを解析できませんでした。"
    }

    return $rows
}

function Get-NotificationIdentity {
    param([Parameter(Mandatory = $true)][object]$Row)

    # 通知パネルには通知IDが露出していないため、
    # 発生日時・種別・タイトル・補足・URL・本文の組み合わせを同一通知判定に使う。
    return @(
        [string]$Row.OccurredAt,
        [string]$Row.Kind,
        [string]$Row.Title,
        [string]$Row.Subtitle,
        [string]$Row.Url,
        [string]$Row.Body
    ) -join ([string][char]31)
}

function Append-NewNotificationsOnly {
    param(
        [Parameter(Mandatory = $true)][string]$Path,
        [Parameter(Mandatory = $true)][AllowEmptyCollection()][object[]]$Rows
    )

    if ($Rows.Count -eq 0) { return @() }

    $known = New-Object System.Collections.Generic.HashSet[string]

    if (Test-Path -LiteralPath $Path) {
        try {
            foreach ($row in (Import-Csv -LiteralPath $Path -Encoding UTF8)) {
                [void]$known.Add((Get-NotificationIdentity $row))
            }
        }
        catch {
            throw "既存 notifications.csv を読み込めません: $Path`n$($_.Exception.Message)"
        }
    }

    $newRows = @()
    foreach ($row in $Rows) {
        $identity = Get-NotificationIdentity $row
        if (-not $known.Contains($identity)) {
            $newRows += $row
            [void]$known.Add($identity)
        }
    }

    if ($newRows.Count -gt 0) {
        Append-CsvRows -Path $Path -Rows $newRows
    }

    return $newRows
}


# ============================================================
# フォロー関係・レビュー・反応解析
# ============================================================

function Get-ContextAroundMatch {
    param(
        [Parameter(Mandatory = $true)][string]$Html,
        [Parameter(Mandatory = $true)][int]$Index,
        [int]$Before = 1200,
        [int]$After = 2800
    )

    $start = [Math]::Max(0, $Index - $Before)
    $length = [Math]::Min($Html.Length - $start, $Before + $After)
    return $Html.Substring($start, $length)
}

function Get-AnchorTextFromContext {
    param(
        [Parameter(Mandatory = $true)][string]$Context,
        [Parameter(Mandatory = $true)][string]$HrefPattern
    )

    $pattern = '<a\b[^>]*href=["'']' + $HrefPattern + '["''][^>]*>(?<text>.*?)</a>'
    $m = [regex]::Match(
        $Context,
        $pattern,
        [System.Text.RegularExpressions.RegexOptions]::IgnoreCase -bor [System.Text.RegularExpressions.RegexOptions]::Singleline
    )

    if (-not $m.Success) { return "" }

    $plain = Convert-HtmlToPlainText $m.Groups['text'].Value
    return ([regex]::Replace($plain, '\s+', ' ')).Trim()
}

function Parse-UserRelationPage {
    param(
        [Parameter(Mandatory = $true)][string]$Html,
        [Parameter(Mandatory = $true)][string]$Relation,
        [Parameter(Mandatory = $true)][datetimeoffset]$RetrievedAt
    )

    $rows = @()
    $seen = New-Object System.Collections.Generic.HashSet[string]

    $matches = [regex]::Matches(
        $Html,
        'href=["'']/users/(?<id>[A-Za-z0-9_.@-]+)(?:["''/?#])',
        [System.Text.RegularExpressions.RegexOptions]::IgnoreCase
    )

    foreach ($m in $matches) {
        $userId = $m.Groups['id'].Value
        if ([string]::IsNullOrWhiteSpace($userId)) { continue }
        if ($userId -eq $config.NewsUser) { continue }
        if (-not $seen.Add($userId)) { continue }

        $context = Get-ContextAroundMatch -Html $Html -Index $m.Index
        $escapedId = [regex]::Escape($userId)
        $displayName = Get-AnchorTextFromContext -Context $context -HrefPattern ('/users/' + $escapedId + '(?:[/?#][^"'']*)?')

        # ヘッダーやフッター由来の誤検出を減らす。
        $contextPlain = Convert-HtmlToPlainText $context
        if (
            $context -notmatch '(?i)(widget|user|follow|author|profile)' -and
            $contextPlain -notmatch 'フォロー'
        ) {
            continue
        }

        $rows += [PSCustomObject]@{
            RetrievedAt = $RetrievedAt.ToString("yyyy-MM-ddTHH:mm:sszzz")
            Relation    = $Relation
            UserId      = $userId
            DisplayName = $displayName
            Url         = "$BaseUrl/users/$userId"
        }
    }

    return @($rows | Sort-Object UserId)
}

function Parse-FollowingWorksPage {
    param(
        [Parameter(Mandatory = $true)][string]$Html,
        [Parameter(Mandatory = $true)][datetimeoffset]$RetrievedAt
    )

    $rows = @()
    $seen = New-Object System.Collections.Generic.HashSet[string]
    $matches = [regex]::Matches(
        $Html,
        'href=["'']/works/(?<id>\d+)(?:["''/?#])',
        [System.Text.RegularExpressions.RegexOptions]::IgnoreCase
    )

    foreach ($m in $matches) {
        $workId = $m.Groups['id'].Value
        if (-not $seen.Add($workId)) { continue }

        $context = Get-ContextAroundMatch -Html $Html -Index $m.Index
        $title = Get-AnchorTextFromContext -Context $context -HrefPattern ('/works/' + [regex]::Escape($workId) + '(?:[/?#][^"'']*)?')

        $plain = Convert-HtmlToPlainText $context
        $plain = [regex]::Replace($plain, '\s+', ' ').Trim()
        if ($plain.Length -gt 500) { $plain = $plain.Substring(0, 500) }

        $rows += [PSCustomObject]@{
            RetrievedAt = $RetrievedAt.ToString("yyyy-MM-ddTHH:mm:sszzz")
            WorkId      = $workId
            Title       = $title
            Url         = "$BaseUrl/works/$workId"
            Context     = $plain
        }
    }

    return @($rows | Sort-Object WorkId)
}

function Parse-WorkFollowersPage {
    param(
        [Parameter(Mandatory = $true)][string]$Html,
        [Parameter(Mandatory = $true)][object]$Work,
        [Parameter(Mandatory = $true)][datetimeoffset]$RetrievedAt
    )

    $users = @(Parse-UserRelationPage -Html $Html -Relation "work_follower" -RetrievedAt $RetrievedAt)
    $rows = @()

    foreach ($user in $users) {
        $rows += [PSCustomObject]@{
            RetrievedAt = $RetrievedAt.ToString("yyyy-MM-ddTHH:mm:sszzz")
            WorkId      = $Work.WorkId
            WorkKey     = $Work.WorkKey
            WorkTitle   = $Work.DisplayName
            UserId      = $user.UserId
            DisplayName = $user.DisplayName
            Url         = $user.Url
        }
    }

    return $rows
}

function Parse-WorkReviewsPage {
    param(
        [Parameter(Mandatory = $true)][string]$Html,
        [Parameter(Mandatory = $true)][object]$Work,
        [Parameter(Mandatory = $true)][datetimeoffset]$RetrievedAt
    )

    $rows = @()
    $seen = New-Object System.Collections.Generic.HashSet[string]
    $pattern = '/works/' + [regex]::Escape($Work.WorkId) + '/reviews/(?<id>\d+)'
    $matches = [regex]::Matches($Html, $pattern, [System.Text.RegularExpressions.RegexOptions]::IgnoreCase)

    foreach ($m in $matches) {
        $reviewId = $m.Groups['id'].Value
        if (-not $seen.Add($reviewId)) { continue }

        $context = Get-ContextAroundMatch -Html $Html -Index $m.Index -Before 2000 -After 5000
        $plain = Convert-HtmlToPlainText $context
        $plain = [regex]::Replace($plain, '\s+', ' ').Trim()
        if ($plain.Length -gt 1200) { $plain = $plain.Substring(0, 1200) }

        $reviewUrl = "$BaseUrl/works/$($Work.WorkId)/reviews/$reviewId"
        $rows += [PSCustomObject]@{
            RetrievedAt = $RetrievedAt.ToString("yyyy-MM-ddTHH:mm:sszzz")
            WorkId      = $Work.WorkId
            WorkKey     = $Work.WorkKey
            WorkTitle   = $Work.DisplayName
            ReviewId    = $reviewId
            Url         = $reviewUrl
            Context     = $plain
        }
    }

    # 個別レビューURLがHTMLにない場合でも、raw HTML自体は保存される。
    return $rows
}

function Parse-MyReactionsPage {
    param(
        [Parameter(Mandatory = $true)][string]$Html,
        [Parameter(Mandatory = $true)][datetimeoffset]$RetrievedAt
    )

    $rows = @()
    $seen = New-Object System.Collections.Generic.HashSet[string]

    # 反応ページ内の作品・話・近況ノート等へのリンクを基点に周辺テキストを記録する。
    $matches = [regex]::Matches(
        $Html,
        'href=["''](?<url>/(?:works/\d+(?:/episodes/\d+)?(?:/comments)?|users/[A-Za-z0-9_.@-]+/news/\d+|my/episode_comments)[^"'']*)["'']',
        [System.Text.RegularExpressions.RegexOptions]::IgnoreCase
    )

    foreach ($m in $matches) {
        $relativeUrl = [System.Net.WebUtility]::HtmlDecode($m.Groups['url'].Value)
        if (-not $seen.Add($relativeUrl)) { continue }

        $context = Get-ContextAroundMatch -Html $Html -Index $m.Index -Before 1600 -After 3600
        $plain = Convert-HtmlToPlainText $context
        $plain = [regex]::Replace($plain, '\s+', ' ').Trim()
        if ($plain.Length -gt 1000) { $plain = $plain.Substring(0, 1000) }

        $kind = ""
        if ($plain -match '応援コメント') { $kind = "応援コメント" }
        elseif ($plain -match '応援') { $kind = "応援" }
        elseif ($plain -match 'レビュー') { $kind = "レビュー" }
        elseif ($plain -match 'いいね') { $kind = "いいね" }
        elseif ($plain -match 'フォロー') { $kind = "フォロー" }

        $rows += [PSCustomObject]@{
            RetrievedAt = $RetrievedAt.ToString("yyyy-MM-ddTHH:mm:sszzz")
            Kind        = $kind
            Url         = Convert-KakuyomuUrlToAbsolute $relativeUrl
            Context     = $plain
        }
    }

    return $rows
}

function Parse-NewsDetailReactions {
    param(
        [Parameter(Mandatory = $true)][string]$Html,
        [Parameter(Mandatory = $true)][object]$News,
        [Parameter(Mandatory = $true)][datetimeoffset]$RetrievedAt
    )

    $plain = Convert-HtmlToPlainText $Html
    $likeCount = ""
    $commentCount = ""

    $m = [regex]::Match($plain, 'いいね[!!]?\s*(?<n>[\d,]+)')
    if ($m.Success) { $likeCount = Convert-ToInteger $m.Groups['n'].Value }

    $m = [regex]::Match($plain, 'コメント\s*(?<n>[\d,]+)')
    if ($m.Success) { $commentCount = Convert-ToInteger $m.Groups['n'].Value }

    # 個別ページからユーザーリンクを収集。反応者候補として保存する。
    $users = @()
    $seen = New-Object System.Collections.Generic.HashSet[string]
    $matches = [regex]::Matches(
        $Html,
        'href=["'']/users/(?<id>[A-Za-z0-9_.@-]+)(?:["''/?#])',
        [System.Text.RegularExpressions.RegexOptions]::IgnoreCase
    )

    foreach ($m in $matches) {
        $userId = $m.Groups['id'].Value
        if ($userId -eq $config.NewsUser) { continue }
        if (-not $seen.Add($userId)) { continue }

        $context = Get-ContextAroundMatch -Html $Html -Index $m.Index -Before 700 -After 1200
        $contextPlain = Convert-HtmlToPlainText $context

        # 記事本文中の通常リンクを反応者と誤認しにくくする。
        if ($contextPlain -notmatch 'いいね|コメント|フォロー|応援') { continue }

        $users += $userId
    }

    return [PSCustomObject]@{
        RetrievedAt     = $RetrievedAt.ToString("yyyy-MM-ddTHH:mm:sszzz")
        NewsId          = $News.NewsId
        Title           = $News.Title
        Url             = $News.Url
        LikeCount       = $likeCount
        CommentCount    = $commentCount
        ReactionUserIds = ($users -join "|")
    }
}

function Get-SnapshotIdentity {
    param(
        [Parameter(Mandatory = $true)][object]$Row,
        [Parameter(Mandatory = $true)][string]$Kind
    )

    switch ($Kind) {
        "user" { return [string]$Row.UserId }
        "work" { return [string]$Row.WorkId }
        "work_user" { return ([string]$Row.WorkId + "|" + [string]$Row.UserId) }
        default { return "" }
    }
}

function Compare-Snapshot {
    param(
        [Parameter(Mandatory = $true)][AllowEmptyCollection()][object[]]$PreviousRows,
        [Parameter(Mandatory = $true)][AllowEmptyCollection()][object[]]$CurrentRows,
        [Parameter(Mandatory = $true)][string]$SnapshotName,
        [Parameter(Mandatory = $true)][ValidateSet("user", "work", "work_user")][string]$IdentityKind,
        [Parameter(Mandatory = $true)][datetimeoffset]$RetrievedAt
    )

    $previous = @{}
    foreach ($row in $PreviousRows) {
        $id = Get-SnapshotIdentity -Row $row -Kind $IdentityKind
        if (-not [string]::IsNullOrWhiteSpace($id)) { $previous[$id] = $row }
    }

    $current = @{}
    foreach ($row in $CurrentRows) {
        $id = Get-SnapshotIdentity -Row $row -Kind $IdentityKind
        if (-not [string]::IsNullOrWhiteSpace($id)) { $current[$id] = $row }
    }

    $events = @()

    foreach ($id in $current.Keys) {
        if (-not $previous.ContainsKey($id)) {
            $row = $current[$id]
            $events += [PSCustomObject]@{
                RetrievedAt = $RetrievedAt.ToString("yyyy-MM-ddTHH:mm:sszzz")
                Snapshot    = $SnapshotName
                Event       = "added"
                Identity    = $id
                UserId      = if ($row.PSObject.Properties.Name -contains "UserId") { $row.UserId } else { "" }
                WorkId      = if ($row.PSObject.Properties.Name -contains "WorkId") { $row.WorkId } else { "" }
                DisplayName = if ($row.PSObject.Properties.Name -contains "DisplayName") { $row.DisplayName } elseif ($row.PSObject.Properties.Name -contains "Title") { $row.Title } else { "" }
            }
        }
    }

    foreach ($id in $previous.Keys) {
        if (-not $current.ContainsKey($id)) {
            $row = $previous[$id]
            $events += [PSCustomObject]@{
                RetrievedAt = $RetrievedAt.ToString("yyyy-MM-ddTHH:mm:sszzz")
                Snapshot    = $SnapshotName
                Event       = "removed"
                Identity    = $id
                UserId      = if ($row.PSObject.Properties.Name -contains "UserId") { $row.UserId } else { "" }
                WorkId      = if ($row.PSObject.Properties.Name -contains "WorkId") { $row.WorkId } else { "" }
                DisplayName = if ($row.PSObject.Properties.Name -contains "DisplayName") { $row.DisplayName } elseif ($row.PSObject.Properties.Name -contains "Title") { $row.Title } else { "" }
            }
        }
    }

    return $events
}

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

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

    try {
        return @(Import-Csv -LiteralPath $Path -Encoding UTF8)
    }
    catch {
        Write-RunLog "警告: 既存CSVを比較用に読み込めませんでした: $Path"
        return @()
    }
}

# ============================================================
# 実行本体
# ============================================================

$pageCount = 0
$rawRunDir = Join-Path $RawDir $runId
Ensure-Directory $rawRunDir

try {
    Invoke-KakuyomuLogin

    $retrievedAt = [datetimeoffset]::Now

    # 1. 自作品一覧
    $myWorksUrl = "$BaseUrl/my/works"
    $myWorksHtml = Get-AuthenticatedPage -Url $myWorksUrl -SavePath (Join-Path $rawRunDir "my_works.html") -RequireMyPage
    $pageCount++
    $myWorksRows = @(Parse-MyWorks $myWorksHtml)
    Write-CsvFile -Path (Join-Path $LatestDir "my_works_latest.csv") -Rows $myWorksRows

    foreach ($work in $config.Works) {
        if (-not ($myWorksRows | Where-Object { $_.WorkId -eq $work.WorkId })) {
            Write-RunLog "警告: /my/works から設定作品IDを検出できませんでした: $($work.WorkId) $($work.DisplayName)"
        }
    }
    foreach ($row in $myWorksRows | Where-Object { $_.Configured -eq "no" }) {
        Write-RunLog "未設定作品を検出: $($row.WorkId)"
    }

    # 2. 各作品管理ページ
    $summaryRows = @()
    $episodeRows = @()
    $dailyRows = @()

    foreach ($work in $config.Works) {
        $url = "$BaseUrl/my/works/$($work.WorkId)"
        $safeKey = $work.WorkKey -replace '[^A-Za-z0-9_-]', '_'
        $html = Get-AuthenticatedPage -Url $url -SavePath (Join-Path $rawRunDir ("work_{0}.html" -f $safeKey)) -RequireMyPage
        $pageCount++

        $parsed = Parse-WorkPage -Html $html -Work $work -RetrievedAt $retrievedAt
        $summaryRows += $parsed.Summary
        $episodeRows += $parsed.Episodes
        $dailyRows += $parsed.DailyStats

        Write-RunLog ("解析OK: {0} 公開{1}話 総PV={2} 今日={3} 今週={4} 今月={5}" -f $work.DisplayName, $parsed.Summary.PublicEpisodes, $parsed.Summary.TotalPV, $parsed.Summary.TodayPV, $parsed.Summary.WeekPV, $parsed.Summary.MonthPV)
    }

    # 3. 近況ノート一覧
    $newsUrl = "$BaseUrl/users/$($config.NewsUser)/news"
    $newsHtml = Get-AuthenticatedPage -Url $newsUrl -SavePath (Join-Path $rawRunDir "news.html")
    $pageCount++
    $newsRows = @(Parse-NewsPage -Html $newsHtml -NewsUser $config.NewsUser -RetrievedAt $retrievedAt)

    # 4. 通知一覧
    $notificationsUrl = "$BaseUrl/notifications/notices_panel"
    $notificationsHtml = Get-AuthenticatedPage -Url $notificationsUrl -SavePath (Join-Path $rawRunDir "notices_panel.html")
    $pageCount++
    $notificationRows = @(Parse-NoticesPanel -Html $notificationsHtml -RetrievedAt $retrievedAt)

    # 5. 作者アカウントのフォロー関係
    $followingUsersUrl = "$BaseUrl/users/$($config.NewsUser)/following_users"
    $followingUsersHtml = Get-AuthenticatedPage -Url $followingUsersUrl -SavePath (Join-Path $rawRunDir "following_users.html")
    $pageCount++
    $followingUsersRows = @(Parse-UserRelationPage -Html $followingUsersHtml -Relation "following_user" -RetrievedAt $retrievedAt)

    $followingWorksUrl = "$BaseUrl/users/$($config.NewsUser)/following_works"
    $followingWorksHtml = Get-AuthenticatedPage -Url $followingWorksUrl -SavePath (Join-Path $rawRunDir "following_works.html")
    $pageCount++
    $followingWorksRows = @(Parse-FollowingWorksPage -Html $followingWorksHtml -RetrievedAt $retrievedAt)

    $followersUrl = "$BaseUrl/users/$($config.NewsUser)/followers"
    $followersHtml = Get-AuthenticatedPage -Url $followersUrl -SavePath (Join-Path $rawRunDir "followers.html")
    $pageCount++
    $followersRows = @(Parse-UserRelationPage -Html $followersHtml -Relation "follower" -RetrievedAt $retrievedAt)

    # 6. 各作品のフォロワー・レビュー
    $workFollowerRows = @()
    $workReviewRows = @()

    foreach ($work in $config.Works) {
        $safeKey = $work.WorkKey -replace '[^A-Za-z0-9_-]', '_'

        $workFollowersUrl = "$BaseUrl/works/$($work.WorkId)/followers"
        $workFollowersHtml = Get-AuthenticatedPage -Url $workFollowersUrl -SavePath (Join-Path $rawRunDir ("work_{0}_followers.html" -f $safeKey))
        $pageCount++
        $workFollowerRows += @(Parse-WorkFollowersPage -Html $workFollowersHtml -Work $work -RetrievedAt $retrievedAt)

        $workReviewsUrl = "$BaseUrl/works/$($work.WorkId)/reviews"
        $workReviewsHtml = Get-AuthenticatedPage -Url $workReviewsUrl -SavePath (Join-Path $rawRunDir ("work_{0}_reviews.html" -f $safeKey))
        $pageCount++
        $workReviewRows += @(Parse-WorkReviewsPage -Html $workReviewsHtml -Work $work -RetrievedAt $retrievedAt)
    }

    # 7. 読者からの反応ページ
    $reactionsUrl = "$BaseUrl/my/reactions"
    $reactionsHtml = Get-AuthenticatedPage -Url $reactionsUrl -SavePath (Join-Path $rawRunDir "my_reactions.html") -RequireMyPage
    $pageCount++
    $reactionRows = @(Parse-MyReactionsPage -Html $reactionsHtml -RetrievedAt $retrievedAt)

    # 8. 近況ノート個別ページの反応
    # 一覧に出ている最新分のみ。過剰アクセスを避けるため上限20件。
    $newsReactionRows = @()
    $newsDetailTargets = @($newsRows | Sort-Object NewsId -Descending | Select-Object -First $NewsDetailLimit)

    foreach ($news in $newsDetailTargets) {
        $newsId = [string]$news.NewsId
        if ([string]::IsNullOrWhiteSpace($newsId)) { continue }

        $newsDetailHtml = Get-AuthenticatedPage -Url $news.Url -SavePath (Join-Path $rawRunDir ("news_{0}.html" -f $newsId))
        $pageCount++
        $newsReactionRows += @(Parse-NewsDetailReactions -Html $newsDetailHtml -News $news -RetrievedAt $retrievedAt)
    }

    # 9. 前回 latest と比較して関係変化を記録
    $followingUsersLatestPath = Join-Path $LatestDir "following_users_latest.csv"
    $followingWorksLatestPath = Join-Path $LatestDir "following_works_latest.csv"
    $followersLatestPath = Join-Path $LatestDir "followers_latest.csv"
    $workFollowersLatestPath = Join-Path $LatestDir "work_followers_latest.csv"

    $previousFollowingUsers = @(Read-CsvIfExists $followingUsersLatestPath)
    $previousFollowingWorks = @(Read-CsvIfExists $followingWorksLatestPath)
    $previousFollowers = @(Read-CsvIfExists $followersLatestPath)
    $previousWorkFollowers = @(Read-CsvIfExists $workFollowersLatestPath)

    $relationshipEvents = @()
    $relationshipEvents += @(Compare-Snapshot -PreviousRows $previousFollowingUsers -CurrentRows $followingUsersRows -SnapshotName "following_users" -IdentityKind "user" -RetrievedAt $retrievedAt)
    $relationshipEvents += @(Compare-Snapshot -PreviousRows $previousFollowingWorks -CurrentRows $followingWorksRows -SnapshotName "following_works" -IdentityKind "work" -RetrievedAt $retrievedAt)
    $relationshipEvents += @(Compare-Snapshot -PreviousRows $previousFollowers -CurrentRows $followersRows -SnapshotName "followers" -IdentityKind "user" -RetrievedAt $retrievedAt)
    $relationshipEvents += @(Compare-Snapshot -PreviousRows $previousWorkFollowers -CurrentRows $workFollowerRows -SnapshotName "work_followers" -IdentityKind "work_user" -RetrievedAt $retrievedAt)

    # 10. AI分析向け history 保存
    $workHistoryFile = Join-Path $HistoryDir "work_stats.csv"
    $episodeHistoryFile = Join-Path $HistoryDir ("episode_stats_{0}.csv" -f $retrievedAt.ToString("yyyyMM"))
    $newsHistoryFile = Join-Path $HistoryDir "news.csv"
    $dailyHistoryFile = Join-Path $HistoryDir "daily_pv.csv"
    $notificationsHistoryFile = Join-Path $HistoryDir "notifications.csv"
    $followingUsersHistoryFile = Join-Path $HistoryDir "following_users.csv"
    $followingWorksHistoryFile = Join-Path $HistoryDir "following_works.csv"
    $followersHistoryFile = Join-Path $HistoryDir "followers.csv"
    $workFollowersHistoryFile = Join-Path $HistoryDir "work_followers.csv"
    $workReviewsHistoryFile = Join-Path $HistoryDir "work_reviews.csv"
    $reactionsHistoryFile = Join-Path $HistoryDir "reactions.csv"
    $newsReactionsHistoryFile = Join-Path $HistoryDir "news_reactions.csv"
    $relationshipEventsFile = Join-Path $HistoryDir "relationship_events.csv"

    Append-CsvRows -Path $workHistoryFile -Rows $summaryRows
    Append-CsvRows -Path $episodeHistoryFile -Rows $episodeRows
    Append-CsvRows -Path $dailyHistoryFile -Rows $dailyRows
    $newNews = @(Append-NewNewsOnly -Path $newsHistoryFile -Rows $newsRows)
    $newNotifications = @(Append-NewNotificationsOnly -Path $notificationsHistoryFile -Rows $notificationRows)

    # スナップショット系は毎取得時点をそのまま残す。
    Append-CsvRows -Path $followingUsersHistoryFile -Rows $followingUsersRows
    Append-CsvRows -Path $followingWorksHistoryFile -Rows $followingWorksRows
    Append-CsvRows -Path $followersHistoryFile -Rows $followersRows
    Append-CsvRows -Path $workFollowersHistoryFile -Rows $workFollowerRows
    Append-CsvRows -Path $workReviewsHistoryFile -Rows $workReviewRows
    Append-CsvRows -Path $reactionsHistoryFile -Rows $reactionRows
    Append-CsvRows -Path $newsReactionsHistoryFile -Rows $newsReactionRows
    Append-CsvRows -Path $relationshipEventsFile -Rows $relationshipEvents

    # 11. latest 保存
    Write-CsvFile -Path (Join-Path $LatestDir "work_stats_latest.csv") -Rows $summaryRows
    Write-CsvFile -Path (Join-Path $LatestDir "episode_stats_latest.csv") -Rows $episodeRows
    Write-CsvFile -Path (Join-Path $LatestDir "daily_pv_latest.csv") -Rows $dailyRows
    Write-CsvFile -Path (Join-Path $LatestDir "news_latest.csv") -Rows $newsRows
    Write-CsvFile -Path (Join-Path $LatestDir "notifications_latest.csv") -Rows $notificationRows
    Write-CsvFile -Path $followingUsersLatestPath -Rows $followingUsersRows
    Write-CsvFile -Path $followingWorksLatestPath -Rows $followingWorksRows
    Write-CsvFile -Path $followersLatestPath -Rows $followersRows
    Write-CsvFile -Path $workFollowersLatestPath -Rows $workFollowerRows
    Write-CsvFile -Path (Join-Path $LatestDir "work_reviews_latest.csv") -Rows $workReviewRows
    Write-CsvFile -Path (Join-Path $LatestDir "reactions_latest.csv") -Rows $reactionRows
    Write-CsvFile -Path (Join-Path $LatestDir "news_reactions_latest.csv") -Rows $newsReactionRows
    Write-CsvFile -Path (Join-Path $LatestDir "relationship_events_latest.csv") -Rows $relationshipEvents

    # README は初回のみ作る。
    $readme = Join-Path $OutputDir "README.md"
    if (-not (Test-Path -LiteralPath $readme)) {
        $readmeText = @"
# カクヨム統計

このフォルダは、PowerShellによるカクヨム統計定点観測の正本です。
AI分析時は、まず `latest` を読み、時系列比較が必要な場合に `history` を参照します。

## history
- `work_stats.csv`: 作品単位の定点統計
- `episode_stats_YYYYMM.csv`: 各話PVの定点統計(月単位)
- `daily_pv.csv`: カクヨム管理画面に表示される日別PV
- `news.csv`: 近況ノート履歴
- `notifications.csv`: 通知履歴
- `following_users.csv`: こちらがフォローしているユーザーの定点スナップショット
- `following_works.csv`: こちらがフォローしている作品の定点スナップショット
- `followers.csv`: 作者フォロワーの定点スナップショット
- `work_followers.csv`: 各作品フォロワーの定点スナップショット
- `work_reviews.csv`: 各作品レビューの定点スナップショット
- `reactions.csv`: /my/reactions から抽出した反応
- `news_reactions.csv`: 近況ノート個別ページの反応数・反応者候補
- `relationship_events.csv`: フォロー関係の追加・解除イベント
- `runs.csv`: 実行・スキップ・失敗履歴

## latest
各 `*_latest.csv` は最新取得時点の状態です。
AI分析ではまずこちらを参照し、変化の確認が必要な場合のみ history を参照します。

## raw
各実行時点のHTML。
解析ロジックを将来改善した場合の再解析用です。

## state
`last_success.txt` は最後に全処理が成功した日時。
Scheduledモードは、この時刻からConfigの `interval_hours` 未満なら取得をスキップします。
Manualモードは間隔を無視して取得します。

PVは累積値です。期間PVは、異なる取得日時の累積値の差分として計算します。
"@
        Write-Utf8NoBomText -Path $readme -Text $readmeText
    }

    # 12. 全成功後のみ last_success 更新
    $successText = [datetimeoffset]::Now.ToString("o")
    Write-Utf8NoBomText -Path $LastSuccessFile -Text ($successText + "`r`n")

    $message = "成功: $pageCount ページ取得、作品 $($config.Works.Count) 件、近況新規 $($newNews.Count) 件、通知新規 $($newNotifications.Count) 件、関係変化 $($relationshipEvents.Count) 件。"
    Write-RunLog $message
    Add-RunRecord -Result "SUCCESS" -PageCount $pageCount -Message $message
    exit 0
}
catch {
    $message = "失敗: $($_.Exception.Message)"
    Write-RunLog $message
    try { Add-RunRecord -Result "ERROR" -PageCount $pageCount -Message $message } catch { }
    Write-Error $_
    exit 1
}

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

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

アークリーチャーズ

頭のない異形が歩く世界で、頭部を外して戦う汎用人型兵器カイを中心に描く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?