#!/usr/bin/env python3 import csv, io, json, os, re, sys from datetime import datetime from zoneinfo import ZoneInfo if len(sys.argv) not in (3, 4): raise SystemExit('usage: transform_tableau.py SPEND.csv YYYY-MM-DD [EVENTS.csv]') src, target = sys.argv[1:3] events_path = sys.argv[3] if len(sys.argv) == 4 else None raw = open(src, 'rb').read() if b' text.count(',') else ',' matrix = list(csv.reader(io.StringIO(text), delimiter=delimiter)) aliases = { 'pkg': {'pkg', '维度1'}, 'source': {'source', 'platform'}, 'date': {'date', 'dt', '日期'}, 'campaign': {'campaign name', 'campaign_name', '维度2'}, 'impression': {'impression', 'impressions'}, 'click': {'click', 'clicks'}, 'conversions': {'conversion', 'conversions'}, 'spend': {'spend', 'cost'}, } header = [c.strip().lower() for c in matrix[0]] if matrix else [] has_header = any(c in aliases['pkg'] for c in header) and any(c in aliases['campaign'] for c in header) def cell(row, field): if has_header: for i, name in enumerate(header): if name in aliases[field]: return row[i] if i < len(row) else '' return '' positions = {'pkg':0, 'source':1, 'date':3, 'campaign':4, 'impression':5, 'click':6, 'conversions':7, 'spend':8} i = positions[field] return row[i] if i < len(row) else '' def num(v): try: return float(str(v or '0').replace(',', '').replace('%', '')) except ValueError: return 0 def event_names(value): value = str(value or '').strip() if not value or value == '-': return [] return [x.strip().lower() for x in re.split(r'[,,;/;\n]+', value) if x.strip()] events, covered_scopes = {}, set() if events_path: with open(events_path, encoding='utf-8-sig', newline='') as f: for event_row in csv.DictReader(f): dt = re.sub(r'[^0-9]', '', event_row.get('dt', '')) if len(dt) == 8: dt = f'{dt[:4]}-{dt[4:6]}-{dt[6:]}' channel = event_row.get('second_channel','').lower() event_source = next((s for s in ('mintegral','bigo','kwai') if s in channel), '') if not event_source: continue covered_scopes.add((dt, event_source)) key = (dt, event_row.get('pkg_name','').strip(), event_source, event_row.get('offer_name','').strip(), event_row.get('event_name','').strip().lower()) events[key] = events.get(key, 0) + num(event_row.get('event_num')) config = [r for r in json.load(open('campaign_config.json', encoding='utf-8')) if r['status'].lower() == 'active'] def active_on(meta, date): """Respect the Campaign info remark such as 0912新上 for historical backfills.""" match = re.search(r'(?= f'{date[:4]}-{match.group(1)}-{match.group(2)}' experiment = {} for r in config: if r['group'] == '实验组': experiment.setdefault((r['pkg'], r['channel'].lower()), []).append((str(r['adping_id']), r)) # Kwai can occasionally return GEO in the pkg column for an experimental campaign. # An adping ID is unique to the experiment, so use it as a guarded fallback and # normalize the emitted pkg to the Campaign info configuration. experiment_by_source = {} for (_, source), items in experiment.items(): experiment_by_source.setdefault(source, []).extend(items) scopes = {} for r in config: scopes.setdefault((r['pkg'], r['channel'].lower(), r['geo'].upper()), r) def resolve_geo(pkg, source, campaign): candidates = sorted({g for p, s, g in scopes if p == pkg and s == source}, key=len, reverse=True) upper = campaign.upper() # Some platform campaign names append a sequence directly to the GEO (for example MY2/TH1). matches = [g for g in candidates if re.search(r'(^|[^A-Z0-9])' + re.escape(g) + r'(?=[^A-Z]|$)', upper)] if len(matches) == 1: return matches[0] return candidates[0] if len(candidates) == 1 else None rows, unresolved = [], [] for raw_row in matrix[1:] if has_header else matrix: pkg, source = cell(raw_row, 'pkg').strip(), cell(raw_row, 'source').strip().lower() campaign = cell(raw_row, 'campaign').strip() if not pkg or not source or not campaign: continue date = cell(raw_row, 'date').strip() or target exp = next((meta for adping, meta in experiment.get((pkg, source), []) if re.search(r'(?