Publish King campaign daily report
This commit is contained in:
+283
@@ -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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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>
|
||||
@@ -0,0 +1 @@
|
||||
24
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user