Add campaign experiment and control reporting

This commit is contained in:
zouyun
2026-08-28 16:37:10 +08:00
parent 1ae965915a
commit 889e08a18b
4 changed files with 106 additions and 64 deletions
+64
View File
@@ -0,0 +1,64 @@
pkg,adping_id,group,owner,geo,platform,status
com.alibaba.aliexpresshd,11079265,实验组,谭超,BR,Kwai,Active
com.alibaba.aliexpresshd,9751142,对照组,谭超,BR,Kwai,Active
com.edadeal.android,10599157,实验组,万科,RU,MTG,Active
com.edadeal.android,6395488,对照组,万科,RU,MTG,Active
com.edadeal.android,91352117,对照组,万科,RU,MTG,Active
com.fxpro.direct.application,11080098,实验组,詹涵粤,PK,MTG,Active
com.fxpro.direct.application,7202934,对照组,詹涵粤,PK,MTG,Active
com.king.candycrushsaga,11324493,实验组,万科,TH,MTG,Active
com.king.candycrushsaga,5497580,对照组,万科,TH,MTG,Active
com.king.candycrushsaga,8998645,对照组,万科,TH,MTG,Active
com.king.candycrushsaga,91535331,实验组,万科,IN,MTG,Active
com.king.candycrushsaga,91410799,对照组,黄政,IN,MTG,Active
com.krom.android,11079953,实验组,詹涵粤,ID,MTG,Active
com.krom.android,5556054,对照组,詹涵粤,ID,MTG,Active
com.vitastudio.mahjong,91538300,实验组,周梓君,ID,MTG,Active
com.vitastudio.mahjong,91538301,实验组,周梓君,RU,MTG,Active
com.vitastudio.mahjong,91538306,实验组,周梓君,PH,MTG,Active
com.vitastudio.mahjong,91538307,实验组,周梓君,TH,MTG,Active
com.vitastudio.mahjong,91538308,实验组,周梓君,BR,MTG,Active
com.vitastudio.mahjong,91538309,实验组,周梓君,MY,MTG,Active
com.vitastudio.mahjong,91467820,对照组,谭超,ID,MTG,Active
com.vitastudio.mahjong,91496148,对照组,谭超,RU,MTG,Active
com.vitastudio.mahjong,91496145,对照组,谭超,PH,MTG,Active
com.vitastudio.mahjong,91467821,对照组,谭超,TH,MTG,Active
com.vitastudio.mahjong,91496147,对照组,谭超,BR,MTG,Active
com.vitastudio.mahjong,91496146,对照组,谭超,MY,MTG,Active
com.yandex.bank,11078963,实验组,詹涵粤,RU,MTG,Active
com.yandex.bank,2937658,对照组,詹涵粤,RU,MTG,Active
daily.number.match.free.puzzle,91528810,实验组,周梓君,RU,MTG,Active
ru.aliexpress.buyer,6060965,实验组,万科,RU,MTG,Active
ru.aliexpress.buyer,4383473,对照组,万科,RU,MTG,Active
ru.aliexpress.buyer,6061077,对照组,万科,RU,MTG,Active
ru.aliexpress.buyer,6607117,对照组,万科,RU,MTG,Active
ru.kinopoisk,10599681,实验组,万科,RU,MTG,Active
ru.kinopoisk,8291713,对照组,万科,RU,MTG,Active
ru.ozon.app.android,11078827,实验组,詹涵粤,UZ,MTG,Active
ru.ozon.app.android,11078981,实验组,詹涵粤,KZ,MTG,Active
ru.ozon.app.android,11080833,实验组,詹涵粤,KG,MTG,Active
ru.ozon.app.android,11081056,实验组,詹涵粤,AM,MTG,Active
ru.ozon.app.android,5963400,对照组,詹涵粤,UZ,MTG,Active
ru.ozon.app.android,5963354,对照组,詹涵粤,KZ,MTG,Active
ru.ozon.app.android,9686529,对照组,詹涵粤,KG,MTG,Active
ru.ozon.app.android,10080665,对照组,詹涵粤,AM,MTG,Active
tech.jm,10501490,实验组,黄政,RU,MTG,Active
tech.jm,1745925,对照组,黄政,RU,MTG,Active
com.esprit.rpg.cntrctfft,11715627,实验组,,RU,MTG,Active
com.esprit.rpg.cntrctfft,9311355,对照组,,RU,MTG,Active
com.king.candycrushsodasaga,11715655,实验组,,BR,MTG,Active
com.king.candycrushsodasaga,8460883,对照组,,BR,MTG,Active
com.king.candycrushsodasaga,11715811,实验组,,TH,MTG,Active
com.king.candycrushsodasaga,6951600,对照组,,TH,MTG,Active
com.uma.musicvk,11690183,实验组,,RU,MTG,Active
com.uma.musicvk,2783561,对照组,,RU,MTG,Active
com.amazon.mShop.android.shopping,11729820,实验组,黄政,AE,MTG,Active
com.amazon.mShop.android.shopping,11729927,实验组,黄政,ZA,MTG,Active
com.amazon.mShop.android.shopping,11729830,实验组,黄政,BR,BIGO,Active
com.amazon.mShop.android.shopping,7572369,对照组,黄政,AE,MTG,Active
com.amazon.mShop.android.shopping,6425889,对照组,黄政,ZA,MTG,Active
com.amazon.mShop.android.shopping,10813123,对照组,黄政,BR,BIGO,Active
com.esprit.rpg.lttlefirykng,11655596,对照组,,RU,BIGO,Active
com.esprit.rpg.lttlefirykng,11763482,实验组,,RU,BIGO,Active
com.king.candycrushsaga,8057746,实验组,詹涵粤,MY,MTG,Active
com.king.candycrushsaga,5464039,对照组,詹涵粤,MY,MTG,Active
1 pkg adping_id group owner geo platform status
2 com.alibaba.aliexpresshd 11079265 实验组 谭超 BR Kwai Active
3 com.alibaba.aliexpresshd 9751142 对照组 谭超 BR Kwai Active
4 com.edadeal.android 10599157 实验组 万科 RU MTG Active
5 com.edadeal.android 6395488 对照组 万科 RU MTG Active
6 com.edadeal.android 91352117 对照组 万科 RU MTG Active
7 com.fxpro.direct.application 11080098 实验组 詹涵粤 PK MTG Active
8 com.fxpro.direct.application 7202934 对照组 詹涵粤 PK MTG Active
9 com.king.candycrushsaga 11324493 实验组 万科 TH MTG Active
10 com.king.candycrushsaga 5497580 对照组 万科 TH MTG Active
11 com.king.candycrushsaga 8998645 对照组 万科 TH MTG Active
12 com.king.candycrushsaga 91535331 实验组 万科 IN MTG Active
13 com.king.candycrushsaga 91410799 对照组 黄政 IN MTG Active
14 com.krom.android 11079953 实验组 詹涵粤 ID MTG Active
15 com.krom.android 5556054 对照组 詹涵粤 ID MTG Active
16 com.vitastudio.mahjong 91538300 实验组 周梓君 ID MTG Active
17 com.vitastudio.mahjong 91538301 实验组 周梓君 RU MTG Active
18 com.vitastudio.mahjong 91538306 实验组 周梓君 PH MTG Active
19 com.vitastudio.mahjong 91538307 实验组 周梓君 TH MTG Active
20 com.vitastudio.mahjong 91538308 实验组 周梓君 BR MTG Active
21 com.vitastudio.mahjong 91538309 实验组 周梓君 MY MTG Active
22 com.vitastudio.mahjong 91467820 对照组 谭超 ID MTG Active
23 com.vitastudio.mahjong 91496148 对照组 谭超 RU MTG Active
24 com.vitastudio.mahjong 91496145 对照组 谭超 PH MTG Active
25 com.vitastudio.mahjong 91467821 对照组 谭超 TH MTG Active
26 com.vitastudio.mahjong 91496147 对照组 谭超 BR MTG Active
27 com.vitastudio.mahjong 91496146 对照组 谭超 MY MTG Active
28 com.yandex.bank 11078963 实验组 詹涵粤 RU MTG Active
29 com.yandex.bank 2937658 对照组 詹涵粤 RU MTG Active
30 daily.number.match.free.puzzle 91528810 实验组 周梓君 RU MTG Active
31 ru.aliexpress.buyer 6060965 实验组 万科 RU MTG Active
32 ru.aliexpress.buyer 4383473 对照组 万科 RU MTG Active
33 ru.aliexpress.buyer 6061077 对照组 万科 RU MTG Active
34 ru.aliexpress.buyer 6607117 对照组 万科 RU MTG Active
35 ru.kinopoisk 10599681 实验组 万科 RU MTG Active
36 ru.kinopoisk 8291713 对照组 万科 RU MTG Active
37 ru.ozon.app.android 11078827 实验组 詹涵粤 UZ MTG Active
38 ru.ozon.app.android 11078981 实验组 詹涵粤 KZ MTG Active
39 ru.ozon.app.android 11080833 实验组 詹涵粤 KG MTG Active
40 ru.ozon.app.android 11081056 实验组 詹涵粤 AM MTG Active
41 ru.ozon.app.android 5963400 对照组 詹涵粤 UZ MTG Active
42 ru.ozon.app.android 5963354 对照组 詹涵粤 KZ MTG Active
43 ru.ozon.app.android 9686529 对照组 詹涵粤 KG MTG Active
44 ru.ozon.app.android 10080665 对照组 詹涵粤 AM MTG Active
45 tech.jm 10501490 实验组 黄政 RU MTG Active
46 tech.jm 1745925 对照组 黄政 RU MTG Active
47 com.esprit.rpg.cntrctfft 11715627 实验组 RU MTG Active
48 com.esprit.rpg.cntrctfft 9311355 对照组 RU MTG Active
49 com.king.candycrushsodasaga 11715655 实验组 BR MTG Active
50 com.king.candycrushsodasaga 8460883 对照组 BR MTG Active
51 com.king.candycrushsodasaga 11715811 实验组 TH MTG Active
52 com.king.candycrushsodasaga 6951600 对照组 TH MTG Active
53 com.uma.musicvk 11690183 实验组 RU MTG Active
54 com.uma.musicvk 2783561 对照组 RU MTG Active
55 com.amazon.mShop.android.shopping 11729820 实验组 黄政 AE MTG Active
56 com.amazon.mShop.android.shopping 11729927 实验组 黄政 ZA MTG Active
57 com.amazon.mShop.android.shopping 11729830 实验组 黄政 BR BIGO Active
58 com.amazon.mShop.android.shopping 7572369 对照组 黄政 AE MTG Active
59 com.amazon.mShop.android.shopping 6425889 对照组 黄政 ZA MTG Active
60 com.amazon.mShop.android.shopping 10813123 对照组 黄政 BR BIGO Active
61 com.esprit.rpg.lttlefirykng 11655596 对照组 RU BIGO Active
62 com.esprit.rpg.lttlefirykng 11763482 实验组 RU BIGO Active
63 com.king.candycrushsaga 8057746 实验组 詹涵粤 MY MTG Active
64 com.king.candycrushsaga 5464039 对照组 詹涵粤 MY MTG Active
+1 -1
View File
File diff suppressed because one or more lines are too long
+11 -35
View File
@@ -1,36 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Manta 在投人群包表现</title>
<style>
:root{--bg:#f5f7fb;--panel:#fff;--ink:#152238;--muted:#69778c;--line:#e5eaf2;--blue:#356ae6;--cyan:#28b8c7;--green:#1b9e77;--orange:#ee8b2d;--red:#d95757;--shadow:0 10px 30px rgba(32,55,88,.08)}
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC",sans-serif}.wrap{max-width:1500px;margin:auto;padding:30px}.top{display:flex;justify-content:space-between;gap:24px;align-items:flex-start;margin-bottom:22px}.eyebrow{color:var(--blue);font-size:12px;font-weight:800;letter-spacing:.12em;text-transform:uppercase}h1{font-size:30px;line-height:1.15;margin:7px 0 8px}.sub{color:var(--muted)}.fresh{background:#eaf1ff;color:#2858bf;border-radius:999px;padding:9px 13px;white-space:nowrap;font-weight:700}.filters{display:grid;grid-template-columns:1.2fr 1fr 1fr auto;gap:12px;background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:14px;box-shadow:var(--shadow);margin-bottom:18px}label{display:flex;flex-direction:column;gap:5px;color:var(--muted);font-size:12px}select,input,button{height:39px;border:1px solid var(--line);border-radius:9px;background:#fff;color:var(--ink);padding:0 11px;font:inherit}button{align-self:end;cursor:pointer;font-weight:700}button:hover{border-color:#b9c7df}.kpis{display:grid;grid-template-columns:repeat(5,1fr);gap:12px;margin-bottom:18px}.card,.panel{background:var(--panel);border:1px solid var(--line);border-radius:14px;box-shadow:var(--shadow)}.card{padding:16px}.card .label{color:var(--muted);font-size:12px}.card .value{font-size:25px;font-weight:800;margin-top:4px}.card .hint{color:var(--muted);font-size:11px;margin-top:2px}.grid{display:grid;grid-template-columns:1.2fr .8fr;gap:18px;margin-bottom:18px}.panel{padding:18px;min-width:0}.panel h2{font-size:16px;margin:0 0 3px}.panel .desc{color:var(--muted);font-size:12px;margin-bottom:12px}.chart{height:330px;position:relative}.chart svg{width:100%;height:100%;overflow:visible}.chart text{font-size:11px;fill:var(--muted)}.legend{display:flex;gap:15px;flex-wrap:wrap;margin-top:7px;color:var(--muted);font-size:12px}.dot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:5px}.table-panel{padding:0;overflow:hidden}.table-head{display:flex;justify-content:space-between;align-items:center;padding:18px}.table-wrap{overflow:auto;max-height:600px;border-top:1px solid var(--line)}table{border-collapse:collapse;width:100%;min-width:1130px}th,td{padding:11px 12px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap}th{position:sticky;top:0;background:#f9fbfe;color:var(--muted);font-size:11px;text-transform:uppercase;letter-spacing:.04em;cursor:pointer;z-index:1}th:first-child,td:first-child{text-align:left;position:sticky;left:0;background:inherit}tbody tr:nth-child(even){background:#fbfcff}tbody tr:hover{background:#eef4ff}.pkg{font-weight:700}.nodata{color:var(--red);font-size:11px}.method{display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-top:18px;color:var(--muted);font-size:12px}.method strong{color:var(--ink)}.error{padding:30px;text-align:center;color:var(--red)}@media(max-width:950px){.wrap{padding:18px}.top{flex-direction:column}.filters{grid-template-columns:1fr 1fr}.kpis{grid-template-columns:repeat(2,1fr)}.grid{grid-template-columns:1fr}.method{grid-template-columns:1fr}}@media(max-width:560px){.filters{grid-template-columns:1fr}.kpis{grid-template-columns:1fr 1fr}h1{font-size:24px}}
</style>
</head>
<body>
<main class="wrap" id="app"><div class="error">正在加载数据…</div></main>
<script>
const $=s=>document.querySelector(s), fmt={int:n=>n==null?'—':Math.round(n).toLocaleString('en-US'),money:n=>n==null?'—':'$'+n.toLocaleString('en-US',{minimumFractionDigits:2,maximumFractionDigits:2}),pct:n=>n==null?'—':(n*100).toFixed(2)+'%',num:n=>n==null?'—':n.toFixed(2)};
let DATA,sort={key:'spend',dir:-1};
<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Manta 在投人群包表现</title><style>
:root{--bg:#f5f7fb;--panel:#fff;--ink:#152238;--muted:#69778c;--line:#e5eaf2;--blue:#356ae6;--orange:#ee8b2d;--red:#d95757;--shadow:0 10px 30px rgba(32,55,88,.08)}*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC",sans-serif}.wrap{max-width:1560px;margin:auto;padding:30px}.top{display:flex;justify-content:space-between;gap:24px;align-items:flex-start;margin-bottom:22px}.eyebrow{color:var(--blue);font-size:12px;font-weight:800;letter-spacing:.12em;text-transform:uppercase}h1{font-size:30px;line-height:1.15;margin:7px 0 8px}.sub,.desc{color:var(--muted)}.fresh{background:#eaf1ff;color:#2858bf;border-radius:999px;padding:9px 13px;white-space:nowrap;font-weight:700}.filters{display:grid;grid-template-columns:1.5fr repeat(3,1fr) 1.05fr 1.05fr auto;gap:10px;background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:14px;box-shadow:var(--shadow);margin-bottom:18px}label{display:flex;flex-direction:column;gap:5px;color:var(--muted);font-size:12px}select,input,button{height:39px;border:1px solid var(--line);border-radius:9px;background:#fff;color:var(--ink);padding:0 11px;font:inherit}button{cursor:pointer;font-weight:700}.filters button{align-self:end}.period-bar{display:flex;justify-content:space-between;align-items:center;gap:15px;margin:3px 0 12px}.seg{display:inline-flex;background:#edf1f7;padding:3px;border-radius:10px}.seg button{border:0;background:transparent;height:34px;color:var(--muted)}.seg button.on{background:#fff;color:var(--blue);box-shadow:0 3px 10px rgba(30,60,100,.1)}.period-select{min-width:180px}.kpis{display:grid;grid-template-columns:repeat(5,1fr);gap:12px;margin-bottom:18px}.card,.panel{background:var(--panel);border:1px solid var(--line);border-radius:14px;box-shadow:var(--shadow)}.card{padding:16px}.card .label{color:var(--muted);font-size:12px}.card .value{font-size:25px;font-weight:800;margin-top:4px}.card .hint{color:var(--muted);font-size:11px;margin-top:2px}.compare{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:18px}.group-card{padding:17px;border-left:4px solid var(--blue)}.group-card.control{border-left-color:var(--orange)}.group-card h3{margin:0 0 11px}.mini{display:grid;grid-template-columns:repeat(5,1fr);gap:8px}.mini span{background:#f7f9fc;border-radius:8px;padding:8px;color:var(--muted);font-size:11px}.mini b{display:block;color:var(--ink);font-size:14px;margin-top:2px}.panel{padding:18px;min-width:0;margin-bottom:18px}.panel h2{font-size:16px;margin:0 0 3px}.desc{font-size:12px}.table-panel{padding:0;overflow:hidden}.table-head{display:flex;justify-content:space-between;align-items:center;gap:15px;padding:18px}.table-wrap{overflow:auto;max-height:510px;border-top:1px solid var(--line)}table{border-collapse:collapse;width:100%;min-width:1380px}th,td{padding:10px 11px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap}th{position:sticky;top:0;background:#f9fbfe;color:var(--muted);font-size:11px;text-transform:uppercase;cursor:pointer;z-index:2}th:first-child,td:first-child{text-align:left;position:sticky;left:0;background:inherit;z-index:1}tbody tr:nth-child(even){background:#fbfcff}tbody tr:hover{background:#eef4ff}.pkg{font-weight:700}.badge{display:inline-block;border-radius:999px;padding:2px 8px;font-size:11px;font-weight:700}.exp{background:#e8f1ff;color:#2858bf}.ctl{background:#fff0e2;color:#b66113}.daily-tools{display:flex;align-items:end;gap:10px}.method{display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-top:18px;color:var(--muted);font-size:12px}.method strong{color:var(--ink)}.error,.empty{padding:30px;text-align:center;color:var(--red)}.empty{color:var(--muted)}@media(max-width:1100px){.filters{grid-template-columns:repeat(3,1fr)}.kpis{grid-template-columns:repeat(3,1fr)}.compare{grid-template-columns:1fr}.mini{grid-template-columns:repeat(3,1fr)}}@media(max-width:700px){.wrap{padding:18px}.top,.period-bar,.table-head{flex-direction:column;align-items:flex-start}.filters{grid-template-columns:1fr 1fr}.kpis{grid-template-columns:1fr 1fr}.mini{grid-template-columns:1fr 1fr}.method{grid-template-columns:1fr}}
</style></head><body><main class="wrap" id="app"><div class="error">正在加载数据…</div></main><script>
const $=s=>document.querySelector(s),fmt={int:n=>n==null?'—':Math.round(n).toLocaleString('en-US'),money:n=>n==null?'—':'$'+n.toLocaleString('en-US',{minimumFractionDigits:2,maximumFractionDigits:2}),pct:n=>n==null?'—':(n*100).toFixed(2)+'%'};let DATA,grain='week',sort={key:'spend',dir:-1};
fetch('data.json',{cache:'no-store'}).then(r=>{if(!r.ok)throw Error('data.json '+r.status);return r.json()}).then(d=>{DATA=d;boot()}).catch(e=>{$('#app').innerHTML='<div class="error">数据加载失败:'+e.message+'</div>'});
function boot(){const m=DATA.meta,pkgs=DATA.summary.map(x=>x.pkg);$('#app').innerHTML=`
<section class="top"><div><div class="eyebrow">Manta · Audience Package Tracking</div><h1>在投人群包 pkg 表现</h1><div class="sub">覆盖 ${m.date_start||'—'} 至 ${m.date_end||'—'} · Tableau 最近 45 天窗口</div></div><div class="fresh">更新于 ${new Date(m.generated_at).toLocaleString('zh-CN',{timeZone:'Asia/Shanghai'})}</div></section>
<section class="filters"><label>人群包 pkg<select id="pkg"><option value="">全部在投 pkg</option>${pkgs.map(p=>`<option>${esc(p)}</option>`).join('')}</select></label><label>开始日期<input id="start" type="date" value="${m.date_start||''}"></label><label>结束日期<input id="end" type="date" value="${m.date_end||''}"></label><button id="reset">重置筛选</button></section>
<section class="kpis" id="kpis"></section>
<section class="grid"><article class="panel"><h2>量级趋势</h2><div class="desc">Impression、Click 与 Conversion(日)</div><div id="trend" class="chart"></div><div class="legend"><span><i class="dot" style="background:var(--blue)"></i>Impression</span><span><i class="dot" style="background:var(--cyan)"></i>Click</span><span><i class="dot" style="background:var(--orange)"></i>Conversion</span></div></article><article class="panel"><h2>Spend 排名</h2><div class="desc">当前筛选周期内,按 pkg 汇总</div><div id="rank" class="chart"></div></article></section>
<section class="panel table-panel"><div class="table-head"><div><h2>pkg 指标明细</h2><div class="desc" id="count"></div></div><div class="sub">点击表头排序</div></div><div class="table-wrap"><table><thead><tr>${[['pkg','pkg'],['impression','Impression'],['click','Click'],['conversion','Conversion'],['spend','Spend'],['ctr','CTR'],['cvr','CVR'],['cac','CAC'],['ivr','IVR'],['ecpi','eCPI']].map(([k,l])=>`<th data-k="${k}">${l}</th>`).join('')}</tr></thead><tbody id="tbody"></tbody></table></div></section>
<section class="method"><div><strong>指标口径</strong><br>CTR = Click / Impression;CVR = Conversion / Click;CAC = Spend / Conversion;IVR = Conversion / Impression;eCPI = Spend / Conversion。所有比率均用基础量汇总后重算。</div><div><strong>数据与在投口径</strong><br>表现数据来自 Tableau「DSP Spend / DSP Daily Data」;在投 pkg 来自钉钉文档「Manta人群标签外投 / Campaign info」中 status=Active 的清单。没有 Tableau 数据的在投 pkg 仍保留并标注。</div></section>`;
['pkg','start','end'].forEach(id=>$('#'+id).onchange=render);$('#reset').onclick=()=>{$('#pkg').value='';$('#start').value=m.date_start||'';$('#end').value=m.date_end||'';render()};document.querySelectorAll('th').forEach(th=>th.onclick=()=>{const k=th.dataset.k;sort=k===sort.key?{key:k,dir:-sort.dir}:{key:k,dir:k==='pkg'?1:-1};render()});addEventListener('resize',debounce(render,100));render()}
function slice(){let start=$('#start').value,end=$('#end').value,pkg=$('#pkg').value;return DATA.daily.filter(r=>(!pkg||r.pkg===pkg)&&(!start||r.date>=start)&&(!end||r.date<=end))}
function aggregate(rows){const map=new Map(DATA.summary.map(x=>[x.pkg,{pkg:x.pkg,impression:0,click:0,conversion:0,spend:0,has_data:false}]));rows.forEach(r=>{let x=map.get(r.pkg);x.impression+=r.impression;x.click+=r.click;x.conversion+=r.conversion;x.spend+=r.spend;x.has_data=true});return [...map.values()].map(calc).filter(x=>!$('#pkg').value||x.pkg===$('#pkg').value)}
function calc(x){x.ctr=x.impression?x.click/x.impression:null;x.cvr=x.click?x.conversion/x.click:null;x.ivr=x.impression?x.conversion/x.impression:null;x.cac=x.conversion?x.spend/x.conversion:null;x.ecpi=x.cac;return x}
function render(){const rows=slice(),sum=calc(rows.reduce((a,r)=>({impression:a.impression+r.impression,click:a.click+r.click,conversion:a.conversion+r.conversion,spend:a.spend+r.spend}),{impression:0,click:0,conversion:0,spend:0}));$('#kpis').innerHTML=[['Impression',fmt.int(sum.impression),'曝光量'],['Click',fmt.int(sum.click),'点击量 · CTR '+fmt.pct(sum.ctr)],['Conversion',fmt.int(sum.conversion),'转化量 · CVR '+fmt.pct(sum.cvr)],['Spend',fmt.money(sum.spend),'总消耗'],['CAC / eCPI',fmt.money(sum.cac),'IVR '+fmt.pct(sum.ivr)]].map(x=>`<article class="card"><div class="label">${x[0]}</div><div class="value">${x[1]}</div><div class="hint">${x[2]}</div></article>`).join('');const agg=aggregate(rows);drawTrend(rows);drawRank(agg);drawTable(agg)}
function drawTable(rows){rows.sort((a,b)=>{let x=a[sort.key],y=b[sort.key];if(x==null)x=-Infinity;if(y==null)y=-Infinity;return typeof x==='string'?x.localeCompare(y)*sort.dir:(x-y)*sort.dir});$('#count').textContent=`${rows.filter(x=>x.has_data).length} 个 pkg 有数据,${rows.filter(x=>!x.has_data).length} 个暂无数据`;$('#tbody').innerHTML=rows.map(x=>`<tr><td><span class="pkg">${esc(x.pkg)}</span>${x.has_data?'':' <span class="nodata">暂无数据</span>'}</td><td>${fmt.int(x.impression)}</td><td>${fmt.int(x.click)}</td><td>${fmt.int(x.conversion)}</td><td>${fmt.money(x.spend)}</td><td>${fmt.pct(x.ctr)}</td><td>${fmt.pct(x.cvr)}</td><td>${fmt.money(x.cac)}</td><td>${fmt.pct(x.ivr)}</td><td>${fmt.money(x.ecpi)}</td></tr>`).join('')}
function drawTrend(rows){const dates=[...new Set(rows.map(r=>r.date))].sort(),by=dates.map(d=>calc(rows.filter(r=>r.date===d).reduce((a,r)=>({date:d,impression:a.impression+r.impression,click:a.click+r.click,conversion:a.conversion+r.conversion,spend:a.spend+r.spend}),{impression:0,click:0,conversion:0,spend:0})));const el=$('#trend'),w=el.clientWidth||600,h=310,p={l:48,r:48,t:15,b:35};if(!by.length){el.innerHTML='<div class="error">当前筛选无数据</div>';return}const max=Math.max(...by.flatMap(x=>[x.impression,x.click]),1),maxC=Math.max(...by.map(x=>x.conversion),1),xp=i=>p.l+i*(w-p.l-p.r)/Math.max(by.length-1,1),yp=v=>p.t+(1-v/max)*(h-p.t-p.b),yc=v=>p.t+(1-v/maxC)*(h-p.t-p.b),path=(k,y)=>by.map((d,i)=>(i?'L':'M')+xp(i).toFixed(1)+','+y(d[k]).toFixed(1)).join(' ');el.innerHTML=`<svg viewBox="0 0 ${w} ${h}">${[0,.25,.5,.75,1].map(t=>`<line x1="${p.l}" y1="${yp(max*t)}" x2="${w-p.r}" y2="${yp(max*t)}" stroke="#e8edf5"/><text x="${p.l-7}" y="${yp(max*t)+4}" text-anchor="end">${compact(max*t)}</text><text x="${w-p.r+7}" y="${yc(maxC*t)+4}" text-anchor="start" fill="#ee8b2d">${compact(maxC*t)}</text>`).join('')}<text x="${p.l}" y="10">曝光 / 点击</text><text x="${w-p.r}" y="10" text-anchor="end" fill="#ee8b2d">转化</text><path d="${path('impression',yp)}" fill="none" stroke="#356ae6" stroke-width="2.4"/><path d="${path('click',yp)}" fill="none" stroke="#28b8c7" stroke-width="2.4"/><path d="${path('conversion',yc)}" fill="none" stroke="#ee8b2d" stroke-width="2.4"/>${by.filter((_,i)=>i%Math.ceil(by.length/6)===0||i===by.length-1).map(d=>{let idx=by.indexOf(d);return `<text x="${xp(idx)}" y="${h-10}" text-anchor="middle">${d.date.slice(5)}</text>`}).join('')}</svg>`}
function drawRank(rows){const data=rows.filter(x=>x.has_data).sort((a,b)=>b.spend-a.spend).slice(0,10),el=$('#rank'),w=el.clientWidth||500,h=310,p={l:190,r:45,t:10,b:15},max=Math.max(...data.map(x=>x.spend),1),bh=(h-p.t-p.b)/Math.max(data.length,1);el.innerHTML=data.length?`<svg viewBox="0 0 ${w} ${h}">${data.map((d,i)=>{let y=p.t+i*bh+3,bw=(w-p.l-p.r)*d.spend/max;return `<text x="${p.l-7}" y="${y+bh*.55}" text-anchor="end">${esc(short(d.pkg,25))}</text><rect x="${p.l}" y="${y}" width="${bw}" height="${Math.max(bh-7,4)}" rx="4" fill="#356ae6" opacity="${.45+.5*(1-i/data.length)}"/><text x="${p.l+bw+5}" y="${y+bh*.55}">${fmt.money(d.spend)}</text>`}).join('')}</svg>`:'<div class="error">当前筛选无数据</div>'}
function compact(n){return n>=1e9?(n/1e9).toFixed(1)+'B':n>=1e6?(n/1e6).toFixed(1)+'M':n>=1e3?(n/1e3).toFixed(1)+'K':Math.round(n)}function short(s,n){return s.length>n?s.slice(0,n-1)+'…':s}function esc(s){return String(s).replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]))}function debounce(fn,ms){let t;return()=>{clearTimeout(t);t=setTimeout(fn,ms)}}
</script>
</body>
</html>
function boot(){let m=DATA.meta,pkgs=uniq('pkg'),geos=uniq('geo'),plats=uniq('platform');$('#app').innerHTML=`<section class="top"><div><div class="eyebrow">Manta · Experiment Tracking</div><h1>在投 pkg 实验组 / 对照组表现</h1><div class="sub">覆盖 ${m.date_start||'—'} 至 ${m.date_end||'—'} · ${m.active_campaign_count} 个 Active campaign</div></div><div class="fresh">更新于 ${new Date(m.generated_at).toLocaleString('zh-CN',{timeZone:'Asia/Shanghai'})}</div></section><section class="filters"><label>pkg<select id="pkg"><option value="">全部 pkg</option>${pkgs.map(opt).join('')}</select></label><label>组别<select id="group"><option value="">实验组 + 对照组</option><option>实验组</option><option>对照组</option></select></label><label>GEO<select id="geo"><option value="">全部 GEO</option>${geos.map(opt).join('')}</select></label><label>Platform<select id="platform"><option value="">全部 Platform</option>${plats.map(opt).join('')}</select></label><label>开始日期<input id="start" type="date" value="${m.date_start||''}"></label><label>结束日期<input id="end" type="date" value="${m.date_end||''}"></label><button id="reset">重置筛选</button></section><section class="period-bar"><div><h2 style="margin:0">周期指标表现</h2><div class="desc">按 campaign(adping_id)查看</div></div><div style="display:flex;gap:10px;align-items:center"><div class="seg"><button id="week" class="on">Weekly</button><button id="month">Monthly</button></div><select id="period" class="period-select"></select></div></section><section id="kpis" class="kpis"></section><section id="compare" class="compare"></section>${tableSection('Campaign 周期表现','period-desc','period-body',false)}<section class="panel table-panel"><div class="table-head"><div><h2>每天实验组 / 对照组表现</h2><div class="desc" id="daily-desc"></div></div><div class="daily-tools"><label>日期<select id="day"></select></label></div></div><div class="table-wrap"><table><thead>${head(true)}</thead><tbody id="daily-body"></tbody></table></div></section>${tableSection('pkg 汇总','pkg-desc','pkg-body','pkg')}<section class="method"><div><strong>计算口径</strong><br>CTR = Click / Impression × 100%;CVR = Conversion / Click × 100%;IVR = Conversion / Impression × 100%;CAC = Spend / Conversion;eCPI = Spend / Conversion。聚合时先汇总基础量,再重算比率。</div><div><strong>分组与基础信息</strong><br>pkg、adping_id、实验/对照组、GEO、Platform、投放状态来自钉钉「Campaign info」;仅纳入 G 列为 Active 的 campaign。表现数据来自 Tableau「DSP Daily Data」。</div></section>`;['pkg','group','geo','platform','start','end'].forEach(id=>$('#'+id).onchange=filtersChanged);$('#reset').onclick=()=>{['pkg','group','geo','platform'].forEach(id=>$('#'+id).value='');$('#start').value=m.date_start||'';$('#end').value=m.date_end||'';filtersChanged()};$('#week').onclick=()=>setGrain('week');$('#month').onclick=()=>setGrain('month');$('#period').onchange=render;$('#day').onchange=renderDaily;document.querySelectorAll('th[data-k]').forEach(th=>th.onclick=()=>{let k=th.dataset.k;sort=k===sort.key?{key:k,dir:-sort.dir}:{key:k,dir:k==='pkg'?1:-1};render()});filtersChanged()}
function tableSection(title,desc,body,type){return `<section class="panel table-panel"><div class="table-head"><div><h2>${title}</h2><div class="desc" id="${desc}"></div></div><div class="sub">点击表头排序</div></div><div class="table-wrap"><table><thead>${type==='pkg'?pkgHead():head()}</thead><tbody id="${body}"></tbody></table></div></section>`}function uniq(k){return [...new Set(DATA.campaigns.map(x=>x[k]).filter(Boolean))].sort()}function opt(v){return `<option>${esc(v)}</option>`}function head(day=false){let c=[['pkg','pkg'],['adping_id','Campaign'],['group','组别'],['geo','GEO'],['platform','Platform']];if(day)c.unshift(['date','日期']);return `<tr>${c.concat(metrics()).map(([k,l])=>`<th data-k="${k}">${l}</th>`).join('')}</tr>`}function pkgHead(){return `<tr>${[['pkg','pkg'],...metrics()].map(([k,l])=>`<th data-k="${k}">${l}</th>`).join('')}</tr>`}function metrics(){return [['impression','Impression'],['click','Click'],['conversion','Conversion'],['spend','Spend'],['ctr','CTR'],['cvr','CVR'],['ivr','IVR'],['cac','CAC'],['ecpi','eCPI']]}
function filtersChanged(){fillPeriods();fillDays();render()}function setGrain(g){grain=g;$('#week').classList.toggle('on',g==='week');$('#month').classList.toggle('on',g==='month');fillPeriods();render()}function baseRows(){let f={pkg:$('#pkg').value,group:$('#group').value,geo:$('#geo').value,platform:$('#platform').value},s=$('#start').value,e=$('#end').value;return DATA.campaign_daily.filter(r=>(!f.pkg||r.pkg===f.pkg)&&(!f.group||r.group===f.group)&&(!f.geo||r.geo===f.geo)&&(!f.platform||r.platform===f.platform)&&(!s||r.date>=s)&&(!e||r.date<=e))}function pkey(date){if(grain==='month')return date.slice(0,7);let d=new Date(date+'T00:00:00Z'),n=d.getUTCDay()||7;d.setUTCDate(d.getUTCDate()-n+1);return d.toISOString().slice(0,10)}function plabel(k){if(grain==='month')return k;let e=new Date(k+'T00:00:00Z');e.setUTCDate(e.getUTCDate()+6);return k+' — '+e.toISOString().slice(0,10)}function fillPeriods(){let cur=$('#period').value,ks=[...new Set(baseRows().map(r=>pkey(r.date)))].sort().reverse();$('#period').innerHTML=ks.map(k=>`<option value="${k}">${plabel(k)}</option>`).join('');if(ks.includes(cur))$('#period').value=cur}function fillDays(){let cur=$('#day').value,ds=[...new Set(baseRows().map(r=>r.date))].sort().reverse();$('#day').innerHTML=ds.map(opt).join('');if(ds.includes(cur))$('#day').value=cur}
function calc(x){x.ctr=x.impression?x.click/x.impression:null;x.cvr=x.click?x.conversion/x.click:null;x.ivr=x.impression?x.conversion/x.impression:null;x.cac=x.conversion?x.spend/x.conversion:null;x.ecpi=x.cac;return x}function agg(rows,keys){let m=new Map;rows.forEach(r=>{let id=keys.map(k=>r[k]).join('|'),x=m.get(id);if(!x){x={};keys.forEach(k=>x[k]=r[k]);Object.assign(x,{impression:0,click:0,conversion:0,spend:0});m.set(id,x)}metrics().slice(0,4).forEach(([k])=>x[k]+=r[k])});return [...m.values()].map(calc)}function total(rows){return calc(rows.reduce((a,r)=>{metrics().slice(0,4).forEach(([k])=>a[k]+=r[k]);return a},{impression:0,click:0,conversion:0,spend:0}))}
function render(){let rows=baseRows(),pk=$('#period').value,pr=rows.filter(r=>pkey(r.date)===pk),campaign=agg(pr,['pkg','adping_id','group','geo','platform']),sum=total(pr);$('#period-desc').textContent=(pk?plabel(pk):'当前筛选无数据')+' · '+campaign.length+' 个 campaign';$('#kpis').innerHTML=[['Impression',fmt.int(sum.impression),'曝光量'],['Click',fmt.int(sum.click),'CTR '+fmt.pct(sum.ctr)],['Conversion',fmt.int(sum.conversion),'CVR '+fmt.pct(sum.cvr)],['Spend',fmt.money(sum.spend),'总消耗'],['CAC / eCPI',fmt.money(sum.cac),'IVR '+fmt.pct(sum.ivr)]].map(x=>`<article class="card"><div class="label">${x[0]}</div><div class="value">${x[1]}</div><div class="hint">${x[2]}</div></article>`).join('');renderCompare(pr);renderRows($('#period-body'),campaign);renderDaily();renderPkg(rows)}function renderCompare(rows){let map=Object.fromEntries(agg(rows,['group']).map(x=>[x.group,x]));$('#compare').innerHTML=['实验组','对照组'].map((g,i)=>{let x=map[g]||total([]);return `<article class="panel group-card ${i?'control':''}"><h3><span class="badge ${i?'ctl':'exp'}">${g}</span></h3><div class="mini">${[['CTR',fmt.pct(x.ctr)],['CVR',fmt.pct(x.cvr)],['IVR',fmt.pct(x.ivr)],['CAC',fmt.money(x.cac)],['eCPI',fmt.money(x.ecpi)]].map(v=>`<span>${v[0]}<b>${v[1]}</b></span>`).join('')}</div></article>`}).join('')}function renderDaily(){let d=$('#day').value,items=agg(baseRows().filter(r=>r.date===d),['date','pkg','adping_id','group','geo','platform']);$('#daily-desc').textContent=(d||'当前筛选无数据')+' · '+items.length+' 个 campaign';renderRows($('#daily-body'),items)}function renderPkg(rows){let items=agg(rows,['pkg']).sort((a,b)=>b.spend-a.spend);$('#pkg-desc').textContent=items.length+' 个 pkg · 当前日期与维度筛选汇总';$('#pkg-body').innerHTML=items.map(x=>`<tr><td class="pkg">${esc(x.pkg)}</td>${cells(x)}</tr>`).join('')||empty(10)}
function renderRows(el,rows){rows.sort((a,b)=>{let x=a[sort.key],y=b[sort.key];if(x==null)x=-Infinity;if(y==null)y=-Infinity;return typeof x==='string'?x.localeCompare(y)*sort.dir:(x-y)*sort.dir});el.innerHTML=rows.map(x=>`<tr>${x.date?`<td>${x.date}</td>`:''}<td class="pkg">${esc(x.pkg)}</td><td>${esc(x.adping_id)}</td><td><span class="badge ${x.group==='实验组'?'exp':'ctl'}">${x.group}</span></td><td>${esc(x.geo||'—')}</td><td>${esc(x.platform||'—')}</td>${cells(x)}</tr>`).join('')||empty(rows.some(x=>x.date)?15:14)}function cells(x){return `<td>${fmt.int(x.impression)}</td><td>${fmt.int(x.click)}</td><td>${fmt.int(x.conversion)}</td><td>${fmt.money(x.spend)}</td><td>${fmt.pct(x.ctr)}</td><td>${fmt.pct(x.cvr)}</td><td>${fmt.pct(x.ivr)}</td><td>${fmt.money(x.cac)}</td><td>${fmt.money(x.ecpi)}</td>`}function empty(n){return `<tr><td colspan="${n}" class="empty">当前筛选无数据</td></tr>`}function esc(s){return String(s??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]))}
</script></body></html>
+30 -28
View File
@@ -16,7 +16,7 @@ RAW_CSV="$TMP_DIR/tableau.csv"
if [ -n "${SOURCE_CSV:-}" ]; then
cp "$SOURCE_CSV" "$RAW_CSV"
else
TABLEAU_URL=${TABLEAU_CSV_URL:-'https://mars.ushareit.org/views/DSPSpend/DSPDailyData.csv?:showVizHome=no&%E7%BB%B4%E5%BA%A61=pkg'}
TABLEAU_URL=${TABLEAU_CSV_URL:-'https://mars.ushareit.org/views/DSPSpend/DSPDailyData.csv?:showVizHome=no&%E7%BB%B4%E5%BA%A61=pkg&%E7%BB%B4%E5%BA%A62=adping_id'}
if [ -n "${TABLEAU_COOKIE:-}" ]; then
curl --fail --silent --show-error --location \
--cookie "$TABLEAU_COOKIE" "$TABLEAU_URL" --output "$RAW_CSV"
@@ -25,7 +25,7 @@ else
fi
fi
python3 - "$RAW_CSV" "$ROOT_DIR/data.json" <<'PY'
python3 - "$RAW_CSV" "$ROOT_DIR/campaigns.csv" "$ROOT_DIR/data.json" <<'PY'
import csv
import datetime as dt
import io
@@ -34,7 +34,8 @@ import pathlib
import sys
source_path = pathlib.Path(sys.argv[1])
output_path = pathlib.Path(sys.argv[2])
campaign_path = pathlib.Path(sys.argv[2])
output_path = pathlib.Path(sys.argv[3])
raw = source_path.read_bytes()
head = raw[:500].lower()
if b"<html" in head or b"tableau server" in head:
@@ -48,26 +49,10 @@ else:
sample = text[:4096]
delimiter = "\t" if sample.count("\t") > sample.count(",") else ","
reader = csv.DictReader(io.StringIO(text), delimiter=delimiter)
active_pkgs = [
"com.alibaba.aliexpresshd",
"com.edadeal.android",
"com.fxpro.direct.application",
"com.king.candycrushsaga",
"com.krom.android",
"com.vitastudio.mahjong",
"com.yandex.bank",
"daily.number.match.free.puzzle",
"ru.aliexpress.buyer",
"ru.kinopoisk",
"ru.ozon.app.android",
"tech.im",
"com.esprit.rpg.cntrctfft",
"com.uma.musicvk",
"com.amazon.mShop.android.shopping",
"com.esprit.rpg.lttlefirykng",
]
active = set(active_pkgs)
with campaign_path.open(encoding="utf-8-sig", newline="") as f:
campaigns = list(csv.DictReader(f))
campaign_by_key = {(r["pkg"], r["adping_id"]): r for r in campaigns if r["status"] == "Active"}
active_pkgs = sorted({r["pkg"] for r in campaigns if r["status"] == "Active"})
def pick(row, *names):
for name in names:
@@ -84,16 +69,23 @@ def number(value):
except ValueError:
return 0.0
daily_map = {}
campaign_daily_map = {}
for row in reader:
pkg = pick(row, "维度1", "pkg", "Pkg").strip()
if pkg not in active:
adping_id = pick(row, "维度2", "adping_id", "adpingid").strip()
campaign = campaign_by_key.get((pkg, adping_id))
if not campaign:
continue
date = pick(row, "dt1", "dt", "date").strip()
if not date:
continue
key = (date, pkg)
item = daily_map.setdefault(key, {"date": date, "pkg": pkg, "impression": 0, "click": 0, "conversion": 0, "spend": 0.0})
key = (date, pkg, adping_id)
item = campaign_daily_map.setdefault(key, {
"date": date, "pkg": pkg, "adping_id": adping_id,
"group": campaign["group"], "geo": campaign["geo"],
"platform": campaign["platform"], "owner": campaign["owner"],
"impression": 0, "click": 0, "conversion": 0, "spend": 0.0,
})
item["impression"] += round(number(pick(row, "impression", "Impression")))
item["click"] += round(number(pick(row, "click", "Click")))
item["conversion"] += round(number(pick(row, "conversions", "conversion", "Conversion")))
@@ -112,6 +104,13 @@ def metrics(item):
item["ecpi"] = spend / conv if conv else None
return item
campaign_daily = [metrics(v) for _, v in sorted(campaign_daily_map.items())]
daily_map = {}
for row in campaign_daily:
key = (row["date"], row["pkg"])
item = daily_map.setdefault(key, {"date": row["date"], "pkg": row["pkg"], "impression": 0, "click": 0, "conversion": 0, "spend": 0.0})
for field in ("impression", "click", "conversion", "spend"):
item[field] += row[field]
daily = [metrics(v) for _, v in sorted(daily_map.items())]
summary = []
for pkg in active_pkgs:
@@ -133,6 +132,8 @@ payload = {
"date_start": dates[0] if dates else None,
"date_end": dates[-1] if dates else None,
"active_pkg_count": len(active_pkgs),
"active_campaign_count": len(campaigns),
"campaign_with_data_count": len({(r["pkg"], r["adping_id"]) for r in campaign_daily}),
"pkg_with_data_count": sum(1 for x in summary if x["has_data"]),
"definitions": {
"CTR": "click / impression",
@@ -144,10 +145,11 @@ payload = {
},
"summary": summary,
"daily": daily,
"campaigns": campaigns,
"campaign_daily": campaign_daily,
}
tmp = output_path.with_suffix(".json.tmp")
tmp.write_text(json.dumps(payload, ensure_ascii=False, separators=(",", ":")), encoding="utf-8")
tmp.replace(output_path)
PY