"""每日拉取穿山甲 GroMore 天级收益报表入库(供 admin 广告收益报表的「穿山甲后台收益」)。 GroMore 数据 API 为 T+1:次日穿山甲约 10:00 出数。建议线上每天 ~10:30 由 systemd timer 跑一次 (默认拉【昨天】);穿山甲对历史数据可能订正,故支持回补近 N 天(幂等 upsert,重跑无害)。 用法: python -m scripts.sync_pangle_revenue # 拉昨天(北京时间) python -m scripts.sync_pangle_revenue --days 3 # 回补昨天起往前 3 天 python -m scripts.sync_pangle_revenue --date 2026-06-27 # 指定单天 python -m scripts.sync_pangle_revenue --start 2026-06-01 --end 2026-06-27 # 指定区间 约束(来自官方文档):天级跨度 ≤ 1 个月、不早于 12 个月;「今天」与「今天以前」必须分开查, 本脚本默认只拉昨天及更早,不混查今天。未配置 PANGLE_REPORT_* 时直接 no-op 退出。 """ from __future__ import annotations import argparse import sys from datetime import date, timedelta # Windows 控制台按 UTF-8 输出中文/¥ try: sys.stdout.reconfigure(encoding="utf-8") # type: ignore[attr-defined] except Exception: # noqa: BLE001 pass from app.core.config import settings from app.core.rewards import cn_today from app.db.session import SessionLocal from app.integrations import pangle_report from app.integrations.pangle_report import PangleReportError from app.repositories import ad_pangle_revenue as repo # 拉取维度固定:date×site_id×ad_unit_id。⚠️ 用 ad_unit_id(GroMore 广告位ID = 我们客户端配的 104xxx # = ad_ecpm_record.our_code_id)而**非** code_id —— 实测 code_id 返回的是底层各 ADN 的代码位 # (如 983674557 / youlianghui_xinxiliu),对不上我们的口径。指标取预估 + Api 两个收益。 _DIMENSIONS = "date,site_id,ad_unit_id" _METRICS = "revenue,api_revenue,ecpm,imp_cnt" def _to_float(v: object) -> float | None: """接口数值字段都是字符串;空/非法 → None(api_revenue 缺失时即为 None,前端显示「-」)。""" if v is None or str(v).strip() == "": return None try: return float(str(v).strip()) except (TypeError, ValueError): return None def _row_date(row: dict) -> str | None: """报表行的日期:优先 date 维度,回退 start_date;取前 10 位 'YYYY-MM-DD'。""" raw = row.get("date") or row.get("start_date") or "" raw = str(raw).strip() return raw[:10] if len(raw) >= 10 else None def _normalize(rows: list[dict]) -> tuple[list[dict], int, set[str]]: """接口原始行 → 入库行;返回 (入库行, 跳过数, 未映射 site_id 集合)。 site_id 经 settings 映射成 app_env(prod/test);未映射的仍入库(app_env=site:)但提示, 避免静默丢收益。缺 code_id / 日期的行跳过。 """ site_map = settings.pangle_report_site_id_to_env out: list[dict] = [] skipped = 0 unmapped: set[str] = set() for row in rows: rdate = _row_date(row) # 用 ad_unit_id(GroMore 广告位ID = 我们的 104xxx),不是 code_id(底层 ADN 代码位) code_id = str(row.get("ad_unit_id") or "").strip() if not rdate or not code_id: skipped += 1 continue site_id = str(row.get("site_id") or "").strip() app_env = site_map.get(site_id) if app_env is None: app_env = f"site:{site_id}" if site_id else "unknown" if site_id: unmapped.add(site_id) out.append({ "report_date": rdate, "app_env": app_env, "site_id": site_id or None, "our_code_id": code_id, "adn": "", "revenue_yuan": _to_float(row.get("revenue")) or 0.0, "api_revenue_yuan": _to_float(row.get("api_revenue")), "ecpm": (str(row.get("ecpm")).strip() or None) if row.get("ecpm") is not None else None, "impressions": int(_to_float(row.get("imp_cnt")) or 0), "currency": "cny", }) return out, skipped, unmapped def sync_range(start_date: str, end_date: str) -> None: if not settings.pangle_report_configured: print("PANGLE_REPORT_* 未配置,跳过(no-op)。需在后台「接入中心 → GroMore-API」领取 " "user_id/role_id/Security Key 并填入 .env。") return print(f"拉取 GroMore 天级收益 {start_date} ~ {end_date} (dims={_DIMENSIONS}) ...") try: raw = pangle_report.fetch_daily_report( start_date=start_date, end_date=end_date, dimensions=_DIMENSIONS, metrics=_METRICS, time_zone=8, ) except PangleReportError as e: print(f"❌ 拉取失败:{e}") sys.exit(1) rows, skipped, unmapped = _normalize(raw) if unmapped: print(f"⚠️ 有未映射 site_id(收益仍入库,app_env=site:):{sorted(unmapped)};" f"如需归入 prod/test,补 PANGLE_REPORT_SITE_ID_* 配置。") with SessionLocal() as db: stats = repo.upsert_daily_rows(db, rows) total_rev = round(sum(r["revenue_yuan"] for r in rows), 4) print(f"✅ 完成:接口 {len(raw)} 行 → 入库 {len(rows)} 行(跳过 {skipped})," f"新增 {stats['inserted']} / 更新 {stats['updated']};预估收益合计 ¥{total_rev}") def main() -> None: ap = argparse.ArgumentParser(description="拉取穿山甲 GroMore 天级收益报表入库") ap.add_argument("--date", help="指定单天 YYYY-MM-DD") ap.add_argument("--start", help="区间起始 YYYY-MM-DD(与 --end 同用)") ap.add_argument("--end", help="区间结束 YYYY-MM-DD(与 --start 同用)") ap.add_argument("--days", type=int, help="回补:昨天起往前 N 天(含昨天)") args = ap.parse_args() yesterday = cn_today() - timedelta(days=1) if args.date: start = end = args.date elif args.start and args.end: start, end = args.start, args.end elif args.days: start = (yesterday - timedelta(days=args.days - 1)).isoformat() end = yesterday.isoformat() else: start = end = yesterday.isoformat() # 防呆:跨度 > 31 天直接拒(接口上限 1 个月,超了会返回 114) if (date.fromisoformat(end) - date.fromisoformat(start)).days > 31: print("❌ 区间跨度超过 31 天(接口上限 1 个月),请分批拉。") sys.exit(1) sync_range(start, end) if __name__ == "__main__": main()