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