chore: scripts Dokploy/BPS (cron secret, Ads MCC, sync y probes)
Incluye utilidades de ops para CRON_SECRET, login customer Google Ads, sync de seguimiento BPS y probes de conexiones. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
224
public/preview-seguimiento.html
Normal file
224
public/preview-seguimiento.html
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8"/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||||
|
<title>Preview · Seguimiento · Virginia Molet</title>
|
||||||
|
<style>
|
||||||
|
:root { --bg:#0B0F19; --card:#111827; --side:#0D121F; --line:#1F2937; --brand:#E58A3C; --text:#F3F4F6; --muted:#9CA3AF; }
|
||||||
|
* { box-sizing:border-box; }
|
||||||
|
body { margin:0; font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, sans-serif; background:var(--bg); color:var(--text); }
|
||||||
|
.shell { display:flex; min-height:100vh; }
|
||||||
|
aside { width:240px; background:var(--side); border-right:1px solid #1f2937; padding:20px 14px; }
|
||||||
|
.logo { display:flex; gap:10px; align-items:center; padding:8px; border-bottom:1px solid #1f293780; margin-bottom:18px; }
|
||||||
|
.mark { width:36px; height:36px; border-radius:10px; background:linear-gradient(135deg,#E58A3C,#B35E1B); display:grid; place-items:center; font-weight:800; }
|
||||||
|
nav a { display:flex; gap:10px; align-items:center; color:#D1D5DB; text-decoration:none; padding:10px 12px; border-radius:10px; font-size:14px; margin-bottom:4px; }
|
||||||
|
nav a.active { background:#1f293799; color:#fff; }
|
||||||
|
main { flex:1; padding:28px 36px; max-width:980px; }
|
||||||
|
h1 { margin:0; font-size:28px; letter-spacing:-.02em; }
|
||||||
|
.sub { color:var(--muted); font-size:14px; margin-top:6px; }
|
||||||
|
.badge { font-size:11px; color:#9CA3AF; border:1px solid #1f2937; background:#0d121f80; border-radius:8px; padding:8px 12px; }
|
||||||
|
.card { background:var(--card); border:1px solid #1f2937; border-radius:14px; padding:20px; margin-top:22px; }
|
||||||
|
.head { display:flex; justify-content:space-between; align-items:center; gap:12px; flex-wrap:wrap; }
|
||||||
|
.tabs { display:flex; gap:4px; background:#0003; border:1px solid #1f2937; border-radius:10px; padding:3px; }
|
||||||
|
.tabs span { font-size:12px; padding:6px 10px; border-radius:8px; color:#9CA3AF; }
|
||||||
|
.tabs span.on { background:#E58A3C33; color:#F0B27A; }
|
||||||
|
.chs { display:flex; gap:8px; margin:14px 0 8px; flex-wrap:wrap; }
|
||||||
|
.chs span { font-size:12px; border:1px solid #374151; border-radius:999px; padding:4px 10px; text-transform:capitalize; display:inline-flex; gap:6px; align-items:center; }
|
||||||
|
.chs i { width:8px; height:8px; border-radius:50%; display:inline-block; }
|
||||||
|
.preview-note { font-size:11px; color:#FBBF24; margin-top:8px; }
|
||||||
|
ol { list-style:none; margin:18px 0 0; padding:0 0 0 14px; border-left:1px solid #1f2937; }
|
||||||
|
li { position:relative; margin:0 0 14px 12px; }
|
||||||
|
.dot { position:absolute; left:-19px; top:18px; width:12px; height:12px; border-radius:50%; background:var(--brand); box-shadow:0 0 0 4px var(--bg); }
|
||||||
|
article { background:var(--card); border:1px solid #1f2937; border-radius:14px; padding:14px 16px; }
|
||||||
|
.row { display:flex; justify-content:space-between; gap:10px; }
|
||||||
|
.pill { font-size:10px; text-transform:uppercase; letter-spacing:.06em; border:1px solid; border-radius:6px; padding:2px 7px; margin-right:8px; }
|
||||||
|
.when { font-size:11px; color:#6B7280; }
|
||||||
|
h3 { margin:8px 0 0; font-size:14px; }
|
||||||
|
article p { margin:8px 0 0; font-size:12px; color:#9CA3AF; line-height:1.45; }
|
||||||
|
article a { color:#F0B27A; font-size:11px; text-decoration:none; white-space:nowrap; }
|
||||||
|
.top { display:flex; justify-content:space-between; align-items:flex-end; gap:16px; flex-wrap:wrap; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="shell">
|
||||||
|
<aside>
|
||||||
|
<div class="logo"><div class="mark">4V</div><div><strong style="font-size:13px">4VENTURES</strong><div style="font-size:11px;color:#9CA3AF">Centro de Mando</div></div></div>
|
||||||
|
<nav>
|
||||||
|
<a href="#">Métricas</a>
|
||||||
|
<a class="active" href="#">● Seguimiento</a>
|
||||||
|
<a href="#">Objetivos del Mes</a>
|
||||||
|
<a href="#">Calendario Editorial</a>
|
||||||
|
<a href="#">Biblioteca de Activos</a>
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
<main>
|
||||||
|
<div class="top">
|
||||||
|
<div>
|
||||||
|
<h1>Seguimiento & evolución</h1>
|
||||||
|
<p class="sub">Virginia Molet · timeline Superhuman + series de canal</p>
|
||||||
|
</div>
|
||||||
|
<div class="badge">Preview UI · datos reales de timeline</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<div class="head">
|
||||||
|
<strong>Evolución por canal</strong>
|
||||||
|
<div class="tabs"><span class="on">Leads</span><span>Inversión</span><span>CPL</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="chs">
|
||||||
|
<span><i style="background:#E58A3C"></i>meta</span>
|
||||||
|
<span><i style="background:#38BDF8"></i>google</span>
|
||||||
|
<span><i style="background:#34D399"></i>organic</span>
|
||||||
|
</div>
|
||||||
|
<svg viewBox="0 0 720 260" width="100%" style="max-width:100%">
|
||||||
|
<line x1="44" x2="704" y1="224" y2="224" stroke="#1F2937"/><text x="36" y="227" text-anchor="end" fill="#6B7280" font-size="10">0</text><line x1="44" x2="704" y1="172.0" y2="172.0" stroke="#1F2937"/><text x="36" y="175.0" text-anchor="end" fill="#6B7280" font-size="10">18</text><line x1="44" x2="704" y1="120.0" y2="120.0" stroke="#1F2937"/><text x="36" y="123.0" text-anchor="end" fill="#6B7280" font-size="10">36</text><line x1="44" x2="704" y1="68.0" y2="68.0" stroke="#1F2937"/><text x="36" y="71.0" text-anchor="end" fill="#6B7280" font-size="10">54</text><line x1="44" x2="704" y1="16" y2="16" stroke="#1F2937"/><text x="36" y="19" text-anchor="end" fill="#6B7280" font-size="10">72</text>
|
||||||
|
<path d="M 44.0 102.7 L 138.3 85.3 L 232.6 65.1 L 326.9 76.7 L 421.1 50.7 L 515.4 56.4 L 609.7 30.4 L 704.0 16.0" fill="none" stroke="#E58A3C" stroke-width="2.5"/><circle cx="44.0" cy="102.7" r="3.5" fill="#E58A3C"/><circle cx="138.3" cy="85.3" r="3.5" fill="#E58A3C"/><circle cx="232.6" cy="65.1" r="3.5" fill="#E58A3C"/><circle cx="326.9" cy="76.7" r="3.5" fill="#E58A3C"/><circle cx="421.1" cy="50.7" r="3.5" fill="#E58A3C"/><circle cx="515.4" cy="56.4" r="3.5" fill="#E58A3C"/><circle cx="609.7" cy="30.4" r="3.5" fill="#E58A3C"/><circle cx="704.0" cy="16.0" r="3.5" fill="#E58A3C"/><path d="M 44.0 189.3 L 138.3 183.6 L 232.6 192.2 L 326.9 177.8 L 421.1 172.0 L 515.4 180.7 L 609.7 166.2 L 704.0 160.4" fill="none" stroke="#38BDF8" stroke-width="2.5"/><circle cx="44.0" cy="189.3" r="3.5" fill="#38BDF8"/><circle cx="138.3" cy="183.6" r="3.5" fill="#38BDF8"/><circle cx="232.6" cy="192.2" r="3.5" fill="#38BDF8"/><circle cx="326.9" cy="177.8" r="3.5" fill="#38BDF8"/><circle cx="421.1" cy="172.0" r="3.5" fill="#38BDF8"/><circle cx="515.4" cy="180.7" r="3.5" fill="#38BDF8"/><circle cx="609.7" cy="166.2" r="3.5" fill="#38BDF8"/><circle cx="704.0" cy="160.4" r="3.5" fill="#38BDF8"/><path d="M 44.0 200.9 L 138.3 198.0 L 232.6 203.8 L 326.9 195.1 L 421.1 192.2 L 515.4 198.0 L 609.7 189.3 L 704.0 186.4" fill="none" stroke="#34D399" stroke-width="2.5"/><circle cx="44.0" cy="200.9" r="3.5" fill="#34D399"/><circle cx="138.3" cy="198.0" r="3.5" fill="#34D399"/><circle cx="232.6" cy="203.8" r="3.5" fill="#34D399"/><circle cx="326.9" cy="195.1" r="3.5" fill="#34D399"/><circle cx="421.1" cy="192.2" r="3.5" fill="#34D399"/><circle cx="515.4" cy="198.0" r="3.5" fill="#34D399"/><circle cx="609.7" cy="189.3" r="3.5" fill="#34D399"/><circle cx="704.0" cy="186.4" r="3.5" fill="#34D399"/>
|
||||||
|
<text x="44.0" y="250" text-anchor="middle" fill="#6B7280" font-size="9">07-06</text><text x="138.3" y="250" text-anchor="middle" fill="#6B7280" font-size="9">07-13</text><text x="232.6" y="250" text-anchor="middle" fill="#6B7280" font-size="9">07-20</text><text x="326.9" y="250" text-anchor="middle" fill="#6B7280" font-size="9">07-27</text><text x="421.1" y="250" text-anchor="middle" fill="#6B7280" font-size="9">08-03</text><text x="515.4" y="250" text-anchor="middle" fill="#6B7280" font-size="9">08-10</text><text x="609.7" y="250" text-anchor="middle" fill="#6B7280" font-size="9">08-17</text><text x="704.0" y="250" text-anchor="middle" fill="#6B7280" font-size="9">08-24</text>
|
||||||
|
</svg>
|
||||||
|
<p class="preview-note">La gráfica usa series de ejemplo (Virginia aún no tiene kpi_weekly). El timeline inferior sí es sync real de Superhuman.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section style="margin-top:28px">
|
||||||
|
<strong style="font-size:15px">Timeline reunión a reunión <span style="color:#6B7280;font-weight:400">(12)</span></strong>
|
||||||
|
<ol>
|
||||||
|
|
||||||
|
<li>
|
||||||
|
<span class="dot"></span>
|
||||||
|
<article>
|
||||||
|
<div class="row">
|
||||||
|
<div>
|
||||||
|
<span class="pill" style="color:#34D399;border-color:#34D39955;background:#34D39922">Tema</span>
|
||||||
|
<span class="when">18 Aug 2026</span>
|
||||||
|
<h3>Gravitae: Meta Ads/Google ADs</h3>
|
||||||
|
</div>
|
||||||
|
<a href="https://docs.superhuman.com/d/_dqt1ZA3XaMp/_suvQLvnL" target="_blank">Superhuman ↗</a>
|
||||||
|
</div>
|
||||||
|
<p>- **[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</p>
|
||||||
|
</article>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="dot"></span>
|
||||||
|
<article>
|
||||||
|
<div class="row">
|
||||||
|
<div>
|
||||||
|
<span class="pill" style="color:#34D399;border-color:#34D39955;background:#34D39922">Tema</span>
|
||||||
|
<span class="when">01 Jul 2026</span>
|
||||||
|
<h3>wbf_mad_sice.pdf</h3>
|
||||||
|
</div>
|
||||||
|
<a href="https://docs.superhuman.com/d/_dqt1ZA3XaMp/_suJVY_uk" target="_blank">Superhuman ↗</a>
|
||||||
|
</div>
|
||||||
|
<p>Abrir en Superhuman para el detalle completo.</p>
|
||||||
|
</article>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="dot"></span>
|
||||||
|
<article>
|
||||||
|
<div class="row">
|
||||||
|
<div>
|
||||||
|
<span class="pill" style="color:#34D399;border-color:#34D39955;background:#34D39922">Tema</span>
|
||||||
|
<span class="when">01 Jul 2026</span>
|
||||||
|
<h3>Info emails Open Event 9/7/26</h3>
|
||||||
|
</div>
|
||||||
|
<a href="https://docs.superhuman.com/d/_dqt1ZA3XaMp/_su8PImAd" target="_blank">Superhuman ↗</a>
|
||||||
|
</div>
|
||||||
|
<p>> 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</p>
|
||||||
|
</article>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="dot"></span>
|
||||||
|
<article>
|
||||||
|
<div class="row">
|
||||||
|
<div>
|
||||||
|
<span class="pill" style="color:#34D399;border-color:#34D39955;background:#34D39922">Tema</span>
|
||||||
|
<span class="when">25 Jun 2026</span>
|
||||||
|
<h3>Email Open Event 9 julio.pdf</h3>
|
||||||
|
</div>
|
||||||
|
<a href="https://docs.superhuman.com/d/_dqt1ZA3XaMp/_suQBkgXM" target="_blank">Superhuman ↗</a>
|
||||||
|
</div>
|
||||||
|
<p>Abrir en Superhuman para el detalle completo.</p>
|
||||||
|
</article>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="dot"></span>
|
||||||
|
<article>
|
||||||
|
<div class="row">
|
||||||
|
<div>
|
||||||
|
<span class="pill" style="color:#34D399;border-color:#34D39955;background:#34D39922">Tema</span>
|
||||||
|
<span class="when">18 Jun 2026</span>
|
||||||
|
<h3>Email 1</h3>
|
||||||
|
</div>
|
||||||
|
<a href="https://docs.superhuman.com/d/_dqt1ZA3XaMp/_suSUWITB" target="_blank">Superhuman ↗</a>
|
||||||
|
</div>
|
||||||
|
<p>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,</p>
|
||||||
|
</article>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="dot"></span>
|
||||||
|
<article>
|
||||||
|
<div class="row">
|
||||||
|
<div>
|
||||||
|
<span class="pill" style="color:#34D399;border-color:#34D39955;background:#34D39922">Tema</span>
|
||||||
|
<span class="when">08 Jun 2026</span>
|
||||||
|
<h3>WhatApp</h3>
|
||||||
|
</div>
|
||||||
|
<a href="https://docs.superhuman.com/d/_dqt1ZA3XaMp/_suguIU-Q" target="_blank">Superhuman ↗</a>
|
||||||
|
</div>
|
||||||
|
<p>- 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</p>
|
||||||
|
</article>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="dot"></span>
|
||||||
|
<article>
|
||||||
|
<div class="row">
|
||||||
|
<div>
|
||||||
|
<span class="pill" style="color:#34D399;border-color:#34D39955;background:#34D39922">Tema</span>
|
||||||
|
<span class="when">24 Apr 2026</span>
|
||||||
|
<h3>Secuencia emails 240426</h3>
|
||||||
|
</div>
|
||||||
|
<a href="https://docs.superhuman.com/d/_dqt1ZA3XaMp/_sulZ9VOu" target="_blank">Superhuman ↗</a>
|
||||||
|
</div>
|
||||||
|
<p>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</p>
|
||||||
|
</article>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="dot"></span>
|
||||||
|
<article>
|
||||||
|
<div class="row">
|
||||||
|
<div>
|
||||||
|
<span class="pill" style="color:#34D399;border-color:#34D39955;background:#34D39922">Tema</span>
|
||||||
|
<span class="when">23 Apr 2026</span>
|
||||||
|
<h3>Web Salón V2</h3>
|
||||||
|
</div>
|
||||||
|
<a href="https://docs.superhuman.com/d/_dqt1ZA3XaMp/_sumI6wo-" target="_blank">Superhuman ↗</a>
|
||||||
|
</div>
|
||||||
|
<p>**Pestanas: Registro / Agenda / Organiza / Contacto**
|
||||||
|
|
||||||
|
### **AGENDA**
|
||||||
|
|
||||||
|
Te mando por aquí el texto para la agenda: primero poner un texto introductorio, luego los conferenciantes c</p>
|
||||||
|
</article>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
159
scripts/dokploy_set_cron_secret.py
Normal file
159
scripts/dokploy_set_cron_secret.py
Normal file
@@ -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())
|
||||||
121
scripts/dokploy_set_gads_login_cid.py
Normal file
121
scripts/dokploy_set_gads_login_cid.py
Normal file
@@ -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())
|
||||||
63
scripts/export_bps_calendar_doc.ts
Normal file
63
scripts/export_bps_calendar_doc.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
33
scripts/export_bps_xlsx.ts
Normal file
33
scripts/export_bps_xlsx.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
450
scripts/gap_report_connections.py
Normal file
450
scripts/gap_report_connections.py
Normal file
@@ -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())
|
||||||
457
scripts/import_bps_calendars.ts
Normal file
457
scripts/import_bps_calendars.ts
Normal file
@@ -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<string, string> = {
|
||||||
|
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<string>();
|
||||||
|
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<string, RrssPiece[]> {
|
||||||
|
const byMonth = new Map<string, RrssPiece[]>();
|
||||||
|
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<string, RrssPiece[]>();
|
||||||
|
const gridMonths = new Set<string>();
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
});
|
||||||
53
scripts/list_bps_calendar_sheets.ts
Normal file
53
scripts/list_bps_calendar_sheets.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
64
scripts/list_drive_calendars.ts
Normal file
64
scripts/list_drive_calendars.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
101
scripts/probe_bps_seguimiento_drive.ts
Normal file
101
scripts/probe_bps_seguimiento_drive.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
52
scripts/probe_bps_superhuman.ts
Normal file
52
scripts/probe_bps_superhuman.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
41
scripts/probe_seguimiento_html.ts
Normal file
41
scripts/probe_seguimiento_html.ts
Normal file
@@ -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(/<h[1-6][^>]*>[\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(/<style[\s\S]*?<\/style>/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);
|
||||||
|
});
|
||||||
96
scripts/provision_victor_bps.ts
Normal file
96
scripts/provision_victor_bps.ts
Normal file
@@ -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<string, unknown>; user_metadata?: Record<string, unknown> } | 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);
|
||||||
|
});
|
||||||
592
scripts/refresh_agency_connections.py
Normal file
592
scripts/refresh_agency_connections.py
Normal file
@@ -0,0 +1,592 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Refresh Meta + Google agency matches into client fichas and audit."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
from collections import defaultdict
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
META = "https://graph.facebook.com/v21.0"
|
||||||
|
GADS = "https://googleads.googleapis.com/v18"
|
||||||
|
|
||||||
|
|
||||||
|
def load_env() -> dict[str, str]:
|
||||||
|
vals: dict[str, str] = {}
|
||||||
|
for p in (ROOT / ".env.local", Path("/Users/4ventures/Projects/bps-facturacion/.env")):
|
||||||
|
if not p.is_file():
|
||||||
|
continue
|
||||||
|
for line in p.read_text(encoding="utf-8").splitlines():
|
||||||
|
if not line.strip() or line.strip().startswith("#") or "=" not in line:
|
||||||
|
continue
|
||||||
|
k, v = line.split("=", 1)
|
||||||
|
vals.setdefault(k.strip(), v.strip().strip('"').strip("'"))
|
||||||
|
return vals
|
||||||
|
|
||||||
|
|
||||||
|
ENV = load_env()
|
||||||
|
BASE = ENV["NEXT_PUBLIC_SUPABASE_URL"].rstrip("/")
|
||||||
|
KEY = ENV["SUPABASE_SERVICE_ROLE_KEY"]
|
||||||
|
SH = {"apikey": KEY, "Authorization": f"Bearer {KEY}"}
|
||||||
|
|
||||||
|
|
||||||
|
def sb_get(path: str):
|
||||||
|
req = urllib.request.Request(BASE + path, headers=SH)
|
||||||
|
with urllib.request.urlopen(req, timeout=90) as r:
|
||||||
|
return json.loads(r.read().decode())
|
||||||
|
|
||||||
|
|
||||||
|
def sb_patch(path: str, body: dict) -> bool:
|
||||||
|
data = json.dumps(body).encode()
|
||||||
|
req = urllib.request.Request(
|
||||||
|
BASE + path,
|
||||||
|
data=data,
|
||||||
|
headers={**SH, "Content-Type": "application/json", "Prefer": "return=minimal"},
|
||||||
|
method="PATCH",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=60) as r:
|
||||||
|
return 200 <= r.status < 300
|
||||||
|
except urllib.error.HTTPError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def sb_upsert_integ(tenant_id: str, provider: str, patch: dict) -> bool:
|
||||||
|
path = (
|
||||||
|
f"/rest/v1/integrations?tenant_id=eq.{urllib.parse.quote(tenant_id)}"
|
||||||
|
f"&provider=eq.{urllib.parse.quote(provider)}"
|
||||||
|
)
|
||||||
|
body = {**patch, "updated_at": datetime.now(timezone.utc).isoformat()}
|
||||||
|
if sb_patch(path, body):
|
||||||
|
return True
|
||||||
|
data = json.dumps(
|
||||||
|
{"tenant_id": tenant_id, "provider": provider, "enabled": True, **body}
|
||||||
|
).encode()
|
||||||
|
req = urllib.request.Request(
|
||||||
|
BASE + "/rest/v1/integrations",
|
||||||
|
data=data,
|
||||||
|
headers={
|
||||||
|
**SH,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Prefer": "resolution=merge-duplicates,return=minimal",
|
||||||
|
},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=60) as _:
|
||||||
|
return True
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
print("upsert fail", provider, e.read().decode()[:200])
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def norm(s: str) -> str:
|
||||||
|
s = s.lower()
|
||||||
|
s = (
|
||||||
|
s.replace("á", "a")
|
||||||
|
.replace("é", "e")
|
||||||
|
.replace("í", "i")
|
||||||
|
.replace("ó", "o")
|
||||||
|
.replace("ú", "u")
|
||||||
|
.replace("ñ", "n")
|
||||||
|
)
|
||||||
|
return re.sub(r"[^a-z0-9]+", " ", s).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def score_name(tenant_name: str, tenant_slug: str, account_name: str) -> int:
|
||||||
|
t = norm(tenant_name)
|
||||||
|
s = norm(tenant_slug.replace("-", " "))
|
||||||
|
a = norm(account_name)
|
||||||
|
if not a:
|
||||||
|
return 0
|
||||||
|
if a == t or a == s:
|
||||||
|
return 100
|
||||||
|
if t and (t in a or a in t):
|
||||||
|
return 85
|
||||||
|
if s and (s in a or a in s):
|
||||||
|
return 80
|
||||||
|
tt = set(x for x in t.split() if len(x) > 2)
|
||||||
|
aa = set(x for x in a.split() if len(x) > 2)
|
||||||
|
if not tt or not aa:
|
||||||
|
return 0
|
||||||
|
hit = len(tt & aa) / len(tt)
|
||||||
|
if hit >= 0.8:
|
||||||
|
return 70
|
||||||
|
if hit >= 0.5:
|
||||||
|
return 55
|
||||||
|
return int(hit * 50)
|
||||||
|
|
||||||
|
|
||||||
|
def best_matches(tenants, accounts, threshold=55):
|
||||||
|
"""Greedy 1:1 match like portal."""
|
||||||
|
scored = []
|
||||||
|
for t in tenants:
|
||||||
|
for acc in accounts:
|
||||||
|
sc = score_name(t["name"], t["slug"], acc["name"])
|
||||||
|
if sc >= threshold:
|
||||||
|
scored.append((sc, t["id"], acc))
|
||||||
|
scored.sort(key=lambda x: -x[0])
|
||||||
|
used_t, used_a = set(), set()
|
||||||
|
out = {}
|
||||||
|
for sc, tid, acc in scored:
|
||||||
|
if tid in used_t or acc["id"] in used_a:
|
||||||
|
continue
|
||||||
|
used_t.add(tid)
|
||||||
|
used_a.add(acc["id"])
|
||||||
|
out[tid] = (acc, sc)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def meta_get(token: str, path: str):
|
||||||
|
url = f"{META}/{path}"
|
||||||
|
sep = "&" if "?" in path else "?"
|
||||||
|
req = urllib.request.Request(url + sep + "access_token=" + urllib.parse.quote(token))
|
||||||
|
with urllib.request.urlopen(req, timeout=90) as r:
|
||||||
|
return json.loads(r.read().decode())
|
||||||
|
|
||||||
|
|
||||||
|
def meta_paginate(token: str, path: str):
|
||||||
|
out = []
|
||||||
|
url = f"{META}/{path}"
|
||||||
|
sep = "&" if "?" in path else "?"
|
||||||
|
url = url + sep + "access_token=" + urllib.parse.quote(token)
|
||||||
|
while url:
|
||||||
|
req = urllib.request.Request(url)
|
||||||
|
with urllib.request.urlopen(req, timeout=90) as r:
|
||||||
|
j = json.loads(r.read().decode())
|
||||||
|
out.extend(j.get("data") or [])
|
||||||
|
url = (j.get("paging") or {}).get("next")
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def google_refresh(refresh: str) -> str:
|
||||||
|
body = urllib.parse.urlencode(
|
||||||
|
{
|
||||||
|
"refresh_token": refresh,
|
||||||
|
"client_id": ENV["GOOGLE_OAUTH_CLIENT_ID"],
|
||||||
|
"client_secret": ENV["GOOGLE_OAUTH_CLIENT_SECRET"],
|
||||||
|
"grant_type": "refresh_token",
|
||||||
|
}
|
||||||
|
).encode()
|
||||||
|
req = urllib.request.Request(
|
||||||
|
"https://oauth2.googleapis.com/token",
|
||||||
|
data=body,
|
||||||
|
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=60) as r:
|
||||||
|
return json.loads(r.read().decode())["access_token"]
|
||||||
|
|
||||||
|
|
||||||
|
def gads_access() -> str:
|
||||||
|
refresh = ENV.get("GOOGLE_ADS_REFRESH_TOKEN") or ENV.get("GOOGLE_OAUTH_REFRESH_TOKEN")
|
||||||
|
cid = ENV.get("GOOGLE_ADS_CLIENT_ID") or ENV.get("GOOGLE_OAUTH_CLIENT_ID")
|
||||||
|
secret = ENV.get("GOOGLE_ADS_CLIENT_SECRET") or ENV.get("GOOGLE_OAUTH_CLIENT_SECRET")
|
||||||
|
body = urllib.parse.urlencode(
|
||||||
|
{
|
||||||
|
"refresh_token": refresh,
|
||||||
|
"client_id": cid,
|
||||||
|
"client_secret": secret,
|
||||||
|
"grant_type": "refresh_token",
|
||||||
|
}
|
||||||
|
).encode()
|
||||||
|
req = urllib.request.Request(
|
||||||
|
"https://oauth2.googleapis.com/token",
|
||||||
|
data=body,
|
||||||
|
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=60) as r:
|
||||||
|
return json.loads(r.read().decode())["access_token"]
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
tenants = sb_get("/rest/v1/tenants?select=id,name,slug&order=name.asc")
|
||||||
|
print(f"tenants={len(tenants)}")
|
||||||
|
|
||||||
|
# --- tokens ---
|
||||||
|
g_rows = sb_get(
|
||||||
|
"/rest/v1/agency_oauth?provider=eq.google_drive&select=access_token,refresh_token,expires_at,account_email,scopes"
|
||||||
|
)
|
||||||
|
m_rows = sb_get(
|
||||||
|
"/rest/v1/agency_oauth?provider=eq.meta_ads&select=access_token,account_email,scopes,updated_at"
|
||||||
|
)
|
||||||
|
if not g_rows:
|
||||||
|
print("FAIL: no google_drive oauth")
|
||||||
|
return 1
|
||||||
|
if not m_rows:
|
||||||
|
print("FAIL: no meta_ads oauth")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
grow = g_rows[0]
|
||||||
|
mrow = m_rows[0]
|
||||||
|
print("Google:", grow.get("account_email"))
|
||||||
|
print("Meta:", mrow.get("account_email"), "updated", mrow.get("updated_at"))
|
||||||
|
|
||||||
|
gtoken = grow["access_token"]
|
||||||
|
exp = grow.get("expires_at")
|
||||||
|
if exp:
|
||||||
|
try:
|
||||||
|
et = datetime.fromisoformat(exp.replace("Z", "+00:00"))
|
||||||
|
if et.timestamp() < datetime.now(timezone.utc).timestamp() + 60:
|
||||||
|
print("Refreshing Google access token…")
|
||||||
|
gtoken = google_refresh(grow["refresh_token"])
|
||||||
|
except Exception as e:
|
||||||
|
print("google refresh warn", e)
|
||||||
|
gtoken = google_refresh(grow["refresh_token"])
|
||||||
|
|
||||||
|
mtoken = mrow["access_token"]
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
linked_counts = defaultdict(int)
|
||||||
|
|
||||||
|
# ========== META ==========
|
||||||
|
print("\n=== META Ads + Pages + IG ===")
|
||||||
|
ad_accounts = []
|
||||||
|
for row in meta_paginate(
|
||||||
|
mtoken, "me/adaccounts?fields=id,account_id,name,account_status&limit=200"
|
||||||
|
):
|
||||||
|
aid = row.get("account_id") or row["id"].replace("act_", "")
|
||||||
|
ad_accounts.append({"id": f"act_{aid}", "name": row.get("name") or aid})
|
||||||
|
print(f"Meta ad accounts visible: {len(ad_accounts)}")
|
||||||
|
|
||||||
|
pages = []
|
||||||
|
igs = []
|
||||||
|
for page in meta_paginate(
|
||||||
|
mtoken,
|
||||||
|
"me/accounts?fields=id,name,instagram_business_account{id,username,name}&limit=100",
|
||||||
|
):
|
||||||
|
pages.append({"id": page["id"], "name": page.get("name") or page["id"]})
|
||||||
|
ig = page.get("instagram_business_account") or {}
|
||||||
|
if ig.get("id"):
|
||||||
|
igs.append(
|
||||||
|
{
|
||||||
|
"id": ig["id"],
|
||||||
|
"name": f"@{ig['username']}" if ig.get("username") else (ig.get("name") or page.get("name") or ig["id"]),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
print(f"FB pages visible: {len(pages)} · IG: {len(igs)}")
|
||||||
|
|
||||||
|
for provider, accounts, label in (
|
||||||
|
("meta_ads", ad_accounts, "Meta Ads"),
|
||||||
|
("facebook", pages, "Facebook Page"),
|
||||||
|
("instagram", igs, "Instagram"),
|
||||||
|
):
|
||||||
|
matches = best_matches(tenants, accounts, threshold=55)
|
||||||
|
for tid, (acc, sc) in matches.items():
|
||||||
|
ok = sb_upsert_integ(
|
||||||
|
tid,
|
||||||
|
provider,
|
||||||
|
{
|
||||||
|
"status": "connected",
|
||||||
|
"external_account_id": acc["id"],
|
||||||
|
"label": acc["name"],
|
||||||
|
"last_sync_at": now,
|
||||||
|
"error_message": None,
|
||||||
|
"enabled": True,
|
||||||
|
"meta": {
|
||||||
|
"account_name": acc["name"],
|
||||||
|
"match_score": sc,
|
||||||
|
"matched_at": now,
|
||||||
|
"source": "refresh_script",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if ok:
|
||||||
|
linked_counts[provider] += 1
|
||||||
|
print(f" linked {provider}: {linked_counts[provider]}/{len(accounts)}")
|
||||||
|
|
||||||
|
# ========== GOOGLE DRIVE ==========
|
||||||
|
print("\n=== Google Drive folders ===")
|
||||||
|
folders = []
|
||||||
|
page_token = None
|
||||||
|
while True:
|
||||||
|
params = {
|
||||||
|
"q": "mimeType='application/vnd.google-apps.folder' and trashed=false",
|
||||||
|
"fields": "nextPageToken,files(id,name)",
|
||||||
|
"pageSize": "1000",
|
||||||
|
"corpora": "allDrives",
|
||||||
|
"includeItemsFromAllDrives": "true",
|
||||||
|
"supportsAllDrives": "true",
|
||||||
|
}
|
||||||
|
if page_token:
|
||||||
|
params["pageToken"] = page_token
|
||||||
|
req = urllib.request.Request(
|
||||||
|
"https://www.googleapis.com/drive/v3/files?" + urllib.parse.urlencode(params),
|
||||||
|
headers={"Authorization": f"Bearer {gtoken}"},
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=90) as r:
|
||||||
|
j = json.loads(r.read().decode())
|
||||||
|
for f in j.get("files") or []:
|
||||||
|
folders.append({"id": f["id"], "name": f["name"]})
|
||||||
|
page_token = j.get("nextPageToken")
|
||||||
|
if not page_token:
|
||||||
|
break
|
||||||
|
print(f"Drive folders: {len(folders)}")
|
||||||
|
matches = best_matches(tenants, folders, threshold=55)
|
||||||
|
for tid, (acc, sc) in matches.items():
|
||||||
|
if sb_upsert_integ(
|
||||||
|
tid,
|
||||||
|
"google_drive",
|
||||||
|
{
|
||||||
|
"status": "connected",
|
||||||
|
"external_account_id": acc["id"],
|
||||||
|
"label": acc["name"],
|
||||||
|
"last_sync_at": now,
|
||||||
|
"error_message": None,
|
||||||
|
"meta": {"folder_name": acc["name"], "match_score": sc, "source": "refresh_script"},
|
||||||
|
},
|
||||||
|
):
|
||||||
|
linked_counts["google_drive"] += 1
|
||||||
|
print(f" linked google_drive: {linked_counts['google_drive']}")
|
||||||
|
|
||||||
|
# ========== GA4 ==========
|
||||||
|
print("\n=== GA4 properties ===")
|
||||||
|
ga4 = []
|
||||||
|
req = urllib.request.Request(
|
||||||
|
"https://analyticsadmin.googleapis.com/v1beta/accountSummaries",
|
||||||
|
headers={"Authorization": f"Bearer {gtoken}"},
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=90) as r:
|
||||||
|
summaries = json.loads(r.read().decode())
|
||||||
|
for acc in summaries.get("accountSummaries") or []:
|
||||||
|
for p in acc.get("propertySummaries") or []:
|
||||||
|
prop = p.get("property")
|
||||||
|
if not prop:
|
||||||
|
continue
|
||||||
|
# measurement id
|
||||||
|
mid = None
|
||||||
|
try:
|
||||||
|
req2 = urllib.request.Request(
|
||||||
|
f"https://analyticsadmin.googleapis.com/v1beta/{prop}/dataStreams",
|
||||||
|
headers={"Authorization": f"Bearer {gtoken}"},
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req2, timeout=60) as r2:
|
||||||
|
streams = json.loads(r2.read().decode())
|
||||||
|
for s in streams.get("dataStreams") or []:
|
||||||
|
mid = (s.get("webStreamData") or {}).get("measurementId")
|
||||||
|
if mid:
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
ga4.append(
|
||||||
|
{
|
||||||
|
"id": mid or prop,
|
||||||
|
"name": p.get("displayName") or prop,
|
||||||
|
"property_id": prop,
|
||||||
|
"measurement_id": mid,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
print(f"GA4 properties: {len(ga4)}")
|
||||||
|
matches = best_matches(tenants, ga4, threshold=50)
|
||||||
|
for tid, (acc, sc) in matches.items():
|
||||||
|
if sb_upsert_integ(
|
||||||
|
tid,
|
||||||
|
"ga4",
|
||||||
|
{
|
||||||
|
"status": "connected",
|
||||||
|
"external_account_id": acc["id"],
|
||||||
|
"label": acc["name"],
|
||||||
|
"last_sync_at": now,
|
||||||
|
"error_message": None,
|
||||||
|
"meta": {
|
||||||
|
"property_id": acc.get("property_id"),
|
||||||
|
"measurement_id": acc.get("measurement_id"),
|
||||||
|
"display_name": acc["name"],
|
||||||
|
"match_score": sc,
|
||||||
|
"source": "refresh_script",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
):
|
||||||
|
linked_counts["ga4"] += 1
|
||||||
|
print(f" linked ga4: {linked_counts['ga4']}")
|
||||||
|
|
||||||
|
# ========== GSC ==========
|
||||||
|
print("\n=== Search Console ===")
|
||||||
|
req = urllib.request.Request(
|
||||||
|
"https://www.googleapis.com/webmasters/v3/sites",
|
||||||
|
headers={"Authorization": f"Bearer {gtoken}"},
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=90) as r:
|
||||||
|
sites_j = json.loads(r.read().decode())
|
||||||
|
gsc = []
|
||||||
|
for s in sites_j.get("siteEntry") or []:
|
||||||
|
url = s.get("siteUrl")
|
||||||
|
if url:
|
||||||
|
gsc.append({"id": url, "name": url})
|
||||||
|
print(f"GSC sites: {len(gsc)}")
|
||||||
|
matches = best_matches(tenants, gsc, threshold=40)
|
||||||
|
for tid, (acc, sc) in matches.items():
|
||||||
|
if sb_upsert_integ(
|
||||||
|
tid,
|
||||||
|
"search_console",
|
||||||
|
{
|
||||||
|
"status": "connected",
|
||||||
|
"external_account_id": acc["id"],
|
||||||
|
"label": acc["id"],
|
||||||
|
"last_sync_at": now,
|
||||||
|
"error_message": None,
|
||||||
|
"meta": {"site_url": acc["id"], "match_score": sc, "source": "refresh_script"},
|
||||||
|
},
|
||||||
|
):
|
||||||
|
linked_counts["gsc"] += 1
|
||||||
|
print(f" linked gsc: {linked_counts['gsc']}")
|
||||||
|
|
||||||
|
# ========== Google Ads ==========
|
||||||
|
print("\n=== Google Ads ===")
|
||||||
|
try:
|
||||||
|
at = gads_access()
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {at}",
|
||||||
|
"developer-token": ENV["GOOGLE_ADS_DEVELOPER_TOKEN"],
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
login = (ENV.get("GOOGLE_ADS_LOGIN_CUSTOMER_ID") or "").replace("-", "")
|
||||||
|
if login:
|
||||||
|
headers["login-customer-id"] = login
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"{GADS}/customers:listAccessibleCustomers", headers=headers
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=90) as r:
|
||||||
|
listed = json.loads(r.read().decode())
|
||||||
|
ids = [rn.replace("customers/", "") for rn in listed.get("resourceNames") or []]
|
||||||
|
gads_accounts = []
|
||||||
|
for cid in ids:
|
||||||
|
h = dict(headers)
|
||||||
|
h["login-customer-id"] = cid
|
||||||
|
body = json.dumps(
|
||||||
|
{
|
||||||
|
"query": "SELECT customer.id, customer.descriptive_name, customer.manager FROM customer LIMIT 1"
|
||||||
|
}
|
||||||
|
).encode()
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"{GADS}/customers/{cid}/googleAds:search",
|
||||||
|
data=body,
|
||||||
|
headers=h,
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=60) as r:
|
||||||
|
j = json.loads(r.read().decode())
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
c = ((j.get("results") or [{}])[0]).get("customer") or {}
|
||||||
|
if not c:
|
||||||
|
continue
|
||||||
|
name = c.get("descriptiveName") or cid
|
||||||
|
if c.get("manager"):
|
||||||
|
# pull clients
|
||||||
|
body2 = json.dumps(
|
||||||
|
{
|
||||||
|
"query": (
|
||||||
|
"SELECT customer_client.id, customer_client.descriptive_name, "
|
||||||
|
"customer_client.manager, customer_client.status FROM customer_client "
|
||||||
|
"WHERE customer_client.status = 'ENABLED' AND customer_client.manager = FALSE"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
).encode()
|
||||||
|
req2 = urllib.request.Request(
|
||||||
|
f"{GADS}/customers/{cid}/googleAds:search",
|
||||||
|
data=body2,
|
||||||
|
headers=h,
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req2, timeout=90) as r2:
|
||||||
|
j2 = json.loads(r2.read().decode())
|
||||||
|
for row in j2.get("results") or []:
|
||||||
|
cc = row.get("customerClient") or {}
|
||||||
|
if cc.get("id"):
|
||||||
|
gads_accounts.append(
|
||||||
|
{
|
||||||
|
"id": str(cc["id"]),
|
||||||
|
"name": cc.get("descriptiveName") or str(cc["id"]),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
gads_accounts.append({"id": str(c.get("id") or cid), "name": name})
|
||||||
|
# dedupe
|
||||||
|
mmap = {a["id"]: a for a in gads_accounts}
|
||||||
|
gads_accounts = list(mmap.values())
|
||||||
|
print(f"Google Ads accounts: {len(gads_accounts)}")
|
||||||
|
matches = best_matches(tenants, gads_accounts, threshold=55)
|
||||||
|
for tid, (acc, sc) in matches.items():
|
||||||
|
if sb_upsert_integ(
|
||||||
|
tid,
|
||||||
|
"google_ads",
|
||||||
|
{
|
||||||
|
"status": "connected",
|
||||||
|
"external_account_id": acc["id"],
|
||||||
|
"label": acc["name"],
|
||||||
|
"last_sync_at": now,
|
||||||
|
"error_message": None,
|
||||||
|
"meta": {
|
||||||
|
"account_name": acc["name"],
|
||||||
|
"customer_id": acc["id"],
|
||||||
|
"match_score": sc,
|
||||||
|
"source": "refresh_script",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
):
|
||||||
|
linked_counts["google_ads"] += 1
|
||||||
|
print(f" linked google_ads: {linked_counts['google_ads']}")
|
||||||
|
except Exception as e:
|
||||||
|
print("Google Ads ERROR:", e)
|
||||||
|
|
||||||
|
# ========== AUDIT ==========
|
||||||
|
print("\n=== AUDIT ===")
|
||||||
|
integs = sb_get(
|
||||||
|
"/rest/v1/integrations?provider=in.(ga4,search_console,google_drive,google_ads,meta_ads,facebook,instagram)"
|
||||||
|
"&select=tenant_id,provider,status,external_account_id,error_message"
|
||||||
|
)
|
||||||
|
by = defaultdict(dict)
|
||||||
|
for i in integs:
|
||||||
|
by[i["tenant_id"]][i["provider"]] = i
|
||||||
|
|
||||||
|
def flag(i):
|
||||||
|
if not i:
|
||||||
|
return "—"
|
||||||
|
if i.get("status") == "connected" and i.get("external_account_id"):
|
||||||
|
return "OK"
|
||||||
|
if i.get("status") == "error":
|
||||||
|
return "ERR"
|
||||||
|
return "—"
|
||||||
|
|
||||||
|
print(
|
||||||
|
f"{'Cliente':30} {'GAds':5} {'Meta':5} {'GA4':5} {'GSC':5} {'Drive':5} {'FB':5} {'IG':5} Org"
|
||||||
|
)
|
||||||
|
for t in tenants:
|
||||||
|
m = by[t["id"]]
|
||||||
|
# organic count
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"{BASE}/rest/v1/organic_daily?tenant_id=eq.{t['id']}&select=day",
|
||||||
|
headers={**SH, "Prefer": "count=exact", "Range": "0-0"},
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as r:
|
||||||
|
cr = r.headers.get("content-range") or "*/0"
|
||||||
|
org = cr.split("/")[-1]
|
||||||
|
print(
|
||||||
|
f"{t['name'][:30]:30} "
|
||||||
|
f"{flag(m.get('google_ads')):5} "
|
||||||
|
f"{flag(m.get('meta_ads')):5} "
|
||||||
|
f"{flag(m.get('ga4')):5} "
|
||||||
|
f"{flag(m.get('search_console')):5} "
|
||||||
|
f"{flag(m.get('google_drive')):5} "
|
||||||
|
f"{flag(m.get('facebook')):5} "
|
||||||
|
f"{flag(m.get('instagram')):5} "
|
||||||
|
f"{org}"
|
||||||
|
)
|
||||||
|
|
||||||
|
print("\n=== Linked counts this run ===")
|
||||||
|
print(dict(linked_counts))
|
||||||
|
print("\nDone. Orgánico days still need app sync (Emparejar todo Google / abrir Orgánico).")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
203
scripts/refresh_agency_connections.ts
Normal file
203
scripts/refresh_agency_connections.ts
Normal file
@@ -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<number> {
|
||||||
|
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<string, unknown> | null;
|
||||||
|
}[];
|
||||||
|
|
||||||
|
const by = new Map<string, typeof integs>();
|
||||||
|
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<string, string>;
|
||||||
|
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<string, string> = {};
|
||||||
|
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);
|
||||||
|
});
|
||||||
145
scripts/sync_bps_gdoc_days.ts
Normal file
145
scripts/sync_bps_gdoc_days.ts
Normal file
@@ -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: `<pre>${sec.body
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')}</pre>`,
|
||||||
|
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);
|
||||||
|
});
|
||||||
59
scripts/sync_bps_seguimiento.ts
Normal file
59
scripts/sync_bps_seguimiento.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user