feat: publish sqlite click gap dashboard

This commit is contained in:
SHAREit
2026-10-16 14:54:13 +08:00
parent 5b2dba67bf
commit b5b5feba31
4 changed files with 106 additions and 216 deletions
+65 -183
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""Extract AppsFlyer click-gap reports and publish validated static JSON."""
"""Read click-gap data from SQLite and publish validated static JSON."""
from __future__ import annotations
import argparse
@@ -12,23 +12,18 @@ import re
import shutil
import sqlite3
import subprocess
import sys
import tempfile
import time
from pathlib import Path
from typing import Any
DB_PATH = "/Users/ads-center/Documents/database/af_click_gap.db"
PID = "shareit"
CLICK_NUM = 300
REPORT_COLUMNS = ["dt", "attr_platform", "pkg_name", "shareit_clicks", "af_clicks", "installs_ua", "clicks_diff", "clicks_diff_rate"]
SPECIAL_PKGS = {
"za.co.freeurl.easybetapp.fwdb": "za.co.freeurl.easybetapp.fwdb-Shareit",
"org.melbet.client": "org.melbet.client-Custom", "org.xbet.client1": "org.xbet.client1-Custom",
"org.betwinner_ng.client": "org.betwinner_ng.client-Custom", "org.betwinner_zm.client": "org.betwinner_zm.client-Custom",
"org.betwinner_cm.client": "org.betwinner_cm.client-Custom", "org.betwinner_cd.client": "org.betwinner_cd.client-Custom",
EXPECTED_TYPES = {
"pid": "TEXT", "dt": "TEXT", "attr_platform": "TEXT", "pkg_name": "TEXT",
"shareit_clicks": "REAL", "af_clicks": "REAL", "installs_ua": "REAL",
"clicks_diff": "REAL", "clicks_diff_rate": "REAL", "updated_at": "TEXT",
}
EXPECTED_TYPES = {"pid": "TEXT", "dt": "TEXT", "attr_platform": "TEXT", "pkg_name": "TEXT", "shareit_clicks": "REAL", "af_clicks": "REAL", "installs_ua": "REAL", "clicks_diff": "REAL", "clicks_diff_rate": "REAL", "updated_at": "TEXT"}
LOG_PATH = Path(os.path.expanduser("~/Library/Logs/af-click-gap/pipeline.log"))
@@ -47,21 +42,6 @@ def logger_setup() -> logging.Logger:
LOG = logger_setup()
def date8(value: str) -> str:
try:
parsed = dt.datetime.strptime(value, "%Y%m%d")
except ValueError as exc:
raise ValueError(f"非法日期: {value}") from exc
return parsed.strftime("%Y%m%d")
def date8_from_iso(value: str) -> str:
try:
return dt.datetime.strptime(value, "%Y-%m-%d").strftime("%Y%m%d")
except ValueError as exc:
raise ValueError(f"日期必须为 YYYY-MM-DD: {value}") from exc
def window(start: str | None, end: str | None) -> tuple[str, str]:
today = dt.date.today()
if start is None and end is None:
@@ -69,15 +49,25 @@ def window(start: str | None, end: str | None) -> tuple[str, str]:
else:
if not start or not end:
raise ValueError("--start-date 与 --end-date 必须同时提供")
first = dt.datetime.strptime(start, "%Y-%m-%d").date()
last = dt.datetime.strptime(end, "%Y-%m-%d").date()
try:
first = dt.date.fromisoformat(start)
last = dt.date.fromisoformat(end)
except ValueError as exc:
raise ValueError("日期必须为 YYYY-MM-DD") from exc
if first > last or (last - first).days > 6:
raise ValueError("日期区间必须为包含首尾且不超过 7 天")
return first.strftime("%Y%m%d"), last.strftime("%Y%m%d")
def date8(value: str) -> str:
try:
return dt.datetime.strptime(value, "%Y%m%d").strftime("%Y%m%d")
except ValueError as exc:
raise ValueError(f"非法日期: {value}") from exc
def finite_number(value: Any, field: str, allow_null: bool = False) -> int | float | None:
if value is None or (isinstance(value, float) and math.isnan(value)):
if value is None:
if allow_null:
return None
raise ValueError(f"{field} 不能为空")
@@ -108,130 +98,45 @@ def validate_existing_schema(path: str) -> None:
for name, expected in EXPECTED_TYPES.items():
if str(by_name[name][2]).upper() != expected:
raise RuntimeError(f"click_gap 列类型不兼容: {name}")
if [(row[1], row[5]) for row in columns if row[5]] != [("pid", 1), ("dt", 2), ("pkg_name", 3)]:
primary_key = [(row[1], row[5]) for row in columns if row[5]]
if primary_key != [("pid", 1), ("dt", 2), ("pkg_name", 3)]:
raise RuntimeError("click_gap 主键必须为 (pid, dt, pkg_name)")
nullable_required = {"pid", "dt", "pkg_name", "shareit_clicks", "af_clicks", "installs_ua", "clicks_diff", "updated_at"}
if any(by_name[name][3] != 1 for name in nullable_required):
required = {"pid", "dt", "pkg_name", "shareit_clicks", "af_clicks", "installs_ua", "clicks_diff", "updated_at"}
if any(by_name[name][3] != 1 for name in required):
raise RuntimeError("click_gap 必填列不得允许 NULL")
def read_existing_rows(path: str, start: str, end: str, pid: str = PID) -> list[dict[str, Any]]:
def read_rows(path: str, start: str, end: str, pid: str = PID) -> list[dict[str, Any]]:
validate_existing_schema(path)
with sqlite3.connect(path) as conn:
conn.row_factory = sqlite3.Row
return [dict(row) for row in conn.execute("SELECT * FROM click_gap WHERE pid=? AND dt BETWEEN ? AND ?", (pid, start, end))]
try:
with sqlite3.connect(f"file:{path}?mode=ro", uri=True) as conn:
conn.row_factory = sqlite3.Row
result = conn.execute(
"SELECT dt, attr_platform, pkg_name, shareit_clicks, af_clicks, installs_ua, clicks_diff, clicks_diff_rate "
"FROM click_gap WHERE pid=? AND dt BETWEEN ? AND ? ORDER BY dt DESC, shareit_clicks DESC, pkg_name",
(pid, start, end),
)
return [dict(row) for row in result]
except sqlite3.Error as exc:
raise RuntimeError(f"SQLite 查询失败: {exc}") from exc
def upsert_report(conn: sqlite3.Connection, rows: list[dict[str, Any]], start: str, end: str) -> int:
now = dt.datetime.now().astimezone().isoformat(timespec="seconds")
conn.execute("DELETE FROM click_gap WHERE pid=? AND dt BETWEEN ? AND ?", (PID, start, end))
values = []
def validate_rows(rows: list[dict[str, Any]], start: str, end: str) -> list[dict[str, Any]]:
normalized = []
for row in rows:
values.append((PID, row["dt"], row["attr_platform"], row["pkg_name"], row["shareit_clicks"], row["af_clicks"], row["installs_ua"], row["clicks_diff"], row["clicks_diff_rate"], now))
conn.executemany("INSERT INTO click_gap (pid,dt,attr_platform,pkg_name,shareit_clicks,af_clicks,installs_ua,clicks_diff,clicks_diff_rate,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?)", values)
return len(values)
def superset_rows(start: str, end: str) -> list[dict[str, Any]]:
from superset_login import get_cookies
import requests
from requests_toolbelt import MultipartEncoder
session, csrf = get_cookies()
nextday = (dt.datetime.strptime(end, "%Y%m%d").date() + dt.timedelta(days=1)).strftime("%Y-%m-%d")
start_iso = dt.datetime.strptime(start, "%Y%m%d").strftime("%Y-%m-%d")
query = {"datasource": "114__table", "viz_type": "table", "slice_id": 1344, "url_params": {}, "time_range_endpoints": ["inclusive", "exclusive"], "granularity_sqla": "dt_as_time", "time_grain_sqla": "P1D", "time_range": f"{start_iso}T00:00:00 : {nextday}T00:00:00", "query_mode": "aggregate", "groupby": ["dt", "attr_platform", "pkg_name"], "metrics": ["clicks", "pb_install"], "all_columns": [], "array_join": [], "percent_metrics": [], "timeseries_limit_metric": ["dt_sort"], "order_by_cols": [], "row_limit": None, "order_desc": True, "adhoc_filters": [{"clause": "WHERE", "expressionType": "SIMPLE", "comparator": ["appsflyer"], "operator": "IN", "subject": "attr_platform"}, {"clause": "WHERE", "expressionType": "SIMPLE", "comparator": ["0"], "operator": "IN", "subject": "is_internal"}], "table_timestamp_format": "smart_date", "color_pn": True, "show_cell_bars": True}
encoder = MultipartEncoder(fields={"form_data": json.dumps(query)})
response = session.post('https://ads-bi.ushareit.org/superset/explore_json/?form_data={"slice_id":1344}&force=true', headers={"x-csrftoken": csrf, "content-type": encoder.content_type}, data=encoder, timeout=(10, 60))
if response.status_code != 200:
raise RuntimeError(f"Superset 请求失败: HTTP {response.status_code}")
payload = response.json()
data = payload.get("data") if isinstance(payload, dict) else None
if not isinstance(data, dict) or not isinstance(data.get("records"), list) or not isinstance(data.get("columns"), list):
raise RuntimeError("Superset 响应格式错误")
return [dict(zip(data["columns"], row)) if isinstance(row, (list, tuple)) else row for row in data["records"]]
def pkg_list(rows: list[dict[str, Any]]) -> list[str]:
totals: dict[str, float] = {}
for row in rows:
pkg = str(row.get("pkg_name") or "").strip()
if pkg:
totals[pkg] = totals.get(pkg, 0) + float(row.get("clicks") or 0)
return sorted(pkg for pkg, clicks in totals.items() if clicks > CLICK_NUM)
def appsflyer_rows(packages: list[str], start: str, end: str, browser: Any) -> list[dict[str, Any]]:
import requests
cookie = browser.get_cookie_str()
if not cookie:
raise RuntimeError("AppsFlyer cookie 为空")
payload = {"dates": {"start": dt.datetime.strptime(start, "%Y%m%d").strftime("%Y-%m-%d"), "end": dt.datetime.strptime(end, "%Y%m%d").strftime("%Y-%m-%d")}, "filters": {"app-id": []}, "view-type": "unified", "localization": {"timezone": "UTC", "currency": "USD"}, "groupings": [{"dimension": "date", "limit": 200}], "summations": ["totals", "others"], "metrics": [{"metric-id": "clicks"}, {"metric-id": "installs-ua"}], "format": "json", "granularity": "days", "implicit-sorting": True}
result: list[dict[str, Any]] = []
url = "https://hq1.appsflyer.com/platform/dashboard?widget=platform-table__performance:3"
for package in packages:
app_id = SPECIAL_PKGS.get(package, package)
payload["filters"]["app-id"] = [app_id]
last_error = None
for attempt in range(4):
try:
response = requests.post(url, data=json.dumps(payload), headers={"content-type": "application/json;charset=UTF-8", "cookie": cookie}, timeout=(10, 60))
if response.status_code == 429 or response.status_code >= 500:
raise RuntimeError(f"HTTP {response.status_code}")
if response.status_code != 200:
raise RuntimeError(f"HTTP {response.status_code}")
data = response.json().get("data")
if not isinstance(data, list) or any(not isinstance(item, dict) for item in data):
raise RuntimeError("响应格式错误")
for item in data:
item["app_id"] = package
result.extend(data)
break
except Exception as exc: # do not leak request contents
last_error = exc
if attempt < 3:
time.sleep(2 ** (attempt + 1))
else:
raise RuntimeError(f"AppsFlyer 查询失败 pkg={package}: {last_error}")
return result
def build_report(shareit: list[dict[str, Any]], af: list[dict[str, Any]], packages: list[str]) -> list[dict[str, Any]]:
shared: dict[tuple[str, str, str], float] = {}
for row in shareit:
if str(row.get("attr_platform") or "") != "appsflyer":
continue
pkg = str(row.get("pkg_name") or "").strip()
if not pkg or pkg not in packages:
continue
raw = str(row.get("dt") or "")[:10].replace("-", "")
date = date8(raw)
shared[(date, "appsflyer", pkg)] = shared.get((date, "appsflyer", pkg), 0) + float(row.get("clicks") or 0)
af_totals: dict[tuple[str, str], list[float]] = {}
for row in af:
raw = str(row.get("date") or "")[:10].replace("-", "")
date = date8(raw)
pkg = str(row.get("app_id") or "").strip()
key = (date, pkg)
values = af_totals.setdefault(key, [0.0, 0.0])
values[0] += float(row.get("filtersGranularityMetricIdClicksPeriod", row.get("clicks", 0)) or 0)
values[1] += float(row.get("attributionSourceAppsflyerFiltersGranularityMetricIdInstallsUaPeriod", row.get("installs_ua", 0)) or 0)
result = []
for (date, platform, pkg), share_clicks in shared.items():
af_clicks, installs = af_totals.get((date, pkg), [0.0, 0.0])
diff = share_clicks - af_clicks
result.append({"dt": date, "attr_platform": platform, "pkg_name": pkg, "shareit_clicks": finite_number(share_clicks, "shareit_clicks"), "af_clicks": finite_number(af_clicks, "af_clicks"), "installs_ua": finite_number(installs, "installs_ua"), "clicks_diff": finite_number(diff, "clicks_diff"), "clicks_diff_rate": None if share_clicks == 0 else finite_number(round(diff / share_clicks, 3), "clicks_diff_rate", True)})
result.sort(key=lambda row: (row["dt"], row["shareit_clicks"]), reverse=True)
if len({(r["dt"], r["pkg_name"]) for r in result}) != len(result):
raise RuntimeError("报告存在重复业务键")
return result
def validate_rows(rows: list[dict[str, Any]], start: str, end: str) -> None:
for row in rows:
if list(row) != REPORT_COLUMNS or date8(row["dt"]) < start or date8(row["dt"]) > end or not str(row["pkg_name"]).strip():
raise ValueError("报告字段、日期或包名不合法")
if list(row) != REPORT_COLUMNS:
raise ValueError("报告字段不合法")
normalized_row = dict(row)
normalized_row["dt"] = date8(str(row["dt"]))
if not start <= normalized_row["dt"] <= end or not str(row["attr_platform"]).strip() or not str(row["pkg_name"]).strip():
raise ValueError("报告日期、平台或包名不合法")
for field in REPORT_COLUMNS[3:]:
finite_number(row[field], field, field == "clicks_diff_rate")
normalized_row[field] = finite_number(row[field], field, field == "clicks_diff_rate")
normalized.append(normalized_row)
keys = [(row["dt"], row["pkg_name"]) for row in normalized]
if len(set(keys)) != len(keys):
raise ValueError("报告存在重复业务键")
return normalized
def make_files(rows: list[dict[str, Any]], dates: list[str], out: Path) -> None:
@@ -270,15 +175,14 @@ def git_run(repo: Path, args: list[str]) -> subprocess.CompletedProcess[str]:
def git_publish(repo: Path, start: str, end: str, no_push: bool) -> str:
if not (repo / ".git").exists():
raise RuntimeError("仓库尚未初始化;请先执行 git init 并配置 remote")
raise RuntimeError("仓库尚未初始化")
status = git_run(repo, ["status", "--porcelain"]).stdout.strip().splitlines()
allowed = {"README.md", ".gitignore", "index.html", "scripts/af_click_gap_pipeline.py", "launchd/com.roasbloom.af-click-gap.plist.template"}
allowed.update({f"data/{date}.json" for date in (repo / "data").glob("*.json")})
unexpected = [line for line in status if line[3:] not in allowed]
allowed.update({f"data/{date.name}" for date in (repo / "data").glob("*.json")})
unexpected = [line for line in status if len(line) < 4 or line[3:] not in allowed]
if unexpected:
raise RuntimeError("工作区已有未预期改动: " + ", ".join(unexpected))
paths = ["README.md", ".gitignore", "index.html", "scripts/af_click_gap_pipeline.py", "launchd/com.roasbloom.af-click-gap.plist.template", "data"]
add = git_run(repo, ["add", *paths])
add = git_run(repo, ["add", "README.md", ".gitignore", "index.html", "scripts/af_click_gap_pipeline.py", "launchd/com.roasbloom.af-click-gap.plist.template", "data"])
if add.returncode:
raise RuntimeError("git add 失败: " + add.stderr.strip())
staged = git_run(repo, ["diff", "--cached", "--name-only"])
@@ -290,8 +194,7 @@ def git_publish(repo: Path, start: str, end: str, no_push: bool) -> str:
commit_hash = git_run(repo, ["rev-parse", "HEAD"]).stdout.strip()
if no_push:
return commit_hash + " (未 push)"
remote = git_run(repo, ["remote", "-v"])
if remote.returncode or not remote.stdout.strip():
if not git_run(repo, ["remote", "-v"]).stdout.strip():
raise RuntimeError("未配置 Git remote;本地 commit 已保留")
push = git_run(repo, ["push"])
if push.returncode:
@@ -300,13 +203,13 @@ def git_publish(repo: Path, start: str, end: str, no_push: bool) -> str:
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Extract and publish AppsFlyer click-gap JSON")
parser.add_argument("--start-date", help="补数开始日期 YYYY-MM-DD")
parser.add_argument("--end-date", help="补数结束日期 YYYY-MM-DD")
parser.add_argument("--dry-run", action="store_true", help="只提取并校验,不写正式文件、数据库或 Git")
parser = argparse.ArgumentParser(description="Read SQLite click-gap data and publish JSON")
parser.add_argument("--start-date", help="读取开始日期 YYYY-MM-DD")
parser.add_argument("--end-date", help="读取结束日期 YYYY-MM-DD")
parser.add_argument("--dry-run", action="store_true", help="只读取、校验并生成临时 JSON,不写文件或 Git")
group = parser.add_mutually_exclusive_group()
group.add_argument("--no-push", action="store_true", help="写文件和数据库但不 push")
group.add_argument("--push", action="store_true", help="写文件、数据库并 commit/push(默认)")
group.add_argument("--no-push", action="store_true", help="写文件但不 push")
group.add_argument("--push", action="store_true", help="写文件并 commit/push(默认)")
parser.add_argument("--db-path", default=DB_PATH, help=argparse.SUPPRESS)
return parser.parse_args()
@@ -317,24 +220,10 @@ def main() -> int:
if args.dry_run and (args.no_push or args.push):
raise ValueError("--dry-run 不可与 --push/--no-push 同时使用")
start, end = window(args.start_date, args.end_date)
validate_existing_schema(args.db_path)
shareit = superset_rows(start, end)
packages = pkg_list(shareit)
af: list[dict[str, Any]] = []
browser = None
if packages:
from af_browser import AFBrowser
browser = AFBrowser()
try:
af = appsflyer_rows(packages, start, end, browser)
finally:
try:
browser.close()
except Exception:
LOG.warning("关闭浏览器失败")
rows = build_report(shareit, af, packages)
validate_rows(rows, start, end)
dates = [(dt.datetime.strptime(start, "%Y%m%d").date() + dt.timedelta(days=i)).strftime("%Y%m%d") for i in range((dt.datetime.strptime(end, "%Y%m%d").date() - dt.datetime.strptime(start, "%Y%m%d").date()).days + 1)]
rows = validate_rows(read_rows(args.db_path, start, end), start, end)
first = dt.datetime.strptime(start, "%Y%m%d").date()
last = dt.datetime.strptime(end, "%Y%m%d").date()
dates = [(first + dt.timedelta(days=i)).strftime("%Y%m%d") for i in range((last - first).days + 1)]
repo = Path(__file__).resolve().parents[1]
with tempfile.TemporaryDirectory(prefix="af-click-gap-") as temp:
generated = Path(temp) / "data"
@@ -344,15 +233,8 @@ def main() -> int:
LOG.info("dry-run 成功: %s-%s, %d 行", start, end, len(rows))
return 0
publish_files(repo, generated, dates)
with sqlite3.connect(args.db_path) as conn:
conn.execute("BEGIN")
upsert_report(conn, rows, start, end)
conn.commit()
if args.no_push:
LOG.info("已发布本地文件和 SQLite,未执行 Git")
else:
result = git_publish(repo, start, end, False)
LOG.info("Git 结果: %s", result)
result = git_publish(repo, start, end, args.no_push)
LOG.info("Git 结果: %s", result)
return 0
except ValueError as exc:
LOG.error("输入失败: %s", exc)