すくらっぷ あんど びるどー(したい)

日々やった事のメモとかまとめ。

自動化によるShortNoteカテゴリの集約

短いメモを作るのは成功したので、あとはこれをどうやって管理しようかと考えていた。 ただこれをログとして残しておいた方がいいとは思うものの、個別にしておくと邪魔くさいし、メモ的なものなので数が多くなる。 そのため、これも自動化でまとめて一括の記事として処理するようにした。

やりたかったこと

  1. ShortNoteカテゴリの自動集約: 独立した短いメモを一つの「まとめ記事」に統合する。
  2. 完全自動同期: ローカルからコマンド一つではてなブログへ反映させる。
  3. メインフィードの衛生管理: 統合済みの個別メモは自動的に「下書き」へ戻し、ブログ上では「まとめ記事」のみを表示させる。
  4. 日本語化とUXの向上: まとめ記事のタイトルやカテゴリーを日本語(Shortnoteまとめ)で統一する。

技術スタック

  • blogsync: Go言語製のはてなブログ投稿ツール。AtomPub APIを利用。
  • PowerShell 5.1 / 7: ファイル操作と外部ツール連携のメイン。
  • .NET API (System.IO.File): Windows特有の文字化け(UTF-8 BOM問題)を回避するために利用。

システムの核:自動集計スクリプト

セキュリティ上の懸念となるAPIキーなどは直接記述せず、blogsync側の設定に委ねる構成。 また、Windowsの日本語環境で発生しやすい「パースエラー」や「文字化け」を、BOM付きUTF-8保存と明示的なエンコーディング指定により解消した。

PowerShellスクリプト (aggregate_shortnotes.ps1)

# ShortNote Aggregator (Robust & Enhanced Version)
param (
    [switch]$Cleanup,
    [int]$MonthsToKeep = 0,
    [switch]$Post,
    [switch]$DraftOriginals,
    [switch]$IncludeDrafts # 既存の下書きも集計に含める場合は指定
)

# Set encoding
[Console]::InputEncoding = [System.Text.Encoding]::UTF8
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
$OutputEncoding = [System.Text.Encoding]::UTF8

# --- 1. 環境検知とディレクトリ設定 ---
$workDir = "<YOUR_BLOG_ROOT_PATH>" # ご自身の環境(例: J:/blogsync)に合わせて変更してください
if (-not (Test-Path $workDir)) {
    Write-Error "Work directory not found: $workDir"
    exit 1
}
Set-Location $workDir

# 使用するドメインを優先順位をつけて設定(ご自身のドメインに書き換えてください)
$domain = "<YOUR_DOMAIN_1>" 
if (-not (Test-Path "$workDir/$domain")) {
    $domain = "<YOUR_DOMAIN_2>"
}
$baseDir = "$workDir/$domain/entry"
$blogsyncExe = "$HOME/go/bin/blogsync.exe"
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
$summaryTitle = "Shortnoteまとめ"

Write-Host "Domain detected: $domain"

# --- 2. 既存のまとめ記事検索 ---
$allMdFiles = Get-ChildItem -Path $baseDir -Filter "*.md" -Recurse
$targetFile = $null
$existingMeta = ""
$summaryFiles = @()

foreach ($f in $allMdFiles) {
    try {
        $text = [System.IO.File]::ReadAllText($f.FullName, [System.Text.Encoding]::UTF8)
        if ($text -match "(?s)^---\r?\n(.*?)\r?\n---") {
            $m = $matches[1]
            if ($m -match "Title:\s*(ShortNoteSummary|Shortnoteまとめ)") {
                $summaryFiles += [PSCustomObject]@{ File = $f; Meta = $m; Time = $f.LastWriteTime }
            }
        }
    } catch { }
}

if ($summaryFiles.Count -gt 0) {
    $best = $summaryFiles | Sort-Object { if ($_.Meta -match "EditURL:") { 1 } else { 0 } }, Time -Descending | Select-Object -First 1
    $targetFile = $best.File
    $existingMeta = $best.Meta
}
$targetPath = if ($targetFile) { $targetFile.FullName } else { Join-Path $baseDir "shortnote_summary.md" }

# --- 3. 集計処理 (AGGREGATE) ---
Write-Host "Aggregating tags: Category: ShortNote ..."
$notes = @()
$filesToDraft = @()
$filesToDelete = @()
$threshold = (Get-Date).AddMonths(-$MonthsToKeep)

