From ffdc68521a38afeff255152aaf8ad9c66d2629e8 Mon Sep 17 00:00:00 2001 From: SHAREit Date: Thu, 27 Aug 2026 15:32:56 +0800 Subject: [PATCH] Publish King campaign daily report --- data.json | 1201 +++++++++++++++++++++++++++++++++++++++++ index.html | 283 ++++++++++ schedule.conf | 1 + update.sh | 13 + update_report_data.py | 568 +++++++++++++++++++ 5 files changed, 2066 insertions(+) create mode 100644 data.json create mode 100644 index.html create mode 100644 schedule.conf create mode 100644 update.sh create mode 100644 update_report_data.py diff --git a/data.json b/data.json new file mode 100644 index 0000000..37d91c8 --- /dev/null +++ b/data.json @@ -0,0 +1,1201 @@ +{ + "title": "King · 当月 Campaign 表现", + "generated_at": "2026-08-27T15:26:33+08:00", + "timezone": "Asia/Shanghai", + "period": { + "start": "2026-08-01", + "end": "2026-08-26", + "days": 26 + }, + "sources": { + "client": "public", + "cost": "public", + "budget": "public", + "budget_column_header": "Aug", + "target": "credentials" + }, + "performance": { + "actual": { + "revenue": 90492.35, + "cost": 49342.7704, + "gp": 41149.5796, + "gp_rate": 45.47 + }, + "target": { + "revenue": 329376.0, + "gp": 210801.0, + "gp_rate": 64.0 + }, + "completion": { + "revenue": 27.47, + "gp": 19.52, + "gp_rate": 71.05 + }, + "time_progress_percent": 83.87, + "month_days": 31, + "elapsed_days": 26, + "sands": { + "cost": 49342.7704, + "scope": "King 包合计 ['com.king.candycrushsaga', 'com.king.candycrushsodasaga']", + "pkgs": [ + "com.king.candycrushsaga", + "com.king.candycrushsodasaga" + ], + "missing_pkgs": [], + "fetched_at": "2026-08-27T15:20:08+08:00", + "source_url": "https://sands.hellay.net/#/newSanReports2", + "detail_rows": 100 + } + }, + "kpis": { + "TOTAL": { + "installs": 217455.0, + "cost": 90492.35, + "budget": 134400.0, + "ffr_percent": 67.33, + "cpi": 0.4161 + } + }, + "campaigns": [ + "ccs_de_android_test12112024_bauna_ua_kbroad_all_cpi", + "CCS_MY_ANDROID_TTEST_SHAREIT_15012024", + "CCS_TH_ANDROID_TTEST_SHAREIT_15012024", + "ccss_BR_android_test16122025_bauna_ua_ all_all_cpi", + "ccss_DE_android_test16122025_bauna_ua_ all_all_cpi", + "ccss_TH_android_test16122025_bauna_ua_ all_all_cpi", + "ccss_us_android_test16122025_bauna_ua_ all_all_cpi" + ], + "summary": [ + { + "campaign": "ccs_de_android_test12112024_bauna_ua_kbroad_all_cpi", + "app": "Candy Crush Saga", + "installs": 24070.0, + "cost": 25462.8, + "budget": 31000.0, + "budget_match": "exact", + "ffr_percent": 82.14, + "cpi": 1.0579 + }, + { + "campaign": "CCS_MY_ANDROID_TTEST_SHAREIT_15012024", + "app": "Candy Crush Saga", + "installs": 59122.0, + "cost": 17736.7, + "budget": 31000.0, + "budget_match": "exact", + "ffr_percent": 57.22, + "cpi": 0.3 + }, + { + "campaign": "CCS_TH_ANDROID_TTEST_SHAREIT_15012024", + "app": "Candy Crush Saga", + "installs": 59944.0, + "cost": 17983.5, + "budget": 24800.0, + "budget_match": "exact", + "ffr_percent": 72.51, + "cpi": 0.3 + }, + { + "campaign": "ccss_BR_android_test16122025_bauna_ua_ all_all_cpi", + "app": "Candy Crush Soda Saga", + "installs": 45159.0, + "cost": 7559.78, + "budget": 9300.0, + "budget_match": "brand+geo", + "ffr_percent": 81.29, + "cpi": 0.1674 + }, + { + "campaign": "ccss_DE_android_test16122025_bauna_ua_ all_all_cpi", + "app": "Candy Crush Soda Saga", + "installs": 10445.0, + "cost": 7776.11, + "budget": 15500.0, + "budget_match": "geo", + "ffr_percent": 50.17, + "cpi": 0.7445 + }, + { + "campaign": "ccss_TH_android_test16122025_bauna_ua_ all_all_cpi", + "app": "Candy Crush Soda Saga", + "installs": 4394.0, + "cost": 748.51, + "budget": 9300.0, + "budget_match": "brand+geo", + "ffr_percent": 8.05, + "cpi": 0.1703 + }, + { + "campaign": "ccss_us_android_test16122025_bauna_ua_ all_all_cpi", + "app": "Candy Crush Soda Saga", + "installs": 14321.0, + "cost": 13224.95, + "budget": 13500.0, + "budget_match": "brand+geo", + "ffr_percent": 97.96, + "cpi": 0.9235 + } + ], + "records": [ + { + "date": "2026-08-01", + "campaign": "ccs_de_android_test12112024_bauna_ua_kbroad_all_cpi", + "installs": 762.0, + "cost": 838.2 + }, + { + "date": "2026-08-02", + "campaign": "ccs_de_android_test12112024_bauna_ua_kbroad_all_cpi", + "installs": 799.0, + "cost": 878.9 + }, + { + "date": "2026-08-03", + "campaign": "ccs_de_android_test12112024_bauna_ua_kbroad_all_cpi", + "installs": 736.0, + "cost": 809.6 + }, + { + "date": "2026-08-04", + "campaign": "ccs_de_android_test12112024_bauna_ua_kbroad_all_cpi", + "installs": 851.0, + "cost": 936.1 + }, + { + "date": "2026-08-05", + "campaign": "ccs_de_android_test12112024_bauna_ua_kbroad_all_cpi", + "installs": 953.0, + "cost": 1000.0 + }, + { + "date": "2026-08-06", + "campaign": "ccs_de_android_test12112024_bauna_ua_kbroad_all_cpi", + "installs": 945.0, + "cost": 1000.0 + }, + { + "date": "2026-08-07", + "campaign": "ccs_de_android_test12112024_bauna_ua_kbroad_all_cpi", + "installs": 982.0, + "cost": 1000.0 + }, + { + "date": "2026-08-08", + "campaign": "ccs_de_android_test12112024_bauna_ua_kbroad_all_cpi", + "installs": 986.0, + "cost": 1000.0 + }, + { + "date": "2026-08-09", + "campaign": "ccs_de_android_test12112024_bauna_ua_kbroad_all_cpi", + "installs": 979.0, + "cost": 1000.0 + }, + { + "date": "2026-08-10", + "campaign": "ccs_de_android_test12112024_bauna_ua_kbroad_all_cpi", + "installs": 934.0, + "cost": 1000.0 + }, + { + "date": "2026-08-11", + "campaign": "ccs_de_android_test12112024_bauna_ua_kbroad_all_cpi", + "installs": 899.0, + "cost": 1000.0 + }, + { + "date": "2026-08-12", + "campaign": "ccs_de_android_test12112024_bauna_ua_kbroad_all_cpi", + "installs": 909.0, + "cost": 1000.0 + }, + { + "date": "2026-08-13", + "campaign": "ccs_de_android_test12112024_bauna_ua_kbroad_all_cpi", + "installs": 990.0, + "cost": 1000.0 + }, + { + "date": "2026-08-14", + "campaign": "ccs_de_android_test12112024_bauna_ua_kbroad_all_cpi", + "installs": 966.0, + "cost": 1000.0 + }, + { + "date": "2026-08-15", + "campaign": "ccs_de_android_test12112024_bauna_ua_kbroad_all_cpi", + "installs": 976.0, + "cost": 1000.0 + }, + { + "date": "2026-08-16", + "campaign": "ccs_de_android_test12112024_bauna_ua_kbroad_all_cpi", + "installs": 999.0, + "cost": 1000.0 + }, + { + "date": "2026-08-17", + "campaign": "ccs_de_android_test12112024_bauna_ua_kbroad_all_cpi", + "installs": 926.0, + "cost": 1000.0 + }, + { + "date": "2026-08-18", + "campaign": "ccs_de_android_test12112024_bauna_ua_kbroad_all_cpi", + "installs": 954.0, + "cost": 1000.0 + }, + { + "date": "2026-08-19", + "campaign": "ccs_de_android_test12112024_bauna_ua_kbroad_all_cpi", + "installs": 1000.0, + "cost": 1000.0 + }, + { + "date": "2026-08-20", + "campaign": "ccs_de_android_test12112024_bauna_ua_kbroad_all_cpi", + "installs": 917.0, + "cost": 1000.0 + }, + { + "date": "2026-08-21", + "campaign": "ccs_de_android_test12112024_bauna_ua_kbroad_all_cpi", + "installs": 953.0, + "cost": 1000.0 + }, + { + "date": "2026-08-22", + "campaign": "ccs_de_android_test12112024_bauna_ua_kbroad_all_cpi", + "installs": 941.0, + "cost": 1000.0 + }, + { + "date": "2026-08-23", + "campaign": "ccs_de_android_test12112024_bauna_ua_kbroad_all_cpi", + "installs": 952.0, + "cost": 1000.0 + }, + { + "date": "2026-08-24", + "campaign": "ccs_de_android_test12112024_bauna_ua_kbroad_all_cpi", + "installs": 913.0, + "cost": 1000.0 + }, + { + "date": "2026-08-25", + "campaign": "ccs_de_android_test12112024_bauna_ua_kbroad_all_cpi", + "installs": 939.0, + "cost": 1000.0 + }, + { + "date": "2026-08-26", + "campaign": "ccs_de_android_test12112024_bauna_ua_kbroad_all_cpi", + "installs": 909.0, + "cost": 1000.0 + }, + { + "date": "2026-08-01", + "campaign": "CCS_MY_ANDROID_TTEST_SHAREIT_15012024", + "installs": 2313.0, + "cost": 693.9 + }, + { + "date": "2026-08-02", + "campaign": "CCS_MY_ANDROID_TTEST_SHAREIT_15012024", + "installs": 2058.0, + "cost": 617.4 + }, + { + "date": "2026-08-03", + "campaign": "CCS_MY_ANDROID_TTEST_SHAREIT_15012024", + "installs": 1602.0, + "cost": 480.6 + }, + { + "date": "2026-08-04", + "campaign": "CCS_MY_ANDROID_TTEST_SHAREIT_15012024", + "installs": 1840.0, + "cost": 552.0 + }, + { + "date": "2026-08-05", + "campaign": "CCS_MY_ANDROID_TTEST_SHAREIT_15012024", + "installs": 1674.0, + "cost": 502.2 + }, + { + "date": "2026-08-06", + "campaign": "CCS_MY_ANDROID_TTEST_SHAREIT_15012024", + "installs": 1717.0, + "cost": 515.1 + }, + { + "date": "2026-08-07", + "campaign": "CCS_MY_ANDROID_TTEST_SHAREIT_15012024", + "installs": 1883.0, + "cost": 564.9 + }, + { + "date": "2026-08-08", + "campaign": "CCS_MY_ANDROID_TTEST_SHAREIT_15012024", + "installs": 1959.0, + "cost": 587.7 + }, + { + "date": "2026-08-09", + "campaign": "CCS_MY_ANDROID_TTEST_SHAREIT_15012024", + "installs": 1796.0, + "cost": 538.8 + }, + { + "date": "2026-08-10", + "campaign": "CCS_MY_ANDROID_TTEST_SHAREIT_15012024", + "installs": 1983.0, + "cost": 594.9 + }, + { + "date": "2026-08-11", + "campaign": "CCS_MY_ANDROID_TTEST_SHAREIT_15012024", + "installs": 2079.0, + "cost": 623.7 + }, + { + "date": "2026-08-12", + "campaign": "CCS_MY_ANDROID_TTEST_SHAREIT_15012024", + "installs": 2048.0, + "cost": 614.4 + }, + { + "date": "2026-08-13", + "campaign": "CCS_MY_ANDROID_TTEST_SHAREIT_15012024", + "installs": 2275.0, + "cost": 682.5 + }, + { + "date": "2026-08-14", + "campaign": "CCS_MY_ANDROID_TTEST_SHAREIT_15012024", + "installs": 2585.0, + "cost": 776.0 + }, + { + "date": "2026-08-15", + "campaign": "CCS_MY_ANDROID_TTEST_SHAREIT_15012024", + "installs": 2828.0, + "cost": 848.0 + }, + { + "date": "2026-08-16", + "campaign": "CCS_MY_ANDROID_TTEST_SHAREIT_15012024", + "installs": 2573.0, + "cost": 772.0 + }, + { + "date": "2026-08-17", + "campaign": "CCS_MY_ANDROID_TTEST_SHAREIT_15012024", + "installs": 2497.0, + "cost": 749.0 + }, + { + "date": "2026-08-18", + "campaign": "CCS_MY_ANDROID_TTEST_SHAREIT_15012024", + "installs": 2451.0, + "cost": 735.3 + }, + { + "date": "2026-08-19", + "campaign": "CCS_MY_ANDROID_TTEST_SHAREIT_15012024", + "installs": 2284.0, + "cost": 685.2 + }, + { + "date": "2026-08-20", + "campaign": "CCS_MY_ANDROID_TTEST_SHAREIT_15012024", + "installs": 2661.0, + "cost": 798.3 + }, + { + "date": "2026-08-21", + "campaign": "CCS_MY_ANDROID_TTEST_SHAREIT_15012024", + "installs": 2670.0, + "cost": 801.0 + }, + { + "date": "2026-08-22", + "campaign": "CCS_MY_ANDROID_TTEST_SHAREIT_15012024", + "installs": 2906.0, + "cost": 871.8 + }, + { + "date": "2026-08-23", + "campaign": "CCS_MY_ANDROID_TTEST_SHAREIT_15012024", + "installs": 2841.0, + "cost": 852.3 + }, + { + "date": "2026-08-24", + "campaign": "CCS_MY_ANDROID_TTEST_SHAREIT_15012024", + "installs": 2388.0, + "cost": 716.4 + }, + { + "date": "2026-08-25", + "campaign": "CCS_MY_ANDROID_TTEST_SHAREIT_15012024", + "installs": 2734.0, + "cost": 820.2 + }, + { + "date": "2026-08-26", + "campaign": "CCS_MY_ANDROID_TTEST_SHAREIT_15012024", + "installs": 2477.0, + "cost": 743.1 + }, + { + "date": "2026-08-01", + "campaign": "CCS_TH_ANDROID_TTEST_SHAREIT_15012024", + "installs": 2138.0, + "cost": 641.4 + }, + { + "date": "2026-08-02", + "campaign": "CCS_TH_ANDROID_TTEST_SHAREIT_15012024", + "installs": 1939.0, + "cost": 581.7 + }, + { + "date": "2026-08-03", + "campaign": "CCS_TH_ANDROID_TTEST_SHAREIT_15012024", + "installs": 1424.0, + "cost": 427.2 + }, + { + "date": "2026-08-04", + "campaign": "CCS_TH_ANDROID_TTEST_SHAREIT_15012024", + "installs": 2013.0, + "cost": 603.9 + }, + { + "date": "2026-08-05", + "campaign": "CCS_TH_ANDROID_TTEST_SHAREIT_15012024", + "installs": 2024.0, + "cost": 607.2 + }, + { + "date": "2026-08-06", + "campaign": "CCS_TH_ANDROID_TTEST_SHAREIT_15012024", + "installs": 2042.0, + "cost": 612.6 + }, + { + "date": "2026-08-07", + "campaign": "CCS_TH_ANDROID_TTEST_SHAREIT_15012024", + "installs": 2216.0, + "cost": 664.8 + }, + { + "date": "2026-08-08", + "campaign": "CCS_TH_ANDROID_TTEST_SHAREIT_15012024", + "installs": 2304.0, + "cost": 691.2 + }, + { + "date": "2026-08-09", + "campaign": "CCS_TH_ANDROID_TTEST_SHAREIT_15012024", + "installs": 2042.0, + "cost": 612.6 + }, + { + "date": "2026-08-10", + "campaign": "CCS_TH_ANDROID_TTEST_SHAREIT_15012024", + "installs": 2405.0, + "cost": 721.5 + }, + { + "date": "2026-08-11", + "campaign": "CCS_TH_ANDROID_TTEST_SHAREIT_15012024", + "installs": 2253.0, + "cost": 675.9 + }, + { + "date": "2026-08-12", + "campaign": "CCS_TH_ANDROID_TTEST_SHAREIT_15012024", + "installs": 2236.0, + "cost": 670.8 + }, + { + "date": "2026-08-13", + "campaign": "CCS_TH_ANDROID_TTEST_SHAREIT_15012024", + "installs": 1728.0, + "cost": 518.4 + }, + { + "date": "2026-08-14", + "campaign": "CCS_TH_ANDROID_TTEST_SHAREIT_15012024", + "installs": 2401.0, + "cost": 720.0 + }, + { + "date": "2026-08-15", + "campaign": "CCS_TH_ANDROID_TTEST_SHAREIT_15012024", + "installs": 2423.0, + "cost": 727.0 + }, + { + "date": "2026-08-16", + "campaign": "CCS_TH_ANDROID_TTEST_SHAREIT_15012024", + "installs": 2192.0, + "cost": 658.0 + }, + { + "date": "2026-08-17", + "campaign": "CCS_TH_ANDROID_TTEST_SHAREIT_15012024", + "installs": 2413.0, + "cost": 724.0 + }, + { + "date": "2026-08-18", + "campaign": "CCS_TH_ANDROID_TTEST_SHAREIT_15012024", + "installs": 2535.0, + "cost": 760.5 + }, + { + "date": "2026-08-19", + "campaign": "CCS_TH_ANDROID_TTEST_SHAREIT_15012024", + "installs": 2450.0, + "cost": 735.0 + }, + { + "date": "2026-08-20", + "campaign": "CCS_TH_ANDROID_TTEST_SHAREIT_15012024", + "installs": 2838.0, + "cost": 851.4 + }, + { + "date": "2026-08-21", + "campaign": "CCS_TH_ANDROID_TTEST_SHAREIT_15012024", + "installs": 2647.0, + "cost": 794.1 + }, + { + "date": "2026-08-22", + "campaign": "CCS_TH_ANDROID_TTEST_SHAREIT_15012024", + "installs": 2861.0, + "cost": 858.3 + }, + { + "date": "2026-08-23", + "campaign": "CCS_TH_ANDROID_TTEST_SHAREIT_15012024", + "installs": 2733.0, + "cost": 819.9 + }, + { + "date": "2026-08-24", + "campaign": "CCS_TH_ANDROID_TTEST_SHAREIT_15012024", + "installs": 2145.0, + "cost": 643.5 + }, + { + "date": "2026-08-25", + "campaign": "CCS_TH_ANDROID_TTEST_SHAREIT_15012024", + "installs": 2598.0, + "cost": 779.4 + }, + { + "date": "2026-08-26", + "campaign": "CCS_TH_ANDROID_TTEST_SHAREIT_15012024", + "installs": 2944.0, + "cost": 883.2 + }, + { + "date": "2026-08-01", + "campaign": "ccss_BR_android_test16122025_bauna_ua_ all_all_cpi", + "installs": 1726.0, + "cost": 293.42 + }, + { + "date": "2026-08-02", + "campaign": "ccss_BR_android_test16122025_bauna_ua_ all_all_cpi", + "installs": 1988.0, + "cost": 300.0 + }, + { + "date": "2026-08-03", + "campaign": "ccss_BR_android_test16122025_bauna_ua_ all_all_cpi", + "installs": 1543.0, + "cost": 262.31 + }, + { + "date": "2026-08-04", + "campaign": "ccss_BR_android_test16122025_bauna_ua_ all_all_cpi", + "installs": 1643.0, + "cost": 279.31 + }, + { + "date": "2026-08-05", + "campaign": "ccss_BR_android_test16122025_bauna_ua_ all_all_cpi", + "installs": 1840.0, + "cost": 300.0 + }, + { + "date": "2026-08-06", + "campaign": "ccss_BR_android_test16122025_bauna_ua_ all_all_cpi", + "installs": 1785.0, + "cost": 300.0 + }, + { + "date": "2026-08-07", + "campaign": "ccss_BR_android_test16122025_bauna_ua_ all_all_cpi", + "installs": 1514.0, + "cost": 257.38 + }, + { + "date": "2026-08-08", + "campaign": "ccss_BR_android_test16122025_bauna_ua_ all_all_cpi", + "installs": 1658.0, + "cost": 281.86 + }, + { + "date": "2026-08-09", + "campaign": "ccss_BR_android_test16122025_bauna_ua_ all_all_cpi", + "installs": 1969.0, + "cost": 334.73 + }, + { + "date": "2026-08-10", + "campaign": "ccss_BR_android_test16122025_bauna_ua_ all_all_cpi", + "installs": 1697.0, + "cost": 288.49 + }, + { + "date": "2026-08-11", + "campaign": "ccss_BR_android_test16122025_bauna_ua_ all_all_cpi", + "installs": 1479.0, + "cost": 251.43 + }, + { + "date": "2026-08-12", + "campaign": "ccss_BR_android_test16122025_bauna_ua_ all_all_cpi", + "installs": 1800.0, + "cost": 300.0 + }, + { + "date": "2026-08-13", + "campaign": "ccss_BR_android_test16122025_bauna_ua_ all_all_cpi", + "installs": 1788.0, + "cost": 300.0 + }, + { + "date": "2026-08-14", + "campaign": "ccss_BR_android_test16122025_bauna_ua_ all_all_cpi", + "installs": 1624.0, + "cost": 276.0 + }, + { + "date": "2026-08-15", + "campaign": "ccss_BR_android_test16122025_bauna_ua_ all_all_cpi", + "installs": 1729.0, + "cost": 294.0 + }, + { + "date": "2026-08-16", + "campaign": "ccss_BR_android_test16122025_bauna_ua_ all_all_cpi", + "installs": 1903.0, + "cost": 324.0 + }, + { + "date": "2026-08-17", + "campaign": "ccss_BR_android_test16122025_bauna_ua_ all_all_cpi", + "installs": 1696.0, + "cost": 288.0 + }, + { + "date": "2026-08-18", + "campaign": "ccss_BR_android_test16122025_bauna_ua_ all_all_cpi", + "installs": 1616.0, + "cost": 274.72 + }, + { + "date": "2026-08-19", + "campaign": "ccss_BR_android_test16122025_bauna_ua_ all_all_cpi", + "installs": 1800.0, + "cost": 300.0 + }, + { + "date": "2026-08-20", + "campaign": "ccss_BR_android_test16122025_bauna_ua_ all_all_cpi", + "installs": 1852.0, + "cost": 300.0 + }, + { + "date": "2026-08-21", + "campaign": "ccss_BR_android_test16122025_bauna_ua_ all_all_cpi", + "installs": 1693.0, + "cost": 287.81 + }, + { + "date": "2026-08-22", + "campaign": "ccss_BR_android_test16122025_bauna_ua_ all_all_cpi", + "installs": 1759.0, + "cost": 299.03 + }, + { + "date": "2026-08-23", + "campaign": "ccss_BR_android_test16122025_bauna_ua_ all_all_cpi", + "installs": 1944.0, + "cost": 300.0 + }, + { + "date": "2026-08-24", + "campaign": "ccss_BR_android_test16122025_bauna_ua_ all_all_cpi", + "installs": 1776.0, + "cost": 300.0 + }, + { + "date": "2026-08-25", + "campaign": "ccss_BR_android_test16122025_bauna_ua_ all_all_cpi", + "installs": 1607.0, + "cost": 273.19 + }, + { + "date": "2026-08-26", + "campaign": "ccss_BR_android_test16122025_bauna_ua_ all_all_cpi", + "installs": 1730.0, + "cost": 294.1 + }, + { + "date": "2026-08-01", + "campaign": "ccss_DE_android_test16122025_bauna_ua_ all_all_cpi", + "installs": 377.0, + "cost": 252.59 + }, + { + "date": "2026-08-02", + "campaign": "ccss_DE_android_test16122025_bauna_ua_ all_all_cpi", + "installs": 406.0, + "cost": 272.02 + }, + { + "date": "2026-08-03", + "campaign": "ccss_DE_android_test16122025_bauna_ua_ all_all_cpi", + "installs": 335.0, + "cost": 224.45 + }, + { + "date": "2026-08-04", + "campaign": "ccss_DE_android_test16122025_bauna_ua_ all_all_cpi", + "installs": 378.0, + "cost": 264.6 + }, + { + "date": "2026-08-05", + "campaign": "ccss_DE_android_test16122025_bauna_ua_ all_all_cpi", + "installs": 387.0, + "cost": 270.9 + }, + { + "date": "2026-08-06", + "campaign": "ccss_DE_android_test16122025_bauna_ua_ all_all_cpi", + "installs": 440.0, + "cost": 308.0 + }, + { + "date": "2026-08-07", + "campaign": "ccss_DE_android_test16122025_bauna_ua_ all_all_cpi", + "installs": 427.0, + "cost": 311.71 + }, + { + "date": "2026-08-08", + "campaign": "ccss_DE_android_test16122025_bauna_ua_ all_all_cpi", + "installs": 386.0, + "cost": 289.5 + }, + { + "date": "2026-08-09", + "campaign": "ccss_DE_android_test16122025_bauna_ua_ all_all_cpi", + "installs": 415.0, + "cost": 302.95 + }, + { + "date": "2026-08-10", + "campaign": "ccss_DE_android_test16122025_bauna_ua_ all_all_cpi", + "installs": 407.0, + "cost": 297.11 + }, + { + "date": "2026-08-11", + "campaign": "ccss_DE_android_test16122025_bauna_ua_ all_all_cpi", + "installs": 401.0, + "cost": 292.73 + }, + { + "date": "2026-08-12", + "campaign": "ccss_DE_android_test16122025_bauna_ua_ all_all_cpi", + "installs": 408.0, + "cost": 297.84 + }, + { + "date": "2026-08-13", + "campaign": "ccss_DE_android_test16122025_bauna_ua_ all_all_cpi", + "installs": 422.0, + "cost": 308.06 + }, + { + "date": "2026-08-14", + "campaign": "ccss_DE_android_test16122025_bauna_ua_ all_all_cpi", + "installs": 370.0, + "cost": 270.0 + }, + { + "date": "2026-08-15", + "campaign": "ccss_DE_android_test16122025_bauna_ua_ all_all_cpi", + "installs": 392.0, + "cost": 286.0 + }, + { + "date": "2026-08-16", + "campaign": "ccss_DE_android_test16122025_bauna_ua_ all_all_cpi", + "installs": 417.0, + "cost": 313.0 + }, + { + "date": "2026-08-17", + "campaign": "ccss_DE_android_test16122025_bauna_ua_ all_all_cpi", + "installs": 383.0, + "cost": 280.0 + }, + { + "date": "2026-08-18", + "campaign": "ccss_DE_android_test16122025_bauna_ua_ all_all_cpi", + "installs": 411.0, + "cost": 308.25 + }, + { + "date": "2026-08-19", + "campaign": "ccss_DE_android_test16122025_bauna_ua_ all_all_cpi", + "installs": 429.0, + "cost": 343.2 + }, + { + "date": "2026-08-20", + "campaign": "ccss_DE_android_test16122025_bauna_ua_ all_all_cpi", + "installs": 434.0, + "cost": 347.2 + }, + { + "date": "2026-08-21", + "campaign": "ccss_DE_android_test16122025_bauna_ua_ all_all_cpi", + "installs": 416.0, + "cost": 332.8 + }, + { + "date": "2026-08-22", + "campaign": "ccss_DE_android_test16122025_bauna_ua_ all_all_cpi", + "installs": 429.0, + "cost": 343.2 + }, + { + "date": "2026-08-23", + "campaign": "ccss_DE_android_test16122025_bauna_ua_ all_all_cpi", + "installs": 423.0, + "cost": 338.4 + }, + { + "date": "2026-08-24", + "campaign": "ccss_DE_android_test16122025_bauna_ua_ all_all_cpi", + "installs": 365.0, + "cost": 292.0 + }, + { + "date": "2026-08-25", + "campaign": "ccss_DE_android_test16122025_bauna_ua_ all_all_cpi", + "installs": 369.0, + "cost": 295.2 + }, + { + "date": "2026-08-26", + "campaign": "ccss_DE_android_test16122025_bauna_ua_ all_all_cpi", + "installs": 418.0, + "cost": 334.4 + }, + { + "date": "2026-08-01", + "campaign": "ccss_TH_android_test16122025_bauna_ua_ all_all_cpi", + "installs": 200.0, + "cost": 34.0 + }, + { + "date": "2026-08-02", + "campaign": "ccss_TH_android_test16122025_bauna_ua_ all_all_cpi", + "installs": 223.0, + "cost": 37.91 + }, + { + "date": "2026-08-03", + "campaign": "ccss_TH_android_test16122025_bauna_ua_ all_all_cpi", + "installs": 178.0, + "cost": 30.26 + }, + { + "date": "2026-08-04", + "campaign": "ccss_TH_android_test16122025_bauna_ua_ all_all_cpi", + "installs": 175.0, + "cost": 29.75 + }, + { + "date": "2026-08-05", + "campaign": "ccss_TH_android_test16122025_bauna_ua_ all_all_cpi", + "installs": 216.0, + "cost": 36.72 + }, + { + "date": "2026-08-06", + "campaign": "ccss_TH_android_test16122025_bauna_ua_ all_all_cpi", + "installs": 226.0, + "cost": 38.42 + }, + { + "date": "2026-08-07", + "campaign": "ccss_TH_android_test16122025_bauna_ua_ all_all_cpi", + "installs": 151.0, + "cost": 25.67 + }, + { + "date": "2026-08-08", + "campaign": "ccss_TH_android_test16122025_bauna_ua_ all_all_cpi", + "installs": 189.0, + "cost": 32.13 + }, + { + "date": "2026-08-09", + "campaign": "ccss_TH_android_test16122025_bauna_ua_ all_all_cpi", + "installs": 245.0, + "cost": 41.65 + }, + { + "date": "2026-08-10", + "campaign": "ccss_TH_android_test16122025_bauna_ua_ all_all_cpi", + "installs": 210.0, + "cost": 35.7 + }, + { + "date": "2026-08-11", + "campaign": "ccss_TH_android_test16122025_bauna_ua_ all_all_cpi", + "installs": 144.0, + "cost": 24.48 + }, + { + "date": "2026-08-12", + "campaign": "ccss_TH_android_test16122025_bauna_ua_ all_all_cpi", + "installs": 194.0, + "cost": 32.98 + }, + { + "date": "2026-08-13", + "campaign": "ccss_TH_android_test16122025_bauna_ua_ all_all_cpi", + "installs": 203.0, + "cost": 34.51 + }, + { + "date": "2026-08-14", + "campaign": "ccss_TH_android_test16122025_bauna_ua_ all_all_cpi", + "installs": 139.0, + "cost": 24.0 + }, + { + "date": "2026-08-15", + "campaign": "ccss_TH_android_test16122025_bauna_ua_ all_all_cpi", + "installs": 186.0, + "cost": 32.0 + }, + { + "date": "2026-08-16", + "campaign": "ccss_TH_android_test16122025_bauna_ua_ all_all_cpi", + "installs": 209.0, + "cost": 36.0 + }, + { + "date": "2026-08-17", + "campaign": "ccss_TH_android_test16122025_bauna_ua_ all_all_cpi", + "installs": 157.0, + "cost": 27.0 + }, + { + "date": "2026-08-18", + "campaign": "ccss_TH_android_test16122025_bauna_ua_ all_all_cpi", + "installs": 99.0, + "cost": 16.83 + }, + { + "date": "2026-08-19", + "campaign": "ccss_TH_android_test16122025_bauna_ua_ all_all_cpi", + "installs": 136.0, + "cost": 23.12 + }, + { + "date": "2026-08-20", + "campaign": "ccss_TH_android_test16122025_bauna_ua_ all_all_cpi", + "installs": 150.0, + "cost": 25.5 + }, + { + "date": "2026-08-21", + "campaign": "ccss_TH_android_test16122025_bauna_ua_ all_all_cpi", + "installs": 153.0, + "cost": 26.01 + }, + { + "date": "2026-08-22", + "campaign": "ccss_TH_android_test16122025_bauna_ua_ all_all_cpi", + "installs": 167.0, + "cost": 28.39 + }, + { + "date": "2026-08-23", + "campaign": "ccss_TH_android_test16122025_bauna_ua_ all_all_cpi", + "installs": 205.0, + "cost": 34.85 + }, + { + "date": "2026-08-24", + "campaign": "ccss_TH_android_test16122025_bauna_ua_ all_all_cpi", + "installs": 82.0, + "cost": 13.94 + }, + { + "date": "2026-08-25", + "campaign": "ccss_TH_android_test16122025_bauna_ua_ all_all_cpi", + "installs": 71.0, + "cost": 12.07 + }, + { + "date": "2026-08-26", + "campaign": "ccss_TH_android_test16122025_bauna_ua_ all_all_cpi", + "installs": 86.0, + "cost": 14.62 + }, + { + "date": "2026-08-01", + "campaign": "ccss_us_android_test16122025_bauna_ua_ all_all_cpi", + "installs": 753.0, + "cost": 750.0 + }, + { + "date": "2026-08-02", + "campaign": "ccss_us_android_test16122025_bauna_ua_ all_all_cpi", + "installs": 834.0, + "cost": 750.0 + }, + { + "date": "2026-08-03", + "campaign": "ccss_us_android_test16122025_bauna_ua_ all_all_cpi", + "installs": 790.0, + "cost": 750.0 + }, + { + "date": "2026-08-04", + "campaign": "ccss_us_android_test16122025_bauna_ua_ all_all_cpi", + "installs": 861.0, + "cost": 750.0 + }, + { + "date": "2026-08-05", + "campaign": "ccss_us_android_test16122025_bauna_ua_ all_all_cpi", + "installs": 796.0, + "cost": 750.0 + }, + { + "date": "2026-08-06", + "campaign": "ccss_us_android_test16122025_bauna_ua_ all_all_cpi", + "installs": 820.0, + "cost": 750.0 + }, + { + "date": "2026-08-07", + "campaign": "ccss_us_android_test16122025_bauna_ua_ all_all_cpi", + "installs": 754.0, + "cost": 750.0 + }, + { + "date": "2026-08-08", + "campaign": "ccss_us_android_test16122025_bauna_ua_ all_all_cpi", + "installs": 818.0, + "cost": 750.0 + }, + { + "date": "2026-08-09", + "campaign": "ccss_us_android_test16122025_bauna_ua_ all_all_cpi", + "installs": 838.0, + "cost": 750.0 + }, + { + "date": "2026-08-10", + "campaign": "ccss_us_android_test16122025_bauna_ua_ all_all_cpi", + "installs": 904.0, + "cost": 750.0 + }, + { + "date": "2026-08-11", + "campaign": "ccss_us_android_test16122025_bauna_ua_ all_all_cpi", + "installs": 678.0, + "cost": 750.0 + }, + { + "date": "2026-08-12", + "campaign": "ccss_us_android_test16122025_bauna_ua_ all_all_cpi", + "installs": 734.0, + "cost": 750.0 + }, + { + "date": "2026-08-13", + "campaign": "ccss_us_android_test16122025_bauna_ua_ all_all_cpi", + "installs": 801.0, + "cost": 750.0 + }, + { + "date": "2026-08-14", + "campaign": "ccss_us_android_test16122025_bauna_ua_ all_all_cpi", + "installs": 721.0, + "cost": 750.0 + }, + { + "date": "2026-08-15", + "campaign": "ccss_us_android_test16122025_bauna_ua_ all_all_cpi", + "installs": 894.0, + "cost": 750.0 + }, + { + "date": "2026-08-16", + "campaign": "ccss_us_android_test16122025_bauna_ua_ all_all_cpi", + "installs": 960.0, + "cost": 750.0 + }, + { + "date": "2026-08-17", + "campaign": "ccss_us_android_test16122025_bauna_ua_ all_all_cpi", + "installs": 952.0, + "cost": 750.0 + }, + { + "date": "2026-08-18", + "campaign": "ccss_us_android_test16122025_bauna_ua_ all_all_cpi", + "installs": 413.0, + "cost": 474.95 + } + ], + "definitions": { + "period": "当月 1 号至昨天", + "installs": "Cost_Client 表 Installs 列按天汇总", + "cost": "Breakdown_cost【day】按天消耗;缺失时用 Cost_Client 的 Cost 兜底(USD)", + "budget": "forPrebill 表 B 列 Campaign name 对应 Z 列当月预算", + "budget_match": "exact 为同名匹配;brand+geo 或 geo 为近似匹配(预算表命名与投放表不一致时使用)", + "ffr_percent": "累计消耗 ÷ 当月预算 × 100", + "cpi": "累计消耗 ÷ 累计安装", + "excluded": "带“总计/合计/Total”的小计行已排除", + "performance_revenue": "Revenue 取本报表的 Cost 合计(客户口径消耗)", + "performance_cost": "Cost 取 Sands Report 2.0 的 Cost 合计,需本地抓取", + "performance_gp": "GP = Revenue − Cost;GP rate = GP ÷ Revenue × 100", + "performance_target": "Target 来自 Oversea FY2627 Monthly FFR 表 King 行", + "performance_completion": "完成率 = 实际 ÷ Target × 100;Target 为整月目标" + } +} \ No newline at end of file diff --git a/index.html b/index.html new file mode 100644 index 0000000..bb07b15 --- /dev/null +++ b/index.html @@ -0,0 +1,283 @@ + + + +King · Campaign 表现 +
+

