32blogby StudioMitsu
nextjs12 min read

Next.jsでAICP相当のAdSense狩り対策を作る

WordPressのAICPプラグインに相当するAdSense不正クリック対策をNext.jsで自作する方法。blur+mouseenter検知、Upstash Redis、Discord通知まで完全実装。

Next.jsAdSenseセキュリティ不正クリック対策
目次

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) というプラグインがデファクトスタンダードになっている。仕組みはシンプルで:

  1. ユーザーの広告クリックをカウント
  2. 一定時間内に閾値を超えたら、そのユーザーに広告を表示しない
  3. Cookie や IP で識別

僕は WordPress から Next.js にブログを移行したとき、AICP の代替がないことに気づいた。npm で探しても、Next.js 向けの AdSense 保護パッケージは見つからない。

ないなら作るしかない。

全体アーキテクチャ

今回作るシステムは4つの要素で構成される。

mermaid
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 パターン だ。

  1. 広告コンテナに mouseenter イベントを設定し、マウスが広告上にあることを追跡する
  2. windowblur イベントを監視する。ユーザーが iframe 内の広告をクリックすると、フォーカスが iframe に移り、親ウィンドウの blur が発火する
  3. マウスが広告上にある状態で blur が発火したら「広告クリック」と判定する
tsx
// 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 と相性がよく、コールドスタートが速い。

セットアップ

bash
npm install @upstash/redis

.env.local に Upstash のクレデンシャルを追加する。

bash
UPSTASH_REDIS_REST_URL=https://your-instance.upstash.io
UPSTASH_REDIS_REST_TOKEN=your-token-here

API Route の実装

ts
// 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 で代替できる。

ts
// 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 を直接使ってもいいが、設定を外出しにしてラッパーコンポーネントを作ると運用しやすい。

ts
// lib/ad-guard-config.ts
export const AD_GUARD_CONFIG = {
  /** 時間ウィンドウ内の最大クリック数 */
  maxClicks: 3,
  /** ウィンドウの長さ(秒) */
  windowSeconds: 60 * 60, // 1時間
  /** Redis キーのプレフィックス */
  redisKeyPrefix: "ad-guard",
} as const;
tsx
// 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 に置き換えるだけ。

tsx
// 使用例
<ProtectedAdSlot slotId="1234567890" />

Discord通知で異常を検知する(オプション)

制限超過が発生したら Discord に通知を飛ばすと、リアルタイムで状況を把握できる。

ts
// 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 を発行するだけで使える。

  1. Discord サーバーの設定 → 連携サービス → ウェブフック
  2. 新しいウェブフックを作成し、URL をコピー
  3. .env.localDISCORD_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 プラグインがやっていることは、分解すればシンプルだ。

  1. クリック検知 — blur + mouseenter パターン
  2. カウント — Upstash Redis で IP ごとに記録
  3. 制限 — 閾値超過で広告を非表示
  4. 通知 — Discord Webhook でアラート

Next.js に移行して AICP がなくなっても、100行程度のコードで同等の機能を実装できる。完璧な対策ではないが、何もしないよりはるかにましだ。

AdSense 狩りに遭ってからでは遅い。アカウント停止のリスクを減らすために、早めの導入をおすすめする。