"""生成 King 各 Campaign 报告数据。 取数范围:当月 1 号至昨天。 指标:Install、Cost(消耗)、Monthly Budget、FFR、CPI。 数据来源(同一份 Spreadsheet): - Cost_Client(gid=0)长表:A=Date, B=Campaign Name, G=Installs, H=Cost, I=App - Breakdown_cost【day】(gid=1217689915)宽表:按天消耗,作为消耗主来源 - forPrebill(gid=450129149):B 列 Campaign name,Z 列当月预算 读取优先使用公开 CSV 导出;失败时回退到本地 credentials.json(仅本机可用)。 脚本不包含任何账号、Token 或私钥。 """ import csv import io import json import re import sys import urllib.request from collections import defaultdict from datetime import datetime, timedelta from pathlib import Path from zoneinfo import ZoneInfo SPREADSHEET_ID = "1zb6UR9d9j5Xn3ip6LcWEAsXBjLuPhaaQCXXI7C0gyg0" CLIENT_GID = 0 COST_GID = 1217689915 BUDGET_GID = 450129149 TARGET_SPREADSHEET_ID = "1Bnt94y2ri3ISxF07tbzfVOLKfxaHAo__swNhmSOdccw" TARGET_GID = 0 TARGET_KA = "king" BUDGET_NAME_COL = 1 BUDGET_VALUE_COL = 25 TIMEZONE = ZoneInfo("Asia/Shanghai") SKIP_MARKS = ("总计", "合计", "total") def clean(value): return " ".join(str(value or "").replace("\xa0", " ").split()) def norm(value): return re.sub(r"\s+", "", str(value or "")).lower() def cell(row, index): if index is None or index < 0 or index >= len(row): return "" return row[index] def column_letter(index): if index < 26: return chr(65 + index) return chr(64 + index // 26) + chr(65 + index % 26) def parse_date(value): text = clean(value) for fmt in ("%Y/%m/%d", "%Y-%m-%d", "%Y%m%d", "%m/%d/%Y"): try: return datetime.strptime(text, fmt).date() except ValueError: pass match = re.fullmatch(r"(\d{4})\D{1,3}(\d{1,2})\D{1,3}(\d{1,2})", text) if not match: return None try: return datetime(*map(int, match.groups())).date() except ValueError: return None def parse_number(value): text = clean(value).replace(",", "").replace("$", "").replace("%", "") if not text or text in {"-", "—"} or text.startswith("#"): return None negative = text.startswith("(") and text.endswith(")") cleaned = re.sub(r"[^0-9.\-]", "", text) if cleaned in {"", "-", ".", "-."}: return None try: number = float(cleaned) except ValueError: return None return -number if negative else number def is_subtotal(text): lowered = clean(text).lower() return any(mark in lowered for mark in SKIP_MARKS) def fetch_public(gid, spreadsheet_id=SPREADSHEET_ID): url = ( f"https://docs.google.com/spreadsheets/d/{spreadsheet_id}" f"/export?format=csv&gid={gid}" ) request = urllib.request.Request(url, headers={"User-Agent": "AgentStage-King-Report/1.0"}) with urllib.request.urlopen(request, timeout=120) as response: return list(csv.reader(io.StringIO(response.read().decode("utf-8-sig")))) def fetch_private(gid, spreadsheet_id=SPREADSHEET_ID): import gspread from google.oauth2.service_account import Credentials here = Path(__file__).resolve() candidates = [here.parent / "credentials.json"] + [ parent / "credentials.json" for parent in here.parents ] path = next((item for item in candidates if item.exists()), None) if path is None: raise FileNotFoundError("未找到 credentials.json") creds = Credentials.from_service_account_file( str(path), scopes=["https://www.googleapis.com/auth/spreadsheets.readonly"] ) spreadsheet = gspread.authorize(creds).open_by_key(spreadsheet_id) for worksheet in spreadsheet.worksheets(): if worksheet.id == gid: return worksheet.get_all_values() raise LookupError(f"未找到 gid={gid}") def fetch_rows(gid, label, spreadsheet_id=SPREADSHEET_ID): try: rows = fetch_public(gid, spreadsheet_id) if rows: print(f" {label}: 公开导出成功,{len(rows)} 行") return rows, "public" reason = "空表" except Exception as error: reason = error rows = fetch_private(gid, spreadsheet_id) print(f" {label}: 公开导出不可用({reason}),已使用本地凭据,{len(rows)} 行") return rows, "credentials" def find_header(rows, required): """在前若干行中寻找同时包含指定表头的行,返回列索引映射。""" for index, row in enumerate(rows[:12]): found = {} for column, value in enumerate(row): name = clean(value).lower() for key, aliases in required.items(): if name in aliases and key not in found: found[key] = column if all(key in found for key in required): return index, found return None, {} def read_client_long(rows, start_date, end_date): """Cost_Client 长表:每行一个 Campaign 的某一天数据。""" header_row, columns = find_header( rows, { "date": {"date"}, "campaign": {"campaign name", "campaign"}, "installs": {"installs", "install"}, "cost": {"cost"}, }, ) if header_row is None: raise SystemExit("Cost_Client 未识别到 Date / Campaign Name / Installs / Cost 表头") app_column = next( (i for i, value in enumerate(rows[header_row]) if clean(value).lower() == "app"), None ) print( f" 表头第 {header_row + 1} 行:" f"日期={column_letter(columns['date'])} Campaign={column_letter(columns['campaign'])} " f"Install={column_letter(columns['installs'])} Cost={column_letter(columns['cost'])}" ) installs = defaultdict(float) costs = defaultdict(float) apps = {} display = {} latest = None for row in rows[header_row + 1:]: name = clean(cell(row, columns["campaign"])) if not name or is_subtotal(name): continue day = parse_date(cell(row, columns["date"])) if day is None: continue if latest is None or day > latest: latest = day if not (start_date <= day <= end_date): continue key = norm(name) display.setdefault(key, name) if app_column is not None: app = clean(cell(row, app_column)) if app and not is_subtotal(app): apps.setdefault(key, app) install = parse_number(cell(row, columns["installs"])) cost = parse_number(cell(row, columns["cost"])) if install is not None: installs[(key, day.isoformat())] += install if cost is not None: costs[(key, day.isoformat())] += cost print(f" 数据截止 {latest},命中 {len(display)} 个 Campaign") return installs, costs, apps, display def read_cost_wide(rows, start_date, end_date): """Breakdown_cost【day】宽表:日期横向排列。""" date_row = None date_columns = {} for index, row in enumerate(rows[:12]): found = {} for column, value in enumerate(row): day = parse_date(value) if day is not None: found[column] = day if len(found) >= 3: date_row, date_columns = index, found break if date_row is None: print(" 未识别到日期行,跳过该来源") return {}, {}, None campaign_col = next( (i for i, value in enumerate(rows[date_row]) if "campaign" in clean(value).lower()), None ) if campaign_col is None: print(" 未识别到 Campaign 列,跳过该来源") return {}, {}, None app_col = next( (i for i, value in enumerate(rows[date_row]) if clean(value).lower() == "app"), None ) in_range = {c: d for c, d in date_columns.items() if start_date <= d <= end_date} latest = max(date_columns.values()) print( f" 日期行第 {date_row + 1} 行,Campaign={column_letter(campaign_col)}," f"命中 {len(in_range)} 个日期列,数据截止 {latest}" ) costs = defaultdict(float) apps = {} for row in rows[date_row + 1:]: name = clean(cell(row, campaign_col)) if not name or is_subtotal(name): continue key = norm(name) if app_col is not None: app = clean(cell(row, app_col)) if app and not is_subtotal(app): apps.setdefault(key, app) for column, day in in_range.items(): number = parse_number(cell(row, column)) if number is not None: costs[(key, day.isoformat())] += number return costs, apps, latest def split_name(name): """从 Campaign 名提取品牌前缀与国家代码,用于跨命名规则匹配。""" tokens = [item for item in re.split(r"[_\s]+", clean(name)) if item] brand = tokens[0].lower() if tokens else "" geo = "" for token in tokens[1:3]: if re.fullmatch(r"[A-Za-z]{2}", token): geo = token.lower() break if not geo and re.fullmatch(r"[A-Za-z]{2}", brand): # 只写了国家代码的行,例如 B 列为 "DE" return "", brand return brand, geo def read_budgets(rows): header = clean(cell(rows[0], BUDGET_VALUE_COL)) if rows else "" exact = {} by_brand_geo = defaultdict(list) by_geo = defaultdict(list) for row in rows[1:]: name = clean(cell(row, BUDGET_NAME_COL)) if not name or is_subtotal(name): continue number = parse_number(cell(row, BUDGET_VALUE_COL)) if number is None or number <= 0: continue exact.setdefault(norm(name), number) brand, geo = split_name(name) if brand and geo: by_brand_geo[(brand, geo)].append(number) elif geo: by_geo[geo].append(number) print( f" Z 列表头={header or '(空)'},精确 {len(exact)} 条," f"品牌+国家 {len(by_brand_geo)} 条,仅国家 {len(by_geo)} 条" ) return {"exact": exact, "brand_geo": by_brand_geo, "geo": by_geo}, header def resolve_budget(name, budgets): """三级匹配:精确名 -> 品牌+国家 -> 仅国家(唯一时)。""" value = budgets["exact"].get(norm(name)) if value is not None: return value, "exact" brand, geo = split_name(name) if brand and geo: values = budgets["brand_geo"].get((brand, geo)) if values and len(set(values)) == 1: return values[0], "brand+geo" if geo: values = budgets["geo"].get(geo) if values and len(set(values)) == 1: return values[0], "geo" return None, None def read_targets(rows): """解析 Monthly FFR 表:上一行是分组名(Target/Actual),下一行是 Rev/GP/GP%。""" metric_row = None for index, row in enumerate(rows[:12]): names = [clean(value).lower().replace(" ", "") for value in row] if "rev" in names and "gp" in names: metric_row = index break if metric_row is None: raise LookupError("未找到 Rev / GP 指标行") group_row = rows[metric_row - 1] if metric_row > 0 else [] columns = {} current = "" for column, value in enumerate(rows[metric_row]): label = clean(cell(group_row, column)) if label: current = label.lower() metric = clean(value).lower().replace(" ", "") if metric in {"rev", "gp", "gp%"} and current: columns.setdefault((current, metric), column) target_row = None for row in rows[metric_row + 1:]: if clean(cell(row, 0)).lower() == TARGET_KA: target_row = row break if target_row is None: raise LookupError(f"未找到 KA 为 {TARGET_KA} 的行") def pick(group, metric): for (name, key), column in columns.items(): if key == metric and group in name: return parse_number(cell(target_row, column)) return None revenue = pick("target", "rev") gp = pick("target", "gp") gp_rate = pick("target", "gp%") print(f" Target: revenue={revenue} gp={gp} gp_rate={gp_rate}") return {"revenue": revenue, "gp": gp, "gp_rate": gp_rate} def load_sands_cost(): """读取本地抓取的 Sands Cost(登录站点,服务器无法自动获取)。""" here = Path(__file__).resolve() candidates = [here.parent / "sands_cost.json"] + [ parent / "sands_cost.json" for parent in here.parents ] path = next((item for item in candidates if item.exists()), None) if path is None: print(" Sands Cost:未找到 sands_cost.json,将留空") return None data = json.loads(path.read_text(encoding="utf-8")) king = data.get("king_total") or {} # 优先使用 King 两个包的合计;没有则退回整表合计 if king.get("cost"): cost, scope = king["cost"], f"King 包合计 {king.get('pkgs')}" missing = king.get("missing_pkgs") or [] if missing: print(f" 警告:Sands 结果中缺少包 {missing},Cost 可能不完整") else: cost, scope = data.get("total_cost"), "整表合计" print(f" Sands Cost:{cost}({scope},抓取于 {data.get('fetched_at') or '未知'})") return { "cost": cost, "scope": scope, "pkgs": king.get("pkgs"), "missing_pkgs": king.get("missing_pkgs"), "fetched_at": data.get("fetched_at"), "source_url": data.get("source_url"), "detail_rows": data.get("detail_rows"), } def build_performance(revenue, sands, targets, period_days, month_days): """Revenue 取日报 Cost;Cost 取 Sands;GP = Revenue - Cost。""" cost = sands.get("cost") if sands else None gp = round(revenue - cost, 4) if cost is not None else None gp_rate = round(gp / revenue * 100, 2) if gp is not None and revenue else None def ratio(actual, target): if actual is None or not target: return None return round(actual / target * 100, 2) target_gp_rate = targets.get("gp_rate") if targets else None if target_gp_rate is not None and target_gp_rate <= 1: target_gp_rate = round(target_gp_rate * 100, 2) return { "actual": {"revenue": round(revenue, 4), "cost": cost, "gp": gp, "gp_rate": gp_rate}, "target": { "revenue": targets.get("revenue") if targets else None, "gp": targets.get("gp") if targets else None, "gp_rate": target_gp_rate, }, "completion": { "revenue": ratio(revenue, targets.get("revenue") if targets else None), "gp": ratio(gp, targets.get("gp") if targets else None), "gp_rate": ratio(gp_rate, target_gp_rate), }, "time_progress_percent": round(period_days / month_days * 100, 2) if month_days else None, "month_days": month_days, "elapsed_days": period_days, "sands": sands, } def build_payload(now): end_date = now.date() - timedelta(days=1) start_date = end_date.replace(day=1) days = (end_date - start_date).days + 1 print(f"取数范围:{start_date} 至 {end_date}({days} 天)") client_rows, client_source = fetch_rows(CLIENT_GID, "Cost_Client") installs, client_costs, client_apps, display = read_client_long(client_rows, start_date, end_date) cost_rows, cost_source = fetch_rows(COST_GID, "Breakdown_cost") wide_costs, wide_apps, wide_latest = read_cost_wide(cost_rows, start_date, end_date) budget_rows, budget_source = fetch_rows(BUDGET_GID, "forPrebill") budgets, budget_header = read_budgets(budget_rows) targets = {"revenue": None, "gp": None, "gp_rate": None} target_source = "unavailable" try: target_rows, target_source = fetch_rows(TARGET_GID, "Monthly FFR", TARGET_SPREADSHEET_ID) targets = read_targets(target_rows) except Exception as error: print(f" Target 读取失败({error});请把该表共享给服务账号或开放链接查看") sands = load_sands_cost() # 消耗表里可能出现 Cost_Client 未覆盖的 Campaign,一并纳入 for key, _day in wide_costs: display.setdefault(key, key) dates = [(start_date + timedelta(days=offset)).isoformat() for offset in range(days)] keys = sorted(display, key=lambda item: display[item].lower()) records = [] for key in keys: for day in dates: cost = wide_costs.get((key, day)) if cost is None: cost = client_costs.get((key, day)) install = installs.get((key, day)) if cost is None and install is None: continue records.append({ "date": day, "campaign": display[key], "installs": round(install or 0, 2), "cost": round(cost or 0, 4), }) summary = [] for key in keys: install_total = sum(installs.get((key, day), 0.0) for day in dates) cost_total = sum( wide_costs.get((key, day), client_costs.get((key, day), 0.0)) for day in dates ) if install_total == 0 and cost_total == 0: continue budget, match = resolve_budget(display[key], budgets) summary.append({ "campaign": display[key], "app": client_apps.get(key) or wide_apps.get(key) or "", "installs": round(install_total, 2), "cost": round(cost_total, 4), "budget": budget, "budget_match": match, "ffr_percent": round(cost_total / budget * 100, 2) if budget else None, "cpi": round(cost_total / install_total, 4) if install_total else None, }) approximate = [item["campaign"] for item in summary if item["budget_match"] not in (None, "exact")] missing = [item["campaign"] for item in summary if item["budget"] is None] if approximate: print(f" 预算近似匹配 {len(approximate)} 个:") for item in summary: if item["budget_match"] not in (None, "exact"): print(f" {item['campaign']} <- {item['budget_match']} = {item['budget']}") if missing: print(f" 未匹配到预算 {len(missing)} 个:") for name in missing: print(f" {name}") total_install = round(sum(item["installs"] for item in summary), 2) total_cost = round(sum(item["cost"] for item in summary), 4) month_days = (start_date.replace(day=28) + timedelta(days=4)).replace(day=1) - timedelta(days=1) performance = build_performance(total_cost, sands, targets, days, month_days.day) budget_values = [item["budget"] for item in summary if item["budget"] is not None] total_budget = round(sum(budget_values), 4) if budget_values else None return { "title": "King · 当月 Campaign 表现", "generated_at": now.isoformat(timespec="seconds"), "timezone": "Asia/Shanghai", "period": {"start": start_date.isoformat(), "end": end_date.isoformat(), "days": days}, "sources": { "client": client_source, "cost": cost_source, "budget": budget_source, "budget_column_header": budget_header, "target": target_source, }, "performance": performance, "kpis": { "TOTAL": { "installs": total_install, "cost": total_cost, "budget": total_budget, "ffr_percent": round(total_cost / total_budget * 100, 2) if total_budget else None, "cpi": round(total_cost / total_install, 4) if total_install else None, } }, "campaigns": [item["campaign"] for item in summary], "summary": summary, "records": records, "definitions": { "period": "当月 1 号至昨天", "installs": "Cost_Client 表 Installs 列按天汇总", "cost": "Breakdown_cost【day】按天消耗;缺失时用 Cost_Client 的 Cost 兜底(USD)", "budget": "forPrebill 表 B 列 Campaign name 对应 Z 列当月预算", "budget_match": "exact 为同名匹配;brand+geo 或 geo 为近似匹配(预算表命名与投放表不一致时使用)", "ffr_percent": "累计消耗 ÷ 当月预算 × 100", "cpi": "累计消耗 ÷ 累计安装", "excluded": "带“总计/合计/Total”的小计行已排除", "performance_revenue": "Revenue 取本报表的 Cost 合计(客户口径消耗)", "performance_cost": "Cost 取 Sands Report 2.0 的 Cost 合计,需本地抓取", "performance_gp": "GP = Revenue − Cost;GP rate = GP ÷ Revenue × 100", "performance_target": "Target 来自 Oversea FY2627 Monthly FFR 表 King 行", "performance_completion": "完成率 = 实际 ÷ Target × 100;Target 为整月目标", }, } def main(): if len(sys.argv) < 2: raise SystemExit("用法: update_report_data.py <输出文件>") payload = build_payload(datetime.now(TIMEZONE)) with open(sys.argv[1], "w", encoding="utf-8") as handle: json.dump(payload, handle, ensure_ascii=False, indent=2) kpis = payload["kpis"]["TOTAL"] print( f"OK campaigns={len(payload['summary'])} records={len(payload['records'])} " f"installs={kpis['installs']} cost={kpis['cost']} budget={kpis['budget']}" ) if __name__ == "__main__": main()