Publish top10 KA FFR report
This commit is contained in:
+139
@@ -0,0 +1,139 @@
|
||||
{
|
||||
"_说明": "top10-ffr 报表配置。每个 account 生成一个 tab;campaigns 为该 KA 下的投放单元。",
|
||||
"_口径": "各日报表的 Daily Spend / Spend / Cost 均为客户口径消耗,即我们的 Revenue;Cost 取 Sands,GP = Revenue − Cost。",
|
||||
"target_spreadsheet_id": "1Bnt94y2ri3ISxF07tbzfVOLKfxaHAo__swNhmSOdccw",
|
||||
"target_gid": 0,
|
||||
"sheets": {
|
||||
"shared": "1KaNOwaZ3DqjTXRK8XhF1OGF9qUmOh0NLXOgxVrh14h0",
|
||||
"king": "1zb6UR9d9j5Xn3ip6LcWEAsXBjLuPhaaQCXXI7C0gyg0",
|
||||
"performdm": "1qSke7u_PlzUJc23C6Vg-AUfXEDATGiq4nOc8CBbiv-E",
|
||||
"caliente": "15Ljge41IyTzsYWlsHHwwEYdnV_7lrfgfu_I9t200his",
|
||||
"amazon": "1mfrPyev9L9KQ7U6ai6yxtposZgHNSZSB9HydZ2zmVIM"
|
||||
},
|
||||
"accounts": [
|
||||
{
|
||||
"key": "king",
|
||||
"name": "King",
|
||||
"target_ka": "king",
|
||||
"layout": "king",
|
||||
"pkgs": ["com.king.candycrushsaga", "com.king.candycrushsodasaga"],
|
||||
"sources": {
|
||||
"sheet": "king",
|
||||
"client_gid": 0,
|
||||
"cost_gid": 1217689915
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "amazon",
|
||||
"name": "Amazon",
|
||||
"target_ka": "amazon",
|
||||
"layout": "mixed",
|
||||
"pkgs": ["com.amazon.mShop.android.shopping"],
|
||||
"sources": {
|
||||
"report_url": "https://ops.roasbloom.site/zouyun-amazon-za-daily-report/data.json",
|
||||
"revenue_field": "payable_spend",
|
||||
"install_field": "install",
|
||||
"campaign_name": "Amazon ZA"
|
||||
},
|
||||
"campaigns": [
|
||||
{"name": "Amazon MENA", "sheet": "amazon", "gid": 917373507, "pkg": "com.amazon.mShop.android.shopping",
|
||||
"header_row": 11, "date_col": "B",
|
||||
"install_col": ["D", "Q", "AD", "AT"],
|
||||
"revenue_col": ["I", "V", "AI", "AY"],
|
||||
"_备注": "MENA 多地区并排,Install 与美元 Revenue 均为多列求和"},
|
||||
{"name": "Amazon BR", "sheet": "amazon", "gid": 117168695, "pkg": "com.amazon.mShop.android.shopping",
|
||||
"header_row": 10, "install_col": "D", "revenue_col": "R",
|
||||
"_备注": "M 列为 spend R$(巴西币),R 列为美元,取 R"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "ae",
|
||||
"name": "AE",
|
||||
"target_ka": "ae",
|
||||
"layout": "campaign_sheets",
|
||||
"pkgs": ["ru.aliexpress.buyer"],
|
||||
"campaigns": [
|
||||
{"name": "AliExpress", "sheet": "shared", "gid": 650573903, "pkg": "ru.aliexpress.buyer"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "vk",
|
||||
"name": "VK",
|
||||
"target_ka": "vk",
|
||||
"layout": "campaign_sheets",
|
||||
"pkgs": ["ru.mail.cloud", "com.vk.love", "com.uma.musicvk"],
|
||||
"campaigns": [
|
||||
{"name": "MailCloud", "sheet": "shared", "gid": 1961807506, "pkg": "ru.mail.cloud"},
|
||||
{"name": "VkLove", "sheet": "shared", "gid": 1938899781, "pkg": "com.vk.love"},
|
||||
{"name": "VKmusic", "sheet": "shared", "gid": 1326460173, "pkg": "com.uma.musicvk"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "yandex",
|
||||
"name": "Yandex",
|
||||
"target_ka": "yandex",
|
||||
"layout": "campaign_sheets",
|
||||
"pkgs": ["com.yandex.bank", "com.yandex.lavka", "ru.yandex.weatherplugin"],
|
||||
"campaigns": [
|
||||
{"name": "YandexBank", "sheet": "shared", "gid": 67392848, "pkg": "com.yandex.bank"},
|
||||
{"name": "YandexLavka", "sheet": "shared", "gid": 626239503, "pkg": "com.yandex.lavka"},
|
||||
{"name": "YandexWeather", "sheet": "shared", "gid": 1451085739, "pkg": "ru.yandex.weatherplugin"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "performdm",
|
||||
"name": "PerformDM",
|
||||
"target_ka": "performdm",
|
||||
"layout": "campaign_sheets",
|
||||
"pkgs": ["za.co.nedbank", "capitecbank.co.za"],
|
||||
"campaigns": [
|
||||
{"name": "Nedbank", "sheet": "performdm", "gid": 1621007637, "pkg": "za.co.nedbank",
|
||||
"header_row": 2, "revenue_col": "M", "_备注": "Spends USD 在 M 列"},
|
||||
{"name": "CapitecFuneral", "sheet": "performdm", "gid": 1865192528, "pkg": "capitecbank.co.za",
|
||||
"header_row": 2, "date_col": "A", "install_col": "D", "revenue_col": "J",
|
||||
"metric_label": "Impression",
|
||||
"_备注": "CPM 单:D 列为 Impression(非 Install),J 列为 cost"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "caliente",
|
||||
"name": "Caliente",
|
||||
"target_ka": "caliente",
|
||||
"layout": "campaign_sheets",
|
||||
"pkgs": ["sports.caliente.mx.calientedeportes"],
|
||||
"campaigns": [
|
||||
{"name": "Caliente", "sheet": "caliente", "gid": 68602299, "pkg": "sports.caliente.mx.calientedeportes",
|
||||
"header_row": 14, "date_col": "B", "install_col": "E", "revenue_col": "L",
|
||||
"_备注": "日期为 B 列 Dt;A 列是年月不可用;Revenue 取 L 列 cost"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "joom",
|
||||
"name": "Joom",
|
||||
"target_ka": "joom",
|
||||
"layout": "campaign_sheets",
|
||||
"pkgs": ["tech.jm"],
|
||||
"campaigns": [
|
||||
{"name": "Joom", "sheet": "shared", "gid": 1582829999, "pkg": "tech.jm"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "xbet",
|
||||
"name": "1xbet",
|
||||
"target_ka": "xbet",
|
||||
"layout": "placeholder",
|
||||
"note": "本月几乎无数据,暂不接入",
|
||||
"pkgs": [],
|
||||
"campaigns": []
|
||||
},
|
||||
{
|
||||
"key": "casasbahia",
|
||||
"name": "Casas Bahia",
|
||||
"target_ka": "cacas bahia",
|
||||
"layout": "placeholder",
|
||||
"note": "暂不接入",
|
||||
"pkgs": [],
|
||||
"campaigns": []
|
||||
}
|
||||
]
|
||||
}
|
||||
+273
@@ -0,0 +1,273 @@
|
||||
<!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>Top10 KA · FFR 与 GP</title>
|
||||
<style>
|
||||
:root{--bg:#f4f7fb;--card:#fff;--text:#14213d;--muted:#6b7280;--line:#e5eaf1;--blue:#5577ee;--green:#23a673;--pink:#e4568b;--amber:#b45309}
|
||||
*{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:1440px;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}
|
||||
.tabs{display:flex;gap:8px;flex-wrap:wrap;margin:16px 0 0}
|
||||
.tab{border:1px solid var(--line);background:#fff;color:var(--text);padding:9px 15px;border-radius:10px;font:inherit;cursor:pointer;white-space:nowrap}
|
||||
.tab.active{background:var(--blue);border-color:var(--blue);color:#fff;font-weight:700}
|
||||
.tab.empty{color:#9aa4b2}
|
||||
.grid{display:grid;grid-template-columns:repeat(5,1fr);gap:13px;margin:14px 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:560px;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}
|
||||
th:first-child,td:first-child{text-align:left}
|
||||
tr.link{cursor:pointer}tr.link:hover{background:#f8fafc}
|
||||
.total-row{background:#f8fafc;font-weight:800}
|
||||
.pos{color:var(--green)}.neg{color:var(--pink)}.warn{color:var(--amber)}
|
||||
.bar{position:relative;display:block;height:16px;border-radius:4px;background:#eef1f7;min-width:78px}
|
||||
.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}
|
||||
.bar.over>i{background:var(--blue)}.bar.low>i{background:var(--pink)}
|
||||
.notes{font-size:11px;color:#94a3b8;margin-top:8px;line-height:1.8}
|
||||
.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>Top10 KA · FFR 与 GP</h1><p id="period">正在加载数据…</p></div><div class="updated" id="updated">数据加载中</div></header>
|
||||
<div class="tabs" id="tabs"></div>
|
||||
<div id="view"></div>
|
||||
<div class="footer">Revenue 来自各 KA 日报表客户口径消耗 · Cost 来自 Sands · GP = Revenue − Cost</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,current='overview';
|
||||
|
||||
function progress(){const p=report.period||{};return ok(p.time_progress_percent)?Number(p.time_progress_percent):null}
|
||||
|
||||
/* 完成率与时间进度的差距:正数超前,负数落后 */
|
||||
function gapCell(value){
|
||||
const p=progress();
|
||||
if(!ok(value)||p===null)return '—';
|
||||
const diff=Number(value)-p;
|
||||
return `<span class="${diff>=0?'pos':'neg'}">${diff>=0?'+':''}${num(diff,2)} pt</span>`;
|
||||
}
|
||||
|
||||
function bar(value){
|
||||
if(!ok(value))return '—';
|
||||
const v=Number(value),width=Math.min(100,Math.max(0,v)),p=progress();
|
||||
const cls=v>=100?'over':(p!==null&&v<p?'low':'');
|
||||
return `<span class="bar ${cls}"><i style="width:${width}%"></i><b>${num(v,2)}%</b></span>`;
|
||||
}
|
||||
|
||||
function accounts(){return Array.isArray(report.accounts)?report.accounts:[]}
|
||||
function findAccount(key){return accounts().find(item=>item.key===key)}
|
||||
function hasData(item){return n(item.actual&&item.actual.revenue)!==0||ok(item.actual&&item.actual.cost)}
|
||||
|
||||
function renderTabs(){
|
||||
const items=[{key:'overview',name:'总览'}].concat(accounts().map(item=>({
|
||||
key:item.key,name:item.name,empty:!hasData(item)
|
||||
})));
|
||||
$('tabs').innerHTML=items.map(item=>
|
||||
`<button class="tab${item.key===current?' active':''}${item.empty?' empty':''}" data-key="${item.key}">${esc(item.name)}</button>`
|
||||
).join('');
|
||||
document.querySelectorAll('.tab').forEach(button=>button.onclick=()=>{
|
||||
current=button.dataset.key;renderTabs();renderView();window.scrollTo({top:0,behavior:'smooth'});
|
||||
});
|
||||
}
|
||||
|
||||
function overview(){
|
||||
const totals=report.totals||{},rows=accounts();
|
||||
const cards=[
|
||||
['Revenue',usd(totals.revenue),'全部 KA 合计'],
|
||||
['Cost',usd(totals.cost),'Sands 口径'],
|
||||
['GP',usd(totals.gp),'Revenue − Cost'],
|
||||
['GP rate',pct(totals.gp_rate),'GP ÷ Revenue'],
|
||||
['Target Revenue',usd(totals.target_revenue),`完成 ${pct((totals.completion||{}).revenue)}`]
|
||||
];
|
||||
const body=rows.map(item=>{
|
||||
const a=item.actual||{},t=item.target||{},c=item.completion||{};
|
||||
const missing=Array.isArray(item.missing_pkgs)&&item.missing_pkgs.length;
|
||||
return `<tr class="link" data-key="${item.key}">
|
||||
<td>${esc(item.name)}${item.note?` <span class="hint">(${esc(item.note)})</span>`:''}</td>
|
||||
<td>${usd(a.revenue)}</td>
|
||||
<td>${ok(a.cost)?usd(a.cost):`<span class="warn" title="Sands 缺少包:${esc((item.missing_pkgs||[]).join(', '))}">待补</span>`}</td>
|
||||
<td>${ok(a.gp)?`<span class="${n(a.gp)>=0?'pos':'neg'}">${usd(a.gp)}</span>`:'—'}</td>
|
||||
<td>${pct(a.gp_rate)}</td>
|
||||
<td>${usd(t.revenue)}</td>
|
||||
<td>${bar(c.revenue)}</td>
|
||||
<td>${gapCell(c.revenue)}</td>
|
||||
<td>${usd(t.gp)}</td>
|
||||
<td>${bar(c.gp)}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
return `<div class="grid">${cards.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('')}</div>
|
||||
<section class="panel"><div class="panel-head"><div class="panel-title">各 KA 对比</div>
|
||||
<span class="hint">点击任意行进入该 KA 的明细</span></div>
|
||||
<div class="table-wrap"><table><thead><tr>
|
||||
<th>KA</th><th>Revenue</th><th>Cost</th><th>GP</th><th>GP rate</th>
|
||||
<th>Target Rev</th><th>Rev 完成率</th><th>对比时间进度</th><th>Target GP</th><th>GP 完成率</th>
|
||||
</tr></thead><tbody>${body}
|
||||
<tr class="total-row"><td>合计</td><td>${usd(totals.revenue)}</td><td>${usd(totals.cost)}</td>
|
||||
<td>${usd(totals.gp)}</td><td>${pct(totals.gp_rate)}</td><td>${usd(totals.target_revenue)}</td>
|
||||
<td>${bar((totals.completion||{}).revenue)}</td><td>${gapCell((totals.completion||{}).revenue)}</td>
|
||||
<td>${usd(totals.target_gp)}</td><td>${bar((totals.completion||{}).gp)}</td></tr>
|
||||
</tbody></table></div>
|
||||
<div class="notes">Cost 显示「待补」表示 Sands 抓取结果中没有该 KA 的包,GP 无法计算,不做估算。</div>
|
||||
</section>`;
|
||||
}
|
||||
|
||||
function accountView(item){
|
||||
const a=item.actual||{},t=item.target||{},c=item.completion||{};
|
||||
const showImpression=n(a.impressions)>0;
|
||||
const cards=[
|
||||
['Revenue',usd(a.revenue),'客户口径消耗'],
|
||||
['Cost',ok(a.cost)?usd(a.cost):'待补',ok(a.cost)?'Sands 口径':'Sands 缺少该 KA 的包'],
|
||||
['GP',ok(a.gp)?usd(a.gp):'—','Revenue − Cost'],
|
||||
['GP rate',pct(a.gp_rate),'GP ÷ Revenue'],
|
||||
[showImpression?'Impression':'Install',showImpression?num(a.impressions):num(a.installs),
|
||||
showImpression?'CPM 单,单独统计':'安装量']
|
||||
];
|
||||
if(showImpression&&n(a.installs)>0)cards.push(['Install',num(a.installs),'安装量']);
|
||||
|
||||
const targetRows=[
|
||||
['Revenue',usd(t.revenue),usd(a.revenue),bar(c.revenue),gapCell(c.revenue)],
|
||||
['GP',usd(t.gp),ok(a.gp)?usd(a.gp):'—',bar(c.gp),gapCell(c.gp)],
|
||||
['GP rate',pct(t.gp_rate),pct(a.gp_rate),'—',ratePoints(a.gp_rate,t.gp_rate)]
|
||||
];
|
||||
|
||||
const campaigns=(item.campaigns||[]).map(row=>`<tr>
|
||||
<td>${esc(row.campaign)}</td>
|
||||
<td>${row.metric==='impression'?'—':num(row.installs)}</td>
|
||||
<td>${row.metric==='impression'?num(row.impressions):'—'}</td>
|
||||
<td>${usd(row.revenue)}</td>
|
||||
<td>${ok(row.ecpi)?'$'+num(row.ecpi,3):(ok(row.ecpm)?'$'+num(row.ecpm,2)+' eCPM':'—')}</td>
|
||||
</tr>`).join('');
|
||||
|
||||
return `<div class="grid">${cards.map(card=>
|
||||
`<div class="card"><div class="label">${card[0]}</div><div class="value">${card[1]}</div><div class="hint">${esc(card[2])}</div></div>`
|
||||
).join('')}</div>
|
||||
|
||||
<section class="panel"><div class="panel-head"><div class="panel-title">Target 完成情况</div>
|
||||
<span class="hint">${item.data_cutoff?`数据截止 ${esc(item.data_cutoff)}`:''}</span></div>
|
||||
<div class="table-wrap"><table><thead><tr>
|
||||
<th>指标</th><th>Target(整月)</th><th>实际(至昨天)</th><th>完成率</th><th>对比时间进度</th>
|
||||
</tr></thead><tbody>${targetRows.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('')}</tbody></table></div></section>
|
||||
|
||||
<section class="panel"><div class="panel-head"><div class="panel-title">Revenue 与量级趋势</div></div>
|
||||
<div class="chart-wrap"><svg id="chart" class="chart" viewBox="0 0 1120 360" role="img" aria-label="趋势图"></svg><div class="tip" id="tip"></div></div>
|
||||
<div class="legend"><span><i class="dot" style="background:var(--blue)"></i>${showImpression?'Impression':'Install'}(左轴,实线)</span>
|
||||
<span><i class="dot" style="background:var(--green)"></i>Revenue(右轴 USD,虚线)</span></div></section>
|
||||
|
||||
<section class="panel"><div class="panel-head"><div class="panel-title">Campaign 明细</div></div>
|
||||
<div class="table-wrap"><table><thead><tr>
|
||||
<th>Campaign</th><th>Install</th><th>Impression</th><th>Revenue</th><th>eCPI / eCPM</th>
|
||||
</tr></thead><tbody>${campaigns||'<tr><td colspan="5">暂无数据</td></tr>'}</tbody></table></div>
|
||||
${item.note?`<div class="notes">${esc(item.note)}</div>`:''}
|
||||
</section>`;
|
||||
}
|
||||
|
||||
function ratePoints(actual,target){
|
||||
if(!ok(actual)||!ok(target))return '—';
|
||||
const diff=Number(actual)-Number(target);
|
||||
return `<span class="${diff>=0?'pos':'neg'}">${diff>=0?'+':''}${num(diff,2)} pt</span>`;
|
||||
}
|
||||
|
||||
function series(item){
|
||||
const map={};
|
||||
(item.records||[]).forEach(row=>{
|
||||
const entry=map[row.date]||(map[row.date]={date:row.date,volume:0,revenue:0});
|
||||
entry.volume+=n(row.installs)+n(row.impressions);
|
||||
entry.revenue+=n(row.revenue);
|
||||
});
|
||||
return Object.values(map).sort((a,b)=>a.date.localeCompare(b.date));
|
||||
}
|
||||
|
||||
function px(i,count,m,iw){return count>1?m.l+i*iw/(count-1):m.l+iw/2}
|
||||
|
||||
function drawChart(item){
|
||||
const svg=$('chart');if(!svg)return;
|
||||
const rows=series(item);
|
||||
if(!rows.length){svg.innerHTML='<text x="560" y="170" text-anchor="middle" fill="#8190a5" font-size="14">暂无趋势数据</text>';return}
|
||||
const W=1120,H=360,m={l:72,r:80,t:22,b:48},iw=W-m.l-m.r,ih=H-m.t-m.b,
|
||||
maxV=Math.max(1,...rows.map(r=>r.volume))*1.12,
|
||||
maxR=Math.max(1,...rows.map(r=>r.revenue))*1.12,
|
||||
x=i=>px(i,rows.length,m,iw),
|
||||
yV=v=>m.t+ih-v/maxV*ih,
|
||||
yR=v=>m.t+ih-v/maxR*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(maxV*ratio)}</text>`;
|
||||
h+=`<text x="${W-m.r+8}" y="${y+4}" fill="#23a673" font-size="11">${num(maxR*ratio)}</text>`}
|
||||
rows.forEach((row,i)=>{if(i%2===0||i===rows.length-1)
|
||||
h+=`<text x="${x(i)}" y="${H-16}" text-anchor="middle" fill="#8190a5" font-size="11">${esc(row.date.slice(5))}</text>`});
|
||||
h+=`<polyline points="${rows.map((r,i)=>`${x(i)},${yV(r.volume)}`).join(' ')}" fill="none" stroke="#5577ee" stroke-width="3"/>`;
|
||||
h+=`<polyline points="${rows.map((r,i)=>`${x(i)},${yR(r.revenue)}`).join(' ')}" fill="none" stroke="#23a673" stroke-width="2.5" stroke-dasharray="8 5"/>`;
|
||||
rows.forEach((r,i)=>{
|
||||
h+=`<circle cx="${x(i)}" cy="${yR(r.revenue)}" r="4.2" fill="#fff" stroke="#23a673" stroke-width="2"/>`;
|
||||
h+=`<circle cx="${x(i)}" cy="${yV(r.volume)}" 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=$('tip'),unit=n(item.actual&&item.actual.impressions)>0?'Impression':'Install';
|
||||
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)}<br>${unit}:${num(row.volume)}<br>Revenue:${usd(row.revenue)}`;
|
||||
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 renderView(){
|
||||
if(current==='overview'){
|
||||
$('view').innerHTML=overview();
|
||||
document.querySelectorAll('tr.link').forEach(row=>row.onclick=()=>{
|
||||
current=row.dataset.key;renderTabs();renderView();window.scrollTo({top:0,behavior:'smooth'});
|
||||
});
|
||||
return;
|
||||
}
|
||||
const item=findAccount(current);
|
||||
if(!item){$('view').innerHTML='<section class="panel">未找到该 KA</section>';return}
|
||||
$('view').innerHTML=accountView(item);
|
||||
drawChart(item);
|
||||
}
|
||||
|
||||
function render(){
|
||||
const p=report.period||{},sands=report.sands||{};
|
||||
$('period').textContent=`${p.start||'—'} 至 ${p.end||'—'} · ${num(p.days)}/${num(p.month_days)} 天 · 时间进度 ${pct(p.time_progress_percent)}`;
|
||||
const parts=[`最近更新:${esc(String(report.generated_at||'—').replace('T',' '))}`];
|
||||
if(sands.fetched_at)parts.push(`Sands Cost 抓取于 ${esc(String(sands.fetched_at).replace('T',' '))}`);
|
||||
if(sands.carried_over)parts.push('Cost 沿用上次抓取值');
|
||||
$('updated').innerHTML=parts.join('<br>');
|
||||
renderTabs();renderView();
|
||||
}
|
||||
|
||||
window.addEventListener('resize',()=>{if(report&¤t!=='overview')drawChart(findAccount(current))});
|
||||
|
||||
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,648 @@
|
||||
"""生成 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)
|
||||
end_date = now.date() - timedelta(days=1)
|
||||
start_date = end_date.replace(day=1)
|
||||
days = (end_date - start_date).days + 1
|
||||
month_days = ((start_date.replace(day=28) + timedelta(days=4)).replace(day=1) - timedelta(days=1)).day
|
||||
print(f"取数范围:{start_date} 至 {end_date}({days}/{month_days} 天)")
|
||||
|
||||
sands = load_sands()
|
||||
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()
|
||||
Reference in New Issue
Block a user