⚠️ 現在製作中のため、内容は不正確なことがあります。
D1W で「同じブラウザから投稿した人には同じ会員番号」という仕様を Claude Code に作ってもらった。本記事では、その「同じブラウザ判定」を自分で組み立てるパターンを学ぶ。題材は いいねカウント だが、応用範囲は広い。
Cloudflare Pages Functions と D1 で いいねカウントAPI を作り、それを自分のサイトに ブログパーツ として埋め込む(<script> 1行で動く形)。
APIは別経路(ブックマークレット・Chrome拡張機能)からも使えるように、共通の window.likeApi というインターフェースを公開しておく。それらの呼び出しは続編の LEX(いいねカウントAPIをブックマークレットとChrome拡張機能から使う) で扱う。
本記事は応用編。ロードマップの Git と GitHub の基本・Wranglerハンズオン・CGA(GitHub Actions)・D1W(D1 Webアプリ) を一通り体験している前提で進める。
ページごとの「いいね数」をD1に保存し、ブラウザから取得・+1できる小さなAPIサービス。同じブラウザからは1回しかカウントが増えないように、crypto.randomUUID() でクライアントごとのIDを発行し、サーバー側で重複判定する。
全体像:
[ブラウザ] [Cloudflare]
ブログパーツ
<script src=...like.js>
<button id="like-button">♥</button> Pages Functions D1
│ /api/like/:id ──→ pages
↓ fetch (UUID付きPOST) ──→ likes
──────────────────────────→
題材はいいねカウントだが、同じパターン(API + UUID + INSERT OR IGNORE)は「閲覧回数」「ブックマーク数」「お気に入り」などにそのまま使える。
このハンズオンで作るのは「個人ブログで動く程度のいいねボタン」。本格的な不正対策(連打防止・複アカ防止)は範囲外。詳しくは 第11章 担保の範囲と限界 で正直に説明する。
GitHubアカウントの作成や gh のセットアップはGit と GitHub の基本を参照のこと。
GitHub → New repository → リポジトリ名を mk-like-api で作成。Private にしておく(意図しないファイルの公開を防ぐため)。
名前は別のものでもよい。以降
mk-like-apiと出てきたら自分のリポジトリ名に読み替える。
Cloudflareアカウントがなければ作る(こちらの手順(1-1))。
GitHub ActionsでCloudflare自動デプロイを設定するの 2章「事前準備」 を参照して、Account IDの確認・APIトークンの作成・GitHubへの Secrets 登録を行う。完了したらここに戻る。
ターミナル(Claudeデスクトップアプリ右上の ビューメニュー → ターミナル でもよい)で以下を実行する。
mkdir -p ~/Desktop/claude
cd ~/Desktop/claude
git clone [email protected]:ユーザー名/mk-like-api.git
GitHub への接続がまだの場合はGit と GitHub の基本を参照。
Claudeデスクトップアプリを起動。
Code(Claude Code)を選択 → New session をクリック → 作業フォルダを指定(~/Desktop/claude/mk-like-api)
公開ファイルは public/ 配下、サーバー側のAPI実装は functions/api/ 配下、設定ファイルはルート直下に置く構成にする。
Wrangler ハンズオンを完了している場合、Node.jsとWranglerログインは済んでいるはず。下記コマンドでログイン状態を確認する。
npx wrangler whoami
アカウント名やメールアドレスが表示されればOK。表示されない場合はWranglerハンズオンの2章を参照してインストール・ログインする。
D1データベースとPages Functionsを接続するための設定ファイル。Claude Code に作成を依頼する。
以下のテンプレートで wrangler.toml を作成してください。
プロジェクト名は「mk-like-api」、データベース名は「mk-like-api-db」、YYYY-MM-DDは昨日、
database_id はあとで記入するので xxxxxx のままにしておいてください。
---
name = "プロジェクト名"
compatibility_date = "YYYY-MM-DD"
pages_build_output_dir = "./public"
[[d1_databases]]
binding = "DB"
database_name = "データベース名"
database_id = "xxxxxx"
migrations_dir = "migrations"
---
pages_build_output_dir = "./public" で公開対象を public/ 配下のファイルに絞る。
ターミナルで実行するか、Claude Code にプロンプトとして渡す。
npx wrangler d1 create mk-like-api-db
「既に存在しています」エラーが出たら、別名(例:
mk-like-api-db2)で作り直す。wrangler.tomlのdatabase_nameも合わせて書き換える。
実行すると database_id が表示される。Claude Code に伝えて wrangler.toml を書き換えてもらう。
wrangler.toml の database_id を「xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx」に書き換えてください。
Database ID(
database_id) — データベースの識別子。仮に外部に漏れても、API Tokenがなければ操作できないため問題なし。
Claude Code にアプリの仕様を伝えてテーブル設計を相談する。
いいねカウントAPIを作ります。仕様は以下のとおりです。
- ページ単位でいいね数をカウントする
- 同じブラウザから何度押しても1票しかカウントされないようにする
(ブラウザ側で localStorage に UUID を持つ前提)
- いいね数の取得と +1 のAPIを用意する
このアプリに必要なデータベースのテーブル設計を提案してください。
Claude Code が以下のようなスキーマを提案してくれる。
CREATE TABLE pages (
id TEXT PRIMARY KEY,
count INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE likes (
uuid TEXT NOT NULL,
page_id TEXT NOT NULL,
PRIMARY KEY (uuid, page_id)
);
pages: ページごとの「いいね数」を保持する集計テーブルlikes: 「誰が(uuid)どのページに(page_id)いいねしたか」の事実テーブル。複合主キー (uuid, page_id) が1ブラウザ1いいねの担保この複合主キーを INSERT OR IGNORE と組み合わせると、SQLレベルで「初回だけ追加、2回目以降は無視」が一文で書ける。これが重複判定の核心パターン。
INSERT OR IGNORE — SQLite/D1 で「制約違反になる INSERT は静かに無視する」構文。
INSERT INTO ...でPK重複なら通常はエラーになるが、INSERT OR IGNORE INTO ...なら何も起きずに 0 行影響で終わる。今回は「既にいいね済み」を判定する用途で使う。
「pages.count を別に持つ理由」は、毎回 SELECT COUNT(*) FROM likes WHERE page_id = ? だと数が増えたとき重くなるため。POST 成功時だけ集計値を +1 して、GET を軽くする。
スキーマに納得したら、マイグレーションファイルを作る。
このスキーマでマイグレーションファイルを作成してください。
ファイルは migrations/0001_init.sql に保存してください。
REST 風にシンプルにまとめる。
エンドポイント
| メソッド | パス | 用途 |
|---|---|---|
GET |
/api/like/:id |
ページ :id のいいね数を取得 |
POST |
/api/like/:id |
ページ :id のいいね数を +1 |
:id はパスパラメータで、ページを識別する文字列(後述の正規化URL)。
レスポンス
GET /api/like/:id:
{ "count": 42 }
該当ページがまだ登録されていない場合は { "count": 0 }。
POST /api/like/:id:リクエストボディは { "uuid": "..." }。レスポンスは
{ "count": 43, "liked": true }
liked: true … 今回新規にいいねが追加されたliked: false … この UUID は既にこのページにいいね済みで、カウントは変わらないCORS は別オリジンから呼ぶ前提なので Access-Control-Allow-Origin: * を付ける。
Claude Code に依頼してファイルを作ってもらう。
Cloudflare Pages Functions で、以下の API を functions/api/like/[id].js に実装してください。
- GET: D1 の pages テーブルから id のカウントを取得して { count } を返す。レコードがなければ count: 0
- POST: リクエストボディの uuid を受け取り、likes テーブルに (uuid, page_id) を INSERT OR IGNORE。
影響行数が 1 なら pages の count を +1(レコードがなければ INSERT)し、{ count: 最新値, liked: true } を返す。
影響行数が 0 なら { count: 既存値, liked: false } を返す。
- CORS の Access-Control-Allow-Origin: * と OPTIONS への対応も入れる
- D1 の binding 名は DB
生成されたコードの動きを Claude Code に箇条書きで説明してもらってから、自分の手で一度はざっと読む。「いまどの SQL が走って、何を返しているか」が頭に入っていると、後でデバッグするときに楽。
GitHubにpushすると、GitHub ActionsがD1マイグレーションとCloudflare Pagesデプロイを自動実行する。
ワークフローファイル .github/workflows/deploy.yml を作成。Claude Code に依頼する。
以下のテンプレートで .github/workflows/deploy.yml を作成してください。
プロジェクト名は「mk-like-api」、データベース名は「mk-like-api-db」としてください。
---
name: Deploy to Cloudflare Pages
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Apply D1 migrations
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
wranglerVersion: "4"
command: d1 migrations apply データベース名 --remote
- name: Create Pages project (初回のみ)
continue-on-error: true
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
wranglerVersion: "4"
command: pages project create プロジェクト名 --production-branch=main
- name: Deploy to Cloudflare Pages
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
wranglerVersion: "4"
command: pages deploy ./public --project-name=プロジェクト名
---
ワークフローの流れ:
pushをトリガーに、以下の順で実行される。
actions/checkout) — GitHubのファイルをCI環境に展開するd1 migrations apply --remote) — 未適用のSQLファイルを本番DBに適用するpages project create) — 初回のみ実行。2回目以降は失敗するが continue-on-error: true で無視されるpages deploy) — public/ のファイルとPages Functions(functions/)を本番環境に反映するpush 前に .gitignore の設定。プロジェクトに作られる .wrangler/ フォルダ(ローカル状態のキャッシュ)はコミット不要なので追加しておく。
.gitignore に .wrangler/ を追加して
続けて push する。Claude Code にプロンプトを渡す。
git add .
git commit -m "initial"
git push -u origin main
GitHub Actions がワークフローにそって、マイグレーション・プロジェクト作成・デプロイを自動実行する。
デプロイ完了後、ブラウザで https://mk-like-api.pages.dev/api/like/test を直接開いてみる。{"count":0} が返ってくれば API は動いている。
POST の動作確認は curl などで:
curl -X POST https://mk-like-api.pages.dev/api/like/test \
-H "Content-Type: application/json" \
-d '{"uuid":"test-uuid-1"}'
{"count":1,"liked":true} が返ってきて、もう一度同じコマンドを叩くと {"count":1,"liked":false} になれば、UUID 重複判定が機能している。
API が動いたら、次は自分のサイトに <script> で埋め込んで動かす。共通ロジックは window.likeApi として公開し、続編 LEX のブックマークレット・Chrome拡張機能でも再利用できるようにしておく。
crypto.randomUUID() でランダムな UUID(v4)を作る。これがこのブラウザの「ローカル識別子」になる。
let uuid = localStorage.getItem('like-uuid');
if (!uuid) {
uuid = crypto.randomUUID();
localStorage.setItem('like-uuid', uuid);
}
初回アクセス時にだけ生成、以降は同じものを使い回す。
localStorage は オリジン単位(スキーム + ホスト + ポート)で永続化される。同じ人でも blog-a.com と blog-b.com では別の UUID になる点に注意。
localStorage — ブラウザに文字列で値を保存できる仕組み。Cookie と違ってサーバーに自動送信されない。クリアしないと永続。容量は5MB前後。
「いいねの集計単位」をどう決めるか。本記事では URL ベースで正規化したものを pageId として使う。
function getPageId() {
const u = new URL(window.location.href);
u.hash = ''; // # 以降を削除(SPA等の内部リンク)
u.search = ''; // ? 以降を削除(utm 等のトラッキングパラメータ)
let id = u.toString();
id = id.replace(/\/$/, ''); // 末尾スラッシュを削除
return id;
}
正規化ルールの理由:
| ルール | 例 | 理由 |
|---|---|---|
# 以降を削除 |
/post1#section → /post1 |
ページ内アンカーは同一ページ |
? 以降を削除 |
/post1?utm=twitter → /post1 |
SNSのトラッキング流入を別カウントしない |
末尾 / を削除 |
/post1/ → /post1 |
/post1 と /post1/ を同一視 |
ホスト名はそのまま残る(URL オブジェクトが自動で小文字化する)。
本格的な用途だと
?p=2のようなページネーション用パラメータも別カウントしたい場合があり、許可リスト方式の正規化が必要。本記事のサンプル実装では割り切って一律削除する。
このまま URL を pageId として使うので、pageId は https://example.com/post1 のような 読める文字列 になる。D1 で SELECT * FROM pages したときにデバッグしやすい。
public/like.js として配置する(Cloudflare Pages から配信される静的ファイル)。LEX のブックマークレットや Chrome 拡張機能でも同じファイルを再利用するので、共通ロジックは window.likeApi として公開しておく。
public/like.js を作って。以下の動作をする。
【共通ロジック(IIFE 内に閉じ込める)】
- localStorage から like-uuid を取得。なければ crypto.randomUUID() で発行して保存
- 現在ページのURLを正規化(hash と search を削除、末尾スラッシュを削除)して pageId にする
- getCount(): pageId の API(https://mk-like-api.pages.dev/api/like/:pageId)に GET して { count } を返す
- like(): 同じ API に uuid 付きで POST して { count, liked } を返す
【公開API】
- window.likeApi = { getCount, like } として外から呼べるようにする
【ボタン自動バインド(ブログパーツ用途)】
- DOMContentLoaded 後(または既に load 済みなら即時)、ページ内に <button id="like-button"> があれば
- getCount() で取得した数字を「♥ 数字」の形でボタンに表示
- クリックで like() を呼び、レスポンスの count でボタンを更新
- ボタンが無ければ何もしない(ブックマークレットから呼ばれる用途を想定)
ブログパーツを使う側のHTMLには、以下を貼る:
<button id="like-button">♥</button>
<script src="https://mk-like-api.pages.dev/like.js"></script>
これだけで、別ドメインのサイトからもこの API を使えるようになる。
ブックマークレットや Chrome 拡張機能から同じ API を呼ぶ方法は、続編 LEX(いいねカウントAPIをブックマークレットとChrome拡張機能から使う) を参照。
like.jsのwindow.likeApiをそのまま使い回す。
正直に書く。
| ケース | 結果 |
|---|---|
| 普通のユーザー(同じブラウザ) | 1票のみ。サーバー側 INSERT OR IGNORE で担保 |
| localStorage を消去した人 | 新しい UUID が発行されるので再カウント可能 |
| プライベートブラウジング | localStorage がセッション限り。閉じると毎回新規 UUID |
| 別ブラウザ・別デバイス | それぞれ別人扱い(仕様通り) |
| 別ドメインに埋め込まれた同一人物 | オリジンごとに別 localStorage なので別人扱い |
| 開発者ツールで UUID を毎回変える攻撃者 | 防げない |
| 連打する攻撃者 | UUID が同じなら2回目以降は無視されるが、毎回違う UUID を送られたら防げない |
つまり、これは 「素直なユーザーが意図せず多重投票することを防ぐ」 装置であって、「攻撃者の不正投票を防ぐ」 ものではない。
個人ブログのいいねカウントとしては十分なレベル。本格的な投票システムや有料コンテンツ評価などには向かない。
UUID は ユーザーごとに一意なランダム識別子 であり、扱い方によってはトラッキング目的にも使える。以下を守る:
「いいね数を集計する」だけが目的なら、UUID をDBに残す以上の処理は不要。
サンプル実装としては十分だが、もう少し本格的にしたい場合の方向性:
DELETE /api/like/:id を追加して、likes から行を消し、pages.count を -1Origin ヘッダーをチェックlikes.created_at を追加して時系列クエリこれらを少しずつ足していくと、本格的なリアクションシステムに育っていく。
2026-05-21 (last updated: 2026-05-24) タツヲ (yto)