DM-Agent / src /google_api.py
wen Qiu
fix: include detailed error message in Google Ads API failures
a9f3229
Raw History Blame Contribute Delete
8.35 kB
"""
Google Ads API 数据拉取引擎。
通过 REST API 拉取 campaign 级日度数据,转换为与 Google Sheets 相同的列结构。
"""
import json
import logging
import os
from datetime import date, timedelta
from typing import Any
import pandas as pd
import requests
from dotenv import load_dotenv
load_dotenv()
log = logging.getLogger("ad-agent")
API_VERSION = "v23"
BASE_URL = f"https://googleads.googleapis.com/{API_VERSION}"
CHANNEL_TYPE_MAP = {
"SEARCH": "Conversion",
"SHOPPING": "Conversion",
"PERFORMANCE_MAX": "Conversion",
"DISPLAY": "Awareness",
"VIDEO": "Consideration",
"MULTI_CHANNEL": "Conversion",
"DEMAND_GEN": "Consideration",
"LOCAL": "Conversion",
"SMART": "Conversion",
"DISCOVERY": "Consideration",
}
GAQL_QUERY = """
SELECT
campaign.name,
campaign.id,
campaign.advertising_channel_type,
metrics.cost_micros,
metrics.impressions,
metrics.clicks,
metrics.ctr,
metrics.average_cpc,
metrics.average_cpm,
metrics.conversions,
metrics.conversions_value,
segments.date
FROM campaign
WHERE segments.date BETWEEN '{since}' AND '{until}'
AND metrics.impressions > 0
ORDER BY segments.date DESC, campaign.name
"""
def _get_access_token() -> str:
resp = requests.post("https://oauth2.googleapis.com/token", data={
"client_id": os.getenv("GOOGLE_ADS_CLIENT_ID"),
"client_secret": os.getenv("GOOGLE_ADS_CLIENT_SECRET"),
"refresh_token": os.getenv("GOOGLE_ADS_REFRESH_TOKEN"),
"grant_type": "refresh_token",
})
token = resp.json().get("access_token")
if not token:
raise RuntimeError(f"Google OAuth token 刷新失败: {resp.text[:200]}")
return token
def _fetch_campaigns(
customer_id: str, access_token: str, since: str, until: str
) -> list[dict]:
"""分页拉取一个广告账户的 campaign 级日度数据。"""
login_id = os.getenv("GOOGLE_ADS_LOGIN_CUSTOMER_ID", "")
dev_token = os.getenv("GOOGLE_ADS_DEVELOPER_TOKEN", "")
headers = {
"Authorization": f"Bearer {access_token}",
"developer-token": dev_token,
"Content-Type": "application/json",
}
if login_id:
headers["login-customer-id"] = login_id
query = GAQL_QUERY.format(since=since, until=until)
all_results: list[dict] = []
page_token = None
while True:
body: dict[str, Any] = {"query": query}
if page_token:
body["pageToken"] = page_token
url = f"{BASE_URL}/customers/{customer_id}/googleAds:search"
resp = requests.post(url, headers=headers, json=body)
if resp.status_code != 200:
detail = resp.text[:800]
log.error(f"Google Ads API error ({resp.status_code}): {detail}")
raise RuntimeError(f"Google Ads API 请求失败 ({resp.status_code}): {detail}")
data = resp.json()
all_results.extend(data.get("results", []))
page_token = data.get("nextPageToken")
if not page_token:
break
return all_results
def _parse_row(row: dict, country: str) -> dict[str, Any]:
"""将一条 Google Ads API 返回数据转换为与 Google Sheets 相同的列结构。"""
campaign = row.get("campaign", {})
metrics = row.get("metrics", {})
segments = row.get("segments", {})
cost = int(metrics.get("costMicros", 0)) / 1_000_000
impressions = int(metrics.get("impressions", 0))
clicks = int(metrics.get("clicks", 0))
ctr = float(metrics.get("ctr", 0))
avg_cpc = int(metrics.get("averageCpc", 0)) / 1_000_000
avg_cpm = int(metrics.get("averageCpm", 0)) / 1_000_000
conversions = float(metrics.get("conversions", 0))
conv_value = float(metrics.get("conversionsValue", 0))
video_views = int(metrics.get("videoViews", 0))
bounce_rate = None
roas = conv_value / cost if cost > 0 else None
cvr = conversions / clicks if clicks > 0 else None
cplpv = cost / clicks if clicks > 0 else None
campaign_name = campaign.get("name", "")
name_parts = campaign_name.split("_")
name_funnel = name_parts[1] if len(name_parts) > 1 else ""
if name_funnel in ("Awareness", "Consideration", "Conversion"):
campaign_type = name_funnel
else:
channel_type = campaign.get("advertisingChannelType", "")
campaign_type = CHANNEL_TYPE_MAP.get(channel_type, "Conversion")
name_lower = campaign_name.lower()
if "awareness" in name_lower or "aw_" in name_lower:
campaign_type = "Awareness"
elif "consideration" in name_lower or "traffic" in name_lower:
campaign_type = "Consideration"
elif "conversion" in name_lower or "purchase" in name_lower or "pmax" in name_lower:
campaign_type = "Conversion"
return {
"country": country,
"platform": "Google",
"campaign_type": campaign_type,
"campaign_name": campaign_name,
"date": segments.get("date", ""),
"Cost": cost,
"Ads.Sales": conv_value,
"Ads.Sales(1d+7d)": None,
"SPF.Sales": None,
"ROAS": roas,
"GA4.Sessions": clicks,
"SPF.Sessions": None,
"LPV": clicks,
"CTR": ctr,
"CVR": cvr,
"CPC": avg_cpc,
"CPSE": None,
"CPLPV": cplpv,
"CPM": avg_cpm,
"Impressions": impressions,
"Bounce Rate": bounce_rate,
"Average Duration": None,
"ATC": None,
"CP-ATC": None,
"Email": None,
"CP-Email": None,
"Video Views": video_views if video_views > 0 else None,
"CP-Video Views": (cost / video_views) if video_views > 0 else None,
"50% Play Rate": None,
"100% Play Rate": None,
"Hook Rate": None,
}
def fetch_google_data(
since: date | None = None,
until: date | None = None,
days_back: int = 90,
) -> pd.DataFrame:
"""
从所有配置的 Google Ads 账户拉取数据,返回与 Google Sheets 相同结构的 DataFrame。
"""
refresh_token = os.getenv("GOOGLE_ADS_REFRESH_TOKEN")
if not refresh_token:
raise RuntimeError("未配置 GOOGLE_ADS_REFRESH_TOKEN")
accounts_str = os.getenv("GOOGLE_ADS_CUSTOMER_IDS", "")
if not accounts_str:
raise RuntimeError("未配置 GOOGLE_ADS_CUSTOMER_IDS (格式: customer_id:country,...)")
accounts = []
for pair in accounts_str.split(","):
pair = pair.strip()
if ":" in pair:
cid, country = pair.split(":", 1)
accounts.append((cid.strip(), country.strip()))
if not accounts:
raise RuntimeError("GOOGLE_ADS_CUSTOMER_IDS 格式错误")
if until is None:
until = date.today() - timedelta(days=1)
if since is None:
since = until - timedelta(days=days_back)
since_str = since.strftime("%Y-%m-%d")
until_str = until.strftime("%Y-%m-%d")
access_token = _get_access_token()
all_parsed: list[dict] = []
for customer_id, country in accounts:
log.info(f"拉取 Google Ads 数据: customer={customer_id} country={country} {since_str}~{until_str}")
try:
rows = _fetch_campaigns(customer_id, access_token, since_str, until_str)
log.info(f" 获取 {len(rows)} 条记录")
for row in rows:
parsed = _parse_row(row, country)
all_parsed.append(parsed)
except Exception as e:
log.error(f" 拉取失败: {e}")
raise
if not all_parsed:
return pd.DataFrame()
df = pd.DataFrame(all_parsed)
df["date"] = pd.to_datetime(df["date"])
return df
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO)
df = fetch_google_data(days_back=7)
print(f"\n拉取到 {len(df)} 条数据")
print(f"日期范围: {df['date'].min()} ~ {df['date'].max()}")
print(f"国家: {df['country'].unique()}")
print(f"Campaign Types: {df['campaign_type'].unique()}")
print(f"\n汇总:")
print(f" 总花费: ${df['Cost'].sum():,.2f}")
print(f" 总销售: ${df['Ads.Sales'].sum():,.2f}")
total_cost = df["Cost"].sum()
total_sales = df["Ads.Sales"].sum()
print(f" ROAS: {total_sales / total_cost:.2f}" if total_cost > 0 else " ROAS: N/A")