From 1a58af129d15ec97e9fc54ee133b5ff4a46013f6 Mon Sep 17 00:00:00 2001 From: 4Ventures <4ventures@MacBook-Air-de-4Ventures.local> Date: Tue, 29 Sep 2026 16:48:44 +0200 Subject: [PATCH] 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 --- public/preview-seguimiento.html | 224 ++++++++++ scripts/dokploy_set_cron_secret.py | 159 +++++++ scripts/dokploy_set_gads_login_cid.py | 121 +++++ scripts/export_bps_calendar_doc.ts | 63 +++ scripts/export_bps_xlsx.ts | 33 ++ scripts/gap_report_connections.py | 450 +++++++++++++++++++ scripts/import_bps_calendars.ts | 457 +++++++++++++++++++ scripts/list_bps_calendar_sheets.ts | 53 +++ scripts/list_drive_calendars.ts | 64 +++ scripts/probe_bps_seguimiento_drive.ts | 101 +++++ scripts/probe_bps_superhuman.ts | 52 +++ scripts/probe_seguimiento_html.ts | 41 ++ scripts/provision_victor_bps.ts | 96 ++++ scripts/refresh_agency_connections.py | 592 +++++++++++++++++++++++++ scripts/refresh_agency_connections.ts | 203 +++++++++ scripts/sync_bps_gdoc_days.ts | 145 ++++++ scripts/sync_bps_seguimiento.ts | 59 +++ 17 files changed, 2913 insertions(+) create mode 100644 public/preview-seguimiento.html create mode 100644 scripts/dokploy_set_cron_secret.py create mode 100644 scripts/dokploy_set_gads_login_cid.py create mode 100644 scripts/export_bps_calendar_doc.ts create mode 100644 scripts/export_bps_xlsx.ts create mode 100644 scripts/gap_report_connections.py create mode 100644 scripts/import_bps_calendars.ts create mode 100644 scripts/list_bps_calendar_sheets.ts create mode 100644 scripts/list_drive_calendars.ts create mode 100644 scripts/probe_bps_seguimiento_drive.ts create mode 100644 scripts/probe_bps_superhuman.ts create mode 100644 scripts/probe_seguimiento_html.ts create mode 100644 scripts/provision_victor_bps.ts create mode 100644 scripts/refresh_agency_connections.py create mode 100644 scripts/refresh_agency_connections.ts create mode 100644 scripts/sync_bps_gdoc_days.ts create mode 100644 scripts/sync_bps_seguimiento.ts diff --git a/public/preview-seguimiento.html b/public/preview-seguimiento.html new file mode 100644 index 0000000..bdcc9f0 --- /dev/null +++ b/public/preview-seguimiento.html @@ -0,0 +1,224 @@ + + + + + +Preview · Seguimiento · Virginia Molet + + + +
+ +
+
+
+

Seguimiento & evolución

+

Virginia Molet · timeline Superhuman + series de canal

+
+
Preview UI · datos reales de timeline
+
+ +
+
+ Evolución por canal +
LeadsInversiónCPL
+
+
+ meta + google + organic +
+ + 018365472 + + 07-0607-1307-2007-2708-0308-1008-1708-24 + +

La gráfica usa series de ejemplo (Virginia aún no tiene kpi_weekly). El timeline inferior sí es sync real de Superhuman.

