- 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
420 lines
14 KiB
Python
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()
|