Publish King campaign daily report

This commit is contained in:
SHAREit
2026-08-27 15:32:56 +08:00
commit ffdc68521a
5 changed files with 2066 additions and 0 deletions
+1201
View File
File diff suppressed because it is too large Load Diff
+283
View File
@@ -0,0 +1,283 @@
<!doctype html>
<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate"><meta http-equiv="Pragma" content="no-cache"><meta http-equiv="Expires" content="0">
<title>King · Campaign 表现</title>
<style>
:root{--bg:#f4f7fb;--card:#fff;--text:#14213d;--muted:#6b7280;--line:#e5eaf1;--blue:#5577ee;--green:#23a673;--pink:#e4568b;--amber:#f59e0b}
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font-family:Inter,"Microsoft YaHei",system-ui,sans-serif}
.shell{max-width:1420px;margin:auto;padding:26px}
.hero{display:flex;justify-content:space-between;gap:18px;align-items:end;padding:24px 27px;border-radius:20px;color:#fff;background:linear-gradient(125deg,#111827,#26355e)}
h1{margin:0 0 6px;font-size:27px}.hero p,.updated{margin:0;color:#cbd5e1;font-size:12px;line-height:1.7}.updated{text-align:right}
.scope{display:flex;align-items:center;gap:10px;margin-top:16px;font-size:13px;color:var(--muted);flex-wrap:wrap}
select,input{border:1px solid var(--line);background:#fff;padding:8px 11px;border-radius:9px;color:var(--text);font:inherit}
.grid{display:grid;grid-template-columns:repeat(5,1fr);gap:13px;margin:13px 0}
.card,.panel{background:var(--card);border:1px solid var(--line);border-radius:15px;box-shadow:0 8px 28px rgba(24,39,75,.05)}
.card{padding:17px}.label{font-size:12px;color:var(--muted)}.value{font-size:24px;font-weight:800;margin-top:6px}.hint{font-size:11px;color:#94a3b8;margin-top:5px}
.panel{padding:18px;margin-top:14px}.panel-head{display:flex;justify-content:space-between;align-items:center;gap:12px;margin-bottom:10px;flex-wrap:wrap}.panel-title{font-size:17px;font-weight:800}
.chart-wrap{position:relative;overflow:auto}.chart{display:block;width:100%;min-width:780px;height:auto}
.tip{position:absolute;pointer-events:none;opacity:0;transform:translate(-50%,-100%);background:rgba(17,24,39,.94);color:#fff;font-size:12px;line-height:1.6;padding:8px 11px;border-radius:9px;white-space:nowrap;z-index:5;transition:opacity .12s}
.legend{text-align:center;color:var(--muted);font-size:12px;margin-top:7px}.legend span{margin:0 9px;white-space:nowrap}
.dot{display:inline-block;width:9px;height:9px;border-radius:50%;margin-right:5px}
.table-wrap{overflow:auto;max-height:620px;border:1px solid var(--line);border-radius:10px}
table{width:100%;border-collapse:separate;border-spacing:0;font-size:12px}
th,td{padding:9px 11px;text-align:right;white-space:nowrap;border-bottom:1px solid #edf0f5}
th{position:sticky;top:0;background:#f8fafc;color:#64748b;z-index:1;cursor:pointer;user-select:none}
th:first-child,td:first-child,th:nth-child(2),td:nth-child(2){text-align:left}
td.name{max-width:340px;overflow:hidden;text-overflow:ellipsis}
.total-row{background:#f8fafc;font-weight:800}
.bar{position:relative;display:block;height:16px;border-radius:4px;background:#eef1f7;min-width:70px}
.bar>i{position:absolute;inset:0 auto 0 0;border-radius:4px;background:var(--green)}
.bar>b{position:relative;display:block;text-align:right;padding-right:4px;font-size:11px;line-height:16px}
.over>i{background:var(--pink)}
.error{margin:40px auto;max-width:700px;text-align:center;padding:44px;color:#b42318}
.footer{text-align:center;color:#94a3b8;font-size:11px;margin:18px}
@media(max-width:1100px){.grid{grid-template-columns:repeat(3,1fr)}}
@media(max-width:640px){.shell{padding:12px}.hero{display:block}.updated{text-align:left;margin-top:12px}.grid{grid-template-columns:1fr 1fr;gap:9px}.card{padding:13px}.value{font-size:19px}.panel{padding:13px}}
</style></head><body><main class="shell">
<header class="hero"><div><h1>King · Campaign 表现</h1><p id="period">正在加载数据…</p></div><div class="updated" id="updated">数据加载中</div></header>
<div class="scope">
<label>Campaign <select id="scope"></select></label>
<label>App <select id="appFilter"></select></label>
<label>搜索 <input id="search" type="search" placeholder="Campaign 关键词" autocomplete="off"></label>
<span id="scopeHint"></span>
</div>
<div class="grid" id="kpis"></div>
<section class="panel" id="perfPanel" hidden>
<div class="panel-head"><div class="panel-title">Target 完成情况</div><span class="hint" id="perfHint"></span></div>
<div class="table-wrap"><table>
<thead><tr><th>指标</th><th>Target(整月)</th><th>实际(至昨天)</th><th>完成率</th><th>对比时间进度</th></tr></thead>
<tbody id="perfRows"></tbody></table></div>
<div class="legend" id="perfNote"></div>
</section>
<section class="panel">
<div class="panel-head"><div class="panel-title">Install 与 Cost 趋势</div></div>
<div class="chart-wrap"><svg id="mainChart" class="chart" viewBox="0 0 1120 380" role="img" aria-label="Install 与 Cost 趋势图"></svg><div class="tip" id="mainTip"></div></div>
<div class="legend"><span><i class="dot" style="background:var(--blue)"></i>Install(左轴,实线)</span><span><i class="dot" style="background:var(--green)"></i>Cost(右轴 USD,虚线)</span></div>
</section>
<section class="panel">
<div class="panel-head"><div class="panel-title">Campaign 汇总</div><span class="hint" id="tableHint"></span></div>
<div class="table-wrap"><table>
<thead><tr>
<th data-sort="app">App</th><th data-sort="campaign">Campaign</th>
<th data-sort="installs">Install</th><th data-sort="cost">Cost</th><th data-sort="cpi">CPI</th>
<th data-sort="budget">Budget</th><th data-sort="ffr_percent">FFR</th>
</tr></thead>
<tbody id="summaryRows"></tbody></table></div>
</section>
<div class="footer">数据来源:Google Sheets · Breakdown_cost【day】 / Breakdown_install / forPrebill</div></main>
<script>
'use strict';
const $=id=>document.getElementById(id),
esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])),
ok=v=>v!==null&&v!==undefined&&v!==''&&Number.isFinite(Number(v)),
n=v=>ok(v)?Number(v):0,
num=(v,d=0)=>n(v).toLocaleString('en-US',{minimumFractionDigits:d,maximumFractionDigits:d}),
usd=v=>ok(v)?'$'+num(v,2):'—',
pct=v=>ok(v)?num(v,2)+'%':'—';
let report=null,sortKey='cost',sortDesc=true;
function records(){return Array.isArray(report.records)?report.records:[]}
function summary(){return Array.isArray(report.summary)?report.summary:[]}
function dates(){const period=report.period||{},out=[];
if(!period.start||!ok(period.days))return [...new Set(records().map(r=>r.date))].sort();
const start=new Date(period.start+'T00:00:00Z');
for(let i=0;i<Number(period.days);i++){const d=new Date(start.getTime()+i*86400000);out.push(d.toISOString().slice(0,10))}
return out}
/* 表格筛选:App + 关键词 */
function visibleRows(){
const app=$('appFilter').value,keyword=$('search').value.trim().toLowerCase();
return summary().filter(row=>(app==='ALL'||row.app===app)
&&(!keyword||String(row.campaign||'').toLowerCase().includes(keyword)));
}
function scopeRecords(){
const scope=$('scope').value;
if(scope==='TOTAL'){const allowed=new Set(visibleRows().map(r=>r.campaign));
return records().filter(r=>allowed.has(r.campaign))}
return records().filter(r=>r.campaign===scope);
}
function series(){
const rows=scopeRecords();
return dates().map(date=>{const day=rows.filter(r=>r.date===date);
return {date,installs:day.reduce((s,r)=>s+n(r.installs),0),cost:day.reduce((s,r)=>s+n(r.cost),0)}});
}
function kpi(){
const scope=$('scope').value;
if(scope!=='TOTAL'){
const row=summary().find(r=>r.campaign===scope)||{};
return {installs:n(row.installs),cost:n(row.cost),budget:ok(row.budget)?Number(row.budget):null,
ffr:ok(row.ffr_percent)?Number(row.ffr_percent):null,cpi:ok(row.cpi)?Number(row.cpi):null,label:scope};
}
const rows=visibleRows(),stored=(report.kpis&&report.kpis.TOTAL)||{},
filtered=rows.length!==summary().length,
installs=rows.reduce((s,r)=>s+n(r.installs),0),
cost=rows.reduce((s,r)=>s+n(r.cost),0),
budgets=rows.map(r=>r.budget).filter(ok),
budget=budgets.length?budgets.reduce((a,b)=>a+Number(b),0):null;
if(!filtered&&ok(stored.cost))
return {installs:n(stored.installs),cost:n(stored.cost),budget:ok(stored.budget)?Number(stored.budget):budget,
ffr:ok(stored.ffr_percent)?Number(stored.ffr_percent):null,cpi:ok(stored.cpi)?Number(stored.cpi):null,
label:`全部 ${rows.length} 个 Campaign`};
return {installs,cost,budget,ffr:budget?cost/budget*100:null,cpi:installs?cost/installs:null,
label:`${rows.length} 个 Campaign${filtered?'(已筛选)':''}`};
}
function px(i,count,m,iw){return count>1?m.l+i*iw/(count-1):m.l+iw/2}
function drawMain(){
const svg=$('mainChart'),rows=series();
if(!rows.length){svg.innerHTML='<text x="560" y="180" text-anchor="middle" fill="#8190a5" font-size="14">暂无趋势数据</text>';return}
const W=1120,H=380,m={l:70,r:78,t:22,b:50},iw=W-m.l-m.r,ih=H-m.t-m.b,
maxI=Math.max(1,...rows.map(r=>r.installs))*1.12,
maxC=Math.max(1,...rows.map(r=>r.cost))*1.12,
x=i=>px(i,rows.length,m,iw),
yI=v=>m.t+ih-v/maxI*ih,
yC=v=>m.t+ih-v/maxC*ih;
let h='';
for(let i=0;i<=5;i++){const y=m.t+ih*i/5,ratio=1-i/5;
h+=`<line x1="${m.l}" y1="${y}" x2="${W-m.r}" y2="${y}" stroke="#e8edf5"/>`;
h+=`<text x="${m.l-9}" y="${y+4}" text-anchor="end" fill="#8190a5" font-size="11">${num(maxI*ratio)}</text>`;
h+=`<text x="${W-m.r+8}" y="${y+4}" fill="#23a673" font-size="11">${num(maxC*ratio)}</text>`}
rows.forEach((row,i)=>{if(i%2===0||i===rows.length-1)
h+=`<text x="${x(i)}" y="${H-18}" text-anchor="middle" fill="#8190a5" font-size="11">${esc(row.date.slice(5))}</text>`});
h+=`<polyline points="${rows.map((r,i)=>`${x(i)},${yI(r.installs)}`).join(' ')}" fill="none" stroke="#5577ee" stroke-width="3"/>`;
h+=`<polyline points="${rows.map((r,i)=>`${x(i)},${yC(r.cost)}`).join(' ')}" fill="none" stroke="#23a673" stroke-width="2.5" stroke-dasharray="8 5"/>`;
rows.forEach((r,i)=>{
h+=`<circle cx="${x(i)}" cy="${yC(r.cost)}" r="4.4" fill="#fff" stroke="#23a673" stroke-width="2"/>`;
h+=`<circle cx="${x(i)}" cy="${yI(r.installs)}" r="3.2" fill="#5577ee"/>`});
h+=`<line id="cursor" x1="0" y1="${m.t}" x2="0" y2="${m.t+ih}" stroke="#94a3b8" stroke-dasharray="4 4" opacity="0"/>`;
h+=`<rect x="${m.l}" y="${m.t}" width="${iw}" height="${ih}" fill="transparent"/>`;
svg.innerHTML=h;
const tip=$('mainTip'),label=kpi().label;
svg.onmousemove=event=>{
const box=svg.getBoundingClientRect(),vb=svg.viewBox.baseVal,
vx=(event.clientX-box.left)/box.width*vb.width;
let index=0,best=Infinity;
rows.forEach((row,i)=>{const d=Math.abs(x(i)-vx);if(d<best){best=d;index=i}});
const row=rows[index],cx=x(index),cursor=svg.querySelector('#cursor');
if(cursor){cursor.setAttribute('x1',cx);cursor.setAttribute('x2',cx);cursor.setAttribute('opacity','1')}
tip.innerHTML=`${esc(row.date)} · ${esc(label)}<br>Install:${num(row.installs)}<br>Cost:${usd(row.cost)}`;
tip.style.left=(cx/vb.width*box.width)+'px';
tip.style.top=Math.max(38,event.clientY-box.top-14)+'px';
tip.style.opacity='1'};
svg.onmouseleave=()=>{tip.style.opacity='0';const cursor=svg.querySelector('#cursor');if(cursor)cursor.setAttribute('opacity','0')};
}
function renderKpis(){
const k=kpi(),period=report.period||{};
$('scopeHint').textContent=`${k.label} · ${period.start||'—'} 至 ${period.end||'—'}`;
$('kpis').innerHTML=[
['Install',num(k.installs),k.label],
['Cost',usd(k.cost),'USD'],
['CPI',ok(k.cpi)?'$'+num(k.cpi,3):'—','Cost ÷ Install'],
['Budget',usd(k.budget),ok(k.budget)?'当月预算':'预算表无对应值'],
['FFR',pct(k.ffr),ok(k.ffr)?'Cost ÷ Budget':'缺预算无法计算']
].map(item=>`<div class="card"><div class="label">${item[0]}</div><div class="value">${item[1]}</div><div class="hint">${esc(item[2])}</div></div>`).join('');
}
/* 预算表命名与投放表不一致时为近似匹配,用 ~ 标注并在悬停说明 */
function budgetCell(row){
if(!ok(row.budget))return '<span title="预算表中未找到对应 Campaign">—</span>';
const approx=row.budget_match&&row.budget_match!=='exact';
return approx
? `<span title="近似匹配:${esc(row.budget_match)}(预算表命名与投放表不一致)" style="color:#b45309">~${usd(row.budget)}</span>`
: usd(row.budget);
}
function ffrBar(value){
if(!ok(value))return '—';
const width=Math.min(100,Math.max(0,Number(value)));
return `<span class="bar${Number(value)>100?' over':''}"><i style="width:${width}%"></i><b>${num(value,2)}%</b></span>`;
}
function renderTable(){
const rows=visibleRows().slice().sort((a,b)=>{
const x=a[sortKey],y=b[sortKey];
if(typeof x==='string'||typeof y==='string')
return String(x||'').localeCompare(String(y||''))*(sortDesc?-1:1);
const dx=ok(x)?Number(x):-Infinity,dy=ok(y)?Number(y):-Infinity;
return (dx-dy)*(sortDesc?-1:1)});
const totals=rows.reduce((acc,row)=>{acc.installs+=n(row.installs);acc.cost+=n(row.cost);
if(ok(row.budget))acc.budget+=Number(row.budget);return acc},{installs:0,cost:0,budget:0});
$('summaryRows').innerHTML=rows.map(row=>`<tr>
<td>${esc(row.app||'—')}</td><td class="name" title="${esc(row.campaign)}">${esc(row.campaign)}</td>
<td>${num(row.installs)}</td><td>${usd(row.cost)}</td><td>${ok(row.cpi)?'$'+num(row.cpi,3):'—'}</td>
<td>${budgetCell(row)}</td><td>${ffrBar(row.ffr_percent)}</td></tr>`).join('')
+`<tr class="total-row"><td>Total</td><td>${rows.length} 个 Campaign</td>
<td>${num(totals.installs)}</td><td>${usd(totals.cost)}</td>
<td>${totals.installs?'$'+num(totals.cost/totals.installs,3):'—'}</td>
<td>${totals.budget?usd(totals.budget):'—'}</td>
<td>${totals.budget?ffrBar(totals.cost/totals.budget*100):'—'}</td></tr>`;
$('tableHint').textContent=`点击表头排序 · 当前 ${sortKey} ${sortDesc?'降序':'升序'}`;
}
/* Target 完成情况:Revenue 取日报 Cost,Cost 取 Sands,GP = Revenue - Cost */
function renderPerformance(){
const perf=report.performance;
if(!perf){$('perfPanel').hidden=true;return}
$('perfPanel').hidden=false;
const actual=perf.actual||{},target=perf.target||{},done=perf.completion||{},
progress=ok(perf.time_progress_percent)?Number(perf.time_progress_percent):null;
const gap=value=>{
if(!ok(value)||progress===null)return '—';
const diff=Number(value)-progress;
const color=diff>=0?'#23a673':'#e4568b';
return `<span style="color:${color}">${diff>=0?'+':''}${num(diff,2)} pt</span>`;
};
const rows=[
['Revenue',usd(target.revenue),usd(actual.revenue),pct(done.revenue),gap(done.revenue)],
['GP',usd(target.gp),usd(actual.gp),pct(done.gp),gap(done.gp)],
['GP rate',pct(target.gp_rate),pct(actual.gp_rate),pct(done.gp_rate),'—']
];
$('perfRows').innerHTML=rows.map(row=>`<tr><td>${row[0]}</td><td>${row[1]}</td><td>${row[2]}</td><td>${row[3]}</td><td>${row[4]}</td></tr>`).join('')
+`<tr class="total-row"><td>Cost(Sands)</td><td>—</td><td>${usd(actual.cost)}</td><td>—</td><td>—</td></tr>`;
$('perfHint').textContent=progress===null?''
:`时间进度 ${num(progress,2)}%(${num(perf.elapsed_days)}/${num(perf.month_days)} 天)`;
const sands=perf.sands||{};
const notes=[];
notes.push('Revenue = 本报表 Cost 合计(客户口径)');
notes.push('GP = Revenue − Sands Cost');
if(!ok(actual.cost))notes.push('Sands Cost 缺失:需在本地抓取后再发布');
else{
if(sands.fetched_at)notes.push(`Sands Cost 抓取于 ${esc(String(sands.fetched_at).replace('T',' '))},不随每日自动更新`);
if(sands.scope)notes.push(`Cost 范围:${esc(sands.scope)}`);
if(Array.isArray(sands.missing_pkgs)&&sands.missing_pkgs.length)
notes.push(`<b style="color:#b42318">Cost 不完整,缺少包:${esc(sands.missing_pkgs.join(', '))}</b>`);
}
if(!ok(target.revenue))notes.push('Target 未取到:请确认 Monthly FFR 表访问权限');
$('perfNote').innerHTML=notes.map(item=>`<span>${item}</span>`).join('');
}
function render(){
const period=report.period||{},rows=summary();
$('period').textContent=`${period.start||'—'} 至 ${period.end||'—'}${ok(period.days)?` · ${num(period.days)} 天`:''}`;
$('updated').innerHTML=`最近更新:${esc(String(report.generated_at||'—').replace('T',' '))}<br>时区:${esc(report.timezone||'—')}`;
$('scope').innerHTML='<option value="TOTAL">Total(全部 Campaign)</option>'
+rows.map(row=>`<option value="${esc(row.campaign)}">${esc(row.campaign)}</option>`).join('');
const apps=[...new Set(rows.map(row=>row.app).filter(Boolean))].sort();
$('appFilter').innerHTML='<option value="ALL">全部 App</option>'+apps.map(app=>`<option>${esc(app)}</option>`).join('');
renderPerformance();renderKpis();renderTable();drawMain();
}
$('scope').onchange=()=>{renderKpis();drawMain()};
$('appFilter').onchange=()=>{renderKpis();renderTable();drawMain()};
$('search').oninput=()=>{renderKpis();renderTable();drawMain()};
document.querySelectorAll('th[data-sort]').forEach(th=>th.onclick=()=>{
const key=th.dataset.sort;
if(key===sortKey)sortDesc=!sortDesc;else{sortKey=key;sortDesc=true}
renderTable()});
window.addEventListener('resize',()=>{if(report)drawMain()});
fetch('data.json',{cache:'no-store'})
.then(response=>{if(!response.ok)throw new Error(`HTTP ${response.status}`);return response.json()})
.then(data=>{report=data;render()})
.catch(error=>{document.querySelector('.shell').innerHTML=`<section class="panel error"><h2>数据加载失败</h2><p>${esc(error.message)}</p><p>请稍后刷新,页面不会使用旧值冒充最新数据。</p></section>`});
</script></body></html>
+1
View File
@@ -0,0 +1 @@
24
+13
View File
@@ -0,0 +1,13 @@
#!/bin/sh
set -eu
# 平台按 schedule.conf 每 24 小时执行一次。
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
TMP_FILE="$SCRIPT_DIR/data.json.tmp"
OUTPUT_FILE="$SCRIPT_DIR/data.json"
rm -f "$TMP_FILE"
python3 "$SCRIPT_DIR/update_report_data.py" "$TMP_FILE"
mv "$TMP_FILE" "$OUTPUT_FILE"
echo "Updated $OUTPUT_FILE"
+568
View File
@@ -0,0 +1,568 @@
"""生成 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()