コンテンツにスキップ

3画面パック ⑧ コメント機能 — 仕様書

結論(3行)

  1. 静的なサイトのままでは作れません。配信を「ファイルを置くだけ」から 小さな常駐サーバ1本に差し替えます。
  2. 記録は1件1行の追記だけにします。まとめて1つのファイルを書き戻す作りにすると、同時に書いたときに消えます。
  3. 記録の置き場は、サイトの外です。MkDocs は配信のたびに site/丸ごと作り直すので、中に置いたものは消えます。

📦 このパックの入口 | ⑦ 要件定義(何のために作るか) | pmsw-pack.zip

この紙は 作る人(右腕)が読む紙です。07_comment_req を先に読んでください。 ここに書いてある「なぜそうするか」まで含めて実装してください。理由を外すと、同じ落とし穴に落ちます。


1. 全体の構成

flowchart TD
    subgraph BR["読む人のブラウザ"]
        P["MkDocs のページ<br>💬 ボタン + 吹き出し"]
        D["書きかけ<br>(このブラウザの中だけ)"]
        P <--> D
    end
    P -->|送信| S["常駐サーバ(1本)<br>ページも配る/書き込みも受ける"]
    S -->|① まず残す| J["記録ファイル<br>comments.jsonl<br>🔴 サイトの外"]
    S -->|② それから流す| W["Discord の通知口<br>(Webhook)"]
    W --> C["Discord のチャンネル"]
    C -->|許可リストを開けてあれば| A["右腕が起き上がる"]
    A -->|状態を変える| S
    style J fill:#2f6f4e,color:#fff
    style W fill:#1971c2,color:#fff

🔴 ①と②の順番を入れ替えないこと

必ず「残してから流す」です。逆にすると、Discord が落ちている日に書いたものが消えます。 通知の失敗は失敗として正直に返しますが、保存は成功のままです(受入条件 A5)。

部品 何をするか 置き場
常駐サーバ ページを配る + 4つの窓口(API)を持つ tools/comment_server.py のような1ファイルで足ります
画面の部品 💬 ボタンと吹き出しを描く docs/assets/comment.js原稿側に置く。→ 2節
記録 1件1行の追記 <データ置き場>/comments/comments.jsonl
通知口の名簿 Webhook の URL を持つ <データ置き場>/comments/destinations.json権限 600・git に入れない)

2. MkDocs との組み合わせ方

MkDocs は、配信のたびに site/ を作り直します

mkdocs buildsite/ の中身を消してから作り直します。 つまり site/ の中に置いたものは、次のビルドで全部消えます。

flowchart LR
    subgraph SRC["原稿(あなたが書く側)"]
        MD["docs/*.md"]
        JS["docs/assets/comment.js<br>🟢 ここに置く"]
        YML["mkdocs.yml<br>extra_javascript: [assets/comment.js]"]
    end
    SRC -->|mkdocs build<br>毎回まっさらに作り直す| SITE["site/<br>(配信されるもの)"]
    DATA["データ置き場<br>comments.jsonl<br>🟢 site/ の外"]
    SITE --> SRV["常駐サーバ"]
    DATA --> SRV
    style JS fill:#2f6f4e,color:#fff
    style DATA fill:#2f6f4e,color:#fff