+
+ +
+ Timeline reunión a reunión (12) +
    + +
  1. + +
    +
    +
    + Tema + 18 Aug 2026 +

    Gravitae: Meta Ads/Google ADs

    +
    + Superhuman ↗ +
    +

    - **[Landing Page del Practitioner en Coaching Ejecutivo](https://gravitaeacademy.com/es/diploma-en-coaching-ejecutivo/)** + + +📊 KPIs a vigilar las primeras 2 semanas + +## Copy of T

    +
    +
  2. +
  3. + +
    +
    +
    + Tema + 01 Jul 2026 +

    wbf_mad_sice.pdf

    +
    + Superhuman ↗ +
    +

    Abrir en Superhuman para el detalle completo.

    +
    +
  4. +
  5. + +
    +
    +
    + Tema + 01 Jul 2026 +

    Info emails Open Event 9/7/26

    +
    + Superhuman ↗ +
    +

    > Añadimos a Carl Lewis que tiene mucho nombre + +También quería compartir una oportunidad que puede ser de interés para quienes trabajáis en liderazgo, gestión de equipos o transfo

    +
    +
  6. +
  7. + +
    +
    +
    + Tema + 25 Jun 2026 +

    Email Open Event 9 julio.pdf

    +
    + Superhuman ↗ +
    +

    Abrir en Superhuman para el detalle completo.

    +
    +
  8. +
  9. + +
    +
    +
    + Tema + 18 Jun 2026 +

    Email 1

    +
    + Superhuman ↗ +
    +

    1. EMAIL A TODOS LOS QUE SE HAN REGISTRADO AL EVENTO Y NO HAN IDO + + + +TITULO: Ponte al día con lo que te perdiste en el I Salón Internacional de Coaching Ejecutivo + + + +Hola NOMBRE,

    +
    +
  10. +
  11. + +
    +
    +
    + Tema + 08 Jun 2026 +

    WhatApp

    +
    + Superhuman ↗ +
    +

    - Hola nombre. Quedan 4 días, te esperamos el viernes a las 9:30 en el saloninternacionacionalcoaching.com., por favor confirma tu asistencia abajo. + - Confirmar Asistencia + - N

    +
    +
  12. +
  13. + +
    +
    +
    + Tema + 24 Apr 2026 +

    Secuencia emails 240426

    +
    + Superhuman ↗ +
    +

    Es una [secuencia de 6 emails](https://drive.google.com/drive/folders/1i7717lRLgpgU5Y6FZonbbrpWjS3_pD96?usp=sharing), el primero para hoy si es posible y os ha dado tiempo a prepar

    +
    +
  14. +
  15. + +
    +
    +
    + Tema + 23 Apr 2026 +

    Web Salón V2

    +
    + Superhuman ↗ +
    +

    **Pestanas: Registro / Agenda / Organiza / Contacto** + +### **AGENDA** + +Te mando por aquí el texto para la agenda: primero poner un texto introductorio, luego los conferenciantes c

    +
    +
  16. +
+
+
+
+ + \ No newline at end of file diff --git a/scripts/dokploy_set_cron_secret.py b/scripts/dokploy_set_cron_secret.py new file mode 100644 index 0000000..3510aff --- /dev/null +++ b/scripts/dokploy_set_cron_secret.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python3 +"""Merge CRON_SECRET into client-portal Dokploy env, then Deploy.""" +from __future__ import annotations + +import json +import re +import sys +import time +from pathlib import Path + +from playwright.sync_api import sync_playwright + +ROOT = Path("/Users/4ventures/4ventures-client-portal") +BPS = Path("/Users/4ventures/Projects/bps-facturacion") +BASE = "https://dokploy.4ventures.es" +APP_ID = "NZS797t9Rnq9Pj4mHiii3" +APP = ( + f"{BASE}/dashboard/project/Z_4M-uDOH1_--itGSTh4l" + f"/environment/bGja70l_a7HQ1dxyXqSuf/services/application/{APP_ID}" +) +HEALTH = "https://app.4ventures.es/api/health" + + +def cred(key: str) -> str: + import os + + v = os.environ.get(key, "").strip() + if v: + return v + for p in (BPS / ".env", ROOT / ".env.local"): + if not p.is_file(): + continue + for line in p.read_text(encoding="utf-8").splitlines(): + if line.startswith(f"{key}="): + return line.split("=", 1)[1].strip().strip('"').strip("'") + return "" + + +def main() -> int: + cron = cred("CRON_SECRET") + if not cron: + print("missing CRON_SECRET locally", flush=True) + return 2 + email = cred("DOKPLOY_EMAIL") or "marketing@4ventures.es" + password = cred("DOKPLOY_PASSWORD") + if not password: + print("missing DOKPLOY_PASSWORD", flush=True) + return 2 + + with sync_playwright() as p: + browser = p.chromium.launch(headless=True) + page = browser.new_page(viewport={"width": 1400, "height": 1000}) + page.goto(BASE + "/", wait_until="domcontentloaded", timeout=90000) + page.wait_for_timeout(600) + if "/dashboard" not in page.url: + page.locator('input[type="email"], input[name="email"]').first.fill(email) + page.locator('input[type="password"]').first.fill(password) + page.locator('button[type="submit"]').first.click() + page.wait_for_url(re.compile(r"/dashboard"), timeout=90000) + print("login ok", flush=True) + + page.goto(APP, wait_until="networkidle", timeout=120000) + page.wait_for_timeout(800) + + result = page.evaluate( + """async ({ appId, cronSecret }) => { + const oneUrl = '/api/trpc/application.one?batch=1&input=' + encodeURIComponent(JSON.stringify({ '0': { json: { applicationId: appId } } })); + const one = await fetch(oneUrl, { credentials: 'include' }); + const oneText = await one.text(); + if (!one.ok) return { ok: false, step: 'one', status: one.status, body: oneText.slice(0, 400) }; + let envText = ''; + try { + const j = JSON.parse(oneText); + const data = j[0]?.result?.data?.json || j[0]?.result?.data || j; + envText = data?.env || data?.environment || ''; + if (typeof envText !== 'string') envText = ''; + } catch (e) { + return { ok: false, step: 'parse', error: String(e), body: oneText.slice(0, 400) }; + } + const lines = envText.split('\\n').filter(Boolean); + const map = {}; + for (const line of lines) { + const i = line.indexOf('='); + if (i < 0) continue; + map[line.slice(0, i)] = line.slice(i + 1); + } + map['CRON_SECRET'] = cronSecret; + const merged = Object.keys(map).map(k => k + '=' + map[k]).join('\\n') + '\\n'; + const keys = Object.keys(map); + + const tries = []; + const payloads = [ + { path: '/api/application.update', body: { applicationId: appId, env: merged } }, + { path: '/api/trpc/application.update?batch=1', body: { '0': { json: { applicationId: appId, env: merged } } } }, + { path: '/api/trpc/application.saveEnvironment?batch=1', body: { '0': { json: { applicationId: appId, env: merged } } } }, + ]; + for (const t of payloads) { + const r = await fetch(t.path, { + method: 'POST', + credentials: 'include', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(t.body), + }); + const text = await r.text(); + tries.push({ path: t.path, status: r.status, body: text.slice(0, 180) }); + if (r.ok) return { ok: true, keys, tries, hadCron: keys.includes('CRON_SECRET') }; + } + return { ok: false, keys, tries, envLen: envText.length }; + }""", + {"appId": APP_ID, "cronSecret": cron}, + ) + print( + json.dumps( + { + "ok": result.get("ok"), + "hadCron": result.get("hadCron"), + "keyCount": len(result.get("keys") or []), + "tries": result.get("tries"), + "step": result.get("step"), + "status": result.get("status"), + }, + indent=2, + )[:2000], + flush=True, + ) + if not result.get("ok"): + browser.close() + return 6 + + page.goto(APP, wait_until="domcontentloaded", timeout=90000) + page.wait_for_timeout(700) + page.get_by_role("tab", name=re.compile(r"^General$", re.I)).first.click() + page.wait_for_timeout(500) + page.get_by_role("button", name=re.compile(r"^Deploy$", re.I)).first.click() + page.wait_for_timeout(600) + conf = page.get_by_role("button", name=re.compile(r"^Confirm$", re.I)) + if conf.count(): + conf.first.click() + print("DEPLOY_CLICKED", flush=True) + + for i in range(30): + time.sleep(10) + try: + import urllib.request + + code = urllib.request.urlopen(HEALTH, timeout=12).getcode() + except Exception as e: + code = str(e) + print(f"health_try{i}", code, flush=True) + if code == 200 and i >= 6: + # give container time to pick new env after first healthy + break + + browser.close() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/dokploy_set_gads_login_cid.py b/scripts/dokploy_set_gads_login_cid.py new file mode 100644 index 0000000..b850263 --- /dev/null +++ b/scripts/dokploy_set_gads_login_cid.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +"""Merge GOOGLE_ADS_LOGIN_CUSTOMER_ID into client-portal Dokploy env.""" +from __future__ import annotations + +import re +import sys +from pathlib import Path + +from playwright.sync_api import sync_playwright + +ROOT = Path("/Users/4ventures/4ventures-client-portal") +BPS = Path("/Users/4ventures/Projects/bps-facturacion") +BASE = "https://dokploy.4ventures.es" +APP_ID = "NZS797t9Rnq9Pj4mHiii3" +APP = ( + f"{BASE}/dashboard/project/Z_4M-uDOH1_--itGSTh4l" + f"/environment/bGja70l_a7HQ1dxyXqSuf/services/application/{APP_ID}" +) +KEY = "GOOGLE_ADS_LOGIN_CUSTOMER_ID" +VALUE = "5963150101" + + +def cred(key: str) -> str: + import os + + v = os.environ.get(key, "").strip() + if v: + return v + p = BPS / ".env" + if not p.is_file(): + return "" + for line in p.read_text(encoding="utf-8").splitlines(): + if line.startswith(f"{key}="): + return line.split("=", 1)[1].strip().strip('"').strip("'") + return "" + + +def main() -> int: + email = cred("DOKPLOY_EMAIL") or "marketing@4ventures.es" + password = cred("DOKPLOY_PASSWORD") + if not password: + print("missing DOKPLOY_PASSWORD", flush=True) + return 2 + + chromium = Path.home() / "Library/Caches/ms-playwright/chromium-1148/chrome-mac/Chromium.app/Contents/MacOS/Chromium" + launch_kwargs = {"headless": True} + if chromium.is_file(): + launch_kwargs["executable_path"] = str(chromium) + + with sync_playwright() as p: + browser = p.chromium.launch(**launch_kwargs) + page = browser.new_page(viewport={"width": 1400, "height": 1000}) + page.goto(BASE + "/", wait_until="domcontentloaded", timeout=90000) + page.wait_for_timeout(600) + if "/dashboard" not in page.url: + page.locator('input[type="email"], input[name="email"]').first.fill(email) + page.locator('input[type="password"]').first.fill(password) + page.locator('button[type="submit"]').first.click() + page.wait_for_url(re.compile(r"/dashboard"), timeout=90000) + print("login ok", flush=True) + + page.goto(APP, wait_until="networkidle", timeout=120000) + page.wait_for_timeout(800) + + result = page.evaluate( + """async ({ appId, key, value }) => { + const oneUrl = '/api/trpc/application.one?batch=1&input=' + encodeURIComponent(JSON.stringify({ '0': { json: { applicationId: appId } } })); + const one = await fetch(oneUrl, { credentials: 'include' }); + const oneText = await one.text(); + if (!one.ok) return { ok: false, step: 'one', status: one.status, body: oneText.slice(0, 400) }; + let envText = ''; + try { + const j = JSON.parse(oneText); + const data = j[0]?.result?.data?.json || j[0]?.result?.data || j; + envText = data?.env || data?.environment || ''; + if (typeof envText !== 'string') envText = ''; + } catch (e) { + return { ok: false, step: 'parse', error: String(e), body: oneText.slice(0, 400) }; + } + const lines = envText.split('\\n').filter(Boolean); + const map = {}; + for (const line of lines) { + const i = line.indexOf('='); + if (i < 0) continue; + map[line.slice(0, i)] = line.slice(i + 1); + } + const prev = map[key] || null; + map[key] = value; + const merged = Object.keys(map).map(k => k + '=' + map[k]).join('\\n') + '\\n'; + + const tries = []; + const payloads = [ + { path: '/api/application.update', body: { applicationId: appId, env: merged } }, + { path: '/api/trpc/application.update?batch=1', body: { '0': { json: { applicationId: appId, env: merged } } } }, + { path: '/api/trpc/application.saveEnvironment?batch=1', body: { '0': { json: { applicationId: appId, env: merged } } } }, + ]; + for (const t of payloads) { + const r = await fetch(t.path, { + method: 'POST', + credentials: 'include', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(t.body), + }); + const text = await r.text(); + tries.push({ path: t.path, status: r.status, body: text.slice(0, 160) }); + if (r.ok) { + return { ok: true, prev, set: value, keys: Object.keys(map).length, tries }; + } + } + return { ok: false, step: 'update', prev, tries }; + }""", + {"appId": APP_ID, "key": KEY, "value": VALUE}, + ) + browser.close() + + print(result, flush=True) + return 0 if result.get("ok") else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/export_bps_calendar_doc.ts b/scripts/export_bps_calendar_doc.ts new file mode 100644 index 0000000..b84dcfd --- /dev/null +++ b/scripts/export_bps_calendar_doc.ts @@ -0,0 +1,63 @@ +import { config } from 'dotenv'; +import { resolve } from 'path'; +import { writeFileSync, mkdirSync } from 'fs'; +config({ path: resolve(process.cwd(), '.env.local') }); +import { getValidAccessToken } from '../src/lib/google-drive'; + +const IDS = [ + '1YPcHBjioiHHQy22Q2xevkvUqA5jMq87v6IviV7VTL2s', + '1-Zss1suBLDxAgqUuP9DrJYWHiicyin8aQqMOTIIekAg', +]; + +async function main() { + const token = await getValidAccessToken(); + mkdirSync('/tmp/bps-cal', { recursive: true }); + for (const id of IDS) { + const metaR = await fetch( + `https://www.googleapis.com/drive/v3/files/${id}?fields=id,name,mimeType&supportsAllDrives=true`, + { headers: { Authorization: `Bearer ${token}` } } + ); + const meta = (await metaR.json()) as { + id: string; + name: string; + mimeType: string; + }; + console.log('META', meta); + const mime = meta.mimeType || ''; + let buf: Buffer; + if (mime.includes('spreadsheet')) { + const r = await fetch( + `https://www.googleapis.com/drive/v3/files/${id}/export?mimeType=${encodeURIComponent('text/csv')}`, + { headers: { Authorization: `Bearer ${token}` } } + ); + if (!r.ok) throw new Error(await r.text()); + buf = Buffer.from(await r.arrayBuffer()); + writeFileSync(`/tmp/bps-cal/${id}.csv`, buf); + } else if (mime.includes('document')) { + const r = await fetch( + `https://www.googleapis.com/drive/v3/files/${id}/export?mimeType=${encodeURIComponent('text/plain')}`, + { headers: { Authorization: `Bearer ${token}` } } + ); + if (!r.ok) throw new Error(await r.text()); + buf = Buffer.from(await r.arrayBuffer()); + writeFileSync(`/tmp/bps-cal/${id}.txt`, buf); + } else { + const r = await fetch( + `https://www.googleapis.com/drive/v3/files/${id}?alt=media&supportsAllDrives=true`, + { headers: { Authorization: `Bearer ${token}` } } + ); + if (!r.ok) throw new Error(await r.text()); + buf = Buffer.from(await r.arrayBuffer()); + writeFileSync(`/tmp/bps-cal/${id}.bin`, buf); + } + const text = buf.toString('utf8'); + console.log('size', buf.length); + console.log(text.slice(0, 3000)); + console.log('----\n'); + } +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/scripts/export_bps_xlsx.ts b/scripts/export_bps_xlsx.ts new file mode 100644 index 0000000..dcd2734 --- /dev/null +++ b/scripts/export_bps_xlsx.ts @@ -0,0 +1,33 @@ +import { config } from 'dotenv'; +import { resolve } from 'path'; +import { writeFileSync, mkdirSync } from 'fs'; +config({ path: resolve(process.cwd(), '.env.local') }); +import { getValidAccessToken } from '../src/lib/google-drive'; + +const ID = '1YPcHBjioiHHQy22Q2xevkvUqA5jMq87v6IviV7VTL2s'; +const OCT = '1-Zss1suBLDxAgqUuP9DrJYWHiicyin8aQqMOTIIekAg'; + +async function exportXlsx(token: string, id: string, path: string) { + const mime = + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'; + const r = await fetch( + `https://www.googleapis.com/drive/v3/files/${id}/export?mimeType=${encodeURIComponent(mime)}`, + { headers: { Authorization: `Bearer ${token}` } } + ); + if (!r.ok) throw new Error(`${id} ${r.status} ${(await r.text()).slice(0, 300)}`); + const buf = Buffer.from(await r.arrayBuffer()); + writeFileSync(path, buf); + console.log('wrote', path, buf.length); +} + +async function main() { + const token = await getValidAccessToken(); + mkdirSync('/tmp/bps-cal', { recursive: true }); + await exportXlsx(token, ID, '/tmp/bps-cal/bps-calendario.xlsx'); + await exportXlsx(token, OCT, '/tmp/bps-cal/bps-octubre.xlsx'); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/scripts/gap_report_connections.py b/scripts/gap_report_connections.py new file mode 100644 index 0000000..c096ee9 --- /dev/null +++ b/scripts/gap_report_connections.py @@ -0,0 +1,450 @@ +#!/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()) diff --git a/scripts/import_bps_calendars.ts b/scripts/import_bps_calendars.ts new file mode 100644 index 0000000..68319aa --- /dev/null +++ b/scripts/import_bps_calendars.ts @@ -0,0 +1,457 @@ +/** + * Import BPS editorial calendars from Drive xlsx into documents (type=calendar). + * Includes hidden month tabs (JULIO…JUNIO, ENERO 2026, *26). + * + * Prerequisite: /tmp/bps-cal/bps-calendario.xlsx (+ optional bps-octubre.xlsx) + * npx tsx scripts/export_bps_xlsx.ts + * npx tsx scripts/import_bps_calendars.ts + */ +import { config } from 'dotenv'; +import { resolve } from 'path'; +import { writeFileSync } from 'fs'; +import { execFileSync } from 'child_process'; +config({ path: resolve(process.cwd(), '.env.local') }); + +import { serviceHeaders, supabaseUrl } from '../src/lib/agency'; +import type { RrssMonthPlan, RrssPiece } from '../src/components/RrssMonthView'; + +const BPS_ID = '8bec977a-6032-4759-aa7f-b5042081f450'; + +/** + * Explicit year/month for every editorial month sheet (incl. hidden). + * Workbook order: JUL–DIC 2025 → ENE–JUN 2026 → JUL–OCT 26. + */ +const SHEET_YM: Record = { + JULIO: '2025-07', + AGOSTO: '2025-08', + SEPTIEMBRE: '2025-09', + OCTUBRE: '2025-10', + NOVIEMBRE: '2025-11', + DICIEMBRE: '2025-12', + 'ENERO 2026': '2026-01', + FEBRERO: '2026-02', + MARZO: '2026-03', + ABRIL: '2026-04', + MAYO: '2026-05', + JUNIO: '2026-06', + 'JULIO 26': '2026-07', + 'AGOSTO 26': '2026-08', + 'SEPTIMEBRE 26': '2026-09', + 'OCTUBRE 26': '2026-10', +}; + +const MONTH_SHEET_NAMES = new Set(Object.keys(SHEET_YM)); + +function slugify(s: string) { + return s + .toLowerCase() + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, '') + .slice(0, 48); +} + +function parseDayHeader(h: string): number | null { + const raw = String(h || '').trim(); + if (!raw || /^x\b/i.test(raw) || /^columna/i.test(raw)) return null; + const m = raw.match(/(\d{1,2})\s*$/); + if (!m) return null; + const n = Number(m[1]); + return n >= 1 && n <= 31 ? n : null; +} + +type SheetRows = (string | number | null)[][]; + +/** Parse SEMANA/STORIES/POST grid into pieces. */ +function parseWeekGrid( + sheetName: string, + rows: SheetRows, + year: number, + month: number +): RrssPiece[] { + const pieces: RrssPiece[] = []; + let dayCols: { col: number; day: number }[] = []; + let kind: 'STORIES' | 'POST' | null = null; + /** Week wraps when header has e.g. LUNES 30 … MARTES 1 (spillover from prev month). */ + let weekWraps = false; + + for (const row of rows) { + if (!row || !row.some((c) => c != null && String(c).trim())) continue; + const c0 = String(row[0] ?? '') + .trim() + .toUpperCase(); + + if ( + c0.startsWith('SEMANA') || + /^(LUNES|MARTES|MI[EÉ]RCOLES)/i.test(String(row[1] ?? '')) + ) { + dayCols = []; + for (let col = 1; col < Math.min(row.length, 10); col++) { + const day = parseDayHeader(String(row[col] ?? '')); + if (day) dayCols.push({ col, day }); + } + weekWraps = dayCols.some( + (d, i) => + i > 0 && + d.day < dayCols[i - 1].day && + dayCols[i - 1].day >= 28 && + d.day <= 7 + ); + kind = null; + continue; + } + + if (c0.startsWith('STORIES') || c0 === 'STORY') { + kind = 'STORIES'; + } else if (c0.startsWith('POST')) { + kind = 'POST'; + } else if (!kind) { + continue; + } + + for (const { col, day } of dayCols) { + const raw = row[col]; + if (raw == null) continue; + const text = String(raw).trim(); + if (!text || text === 'X' || /^columna/i.test(text)) continue; + + let y = year; + let m = month; + if (weekWraps && day >= 28) { + m = month - 1; + if (m < 1) { + m = 12; + y = year - 1; + } + } + + const mm = String(m).padStart(2, '0'); + const dd = String(day).padStart(2, '0'); + const date = `${y}-${mm}-${dd}`; + const dt = new Date(`${date}T12:00:00Z`); + if (Number.isNaN(dt.getTime()) || dt.getUTCMonth() + 1 !== m) continue; + + const id = `bps-${date}-${kind === 'STORIES' ? 'st' : 'post'}-${slugify(text).slice(0, 24)}`; + pieces.push({ + id, + date, + platform: 'Instagram', + format: kind === 'STORIES' ? 'Stories' : 'Post', + pillar: kind === 'STORIES' ? 'stories' : 'feed', + title: text.split('\n')[0].slice(0, 120), + body: text, + status: 'planned', + kind: kind === 'STORIES' ? 'story' : 'post', + color: kind === 'STORIES' ? '#D9821F' : '#2071cf', + }); + } + } + + const seen = new Set(); + return pieces.filter((p) => { + if (seen.has(p.id)) return false; + seen.add(p.id); + return true; + }); +} + +async function loadSheetsFromPython(): Promise< + { name: string; rows: SheetRows; state: string }[] +> { + const py = ` +import openpyxl, json +wb=openpyxl.load_workbook('/tmp/bps-cal/bps-calendario.xlsx', data_only=True) +out=[] +month_names={${[...MONTH_SHEET_NAMES].map((n) => JSON.stringify(n)).join(',')}} +for name in wb.sheetnames: + ws=wb[name] + keep = name in month_names or name == 'Calendario' + if not keep: + continue + rows=[] + # Read enough rows; openpyxl max_row is often inflated (1000) — stop after 80 blank streak + blank=0 + for i,row in enumerate(ws.iter_rows(min_row=1, max_row=min(ws.max_row, 200), values_only=True),1): + vals=[None if c is None else (c.isoformat() if hasattr(c,'isoformat') else c) for c in row[:14]] + if not any(v is not None and str(v).strip() for v in vals): + blank+=1 + if blank>15 and i>30: break + continue + blank=0 + rows.append(vals) + out.append({'name': name, 'rows': rows, 'state': ws.sheet_state}) +try: + wb2=openpyxl.load_workbook('/tmp/bps-cal/bps-octubre.xlsx', data_only=True) + for name in wb2.sheetnames: + ws=wb2[name] + rows=[] + blank=0 + for i,row in enumerate(ws.iter_rows(min_row=1, max_row=min(ws.max_row, 80), values_only=True),1): + vals=[None if c is None else (c.isoformat() if hasattr(c,'isoformat') else c) for c in row[:14]] + if not any(v is not None and str(v).strip() for v in vals): + blank+=1 + if blank>10: break + continue + blank=0 + rows.append(vals) + out.append({'name': name+' (prov)', 'rows': rows, 'state': 'visible'}) +except Exception: + pass +print(json.dumps(out, ensure_ascii=False, default=str)) +`; + const raw = execFileSync('python3', ['-c', py], { maxBuffer: 20_000_000 }); + return JSON.parse(raw.toString('utf8')); +} + +function parseStructuredCalendario(rows: SheetRows): Map { + const byMonth = new Map(); + for (const r of rows.slice(1)) { + if (!r) continue; + const dia = r[2]; + const formato = String(r[3] ?? ''); + const contenido = String(r[4] ?? '').trim(); + const estado = String(r[6] ?? ''); + if (!contenido && !formato) continue; + let dateStr = ''; + if (typeof dia === 'string' && /^\d{4}-\d{2}-\d{2}/.test(dia)) { + dateStr = dia.slice(0, 10); + } else { + continue; + } + const monthKey = dateStr.slice(0, 7); + const isStory = /storie/i.test(formato); + const isBlog = /blog/i.test(formato); + const piece: RrssPiece = { + id: `bps-${dateStr}-${isStory ? 'st' : isBlog ? 'blog' : 'post'}-${slugify(contenido || formato)}`, + date: dateStr, + platform: isBlog ? 'Blog' : 'Instagram', + format: isStory ? 'Stories' : isBlog ? 'Blog' : 'Post', + pillar: isStory ? 'stories' : 'feed', + title: (contenido || formato).split('\n')[0].slice(0, 120) || formato, + body: contenido || undefined, + status: /subido/i.test(estado) + ? 'published' + : /pendiente/i.test(estado) + ? 'planned' + : 'planned', + kind: isStory ? 'story' : 'post', + color: isStory ? '#D9821F' : '#2071cf', + }; + const list = byMonth.get(monthKey) || []; + list.push(piece); + byMonth.set(monthKey, list); + } + return byMonth; +} + +function monthLabel(ym: string) { + const [y, m] = ym.split('-').map(Number); + const names = [ + '', + 'Enero', + 'Febrero', + 'Marzo', + 'Abril', + 'Mayo', + 'Junio', + 'Julio', + 'Agosto', + 'Septiembre', + 'Octubre', + 'Noviembre', + 'Diciembre', + ]; + return `${names[m]} ${y}`; +} + +function buildPlan(ym: string, pieces: RrssPiece[]): RrssMonthPlan { + const posts = pieces.filter((p) => p.kind !== 'story').length; + const stories = pieces.filter((p) => p.kind === 'story').length; + return { + kind: 'rrss_month', + month: ym, + label: monthLabel(ym), + hero: { + eyebrow: '4VENTURES × BLUE PARROT SCHOOL · PLAN RRSS', + title: `Calendario editorial · ${monthLabel(ym)}`, + lede: 'Importado desde Drive (CALENDARIO Blue Parrot School), incluidas pestañas ocultas.', + chips: [ + `${posts} posts`, + `${stories} stories`, + 'Fuente: Google Drive', + ], + }, + pieces: pieces.sort((a, b) => a.date.localeCompare(b.date)), + }; +} + +async function upsertPlan(plan: RrssMonthPlan) { + const headers = serviceHeaders(); + if (!headers) throw new Error('no service role'); + const base = supabaseUrl(); + const title = `RRSS ${plan.label}`; + const existing = await fetch( + `${base}/rest/v1/documents?tenant_id=eq.${BPS_ID}&type=eq.calendar&select=id,title,content`, + { headers } + ); + const docs = (await existing.json()) as { + id: string; + title: string; + content: unknown; + }[]; + for (const d of docs) { + let c = d.content as RrssMonthPlan | string | null; + if (typeof c === 'string') { + try { + c = JSON.parse(c) as RrssMonthPlan; + } catch { + c = null; + } + } + const month = c && typeof c === 'object' ? c.month : null; + const sameTitle = d.title === title || d.title === `RRSS ${plan.label}`; + if (month === plan.month || sameTitle) { + await fetch(`${base}/rest/v1/documents?id=eq.${d.id}`, { + method: 'DELETE', + headers: { ...headers, Prefer: 'return=minimal' }, + }); + } + } + const row = { + tenant_id: BPS_ID, + type: 'calendar', + title, + content: plan, + status: 'approved', + }; + const ins = await fetch(`${base}/rest/v1/documents`, { + method: 'POST', + headers: { + ...headers, + 'Content-Type': 'application/json', + Prefer: 'return=representation', + }, + body: JSON.stringify(row), + }); + if (!ins.ok) throw new Error(`insert ${plan.month}: ${(await ins.text()).slice(0, 300)}`); + const saved = (await ins.json()) as { id: string }[]; + console.log('OK', plan.month, plan.pieces.length, 'pieces', saved[0]?.id); +} + +function mergePieces(a: RrssPiece[], b: RrssPiece[]): RrssPiece[] { + const ids = new Set(a.map((p) => p.id)); + return a.concat(b.filter((p) => !ids.has(p.id))); +} + +async function main() { + const sheets = await loadSheetsFromPython(); + const byMonth = new Map(); + const gridMonths = new Set(); + + // 1) All month tabs (hidden + visible) — primary source + for (const sh of sheets) { + const baseName = sh.name.replace(' (prov)', ''); + const ym = SHEET_YM[baseName]; + if (!ym) continue; + const [year, month] = ym.split('-').map(Number); + const pieces = parseWeekGrid(sh.name, sh.rows as SheetRows, year, month); + const cur = byMonth.get(ym) || []; + if (sh.name.includes('(prov)') && pieces.length >= cur.length) { + byMonth.set(ym, pieces); + } else { + byMonth.set(ym, mergePieces(cur, pieces)); + } + if (pieces.length) gridMonths.add(ym); + console.log( + `parsed ${sh.name} [${sh.state}] → ${ym} ${pieces.length} pieces` + ); + } + + // 2) Structured "Calendario" sheet: only fill months that have NO week-grid tab + // (May–Aug 2025 detail) or merge blog rows into existing months + for (const sh of sheets) { + if (sh.name !== 'Calendario') continue; + const structured = parseStructuredCalendario(sh.rows as SheetRows); + for (const [ym, pieces] of structured) { + if (gridMonths.has(ym)) { + // keep grid as source of truth; still add blog-only pieces + const blogs = pieces.filter((p) => p.platform === 'Blog'); + if (blogs.length) { + byMonth.set(ym, mergePieces(byMonth.get(ym) || [], blogs)); + console.log(`calendario blogs → ${ym} +${blogs.length}`); + } + continue; + } + byMonth.set(ym, mergePieces(byMonth.get(ym) || [], pieces)); + console.log(`calendario only → ${ym} ${pieces.length}`); + } + } + + // Drop wrongly-year'd leftovers from prior import (e.g. Feb–Apr as 2025) + const keepMonths = new Set(byMonth.keys()); + + writeFileSync( + '/tmp/bps-cal/plans-summary.json', + JSON.stringify( + [...byMonth.entries()] + .sort(([a], [b]) => a.localeCompare(b)) + .map(([m, p]) => ({ month: m, n: p.length })), + null, + 2 + ) + ); + + const headers = serviceHeaders(); + const base = supabaseUrl(); + if (headers && base) { + const existing = await fetch( + `${base}/rest/v1/documents?tenant_id=eq.${BPS_ID}&type=eq.calendar&select=id,title,content`, + { headers } + ); + const docs = (await existing.json()) as { + id: string; + content: unknown; + }[]; + for (const d of docs) { + let c = d.content as { month?: string } | string | null; + if (typeof c === 'string') { + try { + c = JSON.parse(c) as { month?: string }; + } catch { + c = null; + } + } + const m = c && typeof c === 'object' ? c.month : null; + if (!m) continue; + // Remove old 2025-02..04 if we now have 2026-02..04 from hidden sheets + if ( + /^2025-0[2-4]$/.test(m) && + keepMonths.has(m.replace('2025', '2026')) + ) { + await fetch(`${base}/rest/v1/documents?id=eq.${d.id}`, { + method: 'DELETE', + headers: { ...headers, Prefer: 'return=minimal' }, + }); + console.log('deleted stale', m, d.id); + } + } + } + + for (const [ym, pieces] of [...byMonth.entries()].sort()) { + if (!pieces.length) continue; + await upsertPlan(buildPlan(ym, pieces)); + } + + console.log( + 'months', + [...byMonth.keys()].sort().join(', '), + 'total pieces', + [...byMonth.values()].reduce((s, p) => s + p.length, 0) + ); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/scripts/list_bps_calendar_sheets.ts b/scripts/list_bps_calendar_sheets.ts new file mode 100644 index 0000000..03de0df --- /dev/null +++ b/scripts/list_bps_calendar_sheets.ts @@ -0,0 +1,53 @@ +import { config } from 'dotenv'; +import { resolve } from 'path'; +import { writeFileSync, mkdirSync } from 'fs'; +config({ path: resolve(process.cwd(), '.env.local') }); +import { getValidAccessToken } from '../src/lib/google-drive'; + +const ID = '1YPcHBjioiHHQy22Q2xevkvUqA5jMq87v6IviV7VTL2s'; +const OCT = '1-Zss1suBLDxAgqUuP9DrJYWHiicyin8aQqMOTIIekAg'; + +async function main() { + const token = await getValidAccessToken(); + mkdirSync('/tmp/bps-cal', { recursive: true }); + for (const sid of [ID, OCT]) { + const r = await fetch( + `https://sheets.googleapis.com/v4/spreadsheets/${sid}?fields=properties.title,sheets.properties`, + { headers: { Authorization: `Bearer ${token}` } } + ); + const j = (await r.json()) as { + properties?: { title?: string }; + sheets?: { properties?: { title?: string; sheetId?: number } }[]; + error?: unknown; + }; + console.log('status', r.status, JSON.stringify(j).slice(0, 500)); + console.log('\n===', j.properties?.title, '==='); + for (const s of j.sheets || []) { + const title = s.properties?.title || ''; + console.log(' sheet:', title); + const csv = await fetch( + `https://docs.google.com/spreadsheets/d/${sid}/export?format=csv&gid=${s.properties?.sheetId ?? 0}`, + { headers: { Authorization: `Bearer ${token}` }, redirect: 'follow' } + ); + // Sheets export via docs URL may need alternate - use values API + const vals = await fetch( + `https://sheets.googleapis.com/v4/spreadsheets/${sid}/values/${encodeURIComponent(title)}!A1:Z80`, + { headers: { Authorization: `Bearer ${token}` } } + ); + const vj = (await vals.json()) as { values?: string[][] }; + const rows = vj.values || []; + const safe = title.replace(/[^\w.-]+/g, '_'); + writeFileSync( + `/tmp/bps-cal/${sid.slice(0, 6)}_${safe}.json`, + JSON.stringify(rows, null, 2) + ); + console.log(' rows', rows.length, 'head0', (rows[0] || []).slice(0, 8).join(' | ')); + console.log(' head1', (rows[1] || []).slice(0, 8).join(' | ')); + } + } +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/scripts/list_drive_calendars.ts b/scripts/list_drive_calendars.ts new file mode 100644 index 0000000..8c82470 --- /dev/null +++ b/scripts/list_drive_calendars.ts @@ -0,0 +1,64 @@ +/** List Drive calendar folder and find BPS files. */ +import { config } from 'dotenv'; +import { resolve } from 'path'; +config({ path: resolve(process.cwd(), '.env.local') }); + +import { getValidAccessToken } from '../src/lib/google-drive'; + +const FOLDER = '12tPM0_nuVEf9MBUeyipmcWAJ-gFFgqXh'; + +async function listChildren(token: string, parentId: string) { + const out: { id: string; name: string; mimeType: string }[] = []; + let pageToken: string | undefined; + do { + const params = new URLSearchParams({ + q: `'${parentId}' in parents and trashed=false`, + fields: 'nextPageToken,files(id,name,mimeType,modifiedTime)', + pageSize: '100', + supportsAllDrives: 'true', + includeItemsFromAllDrives: 'true', + corpora: 'allDrives', + }); + if (pageToken) params.set('pageToken', pageToken); + const r = await fetch(`https://www.googleapis.com/drive/v3/files?${params}`, { + headers: { Authorization: `Bearer ${token}` }, + }); + const j = (await r.json()) as { + files?: { id: string; name: string; mimeType: string }[]; + nextPageToken?: string; + }; + if (!r.ok) throw new Error(JSON.stringify(j).slice(0, 400)); + out.push(...(j.files || [])); + pageToken = j.nextPageToken; + } while (pageToken); + return out; +} + +async function main() { + const token = await getValidAccessToken(); + console.log('token ok'); + const top = await listChildren(token, FOLDER); + console.log('top count', top.length); + for (const f of top.sort((a, b) => a.name.localeCompare(b.name))) { + const kind = f.mimeType.includes('folder') ? 'DIR' : 'FILE'; + console.log(`${kind}\t${f.name}\t${f.id}`); + } + const bps = top.filter((f) => /bps|blue.?parrot|parrot/i.test(f.name)); + console.log('\nBPS matches', bps.length); + for (const f of bps) { + if (f.mimeType.includes('folder')) { + const kids = await listChildren(token, f.id); + console.log(`\n--- ${f.name} (${kids.length}) ---`); + for (const k of kids.sort((a, b) => a.name.localeCompare(b.name))) { + console.log( + ` ${k.mimeType.includes('folder') ? 'DIR' : 'FILE'}\t${k.name}\t${k.id}` + ); + } + } + } +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/scripts/probe_bps_seguimiento_drive.ts b/scripts/probe_bps_seguimiento_drive.ts new file mode 100644 index 0000000..d552e29 --- /dev/null +++ b/scripts/probe_bps_seguimiento_drive.ts @@ -0,0 +1,101 @@ +import { config } from 'dotenv'; +import { resolve } from 'path'; +config({ path: resolve(process.cwd(), '.env.local') }); + +import { getValidAccessToken } from '../src/lib/google-drive'; +import { + buildSeguimientoRowsForDoc, + upsertSeguimientoRows, + patchSuperhumanIntegration, + findSeguimientoGoogleDoc, + exportGoogleDocHtml, + splitHtmlNotesByDay, +} from '../src/lib/superhuman-docs'; + +const BPS = '8bec977a-6032-4759-aa7f-b5042081f450'; +const DOC = '7TGywaNiUY'; + +async function driveSearch(q: string) { + const token = await getValidAccessToken(); + const params = new URLSearchParams({ + q, + fields: 'files(id,name,mimeType,webViewLink,modifiedTime)', + pageSize: '30', + corpora: 'allDrives', + includeItemsFromAllDrives: 'true', + supportsAllDrives: 'true', + }); + const r = await fetch(`https://www.googleapis.com/drive/v3/files?${params}`, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!r.ok) throw new Error(await r.text()); + return ((await r.json()) as { files?: unknown[] }).files || []; +} + +async function main() { + console.log( + 'search seguimiento+parrot', + await driveSearch( + "name contains 'Seguimiento' and name contains 'Parrot' and trashed=false" + ) + ); + console.log( + 'search seguimiento+BPS', + await driveSearch( + "name contains 'Seguimiento' and name contains 'BPS' and trashed=false" + ) + ); + console.log( + 'search Blue Parrot docs', + await driveSearch( + "name contains 'Blue Parrot' and mimeType='application/vnd.google-apps.document' and trashed=false" + ) + ); + console.log( + 'findSeguimiento', + await findSeguimientoGoogleDoc([ + 'Seguimiento Semanal Blue Parrot School', + 'Seguimiento Semanal BPS', + 'Seguimiento Semanal Blue Parrot', + 'Blue Parrot School', + 'BPS', + ]) + ); + + // Re-run full build (includes Drive embed discovery from Superhuman pages) + const rows = await buildSeguimientoRowsForDoc({ + tenantId: BPS, + tenantName: 'Blue Parrot School', + docId: DOC, + maxEntries: 120, + exportBodies: true, + }); + console.log( + 'rows', + rows.length, + rows.slice(0, 8).map((r) => ({ + title: r.title, + occurred: r.occurred_at, + ext: r.external_id, + bodyLen: (r.body_md || '').length, + })) + ); + const n = await upsertSeguimientoRows(rows); + await patchSuperhumanIntegration(BPS, { + status: 'connected', + external_account_id: DOC, + label: '01.BLUE PARROT SCHOOL', + last_sync_at: new Date().toISOString(), + error_message: null, + meta: { + doc_name: '01.BLUE PARROT SCHOOL', + entries: n, + synced_at: new Date().toISOString(), + }, + }); + console.log('upserted', n); +} +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/scripts/probe_bps_superhuman.ts b/scripts/probe_bps_superhuman.ts new file mode 100644 index 0000000..388b842 --- /dev/null +++ b/scripts/probe_bps_superhuman.ts @@ -0,0 +1,52 @@ +import { config } from 'dotenv'; +import { resolve } from 'path'; +config({ path: resolve(process.cwd(), '.env.local') }); + +import { + listDocs, + bestDocForTenant, + cleanClientDocName, +} from '../src/lib/superhuman-docs'; +import { scoreNameToTenant } from '../src/lib/match'; +import { findSeguimientoGoogleDoc } from '../src/lib/superhuman-docs'; + +async function main() { + const docs = await listDocs(); + console.log('docs', docs.length); + for (const d of docs) { + const clean = cleanClientDocName(d.name); + const s1 = scoreNameToTenant(clean, 'BPS', 'bps'); + const s2 = scoreNameToTenant(clean, 'Blue Parrot School', 'blue-parrot'); + if ( + s1 >= 15 || + s2 >= 15 || + /parrot|bps|blue/i.test(d.name) + ) { + console.log({ name: d.name, clean, s1, s2, id: d.id }); + } + } + console.log( + 'best BPS', + bestDocForTenant({ name: 'BPS', slug: 'bps' }, docs) + ); + console.log( + 'best Blue Parrot School', + bestDocForTenant( + { name: 'Blue Parrot School', slug: 'blue-parrot-school' }, + docs + ) + ); + + const drive = await findSeguimientoGoogleDoc([ + 'Seguimiento Semanal BPS', + 'Seguimiento Semanal Blue Parrot School', + 'Seguimiento Semanal Blue Parrot', + 'BPS', + 'Blue Parrot School', + ]); + console.log('drive hit', drive); +} +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/scripts/probe_seguimiento_html.ts b/scripts/probe_seguimiento_html.ts new file mode 100644 index 0000000..fb03302 --- /dev/null +++ b/scripts/probe_seguimiento_html.ts @@ -0,0 +1,41 @@ +import { config } from 'dotenv'; +import { resolve } from 'path'; +config({ path: resolve(process.cwd(), '.env.local') }); + +import { + exportGoogleDocHtml, + splitHtmlNotesByDay, + splitNotesByDay, +} from '../src/lib/superhuman-docs'; + +const FILE = '1uDnRPWf1Dqx5b9L3TuhfvutyZpSaifz7YFNrRy_eBhw'; + +async function main() { + const html = await exportGoogleDocHtml(FILE); + console.log('html len', html.length); + console.log('sample', html.slice(0, 1500)); + // find date-like patterns + const dates = html.match(/\d{1,2}[\/\-.]\d{1,2}[\/\-.]\d{2,4}/g) || []; + console.log('date-like', [...new Set(dates)].slice(0, 40)); + const headings = html.match(/]*>[\s\S]*?<\/h[1-6]>/gi) || []; + console.log( + 'headings', + headings.slice(0, 20).map((h) => h.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()) + ); + const secs = splitHtmlNotesByDay(html); + console.log('html sections', secs.length, secs.slice(0, 3).map((s) => s.label)); + const plain = html + .replace(//gi, '') + .replace(/<[^>]+>/g, '\n'); + const plainSecs = splitNotesByDay(plain); + console.log( + 'plain sections', + plainSecs.length, + plainSecs.slice(0, 5).map((s) => s.label) + ); + console.log('plain sample\n', plain.slice(0, 2000)); +} +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/scripts/provision_victor_bps.ts b/scripts/provision_victor_bps.ts new file mode 100644 index 0000000..a0866b8 --- /dev/null +++ b/scripts/provision_victor_bps.ts @@ -0,0 +1,96 @@ +/** + * Provision victor@4ventures.es as BPS client portal user. + * npx tsx scripts/provision_victor_bps.ts + */ +import { config } from 'dotenv'; +import { resolve } from 'path'; +import { writeFileSync } from 'fs'; +import { createClient } from '@supabase/supabase-js'; +import { randomBytes } from 'crypto'; + +config({ path: resolve(process.cwd(), '.env.local') }); + +const BPS = '8bec977a-6032-4759-aa7f-b5042081f450'; +const EMAIL = 'victor@4ventures.es'; + +async function main() { + const url = process.env.NEXT_PUBLIC_SUPABASE_URL?.trim(); + const key = process.env.SUPABASE_SERVICE_ROLE_KEY?.trim(); + if (!url || !key) throw new Error('Faltan NEXT_PUBLIC_SUPABASE_URL / SUPABASE_SERVICE_ROLE_KEY'); + + const admin = createClient(url, key, { + auth: { persistSession: false, autoRefreshToken: false }, + }); + + const password = `BpsVictor-${randomBytes(4).toString('hex')}!9`; + + let found: { id: string; app_metadata?: Record; user_metadata?: Record } | null = + null; + for (let page = 1; page <= 30; page++) { + const { data, error } = await admin.auth.admin.listUsers({ page, perPage: 200 }); + if (error) throw error; + const hit = data.users.find((u) => (u.email || '').toLowerCase() === EMAIL); + if (hit) { + found = hit; + break; + } + if (data.users.length < 200) break; + } + + const app_metadata = { + ...(found?.app_metadata || {}), + role: 'client', + tenant_id: BPS, + company: 'Blue Parrot School', + }; + const user_metadata = { + ...(found?.user_metadata || {}), + role: 'client', + tenant_id: BPS, + company: 'Blue Parrot School', + }; + + if (found) { + const { error } = await admin.auth.admin.updateUserById(found.id, { + password, + email_confirm: true, + app_metadata, + user_metadata, + }); + if (error) throw error; + console.log('updated', found.id); + } else { + const { data, error } = await admin.auth.admin.createUser({ + email: EMAIL, + password, + email_confirm: true, + app_metadata, + user_metadata, + }); + if (error) throw error; + console.log('created', data.user?.id); + } + + writeFileSync( + resolve(process.cwd(), '.ops/victor-bps-credentials.txt'), + [ + `email=${EMAIL}`, + `password=${password}`, + `tenant=Blue Parrot School (${BPS})`, + `role=client`, + `updated_at=${new Date().toISOString()}`, + '', + ].join('\n'), + { mode: 0o600 } + ); + + console.log('---'); + console.log('url https://app.4ventures.es/login'); + console.log('email', EMAIL); + console.log('password', password); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/scripts/refresh_agency_connections.py b/scripts/refresh_agency_connections.py new file mode 100644 index 0000000..8f4acb2 --- /dev/null +++ b/scripts/refresh_agency_connections.py @@ -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()) diff --git a/scripts/refresh_agency_connections.ts b/scripts/refresh_agency_connections.ts new file mode 100644 index 0000000..ebd64dd --- /dev/null +++ b/scripts/refresh_agency_connections.ts @@ -0,0 +1,203 @@ +/** + * One-shot: bootstrap Google + discover Meta → fichas, then print audit. + * Usage: npx tsx --env-file=.env.local scripts/refresh_agency_connections.ts + */ +import { bootstrapAgencyGoogle } from '../src/lib/agency-google-bootstrap'; +import { discoverAndMatch } from '../src/lib/ads-discovery'; +import { serviceHeaders, supabaseUrl } from '../src/lib/agency'; + +async function get(path: string) { + const headers = serviceHeaders(); + if (!headers) throw new Error('no service role'); + const r = await fetch(`${supabaseUrl()}${path}`, { + headers, + cache: 'no-store', + }); + if (!r.ok) throw new Error(`${path}: ${await r.text()}`); + return r.json(); +} + +async function organicCount(tenantId: string): Promise { + const headers = serviceHeaders(); + if (!headers) return 0; + const r = await fetch( + `${supabaseUrl()}/rest/v1/organic_daily?tenant_id=eq.${tenantId}&select=day`, + { + headers: { ...headers, Prefer: 'count=exact', Range: '0-0' }, + cache: 'no-store', + } + ); + const cr = r.headers.get('content-range') || ''; + if (cr.includes('/')) return Number(cr.split('/').pop()) || 0; + return 0; +} + +async function main() { + console.log('=== 1. Google bootstrap (Drive + GA4 + GSC + Ads + sync) ==='); + const boot = await bootstrapAgencyGoogle({ + includeGoogleAds: true, + syncOrganic: true, + organicDays: 28, + }); + console.log(JSON.stringify(boot, null, 2)); + + console.log('\n=== 2. Meta discover (Ads + FB + IG) ==='); + const meta = await discoverAndMatch({ + apply: true, + providers: ['meta_ads', 'google_ads'], + }); + console.log( + JSON.stringify( + { + meta: { + accounts: meta.meta?.accounts, + linked: meta.meta?.linked, + pagesLinked: meta.meta?.pagesLinked, + igLinked: meta.meta?.igLinked, + error: meta.meta?.error, + }, + google: { + accounts: meta.google?.accounts, + linked: meta.google?.linked, + error: meta.google?.error, + }, + }, + null, + 2 + ) + ); + + console.log('\n=== 3. Audit por cliente ==='); + const tenants = (await get( + '/rest/v1/tenants?select=id,name,slug&order=name.asc' + )) as { id: string; name: string; slug: string }[]; + const integs = (await 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,meta' + )) as { + tenant_id: string; + provider: string; + status: string; + external_account_id?: string | null; + error_message?: string | null; + meta?: Record | null; + }[]; + + const by = new Map(); + for (const i of integs) { + const arr = by.get(i.tenant_id) || []; + arr.push(i); + by.set(i.tenant_id, arr); + } + + const providers = [ + 'google_ads', + 'meta_ads', + 'ga4', + 'search_console', + 'google_drive', + 'facebook', + 'instagram', + ] as const; + + type Row = { + name: string; + organic_days: number; + channels: Record; + gaps: string[]; + }; + const rows: Row[] = []; + + for (const t of tenants) { + const map = new Map( + (by.get(t.id) || []).map((i) => [i.provider, i] as const) + ); + const channels: Record = {}; + const gaps: string[] = []; + for (const p of providers) { + const i = map.get(p); + if (i?.status === 'connected' && i.external_account_id) { + const id = String(i.external_account_id); + channels[p] = id.length > 42 ? id.slice(0, 40) + '…' : id; + } else if (i?.status === 'error') { + channels[p] = `ERR:${(i.error_message || '').slice(0, 40)}`; + gaps.push(p); + } else { + channels[p] = '—'; + gaps.push(p); + } + } + const org = await organicCount(t.id); + rows.push({ name: t.name, organic_days: org, channels, gaps }); + } + + console.log( + [ + 'Cliente'.padEnd(28), + 'GAds', + 'Meta', + 'GA4', + 'GSC', + 'Drive', + 'FB', + 'IG', + 'OrgDías', + ].join('\t') + ); + for (const r of rows) { + const c = r.channels; + const flag = (v: string) => (v === '—' ? '—' : v.startsWith('ERR') ? 'ERR' : 'OK'); + console.log( + [ + r.name.slice(0, 28).padEnd(28), + flag(c.google_ads), + flag(c.meta_ads), + flag(c.ga4), + flag(c.search_console), + flag(c.google_drive), + flag(c.facebook), + flag(c.instagram), + String(r.organic_days), + ].join('\t') + ); + } + + console.log('\n=== Detalle IDs (solo conectados) ==='); + for (const r of rows) { + const linked = Object.entries(r.channels).filter(([, v]) => v !== '—' && !v.startsWith('ERR')); + if (!linked.length && r.organic_days === 0) continue; + console.log(`\n${r.name} · organic_daily=${r.organic_days}`); + for (const [p, v] of linked) console.log(` ${p}: ${v}`); + } + + const withAds = rows.filter( + (r) => r.channels.google_ads !== '—' || r.channels.meta_ads !== '—' + ).length; + const withOrg = rows.filter( + (r) => + r.channels.ga4 !== '—' || + r.channels.search_console !== '—' || + r.organic_days > 0 + ).length; + const withSocial = rows.filter( + (r) => r.channels.facebook !== '—' || r.channels.instagram !== '—' + ).length; + console.log('\n=== RESUMEN ==='); + console.log( + JSON.stringify( + { + tenants: rows.length, + with_ads: withAds, + with_organic_link_or_data: withOrg, + with_fb_or_ig: withSocial, + organic_tenants_with_rows: rows.filter((r) => r.organic_days > 0).length, + }, + null, + 2 + ) + ); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/scripts/sync_bps_gdoc_days.ts b/scripts/sync_bps_gdoc_days.ts new file mode 100644 index 0000000..d769b8e --- /dev/null +++ b/scripts/sync_bps_gdoc_days.ts @@ -0,0 +1,145 @@ +import { config } from 'dotenv'; +import { resolve } from 'path'; +config({ path: resolve(process.cwd(), '.env.local') }); + +import { + exportGoogleDocPlain, + exportGoogleDocHtml, + upsertSeguimientoRows, + patchSuperhumanIntegration, +} from '../src/lib/superhuman-docs'; +import { getValidAccessToken } from '../src/lib/google-drive'; + +const BPS = '8bec977a-6032-4759-aa7f-b5042081f450'; +const FILE = '1uDnRPWf1Dqx5b9L3TuhfvutyZpSaifz7YFNrRy_eBhw'; +const DOC = '7TGywaNiUY'; + +function parseDayLabel(raw: string): { dateIso: string; label: string } | null { + const m = raw.trim().match(/^(\d{1,2})[\/.\-](\d{1,2})[\/.\-](\d{2,4})$/); + if (!m) return null; + let y = Number(m[3]); + const mo = Number(m[2]); + const d = Number(m[1]); + if (y < 100) y += 2000; + if (y < 2000 || y > 2100 || mo < 1 || mo > 12 || d < 1 || d > 31) return null; + return { + dateIso: `${y}-${String(mo).padStart(2, '0')}-${String(d).padStart(2, '0')}`, + label: `${d}/${mo}/${String(y).slice(2)}`, + }; +} + +function splitNotesByDay(text: string) { + const clean = text.replace(/^\uFEFF/, '').replace(/\r\n/g, '\n'); + const dayRe = + /^(?:#{1,4}\s*)?(\d{1,2})[\/.\-](\d{1,2})[\/.\-](\d{2,4})\s*$/; + const lines = clean.split('\n'); + const hits: { idx: number; label: string; dateIso: string }[] = []; + for (let i = 0; i < lines.length; i++) { + const m = lines[i].trim().match(dayRe); + if (!m) continue; + const parsed = parseDayLabel(`${m[1]}/${m[2]}/${m[3]}`); + if (!parsed) continue; + hits.push({ idx: i, ...parsed }); + } + if (!hits.length) return [] as { dateIso: string; label: string; body: string }[]; + const sections: { dateIso: string; label: string; body: string }[] = []; + for (let h = 0; h < hits.length; h++) { + const start = hits[h].idx + 1; + const end = h + 1 < hits.length ? hits[h + 1].idx : lines.length; + const body = lines + .slice(start, end) + .join('\n') + .replace(/^\n+/, '') + .replace(/\n{3,}/g, '\n\n') + .trim(); + if (!body) continue; + sections.push({ dateIso: hits[h].dateIso, label: hits[h].label, body }); + } + sections.sort((a, b) => b.dateIso.localeCompare(a.dateIso)); + return sections; +} + +async function exportViaDownload(fileId: string, mime: string) { + const token = await getValidAccessToken(); + const url = `https://www.googleapis.com/drive/v3/files/${encodeURIComponent(fileId)}/export?mimeType=${encodeURIComponent(mime)}`; + const r = await fetch(url, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!r.ok) throw new Error(`${mime} ${r.status}: ${(await r.text()).slice(0, 200)}`); + return await r.text(); +} + +async function main() { + let plain = ''; + try { + plain = await exportGoogleDocPlain(FILE); + console.log('plain via helper', plain.length); + } catch (e) { + console.log('plain helper fail', e instanceof Error ? e.message : e); + } + if (!plain) { + try { + plain = await exportViaDownload(FILE, 'text/plain'); + console.log('plain via download', plain.length); + } catch (e) { + console.log('plain download fail', e instanceof Error ? e.message : e); + } + } + try { + const html = await exportGoogleDocHtml(FILE); + console.log('html ok', html.length); + } catch (e) { + console.log('html fail', e instanceof Error ? e.message : e); + } + + if (!plain) throw new Error('no plain export'); + console.log('--- sample ---\n', plain.slice(0, 2000)); + const secs = splitNotesByDay(plain); + console.log('sections', secs.length, secs.slice(0, 8).map((s) => s.label)); + + const now = new Date().toISOString(); + const rows = secs.slice(0, 120).map((sec) => { + const firstLine = + sec.body + .split('\n') + .map((l) => l.replace(/^[\s*•\-]+/, '').trim()) + .find((l) => l.length > 8) || ''; + return { + tenant_id: BPS, + source: 'superhuman' as const, + entry_type: 'meeting' as const, + external_id: `gdoc:${FILE}:${sec.dateIso}`, + title: `Seguimiento · ${sec.label}`, + summary: firstLine.slice(0, 220) || null, + body_md: `
${sec.body
+        .replace(/&/g, '&')
+        .replace(//g, '>')}
`, + browser_link: `https://docs.google.com/document/d/${FILE}/edit`, + metrics: {}, + occurred_at: `${sec.dateIso}T12:00:00.000Z`, + synced_at: now, + updated_at: now, + }; + }); + + const n = await upsertSeguimientoRows(rows); + await patchSuperhumanIntegration(BPS, { + status: 'connected', + external_account_id: DOC, + label: '01.BLUE PARROT SCHOOL', + last_sync_at: now, + error_message: null, + meta: { + doc_name: '01.BLUE PARROT SCHOOL', + gdoc_id: FILE, + gdoc_days: n, + synced_at: now, + }, + }); + console.log('upserted day notes', n); +} +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/scripts/sync_bps_seguimiento.ts b/scripts/sync_bps_seguimiento.ts new file mode 100644 index 0000000..dac7f77 --- /dev/null +++ b/scripts/sync_bps_seguimiento.ts @@ -0,0 +1,59 @@ +import { config } from 'dotenv'; +import { resolve } from 'path'; +config({ path: resolve(process.cwd(), '.env.local') }); + +import { + buildSeguimientoRowsForDoc, + upsertSeguimientoRows, + patchSuperhumanIntegration, + findSeguimientoGoogleDoc, +} from '../src/lib/superhuman-docs'; + +const BPS = '8bec977a-6032-4759-aa7f-b5042081f450'; +const DOC = '7TGywaNiUY'; + +async function main() { + const hit = await findSeguimientoGoogleDoc([ + 'Seguimiento BPS', + 'Seguimiento Semanal BPS', + 'Seguimiento Blue Parrot School', + 'Seguimiento Semanal Blue Parrot School', + ]); + console.log('drive hit', hit); + + const rows = await buildSeguimientoRowsForDoc({ + tenantId: BPS, + tenantName: 'BPS', + docId: DOC, + maxEntries: 120, + exportBodies: true, + }); + const gdoc = rows.filter((r) => r.external_id.startsWith('gdoc:')); + console.log('total', rows.length, 'gdoc days', gdoc.length); + console.log( + gdoc.slice(0, 5).map((r) => ({ + title: r.title, + day: r.occurred_at, + len: (r.body_md || '').length, + })) + ); + const n = await upsertSeguimientoRows(rows); + await patchSuperhumanIntegration(BPS, { + status: 'connected', + external_account_id: DOC, + label: '01.BLUE PARROT SCHOOL', + last_sync_at: new Date().toISOString(), + error_message: null, + meta: { + doc_name: '01.BLUE PARROT SCHOOL', + entries: n, + gdoc_days: gdoc.length, + synced_at: new Date().toISOString(), + }, + }); + console.log('upserted', n); +} +main().catch((e) => { + console.error(e); + process.exit(1); +});