Files
moreminimore-service-system/scripts/mm_google_ads.py
Kunthawat Greethong 7b5af16f6d Initial commit: moreminimore-service content pipeline
- 9 mm* skills (orchestrator, article, social, publish, analytics)
- 6 dependency skills (content-writer, geo-optimizer, etc.)
- 3 analytics scripts (GSC, Google Ads, Meta Ads)
- Config template + setup guide
- SOUL-MM.md persona extension
- OrbitOS integration reference
2026-07-02 10:01:53 +07:00

420 lines
14 KiB
Python

#!/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()