commit 7b5af16f6d300b06fd38e78b81e2ed0b75012cb0 Author: Kunthawat Greethong Date: Thu Jul 2 10:01:53 2026 +0700 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..538e96e --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +# Credentials (NEVER commit real values) +config/clients-config.json + +# Vault data (machine-specific) +vault/ + +# OS +.DS_Store +Thumbs.db + +# Python +__pycache__/ +*.pyc +.venv/ + +# IDE +.vscode/ +.idea/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..fd4d9ef --- /dev/null +++ b/README.md @@ -0,0 +1,103 @@ +# moreminimore-service-system + +Content pipeline and marketing automation system for moreminimore-service clients. + +## Architecture + +``` +Data Stage → Content Stage → Publish Article → Social Stage → Publish Social → Analytics +``` + +### Skills (16 total) + +**Core pipeline (mm*):** +- `mm-content-orchestrator` — top-level pipeline coordinator +- `mm-article-from-research` — Data Stage A: vault research → brief +- `mm-article-idea-extract` — Data Stage B: idea → research → brief +- `mm-content-writer` — Content Stage: brief → article + images +- `mm-social-writer` — Social Stage: article + URL → posts +- `mm-publish-content` — Publish Stage: push to platforms +- `mm-blog-categories` — fetch/cache blog categories +- `mm-analytics` — pull analytics data +- `mm-analytics-orchestrator` — analytics → recommendations + +**Dependencies (from Hermes skill library):** +- `content-writer` — SEO writing guidelines +- `geo-optimizer` — GEO optimization +- `post-formatter` — framework-based posts +- `hook-generator` — social hooks +- `graphic-designer` — graphics +- `xurl` — X/Twitter API + +**External (OrbitOS — install separately):** +- `obsidian` — vault read/write +- `orbites-research` — deep research +- `orbites-parse-knowledge` — structure text +- `orbites-obsidian-markdown` — markdown reference + +See `references/orbitos-skills.md` for OrbitOS integration details. + +### Scripts + +``` +scripts/ +├── mm_gsc.py — Google Search Console analytics +├── mm_google_ads.py — Google Ads analytics (GAQL v24) +├── mm_meta_ads.py — Meta Ads analytics (Graph API) +└── README.md — setup guide +``` + +All scripts read from `~/vault/99_System/clients-config.json`. + +### Config + +``` +config/ +├── clients-config.template.json — template (no credentials) +└── README.md — setup guide +``` + +Copy template to `~/vault/99_System/clients-config.json` and fill in credentials. + +### Vault Structure + +``` +~/vault/ +├── 30_Research// — research per client +│ └── Archive/ — used research +├── 50_Resources/Strategy for Customers// +│ — analytics reports +├── 60_Articles// — articles per client +│ └── Archive/ — published articles +└── 99_System/clients-config.json — client credentials +``` + +## Quick Start + +1. Copy config template: + ```bash + cp config/clients-config.template.json ~/vault/99_System/clients-config.json + ``` + +2. Fill in credentials (see `config/README.md`) + +3. Install OrbitOS skills (see `references/orbitos-skills.md`) + +4. Run orchestrator: + ``` + mm idea: [topic] + ``` + +## Workflow Commands + +``` +mm from research — start from vault research +mm idea: [topic] — start from idea +mm status — check pipeline status +mm analytics [client] — run analytics report +``` + +## SOUL-MM.md + +Marketing-specific persona extension. Load when working on client campaigns. +See `SOUL-MM.md`. diff --git a/SOUL-MM.md b/SOUL-MM.md new file mode 100644 index 0000000..cb7b9e9 --- /dev/null +++ b/SOUL-MM.md @@ -0,0 +1,93 @@ +# SOUL-MM.md — Moreminimore Service Context + +Extends: SOUL.md (Macky base persona) + +## Role + +Digital marketing operator for moreminimore-service clients. Manage end-to-end content pipeline: research, article creation, social posting, publishing, and analytics. + +## Clients + +Client data lives in `~/vault/99_System/clients-config.json`. Each client has: +- `id` — unique identifier (kebab-case) +- `display_name` — human-readable name +- `contact` — phone, email, line, facebook_url, cta_text +- `website` — type (astro/wordpress), url, categories +- `social` — facebook, instagram, x credentials +- `social_platforms` — which platforms to use (e.g. ["facebook"]) +- `analytics` — gsc_property, ga_property_id, google_ads_customer_id, meta_ad_account_id + +When adding a new client, browse their website to extract contact info automatically. + +## Workflow + +``` +Data Stage → Content Stage → Publish Article → Social Stage → Publish Social → Analytics +``` + +1. **Data Stage**: Research topic (from vault or internet) → produce brief.md +2. **Content Stage**: Write article with SEO + GEO + images → user approval +3. **Publish Article**: Push to website (Astro/WordPress) → capture published_url +4. **Social Stage**: Create posts with article URL → user approval +5. **Publish Social**: Post to platforms in social_platforms → archive check +6. **Analytics**: Pull GSC + Ads data → strategy recommendations + +## Rules + +1. **Never publish without user approval** — always present for review first +2. **Never create posts for platforms not in client's social_platforms** +3. **CTA/contact info is for ad copy only** — social posts do NOT include CTA +4. **Archive only when ALL files are published** — article.md + all post-*.md must have status: published +5. **Match user's language** — Thai if they write Thai, English if English +6. **Check clients-config.json before any platform operation** — credentials may differ per client +7. **Save analytics reports to** `~/vault/50_Resources/Strategy for Customers//` + +## Vault Structure + +``` +~/vault/ +├── 30_Research/ +│ ├── general/ ← research not tied to a client +│ └── / ← research per client +│ └── Archive/ ← used research +├── 50_Resources/ +│ └── Strategy for Customers/ +│ └── / ← analytics reports +├── 60_Articles/ +│ └── / +│ ├── -/ ← active article +│ └── Archive/ ← fully published articles +└── 99_System/ + └── clients-config.json ← all client credentials +``` + +## Key Skills + +| Skill | Purpose | +|-------|---------| +| mm-content-orchestrator | Top-level pipeline coordinator | +| mm-article-from-research | Data Stage A: vault research → brief | +| mm-article-idea-extract | Data Stage B: idea → research → brief | +| mm-content-writer | Content Stage: brief → article + images | +| mm-social-writer | Social Stage: article + URL → posts | +| mm-publish-content | Publish Stage: push to platforms | +| mm-analytics-orchestrator | Analytics Stage: data → recommendations | +| mm-blog-categories | Fetch/cache blog categories | +| mm-analytics | Pull analytics data | + +## OrbitOS Integration + +This project uses OrbitOS vault conventions. Load these skills when needed: +- `obsidian` — vault read/write/search +- `orbites-research` — deep research workflow +- `orbites-parse-knowledge` — structure unstructured text +- `orbites-obsidian-markdown` — markdown format reference + +## Scripts + +Analytics scripts live in `~/Gitea/moreminimore-service-system/scripts/`: +- `mm_gsc.py` — Google Search Console data +- `mm_google_ads.py` — Google Ads data +- `mm_meta_ads.py` — Meta Ads data + +All scripts read from `~/vault/99_System/clients-config.json`. diff --git a/config/README.md b/config/README.md new file mode 100644 index 0000000..6879816 --- /dev/null +++ b/config/README.md @@ -0,0 +1,47 @@ +# Config Setup Guide + +## 1. Copy template + +```bash +cp config/clients-config.template.json ~/vault/99_System/clients-config.json +``` + +## 2. Fill in global credentials + +### Google (for GSC + Google Ads) +- `service_account_key` — path to service account JSON key file +- `developer_token` — from Google Ads → Tools → API Center +- `manager_account_id` — MCC account ID (format: 123-456-7890) + +### Meta (for Facebook/Instagram Ads) +- `app_id` — from developers.facebook.com → App Settings +- `app_secret` — from developers.facebook.com → App Settings → Basic +- `system_user_token` — Business Settings → System Users → Generate Token + +## 3. Add clients + +Each client needs: +- `id` — unique kebab-case identifier +- `display_name` — human-readable name +- `contact` — phone, email, line (browsed from website) +- `website` — type (astro/wordpress), url +- `social_platforms` — which platforms to use (e.g. ["facebook"]) +- `analytics` — GSC property, GA ID, Google Ads ID, Meta Ads ID + +## 4. Set up GSC access + +Add service account email as user in each client's GSC property: +1. Go to https://search.google.com/search-console +2. Select client's property +3. Settings → Users and permissions → Add user +4. Email: [from service_account_key → client_email] +5. Permission: Full or Restricted + +## 5. Verify + +```bash +cd ~/Gitea/moreminimore-service-system/scripts +python3 mm_gsc.py --client --days 7 +python3 mm_google_ads.py --client campaigns +python3 mm_meta_ads.py --client campaigns +``` diff --git a/config/clients-config.template.json b/config/clients-config.template.json new file mode 100644 index 0000000..1f91165 --- /dev/null +++ b/config/clients-config.template.json @@ -0,0 +1,47 @@ +{ + "global": { + "google": { + "service_account_key": "~/.hermes/keys/google-service-account.json", + "developer_token": "", + "manager_account_id": "" + }, + "meta": { + "app_id": "", + "app_secret": "", + "system_user_token": "" + } + }, + "clients": [ + { + "id": "example", + "display_name": "Example Client", + "contact": { + "phone": "", + "email": "", + "line": "", + "facebook_url": "", + "website_url": "https://example.com", + "address": "", + "cta_text": "" + }, + "website": { + "type": "wordpress", + "url": "https://example.com", + "categories": [], + "categories_cached_at": null + }, + "social": { + "facebook": { "page_id": "", "page_token": "" }, + "instagram": { "user_id": "", "token": "" }, + "x": { "username": "", "app": "" } + }, + "social_platforms": ["facebook"], + "analytics": { + "gsc_property": "", + "ga_property_id": "", + "google_ads_customer_id": "", + "meta_ad_account_id": "" + } + } + ] +} diff --git a/references/orbitos-skills.md b/references/orbitos-skills.md new file mode 100644 index 0000000..922ef5d --- /dev/null +++ b/references/orbitos-skills.md @@ -0,0 +1,154 @@ +# OrbitOS Skills Reference + +These skills are from the OrbitOS productivity framework and are NOT included in this project. +They must be installed separately in the Hermes skill system (`~/.hermes/skills/`). + +This reference documents how moreminimore-service skills call OrbitOS skills. + +--- + +## obsidian + +**Purpose:** Read, search, create, and edit notes in the Obsidian vault. + +**When mm* skills use it:** +- Reading research notes from `30_Research/` +- Creating article briefs in `60_Articles/` +- Searching vault for existing content +- Updating frontmatter status fields + +**Key operations:** +``` +read_file(path) — read a note +search_files(pattern) — search vault content +write_file(path, content) — create a note +patch(path, old, new) — edit specific parts +``` + +**Vault path:** `~/vault/` (resolve before calling file tools) + +**Critical rules:** +- No empty line after frontmatter `---` +- Frontmatter MUST be at line 1 +- Use wikilinks `[[Note Name]]` for internal links +- Use array syntax for tags: `tags: [tag1, tag2]` + +--- + +## orbites-research + +**Purpose:** Deep research workflow — investigate a topic, create main research note, extract atomic wiki concepts. + +**When mm* skills use it:** +- `mm-article-idea-extract` calls this for deep topic research +- Produces structured notes in `30_Research/` + atomic concepts in `40_Wiki/` + +**How to invoke:** +``` +Load skill: orbites-research +Provide: topic + user context +It will: create research plan → execute → produce notes +``` + +**Output structure:** +``` +30_Research///.md ← main research note +40_Wiki//.md ← atomic concepts +``` + +**Frontmatter format:** +```yaml +--- +type: reference +created: YYYY-MM-DD +area: "[[AreaName]]" +tags: [research, topic-tags] +status: complete +--- +``` + +--- + +## orbites-parse-knowledge + +**Purpose:** Take unstructured text and parse it into structured vault content (research notes + wiki concepts). + +**When mm* skills use it:** +- `mm-article-idea-extract` uses this to structure research findings +- Converts raw web content into organized vault notes + +**How to invoke:** +``` +Load skill: orbites-parse-knowledge +Provide: unstructured text + topic context +It will: identify area → create research note → extract wiki concepts +``` + +**Output:** +``` +30_Research///.md ← structured research note +40_Wiki//.md ← atomic concepts +``` + +--- + +## orbites-obsidian-markdown + +**Purpose:** Reference for Obsidian Flavored Markdown — wikilinks, callouts, embeds, frontmatter. + +**When mm* skills use it:** +- Any skill that creates or edits vault notes +- Ensures correct frontmatter format +- Validates wikilink syntax + +**Key formats:** + +**Wikilinks:** +```markdown +[[Note Name]] +[[Note Name|Display Text]] +[[Note Name#Heading]] +![[image.png|300]] +``` + +**Callouts:** +```markdown +> [!note] Title +> Content here +``` + +**Frontmatter:** +```yaml +--- +title: My Note +tags: [tag1, tag2] +status: in-progress +--- +``` + +**Critical DO NOT:** +- No empty line after frontmatter `---` +- No duplicate keys in frontmatter +- Frontmatter MUST be at line 1 + +--- + +## Installation + +To install OrbitOS skills: + +```bash +# Clone OrbitOS skill bundle +git clone https://github.com/MarsWang42/OrbitOS.git /tmp/orbitos + +# Copy skills to Hermes +cp -r /tmp/orbitos/skills/* ~/.hermes/skills/ + +# Verify +hermes skills list | grep orbites +``` + +Or install via Hermes: +``` +hermes skill install orbitos +``` diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..67e9dd9 --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,87 @@ +# Platform API Scripts — คู่มือการติดตั้ง + +scripts/ เหล่านี้เป็น bridge สำหรับเชื่อมต่อกับ platform APIs +ณ ตอนนี้เป็น **placeholder** — ต้องติดตั้ง dependencies ก่อนใช้งาน + +## Prerequisites (ทั่วไป) + +```bash +# Hermes ต้องมี tools เหล่านี้ +# web_search สำหรับค้นหาข้อมูล +# terminal สำหรับรัน scripts +``` + +## Google Ads API + +### Dependencies +```bash +pip install google-ads +``` + +### Authentication +1. ไปที่ https://console.cloud.google.com/ → สร้าง Project +2. Enable Google Ads API +3. สร้าง OAuth 2.0 credentials → ดาวน์โหลด JSON +4. ไปที่ https://ads.google.com/ → Tools → API Center + - ขอ Developer Token (ต้องผ่าน basic approval) +5. ตั้งค่า environment: +```bash +export GOOGLE_ADS_DEVELOPER_TOKEN="your-token" +export GOOGLE_ADS_CLIENT_ID="your-client-id" +export GOOGLE_ADS_CLIENT_SECRET="your-client-secret" +export GOOGLE_ADS_REFRESH_TOKEN="your-refresh-token" +export GOOGLE_ADS_LOGIN_CUSTOMER_ID="manager-account-id" +``` + +### ทางเลือก: Google Ads MCP Server +https://github.com/googleads/google-ads-mcp +ใช้กับ Hermes/Claude โดยตรง ไม่ต้องเขียน script แยก + +## Meta Ads API + +### Dependencies +```bash +pip install facebook-business +``` + +### Authentication +1. ไปที่ https://developers.facebook.com/ → สร้าง App +2. เพิ่ม Marketing API product +3. ต้องผ่าน business verification (ใช้บัตรประชาชน/เอกบริษัท) +4. Generate access token: + - permissions: `ads_read`, `ads_management`, `business_management` +5. ตั้งค่า environment: +```bash +export META_ACCESS_TOKEN="your-token" +export META_AD_ACCOUNT_ID="act_xxxxxxxx" +export META_APP_ID="your-app-id" +export META_APP_SECRET="your-app-secret" +``` + +## Google Search Console API + +### Dependencies +```bash +pip install google-auth google-auth-oauthlib google-auth-httplib2 +``` + +### Authentication +ใช้ gcloud application-default credentials: +```bash +gcloud auth application-default login \ + --scopes=https://www.googleapis.com/auth/webmasters.readonly +``` + +### หมายสำคัญ +- **seo-analysis skill** มี scripts GSC ครบถ้วนที่ `~/.hermes/skills/seo-analysis/scripts/` +- สามารถ adapt มาใช้กับ multi-client ได้ +- แต่ละ client ต้องเพิ่ม service account email ใน GSC property → Settings → Users + +## แนวทางการสร้าง scripts จริง + +เมื่อพร้อม implement scripts จริง: + +1. **Copy pattern** จาก seo-analysis skill scripts — มีโครงสร้างครบ (analyze_gsc.py, url_inspection.py, ฯลฯ) +2. **แยก config** ต่อ client — ใช้ .env หรือ config.json ต่อ client +3. **Output format** — JSON ที่ orchestrator อ่านได้ (state.json friendly) +4. **Error handling** — ถ้า token หมดอายุ หรือ API quota หมด ให้แจ้ง orchestrator diff --git a/scripts/google-ads.sh b/scripts/google-ads.sh new file mode 100755 index 0000000..cfa46a0 --- /dev/null +++ b/scripts/google-ads.sh @@ -0,0 +1,37 @@ +#!/bin/bash +# Google Ads API Bridge Script +# ============================= +# Placeholder — implement when Google Ads API access is configured +# +# Required setup: +# 1. Google Ads API developer token +# 2. OAuth 2.0 client credentials +# 3. Google Ads MCP server (recommended) or direct API client +# https://github.com/googleads/google-ads-mcp +# +# Usage: +# ./google-ads.sh [options] +# +# Actions: +# audit Run full Google Ads audit (uses ads-google skill data) +# campaigns List active campaign structure +# keywords Get keyword performance data +# create Create new campaign from campaign-brief.md +# stats Get performance stats for a date range +# +# This script is called by the moreminimore-orchestrator when +# campaign execution is requested. + +echo "⚠️ Google Ads API script not yet implemented." +echo "" +echo "Required before use:" +echo " 1. Create OAuth 2.0 credentials in Google Cloud Console" +echo " 2. Apply for Google Ads API developer token" +echo " 3. Install google-ads Python client: pip install google-ads" +echo " 4. Set up authentication: export GOOGLE_ADS_JSON_KEY=/path/to/key.json" +echo "" +echo "Alternative: Use Google Ads MCP server for automated data collection" +echo " https://github.com/googleads/google-ads-mcp" +echo "" +echo "See: /Users/kunthawat/Gitea/moreminimore-service-system/scripts/README.md" +exit 1 diff --git a/scripts/meta-ads.sh b/scripts/meta-ads.sh new file mode 100755 index 0000000..252ffca --- /dev/null +++ b/scripts/meta-ads.sh @@ -0,0 +1,32 @@ +#!/bin/bash +# Meta Ads API Bridge Script +# =========================== +# Placeholder — implement when Meta Ads API access is configured +# +# Required setup: +# 1. Facebook App with Marketing API permission +# 2. Access token with ads_read + ads_manage scope +# 3. Ad account ID +# +# Usage: +# ./meta-ads.sh [options] +# +# Actions: +# audit Run full Meta Ads audit (uses ads-meta skill data) +# campaigns List active campaign structure +# create Create new campaign from campaign-brief.md +# stats Get performance stats for a date range +# +# This script is called by the moreminimore-orchestrator when +# campaign execution is requested. + +echo "⚠️ Meta Ads API script not yet implemented." +echo "" +echo "Required before use:" +echo " 1. Create Facebook App at https://developers.facebook.com/" +echo " 2. Get Marketing API access (needs business verification)" +echo " 3. Generate long-lived access token" +echo " 4. Install facebook-business Python SDK: pip install facebook-business" +echo "" +echo "See: /Users/kunthawat/Gitea/moreminimore-service-system/scripts/README.md" +exit 1 diff --git a/scripts/mm_google_ads.py b/scripts/mm_google_ads.py new file mode 100644 index 0000000..643714c --- /dev/null +++ b/scripts/mm_google_ads.py @@ -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() diff --git a/scripts/mm_gsc.py b/scripts/mm_gsc.py new file mode 100644 index 0000000..ce443be --- /dev/null +++ b/scripts/mm_gsc.py @@ -0,0 +1,319 @@ +#!/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() diff --git a/scripts/mm_meta_ads.py b/scripts/mm_meta_ads.py new file mode 100644 index 0000000..2dabb98 --- /dev/null +++ b/scripts/mm_meta_ads.py @@ -0,0 +1,303 @@ +#!/usr/bin/env python3 +""" +Meta Ads API for moreminimore-service. +Reads clients-config.json, pulls campaign/ad set/ad data. + +Usage: + python3 mm_meta_ads.py --client moreminimore campaigns + python3 mm_meta_ads.py --client moreminimore adsets + python3 mm_meta_ads.py --client moreminimore stats --days 30 +""" + +import argparse +import json +import os +import sys +import urllib.request +import urllib.error +import urllib.parse +from datetime import datetime, timedelta + + +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_meta_config(config, client): + """Get Meta Ads credentials from config""" + meta = config.get("global", {}).get("meta", {}) + ad_account_id = client.get("analytics", {}).get("meta_ad_account_id", "") + page_id = client.get("social", {}).get("facebook", {}).get("page_id", "") + page_token = client.get("social", {}).get("facebook", {}).get("page_token", "") + system_user_token = meta.get("system_user_token", "") + + # Use page_token for page operations, system_user_token for ad operations + token = page_token or system_user_token + + if not token: + print("ERROR: No page_token or system_user_token in config", file=sys.stderr) + sys.exit(1) + if not ad_account_id: + print(f"ERROR: No meta_ad_account_id for client '{client['id']}'", file=sys.stderr) + sys.exit(1) + + return { + "token": token, + "ad_account_id": ad_account_id, + "page_id": page_id, + } + + +def meta_api_get(endpoint, params=None): + """Make a GET request to Meta Graph API""" + base_url = "https://graph.facebook.com/v21.0" + url = f"{base_url}/{endpoint}" + + if params: + url += "?" + urllib.parse.urlencode(params) + + req = urllib.request.Request(url) + + 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: Meta 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 meta_api_post(endpoint, data): + """Make a POST request to Meta Graph API""" + base_url = "https://graph.facebook.com/v21.0" + url = f"{base_url}/{endpoint}" + + body = urllib.parse.urlencode(data).encode() + req = urllib.request.Request(url, data=body, 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: Meta 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(meta_config): + """List active campaigns""" + result = meta_api_get( + f"act_{meta_config['ad_account_id']}/campaigns", + { + "fields": "id,name,status,objective,daily_budget,lifetime_budget,created_time", + "filtering": json.dumps([{"field": "effective_status", "operator": "IN", "value": ["ACTIVE"]}]), + "access_token": meta_config["token"], + } + ) + return result + + +def list_adsets(meta_config): + """List active ad sets""" + result = meta_api_get( + f"act_{meta_config['ad_account_id']}/adsets", + { + "fields": "id,name,status,targeting,daily_budget,lifetime_budget,optimization_goal,billing_event,created_time", + "filtering": json.dumps([{"field": "effective_status", "operator": "IN", "value": ["ACTIVE"]}]), + "access_token": meta_config["token"], + } + ) + return result + + +def get_insights(meta_config, days=30, level="campaign"): + """Get performance insights""" + date_start = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d") + date_end = datetime.now().strftime("%Y-%m-%d") + + result = meta_api_get( + f"act_{meta_config['ad_account_id']}/insights", + { + "level": level, + "fields": "campaign_name,adset_name,impressions,clicks,spend,actions,cpm,cpc,ctr,reach,frequency", + "time_range": json.dumps({"since": date_start, "until": date_end}), + "time_increment": "1", + "access_token": meta_config["token"], + } + ) + return result + + +def format_currency(amount): + """Format currency""" + if not amount: + return "฿0" + return f"฿{float(amount):,.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""" + if not data or not data.get("data"): + print("ไม่พบ campaign ที่ active") + return + + campaigns = data["data"] + print(f"\n📋 Active Campaigns ({len(campaigns)})") + print("-" * 90) + print(f"{'Name':<35} {'Objective':<20} {'Daily Budget':>15} {'Status':<10}") + print("-" * 90) + + for c in campaigns: + name = c.get("name", "")[:33] + objective = c.get("objective", "") + daily_budget = c.get("daily_budget", "") + status = c.get("status", "") + + budget_str = f"฿{int(daily_budget)/100:,.2f}" if daily_budget else "-" + + print(f"{name:<35} {objective:<20} {budget_str:>15} {status:<10}") + + +def display_adsets(data): + """Display ad set data""" + if not data or not data.get("data"): + print("ไม่พบ ad set ที่ active") + return + + adsets = data["data"] + print(f"\n📋 Active Ad Sets ({len(adsets)})") + print("-" * 100) + print(f"{'Name':<30} {'Optimization':<20} {'Billing':<15} {'Daily Budget':>15}") + print("-" * 100) + + for a in adsets: + name = a.get("name", "")[:28] + opt_goal = a.get("optimization_goal", "") + billing = a.get("billing_event", "") + daily_budget = a.get("daily_budget", "") + + budget_str = f"฿{int(daily_budget)/100:,.2f}" if daily_budget else "-" + + print(f"{name:<30} {opt_goal:<20} {billing:<15} {budget_str:>15}") + + +def display_stats(data, level="campaign"): + """Display performance stats""" + if not data or not data.get("data"): + print("ไม่พบข้อมูลสถิติ") + return + + rows = data["data"] + + # Aggregate by campaign or adset + aggregated = {} + for row in rows: + key = row.get("campaign_name", "") if level == "campaign" else row.get("adset_name", "") + if key not in aggregated: + aggregated[key] = { + "impressions": 0, "clicks": 0, "spend": 0, "reach": 0 + } + + aggregated[key]["impressions"] += int(row.get("impressions", 0)) + aggregated[key]["clicks"] += int(row.get("clicks", 0)) + aggregated[key]["spend"] += float(row.get("spend", 0)) + aggregated[key]["reach"] += int(row.get("reach", 0)) + + label = "Campaign" if level == "campaign" else "Ad Set" + print(f"\n📊 {label} Stats") + print("-" * 100) + print(f"{label:<30} {'Impressions':>12} {'Clicks':>10} {'Spend':>12} {'Reach':>12} {'CTR':>8} {'CPC':>10}") + print("-" * 100) + + for name, m in aggregated.items(): + ctr = (m["clicks"] / m["impressions"] * 100) if m["impressions"] > 0 else 0 + cpc = (m["spend"] / m["clicks"]) if m["clicks"] > 0 else 0 + + print(f"{name[:28]:<30} {format_number(m['impressions']):>12} " + f"{format_number(m['clicks']):>10} " + f"{format_currency(m['spend']):>12} " + f"{format_number(m['reach']):>12} " + f"{ctr:>7.2f}% " + f"{format_currency(cpc):>10}") + + +def main(): + parser = argparse.ArgumentParser(description="Meta Ads API for moreminimore-service") + parser.add_argument("--client", required=True, help="Client ID from clients-config.json") + parser.add_argument("action", choices=["campaigns", "adsets", "stats"], help="Action to perform") + parser.add_argument("--days", type=int, default=30, help="Number of days for stats (default: 30)") + parser.add_argument("--level", choices=["campaign", "adset"], default="campaign", help="Stats level") + 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 Meta config + meta_config = get_meta_config(config, client) + + print(f"📊 Meta Ads — {client['display_name']}") + print(f" Ad Account: {meta_config['ad_account_id']}") + print() + + # Execute action + if args.action == "campaigns": + data = list_campaigns(meta_config) + if args.json: + print(json.dumps(data, indent=2)) + else: + display_campaigns(data) + + elif args.action == "adsets": + data = list_adsets(meta_config) + if args.json: + print(json.dumps(data, indent=2)) + else: + display_adsets(data) + + elif args.action == "stats": + data = get_insights(meta_config, args.days, args.level) + if args.json: + print(json.dumps(data, indent=2)) + else: + display_stats(data, args.level) + + +if __name__ == "__main__": + main() diff --git a/scripts/search-console.sh b/scripts/search-console.sh new file mode 100755 index 0000000..975acaa --- /dev/null +++ b/scripts/search-console.sh @@ -0,0 +1,38 @@ +#!/bin/bash +# Google Search Console Bridge Script +# ==================================== +# Placeholder — implement when GSC API access is configured +# +# Required setup: +# 1. Google Cloud Project with Search Console API enabled +# 2. GCP service account (or OAuth) with access to client's GSC property +# 3. Client must add service account email as GSC property user +# +# Usage: +# ./search-console.sh [options] +# +# Actions: +# top-queries Get top queries by clicks/impressions +# top-pages Get top pages by clicks/impressions +# trends Get traffic trends over time +# content-gaps Find queries ranking 11-30 with no dedicated page +# ctr-gaps Find high-impression, low-CTR opportunities +# +# This script is called by the moreminimore-orchestrator when +# analytics monitoring is active (Phase 3 — Analytics Loop). + +echo "⚠️ Google Search Console API script not yet implemented." +echo "" +echo "Required before use:" +echo " 1. Create Google Cloud Project" +echo " 2. Enable Search Console API: gcloud services enable searchconsole.googleapis.com" +echo " 3. Set up service account or application-default credentials" +echo " 4. Client adds service account email to GSC property as user" +echo "" +echo "The seo-analysis skill has full GSC scripts at:" +echo " ~/.hermes/skills/seo-analysis/scripts/" +echo "" +echo "Consider adapting those scripts for multi-client use." +echo "" +echo "See: /Users/kunthawat/Gitea/moreminimore-service-system/scripts/README.md" +exit 1 diff --git a/skills/content-writer/SKILL.md b/skills/content-writer/SKILL.md new file mode 100644 index 0000000..b8c4382 --- /dev/null +++ b/skills/content-writer/SKILL.md @@ -0,0 +1,338 @@ +--- +name: content-writer +argument-hint: "" +description: > + Write SEO-optimized blog posts, landing pages, and content improvements + following Google's E-E-A-T and Helpful Content guidelines. Handles new content + creation from a keyword or topic, and improving existing pages. Use when asked + to "write a blog post", "create a landing page", "improve this page", "write + content about X", "content for keyword X", "draft an article", "blog post + about", "landing page for", "service page", "product page copy", "rewrite + this page", "make this page rank better", "content brief", "how-to guide", + "listicle", or any content creation or improvement task for a website. +--- + +# Content Writer + +You are NotFair's senior content strategist — the "unfair" SEO/Ads agent. You +write content that ranks on Google AND genuinely helps readers. You combine SEO +best practices with strong editorial standards. Every piece must pass Google's +"helpful content" bar — it should be the last click the reader needs. + +The editorial bar for blog posts is set by NotFair's own posts (see +[`facebook-seo-optimization`](https://notfair.co/blog/facebook-seo-optimization) +as a reference): hook-driven title, opinionated opening that breaks reader +expectation, table of contents, ≥ 1000 words, featured image plus 3+ inline +images, data-backed claims, and a clear takeaway in every section. Do not ship +work below that bar. + +You handle three jobs: +1. **New blog post** — from a keyword or topic +2. **New landing page** — service, product, location, or comparison page +3. **Content improvement** — audit and rewrite an existing page + +--- + +## Step 1 — Determine the Job + +Infer from the user's message. If obvious, skip asking. + +**Signals:** +- "blog post about X", "how-to guide", "article about X", "listicle" → **Blog post** +- "landing page", "service page", "product page", "pricing page", "location page" → **Landing page** +- "improve this page", "rewrite", "make this better", URL or file path provided → **Content improvement** + +If ambiguous: "Are you looking for a blog post (educational), a landing page +(conversion-focused), or improving an existing page?" + +--- + +## Step 2 — Gather Context + +Collect what you need. Don't ask for things you can infer. + +### For new content (blog post or landing page): +- **Target keyword** (required) — the primary query to rank for +- **Audience** — who is this for? +- **Site/brand context** — what does the business do, value prop? +- **Existing pages** — related pages on the site to link to? +- **Competitors** — what currently ranks? (offer to research if you have web access) + +### For content improvement: +- **The content** — read the existing page (URL via firecrawl/web, or file path) +- **Target keyword** — ask if not obvious from the content +- **Goal** — better rankings, better conversion, or both? + +If spawned by seo-analysis, this context is already provided. Use it directly. + +--- + +## Step 3 — Read the Guidelines + +Locate and read the content writing reference: + +```bash +CONTENT_REF=$(find ~/.claude/plugins ~/.claude/skills ~/.codex/skills .agents/skills -name "content-writing.md" -path "*content-writer*" 2>/dev/null | head -1) +if [ -z "$CONTENT_REF" ]; then + echo "WARNING: Could not find content-writing.md reference" +else + echo "Reference at: $CONTENT_REF" +fi +``` + +Read `$CONTENT_REF` (or `references/content-writing.md` if invoked directly). +Follow the guidelines precisely throughout Steps 4-6. + +--- + +## Step 4 — Research & Plan + +### Blog posts + +1. **Classify search intent** — informational or commercial investigation. + If the intent is transactional, tell the user a landing page would rank better. +2. **SERP analysis** — if you have web access (firecrawl, WebSearch, browse), search + the target keyword. Note what the top 5 results use: format, depth, subtopics + covered, what they miss. +3. **Define your angle** — what makes this post different? Original data, first-hand + experience, a more actionable approach, a specific niche. Never write a post that + just restates what's already ranking. +4. **Draft a hook-driven title.** Plain keyword titles ("Facebook SEO Optimization") + die in the SERP. Pair the keyword with a hook: a number, a contrarian claim, a + specific audience, or a curiosity gap. See "Title hook patterns" in the + guidelines reference for the four working formulas and examples. The keyword + still has to be front-loaded and the title still has to be ≤ 60 chars. +5. **Plan the visuals.** A blog post ships with a **featured/thumbnail image** plus + **≥ 3 inline images** (diagram, screenshot, comparison, or illustrative — + purely decorative stock is a fail). Decide each image's role and placement + *before* writing — every image must earn its spot by explaining something the + prose can't. +6. **Create an outline:** + +``` +# [Hook-driven title] (< 60 chars, keyword front-loaded) + +Meta description: [120-160 chars, keyword + CTA] +Target keyword: [primary] +Secondary keywords: [2-4 related terms] +Search intent: [type] +Content angle: [differentiator] +Target length: [≥ 1000 words] + +Featured image: [role + concept] +Inline images: [3+ planned with role + placement] + +## Table of Contents +## [H2 — opening hook + answers the core question first] +## [H2 — next most important subtopic] +## [H2 — practical examples / case studies] +## [H2 — common mistakes] +## FAQ +``` + +7. **Present outline for approval** before writing. If spawned by seo-analysis with + clear context, proceed directly but show the outline as you go. + +### Landing pages + +1. **Verify intent** — must be transactional or commercial. If informational, suggest + a blog post instead. +2. **Determine page type** — service, product, location, or comparison. Use the + matching template from the guidelines. +3. **Define conversion strategy:** + - Primary CTA (the one action you want) + - Key objections to address + - Trust signals needed (testimonials, logos, case studies, guarantees) + - Differentiation (why this over competitors — be specific) +4. **Create page structure** using the guidelines template for the page type. +5. **Present for approval.** + +### Content improvement + +1. **Audit the existing content** against the full guidelines — On-Page SEO Checklist, + Anti-Patterns, E-E-A-T signals, heading structure, keyword usage, search intent match. +2. **Classify what's wrong:** + - Intent mismatch (wrong content type for the keyword) + - Thin content (not enough depth) + - Missing E-E-A-T signals (no examples, data, or experience) + - Poor structure (no headings, wall of text) + - Keyword issues (stuffing, missing, or wrong target) + - Stale information (outdated stats, methods, pricing) +3. **Present gap analysis:** + - What's working (keep) + - What's missing (add) + - What's hurting (remove or rewrite) + - Structural changes needed +4. **Get approval** before rewriting. + +--- + +## Step 5 — Write + +Follow the writing rules from the guidelines for the content type. Key principles +that apply to all content: + +**Lead with value.** First paragraph directly addresses the search intent. No +throat-clearing ("In today's digital landscape..."). + +**Show experience.** Specific examples, data, scenarios. "We found that..." and +"In our testing..." signal first-hand knowledge. If the site has its own data, +weave it in. + +**Be concrete.** Every recommendation includes the what, why, and how. "Add a +sticky CTA bar — we saw a 23% increase on mobile" not "improve your CTAs." + +**Structure for scanning.** Short paragraphs (2-4 sentences), bullet lists, bold +key phrases, tables for comparisons. One idea per paragraph. + +**Keyword placement.** Primary keyword in: title tag (front-loaded), H1, first +100 words, 1-2 H2s naturally, meta description. After that: synonyms and natural +language. No stuffing. + +**Compliance-sensitive topics require official sources.** For travel rules, +government paperwork, health/safety, reimbursements, regulated products, or +deadlines, cite the official rule-making source, name timing windows and +exceptions, and avoid implying optionality where a step is normally mandatory. +If a calculator or checklist is included, encode the same caveats in the tool. + +**Validate media assets.** Generated or edited images must have matching file +extension, MIME/file signature, dimensions, and Open Graph metadata. Web images +should be compressed to the displayed size before publishing. + +### Deliverables for blog posts: +1. Full post in markdown with heading hierarchy (H1 → H2 → H3), opening with a + hook paragraph (not throat-clearing) and a table of contents that mirrors the + H2 structure +2. **Minimum 1000 words** of substantive body content — write to completeness, + but treat 1000 as the floor a serious post has to clear +3. SEO metadata: title tag (< 60 chars, hook-driven), meta description (120-160 + chars), URL slug +4. **Images** — a featured/thumbnail image and **≥ 3 inline images** placed at + meaningful points in the body. For each image, provide: role (diagram / + screenshot / illustrative / data viz), placement (which H2), descriptive alt + text, a filename suggestion, and a detailed generation prompt (see "Image + generation" below) +5. JSON-LD structured data (`Article`/`BlogPosting` with `image` populated, plus + `FAQPage` if FAQ included) +6. Internal linking plan (pages to link to and from) +7. Publishing checklist + +### Image generation + +Generate the images during the writing step — don't leave them as TODO. Pick the +first surface that's actually available in the current host, in this order: + +1. **Codex or other host with native image generation** (gpt-image, Imagen, etc.) + — generate the image inline. Save to `images/` or the path the user specifies. +2. **NotFair MCP `generate_image`** — if a NotFair MCP server is connected + (`mcp__NotFair-GoogleAds__generate_image` or `mcp__NotFair-MetaAds__generate_image`), + it generates marketing-grade visuals from a prompt. Works for blog imagery too. +3. **No image-gen surface available** — output the detailed prompt for each image + so the user can run it in their own tool (Midjourney, DALL·E, Figma AI, etc.), + and place the markdown image tag with the planned filename so the post is ready + to drop the asset in. + +Every image prompt must be specific: subject, style (photographic / 3D render / +flat illustration / data viz), composition, color palette, mood, and what to +*avoid* (no stock-photo handshakes, no generic "person at laptop"). Lean +illustrative or diagrammatic — purely decorative stock makes the post look AI- +written. + +### Deliverables for landing pages: +1. Full page copy in markdown with heading hierarchy and CTA placements marked +2. SEO metadata: title tag, meta description, URL slug +3. Conversion strategy: primary CTA, objections addressed, trust signals +4. JSON-LD structured data (`Service`/`Product`/`LocalBusiness` + `FAQPage`) +5. Internal linking plan + navigation placement suggestion +6. Publishing checklist + +### Deliverables for content improvement: +1. Rewritten content in markdown (full replacement, not patches) +2. Change summary: what changed and why (tied to specific guideline violations) +3. Updated SEO metadata if needed +4. Updated structured data if needed + +### Output Format + +``` +# [Content Type]: [Title] + +## SEO Metadata +- **Title tag:** [< 60 chars] +- **Meta description:** [120-160 chars] +- **URL slug:** /[slug] +- **Target keyword:** [primary] +- **Secondary keywords:** [list] + +## Content +[Full content in markdown with proper heading hierarchy] + +## Structured Data +[JSON-LD ready to paste] + +## Internal Linking Plan +- **Link TO this page from:** [existing pages + suggested anchor text] +- **This page links to:** [internal links in the content] + +## Publishing Checklist +- [ ] Title tag and meta description set +- [ ] URL slug configured +- [ ] Structured data added (with `image` populated) +- [ ] Featured/thumbnail image uploaded; set as Open Graph image +- [ ] All inline images uploaded, alt text set, lazy-loaded below the fold +- [ ] Table of contents renders with working anchor links +- [ ] Internal links placed (both directions) +- [ ] Open Graph image added +- [ ] Open Graph image file format, dimensions, and byte size verified +- [ ] Official sources cited for compliance-sensitive claims +- [ ] Canonical URL set to self +- [ ] Mobile rendering verified +``` + +--- + +## Step 6 — Quality Gate + +Before delivering, verify against every check. Fix failures before presenting. + +### Blog-post hard requirements +A blog post is not done until **all** of these are true. No exceptions. + +- [ ] Title carries an attention hook (number, contrarian claim, named audience, + or curiosity gap) — not just the bare keyword +- [ ] Featured/thumbnail image present, with alt text and a generation prompt + (or a generated asset) +- [ ] ≥ 3 inline images placed at meaningful points (diagram / screenshot / + illustration / data viz — not decorative stock) +- [ ] Table of contents at the top mirroring every H2 +- [ ] ≥ 1000 words of substantive body content +- [ ] Opening paragraph is a hook, not throat-clearing +- [ ] FAQ section with 3–5 questions (targets People Also Ask) + +### The "Last Click" Test +Would the reader need to search again? If yes, the content isn't done. + +### E-E-A-T Check +- Does it contain specific examples only someone with experience would include? +- Is there original analysis or insight — not just restated common knowledge? +- Are claims backed by sources or data? + +### Anti-Pattern Check (from guidelines) +- No keyword stuffing +- No filler paragraphs (every paragraph earns its place) +- No generic AI hedging ("it depends", "many factors" without committing) +- No wall of text (headings, bullets, bold key phrases throughout) +- No duplicate intent with existing pages on the site + +### Format Match +Does the content type match what Google shows for this query? + +### On-Page SEO (from guidelines checklist) +Title, meta description, H1, heading hierarchy, keyword placement, internal links, +image alt text, URL slug — all present and correct. + +### Landing Page Extra Checks +- Would you convert after reading this? What's missing if not? +- Are vague claims replaced with specifics? +- Is every major objection addressed? +- Is the CTA immediately clear? diff --git a/skills/content-writer/evals/evals.json b/skills/content-writer/evals/evals.json new file mode 100644 index 0000000..bbad090 --- /dev/null +++ b/skills/content-writer/evals/evals.json @@ -0,0 +1,49 @@ +{ + "skill_name": "content-writer", + "evals": [ + { + "id": 1, + "prompt": "Write a blog post targeting the keyword 'how to clean a dryer vent'. My site is a home services company called CleanAir Pros based in Denver.", + "expected_output": "A complete blog post with proper heading structure (H1, H2s, H3s), the target keyword in the title and first 100 words, an estimated word count of 1500-2500 words, E-E-A-T signals (author expertise, practical experience), internal linking suggestions, and a meta description.", + "files": [], + "expectations": [ + "Title includes the target keyword 'how to clean a dryer vent' or close variant", + "Content has a clear heading hierarchy with H2s for major sections", + "Includes practical step-by-step instructions (not just theory)", + "References Denver or local context where relevant", + "Includes E-E-A-T signals — mentions experience, expertise, or practical knowledge", + "Suggests a meta description with character count", + "Content is genuinely helpful and would satisfy the search intent (informational how-to)" + ] + }, + { + "id": 2, + "prompt": "Improve this landing page content for our roof repair service. Target keyword: 'roof repair austin'. Here's the current page:\n\n# Roof Repair\n\nWe fix roofs. Our technicians are experienced. We offer leak repair, shingle replacement, and inspections.\n\nCall us today!\n\nPrices start at $400.", + "expected_output": "A rewritten landing page that is conversion-focused, includes the target keyword naturally, adds social proof sections, has a clear CTA, includes service details with pricing structure, and addresses customer pain points. Should identify that the original is thin content with no location specificity, no trust signals, and no differentiation.", + "files": [], + "expectations": [ + "Identifies the original as thin content and explains why it won't rank", + "Rewrites with 'austin' location keyword integrated naturally throughout", + "Adds social proof elements (reviews, years in business, certifications)", + "Includes specific service descriptions beyond the generic list", + "Has clear CTAs (not just 'call us today')", + "Addresses common customer concerns (e.g., anxiety, breed-specific needs)", + "Resulting content is meaningfully longer and more detailed than the original" + ] + }, + { + "id": 3, + "prompt": "I need a comparison landing page: 'tankless vs tank water heaters'. We sell and install both but want to push tankless. We're Pacific Plumbing in Portland, OR.", + "expected_output": "A comparison page that fairly presents both options but subtly favors tankless through data and positioning. Should include a comparison table, pros/cons for each, cost analysis, and a CTA that leads toward tankless consultation. The content should satisfy commercial investigation intent.", + "files": [], + "expectations": [ + "Includes a comparison table or structured comparison format", + "Presents both options fairly (not a one-sided sales pitch)", + "Uses data points (energy savings, lifespan, upfront cost vs long-term) to favor tankless", + "Includes Portland/Oregon-specific context (climate, energy costs, rebates)", + "Has conversion elements (CTA for consultation, phone number placeholder)", + "Satisfies commercial investigation search intent — helps the reader make a decision" + ] + } + ] +} diff --git a/skills/content-writer/references/content-writing.md b/skills/content-writer/references/content-writing.md new file mode 100644 index 0000000..c2bbd99 --- /dev/null +++ b/skills/content-writer/references/content-writing.md @@ -0,0 +1,390 @@ +# Content Writing Guidelines — Google Best Practices + +Reference for writing content that ranks. Derived from Google's Helpful Content +System, E-E-A-T quality rater guidelines, and Search Central documentation. + +--- + +## Table of Contents + +1. [E-E-A-T Framework](#e-e-a-t-framework) +2. [Helpful Content Signals](#helpful-content-signals) +3. [Blog Posts](#blog-posts) +4. [Title Hook Patterns](#title-hook-patterns) +5. [Images](#images) +6. [Landing Pages](#landing-pages) +7. [Search Intent Matching](#search-intent-matching) +8. [On-Page SEO Checklist](#on-page-seo-checklist) +9. [Anti-Patterns](#anti-patterns) + +--- + +## E-E-A-T Framework + +Google evaluates content on four axes: + +### Experience +- Show first-hand experience — specific examples, scenarios, case studies +- Use language that reflects direct involvement ("we found", "in our testing") +- Reference real data, outcomes, or results + +### Expertise +- Go beyond surface-level — cover edge cases only a practitioner would know +- Use precise terminology correctly but define it for the reader +- Link to authoritative sources for factual claims + +### Authoritativeness +- Establish why this source is credible on this topic +- Include author bios with relevant credentials +- Build topical authority via internal linking to related content + +### Trustworthiness +- Be transparent about limitations and conflicts of interest +- Cite sources for statistics and claims +- Keep content accurate and up to date + +**YMYL topics** (health, finance, safety, legal) require the highest E-E-A-T bar. + +### High-Stakes Factual & Compliance Topics +Some SEO topics are not legally YMYL but still create real-world risk if the +answer is wrong: travel requirements, government paperwork, animal welfare, +health certificates, military reimbursement, taxes/fees, regulated products, and +anything with official deadlines or eligibility rules. + +For these topics, apply a compliance-quality gate before publishing: +- Link directly to the current official source for each rule-making body + involved, such as government agencies, regulators, standards bodies, airlines, + or program owners. +- Separate official requirements from professional planning advice. Do not use + soft language like "may be required" for a normally mandatory step unless the + official source actually defines meaningful exceptions. +- Name timing windows, exceptions, and scope boundaries explicitly. If guidance + applies only to entry, import, export, one country, one state, or one program, + do not generalize it to the reverse direction or adjacent workflows. +- Include a freshness caveat that tells readers to verify official guidance + before booking, filing, buying, or traveling. +- If the page includes a calculator, estimator, or checklist, encode known rule + adjustments and caveats in the tool itself, not only in surrounding prose. +- Treat unsourced compliance claims, ambiguous requirements, or missing official + links as blockers, not polish items. + +--- + +## Helpful Content Signals + +### Content IS helpful when it: +- Has a clear, specific audience in mind +- Leaves the reader feeling they've learned enough to achieve their goal +- Provides original value — analysis, insight, research, not just compilation +- Has a satisfying amount of information (not thin, not padded) + +### The "Last Click" Test +After reading, would the reader need to search again? If yes, the content isn't +done. The goal is to be the last click. + +--- + +## Blog Posts + +### When to Use +Informational and commercial-investigation intent: "how to", "what is", "best X", +"X vs Y", guides, tutorials, listicles, reviews. + +### Structure +``` +H1: Hook-driven title (one per page, includes primary keyword) +Featured image (above the fold, used as thumbnail + Open Graph) +Hook paragraph (breaks reader expectation, signals what's different) +Table of Contents (anchor links to every H2) + H2: Major section (answer core question first — don't bury the lead) + Inline image #1 (diagram, screenshot, comparison, or data viz) + H3: Supporting detail + H2: Practical examples / case studies + Inline image #2 + H2: Common mistakes + Inline image #3 + H2: FAQ (targets People Also Ask) +``` + +A serious blog post is ≥ 1000 words, ships with a hook-driven title, a table of +contents, a featured image, and at least 3 inline images. Posts below that bar +read like AI filler and don't earn the click. + +### Writing Rules +1. **Open with the answer** — first paragraph directly addresses search intent +2. **Show experience** — specific examples, data, "we found that..." language +3. **Be concrete** — "Add a sticky CTA bar — we saw 23% lift on mobile" not "improve your CTA" +4. **Structure for scanning** — short paragraphs (2-4 sentences), bullets, bold key phrases +5. **Link internally** — 3-5 related pages, descriptive anchor text +6. **Cite sources** — link to data sources and authoritative references +7. **Write to completeness, not word count** — cover the topic fully, then stop + +### Keyword Placement +- Title tag (front-loaded, < 60 chars) +- H1 +- First 100 words +- 1-2 H2 headings naturally +- Meta description (120-160 chars) +- After that: synonyms and natural language — no stuffing + +### Required Sections +1. **Featured image** — set above the fold, doubles as thumbnail + Open Graph +2. **Opening hook (2–4 sentences)** — break expectation. State what most people + get wrong, the counterintuitive truth, or the specific gap this post closes. + No "in today's digital landscape" throat-clearing +3. **Table of contents** — anchor links to every H2, rendered at the top +4. **Core content** — main answer with clear H2/H3 structure, ≥ 1000 words +5. **Inline images (≥ 3)** — placed at meaningful points (one per major section + is a good rule of thumb): diagram, screenshot, comparison table-as-image, or + data viz. No decorative stock +6. **Practical examples** — at least one real scenario with concrete numbers +7. **Common mistakes** — what people get wrong +8. **Next steps / CTA** +9. **FAQ** (3–5 questions targeting related searches) + +### Metadata +- **Title tag:** < 60 chars, keyword front-loaded, includes value prop and hook +- **Meta description:** 120-160 chars, keyword + reason to click +- **URL slug:** short, readable, keyword-rich. `/blog/optimize-title-tags` +- **Structured data:** `Article` or `BlogPosting` (with `image` populated); add + `HowTo` for tutorials, `FAQPage` for FAQ sections + +--- + +## Title Hook Patterns + +The bare keyword as a title ("Facebook SEO Optimization") loses to anything with +a hook in the same SERP. Pick one of these patterns. Keep ≤ 60 chars and keep +the primary keyword front-loaded. + +### 1. Number + specificity +Numbers earn the click because they imply scoped, finite content. +- "7 Facebook SEO Tactics That Actually Move Pipeline" +- "Title Tag Optimization: 12 Patterns That Lift CTR 20%+" + +### 2. Audience-named guide +Naming the audience filters for the right reader and signals depth. +- "Facebook SEO Optimization: A Guide for Performance Marketers" +- "Schema Markup for Ecommerce Product Pages" + +### 3. Contrarian / myth-break +A confident counter-claim creates a curiosity gap. +- "Facebook SEO Is Not Dead — You're Measuring It Wrong" +- "Stop Writing 3,000-Word Blog Posts. Here's What Ranks Instead." + +### 4. Outcome promise + proof +A specific result, backed by something that signals the post will deliver it. +- "How We Cut CPA 38% Without Touching Bids" +- "The Title Tag Rewrite That Lifted Organic Traffic 2.3x" + +**What disqualifies a title:** vague modifiers ("ultimate", "complete"), the +keyword stated twice, ChatGPT-default formatting ("A Comprehensive Guide to…"), +or a hook so clickbait it overpromises what the body delivers. + +--- + +## Images + +Images aren't decoration — they're a quality signal Google uses (helpful +content with original imagery outranks text-only posts in most categories), an +engagement lever (reduces bounce, lifts dwell time), and an Open Graph asset +that determines whether the post gets clicked on social. + +### Minimum bar for a blog post +- **1 featured/thumbnail image** — above the fold, also used as the Open Graph + image and the post's `og:image` / `twitter:image` / structured-data `image` +- **≥ 3 inline images** placed in the body at meaningful points — one per major + H2 is a good rhythm + +### What counts as a real image +- **Diagrams** explaining a workflow, system, or relationship +- **Screenshots** of the tool / dashboard / SERP being discussed +- **Comparison visuals** (before/after, options side-by-side) +- **Data visualization** of a stat the post cites +- **Original illustration** that depicts something specific to the post + +### What does NOT count +- Stock photos of "person at laptop", handshakes, abstract gradients +- Generic AI-generated "marketing hero" art with no informational content +- The same image used decoratively in every section + +### Image generation +The agent should generate the images during the writing pass — don't hand the +user a TODO list. Pick the first available surface: + +1. **Host-native image gen** (Codex `gpt-image`, Gemini Imagen, etc.) — use it + directly. Save to `images/` or the path the user picks. +2. **NotFair MCP** — if a NotFair MCP is connected, `generate_image` produces + marketing-grade visuals from a prompt; works for blog imagery too. +3. **No image-gen available** — emit the detailed prompt for each image along + with the markdown image tag (with the planned filename) so the post is ready + to slot the asset in. + +### Writing an image prompt +Every prompt names: subject, style (photographic / 3D render / flat illustration +/ data viz / diagram), composition, color palette, mood, and an explicit avoid +list. Example: + +> Featured image for a blog post on Facebook SEO optimization. Flat vector +> illustration, isometric perspective. A stylized Facebook page card with search +> bars, magnifying glass, and ranking arrows around it. Color palette: NotFair +> orange (#FF6B35) accents on a clean off-white background, navy line work. No +> stock-photo people, no generic laptop, no abstract gradients. Square 1200×1200. + +### Image SEO +- File name: keyword-relevant, hyphen-separated. `facebook-seo-page-checklist.png` +- Alt text: descriptive of what the image shows, not keyword-stuffed +- Format: WebP or AVIF preferred; PNG for diagrams with hard edges +- Width: 1200px (featured), 800–1000px (inline), 2x for retina +- Lazy-load every image except the featured one + +### Media and Asset Quality +- Use real, relevant visual assets for published pages when the topic benefits + from illustration, inspection, or trust. Avoid purely decorative stock-like + images that do not help the reader understand the product, place, process, or + outcome. +- Validate generated image files after export. The file extension, MIME/file + signature, dimensions, and metadata declarations must agree. +- Keep hero images reasonably sized for web delivery. Prefer WebP/AVIF photos at + the displayed aspect ratio; oversized multi-megabyte source images are a + technical UX issue, not just an asset issue. +- Open Graph image dimensions in metadata must match the actual asset. + +--- + +## Landing Pages + +### When to Use +Transactional and commercial intent: "buy", "pricing", "near me", "[service] in +[city]", "best [product] for [use case]". + +### Core Principles +1. **Specificity converts** — replace every vague claim with numbers or examples +2. **Benefits over features** — lead with what the customer gets, not what it does +3. **Address objections directly** — price, trust, switching costs, "does it work?" +4. **One page, one job** — every element moves toward the CTA +5. **Social proof is mandatory** — testimonials, logos, stats, case studies + +### Structure by Page Type + +**Service page:** +``` +H1: Service + Primary Benefit +Hero subhead + Primary CTA +H2: The Problem (show you understand their situation) +H2: How It Works (3-4 clear steps) +H2: Results (specific outcomes with numbers) +H2: Social Proof (testimonials, logos, case studies) +H2: Pricing (if applicable) +H2: FAQ (5-8 objection-handling questions) +Final CTA +``` + +**Product page:** +``` +H1: Product + Key Benefit +Hero + CTA +H2: Features → Benefits (not just feature lists) +H2: How It Works +H2: Who It's For (specific use cases) +H2: Reviews / Social Proof +H2: Pricing +H2: FAQ +Final CTA +``` + +**Location page:** +``` +H1: [Service] in [City] +Local value prop + CTA +H2: Local-specific content (NOT generic copy with city swapped) +H2: Why Choose Us in [City] (local presence, case studies) +H2: Service Areas +H2: FAQ (local-specific concerns) +CTA with local contact +``` + +### SEO for Landing Pages +- Lighter keyword density than blog posts +- Primary keyword in: title, H1, first paragraph, 1 H2, meta description +- 500-1500 words of genuine content (thin pages with just a headline + form don't rank) +- FAQ section serves dual purpose: conversion (objection handling) + SEO (long-tail queries) + +### Metadata +- **Title tag:** "[Service/Product] — [Benefit] | [Brand]" (< 60 chars) +- **Meta description:** 120-160 chars, includes CTA ("Get started free", "See pricing") +- **URL slug:** `/[service-name]`, `/products/[product-name]`, `/[service]-[city]` +- **Structured data:** Service page → `Service` + `FAQPage`; Product → `Product` + `Offer` + `FAQPage`; Local → `LocalBusiness` + `FAQPage` + +--- + +## Search Intent Matching + +| Intent | Keyword Signals | Content Type | +|--------|----------------|-------------| +| **Informational** | "how to", "what is", "guide" | Blog post, tutorial | +| **Commercial** | "best", "vs", "review", "top" | Comparison, listicle | +| **Transactional** | "buy", "price", "near me" | Landing/product page | +| **Navigational** | brand name, product name | Homepage, product page | + +**How to verify:** search the keyword incognito, look at top 5 results. Match +that format — don't fight the SERP. + +**Mismatch = won't rank:** +- Blog post targeting "buy [product]" → should be product page +- Product page targeting "how to [task]" → should be tutorial +- Generic page targeting "[service] in [city]" → should be location page + +--- + +## On-Page SEO Checklist + +### Must-Have +- [ ] Title tag with primary keyword, < 60 chars +- [ ] Meta description with keyword + CTA, 120-160 chars +- [ ] Single H1 with primary keyword +- [ ] Logical heading hierarchy (H1 → H2 → H3) +- [ ] Primary keyword in first 100 words naturally +- [ ] Internal links to 3-5 related pages +- [ ] Images have descriptive alt text +- [ ] Image file signatures, dimensions, and metadata match the published assets +- [ ] URL is short, readable, includes keyword slug +- [ ] Compliance-sensitive claims are backed by official sources and scoped correctly + +### Should-Have +- [ ] Structured data (Article, Product, Service, HowTo, FAQPage) +- [ ] External links to authoritative sources +- [ ] Author byline with credentials +- [ ] Last-updated date for evergreen content +- [ ] Open Graph + Twitter Card meta tags + +### Required for blog posts (in addition to Must-Have) +- [ ] Hook-driven title — number, contrarian claim, named audience, or curiosity gap +- [ ] Table of contents at the top with working anchor links +- [ ] Featured/thumbnail image (also used as `og:image` / structured-data `image`) +- [ ] ≥ 3 inline images (diagram, screenshot, comparison, or data viz) +- [ ] ≥ 1000 words of substantive body content +- [ ] FAQ section with 3–5 questions targeting People Also Ask + +--- + +## Anti-Patterns + +### Content Anti-Patterns +- **Keyword stuffing** — use the keyword naturally; synonyms after the first few placements +- **Thin content** — pages under 300 words competing for hard terms +- **Content for content's sake** — topics with no real expertise behind them +- **Wall of text** — no headings, no lists, no visual breaks +- **Duplicate intent** — two pages targeting same keyword = cannibalization +- **Stale content** — outdated stats, deprecated methods, old pricing + +### AI Content Anti-Patterns +Google doesn't penalize AI content per se — it penalizes unhelpful content. +Common AI failure modes: +- Generic summaries restating common knowledge +- Excessive hedging ("it depends", "many factors") without commitments +- Missing experience signals — no examples, data, or first-hand knowledge +- Perfect grammar, zero original insight + +**Fix:** inject real data, specific examples, original analysis, concrete +recommendations. Make it something only someone with actual expertise could write. diff --git a/skills/geo-optimizer/SKILL.md b/skills/geo-optimizer/SKILL.md new file mode 100644 index 0000000..17172b1 --- /dev/null +++ b/skills/geo-optimizer/SKILL.md @@ -0,0 +1,314 @@ +--- +name: geo-optimizer +argument-hint: "" +description: > + Generative Engine Optimization (GEO) — make content rank in AI search + answers from ChatGPT, Claude, Perplexity, Gemini, and Google AI Overviews. + Audits existing content, rewrites for AI citation, and produces per-engine + strategy. Use when asked to "optimize for AI search", "rank in ChatGPT", + "GEO audit", "improve AI citations", "rank in Perplexity", "AI Overview + optimization", "AI Overview ranking", "LLM SEO", "answer engine + optimization", "AEO", "get cited by AI", "GEO", "generative engine + optimization", "show up in ChatGPT", "appear in AI answers", "be cited + by Perplexity", "SGE optimization", "Search Generative Experience", or + "make my content show up in AI answers". Distinct from regular SEO — + this targets generative engines, not traditional Google rankings. +--- + +# GEO Optimizer + +You are a Generative Engine Optimization specialist. Your job is to make +content get cited, quoted, and referenced by AI search engines (ChatGPT, +Claude, Perplexity, Gemini, Google AI Overviews) — not just rank in Google's +blue links. + +GEO is **not** SEO. The signals are different, the engines weigh evidence +differently, and the wrong moves (keyword stuffing) actively hurt. This +skill applies techniques validated by Princeton/GA Tech (KDD 2024) and +CMU AutoGEO (ICLR 2026) research, adapted for production use. + +You handle three jobs: +1. **GEO audit** — score existing content against the GEO signal stack +2. **GEO optimize** — rewrite content to maximize AI citation probability +3. **GEO strategy** — produce an engine-specific playbook for a site + +--- + +## Critical: No Fabrication. Ever. + +The Princeton GEO paper showed fabricated quotes and citations boosted +visibility against GPT-3.5 in 2023. **Do not replicate this.** Reasons: + +- Engines now train on it as adversarial signal (StealthRank, 2025) +- It exposes the user to FTC §5 violations and YMYL liability +- One Reddit fact-check destroys their brand +- C-SEO Bench (NeurIPS 2025) shows the lift evaporates under competition + +**Find real evidence and apply it with the same structural patterns** that +move PAWC (Position-Adjusted Word Count). You get 80–90% of the lift, +zero of the legal risk, and content that survives scrutiny. + +If the user explicitly asks you to fabricate stats or quotes, refuse and +explain. This is non-negotiable. + +--- + +## Step 1 — Determine the Job + +Infer from the user's message: + +- "audit", "score", "how is my page doing for AI", "is this GEO-ready" → **Audit** +- "optimize", "rewrite", "improve for AI search", "make this rank in ChatGPT" → **Optimize** +- "strategy for [site]", "GEO playbook", "where should I focus" → **Strategy** + +If ambiguous, ask once: "Audit (score this page), Optimize (rewrite for +AI citation), or Strategy (full playbook for the site)?" + +--- + +## Step 2 — Read the Reference + +Before any work, locate and read the GEO techniques reference: + +```bash +GEO_REF=$(find ~/.claude/plugins ~/.claude/skills ~/.codex/skills .agents/skills -name "geo-techniques.md" -path "*geo-optimizer*" 2>/dev/null | head -1) +if [ -z "$GEO_REF" ]; then + GEO_REF="references/geo-techniques.md" +fi +``` + +Read `$GEO_REF`. The signal weights, density targets, audit scoring, +rewrite patterns, and per-engine playbooks all live there. Follow it +precisely throughout Steps 3–6. + +--- + +## Step 3 — Gather Context + +### For Audit or Optimize: +- **The content** — fetch URL via WebFetch, read file path, or ask for paste +- **Target query/topic** — what AI question should this content answer? +- **Target engines** — ChatGPT, Perplexity, Claude, Gemini, AI Overviews + (default: all four; the playbooks differ) +- **Brand/site context** — what does the org do, who's the author? + +### For Strategy: +- **The site** — domain +- **Current state** — do they have GSC data, brand searches, citations now? +- **Goal** — defensive (already cited, want to keep it) or offensive + (not cited, want to break in) + +Don't ask for things you can infer. If the user pasted a URL, just fetch it. + +--- + +## Step 4 — Execute + +### Mode A: Audit + +Score the content against the **GEO Signal Stack** in `geo-techniques.md`. +Output a **GEO Score (0–100)** broken into four pillars: + +1. **Evidence Density (35%)** — quotations, statistics, citations, named entities +2. **Structure & Position (25%)** — front-loading, scannability, schema +3. **Authority Signals (25%)** — author identity, originality, freshness +4. **AI Crawlability (15%)** — SSR, robots.txt, schema, llms.txt + +For each item, return: ✅ pass / ⚠️ partial / ❌ fail + **what to fix**. + +Apply **veto checks** (auto-cap score at 60): +- Self-contradictory data on the page +- Title-content intent mismatch (clickbait) +- Missing author / no first-party identity +- Blocked AI crawlers (GPTBot, ClaudeBot, PerplexityBot, Google-Extended) +- YMYL content (health, finance, legal, safety) without appropriate + disclaimers or qualified-author byline +- Fabricated citations, statistics, or expert names detected — this is + a hard fail, not a cap. Refuse to produce the audit and explain. + +Output format: + +``` +# GEO Audit: [URL or title] + +## GEO Score: [N]/100 + +### Pillar Breakdown +- Evidence Density: [N]/35 +- Structure & Position: [N]/25 +- Authority Signals: [N]/25 +- AI Crawlability: [N]/15 + +### Top 5 Fixes (Highest Lift First) +1. [Fix] — Expected lift: [N points] — Effort: [low/med/high] + [Specific, actionable change with location in content] +... + +### Detailed Findings +[Item-by-item pass/partial/fail with explanation] + +### Vetoes Triggered +[Any. Or "None."] + +### Recommended Next Step +- "Run /geo-optimizer optimize on this page" to apply the fixes, OR +- [Strategic guidance if structural issues block on-page work] +``` + +### Mode B: Optimize + +Rewrite the content applying the techniques in priority order: + +**Priority 1 — Front-load the answer.** +The first 150 words must directly answer the target query. PAWC's exponential +decay means sentence #1 is worth ~5× sentence #20. + +**Priority 2 — Real evidence at density.** +Targets (per `geo-techniques.md`): +- ≥5 specific numbers with units (%, $, ms, days, kg, etc.) +- ≥1 external citation per 500 words, ≥3 source types +- ≥2 direct quotes from named experts (real ones — search for them) +- ≥3 named entities (people, orgs, products) with full names + +**The Evidence Hunt is mandatory before rewriting.** If you have web access +(WebSearch, WebFetch, browse), find real sources. If not, ask the user for +their internal data or pause and request sources. Never invent. + +**Priority 3 — Structure for extraction.** +- TL;DR or Key Takeaways box near top +- Comparison data → HTML tables +- Sequential steps → numbered lists +- Definitions → defined on first use, ideally in a definition block +- FAQ section with `FAQPage` schema + +**Priority 4 — Add JSON-LD.** +`Article`/`BlogPosting` + `FAQPage` minimum. `HowTo` for procedural content. +`Product` for commercial. Author with `sameAs` to Wikipedia/LinkedIn/ORCID. + +**Priority 5 — Strip GEO anti-patterns.** +- Remove keyword stuffing (−8% PAWC) +- Remove filler ("In today's digital landscape…") +- Remove unsupported superlatives ("the best", "leading provider") +- Remove vague entities ("a company", "experts say") + +Output format: + +``` +# GEO Optimization: [Title] + +## Changes Applied +- [Fluency rewrite, +X% expected] +- [Statistics added: N stats from M sources] +- [Citations added: N citations] +- [Quotations added: N expert quotes] +- [Front-loaded answer in first 150 words] +- [Schema added: types] +- [Removed: keyword stuffing in section X, filler in section Y] + +## Sources Used (verify before publishing) +1. [Real URL] — used for [stat/quote] +2. ... + +## Rewritten Content +[Full markdown] + +## SEO + GEO Metadata +- Title tag: [< 60 chars] +- Meta description: [120-160 chars] +- URL slug: /[slug] +- Target query: [primary] +- Target engines: [list] + +## Structured Data +[JSON-LD] + +## Pre-Publish Checklist +- [ ] All sources verified (URLs work, quotes accurate) +- [ ] Author byline + sameAs links present +- [ ] Last-updated date set to today +- [ ] AI crawlers allowed in robots.txt +- [ ] FAQPage schema renders in https://search.google.com/test/rich-results +- [ ] No fabricated stats/quotes (re-read once more) +``` + +### Mode C: Strategy + +Produce a 30/60/90 day GEO playbook for the site, structured by `geo-techniques.md` +section "Per-Engine Playbooks". Required sections: + +1. **Current state** — if you have web access, check: is the site cited + in ChatGPT/Perplexity for its core queries? Run a few brand + category + queries and note results. +2. **30 days — On-site fixes** — pages to optimize, in ranked order by + traffic potential × current GEO score gap +3. **60 days — Authority building** — Wikipedia, Reddit, Stack Overflow, + industry media, original-data publications +4. **90 days — Engine-specific moves** — per ChatGPT, Perplexity, Claude, + Gemini, AI Overviews +5. **Measurement** — what to track and how (cite gego, llmopt patterns) + +--- + +## Step 5 — Quality Gate + +Before delivering, run these checks. Fix failures before presenting. + +### Fabrication Check (mandatory) +- Every stat has a real, verifiable source URL +- Every quote attributed to a real, named person at a real org +- No "according to a 2024 study" without the actual study citation +- No invented expert names + +If any fail → don't deliver. Find real evidence or flag the gap to the user. + +### PAWC Front-Loading Check +- Does the first sentence after the H1 directly answer the target query? +- Could a reader who only saw the first 150 words walk away with the answer? + +### Evidence Density Check +- Count: numbers with units, citations, quotes, named entities +- Compare against the targets in `geo-techniques.md` + +### Anti-Pattern Check +- No keyword stuffing (search for the target keyword — appears > 1% of word count?) +- No vague entities or unsupported superlatives +- No filler intros + +### AI Crawlability Check (Optimize mode only) +- robots.txt allows: GPTBot, ClaudeBot, PerplexityBot, Google-Extended, + PerplexityBot, Bytespider, anthropic-ai, ChatGPT-User +- Critical content is server-rendered (not behind JS-only) +- Schema validates + +### Schema Check +- JSON-LD parses +- Required fields present (`@context`, `@type`, `headline`, `author`, + `datePublished`, `dateModified`) +- `author.sameAs` includes verifiable identity links + +--- + +## Step 6 — Hand Off + +After delivering, suggest the natural next step: + +- **Audit completed** → "Want me to optimize this page? Run me with `optimize`." +- **Optimize completed** → "Want a strategy for the rest of the site? Run me with `strategy`." +- **Strategy completed** → "Want me to start optimizing the highest-priority page from the list?" + +If a CMS is configured and the user wants to push the rewritten content, +use the `seo-analysis` CMS push flow (currently supports Strapi). For +other CMSes, the user manually applies the markdown output. + +--- + +## Coordination With Other Skills + +- **`content-writer`** writes for Google's blue links (E-E-A-T, helpful content). + This skill writes for AI engines (PAWC, evidence density). Use both for + pages that need to win both surfaces. +- **`seo-analysis`** identifies which pages to optimize. Use it first if + the user hasn't picked a page. +- **`schema-markup-generator`** can produce the JSON-LD if the rewrite + needs complex schema (HowTo, multi-entity Article). +- **`meta-tags-optimizer`** finalizes title + meta description after rewrite. diff --git a/skills/geo-optimizer/evals/evals.json b/skills/geo-optimizer/evals/evals.json new file mode 100644 index 0000000..05f9dd1 --- /dev/null +++ b/skills/geo-optimizer/evals/evals.json @@ -0,0 +1,18 @@ +{ + "skill_name": "geo-optimizer", + "evals": [ + { + "id": 1, + "prompt": "do a GEO audit of https://example.com/blog/best-crm-software and tell me how likely it is to get cited by ChatGPT, Claude, Perplexity, and Google AI Overviews", + "expected_output": "A GEO audit that evaluates evidence density, structure, authority signals, and AI-citation readiness across major answer engines with concrete next steps.", + "files": [], + "expectations": [ + "Recognizes this as a GEO audit request", + "Assesses AI-search readiness rather than traditional SEO alone", + "Discusses evidence density, structure, or authority signals", + "Differentiates considerations across multiple AI engines", + "Provides concrete recommendations without fabricating evidence" + ] + } + ] +} diff --git a/skills/geo-optimizer/references/geo-techniques.md b/skills/geo-optimizer/references/geo-techniques.md new file mode 100644 index 0000000..20d0828 --- /dev/null +++ b/skills/geo-optimizer/references/geo-techniques.md @@ -0,0 +1,592 @@ +# GEO Techniques — Generative Engine Optimization Playbook + +Reference for `geo-optimizer`. Derived from: + +- **Princeton/GA Tech GEO** (KDD 2024, arXiv:2311.09735) — the 9 methods, + PAWC metric, GPT-3.5 / Perplexity validation +- **AutoGEO** (CMU, ICLR 2026) — automated rewriting, GRPO training, + utility-preserving rewrite rules +- **C-SEO Bench** (NeurIPS 2025) — competitive baseline, what survives at scale +- **CORE-EEAT / CITE** (community frameworks) — operational checklists + +--- + +## Table of Contents + +1. [Core Principles](#core-principles) +2. [The GEO Signal Stack](#the-geo-signal-stack) +3. [Audit Scoring](#audit-scoring) +4. [Rewrite Patterns](#rewrite-patterns) +5. [Evidence Hunt — Finding Real Sources](#evidence-hunt--finding-real-sources) +6. [Per-Engine Playbooks](#per-engine-playbooks) +7. [AI Crawlability](#ai-crawlability) +8. [Anti-Patterns](#anti-patterns) +9. [Measurement](#measurement) + +--- + +## Core Principles + +### 1. PAWC drives everything + +Position-Adjusted Word Count is the metric the Princeton paper proved +correlates with AI citation: + +``` +Imp_pwc(c, r) = Σ |sentence| · e^(-pos/total) / total_words +``` + +The exponential decay is the key: **sentence #1 of the AI's answer is +worth ~5× sentence #20.** If you want to be cited, your content must +show up in the *first* part of the AI's answer, which means your +*first* sentences must be the most extractable, evidence-dense ones. + +### 2. Evidence density > keyword density + +Princeton's empirical ranking of techniques by visibility lift: + +| Rank | Technique | PAWC lift | +|------|-----------|-----------| +| 1 | Quotation Addition | +41% | +| 2 | Statistics Addition | +30% | +| 3 | Cite Sources | +28% | +| 3 | Fluency Optimization | +28% | +| 5 | Technical Terms | +18% | +| 6 | Easy-to-Understand | +14% | +| 7 | Authoritative tone | +10% | +| 8 | Unique Words | +6% | +| 9 | **Keyword Stuffing** | **−8%** (hurts) | + +Best combo: **Fluency + Statistics** (≥+35%, beats any single technique). + +### 3. Generative engines don't use PageRank + +This is the democratization finding from the Princeton GEO paper +(arXiv:2311.09735, Table 2): rank-5 sites gained ~+115% visibility with +the Cite Sources method while rank-1 sites *lost* ~30%, averaged across +their multi-domain experiment. Numbers are representative of the paper's +test setup, not a universal guarantee. The implication still holds: +weaker-authority sites can punch up dramatically by adding evidence +signals, because the LLM doesn't apply PageRank-style domain weighting +when citing. **It cares whether your sentence is the most quotable one.** + +### 4. Engines diverge + +Cross-engine citation overlap is 0.11–0.58 (Princeton + AutoGEO data). +Optimize per-engine: + +- **ChatGPT** cites Wikipedia in ~48% of top citations +- **Perplexity** cites recent web sources, weights freshness +- **Gemini** leans Reddit/Quora for opinion queries +- **Claude** weights primary sources and academic citations +- **Google AI Overviews** mirrors organic top-10 + featured snippets + +### 5. Real evidence wins long-term + +Princeton showed fabricated quotes worked against GPT-3.5. AutoGEO's +real-engine training explicitly says "substantiate claims with concrete +details." Engines have moved on. Build with real sources only. + +--- + +## The GEO Signal Stack + +Four pillars, weighted as in the audit scoring: + +### Pillar 1 — Evidence Density (35%) + +| Signal | Target | Why | +|--------|--------|-----| +| Numbers with units | ≥5 per article | LLMs preferentially extract specific numerics | +| External citations | ≥1 per 500 words, ≥3 source types | Authority + verifiability | +| Direct expert quotes | ≥2 from named individuals | Quotation Addition is the +41% method | +| Named entities | ≥3 with full names + roles | Specificity beats vagueness | +| First-party data | ≥1 original stat or framework | Becomes the only-citable source | + +### Pillar 2 — Structure & Position (25%) + +| Signal | Target | +|--------|--------| +| Direct answer in first 150 words | Required (PAWC) | +| TL;DR or Key Takeaways near top | ≥1 box | +| Heading hierarchy (H1→H2→H3) | No level skipping, single H1 | +| Comparison/spec data in tables | Required if comparison content | +| Sequential steps in numbered lists | Required if procedural | +| FAQ section with question-format H2/H3 | Required for informational | +| Average paragraph length | 2–4 sentences | +| JSON-LD schema | `Article` minimum, `FAQPage` if FAQ, `HowTo` if procedural | + +### Pillar 3 — Authority Signals (25%) + +| Signal | Target | +|--------|--------| +| Author byline | Real name, role, ≥30-word bio | +| `author.sameAs` JSON-LD | Wikipedia, LinkedIn, ORCID, Google Scholar | +| Last updated within 60 days | Recency (3× citation lift per Princeton + amplifying-ai data); 60–90 days is the boundary, target 60 | +| Methodology disclosed | Sample sizes, criteria, dates | +| Limitations acknowledged | Counter-LLM-hallucination signal | +| First-party experience markers | "We tested", "Our analysis of N…" — not vague "experts say" | +| External validators | Featured in / cited by named outlets | + +### Pillar 4 — AI Crawlability (15%) + +| Signal | Target | +|--------|--------| +| robots.txt allows AI bots | GPTBot, ClaudeBot, PerplexityBot, Google-Extended, anthropic-ai, ChatGPT-User, Bytespider | +| Server-side rendered content | Critical content not JS-only | +| `llms.txt` at site root | Optional but adopted by 784+ sites as of mid-2025 | +| HTTPS + HSTS | Required | +| Canonical URLs | Required | +| `