diff --git a/data.json b/data.json
index 9eda6c2..b8467c8 100644
--- a/data.json
+++ b/data.json
@@ -1,11 +1,11 @@
{
- "title": "Ozon · 过去14天国家投放表现",
- "generated_at": "2026-08-25T15:28:04+08:00",
+ "title": "Ozon · 当月投放表现",
+ "generated_at": "2026-08-26T16:26:52+08:00",
"timezone": "Asia/Shanghai",
"period": {
- "start": "2026-08-11",
- "end": "2026-08-24",
- "days": 14
+ "start": "2026-08-01",
+ "end": "2026-08-25",
+ "days": 25
},
"countries": [
"KZ",
@@ -13,433 +13,784 @@
"KG",
"AM"
],
+ "kpis": {
+ "TOTAL": {
+ "installs": 40028.0,
+ "revenue": 24820.6,
+ "budget_usd": 33930.0,
+ "ffr_percent": 69.72
+ },
+ "KZ": {
+ "installs": 24033.0,
+ "revenue": 16823.1,
+ "budget_usd": 23450.0,
+ "ffr_percent": 71.74
+ },
+ "UZ": {
+ "installs": 10048.0,
+ "revenue": 5024.0,
+ "budget_usd": 7400.0,
+ "ffr_percent": 67.89
+ },
+ "KG": {
+ "installs": 3619.0,
+ "revenue": 1809.5,
+ "budget_usd": 3080.0,
+ "ffr_percent": 58.75
+ },
+ "AM": {
+ "installs": 2328.0,
+ "revenue": 1164.0,
+ "budget_usd": 1850.0,
+ "ffr_percent": 62.92
+ }
+ },
"summary": [
{
"country": "KZ",
- "installs": 13120.0,
- "spend": 9184.0,
- "avg_cac": 821.6667,
- "cac_days": 12
+ "installs": 24033.0,
+ "revenue": 16823.1,
+ "avg_cac": 743.625,
+ "cac_days": 24,
+ "budget_usd": 23450.0,
+ "ffr_percent": 71.74
},
{
"country": "UZ",
- "installs": 5784.0,
- "spend": 2892.0,
- "avg_cac": 5826.25,
- "cac_days": 12
+ "installs": 10048.0,
+ "revenue": 5024.0,
+ "avg_cac": 4621.5833,
+ "cac_days": 24,
+ "budget_usd": 7400.0,
+ "ffr_percent": 67.89
},
{
"country": "KG",
- "installs": 1757.0,
- "spend": 878.5,
- "avg_cac": 5648.2222,
- "cac_days": 9
+ "installs": 3619.0,
+ "revenue": 1809.5,
+ "avg_cac": 5340.2917,
+ "cac_days": 24,
+ "budget_usd": 3080.0,
+ "ffr_percent": 58.75
},
{
"country": "AM",
- "installs": 966.0,
- "spend": 483.0,
- "avg_cac": 2682.6089,
- "cac_days": 9
+ "installs": 2328.0,
+ "revenue": 1164.0,
+ "avg_cac": 2888.4008,
+ "cac_days": 24,
+ "budget_usd": 1850.0,
+ "ffr_percent": 62.92
}
],
"records": [
+ {
+ "date": "2026-08-01",
+ "country": "KZ",
+ "installs": 890.0,
+ "revenue": 623.0,
+ "cac": 640.0
+ },
+ {
+ "date": "2026-08-02",
+ "country": "KZ",
+ "installs": 893.0,
+ "revenue": 625.1,
+ "cac": 486.0
+ },
+ {
+ "date": "2026-08-03",
+ "country": "KZ",
+ "installs": 939.0,
+ "revenue": 657.3,
+ "cac": 592.0
+ },
+ {
+ "date": "2026-08-04",
+ "country": "KZ",
+ "installs": 927.0,
+ "revenue": 648.9,
+ "cac": 620.0
+ },
+ {
+ "date": "2026-08-05",
+ "country": "KZ",
+ "installs": 928.0,
+ "revenue": 649.6,
+ "cac": 542.0
+ },
+ {
+ "date": "2026-08-06",
+ "country": "KZ",
+ "installs": 982.0,
+ "revenue": 687.4,
+ "cac": 672.0
+ },
+ {
+ "date": "2026-08-07",
+ "country": "KZ",
+ "installs": 997.0,
+ "revenue": 697.9,
+ "cac": 561.0
+ },
+ {
+ "date": "2026-08-08",
+ "country": "KZ",
+ "installs": 969.0,
+ "revenue": 678.3,
+ "cac": 573.0
+ },
+ {
+ "date": "2026-08-09",
+ "country": "KZ",
+ "installs": 1055.0,
+ "revenue": 738.5,
+ "cac": 838.0
+ },
+ {
+ "date": "2026-08-10",
+ "country": "KZ",
+ "installs": 1075.0,
+ "revenue": 752.5,
+ "cac": 636.0
+ },
{
"date": "2026-08-11",
"country": "KZ",
"installs": 1027.0,
- "spend": 718.9,
+ "revenue": 718.9,
"cac": 745.0
},
{
"date": "2026-08-12",
"country": "KZ",
"installs": 1034.0,
- "spend": 723.8,
- "cac": 954.0
+ "revenue": 723.8,
+ "cac": 929.0
},
{
"date": "2026-08-13",
"country": "KZ",
"installs": 1097.0,
- "spend": 767.9,
+ "revenue": 767.9,
"cac": 837.0
},
{
"date": "2026-08-14",
"country": "KZ",
"installs": 1072.0,
- "spend": 750.4,
+ "revenue": 750.4,
"cac": 724.0
},
{
"date": "2026-08-15",
"country": "KZ",
"installs": 718.0,
- "spend": 502.6,
- "cac": 837.0
+ "revenue": 502.6,
+ "cac": 788.0
},
{
"date": "2026-08-16",
"country": "KZ",
"installs": 675.0,
- "spend": 472.5,
+ "revenue": 472.5,
"cac": 1062.0
},
{
"date": "2026-08-17",
"country": "KZ",
"installs": 715.0,
- "spend": 500.5,
- "cac": 702.0
+ "revenue": 500.5,
+ "cac": 683.0
},
{
"date": "2026-08-18",
"country": "KZ",
"installs": 682.0,
- "spend": 477.4,
+ "revenue": 477.4,
"cac": 677.0
},
{
"date": "2026-08-19",
"country": "KZ",
"installs": 612.0,
- "spend": 428.4,
- "cac": 597.0
+ "revenue": 428.4,
+ "cac": 582.0
},
{
"date": "2026-08-20",
"country": "KZ",
"installs": 602.0,
- "spend": 421.4,
+ "revenue": 421.4,
"cac": 770.0
},
{
"date": "2026-08-21",
"country": "KZ",
"installs": 1117.0,
- "spend": 781.9,
- "cac": 909.0
+ "revenue": 781.9,
+ "cac": 851.0
},
{
"date": "2026-08-22",
"country": "KZ",
"installs": 1298.0,
- "spend": 908.6,
- "cac": 1046.0
+ "revenue": 908.6,
+ "cac": 856.0
},
{
"date": "2026-08-23",
"country": "KZ",
"installs": 1256.0,
- "spend": 879.2,
- "cac": null
+ "revenue": 879.2,
+ "cac": 1237.0
},
{
"date": "2026-08-24",
"country": "KZ",
"installs": 1215.0,
- "spend": 850.5,
+ "revenue": 850.5,
+ "cac": 946.0
+ },
+ {
+ "date": "2026-08-25",
+ "country": "KZ",
+ "installs": 1258.0,
+ "revenue": 880.6,
"cac": null
},
+ {
+ "date": "2026-08-01",
+ "country": "UZ",
+ "installs": 248.0,
+ "revenue": 124.0,
+ "cac": 3449.0
+ },
+ {
+ "date": "2026-08-02",
+ "country": "UZ",
+ "installs": 278.0,
+ "revenue": 139.0,
+ "cac": 1737.0
+ },
+ {
+ "date": "2026-08-03",
+ "country": "UZ",
+ "installs": 249.0,
+ "revenue": 124.5,
+ "cac": 3574.0
+ },
+ {
+ "date": "2026-08-04",
+ "country": "UZ",
+ "installs": 265.0,
+ "revenue": 132.5,
+ "cac": 2146.0
+ },
+ {
+ "date": "2026-08-05",
+ "country": "UZ",
+ "installs": 345.0,
+ "revenue": 172.5,
+ "cac": 3618.0
+ },
+ {
+ "date": "2026-08-06",
+ "country": "UZ",
+ "installs": 374.0,
+ "revenue": 187.0,
+ "cac": 4598.0
+ },
+ {
+ "date": "2026-08-07",
+ "country": "UZ",
+ "installs": 349.0,
+ "revenue": 174.5,
+ "cac": 2519.0
+ },
+ {
+ "date": "2026-08-08",
+ "country": "UZ",
+ "installs": 402.0,
+ "revenue": 201.0,
+ "cac": 2416.0
+ },
+ {
+ "date": "2026-08-09",
+ "country": "UZ",
+ "installs": 400.0,
+ "revenue": 200.0,
+ "cac": 3257.0
+ },
+ {
+ "date": "2026-08-10",
+ "country": "UZ",
+ "installs": 550.0,
+ "revenue": 275.0,
+ "cac": 4493.0
+ },
{
"date": "2026-08-11",
"country": "UZ",
"installs": 614.0,
- "spend": 307.0,
- "cac": 5103.0
+ "revenue": 307.0,
+ "cac": 4536.0
},
{
"date": "2026-08-12",
"country": "UZ",
"installs": 614.0,
- "spend": 307.0,
+ "revenue": 307.0,
"cac": 3349.0
},
{
"date": "2026-08-13",
"country": "UZ",
"installs": 608.0,
- "spend": 304.0,
+ "revenue": 304.0,
"cac": 7011.0
},
{
"date": "2026-08-14",
"country": "UZ",
"installs": 587.0,
- "spend": 293.5,
+ "revenue": 293.5,
"cac": 2545.0
},
{
"date": "2026-08-15",
"country": "UZ",
"installs": 224.0,
- "spend": 112.0,
+ "revenue": 112.0,
"cac": 3768.0
},
{
"date": "2026-08-16",
"country": "UZ",
"installs": 176.0,
- "spend": 88.0,
+ "revenue": 88.0,
"cac": 0.0
},
{
"date": "2026-08-17",
"country": "UZ",
"installs": 211.0,
- "spend": 105.5,
+ "revenue": 105.5,
"cac": 8147.0
},
{
"date": "2026-08-18",
"country": "UZ",
"installs": 170.0,
- "spend": 85.0,
+ "revenue": 85.0,
"cac": 20293.0
},
{
"date": "2026-08-19",
"country": "UZ",
"installs": 141.0,
- "spend": 70.5,
+ "revenue": 70.5,
"cac": 8912.0
},
{
"date": "2026-08-20",
"country": "UZ",
"installs": 124.0,
- "spend": 62.0,
+ "revenue": 62.0,
"cac": 3829.0
},
{
"date": "2026-08-21",
"country": "UZ",
"installs": 509.0,
- "spend": 254.5,
- "cac": 3233.0
+ "revenue": 254.5,
+ "cac": 2985.0
},
{
"date": "2026-08-22",
"country": "UZ",
"installs": 530.0,
- "spend": 265.0,
- "cac": 3725.0
+ "revenue": 265.0,
+ "cac": 3415.0
},
{
"date": "2026-08-23",
"country": "UZ",
"installs": 572.0,
- "spend": 286.0,
- "cac": null
+ "revenue": 286.0,
+ "cac": 5181.0
},
{
"date": "2026-08-24",
"country": "UZ",
"installs": 704.0,
- "spend": 352.0,
+ "revenue": 352.0,
+ "cac": 5140.0
+ },
+ {
+ "date": "2026-08-25",
+ "country": "UZ",
+ "installs": 804.0,
+ "revenue": 402.0,
"cac": null
},
+ {
+ "date": "2026-08-01",
+ "country": "KG",
+ "installs": 150.0,
+ "revenue": 75.0,
+ "cac": 2203.0
+ },
+ {
+ "date": "2026-08-02",
+ "country": "KG",
+ "installs": 150.0,
+ "revenue": 75.0,
+ "cac": 0.0
+ },
+ {
+ "date": "2026-08-03",
+ "country": "KG",
+ "installs": 143.0,
+ "revenue": 71.5,
+ "cac": 5062.0
+ },
+ {
+ "date": "2026-08-04",
+ "country": "KG",
+ "installs": 162.0,
+ "revenue": 81.0,
+ "cac": 3550.0
+ },
+ {
+ "date": "2026-08-05",
+ "country": "KG",
+ "installs": 165.0,
+ "revenue": 82.5,
+ "cac": 11642.0
+ },
+ {
+ "date": "2026-08-06",
+ "country": "KG",
+ "installs": 176.0,
+ "revenue": 88.0,
+ "cac": 7450.0
+ },
+ {
+ "date": "2026-08-07",
+ "country": "KG",
+ "installs": 170.0,
+ "revenue": 85.0,
+ "cac": 7380.0
+ },
+ {
+ "date": "2026-08-08",
+ "country": "KG",
+ "installs": 162.0,
+ "revenue": 81.0,
+ "cac": 5100.0
+ },
+ {
+ "date": "2026-08-09",
+ "country": "KG",
+ "installs": 171.0,
+ "revenue": 85.5,
+ "cac": 2684.0
+ },
+ {
+ "date": "2026-08-10",
+ "country": "KG",
+ "installs": 197.0,
+ "revenue": 98.5,
+ "cac": 3278.0
+ },
{
"date": "2026-08-11",
"country": "KG",
"installs": 196.0,
- "spend": 98.0,
- "cac": 2537.0
+ "revenue": 98.0,
+ "cac": 6765.0
},
{
"date": "2026-08-12",
"country": "KG",
"installs": 186.0,
- "spend": 93.0,
- "cac": 3633.0
+ "revenue": 93.0,
+ "cac": 7265.0
},
{
"date": "2026-08-13",
"country": "KG",
"installs": 179.0,
- "spend": 89.5,
- "cac": 20857.0
+ "revenue": 89.5,
+ "cac": 0.0
},
{
"date": "2026-08-14",
"country": "KG",
"installs": 167.0,
- "spend": 83.5,
- "cac": 3520.0
+ "revenue": 83.5,
+ "cac": 5280.0
},
{
"date": "2026-08-15",
"country": "KG",
"installs": 31.0,
- "spend": 15.5,
- "cac": 2545.0
+ "revenue": 15.5,
+ "cac": 12724.0
},
{
"date": "2026-08-16",
"country": "KG",
"installs": 26.0,
- "spend": 13.0,
- "cac": 15506.0
+ "revenue": 13.0,
+ "cac": 0.0
},
{
"date": "2026-08-17",
"country": "KG",
"installs": 30.0,
- "spend": 15.0,
+ "revenue": 15.0,
"cac": 0.0
},
{
"date": "2026-08-18",
"country": "KG",
"installs": 31.0,
- "spend": 15.5,
+ "revenue": 15.5,
"cac": 0.0
},
{
"date": "2026-08-19",
"country": "KG",
"installs": 22.0,
- "spend": 11.0,
- "cac": 2236.0
+ "revenue": 11.0,
+ "cac": 4471.0
},
{
"date": "2026-08-20",
"country": "KG",
"installs": 28.0,
- "spend": 14.0,
- "cac": null
+ "revenue": 14.0,
+ "cac": 6257.0
},
{
"date": "2026-08-21",
"country": "KG",
"installs": 192.0,
- "spend": 96.0,
- "cac": null
+ "revenue": 96.0,
+ "cac": 15812.0
},
{
"date": "2026-08-22",
"country": "KG",
"installs": 210.0,
- "spend": 105.0,
- "cac": null
+ "revenue": 105.0,
+ "cac": 9055.0
},
{
"date": "2026-08-23",
"country": "KG",
"installs": 229.0,
- "spend": 114.5,
- "cac": null
+ "revenue": 114.5,
+ "cac": 6249.0
},
{
"date": "2026-08-24",
"country": "KG",
"installs": 230.0,
- "spend": 115.0,
+ "revenue": 115.0,
+ "cac": 5940.0
+ },
+ {
+ "date": "2026-08-25",
+ "country": "KG",
+ "installs": 216.0,
+ "revenue": 108.0,
"cac": null
},
+ {
+ "date": "2026-08-01",
+ "country": "AM",
+ "installs": 84.0,
+ "revenue": 42.0,
+ "cac": 2308.42
+ },
+ {
+ "date": "2026-08-02",
+ "country": "AM",
+ "installs": 81.0,
+ "revenue": 40.5,
+ "cac": 2224.98
+ },
+ {
+ "date": "2026-08-03",
+ "country": "AM",
+ "installs": 127.0,
+ "revenue": 63.5,
+ "cac": 3587.78
+ },
+ {
+ "date": "2026-08-04",
+ "country": "AM",
+ "installs": 122.0,
+ "revenue": 61.0,
+ "cac": 1751.5
+ },
+ {
+ "date": "2026-08-05",
+ "country": "AM",
+ "installs": 107.0,
+ "revenue": 53.5,
+ "cac": 6019.78
+ },
+ {
+ "date": "2026-08-06",
+ "country": "AM",
+ "installs": 143.0,
+ "revenue": 71.5,
+ "cac": 4135.48
+ },
+ {
+ "date": "2026-08-07",
+ "country": "AM",
+ "installs": 141.0,
+ "revenue": 70.5,
+ "cac": 4074.46
+ },
+ {
+ "date": "2026-08-08",
+ "country": "AM",
+ "installs": 136.0,
+ "revenue": 68.0,
+ "cac": 3824.85
+ },
+ {
+ "date": "2026-08-09",
+ "country": "AM",
+ "installs": 129.0,
+ "revenue": 64.5,
+ "cac": 0.0
+ },
+ {
+ "date": "2026-08-10",
+ "country": "AM",
+ "installs": 124.0,
+ "revenue": 62.0,
+ "cac": 7132.05
+ },
{
"date": "2026-08-11",
"country": "AM",
"installs": 136.0,
- "spend": 68.0,
+ "revenue": 68.0,
"cac": 7806.27
},
{
"date": "2026-08-12",
"country": "AM",
"installs": 126.0,
- "spend": 63.0,
+ "revenue": 63.0,
"cac": 3517.38
},
{
"date": "2026-08-13",
"country": "AM",
"installs": 124.0,
- "spend": 62.0,
+ "revenue": 62.0,
"cac": 7146.1
},
{
"date": "2026-08-14",
"country": "AM",
"installs": 112.0,
- "spend": 56.0,
- "cac": 1686.59
+ "revenue": 56.0,
+ "cac": 2248.79
},
{
"date": "2026-08-15",
"country": "AM",
"installs": 19.0,
- "spend": 9.5,
+ "revenue": 9.5,
"cac": 0.0
},
{
"date": "2026-08-16",
"country": "AM",
"installs": 25.0,
- "spend": 12.5,
+ "revenue": 12.5,
"cac": 0.0
},
{
"date": "2026-08-17",
"country": "AM",
"installs": 30.0,
- "spend": 15.0,
+ "revenue": 15.0,
"cac": 0.0
},
{
"date": "2026-08-18",
"country": "AM",
"installs": 28.0,
- "spend": 14.0,
- "cac": 3987.14
+ "revenue": 14.0,
+ "cac": 0.0
},
{
"date": "2026-08-19",
"country": "AM",
"installs": 19.0,
- "spend": 9.5,
+ "revenue": 9.5,
"cac": 0.0
},
{
"date": "2026-08-20",
"country": "AM",
"installs": 15.0,
- "spend": 7.5,
- "cac": null
+ "revenue": 7.5,
+ "cac": 0.0
},
{
"date": "2026-08-21",
"country": "AM",
"installs": 75.0,
- "spend": 37.5,
- "cac": null
+ "revenue": 37.5,
+ "cac": 0.0
},
{
"date": "2026-08-22",
"country": "AM",
"installs": 67.0,
- "spend": 33.5,
- "cac": null
+ "revenue": 33.5,
+ "cac": 2283.09
},
{
"date": "2026-08-23",
"country": "AM",
"installs": 71.0,
- "spend": 35.5,
- "cac": null
+ "revenue": 35.5,
+ "cac": 0.0
},
{
"date": "2026-08-24",
"country": "AM",
"installs": 119.0,
- "spend": 59.5,
+ "revenue": 59.5,
+ "cac": 11260.69
+ },
+ {
+ "date": "2026-08-25",
+ "country": "AM",
+ "installs": 168.0,
+ "revenue": 84.0,
"cac": null
}
],
"definitions": {
- "installs": "Google Sheet 各国家区块的 Install",
- "spend": "Google Sheet 各国家区块的 Daily Spend(USD)",
- "cac": "Google Sheet 各国家区块的 CAC from client report;汇总值为有数据日期的日均 CAC"
+ "period": "当月 1 号至昨天",
+ "installs": "各国家区块的 Install",
+ "revenue": "各国家区块的 Revenue(USD),取源表 Daily Spend 列",
+ "cac": "各国家区块的 CAC from client report 原始值;不改算、不标记为 USD",
+ "budget_usd": "源表 Current Month Budget,按 Total 与各国读取",
+ "ffr_percent": "源表 FFR,按 Total 与各国读取"
}
}
\ No newline at end of file
diff --git a/index.html b/index.html
index 9446f12..25304ed 100644
--- a/index.html
+++ b/index.html
@@ -1,30 +1,250 @@
-
-
-
-
-Ozon 国家投放表现
+Ozon 投放表现
-
-
-
-
Ozon · 国家投放表现
正在加载过去 14 天数据…
数据加载中
-
-
-
-
-
+:root{--bg:#f4f7fb;--card:#fff;--text:#14213d;--muted:#6b7280;--line:#e5eaf1;--blue:#5577ee;--green:#23a673;--pink:#e4568b}
+*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font-family:Inter,"Microsoft YaHei",system-ui,sans-serif}
+.shell{max-width:1380px;margin:auto;padding:26px}
+.hero{display:flex;justify-content:space-between;gap:18px;align-items:end;padding:24px 27px;border-radius:20px;color:#fff;background:linear-gradient(125deg,#111827,#26355e)}
+h1{margin:0 0 6px;font-size:27px}.hero p,.updated{margin:0;color:#cbd5e1;font-size:12px;line-height:1.7}.updated{text-align:right}
+.scope{display:flex;align-items:center;gap:10px;margin:16px 0 0;font-size:13px;color:var(--muted)}
+select{border:1px solid var(--line);background:#fff;padding:8px 11px;border-radius:9px;color:var(--text);font:inherit}
+.grid{display:grid;grid-template-columns:repeat(4,1fr);gap:13px;margin:13px 0}
+.card,.panel{background:var(--card);border:1px solid var(--line);border-radius:15px;box-shadow:0 8px 28px rgba(24,39,75,.05)}
+.card{padding:17px}.label{font-size:12px;color:var(--muted)}.value{font-size:26px;font-weight:800;margin-top:6px}.hint{font-size:11px;color:#94a3b8;margin-top:5px}
+.panel{padding:18px;margin-top:14px}.panel-head{display:flex;justify-content:space-between;align-items:center;gap:12px;margin-bottom:10px}.panel-title{font-size:17px;font-weight:800}
+.chart-wrap{position:relative;overflow:auto}.chart{display:block;width:100%;min-width:760px;height:auto}
+.tip{position:absolute;pointer-events:none;opacity:0;transform:translate(-50%,-100%);background:rgba(17,24,39,.94);color:#fff;font-size:12px;line-height:1.6;padding:8px 11px;border-radius:9px;white-space:nowrap;z-index:5;transition:opacity .12s}
+.legend{text-align:center;color:var(--muted);font-size:12px;margin-top:7px}.legend span{margin:0 9px;white-space:nowrap}
+.dot{display:inline-block;width:9px;height:9px;border-radius:50%;margin-right:5px}
+.tables{display:grid;grid-template-columns:1fr 1.25fr;gap:14px}
+.table-wrap{overflow:auto;max-height:520px;border:1px solid var(--line);border-radius:10px}
+table{width:100%;border-collapse:separate;border-spacing:0;font-size:12px}
+th,td{padding:9px 11px;text-align:right;white-space:nowrap;border-bottom:1px solid #edf0f5}
+th{position:sticky;top:0;background:#f8fafc;color:#64748b;z-index:1}th:first-child,td:first-child{text-align:left}
+.country{font-weight:800}.total-row{background:#f8fafc;font-weight:800}
+.error{margin:40px auto;max-width:700px;text-align:center;padding:44px;color:#b42318}
+.footer{text-align:center;color:#94a3b8;font-size:11px;margin:18px}
+@media(max-width:900px){.grid{grid-template-columns:repeat(2,1fr)}.tables{grid-template-columns:1fr}}
+@media(max-width:560px){.shell{padding:12px}.hero{display:block}.updated{text-align:left;margin-top:12px}.grid{gap:9px}.card{padding:13px}.value{font-size:20px}.panel{padding:13px}.panel-head{flex-direction:column;align-items:flex-start}}
+
+
+
+国家汇总
| 国家 | Install | CPI | Revenue | Budget | Left Budget | FFR | L3D avr Install | Rec. Daily Install |
|---|
+
+
+
+
+
+
+
+Install(左轴,实线)Revenue(右轴 USD,虚线)CAC(最右轴,源表原始值)
+
+
+
+
-
-
+'use strict';
+const $=id=>document.getElementById(id),COUNTRIES=['KZ','UZ','KG','AM'],
+colors={KZ:'#5577ee',UZ:'#23a673',KG:'#f59e0b',AM:'#e4568b'},
+esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])),
+ok=v=>v!==null&&v!==undefined&&v!==''&&Number.isFinite(Number(v)),
+n=v=>ok(v)?Number(v):0,
+num=(v,d=0)=>n(v).toLocaleString('en-US',{minimumFractionDigits:d,maximumFractionDigits:d}),
+usd=v=>ok(v)?'$'+num(v,2):'—',
+pct=v=>ok(v)?num(v,2)+'%':'—';
+let report=null,countries=COUNTRIES.slice();
+
+function records(){return Array.isArray(report.records)?report.records:[]}
+function summary(){return Array.isArray(report.summary)?report.summary:[]}
+function hasRevenue(){return records().some(r=>ok(r.revenue)||ok(r.spend))}
+function scopeRows(scope){const rows=records();return scope==='TOTAL'?rows:rows.filter(r=>r.country===scope)}
+function dates(){return [...new Set(records().filter(r=>r.date).map(r=>r.date))].sort()}
+
+/* 按日期聚合所选范围:Install/Revenue/Spend 求和;TOTAL 下 CAC 不合并 */
+function series(scope){
+ return dates().map(date=>{
+ const rows=scopeRows(scope).filter(r=>r.date===date);
+ const cacRows=rows.filter(r=>ok(r.cac));
+ return {date,
+ installs:rows.reduce((s,r)=>s+n(r.installs),0),
+ revenue:rows.some(r=>ok(r.revenue)||ok(r.spend))?rows.reduce((s,r)=>s+n(ok(r.revenue)?r.revenue:r.spend),0):null,
+ cac:scope==='TOTAL'?null:(cacRows.length?Number(cacRows[0].cac):null)};
+ });
+}
+
+function kpi(scope){
+ const stored=(report.kpis&&report.kpis[scope])||null,rows=scopeRows(scope),
+ sums=field=>{const pick=r=>field==='revenue'&&!ok(r.revenue)?r.spend:r[field];
+ return rows.some(r=>ok(pick(r)))?rows.reduce((s,r)=>s+n(pick(r)),0):null},
+ fromSummary=field=>{const list=scope==='TOTAL'?summary():summary().filter(r=>r.country===scope),
+ vals=list.map(r=>r[field]).filter(ok);return vals.length?vals.reduce((a,b)=>a+Number(b),0):null};
+ const legacy=report.kpis||{};
+ return {
+ installs:stored&&ok(stored.installs)?Number(stored.installs):(scope==='TOTAL'&&ok(legacy.total_installs)?Number(legacy.total_installs):sums('installs')),
+ revenue:stored&&ok(stored.revenue)?Number(stored.revenue):(sums('revenue')!==null?sums('revenue'):(scope==='TOTAL'&&ok(legacy.spend_usd)?Number(legacy.spend_usd):null)),
+ budget:stored&&ok(stored.budget_usd)?Number(stored.budget_usd):(scope==='TOTAL'&&ok(legacy.budget_usd)?Number(legacy.budget_usd):fromSummary('budget_usd')),
+ ffr:stored&&ok(stored.ffr_percent)?Number(stored.ffr_percent):(scope==='TOTAL'&&ok(legacy.ffr_percent)?Number(legacy.ffr_percent):(scope==='TOTAL'?null:fromSummary('ffr_percent')))
+ };
+}
+
+/* 月度天数进度:已统计天数 / 当月总天数 */
+function monthProgress(period){
+ const end=String(period.end||''),match=end.match(/^(\d{4})-(\d{2})-(\d{2})$/);
+ if(!match||!ok(period.days))return '';
+ const year=Number(match[1]),month=Number(match[2]),
+ totalDays=new Date(year,month,0).getDate(),
+ passed=Math.min(Number(period.days),totalDays);
+ return ` · 月度进度 ${num(passed/totalDays*100,1)}%(${passed}/${totalDays} 天)`;
+}
+
+function fillSelect(select,withTotal,totalLabel){
+ select.innerHTML=(withTotal?``:'')+countries.map(c=>``).join('');
+}
+
+function px(i,count,m,iw){return count>1?m.l+i*iw/(count-1):m.l+iw/2}
+function empty(svg,message){svg.innerHTML=`${esc(message)}`}
+
+function xLabels(rows,m,W,H,iw){
+ return rows.map((row,i)=>(i%2===0||i===rows.length-1)
+ ?`${esc(String(row.date).slice(5))}`:'').join('');
+}
+
+function drawMain(){
+ const scope=$('scope').value,svg=$('mainChart'),rows=series(scope).filter(r=>r);
+ if(!rows.length){empty(svg,'暂无趋势数据');return}
+ const W=1100,H=380,m={l:66,r:132,t:22,b:50},iw=W-m.l-m.r,ih=H-m.t-m.b,
+ maxI=Math.max(1,...rows.map(r=>n(r.installs)))*1.12,
+ revVals=rows.filter(r=>ok(r.revenue)).map(r=>Number(r.revenue)),
+ maxR=revVals.length?Math.max(1,...revVals)*1.12:null,
+ cacVals=rows.filter(r=>ok(r.cac)).map(r=>Number(r.cac)),
+ maxC=cacVals.length?Math.max(1,...cacVals)*1.12:null,
+ x=i=>px(i,rows.length,m,iw),
+ yI=v=>m.t+ih-n(v)/maxI*ih,
+ yR=v=>m.t+ih-Number(v)/maxR*ih,
+ yC=v=>m.t+ih-Number(v)/maxC*ih;
+ let h='';
+ for(let i=0;i<=5;i++){
+ const y=m.t+ih*i/5,ratio=1-i/5;
+ h+=``;
+ h+=`${num(maxI*ratio)}`;
+ if(maxR!=null)h+=`${num(maxR*ratio)}`;
+ if(maxC!=null)h+=`${num(maxC*ratio)}`;
+ }
+ h+=xLabels(rows,m,W,H,iw);
+ h+=``;
+ const line=(getY,valid,color,dash)=>{let out='',seg=[];const flush=()=>{if(seg.length>1)out+=``;seg=[]};
+ rows.forEach((r,i)=>{valid(r)?seg.push(`${x(i)},${getY(r)}`):flush()});flush();return out};
+ /* Revenue 常与 Install 成固定比例、走势重合,用虚线让下方的 Install 实线透出 */
+ if(maxR!=null)h+=line(r=>yR(r.revenue),r=>ok(r.revenue),'#23a673','8 5');
+ if(maxC!=null)h+=line(r=>yC(r.cac),r=>ok(r.cac),'#e4568b','3 4');
+ rows.forEach((r,i)=>{
+ if(maxR!=null&&ok(r.revenue))h+=``;
+ if(maxC!=null&&ok(r.cac))h+=``;
+ h+=``;
+ });
+ h+=``;
+ h+=``;
+ svg.innerHTML=h;
+ hover(svg,$('mainTip'),rows,m,iw,'mainCursor',(row)=>
+ `${esc(row.date)} · ${scope==='TOTAL'?'四国合计':scope}
Install:${num(row.installs)}
Revenue:${usd(row.revenue)}
CAC:${scope==='TOTAL'?'按国家查看':(ok(row.cac)?num(row.cac,2):'—')}`);
+}
+
+/* 鼠标移动时定位最近日期,并在光标旁显示当天数值 */
+function hover(svg,tip,rows,m,iw,cursorId,html){
+ const move=event=>{
+ const box=svg.getBoundingClientRect(),vb=svg.viewBox.baseVal,
+ vx=(event.clientX-box.left)/box.width*vb.width;
+ let index=0,best=Infinity;
+ rows.forEach((row,i)=>{const d=Math.abs(px(i,rows.length,m,iw)-vx);if(d{tip.style.opacity='0';const cursor=svg.querySelector('#'+cursorId);if(cursor)cursor.setAttribute('opacity','0')};
+}
+
+function renderKpis(){
+ const scope=$('scope').value,k=kpi(scope),label=scope==='TOTAL'?'四国合计':scope,
+ period=report.period||{},range=`${period.start||'—'} 至 ${period.end||'—'}`;
+ $('scopeHint').textContent=`${label} · ${range}`;
+ $('kpis').innerHTML=[
+ ['Install',ok(k.installs)?num(k.installs):'—',label],
+ ['Revenue',usd(k.revenue),hasRevenue()?label+' · USD':'源表 Revenue 待刷新'],
+ ['Budget',usd(k.budget),ok(k.budget)?'Current Month Budget':'数据源待刷新'],
+ ['FFR',pct(k.ffr),ok(k.ffr)?'源表 FFR':'数据源待刷新']
+ ].map(item=>`${item[0]}
${item[1]}
${esc(item[2])}
`).join('');
+}
+
+/* 当月剩余天数 = 当月总天数 - 已统计天数 */
+function remainingDays(){
+ const period=report.period||{},match=String(period.end||'').match(/^(\d{4})-(\d{2})-(\d{2})$/);
+ if(!match||!ok(period.days))return null;
+ const totalDays=new Date(Number(match[1]),Number(match[2]),0).getDate();
+ return Math.max(0,totalDays-Math.min(Number(period.days),totalDays));
+}
+
+/* 最近 3 个自然日的平均 Install */
+function last3AvgInstall(scope){
+ const list=dates().slice(-3);
+ if(!list.length)return null;
+ const rows=scopeRows(scope).filter(r=>list.includes(r.date));
+ if(!rows.length)return null;
+ return rows.reduce((s,r)=>s+n(r.installs),0)/list.length;
+}
+
+/* 建议日均 Install = 剩余预算 / CPI / 剩余天数 */
+function recDailyInstall(leftBudget,cpi){
+ const left=remainingDays();
+ if(!ok(leftBudget)||!ok(cpi)||cpi<=0||!ok(left)||left<=0)return null;
+ return leftBudget/cpi/left;
+}
+
+function summaryCells(name,installs,revenue,budget,ffr,scope){
+ const cpi=ok(installs)&&installs>0&&ok(revenue)?revenue/installs:null,
+ left=ok(budget)&&ok(revenue)?budget-revenue:null,
+ l3d=last3AvgInstall(scope),
+ rec=recDailyInstall(left,cpi);
+ return `${num(installs)} | ${ok(cpi)?'$'+num(cpi,2):'—'} | ${usd(revenue)} | `
+ +`${usd(budget)} | ${usd(left)} | ${pct(ffr)} | `
+ +`${ok(l3d)?num(l3d):'—'} | ${ok(rec)?num(rec):'—'} | `;
+}
+
+function renderSummary(){
+ const rows=summary(),total=kpi('TOTAL');
+ $('summaryRows').innerHTML=rows.map(row=>{
+ const revenue=ok(row.revenue)?Number(row.revenue):n(row.spend);
+ return `| ${esc(row.country)} | `
+ +summaryCells(row.country,n(row.installs),revenue,ok(row.budget_usd)?Number(row.budget_usd):null,row.ffr_percent,row.country)+'
';
+ }).join('')
+ +`| Total | ${summaryCells('Total',total.installs,total.revenue,total.budget,total.ffr,'TOTAL')}
`;
+}
+
+function renderDetails(){
+ const scope=$('detailScope').value,
+ rows=scopeRows(scope).slice().sort((a,b)=>String(b.date).localeCompare(String(a.date))||String(a.country).localeCompare(String(b.country)));
+ $('detailRows').innerHTML=rows.map(row=>`| ${esc(row.date)} | ${esc(row.country)} | ${num(row.installs)} | ${usd(ok(row.revenue)?row.revenue:row.spend)} | ${ok(row.cac)?num(row.cac,2):'—'} |
`).join('');
+}
+
+function render(){
+ if(Array.isArray(report.countries)&&report.countries.length)countries=report.countries;
+ const period=report.period||{};
+ $('period').textContent=`${period.start||'—'} 至 ${period.end||'—'}${ok(period.days)?` · ${num(period.days)} 天`:''}${monthProgress(period)}`;
+ $('updated').innerHTML=`最近更新:${esc(String(report.generated_at||'—').replace('T',' '))}
时区:${esc(report.timezone||'—')}`;
+ fillSelect($('scope'),true,'Total(四国合计)');
+ fillSelect($('detailScope'),true,'全部国家');
+ renderKpis();renderSummary();renderDetails();drawMain();
+}
+
+$('scope').onchange=()=>{renderKpis();drawMain()};
+$('detailScope').onchange=renderDetails;
+window.addEventListener('resize',()=>{if(report)drawMain()});
+
+fetch('data.json',{cache:'no-store'})
+ .then(response=>{if(!response.ok)throw new Error(`HTTP ${response.status}`);return response.json()})
+ .then(data=>{report=data;render()})
+ .catch(error=>{document.querySelector('.shell').innerHTML=`数据加载失败
${esc(error.message)}
请稍后刷新,页面不会使用旧值冒充最新数据。
`});
+
diff --git a/update.sh b/update.sh
old mode 100755
new mode 100644
index 28513dd..5f2df5e
--- a/update.sh
+++ b/update.sh
@@ -5,135 +5,7 @@ SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
TMP_FILE="$SCRIPT_DIR/data.json.tmp"
OUTPUT_FILE="$SCRIPT_DIR/data.json"
-python3 - "$TMP_FILE" <<'PY'
-import csv
-import json
-import re
-import sys
-import urllib.request
-from collections import defaultdict
-from datetime import datetime, timedelta
-from zoneinfo import ZoneInfo
-
-CSV_URL = (
- "https://docs.google.com/spreadsheets/d/"
- "1KaNOwaZ3DqjTXRK8XhF1OGF9qUmOh0NLXOgxVrh14h0/"
- "export?format=csv&gid=1445753035"
-)
-TIMEZONE = ZoneInfo("Asia/Shanghai")
-DAYS = 14
-BLOCKS = [
- {"country": "KZ", "date": 2, "geo": 3, "installs": 5, "spend": 8, "cac": 10},
- {"country": "UZ", "date": 14, "geo": 15, "installs": 17, "spend": 18, "cac": 20},
- {"country": "KG", "date": 25, "geo": 26, "installs": 28, "spend": 29, "cac": 31},
- {"country": "AM", "date": 35, "geo": 36, "installs": 38, "spend": 39, "cac": 41},
-]
-
-
-def parse_date(value):
- text = str(value or "").strip()
- for fmt in ("%Y/%m/%d", "%Y-%m-%d", "%Y%m%d", "%m/%d/%Y"):
- try:
- return datetime.strptime(text, fmt).date()
- except ValueError:
- pass
- match = re.fullmatch(r"(\d{4})[/.-](\d{1,2})[/.-](\d{1,2})", text)
- if not match:
- return None
- try:
- return datetime(*map(int, match.groups())).date()
- except ValueError:
- return None
-
-
-def parse_number(value):
- text = str(value or "").strip().replace("\xa0", "").replace(",", "")
- if not text or text in {"-", "—"}:
- return None
- negative = text.startswith("(") and text.endswith(")")
- cleaned = re.sub(r"[^0-9.\-]", "", text)
- try:
- number = float(cleaned)
- return -number if negative else number
- except ValueError:
- return None
-
-
-def cell(row, index):
- return row[index] if index < len(row) else ""
-
-
-request = urllib.request.Request(CSV_URL, headers={"User-Agent": "AgentStage-Ozon-Report/1.0"})
-with urllib.request.urlopen(request, timeout=90) as response:
- text = response.read().decode("utf-8-sig")
-rows = list(csv.reader(text.splitlines()))
-
-now = datetime.now(TIMEZONE)
-end_date = now.date() - timedelta(days=1)
-start_date = end_date - timedelta(days=DAYS - 1)
-aggregated = defaultdict(lambda: {"installs": 0.0, "spend": 0.0, "cac_values": []})
-
-for row in rows:
- for block in BLOCKS:
- day = parse_date(cell(row, block["date"]))
- if day is None or not (start_date <= day <= end_date):
- continue
- country = str(cell(row, block["geo"])).strip().upper() or block["country"]
- if country != block["country"]:
- continue
- key = (country, day.isoformat())
- installs = parse_number(cell(row, block["installs"]))
- spend = parse_number(cell(row, block["spend"]))
- cac = parse_number(cell(row, block["cac"]))
- aggregated[key]["installs"] += installs or 0
- aggregated[key]["spend"] += spend or 0
- if cac is not None:
- aggregated[key]["cac_values"].append(cac)
-
-records = []
-for country in [item["country"] for item in BLOCKS]:
- for offset in range(DAYS):
- day = (start_date + timedelta(days=offset)).isoformat()
- values = aggregated.get((country, day), {"installs": 0, "spend": 0, "cac_values": []})
- cac_values = values["cac_values"]
- records.append({
- "date": day,
- "country": country,
- "installs": round(values["installs"], 2),
- "spend": round(values["spend"], 4),
- "cac": round(sum(cac_values) / len(cac_values), 4) if cac_values else None,
- })
-
-summary = []
-for country in [item["country"] for item in BLOCKS]:
- country_rows = [row for row in records if row["country"] == country]
- cac_values = [row["cac"] for row in country_rows if row["cac"] is not None]
- summary.append({
- "country": country,
- "installs": round(sum(row["installs"] for row in country_rows), 2),
- "spend": round(sum(row["spend"] for row in country_rows), 4),
- "avg_cac": round(sum(cac_values) / len(cac_values), 4) if cac_values else None,
- "cac_days": len(cac_values),
- })
-
-payload = {
- "title": "Ozon · 过去14天国家投放表现",
- "generated_at": now.isoformat(timespec="seconds"),
- "timezone": "Asia/Shanghai",
- "period": {"start": start_date.isoformat(), "end": end_date.isoformat(), "days": DAYS},
- "countries": [item["country"] for item in BLOCKS],
- "summary": summary,
- "records": records,
- "definitions": {
- "installs": "Google Sheet 各国家区块的 Install",
- "spend": "Google Sheet 各国家区块的 Daily Spend(USD)",
- "cac": "Google Sheet 各国家区块的 CAC from client report;汇总值为有数据日期的日均 CAC",
- },
-}
-
-with open(sys.argv[1], "w", encoding="utf-8") as handle:
- json.dump(payload, handle, ensure_ascii=False, indent=2)
-PY
+python3 "$SCRIPT_DIR/update_report_data.py" "$TMP_FILE"
mv "$TMP_FILE" "$OUTPUT_FILE"
echo "Updated $OUTPUT_FILE"
diff --git a/update_report_data.py b/update_report_data.py
new file mode 100644
index 0000000..36b8965
--- /dev/null
+++ b/update_report_data.py
@@ -0,0 +1,259 @@
+"""生成 Ozon 报告数据。
+
+取数范围:当月 1 号至昨天。
+指标:Install、Revenue、CAC from client report。
+Revenue 取源表的 Daily Spend 列(该列即收入口径)。
+月度指标:Current Month Budget、FFR,按 Total/KZ/UZ/KG/AM 读取源表。
+
+列位置通过表头识别,不依赖固定列号。脚本不包含任何账号、Token 或私钥。
+"""
+
+import csv
+import json
+import re
+import sys
+import urllib.request
+from datetime import datetime, timedelta
+from zoneinfo import ZoneInfo
+
+CSV_URL = (
+ "https://docs.google.com/spreadsheets/d/"
+ "1KaNOwaZ3DqjTXRK8XhF1OGF9qUmOh0NLXOgxVrh14h0/"
+ "export?format=csv&gid=1445753035"
+)
+TIMEZONE = ZoneInfo("Asia/Shanghai")
+COUNTRIES = ["KZ", "UZ", "KG", "AM"]
+
+DATE_HEADERS = {"date"}
+GEO_HEADERS = {"geo", "country"}
+INSTALL_HEADERS = {"install", "installs"}
+"""Revenue 使用源表 Daily Spend 列;Revenue 表头若存在则优先使用。"""
+REVENUE_HEADERS = {"revenue", "income from client", "income from client($)"}
+REVENUE_FALLBACK_HEADERS = {"daily spend", "daily spend($)", "spend"}
+CAC_HEADERS = {"cac from client report", "cac from client report($)", "cac"}
+BUDGET_LABELS = {"current month budget", "month budget", "budget"}
+FFR_LABELS = {"ffr%", "ffr", "ffr(%)"}
+
+
+def clean(value):
+ return " ".join(str(value or "").replace("\xa0", " ").split())
+
+
+def key(value):
+ return clean(value).lower()
+
+
+def cell(row, index):
+ if index is None or index < 0 or index >= len(row):
+ return ""
+ return row[index]
+
+
+def parse_date(value):
+ text = clean(value)
+ for fmt in ("%Y/%m/%d", "%Y-%m-%d", "%Y%m%d", "%m/%d/%Y", "%d/%m/%Y"):
+ try:
+ return datetime.strptime(text, fmt).date()
+ except ValueError:
+ pass
+ match = re.fullmatch(r"(\d{4})\D{1,3}(\d{1,2})\D{1,3}(\d{1,2})", text)
+ if not match:
+ return None
+ try:
+ return datetime(*map(int, match.groups())).date()
+ except ValueError:
+ return None
+
+
+def parse_number(value):
+ text = clean(value).replace(",", "").replace("%", "")
+ if not text or text in {"-", "—", "#DIV/0!", "#N/A", "#REF!", "#VALUE!"}:
+ return None
+ negative = text.startswith("(") and text.endswith(")")
+ cleaned = re.sub(r"[^0-9.\-]", "", text)
+ if cleaned in {"", "-", ".", "-."}:
+ return None
+ try:
+ number = float(cleaned)
+ except ValueError:
+ return None
+ return -number if negative else number
+
+
+def find_blocks(rows):
+ """按表头识别每个国家的横向数据区块。"""
+ blocks = []
+ for row_index, row in enumerate(rows):
+ date_columns = [i for i, value in enumerate(row) if key(value) in DATE_HEADERS]
+ if not date_columns:
+ continue
+ for position, start in enumerate(date_columns):
+ end = date_columns[position + 1] if position + 1 < len(date_columns) else len(row)
+ block = {"header_row": row_index, "date": start}
+ for column in range(start, end):
+ name = key(cell(row, column))
+ if name in GEO_HEADERS:
+ block.setdefault("geo", column)
+ elif name in INSTALL_HEADERS:
+ block.setdefault("installs", column)
+ elif name in REVENUE_HEADERS:
+ block["revenue"] = column
+ elif name in REVENUE_FALLBACK_HEADERS:
+ block.setdefault("revenue_fallback", column)
+ elif name in CAC_HEADERS:
+ block.setdefault("cac", column)
+ if "revenue" not in block and "revenue_fallback" in block:
+ block["revenue"] = block["revenue_fallback"]
+ if "geo" in block and "installs" in block:
+ blocks.append(block)
+ return blocks
+
+
+def find_monthly_metrics(rows):
+ """读取顶部 Current Month Budget 与 FFR 的 Total/各国数值。"""
+ metrics = {"budget": {}, "ffr": {}}
+ for row_index, row in enumerate(rows[:30]):
+ labels = {key(value) for value in row}
+ if "total" not in labels:
+ continue
+ columns = {}
+ for column, value in enumerate(row):
+ name = clean(value).upper()
+ if name == "TOTAL":
+ columns["TOTAL"] = column
+ elif name in COUNTRIES:
+ columns[name] = column
+ if len(columns) < 2:
+ continue
+ window = rows[row_index: min(row_index + 12, len(rows))]
+ for line in window:
+ labels_in_line = [key(value) for value in line]
+ target = None
+ if any(name in BUDGET_LABELS for name in labels_in_line):
+ target = "budget"
+ elif any(name in FFR_LABELS for name in labels_in_line):
+ target = "ffr"
+ if target is None:
+ continue
+ for name, column in columns.items():
+ number = parse_number(cell(line, column))
+ if number is not None and name not in metrics[target]:
+ metrics[target][name] = number
+ if metrics["budget"] or metrics["ffr"]:
+ break
+ return metrics
+
+
+def build_payload(rows, now):
+ end_date = now.date() - timedelta(days=1)
+ start_date = end_date.replace(day=1)
+ days = (end_date - start_date).days + 1
+
+ blocks = find_blocks(rows)
+ if not blocks:
+ raise SystemExit("未能通过表头识别国家数据区块,已中止,不覆盖旧数据。")
+
+ daily = {}
+ for row in rows:
+ for block in blocks:
+ day = parse_date(cell(row, block["date"]))
+ if day is None or not (start_date <= day <= end_date):
+ continue
+ country = clean(cell(row, block["geo"])).upper()
+ if country not in COUNTRIES:
+ continue
+ item = daily.setdefault(
+ (country, day.isoformat()),
+ {"installs": 0.0, "revenue": 0.0, "cac": None},
+ )
+ item["installs"] += parse_number(cell(row, block.get("installs"))) or 0.0
+ item["revenue"] += parse_number(cell(row, block.get("revenue"))) or 0.0
+ cac = parse_number(cell(row, block.get("cac")))
+ if cac is not None and item["cac"] is None:
+ item["cac"] = cac
+
+ records = []
+ for country in COUNTRIES:
+ for offset in range(days):
+ day = (start_date + timedelta(days=offset)).isoformat()
+ values = daily.get((country, day))
+ records.append({
+ "date": day,
+ "country": country,
+ "installs": round(values["installs"], 2) if values else 0,
+ "revenue": round(values["revenue"], 4) if values else 0,
+ "cac": values["cac"] if values else None,
+ })
+
+ monthly = find_monthly_metrics(rows)
+ summary = []
+ for country in COUNTRIES:
+ country_rows = [row for row in records if row["country"] == country]
+ cac_values = [row["cac"] for row in country_rows if row["cac"] is not None]
+ summary.append({
+ "country": country,
+ "installs": round(sum(row["installs"] for row in country_rows), 2),
+ "revenue": round(sum(row["revenue"] for row in country_rows), 4),
+ "avg_cac": round(sum(cac_values) / len(cac_values), 4) if cac_values else None,
+ "cac_days": len(cac_values),
+ "budget_usd": monthly["budget"].get(country),
+ "ffr_percent": monthly["ffr"].get(country),
+ })
+
+ total_budget = monthly["budget"].get("TOTAL")
+ if total_budget is None:
+ country_budgets = [row["budget_usd"] for row in summary if row["budget_usd"] is not None]
+ total_budget = round(sum(country_budgets), 4) if country_budgets else None
+
+ kpis = {
+ "TOTAL": {
+ "installs": round(sum(row["installs"] for row in records), 2),
+ "revenue": round(sum(row["revenue"] for row in records), 4),
+ "budget_usd": total_budget,
+ "ffr_percent": monthly["ffr"].get("TOTAL"),
+ }
+ }
+ for row in summary:
+ kpis[row["country"]] = {
+ "installs": row["installs"],
+ "revenue": row["revenue"],
+ "budget_usd": row["budget_usd"],
+ "ffr_percent": row["ffr_percent"],
+ }
+
+ return {
+ "title": "Ozon · 当月投放表现",
+ "generated_at": now.isoformat(timespec="seconds"),
+ "timezone": "Asia/Shanghai",
+ "period": {"start": start_date.isoformat(), "end": end_date.isoformat(), "days": days},
+ "countries": COUNTRIES,
+ "kpis": kpis,
+ "summary": summary,
+ "records": records,
+ "definitions": {
+ "period": "当月 1 号至昨天",
+ "installs": "各国家区块的 Install",
+ "revenue": "各国家区块的 Revenue(USD),取源表 Daily Spend 列",
+ "cac": "各国家区块的 CAC from client report 原始值;不改算、不标记为 USD",
+ "budget_usd": "源表 Current Month Budget,按 Total 与各国读取",
+ "ffr_percent": "源表 FFR,按 Total 与各国读取",
+ },
+ }
+
+
+def main():
+ if len(sys.argv) < 2:
+ raise SystemExit("用法: update_report_data.py <输出文件>")
+ request = urllib.request.Request(CSV_URL, headers={"User-Agent": "AgentStage-Ozon-Report/2.0"})
+ with urllib.request.urlopen(request, timeout=90) as response:
+ text = response.read().decode("utf-8-sig")
+ rows = list(csv.reader(text.splitlines()))
+ payload = build_payload(rows, datetime.now(TIMEZONE))
+ with open(sys.argv[1], "w", encoding="utf-8") as handle:
+ json.dump(payload, handle, ensure_ascii=False, indent=2)
+ period = payload["period"]
+ print(f"OK {period['start']}..{period['end']} records={len(payload['records'])}")
+
+
+if __name__ == "__main__":
+ main()