コンテンツにスキップ

いいねカウントAPIをブログパーツとして使う

⚠️ このハンズオンは制作途中です。動かしてみることはできますが、説明や手順が今後書き換わる可能性があります。気になる点があればフィードバックいただけると助かります。

ページの「いいねカウント」を Cloudflare 上で動かす小さな API と、それを自分のサイトに ブログパーツ として埋め込む <script> 1行を作る。「同じブラウザからは1回だけカウント」の仕組みを自分で組み立てるのが今回のテーマ。題材はいいねだが、応用範囲は広い。

APIは別経路(ブックマークレット・Chrome拡張機能)からも使えるように、共通の window.likeApi というインターフェースを公開しておく。それらの呼び出しは続編の LEX(いいねカウントAPIをブックマークレットとChrome拡張機能から使う) で扱う。

本記事は応用編。ロードマップの Wranglerハンズオン を完了している前提で進める。Wrangler を使って手元から直接デプロイする方式なので、GitHub も Git連携も不要。

ページにいいねボタンを置きたいなら、Claude Code にこんなざっくりとした依頼でも進められる:

Claude
ブログに付けるいいねボタンを Cloudflare で作りたい

Claude Code は使う技術や構成を聞きながら案内してくれる。ただし、その過程で取る道は人によって違う。

本記事では、その中で Cloudflare Workers + Workers Static Assets + D1 を Wrangler で公開する 道筋を、つまずきにくい順序で追っていく。

ページごとの「いいね数」をブラウザから取得・+1できる小さなAPIサービス。

仕様:

  • ページ単位でいいね数をカウントする
    • URL単位でカウント
    • このAPIを使う全利用者のいいね数
  • 同じブラウザから何度押しても1票しかカウントされないようにする
    • ブラウザ側で十分な長さを持つランダムなIDを発行してそれをいいねAPIのIDとして使う
    • IDは localStorage で保存することで1ブラウザにつき 1 IDとなる
  • いいね数を取得するAPIといいねを +1 するAPIを用意
  • APIはあらゆるWebページからアクセスできる

使い方の一例:

  • ブログパーツ(APIとは別に作成)として、ブログの各記事のテンプレートに以下を貼り付ける
    <button id="like-button"></button>
    <script src="https://my-like-api.〇〇.workers.dev/like.js"></script>
  • <button> がいいねボタン本体、<script> で配信される like.js がボタンに「♥ 数字」の表示・クリック動作を結びつける
  • 記事ごとのURLが識別子になるので、テンプレートに1回貼るだけで全記事に効く

全体像。本記事はデータベース(4〜5章)→ バックエンド(6章)→ フロントエンド相当(8章)の順に作っていく。

sequenceDiagram
    participant pg as 自分のブログの記事ページ<br>=フロントエンド相当(8章)
    participant cf as Cloudflare(my-like-api)<br>=バックエンド(6章)+データベース(4〜5章)
    pg->>cf: like.js を読み込み(script src)
    cf-->>pg: like.js(window.likeApi)
    pg->>cf: fetch GET /api/like/:id
    cf-->>pg: { count }
    Note over pg: ボタンに「♥ 数字」を表示<br>読者がクリック
    pg->>cf: fetch POST /api/like/:id(client_id 付き)
    Note over cf: Worker が D1<br>(pages / likes)を読み書き
    cf-->>pg: { count, liked }
    Note over pg: ボタンの数字を更新

題材はいいねカウントだが、同じパターンで「閲覧回数表示」「不適切報告」「オンラインブックマーク」などにも流用できる。

このハンズオンで作るのは「個人ブログで動く程度のいいねボタン」。本格的な不正対策(連打防止・複アカ防止)は範囲外。詳しくは 9-1. 担保の範囲 で正直に説明する。

2-1. 作業フォルダを作って Claude Code を起動

Section titled “2-1. 作業フォルダを作って Claude Code を起動”

作業フォルダ ~/claude/my-like-api を作って、Claudeデスクトップアプリを起動。

