chore: scripts Dokploy/BPS (cron secret, Ads MCC, sync y probes)

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>
This commit is contained in:
4Ventures
2026-09-29 16:48:44 +02:00
parent 8c5acb626f
commit 1a58af129d
17 changed files with 2913 additions and 0 deletions

View File

@@ -0,0 +1,592 @@
#!/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())