From feef339c2b3b5e5460dbdd9721d564720f85d796 Mon Sep 17 00:00:00 2001 From: 4Ventures <4ventures@MacBook-Air-de-4Ventures.local> Date: Wed, 14 Oct 2026 13:31:12 +0200 Subject: [PATCH] Import missing RRSS months from live Drive calendars. Agua Miia and TYD had many tabs in Drive that were never in documents; add a live importer and expand the batch JOBS map so future syncs cover them. Co-authored-by: Cursor --- scripts/import_drive_calendars_batch.ts | 49 +- scripts/import_drive_calendars_live.py | 569 ++++++++++++++++++++++++ 2 files changed, 617 insertions(+), 1 deletion(-) create mode 100644 scripts/import_drive_calendars_live.py diff --git a/scripts/import_drive_calendars_batch.ts b/scripts/import_drive_calendars_batch.ts index f8b0a37..f04a217 100644 --- a/scripts/import_drive_calendars_batch.ts +++ b/scripts/import_drive_calendars_batch.ts @@ -126,12 +126,59 @@ const JOBS: ClientJob[] = [ brand: '4Ventures', file: `${ROOT}/4VENTURES/CALENDARIO 4VENTURES.xlsx`, sheets: { + ABRIL: '2026-04', + MAYO: '2026-05', + JUNIO: '2026-06', JULIO: '2026-07', AGOSTO: '2026-08', 'SEPTIEMBRE 2026': '2026-09', 'OCTUBRE 2026': '2026-10', }, - onlyMonths: ['2026-07', '2026-08', '2026-09', '2026-10'], + onlyMonths: [ + '2026-04', + '2026-05', + '2026-06', + '2026-07', + '2026-08', + '2026-09', + '2026-10', + ], + }, + { + slug: 'agua-miia', + brand: 'Agua Miia', + // Prefer live Drive export: python3 scripts/import_drive_calendars_live.py + file: `/tmp/drive-cals/agua-miia.xlsx`, + sheets: { + OCTUBRE: '2025-10', + NOVIEMBRE: '2025-11', + DICIEMBRE: '2025-12', + ENERO: '2026-01', + FEBRERO: '2026-02', + MARZO: '2026-03', + ABRIL: '2026-04', + MAYO: '2026-05', + JUNIO: '2026-06', + JULIO: '2026-07', + AGOSTO: '2026-08', + 'SEPTIEMBRE 2026': '2026-09', + 'OCTUBRE 2026': '2026-10', + }, + onlyMonths: [ + '2025-10', + '2025-11', + '2025-12', + '2026-01', + '2026-02', + '2026-03', + '2026-04', + '2026-05', + '2026-06', + '2026-07', + '2026-08', + '2026-09', + '2026-10', + ], }, ]; diff --git a/scripts/import_drive_calendars_live.py b/scripts/import_drive_calendars_live.py new file mode 100644 index 0000000..d34d8b6 --- /dev/null +++ b/scripts/import_drive_calendars_live.py @@ -0,0 +1,569 @@ +#!/usr/bin/env python3 +""" +Import RRSS calendars from live Google Drive sheets → documents (type=calendar). + + python3 scripts/import_drive_calendars_live.py + +Exports each client's CALENDARIO spreadsheet, maps tabs → YYYY-MM via weekday +alignment, parses SEMANA×STORIES/POST/LINKEDIN grids, upserts missing/thin months. +Preserves richer existing plans (e.g. Agua Miia Oct 2026 bundled) when they have +more pieces than the Drive grid parse. +""" +from __future__ import annotations + +import json +import re +import urllib.parse +import urllib.request +import zipfile +import io +from datetime import date +from pathlib import Path +from typing import Any + +from openpyxl import load_workbook + +ROOT = Path(__file__).resolve().parents[1] +ENV = ROOT / ".env.local" +OUT = Path("/tmp/drive-cals") + +DRIVE_SHEETS = { + "agua-miia": { + "brand": "Agua Miia", + "file_id": "18Dl4ln6uPkoSHK2CABdjYIQYbB870gsxdHtgGDKW8Tw", + }, + "bps": { + "brand": "Blue Parrot School", + "file_id": "1YPcHBjioiHHQy22Q2xevkvUqA5jMq87v6IviV7VTL2s", + }, + "brava-dermoestetica": { + "brand": "Brava Dermoestética", + "file_id": "1blboVr4Ig3xb_YQjO84_I6_yDuT0MjkdKYWjbeNY4dc", + }, + "halal-safety": { + "brand": "Halal Safety", + "file_id": "1SOZQMFFRulLeafd2ojCjBSykvPKRA6npJvOyp3b0_r4", + }, + "noberplast": { + "brand": "Noberplast", + "file_id": "1GhDxBOem20Fv7nFWfJKN445S1ixe_7W4_f1WeGsRPdc", + # Single "Hoja 1" sheet — ops calendar for Oct 2026 (Jueves 1 also matches enero). + "force_sheet_months": {"Hoja 1": "2026-10"}, + }, + "talento-y-deporte": { + "brand": "Talento y Deporte", + "file_id": "1HSbJAfjlBDdpHJubgxhT322UUZVJ2zlQHaPkO0HtevA", + }, + "4ventures-hub": { + "brand": "4Ventures", + "file_id": "1OJVh2FJzXI5-yRm9LB6InbnteUAHxqOT7u6Ch2tEv7Y", + }, +} + +MONTHS = { + "ENERO": 1, + "FEBRERO": 2, + "MARZO": 3, + "ABRIL": 4, + "MAYO": 5, + "JUNIO": 6, + "JULIO": 7, + "AGOSTO": 8, + "SEPTIEMBRE": 9, + "SEPTIMEBRE": 9, + "OCTUBRE": 10, + "NOVIEMBRE": 11, + "DICIEMBRE": 12, +} +WD = { + "LUNES": 0, + "MARTES": 1, + "MIERCOLES": 2, + "JUEVES": 3, + "JUVES": 3, + "VIERNES": 4, + "SABADO": 5, + "DOMINGO": 6, +} +SKIP_TABS = re.compile( + r"ideas|calendario$|publicidad|tareas|competidores|mayo-diciembre", + re.I, +) +# "Hoja N" only skipped when we cannot infer a month; single-sheet clients (Noberplast) keep them. +MONTH_LABELS = [ + "", + "Enero", + "Febrero", + "Marzo", + "Abril", + "Mayo", + "Junio", + "Julio", + "Agosto", + "Septiembre", + "Octubre", + "Noviembre", + "Diciembre", +] + + +def load_env() -> dict[str, str]: + env: dict[str, str] = {} + for line in ENV.read_text().splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + k, v = line.split("=", 1) + env[k] = v.strip().strip('"').strip("'") + return env + + +def rest(env: dict[str, str], path: str, method: str = "GET", body: Any = None): + url = (env.get("NEXT_PUBLIC_SUPABASE_URL") or env.get("SUPABASE_URL") or "").rstrip( + "/" + ) + key = env["SUPABASE_SERVICE_ROLE_KEY"] + headers = { + "apikey": key, + "Authorization": f"Bearer {key}", + "Content-Type": "application/json", + "Prefer": "return=representation", + } + data = None if body is None else json.dumps(body).encode() + req = urllib.request.Request(url + path, data=data, headers=headers, method=method) + with urllib.request.urlopen(req, timeout=120) as r: + raw = r.read().decode() + return json.loads(raw) if raw else None + + +def drive_token(env: dict[str, str]) -> str: + oauth = rest( + env, "/rest/v1/agency_oauth?provider=eq.google_drive&select=refresh_token" + )[0] + cid = env.get("GOOGLE_OAUTH_CLIENT_ID") or env.get("NEXT_PUBLIC_GOOGLE_CLIENT_ID") + csec = env.get("GOOGLE_OAUTH_CLIENT_SECRET") + req = urllib.request.Request( + "https://oauth2.googleapis.com/token", + data=urllib.parse.urlencode( + { + "client_id": cid, + "client_secret": csec, + "refresh_token": oauth["refresh_token"], + "grant_type": "refresh_token", + } + ).encode(), + ) + with urllib.request.urlopen(req, timeout=60) as r: + return json.loads(r.read().decode())["access_token"] + + +def export_xlsx(token: str, file_id: str, dest: Path) -> Path: + export_url = ( + f"https://www.googleapis.com/drive/v3/files/{file_id}/export?" + + urllib.parse.urlencode( + { + "mimeType": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + } + ) + ) + req = urllib.request.Request( + export_url, headers={"Authorization": f"Bearer {token}"} + ) + with urllib.request.urlopen(req, timeout=180) as r: + dest.write_bytes(r.read()) + return dest + + +def slugify(s: str) -> str: + import unicodedata + + s = unicodedata.normalize("NFD", s.lower()) + s = "".join(c for c in s if unicodedata.category(c) != "Mn") + s = re.sub(r"[^a-z0-9]+", "-", s).strip("-") + return s[:48] + + +def infer_month_num(name: str) -> int | None: + u = name.upper().replace("SEPTIMEBRE", "SEPTIEMBRE") + for m, n in MONTHS.items(): + if m in u: + return n + return None + + +def normalize_wd(name: str) -> str: + return ( + name.upper() + .replace("É", "E") + .replace("Á", "A") + .replace("Í", "I") + .replace("Ó", "O") + .replace("Ú", "U") + ) + + +def sample_headers(ws) -> list[tuple[str, int]]: + hits: list[tuple[str, int]] = [] + for row in ws.iter_rows(min_row=1, max_row=50, max_col=12, values_only=True): + for v in row or []: + if v is None: + continue + s = normalize_wd(str(v).strip()) + m = re.match( + r"(LUNES|MARTES|MIERCOLES|JUEVES|JUVES|VIERNES|SABADO|DOMINGO)\s+(\d{1,2})\s*$", + s, + ) + if m: + hits.append((m.group(1), int(m.group(2)))) + return hits[:20] + + +def best_year(month: int, hits: list[tuple[str, int]], tab_name: str) -> int | None: + # Explicit year in tab wins when present + ym = re.search(r"(20\d{2})", tab_name) + if ym: + return int(ym.group(1)) + if re.search(r"\b26\b", tab_name) or tab_name.upper().endswith("26"): + return 2026 + + scores: dict[int, tuple[int, int]] = {} + for y in (2024, 2025, 2026, 2027): + ok = tot = 0 + for wdname, day in hits: + if day < 1 or day > 31: + continue + try: + d = date(y, month, day) + except ValueError: + continue + want = WD.get(wdname) + if want is None: + continue + tot += 1 + if d.weekday() == want: + ok += 1 + scores[y] = (ok, tot) + if not any(v[1] for v in scores.values()): + return None + best = max(scores.items(), key=lambda kv: (kv[1][0], -abs(kv[0] - 2026))) + if best[1][0] == 0: + return None + return best[0] + + +def parse_day_header(h: str) -> int | None: + raw = str(h or "").strip() + if not raw or re.match(r"^x\b", raw, re.I) or re.match(r"^columna", raw, re.I): + return None + m = re.search(r"(\d{1,2})\s*$", raw) + if not m: + return None + n = int(m.group(1)) + return n if 1 <= n <= 31 else None + + +def detect_kind(c0: str) -> str | None: + u = c0.upper() + if u.startswith("STORIES") or u == "STORY" or u.startswith("STORY "): + return "STORIES" + if u.startswith("LINKEDIN"): + return "LINKEDIN" + if u.startswith("POST") or u.startswith("REEL") or u.startswith("CARRUSEL"): + return "POST" + if u == "STORY" or u.startswith("HISTORIA"): + return "STORIES" + return None + + +def piece_meta(kind: str) -> dict[str, str]: + if kind == "STORIES": + return { + "platform": "instagram", + "format": "Stories", + "pillar": "stories", + "kind": "story", + "color": "#D9821F", + "suffix": "st", + } + if kind == "LINKEDIN": + return { + "platform": "linkedin", + "format": "Post", + "pillar": "linkedin", + "kind": "post", + "color": "#7C66B8", + "suffix": "li", + } + return { + "platform": "instagram", + "format": "Post", + "pillar": "feed", + "kind": "post", + "color": "#2071cf", + "suffix": "post", + } + + +def parse_week_grid(prefix: str, rows: list[list[Any]], year: int, month: int) -> list[dict]: + pieces: list[dict] = [] + day_cols: list[tuple[int, int]] = [] + kind: str | None = None + week_wraps = False + + for row in rows: + if not row or not any(c is not None and str(c).strip() for c in row): + continue + c0 = str(row[0] or "").strip() + c0u = c0.upper() + + if c0u.startswith("SEMANA") or re.match( + r"^(LUNES|MARTES|MI[EÉ]RCOLES)", str(row[1] if len(row) > 1 else "") + ): + day_cols = [] + for col in range(1, min(len(row), 12)): + day = parse_day_header(str(row[col] or "")) + if day: + day_cols.append((col, day)) + week_wraps = any( + i > 0 + and day_cols[i][1] < day_cols[i - 1][1] + and day_cols[i - 1][1] >= 28 + and day_cols[i][1] <= 7 + for i in range(len(day_cols)) + ) + kind = None + continue + + if ( + re.match(r"^(PUBLICADO|CLIENTE|APROBADO)$", c0u) + or c0u.startswith("GUIONES") + or c0u.startswith("CARPETA") + ): + continue + + detected = detect_kind(c0u) + if detected: + kind = detected + elif not kind: + continue + if not day_cols: + continue + + meta = piece_meta(kind) + for col, day in day_cols: + if col >= len(row): + continue + raw = row[col] + if raw is None: + continue + text = str(raw).strip() + if ( + not text + or text == "X" + or re.match(r"^columna", text, re.I) + or re.match(r"^(PUBLICADO|APROBADO)$", text, re.I) + ): + continue + + y, m = year, month + if week_wraps and day >= 28: + m = month - 1 + if m < 1: + m = 12 + y = year - 1 + + try: + dt = date(y, m, day) + except ValueError: + continue + + date_s = dt.isoformat() + title = text.split("\n")[0][:120] + pid = f"{prefix}-{date_s}-{meta['suffix']}-{slugify(title)[:24]}" + pieces.append( + { + "id": pid, + "date": date_s, + "platform": meta["platform"], + "format": meta["format"], + "pillar": meta["pillar"], + "title": title, + "body": text, + "status": "planned", + "kind": meta["kind"], + "color": meta["color"], + } + ) + + seen: set[str] = set() + out = [] + for p in pieces: + if p["id"] in seen: + continue + seen.add(p["id"]) + out.append(p) + return out + + +def sheet_rows(ws) -> list[list[Any]]: + rows = [] + for row in ws.iter_rows(values_only=True): + rows.append(list(row)) + return rows + + +def build_plan(brand: str, ym: str, pieces: list[dict], source: str) -> dict: + y, m = map(int, ym.split("-")) + label = f"{MONTH_LABELS[m]} {y}" + posts = sum(1 for p in pieces if p.get("kind") != "story") + stories = sum(1 for p in pieces if p.get("kind") == "story") + pieces = sorted(pieces, key=lambda p: p["date"]) + return { + "kind": "rrss_month", + "month": ym, + "label": label, + "hero": { + "eyebrow": f"4VENTURES × {brand.upper()} · PLAN RRSS", + "title": f"Calendario editorial · {label}", + "lede": f"Importado desde Drive ({source}).", + "chips": [f"{posts} posts", f"{stories} stories", "Fuente: Google Drive"], + }, + "pieces": pieces, + } + + +def existing_plans(env: dict[str, str], tenant_id: str) -> dict[str, dict]: + docs = rest( + env, + f"/rest/v1/documents?tenant_id=eq.{tenant_id}&type=eq.calendar&select=id,title,content", + ) + out: dict[str, dict] = {} + for d in docs or []: + c = d.get("content") + if isinstance(c, str): + try: + c = json.loads(c) + except Exception: + continue + if isinstance(c, dict) and c.get("kind") == "rrss_month" and c.get("month"): + out[str(c["month"])] = { + "id": d["id"], + "title": d.get("title"), + "pieces": len(c.get("pieces") or []), + "content": c, + } + return out + + +def upsert_plan(env: dict[str, str], tenant_id: str, plan: dict) -> str: + title = f"RRSS {plan['label']}" + docs = rest( + env, + f"/rest/v1/documents?tenant_id=eq.{tenant_id}&type=eq.calendar&select=id,title,content", + ) + for d in docs or []: + c = d.get("content") + if isinstance(c, str): + try: + c = json.loads(c) + except Exception: + c = None + month = c.get("month") if isinstance(c, dict) else None + same_title = d.get("title") == title or ( + plan["label"].lower() in (d.get("title") or "").lower() + ) + if month == plan["month"] or same_title: + rest(env, f"/rest/v1/documents?id=eq.{d['id']}", method="DELETE") + saved = rest( + env, + "/rest/v1/documents", + method="POST", + body={ + "tenant_id": tenant_id, + "type": "calendar", + "title": title, + "content": plan, + "status": "approved", + }, + ) + return (saved or [{}])[0].get("id", "") + + +def resolve_tenant(env: dict[str, str], slug: str) -> str: + rows = rest( + env, f"/rest/v1/tenants?slug=eq.{urllib.parse.quote(slug)}&select=id&limit=1" + ) + if not rows: + raise SystemExit(f"tenant not found: {slug}") + return rows[0]["id"] + + +def import_client(env: dict[str, str], token: str, slug: str, meta: dict) -> None: + brand = meta["brand"] + print(f"\n=== {slug} ({brand}) ===") + dest = OUT / f"{slug}.xlsx" + if not dest.exists() or dest.stat().st_size < 1000: + print(" exporting from Drive…") + export_xlsx(token, meta["file_id"], dest) + else: + print(f" using cached {dest}") + + wb = load_workbook(dest, data_only=True) + tenant_id = resolve_tenant(env, slug) + existing = existing_plans(env, tenant_id) + prefix = re.sub(r"[^a-z0-9]+", "", slug)[:12] or "cal" + + force_map = meta.get("force_sheet_months") or {} + for name in wb.sheetnames: + if SKIP_TABS.search(name or ""): + print(f" skip tab {name}") + continue + if re.match(r"^hoja\s*\d+$", name or "", re.I) and len(wb.sheetnames) > 1 and name not in force_map: + print(f" skip tab {name}") + continue + hits = sample_headers(wb[name]) + if name in force_map: + ym = force_map[name] + year, month_n = map(int, ym.split("-")) + print(f" {name} (forced) → {ym}") + else: + month_n = infer_month_num(name) + if not month_n: + print(f" skip tab {name} (no month)") + continue + year = best_year(month_n, hits, name) + if not year: + print(f" skip tab {name} (cannot infer year)") + continue + ym = f"{year}-{month_n:02d}" + pieces = parse_week_grid(prefix, sheet_rows(wb[name]), year, month_n) + print(f" {name} → {ym} ({len(pieces)} pieces)") + if not pieces: + print(" SKIP empty") + continue + + prev = existing.get(ym) + if prev and prev["pieces"] >= len(pieces) and prev["pieces"] >= 8: + # Keep richer portal plan (e.g. Agua Miia Oct operative JSON). + print( + f" KEEP existing ({prev['pieces']} pieces ≥ Drive {len(pieces)})" + ) + continue + + plan = build_plan(brand, ym, pieces, dest.name) + doc_id = upsert_plan(env, tenant_id, plan) + print(f" UPSERT {ym} → {doc_id[:8] if doc_id else '?'} ({len(pieces)} pieces)") + existing[ym] = {"id": doc_id, "title": plan["label"], "pieces": len(pieces), "content": plan} + + +def main() -> None: + env = load_env() + OUT.mkdir(parents=True, exist_ok=True) + token = drive_token(env) + for slug, meta in DRIVE_SHEETS.items(): + import_client(env, token, slug, meta) + print("\nDone.") + + +if __name__ == "__main__": + main()