Code(Claude Code)を選択 → New session をクリック → 作業フォルダを指定(~/claude/my-like-api

名前は別のものでもよい。以降 my-like-api と出てきたら自分のプロジェクト名に読み替える。

公開ファイルは public/ 配下、Worker のコードは src/index.js、設定ファイルはルート直下に置く構成にする。

Wrangler ハンズオンを完了している場合、Node.jsとWranglerログインは済んでいるはず。下記コマンドでログイン状態を確認する。

Terminal window
npx wrangler whoami

アカウント名やメールアドレスが表示されればOK。表示されない場合はWranglerハンズオンの2章を参照してインストール・ログインする。

Worker と D1、静的ファイル配信をつなぐための設定ファイル。Claude Code に作成を依頼する。

Claude
以下のテンプレートで wrangler.jsonc を作成してください。
プロジェクト名は「my-like-api」、データベース名は「my-like-api-db」、YYYY-MM-DDは昨日、
database_id はあとで記入するので xxxxxx のままにしておいてください。
---
{
"name": "プロジェクト名",
"main": "src/index.js",
"compatibility_date": "YYYY-MM-DD",
"assets": {
"directory": "./public",
"binding": "ASSETS"
},
"d1_databases": [
{
"binding": "DB",
"database_name": "データベース名",
"database_id": "xxxxxx",
"migrations_dir": "migrations"
}
]
}
---

wrangler.jsonc:Wrangler の設定ファイル。プロジェクトのルートに置く。Worker の名前、対応する Cloudflare ランタイムのバージョン、静的ファイルの置き場、データベースへの接続情報などをまとめて宣言する。.jsoncコメントが書ける JSON。Cloudflare は新規プロジェクトに wrangler.jsonc を推奨している。

各項目の意味:

項目内容
mainAPIリクエストを処理する Worker スクリプトのパス
compatibility_date使用する Workers ランタイムのバージョン基準日。UTC基準のため、JSTの今日の日付は未来日になる場合があるので前日以前を指定する
assets.directory静的ファイルの置き場(フロントエンド)
assets.bindingWorker から ASSETS にアクセスするための名前
d1_databasesD1 データベースの接続設定
migrations_dirマイグレーションファイルの置き場

4. 【データベース】D1 データベースを作成(初回のみ)

Section titled “4. 【データベース】D1 データベースを作成(初回のみ)”

ターミナルで実行するか、Claude Code にプロンプトとして渡す。

Terminal window
npx wrangler d1 create my-like-api-db

「既に存在しています」エラーが出たら、別名(例:my-like-api-db2)で作り直す。wrangler.jsoncdatabase_name も合わせて書き換える。

実行すると database_id が表示される。Claude Code に伝えて wrangler.jsonc を書き換えてもらう。

Claude
wrangler.jsonc の database_id を「xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx」に書き換えてください。

Cloudflare D1:Cloudflare が提供するサーバーレスの SQL データベース。中身は SQLite で、Worker から binding を通してアクセスする。サーバーを自前で立てる必要がなく、Cloudflare の世界の中で完結する。無料プランで1日あたり500万行の読み込み、10万行の書き込みまで使えるので、個人サイト規模のいいねカウントなら十分。

Database ID(database_id:データベースの識別子。仮に外部に漏れても、API Tokenがなければ操作できないため問題なし。

5. 【データベース】D1 スキーマとマイグレーション

Section titled “5. 【データベース】D1 スキーマとマイグレーション”

Claude Code にアプリの仕様を伝えてテーブル設計を相談する。

Claude
いいねカウントAPIを作ります。仕様は以下のとおりです。
- ページ単位でいいね数をカウントする
- 同じブラウザから何度押しても1票しかカウントされないようにする(ブラウザごとの識別子で判定)
- いいね数の取得と +1 のAPIを用意する
このアプリに必要なデータベースのテーブル設計を提案してください。

Claude Code が以下のようなスキーマを提案してくれる。

CREATE TABLE pages (
id TEXT PRIMARY KEY,
count INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE likes (
client_id TEXT NOT NULL,
page_id TEXT NOT NULL,
PRIMARY KEY (client_id, page_id)
);
  • pages: ページごとの「いいね数」を保持する集計テーブル
  • likes: 「誰が(client_id)どのページに(page_id)いいねしたか」の事実テーブル。複合主キー (client_id, page_id) が1ブラウザ1いいねの担保

この複合主キーを INSERT OR IGNORE と組み合わせると、SQLレベルで「初回だけ追加、2回目以降は無視」が一文で書ける。これが重複判定の核心パターン。

INSERT OR IGNORE:SQLite/D1 で「制約違反になる INSERT は静かに無視する」構文。INSERT INTO ... でPK重複なら通常はエラーになるが、INSERT OR IGNORE INTO ... なら何も起きずに 0 行影響で終わる。今回は「既にいいね済み」を判定する用途で使う。

マイグレーション:DB のスキーマ変更(テーブル作成や列追加など)を .sql ファイルとして管理する仕組み。migrations/0001_init.sqlmigrations/0002_...sql のように番号付きで並べておくと、未適用のものだけが順に実行される。

pages.count を別に持つ理由」は、毎回 SELECT COUNT(*) FROM likes WHERE page_id = ? だと数が増えたとき重くなるため。POST 成功時だけ集計値を +1 して、GET を軽くする。

スキーマに納得したら、マイグレーションファイルを作る。

Claude
このスキーマでマイグレーションファイルを作成してください。
ファイルは migrations/0001_init.sql に保存してください。

6. 【バックエンド】Worker の実装

Section titled “6. 【バックエンド】Worker の実装”

Claude Code に依頼してファイルを作ってもらう。

Claude
いいねカウントAPIを作って!仕様とスキーマは先ほど設計したものを使う。
エンドポイントは GET /api/like/:id(いいね数の取得)と POST /api/like/:id(+1)。
パスの :id は encodeURIComponent されたページ識別子(: や / を含む)なので、decodeURIComponent してから使う。
Cloudflare Workers + Workers Static Assets の構成で、src/index.js に実装。
/api/* 以外のリクエストは Static Assets にフォールバックして public/ 配下を配信する構成。
D1 の binding 名は DB、Assets の binding 名は ASSETS。
CORS の Access-Control-Allow-Origin: * を付け、OPTIONS への対応も入れてください。

生成されたコードの動きを Claude Code に箇条書きで説明してもらってから、自分の手で一度はざっと読む。「いまどの SQL が走って、何を返しているか」が頭に入っていると、後でデバッグするときに楽。

Claude Code が生成した API は、実行のたびに細部が変わるが、おおよそ次のような外部仕様になっているはず。本記事の以降の章はこの仕様を前提に進める。

エンドポイント

メソッドパス用途
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:リクエストボディは { "client_id": "..." }。レスポンスは

{ "count": 43, "liked": true }
  • liked: true … 今回新規にいいねが追加された
  • liked: false … この client_id は既にこのページにいいね済みで、カウントは変わらない

CORS は別オリジンから呼ぶ前提なので Access-Control-Allow-Origin: * を付ける。

生成された実装が上記と違っていて、本記事の以降の流れに合わせたい場合は、上のプロンプトの末尾に以下を追加して投げ直す:

  • GET のレスポンスは {"count": 42} の形
  • POST のリクエストボディは {"client_id": "..."}、レスポンスは {"count": 43, "liked": true} の形

6-3. Worker と Workers Static Assets の役割分担

Section titled “6-3. Worker と Workers Static Assets の役割分担”

今回の実装で使う Workers Static Assets とは、Worker と一緒に静的ファイル(HTML/JS/CSS/画像など)を配信できる仕組み。wrangler.jsoncassets.directory で指定したフォルダ(例:./public)の中身が、そのまま公開URLでアクセスできる。

静的ファイルへのリクエストは Worker のコードを通さずに配信される(高速、課金対象外)。一方、Worker 側のコードを通したいリクエスト(API など)は、fetch ハンドラ内で処理する。

本記事では:

  • /api/* のリクエスト → Worker が処理(D1 にアクセスしていいねカウント等を返す)
  • それ以外(/like.js/index.html など) → Worker のコードを通さず Static Assets として直接配信

という構成にする。

.gitignore の設定。プロジェクトに作られる .wrangler/ フォルダ(ローカル状態のキャッシュ)はコミット不要。

Claude
.gitignore に .wrangler/ と node_modules/ を追加して

本番DBにマイグレーションを適用する。

Terminal window
npx wrangler d1 migrations apply my-like-api-db --remote

Worker をデプロイ。

Terminal window
npx wrangler deploy

実行すると公開URLが表示される。だいたい https://my-like-api.<アカウント名>.workers.dev のような形式。

表示された URL のサブドメイン部分(<アカウント名> の箇所)は、Cloudflare アカウントごとに固有の文字列が割り当てられる。例:https://my-like-api.tatsuwo.workers.dev のようになる。以降 https://my-like-api.workers.dev と出てきたら自分のアカウントのフルURLに読み替える。

ブラウザで https://〇〇.workers.dev/api/like/page1 を直接開いてみる。{"count":0} が返ってくれば API は動いている。

POST の動作確認は curl で:

Terminal window
curl -X POST https://〇〇.workers.dev/api/like/page1 \
-H "Content-Type: application/json" \
-d '{"client_id":"client-A"}'

{"count":1,"liked":true} が返ってきて、もう一度同じコマンドを叩くと {"count":1,"liked":false} になれば、client_id 重複判定が機能している。

8. 【フロントエンド相当】ブログパーツとして使う

Section titled “8. 【フロントエンド相当】ブログパーツとして使う”

API が動いたら、次は自分のサイトに <script> で埋め込んで動かす。1章の全体像でいう左側(自分のブログ側)を作る工程。共通ロジックは window.likeApi として公開し、続編 LEX のブックマークレット・Chrome拡張機能でも再利用できるようにしておく。

like.js はブログパーツの本体スクリプト。貼り付けたWebページ上で動き、APIを呼んでいいね数を取得・送信し、結果をボタン(「♥ 数字」)として表示する。public/like.js として配置する(Workers Static Assets から配信される)。

初心者版(ざっくり):

Claude
さっき作った API を使って、いいねボタンを動かす JavaScript を public/like.js に作って!
ページに <button id="like-button"> があったら、いいね数を取得してボタンに「♥ 数字」の形で表示。
ボタンをクリックすると +1。同じブラウザから2回目以降は数字が増えないようにして。
API は https://my-like-api.〇〇.workers.dev/api/like/<ページのURL> の形(自分のWorkerのURLに置き換え。最後にいまのページのURLを付ける)。ページのURLは / などを含むので、encodeURIComponent で包んでからパスに付ける。

これで「個人ブログにいいねボタンを置く」用途には十分な実装ができる。

詳細版: 続編 LEX(ブックマークレットや Chrome 拡張機能) でも同じロジックを使い回したい、共通ロジックを window.likeApi として公開しておきたい、といった事情があるなら、より具体的に指定する:

Claude
public/like.js を作って。以下の動作をする。
【共通ロジック(IIFE 内に閉じ込める)】
- localStorage から like-uuid を取得。なければ crypto.randomUUID() で発行して保存
- 現在ページのURLを正規化(hash と search を削除、末尾スラッシュを削除)して pageId にする
- API のパスに pageId を入れるときは encodeURIComponent で包む(pageId は : や / を含むため。Worker 側は decodeURIComponent で戻す)
- getCount(): pageId の API(https://my-like-api.〇〇.workers.dev/api/like/:pageId)に GET して { count } を返す
- like(): 同じ API に client_id 付きで POST して { count, liked } を返す
- API のオリジンは自分のWorkerのURLに置き換える
【公開API】
- window.likeApi = { getCount, like } として外から呼べるようにする
【ボタン自動バインド(ブログパーツ用途)】
- DOMContentLoaded 後(または既に load 済みなら即時)、ページ内に <button id="like-button"> があれば
- getCount() で取得した数字を「♥ 数字」の形でボタンに表示
- クリックで like() を呼び、レスポンスの count でボタンを更新
- ボタンが無ければ何もしない(ブックマークレットから呼ばれる用途を想定)

like.js を更新したら、再度デプロイする:

Terminal window
npx wrangler deploy

ブログパーツを使う側のHTMLには、以下を貼る:

<button id="like-button"></button>
<script src="https://my-like-api.〇〇.workers.dev/like.js"></script>

これだけで、別ドメインのサイトからもこの API を使えるようになる。

ブックマークレットや Chrome 拡張機能から同じ API を呼ぶ方法は、続編 LEX(いいねカウントAPIをブックマークレットとChrome拡張機能から使う) を参照。ブックマークレットは like.jswindow.likeApi をそのまま使い回し、拡張機能は同じ API を直接呼ぶ。

詳細版のプロンプトで明示している、ブラウザを識別する UUID を localStorage で保存するパターンを解説する。

crypto.randomUUID() でランダムな UUID(v4)を作る。これがこのブラウザの「ローカル識別子」になる。

let uuid = localStorage.getItem('like-uuid');
if (!uuid) {
uuid = crypto.randomUUID();
localStorage.setItem('like-uuid', uuid);
}

初回アクセス時にだけ生成、以降は同じものを使い回す。

localStorageオリジン単位(スキーム + ホスト + ポート)で保存される。同じ人でも blog-a.comblog-b.com では別の UUID になる点に注意。

localStorage:ブラウザに文字列で値を保存できる仕組み。Cookie と違ってサーバーに自動送信されない。基本はクリアするまで残るが、Safari・iOS のブラウザ(WebKit)はしばらく開かないと自動で消すことがある(永続ではない)。容量は5MB前後。

詳細版のプロンプトで明示している、ページのURLを正規化して識別子(pageId)として使うルールを解説する。

「いいねの集計単位」をどう決めるか。本記事では 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/post1SNSのトラッキング流入を別カウントしない
末尾 / を削除/post1//post1/post1/post1/ を同一視

ホスト名はそのまま残る(URL オブジェクトが自動で小文字化する)。

本格的な用途だと ?p=2 のようなページネーション用パラメータも別カウントしたい場合があり、許可リスト方式の正規化が必要。本記事のサンプル実装では割り切って一律削除する。

このまま URL を pageId として使うので、pageIdhttps://example.com/post1 のような 読める文字列 になる。D1 で SELECT * FROM pages したときにデバッグしやすい。

pageId:/ を含むので、API のパス(/api/like/:id)に入れるときは encodeURIComponent で包む。受け取る Worker 側は decodeURIComponent で元に戻してから DB のキーにする。これを忘れると / がパスの区切りと解釈され、ルーティングが崩れる。

想定されるケースごとの振る舞いを並べる。正常な利用には効くが、悪意ある攻撃者にはやられ放題なケースもある。隠さず書いておく。

ケース結果
普通のユーザー(同じブラウザ)1票のみ。サーバー側 INSERT OR IGNORE で担保
localStorage を消去した人新しい UUID が発行されるので再カウント可能
Safari・iOS で7日ほど放置localStorage が自動削除され、新しい UUID が発行される(再カウントされうる)
プライベートブラウジングlocalStorage がセッション限り。閉じると毎回新規 UUID
別ブラウザ・別デバイスそれぞれ別人扱い(仕様通り)
別ドメインに埋め込まれた同一人物オリジンごとに別 localStorage なので別人扱い
開発者ツールで UUID を毎回変える攻撃者防げない
連打する攻撃者UUID が同じなら2回目以降は無視されるが、毎回違う UUID を送られたら防げない

つまり、これは 「素直なユーザーが意図せず多重投票することを防ぐ」 装置であって、「攻撃者の不正投票を防ぐ」 ものではない。

個人ブログのいいねカウントとしては十分なレベル。本格的な投票システムや有料コンテンツ評価などには向かない。

UUID は ユーザーごとに一意なランダム識別子 であり、扱い方によってはトラッキング目的にも使える。以下を守る:

  • UUID をサーバー側のログに出さない。Cloudflare のリクエストログにも極力残さない
  • UUID と他の情報(IPアドレス、User-Agent、参照元など)を組み合わせて保存しない
  • 読み取り API(GET)では UUID を要求しない。POST だけで使う
  • GDPR・改正個人情報保護法などを意識する文脈(EU圏ユーザーが多い、企業サイト等)ではプライバシーポリシーに明記する

「いいね数を集計する」だけが目的なら、UUID をDBに残す以上の処理は不要。

個人ブログ規模を超えて本格運用したい場合、以下を組み合わせると安全性が大きく上がる:

  • 認証付き化: GitHub OAuth や Cloudflare Access を組み合わせて、ログインユーザーごとに1票にする
  • 連打防止: 同じ IP からの POST に Cloudflare のレート制限を入れる
  • 動的アクセス制御: 特定オリジンからしか POST を受け付けないように Origin ヘッダーをチェック

機能を広げたい場合の方向性:

  • いいね取り消し: DELETE /api/like/:id を追加して、likes から行を消し、pages.count を -1
  • ハッシュベース pageId: URL をそのまま使うのが嫌なら SHA-256 でハッシュ化(短く・特殊文字なし・URLを隠せる)
  • 集計の高度化: 日別・週別の集計、人気記事ランキングなど。likes.created_at を追加して時系列クエリ
  • 独自ドメインの設定: 〇〇.workers.dev ではなく独自ドメインで配信したい場合は、Cloudflare ダッシュボードの Workers & Pages → 該当 Worker → SettingsDomains & Routes で設定できる

これらを少しずつ足していくと、本格的なリアクションシステムに育っていく。