Put Google ads on a content site and read what they earn — the whole job, from the terminal. Two halves. (1) DISPLAY: the copy-pasteable ad markup (AdSense loader, Auto Ads, manual units, and the Google Publisher Tag / GPT alternative), where to inject it, how to reserve slot height so ads don't wreck Core Web Vitals (CLS), the Content-Security-Policy allowlist ads need (the real blocker on a locked-down site), Consent Mode v2 + a certified CMP for EEA/UK traffic, and the ads.txt file. (2) REPORTING: query the AdSense Management API (earnings, RPM, top pages, country, ad-unit, site approval, policy issues, payments) with curl + an OAuth token — no MCP server, no npm install. Use when the user asks to add / set up / place Google ads or AdSense, monetize pages, choose ad sizes or placements, check ad revenue/RPM/earnings, why ads aren't showing, AdSense approval/policy/consent/ads.txt, or GPT / Google Ad Manager on a website.
Instalar com Codex ou Claude Copie este prompt, cole no Codex, Claude ou outro assistente e deixe que ele revise a página da skill e instale para você.
Um comando direto ignora o prompt de revisão. Verifique a origem antes de executá-lo.
O comando permanece em uma só linha. Role horizontalmente para revisá-lo antes de copiar.
Prefere uma cópia local? Baixe os arquivos disponíveis atualmente no SkillsMP.
Exibindo SKILL.md
SKILL.md
Instruções da origem · Visualização somente leitura
name
adsense
description
Put Google ads on a content site and read what they earn — the whole job, from the terminal. Two halves. (1) DISPLAY: the copy-pasteable ad markup (AdSense loader, Auto Ads, manual units, and the Google Publisher Tag / GPT alternative), where to inject it, how to reserve slot height so ads don't wreck Core Web Vitals (CLS), the Content-Security-Policy allowlist ads need (the real blocker on a locked-down site), Consent Mode v2 + a certified CMP for EEA/UK traffic, and the ads.txt file. (2) REPORTING: query the AdSense Management API (earnings, RPM, top pages, country, ad-unit, site approval, policy issues, payments) with curl + an OAuth token — no MCP server, no npm install. Use when the user asks to add / set up / place Google ads or AdSense, monetize pages, choose ad sizes or placements, check ad revenue/RPM/earnings, why ads aren't showing, AdSense approval/policy/consent/ads.txt, or GPT / Google Ad Manager on a website.
when_to_use
Use when the user wants to add or configure Google ads / AdSense / Google Ad Manager (GPT) on a site, decide ad placements or sizes, set up ads.txt or a consent banner for ads, diagnose why ads don't serve, or read ad earnings/RPM/impressions/approval-status/policy-issues. Examples: "put ads on every page", "set up AdSense", "where should the ad units go", "why are my ads blank", "is my site approved", "how much did ads make last week", "add ads.txt", "do I need a cookie consent banner for ads". Do NOT use for the Google Ads API (buying ads / campaigns / keywords / bids) — that is the advertiser side, a different product. Do NOT use for on-page SEO audits — use the seo-audit skills.
Two jobs live here and they are unrelated except that both say "Google ads":
Display — putting ads on pages. This is HTML/JS you emit, plus one
security-header change and two small files. There is no API and no MCP for it;
an ad unit is a <script> and an <ins>/<div>. The hard parts are not the
snippet — they are CSP, CLS, consent, and policy.
Reporting — reading earnings/RPM/approval/policy. This is the AdSense
Management API, and like Search Console it is curl + a token, not a server.
Why not an MCP server. The AdSense reporting MCPs on GitHub (AppsYogi-com/ adsense-mcp-server, vishmathpati/adsense-mcp, Raffaele86/adsense-mcp — audited
by reading their source, not READMEs) each wrap one report endpoint plus a few
list calls behind ~10 "tools", a native OS-keychain dependency (keytar), and a
SQLite cache. The setup burden (a Google Cloud OAuth client + a human consent click)
is identical whether or not you use them — a server saves the agent writing curl
and saves you nothing. No install hooks or telemetry were found in any of them, so
they're safe; they're just unnecessary. This skill keeps the recipes and drops the
packaging, exactly like the search-console skill.
If LOCAL.md exists in this skill's directory, read it first — it carries the
publisher ID, the live setup state, and the site-specific injection points and
gotchas that make everything below concrete. It is gitignored (per-install).
The auth reality — AdSense is NOT like Search Console
This is the single most important auth fact and it is the opposite of GSC:
The AdSense Management API does not support service accounts. It requires
OAuth 2.0 user consent. (Confirmed by reading three independent MCP servers: two
have no service-account path at all; the third ships one but it is copied GA4/GSC
boilerplate that authenticates and then returns zero accounts, because AdSense
accounts are tied to a personal Google identity and AdSense is not a
Workspace-domain-delegatable API — there is no admin who can grant the delegation.)
So there is one unavoidable human click: a one-time consent flow to mint a
refresh token. After that it is fully headless — refresh token → access token,
re-minted whenever a call 401s. Scope:
(read-only covers 100% of reporting; there is no write surface worth having).
Minting the refresh token once (paste the URL into a browser, approve, paste the
code back):
CID=...; CSECRET=...
# 1. open this, approve, copy the ?code= from the redirect (urn:...:oob shows it on-page)
echo "https://accounts.google.com/o/oauth2/v2/auth?client_id=$CID\
&redirect_uri=urn:ietf:wg:oauth:2.0:oob&response_type=code\
&scope=https://www.googleapis.com/auth/adsense.readonly&access_type=offline&prompt=consent"
# 2. exchange the code for a refresh token
curl -s https://oauth2.googleapis.com/token \
-d client_id="$CID" -d client_secret="$CSECRET" \
-d code="PASTE_CODE_HERE" -d grant_type=authorization_code \
-d redirect_uri="urn:ietf:wg:oauth:2.0:oob" | jq -r .refresh_token
(If Google has retired oob for your client, use redirect_uri=http://localhost and
copy the code query param from the failed-to-load localhost redirect. Same result.)
Reporting: the curl recipes
Everything read-only. The whole product is onereports:generate endpoint
(parameterized by dimensions) plus a handful of list calls.
# token: refresh -> access. Re-run on a 401 (access tokens are short-lived).
adsense_token() {
python3 - <<'PY'
import json,urllib.request,urllib.parse,pathlib
c=json.loads(pathlib.Path.home().joinpath('.adsense_oauth.json').read_text())
d=urllib.parse.urlencode({"client_id":c["client_id"],"client_secret":c["client_secret"],
"refresh_token":c["refresh_token"],"grant_type":"refresh_token"}).encode()
print(json.load(urllib.request.urlopen("https://oauth2.googleapis.com/token",d))["access_token"])
PY
}
TOKEN=$(adsense_token); A="Authorization: Bearer $TOKEN"; B=https://adsense.googleapis.com/v2
# account name -> accounts/pub-XXXXXXXXXXXXXXXX (run first; proves auth)
ACCT=$(curl -s -H "$A" "$B/accounts" | jq -r '.accounts[0].name')
# earnings summary (totals only) for a preset range
curl -s -H "$A" "$B/$ACCT/reports:generate?dateRange=MONTH_TO_DATE\
&metrics=ESTIMATED_EARNINGS&metrics=CLICKS&metrics=IMPRESSIONS\
&metrics=PAGE_VIEWS&metrics=PAGE_VIEWS_CTR&metrics=PAGE_VIEWS_RPM"
# any breakdown = same call + a dimension
curl -s -H "$A" "$B/$ACCT/reports:generate?dateRange=LAST_7_DAYS\
&dimensions=COUNTRY_NAME&metrics=ESTIMATED_EARNINGS&metrics=IMPRESSIONS\
&orderBy=-ESTIMATED_EARNINGS&limit=15"
# custom date range (flat params, no dateRange)
curl -s -H "$A" "$B/$ACCT/reports:generate?dimensions=DATE&metrics=ESTIMATED_EARNINGS\
&startDate.year=2026&startDate.month=7&startDate.day=1\
&endDate.year=2026&endDate.month=7&endDate.day=22"
# the non-report facts
curl -s -H "$A" "$B/$ACCT/sites" # approval state per site
curl -s -H "$A" "$B/$ACCT/alerts" # INFO/WARNING/SEVERE
curl -s -H "$A" "$B/$ACCT/policyIssues" # WARNED / AD_SERVING_RESTRICTED / _DISABLED
curl -s -H "$A" "$B/$ACCT/payments" # payment history
Report vocabulary (real enum names). Repeated params (metrics=…&metrics=…) is
how the API takes arrays.
Metrics:ESTIMATED_EARNINGS, TOTAL_EARNINGS, COST_PER_CLICK, PAGE_VIEWS,
AD_REQUESTS, MATCHED_AD_REQUESTS, IMPRESSIONS, INDIVIDUAL_AD_IMPRESSIONS,
CLICKS, AD_REQUESTS_COVERAGE, PAGE_VIEWS_CTR, AD_REQUESTS_CTR,
IMPRESSIONS_CTR, PAGE_VIEWS_RPM, AD_REQUESTS_RPM, IMPRESSIONS_RPM,
ACTIVE_VIEW_VIEWABILITY, ACTIVE_VIEW_MEASURABILITY. (RPM = revenue per 1,000 —
the number to watch. COVERAGE = matched/requested; low coverage = unfilled slots.)
Dimensions:DATE, WEEK, MONTH, PAGE_URL, DOMAIN_NAME, AD_UNIT_NAME,
AD_UNIT_ID, AD_FORMAT_NAME, AD_PLACEMENT_NAME, COUNTRY_NAME,
PLATFORM_TYPE_NAME (mobile/desktop), TRAFFIC_SOURCE_NAME, CUSTOM_CHANNEL_NAME,
URL_CHANNEL_NAME, OWNED_SITE_DOMAIN_NAME. Combine a few; put the big metric in
orderBy=-METRIC.
Report analysis recipes (all one call + which dimension):
Question
dimension(s) + read
Which pages/silos earn?
PAGE_URL, order by ESTIMATED_EARNINGS; bucket by path prefix (/maps/ vs /modes/ vs landers)
Where is RPM highest/lowest?
PAGE_URL + PAGE_VIEWS_RPM — a high-traffic low-RPM page is a placement/format problem, not a traffic one
Is a slot going unfilled?
AD_UNIT_NAME + AD_REQUESTS_COVERAGE — low coverage = kill or resize the unit
Mobile vs desktop money
PLATFORM_TYPE_NAME — most content traffic (and most CLS pain) is mobile
Which country pays?
COUNTRY_NAME + RPM — cross-check against where your locale investment went
Format performance
AD_FORMAT_NAME — which of display/in-article/multiplex actually earns
Display, part 1 — the ad markup (copy-paste, plain HTML)
All snippets below are the raw markup the popular React libraries ultimately emit
(hustcc/react-adsense, scttcper/react-adsense) and the official Google samples
(googleads/google-publisher-tag-samples) — extracted so they drop into static HTML
with no framework.
No <ins> slots of your own — Google auto-inserts anchor/vignette/in-content ads.
Lowest effort, least control, highest CLS risk (Google injects wherever it likes).
Modern AdSense increasingly drives this from the account UI toggle; this push is the
programmatic equivalent. You can mix Auto Ads with manual units.
Recommendation. On a static, CWV-budgeted site, prefer GPT over AdSense manual
units: native lazyLoad, explicit defineSizeMapping, and you own the exact
reserved <div> height — all the levers CLS needs. Use plain-HTML AdSense <ins>
(or Auto Ads) only if the account is AdSense-only, not Ad Manager. GPT needs a Google
Ad Manager account (free; can serve AdSense demand into GAM). If that account setup is
a blocker, AdSense <ins> with a fixed-height wrapper is the pragmatic path.
Display, part 2 — the four things that actually make or break it
1. CLS — reserve the slot height (non-negotiable on a CWV site)
An empty ad container that grows when the ad lands is a layout shift on every page.
Reserve the space up front, ideally with a styled placeholder:
<div class="ad-slot" style="min-height:280px"><!-- ins/div goes here --></div>
Give the wrapper the min-height of the largest expected creative for that
breakpoint (e.g. 280px reserves a 300×250 + label; 100px for a 728×90 leaderboard).
Google's own words: "Reserving adequate space for ads is key to minimizing layout
shift." For a responsive unit whose height varies, reserve the most-likely creative's
height to bound the shift — you cannot eliminate it, only cap it. Verify with the
core-web-vitals-audit skill after adding ads; CLS is the metric ads regress.
2. CSP — the real blocker on a locked-down site (READ THIS)
If the page ships a strict Content-Security-Policy (e.g. default-src 'none'), ads
are silently, totally dead — the loader script, the ad iframes, and every creative
image are all denied, with only console errors to show for it. This looks exactly like
"AdSense is broken" and it is not. Before placing a single unit, grep the served
headers for a CSP and confirm it permits ad traffic.
Ads need, at minimum, these sources added (this is the practical working baseline —
verify against Google's current CSP guidance, support.google.com AdSense "Content
Security Policy", because ad domains change and creatives load from arbitrary
advertiser hosts):
script-src 'unsafe-inline' https://pagead2.googlesyndication.com
https://securepubads.g.doubleclick.net https://tpc.googlesyndication.com
https://www.googletagservices.com https://partner.googleadservices.com
https://adservice.google.com https://fundingchoicesmessages.google.com
https://www.gstatic.com
frame-src https://googleads.g.doubleclick.net https://tpc.googlesyndication.com
https://www.google.com https://fundingchoicesmessages.google.com
img-src 'self' data: https: ← creatives come from any advertiser host; https: is effectively required
connect-src https://pagead2.googlesyndication.com https://googleads.g.doubleclick.net
https://securepubads.g.doubleclick.net https://www.google.com https://csi.gstatic.com
style-src 'unsafe-inline' ← usually already present
frame-ancestors 'none' ← keep; this one is safe to hold
Be honest about the tradeoff: this materially weakens a default-src 'none'
posture — you are opening img-src to all of https: and allowing Google's ad frames
to run script. That is the price of display ads; there is no CSP-tight way to serve
arbitrary third-party creatives. Document it as a deliberate decision, don't sleepwalk
into it. (If the CSP lives in a server config baked into a build image — Caddy, nginx
in a Dockerfile — changing it is a rebuild + redeploy, not a content push. Know
which before you promise a timeline.)
3. Consent — a Google-certified IAB-TCF CMP (mandatory for EEA/UK/CH)
⚠ READ THIS FIRST — the gotcha that silently caps your EEA revenue.A generic cookie banner is NOT a certified CMP. The popular open-source banner
libraries — orestbida/cookieconsent (~5.6k★), klaro (~1.5k★), the archived
osano/cookieconsent (~3.6k★, last release 2019) — are not IAB-TCF certified and
do not satisfy Google's requirement. They show a banner (and orestbida/klaro even do
real script-blocking), but they emit no TCF __tcfapi / TC-string, so to AdSense
it looks like no consent signal at all. You drop a 5k-star library, the banner looks
great, and AdSense still serves only Limited Ads in Europe — quietly, with no error.
orestbida's own docs say it outright: "CookieConsent does not implement the IAB
Framework - TCF." klaro's and osano's READMEs never mention TCF either.
The iabtcf-es repo (InteractiveAdvertisingBureau, Apache-2.0) is NOT a shortcut —
it's the __tcfapi()/TC-string plumbing CMPs are built on ("the essential toolkit
for CMPs"), not a certified CMP and not a banner UI. Building your own on top of it
and self-certifying with IAB is a real project, not a config line.
Since 16 Jan 2024 (EEA+UK; CH 31 Jul 2024) Google requires a Google-certified CMP
integrated with IAB Europe's Transparency & Consent Framework (TCF) for users in those
regions. Without one you cannot serve personalized ads there — only lower-revenue
Non-personalized / Limited Ads (and for Search-Ads publisher products, no ads at
all if neither a certified CMP nor a consent parameter is present). The gate is by the
user's location, not the page language, so on a multi-locale site the consent flow
must load site-wide. TCF version: the current framework is TCF v2.3 (IAB
Europe released it 19 Jun 2025, transition through end of Feb 2026); v2.2 CMPs
must move to v2.3. Don't hardcode "v2.2" — require "current Google-certified TCF CMP."
What actually satisfies it — a Google-certified CMP (the list is ~150+ providers at
support.google.com/adsense/answer/13554116; verify the exact name is on it, don't
assume):
Google's own CMP is FREE and the default pick — AdSense → Privacy & messaging →
European regulations. Certified (IAB CMP ID 300), wires Consent Mode v2
automatically, loads site-wide. For most publishers this is the whole answer; you
rarely need a third party.
Certified third-party CMPs, if you need one (e.g. richer UI, non-Google stacks):
OneTrust/CookiePro, Usercentrics, Sourcepoint, Didomi, Cookiebot, Iubenda, CookieYes,
Quantcast Choice, Osano (paid tier — the OSS osano/cookieconsent plugin is a
different, non-certified thing). Free/free-tier among these: Google's CMP (free),
CookieYes, Quantcast Choice, Cookiebot and Iubenda (free tiers, usually
page/subpage-capped). "Certified" ≠ "free" and ≠ "the same-named OSS repo" — check both.
Consent Mode v2 default (fire before the AdSense/gtag tag; the certified CMP
flips these to granted on accept):
Verify the TCF signal is really firing (a banner that looks right but emits no
TC-string is the exact failure above). In the browser console on an EEA-geo/VPN session:
No __tcfapi function → not a TCF CMP (just a cookie banner). A tcString + a
cmpId that maps to a certified vendor → you're actually compliant.
3b. US state privacy — GPP / US-privacy (separate from the EEA gate)
The EEA gate above is TCF. US traffic is a different signal and, per Google, not
strictly required — "use of GPP is not required by Google and is just one of multiple
methods" to support US-state-law compliance (CCPA/CPRA + CO/CT/VA/etc.). But if you send
a US signal, send it right:
GPP (IAB Global Privacy Platform) is the current mechanism. AdSense accepts GPP
sections: US National, California, Colorado, Connecticut, Florida, Virginia; the
US National section covers CA/CO/CT/DE/IA/MT/NE/NH/NJ/OR/TX/UT/VA. GPP National
v2 is supported from Sept 2025 (v1 still read).
The legacy US Privacy String (USP, 1--- etc.) was deprecated by IAB in Jan
2024 in favor of GPP; AdSense still reads it for web partners, but new work should
emit GPP.
Easiest path: Google's Privacy & messaging → US states regulations message — it
emits the US signal for you, same UI as the EEA one. No IAB MSPA signature needed.
4. ads.txt (do it or bleed advertiser spend)
A plaintext file at the site root (https://<domain>/ads.txt), served 200 text/plain, not behind auth/redirect. IAB Tech Lab ads.txt v1.1. For AdSense
alone, one line:
Publisher account ID — your seller/account ID within that system (the pub-…
for Google). Case-sensitive; must match that system's sellers.json.
Relationship — exactly DIRECT (you contract the account directly) or
RESELLER (you authorized someone to resell it). No other value is legal.
Certification-authority ID (optional) — the ad system's TAG-ID, a 16-char
lowercase hex string. For Google it is the fixed f08c47fec0942fa0 — identical
for every Google publisher (it identifies Google the ad system, not you); only
the pub-… changes.
# starts a comment (whole-line or inline); blank lines ignored. Google crawls the file
by URL. Technically "recommended," practically required — without it advertisers suppress
bids on your inventory as unverified.
Multi-network (AdSense + Ad Manager + a header-bidding SSP) — one line per
system/account; top-of-file directives first:
OWNERDOMAIN= (v1.1) — the PSL+1 business domain that owns the property; ties
your accounts to the sellers.json publisher entry across all your sites. Declare it on
every file, at most once.
MANAGERDOMAIN= (v1.1) — the PSL+1 domain of a vendor that monetizes/manages
the inventory for you. Optional ISO-3166 alpha-3 country suffix scopes it
(MANAGERDOMAIN=monetizer.com global, or …-us.com USA); one per country max.
SUBDOMAIN= delegates a child subdomain to its own ads.txt; CONTACT= is an
email/URL for the file owner. Companions: sellers.json (hosted by each ad
system, declares who each account ID is) and app-ads.txt (same grammar for
mobile/CTV apps, fetched from the developer's store-listed domain).
Validation checklist (what real validators — e.g. IAB's adstxtcrawler,
haryoiro/ads-txt-validator — enforce; run these before shipping):
Served 200 + Content-Type: text/plain at the exact root URL, no redirect, no
auth wall (the #1 real-world failure — a SPA/framework returning HTML 200 fails).
Each data line has 3 or 4 fields — never 2, never 5.
Field 3 is exactly DIRECT or RESELLER (uppercase canonical; a validator flags
direct/typos).
Field 1 is a valid bare domain (no scheme, no path), ≤253 chars.
Field 4, if present, is 16 hex chars (^[0-9a-f]{16}$).
No duplicate records (dupe key = domain + account-ID + relationship).
≤1 OWNERDOMAIN; MANAGERDOMAIN country codes valid; unknown KEY= variables
flagged.
The pub-…matches your AdSense account exactly (a wrong/old pub-ID silently kills
fill — the line parses fine but authorizes nobody).
Measure the damage — what ads actually cost in Core Web Vitals
Ads are the single biggest third-party perf hit you will add. Don't guess the cost —
measure it, attribute it to the exact slot, and gate regressions. Three tools:
GoogleChrome/web-vitals (field), patrickhulce/third-party-web (the price tag),
GoogleChrome/lighthouse-ci + the official publisher-ads plugin (the CI gate).
The price tag, so you know what "normal" is. third-party-web's HTTPArchive dataset
(2026-03) attributes ~1,764 ms of main-thread time to the Google/Doubleclick ad
stack (adsbygoogle.js + gpt.js + doubleclick) on the median page that runs it — one
of the worst-scoring third parties measured. A managed header-bidding wrapper on top
(Mediavine-class) adds ~6,000 ms. If your measured ΔTBT is far below ~1.7 s, you
probably aren't actually loading ads on that run; far above and something is stacked on
top. This is CPU spent on ad JS instead of answering input (INP) or painting (LCP).
Field measurement that NAMES the offending slot. Use the web-vitals/attribution
build — a bare CLS number says the page shifted; largestShiftTarget says which
element. For an ad it resolves to e.g. div#ad-slot-1 > ins.adsbygoogle:
import {onCLS, onINP, onLCP} from 'web-vitals/attribution';
function beacon({name, value, delta, id, attribution}) {
const target = name === 'CLS' ? attribution.largestShiftTarget // ← the ad slot
: name === 'INP' ? attribution.interactionTarget
: attribution.target; // LCP
navigator.sendBeacon('/your-perf-endpoint', JSON.stringify({
name, value, delta, id, debug_target: target, page: location.pathname,
cls_shift_value: name === 'CLS' ? attribution.largestShiftValue : undefined }));
}
onCLS(beacon); onINP(beacon); onLCP(beacon); // call each ONCE per page
Then GROUP BY debug_target server-side: every row whose target matches an ad container
is ads-caused CLS, in real-user numbers. "Good" thresholds: CLS ≤ 0.1, LCP ≤ 2500 ms,
INP ≤ 200 ms. (Ad JS hurts INP via processingDuration/longestScript and LCP via
elementRenderDelay — the main thread is busy with ad code when your hero wants to paint.)
Gate CI so an ad change can't silently regress you.lighthouserc.json asserts
audit IDs eslint-style; error = non-zero exit (fails the build). Add the official
lighthouse-plugin-publisher-ads for ad-specific audits:
cumulative-ad-shift walks the trace and sums the layout-shift score it can attribute to
ad tasks — the single best "ads caused CLS" signal. maxNumericValue is bytes for sizes,
ms for time, unitless for CLS.
The before/after method (attribute the delta, don't just diff): measure the page with
ads OFF (a ?noads=1 flag) → CLS_base, then ads ON → CLS_ads. ΔCLS is the total
regression, but prove it's the ads: field-side, sum largestShiftValue where the target
is an ad container; lab-side, read the cumulative-ad-shift audit — the two should agree.
Budget = your gate minus CLS_base; if ad-attributed CLS fits the headroom, ship, else
fix (reserve height + no-collapse, below) and re-measure.
The GPT collapse trap (a CLS cause people add by accident).collapseEmptyDivs() /
collapseDiv makes an unfilled slot shrink to zero — and that collapse is itself a
layout shift, fired after content painted (the kind CLS punishes hardest). Default GPT
does not collapse. The safe pattern is the opposite: reserve a fixed slot height and
leave it reserved — accept whitespace on an unfilled slot rather than shift the page.
Same trap on AdSense with data-full-width-responsive="true": the ad height is chosen at
fill time by viewport width, so you can't pre-reserve it exactly → give the <ins> a
min-height sized to the most-likely creative, or use a fixed-size unit.
The revenue ladder — AdSense → GPT → header bidding
There is no single "right" way to run ads; there's a ladder, and the correct rung is set
by your traffic and whether you have an ads engineer. Climbing too early loses
money (ops + latency tax exceeds the RPM gain). The canonical top of the ladder is
Google Ad Manager (GAM) + GPT + Prebid.js, with AdSense/AdX competing as bidders inside
GAM — but most sites should sit far below that.
Tier
What it is
Adds vs below
CWV cost
Worth it when
0 — AdSense Auto Ads
one script tag, Google auto-places
baseline revenue, zero effort
worst CLS — Google injects unreserved slots
any traffic; best RPM per hour of effort for small sites
1 — AdSense manual units
you place <ins> slots
placement control + reserved height → ~0 CLS, better viewability
low (if you reserve height)
immediately, once you care about CWV or placement
2 — GAM + GPT
move the ad server to GAM, run slots via googletag, AdSense linked as backfill
multiple demand sources in one auction, unified reporting, 90–97% fill, and compliant ad refresh (Tiers 0/1 can't)
≈ Tier 1
you have direct deals, want refresh/better reporting — tens of thousands of pageviews
3 — GAM + Prebid header bidding
add the pbjs pre-auction feeding bids into GPT
real competition per impression from many SSPs — the biggest RPM lever (~+50–150%, treat high end skeptically)
real: Prebid JS blocks the ad render on an auction; ~800 ms / LCP 2.1→3.4 s in one cited case; capped by a 1000–1500 ms timeout
~25k–50k+ monthly pageviews, and realistically via a managed wrapper below ~1M. AdX itself needs ~5M pv/mo
The Prebid↔GPT handoff (from prebid/Prebid.jsintegrationExamples/gpt/hello_world.html)
— hold GPT, run the Prebid auction, inject winning bids as GAM targeting, release GPT:
googletag.cmd.push(() => googletag.pubads().disableInitialLoad()); // 1. GPT waits
pbjs.que.push(() => { pbjs.addAdUnits(adUnits);
pbjs.requestBids({ bidsBackHandler: send, timeout: 1000 }); }); // 2. auction (1s cap)
function send() { if (pbjs.adserverRequestSent) return; pbjs.adserverRequestSent = true;
googletag.cmd.push(() => pbjs.que.push(() => {
pbjs.setTargetingForGPTAsync(); // 3. write hb_pb/hb_adid onto the slots
googletag.pubads().refresh(); })); }// 4. NOW GPT fetches — Prebid bids vs GAM/AdSense
setTimeout(send, 3300); // failsafe: ads still load if a bidder hangs
setTargetingForGPTAsync() is the bridge; GAM line items read hb_pb to let a header-bid
win. Client-side Prebid needs no server; Prebid Server (prebid/prebid-server) moves
the auction off the browser so extra bidders cost ~0 CWV (at lower per-bidder match rates)
— the CWV-friendly way to run a big demand stack, usually hybrid (top bidders client-side).
Prebid's own guidance: ~10 bidders is the safe ceiling; prune low-win-rate ones.
Managed alternatives (take ~10–20% or a rev-share, remove the ops burden): Ezoic
(no hard minimum), Mediavine (50k monthly sessions), Raptive/AdThrive (100k
pageviews), and Google's own Open Bidding inside GAM (no Prebid to self-host).
The honest rule: self-host Prebid only with an ads engineer to maintain it; otherwise a
managed wrapper nets more despite the cut — and below the threshold, stay on AdSense.
Ad refresh — the policy line that decides everything. Refresh monetizes long/scroll
pages without more pageviews, but:
AdSense forbids programmatic refresh. Ads may not reload without a user-initiated
action (only a full page navigation counts). On Tiers 0/1 you cannot refresh at all
— a major reason to climb to GAM.
GAM permits refresh under rules: ≥30 s between refreshes (60 s recommended),
viewability-gated (only refresh a slot actually in view, ≥50% — going below 50 gets
your impressions flagged), gated on tab focus, and a max-refreshes-per-slot cap.
Reference implementation: 10up/Ad-Refresh-Control
(gpt-active-view-refresh.js) — listens to GPT impressionViewable /
slotVisibilityChanged (inViewPercentage), enforces a 30 s floor, and calls
googletag.pubads().refresh([slot]). Copy its eligibility gate; don't hand-roll one.
Ads on a cross-origin-isolated page (games, SharedArrayBuffer, COEP)
A page served with COEP: require-corp (needed for SharedArrayBuffer / WASM threads
/ high-res timers — i.e. any serious WASM game or media app) cannot run Google ads on
that document, full stop. This is a hard architectural constraint, not a config you can
tune, and it is easy to waste days on. The facts, with the one counter-intuitive detail
that traps everyone:
require-corp applies recursively to every cross-origin subresource AND iframe.
Google's ad scripts (googlesyndication/doubleclick), their subresources, and the
rendered ad creative iframes ship no CORP and no COEP headers → all blocked. The
adsbygoogle.js/gpt.js fetch fails; adBreak() fires adBreakDone with "no ad".
COEP: credentialless does NOT rescue it (the trap). credentialless relaxes the
rule for subresources only (they load no-cors, credentials stripped). Per Chrome's own
docs, cross-origin iframes under credentialless still need the same conditions as
require-corp — the iframe must itself send COEP + CORP: cross-origin. Ad iframes send
neither, so they're still blocked — while SharedArrayBuffer keeps working. So
credentialless buys nothing for ads and costs nothing for threads; it is not a bridge
between the two. (It's also Chromium-only — no Firefox/Safari.)
Dropping COEP to allow ads means crossOriginIsolated → false → no SharedArrayBuffer
→ an Emscripten -pthread build won't even instantiate; you'd ship a separate
single-threaded WASM bundle and eat the perf loss. For a real-time game, the worst trade.
The architecture that works: monetize around the isolated page, never on it. Google
H5 Games Ads (delivered via AdSense; active in 2026, not deprecated) is the right
product — rewarded + interstitial formats via the Ad Placement API
(adConfig() / adBreak({type:'start'|'pause'|'next'|'browse'|'reward', …})). Run it on a
non-isolated page in the session flow:
Pre-game interstitial (recommended): make the launcher/loading page non-isolated
(normal headers → ads allowed), show a type:'start' or rewarded ad, then hand off to
the isolated threaded game document at a different path. Keeps threads AND earns.
Between-match overlay: surface a non-isolated "match over / next map" screen outside
the isolated document, fire adBreak({type:'next'}) there, return to the game.
The rest of the site (SEO pages, leaderboard, trust pages) is non-isolated already and
carries normal banner AdSense — unaffected.
Whenever a site has a crossOriginIsolated surface, do NOT plan ads onto it — plan a
non-isolated pre/post-session page and monetize that. This is the only design that keeps
WASM threads and earns ad revenue at once.
Policy — what gets a site rejected or an ad turned off
Distilled from mmlTools/seo-adsense-inspector's hard-coded thresholds (a static
policy linter) and current Google policy. Treat the numbers as design rules to
build the generator against, not just a linter to run after:
Content before the ad: don't place a unit with < 50 words of real content
above it. No ad above the first substantive paragraph.
Ad density: don't ship ≥ 3 units on a page with < 800 words. Rule of thumb:
at least 2× more content than ads by visual weight. (There is no fixed per-page
ad cap anymore — the 3-unit limit was removed in 2016 — but density-vs-content is
enforced.)
Never Auto Ads a thin page: auto-ads on a page with < 200 words is an
auto-reject. (And: no ads at all on error/empty/under-construction/purely
navigational pages.)
Thin content:< 300 words = thin, < 200 = very thin. Both are AdSense
approval risks and Search "scaled content" risks.
Placement: ads must not sit next to nav/<header> or be mistakable for
menus/buttons/download links (accidental-click policy). No ad immediately after
</nav>/</header>.
One loader per page. Duplicate adsbygoogle.js tags = policy flag.
Required pages, every ad page: a Privacy Policy (mandatory), plus About
and Contact, reachable from the page. The privacy policy MUST disclose:
third-party ad cookies, Google's personalized-ad cookies (DART), an opt-out path
(Google Ads Settings + aboutads.info), and the EEA consent mechanism (the CMP above).
The scaled-content trap (the big one for auto-generated silos)
Google's March 2024 "scaled content abuse" policy targets "generating many pages
primarily to manipulate rankings, with little or no value added for users" — and it
"applies whether automation or humans are involved." A large tier of templated
pages that differ mainly by a name and boilerplate prose is the textbook pattern. Two
distinct risks when you monetize them: (a) a Search manual action (de-indexing),
and (b) an AdSense "low-value content" hit. Mitigation that matches Google's stance:
each monetized page must add genuine unique value (a real unique asset + substantive
original prose), or exclude the thinnest auto pages from ad serving. Putting ads on
the thin tier does not just risk that tier — a policy action can jeopardize the whole
account. When in doubt, monetize the curated/hand-written pages first and hold the auto
tier until it clears the word-count/uniqueness bar above.
Diagnosing "my ads aren't showing"
In rough order of how often each is the actual cause:
CSP blocks it (see above). Grep the served headers, not the HTML. Console
shows Refused to load … pagead2 / Refused to frame. #1 cause on a hardened site.
New account / new site = "getting ready."sites shows state GETTING_READY or
REQUIRES_REVIEW. Ads are blank until Google approves and crawls. This is normal for
days-to-weeks; not a bug. Check curl … /sites.
ads.txt missing/unreachable → reduced fill, sometimes a dashboard warning.
Consent not granted / no CMP in EEA → blank or limited ads for those users only.
Test from a non-EEA IP to distinguish.
Ad blocker in your own browser. Test in a clean profile before believing a bug.
crossorigin="anonymous" missing or the ?client= pub-id wrong/typo'd.
Low fill on a specific unit is normal, not breakage — check
AD_REQUESTS_COVERAGE by AD_UNIT_NAME.
Reporting honesty rules (same discipline as search-console)
Lead with the number, then the caveat. "/maps/ $2.14 RPM over 7d, 41 clicks"
beats "maps are doing okay."
AdSense data settles. Today's/yesterday's earnings are estimated and revise
down after Google filters invalid traffic. Never quote today's number as final;
finalized figures land days later and again at month close.
RPM, not raw earnings, compares pages. Earnings conflate traffic with monetization;
RPM isolates the placement/format quality you actually control.
Under ~2–4 weeks after enabling ads on a page/tier, refuse the verdict. Fill and
RPM ramp as Google learns the inventory. Sparse early data isn't evidence of failure.
Segment before concluding — sitewide RPM up while one silo tanks is a different
story from both up. Bucket PAGE_URL by path prefix.
Never state invalid-traffic-adjusted totals as exact. Say "about."