こんにちは、ナナオです。
私のブログはhugoというツールで投稿しているのですが、静的ビルドを行ってデプロイするため、予約投稿ができません。
よくある方法として、Github Actionsを使った方法がありますが、今回はRustを使った超ミニマムなCMSを作って予約投稿をしていきたいと思います。
仕様 CMSとは言っているものの、ミニマム実装の段階ではHugoのディレクトリ監視ツールのようなものです。
以下のような設定のconfig.yamlファイルを読み込み、dateの時間になったらhugoのビルドとデプロイを行います。
# 監視対象のhugoプロジェクトディレクトリパスを指定 workdir: path/to/hugo_project # スケジュールされた記事の情報 scheduled: - date: 2026/1/20 00:00:00 # 予約投稿する日時 file: posts/2026/01/test.md # 予約投稿する記事のディレクトリ(workdirからの相対パス) 実装 早速実装していきます。
実装は特にこだわりはないですが、やはり最高のプログラミング言語Rustで行います。テンション上がるしね。
use chrono::{Local, NaiveDateTime}; use regex::Regex; use serde::Deserialize; use std::collections::HashSet; use std::fs; use std::path::{Path, PathBuf}; use std::process::Command; use std::thread; use std::time::Duration; // 設定ファイルの構造体定義 #[derive(Debug, Deserialize)] struct Config { workdir: String, scheduled: Vec<ScheduledPost>, } #[derive(Debug, Deserialize)] struct ScheduledPost { date: String, file: String, } fn main() { println!("Hugo Local CMS: 予約投稿監視を開始しました..."); // 処理済みファイルを記録するSet (日付+ファイル名 をキーにする) let mut processed: HashSet<String> = HashSet::new(); loop { println!("{} ファイルチェック開始", Local::now().to_rfc3339()); // 設定ファイルの読み込み let config = match load_config("config.yaml") { Ok(cfg) => cfg, Err(e) => { eprintln!("設定読み込みエラー: {}", e); thread::sleep(Duration::from_secs(10)); continue; } }; let now = chrono::Local::now().naive_local(); for post in config.scheduled { let key = format!("{}{}", post.date, post.file); // 既に処理済みならスキップ if processed.contains(&key) { continue; } // 日付パース (フォーマット: 2026/01/20 18:45:00) let pub_date = match NaiveDateTime::parse_from_str(&post.date, "%Y/%m/%d %H:%M:%S") { Ok(dt) => dt, Err(e) => { eprintln!("日付パースエラー ({}): {}", post.file, e); continue; } }; // 時間チェック if now > pub_date { println!("⏰ 予約時刻になりました: {}", post.file); // publish処理を実行 publish(&config.workdir, &post.file); // 処理済みリストに追加 processed.insert(key); } } println!("{} ファイルチェック終了", Local::now().to_rfc3339()); // 30秒待機 thread::sleep(Duration::from_secs(30)); } } fn load_config(path: &str) -> Result<Config, Box<dyn std::error::Error>> { let content = fs::read_to_string(path)?; let config: Config = serde_yaml::from_str(&content)?; Ok(config) } fn publish(work_dir: &str, relative_path: &str) { let full_path = Path::new(work_dir).join(relative_path); // 1. ファイルのドラフト解除 if let Err(e) = modify_draft_status(&full_path) { eprintln!("❌ ファイル修正失敗 [{:?}]: {}", full_path, e); return; } // 2. Hugoコマンド実行 execute_hugo(work_dir); } fn modify_draft_status(path: &PathBuf) -> std::io::Result<()> { // ファイル読み込み let content = fs::read_to_string(path)?; // 正規表現: 行頭の "draft = true" (スペース許容) を検索 // (?m) はマルチラインモード有効化 let re = Regex::new(r"(?m)^(draft\s*=\s*)true").unwrap(); if re.is_match(&content) { // 置換実行 ( ${1} は "draft = " の部分 ) let new_content = re.replace(&content, "${1}false"); // 書き込み fs::write(path, new_content.as_bytes())?; println!( "📝 Draftフラグを解除しました: {:?}", path.file_name().unwrap() ); } else { println!("ℹ️ Draftフラグの変更不要: {:?}", path.file_name().unwrap()); } Ok(()) } fn execute_hugo(work_dir: &str) { println!("🏗️ Hugoビルドを開始します..."); // 1. hugo if run_command(work_dir, "hugo", &[]).is_err() { return; } // 2. hugo deploy if run_command(work_dir, "hugo", &["deploy"]).is_err() { return; } println!("✅ Hugoのビルドとデプロイが完了しました。"); } fn run_command(dir: &str, command: &str, args: &[&str]) -> Result<(), ()> { println!("🚀 実行中: {} {:?} (in {})", command, args, dir); let status = Command::new(command) .args(args) .current_dir(dir) // 作業ディレクトリ指定 .status(); // 実行して終了ステータスを待つ match status { Ok(s) if s.success() => Ok(()), Ok(s) => { eprintln!("エラー: コマンドが失敗しました (Exit Code: {:?})", s.code()); Err(()) } Err(e) => { eprintln!("エラー: コマンドを実行できませんでした: {}", e); Err(()) } } } 実装内では30秒ごとにconfig.yamlを走査します。
...