Publish Manta active pkg performance dashboard

This commit is contained in:
zouyun
2026-08-28 15:56:58 +08:00
commit 1ae965915a
4 changed files with 191 additions and 0 deletions
+1
View File
File diff suppressed because one or more lines are too long
+36
View File
@@ -0,0 +1,36 @@
<!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};
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>
+1
View File
@@ -0,0 +1 @@
1
Executable
+153
View File
@@ -0,0 +1,153 @@
#!/bin/sh
set -eu
ROOT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
HOUR_CN=$(TZ=Asia/Shanghai date +%H)
# AgentStage invokes this hourly. Only the 12:00 China-time run refreshes data.
if [ "${FORCE_UPDATE:-0}" != "1" ] && [ "$HOUR_CN" != "12" ]; then
exit 0
fi
TMP_DIR=$(mktemp -d)
trap 'rm -rf "$TMP_DIR"' EXIT HUP INT TERM
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'}
if [ -n "${TABLEAU_COOKIE:-}" ]; then
curl --fail --silent --show-error --location \
--cookie "$TABLEAU_COOKIE" "$TABLEAU_URL" --output "$RAW_CSV"
else
curl --fail --silent --show-error --location "$TABLEAU_URL" --output "$RAW_CSV"
fi
fi
python3 - "$RAW_CSV" "$ROOT_DIR/data.json" <<'PY'
import csv
import datetime as dt
import io
import json
import pathlib
import sys
source_path = pathlib.Path(sys.argv[1])
output_path = pathlib.Path(sys.argv[2])
raw = source_path.read_bytes()
head = raw[:500].lower()
if b"<html" in head or b"tableau server" in head:
raise SystemExit("Tableau returned a login page; configure TABLEAU_COOKIE or an authenticated TABLEAU_CSV_URL")
if raw.startswith((b"\xff\xfe", b"\xfe\xff")) or b"\x00" in raw[:200]:
text = raw.decode("utf-16").lstrip("\ufeff")
else:
text = raw.decode("utf-8-sig")
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)
def pick(row, *names):
for name in names:
if name in row:
return row[name]
return ""
def number(value):
value = (value or "").strip().replace(",", "").replace("$", "")
if value.endswith("%"):
value = value[:-1]
try:
return float(value)
except ValueError:
return 0.0
daily_map = {}
for row in reader:
pkg = pick(row, "维度1", "pkg", "Pkg").strip()
if pkg not in active:
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})
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")))
item["spend"] += number(pick(row, "spend", "Spend"))
def metrics(item):
imp = item["impression"]
click = item["click"]
conv = item["conversion"]
spend = item["spend"]
item["spend"] = round(spend, 2)
item["ctr"] = click / imp if imp else None
item["cvr"] = conv / click if click else None
item["ivr"] = conv / imp if imp else None
item["cac"] = spend / conv if conv else None
item["ecpi"] = spend / conv if conv else None
return item
daily = [metrics(v) for _, v in sorted(daily_map.items())]
summary = []
for pkg in active_pkgs:
rows = [r for r in daily if r["pkg"] == pkg]
total = {"pkg": pkg, "impression": sum(r["impression"] for r in rows), "click": sum(r["click"] for r in rows), "conversion": sum(r["conversion"] for r in rows), "spend": sum(r["spend"] for r in rows)}
total["has_data"] = bool(rows)
total["first_date"] = rows[0]["date"] if rows else None
total["last_date"] = rows[-1]["date"] if rows else None
summary.append(metrics(total))
dates = sorted({r["date"] for r in daily})
payload = {
"meta": {
"generated_at": dt.datetime.now(dt.timezone(dt.timedelta(hours=8))).isoformat(timespec="seconds"),
"timezone": "Asia/Shanghai",
"source": "Tableau / DSP Spend / DSP Daily Data",
"source_url": "https://mars.ushareit.org/#/views/DSPSpend/DSPDailyData?:iid=1",
"active_pkg_source": "DingTalk / Manta人群标签外投 / Campaign info",
"date_start": dates[0] if dates else None,
"date_end": dates[-1] if dates else None,
"active_pkg_count": len(active_pkgs),
"pkg_with_data_count": sum(1 for x in summary if x["has_data"]),
"definitions": {
"CTR": "click / impression",
"CVR": "conversion / click",
"CAC": "spend / conversion",
"IVR": "conversion / impression",
"eCPI": "spend / conversion",
},
},
"summary": summary,
"daily": 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