Incluye utilidades de ops para CRON_SECRET, login customer Google Ads, sync de seguimiento BPS y probes de conexiones. Co-authored-by: Cursor <cursoragent@cursor.com>
593 lines
21 KiB
Python
593 lines
21 KiB
Python
#!/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())
|