もの 置く場所 なぜ
画面の JavaScript docs/assets/comment.js原稿側 ビルドのたびに site/assets/コピーされるsite/ に直接置くと消える
読み込ませる1行 mkdocs.ymlextra_javascript: [assets/comment.js] 全ページの末尾に自動で入る。ページごとに書き足す必要は無い
記録ファイル site/(例 /home/<あなた>/wiki-data/comments/ ビルドで消えないため
通知口の名簿 同上。権限 600 Webhook の URL は鍵と同じ扱い

常駐のさせ方(pm2)

pm2 start tools/comment_server.py --name wiki --interpreter python3 -- \
    --site <サイトの site/ のパス> --data <データ置き場のパス> --bind <待ち受けるアドレス> --port <ポート>
pm2 save
pm2 startup        # 機械を再起動しても立ち上がるようにする

mkdocs serve は使いません

mkdocs serve原稿を見ながら書くための道具で、書き込みを受け取れません。 mkdocs build で作った site/ を、この常駐サーバが配ります。

終わりの印:機械を再起動しても、ブラウザでサイトが開き、💬 が出る。


3. データの形

記録は JSONL(ジェイソンエル=1行に1件の JSON を並べた形)です。 ファイルの末尾に1行足すだけなので、同時に書いても互いを踏みません。

コメント1件(本体)

項目 意味 必須
id このコメントの背番号。後から変えない c20260914-113355-a1b2
ts 書かれた日時 2026-09-14 11:33:55
page どのページか。/ で始まるパス /standard/invoice/
title そのページの見出し(表示用・200字まで) 請求書の出し方
text 本文 振込先が古い。2行目
status 書かれた時点では必ず open open
dest 送り先の背番号 main
destLabel 送り先の表示名(履歴に出す) #右腕
{"id":"c20260914-113355-a1b2","ts":"2026-09-14 11:33:55","page":"/standard/invoice/","title":"請求書の出し方","text":"振込先が古い。2行目","status":"open","dest":"main","destLabel":"#右腕"}

状態の変更(type が付いた行)

🔴 状態を変えるときも、元の行は絶対に書き換えません

「編集」ではなく 「状態が変わった、という記録を1行足す」 で表します。 読むときに同じ id の状態行を後ろから畳んで、いまの状態を出します。

項目 意味 必須
type 固定で status status
id どのコメントの話か c20260914-113355-a1b2
status open / doing / done のどれか done
ts 変えた日時 2026-09-14 14:02:10
{"type":"status","id":"c20260914-113355-a1b2","status":"done","ts":"2026-09-14 14:02:10"}

なぜ1つの JSON にまとめないのか

まとめると、読む → 直す → 書き戻すの3手になります。 2人が同時に書くと、後から書き戻したほうが、先の1件を無かったことにします。 追記だけなら、その事故が起こりようがありません


4. 保存場所と、なぜそこなのか

<データ置き場>/
  comments/
    comments.jsonl        記録。追記のみ。消さない
    destinations.json     通知口の名簿。🔴 権限 600・git に入れない
決め 理由
site/ の外 mkdocs buildsite/ を丸ごと作り直すため(→ 2節
git に入れない 中にWebhook の URL(=そのチャンネルに誰でも投稿できる鍵)が入るため
権限 600 同じ機械の他の利用者からも読めないようにする
バックアップは comments.jsonl だけ取ればよい 追記のみなので、コピーした時点までが必ず正しい。壊れた復元が起きない

5. API の仕様

窓口は4つだけです。すべて JSON を返します。

① コメントを送る

POST /api/comment
リクエスト 必須 説明
page 文字列 / で始まること。そうでなければ 400
text 文字列 前後の空白を落として空なら 4008000字を超えたら 400
title 文字列 200字で切る
dest 文字列 送り先の背番号。知らない値なら既定へ落とす(送れないより送る)
{"ok": true, "id": "c20260914-113355-a1b2", "notified": true,
 "dest": "main", "destLabel": "#右腕"}

notified は正直に返します

Discord へ流せなかったときは notified: false。でも oktrue のままです。 保存は成功しているからです。ここを false にすると、画面が「失敗した」と誤解して 書いたものを捨てます。

② 履歴を読む

GET /api/comments

状態行を畳み込んだ結果を、新しい順に返します(60件まで)。

{"comments": [{"id":"...","ts":"...","page":"/standard/invoice/","title":"請求書の出し方",
               "text":"...","status":"done","status_ts":"2026-09-14 14:02:10",
               "destLabel":"#右腕"}]}

③ 状態を変える

POST /api/comment/status
リクエスト 必須 説明
id 文字列 無ければ 400
status 文字列 open / doing / done 以外は 400
{"ok": true, "id": "c20260914-113355-a1b2", "status": "done"}

右腕は、これを1行で叩けます

curl -s -X POST http://<あなたのサーバ>/api/comment/status \
     -H 'Content-Type: application/json' \
     -d '{"id":"c20260914-113355-a1b2","status":"done"}'

④ 送り先の一覧

GET /api/comment/destinations
{"default": "main",
 "destinations": [{"id": "main", "label": "#右腕"},
                  {"id": "keiri", "label": "#経理"}]}

🔴 ここに Webhook の URL を混ぜてはいけません

返すのは 背番号と表示名だけです。 URL を返すと、ブラウザの画面を右クリックして「ソースを表示」した人全員が、 そのチャンネルに何でも投稿できるようになります。 受入条件 A6 は、応答を文字列として検索して http が無いことで機械判定します。

エラーの返し方(共通)

起きたこと ステータス 本文
JSON として読めない 400 {"error": "bad json"}
本文が空 400 {"error": "text required"}
本文が長すぎる 400 {"error": "text too long"}
page/ で始まらない 400 {"error": "bad page"}
知らない状態名 400 {"error": "status must be one of open/doing/done"}
本文の大きさが 64KB を超える 400 {"error": "bad content-length"}
知らない窓口 404

6. 画面の仕様

flowchart TB
    B["💬 ヘッダーのボタン<br>(書きかけがあると ● が付く)"] --> P
    subgraph P["吹き出し(画面の右下)"]
        direction TB
        T1["【書く】タブ"] --- T2["【履歴】タブ"]
        T1 --> X["右上: × 閉じる / _ 下端にしまう"]
        T1 --> A["本文(打つそばから自動保存)"]
        A --> S["送り先 ▼"]
        S --> BTN["保存 / クリア / 送信"]
    end
    BTN -->|保存| L["ブラウザに残すだけ"]
    BTN -->|クリア| Q["確認してから消す"]
    BTN -->|送信| SV["サーバへ → Discord へ"]
どこ 決め
ボタンの位置 ページのヘッダー(右上)。ページ本文の下ではありません。スクロールしなくても押せることが条件
出るページ 全ページmkdocs.yml の1行で入るので、付け忘れが起きません
吹き出し 画面右下に浮かせる。ページの内容を隠さない(幅 380px 程度・高さは画面の 7割まで)
書きかけの残り方 そのブラウザの中だけlocalStorage)。ページごとに1本。端末をまたぎません
× と _ の違い × は閉じる、_ は下端の細いバーにしまう。どちらも書きかけは消えません
クリア 🔴 確認を1回挟む(元に戻せないため)
送信のあと 本文が空になり、「〈送り先〉へ流しました」と数秒だけ出る
送信に失敗したら 🔴 書きかけを消さない。「送信できませんでした」と出して、本文をそのまま残す
送り先のプルダウン 送り先が2つ以上あるときだけ出す。1つなら出さない(選ばせない)
最後に選んだ送り先 そのブラウザが覚える。🔴 「最後にメッセージが来たチャンネル」を推測してはいけません(別の相手へ誤送信する事故が起きます)
履歴タブ 新しい順。状態の札(未対応=橙/対応中=青/完了=灰)+ 日時 + 送り先 + 元ページへのリンク
状態を変えるのは 右腕5節の③)。画面は表示するだけにします。人が押して直す画面は、最小構成では作りません

