| """ |
| Paid Ads Manager — Google Ads API + Microsoft Ads API + Demo Mode |
| Manages campaign creation, performance reporting, keyword data. |
| """ |
| import json |
| import os |
| from typing import List, Dict, Optional |
| from pathlib import Path |
|
|
| OUTPUT_DIR = Path(__file__).resolve().parent.parent / 'output' |
|
|
| |
| try: |
| from google.ads.googleads.client import GoogleAdsClient |
| GOOGLE_ADS_AVAILABLE = True |
| except ImportError: |
| GoogleAdsClient = None |
| GOOGLE_ADS_AVAILABLE = False |
|
|
| try: |
| from bingads.service_client import ServiceClient |
| from bingads.authorization import AuthorizationData, OAuthWebAuthCodeGrant |
| BING_ADS_AVAILABLE = True |
| except ImportError: |
| BING_ADS_AVAILABLE = False |
|
|
| |
| DEMO_CAMPAIGNS = [ |
| { |
| "id": "111111", "name": "SEO Services — Saudi Arabia", "status": "ENABLED", |
| "clicks": 842, "impressions": 12400, "ctr": 6.79, "avg_cpc": 1.85, |
| "cost": 1557.7, "conversions": 23, "cpa": 67.7, "impression_share": 58.3 |
| }, |
| { |
| "id": "222222", "name": "GEO Platform — تحسين محركات البحث", "status": "ENABLED", |
| "clicks": 524, "impressions": 8100, "ctr": 6.47, "avg_cpc": 2.10, |
| "cost": 1100.4, "conversions": 14, "cpa": 78.6, "impression_share": 41.2 |
| }, |
| { |
| "id": "333333", "name": "Brand Keywords — محرك", "status": "PAUSED", |
| "clicks": 189, "impressions": 3200, "ctr": 5.91, "avg_cpc": 0.75, |
| "cost": 141.75, "conversions": 9, "cpa": 15.75, "impression_share": 72.5 |
| } |
| ] |
|
|
| DEMO_KEYWORDS = [ |
| {"keyword": "تحسين محركات البحث", "match_type": "EXACT", "quality_score": 8, |
| "clicks": 312, "ctr": 7.2, "avg_cpc": 2.10, "cost": 655.2, "conversions": 12}, |
| {"keyword": "شركة سيو", "match_type": "BROAD", "quality_score": 7, |
| "clicks": 198, "ctr": 5.1, "avg_cpc": 1.60, "cost": 316.8, "conversions": 7}, |
| {"keyword": "SEO services Saudi Arabia", "match_type": "EXACT", "quality_score": 9, |
| "clicks": 445, "ctr": 8.3, "avg_cpc": 1.90, "cost": 845.5, "conversions": 15}, |
| {"keyword": "سيو عربي", "match_type": "BROAD", "quality_score": 6, |
| "clicks": 124, "ctr": 3.2, "avg_cpc": 0.95, "cost": 117.8, "conversions": 2}, |
| {"keyword": "keyword ranking tool", "match_type": "EXACT", "quality_score": 5, |
| "clicks": 87, "ctr": 2.1, "avg_cpc": 1.20, "cost": 104.4, "conversions": 0}, |
| {"keyword": "خدمات التسويق الرقمي", "match_type": "BROAD", "quality_score": 8, |
| "clicks": 201, "ctr": 6.4, "avg_cpc": 2.30, "cost": 462.3, "conversions": 9}, |
| ] |
|
|
| DEMO_SEARCH_TERMS = { |
| "converting_terms": [ |
| {"term": "تحسين موقع جوجل", "campaign": "SEO — SA", "ad_group": "Arabic SEO", |
| "clicks": 34, "conversions": 4, "avg_cpc": 1.95}, |
| {"term": "افضل شركة سيو في السعودية", "campaign": "SEO — SA", "ad_group": "Arabic SEO", |
| "clicks": 21, "conversions": 3, "avg_cpc": 2.20}, |
| {"term": "SEO optimization company Riyadh", "campaign": "SEO — SA", "ad_group": "English SEO", |
| "clicks": 18, "conversions": 2, "avg_cpc": 1.85}, |
| ], |
| "wasted_spend": [ |
| {"term": "SEO salary jobs", "campaign": "SEO — SA", "ad_group": "English SEO", |
| "clicks": 42, "conversions": 0, "avg_cpc": 1.10}, |
| {"term": "how to learn SEO free", "campaign": "SEO — SA", "ad_group": "English SEO", |
| "clicks": 38, "conversions": 0, "avg_cpc": 0.90}, |
| {"term": "سيو يوتيوب", "campaign": "GEO Platform", "ad_group": "Arabic SEO", |
| "clicks": 29, "conversions": 0, "avg_cpc": 0.75}, |
| ] |
| } |
|
|
|
|
| |
| def save_ads_config(config: dict) -> None: |
| """Persist ads credentials to output/ads_config.json (never exposed in git).""" |
| config_path = OUTPUT_DIR / 'ads_config.json' |
| with open(config_path, 'w', encoding='utf-8') as f: |
| json.dump(config, f, ensure_ascii=False, indent=2) |
|
|
|
|
| def load_ads_config() -> dict: |
| """Load saved ads credentials.""" |
| config_path = OUTPUT_DIR / 'ads_config.json' |
| if config_path.exists(): |
| with open(config_path, 'r') as f: |
| return json.load(f) |
| return {} |
|
|
|
|
| |
| def _get_google_client(credentials: dict) -> Optional[object]: |
| """Initialize Google Ads client from credentials dict.""" |
| if not GOOGLE_ADS_AVAILABLE or not credentials: |
| return None |
| try: |
| client = GoogleAdsClient.load_from_dict({ |
| 'developer_token': credentials.get('developer_token'), |
| 'client_id': credentials.get('client_id'), |
| 'client_secret': credentials.get('client_secret'), |
| 'refresh_token': credentials.get('refresh_token'), |
| 'login_customer_id': credentials.get('customer_id'), |
| 'use_proto_plus': True |
| }) |
| return client |
| except Exception as e: |
| print(f"[AdsManager] Google Ads client error: {e}") |
| return None |
|
|
|
|
| def verify_google_connection(credentials: dict) -> dict: |
| """Test if credentials work. Returns account info or error.""" |
| client = _get_google_client(credentials) |
| if not client: |
| return {'ok': False, 'error': 'Library not available or invalid credentials'} |
| try: |
| customer_service = client.get_service("CustomerService") |
| cid = credentials.get('customer_id', '').replace('-', '') |
| customer = customer_service.get_customer( |
| resource_name=f"customers/{cid}" |
| ) |
| return { |
| 'ok': True, |
| 'account_name': customer.descriptive_name, |
| 'customer_id': cid, |
| 'currency': customer.currency_code, |
| 'timezone': customer.time_zone |
| } |
| except Exception as e: |
| return {'ok': False, 'error': str(e)} |
|
|
|
|
| def is_demo_mode(credentials: dict) -> bool: |
| """Return True when no real Google Ads credentials are available.""" |
| return not GOOGLE_ADS_AVAILABLE or not credentials.get('customer_id') |
|
|
|
|
| |
| def get_campaign_performance(credentials: dict, days: int = 30) -> List[Dict]: |
| """Get campaign performance. Returns demo data when credentials are absent.""" |
| client = _get_google_client(credentials) |
| if not client: |
| return DEMO_CAMPAIGNS |
|
|
| cid = credentials.get('customer_id', '').replace('-', '') |
| ga_service = client.get_service("GoogleAdsService") |
| query = f""" |
| SELECT |
| campaign.id, campaign.name, campaign.status, |
| metrics.clicks, metrics.impressions, metrics.ctr, |
| metrics.average_cpc, metrics.cost_micros, |
| metrics.conversions, metrics.cost_per_conversion, |
| metrics.search_impression_share |
| FROM campaign |
| WHERE segments.date DURING LAST_{days}_DAYS |
| AND campaign.status != 'REMOVED' |
| ORDER BY metrics.cost_micros DESC |
| """ |
| results = [] |
| try: |
| for row in ga_service.search(customer_id=cid, query=query): |
| results.append({ |
| "id": str(row.campaign.id), |
| "name": row.campaign.name, |
| "status": row.campaign.status.name, |
| "clicks": row.metrics.clicks, |
| "impressions": row.metrics.impressions, |
| "ctr": round(row.metrics.ctr * 100, 2), |
| "avg_cpc": round(row.metrics.average_cpc / 1_000_000, 2), |
| "cost": round(row.metrics.cost_micros / 1_000_000, 2), |
| "conversions": row.metrics.conversions, |
| "cpa": round(row.metrics.cost_per_conversion / 1_000_000, 2), |
| "impression_share": round(row.metrics.search_impression_share * 100, 1) |
| }) |
| except Exception as e: |
| print(f"[AdsManager] Campaign query error: {e}") |
| return DEMO_CAMPAIGNS |
| return results or DEMO_CAMPAIGNS |
|
|
|
|
| def get_keyword_performance(credentials: dict) -> List[Dict]: |
| """Get keyword-level data with Quality Scores. Returns demo data when credentials are absent.""" |
| client = _get_google_client(credentials) |
| if not client: |
| return DEMO_KEYWORDS |
|
|
| cid = credentials.get('customer_id', '').replace('-', '') |
| ga_service = client.get_service("GoogleAdsService") |
| query = """ |
| SELECT |
| ad_group_criterion.keyword.text, |
| ad_group_criterion.keyword.match_type, |
| ad_group_criterion.quality_info.quality_score, |
| metrics.clicks, metrics.impressions, metrics.ctr, |
| metrics.average_cpc, metrics.cost_micros, metrics.conversions |
| FROM keyword_view |
| WHERE segments.date DURING LAST_30_DAYS |
| AND ad_group_criterion.status = 'ENABLED' |
| ORDER BY metrics.cost_micros DESC |
| LIMIT 100 |
| """ |
| results = [] |
| try: |
| for row in ga_service.search(customer_id=cid, query=query): |
| kw = row.ad_group_criterion |
| results.append({ |
| "keyword": kw.keyword.text, |
| "match_type": kw.keyword.match_type.name, |
| "quality_score": kw.quality_info.quality_score, |
| "clicks": row.metrics.clicks, |
| "ctr": round(row.metrics.ctr * 100, 2), |
| "avg_cpc": round(row.metrics.average_cpc / 1_000_000, 2), |
| "cost": round(row.metrics.cost_micros / 1_000_000, 2), |
| "conversions": row.metrics.conversions, |
| }) |
| except Exception as e: |
| print(f"[AdsManager] Keyword query error: {e}") |
| return DEMO_KEYWORDS |
| return results or DEMO_KEYWORDS |
|
|
|
|
| def get_search_terms(credentials: dict, min_clicks: int = 5) -> dict: |
| """Get real user queries that triggered ads. Returns demo data when credentials are absent.""" |
| client = _get_google_client(credentials) |
| if not client: |
| return DEMO_SEARCH_TERMS |
|
|
| cid = credentials.get('customer_id', '').replace('-', '') |
| ga_service = client.get_service("GoogleAdsService") |
| query = f""" |
| SELECT |
| search_term_view.search_term, |
| metrics.clicks, metrics.impressions, |
| metrics.ctr, metrics.average_cpc, metrics.conversions, |
| campaign.name, ad_group.name |
| FROM search_term_view |
| WHERE segments.date DURING LAST_30_DAYS |
| AND metrics.clicks >= {min_clicks} |
| ORDER BY metrics.conversions DESC |
| LIMIT 200 |
| """ |
| terms = [] |
| try: |
| for row in ga_service.search(customer_id=cid, query=query): |
| terms.append({ |
| "term": row.search_term_view.search_term, |
| "campaign": row.campaign.name, |
| "ad_group": row.ad_group.name, |
| "clicks": row.metrics.clicks, |
| "conversions": row.metrics.conversions, |
| "avg_cpc": round(row.metrics.average_cpc / 1_000_000, 2), |
| }) |
| except Exception as e: |
| print(f"[AdsManager] Search terms error: {e}") |
| return DEMO_SEARCH_TERMS |
|
|
| converting = [t for t in terms if t["conversions"] > 0] |
| wasted = [t for t in terms if t["conversions"] == 0 and t["clicks"] > 10] |
| return {"converting_terms": converting, "wasted_spend": wasted} |
|
|
|
|
| |
| def create_campaign(credentials: dict, name: str, budget_usd: float, |
| target_cpa: Optional[float] = None) -> dict: |
| """Create a new Google Ads campaign (starts PAUSED for safety).""" |
| client = _get_google_client(credentials) |
| if not client: |
| return {"ok": False, "error": "Demo mode — real API credentials required to create campaigns"} |
|
|
| cid = credentials.get('customer_id', '').replace('-', '') |
| try: |
| budget_micros = int(budget_usd * 1_000_000) |
|
|
| |
| budget_op = client.get_type("CampaignBudgetOperation") |
| budget = budget_op.create |
| budget.name = f"Budget: {name}" |
| budget.amount_micros = budget_micros |
| budget.delivery_method = client.enums.BudgetDeliveryMethodEnum.STANDARD |
|
|
| budget_service = client.get_service("CampaignBudgetService") |
| budget_res = budget_service.mutate_campaign_budgets( |
| customer_id=cid, operations=[budget_op] |
| ) |
| budget_rn = budget_res.results[0].resource_name |
|
|
| |
| camp_op = client.get_type("CampaignOperation") |
| camp = camp_op.create |
| camp.name = name |
| camp.status = client.enums.CampaignStatusEnum.PAUSED |
| camp.advertising_channel_type = client.enums.AdvertisingChannelTypeEnum.SEARCH |
| camp.campaign_budget = budget_rn |
| if target_cpa: |
| camp.target_cpa.target_cpa_micros = int(target_cpa * 1_000_000) |
| else: |
| camp.manual_cpc.enhanced_cpc_enabled = True |
| camp.network_settings.target_google_search = True |
| camp.network_settings.target_search_network = True |
|
|
| camp_service = client.get_service("CampaignService") |
| result = camp_service.mutate_campaigns(customer_id=cid, operations=[camp_op]) |
| rn = result.results[0].resource_name |
| return {"ok": True, "campaign_resource_name": rn, "name": name, |
| "budget_per_day": budget_usd, "status": "PAUSED"} |
| except Exception as e: |
| return {"ok": False, "error": str(e)} |
|
|
|
|
| |
| def build_ads_summary(campaigns: List[Dict], credentials: dict = None) -> Dict: |
| """Compute KPI summary across all campaigns.""" |
| total_spend = sum(c.get("cost", 0) for c in campaigns) |
| total_clicks = sum(c.get("clicks", 0) for c in campaigns) |
| total_conv = sum(c.get("conversions", 0) for c in campaigns) |
| avg_cpa = round(total_spend / total_conv, 2) if total_conv > 0 else 0.0 |
| creds = credentials if credentials is not None else load_ads_config() |
| return { |
| "total_spend": round(total_spend, 2), |
| "total_clicks": total_clicks, |
| "total_conversions": total_conv, |
| "avg_cpa": avg_cpa, |
| "active_campaigns": sum(1 for c in campaigns if c.get("status") == "ENABLED"), |
| "is_demo": is_demo_mode(creds) |
| } |
|
|