3画面パック ⑧ コメント機能 — 仕様書¶
結論(3行)
- 静的なサイトのままでは作れません。配信を「ファイルを置くだけ」から 小さな常駐サーバ1本に差し替えます。
- 記録は1件1行の追記だけにします。まとめて1つのファイルを書き戻す作りにすると、同時に書いたときに消えます。
- 記録の置き場は、サイトの外です。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 build は site/ の中身を消してから作り直します。
つまり 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.yml の extra_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 build が site/ を丸ごと作り直すため(→ 2節) |
| git に入れない | 中にWebhook の URL(=そのチャンネルに誰でも投稿できる鍵)が入るため |
| 権限 600 | 同じ機械の他の利用者からも読めないようにする |
バックアップは comments.jsonl だけ取ればよい |
追記のみなので、コピーした時点までが必ず正しい。壊れた復元が起きない |
5. API の仕様¶
窓口は4つだけです。すべて JSON を返します。
① コメントを送る¶
POST /api/comment
| リクエスト | 型 | 必須 | 説明 |
|---|---|---|---|
page |
文字列 | ✅ | / で始まること。そうでなければ 400 |
text |
文字列 | ✅ | 前後の空白を落として空なら 400。8000字を超えたら 400 |
title |
文字列 | — | 200字で切る |
dest |
文字列 | — | 送り先の背番号。知らない値なら既定へ落とす(送れないより送る) |
{"ok": true, "id": "c20260914-113355-a1b2", "notified": true,
"dest": "main", "destLabel": "#右腕"}
notified は正直に返します
Discord へ流せなかったときは notified: false。でも ok は true のままです。
保存は成功しているからです。ここを 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 | 送信できる | 200 + id が返る |
| 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 が混ざらない | 応答を文字列にして http も webhook も含まれない |
陰性対照(落ちるべきもの)¶
| # | 食わせるもの | 期待 |
|---|---|---|
| 11 | 本文が空白だけ | 400 |
| 12 | 本文が 9000字 | 400 |
| 13 | page が javascript:x |
400 |
| 14 | 状態が bogus |
400 |
| 15 | 知らない窓口 /api/nope |
404 |
検査器そのものを疑う(1回だけやる)¶
| やること | 期待 |
|---|---|
| 記録の保存を、わざと「読んで書き戻す」作りに差し替えて、6番を回す | 6番が落ちること。落ちないなら、その検査は同時書き込みを見ていません |
| 通知口を止めずに 7番を回す | 7番が落ちること(notified が true になる)。落ちないなら、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 | ブラウザの中の小さな置き場。その端末のそのブラウザにだけ残る |