- 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.
- allowed-tools
- Bash(curl:*) Bash(jq:*) Bash(python3:*) Read Write Edit Grep
# Google AdSense / Ad Manager (display + reporting)
Two jobs live here and they are unrelated except that both say "Google ads":
1. **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**.
2. **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: **`https://www.googleapis.com/auth/adsense.readonly`**
(read-only covers 100% of reporting; there is no write surface worth having).
### One-time setup
1. [console.cloud.google.com](https://console.cloud.google.com) → a project →
**APIs & Services → Library** → enable **"AdSense Management API"**.
2. **Credentials → Create credentials → OAuth client ID → Desktop app.** Copy the
**Client ID** and **Client Secret**.
3. **OAuth consent screen** → add your Google account (the one that owns the AdSense
account) as a **Test user** (fine to leave the app in "Testing").
4. Do the consent exchange once to get a refresh token (below), then store all three
values in `~/.adsense_oauth.json` (0600, outside any repo):
```json
{ "client_id": "...", "client_secret": "...", "refresh_token": "..." }
```
**Minting the refresh token once** (paste the URL into a browser, approve, paste the
`code` back):
```bash
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 **one** `reports:generate` endpoint
(parameterized by `dimensions`) plus a handful of `list` calls.
```bash
# 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`.
- **Other params:** `dateRange` ∈ {`TODAY`,`YESTERDAY`,`MONTH_TO_DATE`,`YEAR_TO_DATE`,
`LAST_7_DAYS`,`LAST_28_DAYS`,`LAST_30_DAYS`,`CUSTOM`}; `limit` (cap 100000);
`currencyCode`; `reportingTimeZone=ACCOUNT_TIME_ZONE`. CSV: `reports:generateCsv`.
**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.
### Loader (once, in `<head>`)
```html
<script async src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-XXXXXXXXXXXXXXXX"
crossorigin="anonymous"></script>
```
Include `?client=ca-pub-…` so the *same* tag also powers Auto Ads.
### Auto Ads (Google places everything; the one-line switch)
```html
<script>
(window.adsbygoogle = window.adsbygoogle || []).push({
google_ad_client: "ca-pub-XXXXXXXXXXXXXXXX", enable_page_level_ads: true });
</script>
```
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.
### Manual display unit (responsive)
```html
<ins class="adsbygoogle" style="display:block"
data-ad-client="ca-pub-XXXXXXXXXXXXXXXX" data-ad-slot="1234567890"
data-ad-format="auto" data-full-width-responsive="true"></ins>
<script>(adsbygoogle = window.adsbygoogle || []).push({});</script>
```
One `push({})` per unit. Per ad type:
| Type | attributes |
|---|---|
| Display (responsive) | `data-ad-format="auto"` `data-full-width-responsive="true"` |
| In-article | `data-ad-format="fluid"` `data-ad-layout="in-article"` |
| In-feed | `data-ad-format="fluid"` `data-ad-layout="in-feed"` `data-ad-layout-key="<from your account>"` |
| Multiplex (grid) | `data-ad-format="autorelaxed"` |
| Fixed | `style="display:inline-block;width:728px;height:90px"` + drop the responsive attrs |
### The GPT alternative (often the better fit — see the recommendation)
Loader is a different origin:
```html
<script async src="https://securepubads.g.doubleclick.net/tag/js/gpt.js" crossorigin="anonymous"></script>
<script>window.googletag = window.googletag || { cmd: [] };</script>
```
Slot + single-request + display:
```html
<div id="banner-ad" style="width:728px;height:90px"></div>
<script>
googletag.cmd.push(() => {
googletag.defineSlot("/NETWORK/AdUnit", [728,90], "banner-ad").addService(googletag.pubads());
googletag.setConfig({ singleRequest: true });
googletag.enableServices();
googletag.display("banner-ad");
});
</script>
```
Responsive size mapping (viewport → allowed creative sizes; largest first,
`[0,0]→[]` = no ad on tiny screens):
```js
const map = googletag.sizeMapping()
.addSize([1024,768], [[728,90],[750,200]])
.addSize([640,480], [300,250])
.addSize([0,0], []).build();
slot.defineSizeMapping(map);
```
Native lazy-load (no IntersectionObserver of your own — GPT only):
```js
googletag.setConfig({ lazyLoad: { fetchMarginPercent:500, renderMarginPercent:200, mobileScaling:2.0 }, singleRequest:true });
```
**Recommendation.** On a **static, CWV-budgeted site, prefer GPT over AdSense manual
View on GitHub