feat: add MTG 10/13 performance data
This commit is contained in:
+142
-1
@@ -261,19 +261,160 @@ def aggregate(records: list[dict[str, str]], counters: dict[str, int], source: P
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_mtg1013(source: Path, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Parse MTG performance rows and relate them to the order coverage keys."""
|
||||||
|
import csv
|
||||||
|
from decimal import Decimal, InvalidOperation
|
||||||
|
|
||||||
|
if not source.is_file():
|
||||||
|
raise FileNotFoundError(f"MTG CSV not found: {source}")
|
||||||
|
|
||||||
|
required = {
|
||||||
|
"Adping ID",
|
||||||
|
"Pkg Name",
|
||||||
|
"Target Geo",
|
||||||
|
"Valid Clicks",
|
||||||
|
"Postback Installs",
|
||||||
|
"Revenue",
|
||||||
|
"Cost",
|
||||||
|
}
|
||||||
|
coverage_by_key = {
|
||||||
|
(row["pkg"].casefold(), row["country"]): row for row in payload["records"]
|
||||||
|
}
|
||||||
|
|
||||||
|
def number(value: Any, field: str, line: int) -> Decimal:
|
||||||
|
try:
|
||||||
|
return Decimal(clean_text(value) or "0")
|
||||||
|
except InvalidOperation as exc:
|
||||||
|
raise ValueError(f"Invalid {field} on CSV line {line}: {value}") from exc
|
||||||
|
|
||||||
|
def decimal_value(value: Decimal, places: str = "0.0001") -> float:
|
||||||
|
return float(value.quantize(Decimal(places)))
|
||||||
|
|
||||||
|
records: list[dict[str, Any]] = []
|
||||||
|
with source.open("r", encoding="utf-8-sig", newline="") as handle:
|
||||||
|
reader = csv.DictReader(handle)
|
||||||
|
missing = required.difference(reader.fieldnames or [])
|
||||||
|
if missing:
|
||||||
|
raise ValueError(f"Missing MTG CSV columns: {', '.join(sorted(missing))}")
|
||||||
|
for line, row in enumerate(reader, start=2):
|
||||||
|
adpingid = clean_text(row["Adping ID"])
|
||||||
|
pkg = clean_text(row["Pkg Name"])
|
||||||
|
country = clean_text(row["Target Geo"]).upper()
|
||||||
|
if not adpingid or not pkg or not country:
|
||||||
|
raise ValueError(f"Incomplete MTG CSV key on line {line}")
|
||||||
|
if re.match(r"^https?://", pkg, flags=re.IGNORECASE):
|
||||||
|
raise ValueError(f"Invalid MTG CSV pkg URL on line {line}")
|
||||||
|
|
||||||
|
clicks = int(number(row["Valid Clicks"], "Valid Clicks", line))
|
||||||
|
installs = int(number(row["Postback Installs"], "Postback Installs", line))
|
||||||
|
revenue = number(row["Revenue"], "Revenue", line)
|
||||||
|
cost = number(row["Cost"], "Cost", line)
|
||||||
|
key = (pkg.casefold(), country)
|
||||||
|
coverage = coverage_by_key.get(key)
|
||||||
|
records.append(
|
||||||
|
{
|
||||||
|
"adpingid": adpingid,
|
||||||
|
"pkg": pkg,
|
||||||
|
"country": country,
|
||||||
|
"valid_clicks": clicks,
|
||||||
|
"installs": installs,
|
||||||
|
"revenue": decimal_value(revenue),
|
||||||
|
"cost": decimal_value(cost),
|
||||||
|
"profit": decimal_value(revenue - cost),
|
||||||
|
"roas": round(float(revenue / cost), 4) if cost else None,
|
||||||
|
"install_rate": round(installs / clicks, 4) if clicks else None,
|
||||||
|
"order_match": coverage is not None,
|
||||||
|
"adpingid_match": bool(coverage and adpingid in coverage["adpingids"]),
|
||||||
|
"target_present": bool(coverage and coverage["target_present"]),
|
||||||
|
"missing_target": bool(coverage and coverage["missing_target"]),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
records.sort(key=lambda row: (-row["cost"], -row["valid_clicks"], row["adpingid"]))
|
||||||
|
total_clicks = sum(row["valid_clicks"] for row in records)
|
||||||
|
total_installs = sum(row["installs"] for row in records)
|
||||||
|
total_revenue = sum(Decimal(str(row["revenue"])) for row in records)
|
||||||
|
total_cost = sum(Decimal(str(row["cost"])) for row in records)
|
||||||
|
|
||||||
|
by_key: dict[tuple[str, str], list[dict[str, Any]]] = defaultdict(list)
|
||||||
|
by_country: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
||||||
|
for row in records:
|
||||||
|
by_key[(row["pkg"].casefold(), row["country"])].append(row)
|
||||||
|
by_country[row["country"]].append(row)
|
||||||
|
for key, rows in by_key.items():
|
||||||
|
coverage = coverage_by_key.get(key)
|
||||||
|
if not coverage:
|
||||||
|
continue
|
||||||
|
coverage["mtg1013"] = {
|
||||||
|
"adpingids": sort_adpingids({row["adpingid"] for row in rows}),
|
||||||
|
"valid_clicks": sum(row["valid_clicks"] for row in rows),
|
||||||
|
"installs": sum(row["installs"] for row in rows),
|
||||||
|
"revenue": round(sum(row["revenue"] for row in rows), 4),
|
||||||
|
"cost": round(sum(row["cost"] for row in rows), 4),
|
||||||
|
}
|
||||||
|
|
||||||
|
country_summary = []
|
||||||
|
for country, rows in by_country.items():
|
||||||
|
revenue = sum(row["revenue"] for row in rows)
|
||||||
|
cost = sum(row["cost"] for row in rows)
|
||||||
|
country_summary.append(
|
||||||
|
{
|
||||||
|
"country": country,
|
||||||
|
"rows": len(rows),
|
||||||
|
"valid_clicks": sum(row["valid_clicks"] for row in rows),
|
||||||
|
"installs": sum(row["installs"] for row in rows),
|
||||||
|
"revenue": round(revenue, 4),
|
||||||
|
"cost": round(cost, 4),
|
||||||
|
"profit": round(revenue - cost, 4),
|
||||||
|
"roas": round(revenue / cost, 4) if cost else None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
country_summary.sort(key=lambda row: (-row["cost"], row["country"]))
|
||||||
|
|
||||||
|
return {
|
||||||
|
"meta": {
|
||||||
|
"source": source.name,
|
||||||
|
"rows": len(records),
|
||||||
|
"unique_keys": len(by_key),
|
||||||
|
"unique_adpingids": len({row["adpingid"] for row in records}),
|
||||||
|
"matched_order_rows": sum(row["order_match"] for row in records),
|
||||||
|
"matched_adpingid_rows": sum(row["adpingid_match"] for row in records),
|
||||||
|
"unmatched_order_rows": sum(not row["order_match"] for row in records),
|
||||||
|
"valid_clicks": total_clicks,
|
||||||
|
"installs": total_installs,
|
||||||
|
"revenue": decimal_value(total_revenue),
|
||||||
|
"cost": decimal_value(total_cost),
|
||||||
|
"profit": decimal_value(total_revenue - total_cost),
|
||||||
|
"roas": round(float(total_revenue / total_cost), 4) if total_cost else None,
|
||||||
|
"install_rate": round(total_installs / total_clicks, 4) if total_clicks else None,
|
||||||
|
},
|
||||||
|
"country_summary": country_summary,
|
||||||
|
"records": records,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
parser = argparse.ArgumentParser(description=__doc__)
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
parser.add_argument("--source", type=Path, default=DEFAULT_SOURCE, help="Source XLSX workbook")
|
parser.add_argument("--source", type=Path, default=DEFAULT_SOURCE, help="Source XLSX workbook")
|
||||||
|
parser.add_argument(
|
||||||
|
"--mtg-source",
|
||||||
|
type=Path,
|
||||||
|
default=DEFAULT_SOURCE.with_name("mtg1013.csv"),
|
||||||
|
help="MTG 10/13 performance CSV",
|
||||||
|
)
|
||||||
parser.add_argument("--output", type=Path, default=Path(__file__).with_name("data.json"), help="Output JSON")
|
parser.add_argument("--output", type=Path, default=Path(__file__).with_name("data.json"), help="Output JSON")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
records, counters = parse_workbook(args.source)
|
records, counters = parse_workbook(args.source)
|
||||||
payload = aggregate(records, counters, args.source)
|
payload = aggregate(records, counters, args.source)
|
||||||
|
payload["mtg1013"] = parse_mtg1013(args.mtg_source, payload)
|
||||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||||
args.output.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
args.output.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
print(
|
print(
|
||||||
f"Wrote {args.output}: {payload['meta']['pkg_country_count']} pkg-country keys, "
|
f"Wrote {args.output}: {payload['meta']['pkg_country_count']} pkg-country keys, "
|
||||||
f"{payload['meta']['missing_count']} missing {TARGET_PLATFORM}"
|
f"{payload['meta']['missing_count']} missing {TARGET_PLATFORM}, "
|
||||||
|
f"{payload['mtg1013']['meta']['rows']} MTG rows"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+161
@@ -456,6 +456,166 @@
|
|||||||
document.body.append(link); link.click(); link.remove(); URL.revokeObjectURL(url);
|
document.body.append(link); link.click(); link.remove(); URL.revokeObjectURL(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const mtgState = { filtered: [], page: 1 };
|
||||||
|
|
||||||
|
function money(value) {
|
||||||
|
return `$${Number(value || 0).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mtgMatchStatus(record) {
|
||||||
|
if (!record.order_match) return { key: 'unmatched', label: '未关联订单' };
|
||||||
|
if (record.missing_target) return { key: 'missing', label: '待搬单' };
|
||||||
|
if (record.target_present) return { key: 'covered', label: '已覆盖' };
|
||||||
|
return { key: 'matched', label: '已关联' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMtg1013() {
|
||||||
|
const data = state.data.mtg1013;
|
||||||
|
if (!data || !Array.isArray(data.records)) return;
|
||||||
|
const panel = el('section', 'panel workspace');
|
||||||
|
panel.id = 'mtg1013Panel';
|
||||||
|
const header = el('div', 'workspace-head');
|
||||||
|
const head = el('div', 'panel-head');
|
||||||
|
const title = el('div');
|
||||||
|
title.append(
|
||||||
|
el('h2', '', 'MTG 10/13 投放表现'),
|
||||||
|
el('div', 'panel-desc', `数据源:${data.meta.source} · ${nf.format(data.meta.rows)} 条 Adping 明细 · 按 Pkg + 国家关联订单`)
|
||||||
|
);
|
||||||
|
head.append(title);
|
||||||
|
|
||||||
|
const summary = el('section', 'kpis');
|
||||||
|
summary.append(
|
||||||
|
kpi('Valid Clicks', nf.format(data.meta.valid_clicks), `${nf.format(data.meta.rows)} 条明细`),
|
||||||
|
kpi('Postback Installs', nf.format(data.meta.installs), `安装率 ${data.meta.install_rate === null ? '-' : percent(data.meta.install_rate)}`),
|
||||||
|
kpi('Revenue', money(data.meta.revenue), `利润 ${money(data.meta.profit)}`, 'success'),
|
||||||
|
kpi('Cost', money(data.meta.cost), `${nf.format(data.meta.matched_order_rows)} 条已关联订单`),
|
||||||
|
kpi('ROAS', data.meta.roas === null ? '-' : percent(data.meta.roas), `${nf.format(data.meta.unmatched_order_rows)} 条未关联订单`)
|
||||||
|
);
|
||||||
|
summary.style.marginBottom = '16px';
|
||||||
|
|
||||||
|
const filters = el('div', 'filters');
|
||||||
|
const search = el('input', 'control');
|
||||||
|
search.id = 'mtgSearch'; search.type = 'search'; search.placeholder = '搜索 Adping ID 或 Pkg'; search.autocomplete = 'off';
|
||||||
|
const country = selectControl('mtgCountry', '全部国家');
|
||||||
|
[...new Set(data.records.map(row => row.country))].sort().forEach(value => country.append(new Option(value, value)));
|
||||||
|
const match = selectControl('mtgMatch', '');
|
||||||
|
[['all','全部关联状态'], ['matched','已关联订单'], ['missing','关联且待搬单'], ['covered','关联且已覆盖'], ['unmatched','未关联订单']]
|
||||||
|
.forEach(([value,label]) => match.append(new Option(label, value)));
|
||||||
|
const exportButton = el('button', 'button', '导出 MTG 数据');
|
||||||
|
exportButton.id = 'mtgExport'; exportButton.type = 'button';
|
||||||
|
filters.append(search, country, match, exportButton);
|
||||||
|
header.append(head, summary, filters);
|
||||||
|
|
||||||
|
panel.append(header, el('div', 'result-meta'), el('div', 'table-wrap'), el('div', 'pagination'));
|
||||||
|
const footer = document.querySelector('footer');
|
||||||
|
document.getElementById('app').insertBefore(panel, footer);
|
||||||
|
|
||||||
|
search.addEventListener('input', () => { mtgState.page = 1; applyMtgFilters(); });
|
||||||
|
country.addEventListener('change', () => { mtgState.page = 1; applyMtgFilters(); });
|
||||||
|
match.addEventListener('change', () => { mtgState.page = 1; applyMtgFilters(); });
|
||||||
|
exportButton.addEventListener('click', exportMtgCsv);
|
||||||
|
applyMtgFilters();
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyMtgFilters() {
|
||||||
|
const query = document.getElementById('mtgSearch').value.trim().toLocaleLowerCase();
|
||||||
|
const country = document.getElementById('mtgCountry').value;
|
||||||
|
const match = document.getElementById('mtgMatch').value;
|
||||||
|
mtgState.filtered = state.data.mtg1013.records.filter(row => {
|
||||||
|
const status = mtgMatchStatus(row).key;
|
||||||
|
const matched = match === 'all'
|
||||||
|
|| status === match
|
||||||
|
|| (match === 'matched' && row.order_match);
|
||||||
|
return (!query || `${row.adpingid} ${row.pkg}`.toLocaleLowerCase().includes(query))
|
||||||
|
&& (country === 'all' || row.country === country)
|
||||||
|
&& matched;
|
||||||
|
});
|
||||||
|
renderMtgResults();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMtgResults() {
|
||||||
|
const panel = document.getElementById('mtg1013Panel');
|
||||||
|
const totalPages = Math.max(1, Math.ceil(mtgState.filtered.length / PAGE_SIZE));
|
||||||
|
mtgState.page = Math.min(mtgState.page, totalPages);
|
||||||
|
const start = (mtgState.page - 1) * PAGE_SIZE;
|
||||||
|
const rows = mtgState.filtered.slice(start, start + PAGE_SIZE);
|
||||||
|
const meta = panel.querySelector('.result-meta');
|
||||||
|
meta.textContent = '';
|
||||||
|
meta.append(
|
||||||
|
el('span', '', `筛选结果 ${nf.format(mtgState.filtered.length)} 条`),
|
||||||
|
el('span', '', `第 ${mtgState.page} / ${totalPages} 页`)
|
||||||
|
);
|
||||||
|
|
||||||
|
const wrap = panel.querySelector('.table-wrap');
|
||||||
|
wrap.textContent = '';
|
||||||
|
const table = el('table');
|
||||||
|
const thead = el('thead'); const header = el('tr');
|
||||||
|
['Adping ID', 'Pkg', '国家', 'Valid Clicks', 'Postback Installs', '安装率', 'Revenue', 'Cost', '利润', 'ROAS', '订单关联', 'TaurusxDSP 状态']
|
||||||
|
.forEach(label => header.append(el('th', '', label)));
|
||||||
|
thead.append(header); table.append(thead);
|
||||||
|
const tbody = el('tbody');
|
||||||
|
if (!rows.length) {
|
||||||
|
const cell = el('td', 'empty', '没有符合当前条件的 MTG 记录'); cell.colSpan = 12;
|
||||||
|
const row = el('tr'); row.append(cell); tbody.append(row);
|
||||||
|
}
|
||||||
|
rows.forEach(record => {
|
||||||
|
const row = el('tr');
|
||||||
|
const country = el('td'); country.append(el('span', 'country-pill', record.country));
|
||||||
|
const match = mtgMatchStatus(record);
|
||||||
|
const orderStatus = el('td');
|
||||||
|
orderStatus.append(el('span', `badge ${record.order_match ? 'covered' : 'target-only'}`, record.order_match ? (record.adpingid_match ? 'ID 已匹配' : '仅 Pkg_国家匹配') : '未关联'));
|
||||||
|
const targetStatus = el('td');
|
||||||
|
targetStatus.append(el('span', `badge ${match.key === 'unmatched' ? 'target-only' : match.key}`, match.label));
|
||||||
|
row.append(
|
||||||
|
el('td', 'pkg', record.adpingid),
|
||||||
|
el('td', 'pkg', record.pkg),
|
||||||
|
country,
|
||||||
|
el('td', '', nf.format(record.valid_clicks)),
|
||||||
|
el('td', '', nf.format(record.installs)),
|
||||||
|
el('td', '', record.install_rate === null ? '-' : percent(record.install_rate)),
|
||||||
|
el('td', '', money(record.revenue)),
|
||||||
|
el('td', '', money(record.cost)),
|
||||||
|
el('td', '', money(record.profit)),
|
||||||
|
el('td', '', record.roas === null ? '-' : percent(record.roas)),
|
||||||
|
orderStatus,
|
||||||
|
targetStatus
|
||||||
|
);
|
||||||
|
tbody.append(row);
|
||||||
|
});
|
||||||
|
table.append(tbody); wrap.append(table);
|
||||||
|
|
||||||
|
const pager = panel.querySelector('.pagination');
|
||||||
|
pager.textContent = '';
|
||||||
|
const prev = el('button', 'page-button', '‹'); prev.type = 'button'; prev.disabled = mtgState.page <= 1;
|
||||||
|
const next = el('button', 'page-button', '›'); next.type = 'button'; next.disabled = mtgState.page >= totalPages;
|
||||||
|
prev.addEventListener('click', () => { mtgState.page -= 1; renderMtgResults(); panel.scrollIntoView({ behavior: 'smooth' }); });
|
||||||
|
next.addEventListener('click', () => { mtgState.page += 1; renderMtgResults(); panel.scrollIntoView({ behavior: 'smooth' }); });
|
||||||
|
pager.append(prev, el('span', 'panel-desc', `${mtgState.page} / ${totalPages}`), next);
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportMtgCsv() {
|
||||||
|
const headers = ['Adping ID', 'Pkg', '国家', 'Valid Clicks', 'Postback Installs', '安装率', 'Revenue', 'Cost', '利润', 'ROAS', '订单关联', 'TaurusxDSP状态'];
|
||||||
|
const rows = [headers, ...mtgState.filtered.map(record => [
|
||||||
|
record.adpingid,
|
||||||
|
record.pkg,
|
||||||
|
record.country,
|
||||||
|
record.valid_clicks,
|
||||||
|
record.installs,
|
||||||
|
record.install_rate === null ? '' : record.install_rate,
|
||||||
|
record.revenue,
|
||||||
|
record.cost,
|
||||||
|
record.profit,
|
||||||
|
record.roas === null ? '' : record.roas,
|
||||||
|
record.order_match ? (record.adpingid_match ? 'ID已匹配' : '仅Pkg_国家匹配') : '未关联',
|
||||||
|
mtgMatchStatus(record).label
|
||||||
|
])];
|
||||||
|
const csv = '\ufeff' + rows.map(row => row.map(value => `"${String(value).replaceAll('"', '""')}"`).join(',')).join('\n');
|
||||||
|
const url = URL.createObjectURL(new Blob([csv], { type: 'text/csv;charset=utf-8' }));
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url; link.download = `MTG1013_${new Date().toISOString().slice(0,10)}.csv`;
|
||||||
|
document.body.append(link); link.click(); link.remove(); URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
fetch(`data.json?v=${Date.now()}`, { cache: 'no-store' })
|
fetch(`data.json?v=${Date.now()}`, { cache: 'no-store' })
|
||||||
.then(response => {
|
.then(response => {
|
||||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
@@ -465,6 +625,7 @@
|
|||||||
if (!data.meta || !Array.isArray(data.records)) throw new Error('data.json 格式不正确');
|
if (!data.meta || !Array.isArray(data.records)) throw new Error('data.json 格式不正确');
|
||||||
state.data = data;
|
state.data = data;
|
||||||
renderShell();
|
renderShell();
|
||||||
|
renderMtg1013();
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
const app = document.getElementById('app');
|
const app = document.getElementById('app');
|
||||||
|
|||||||
Reference in New Issue
Block a user