WordPress から Next.js にブログを移行して気づいたことがある。AICP(AdSense Invalid Click Protector)がない。
WordPress なら AICP プラグインを入れれば済む。不正クリックを検知して、怪しいユーザーには広告を非表示にしてくれる。でも Next.js にはそんな便利なプラグインは存在しない。
この記事では、AICP と同等の機能を Next.js の App Router で自作する方法を解説する。クリック検知、API Route でのカウント、制限超過時の広告非表示、Discord 通知まで、一通り実装する。
AdSense狩りとは何か
AdSense 狩りとは、第三者が意図的に広告を繰り返しクリックして、AdSense アカウントを停止に追い込む行為のこと。Google は不正クリックを検知するとパブリッシャー側のアカウントを制限する。被害者はサイト運営者のほうだ。
WordPress では AICP(AdSense Invalid Click Protector) というプラグインがデファクトスタンダードになっている。仕組みはシンプルで:
- ユーザーの広告クリックをカウント
- 一定時間内に閾値を超えたら、そのユーザーに広告を表示しない
- Cookie や IP で識別
僕は WordPress から Next.js にブログを移行したとき、AICP の代替がないことに気づいた。npm で探しても、Next.js 向けの AdSense 保護パッケージは見つからない。
ないなら作るしかない。
全体アーキテクチャ
今回作るシステムは4つの要素で構成される。
graph TD
A[ユーザーが広告をクリック] --> B[AdClickGuard: blur+mouseenter検知]
B --> C[POST /api/ad-guard]
C --> D[Upstash Redis: IPごとにカウント]
D --> E{制限超過?}
E -->|No| F[通常表示を継続]
E -->|Yes| G[広告を非表示にする]
E -->|Yes| H[Discord通知]
- AdClickGuard — クライアントサイドで広告クリックを検知するコンポーネント
- API Route — クリック数を記録し、制限超過かどうかを判定する
- Upstash Redis — IP ごとのクリックカウントを保持するストレージ
- Discord 通知 — 異常なクリックパターンを検知したときにアラートを送る(オプション)
AICP も内部的にはこれと同じことをやっている。WordPress のフック機構の代わりに、React コンポーネントと API Route で実現する。
クリック検知の仕組み
広告クリックの検知には課題がある。AdSense の広告は iframe で配信されるため、iframe 内のクリックイベントは Same-Origin Policy によって親ページから取得できない。
代わりに使うのが blur + mouseenter パターン だ。
- 広告コンテナに
mouseenterイベントを設定し、マウスが広告上にあることを追跡する windowのblurイベントを監視する。ユーザーが iframe 内の広告をクリックすると、フォーカスが iframe に移り、親ウィンドウのblurが発火する- マウスが広告上にある状態で
blurが発火したら「広告クリック」と判定する
// components/AdClickGuard.tsx
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
const AD_GUARD_ENDPOINT = "/api/ad-guard";
type Props = {
children: React.ReactNode;
maxClicks?: number;
};
export function AdClickGuard({ children, maxClicks = 3 }: Props) {
const containerRef = useRef<HTMLDivElement>(null);
const isMouseOverAd = useRef(false);
const [blocked, setBlocked] = useState(false);
// マウント時に制限状態を確認
useEffect(() => {
fetch(AD_GUARD_ENDPOINT)
.then((res) => res.json())
.then((data) => {
if (data.blocked) setBlocked(true);
})
.catch(() => {});
}, []);
const reportClick = useCallback(async () => {
try {
const res = await fetch(AD_GUARD_ENDPOINT, { method: "POST" });
const data = await res.json();
if (data.blocked) {
setBlocked(true);
}
} catch {
// ネットワークエラー時は無視
}
}, []);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const handleMouseEnter = () => {
isMouseOverAd.current = true;
};
const handleMouseLeave = () => {
isMouseOverAd.current = false;
};
const handleBlur = () => {
if (isMouseOverAd.current) {
reportClick();
// blur 後にフォーカスを戻す(連続クリック検知のため)
setTimeout(() => window.focus(), 0);
}
};
container.addEventListener("mouseenter", handleMouseEnter);
container.addEventListener("mouseleave", handleMouseLeave);
window.addEventListener("blur", handleBlur);
return () => {
container.removeEventListener("mouseenter", handleMouseEnter);
container.removeEventListener("mouseleave", handleMouseLeave);
window.removeEventListener("blur", handleBlur);
};
}, [reportClick]);
if (blocked) return null;
return <div ref={containerRef}>{children}</div>;
}
ポイントは setTimeout(() => window.focus(), 0) の部分。blur 後にフォーカスを親ウィンドウに戻すことで、次のクリックも検知できるようにしている。
API Routeでクリック数を記録する
クリックの記録には Upstash Redis を使う。Edge Runtime と相性がよく、コールドスタートが速い。
セットアップ
npm install @upstash/redis
.env.local に Upstash のクレデンシャルを追加する。
UPSTASH_REDIS_REST_URL=https://your-instance.upstash.io
UPSTASH_REDIS_REST_TOKEN=your-token-here
API Route の実装
// app/api/ad-guard/route.ts
import { Redis } from "@upstash/redis";
import { headers } from "next/headers";
import { NextResponse } from "next/server";
import { AD_GUARD_CONFIG } from "@/lib/ad-guard-config";
const redis = Redis.fromEnv();
function getKey(ip: string): string {
return `${AD_GUARD_CONFIG.redisKeyPrefix}:${ip}`;
}
async function getClientIp(): Promise<string> {
const h = await headers();
return (
h.get("x-forwarded-for")?.split(",")[0]?.trim() ??
h.get("x-real-ip") ??
"unknown"
);
}
// GET: 現在のブロック状態を確認
export async function GET() {
const ip = await getClientIp();
const count = (await redis.get<number>(getKey(ip))) ?? 0;
return NextResponse.json({
blocked: count >= AD_GUARD_CONFIG.maxClicks,
count,
});
}
// POST: クリックを記録
export async function POST() {
const ip = await getClientIp();
const key = getKey(ip);
const count = await redis.incr(key);
// 初回のみ有効期限を設定(スライディングウィンドウではなく固定ウィンドウ)
if (count === 1) {
await redis.expire(key, AD_GUARD_CONFIG.windowSeconds);
}
const blocked = count >= AD_GUARD_CONFIG.maxClicks;
if (blocked) {
// Discord通知(オプション)
if (process.env.DISCORD_WEBHOOK_URL) {
const { notifyDiscord } = await import("@/lib/notify-discord");
await notifyDiscord(ip, count);
}
}
return NextResponse.json({ blocked, count });
}
開発環境では Redis なしで動かす
ローカル開発では Upstash を使わなくても、インメモリの Map で代替できる。
// lib/ad-guard-store-local.ts(開発用)
const store = new Map<string, { count: number; expiresAt: number }>();
export function increment(ip: string, windowSeconds: number): number {
const now = Date.now();
const existing = store.get(ip);
if (!existing || existing.expiresAt < now) {
store.set(ip, { count: 1, expiresAt: now + windowSeconds * 1000 });
return 1;
}
existing.count += 1;
return existing.count;
}
export function getCount(ip: string): number {
const existing = store.get(ip);
if (!existing || existing.expiresAt < Date.now()) return 0;
return existing.count;
}
制限超過で広告を非表示にする
AdClickGuard を直接使ってもいいが、設定を外出しにしてラッパーコンポーネントを作ると運用しやすい。
// lib/ad-guard-config.ts
export const AD_GUARD_CONFIG = {
/** 時間ウィンドウ内の最大クリック数 */
maxClicks: 3,
/** ウィンドウの長さ(秒) */
windowSeconds: 60 * 60, // 1時間
/** Redis キーのプレフィックス */
redisKeyPrefix: "ad-guard",
} as const;
// components/ProtectedAdSlot.tsx
"use client";
import { AdClickGuard } from "./AdClickGuard";
import { AD_GUARD_CONFIG } from "@/lib/ad-guard-config";
type Props = {
slotId: string;
format?: string;
className?: string;
};
export function ProtectedAdSlot({
slotId,
format = "auto",
className,
}: Props) {
return (
<AdClickGuard maxClicks={AD_GUARD_CONFIG.maxClicks}>
<ins
className={`adsbygoogle ${className ?? ""}`}
style={{ display: "block" }}
data-ad-client="ca-pub-XXXXXXXXXXXXXXX"
data-ad-slot={slotId}
data-ad-format={format}
data-full-width-responsive="true"
/>
</AdClickGuard>
);
}
使い方はシンプルだ。既存の広告コンポーネントを ProtectedAdSlot に置き換えるだけ。
// 使用例
<ProtectedAdSlot slotId="1234567890" />
Discord通知で異常を検知する(オプション)
制限超過が発生したら Discord に通知を飛ばすと、リアルタイムで状況を把握できる。
// lib/notify-discord.ts
export async function notifyDiscord(
ip: string,
clickCount: number
): Promise<void> {
const webhookUrl = process.env.DISCORD_WEBHOOK_URL;
if (!webhookUrl) return;
const maskedIp = ip.replace(/\.\d+$/, ".***");
await fetch(webhookUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
embeds: [
{
title: "⚠️ AdSense Click Guard Alert",
color: 0xff6b35,
fields: [
{ name: "IP", value: maskedIp, inline: true },
{ name: "Clicks", value: String(clickCount), inline: true },
{
name: "Action",
value: "Ads hidden for this visitor",
inline: true,
},
],
timestamp: new Date().toISOString(),
},
],
}),
});
}
Discord Bot を作る必要はない。サーバー設定から Webhook URL を発行するだけで使える。
- Discord サーバーの設定 → 連携サービス → ウェブフック
- 新しいウェブフックを作成し、URL をコピー
.env.localにDISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/...を追加
本番適用時の注意点
誤検知のリスク
blur + mouseenter パターンは完璧ではない。以下のケースで誤検知が発生し得る。
- ユーザーが広告に触れた状態でタブを切り替えた
- モバイルのタッチ操作で意図せず blur が発火した
- 同一 IP(企業の NAT、カフェの WiFi)で複数人が広告をクリックした
だからこそ閾値の設定が重要になる。1回のクリックで即ブロックするのではなく、1時間に3回以上 のような余裕のある閾値を設ける。AICP のデフォルト設定も同様の考え方だ。
パフォーマンス
AdClickGuardはクライアントサイドのイベントリスナーのみ。パフォーマンスへの影響はほぼゼロ- API Route は Upstash の REST API を呼ぶだけ。レイテンシは数ミリ秒
- Redis の EXPIRE で古いデータは自動削除されるので、ストレージの心配も不要
Google AdSense ポリシーとの関係
この仕組みは「不正クリックからアカウントを守る」ための防御策であり、AdSense ポリシーに違反しない。Google も不正クリックの報告フォームを提供しており、パブリッシャー側での対策を推奨している。
ただし、正常なユーザーの広告表示を過度に制限する と、インプレッション数が減少し収益に影響する。閾値は慎重に設定すること。
まとめ
WordPress の AICP プラグインがやっていることは、分解すればシンプルだ。
- クリック検知 — blur + mouseenter パターン
- カウント — Upstash Redis で IP ごとに記録
- 制限 — 閾値超過で広告を非表示
- 通知 — Discord Webhook でアラート
Next.js に移行して AICP がなくなっても、100行程度のコードで同等の機能を実装できる。完璧な対策ではないが、何もしないよりはるかにましだ。
AdSense 狩りに遭ってからでは遅い。アカウント停止のリスクを減らすために、早めの導入をおすすめする。