32blogby StudioMitsu

Crea un escudo contra fraude de clics en AdSense con Next.js

Construye un sistema de protección contra fraude de clics en AdSense equivalente a AICP en Next.js. Incluye detección blur+mouseenter, tracking con Upstash Redis y alertas por Discord.

9 min read
Next.jsAdSensesecurityclick-fraud-protection
Contenido

Después de migrar mi blog de WordPress a Next.js, noté que algo faltaba: AICP (AdSense Invalid Click Protector).

En WordPress, instalas el plugin AICP y listo. Detecta clics fraudulentos y oculta los anuncios a visitantes sospechosos. Pero en el ecosistema de Next.js, no existe nada parecido.

Este artículo te guía paso a paso para construir un sistema de protección equivalente a AICP usando Next.js App Router. Cubriremos la detección de clics, el tracking basado en API con Upstash Redis, la ocultación automática de anuncios y alertas opcionales por Discord.

¿Qué es el fraude de clics en AdSense?

El fraude de clics en AdSense — a veces llamado "click bombing" — ocurre cuando alguien hace clic repetidamente en tus anuncios para activar la detección de clics inválidos de Google. La ironía: Google penaliza al publicador, no al atacante. Tu cuenta de AdSense se restringe o se suspende.

En el mundo de WordPress, AICP (AdSense Invalid Click Protector) se convirtió en la defensa estándar. Funciona así:

  1. Rastrea los clics en anuncios por visitante
  2. Bloquea la visualización de anuncios cuando los clics superan un umbral dentro de una ventana de tiempo
  3. Identifica a los visitantes por cookie e IP

Cuando migré de WordPress a Next.js, busqué en npm un paquete equivalente. Nada. No existe ninguna biblioteca de protección de AdSense específica para Next.js.

Así que construí una.

Visión general de la arquitectura

El sistema tiene cuatro componentes.

mermaid
graph TD
  A[El usuario hace clic en un anuncio] --> B[AdClickGuard: detección blur+mouseenter]
  B --> C[POST /api/ad-guard]
  C --> D[Upstash Redis: contador por IP]
  D --> E{¿Límite superado?}
  E -->|No| F[Seguir mostrando anuncios]
  E -->|Sí| G[Ocultar anuncios a este visitante]
  E -->|Sí| H[Notificación por Discord]
  • AdClickGuard — Componente del lado del cliente que detecta clics en anuncios
  • API Route — Registra conteos de clics y determina si un visitante está bloqueado
  • Upstash Redis — Almacena conteos de clics por IP con expiración automática
  • Notificación por Discord — Te alerta cuando se detecta actividad sospechosa (opcional)

Internamente, AICP hace exactamente lo mismo. Estamos reemplazando los hooks de WordPress con componentes de React y API Routes.

Detección de clics en anuncios

La detección de clics en anuncios tiene un desafío fundamental: los anuncios de AdSense se sirven dentro de iframes, y la Same-Origin Policy impide que la página padre acceda a los eventos de clic dentro de iframes de origen cruzado.