見出しの を表題に混ぜないこと

MkDocs は見出しの末尾に (その見出しへのリンク)を挿します。 そのまま表題として送ると、Discord に 請求書の出し方¶ と出ます。末尾から落としてください。


7. Discord への送り方と、右腕が起きるまで

送り方

使うもの Discord の Webhook(チャンネル設定から作る、投稿専用の URL)
Bot のトークンは使わない トークンはそのチャンネル以外にも手が届く鍵です。Webhook はそのチャンネルに投稿することしかできません(弱い鍵で足りるなら弱い鍵を使う)
送る中身 表題 + 本文を > で引用 + 元ページの URL + コメントの背番号
長さ Discord の1通は 2000字まで1500字で切って「…(以下略)」を付ける(全文は履歴で読めます)
🔴 名乗り(User-Agent) 必ず自分で書き換えること。 Python の既定の名乗り(Python-urllib/3.x)は Discord に 403 で弾かれます。手元の試験では自前の受け皿を使うのでこの穴は出ません(実機で1回は通すこと)
💬 **Wiki コメント** — 請求書の出し方
> 振込先が古い。2行目
http://<あなたのサーバ>/standard/invoice/
`c20260914-113355-a1b2`

名簿の作り方(URL を人目に触れさせない)

🔴 Webhook の URL を、チャットに貼らせないでください

