Files
Manta-tracking-new/transform_tableau.py

141 lines
7.1 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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'<html' in raw[:1000].lower():
raise SystemExit('Tableau authentication redirected to HTML')
text = raw.decode('utf-16') if raw.startswith((b'\xff\xfe', b'\xfe\xff')) else raw.decode('utf-8-sig')
delimiter = '\t' if text.count('\t') > 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'))
all_config = json.load(open('campaign_config.json', encoding='utf-8'))
inactive_experiments = [r for r in all_config
if r['group'] == '实验组' and r['status'].lower() != 'active']
config = [r for r in all_config 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'(?<!\d)(\d{2})(\d{2})\s*新上', str(meta.get('remark', '')))
if not match:
return True
return date >= 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
# Do not let a known paused experiment fall through to control GEO matching.
if any(re.search(r'(?<!\d)' + re.escape(str(meta['adping_id'])) + r'(?!\d)', campaign)
for meta in inactive_experiments):
continue
date = cell(raw_row, 'date').strip() or target
if date != target: continue
exp = next((meta for adping, meta in experiment.get((pkg, source), [])
if re.search(r'(?<!\d)' + re.escape(adping) + r'(?!\d)', campaign)), None)
if not exp:
exp = next((meta for adping, meta in experiment_by_source.get(source, [])
if re.search(r'(?<!\d)' + re.escape(adping) + r'(?!\d)', campaign)), None)
if exp:
meta, group, shown, adping, pkg = exp, '实验组', campaign, str(exp['adping_id']), exp['pkg']
else:
geo = resolve_geo(pkg, source, campaign)
meta = scopes.get((pkg, source, geo)) if geo else None
if not meta:
unresolved.append(campaign)
continue
group, shown, adping = '对照组', 'General campaign', ''
if not active_on(meta, date):
continue
required = event_names(meta.get('kpi',''))
matched_events = [(date, pkg, source, campaign, name) for name in required
if (date, pkg, source, campaign, name) in events]
if not required or (date, source) not in covered_scopes or not matched_events:
event = None
else:
event = sum(events[key] for key in matched_events)
rows.append({'date': date, 'pkg':pkg, 'geo':meta['geo'], 'source':source,
'group':group, 'adpingId':adping, 'campaign':shown, 'status':'Active', 'kpi':meta.get('kpi',''),
'event':event,
'impression':num(cell(raw_row,'impression')), 'click':num(cell(raw_row,'click')),
'conversions':num(cell(raw_row,'conversions')), 'spend':num(cell(raw_row,'spend'))})
if not rows: raise SystemExit('No valid Tableau rows; data.json preserved')
old = json.load(open('data.json',encoding='utf-8')) if os.path.exists('data.json') else {'rows':[]}
old['rows'] = [r for r in old.get('rows',[]) if r.get('date') != target] + rows
old['latestDate'] = max(r['date'] for r in old['rows'])
old['generatedAt'] = datetime.now(ZoneInfo('Asia/Shanghai')).isoformat()
old['source'] = 'DSP Daily Data / Campaign info'
old['quality'] = {'unresolvedCount':len(unresolved), 'unresolvedExamples':unresolved[:20]}
with open('data.json.tmp','w',encoding='utf-8') as f: json.dump(old,f,ensure_ascii=False,separators=(',',':'))
os.replace('data.json.tmp','data.json')
print(json.dumps({'date':target,'rows':len(rows),'unresolved':len(unresolved)},ensure_ascii=False))