La solución es el patrón blur + mouseenter.

  1. Adjuntar un listener mouseenter al contenedor del anuncio para rastrear cuando el ratón está sobre el anuncio
  2. Escuchar el evento blur de window — cuando un usuario hace clic dentro de un iframe, el foco se mueve al iframe, activando blur en la ventana padre
  3. Si blur se dispara mientras el ratón está sobre el contenedor del anuncio, contarlo como un clic en el anuncio
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);

  // Check block status on mount
  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 {
      // Ignore network errors
    }
  }, []);

  useEffect(() => {
    const container = containerRef.current;
    if (!container) return;

    const handleMouseEnter = () => {
      isMouseOverAd.current = true;
    };
    const handleMouseLeave = () => {
      isMouseOverAd.current = false;
    };

    const handleBlur = () => {
      if (isMouseOverAd.current) {
        reportClick();
        // Refocus to detect subsequent clicks
        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>;
}

El detalle clave es setTimeout(() => window.focus(), 0). Al devolver el foco a la ventana padre después de un blur, podemos detectar el siguiente clic también.

Tracking de clics con una API Route

Usamos Upstash Redis para el tracking de clics. Se integra naturalmente con entornos serverless y tiene una latencia de arranque en frío casi nula.

Configuración

bash
npm install @upstash/redis

Agrega tus credenciales de Upstash a .env.local:

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

Implementación de la 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: Check current block status
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: Record a click
export async function POST() {
  const ip = await getClientIp();
  const key = getKey(ip);

  const count = await redis.incr(key);

  // Set expiration only on first click (fixed window, not sliding)
  if (count === 1) {
    await redis.expire(key, AD_GUARD_CONFIG.windowSeconds);
  }

  const blocked = count >= AD_GUARD_CONFIG.maxClicks;

  if (blocked) {
    // Discord notification (optional)
    if (process.env.DISCORD_WEBHOOK_URL) {
      const { notifyDiscord } = await import("@/lib/notify-discord");
      await notifyDiscord(ip, count);
    }
  }

  return NextResponse.json({ blocked, count });
}

Ejecución local sin Redis

Para desarrollo local, puedes sustituir Redis por un Map en memoria.

ts
// lib/ad-guard-store-local.ts (development only)
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;
}

Ocultar anuncios cuando se supera el límite

Aunque puedes usar AdClickGuard directamente, extraer la configuración en un archivo separado y crear un componente wrapper facilita la gestión.

ts
// lib/ad-guard-config.ts
export const AD_GUARD_CONFIG = {
  /** Maximum clicks allowed within the time window */
  maxClicks: 3,
  /** Time window in seconds */
  windowSeconds: 60 * 60, // 1 hour
  /** Redis key prefix */
  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>
  );
}

Colócalo donde quieras poner un anuncio:

tsx
// Usage
<ProtectedAdSlot slotId="1234567890" />

Alertas por Discord (Opcional)

Cuando un visitante es bloqueado, una notificación por Discord te permite monitorear la situación en tiempo real.

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(),
        },
      ],
    }),
  });
}

No necesitas crear un bot de Discord. Solo genera una Webhook URL desde la configuración de tu servidor.

  1. Configuración del servidor → Integraciones → Webhooks
  2. Crea un nuevo webhook, copia la URL
  3. Agrega DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/... a .env.local

Consideraciones para producción

Falsos positivos

El patrón blur + mouseenter no es perfecto. Los falsos positivos pueden ocurrir cuando:

  • Un visitante pasa el ratón sobre el área del anuncio mientras cambia de pestaña
  • Los eventos táctiles en móvil activan blur inesperadamente
  • Múltiples personas detrás de la misma IP (NAT corporativo, WiFi de cafetería) hacen clic legítimamente en anuncios

Por eso el umbral importa. No bloquees por un solo clic — usa un límite generoso como 3 clics por hora. Los valores predeterminados de AICP siguen la misma filosofía.

Rendimiento

  • AdClickGuard son event listeners puramente del lado del cliente. Cero impacto en el rendimiento de renderizado
  • La API Route hace una sola llamada REST a Upstash. La latencia es de milisegundos de un solo dígito
  • Redis EXPIRE maneja la limpieza automática — no se necesita mantenimiento de almacenamiento

Política de Google AdSense

Este sistema es una medida defensiva para proteger tu cuenta de clics inválidos. No viola las políticas de AdSense. Google proporciona un formulario de contacto para clics inválidos y anima a los publicadores a tomar medidas proactivas.

Sin embargo, umbrales excesivamente agresivos que ocultan anuncios a visitantes legítimos reducirán las impresiones y los ingresos. Ajusta tus umbrales con cuidado.

Conclusión

Lo que el plugin AICP de WordPress hace internamente es sencillo:

  1. Detección de clics — patrón blur + mouseenter
  2. Conteo — Upstash Redis con tracking por IP
  3. Bloqueo — Ocultar anuncios cuando se supera el umbral
  4. Alertas — Notificaciones por webhook de Discord

Migrar de WordPress a Next.js no significa perder esta protección. Unas 100 líneas de código te dan una funcionalidad equivalente. No es una solución perfecta, pero es mucho mejor que nada.

No esperes a ser víctima de fraude de clics. Una suspensión de cuenta de AdSense es difícil de revertir — configura la protección desde el principio.