671 lines
26 KiB
Python
671 lines
26 KiB
Python
"""生成 top10-ffr 报表数据(多 KA,按 tab 展示)。
|
||
|
||
取数范围:当月 1 号至昨天。
|
||
口径:
|
||
- Revenue 来自各 KA 日报表的 Daily Spend / Spend / Cost(客户口径消耗)
|
||
- Cost 来自 Sands Report 2.0,按包名汇总(需本地抓取 sands_cost.json)
|
||
- GP = Revenue − Cost;GP rate = GP ÷ Revenue × 100
|
||
- Target 来自 Oversea FY2627 Monthly FFR 表对应 KA 行
|
||
|
||
读取优先公开 CSV 导出,失败回退本地 credentials.json。脚本不含任何凭据。
|
||
"""
|
||
|
||
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
|
||
|
||
BASE = Path(__file__).resolve().parent
|
||
CONFIG_FILE = BASE / "accounts.json"
|
||
TIMEZONE = ZoneInfo("Asia/Shanghai")
|
||
SKIP_MARKS = ("总计", "合计", "total")
|
||
SPEND_NAMES = {"cost"}
|
||
SPEND_BLOCKLIST = ("cap", "budget", "rate", "expend")
|
||
INSTALL_BLOCKLIST = ("cap", "rate", "overall", "superset", "rejected", "reject")
|
||
|
||
|
||
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 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_install_header(text):
|
||
"""Install 列有 install / installs / Installations / Install(new device) 等写法。"""
|
||
name = clean(text).lower()
|
||
if not name.startswith("install"):
|
||
return False
|
||
return not any(word in name for word in INSTALL_BLOCKLIST)
|
||
|
||
|
||
def is_spend_header(text):
|
||
"""Revenue 列写法:spend / Spend / Daily Spend / Spends USD / Cost。"""
|
||
name = clean(text).lower()
|
||
if name in SPEND_NAMES:
|
||
return True
|
||
if not (name.startswith("spend") or name.startswith("daily spend")):
|
||
return False
|
||
return not any(word in name for word in SPEND_BLOCKLIST)
|
||
|
||
|
||
def fetch_public(spreadsheet_id, gid):
|
||
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-Top10/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(spreadsheet_id, gid):
|
||
import gspread
|
||
from google.oauth2.service_account import Credentials
|
||
|
||
candidates = [BASE / "credentials.json"] + [p / "credentials.json" for p in BASE.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(spreadsheet_id, gid):
|
||
try:
|
||
rows = fetch_public(spreadsheet_id, gid)
|
||
if rows:
|
||
return rows, "public"
|
||
except Exception:
|
||
pass
|
||
return fetch_private(spreadsheet_id, gid), "credentials"
|
||
|
||
|
||
def find_blocks(rows):
|
||
"""定位表头行与其中所有横向区块(同一行可能有多套 Date/Install/Spend)。"""
|
||
for index, row in enumerate(rows[:40]):
|
||
date_columns = [i for i, v in enumerate(row) if clean(v).lower() == "date"]
|
||
if not date_columns:
|
||
continue
|
||
blocks = []
|
||
for position, start in enumerate(date_columns):
|
||
end = date_columns[position + 1] if position + 1 < len(date_columns) else len(row)
|
||
block = {"date": start}
|
||
for column in range(start, end):
|
||
name = cell(row, column)
|
||
if "install" not in block and is_install_header(name):
|
||
block["install"] = [column]
|
||
elif "spend" not in block and is_spend_header(name):
|
||
block["spend"] = [column]
|
||
if "install" in block or "spend" in block:
|
||
blocks.append(block)
|
||
if blocks:
|
||
return index, blocks
|
||
return None, []
|
||
|
||
|
||
def single_index(value):
|
||
if value is None:
|
||
return None
|
||
if isinstance(value, int):
|
||
return value
|
||
text = clean(value).upper()
|
||
if not text:
|
||
return None
|
||
if text.isdigit():
|
||
return int(text)
|
||
index = 0
|
||
for char in text:
|
||
if not ("A" <= char <= "Z"):
|
||
return None
|
||
index = index * 26 + (ord(char) - 64)
|
||
return index - 1
|
||
|
||
|
||
def column_index(value):
|
||
"""支持单列或多列求和:'D' 或 ['D','Q','AD','AT']。"""
|
||
if value is None:
|
||
return None
|
||
if isinstance(value, (list, tuple)):
|
||
items = [single_index(item) for item in value]
|
||
items = [item for item in items if item is not None]
|
||
return items or None
|
||
index = single_index(value)
|
||
return None if index is None else [index]
|
||
|
||
|
||
def read_campaign_sheet(rows, start_date, end_date, label, overrides=None):
|
||
"""按表头自动识别;overrides 可显式指定表头行与列,避免取到本币列。"""
|
||
overrides = overrides or {}
|
||
forced_row = overrides.get("header_row")
|
||
if forced_row:
|
||
header_row = int(forced_row) - 1
|
||
if not 0 <= header_row < len(rows):
|
||
print(f" {label}: 指定的表头行 {forced_row} 超出范围,跳过")
|
||
return {}
|
||
block = {}
|
||
header = rows[header_row]
|
||
date_index = column_index(overrides.get("date_col"))
|
||
if date_index:
|
||
block["date"] = date_index[0]
|
||
for key, field in (("install", "install_col"), ("spend", "revenue_col")):
|
||
index = column_index(overrides.get(field))
|
||
if index:
|
||
block[key] = index
|
||
if "date" not in block:
|
||
found = next((i for i, v in enumerate(header) if clean(v).lower() == "date"), None)
|
||
if found is None:
|
||
print(f" {label}: 第 {forced_row} 行未找到 Date 列,跳过")
|
||
return {}
|
||
block["date"] = found
|
||
if "install" not in block:
|
||
found = next((i for i, v in enumerate(header) if is_install_header(v)), None)
|
||
if found is not None:
|
||
block["install"] = [found]
|
||
if "spend" not in block:
|
||
found = next((i for i, v in enumerate(header) if is_spend_header(v)), None)
|
||
if found is not None:
|
||
block["spend"] = [found]
|
||
# 防护:Revenue 列不能是 FFR、比率或本币列
|
||
for index in list(block.get("spend") or []):
|
||
name = clean(cell(header, index)).lower()
|
||
if any(word in name for word in ("ffr", "%", "rate", "r$")):
|
||
print(f" {label}: 拒绝把 {letter(index)}({name}) 当作 Revenue,已剔除")
|
||
block["spend"].remove(index)
|
||
if not block.get("spend"):
|
||
block.pop("spend", None)
|
||
|
||
def describe(key):
|
||
indexes = block.get(key)
|
||
if not indexes:
|
||
return f" {key}=缺失"
|
||
parts = "+".join(f"{letter(i)}({clean(cell(header, i))})" for i in indexes)
|
||
return f" {key}={parts}"
|
||
|
||
blocks = [block]
|
||
described = f"date={letter(block['date'])}" + describe("install") + describe("spend")
|
||
print(f" {label}: 指定表头第 {forced_row} 行,{described}")
|
||
return accumulate(rows, header_row, blocks, start_date, end_date, label)
|
||
|
||
header_row, blocks = find_blocks(rows)
|
||
if header_row is None:
|
||
print(f" {label}: 未识别到表头,跳过")
|
||
return {}
|
||
described = "; ".join(
|
||
f"date={letter(b['date'])}"
|
||
+ (f" install={letter(b['install'][0])}" if b.get("install") else "")
|
||
+ (f" spend={letter(b['spend'][0])}" if b.get("spend") else "")
|
||
for b in blocks
|
||
)
|
||
print(f" {label}: 表头第 {header_row + 1} 行,{len(blocks)} 个区块 [{described}]")
|
||
return accumulate(rows, header_row, blocks, start_date, end_date, label)
|
||
|
||
|
||
def accumulate(rows, header_row, blocks, start_date, end_date, label=""):
|
||
daily = defaultdict(lambda: {"installs": 0.0, "revenue": 0.0})
|
||
parsed = 0
|
||
in_range = 0
|
||
sample = []
|
||
found_dates = []
|
||
for row in rows[header_row + 1:]:
|
||
for block in blocks:
|
||
raw = clean(cell(row, block["date"]))
|
||
day = parse_date(raw)
|
||
if day is None:
|
||
if raw and len(sample) < 3:
|
||
sample.append(raw)
|
||
continue
|
||
parsed += 1
|
||
found_dates.append(day)
|
||
if not (start_date <= day <= end_date):
|
||
continue
|
||
in_range += 1
|
||
item = daily[day.isoformat()]
|
||
for index in block.get("install") or []:
|
||
value = parse_number(cell(row, index))
|
||
if value is not None:
|
||
item["installs"] += value
|
||
for index in block.get("spend") or []:
|
||
value = parse_number(cell(row, index))
|
||
if value is not None:
|
||
item["revenue"] += value
|
||
if label and in_range == 0:
|
||
header = rows[header_row] if header_row < len(rows) else []
|
||
column = blocks[0]["date"] if blocks else None
|
||
title = clean(cell(header, column)) if column is not None else ""
|
||
print(
|
||
f" 诊断 {label}: 日期列 {letter(column)}({title or '无表头'}),"
|
||
f"可解析 {parsed} 行,落在区间 0 行"
|
||
)
|
||
if found_dates:
|
||
print(f" 解析到的日期范围 {min(found_dates)} ~ {max(found_dates)}")
|
||
if sample:
|
||
print(f" 无法解析的样例 {sample}")
|
||
return daily
|
||
|
||
|
||
def read_king(sources, sheets, start_date, end_date):
|
||
"""King 为长表:Date / Campaign Name / Installs / Cost 每行一条。"""
|
||
rows, source = fetch_rows(sheets[sources["sheet"]], sources["client_gid"])
|
||
header_row = None
|
||
columns = {}
|
||
for index, row in enumerate(rows[:12]):
|
||
found = {}
|
||
for column, value in enumerate(row):
|
||
name = clean(value).lower()
|
||
if name == "date":
|
||
found.setdefault("date", column)
|
||
elif name in {"campaign name", "campaign"}:
|
||
found.setdefault("campaign", column)
|
||
elif is_install_header(value):
|
||
found.setdefault("install", column)
|
||
elif name == "cost":
|
||
found.setdefault("spend", column)
|
||
if len(found) == 4:
|
||
header_row, columns = index, found
|
||
break
|
||
if header_row is None:
|
||
raise SystemExit("King 表未识别到 Date / Campaign Name / Install / Cost")
|
||
|
||
print(f" King: 表头第 {header_row + 1} 行,来源={source}")
|
||
per_campaign = defaultdict(lambda: defaultdict(lambda: {"installs": 0.0, "revenue": 0.0}))
|
||
for row in rows[header_row + 1:]:
|
||
name = clean(cell(row, columns["campaign"]))
|
||
if not name or any(mark in name.lower() for mark in SKIP_MARKS):
|
||
continue
|
||
day = parse_date(cell(row, columns["date"]))
|
||
if day is None or not (start_date <= day <= end_date):
|
||
continue
|
||
item = per_campaign[name][day.isoformat()]
|
||
install = parse_number(cell(row, columns["install"]))
|
||
revenue = parse_number(cell(row, columns["spend"]))
|
||
if install is not None:
|
||
item["installs"] += install
|
||
if revenue is not None:
|
||
item["revenue"] += revenue
|
||
return per_campaign
|
||
|
||
|
||
def read_existing_report(sources, start_date, end_date):
|
||
"""Amazon:直接读取已有报表的 data.json。"""
|
||
url = sources["report_url"]
|
||
request = urllib.request.Request(url, headers={"User-Agent": "AgentStage-Top10/1.0"})
|
||
with urllib.request.urlopen(request, timeout=90) as response:
|
||
data = json.loads(response.read().decode("utf-8"))
|
||
revenue_field = sources.get("revenue_field", "payable_spend")
|
||
install_field = sources.get("install_field", "install")
|
||
daily = defaultdict(lambda: {"installs": 0.0, "revenue": 0.0})
|
||
cutoff = None
|
||
for row in data.get("daily", []):
|
||
day = parse_date(row.get("date"))
|
||
if day is None:
|
||
continue
|
||
if row.get("is_actual") is False:
|
||
continue
|
||
if cutoff is None or day > cutoff:
|
||
cutoff = day
|
||
if not (start_date <= day <= end_date):
|
||
continue
|
||
item = daily[day.isoformat()]
|
||
item["installs"] += float(row.get(install_field) or 0)
|
||
item["revenue"] += float(row.get(revenue_field) or 0)
|
||
print(f" Amazon: 使用 {revenue_field} 作为 Revenue,实际数据截止 {cutoff}")
|
||
return daily, cutoff
|
||
|
||
|
||
def read_targets(rows):
|
||
"""Monthly FFR:上一行是分组名(Target/Actual),下一行是 Rev/GP/GP%。"""
|
||
metric_row = None
|
||
for index, row in enumerate(rows[:12]):
|
||
names = [norm(v) for v 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 = norm(value)
|
||
if metric in {"rev", "gp", "gp%"} and current:
|
||
columns.setdefault((current, metric), column)
|
||
|
||
targets = {}
|
||
for row in rows[metric_row + 1:]:
|
||
name = clean(cell(row, 0)).lower()
|
||
if not name:
|
||
continue
|
||
|
||
def pick(metric):
|
||
for (group, key), column in columns.items():
|
||
if key == metric and "target" in group:
|
||
return parse_number(cell(row, column))
|
||
return None
|
||
|
||
targets[name] = {"revenue": pick("rev"), "gp": pick("gp"), "gp_rate": pick("gp%")}
|
||
return targets
|
||
|
||
|
||
def load_sands():
|
||
candidates = [BASE / "sands_cost.json"] + [p / "sands_cost.json" for p in BASE.parents]
|
||
path = next((item for item in candidates if item.exists()), None)
|
||
if path is None:
|
||
previous = BASE / "data.json"
|
||
if previous.exists():
|
||
try:
|
||
sands = json.loads(previous.read_text(encoding="utf-8")).get("sands")
|
||
except Exception:
|
||
sands = None
|
||
if sands:
|
||
sands = dict(sands)
|
||
sands["carried_over"] = True
|
||
print(f"Sands:沿用上一版(抓取于 {sands.get('fetched_at')})")
|
||
return sands
|
||
print("Sands:未找到 sands_cost.json,Cost 与 GP 将留空")
|
||
return None
|
||
data = json.loads(path.read_text(encoding="utf-8"))
|
||
by_pkg = {name: entry.get("cost") for name, entry in (data.get("by_pkg") or {}).items()}
|
||
print(f"Sands:{len(by_pkg)} 个包,抓取于 {data.get('fetched_at')}")
|
||
return {
|
||
"fetched_at": data.get("fetched_at"),
|
||
"source_url": data.get("source_url"),
|
||
"by_pkg": by_pkg,
|
||
"total_cost": data.get("total_cost"),
|
||
}
|
||
|
||
|
||
def account_cost(account, sands):
|
||
if not sands or not sands.get("by_pkg"):
|
||
return None, []
|
||
by_pkg = sands["by_pkg"]
|
||
total = 0.0
|
||
missing = []
|
||
found = False
|
||
for pkg in account.get("pkgs", []):
|
||
value = by_pkg.get(pkg)
|
||
if value is None:
|
||
missing.append(pkg)
|
||
else:
|
||
total += float(value)
|
||
found = True
|
||
return (round(total, 4) if found else None), missing
|
||
|
||
|
||
def build_account(account, sheets, start_date, end_date, sands, targets):
|
||
key, name, layout = account["key"], account["name"], account.get("layout")
|
||
print(f"\n[{name}] layout={layout}")
|
||
campaigns = {}
|
||
cutoff = None
|
||
|
||
if layout == "king":
|
||
per_campaign = read_king(account["sources"], sheets, start_date, end_date)
|
||
campaigns = {campaign: dict(days) for campaign, days in per_campaign.items()}
|
||
elif layout == "placeholder":
|
||
print(" 占位账号,无数据")
|
||
else:
|
||
# 同一个 KA 可以同时有已有报表和源表两种来源
|
||
sources = account.get("sources") or {}
|
||
if sources.get("report_url"):
|
||
daily, cutoff = read_existing_report(sources, start_date, end_date)
|
||
if daily:
|
||
campaigns[sources.get("campaign_name", name)] = dict(daily)
|
||
for campaign in account.get("campaigns", []):
|
||
try:
|
||
rows, source = fetch_rows(sheets[campaign["sheet"]], campaign["gid"])
|
||
except Exception as error:
|
||
print(f" {campaign['name']}: 读取失败 {type(error).__name__}: {error or '(无消息)'}")
|
||
continue
|
||
daily = read_campaign_sheet(
|
||
rows, start_date, end_date, f"{campaign['name']}({source})", campaign
|
||
)
|
||
if daily:
|
||
campaigns[campaign["name"]] = dict(daily)
|
||
|
||
# CPM 单的曝光不能与安装相加,单独统计
|
||
impression_campaigns = {
|
||
campaign.get("name"): campaign.get("metric_label")
|
||
for campaign in account.get("campaigns", [])
|
||
if str(campaign.get("metric_label", "")).lower().startswith("impression")
|
||
}
|
||
|
||
records = []
|
||
campaign_summary = []
|
||
for campaign, days in campaigns.items():
|
||
is_impression = campaign in impression_campaigns
|
||
volume = round(sum(item["installs"] for item in days.values()), 2)
|
||
revenue = round(sum(item["revenue"] for item in days.values()), 4)
|
||
campaign_summary.append({
|
||
"campaign": campaign,
|
||
"metric": "impression" if is_impression else "install",
|
||
"installs": 0 if is_impression else volume,
|
||
"impressions": volume if is_impression else 0,
|
||
"revenue": revenue,
|
||
"ecpi": None if is_impression or not volume else round(revenue / volume, 4),
|
||
"ecpm": round(revenue / volume * 1000, 4) if is_impression and volume else None,
|
||
})
|
||
for date, item in sorted(days.items()):
|
||
value = round(item["installs"], 2)
|
||
records.append({
|
||
"date": date,
|
||
"campaign": campaign,
|
||
"metric": "impression" if is_impression else "install",
|
||
"installs": 0 if is_impression else value,
|
||
"impressions": value if is_impression else 0,
|
||
"revenue": round(item["revenue"], 4),
|
||
})
|
||
|
||
campaign_summary.sort(key=lambda item: -item["revenue"])
|
||
revenue = round(sum(item["revenue"] for item in campaign_summary), 4)
|
||
installs = round(sum(item["installs"] for item in campaign_summary), 2)
|
||
impressions = round(sum(item["impressions"] for item in campaign_summary), 2)
|
||
cost, missing_pkgs = account_cost(account, sands)
|
||
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
|
||
|
||
target = dict(targets.get(account.get("target_ka", "").lower()) or {})
|
||
if target.get("gp_rate") is not None and target["gp_rate"] <= 1:
|
||
target["gp_rate"] = round(target["gp_rate"] * 100, 2)
|
||
|
||
def ratio(actual, goal):
|
||
if actual is None or not goal:
|
||
return None
|
||
return round(actual / goal * 100, 2)
|
||
|
||
print(
|
||
f" Revenue={revenue} Install={installs} Impression={impressions} "
|
||
f"Cost={cost} GP={gp} Target Rev={target.get('revenue')}"
|
||
)
|
||
if missing_pkgs:
|
||
print(f" Sands 缺少包:{missing_pkgs}")
|
||
|
||
return {
|
||
"key": key,
|
||
"name": name,
|
||
"layout": layout,
|
||
"note": account.get("note", ""),
|
||
"pkgs": account.get("pkgs", []),
|
||
"data_cutoff": cutoff.isoformat() if cutoff else None,
|
||
"actual": {
|
||
"installs": installs,
|
||
"impressions": impressions,
|
||
"revenue": revenue,
|
||
"cost": cost,
|
||
"gp": gp,
|
||
"gp_rate": gp_rate,
|
||
},
|
||
"target": {
|
||
"revenue": target.get("revenue"),
|
||
"gp": target.get("gp"),
|
||
"gp_rate": target.get("gp_rate"),
|
||
},
|
||
"completion": {
|
||
"revenue": ratio(revenue, target.get("revenue")),
|
||
"gp": ratio(gp, target.get("gp")),
|
||
},
|
||
"missing_pkgs": missing_pkgs,
|
||
"campaigns": campaign_summary,
|
||
"records": records,
|
||
}
|
||
|
||
|
||
def main():
|
||
if len(sys.argv) < 2:
|
||
raise SystemExit("用法: update_report_data.py <输出文件>")
|
||
config = json.loads(CONFIG_FILE.read_text(encoding="utf-8"))
|
||
sheets = config["sheets"]
|
||
|
||
now = datetime.now(TIMEZONE)
|
||
# 默认当月 1 号至昨天;--month YYYY-MM 可指定月份(如已结束的月份则取整月)
|
||
month_arg = next((item.split("=", 1)[-1] for item in sys.argv[2:] if item.startswith("--month")), None)
|
||
if month_arg:
|
||
year, month = (int(part) for part in month_arg.split("-")[:2])
|
||
start_date = datetime(year, month, 1).date()
|
||
else:
|
||
start_date = (now.date() - timedelta(days=1)).replace(day=1)
|
||
month_end = (start_date.replace(day=28) + timedelta(days=4)).replace(day=1) - timedelta(days=1)
|
||
end_date = min(month_end, now.date() - timedelta(days=1))
|
||
if end_date < start_date:
|
||
raise SystemExit(f"指定月份 {start_date:%Y-%m} 尚无已结束的日期")
|
||
days = (end_date - start_date).days + 1
|
||
month_days = month_end.day
|
||
print(f"取数范围:{start_date} 至 {end_date}({days}/{month_days} 天)")
|
||
|
||
sands = load_sands()
|
||
# 判断 Cost 是否滞后于数据周期:抓取日在周期结束日当天或更早,说明未覆盖完整周期
|
||
if sands and sands.get("fetched_at"):
|
||
try:
|
||
fetched = datetime.fromisoformat(sands["fetched_at"]).date()
|
||
except ValueError:
|
||
fetched = None
|
||
if fetched:
|
||
sands["fetched_date"] = fetched.isoformat()
|
||
lag = (end_date - fetched).days + 1
|
||
sands["lag_days"] = max(0, lag)
|
||
sands["stale"] = lag > 0
|
||
if sands["stale"]:
|
||
print(f"警告:Sands Cost 抓取于 {fetched},可能未覆盖到 {end_date},滞后约 {lag} 天")
|
||
try:
|
||
target_rows, _ = fetch_rows(config["target_spreadsheet_id"], config["target_gid"])
|
||
targets = read_targets(target_rows)
|
||
print(f"Target:命中 {len(targets)} 个 KA")
|
||
except Exception as error:
|
||
targets = {}
|
||
print(f"Target 读取失败({error})")
|
||
|
||
accounts = [
|
||
build_account(account, sheets, start_date, end_date, sands, targets)
|
||
for account in config["accounts"]
|
||
]
|
||
active = [item for item in accounts if item["actual"]["revenue"] or item["actual"]["cost"]]
|
||
|
||
totals = {
|
||
"installs": round(sum(item["actual"]["installs"] for item in active), 2),
|
||
"impressions": round(sum(item["actual"].get("impressions") or 0 for item in active), 2),
|
||
"revenue": round(sum(item["actual"]["revenue"] for item in active), 4),
|
||
"cost": round(sum(item["actual"]["cost"] or 0 for item in active), 4),
|
||
"target_revenue": round(sum(item["target"]["revenue"] or 0 for item in active), 4),
|
||
"target_gp": round(sum(item["target"]["gp"] or 0 for item in active), 4),
|
||
}
|
||
totals["gp"] = round(totals["revenue"] - totals["cost"], 4)
|
||
totals["gp_rate"] = round(totals["gp"] / totals["revenue"] * 100, 2) if totals["revenue"] else None
|
||
totals["completion"] = {
|
||
"revenue": round(totals["revenue"] / totals["target_revenue"] * 100, 2) if totals["target_revenue"] else None,
|
||
"gp": round(totals["gp"] / totals["target_gp"] * 100, 2) if totals["target_gp"] else None,
|
||
}
|
||
|
||
payload = {
|
||
"title": "Top10 KA · 当月 FFR 与 GP",
|
||
"generated_at": now.isoformat(timespec="seconds"),
|
||
"timezone": "Asia/Shanghai",
|
||
"period": {
|
||
"start": start_date.isoformat(),
|
||
"end": end_date.isoformat(),
|
||
"days": days,
|
||
"month_days": month_days,
|
||
"time_progress_percent": round(days / month_days * 100, 2),
|
||
},
|
||
"sands": sands,
|
||
"totals": totals,
|
||
"accounts": accounts,
|
||
"definitions": {
|
||
"revenue": "各 KA 日报表的 Daily Spend / Spend / Cost,客户口径消耗,即我们的 Revenue",
|
||
"cost": "Sands Report 2.0 按包名汇总的 Cost,需本地抓取",
|
||
"gp": "GP = Revenue − Cost;GP rate = GP ÷ Revenue × 100",
|
||
"target": "Oversea FY2627 Monthly FFR 表对应 KA 行的 Target Rev / GP / GP%",
|
||
"completion": "完成率 = 实际 ÷ Target × 100,仅用于 Revenue 与 GP",
|
||
"impressions": "CPM 单(如 CapitecFuneral)的曝光量,单独统计,不并入 Install",
|
||
},
|
||
}
|
||
|
||
with open(sys.argv[1], "w", encoding="utf-8") as handle:
|
||
json.dump(payload, handle, ensure_ascii=False, indent=2)
|
||
print(
|
||
f"\nOK accounts={len(accounts)} active={len(active)} "
|
||
f"revenue={totals['revenue']} cost={totals['cost']} gp={totals['gp']}"
|
||
)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|