短いメモを作るのは成功したので、あとはこれをどうやって管理しようかと考えていた。 ただこれをログとして残しておいた方がいいとは思うものの、個別にしておくと邪魔くさいし、メモ的なものなので数が多くなる。 そのため、これも自動化でまとめて一括の記事として処理するようにした。
やりたかったこと
- ShortNoteカテゴリの自動集約: 独立した短いメモを一つの「まとめ記事」に統合する。
- 完全自動同期: ローカルからコマンド一つではてなブログへ反映させる。
- メインフィードの衛生管理: 統合済みの個別メモは自動的に「下書き」へ戻し、ブログ上では「まとめ記事」のみを表示させる。
- 日本語化と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. メタデータの動的継承
通常、新しくファイルを作成すると、はてなブログ側では「新規投稿」扱いになってしまいますが、本スクリプトは既存ファイルから EditURL や URL を読み取り、自動でヘッダーへ再注入。これにより、タイトルを変更した後も、既に存在する特定の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
}