King · Campaign 表现

正在加载数据…

数据加载中
+ +
+ + + + +
+
+ + + +
+
Install 与 Cost 趋势
+
+
Install(左轴,实线)Cost(右轴 USD,虚线)
+
+ +
+
Campaign 汇总
+
+ + + + + +
AppCampaignInstallCostCPIBudgetFFR
+
+ +
+ diff --git a/schedule.conf b/schedule.conf new file mode 100644 index 0000000..1d5e644 --- /dev/null +++ b/schedule.conf @@ -0,0 +1 @@ +24 diff --git a/update.sh b/update.sh new file mode 100644 index 0000000..a31a630 --- /dev/null +++ b/update.sh @@ -0,0 +1,13 @@ +#!/bin/sh +set -eu + +# 平台按 schedule.conf 每 24 小时执行一次。 +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +TMP_FILE="$SCRIPT_DIR/data.json.tmp" +OUTPUT_FILE="$SCRIPT_DIR/data.json" + +rm -f "$TMP_FILE" +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..e247384 --- /dev/null +++ b/update_report_data.py @@ -0,0 +1,568 @@ +"""生成 King 各 Campaign 报告数据。 + +取数范围:当月 1 号至昨天。 +指标:Install、Cost(消耗)、Monthly Budget、FFR、CPI。 + +数据来源(同一份 Spreadsheet): +- Cost_Client(gid=0)长表:A=Date, B=Campaign Name, G=Installs, H=Cost, I=App +- Breakdown_cost【day】(gid=1217689915)宽表:按天消耗,作为消耗主来源 +- forPrebill(gid=450129149):B 列 Campaign name,Z 列当月预算 + +读取优先使用公开 CSV 导出;失败时回退到本地 credentials.json(仅本机可用)。 +脚本不包含任何账号、Token 或私钥。 +""" + +import csv +import io +import json +import re +import sys +import urllib.request +from collections import defaultdict +from datetime import datetime, timedelta +from pathlib import Path +from zoneinfo import ZoneInfo + +SPREADSHEET_ID = "1zb6UR9d9j5Xn3ip6LcWEAsXBjLuPhaaQCXXI7C0gyg0" +CLIENT_GID = 0 +COST_GID = 1217689915 +BUDGET_GID = 450129149 +TARGET_SPREADSHEET_ID = "1Bnt94y2ri3ISxF07tbzfVOLKfxaHAo__swNhmSOdccw" +TARGET_GID = 0 +TARGET_KA = "king" +BUDGET_NAME_COL = 1 +BUDGET_VALUE_COL = 25 +TIMEZONE = ZoneInfo("Asia/Shanghai") +SKIP_MARKS = ("总计", "合计", "total") + + +def clean(value): + return " ".join(str(value or "").replace("\xa0", " ").split()) + + +def norm(value): + return re.sub(r"\s+", "", str(value or "")).lower() + + +def cell(row, index): + if index is None or index < 0 or index >= len(row): + return "" + return row[index] + + +def column_letter(index): + if index < 26: + return chr(65 + index) + return chr(64 + index // 26) + chr(65 + index % 26) + + +def parse_date(value): + text = clean(value) + 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,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("$", "").replace("%", "") + if not text or text in {"-", "—"} or text.startswith("#"): + 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 is_subtotal(text): + lowered = clean(text).lower() + return any(mark in lowered for mark in SKIP_MARKS) + + +def fetch_public(gid, spreadsheet_id=SPREADSHEET_ID): + url = ( + f"https://docs.google.com/spreadsheets/d/{spreadsheet_id}" + f"/export?format=csv&gid={gid}" + ) + request = urllib.request.Request(url, headers={"User-Agent": "AgentStage-King-Report/1.0"}) + with urllib.request.urlopen(request, timeout=120) as response: + return list(csv.reader(io.StringIO(response.read().decode("utf-8-sig")))) + + +def fetch_private(gid, spreadsheet_id=SPREADSHEET_ID): + import gspread + from google.oauth2.service_account import Credentials + + here = Path(__file__).resolve() + candidates = [here.parent / "credentials.json"] + [ + parent / "credentials.json" for parent in here.parents + ] + path = next((item for item in candidates if item.exists()), None) + if path is None: + raise FileNotFoundError("未找到 credentials.json") + creds = Credentials.from_service_account_file( + str(path), scopes=["https://www.googleapis.com/auth/spreadsheets.readonly"] + ) + spreadsheet = gspread.authorize(creds).open_by_key(spreadsheet_id) + for worksheet in spreadsheet.worksheets(): + if worksheet.id == gid: + return worksheet.get_all_values() + raise LookupError(f"未找到 gid={gid}") + + +def fetch_rows(gid, label, spreadsheet_id=SPREADSHEET_ID): + try: + rows = fetch_public(gid, spreadsheet_id) + if rows: + print(f" {label}: 公开导出成功,{len(rows)} 行") + return rows, "public" + reason = "空表" + except Exception as error: + reason = error + rows = fetch_private(gid, spreadsheet_id) + print(f" {label}: 公开导出不可用({reason}),已使用本地凭据,{len(rows)} 行") + return rows, "credentials" + + +def find_header(rows, required): + """在前若干行中寻找同时包含指定表头的行,返回列索引映射。""" + for index, row in enumerate(rows[:12]): + found = {} + for column, value in enumerate(row): + name = clean(value).lower() + for key, aliases in required.items(): + if name in aliases and key not in found: + found[key] = column + if all(key in found for key in required): + return index, found + return None, {} + + +def read_client_long(rows, start_date, end_date): + """Cost_Client 长表:每行一个 Campaign 的某一天数据。""" + header_row, columns = find_header( + rows, + { + "date": {"date"}, + "campaign": {"campaign name", "campaign"}, + "installs": {"installs", "install"}, + "cost": {"cost"}, + }, + ) + if header_row is None: + raise SystemExit("Cost_Client 未识别到 Date / Campaign Name / Installs / Cost 表头") + app_column = next( + (i for i, value in enumerate(rows[header_row]) if clean(value).lower() == "app"), None + ) + print( + f" 表头第 {header_row + 1} 行:" + f"日期={column_letter(columns['date'])} Campaign={column_letter(columns['campaign'])} " + f"Install={column_letter(columns['installs'])} Cost={column_letter(columns['cost'])}" + ) + + installs = defaultdict(float) + costs = defaultdict(float) + apps = {} + display = {} + latest = None + for row in rows[header_row + 1:]: + name = clean(cell(row, columns["campaign"])) + if not name or is_subtotal(name): + continue + day = parse_date(cell(row, columns["date"])) + if day is None: + continue + if latest is None or day > latest: + latest = day + if not (start_date <= day <= end_date): + continue + key = norm(name) + display.setdefault(key, name) + if app_column is not None: + app = clean(cell(row, app_column)) + if app and not is_subtotal(app): + apps.setdefault(key, app) + install = parse_number(cell(row, columns["installs"])) + cost = parse_number(cell(row, columns["cost"])) + if install is not None: + installs[(key, day.isoformat())] += install + if cost is not None: + costs[(key, day.isoformat())] += cost + print(f" 数据截止 {latest},命中 {len(display)} 个 Campaign") + return installs, costs, apps, display + + +def read_cost_wide(rows, start_date, end_date): + """Breakdown_cost【day】宽表:日期横向排列。""" + date_row = None + date_columns = {} + for index, row in enumerate(rows[:12]): + found = {} + for column, value in enumerate(row): + day = parse_date(value) + if day is not None: + found[column] = day + if len(found) >= 3: + date_row, date_columns = index, found + break + if date_row is None: + print(" 未识别到日期行,跳过该来源") + return {}, {}, None + + campaign_col = next( + (i for i, value in enumerate(rows[date_row]) if "campaign" in clean(value).lower()), None + ) + if campaign_col is None: + print(" 未识别到 Campaign 列,跳过该来源") + return {}, {}, None + app_col = next( + (i for i, value in enumerate(rows[date_row]) if clean(value).lower() == "app"), None + ) + in_range = {c: d for c, d in date_columns.items() if start_date <= d <= end_date} + latest = max(date_columns.values()) + print( + f" 日期行第 {date_row + 1} 行,Campaign={column_letter(campaign_col)}," + f"命中 {len(in_range)} 个日期列,数据截止 {latest}" + ) + + costs = defaultdict(float) + apps = {} + for row in rows[date_row + 1:]: + name = clean(cell(row, campaign_col)) + if not name or is_subtotal(name): + continue + key = norm(name) + if app_col is not None: + app = clean(cell(row, app_col)) + if app and not is_subtotal(app): + apps.setdefault(key, app) + for column, day in in_range.items(): + number = parse_number(cell(row, column)) + if number is not None: + costs[(key, day.isoformat())] += number + return costs, apps, latest + + +def split_name(name): + """从 Campaign 名提取品牌前缀与国家代码,用于跨命名规则匹配。""" + tokens = [item for item in re.split(r"[_\s]+", clean(name)) if item] + brand = tokens[0].lower() if tokens else "" + geo = "" + for token in tokens[1:3]: + if re.fullmatch(r"[A-Za-z]{2}", token): + geo = token.lower() + break + if not geo and re.fullmatch(r"[A-Za-z]{2}", brand): + # 只写了国家代码的行,例如 B 列为 "DE" + return "", brand + return brand, geo + + +def read_budgets(rows): + header = clean(cell(rows[0], BUDGET_VALUE_COL)) if rows else "" + exact = {} + by_brand_geo = defaultdict(list) + by_geo = defaultdict(list) + for row in rows[1:]: + name = clean(cell(row, BUDGET_NAME_COL)) + if not name or is_subtotal(name): + continue + number = parse_number(cell(row, BUDGET_VALUE_COL)) + if number is None or number <= 0: + continue + exact.setdefault(norm(name), number) + brand, geo = split_name(name) + if brand and geo: + by_brand_geo[(brand, geo)].append(number) + elif geo: + by_geo[geo].append(number) + print( + f" Z 列表头={header or '(空)'},精确 {len(exact)} 条," + f"品牌+国家 {len(by_brand_geo)} 条,仅国家 {len(by_geo)} 条" + ) + return {"exact": exact, "brand_geo": by_brand_geo, "geo": by_geo}, header + + +def resolve_budget(name, budgets): + """三级匹配:精确名 -> 品牌+国家 -> 仅国家(唯一时)。""" + value = budgets["exact"].get(norm(name)) + if value is not None: + return value, "exact" + brand, geo = split_name(name) + if brand and geo: + values = budgets["brand_geo"].get((brand, geo)) + if values and len(set(values)) == 1: + return values[0], "brand+geo" + if geo: + values = budgets["geo"].get(geo) + if values and len(set(values)) == 1: + return values[0], "geo" + return None, None + + +def read_targets(rows): + """解析 Monthly FFR 表:上一行是分组名(Target/Actual),下一行是 Rev/GP/GP%。""" + metric_row = None + for index, row in enumerate(rows[:12]): + names = [clean(value).lower().replace(" ", "") for value in row] + if "rev" in names and "gp" in names: + metric_row = index + break + if metric_row is None: + raise LookupError("未找到 Rev / GP 指标行") + + group_row = rows[metric_row - 1] if metric_row > 0 else [] + columns = {} + current = "" + for column, value in enumerate(rows[metric_row]): + label = clean(cell(group_row, column)) + if label: + current = label.lower() + metric = clean(value).lower().replace(" ", "") + if metric in {"rev", "gp", "gp%"} and current: + columns.setdefault((current, metric), column) + + target_row = None + for row in rows[metric_row + 1:]: + if clean(cell(row, 0)).lower() == TARGET_KA: + target_row = row + break + if target_row is None: + raise LookupError(f"未找到 KA 为 {TARGET_KA} 的行") + + def pick(group, metric): + for (name, key), column in columns.items(): + if key == metric and group in name: + return parse_number(cell(target_row, column)) + return None + + revenue = pick("target", "rev") + gp = pick("target", "gp") + gp_rate = pick("target", "gp%") + print(f" Target: revenue={revenue} gp={gp} gp_rate={gp_rate}") + return {"revenue": revenue, "gp": gp, "gp_rate": gp_rate} + + +def load_sands_cost(): + """读取本地抓取的 Sands Cost(登录站点,服务器无法自动获取)。""" + here = Path(__file__).resolve() + candidates = [here.parent / "sands_cost.json"] + [ + parent / "sands_cost.json" for parent in here.parents + ] + path = next((item for item in candidates if item.exists()), None) + if path is None: + print(" Sands Cost:未找到 sands_cost.json,将留空") + return None + data = json.loads(path.read_text(encoding="utf-8")) + king = data.get("king_total") or {} + # 优先使用 King 两个包的合计;没有则退回整表合计 + if king.get("cost"): + cost, scope = king["cost"], f"King 包合计 {king.get('pkgs')}" + missing = king.get("missing_pkgs") or [] + if missing: + print(f" 警告:Sands 结果中缺少包 {missing},Cost 可能不完整") + else: + cost, scope = data.get("total_cost"), "整表合计" + print(f" Sands Cost:{cost}({scope},抓取于 {data.get('fetched_at') or '未知'})") + return { + "cost": cost, + "scope": scope, + "pkgs": king.get("pkgs"), + "missing_pkgs": king.get("missing_pkgs"), + "fetched_at": data.get("fetched_at"), + "source_url": data.get("source_url"), + "detail_rows": data.get("detail_rows"), + } + + +def build_performance(revenue, sands, targets, period_days, month_days): + """Revenue 取日报 Cost;Cost 取 Sands;GP = Revenue - Cost。""" + cost = sands.get("cost") if sands else None + gp = round(revenue - cost, 4) if cost is not None else None + gp_rate = round(gp / revenue * 100, 2) if gp is not None and revenue else None + + def ratio(actual, target): + if actual is None or not target: + return None + return round(actual / target * 100, 2) + + target_gp_rate = targets.get("gp_rate") if targets else None + if target_gp_rate is not None and target_gp_rate <= 1: + target_gp_rate = round(target_gp_rate * 100, 2) + + return { + "actual": {"revenue": round(revenue, 4), "cost": cost, "gp": gp, "gp_rate": gp_rate}, + "target": { + "revenue": targets.get("revenue") if targets else None, + "gp": targets.get("gp") if targets else None, + "gp_rate": target_gp_rate, + }, + "completion": { + "revenue": ratio(revenue, targets.get("revenue") if targets else None), + "gp": ratio(gp, targets.get("gp") if targets else None), + "gp_rate": ratio(gp_rate, target_gp_rate), + }, + "time_progress_percent": round(period_days / month_days * 100, 2) if month_days else None, + "month_days": month_days, + "elapsed_days": period_days, + "sands": sands, + } + + +def build_payload(now): + end_date = now.date() - timedelta(days=1) + start_date = end_date.replace(day=1) + days = (end_date - start_date).days + 1 + print(f"取数范围:{start_date} 至 {end_date}({days} 天)") + + client_rows, client_source = fetch_rows(CLIENT_GID, "Cost_Client") + installs, client_costs, client_apps, display = read_client_long(client_rows, start_date, end_date) + + cost_rows, cost_source = fetch_rows(COST_GID, "Breakdown_cost") + wide_costs, wide_apps, wide_latest = read_cost_wide(cost_rows, start_date, end_date) + + budget_rows, budget_source = fetch_rows(BUDGET_GID, "forPrebill") + budgets, budget_header = read_budgets(budget_rows) + + targets = {"revenue": None, "gp": None, "gp_rate": None} + target_source = "unavailable" + try: + target_rows, target_source = fetch_rows(TARGET_GID, "Monthly FFR", TARGET_SPREADSHEET_ID) + targets = read_targets(target_rows) + except Exception as error: + print(f" Target 读取失败({error});请把该表共享给服务账号或开放链接查看") + sands = load_sands_cost() + + # 消耗表里可能出现 Cost_Client 未覆盖的 Campaign,一并纳入 + for key, _day in wide_costs: + display.setdefault(key, key) + + dates = [(start_date + timedelta(days=offset)).isoformat() for offset in range(days)] + keys = sorted(display, key=lambda item: display[item].lower()) + + records = [] + for key in keys: + for day in dates: + cost = wide_costs.get((key, day)) + if cost is None: + cost = client_costs.get((key, day)) + install = installs.get((key, day)) + if cost is None and install is None: + continue + records.append({ + "date": day, + "campaign": display[key], + "installs": round(install or 0, 2), + "cost": round(cost or 0, 4), + }) + + summary = [] + for key in keys: + install_total = sum(installs.get((key, day), 0.0) for day in dates) + cost_total = sum( + wide_costs.get((key, day), client_costs.get((key, day), 0.0)) for day in dates + ) + if install_total == 0 and cost_total == 0: + continue + budget, match = resolve_budget(display[key], budgets) + summary.append({ + "campaign": display[key], + "app": client_apps.get(key) or wide_apps.get(key) or "", + "installs": round(install_total, 2), + "cost": round(cost_total, 4), + "budget": budget, + "budget_match": match, + "ffr_percent": round(cost_total / budget * 100, 2) if budget else None, + "cpi": round(cost_total / install_total, 4) if install_total else None, + }) + + approximate = [item["campaign"] for item in summary if item["budget_match"] not in (None, "exact")] + missing = [item["campaign"] for item in summary if item["budget"] is None] + if approximate: + print(f" 预算近似匹配 {len(approximate)} 个:") + for item in summary: + if item["budget_match"] not in (None, "exact"): + print(f" {item['campaign']} <- {item['budget_match']} = {item['budget']}") + if missing: + print(f" 未匹配到预算 {len(missing)} 个:") + for name in missing: + print(f" {name}") + + total_install = round(sum(item["installs"] for item in summary), 2) + total_cost = round(sum(item["cost"] for item in summary), 4) + month_days = (start_date.replace(day=28) + timedelta(days=4)).replace(day=1) - timedelta(days=1) + performance = build_performance(total_cost, sands, targets, days, month_days.day) + budget_values = [item["budget"] for item in summary if item["budget"] is not None] + total_budget = round(sum(budget_values), 4) if budget_values else None + + return { + "title": "King · 当月 Campaign 表现", + "generated_at": now.isoformat(timespec="seconds"), + "timezone": "Asia/Shanghai", + "period": {"start": start_date.isoformat(), "end": end_date.isoformat(), "days": days}, + "sources": { + "client": client_source, + "cost": cost_source, + "budget": budget_source, + "budget_column_header": budget_header, + "target": target_source, + }, + "performance": performance, + "kpis": { + "TOTAL": { + "installs": total_install, + "cost": total_cost, + "budget": total_budget, + "ffr_percent": round(total_cost / total_budget * 100, 2) if total_budget else None, + "cpi": round(total_cost / total_install, 4) if total_install else None, + } + }, + "campaigns": [item["campaign"] for item in summary], + "summary": summary, + "records": records, + "definitions": { + "period": "当月 1 号至昨天", + "installs": "Cost_Client 表 Installs 列按天汇总", + "cost": "Breakdown_cost【day】按天消耗;缺失时用 Cost_Client 的 Cost 兜底(USD)", + "budget": "forPrebill 表 B 列 Campaign name 对应 Z 列当月预算", + "budget_match": "exact 为同名匹配;brand+geo 或 geo 为近似匹配(预算表命名与投放表不一致时使用)", + "ffr_percent": "累计消耗 ÷ 当月预算 × 100", + "cpi": "累计消耗 ÷ 累计安装", + "excluded": "带“总计/合计/Total”的小计行已排除", + "performance_revenue": "Revenue 取本报表的 Cost 合计(客户口径消耗)", + "performance_cost": "Cost 取 Sands Report 2.0 的 Cost 合计,需本地抓取", + "performance_gp": "GP = Revenue − Cost;GP rate = GP ÷ Revenue × 100", + "performance_target": "Target 来自 Oversea FY2627 Monthly FFR 表 King 行", + "performance_completion": "完成率 = 实际 ÷ Target × 100;Target 为整月目标", + }, + } + + +def main(): + if len(sys.argv) < 2: + raise SystemExit("用法: update_report_data.py <输出文件>") + payload = build_payload(datetime.now(TIMEZONE)) + with open(sys.argv[1], "w", encoding="utf-8") as handle: + json.dump(payload, handle, ensure_ascii=False, indent=2) + kpis = payload["kpis"]["TOTAL"] + print( + f"OK campaigns={len(payload['summary'])} records={len(payload['records'])} " + f"installs={kpis['installs']} cost={kpis['cost']} budget={kpis['budget']}" + ) + + +if __name__ == "__main__": + main()