はじめに
個人で「AIニュースを日本語に翻訳してX(旧Twitter)に自動投稿するbot」を運用しています。RSSで18個以上のAI系フィード(OpenAI / Anthropic / Google AI など)を集め、翻訳して、Vercel Cronで定期投稿する、という構成です。
このbotには長らく 「毎時実行(0 * * * *)」 という雑なcron設定が刺さっていました。1日24回、深夜3時だろうとお構いなしに投稿する。コードは動いていたし、エラーも出ない。でも数字は伸びない。
この記事は、その毎時実行をやめて JSTのピーク時間帯(7:00 / 12:00 / 18:00 / 20:00)だけに投稿を寄せる という変更(実際のPR: Issue #20)を題材に、
- cronのUTC/JST変換でハマるポイント
- 「投稿数を時間帯で動的に変える」実装の具体コード
- そして 投稿最適化を"回数の最大化"だと思っていた自分が、何を取り違えていたか
を、動くコードと正直な反省込みで書きます。
対象読者: Vercel/サーバーレスで定期実行(cron)を一度は触ったことがあり、「とりあえず動かした定期投稿」を一段良くしたい個人開発者・中級者。cronの記法(* * * * *)がうっすら読める人を想定しています。
何を解決したかったのか(背景)
最初の実装はこうでした。
// vercel.json(変更前)
{
"crons": [
{ "path": "/api/cron/auto-post", "schedule": "0 * * * *" }
]
}
0 * * * * は「毎時0分」。つまり1日24回起動します。一見たくさん露出できて良さそうに見えます。
毎時実行のままでよくない?露出は多いほうが得でしょ?
最初の自分もそう思っていました。しかし運用してみると問題が2つ見えてきます。
- 深夜帯の投稿が"無駄玉"になる。 日本のユーザーが寝ている UTC基準の真夜中(JSTの昼間とズレる)に投稿しても、誰のタイムラインにも残らず流れていく。
- 投稿頻度が高すぎるとスパム判定リスクがある。 フォロワーのTLを自分の投稿で埋め尽くすと、逆にフォロー解除を招きます。
つまり問題は「露出が足りない」ではなく 「沈黙すべき時間にも喋り続けている」 ことでした。ここが今回のキモです。
変更内容:毎時 → JSTピーク帯の4回
Vercel Cronのスケジュールは UTC基準 で書きます。ここが最初の罠です。日本時間(JST)は UTC+9 なので、JSTの投稿したい時刻から9時間引いた値をcronに書く必要があります。
| 時間帯 | JST | UTC(cronに書く値) | 理由 |
|---|---|---|---|
| 🌅 朝 | 07:00 | 22:00(前日) | 通勤・起床直後のスマホチェック |
| 🌞 昼 | 12:00 | 03:00 | 昼休みのリラックスタイム |
| 🌆 夕 | 18:00 | 09:00 | 退勤・帰宅前 |
| 🌙 夜 | 20:00 | 11:00 | SNS利用の最ピーク |
実際の差分(vercel.json)はこうなりました。
{
"path": "/api/cron/auto-post",
- "schedule": "0 * * * *"
+ "schedule": "0 22 * * *"
+ },
+ {
+ "path": "/api/cron/auto-post",
+ "schedule": "0 3 * * *"
+ },
+ {
+ "path": "/api/cron/auto-post",
+ "schedule": "0 9 * * *"
+ },
+ {
+ "path": "/api/cron/auto-post",
+ "schedule": "0 11 * * *"
}
1日24回 → 1日4回。回数を「6分の1」に減らす変更です。
投稿回数を6分の1に減らして、本当に成長するの?むしろ露出減るのでは?
ここが直感に反するところです。後述しますが、露出の総量より「誰が見ている時間に出すか」のほうが効く、というのが今回の賭けでした。
ハマりどころ:cronのUTC変換を"アプリ側でも"間違えない
cronをUTCで書くのは慣れれば良いのですが、アプリのコード内で時間帯判定をするときも同じ罠があります。Vercel Functionsのサーバー時刻はUTCで動くため、new Date().getHours() をそのまま使うとJSTのつもりがUTCで判定されて9時間ズレます。
そこで、JSTの「時」を明示的に計算するヘルパーを噛ませています。
/** JST(日本標準時)での現在の「時」を取得 */
function getJSTHour(): number {
const now = new Date();
const jstOffset = 9 * 60; // UTC+9 を「分」で表現
const jstTime = new Date(now.getTime() + jstOffset * 60 * 1000);
return jstTime.getUTCHours(); // ← getHours() ではなく getUTCHours()
}
ポイントは最後の getUTCHours() です。getTime() に9時間ぶんのミリ秒を足した「ズラした時刻」に対して、さらにローカルタイムゾーン補正のかかる getHours() を使うと二重補正になります。ズラした後は必ず getUTCHours() で読む、と覚えておくと事故りません。
一歩進める:時間帯で"投稿数"も動的に変える
cronで「いつ動くか」を絞ったら、次は「1回あたり何件投稿するか」も時間帯で変えたくなります。ピーク帯は厚く、準ピークは中くらい、万一オフピークに動いても薄く。
実装はシンプルな分岐です。
/**
* 時間帯に基づいて最大投稿数を動的に決定する。
* ピークタイムは多めに、オフピークは抑える。
*/
function getMaxPostsForCurrentTime(): number {
// 環境変数で明示指定があればそれを尊重(運用での上書き口を残す)
if (process.env.AUTO_POST_MAX_PER_RUN) {
return DEFAULT_MAX_POSTS_PER_RUN;
}
const jstHour = getJSTHour();
// ピーク(朝の通勤 / 昼休み / 夕〜夜)→ 多め:5件
if (
(jstHour >= 7 && jstHour < 9) ||
(jstHour >= 12 && jstHour < 13) ||
(jstHour >= 18 && jstHour < 21)
) {
return 5;
}
// 準ピーク(午前中 / 夜遅め)→ 中程度:3件
if (
(jstHour >= 9 && jstHour < 12) ||
(jstHour >= 21 && jstHour < 23)
) {
return 3;
}
// オフピーク → 少なめ:2件
return 2;
}
ざっくり言うと「人がいる時間は5件、いない時間は2件」という強弱です。環境変数 AUTO_POST_MAX_PER_RUN が立っていればその値を優先する、という逃げ道を最初の行に残しているのも地味に大事で、検証時に「今だけ全時間帯10件で流したい」みたいな上書きができます。
さらに、流す記事は適当に選ぶのではなくソース優先度でソートしてから上位N件だけ出します。
export const SOURCE_PRIORITY: Record<string, number> = {
"OpenAI Blog": 10,
"Anthropic News": 10,
"Google AI Blog": 9,
// …media は 6、arXiv 等のアカデミックは 3
};
「人がいる時間に、価値の高いソースから出す」——時間と中身の両方でピークに資源を寄せる、という設計です。
で、結局どうなった?(効果と、その正直な扱い)
PRの狙いは エンゲージメント率の20〜40%向上 でした。ただし——ここは正直に書きます——
この20〜40%は「狙い・仮説」であって、まだA/Bで厳密に取り切った確定値ではありません。
理由は2つあります。第一に、変更前後で「投稿時間」も「投稿数」も同時に動かしているので、効果の切り分け(時間帯のおかげなのか、件数を絞ったおかげなのか)ができていません。第二に、Xの個人アカウント規模ではインプレッションの分散が大きく、数日では有意差と言い切れません。
なので現時点で胸を張って言えるのは、「定量効果は検証中。ただしオフピークの無駄投稿がゼロになったのは確実」 という点までです。誇張せず、ここで止めておきます。
検証環境(2026年6月時点):
- ランタイム: Node.js v20.12.2 / Vercel Functions
- ライブラリ:
twitter-api-v2@^1.29.0 - プラン: Vercel Pro(後述の理由で必須)
正直なデメリットと、踏んだ地雷
メリットだけ書くと嘘くさいので、リアルな話を3つ。
1. Vercel Pro(有料)がほぼ必須になった。
1回の起動で「最大5件 × 10秒間隔」だと処理に最大100秒ほどかかります。Functionのデフォルトタイムアウト10秒では全く足りず、maxDuration: 300 が要る。これは無料Hobbyプランの上限(10秒)を超えるため、Proプラン前提になります。「個人開発で月額が乗る」のは正直に言うべきデメリットです。
2. ドキュメントとコードがズレていた(自戒)。
リポジトリ内の古い設計メモには「1日3回(08:00/12:30/20:00)」と書いてあるのに、実際のcronは「4回(7/12/18/20)」。スケジュールという"プロダクトの意思"がコードとドキュメントで二重管理になっていて、片方が腐っていました。cronの値はコードであると同時に運用ドキュメントでもある、と痛感した部分です。
3. cronの起動時刻と"投稿数テーブル"が完全には噛み合っていない。
getMaxPostsForCurrentTime() は「18〜21時はピークで5件」と判定しますが、cronが夕方に起動するのは18:00ちょうどの1回だけ。テーブル側はもっと細かい時間帯を想定して作ってあるので、現状は実装の余力が先行している状態です。将来cronを増やしたときに効いてくる設計、と前向きに捉えていますが、「今この瞬間フルには使われていない」のは事実です。
まとめ:最適化とは「沈黙する時間を設計すること」だった
この変更を通して、自分のなかの定義が一つ書き換わりました。
投稿の最適化とは「回数を最大化すること」ではなく、「いつ黙るかを決めること」である。
毎時実行をやめるのは、機能を足す変更ではなく 機能を引く変更 です。コードはむしろ複雑になった(時間帯判定が増えた)のに、botの振る舞いは「静かになった」。この非対称さが、今回いちばん面白かった点でした。
学びを3行で:
-
cronはUTCで書く。 JSTは「やりたい時刻 − 9時間」。アプリ内判定では
getUTCHours()で二重補正を避ける。 - 時間も中身もピークに寄せる。 起動時刻を絞り、件数を動的にし、優先度の高いソースから出す。
- 減らす最適化を恐れない。 露出の総量より「誰が見ている時間に出すか」。沈黙の設計はコストではなく戦略。