foreach ($f in $allMdFiles) {
    # まとめ記事自体は除外
    $isS = $false
    foreach ($sf in $summaryFiles) { if ($f.FullName -eq $sf.File.FullName) { $isS = $true; break } }
    if ($isS) { continue }

    try {
        $text = [System.IO.File]::ReadAllText($f.FullName, [System.Text.Encoding]::UTF8)
        if ($text -match "(?s)^---\r?\n(.*?)\r?\n---") {
            $m = $matches[1]
            
            # 重複ガード: すでに「下書き」になっているものはスキップ
            if (-not $IncludeDrafts -and $m -match "Draft:\s*true") { continue }

            if ($m -match "(?i)Category:[\s\S]*?-\s*ShortNote") {
                $t = "NoTitle"; $ds = ""; $u = ""
                if ($m -match "Title:\s*(.*)") { $t = $matches[1].Trim() }
                if ($m -match "Date:\s*(.*)") { $ds = $matches[1].Trim() }
                if ($m -match "URL:\s*(.*)") { $u = $matches[1].Trim() }
                
                $dt = $f.LastWriteTime
                if ($ds -match "^(\d{4}-\d{2}-\d{2})") { $dt = [DateTime]::Parse($matches[1]) }
                else { $ds = $dt.ToString("yyyy-MM-ddTHH:mm:ssK") }
                
                $body = ($text -replace "(?s)^---.*?---", "").Trim()
                $excerpt = $body.Substring(0, [Math]::Min(200, $body.Length))
                $notes += [PSCustomObject]@{ Date = $ds; DT = $dt; Title = $t; URL = $u; Body = $excerpt }
                
                if ($DraftOriginals -and ($m -notmatch "Draft:\s*true")) {
                    $newC = if ($m -match "Draft:\s*false") { $text -replace "Draft:\s*false", "Draft: true" }
                            else { $text.Replace($m, ($m.TrimEnd() + "`r`nDraft: true")) }
                    [System.IO.File]::WriteAllText($f.FullName, $newC, $utf8NoBom)
                    $filesToDraft += $f
                }
                if ($Cleanup -and ($MonthsToKeep -gt 0) -and ($dt -lt $threshold)) { $filesToDelete += $f }
            }
        }
    } catch { }
}

# --- 4. まとめ記事構築 (BUILD) ---
$h = "---`r`nTitle: ${summaryTitle}`r`n"
$fields = @("EditURL", "URL", "Date", "PreviewURL")

foreach ($fi in $fields) {
    if ($existingMeta -match "(?m)^${fi}:\s*(.*)") { 
        $val = $matches[1].Trim()
        # EditURL 内のドメインが現在のドメインと異なる場合は修正 (不整合回避)
        if ($fi -eq "EditURL") {
            if ($val -match "https?://[^/]+/[^/]+/(<YOUR_DOMAIN_REGEX>)/atom/entry/") {
                $metaDomain = $matches[1]
                if ($metaDomain -ine $domain) {
                    $val = $val -replace "https?://([^/]+/[^/]+)/$metaDomain/atom/entry/", "https://`$1/$domain/atom/entry/"
                }
            }
        }
        $h += "${fi}: ${val}`r`n" 
    }
}
$h += "Category:`r`n- Shortnoteまとめ`r`nDraft: true`r`n---`r`n`r`n"
$b = "ShortNote集計(更新: $(Get-Date -Format 'yyyy-MM-dd HH:mm'))`r`n`r`n"
foreach ($n in ($notes | Sort-Object DT -Descending)) {
    $l = if ($n.URL) { "## [$($n.Title)]($($n.URL))" } else { "## $($n.Title)" }
    $b += "$l`r`n**Date: $($n.Date)**`r`n`r`n$($n.Body)...`r`n`r`n---`r`n`r`n"
}
[System.IO.File]::WriteAllText($targetPath, ($h + $b), $utf8NoBom)
Write-Host "Updated ($($notes.Count) notes): $targetPath"

# --- 5. 不要ファイル削除 (CLEANUP) ---
# 注意: このスイッチを有効にすると、まとめ済みの古いファイルを「削除」します。
if ($Cleanup) {
    Write-Host "Warning: Cleanup mode is ON. Removing old files..." -ForegroundColor Yellow
    foreach ($sf in $summaryFiles) {
        if ([System.IO.Path]::GetFullPath($sf.File.FullName) -ne [System.IO.Path]::GetFullPath($targetPath)) {
            Remove-Item -Path $sf.File.FullName -Force
        }
    }
}

