# /keyword-research

> Google Keyword Planner via the Google Ads API.


# Keyword Research

## What this does

Wraps Google's `<id>` (`<id>` and `generateKeywordIdeas`) so we can pull real Keyword Planner data without using the Ads UI. Same data you'd see in the Ads Manager UI, but scriptable.

## Prerequisites — Standard Access

**Status: APPROVED and working.** The Keyword Planner endpoints require **Google Ads API Standard Access**, and our developer token now has it — `generateKeywordIdeas` / `<id>` return **exact** volumes, competition, and CPC ranges (confirmed 2026-06-16, ACME Agency run). No `<id>` errors.

**Calling-account caveat still applies:** search-volume accuracy depends on the *calling* account (`--customer-id` / the client's `google_ads_id`) having recent ad spend. Active spenders return exact numbers; truly dormant accounts can return bucketed ranges ("1K–10K"). Kaiser's account (Meta-only, low Google spend) still returned exact numbers, so most of our accounts qualify — but if you get bucketed ranges, pass `--customer-id` of a high-spend account in the MCC.

(Historical note: the token previously had Basic Access only; Standard Access was applied for via https://support.google.com/adspolicy/contact/new_token_application and has since been granted.)

## Modes

### 1. Historical — validate existing keywords

Given a campaign's keyword list, pull monthly search volume + competition for each. Highlights:
- Zero-volume keywords (drop these — they kill quality score)
- Keywords intended as EXACT that should be PHRASE/BROAD (or vice versa)
- High-cost keywords that may need bid adjustment

```bash
node ACME Agency/scripts/keyword_research.mjs \
  --client "ACME Agency" --geo AT \
  --keywords-file "ACME Agency/clients/ACME Agency/at_keywords.json"
```

Accepted keyword-file shapes:
- Ad-group object (`{ ad_groups: [ { name, exact, phrase, broad } ] }`) — preserves match-type intent
- Flat array (`["kw1", "kw2"]`)
- Wrapped (`{ keywords: [...] }`)

### 2. Ideas — discover new keywords

Seed with keywords, a URL, or both:

```bash
# From seed keywords
node ACME Agency/scripts/keyword_research.mjs \
  --client "ACME Agency" --geo DE \
  --seeds "bioklimatische pergola,lamellendach,terrassenüberdachung"

# From a competitor URL
node ACME Agency/scripts/keyword_research.mjs \
  --client "ACME Agency" --geo AT \
  --url "https://www.competitor.at/pergolen"

# Both
node ACME Agency/scripts/keyword_research.mjs \
  --client "ACME Agency" --geo AT \
  --seeds "pergola" --url "https://anmeldung.<id>.de/"
```

## Languages

Default behavior: **local language for the geo + English**. Rationale: many users in DE/AT/HR have their browser/account set to English even when searching in their local language, so excluding English under-counts demand.

Override:
- `--lang de,en` — explicit code list
- `--no-english` — local only
- `--lang-ids 1001,1000` — pass raw Google language constant IDs

Built-in geo + language mapping (see `ACME Agency/scripts/lib/keyword_planner.mjs`):
- Geos: AT, DE, CH, HR, BA, RS, SI, ME, MK, IT, FR, ES, UK, US, CA, IE, NL, BE, PL, RO
- Languages: en, de, fr, es, it, pt, nl, ro, pl, hr, sr, sl, bg, cs, sk, hu, tr, el

For anything else, pass the numeric ID directly (e.g. `--geo 2792` for Turkey).

## Output

For each run:
- CSV at `ACME Agency/clients/<ClientFolder>/keyword_research/<mode>_<geo>_<date>.csv`
- JSON snapshot alongside (for downstream analysis)
- Stdout summary: top-N keywords by volume, ad-group breakdown (historical mode), zero-volume drop list
- If `--slack`: a summary message to the client's Slack channel

CSV columns (historical):
`ad_group, keyword, intended_match_type, avg_monthly_searches, competition, competition_index, low_bid_eur, high_bid_eur, <id>, volume_drop_vs_intent, note`

CSV columns (ideas):
`keyword, avg_monthly_searches, competition, competition_index, low_bid_eur, high_bid_eur, <id>`

## Match-type recommendation heuristic

The script applies a simple rule to suggest a match type:

| Avg monthly searches | Recommended match type |
|---|---|
| 0 | DROP — no demand, kills Quality Score |
| 1–99 | PHRASE if competition=HIGH, otherwise BROAD |
| 100–999 | PHRASE |
| ≥ 1000 | EXACT |

These are starting points — humans override based on intent.

## Customer ID

The script uses the client's `google_ads_id` from `clients.json` as the calling customer. **Search-volume accuracy depends on the calling account having recent ad spend** — dormant accounts return bucketed ranges (e.g. "1K–10K") instead of exact numbers. All our active client accounts qualify.

If a client doesn't have a Google Ads account yet (or you want to probe a niche from scratch), pass `--customer-id <id>` of any spending account in the MCC.