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>
451 lines
16 KiB
Python
451 lines
16 KiB
Python
#!/usr/bin/env python3
|
|
"""Exact gap report: visible assets vs tenant matches + name candidates."""
|
|
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"
|
|
|
|
|
|
def load_env() -> dict[str, str]:
|
|
vals: dict[str, str] = {}
|
|
for line in (ROOT / ".env.local").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[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(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 norm(s: str) -> str:
|
|
s = s.lower()
|
|
for a, b in (
|
|
("á", "a"),
|
|
("é", "e"),
|
|
("í", "i"),
|
|
("ó", "o"),
|
|
("ú", "u"),
|
|
("ñ", "n"),
|
|
):
|
|
s = s.replace(a, b)
|
|
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 = {x for x in t.split() if len(x) > 2}
|
|
aa = {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 top_candidates(tenants, account_name, n=3):
|
|
scored = []
|
|
for t in tenants:
|
|
sc = score_name(t["name"], t["slug"], account_name)
|
|
if sc > 0:
|
|
scored.append((sc, t["name"]))
|
|
scored.sort(key=lambda x: -x[0])
|
|
return scored[:n]
|
|
|
|
|
|
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 main() -> int:
|
|
tenants = sb("/rest/v1/tenants?select=id,name,slug&order=name.asc")
|
|
integs = sb(
|
|
"/rest/v1/integrations?provider=in.(ga4,search_console,google_drive,google_ads,meta_ads,facebook,instagram)"
|
|
"&select=tenant_id,provider,status,external_account_id,meta,error_message"
|
|
)
|
|
by = defaultdict(dict)
|
|
for i in integs:
|
|
by[i["tenant_id"]][i["provider"]] = i
|
|
|
|
grow = sb(
|
|
"/rest/v1/agency_oauth?provider=eq.google_drive&select=access_token,refresh_token,account_email"
|
|
)[0]
|
|
mrow = sb(
|
|
"/rest/v1/agency_oauth?provider=eq.meta_ads&select=access_token,account_email,scopes"
|
|
)[0]
|
|
gtoken = google_refresh(grow["refresh_token"])
|
|
mtoken = mrow["access_token"]
|
|
|
|
print("=== TOKENS ===")
|
|
print("Google:", grow.get("account_email"))
|
|
print("Meta:", mrow.get("account_email"))
|
|
|
|
# Meta inventory
|
|
ads = []
|
|
for row in meta_paginate(
|
|
mtoken, "me/adaccounts?fields=id,account_id,name&limit=200"
|
|
):
|
|
aid = row.get("account_id") or row["id"].replace("act_", "")
|
|
ads.append({"id": f"act_{aid}", "name": row.get("name") or aid})
|
|
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"]),
|
|
"page": page.get("name"),
|
|
}
|
|
)
|
|
|
|
print(f"\n=== META VISIBLE === ads={len(ads)} pages={len(pages)} ig={len(igs)}")
|
|
print("Ad accounts:")
|
|
for a in ads:
|
|
cands = top_candidates(tenants, a["name"])
|
|
linked = next(
|
|
(
|
|
t["name"]
|
|
for t in tenants
|
|
if (by[t["id"]].get("meta_ads") or {}).get("external_account_id")
|
|
== a["id"]
|
|
),
|
|
None,
|
|
)
|
|
print(
|
|
f" {a['name']!r} ({a['id']}) → linked={linked or 'NO'} best={cands}"
|
|
)
|
|
print("Pages:")
|
|
for a in pages:
|
|
cands = top_candidates(tenants, a["name"])
|
|
linked = next(
|
|
(
|
|
t["name"]
|
|
for t in tenants
|
|
if (by[t["id"]].get("facebook") or {}).get("external_account_id")
|
|
== a["id"]
|
|
),
|
|
None,
|
|
)
|
|
print(
|
|
f" {a['name']!r} ({a['id']}) → linked={linked or 'NO'} best={cands}"
|
|
)
|
|
print("Instagram:")
|
|
for a in igs:
|
|
cands = top_candidates(tenants, a["name"]) + top_candidates(
|
|
tenants, a.get("page") or ""
|
|
)
|
|
# dedupe keep best
|
|
seen = {}
|
|
for sc, name in cands:
|
|
seen[name] = max(sc, seen.get(name, 0))
|
|
cands = sorted(((v, k) for k, v in seen.items()), reverse=True)[:3]
|
|
linked = next(
|
|
(
|
|
t["name"]
|
|
for t in tenants
|
|
if (by[t["id"]].get("instagram") or {}).get("external_account_id")
|
|
== a["id"]
|
|
),
|
|
None,
|
|
)
|
|
print(
|
|
f" {a['name']!r} page={a.get('page')!r} ({a['id']}) → linked={linked or 'NO'} best={cands}"
|
|
)
|
|
|
|
# GA4 / GSC inventory
|
|
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())
|
|
ga4 = []
|
|
for acc in summaries.get("accountSummaries") or []:
|
|
for p in acc.get("propertySummaries") or []:
|
|
if p.get("property"):
|
|
ga4.append(
|
|
{
|
|
"id": p["property"],
|
|
"name": p.get("displayName") or p["property"],
|
|
"account": acc.get("displayName"),
|
|
}
|
|
)
|
|
|
|
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 = [
|
|
{"id": s["siteUrl"], "name": s["siteUrl"]}
|
|
for s in (sites_j.get("siteEntry") or [])
|
|
if s.get("siteUrl")
|
|
]
|
|
|
|
print(f"\n=== GOOGLE VISIBLE === ga4={len(ga4)} gsc={len(gsc)}")
|
|
|
|
# Test Data API on first property
|
|
if ga4:
|
|
prop = ga4[0]["id"].replace("properties/", "")
|
|
end = (datetime.now(timezone.utc) - timedelta(days=1)).date()
|
|
start = end - timedelta(days=2)
|
|
body = json.dumps(
|
|
{
|
|
"dateRanges": [{"startDate": str(start), "endDate": str(end)}],
|
|
"dimensions": [{"name": "date"}],
|
|
"metrics": [{"name": "sessions"}],
|
|
}
|
|
).encode()
|
|
req = urllib.request.Request(
|
|
f"https://analyticsdata.googleapis.com/v1beta/properties/{prop}:runReport",
|
|
data=body,
|
|
headers={
|
|
"Authorization": f"Bearer {gtoken}",
|
|
"Content-Type": "application/json",
|
|
},
|
|
method="POST",
|
|
)
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=60) as r:
|
|
print("Data API test: OK on", ga4[0]["name"], prop)
|
|
except urllib.error.HTTPError as e:
|
|
print("Data API test: FAIL", e.read().decode()[:250])
|
|
|
|
# Unmatched GA4 with best candidates (only those not linked)
|
|
linked_ga4_ids = {
|
|
(by[t["id"]].get("ga4") or {}).get("external_account_id")
|
|
for t in tenants
|
|
}
|
|
linked_ga4_props = set()
|
|
for t in tenants:
|
|
meta = (by[t["id"]].get("ga4") or {}).get("meta") or {}
|
|
if meta.get("property_id"):
|
|
linked_ga4_props.add(meta["property_id"])
|
|
|
|
print("\n=== GA4 sin ficha clara (score<50 o no enlazado) — top candidatos ===")
|
|
for a in ga4:
|
|
linked = next(
|
|
(
|
|
t["name"]
|
|
for t in tenants
|
|
if (by[t["id"]].get("ga4") or {}).get("meta", {}).get("property_id")
|
|
== a["id"]
|
|
or (by[t["id"]].get("ga4") or {}).get("external_account_id")
|
|
in (a["id"], a["name"])
|
|
),
|
|
None,
|
|
)
|
|
cands = top_candidates(tenants, a["name"])
|
|
best = cands[0][0] if cands else 0
|
|
if linked and best >= 50:
|
|
continue
|
|
if linked:
|
|
continue
|
|
print(f" GA4 {a['name']!r} ({a['id']}) acct={a.get('account')!r} best={cands}")
|
|
|
|
print("\n=== GSC sin ficha clara ===")
|
|
for a in gsc:
|
|
linked = next(
|
|
(
|
|
t["name"]
|
|
for t in tenants
|
|
if (by[t["id"]].get("search_console") or {}).get("external_account_id")
|
|
== a["id"]
|
|
),
|
|
None,
|
|
)
|
|
if linked:
|
|
continue
|
|
cands = top_candidates(tenants, a["name"])
|
|
# also try domain without sc-domain
|
|
bare = a["name"].replace("sc-domain:", "").replace("https://", "").replace("http://", "").strip("/")
|
|
cands2 = top_candidates(tenants, bare)
|
|
merged = {}
|
|
for sc, name in cands + cands2:
|
|
merged[name] = max(sc, merged.get(name, 0))
|
|
cands = sorted(((v, k) for k, v in merged.items()), reverse=True)[:3]
|
|
print(f" GSC {a['name']!r} best={cands}")
|
|
|
|
# Per-tenant gaps with why
|
|
print("\n=== POR CLIENTE: qué falta y por qué ===")
|
|
providers = [
|
|
"google_ads",
|
|
"meta_ads",
|
|
"ga4",
|
|
"search_console",
|
|
"google_drive",
|
|
"facebook",
|
|
"instagram",
|
|
]
|
|
|
|
def ok(i):
|
|
return bool(i and i.get("status") == "connected" and i.get("external_account_id"))
|
|
|
|
for t in tenants:
|
|
m = by[t["id"]]
|
|
gaps = []
|
|
for p in providers:
|
|
if ok(m.get(p)):
|
|
continue
|
|
if p == "meta_ads":
|
|
cands = [(score_name(t["name"], t["slug"], a["name"]), a["name"], a["id"]) for a in ads]
|
|
cands = [c for c in sorted(cands, reverse=True) if c[0] > 0][:2]
|
|
if not ads:
|
|
gaps.append("meta_ads: token no ve ad accounts")
|
|
elif not cands:
|
|
gaps.append("meta_ads: no hay ad account cuyo nombre se parezca (¿no está en las 9 visibles?)")
|
|
else:
|
|
gaps.append(f"meta_ads: no auto-match; candidatos {cands}")
|
|
elif p == "facebook":
|
|
cands = [(score_name(t["name"], t["slug"], a["name"]), a["name"], a["id"]) for a in pages]
|
|
cands = [c for c in sorted(cands, reverse=True) if c[0] > 0][:2]
|
|
if not cands:
|
|
gaps.append("facebook: ninguna Page visible se parece al nombre")
|
|
else:
|
|
gaps.append(f"facebook: no auto-match; candidatos {cands}")
|
|
elif p == "instagram":
|
|
cands = []
|
|
for a in igs:
|
|
sc = max(
|
|
score_name(t["name"], t["slug"], a["name"]),
|
|
score_name(t["name"], t["slug"], a.get("page") or ""),
|
|
)
|
|
if sc > 0:
|
|
cands.append((sc, a["name"], a.get("page"), a["id"]))
|
|
cands = sorted(cands, reverse=True)[:2]
|
|
if not cands:
|
|
gaps.append("instagram: ningún @ / page visible se parece")
|
|
else:
|
|
gaps.append(f"instagram: no auto-match; candidatos {cands}")
|
|
elif p == "ga4":
|
|
cands = [(score_name(t["name"], t["slug"], a["name"]), a["name"], a["id"]) for a in ga4]
|
|
cands = [c for c in sorted(cands, reverse=True) if c[0] > 0][:2]
|
|
if not cands:
|
|
gaps.append("ga4: ninguna propiedad visible se parece al nombre")
|
|
else:
|
|
gaps.append(f"ga4: no auto-match (umbral 50); candidatos {cands}")
|
|
elif p == "search_console":
|
|
cands = []
|
|
for a in gsc:
|
|
bare = a["name"].replace("sc-domain:", "").replace("https://", "").replace("http://", "").strip("/")
|
|
sc = max(
|
|
score_name(t["name"], t["slug"], a["name"]),
|
|
score_name(t["name"], t["slug"], bare),
|
|
)
|
|
if sc > 0:
|
|
cands.append((sc, a["name"]))
|
|
cands = sorted(cands, reverse=True)[:2]
|
|
if not cands:
|
|
gaps.append("gsc: ningún site visible se parece")
|
|
else:
|
|
gaps.append(f"gsc: no auto-match (umbral 40); candidatos {cands}")
|
|
elif p == "google_drive":
|
|
gaps.append("drive: sin carpeta auto-matched (revisar nombre carpeta Drive)")
|
|
elif p == "google_ads":
|
|
gaps.append("google_ads: sin cuenta enlazada (MCC / nombre)")
|
|
if gaps:
|
|
print(f"\n{t['name']}:")
|
|
for g in gaps:
|
|
print(f" - {g}")
|
|
else:
|
|
print(f"\n{t['name']}: COMPLETO (todos los canales con ID)")
|
|
|
|
# BPS GSC permission probe
|
|
bps = next(t for t in tenants if t["name"] == "BPS")
|
|
gsc_id = (by[bps["id"]].get("search_console") or {}).get("external_account_id")
|
|
if gsc_id:
|
|
end = (datetime.now(timezone.utc) - timedelta(days=1)).date()
|
|
start = end - timedelta(days=3)
|
|
body = json.dumps(
|
|
{"startDate": str(start), "endDate": str(end), "dimensions": ["date"]}
|
|
).encode()
|
|
req = urllib.request.Request(
|
|
f"https://www.googleapis.com/webmasters/v3/sites/{urllib.parse.quote(gsc_id, safe='')}/searchAnalytics/query",
|
|
data=body,
|
|
headers={
|
|
"Authorization": f"Bearer {gtoken}",
|
|
"Content-Type": "application/json",
|
|
},
|
|
method="POST",
|
|
)
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=60) as r:
|
|
print("\nBPS GSC probe: OK")
|
|
except urllib.error.HTTPError as e:
|
|
print("\nBPS GSC probe: FAIL", e.read().decode()[:220])
|
|
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|