Publish top10 KA FFR report

This commit is contained in:
SHAREit
2026-08-31 14:19:00 +08:00
commit eb392ec47e
4 changed files with 6585 additions and 0 deletions
+139
View File
@@ -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": []
}
]
}
+5525
View File
File diff suppressed because it is too large Load Diff
+273
View File
@@ -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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])),
ok=v=>v!==null&&v!==undefined&&v!==''&&Number.isFinite(Number(v)),
n=v=>ok(v)?Number(v):0,
num=(v,d=0)=>n(v).toLocaleString('en-US',{minimumFractionDigits:d,maximumFractionDigits:d}),
usd=v=>ok(v)?'$'+num(v,2):'—',
pct=v=>ok(v)?num(v,2)+'%':'—';
let report=null,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&&current!=='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>
+648
View File
@@ -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()