The operators running Google Ads for small B&Bs face a structural problem that isn’t primarily about budget. OTAs dominate paid search through three compounding advantages: years of account history that builds Quality Score over time, commission rates higher than most people realize that fund ad budgets small operators can’t match, and full-time PPC teams running experiments 52 weeks a year. A family-run property on the Hengchun peninsula competes against all of that with one person who checks the account when something feels wrong.
The question isn’t whether professional-grade keyword management would help — it would. The question is whether it’s economically viable without a full-time marketing hire.
What ran before
An n8n setup on Railway had already found an approach. It pulled keyword data from Google Ads weekly, ran it through an LLM with B&B-specific guardrails, and generated an operator-readable report with add and remove suggestions. The operator clicked a button in an email to approve changes. For a property that had been running ads without systematic review, it was a material change.
The infrastructure to run it: four Railway containers — n8n Main, Worker, Redis, and Postgres — running 24/7. CPU utilization between the weekly runs was near zero. Cost: approximately $10/month. That’s not large in absolute terms, but it’s a fixed infrastructure line item that doesn’t go to zero between runs. For a side service supporting clients on thin margins, that’s the kind of cost that eventually gets cut — not because anyone decided to, but because it’s hard to justify indefinitely.
The migration decision
The move to Python on GitHub Actions isn’t primarily about saving $10/month. It’s about whether this tool is still running two years from now.
GitHub Actions free tier covers a weekly cron job with room to spare. LLM API cost for four analyzer runs per month sits in the range of cents. Zero always-on containers means zero idle spend. The tool can exist indefinitely without a recurring infrastructure budget — which changes the conversation from “is this worth the cost” to “is this working.”
Python was the right choice for this. The google-ads SDK is more mature than its TypeScript counterpart, with better GAQL support and clean proto-plus enum handling. More importantly, the entire Ads + Sheets + Gmail surface shares a single OAuth refresh token — one consent flow, one secret to manage in GitHub. In the n8n version, each API had its own credential set managed through n8n’s UI.
Two workflows, one human gate
analyzer.yml runs on a weekly cron (Monday 9 AM Taipei time) and on workflow_dispatch. For each configured account, it pulls enabled keywords from the Google Ads API via GAQL, reads the keyword inventory from Google Sheets to get state history, diffs the two to track keywords that have been added, removed, or newly appeared since the last run, then sends the combined payload to Claude Opus with the account’s system prompt. The model returns a JSON object: mvp (best-performing keyword with rationale), monitor (watchlist), add_suggestions (keywords to test), remove_suggestions (candidates to remove). That result gets written as a task row in Sheets, and an HTML analysis email goes to the operator.
executor.yml is triggered manually. The operator reads the email, then fires the workflow from the GitHub Actions UI with two inputs: a task_id copied from the email and an action — either add or remove. The task row stores both operations as separate keys. Approving keyword additions doesn’t require approving removals in the same run. The operator can batch adds this week and hold removes for next month — the architecture supports it without workarounds.
Nothing writes to a live ad account without that manual trigger. This matters for a client who wants a review period between analysis and action.
The 14-day rule in two places
Every system prompt includes a new-keyword protection rule: keywords added within the last 14 days are immune from remove suggestions regardless of performance metrics. A new keyword hasn’t had time to accumulate signal; a zero-CTR reading at four days is noise.
Prompts drift under data pressure. When the keyword list is long and the JSON payload is dense, a model may honor the rule for 11 entries and silently miss it on the 12th. This is the class of failure that’s hard to catch in testing — it doesn’t error, it just produces a slightly wrong output that looks right.
So decisions.py re-applies the same rule as a hard Python filter after the LLM returns its output. filter_protected_removals() takes the model’s remove_suggestions list, checks each suggestion’s criterion_id against the inventory’s date_added, and blocks any keyword within 14 days of today. If the date_added field is missing or malformed, the keyword is treated as protected by default — defensive over permissive.
The blocked suggestions don’t disappear silently. They surface in the operator’s email as a separate section, listing what the model tried to remove but couldn’t. The operator sees the full picture, not just the filtered output.
This belt-and-braces design — rule in the prompt, same rule enforced in code — reflects a decision about what the failure mode should look like. If the prompt fails, the code catches it. If somehow both fail, a human still approves the executor run before anything touches the live account. Three layers for an operation that can’t easily be undone.
Per-account domain knowledge
Each B&B account has a topic_facts block in config.py that gets injected into the system prompt at runtime. One property’s block specifies: no large pools, no sea or mountain views; emphasize the shallow paddling pool, BBQ facilities, KTV room, and walkability to the neighborhood supermarket and night market. These are facts and prohibitions, not style instructions.
They exist because a keyword-analysis prompt operating without property knowledge can hallucinate feature associations. If the model infers from search term data that a property should target “pool resort” keywords — and the property has a paddling pool, not a swimming pool — the operator who acts on that advice creates a gap between ad promise and guest experience. Quality Score deteriorates from the mismatch. The topic_facts block prevents the model from reasoning toward recommendations that are technically plausible from the data but wrong for this specific property.
The block is a per-account constant, not part of the prompt template. Same analyzer loop, same code, different domain context injected at build time. Adding an account is a config entry and a consent-flow run — not a code change.
What this makes possible
Four Railway containers retired. Infrastructure cost from ~$10/month to the API cost of four Claude runs per month — a number that doesn’t meaningfully change whether the service continues to exist.
Two accounts configured today, with a multi-account loop in analyzer.py that extends to additional accounts via config, not code. The n8n predecessor was hardcoded for one account; adding a second would have been an infrastructure operation.
Professional-grade keyword hygiene — weekly analysis, 14-day new-keyword protection enforced in code, B&B-specific domain knowledge in every prompt, human approval before any live mutation — at a cost structure that matches what these clients can actually sustain. The OTA structural advantage doesn’t disappear. But a property running clean, properly targeted keywords with consistent weekly review closes part of that gap without a full-time PPC team. That’s the economic logic the system is designed around — and the reason the infrastructure has to be cheap enough to keep running when nobody’s thinking about it.