ads: server-side impression beacon in middleware (AdSanity parity)
Fires one impression per page render, per slot, server-side — mirrors AdSanity's WP-level counting model. Picks one banner from each slot's rotation pool per request. Edge-worker-cached pool refresh, 5min TTL. Closes the structural gap with historical AdSanity numbers (client-side counting was dropping 25-40% to ad blockers, plus the beacon failed silently on fast navigations).
This commit is contained in:
@@ -1,8 +1,6 @@
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
|
||||
// PR firms / writers authenticate at the distribute platform, not here.
|
||||
// Anything that looks like a WordPress login or the bare /login path
|
||||
// redirects there so old bookmarks don't dead-end.
|
||||
const AUTH_TARGET = "https://distribution.relevantmediaproperties.com/login";
|
||||
|
||||
const REDIRECT_PATHS = new Set<string>([
|
||||
@@ -14,8 +12,84 @@ const REDIRECT_PATHS = new Set<string>([
|
||||
"/register",
|
||||
]);
|
||||
|
||||
export function middleware(req: NextRequest) {
|
||||
// ─── Server-side impression beacon (AdSanity-equivalent) ────────────────────
|
||||
//
|
||||
// Why: client-side beacons drop 20-50% to ad blockers, JS-disabled clients,
|
||||
// and lost beacons on fast navigation. AdSanity counted server-side — every
|
||||
// WP page render = 1 impression per zone. To get parity with the historical
|
||||
// numbers our advertisers expect, we fire impressions here on every HTML
|
||||
// page request.
|
||||
//
|
||||
// For each slot (728x90, 300x600, 300x250) we pick ONE banner from the
|
||||
// rotation pool per page render — same model AdSanity used. The rotation
|
||||
// pool is fetched from advertising-rmp and cached per-edge-worker for 5min.
|
||||
|
||||
const RMP_BASE =
|
||||
(process.env.NEXT_PUBLIC_RMP_ADS_API
|
||||
|| "https://advertising.relevantmediaproperties.com").replace(/\/$/, "");
|
||||
|
||||
const PROPERTY = "broadcastbeat";
|
||||
const SLOTS = ["728x90", "300x600", "300x250"] as const;
|
||||
type Slot = typeof SLOTS[number];
|
||||
|
||||
// Edge worker-local cache. Each region's edge will refresh independently.
|
||||
let poolCache: Record<Slot, string[]> = { "728x90": [], "300x600": [], "300x250": [] };
|
||||
let poolLoadedAt = 0;
|
||||
const POOL_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
async function loadPool() {
|
||||
if (Date.now() - poolLoadedAt < POOL_TTL_MS) return;
|
||||
try {
|
||||
const results = await Promise.all(
|
||||
SLOTS.map(async (size) => {
|
||||
const res = await fetch(`${RMP_BASE}/api/banners/${PROPERTY}/${size}`, {
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) return [size, [] as string[]] as const;
|
||||
const d = (await res.json()) as { banners?: Array<{ campaign_id?: string }> };
|
||||
return [size, (d.banners || []).map((b) => b.campaign_id).filter(Boolean) as string[]] as const;
|
||||
})
|
||||
);
|
||||
const next: Record<Slot, string[]> = { "728x90": [], "300x600": [], "300x250": [] };
|
||||
for (const [size, ids] of results) next[size] = ids;
|
||||
poolCache = next;
|
||||
poolLoadedAt = Date.now();
|
||||
} catch {
|
||||
// keep stale cache on failure
|
||||
}
|
||||
}
|
||||
|
||||
// Paths that don't count as a "page view" — assets, APIs, edge probes.
|
||||
const SKIP_RE = /^\/(api|_next|legacy|favicon|robots\.txt|sitemap|opensearch|.*\.(?:css|js|map|png|jpg|jpeg|gif|webp|svg|ico|woff|woff2|ttf|eot|mp4|webm|xml|txt|json))$/i;
|
||||
|
||||
function fireImpression(req: NextRequest, campaign_id: string) {
|
||||
const url = new URL(req.url);
|
||||
fetch(`${RMP_BASE}/api/track/impression`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"user-agent": req.headers.get("user-agent") || "",
|
||||
"x-forwarded-for":
|
||||
req.headers.get("x-forwarded-for") ||
|
||||
req.headers.get("cf-connecting-ip") ||
|
||||
"",
|
||||
"referer": req.headers.get("referer") || "",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
campaign_id,
|
||||
page_path: url.pathname,
|
||||
page_url: req.url,
|
||||
viewport: false,
|
||||
}),
|
||||
cache: "no-store",
|
||||
keepalive: true,
|
||||
}).catch(() => undefined);
|
||||
}
|
||||
|
||||
export async function middleware(req: NextRequest) {
|
||||
const path = req.nextUrl.pathname;
|
||||
|
||||
// 1) auth redirects (legacy WP-style logins)
|
||||
if (
|
||||
REDIRECT_PATHS.has(path) ||
|
||||
path.startsWith("/wp-admin/") ||
|
||||
@@ -23,11 +97,27 @@ export function middleware(req: NextRequest) {
|
||||
) {
|
||||
return NextResponse.redirect(AUTH_TARGET, 301);
|
||||
}
|
||||
|
||||
// 2) server-side impression beacons per page render
|
||||
if (!SKIP_RE.test(path)) {
|
||||
await loadPool();
|
||||
for (const slot of SLOTS) {
|
||||
const pool = poolCache[slot];
|
||||
if (pool.length === 0) continue;
|
||||
const picked = pool[Math.floor(Math.random() * pool.length)];
|
||||
fireImpression(req, picked);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
// Match every path (impression beacon needs site-wide coverage) — the
|
||||
// SKIP_RE inside the handler filters out asset/API requests so we only
|
||||
// count real page views.
|
||||
export const config = {
|
||||
matcher: [
|
||||
"/((?!api/|_next/|legacy/|.*\\.).*)",
|
||||
"/login",
|
||||
"/wp-login",
|
||||
"/wp-login.php",
|
||||
|
||||
Reference in New Issue
Block a user