#!/usr/bin/env python3 """ Google Ads API for moreminimore-service. Reads clients-config.json, pulls campaign/keyword/ad data. Usage: python3 mm_google_ads.py --client moreminimore campaigns python3 mm_google_ads.py --client moreminimore keywords python3 mm_google_ads.py --client moreminimore stats --days 30 """ import argparse import json import os import sys import urllib.request import urllib.error def load_config(): """Load clients-config.json""" config_path = os.path.expanduser("~/vault/99_System/clients-config.json") with open(config_path) as f: return json.load(f) def find_client(config, client_id): """Find client by ID""" for client in config.get("clients", []): if client["id"] == client_id: return client return None def get_google_ads_config(config, client): """Get Google Ads credentials from config""" google = config.get("global", {}).get("google", {}) developer_token = google.get("developer_token", "") manager_account_id = google.get("manager_account_id", "") customer_id = client.get("analytics", {}).get("google_ads_customer_id", "") if not developer_token: print("ERROR: No developer_token in global.google config", file=sys.stderr) sys.exit(1) if not customer_id: print(f"ERROR: No google_ads_customer_id for client '{client['id']}'", file=sys.stderr) sys.exit(1) # Remove hyphens from customer_id customer_id = customer_id.replace("-", "") return { "developer_token": developer_token, "manager_account_id": manager_account_id.replace("-", ""), "customer_id": customer_id, } def get_access_token(key_path): """Get access token from service account key using openssl for JWT signing""" import subprocess import base64 import time if not os.path.exists(key_path): print(f"ERROR: Service account key not found: {key_path}", file=sys.stderr) sys.exit(1) try: with open(key_path) as f: key_data = json.load(f) # JWT header header = {"alg": "RS256", "typ": "JWT"} header_b64 = base64.urlsafe_b64encode(json.dumps(header).encode()).decode().rstrip('=') # JWT payload now = int(time.time()) payload = { "iss": key_data["client_email"], "scope": "https://www.googleapis.com/auth/adwords", "aud": "https://oauth2.googleapis.com/token", "iat": now, "exp": now + 3600, } payload_b64 = base64.urlsafe_b64encode(json.dumps(payload).encode()).decode().rstrip('=') # Sign with openssl signing_input = f"{header_b64}.{payload_b64}" # Write private key to temp file import tempfile with tempfile.NamedTemporaryFile(mode='w', suffix='.pem', delete=False) as kf: kf.write(key_data["private_key"]) key_file = kf.name try: # Sign with openssl result = subprocess.run( ["openssl", "dgst", "-sha256", "-sign", key_file], input=signing_input.encode(), capture_output=True, timeout=10, ) if result.returncode != 0: print(f"ERROR: openssl signing failed: {result.stderr.decode()}", file=sys.stderr) sys.exit(1) signature_b64 = base64.urlsafe_b64encode(result.stdout).decode().rstrip('=') signed_jwt = f"{signing_input}.{signature_b64}" finally: os.unlink(key_file) # Exchange JWT for access token token_url = "https://oauth2.googleapis.com/token" data = urllib.parse.urlencode({ "grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer", "assertion": signed_jwt, }).encode() req = urllib.request.Request(token_url, data=data, method="POST") req.add_header("Content-Type", "application/x-www-form-urlencoded") with urllib.request.urlopen(req, timeout=15) as resp: token_data = json.loads(resp.read().decode()) return token_data["access_token"] except Exception as e: print(f"ERROR: Failed to get access token: {e}", file=sys.stderr) print(f" Key file: {key_path}", file=sys.stderr) sys.exit(1) def google_ads_query(customer_id, query, developer_token, access_token, manager_account_id=None): """Execute a GAQL query against Google Ads API""" url = f"https://googleads.googleapis.com/v24/customers/{customer_id}/googleAds:searchStream" headers = { "Authorization": f"Bearer {access_token}", "developer-token": developer_token, "Content-Type": "application/json", } if manager_account_id: headers["login-customer-id"] = manager_account_id body = json.dumps({"query": query}).encode() req = urllib.request.Request(url, data=body, headers=headers, method="POST") try: with urllib.request.urlopen(req, timeout=30) as resp: return json.loads(resp.read().decode()) except urllib.error.HTTPError as e: error_body = e.read().decode() if e.fp else "" print(f"ERROR: Google Ads API returned {e.code}", file=sys.stderr) if error_body: try: error_json = json.loads(error_body) print(json.dumps(error_json, indent=2), file=sys.stderr) except: print(error_body[:500], file=sys.stderr) sys.exit(1) def list_campaigns(ads_config, access_token): """List active campaigns""" query = """ SELECT campaign.id, campaign.name, campaign.status, campaign.advertising_channel_type, metrics.impressions, metrics.clicks, metrics.cost_micros, metrics.conversions FROM campaign WHERE campaign.status = 'ENABLED' ORDER BY campaign.name """ result = google_ads_query( ads_config["customer_id"], query, ads_config["developer_token"], access_token, ads_config.get("manager_account_id"), ) return result def list_keywords(ads_config, access_token): """List active keywords with performance""" query = """ SELECT ad_group.name, ad_group_criterion.keyword.text, ad_group_criterion.keyword.match_type, ad_group_criterion.quality_info.quality_score, metrics.impressions, metrics.clicks, metrics.cost_micros, metrics.conversions, metrics.ctr, metrics.average_cpc FROM keyword_view WHERE ad_group_criterion.status = 'ENABLED' ORDER BY metrics.impressions DESC LIMIT 100 """ result = google_ads_query( ads_config["customer_id"], query, ads_config["developer_token"], access_token, ads_config.get("manager_account_id"), ) return result def get_stats(ads_config, access_token, days=30): """Get campaign performance stats""" query = f""" SELECT campaign.name, segments.date, metrics.impressions, metrics.clicks, metrics.cost_micros, metrics.conversions, metrics.ctr, metrics.average_cpc, metrics.cost_per_conversion FROM campaign WHERE campaign.status = 'ENABLED' AND segments.date DURING LAST_{days}_DAYS ORDER BY segments.date DESC """ result = google_ads_query( ads_config["customer_id"], query, ads_config["developer_token"], access_token, ads_config.get("manager_account_id"), ) return result def format_currency(micros): """Format micros to currency""" if not micros: return "฿0" return f"฿{int(micros) / 1_000_000:,.2f}" def format_number(n): """Format number with commas""" if not n: return "0" return f"{int(n):,}" def display_campaigns(data): """Display campaign data in a table""" if not data or not data[0].get("results"): print("ไม่พบ campaign ที่ active") return results = data[0]["results"] print(f"\n📋 Active Campaigns ({len(results)})") print("-" * 100) print(f"{'Name':<30} {'Type':<15} {'Impressions':>12} {'Clicks':>10} {'Cost':>12} {'Conv':>8}") print("-" * 100) for row in results: campaign = row.get("campaign", {}) metrics = row.get("metrics", {}) name = campaign.get("name", "")[:28] channel = campaign.get("advertisingChannelType", "") print(f"{name:<30} {channel:<15} {format_number(metrics.get('impressions', 0)):>12} " f"{format_number(metrics.get('clicks', 0)):>10} " f"{format_currency(metrics.get('costMicros', 0)):>12} " f"{format_number(metrics.get('conversions', 0)):>8}") def display_keywords(data): """Display keyword data in a table""" if not data or not data[0].get("results"): print("ไม่พบ keyword ที่ active") return results = data[0]["results"] print(f"\n🔑 Keywords ({len(results)})") print("-" * 110) print(f"{'Keyword':<35} {'Match':<10} {'QS':>4} {'Impressions':>12} {'Clicks':>10} {'CTR':>8} {'CPC':>10}") print("-" * 110) for row in results: criterion = row.get("adGroupCriterion", {}) keyword = criterion.get("keyword", {}) metrics = row.get("metrics", {}) qs = criterion.get("qualityInfo", {}).get("qualityScore", "") kw_text = keyword.get("text", "")[:33] match_type = keyword.get("matchType", "") ctr = float(metrics.get("ctr", 0)) * 100 avg_cpc = int(metrics.get("averageCpc", 0)) / 1_000_000 print(f"{kw_text:<35} {match_type:<10} {qs:>4} " f"{format_number(metrics.get('impressions', 0)):>12} " f"{format_number(metrics.get('clicks', 0)):>10} " f"{ctr:>7.2f}% " f"฿{avg_cpc:>8.2f}") def display_stats(data): """Display stats in a table""" if not data or not data[0].get("results"): print("ไม่พบข้อมูลสถิติ") return results = data[0]["results"] # Aggregate by campaign campaigns = {} for row in results: campaign_name = row.get("campaign", {}).get("name", "") metrics = row.get("metrics", {}) if campaign_name not in campaigns: campaigns[campaign_name] = { "impressions": 0, "clicks": 0, "cost": 0, "conversions": 0 } campaigns[campaign_name]["impressions"] += int(metrics.get("impressions", 0)) campaigns[campaign_name]["clicks"] += int(metrics.get("clicks", 0)) campaigns[campaign_name]["cost"] += int(metrics.get("costMicros", 0)) campaigns[campaign_name]["conversions"] += float(metrics.get("conversions", 0)) print(f"\n📊 Campaign Stats") print("-" * 100) print(f"{'Campaign':<30} {'Impressions':>12} {'Clicks':>10} {'Cost':>12} {'Conv':>8} {'CTR':>8} {'CPA':>12}") print("-" * 100) for name, m in campaigns.items(): ctr = (m["clicks"] / m["impressions"] * 100) if m["impressions"] > 0 else 0 cpa = (m["cost"] / m["conversions"]) if m["conversions"] > 0 else 0 print(f"{name[:28]:<30} {format_number(m['impressions']):>12} " f"{format_number(m['clicks']):>10} " f"{format_currency(m['cost']):>12} " f"{int(m['conversions']):>8} " f"{ctr:>7.2f}% " f"{format_currency(cpa):>12}") def main(): parser = argparse.ArgumentParser(description="Google Ads API for moreminimore-service") parser.add_argument("--client", required=True, help="Client ID from clients-config.json") parser.add_argument("action", choices=["campaigns", "keywords", "stats"], help="Action to perform") parser.add_argument("--days", type=int, default=30, help="Number of days for stats (default: 30)") parser.add_argument("--json", action="store_true", help="Output raw JSON") args = parser.parse_args() # Load config config = load_config() # Find client client = find_client(config, args.client) if not client: print(f"ERROR: Client '{args.client}' not found", file=sys.stderr) sys.exit(1) # Get Google Ads config ads_config = get_google_ads_config(config, client) print(f"📊 Google Ads — {client['display_name']}") print(f" Customer ID: {ads_config['customer_id']}") print() # Get access token from service account key key_path = config.get("global", {}).get("google", {}).get("service_account_key", "") if not key_path: print("ERROR: No service_account_key in global.google config", file=sys.stderr) sys.exit(1) access_token = get_access_token(os.path.expanduser(key_path)) # Execute action if args.action == "campaigns": data = list_campaigns(ads_config, access_token) if args.json: print(json.dumps(data, indent=2)) else: display_campaigns(data) elif args.action == "keywords": data = list_keywords(ads_config, access_token) if args.json: print(json.dumps(data, indent=2)) else: display_keywords(data) elif args.action == "stats": data = get_stats(ads_config, access_token, args.days) if args.json: print(json.dumps(data, indent=2)) else: display_stats(data) if __name__ == "__main__": main()