創作サイトに、記事ごとの送信元を記録できる匿名の「感想・ひとこと」フォームを自作した。
コメント欄は少し重い。お問い合わせフォームはもっと重い。SNSで公開リプライを送るほどではないけれど、作者には伝えたい感想もある。
この記事では、WordPress本体のコメント機能や外部フォームサービスを使わず、PHPとSQLiteで小さなフォームを作り、WordPressの記事末へ埋め込むまでに考えたことをまとめる。
公開試用版はここに置いている。
https://kotodutefolio.com/kotodute-lab
私は超文系の事務職で、PHPは書けない。実装はAIに任せ、画面を見ながら要件と修正内容を伝える役を担った。
前回の記事では、個人創作サイト本体をWordPressで組み直した経緯を書いた。今回は、その上に載せた小さなフォームの話である。
前回の記事:WEB制作を学んで「畳んだ」会社員が、Claude Codeに丸投げして「同人サイトの裏ページ」をWordPressで実装した話
作りたかったもの
最初に決めたのは、「感想を求めるフォーム」ではなく、反応を置ける場所にすることだった。
必要だったのは次の三段階である。
- 文章を書かなくても送れる定番リアクション
- もう少し気持ちを足したい人向けの絵文字
- 言葉を残したい人向けの短い自由記述
定番リアクションは4つに絞った。
- 読みました
- 沁みた
- 静かに好き
- 続きが気になる

「感想をください」と大きく構えるより、読み終えた場所でボタンひとつを押せる方が、読者にも作者にも自然だと思った。
なぜ外部サービスではなく自作したのか
マシュマロやWaveboxのようなサービスは、匿名の文章と絵文字の反応という意味でとても近い。
それでも今回は自作した。理由は機能の不足より、データと導線を自分のサイト側で持ちたかったからだ。
- どの記事から届いた反応かを分かるようにしたい
- 受信データを自分の環境に残したい
- サイトの言葉・色・余白に合わせたい
- 読者へ反応数を見せない
- 記事末へ直接置きたい
サービス終了やアカウント停止で、長く使っていた場所が触れなくなる話を何度も見てきた。作品を自分のサイトへ置くなら、そこへ届いた言葉もできるだけ自分の場所で受け取りたかった。
全体の構成
フォーム本体は、WordPressのテーマへ全部を書き込む形にはしなかった。独立した小さなPHPアプリにして、WordPressは表示場所と送信元情報を渡す役に分けた。
WordPress記事
└ 記事末の iframe
└ /kotodute/ (PHP + SQLite)
├ 送信画面
├ 送信完了画面
└ 作者用管理画面
| 役割 | 実装 |
|---|---|
| フォーム表示・送信 | PHP |
| データ保存 | SQLite |
| 記事末への設置 | WordPress子テーマ |
| 管理画面 | 独立PHPページ |
| 本番反映 | PHPアプリの配置 + 子テーマの更新 |
フォーム本体が独立しているので、WordPressのテーマを大きく複雑にせずに済む。反対に、記事末へ置く部分は投稿タイプごとの表示方法を理解する必要があった。
記事ごとの送信元を渡す
運用するとき、どの記事から届いた感想かを追えないフォームは不便である。
そこで、個別ページであることを確認したうえで、iframeのURLへ投稿ID・タイトル・URLをクエリとして渡している。以下は公開用にパラメータ名と関数名を一般化した例である。
if ( ! is_singular( array( 'custom_post_type' ) ) ) {
return;
}
$post_id = get_queried_object_id();
$args = array(
'embed' => '1',
'type' => 'embed',
'post_id' => $post_id,
'title' => get_the_title( $post_id ),
'url' => get_permalink( $post_id ),
);
$src = add_query_arg( $args, home_url( '/kotodute/' ) );
フォーム側では、送信内容と一緒に投稿種別・投稿ID・タイトル・URLをSQLiteへ保存する。実際のフィールド名は、公開コードでは伏せている。
管理画面では長いURLをそのまま出さず、記事タイトルをリンクにした。受け取った感想から、元の記事へすぐ戻れる。
iframeの高さをどう合わせたか
記事末のフォームは、表示内容によって高さが変わる。
- ひとこと入力欄を開く
- 絵文字一覧を開く
- 送信後の完了画面に切り替わる
iframeの高さを固定すると、画面下が切れたり、逆に大きな空白が出たりする。そこでフォーム側から親ページへ現在の高さを通知し、親側でiframeの高さを更新する方式にした。
フォームと親ページは同一オリジンに置いている。フォーム側では postMessage を送る。
window.parent.postMessage(
{ type: 'feedback-resize', height: document.body.scrollHeight },
window.location.origin
);
親ページでは、送信元が同じオリジンかとメッセージ種別を確認してから反映する。
window.addEventListener('message', function (event) {
if (event.origin !== window.location.origin) return;
if (!event.data || event.data.type !== 'feedback-resize') return;
var height = parseInt(event.data.height, 10);
if (!Number.isFinite(height) || height < 120) return;
iframe.style.height = height + 'px';
});
ここは一度で決まらなかった。完了画面のろうそく上部の余白、本文とフォームの距離、スマホでの縦スクロールなど、数十ピクセル単位の調整を何度か繰り返した。
UIは「送信させる」より「迷わせない」
最初の実装では、フォームを開いたときに選択肢や説明が多く、少し重く見えた。
そのため、次のように整理した。
- 4つの定番反応は最初から見せる
- 絵文字と自由記述は必要なときだけ開く
- フォーム単独ページでは、PC・スマホとも定番反応を2列にする
- サイトの個別記事末に埋め込むときだけ、定番反応を横4列にする
- 「続きが気になる」は不自然な位置で一文字だけ改行されないようにする