貼った時点で、その会話を読める全員がそのチャンネルに投稿できます。 画面に出ない形で受け取る小さな道具を1本用意します。

# 対話で受け取る(入力中も画面に出ない)
read -rsp "Discord の Webhook URL を貼って Enter(画面には出ません): " URL

受け取ったら destinations.json に書き、権限を 600 にして終わりです。 名簿の形:

{"default": "main",
 "destinations": [{"id": "main", "label": "#右腕", "webhook": "https://discord.com/api/webhooks/..."}]}

右腕が起き上がるまでの経路

flowchart LR
    C["送信"] --> S1["① 残る<br>comments.jsonl に追記"]
    S1 --> S2["② 出る<br>Discord に投稿される"]
    S2 --> G1{"③ 機械の発言を<br>受け付ける設定か"}
    G1 -->|いいえ(既定)| X["ここで捨てられる<br>🔴 何のエラーも出ない"]
    G1 -->|はい| G2{"④ 呼びかけ方<br>(メンション等)"}
    G2 --> S3["⑤ 右腕が起き上がる"]
    style X fill:#a33,color:#fff
    style S3 fill:#2f6f4e,color:#fff

🔴 いちばん転ぶのはここです。Discord は機械の発言を既定で捨てます

Webhook から投稿されたものは、「人」ではなく「機械(bot)」の発言として扱われます。 多くのエージェントの受け口は、機械の発言を既定で無視します。 しかも 「機械かどうか」の判定は、「自分が呼ばれたかどうか」の判定より先に効きます。

つまり
メンションを付ければ届く 届きません。機械判定のほうが先なので、そこで捨てられます
自動返信(autoreply)の設定が悪い 違います。そこまで話が進んでいません
許可リストに、その Webhook の背番号を入れる これが唯一の入口です

起きないときの切り分け(3手)

上から順に。1手で1つだけ確かめます。

確かめること 出た結果 次にやること
1 Discord のチャンネルを目で見る。投稿は出ているか? 出ていない サーバ→Discord の問題です。notified の値と、サーバのログを見ます。403 なら名乗り(User-Agent)401/404 なら Webhook の URL
2 投稿は出ている。許可リストに、その Webhook の背番号が入っているか? 入っていない 🔴 ここで止まっています。入れて再起動します。autoreply をいじっても直りません
3 入れたのに、たまに反応しない 機械どうしの連投の上限です(既定で3通程度)。人が1回発言すればリセットされます

Webhook の背番号(ID)の調べ方

Webhook の URL をそのまま開く(GET する)と、{"id": "...", "name": "...", "channel_id": "..."} が返ります。 この id が、その Webhook が投稿したときの発言者の背番号です。許可リストにはこれを入れます。

🔴 既存の通知用 Webhook を、許可リストに入れないこと

監視やビルドの通知に使っている Webhook を入れると、通知が届くたびに右腕が起き上がります (使った分だけ費用がかかります)。コメント専用の Webhook を1本、新しく作ってください。 許可リストに「全部許可」を入れるのも同じ理由で駄目です。

終わりの印:誰も話しかけていないのに、コメント1本で右腕が動き出した(受入条件 A9)。


8. 壊れ方の設計

「壊れないもの」は作れません。「壊れ方を選ぶ」ことしかできません。

ここで選ぶのは、どの場合でも〈書いたものは失われない〉という1点です。

