- 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
320 lines
10 KiB
Python
320 lines
10 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
GSC Analytics for moreminimore-service.
|
|
Reads clients-config.json, pulls GSC data using service account key.
|
|
|
|
Usage:
|
|
python3 mm_gsc.py --client moreminimore --days 90
|
|
python3 mm_gsc.py --client moreminimore --days 28
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import sys
|
|
import urllib.request
|
|
import urllib.error
|
|
from datetime import date, timedelta
|
|
|
|
|
|
def load_config():
|
|
"""Load clients-config.json"""
|
|
config_path = os.path.expanduser("~/vault/99_System/clients-config.json")
|
|
if not os.path.exists(config_path):
|
|
print(f"ERROR: Config not found at {config_path}", file=sys.stderr)
|
|
sys.exit(1)
|
|
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_gsc_property(client):
|
|
"""Get GSC property from client config"""
|
|
analytics = client.get("analytics", {})
|
|
gsc_property = analytics.get("gsc_property", "")
|
|
if not gsc_property:
|
|
print(f"ERROR: No gsc_property set for client '{client['id']}'", file=sys.stderr)
|
|
sys.exit(1)
|
|
return gsc_property
|
|
|
|
|
|
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/webmasters.readonly",
|
|
"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 gsc_query(site_property, access_token, request_body):
|
|
"""Execute a GSC API query"""
|
|
# URL encode the site property
|
|
import urllib.parse
|
|
site_encoded = urllib.parse.quote(site_property, safe='')
|
|
|
|
url = f"https://searchconsole.googleapis.com/webmasters/v3/sites/{site_encoded}/searchAnalytics/query"
|
|
|
|
headers = {
|
|
"Authorization": f"Bearer {access_token}",
|
|
"Content-Type": "application/json",
|
|
}
|
|
|
|
body = json.dumps(request_body).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: GSC 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 get_top_queries(site_property, access_token, days=28):
|
|
"""Get top queries"""
|
|
end_date = date.today() - timedelta(days=3) # GSC data lags ~3 days
|
|
start_date = end_date - timedelta(days=days)
|
|
|
|
result = gsc_query(site_property, access_token, {
|
|
"startDate": start_date.isoformat(),
|
|
"endDate": end_date.isoformat(),
|
|
"dimensions": ["query"],
|
|
"rowLimit": 50,
|
|
"type": "web",
|
|
})
|
|
|
|
return result.get("rows", [])
|
|
|
|
|
|
def get_top_pages(site_property, access_token, days=28):
|
|
"""Get top pages"""
|
|
end_date = date.today() - timedelta(days=3)
|
|
start_date = end_date - timedelta(days=days)
|
|
|
|
result = gsc_query(site_property, access_token, {
|
|
"startDate": start_date.isoformat(),
|
|
"endDate": end_date.isoformat(),
|
|
"dimensions": ["page"],
|
|
"rowLimit": 50,
|
|
"type": "web",
|
|
})
|
|
|
|
return result.get("rows", [])
|
|
|
|
|
|
def get_summary(site_property, access_token, days=28):
|
|
"""Get overall summary"""
|
|
end_date = date.today() - timedelta(days=3)
|
|
start_date = end_date - timedelta(days=days)
|
|
|
|
result = gsc_query(site_property, access_token, {
|
|
"startDate": start_date.isoformat(),
|
|
"endDate": end_date.isoformat(),
|
|
"dimensions": ["date"],
|
|
"type": "web",
|
|
})
|
|
|
|
rows = result.get("rows", [])
|
|
total_clicks = sum(r.get("clicks", 0) for r in rows)
|
|
total_impressions = sum(r.get("impressions", 0) for r in rows)
|
|
avg_ctr = (total_clicks / total_impressions * 100) if total_impressions > 0 else 0
|
|
avg_position = sum(r.get("position", 0) * r.get("impressions", 0) for r in rows) / total_impressions if total_impressions > 0 else 0
|
|
|
|
return {
|
|
"clicks": total_clicks,
|
|
"impressions": total_impressions,
|
|
"ctr": avg_ctr,
|
|
"position": avg_position,
|
|
"start_date": start_date.isoformat(),
|
|
"end_date": end_date.isoformat(),
|
|
}
|
|
|
|
|
|
def display_summary(summary):
|
|
"""Display summary"""
|
|
print(f"📊 Summary ({summary['start_date']} to {summary['end_date']})")
|
|
print("-" * 60)
|
|
print(f" Clicks: {summary['clicks']:,}")
|
|
print(f" Impressions: {summary['impressions']:,}")
|
|
print(f" CTR: {summary['ctr']:.2f}%")
|
|
print(f" Avg Position: {summary['position']:.1f}")
|
|
|
|
|
|
def display_queries(rows):
|
|
"""Display top queries"""
|
|
if not rows:
|
|
print("\nไม่พบ queries")
|
|
return
|
|
|
|
print(f"\n🔑 Top Queries ({len(rows)})")
|
|
print("-" * 90)
|
|
print(f"{'Query':<45} {'Clicks':>10} {'Impressions':>12} {'CTR':>8} {'Position':>10}")
|
|
print("-" * 90)
|
|
|
|
for row in rows[:30]:
|
|
keys = row.get("keys", [])
|
|
query = keys[0] if keys else ""
|
|
ctr = row.get("ctr", 0) * 100
|
|
|
|
print(f"{query[:43]:<45} {row.get('clicks', 0):>10,} "
|
|
f"{row.get('impressions', 0):>12,} "
|
|
f"{ctr:>7.2f}% "
|
|
f"{row.get('position', 0):>10.1f}")
|
|
|
|
|
|
def display_pages(rows):
|
|
"""Display top pages"""
|
|
if not rows:
|
|
print("\nไม่พบ pages")
|
|
return
|
|
|
|
print(f"\n📄 Top Pages ({len(rows)})")
|
|
print("-" * 90)
|
|
print(f"{'Page':<55} {'Clicks':>10} {'Impressions':>12} {'CTR':>8}")
|
|
print("-" * 90)
|
|
|
|
for row in rows[:20]:
|
|
keys = row.get("keys", [])
|
|
page = keys[0] if keys else ""
|
|
ctr = row.get("ctr", 0) * 100
|
|
|
|
print(f"{page[:53]:<55} {row.get('clicks', 0):>10,} "
|
|
f"{row.get('impressions', 0):>12,} "
|
|
f"{ctr:>7.2f}%")
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description="GSC Analytics for moreminimore-service")
|
|
parser.add_argument("--client", required=True, help="Client ID from clients-config.json")
|
|
parser.add_argument("--days", type=int, default=90, help="Number of days to analyze (default: 90)")
|
|
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 in config", file=sys.stderr)
|
|
print(f"Available clients: {[c['id'] for c in config.get('clients', [])]}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
# Get GSC property
|
|
gsc_property = get_gsc_property(client)
|
|
|
|
# Get 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)
|
|
|
|
print(f"📊 GSC Analytics for: {client['display_name']}")
|
|
print(f" Property: {gsc_property}")
|
|
print(f" Period: {args.days} days")
|
|
print()
|
|
|
|
# Get access token
|
|
access_token = get_access_token(os.path.expanduser(key_path))
|
|
|
|
# Get data
|
|
summary = get_summary(gsc_property, access_token, args.days)
|
|
queries = get_top_queries(gsc_property, access_token, args.days)
|
|
pages = get_top_pages(gsc_property, access_token, args.days)
|
|
|
|
if args.json:
|
|
result = {
|
|
"summary": summary,
|
|
"queries": queries,
|
|
"pages": pages,
|
|
}
|
|
print(json.dumps(result, indent=2))
|
|
else:
|
|
display_summary(summary)
|
|
display_queries(queries)
|
|
display_pages(pages)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|