カクヨムで作品を公開していると、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 タスク スケジューラから定期実行する
定期実行では Mode に Scheduled を指定します。
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

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