Files
king-daily-report/update_report_data.py
T

569 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""生成 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()