#!/usr/bin/env python3 """Refresh Meta + Google agency matches into client fichas and audit.""" from __future__ import annotations import json import re import urllib.error import urllib.parse import urllib.request from collections import defaultdict from datetime import datetime, timezone, timedelta from pathlib import Path ROOT = Path(__file__).resolve().parents[1] META = "https://graph.facebook.com/v21.0" GADS = "https://googleads.googleapis.com/v18" def load_env() -> dict[str, str]: vals: dict[str, str] = {} for p in (ROOT / ".env.local", Path("/Users/4ventures/Projects/bps-facturacion/.env")): if not p.is_file(): continue for line in p.read_text(encoding="utf-8").splitlines(): if not line.strip() or line.strip().startswith("#") or "=" not in line: continue k, v = line.split("=", 1) vals.setdefault(k.strip(), v.strip().strip('"').strip("'")) return vals ENV = load_env() BASE = ENV["NEXT_PUBLIC_SUPABASE_URL"].rstrip("/") KEY = ENV["SUPABASE_SERVICE_ROLE_KEY"] SH = {"apikey": KEY, "Authorization": f"Bearer {KEY}"} def sb_get(path: str): req = urllib.request.Request(BASE + path, headers=SH) with urllib.request.urlopen(req, timeout=90) as r: return json.loads(r.read().decode()) def sb_patch(path: str, body: dict) -> bool: data = json.dumps(body).encode() req = urllib.request.Request( BASE + path, data=data, headers={**SH, "Content-Type": "application/json", "Prefer": "return=minimal"}, method="PATCH", ) try: with urllib.request.urlopen(req, timeout=60) as r: return 200 <= r.status < 300 except urllib.error.HTTPError: return False def sb_upsert_integ(tenant_id: str, provider: str, patch: dict) -> bool: path = ( f"/rest/v1/integrations?tenant_id=eq.{urllib.parse.quote(tenant_id)}" f"&provider=eq.{urllib.parse.quote(provider)}" ) body = {**patch, "updated_at": datetime.now(timezone.utc).isoformat()} if sb_patch(path, body): return True data = json.dumps( {"tenant_id": tenant_id, "provider": provider, "enabled": True, **body} ).encode() req = urllib.request.Request( BASE + "/rest/v1/integrations", data=data, headers={ **SH, "Content-Type": "application/json", "Prefer": "resolution=merge-duplicates,return=minimal", }, method="POST", ) try: with urllib.request.urlopen(req, timeout=60) as _: return True except urllib.error.HTTPError as e: print("upsert fail", provider, e.read().decode()[:200]) return False def norm(s: str) -> str: s = s.lower() s = ( s.replace("á", "a") .replace("é", "e") .replace("í", "i") .replace("ó", "o") .replace("ú", "u") .replace("ñ", "n") ) return re.sub(r"[^a-z0-9]+", " ", s).strip() def score_name(tenant_name: str, tenant_slug: str, account_name: str) -> int: t = norm(tenant_name) s = norm(tenant_slug.replace("-", " ")) a = norm(account_name) if not a: return 0 if a == t or a == s: return 100 if t and (t in a or a in t): return 85 if s and (s in a or a in s): return 80 tt = set(x for x in t.split() if len(x) > 2) aa = set(x for x in a.split() if len(x) > 2) if not tt or not aa: return 0 hit = len(tt & aa) / len(tt) if hit >= 0.8: return 70 if hit >= 0.5: return 55 return int(hit * 50) def best_matches(tenants, accounts, threshold=55): """Greedy 1:1 match like portal.""" scored = [] for t in tenants: for acc in accounts: sc = score_name(t["name"], t["slug"], acc["name"]) if sc >= threshold: scored.append((sc, t["id"], acc)) scored.sort(key=lambda x: -x[0]) used_t, used_a = set(), set() out = {} for sc, tid, acc in scored: if tid in used_t or acc["id"] in used_a: continue used_t.add(tid) used_a.add(acc["id"]) out[tid] = (acc, sc) return out def meta_get(token: str, path: str): url = f"{META}/{path}" sep = "&" if "?" in path else "?" req = urllib.request.Request(url + sep + "access_token=" + urllib.parse.quote(token)) with urllib.request.urlopen(req, timeout=90) as r: return json.loads(r.read().decode()) def meta_paginate(token: str, path: str): out = [] url = f"{META}/{path}" sep = "&" if "?" in path else "?" url = url + sep + "access_token=" + urllib.parse.quote(token) while url: req = urllib.request.Request(url) with urllib.request.urlopen(req, timeout=90) as r: j = json.loads(r.read().decode()) out.extend(j.get("data") or []) url = (j.get("paging") or {}).get("next") return out def google_refresh(refresh: str) -> str: body = urllib.parse.urlencode( { "refresh_token": refresh, "client_id": ENV["GOOGLE_OAUTH_CLIENT_ID"], "client_secret": ENV["GOOGLE_OAUTH_CLIENT_SECRET"], "grant_type": "refresh_token", } ).encode() req = urllib.request.Request( "https://oauth2.googleapis.com/token", data=body, headers={"Content-Type": "application/x-www-form-urlencoded"}, ) with urllib.request.urlopen(req, timeout=60) as r: return json.loads(r.read().decode())["access_token"] def gads_access() -> str: refresh = ENV.get("GOOGLE_ADS_REFRESH_TOKEN") or ENV.get("GOOGLE_OAUTH_REFRESH_TOKEN") cid = ENV.get("GOOGLE_ADS_CLIENT_ID") or ENV.get("GOOGLE_OAUTH_CLIENT_ID") secret = ENV.get("GOOGLE_ADS_CLIENT_SECRET") or ENV.get("GOOGLE_OAUTH_CLIENT_SECRET") body = urllib.parse.urlencode( { "refresh_token": refresh, "client_id": cid, "client_secret": secret, "grant_type": "refresh_token", } ).encode() req = urllib.request.Request( "https://oauth2.googleapis.com/token", data=body, headers={"Content-Type": "application/x-www-form-urlencoded"}, ) with urllib.request.urlopen(req, timeout=60) as r: return json.loads(r.read().decode())["access_token"] def main() -> int: tenants = sb_get("/rest/v1/tenants?select=id,name,slug&order=name.asc") print(f"tenants={len(tenants)}") # --- tokens --- g_rows = sb_get( "/rest/v1/agency_oauth?provider=eq.google_drive&select=access_token,refresh_token,expires_at,account_email,scopes" ) m_rows = sb_get( "/rest/v1/agency_oauth?provider=eq.meta_ads&select=access_token,account_email,scopes,updated_at" ) if not g_rows: print("FAIL: no google_drive oauth") return 1 if not m_rows: print("FAIL: no meta_ads oauth") return 1 grow = g_rows[0] mrow = m_rows[0] print("Google:", grow.get("account_email")) print("Meta:", mrow.get("account_email"), "updated", mrow.get("updated_at")) gtoken = grow["access_token"] exp = grow.get("expires_at") if exp: try: et = datetime.fromisoformat(exp.replace("Z", "+00:00")) if et.timestamp() < datetime.now(timezone.utc).timestamp() + 60: print("Refreshing Google access token…") gtoken = google_refresh(grow["refresh_token"]) except Exception as e: print("google refresh warn", e) gtoken = google_refresh(grow["refresh_token"]) mtoken = mrow["access_token"] now = datetime.now(timezone.utc).isoformat() linked_counts = defaultdict(int) # ========== META ========== print("\n=== META Ads + Pages + IG ===") ad_accounts = [] for row in meta_paginate( mtoken, "me/adaccounts?fields=id,account_id,name,account_status&limit=200" ): aid = row.get("account_id") or row["id"].replace("act_", "") ad_accounts.append({"id": f"act_{aid}", "name": row.get("name") or aid}) print(f"Meta ad accounts visible: {len(ad_accounts)}") pages = [] igs = [] for page in meta_paginate( mtoken, "me/accounts?fields=id,name,instagram_business_account{id,username,name}&limit=100", ): pages.append({"id": page["id"], "name": page.get("name") or page["id"]}) ig = page.get("instagram_business_account") or {} if ig.get("id"): igs.append( { "id": ig["id"], "name": f"@{ig['username']}" if ig.get("username") else (ig.get("name") or page.get("name") or ig["id"]), } ) print(f"FB pages visible: {len(pages)} · IG: {len(igs)}") for provider, accounts, label in ( ("meta_ads", ad_accounts, "Meta Ads"), ("facebook", pages, "Facebook Page"), ("instagram", igs, "Instagram"), ): matches = best_matches(tenants, accounts, threshold=55) for tid, (acc, sc) in matches.items(): ok = sb_upsert_integ( tid, provider, { "status": "connected", "external_account_id": acc["id"], "label": acc["name"], "last_sync_at": now, "error_message": None, "enabled": True, "meta": { "account_name": acc["name"], "match_score": sc, "matched_at": now, "source": "refresh_script", }, }, ) if ok: linked_counts[provider] += 1 print(f" linked {provider}: {linked_counts[provider]}/{len(accounts)}") # ========== GOOGLE DRIVE ========== print("\n=== Google Drive folders ===") folders = [] page_token = None while True: params = { "q": "mimeType='application/vnd.google-apps.folder' and trashed=false", "fields": "nextPageToken,files(id,name)", "pageSize": "1000", "corpora": "allDrives", "includeItemsFromAllDrives": "true", "supportsAllDrives": "true", } if page_token: params["pageToken"] = page_token req = urllib.request.Request( "https://www.googleapis.com/drive/v3/files?" + urllib.parse.urlencode(params), headers={"Authorization": f"Bearer {gtoken}"}, ) with urllib.request.urlopen(req, timeout=90) as r: j = json.loads(r.read().decode()) for f in j.get("files") or []: folders.append({"id": f["id"], "name": f["name"]}) page_token = j.get("nextPageToken") if not page_token: break print(f"Drive folders: {len(folders)}") matches = best_matches(tenants, folders, threshold=55) for tid, (acc, sc) in matches.items(): if sb_upsert_integ( tid, "google_drive", { "status": "connected", "external_account_id": acc["id"], "label": acc["name"], "last_sync_at": now, "error_message": None, "meta": {"folder_name": acc["name"], "match_score": sc, "source": "refresh_script"}, }, ): linked_counts["google_drive"] += 1 print(f" linked google_drive: {linked_counts['google_drive']}") # ========== GA4 ========== print("\n=== GA4 properties ===") ga4 = [] req = urllib.request.Request( "https://analyticsadmin.googleapis.com/v1beta/accountSummaries", headers={"Authorization": f"Bearer {gtoken}"}, ) with urllib.request.urlopen(req, timeout=90) as r: summaries = json.loads(r.read().decode()) for acc in summaries.get("accountSummaries") or []: for p in acc.get("propertySummaries") or []: prop = p.get("property") if not prop: continue # measurement id mid = None try: req2 = urllib.request.Request( f"https://analyticsadmin.googleapis.com/v1beta/{prop}/dataStreams", headers={"Authorization": f"Bearer {gtoken}"}, ) with urllib.request.urlopen(req2, timeout=60) as r2: streams = json.loads(r2.read().decode()) for s in streams.get("dataStreams") or []: mid = (s.get("webStreamData") or {}).get("measurementId") if mid: break except Exception: pass ga4.append( { "id": mid or prop, "name": p.get("displayName") or prop, "property_id": prop, "measurement_id": mid, } ) print(f"GA4 properties: {len(ga4)}") matches = best_matches(tenants, ga4, threshold=50) for tid, (acc, sc) in matches.items(): if sb_upsert_integ( tid, "ga4", { "status": "connected", "external_account_id": acc["id"], "label": acc["name"], "last_sync_at": now, "error_message": None, "meta": { "property_id": acc.get("property_id"), "measurement_id": acc.get("measurement_id"), "display_name": acc["name"], "match_score": sc, "source": "refresh_script", }, }, ): linked_counts["ga4"] += 1 print(f" linked ga4: {linked_counts['ga4']}") # ========== GSC ========== print("\n=== Search Console ===") req = urllib.request.Request( "https://www.googleapis.com/webmasters/v3/sites", headers={"Authorization": f"Bearer {gtoken}"}, ) with urllib.request.urlopen(req, timeout=90) as r: sites_j = json.loads(r.read().decode()) gsc = [] for s in sites_j.get("siteEntry") or []: url = s.get("siteUrl") if url: gsc.append({"id": url, "name": url}) print(f"GSC sites: {len(gsc)}") matches = best_matches(tenants, gsc, threshold=40) for tid, (acc, sc) in matches.items(): if sb_upsert_integ( tid, "search_console", { "status": "connected", "external_account_id": acc["id"], "label": acc["id"], "last_sync_at": now, "error_message": None, "meta": {"site_url": acc["id"], "match_score": sc, "source": "refresh_script"}, }, ): linked_counts["gsc"] += 1 print(f" linked gsc: {linked_counts['gsc']}") # ========== Google Ads ========== print("\n=== Google Ads ===") try: at = gads_access() headers = { "Authorization": f"Bearer {at}", "developer-token": ENV["GOOGLE_ADS_DEVELOPER_TOKEN"], "Content-Type": "application/json", } login = (ENV.get("GOOGLE_ADS_LOGIN_CUSTOMER_ID") or "").replace("-", "") if login: headers["login-customer-id"] = login req = urllib.request.Request( f"{GADS}/customers:listAccessibleCustomers", headers=headers ) with urllib.request.urlopen(req, timeout=90) as r: listed = json.loads(r.read().decode()) ids = [rn.replace("customers/", "") for rn in listed.get("resourceNames") or []] gads_accounts = [] for cid in ids: h = dict(headers) h["login-customer-id"] = cid body = json.dumps( { "query": "SELECT customer.id, customer.descriptive_name, customer.manager FROM customer LIMIT 1" } ).encode() req = urllib.request.Request( f"{GADS}/customers/{cid}/googleAds:search", data=body, headers=h, method="POST", ) try: with urllib.request.urlopen(req, timeout=60) as r: j = json.loads(r.read().decode()) except Exception: continue c = ((j.get("results") or [{}])[0]).get("customer") or {} if not c: continue name = c.get("descriptiveName") or cid if c.get("manager"): # pull clients body2 = json.dumps( { "query": ( "SELECT customer_client.id, customer_client.descriptive_name, " "customer_client.manager, customer_client.status FROM customer_client " "WHERE customer_client.status = 'ENABLED' AND customer_client.manager = FALSE" ) } ).encode() req2 = urllib.request.Request( f"{GADS}/customers/{cid}/googleAds:search", data=body2, headers=h, method="POST", ) try: with urllib.request.urlopen(req2, timeout=90) as r2: j2 = json.loads(r2.read().decode()) for row in j2.get("results") or []: cc = row.get("customerClient") or {} if cc.get("id"): gads_accounts.append( { "id": str(cc["id"]), "name": cc.get("descriptiveName") or str(cc["id"]), } ) except Exception: pass else: gads_accounts.append({"id": str(c.get("id") or cid), "name": name}) # dedupe mmap = {a["id"]: a for a in gads_accounts} gads_accounts = list(mmap.values()) print(f"Google Ads accounts: {len(gads_accounts)}") matches = best_matches(tenants, gads_accounts, threshold=55) for tid, (acc, sc) in matches.items(): if sb_upsert_integ( tid, "google_ads", { "status": "connected", "external_account_id": acc["id"], "label": acc["name"], "last_sync_at": now, "error_message": None, "meta": { "account_name": acc["name"], "customer_id": acc["id"], "match_score": sc, "source": "refresh_script", }, }, ): linked_counts["google_ads"] += 1 print(f" linked google_ads: {linked_counts['google_ads']}") except Exception as e: print("Google Ads ERROR:", e) # ========== AUDIT ========== print("\n=== AUDIT ===") integs = sb_get( "/rest/v1/integrations?provider=in.(ga4,search_console,google_drive,google_ads,meta_ads,facebook,instagram)" "&select=tenant_id,provider,status,external_account_id,error_message" ) by = defaultdict(dict) for i in integs: by[i["tenant_id"]][i["provider"]] = i def flag(i): if not i: return "—" if i.get("status") == "connected" and i.get("external_account_id"): return "OK" if i.get("status") == "error": return "ERR" return "—" print( f"{'Cliente':30} {'GAds':5} {'Meta':5} {'GA4':5} {'GSC':5} {'Drive':5} {'FB':5} {'IG':5} Org" ) for t in tenants: m = by[t["id"]] # organic count req = urllib.request.Request( f"{BASE}/rest/v1/organic_daily?tenant_id=eq.{t['id']}&select=day", headers={**SH, "Prefer": "count=exact", "Range": "0-0"}, ) with urllib.request.urlopen(req, timeout=30) as r: cr = r.headers.get("content-range") or "*/0" org = cr.split("/")[-1] print( f"{t['name'][:30]:30} " f"{flag(m.get('google_ads')):5} " f"{flag(m.get('meta_ads')):5} " f"{flag(m.get('ga4')):5} " f"{flag(m.get('search_console')):5} " f"{flag(m.get('google_drive')):5} " f"{flag(m.get('facebook')):5} " f"{flag(m.get('instagram')):5} " f"{org}" ) print("\n=== Linked counts this run ===") print(dict(linked_counts)) print("\nDone. Orgánico days still need app sync (Emparejar todo Google / abrir Orgánico).") return 0 if __name__ == "__main__": raise SystemExit(main())