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
This commit is contained in:
419
scripts/mm_google_ads.py
Normal file
419
scripts/mm_google_ads.py
Normal file
@@ -0,0 +1,419 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user