feat: add MTG 10/13 performance data

This commit is contained in:
王源
2026-10-14 17:23:41 +08:00
parent 62373fde87
commit bb10731ddc
3 changed files with 4096 additions and 93 deletions
+142 -1
View File
@@ -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"
) )
+3792 -91
View File
File diff suppressed because it is too large Load Diff
+161
View File
@@ -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');