起きること どうなってほしいか 作り
Discord が落ちている 記録は残る。画面は正直に「通知は失敗した」と言う 保存 → 通知の順。通知の失敗で保存を巻き戻さない。ok:true / notified:false
2人が同時に送信した どちらも残る 追記専用で開く(O_APPEND)。1行が短いので、OS が行を混ぜない。読んで書き戻す作りにしない
たくさん同時に来た 接続を蹴らない サーバの待ち行列を広げる(既定の 5 だと 25件同時で落ちます
記録ファイルの行が壊れた 壊れた行だけ飛ばし、読めた分は返す 1行ずつ読んで、JSON として読めなければ無視
本文が長すぎる 書く前に断る 8000字を超えたら 400。Discord へは 1500字で切る
本文に改行がある Discord で1つの引用に見える 各行の頭に > を付ける(1行だけ付けると2行目以降が地の文になります)
本文にコードブロックがある(バッククォート3つで囲んだ部分) 表示が崩れない 途中で切ると囲いの数が奇数になり、Discord の表示が壊れます。切るときはいったん囲いを閉じてから切る
送信が失敗した 書いたものが消えない 🔴 画面側で書きかけを消さない(消すのは成功したときだけ)
ブラウザのプライベートモード等で保存できない 止まらない 書きかけが残らないだけで、入力と送信は続けられる
通知口の名簿が無い / 壊れている 画面が固まらない 送り先を「未設定」と出し、送信ボタンだけ止める(黙って既定へ送らない)

9. 検査 — 合格条件を機械で判定する

🔴 検査があることと、検査が効いていることは別です

通した数だけを報告させないでください。 わざと壊したものを1回食わせて、ちゃんと落ちること(=陰性対照)まで見せてもらいます。

検査の組み立て方

本物の Discord を叩かずに検査します。自分で小さな受け皿を1つ立てて、通知先をそこに向けます。

flowchart LR
    T["検査プログラム"] --> S["コメントサーバ<br>(一時フォルダを data に指定して起動)"]
    S --> H["偽の通知口<br>(自分で立てた受け皿)"]
    H --> T

陽性対照(通るべきもの)

# 確かめること 判定
1 送信できる 200id が返る
2 記録が1件1行で増える ファイルの行数が 1 増える
3 履歴で読める/初期状態が open /api/comments の1件目が open
4 状態変更で本体が書き換わらない 変更後のファイルが、変更前のファイルで始まっている(前方一致)/行数が +1
5 状態が畳まれて読める 本文は変わらず status だけ done
6 25件同時で欠けない 25件すべて読み出せる(欠け 0
7 Discord が落ちても保存は成功 ok:true / notified:false + 記録は在る
8 通知に元ページの URL と引用が入る 受け皿が受け取った本文に > と URL が含まれる
9 名乗りが既定でない 受け皿が見た User-Agent が Python-urllib始まらない
10 送り先一覧に URL が混ざらない 応答を文字列にして httpwebhook含まれない

陰性対照(落ちるべきもの)

# 食わせるもの 期待
11 本文が空白だけ 400
12 本文が 9000字 400
13 pagejavascript:x 400
14 状態が bogus 400
15 知らない窓口 /api/nope 404

検査器そのものを疑う(1回だけやる)

やること 期待
記録の保存を、わざと「読んで書き戻す」作りに差し替えて、6番を回す 6番が落ちること。落ちないなら、その検査は同時書き込みを見ていません
通知口を止めずに 7番を回す 7番が落ちることnotifiedtrue になる)。落ちないなら、7番は何も見ていません

終わりの印:陽性10・陰性5 の実行結果そのものが並び、上の2つの「壊した版」でちゃんと落ちたことが見せられている。


10. 出来たと言われたら、この2つを見せてもらう

🔴 「動きました」「エラーはありませんでした」は、出来たことの証拠ではありません

見せてもらうもの なぜ
受入条件 A1〜A9 の、実際の出力07の8節 「確認しました」だけの報告は、確かめていない報告です
A9 の実機スモーク 🔴 誰も話しかけていないのに、右腕が動き出したこと。これだけは書類では証明できません。起きたこと自体が合格の証拠です

11. 用語

言葉 言い換え
API(窓口) プログラムどうしの受付。URL を決めた形で叩くと、決めた形で返ってくる
JSONL 1行に1件ずつ JSON を書いて並べたファイル。末尾に足すだけで使える
追記のみ(append-only) 足すだけ。書き換えも削除もしない。だから同時に触っても壊れない
Webhook(通知口) そのチャンネルに投稿するためだけの URL。鍵と同じ扱いにする
User-Agent(名乗り) 通信するときに相手へ伝える「どのソフトか」。既定のままだと断られることがある
陽性対照 / 陰性対照 通るべきものが通るか/落ちるべきものが落ちるか。両方見ないと、検査が効いているか分からない
常駐(デーモン) 機械が動いている間ずっと動き続けること。pm2 が面倒を見る
localStorage ブラウザの中の小さな置き場。その端末のそのブラウザにだけ残る