init: voypace ad report
This commit is contained in:
@@ -0,0 +1,129 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# 在 ads-center 上每天运行:取前一天3渠道数据 -> 合并 mtg_data.json -> 生成矩阵 -> git push
|
||||||
|
# 缺表渠道(Adsterra/TrafficStars/Eagllwin)由 excel_fetch.py 从运营 Excel 补数(可选,找不到就跳过)
|
||||||
|
# 由 launchd 定时触发。Python: /Users/ads-center/py314env/bin/python
|
||||||
|
import sqlite3, json, subprocess, sys, os
|
||||||
|
from collections import defaultdict
|
||||||
|
from datetime import date, timedelta
|
||||||
|
|
||||||
|
DB = "/Users/ads-center/Documents/database/report_api.db"
|
||||||
|
REPO = "/Users/ads-center/voypace-report-sync/voypace-ad-report"
|
||||||
|
|
||||||
|
def log(m): print(f"[sync] {m}", flush=True)
|
||||||
|
|
||||||
|
# 目标日期:前一天(可用参数覆盖,便于补数)
|
||||||
|
TARGET = sys.argv[1] if len(sys.argv) > 1 else (date.today() - timedelta(days=1)).isoformat()
|
||||||
|
|
||||||
|
os.chdir(REPO)
|
||||||
|
# 先 pull 最新,避免冲突
|
||||||
|
subprocess.run(["git", "pull", "--rebase", "-q"], cwd=REPO)
|
||||||
|
|
||||||
|
camps = json.load(open("campaigns.json"))
|
||||||
|
CAMPS = {r["campid"]: {"product": r["product"], "pkg": r["pkg"]} for r in camps}
|
||||||
|
IDS = list(CAMPS.keys())
|
||||||
|
|
||||||
|
conn = sqlite3.connect(DB); conn.row_factory = sqlite3.Row
|
||||||
|
agg = defaultdict(lambda: {"impression":0,"clicks":0,"conversions":0,"spend":0.0})
|
||||||
|
meta = {}
|
||||||
|
|
||||||
|
def cid_of(t):
|
||||||
|
t = str(t or "")
|
||||||
|
for c in IDS:
|
||||||
|
if c in t: return c
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Mintegral(广告单元名可能落在 Offer_Name 或 Campaign_Id,两字段都匹配)
|
||||||
|
like = " OR ".join([f"Offer_Name LIKE '%{c}%' OR Campaign_Id LIKE '%{c}%'" for c in IDS])
|
||||||
|
for r in conn.execute(f"SELECT Date d,Offer_Name nm,Campaign_Id cmp,Location loc,Impression imp,Click clk,Conversion cv,Spend sp FROM mintegral WHERE Date=? AND ({like})", (TARGET,)):
|
||||||
|
c = cid_of(r["nm"]) or cid_of(r["cmp"])
|
||||||
|
if not c: continue
|
||||||
|
k = (r["d"],"Mintegral",c,(r["loc"] or "").upper()); a=agg[k]
|
||||||
|
a["impression"]+=int(r["imp"] or 0); a["clicks"]+=int(r["clk"] or 0)
|
||||||
|
a["conversions"]+=int(r["cv"] or 0); a["spend"]+=float(r["sp"] or 0)
|
||||||
|
meta[k]=(r["nm"] or r["cmp"],CAMPS[c])
|
||||||
|
|
||||||
|
# Bigo
|
||||||
|
like = " OR ".join([f"campaignName LIKE '%{c}%'" for c in IDS])
|
||||||
|
for r in conn.execute(f"SELECT aggregateTime d,campaignName nm,country loc,impression imp,click clk,real_result_conversion cv,totalCost cost FROM bigo WHERE aggregateTime=? AND ({like})", (TARGET,)):
|
||||||
|
c = cid_of(r["nm"])
|
||||||
|
if not c: continue
|
||||||
|
k = (r["d"],"Bigo",c,(r["loc"] or "").upper()); a=agg[k]
|
||||||
|
a["impression"]+=int(r["imp"] or 0); a["clicks"]+=int(r["clk"] or 0)
|
||||||
|
a["conversions"]+=int(r["cv"] or 0); a["spend"]+=float(r["cost"] or 0)/100.0
|
||||||
|
meta[k]=(r["nm"],CAMPS[c])
|
||||||
|
|
||||||
|
# MediaGo (spend = cpc*click)
|
||||||
|
like = " OR ".join([f"campaign_name LIKE '%{c}%'" for c in IDS])
|
||||||
|
for r in conn.execute(f"SELECT date d,campaign_name nm,adset_country loc,vimp imp,click clk,cv cvn,cpc FROM mediago WHERE date=? AND ({like})", (TARGET,)):
|
||||||
|
c = cid_of(r["nm"])
|
||||||
|
if not c: continue
|
||||||
|
k = (r["d"],"MediaGo",c,(r["loc"] or "").upper()); a=agg[k]
|
||||||
|
clk=int(r["clk"] or 0)
|
||||||
|
a["impression"]+=int(r["imp"] or 0); a["clicks"]+=clk
|
||||||
|
a["conversions"]+=int(r["cvn"] or 0); a["spend"]+=float(r["cpc"] or 0)*clk
|
||||||
|
meta[k]=(r["nm"],CAMPS[c])
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
new_rows=[]
|
||||||
|
for k,a in agg.items():
|
||||||
|
dt,ch,c,loc=k; nm,info=meta[k]
|
||||||
|
imp,clk,cv=a["impression"],a["clicks"],a["conversions"]
|
||||||
|
new_rows.append({"date":dt,"channel":ch,"campid":c,"offer_name":nm,"product":info["product"],"pkg":info["pkg"],
|
||||||
|
"location":loc,"bid_type":"","impression":imp,"clicks":clk,"conversions":cv,
|
||||||
|
"ctr":round(clk/imp,5) if imp else 0.0,"cvr":round(cv/clk,5) if clk else 0.0,"spend":round(a["spend"],2)})
|
||||||
|
log(f"{TARGET} DB三渠道新增匹配 {len(new_rows)} 条")
|
||||||
|
|
||||||
|
# 可选:从运营 Excel 补齐缺表渠道(Adsterra/TrafficStars/Eagllwin 等)
|
||||||
|
excel_rows = []
|
||||||
|
try:
|
||||||
|
ef = os.path.join(REPO, "excel_fetch.py")
|
||||||
|
if os.path.exists(ef):
|
||||||
|
p = subprocess.run([sys.executable, ef], cwd=REPO, capture_output=True, text=True)
|
||||||
|
if p.returncode == 0 and p.stdout.strip():
|
||||||
|
excel_rows = json.loads(p.stdout)
|
||||||
|
# 只保留目标日期,保持“每天增量”语义(补数全量跑时可去掉此过滤)
|
||||||
|
excel_rows = [r for r in excel_rows if r.get("date") == TARGET]
|
||||||
|
else:
|
||||||
|
log(f"Excel 补数无输出/失败:{p.stderr[:120]}")
|
||||||
|
except Exception as e:
|
||||||
|
log(f"Excel 补数跳过:{e}")
|
||||||
|
if excel_rows:
|
||||||
|
log(f"Excel 补数新增 {len(excel_rows)} 条")
|
||||||
|
new_rows += excel_rows
|
||||||
|
|
||||||
|
# 合并(按 date+channel+campid+location 去重)
|
||||||
|
existing = json.load(open("mtg_data.json")) if os.path.exists("mtg_data.json") else []
|
||||||
|
merged = {(r["date"],r.get("channel",""),r["campid"],r["location"]): r for r in existing}
|
||||||
|
for r in new_rows:
|
||||||
|
merged[(r["date"],r["channel"],r["campid"],r["location"])] = r
|
||||||
|
data = sorted(merged.values(), key=lambda x:(x["date"],x.get("channel",""),-x["impression"]))
|
||||||
|
json.dump(data, open("mtg_data.json","w"), ensure_ascii=False, indent=2)
|
||||||
|
log(f"合并后共 {len(data)} 条")
|
||||||
|
|
||||||
|
# 重新生成矩阵状态(近3天单日最高展示>=100 且产品订单为Active -> Active)
|
||||||
|
orders = json.load(open("orders.json"))
|
||||||
|
pkg_status={}
|
||||||
|
for o in orders:
|
||||||
|
for p in str(o["pkg"]).replace(" ","").split("/"):
|
||||||
|
if p: pkg_status[p]=o.get("status","Pause")
|
||||||
|
g=defaultdict(lambda: defaultdict(int))
|
||||||
|
for r in data: g[(r["channel"],r["pkg"])][r["date"]]+=r["impression"]
|
||||||
|
alldates=sorted(set(r["date"] for r in data)); last3=alldates[-3:]
|
||||||
|
ov={}
|
||||||
|
for (ch,pkg),days in g.items():
|
||||||
|
ps=pkg_status.get(pkg,"Pause")
|
||||||
|
if ps!="Active": ov[f"{ch}|{pkg}"]="Pause"; continue
|
||||||
|
total=sum(days.values()); rmax=max([days.get(dt,0) for dt in last3]) if last3 else 0
|
||||||
|
ov[f"{ch}|{pkg}"]= "none" if total==0 else ("Active" if rmax>=100 else "Pause")
|
||||||
|
json.dump(ov, open("matrix_overrides.json","w"), ensure_ascii=False, indent=2)
|
||||||
|
log("矩阵状态已刷新")
|
||||||
|
|
||||||
|
# git push
|
||||||
|
subprocess.run(["git","add","mtg_data.json","matrix_overrides.json"], cwd=REPO)
|
||||||
|
r=subprocess.run(["git","commit","-m",f"data: daily sync {TARGET}"], cwd=REPO, capture_output=True, text=True)
|
||||||
|
if "nothing to commit" in (r.stdout+r.stderr):
|
||||||
|
log("无变化,跳过 push")
|
||||||
|
else:
|
||||||
|
p=subprocess.run(["git","push","-q","origin","main"], cwd=REPO, capture_output=True, text=True)
|
||||||
|
log("已 push" if p.returncode==0 else f"push失败: {p.stderr[:200]}")
|
||||||
|
log("完成")
|
||||||
+162
@@ -0,0 +1,162 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"campid": "91504360",
|
||||||
|
"product": "Bistro",
|
||||||
|
"pkg": "ru.bistrodengi.my",
|
||||||
|
"geo": "RU",
|
||||||
|
"channel": "Mintegral",
|
||||||
|
"mode": "自投"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"campid": "91508373",
|
||||||
|
"product": "Bistro",
|
||||||
|
"pkg": "ru.bistrodengi.my",
|
||||||
|
"geo": "RU",
|
||||||
|
"channel": "Bigo",
|
||||||
|
"mode": "自投"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"campid": "91508374",
|
||||||
|
"product": "Bistro",
|
||||||
|
"pkg": "ru.bistrodengi.my",
|
||||||
|
"geo": "RU",
|
||||||
|
"channel": "MediaGo",
|
||||||
|
"mode": "自投"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"campid": "91508376",
|
||||||
|
"product": "Robotzaimer",
|
||||||
|
"pkg": "robot.zaimer.ru",
|
||||||
|
"geo": "RU",
|
||||||
|
"channel": "Bigo",
|
||||||
|
"mode": "自投"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"campid": "91508375",
|
||||||
|
"product": "Robotzaimer",
|
||||||
|
"pkg": "robot.zaimer.ru",
|
||||||
|
"geo": "RU",
|
||||||
|
"channel": "MediaGo",
|
||||||
|
"mode": "自投"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"campid": "91505669",
|
||||||
|
"product": "Robotzaimer",
|
||||||
|
"pkg": "robot.zaimer.ru",
|
||||||
|
"geo": "RU",
|
||||||
|
"channel": "Mintegral",
|
||||||
|
"mode": "自投"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"campid": "91513412",
|
||||||
|
"product": "Melbet",
|
||||||
|
"pkg": "org.melbet.client",
|
||||||
|
"geo": "RU",
|
||||||
|
"channel": "Bigo",
|
||||||
|
"mode": "自投"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"campid": "91513413",
|
||||||
|
"product": "Melbet",
|
||||||
|
"pkg": "org.melbet.client",
|
||||||
|
"geo": "RU",
|
||||||
|
"channel": "Adsterra",
|
||||||
|
"mode": "自投"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"campid": "91513414",
|
||||||
|
"product": "Melbet",
|
||||||
|
"pkg": "org.melbet.client",
|
||||||
|
"geo": "RU",
|
||||||
|
"channel": "TrafficStars",
|
||||||
|
"mode": "自投"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"campid": "91511819",
|
||||||
|
"product": "Avito",
|
||||||
|
"pkg": "se.scmv.morocco",
|
||||||
|
"geo": "RU",
|
||||||
|
"channel": "Bigo",
|
||||||
|
"mode": "自投"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"campid": "91513417",
|
||||||
|
"product": "Avito",
|
||||||
|
"pkg": "se.scmv.morocco",
|
||||||
|
"geo": "RU",
|
||||||
|
"channel": "Mintegral",
|
||||||
|
"mode": "自投"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"campid": "91513418",
|
||||||
|
"product": "Avito",
|
||||||
|
"pkg": "se.scmv.morocco",
|
||||||
|
"geo": "RU",
|
||||||
|
"channel": "MediaGo",
|
||||||
|
"mode": "自投"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"campid": "91513416",
|
||||||
|
"product": "Megapari",
|
||||||
|
"pkg": "org.megapari.client",
|
||||||
|
"geo": "BR",
|
||||||
|
"channel": "Bigo",
|
||||||
|
"mode": "自投"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"campid": "91513572",
|
||||||
|
"product": "Megapari",
|
||||||
|
"pkg": "org.megapari.client",
|
||||||
|
"geo": "BR",
|
||||||
|
"channel": "Adsterra",
|
||||||
|
"mode": "自投"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"campid": "91545519",
|
||||||
|
"product": "Viu",
|
||||||
|
"pkg": "com.vuclip.viu",
|
||||||
|
"geo": "MY",
|
||||||
|
"channel": "Mintegral",
|
||||||
|
"mode": "自投"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"campid": "91545518",
|
||||||
|
"product": "Vdrf",
|
||||||
|
"pkg": "com.cathayholdings.vdrf",
|
||||||
|
"geo": "VN",
|
||||||
|
"channel": "Mintegral",
|
||||||
|
"mode": "自投"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"campid": "91554851",
|
||||||
|
"product": "Viu",
|
||||||
|
"pkg": "com.vuclip.viu",
|
||||||
|
"geo": "ID",
|
||||||
|
"channel": "MediaGo",
|
||||||
|
"mode": "自投"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"campid": "91554852",
|
||||||
|
"product": "Viu",
|
||||||
|
"pkg": "com.vuclip.viu",
|
||||||
|
"geo": "ID",
|
||||||
|
"channel": "Bigo",
|
||||||
|
"mode": "自投"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"campid": "91554853",
|
||||||
|
"product": "Viu",
|
||||||
|
"pkg": "com.vuclip.viu",
|
||||||
|
"geo": "ID",
|
||||||
|
"channel": "Eagllwin",
|
||||||
|
"mode": "自投"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"campid": "91555309",
|
||||||
|
"product": "Viu",
|
||||||
|
"pkg": "com.vuclip.viu",
|
||||||
|
"geo": "ID",
|
||||||
|
"channel": "Mintegral",
|
||||||
|
"mode": "自投"
|
||||||
|
}
|
||||||
|
]
|
||||||
+92
@@ -0,0 +1,92 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# 在 ads-center 上运行:从 report_api.db 取 mintegral/bigo/mediago 三渠道
|
||||||
|
# 按 campid 匹配 Voypace 产品,聚合(日期+campid+国家),输出 JSON 到 stdout。
|
||||||
|
# 参数:start_date(含) end_date(含),格式 yyyy-MM-dd
|
||||||
|
import sqlite3, json, sys, os
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
DB = "/Users/ads-center/Documents/database/report_api.db"
|
||||||
|
START = sys.argv[1] if len(sys.argv) > 1 else "2026-07-15"
|
||||||
|
END = sys.argv[2] if len(sys.argv) > 2 else "2026-12-31"
|
||||||
|
|
||||||
|
# campid -> 产品信息(从同目录 campaigns.json 读取)
|
||||||
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
_camps = json.load(open(os.path.join(HERE, "campaigns.json")))
|
||||||
|
CAMPS = {r["campid"]: {"product": r["product"], "pkg": r["pkg"]} for r in _camps}
|
||||||
|
|
||||||
|
conn = sqlite3.connect(DB)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
|
||||||
|
# 聚合容器:key=(date,channel,campid,location) -> 累加指标
|
||||||
|
agg = defaultdict(lambda: {"impression":0,"clicks":0,"conversions":0,"spend":0.0})
|
||||||
|
meta = {}
|
||||||
|
|
||||||
|
def campid_of(text):
|
||||||
|
t = str(text or "")
|
||||||
|
for cid in CAMPS:
|
||||||
|
if cid in t:
|
||||||
|
return cid
|
||||||
|
return None
|
||||||
|
|
||||||
|
# ---- Mintegral ----
|
||||||
|
# 广告单元名(如 91555309_Viu_ID)可能落在 Offer_Name 或 Campaign_Id,两字段都匹配
|
||||||
|
like = " OR ".join([f"Offer_Name LIKE '%{cid}%' OR Campaign_Id LIKE '%{cid}%'" for cid in CAMPS])
|
||||||
|
sql = f"""SELECT Date d, Offer_Name nm, Campaign_Id cmp, Location loc, Impression imp, Click clk,
|
||||||
|
Conversion cv, Spend sp
|
||||||
|
FROM mintegral WHERE Date>=? AND Date<=? AND ({like})"""
|
||||||
|
for r in conn.execute(sql, (START, END)):
|
||||||
|
cid = campid_of(r["nm"]) or campid_of(r["cmp"])
|
||||||
|
if not cid: continue
|
||||||
|
k = (r["d"], "Mintegral", cid, (r["loc"] or "").upper())
|
||||||
|
a = agg[k]
|
||||||
|
a["impression"] += int(r["imp"] or 0); a["clicks"] += int(r["clk"] or 0)
|
||||||
|
a["conversions"] += int(r["cv"] or 0); a["spend"] += float(r["sp"] or 0)
|
||||||
|
meta[k] = (r["nm"] or r["cmp"], CAMPS[cid])
|
||||||
|
|
||||||
|
# ---- Bigo ----
|
||||||
|
like = " OR ".join([f"campaignName LIKE '%{cid}%'" for cid in CAMPS])
|
||||||
|
sql = f"""SELECT aggregateTime d, campaignName nm, country loc, tgtPkgName pkg,
|
||||||
|
impression imp, click clk, real_result_conversion cv, totalCost cost
|
||||||
|
FROM bigo WHERE aggregateTime>=? AND aggregateTime<=? AND ({like})"""
|
||||||
|
for r in conn.execute(sql, (START, END)):
|
||||||
|
cid = campid_of(r["nm"])
|
||||||
|
if not cid: continue
|
||||||
|
k = (r["d"], "Bigo", cid, (r["loc"] or "").upper())
|
||||||
|
a = agg[k]
|
||||||
|
a["impression"] += int(r["imp"] or 0); a["clicks"] += int(r["clk"] or 0)
|
||||||
|
a["conversions"] += int(r["cv"] or 0); a["spend"] += float(r["cost"] or 0)/100.0
|
||||||
|
meta[k] = (r["nm"], CAMPS[cid])
|
||||||
|
|
||||||
|
# ---- MediaGo ---- (ad级明细,需聚合;spend = cpc*click 反算)
|
||||||
|
like = " OR ".join([f"campaign_name LIKE '%{cid}%'" for cid in CAMPS])
|
||||||
|
sql = f"""SELECT date d, campaign_name nm, adset_country loc, vimp imp, click clk,
|
||||||
|
cv cvn, cpc
|
||||||
|
FROM mediago WHERE date>=? AND date<=? AND ({like})"""
|
||||||
|
for r in conn.execute(sql, (START, END)):
|
||||||
|
cid = campid_of(r["nm"])
|
||||||
|
if not cid: continue
|
||||||
|
k = (r["d"], "MediaGo", cid, (r["loc"] or "").upper())
|
||||||
|
a = agg[k]
|
||||||
|
clk = int(r["clk"] or 0)
|
||||||
|
a["impression"] += int(r["imp"] or 0); a["clicks"] += clk
|
||||||
|
a["conversions"] += int(r["cvn"] or 0); a["spend"] += float(r["cpc"] or 0) * clk
|
||||||
|
meta[k] = (r["nm"], CAMPS[cid])
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
out = []
|
||||||
|
for k, a in agg.items():
|
||||||
|
date, channel, cid, loc = k
|
||||||
|
nm, info = meta[k]
|
||||||
|
imp, clk, cv = a["impression"], a["clicks"], a["conversions"]
|
||||||
|
out.append({
|
||||||
|
"date": date, "channel": channel, "campid": cid,
|
||||||
|
"offer_name": nm, "product": info["product"], "pkg": info["pkg"],
|
||||||
|
"location": loc, "bid_type": "",
|
||||||
|
"impression": imp, "clicks": clk, "conversions": cv,
|
||||||
|
"ctr": round(clk/imp, 5) if imp else 0.0,
|
||||||
|
"cvr": round(cv/clk, 5) if clk else 0.0,
|
||||||
|
"spend": round(a["spend"], 2),
|
||||||
|
})
|
||||||
|
out.sort(key=lambda x: (x["date"], x["channel"], -x["impression"]))
|
||||||
|
print(json.dumps(out, ensure_ascii=False))
|
||||||
+105
@@ -0,0 +1,105 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# 从本机 Excel 补齐「数据库没有表」的渠道数据(Adsterra / TrafficStars / Eagllwin 等)。
|
||||||
|
# 数据库只有 mintegral/bigo/mediago,其余渠道由运营手工维护到 Excel,本脚本读取后
|
||||||
|
# 输出成与 db_fetch.py 相同结构的 JSON(stdout),供 daily_sync 合并进 mtg_data.json。
|
||||||
|
#
|
||||||
|
# ── Excel 规范 ──────────────────────────────────────────────
|
||||||
|
# 路径:<运营>/Voypace/Voypace补充数据.xlsx (可用环境变量 VOYPACE_XLSX 覆盖)
|
||||||
|
# 第一个 sheet,首行为表头,需包含以下列(列名大小写/顺序不限):
|
||||||
|
# date 日期,yyyy-MM-dd(或 Excel 日期)
|
||||||
|
# campid 对应 campaigns.json 里的 campid
|
||||||
|
# impression 展示
|
||||||
|
# clicks 点击
|
||||||
|
# conversions 转化/安装
|
||||||
|
# spend 花费(USD)
|
||||||
|
# 可选列:location(国家,缺省用 campaigns.json 里的 geo)
|
||||||
|
# 渠道/产品/包名 一律由 campid 反查 campaigns.json,Excel 无需再填。
|
||||||
|
# 只读取 campid 命中 campaigns.json 的行;其它行忽略。
|
||||||
|
# ────────────────────────────────────────────────────────────
|
||||||
|
import json, sys, os, datetime
|
||||||
|
|
||||||
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
_camps = json.load(open(os.path.join(HERE, "campaigns.json")))
|
||||||
|
CAMPS = {str(r["campid"]): r for r in _camps}
|
||||||
|
|
||||||
|
DEFAULT_XLSX = os.environ.get(
|
||||||
|
"VOYPACE_XLSX",
|
||||||
|
"/Users/wangyuan/Documents/SHAREit/运营/Voypace/Voypace补充数据.xlsx",
|
||||||
|
)
|
||||||
|
|
||||||
|
def norm_date(v):
|
||||||
|
if v is None: return ""
|
||||||
|
if isinstance(v, (datetime.datetime, datetime.date)):
|
||||||
|
return v.strftime("%Y-%m-%d")
|
||||||
|
s = str(v).strip()
|
||||||
|
return s[:10] if s else ""
|
||||||
|
|
||||||
|
def to_int(v):
|
||||||
|
try: return int(float(str(v).replace(",", "").strip() or 0))
|
||||||
|
except: return 0
|
||||||
|
|
||||||
|
def to_float(v):
|
||||||
|
try: return float(str(v).replace(",", "").replace("$", "").strip() or 0)
|
||||||
|
except: return 0.0
|
||||||
|
|
||||||
|
def main(path):
|
||||||
|
if not os.path.exists(path):
|
||||||
|
# 没有补充表就输出空数组,daily_sync 照常跑
|
||||||
|
print("[]"); return
|
||||||
|
try:
|
||||||
|
import openpyxl
|
||||||
|
except ImportError:
|
||||||
|
sys.stderr.write("openpyxl 未安装,跳过 Excel 补数\n")
|
||||||
|
print("[]"); return
|
||||||
|
|
||||||
|
wb = openpyxl.load_workbook(path, data_only=True)
|
||||||
|
ws = wb.worksheets[0]
|
||||||
|
rows = list(ws.iter_rows(values_only=True))
|
||||||
|
if not rows:
|
||||||
|
print("[]"); return
|
||||||
|
|
||||||
|
header = [str(c).strip().lower() if c is not None else "" for c in rows[0]]
|
||||||
|
def col(*names):
|
||||||
|
for n in names:
|
||||||
|
if n in header: return header.index(n)
|
||||||
|
return None
|
||||||
|
ci = {
|
||||||
|
"date": col("date", "时间", "日期"),
|
||||||
|
"campid": col("campid", "adping id", "adping_id"),
|
||||||
|
"loc": col("location", "地区", "国家"),
|
||||||
|
"imp": col("impression", "impressions", "展示"),
|
||||||
|
"clk": col("clicks", "click", "点击"),
|
||||||
|
"cv": col("conversions", "install", "installs", "转化", "安装"),
|
||||||
|
"spend": col("spend", "花费", "cost"),
|
||||||
|
}
|
||||||
|
|
||||||
|
out = []
|
||||||
|
for r in rows[1:]:
|
||||||
|
if ci["campid"] is None: break
|
||||||
|
cid = str(r[ci["campid"]]).strip() if r[ci["campid"]] is not None else ""
|
||||||
|
cid = cid.split(".")[0] # 去掉可能的 .0
|
||||||
|
if cid not in CAMPS: continue
|
||||||
|
info = CAMPS[cid]
|
||||||
|
loc = ""
|
||||||
|
if ci["loc"] is not None and r[ci["loc"]] is not None:
|
||||||
|
loc = str(r[ci["loc"]]).strip().upper()
|
||||||
|
if not loc: loc = str(info.get("geo", "")).upper()
|
||||||
|
imp = to_int(r[ci["imp"]]) if ci["imp"] is not None else 0
|
||||||
|
clk = to_int(r[ci["clk"]]) if ci["clk"] is not None else 0
|
||||||
|
cv = to_int(r[ci["cv"]]) if ci["cv"] is not None else 0
|
||||||
|
sp = to_float(r[ci["spend"]]) if ci["spend"] is not None else 0.0
|
||||||
|
dt = norm_date(r[ci["date"]]) if ci["date"] is not None else ""
|
||||||
|
if not dt: continue
|
||||||
|
out.append({
|
||||||
|
"date": dt, "channel": info["channel"], "campid": cid,
|
||||||
|
"offer_name": f'{cid}_{info["product"]}', "product": info["product"],
|
||||||
|
"pkg": info["pkg"], "location": loc, "bid_type": "",
|
||||||
|
"impression": imp, "clicks": clk, "conversions": cv,
|
||||||
|
"ctr": round(clk/imp, 5) if imp else 0.0,
|
||||||
|
"cvr": round(cv/clk, 5) if clk else 0.0,
|
||||||
|
"spend": round(sp, 2),
|
||||||
|
})
|
||||||
|
print(json.dumps(out, ensure_ascii=False))
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main(sys.argv[1] if len(sys.argv) > 1 else DEFAULT_XLSX)
|
||||||
+694
@@ -0,0 +1,694 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Voypace 广告投放报告</title>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-datalabels@2.2.0/dist/chartjs-plugin-datalabels.min.js"></script>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif;
|
||||||
|
background: #f7f8fa;
|
||||||
|
color: #1f2933;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.container { max-width: 1400px; margin: 0 auto; padding: 24px 20px 48px; }
|
||||||
|
|
||||||
|
.header { padding: 12px 0 20px; }
|
||||||
|
.header h1 { font-size: 22px; font-weight: 700; color: #111827; }
|
||||||
|
.header p { color: #6b7280; font-size: 13px; margin-top: 4px; }
|
||||||
|
|
||||||
|
/* Tabs */
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
.tab-btn {
|
||||||
|
padding: 10px 20px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
margin-bottom: -1px;
|
||||||
|
transition: color 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
.tab-btn:hover { color: #111827; }
|
||||||
|
.tab-btn.active { color: #2563eb; border-bottom-color: #2563eb; font-weight: 600; }
|
||||||
|
.tab-panel { display: none; }
|
||||||
|
.tab-panel.active { display: block; }
|
||||||
|
|
||||||
|
/* Summary cards */
|
||||||
|
.summary-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 16px 18px;
|
||||||
|
}
|
||||||
|
.card .label { font-size: 12px; color: #6b7280; font-weight: 500; }
|
||||||
|
.card .value { font-size: 22px; font-weight: 700; color: #111827; margin-top: 4px; }
|
||||||
|
.card .sub { font-size: 11px; color: #9ca3af; margin-top: 2px; }
|
||||||
|
|
||||||
|
/* Section */
|
||||||
|
.section {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 20px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.section h2 { font-size: 15px; font-weight: 600; color: #111827; margin-bottom: 14px; }
|
||||||
|
.section .hint { font-size: 12px; color: #9ca3af; margin: -8px 0 14px; }
|
||||||
|
|
||||||
|
/* Controls */
|
||||||
|
.controls { display: flex; gap: 16px; flex-wrap: wrap; align-items: center; margin-bottom: 14px; }
|
||||||
|
.control-group { display: flex; align-items: center; gap: 6px; }
|
||||||
|
.controls label { font-size: 13px; color: #6b7280; }
|
||||||
|
.controls select {
|
||||||
|
padding: 6px 10px;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
background: #fff;
|
||||||
|
color: #374151;
|
||||||
|
outline: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.controls select:focus { border-color: #2563eb; }
|
||||||
|
|
||||||
|
/* Table */
|
||||||
|
.table-wrapper { overflow-x: auto; border: 1px solid #e5e7eb; border-radius: 8px; }
|
||||||
|
table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||||
|
thead { background: #f9fafb; }
|
||||||
|
th {
|
||||||
|
padding: 10px 12px; text-align: left; font-weight: 600; color: #6b7280;
|
||||||
|
white-space: nowrap; font-size: 12px; border-bottom: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
td { padding: 10px 12px; border-bottom: 1px solid #f3f4f6; white-space: nowrap; color: #374151; }
|
||||||
|
tr:last-child td { border-bottom: none; }
|
||||||
|
tr:hover td { background: #f9fafb; }
|
||||||
|
.num { font-variant-numeric: tabular-nums; font-family: 'SF Mono', 'Menlo', monospace; font-size: 12px; }
|
||||||
|
|
||||||
|
.tag { display: inline-block; padding: 2px 8px; border-radius: 6px; font-size: 11px; font-weight: 500; background: #eff6ff; color: #2563eb; }
|
||||||
|
.badge-active { background: #ecfdf5; color: #059669; border: 1px solid #a7f3d0; }
|
||||||
|
.badge-pause { background: #fffbeb; color: #d97706; border: 1px solid #fde68a; }
|
||||||
|
.badge { display: inline-block; padding: 2px 8px; border-radius: 6px; font-size: 11px; font-weight: 600; }
|
||||||
|
.muted { color: #d1d5db; }
|
||||||
|
|
||||||
|
.chart-box { background: #fff; border: 1px solid #e5e7eb; border-radius: 10px; padding: 20px; margin-bottom: 20px; }
|
||||||
|
.chart-box h2 { font-size: 15px; font-weight: 600; margin-bottom: 14px; color: #111827; }
|
||||||
|
.chart-box canvas { width: 100% !important; height: 280px !important; }
|
||||||
|
|
||||||
|
.updated { color: #9ca3af; font-size: 12px; margin-top: 8px; }
|
||||||
|
.empty { text-align: center; color: #9ca3af; padding: 30px; font-size: 13px; }
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.summary-row { grid-template-columns: repeat(2, 1fr); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<h1>Voypace 广告投放报告</h1>
|
||||||
|
<p>投放数据 · 订单信息 · Campaign ID 明细</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tabs">
|
||||||
|
<button class="tab-btn active" data-tab="performance">投放数据</button>
|
||||||
|
<button class="tab-btn" data-tab="orders">订单信息</button>
|
||||||
|
<button class="tab-btn" data-tab="campaigns">Campaign ID 明细</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ============ Tab 1: 投放数据 ============ -->
|
||||||
|
<div class="tab-panel active" id="tab-performance">
|
||||||
|
<!-- 全局筛选器 -->
|
||||||
|
<div class="controls" style="background:#fff;border:1px solid #e5e7eb;border-radius:10px;padding:14px 18px;margin-bottom:16px;">
|
||||||
|
<div class="control-group">
|
||||||
|
<label>渠道</label>
|
||||||
|
<select id="perfChannel"><option value="all">全部</option></select>
|
||||||
|
</div>
|
||||||
|
<div class="control-group">
|
||||||
|
<label>产品</label>
|
||||||
|
<select id="perfProduct"><option value="all">全部</option></select>
|
||||||
|
</div>
|
||||||
|
<div class="control-group">
|
||||||
|
<label>国家</label>
|
||||||
|
<select id="perfCountry"><option value="all">全部</option></select>
|
||||||
|
</div>
|
||||||
|
<div class="control-group">
|
||||||
|
<label>时间范围</label>
|
||||||
|
<select id="perfRange">
|
||||||
|
<option value="all">全部</option>
|
||||||
|
<option value="7">近 7 天</option>
|
||||||
|
<option value="14">近 14 天</option>
|
||||||
|
<option value="30">近 30 天</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="control-group">
|
||||||
|
<label>按天</label>
|
||||||
|
<select id="perfDay"><option value="all">全部日期</option></select>
|
||||||
|
</div>
|
||||||
|
<div class="control-group">
|
||||||
|
<label>自定义日期</label>
|
||||||
|
<input type="date" id="perfStart" style="padding:6px 10px;border:1px solid #d1d5db;border-radius:8px;font-size:13px;color:#374151;">
|
||||||
|
<span style="color:#9ca3af;">~</span>
|
||||||
|
<input type="date" id="perfEnd" style="padding:6px 10px;border:1px solid #d1d5db;border-radius:8px;font-size:13px;color:#374151;">
|
||||||
|
<button id="perfDateApply" style="padding:6px 14px;border:none;border-radius:8px;background:#2563eb;color:#fff;font-size:12px;font-weight:600;cursor:pointer;">确认</button>
|
||||||
|
<button id="perfDateReset" style="padding:6px 12px;border:1px solid #d1d5db;border-radius:8px;background:#f8fafc;color:#374151;font-size:12px;cursor:pointer;">清除</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 模块1:核心指标概览 -->
|
||||||
|
<div class="summary-row" id="perfSummary"></div>
|
||||||
|
|
||||||
|
<!-- 模块2:趋势分析 -->
|
||||||
|
<div class="chart-box">
|
||||||
|
<h2>投放趋势(展示 / 花费 / 转化)</h2>
|
||||||
|
<canvas id="chartTrend"></canvas>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 模块3:渠道对比 -->
|
||||||
|
<div style="display:grid;grid-template-columns:1.4fr 1fr;gap:16px;margin-bottom:20px;">
|
||||||
|
<div class="chart-box" style="margin-bottom:0;">
|
||||||
|
<h2>渠道对比(花费 / 转化)</h2>
|
||||||
|
<canvas id="chartChannel"></canvas>
|
||||||
|
</div>
|
||||||
|
<div class="chart-box" style="margin-bottom:0;">
|
||||||
|
<h2>花费占比</h2>
|
||||||
|
<canvas id="chartSpendShare"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 模块4:渠道效率对比(按 产品_国家 分组,展开各渠道)-->
|
||||||
|
<div class="section">
|
||||||
|
<h2>渠道效率对比(产品_国家 × 渠道)</h2>
|
||||||
|
<p class="hint">每个产品_国家下列出其投放过的各渠道及效率指标,同组内可横向对比不同渠道表现。</p>
|
||||||
|
<div class="table-wrapper">
|
||||||
|
<table id="channelTable">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>产品_国家</th><th>渠道</th><th>展示</th><th>点击</th><th>转化</th>
|
||||||
|
<th>花费</th><th>CTR</th><th>CVR</th><th>eCPM</th><th>CPC</th><th>CPA</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 模块5:产品表现 -->
|
||||||
|
<div class="section">
|
||||||
|
<h2>产品表现排行</h2>
|
||||||
|
<div class="table-wrapper">
|
||||||
|
<table id="productTable">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>产品</th><th>渠道数</th><th>展示</th><th>点击</th><th>转化</th>
|
||||||
|
<th>花费</th><th>CTR</th><th>CVR</th><th>CPA</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 模块6:国家分布 -->
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:20px;">
|
||||||
|
<div class="chart-box" style="margin-bottom:0;">
|
||||||
|
<h2>国家展示分布 Top</h2>
|
||||||
|
<canvas id="chartCountry"></canvas>
|
||||||
|
</div>
|
||||||
|
<div class="section" style="margin-bottom:0;">
|
||||||
|
<h2>国家维度明细</h2>
|
||||||
|
<div class="table-wrapper" style="max-height:280px;overflow-y:auto;">
|
||||||
|
<table id="countryTable">
|
||||||
|
<thead><tr><th>国家</th><th>展示</th><th>点击</th><th>转化</th><th>花费</th><th>CTR</th></tr></thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 模块7:明细数据 -->
|
||||||
|
<div class="section">
|
||||||
|
<h2>明细数据(按 Campaign×日期)</h2>
|
||||||
|
<div class="table-wrapper" style="max-height:460px;overflow-y:auto;">
|
||||||
|
<table id="perfTable">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>日期</th><th>渠道</th><th>产品</th><th>Offer Name</th><th>国家</th>
|
||||||
|
<th>展示</th><th>点击</th><th>转化</th><th>CTR</th><th>CVR</th><th>Spend</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ============ Tab 2: 订单信息 ============ -->
|
||||||
|
<div class="tab-panel" id="tab-orders">
|
||||||
|
<div class="summary-row" id="orderSummary"></div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h2>产品订单基础信息</h2>
|
||||||
|
<div class="table-wrapper">
|
||||||
|
<table id="orderTable">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Product</th>
|
||||||
|
<th>Pkg / 包名</th>
|
||||||
|
<th>平台</th>
|
||||||
|
<th>地区</th>
|
||||||
|
<th>付费类型</th>
|
||||||
|
<th>单价(USD)</th>
|
||||||
|
<th>日预算</th>
|
||||||
|
<th>状态</th>
|
||||||
|
<th>MMP</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h2>渠道投放矩阵(渠道 × 产品)</h2>
|
||||||
|
<p class="hint">
|
||||||
|
<span class="badge badge-active">Active</span> 投放中
|
||||||
|
<span class="badge badge-pause">Pause</span> 投过已停
|
||||||
|
空白 = 暂无数据(未接入或未投放)<br>
|
||||||
|
状态依据实际投放数据判定:产品订单为 Pause 直接判停;产品在投则看该渠道近期展示趋势(掉到个位数/断更视为已停)。仅显示已接入数据的渠道。
|
||||||
|
</p>
|
||||||
|
<div class="table-wrapper">
|
||||||
|
<table id="matrixTable"><thead></thead><tbody></tbody></table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ============ Tab 3: Campaign ID 明细 ============ -->
|
||||||
|
<div class="tab-panel" id="tab-campaigns">
|
||||||
|
<div class="section">
|
||||||
|
<div class="controls">
|
||||||
|
<div class="control-group">
|
||||||
|
<label>产品</label>
|
||||||
|
<select id="campProduct"><option value="all">全部</option></select>
|
||||||
|
</div>
|
||||||
|
<div class="control-group">
|
||||||
|
<label>渠道</label>
|
||||||
|
<select id="campChannel"><option value="all">全部</option></select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrapper">
|
||||||
|
<table id="campaignTable">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Campaign ID</th>
|
||||||
|
<th>Product</th>
|
||||||
|
<th>Pkg / 包名</th>
|
||||||
|
<th>投放国家</th>
|
||||||
|
<th>渠道</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="updated" id="updated"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let perfData = [], orderData = [], campaignData = [], matrixOverrides = {};
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
const [p, o, c, m] = await Promise.all([
|
||||||
|
fetch('mtg_data.json?' + Date.now()).then(r => r.json()).catch(() => []),
|
||||||
|
fetch('orders.json?' + Date.now()).then(r => r.json()).catch(() => []),
|
||||||
|
fetch('campaigns.json?' + Date.now()).then(r => r.json()).catch(() => []),
|
||||||
|
fetch('matrix_overrides.json?' + Date.now()).then(r => r.json()).catch(() => ({}))
|
||||||
|
]);
|
||||||
|
perfData = p; orderData = o; campaignData = c; matrixOverrides = m;
|
||||||
|
initTabs();
|
||||||
|
renderPerformance();
|
||||||
|
renderOrders();
|
||||||
|
renderCampaigns();
|
||||||
|
document.getElementById('updated').textContent = '最后刷新:' + new Date().toLocaleString('zh-CN');
|
||||||
|
} catch (e) { console.error(e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function initTabs() {
|
||||||
|
document.querySelectorAll('.tab-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
|
||||||
|
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
|
||||||
|
btn.classList.add('active');
|
||||||
|
document.getElementById('tab-' + btn.dataset.tab).classList.add('active');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Tab 1: 投放数据 ---------- */
|
||||||
|
// datalabels 默认全局注册,先取消,只在需要的图表按需启用
|
||||||
|
if (window.ChartDataLabels) { try { Chart.unregister(ChartDataLabels); } catch(e){} }
|
||||||
|
const CH_COLORS = { Mintegral:'#3b82f6', MediaGo:'#10b981', Bigo:'#f59e0b', Adsterra:'#8b5cf6', TrafficStars:'#ec4899', Eagllwin:'#06b6d4' };
|
||||||
|
const PALETTE = ['#3b82f6','#10b981','#f59e0b','#ef4444','#8b5cf6','#06b6d4','#ec4899','#84cc16','#f97316','#6366f1'];
|
||||||
|
let perfCharts = {};
|
||||||
|
|
||||||
|
function renderPerformance() {
|
||||||
|
fillSelect('perfChannel', [...new Set(perfData.map(d => d.channel))].sort());
|
||||||
|
fillSelect('perfProduct', [...new Set(perfData.map(d => d.product))].sort());
|
||||||
|
fillSelect('perfCountry', [...new Set(perfData.map(d => d.location))].filter(x=>x&&x!=='0').sort());
|
||||||
|
fillSelect('perfDay', [...new Set(perfData.map(d => d.date))].sort().reverse());
|
||||||
|
// 自定义日期选择器:限制可选范围为数据的最小/最大日期
|
||||||
|
const allD = [...new Set(perfData.map(d => d.date))].sort();
|
||||||
|
if (allD.length) {
|
||||||
|
const s = document.getElementById('perfStart'), e = document.getElementById('perfEnd');
|
||||||
|
s.min = e.min = allD[0]; s.max = e.max = allD[allD.length-1];
|
||||||
|
}
|
||||||
|
// 下拉类筛选即时生效;自定义日期不即时,需点"确认"才应用
|
||||||
|
['perfChannel','perfProduct','perfCountry','perfRange','perfDay'].forEach(id =>
|
||||||
|
document.getElementById(id).addEventListener('change', drawPerf));
|
||||||
|
document.getElementById('perfDateApply').addEventListener('click', drawPerf);
|
||||||
|
document.getElementById('perfDateReset').addEventListener('click', () => {
|
||||||
|
document.getElementById('perfStart').value = '';
|
||||||
|
document.getElementById('perfEnd').value = '';
|
||||||
|
drawPerf();
|
||||||
|
});
|
||||||
|
drawPerf();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPerfFiltered() {
|
||||||
|
let d = perfData;
|
||||||
|
const ch = document.getElementById('perfChannel').value;
|
||||||
|
const pr = document.getElementById('perfProduct').value;
|
||||||
|
const co = document.getElementById('perfCountry').value;
|
||||||
|
const rg = document.getElementById('perfRange').value;
|
||||||
|
const day = document.getElementById('perfDay').value;
|
||||||
|
const start = document.getElementById('perfStart').value;
|
||||||
|
const end = document.getElementById('perfEnd').value;
|
||||||
|
if (ch !== 'all') d = d.filter(x => x.channel === ch);
|
||||||
|
if (pr !== 'all') d = d.filter(x => x.product === pr);
|
||||||
|
if (co !== 'all') d = d.filter(x => x.location === co);
|
||||||
|
// 日期过滤优先级:自定义日期区间 > 按天 > 时间范围
|
||||||
|
if (start || end) {
|
||||||
|
if (start) d = d.filter(x => x.date >= start);
|
||||||
|
if (end) d = d.filter(x => x.date <= end);
|
||||||
|
} else if (day !== 'all') {
|
||||||
|
d = d.filter(x => x.date === day);
|
||||||
|
} else if (rg !== 'all') {
|
||||||
|
const allDates = [...new Set(perfData.map(x => x.date))].sort();
|
||||||
|
const cut = allDates[Math.max(0, allDates.length - parseInt(rg))];
|
||||||
|
d = d.filter(x => x.date >= cut);
|
||||||
|
}
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 指标计算
|
||||||
|
const sum = (arr, k) => arr.reduce((s, x) => s + (x[k]||0), 0);
|
||||||
|
const metrics = (arr) => {
|
||||||
|
const imp=sum(arr,'impression'), clk=sum(arr,'clicks'), cv=sum(arr,'conversions'), sp=sum(arr,'spend');
|
||||||
|
return { imp, clk, cv, sp,
|
||||||
|
ctr: imp?clk/imp:0, cvr: clk?cv/clk:0,
|
||||||
|
ecpm: imp?sp/imp*1000:0, cpc: clk?sp/clk:0, cpa: cv?sp/cv:0 };
|
||||||
|
};
|
||||||
|
const fmtMoney = v => '$'+v.toLocaleString(undefined,{minimumFractionDigits:2,maximumFractionDigits:2});
|
||||||
|
const fmtNum = v => v.toLocaleString();
|
||||||
|
|
||||||
|
function drawPerf() {
|
||||||
|
const data = getPerfFiltered();
|
||||||
|
const m = metrics(data);
|
||||||
|
|
||||||
|
// ---- 模块1:核心指标概览 ----
|
||||||
|
document.getElementById('perfSummary').innerHTML = `
|
||||||
|
<div class="card"><div class="label">总展示</div><div class="value">${fmtNum(m.imp)}</div><div class="sub">${data.length} 条记录</div></div>
|
||||||
|
<div class="card"><div class="label">总点击</div><div class="value">${fmtNum(m.clk)}</div><div class="sub">CTR ${(m.ctr*100).toFixed(2)}%</div></div>
|
||||||
|
<div class="card"><div class="label">总转化</div><div class="value">${fmtNum(m.cv)}</div><div class="sub">CVR ${(m.cvr*100).toFixed(2)}%</div></div>
|
||||||
|
<div class="card"><div class="label">总花费</div><div class="value">${fmtMoney(m.sp)}</div><div class="sub">CPA ${m.cpa?fmtMoney(m.cpa):'-'}</div></div>
|
||||||
|
<div class="card"><div class="label">eCPM</div><div class="value">$${m.ecpm.toFixed(2)}</div><div class="sub">CPC $${m.cpc.toFixed(3)}</div></div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// ---- 模块2:趋势分析(展示/花费/转化 多轴)----
|
||||||
|
const dates = [...new Set(data.map(d => d.date))].sort();
|
||||||
|
const byDate = k => dates.map(dt => sum(data.filter(x=>x.date===dt), k));
|
||||||
|
drawChart('chartTrend', {
|
||||||
|
type: 'line',
|
||||||
|
data: { labels: dates.map(d=>d.slice(5)), datasets: [
|
||||||
|
{ label:'展示', data:byDate('impression'), borderColor:'#3b82f6', backgroundColor:'#3b82f620', yAxisID:'y', tension:.3, fill:true, pointRadius:2 },
|
||||||
|
{ label:'花费($)', data:byDate('spend'), borderColor:'#f59e0b', backgroundColor:'transparent', yAxisID:'y1', tension:.3, pointRadius:2 },
|
||||||
|
{ label:'转化', data:byDate('conversions'), borderColor:'#ef4444', backgroundColor:'transparent', yAxisID:'y2', tension:.3, pointRadius:2 },
|
||||||
|
]},
|
||||||
|
options: { responsive:true, maintainAspectRatio:false, interaction:{mode:'index',intersect:false},
|
||||||
|
plugins:{ legend:{position:'top',labels:{boxWidth:12,font:{size:11}}} },
|
||||||
|
scales:{
|
||||||
|
x:{ grid:{display:false}, ticks:{font:{size:10},color:'#9ca3af'} },
|
||||||
|
y:{ position:'left', title:{display:true,text:'展示',font:{size:10}}, ticks:{color:'#9ca3af'}, grid:{color:'#f3f4f6'} },
|
||||||
|
y1:{ position:'right', title:{display:true,text:'花费',font:{size:10}}, ticks:{color:'#9ca3af'}, grid:{display:false} },
|
||||||
|
y2:{ display:false }
|
||||||
|
} }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- 模块3:渠道对比 ----
|
||||||
|
const chans = [...new Set(data.map(d=>d.channel))];
|
||||||
|
const chSpend = chans.map(c => sum(data.filter(x=>x.channel===c),'spend'));
|
||||||
|
const chConv = chans.map(c => sum(data.filter(x=>x.channel===c),'conversions'));
|
||||||
|
drawChart('chartChannel', {
|
||||||
|
type:'bar',
|
||||||
|
data:{ labels:chans, datasets:[
|
||||||
|
{ label:'花费($)', data:chSpend, backgroundColor:'#3b82f6', borderRadius:4, yAxisID:'y' },
|
||||||
|
{ label:'转化', data:chConv, backgroundColor:'#ef4444', borderRadius:4, yAxisID:'y1' },
|
||||||
|
]},
|
||||||
|
options:{ responsive:true, maintainAspectRatio:false,
|
||||||
|
layout:{padding:{top:24}},
|
||||||
|
plugins:{
|
||||||
|
legend:{position:'top',labels:{boxWidth:12,font:{size:11}}},
|
||||||
|
datalabels:{
|
||||||
|
anchor:'end', align:'end', color:'#374151', font:{size:11,weight:'600'},
|
||||||
|
formatter:(v,ctx)=> ctx.datasetIndex===0 ? '$'+Math.round(v).toLocaleString() : Math.round(v).toLocaleString()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scales:{ x:{grid:{display:false},ticks:{color:'#9ca3af'}},
|
||||||
|
y:{position:'left',ticks:{color:'#9ca3af'},grid:{color:'#f3f4f6'}},
|
||||||
|
y1:{position:'right',ticks:{color:'#9ca3af'},grid:{display:false}} } },
|
||||||
|
plugins:[ChartDataLabels]
|
||||||
|
});
|
||||||
|
const totalSpend = chSpend.reduce((a,b)=>a+b,0) || 1;
|
||||||
|
drawChart('chartSpendShare', {
|
||||||
|
type:'doughnut',
|
||||||
|
data:{ labels:chans, datasets:[{ data:chSpend, backgroundColor:chans.map(c=>CH_COLORS[c]||'#999') }] },
|
||||||
|
options:{ responsive:true, maintainAspectRatio:false,
|
||||||
|
plugins:{
|
||||||
|
legend:{position:'right',labels:{boxWidth:12,font:{size:11}}},
|
||||||
|
datalabels:{
|
||||||
|
color:'#fff', font:{size:12,weight:'700'},
|
||||||
|
formatter:(v)=> (v/totalSpend*100)>=5 ? '$'+Math.round(v).toLocaleString()+'\n'+(v/totalSpend*100).toFixed(0)+'%' : '',
|
||||||
|
textAlign:'center'
|
||||||
|
}
|
||||||
|
} },
|
||||||
|
plugins:[ChartDataLabels]
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- 模块4:渠道效率对比(按 产品_国家 分组,展开各渠道)----
|
||||||
|
// 分组键 = 产品 + 国家
|
||||||
|
const groupMap = {};
|
||||||
|
data.forEach(x => {
|
||||||
|
const g = x.product + '_' + x.location;
|
||||||
|
(groupMap[g] = groupMap[g] || []).push(x);
|
||||||
|
});
|
||||||
|
// 分组按总展示降序;组内渠道也按展示降序
|
||||||
|
const groupKeys = Object.keys(groupMap).sort((a,b)=> sum(groupMap[b],'impression') - sum(groupMap[a],'impression'));
|
||||||
|
let rowsHtml = '';
|
||||||
|
groupKeys.forEach(g => {
|
||||||
|
const rows = groupMap[g];
|
||||||
|
const chansInG = [...new Set(rows.map(x=>x.channel))]
|
||||||
|
.sort((a,b)=> sum(rows.filter(x=>x.channel===b),'impression') - sum(rows.filter(x=>x.channel===a),'impression'));
|
||||||
|
chansInG.forEach((c, idx) => {
|
||||||
|
const cm = metrics(rows.filter(x=>x.channel===c));
|
||||||
|
const groupCell = idx===0
|
||||||
|
? `<td rowspan="${chansInG.length}" style="font-weight:600;vertical-align:top;background:#fafbfc;">${g}</td>`
|
||||||
|
: '';
|
||||||
|
rowsHtml += `<tr>
|
||||||
|
${groupCell}
|
||||||
|
<td><span class="tag" style="background:${(CH_COLORS[c]||'#999')}20;color:${CH_COLORS[c]||'#666'}">${c}</span></td>
|
||||||
|
<td class="num">${fmtNum(cm.imp)}</td><td class="num">${fmtNum(cm.clk)}</td><td class="num">${cm.cv}</td>
|
||||||
|
<td class="num">${fmtMoney(cm.sp)}</td><td class="num">${(cm.ctr*100).toFixed(2)}%</td>
|
||||||
|
<td class="num">${(cm.cvr*100).toFixed(2)}%</td><td class="num">$${cm.ecpm.toFixed(2)}</td>
|
||||||
|
<td class="num">$${cm.cpc.toFixed(3)}</td><td class="num">${cm.cpa?fmtMoney(cm.cpa):'-'}</td>
|
||||||
|
</tr>`;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
document.querySelector('#channelTable tbody').innerHTML = rowsHtml || `<tr><td colspan="11" class="empty">暂无数据</td></tr>`;
|
||||||
|
|
||||||
|
// ---- 模块5:产品表现排行(按花费降序)----
|
||||||
|
const prods = [...new Set(data.map(d=>d.product))];
|
||||||
|
const prodRows = prods.map(p => {
|
||||||
|
const pd = data.filter(x=>x.product===p);
|
||||||
|
const pm = metrics(pd);
|
||||||
|
const chCount = new Set(pd.map(x=>x.channel)).size;
|
||||||
|
return { p, chCount, ...pm };
|
||||||
|
}).sort((a,b)=>b.sp-a.sp);
|
||||||
|
document.querySelector('#productTable tbody').innerHTML = prodRows.map(r => `
|
||||||
|
<tr>
|
||||||
|
<td style="font-weight:500">${r.p}</td><td class="num">${r.chCount}</td>
|
||||||
|
<td class="num">${fmtNum(r.imp)}</td><td class="num">${fmtNum(r.clk)}</td><td class="num">${r.cv}</td>
|
||||||
|
<td class="num">${fmtMoney(r.sp)}</td><td class="num">${(r.ctr*100).toFixed(2)}%</td>
|
||||||
|
<td class="num">${(r.cvr*100).toFixed(2)}%</td><td class="num">${r.cpa?fmtMoney(r.cpa):'-'}</td>
|
||||||
|
</tr>`).join('') || `<tr><td colspan="9" class="empty">暂无数据</td></tr>`;
|
||||||
|
|
||||||
|
// ---- 模块6:国家分布 ----
|
||||||
|
const countries = [...new Set(data.map(d=>d.location))].filter(x=>x&&x!=='0');
|
||||||
|
const cRows = countries.map(c => ({ c, ...metrics(data.filter(x=>x.location===c)) })).sort((a,b)=>b.imp-a.imp);
|
||||||
|
const topC = cRows.slice(0,8);
|
||||||
|
drawChart('chartCountry', {
|
||||||
|
type:'bar',
|
||||||
|
data:{ labels:topC.map(r=>r.c), datasets:[{ label:'展示', data:topC.map(r=>r.imp), backgroundColor:topC.map((_,i)=>PALETTE[i%PALETTE.length]), borderRadius:4 }] },
|
||||||
|
options:{ indexAxis:'y', responsive:true, maintainAspectRatio:false, plugins:{legend:{display:false}},
|
||||||
|
scales:{ x:{ticks:{color:'#9ca3af'},grid:{color:'#f3f4f6'}}, y:{ticks:{color:'#9ca3af'},grid:{display:false}} } }
|
||||||
|
});
|
||||||
|
document.querySelector('#countryTable tbody').innerHTML = cRows.map(r => `
|
||||||
|
<tr><td><span class="tag">${r.c}</span></td><td class="num">${fmtNum(r.imp)}</td>
|
||||||
|
<td class="num">${fmtNum(r.clk)}</td><td class="num">${r.cv}</td>
|
||||||
|
<td class="num">${fmtMoney(r.sp)}</td><td class="num">${(r.ctr*100).toFixed(2)}%</td></tr>`).join('')
|
||||||
|
|| `<tr><td colspan="6" class="empty">暂无数据</td></tr>`;
|
||||||
|
|
||||||
|
// ---- 模块7:明细表 ----
|
||||||
|
const sorted = [...data].sort((a,b)=> a.date<b.date?1:a.date>b.date?-1 : b.impression-a.impression);
|
||||||
|
document.querySelector('#perfTable tbody').innerHTML = sorted.map(d => `
|
||||||
|
<tr>
|
||||||
|
<td>${d.date}</td><td><span class="tag" style="background:${(CH_COLORS[d.channel]||'#999')}20;color:${CH_COLORS[d.channel]||'#666'}">${d.channel}</span></td>
|
||||||
|
<td style="font-weight:500">${d.product}</td><td class="num" style="font-size:11px">${d.offer_name}</td>
|
||||||
|
<td>${d.location}</td><td class="num">${fmtNum(d.impression)}</td><td class="num">${fmtNum(d.clicks)}</td>
|
||||||
|
<td class="num">${d.conversions}</td><td class="num">${(d.ctr*100).toFixed(2)}%</td>
|
||||||
|
<td class="num">${(d.cvr*100).toFixed(2)}%</td><td class="num">${fmtMoney(d.spend)}</td>
|
||||||
|
</tr>`).join('') || `<tr><td colspan="11" class="empty">暂无数据</td></tr>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawChart(id, cfg) {
|
||||||
|
if (perfCharts[id]) perfCharts[id].destroy();
|
||||||
|
perfCharts[id] = new Chart(document.getElementById(id), cfg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Tab 2: 订单信息 ---------- */
|
||||||
|
function renderOrders() {
|
||||||
|
const total = orderData.length;
|
||||||
|
const active = orderData.filter(o => o.status === 'Active').length;
|
||||||
|
const products = new Set(orderData.map(o => o.product)).size;
|
||||||
|
const chSet = new Set();
|
||||||
|
orderData.forEach(o => { (o.agency_channels||[]).forEach(c => chSet.add(c)); (o.self_channels||[]).forEach(c => chSet.add(c)); });
|
||||||
|
document.getElementById('orderSummary').innerHTML = `
|
||||||
|
<div class="card"><div class="label">订单条目</div><div class="value">${total}</div><div class="sub">${products} 个产品</div></div>
|
||||||
|
<div class="card"><div class="label">在投 Active</div><div class="value" style="color:#059669">${active}</div><div class="sub">${total-active} 个 Pause</div></div>
|
||||||
|
<div class="card"><div class="label">投放渠道</div><div class="value">${chSet.size}</div><div class="sub">媒体渠道</div></div>
|
||||||
|
<div class="card"><div class="label">MMP</div><div class="value" style="font-size:16px">AppsFlyer</div><div class="sub">统一归因</div></div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const tbody = document.querySelector('#orderTable tbody');
|
||||||
|
tbody.innerHTML = orderData.map(o => {
|
||||||
|
const cls = o.status === 'Active' ? 'badge-active' : 'badge-pause';
|
||||||
|
return `
|
||||||
|
<tr>
|
||||||
|
<td style="font-weight:500">${o.product}</td>
|
||||||
|
<td class="num" style="font-size:11px">${o.pkg}</td>
|
||||||
|
<td>${o.platform}</td>
|
||||||
|
<td>${o.geo}</td>
|
||||||
|
<td><span class="tag">${o.pay_mode}</span></td>
|
||||||
|
<td class="num">${o.price}</td>
|
||||||
|
<td class="num">${o.budget}</td>
|
||||||
|
<td><span class="badge ${cls}">${o.status}</span></td>
|
||||||
|
<td>${o.mmp}</td>
|
||||||
|
</tr>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
renderMatrix();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMatrix() {
|
||||||
|
// 列 = 产品(带 pkg 用于查数据状态)
|
||||||
|
const cols = orderData.map(o => ({
|
||||||
|
label: o.product, sub: o.geo, pkg: o.pkg,
|
||||||
|
channels: new Set([...(o.agency_channels||[]), ...(o.self_channels||[])])
|
||||||
|
}));
|
||||||
|
const channels = [];
|
||||||
|
orderData.forEach(o => [...(o.agency_channels||[]), ...(o.self_channels||[])].forEach(c => { if (!channels.includes(c)) channels.push(c); }));
|
||||||
|
|
||||||
|
document.querySelector('#matrixTable thead').innerHTML = `
|
||||||
|
<tr>
|
||||||
|
<th style="position:sticky;left:0;background:#f9fafb;z-index:2;">渠道 \\ 产品</th>
|
||||||
|
${cols.map(c => `<th style="text-align:center;min-width:88px;"><div>${c.label}</div><div style="font-weight:400;font-size:10px;color:#9ca3af;">${c.sub}</div></th>`).join('')}
|
||||||
|
</tr>`;
|
||||||
|
|
||||||
|
// 单元格:仅当该「渠道|包名」在 matrixOverrides 有记录时才显示状态(遵从事实:接了数据才判定)
|
||||||
|
// 产品 pkg 可能是 "a / b" 合并形式,任一命中即取用
|
||||||
|
const lookup = (ch, pkg) => {
|
||||||
|
const parts = String(pkg).replace(/\s/g,'').split('/');
|
||||||
|
for (const p of parts) {
|
||||||
|
const k = ch + '|' + p;
|
||||||
|
if (k in matrixOverrides) return matrixOverrides[k];
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
const cell = (col, ch) => {
|
||||||
|
const st = lookup(ch, col.pkg);
|
||||||
|
if (st === undefined || st === 'none') return '<td></td>'; // 无数据 / 零曝光 -> 留空
|
||||||
|
const cls = st === 'Active' ? 'badge-active' : 'badge-pause';
|
||||||
|
return `<td style="text-align:center;"><span class="badge ${cls}">${st}</span></td>`;
|
||||||
|
};
|
||||||
|
document.querySelector('#matrixTable tbody').innerHTML = channels.map(ch => `
|
||||||
|
<tr>
|
||||||
|
<td style="position:sticky;left:0;background:#fff;font-weight:500;">${ch}</td>
|
||||||
|
${cols.map(col => cell(col, ch)).join('')}
|
||||||
|
</tr>`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Tab 3: Campaign ID 明细 ---------- */
|
||||||
|
function renderCampaigns() {
|
||||||
|
const prods = [...new Set(campaignData.map(c => c.product))].sort();
|
||||||
|
const chans = [...new Set(campaignData.map(c => c.channel))].sort();
|
||||||
|
fillSelect('campProduct', prods);
|
||||||
|
fillSelect('campChannel', chans);
|
||||||
|
document.getElementById('campProduct').addEventListener('change', drawCampaigns);
|
||||||
|
document.getElementById('campChannel').addEventListener('change', drawCampaigns);
|
||||||
|
drawCampaigns();
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawCampaigns() {
|
||||||
|
const pf = document.getElementById('campProduct').value;
|
||||||
|
const cf = document.getElementById('campChannel').value;
|
||||||
|
let data = campaignData;
|
||||||
|
if (pf !== 'all') data = data.filter(c => c.product === pf);
|
||||||
|
if (cf !== 'all') data = data.filter(c => c.channel === cf);
|
||||||
|
document.querySelector('#campaignTable tbody').innerHTML = data.map(c => `
|
||||||
|
<tr>
|
||||||
|
<td class="num" style="font-weight:600;color:#2563eb;">${c.campid}</td>
|
||||||
|
<td style="font-weight:500">${c.product}</td>
|
||||||
|
<td class="num" style="font-size:11px">${c.pkg}</td>
|
||||||
|
<td><span class="tag">${c.geo}</span></td>
|
||||||
|
<td>${c.channel}</td>
|
||||||
|
</tr>`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- helpers ---------- */
|
||||||
|
function fillSelect(id, items) {
|
||||||
|
const sel = document.getElementById(id);
|
||||||
|
items.forEach(v => { const o = document.createElement('option'); o.value = v; o.textContent = v; sel.appendChild(o); });
|
||||||
|
}
|
||||||
|
|
||||||
|
load();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"Bigo|org.melbet.client": "Pause",
|
||||||
|
"Bigo|ru.bistrodengi.my": "Pause",
|
||||||
|
"Bigo|robot.zaimer.ru": "Pause",
|
||||||
|
"Bigo|org.megapari.client": "Pause",
|
||||||
|
"MediaGo|ru.bistrodengi.my": "Pause",
|
||||||
|
"MediaGo|robot.zaimer.ru": "Pause",
|
||||||
|
"Mintegral|robot.zaimer.ru": "Pause",
|
||||||
|
"Mintegral|ru.bistrodengi.my": "Pause",
|
||||||
|
"Bigo|se.scmv.morocco": "Pause"
|
||||||
|
}
|
||||||
+434
@@ -0,0 +1,434 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"date": "2026-07-27",
|
||||||
|
"channel": "Bigo",
|
||||||
|
"campid": "91513412",
|
||||||
|
"offer_name": "91513412_Melbet",
|
||||||
|
"product": "Melbet",
|
||||||
|
"pkg": "org.melbet.client",
|
||||||
|
"location": "RU",
|
||||||
|
"bid_type": "",
|
||||||
|
"impression": 24597,
|
||||||
|
"clicks": 15776,
|
||||||
|
"conversions": 0,
|
||||||
|
"spend": 12.62,
|
||||||
|
"ctr": 0.64138,
|
||||||
|
"cvr": 0.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-07-27",
|
||||||
|
"channel": "Bigo",
|
||||||
|
"campid": "91508373",
|
||||||
|
"offer_name": "91508373_Bistro",
|
||||||
|
"product": "Bistro",
|
||||||
|
"pkg": "ru.bistrodengi.my",
|
||||||
|
"location": "RU",
|
||||||
|
"bid_type": "",
|
||||||
|
"impression": 14366,
|
||||||
|
"clicks": 1479,
|
||||||
|
"conversions": 2,
|
||||||
|
"spend": 5.32,
|
||||||
|
"ctr": 0.10295,
|
||||||
|
"cvr": 0.00135
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-07-27",
|
||||||
|
"channel": "Bigo",
|
||||||
|
"campid": "91508376",
|
||||||
|
"offer_name": "91508376_Robotzaimer",
|
||||||
|
"product": "Robotzaimer",
|
||||||
|
"pkg": "robot.zaimer.ru",
|
||||||
|
"location": "RU",
|
||||||
|
"bid_type": "",
|
||||||
|
"impression": 6326,
|
||||||
|
"clicks": 1064,
|
||||||
|
"conversions": 0,
|
||||||
|
"spend": 5.72,
|
||||||
|
"ctr": 0.16819,
|
||||||
|
"cvr": 0.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-07-27",
|
||||||
|
"channel": "Bigo",
|
||||||
|
"campid": "91513416",
|
||||||
|
"offer_name": "91513416_Megapari",
|
||||||
|
"product": "Megapari",
|
||||||
|
"pkg": "org.megapari.client",
|
||||||
|
"location": "BR",
|
||||||
|
"bid_type": "",
|
||||||
|
"impression": 0,
|
||||||
|
"clicks": 0,
|
||||||
|
"conversions": 0,
|
||||||
|
"spend": 0.0,
|
||||||
|
"ctr": 0.0,
|
||||||
|
"cvr": 0.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-07-27",
|
||||||
|
"channel": "MediaGo",
|
||||||
|
"campid": "91508374",
|
||||||
|
"offer_name": "91508374_Bistro",
|
||||||
|
"product": "Bistro",
|
||||||
|
"pkg": "ru.bistrodengi.my",
|
||||||
|
"location": "RU",
|
||||||
|
"bid_type": "",
|
||||||
|
"impression": 3693,
|
||||||
|
"clicks": 40,
|
||||||
|
"conversions": 1,
|
||||||
|
"spend": 0.12,
|
||||||
|
"ctr": 0.01083,
|
||||||
|
"cvr": 0.025
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-07-27",
|
||||||
|
"channel": "MediaGo",
|
||||||
|
"campid": "91508375",
|
||||||
|
"offer_name": "91508375_Robotzaimer",
|
||||||
|
"product": "Robotzaimer",
|
||||||
|
"pkg": "robot.zaimer.ru",
|
||||||
|
"location": "RU",
|
||||||
|
"bid_type": "",
|
||||||
|
"impression": 906,
|
||||||
|
"clicks": 27,
|
||||||
|
"conversions": 0,
|
||||||
|
"spend": 0.06,
|
||||||
|
"ctr": 0.0298,
|
||||||
|
"cvr": 0.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-07-27",
|
||||||
|
"channel": "Mintegral",
|
||||||
|
"campid": "91505669",
|
||||||
|
"offer_name": "91505669_Robotzaimer",
|
||||||
|
"product": "Robotzaimer",
|
||||||
|
"pkg": "robot.zaimer.ru",
|
||||||
|
"location": "RU",
|
||||||
|
"bid_type": "",
|
||||||
|
"impression": 29251,
|
||||||
|
"clicks": 36,
|
||||||
|
"conversions": 0,
|
||||||
|
"spend": 0.0,
|
||||||
|
"ctr": 0.00123,
|
||||||
|
"cvr": 0.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-07-27",
|
||||||
|
"channel": "Mintegral",
|
||||||
|
"campid": "91504360",
|
||||||
|
"offer_name": "91504360_Bistro",
|
||||||
|
"product": "Bistro",
|
||||||
|
"pkg": "ru.bistrodengi.my",
|
||||||
|
"location": "RU",
|
||||||
|
"bid_type": "",
|
||||||
|
"impression": 24835,
|
||||||
|
"clicks": 56,
|
||||||
|
"conversions": 1,
|
||||||
|
"spend": 0.3,
|
||||||
|
"ctr": 0.00225,
|
||||||
|
"cvr": 0.01786
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-07-28",
|
||||||
|
"channel": "Bigo",
|
||||||
|
"campid": "91513416",
|
||||||
|
"offer_name": "91513416_Megapari",
|
||||||
|
"product": "Megapari",
|
||||||
|
"pkg": "org.megapari.client",
|
||||||
|
"location": "BR",
|
||||||
|
"bid_type": "",
|
||||||
|
"impression": 49584,
|
||||||
|
"clicks": 32132,
|
||||||
|
"conversions": 0,
|
||||||
|
"spend": 12.85,
|
||||||
|
"ctr": 0.64803,
|
||||||
|
"cvr": 0.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-07-28",
|
||||||
|
"channel": "Bigo",
|
||||||
|
"campid": "91513412",
|
||||||
|
"offer_name": "91513412_Melbet",
|
||||||
|
"product": "Melbet",
|
||||||
|
"pkg": "org.melbet.client",
|
||||||
|
"location": "RU",
|
||||||
|
"bid_type": "",
|
||||||
|
"impression": 35860,
|
||||||
|
"clicks": 21776,
|
||||||
|
"conversions": 0,
|
||||||
|
"spend": 17.42,
|
||||||
|
"ctr": 0.60725,
|
||||||
|
"cvr": 0.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-07-28",
|
||||||
|
"channel": "Bigo",
|
||||||
|
"campid": "91508376",
|
||||||
|
"offer_name": "91508376_Robotzaimer",
|
||||||
|
"product": "Robotzaimer",
|
||||||
|
"pkg": "robot.zaimer.ru",
|
||||||
|
"location": "RU",
|
||||||
|
"bid_type": "",
|
||||||
|
"impression": 7941,
|
||||||
|
"clicks": 573,
|
||||||
|
"conversions": 1,
|
||||||
|
"spend": 1.27,
|
||||||
|
"ctr": 0.07216,
|
||||||
|
"cvr": 0.00175
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-07-28",
|
||||||
|
"channel": "Bigo",
|
||||||
|
"campid": "91508373",
|
||||||
|
"offer_name": "91508373_Bistro",
|
||||||
|
"product": "Bistro",
|
||||||
|
"pkg": "ru.bistrodengi.my",
|
||||||
|
"location": "RU",
|
||||||
|
"bid_type": "",
|
||||||
|
"impression": 461,
|
||||||
|
"clicks": 1,
|
||||||
|
"conversions": 0,
|
||||||
|
"spend": 0.0,
|
||||||
|
"ctr": 0.00217,
|
||||||
|
"cvr": 0.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-07-28",
|
||||||
|
"channel": "MediaGo",
|
||||||
|
"campid": "91508375",
|
||||||
|
"offer_name": "91508375_Robotzaimer",
|
||||||
|
"product": "Robotzaimer",
|
||||||
|
"pkg": "robot.zaimer.ru",
|
||||||
|
"location": "RU",
|
||||||
|
"bid_type": "",
|
||||||
|
"impression": 20173,
|
||||||
|
"clicks": 79,
|
||||||
|
"conversions": 3,
|
||||||
|
"spend": 0.26,
|
||||||
|
"ctr": 0.00392,
|
||||||
|
"cvr": 0.03797
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-07-28",
|
||||||
|
"channel": "MediaGo",
|
||||||
|
"campid": "91508374",
|
||||||
|
"offer_name": "91508374_Bistro",
|
||||||
|
"product": "Bistro",
|
||||||
|
"pkg": "ru.bistrodengi.my",
|
||||||
|
"location": "RU",
|
||||||
|
"bid_type": "",
|
||||||
|
"impression": 5147,
|
||||||
|
"clicks": 56,
|
||||||
|
"conversions": 1,
|
||||||
|
"spend": 0.17,
|
||||||
|
"ctr": 0.01088,
|
||||||
|
"cvr": 0.01786
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-07-28",
|
||||||
|
"channel": "Mintegral",
|
||||||
|
"campid": "91504360",
|
||||||
|
"offer_name": "91504360_Bistro",
|
||||||
|
"product": "Bistro",
|
||||||
|
"pkg": "ru.bistrodengi.my",
|
||||||
|
"location": "RU",
|
||||||
|
"bid_type": "",
|
||||||
|
"impression": 40376,
|
||||||
|
"clicks": 172,
|
||||||
|
"conversions": 5,
|
||||||
|
"spend": 1.5,
|
||||||
|
"ctr": 0.00426,
|
||||||
|
"cvr": 0.02907
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-07-28",
|
||||||
|
"channel": "Mintegral",
|
||||||
|
"campid": "91505669",
|
||||||
|
"offer_name": "91505669_Robotzaimer",
|
||||||
|
"product": "Robotzaimer",
|
||||||
|
"pkg": "robot.zaimer.ru",
|
||||||
|
"location": "RU",
|
||||||
|
"bid_type": "",
|
||||||
|
"impression": 20173,
|
||||||
|
"clicks": 79,
|
||||||
|
"conversions": 3,
|
||||||
|
"spend": 0.26,
|
||||||
|
"ctr": 0.00392,
|
||||||
|
"cvr": 0.03797
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-07-29",
|
||||||
|
"channel": "Bigo",
|
||||||
|
"campid": "91513416",
|
||||||
|
"offer_name": "91513416_Megapari",
|
||||||
|
"product": "Megapari",
|
||||||
|
"pkg": "org.megapari.client",
|
||||||
|
"location": "BR",
|
||||||
|
"bid_type": "",
|
||||||
|
"impression": 172007,
|
||||||
|
"clicks": 28416,
|
||||||
|
"conversions": 0,
|
||||||
|
"spend": 11.37,
|
||||||
|
"ctr": 0.1652,
|
||||||
|
"cvr": 0.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-07-29",
|
||||||
|
"channel": "Bigo",
|
||||||
|
"campid": "91513412",
|
||||||
|
"offer_name": "91513412_Melbet",
|
||||||
|
"product": "Melbet",
|
||||||
|
"pkg": "org.melbet.client",
|
||||||
|
"location": "RU",
|
||||||
|
"bid_type": "",
|
||||||
|
"impression": 78368,
|
||||||
|
"clicks": 44318,
|
||||||
|
"conversions": 0,
|
||||||
|
"spend": 17.73,
|
||||||
|
"ctr": 0.56551,
|
||||||
|
"cvr": 0.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-07-29",
|
||||||
|
"channel": "Bigo",
|
||||||
|
"campid": "91508373",
|
||||||
|
"offer_name": "91508373_Bistro",
|
||||||
|
"product": "Bistro",
|
||||||
|
"pkg": "ru.bistrodengi.my",
|
||||||
|
"location": "RU",
|
||||||
|
"bid_type": "",
|
||||||
|
"impression": 1136,
|
||||||
|
"clicks": 5,
|
||||||
|
"conversions": 0,
|
||||||
|
"spend": 0.0,
|
||||||
|
"ctr": 0.0044,
|
||||||
|
"cvr": 0.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-07-29",
|
||||||
|
"channel": "MediaGo",
|
||||||
|
"campid": "91508374",
|
||||||
|
"offer_name": "91508374_Bistro",
|
||||||
|
"product": "Bistro",
|
||||||
|
"pkg": "ru.bistrodengi.my",
|
||||||
|
"location": "RU",
|
||||||
|
"bid_type": "",
|
||||||
|
"impression": 33777,
|
||||||
|
"clicks": 29,
|
||||||
|
"conversions": 0,
|
||||||
|
"spend": 0.53,
|
||||||
|
"ctr": 0.00086,
|
||||||
|
"cvr": 0.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-07-29",
|
||||||
|
"channel": "Mintegral",
|
||||||
|
"campid": "91504360",
|
||||||
|
"offer_name": "91504360_Bistro",
|
||||||
|
"product": "Bistro",
|
||||||
|
"pkg": "ru.bistrodengi.my",
|
||||||
|
"location": "RU",
|
||||||
|
"bid_type": "",
|
||||||
|
"impression": 58734,
|
||||||
|
"clicks": 689,
|
||||||
|
"conversions": 5,
|
||||||
|
"spend": 3.0,
|
||||||
|
"ctr": 0.01173,
|
||||||
|
"cvr": 0.00726
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-07-30",
|
||||||
|
"channel": "Bigo",
|
||||||
|
"campid": "91511819",
|
||||||
|
"offer_name": "91511819_Avito",
|
||||||
|
"product": "Avito",
|
||||||
|
"pkg": "se.scmv.morocco",
|
||||||
|
"location": "RU",
|
||||||
|
"bid_type": "",
|
||||||
|
"impression": 170946,
|
||||||
|
"clicks": 2887,
|
||||||
|
"conversions": 0,
|
||||||
|
"spend": 10.12,
|
||||||
|
"ctr": 0.01689,
|
||||||
|
"cvr": 0.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-07-30",
|
||||||
|
"channel": "Bigo",
|
||||||
|
"campid": "91513416",
|
||||||
|
"offer_name": "91513416_Megapari",
|
||||||
|
"product": "Megapari",
|
||||||
|
"pkg": "org.megapari.client",
|
||||||
|
"location": "BR",
|
||||||
|
"bid_type": "",
|
||||||
|
"impression": 42450,
|
||||||
|
"clicks": 2445,
|
||||||
|
"conversions": 0,
|
||||||
|
"spend": 0.98,
|
||||||
|
"ctr": 0.0576,
|
||||||
|
"cvr": 0.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-07-30",
|
||||||
|
"channel": "Bigo",
|
||||||
|
"campid": "91508373",
|
||||||
|
"offer_name": "91508373_Bistro",
|
||||||
|
"product": "Bistro",
|
||||||
|
"pkg": "ru.bistrodengi.my",
|
||||||
|
"location": "RU",
|
||||||
|
"bid_type": "",
|
||||||
|
"impression": 27031,
|
||||||
|
"clicks": 3103,
|
||||||
|
"conversions": 1,
|
||||||
|
"spend": 12.93,
|
||||||
|
"ctr": 0.11479,
|
||||||
|
"cvr": 0.00032
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-07-30",
|
||||||
|
"channel": "Bigo",
|
||||||
|
"campid": "91513412",
|
||||||
|
"offer_name": "91513412_Melbet",
|
||||||
|
"product": "Melbet",
|
||||||
|
"pkg": "org.melbet.client",
|
||||||
|
"location": "RU",
|
||||||
|
"bid_type": "",
|
||||||
|
"impression": 2872,
|
||||||
|
"clicks": 1691,
|
||||||
|
"conversions": 0,
|
||||||
|
"spend": 0.68,
|
||||||
|
"ctr": 0.58879,
|
||||||
|
"cvr": 0.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-07-30",
|
||||||
|
"channel": "MediaGo",
|
||||||
|
"campid": "91508374",
|
||||||
|
"offer_name": "91508374_Bistro",
|
||||||
|
"product": "Bistro",
|
||||||
|
"pkg": "ru.bistrodengi.my",
|
||||||
|
"location": "RU",
|
||||||
|
"bid_type": "",
|
||||||
|
"impression": 5430,
|
||||||
|
"clicks": 15,
|
||||||
|
"conversions": 0,
|
||||||
|
"spend": 0.57,
|
||||||
|
"ctr": 0.00276,
|
||||||
|
"cvr": 0.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-07-30",
|
||||||
|
"channel": "Mintegral",
|
||||||
|
"campid": "91504360",
|
||||||
|
"offer_name": "91504360_Bistro",
|
||||||
|
"product": "Bistro",
|
||||||
|
"pkg": "ru.bistrodengi.my",
|
||||||
|
"location": "RU",
|
||||||
|
"bid_type": "",
|
||||||
|
"impression": 19561,
|
||||||
|
"clicks": 548,
|
||||||
|
"conversions": 9,
|
||||||
|
"spend": 6.75,
|
||||||
|
"ctr": 0.02801,
|
||||||
|
"cvr": 0.01642
|
||||||
|
}
|
||||||
|
]
|
||||||
+129
@@ -0,0 +1,129 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"product": "Bistro",
|
||||||
|
"pkg": "ru.bistrodengi.my",
|
||||||
|
"platform": "Android",
|
||||||
|
"geo": "RU",
|
||||||
|
"pay_mode": "CPI",
|
||||||
|
"price": "0.6",
|
||||||
|
"budget": "-",
|
||||||
|
"status": "Pause",
|
||||||
|
"mmp": "AppsFlyer",
|
||||||
|
"agency_channels": [],
|
||||||
|
"self_channels": [
|
||||||
|
"Mintegral",
|
||||||
|
"Bigo",
|
||||||
|
"MediaGo"
|
||||||
|
],
|
||||||
|
"source": "Voypace投放计划 SO10020030"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"product": "Robotzaimer",
|
||||||
|
"pkg": "robot.zaimer.ru",
|
||||||
|
"platform": "Android",
|
||||||
|
"geo": "RU",
|
||||||
|
"pay_mode": "CPI",
|
||||||
|
"price": "0.3",
|
||||||
|
"budget": "-",
|
||||||
|
"status": "Pause",
|
||||||
|
"mmp": "AppsFlyer",
|
||||||
|
"agency_channels": [],
|
||||||
|
"self_channels": [
|
||||||
|
"Mintegral",
|
||||||
|
"Bigo",
|
||||||
|
"MediaGo"
|
||||||
|
],
|
||||||
|
"source": "Voypace投放计划 SO10020029"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"product": "Melbet",
|
||||||
|
"pkg": "org.melbet.client",
|
||||||
|
"platform": "Android",
|
||||||
|
"geo": "RU",
|
||||||
|
"pay_mode": "CPR",
|
||||||
|
"price": "1",
|
||||||
|
"budget": "-",
|
||||||
|
"status": "Pause",
|
||||||
|
"mmp": "AppsFlyer",
|
||||||
|
"agency_channels": [
|
||||||
|
"Adsterra",
|
||||||
|
"TrafficStars"
|
||||||
|
],
|
||||||
|
"self_channels": [
|
||||||
|
"Bigo"
|
||||||
|
],
|
||||||
|
"source": "Voypace投放计划 SO10020057"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"product": "Avito",
|
||||||
|
"pkg": "se.scmv.morocco",
|
||||||
|
"platform": "Android",
|
||||||
|
"geo": "RU",
|
||||||
|
"pay_mode": "CPI",
|
||||||
|
"price": "0.5",
|
||||||
|
"budget": "-",
|
||||||
|
"status": "Pause",
|
||||||
|
"mmp": "AppsFlyer",
|
||||||
|
"agency_channels": [],
|
||||||
|
"self_channels": [
|
||||||
|
"Mintegral",
|
||||||
|
"Bigo",
|
||||||
|
"MediaGo"
|
||||||
|
],
|
||||||
|
"source": "Voypace投放计划 SO10020058"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"product": "Megapari",
|
||||||
|
"pkg": "org.megapari.client",
|
||||||
|
"platform": "Android",
|
||||||
|
"geo": "BR",
|
||||||
|
"pay_mode": "CPI",
|
||||||
|
"price": "0.4",
|
||||||
|
"budget": "-",
|
||||||
|
"status": "Pause",
|
||||||
|
"mmp": "AppsFlyer",
|
||||||
|
"agency_channels": [
|
||||||
|
"Adsterra"
|
||||||
|
],
|
||||||
|
"self_channels": [
|
||||||
|
"Bigo"
|
||||||
|
],
|
||||||
|
"source": "Voypace投放计划 SO10020059"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"product": "Viu",
|
||||||
|
"pkg": "com.vuclip.viu",
|
||||||
|
"platform": "Android",
|
||||||
|
"geo": "MY / ID",
|
||||||
|
"pay_mode": "CPI / CPA",
|
||||||
|
"price": "0.15 (MY) / 1.5 (ID)",
|
||||||
|
"budget": "-",
|
||||||
|
"status": "Active",
|
||||||
|
"mmp": "AppsFlyer",
|
||||||
|
"agency_channels": [
|
||||||
|
"Eagllwin"
|
||||||
|
],
|
||||||
|
"self_channels": [
|
||||||
|
"Mintegral",
|
||||||
|
"Bigo",
|
||||||
|
"MediaGo"
|
||||||
|
],
|
||||||
|
"source": "Voypace投放计划 SO10020141 / SO10020180"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"product": "Vdrf",
|
||||||
|
"pkg": "com.cathayholdings.vdrf",
|
||||||
|
"platform": "Android",
|
||||||
|
"geo": "VN",
|
||||||
|
"pay_mode": "CPI",
|
||||||
|
"price": "0.3",
|
||||||
|
"budget": "-",
|
||||||
|
"status": "Pause",
|
||||||
|
"mmp": "AppsFlyer",
|
||||||
|
"agency_channels": [],
|
||||||
|
"self_channels": [
|
||||||
|
"Mintegral"
|
||||||
|
],
|
||||||
|
"source": "Voypace投放计划 SO10020140"
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# 一次性:从 运营/Voypace/Voypace投放计划.xlsx 的 Sheet4 生成初始 mtg_data.json
|
||||||
|
# 之后的每日增量由 adscenter_daily_sync.py 负责。
|
||||||
|
import openpyxl, json, os, datetime
|
||||||
|
|
||||||
|
XLSX = "/Users/wangyuan/Documents/SHAREit/运营/Voypace/Voypace投放计划.xlsx"
|
||||||
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
camps = json.load(open(os.path.join(HERE, "campaigns.json")))
|
||||||
|
CAMPS = {r["campid"]: r for r in camps}
|
||||||
|
# 渠道名归一:Sheet4 用 Mediago,campaigns 用 MediaGo
|
||||||
|
CH_NORM = {"mediago": "MediaGo", "mintegral": "Mintegral", "bigo": "Bigo"}
|
||||||
|
# 无 campid 行的兜底:按 (包名, 渠道) 反查 campid
|
||||||
|
BY_PKG_CH = {(r["pkg"], r["channel"]): r["campid"] for r in camps}
|
||||||
|
|
||||||
|
base = datetime.date(1899, 12, 30)
|
||||||
|
def to_date(v):
|
||||||
|
if isinstance(v, (int, float)):
|
||||||
|
return (base + datetime.timedelta(days=int(v))).isoformat()
|
||||||
|
if isinstance(v, (datetime.datetime, datetime.date)):
|
||||||
|
return v.strftime("%Y-%m-%d")
|
||||||
|
return str(v)[:10]
|
||||||
|
|
||||||
|
def num(v):
|
||||||
|
try: return float(str(v).replace(",", "").strip() or 0)
|
||||||
|
except: return 0.0
|
||||||
|
|
||||||
|
wb = openpyxl.load_workbook(XLSX, data_only=True)
|
||||||
|
ws = wb["Sheet4"]
|
||||||
|
rows = list(ws.iter_rows(values_only=True))
|
||||||
|
|
||||||
|
cur_date = None
|
||||||
|
out = {}
|
||||||
|
for r in rows[1:]:
|
||||||
|
# 列: 0时间 1单价 2地区 3包名 4OS 5AdpingID 6投放平台 7Imp 8Clk 9CTR 10Install 11CVR 12Spend 13oCPI
|
||||||
|
if r[0] not in (None, ""):
|
||||||
|
cur_date = to_date(r[0])
|
||||||
|
pkg = (str(r[3]).strip() if r[3] else "")
|
||||||
|
loc = (str(r[2]).strip().upper() if r[2] else "")
|
||||||
|
adping = (str(r[5]).strip() if r[5] else "")
|
||||||
|
ch_raw = (str(r[6]).strip() if r[6] else "")
|
||||||
|
ch = CH_NORM.get(ch_raw.lower(), ch_raw)
|
||||||
|
if not ch or not cur_date:
|
||||||
|
continue
|
||||||
|
# 定位 campid
|
||||||
|
cid = adping.split(".")[0] if adping else ""
|
||||||
|
if cid not in CAMPS:
|
||||||
|
# 兜底:靠 包名+渠道(处理 Avito 那种 campid 为 campaign_name 的行)
|
||||||
|
# 包名可能带 (APK)等后缀,取主体
|
||||||
|
pkg_main = pkg.split("(")[0].split("(")[0].strip()
|
||||||
|
cid = BY_PKG_CH.get((pkg_main, ch), "")
|
||||||
|
if cid not in CAMPS:
|
||||||
|
continue
|
||||||
|
info = CAMPS[cid]
|
||||||
|
imp = int(num(r[7])); clk = int(num(r[8])); cv = int(num(r[10])); sp = round(num(r[12]), 2)
|
||||||
|
if not loc:
|
||||||
|
loc = str(info.get("geo", "")).upper()
|
||||||
|
key = (cur_date, info["channel"], cid, loc)
|
||||||
|
a = out.get(key)
|
||||||
|
if a:
|
||||||
|
a["impression"] += imp; a["clicks"] += clk; a["conversions"] += cv; a["spend"] = round(a["spend"] + sp, 2)
|
||||||
|
else:
|
||||||
|
out[key] = {
|
||||||
|
"date": cur_date, "channel": info["channel"], "campid": cid,
|
||||||
|
"offer_name": f'{cid}_{info["product"]}', "product": info["product"],
|
||||||
|
"pkg": info["pkg"], "location": loc, "bid_type": "",
|
||||||
|
"impression": imp, "clicks": clk, "conversions": cv, "spend": sp,
|
||||||
|
}
|
||||||
|
|
||||||
|
data = []
|
||||||
|
for a in out.values():
|
||||||
|
imp, clk, cv = a["impression"], a["clicks"], a["conversions"]
|
||||||
|
a["ctr"] = round(clk/imp, 5) if imp else 0.0
|
||||||
|
a["cvr"] = round(cv/clk, 5) if clk else 0.0
|
||||||
|
data.append(a)
|
||||||
|
data.sort(key=lambda x: (x["date"], x["channel"], -x["impression"]))
|
||||||
|
json.dump(data, open(os.path.join(HERE, "mtg_data.json"), "w"), ensure_ascii=False, indent=2)
|
||||||
|
print(f"生成 {len(data)} 条 -> mtg_data.json")
|
||||||
Reference in New Issue
Block a user