ガンバラナイ

HTML1枚のサイトを、GitHub Actionsの“AI社員”が毎日回している — つねかめ堂HPの構成と小技

HTML1枚のサイトを、GitHub Actionsの“AI社員”が毎日回している — つねかめ堂HPの構成と小技

はじめに

手ぬぐい作家「つねかめ堂」のHP(https://tsunekame.com/)を作って運用しています。載せているのは、出店予定・Instagramの最新投稿・これまでの出店履歴という、イベント出店をする作家なら誰でも欲しくなる情報です。行った県が塗られていく日本地図も付いています。

作るにあたっての条件は3つでした。

  • 更新する人はエンジニアではありません。 Googleカレンダーに出店予定を入れる、Instagramに投稿する。普段やっていることの延長で情報が更新されてほしい
  • 運用費はほぼゼロにしたい。 サーバも有料SaaSも増やさない
  • 放っておいても情報が古くならないこと。 終わった出店がトップに残り続けるようなサイトにはしたくない

結果として、フレームワークもCMSも使わないHTML1枚の静的サイトに、GitHub Actionsを4本住まわせる構成になりました。この記事では、その全体像と、作っている途中で効いた小技をまとめて紹介します。ひとつひとつを掘り下げると長くなるので、ここでは要点だけにとどめました。

全体像

無人で動くCIが4本あります。それぞれが独立した1本道なので、上から順に読めます。

つねかめ堂HPの全体像。4本のGitHub Actionsが、Google Calendar・Instagram・Cloudflare Analytics・Squareを入力に、静的サイトへの焼き込み、GitHub IssueとTelegramへの通知をそれぞれ独立に行う

  • ① サイトの更新(1日3回・Update feeds) — Googleカレンダーの出店予定とInstagramの最新投稿を取ってきて、public/index.html の決められた範囲に焼き込んでcommit & pushします。pushがそのままCloudflare Pagesのデプロイです
  • ② 広報文の下書き(日次・Promo drafts) — 出店の告知文とお礼文の下書きをAIに書かせ、GitHub Issueに投げます。同じ内容がTelegramにも届きます
  • ③ アクセス解析(月次・Analytics report) — アクセス数・技術SEOの自己診断・売上サマリーを集めてレポート化し、Issueにします
  • ④ 出没情報(日次・LINE profile) — LINE公式アカウントのプロフィールに貼る「出没情報」テキストを組み立ててTelegramに送ります

人がやることは3つだけです。Issueに出てきた広報文をInstagramに貼る。Telegramに届いたテキストをLINEに貼る。あとはmainへのマージを承認する。 それ以外は勝手に進みます。

以下、効いている小技を順に挙げていきます。

サイト本体はHTML1枚

public/index.html にHTML・CSS・JSが全部入っています。ビルドステップはありません。package.json の依存はこれだけです。

{
  "scripts": {
    "test": "node --test scripts/*.test.mjs",
    "test:e2e": "playwright test"
  },
  "devDependencies": {
    "@playwright/test": "^1.48.0"
  }
}

配信物の依存はゼロで、テスト用のPlaywrightだけがdevDependenciesにいます。ビルドスクリプト(scripts/)もNode組み込みのfetchしか使いません。

理由は単純で、このサイトの寿命のほうがフレームワークの寿命より長そうだからです。年に数回しか触らないサイトで、久しぶりに開いたらnpm installが通らない、という事故をなくしたかった。実際いま触っても、cloneしてnode scripts/build-events.mjsが即座に動きます。

とはいえHTML1枚には弱点があります。出店予定をブラウザ側でfetchして描画していると、初期表示が遅く、検索エンジンにも中身が渡りません。 かといってビルド時にプリレンダすると、今度は「同じ描画ロジックがブラウザ用とビルド用の2箇所にできる」という、よくある分岐点に立たされます。

小技①:ブラウザで動いているコードを、ビルドがそのまま借りる

ここでやったのが、ブラウザに配っているコードを、Node側が抜き出してそのまま評価するという方法です。

index.html の<script>の中に、描画ロジックの純粋関数群をマーカーで囲んだブロックがあります。

// CAL-RENDER:START 出店予定リスト生成(純粋関数群・単一ソース)。
// scripts/build-events.mjs がこのマーカー間を抽出・評価し、ビルド時プリレンダで
// render() とバイト同一の HTML を生成する。ここでは document / fetch / el を
// 参照しないこと(Node の new Function でそのまま評価される)。
var WD = ['日','月','火','水','木','金','土'];
// ...(buildCalHtml / hostRole / detectPref など約300行)
// CAL-RENDER:END

ビルド側は、このブロックを文字列として切り出してnew Functionで評価し、関数を受け取ります。

export function extractCalRender(html) {
  const s = html.lastIndexOf(RENDER_START);
  if (s < 0) throw new Error('CAL-RENDER マーカーが public/index.html に見つからない');
  const e = html.indexOf(RENDER_END, s + RENDER_START.length);
  if (e < 0) throw new Error('CAL-RENDER:END マーカーが START 以降に見つからない');
  const block = html.slice(html.indexOf('\n', s) + 1, e);
  return new Function(block + '\nreturn { buildCalHtml: buildCalHtml, findPref: findPref, detectPref: detectPref, hostRole: hostRole };')();
}

new Functionは普通なら避ける手ですが、ここで評価しているのは自分がコミットした自分のindex.htmlです。外から来た文字列ではありません。得られるものは大きくて、ロジックの正本が1箇所になります。

index.htmlのCAL-RENDERブロックが判定ロジックの正本で、build-events・build-past・build-promo-draftsの3つがextractCalRender経由でそれを借り、生成結果は同じindex.htmlのマーカー間に焼き戻される

この関数群を借りているのは3つのビルドスクリプトです。出店予定リストを作るbuild-events.mjs、過去の出店を追記するbuild-past.mjs、そして広報文を書くbuild-promo-drafts.mjs。

たとえば「このイベントは自分が主催なのか、共催なのか、ただの参加なのか」という判定。これはトップの一覧に出すバッジ、JSON-LDのorganizer、そしてAIに書かせる広報文のトーン、と3箇所で必要になるのに、判定ルールはどう考えても1つであるべきものです。正本をindex.htmlに置いて全員が借りる形にしたので、ミラーが存在しません。

細かい工夫が2つあります。

探索がlastIndexOfなのには理由があります。 本物のマーカーはファイル末尾近くの<script>内にあります。一方で、イベントの説明文にたまたま同じ文字列が入ってしまうと、それはプリレンダ済みの静的リストやJSON-LD、つまり本物より手前に現れます。後ろから探せば偽マーカーを拾いません。

ブロック内ではdocumentもfetchも参照しないことをコメントで縛っています。 Nodeで評価される以上これは破れない制約なので、破ったら気づけるようにテストも置いています。

小技②:マーカーの間だけを差し替え、外側は1バイトも触らない

生成したHTMLは、index.htmlのマーカーで囲まれた範囲に書き戻します。この差し替えを担当する関数がこれです。

export function replaceBetween(html, startMarker, endMarker, middle) {
  if (html.indexOf(startMarker) !== html.lastIndexOf(startMarker)) {
    throw new Error('START マーカーが複数あります');
  }
  if (html.indexOf(endMarker) !== html.lastIndexOf(endMarker)) {
    throw new Error('END マーカーが複数あります');
  }
  const startIdx = html.indexOf(startMarker);
  const endIdx = html.indexOf(endMarker);
  if (startIdx === -1 || endIdx === -1 || startIdx > endIdx) {
    throw new Error('マーカーが見つからない/順序が不正です');
  }
  const head = html.slice(0, startIdx + startMarker.length);
  const tail = html.slice(endIdx);
  return head + middle + tail;
}

短い関数ですが、必ず守らせているルールが2つあります。

マーカーの外は1バイトも変更しません。 だから同じ入力で再実行すれば差分はゼロです。CIが1日3回走っても、内容が変わらなければcommitは発生しません。

マーカーが0個・複数・順序不正なら、何も書かずにthrowします。 「見つからないので末尾に追記」みたいな親切をすると、事故が静かに進みます。壊れているなら止まってほしい。

そしてマーカー自身が、人間向けの説明を持っています。

<!-- CAL:START 自動生成: scripts/build-events.mjs が buildCalHtml と同一の静的リストを注入。手で編集しない -->
<!-- CAL:END -->

半年後の自分は、ここが自動生成であることを確実に忘れています。「手で編集しない」と、編集しようとした場所に書いてあるのが一番効きます。

小技③:日本地図は1枚だけ置いて、CSS変数で県を光らせる

出店予定の各行には、その県だけが朱赤で光る小さな地図が付いています。「これまでの出店」には、行ったことのある県が塗られた全国地図があります。

これまでに出店した都道府県が朱赤で塗られた日本地図。東北から九州まで17県が点灯している

素直に作ろうとすると、ここは急に重くなります。47都道府県のパスデータは巨大で、出店予定の行ごとにコピーしたらHTMLが膨れ上がります。 かといって県ごとにPNGを用意すると47枚。しかも「この県を光らせる」たびにJavaScriptでパスを探してfillを書き換えることになります。

やったのは3つです。

地図の実体は1回だけ置いて、あとは参照する。 白地図(geolonia/japanese-prefectures)を<defs>に1回だけ埋め込み、使う側は<use>1行で済ませます。

<svg width="0" height="0" aria-hidden="true" style="position:absolute"><defs><g id="jp-map">
  <g class="ishikawa prefecture" data-code="17" fill="var(--p17,#e6ddcc)" stroke="#c3b89f">...</g>
  ...47都道府県分...
</g></defs></svg>

点灯はCSS変数に任せる。 各県のfillがvar(--p17, #e6ddcc)になっているのがポイントで、変数の既定値が「塗っていない色」です。 光らせたい県は、その変数を定義するだけ。

<svg class="rmap" viewBox="430 330 460 460"><use href="#jp-map" style="--p17:var(--shu)"/></svg>

<use>は参照先のコピーをシャドウツリーとして展開しますが、CSS変数はその中まで継承されます。 だから同じ#jp-mapを参照していても、<use>ごとに違う県を光らせられます。JavaScriptでDOMを探して属性を書き換える処理は、どこにもありません。

地域の拡大はviewBoxを変えるだけ。 石川県なら中日本、秋田県なら東北、というように、同じ地図の「見る窓」をずらします。拡大画像を用意する代わりに、切り出し枠を7つ定数で持っているだけです。

石川県・秋田県・福岡県のミニ地図。同じ日本地図をviewBoxの値だけ変えて、それぞれ中日本・東北・九州に寄せている

/* 地域クロップ: regionキー → [viewBox x, y, 一辺, 地域名](全国図 1000² からの切り出し枠) */
var RCROP={
  hokkaido:[20,0,660,'北海道'], tohoku:[600,0,400,'東北'], kanto:[700,395,265,'関東'],
  chunichi:[430,330,460,'中日本'], west:[150,460,365,'西日本'], kyushu:[15,635,330,'九州'], okinawa:[470,790,260,'沖縄']
};

そして全国の足あとマップは、過去の出店から県コードを集めて、変数をまとめて流し込むだけです。

evs.forEach(function(e){
  if(e.dp && !prefSet[e.dp.code]){ prefSet[e.dp.code]=1; vars.push('--p'+pad(e.dp.code)+':var(--shu)'); }
});

実際に配信されているHTMLでは、こうなっています。

<use id="past-use" href="#jp-map" style="--p23:var(--shu);--p05:var(--shu);--p20:var(--shu);--p39:var(--shu);...">

気に入っているのは、この地図を誰も更新していないことです。 県の判定は会場の住所から自動で行われ(これも小技①でビルドが借りている関数のひとつ、findPrefです。正式名で拾えなければ「東京」「秋田」のような接尾辞なしでも拾います)、終わった出店はbuild-pastが日次で追記します。出店すれば、その県は勝手に朱赤になります。 塗り絵が少しずつ埋まっていくのは、作った側から見ても素朴に楽しいところです。

GitHub Actionsに“社員”を住まわせる

ここからは自動化の話です。CIをテスト実行係ではなく、定期的に何かを作って届けてくれる担当者として使っています。

小技④:事実はコードが所有し、AIは文章だけ書く

「AI広報社員」は、出店の告知文とお礼文の下書きを毎日作ります。

AI広報のフロー。出店予定・売れ筋の柄・関連する過去投稿・お手本をコードが組み立ててGeminiに渡し、返ってきた文章をIssueにする。人が「採用:」コメントを付けるとお手本プールに戻る

設計の中心はこれです。日付・会場・柄といった事実はすべてコードが所有し、AIに任せるのは文章だけ。

Geminiに渡すのは、コードが組み立て終わった事実と、お手本と、関連する過去投稿です。AIは日付を計算しませんし、会場名を思い出したりもしません。生成AIに書かせて一番怖いのは「もっともらしい嘘を公開してしまうこと」なので、嘘をつける余地のあるものを最初から渡さないという方針です。

そして返ってきたJSONが壊れていたり候補がゼロだったりしたら、throwしてIssueを作りません。 空のIssueが立つより、何も来ないほうがマシです。何も来なければ「今日は下書きが来ていないな」と気づけます。

月次の「AIアクセス解析レポート社員」も同じ作りです。アクセス数も前月比も技術SEOの診断結果も、全部コードが計算します。AIが書くのは講評だけ。ついでにWebのアクセス数と対面の売上を結びつけた講評は禁止にしています。両者は別チャネルなので、「サイトを見た人が買った」というファネルの話は根拠がないからです。

小技⑤:「採用:」コメントだけを学習して、文体が寄っていく

広報の下書きは、そのまま貼ることもあれば、手直しして貼ることもあります。この手直し後の最終文を回収する仕組みがあります。

Issueに人が「採用:」で始まるコメントを付けて閉じると、翌日のCIがそれを収穫して、お手本プール(promo-examples.json)に取り込みます。次回以降のfew-shotがそれになるので、使うほど文体が本人に寄っていきます。

ポイントはIssue本文の候補からは一切学習しないことです。本文にあるのは「AIが出した3案」であって、採用されたとは限りません。不採用案を学習すると、いつまでも直したい癖が残ります。学習するのは「人間が明示的に選んだ勝者」だけ、と決めています。

小技⑥:壊れ方を3種類に書き分ける

外部サービスを6つも束ねていると、どれかは必ず落ちます。落ちた時にどうするかを、用途ごとに変えています。

fail closed(絞れないなら何も渡さない) — 広報AIに渡してよいのは「つねかめ堂の手ぬぐいの柄」だけです。委託販売をしている店では、売上上位に他の作家さんの商品が並ぶことがあります。そこでSquareのカテゴリで絞り込むのですが、絞り込みに失敗したら素材ゼロで続行します。生の商品名にフォールバックはしません。

export function isPromoPattern(categoryNames, opts = {}) {
  const { deny = PROMO_DENY_CATEGORIES, allow = PROMO_ALLOW_CATEGORIES } = opts;
  if (hasDeniedCategory(categoryNames, deny)) return false;
  const cats = toSet(categoryNames);
  return allow.some((a) => cats.has(a));
}

除外を許可より必ず先に評価しているのも同じ理由です。広報の失敗は「誤った断定を公開すること」なので、取りこぼしのほうが圧倒的に軽い。

graceful skip(落ちても本業は止めない) — 一方で、Squareが落ちたからといって広報の下書きが出ないのは困ります。売れ筋の柄は「あれば嬉しい素材」なので、取れなければ黙って省いて先に進みます。Squareの障害が広報を落とさないようにしてあります。

降格(数字は落とすが、落としたことは言う) — 月次レポートでアクセス解析が取れなかった時は、analytics=nullにしてレポート本文にバナーを出し、CIログに::warning::を出します。前月分だけが欠けた場合は、今月の数字自体は有効なので降格させず、前月比だけを省いて「なぜ省いたか」を本文に書きます。

momNote: '前月データが 0 件のため前月比は省略しました(トークンのスコープ/retention を確認してください)。',

黙って消えた項目は、そのうち誰も気づかなくなります。消すなら理由を残す。

CI・運用まわりの小技

ここからは短く、独立した小ネタです。

小技⑦:actions/cacheをhash-as-keyに使って変更検知する

LINEの出没情報テキストは毎日組み立て直しますが、内容が変わっていない日に通知が飛ぶと、そのうち誰も読まなくなります。

そこで、生成したテキストのsha256をキャッシュのキーそのものにしました。

- name: Hash generated text
  id: hash
  run: echo "hash=$(sha256sum '${{ steps.build.outputs.text_path }}' | cut -d' ' -f1)" >> "$GITHUB_OUTPUT"

- name: Check sent-marker cache
  id: sent
  uses: actions/cache@v4
  with:
    path: .line-profile-sent-marker
    key: line-profile-sent-${{ steps.hash.outputs.hash }}

キャッシュヒット=同じ内容を前に送った、ミス=内容が変わった、という読み替えです。中身が何であるかはどうでもよく、キーが当たるかどうかだけを見ています。前回値を置くための場所を別に用意しなくて済みます。

小技⑧:workflow_runで「お礼の直後」を保証する

出店の最終日の夜、お礼の下書きが届きます。同じタイミングで、終わった出店を落とした新しい出没情報テキストも欲しい。

同時刻にcronを2本置いても順序は保証されませんが、workflow_runなら相手の完了後に起動します。

on:
  schedule:
    - cron: '0 21 * * *'   # 毎日 06:00 JST(保険)
  workflow_dispatch:
  workflow_run:
    workflows: ["Promo drafts"]
    types: [completed]

conclusionで絞っていないのが地味なポイントです。広報が落ちた日でも、予定が変わっていれば貼り替えは必要だからです。毎回起動はしますが、送るかどうかは小技⑦のハッシュが握っているので、うるさくはなりません。

小技⑨:タイムゾーンを2つ回して日付ズレを機械検証する

出店情報はJSTで動きますが、GitHub ActionsのランナーはUTCです。「今日」の解釈が1箇所ずれると、深夜帯だけ前日の予定が出る、というような踏みにくいバグになります。

strategy:
  fail-fast: false
  matrix:
    tz: ['UTC', 'Asia/Tokyo']

ランナーの既定がUTCなので、TZ=UTCを足すだけでは同じ環境を2回走らせることになります。JSTを明示した2ジョブにして、日付のズレを機械に検証させます。 実際にこの手のバグを踏んだので入れました。

小技⑩:通知されないCronを、別のCIから見張る

Instagramの投稿アーカイブは、Cloudflare WorkerのCronで6時間おきに取り込んでいます。ここで問題になったのが、Cloudflare の Cron は失敗しても誰にも通知しないことでした。GitHub Actionsから移した時に、失敗メールという通知経路も一緒に失っていたわけです。

なので、取り込みの生死をGitHub Actions側から見張っています。

// Cron は6時間おき。2回続けて落ちるまでは騒がない(一時的な障害で毎回メールが飛ぶと
// 誰も読まなくなり、本当に止まった時に気づけなくなる)。3回目を跨いだら異常とみなす。
export const MAX_AGE_HOURS = 14;

見張り先の選び方にも一手あります。アーカイブ本体の更新を見てはいけません。 投稿が無ければアーカイブは更新されず、それは正常な状態です。「投稿していないだけ」と「取り込みが死んでいる」を区別できません。そこで、Cronが走るたびに必ず書く実行記録(ingest-status.json)の鮮度を見ています。

小技⑪:JPEGをgitの履歴から追い出す

もともとInstagramの画像をリポジトリに貯めていたのですが、これはやめました。JPEGはgitのdeltaが効かないので、毎回まるごと積み上がります。 年100MB近くが履歴に永久に残る計算でした。一度入れるとgit filter-repoしない限り消えません。

いまは正本をCloudflare R2のバケットに置き、リポジトリでは非追跡にしています。「後から消せないもの」をgitに入れない、という当たり前の話ですが、静的サイトだと画像をついpublic/に置いてしまいがちなので、意識的に線を引いています。

まとめ

改めて並べてみると、効いていた原則は4つでした。

正本を1つに保つ。 同じ判定が複数箇所で必要になったら、コピーするのではなく、1箇所から借りる方法を探す。new Functionでブラウザ用のコードを抜き出して使うのは強引ですが、同じロジックを2つ抱えるよりずっと安全でした。

事実と文章を分ける。 AIに任せるのは文章だけで、日付も数値も柄もコードが所有する。生成AIを運用に組み込む時、ここの線引きが一番効きました。

壊れ方を先に決める。 fail closed・graceful skip・降格。「落ちたらどうするか」を用途ごとに決めておくと、外部サービスがいくつあっても運用が静かになります。そして黙って消さない。省いたなら理由を出力に残す。

「誰も更新しないもの」に寄せる。 足あとマップが良い例で、出店すれば県が勝手に塗られます。手で更新する場所を作らなければ、そこは古くなりません。

個々の話はどれも掘れば長くなるので、ここでは駆け足になりました。特に「HTML1枚で正本を1つに保つ」話、「AIに事実を渡す」話、それに地図まわりは、それぞれ単体でも書ける分量があります。気が向いたらそのうち書くかもしれません。