From f53fd78b2a676600c2f8630ec963b095a7d4224f Mon Sep 17 00:00:00 2001 From: SHAREit Date: Wed, 26 Aug 2026 16:48:00 +0800 Subject: [PATCH] Update report UI and month-to-date metrics --- data.json | 567 ++++++++++++++++++++++++++++++++++-------- index.html | 274 ++++++++++++++++++-- update.sh | 130 +--------- update_report_data.py | 259 +++++++++++++++++++ 4 files changed, 966 insertions(+), 264 deletions(-) mode change 100755 => 100644 update.sh create mode 100644 update_report_data.py 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 天数据…

数据加载中
-
总安装
00000
-
14 天趋势
-
国家汇总
国家安装数消耗日均 CAC
每日明细
日期国家安装数消耗CAC
- -
+: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}} +
+

Ozon · 投放表现

正在加载数据…

数据加载中
+ +
国家汇总
国家InstallCPIRevenueBudgetLeft BudgetFFRL3D avr InstallRec. Daily Install
+ +
+
+ +
+
Install · Revenue · CAC
+
+
Install(左轴,实线)Revenue(右轴 USD,虚线)CAC(最右轴,源表原始值)
+
+ +
每日明细
日期国家InstallRevenueCAC
+
- - +'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()