# --- 6. ブログ同期 (POST) ---
if ($Post) {
    foreach ($f in $filesToDraft) {
        $r = $f.FullName.Replace("$workDir\", "").Replace("\", "/")
        & $blogsyncExe push $r
    }
    $rS = $targetPath.Replace("$workDir\", "").Replace("\", "/")
    & $blogsyncExe push $rS
}

実装のポイントと安全策

1. UTF-8 BOM問題の克服

PowerShell 5.1以前の環境において、スクリプト内に直接日本語(正規表現の「まとめ」など)を記述すると、実行時に読み込みエラーが発生することがあります。これを回避するため、スクリプト本体は必ず BOM付きUTF-8 で保存してください。また、作成されるMarkdownファイルは blogsync の推奨に合わせて BOMなしUTF-8 で出力するように調整しています。

実際これでなんどもこけた。自動で集めたデータが文字化けして、中身が全く分からない状態になった。

2. メタデータの動的継承

通常、新しくファイルを作成すると、はてなブログ側では「新規投稿」扱いになってしまいますが、本スクリプトは既存ファイルから EditURLURL を読み取り、自動でヘッダーへ再注入。これにより、タイトルを変更した後も、既に存在する特定のURLの記事を上書き更新し続けることが可能にしている。

導入のメリット

自動下書き化による衛生管理: まとめた後の元記事を下書きに戻す。
やろうと思えば特定のカテゴリーを全て一括で処理もできるだろう。もっともはてなのサイトでもできるので、この辺は好みだと思わる。今回はまとめてそれを隠すがやりたかったので、こうなった。

まとめ

今回のことで、とりあえず運用としてShortnoteを書いて、そのログをまとめるという形で運用をするつもりである。 これによってどのような思考をしていたのかが分かる……筈である。 とはえいそれもデータとして使うならばなので、必要ないかもしれない。

おまけ:カレントファイルを即座にPushするスクリプト (push_current_file.ps1)

VSCode等からカレントファイルを1ボタンで push するための補助スクリプトです。このスクリプトは、ファイル内の EditURL が保存先フォルダのドメインと一致しない場合、自動的にメタデータを修正して同期エラーを回避。

param (
    [Parameter(Mandatory=$true)]
    [string]$FilePath
)

# --- 初期化:文字化け防止(UTF-8強制) ---
$OutputEncoding = [System.Text.Encoding]::UTF8
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8

if ([string]::IsNullOrWhiteSpace($FilePath)) {
    Write-Error "Error: FilePath is empty."
    exit 1
}

# 1. パスの正規化
try {
    # ドライブレターや相対パスを解決して絶対パス化
    $fullPath = [System.IO.Path]::GetFullPath($FilePath)
    $p = $fullPath.Replace('\', '/')
} catch {
    Write-Error "Error during path normalization: $($_.Exception.Message)"
    exit 1
}

# 2. ドメインとエントリパスの抽出
if ($p -match '(?i)(<YOUR_DOMAIN_1>|<YOUR_DOMAIN_2>)/entry/(.*)$') {
    $domainInPath = $matches[1]
    $entryPath = $matches[2]
    $target = "$domainInPath/entry/$entryPath"
    
    # 3. メタデータ (EditURL) の自動修正
    try {
        if (Test-Path $fullPath) {
            $content = [System.IO.File]::ReadAllText($fullPath, [System.Text.Encoding]::UTF8)
            
            # HatenaのURL構造に一致するかチェック
            if ($content -match 'EditURL:\s*https?://([^/]+/[^/]+)/(<YOUR_DOMAIN_REGEX>)/atom/entry/(\d+)') {
                $prefix = $matches[1]
                $domainInMeta = $matches[2]
                
                if ($domainInMeta -ine $domainInPath) {
                    Write-Host "Notice: Normalizing EditURL domain to match directory ($domainInMeta -> $domainInPath)"
                    $newContent = $content -replace "https?://[^/]+/[^/]+/$domainInMeta/atom/entry/", "https://$prefix/$domainInPath/atom/entry/"
                    [System.IO.File]::WriteAllText($fullPath, $newContent, (New-Object System.Text.UTF8Encoding($false)))
                }
            }
        }
    } catch {
        Write-Warning "Failed to check/update EditURL: $($_.Exception.Message)"
    }

    Write-Host "--- Blogsync Push Start ---"
    Write-Host "File  : $p"
    Write-Host "Target: $target"
    
    # 4. 実行ディレクトリの定義と検証
    $workDir = "<YOUR_BLOG_ROOT_PATH>" # ご自身のパスに変更してください
    
    if (Test-Path $workDir) {
        Push-Location $workDir
        try {
            # blogsync 実行
            & ~/go/bin/blogsync.exe push $target
        } finally {
            Pop-Location
        }
    } else {
        Write-Error "Error: Working directory '$workDir' not found."
        exit 1
    }
    
    Write-Host "--- Complete ---"
} else {
    Write-Error "Error: Not a valid Hatena Blog entry path. Expected 'domain/entry/...' pattern."
    exit 1
}