機能として正しくても、押す前に「何を書けばいいのだろう」と思わせる画面は目的から遠い。フォームでは、文章を書く人だけを想定しない方がよかった。
感想を紹介してよいか、どう聞くか
届いた感想をサイトやSNSで紹介する可能性もある。ここは最初、次のラジオボタンを置いていた。
- 作者だけに届ける
- 匿名で紹介してOK
しかし、送信のために判断をひとつ増やしてしまう。匿名に関する説明も画面内に散りやすかった。
最終的には、自由記述があるときだけ、任意のチェックボックスひとつにした。
□ サイトやSNSで紹介してもOK
紹介する場合は内容のみを匿名で使用します。
個別の返信は行っておりません。

何もしなければ作者だけに届く。チェックした人の内容だけ、紹介可能として保存する。
既存の feedback テーブルに、紹介可否を表す share_allowed を追加した。もともとのテーブルは、次のような小さな構成だった。
CREATE TABLE feedback (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at TEXT NOT NULL,
reaction TEXT,
message TEXT,
name TEXT,
ip_hash TEXT NOT NULL,
is_hidden INTEGER NOT NULL DEFAULT 0,
is_read INTEGER NOT NULL DEFAULT 0
);
起動時にカラム一覧を確認し、既存DBには不足しているカラムだけを追加するようにした。
share_allowed INTEGER NOT NULL DEFAULT 0
管理画面では、紹介可能な投稿だけに「紹介OK」と共有用リンクを出す。送信者名や送信元URLは共有文へ含めない。
安全側を初期値にしつつ、紹介したい人の意思も拾える形になった。
WordPressへ置くときに詰まったところ
サイトには writings、visuals、journal という複数の投稿タイプがある。
writings と journal は本文フィルターで記事末へ追加できた。一方、visuals は独自テンプレートで本文を描画していたため、同じ方法ではフォームが出なかった。
そこで映像・絵図の個別ページだけは、使用テーマが用意している末尾フックから追加した。フック名はテーマごとに異なるため、以下は汎用化した例である。テーマに同等のフックがなければ、the_content フィルターや個別テンプレート側で差し込むことになる。
add_action( 'mytheme_end_entry_main', function () {
if ( ! is_singular( 'custom_post_type' ) ) {
return;
}
echo my_feedback_iframe_markup();
}, 30 );
「同じように見える個別ページでも、実際の描画経路は同じとは限らない」という、WordPressテーマ作業らしい学びだった。
最低限入れた防御
公開フォームなので、最初から何も対策しないわけにはいかない。実装したのは次の範囲である。
- CSRF対策
- ハニーポット
- 送信間隔の制限
- 同一IPハッシュごとの時間あたり上限
- NGワード
- 管理画面のログイン制限
- SQLiteデータベースをWebから直接取得できないようにする設定
IPアドレスそのものではなく、ソルト付きハッシュを使うようにした。フォームの目的に対して、必要以上に個人情報を集めないことも意識した。
たとえば送信制限は、同じIPハッシュについて直近1時間の件数と最終送信時刻だけを調べている。
$stmt = $pdo->prepare(
"SELECT COUNT(*) AS count, MAX(created_at) AS last_sent
FROM feedback
WHERE ip_hash = ?
AND created_at > datetime('now', '-1 hour', 'localtime')"
);
$stmt->execute(array($ip_hash));
$row = $stmt->fetch();
$too_fast = $row['last_sent']
&& ( time() - strtotime($row['last_sent']) ) < $minimum_interval;
上限やエラー時の応答はサイトの運用に合わせて決めるべきなので、ここでは考え方だけを示した。
本番フォームと試用版を分けた
SNSでURLを共有して、第三者に好きに押してもらえる場所もほしかった。
ただし、本番の感想箱に試験投稿が混ざるのは避けたい。そこで /kotodute-lab/ を別ディレクトリに作った。
/kotodute/ 本番の感想フォーム
/kotodute-lab/ 公開試用版
ディレクトリを分けることで、SQLite DB・管理画面・合言葉・IP匿名化ソルトも分離できる。LABは検索結果に出さないよう noindex にし、記事末やフッターからはリンクしない。
「試してもらう場所」と「大切に受け取る場所」を分けたことで、運用時の気持ちがかなり楽になった。
AIと作るとき、人が決めるところ
今回、フォーム本体の機能や管理方法はClaude Codeと相談し、WordPressへの組み込みはCodexと進めた。画面の調整にはClaudeのデザイン提案も使っている。
コードの多くはAIが書いた。
それでも、最後まで人間側に残る判断は多かった。
- 「読みました」は置くが、ハートは置きすぎない
- 自由記述欄は最初から開かない
- 感想紹介の許可は必須にしない
- 本文とフォームの間をどれだけ空けるか
- どの記事の末尾に置くか
- 送信完了画面の文言・装飾・余白をどこまで抑えるか
AIは実装候補を素早く出してくれる。でも「このサイトで読者が押しやすいか」「作品を読んだ直後の気持ちを邪魔しないか」は、実際の画面を見ながら決める必要があった。
おわりに
感想をお願いする前に、置ける場所を作った。
コメント欄より軽く、お問い合わせより近い。押すだけでも、書いてもいい。読者が言葉を置くかどうかは自由だが、置ける場所があるだけで、作者として少し落ち着く。
郵便受けはできた。あとは届くのを待つだけである。
この記事も、構成と素材を筆者が用意し、Codex(GPT)で本文を作成したあと、Claude(claude.ai)へレビューと修正を依頼した。技術的な内容について筆者自身で判断できない部分もあるため、誤りがあれば指摘してほしい。
不具合報告も歓迎している。
◇
このフォームを置いているサイトでは、小説も公開している。近代幻想の連作で、標本医と書記官が名づけられないものを観測し記録する話である。よければ文庫の棚からどうぞ。
サイト:https://kotodutefolio.com