Files
civitai__civitai/scripts/ad-request-check.mjs
T
briant 1eea515b2d fix(ads): stop requesting ads on NSFW-gated pages
Gated pages on civitai.com were still running an auction behind the
"content has moved to civitai.red" card. Measured logged-out: 1 GAM
request for the adhesive unit plus ~1145 prebid/SSP calls, carrying the
gated URL as page_url. That is what lands NSFW URLs in GAM's policy
violation center — ~200 pages are currently flagged and serving no or
low-CPM ads.

Gated already suppressed side_1/side_2/incontent, which live inside its
children. The leak was the adhesive footer: it renders from AppLayout
outside {children}, below AdsProvider in the tree, and so is unreachable
by the gate.

Suppression keys off the content rating, not the gate verdict —
civitai.com serves PG and PG13, so a PG13 page stays monetized even while
an anonymous viewer sees a login gate. isAdGatedContent is also
viewer-independent: one auction from an owner, mod, or crawler is enough
to put a URL in the policy center.

Applied at the AdsProvider context boundary so every ad unit inherits it,
while the Snigel loader and the .red adblock probe keep using the ungated
local — the loader is inert without a slot and drives the CMP handshake
that sets ready. Never applies to .red, which serves direct ads with no
GAM auction.

SSR is the load-bearing half: AdUnitRenderable renders during SSR, so a
gated page ships a reserved-height ad slot that paints before hydration.
Pages declare gating in getServerSideProps; createServerSideProps
resolves it into the adsGated prop _app hands to AdsProvider. useAdGate
covers client-side navigation, where a layout effect lands before paint.

3D models gained useSSG plus a model3d fetch; it previously SSR'd a
loader with no Gated mounted.

Not verifiable locally (isDev forces adsEnabled false) — run
scripts/ad-request-check.mjs against a preview deploy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 14:52:52 -06:00

76 lines
2.6 KiB
JavaScript

/**
* Verifies that gated pages make no ad requests.
*
* A gated page that runs an auction sends its URL to GAM, which lands NSFW URLs in the
* policy violation center even though the gate hides the content. See docs/ads-gating-plan.md.
*
* Usage:
* node scripts/ad-request-check.mjs [origin]
*
* Defaults to https://civitai.com. Ads are disabled when `isDev` is true, so this cannot be
* run against a local dev server — point it at a preview or production deploy.
*/
import { chromium } from 'playwright';
const origin = process.argv[2] ?? 'https://civitai.com';
const targets = [
{ label: 'GATED', path: '/models/1972981/sex-nudes-other-fun-stuff-snofs', expectAds: false },
{ label: 'CONTROL', path: '/models/1166008', expectAds: true },
];
const UA =
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36';
const browser = await chromium.launch({ headless: true });
let failed = false;
for (const { label, path, expectAds } of targets) {
const ctx = await browser.newContext({ userAgent: UA, viewport: { width: 1440, height: 900 } });
const page = await ctx.newPage();
const adRequests = [];
const auctionCalls = [];
page.on('request', (r) => {
const u = r.url();
if (/gampad\/ads|\/pagead\/ads/.test(u)) adRequests.push(u);
else if (/prebid|adnxs|rubiconproject|pubmatic|casalemedia|openx|criteo/i.test(u))
auctionCalls.push(u);
});
const url = `${origin}${path}`;
try {
await page.goto(url, { waitUntil: 'load', timeout: 60000 });
} catch (e) {
console.log(` goto warning: ${e.message.slice(0, 80)}`);
}
// Auctions need the CMP round-trip plus the adSizes effect, and side rails need a scroll.
await page.waitForTimeout(14000);
await page.evaluate(() => window.scrollBy(0, 1200)).catch(() => {});
await page.waitForTimeout(6000);
const slots = await page.evaluate(() => {
try {
return window.googletag?.pubads?.().getSlots?.().map((s) => s.getAdUnitPath()) ?? [];
} catch {
return [];
}
});
const ok = expectAds
? adRequests.length > 0 && slots.length > 0
: adRequests.length === 0 && auctionCalls.length === 0 && slots.length === 0;
if (!ok) failed = true;
console.log(`\n${ok ? 'PASS' : 'FAIL'} ${label} ${url}`);
console.log(` GAM ad requests: ${adRequests.length} auction calls: ${auctionCalls.length}`);
console.log(` defined slots: ${JSON.stringify(slots)}`);
console.log(` expected: ${expectAds ? 'ads served' : 'no ad requests at all'}`);
await ctx.close();
}
await browser.close();
console.log(`\n${failed ? 'FAILED' : 'All checks passed'}`);
process.exit(failed ? 1 : 0);