0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【個人開発】AIニュースbotのX自動投稿、"毎時実行"をやめてJSTピーク帯に寄せたら何が見えたか?

0
Posted at

はじめに

個人で「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つ見えてきます。

  1. 深夜帯の投稿が"無駄玉"になる。 日本のユーザーが寝ている UTC基準の真夜中(JSTの昼間とズレる)に投稿しても、誰のタイムラインにも残らず流れていく。
  2. 投稿頻度が高すぎるとスパム判定リスクがある。 フォロワーの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() で二重補正を避ける。
  • 時間も中身もピークに寄せる。 起動時刻を絞り、件数を動的にし、優先度の高いソースから出す。
  • 減らす最適化を恐れない。 露出の総量より「誰が見ている時間に出すか」。沈黙の設計はコストではなく戦略。

参考資料

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?