From 67969b12cb1aca3b571af3ca0571fb0f154d5ad3 Mon Sep 17 00:00:00 2001 From: 4Ventures <4ventures@MacBook-Air-de-4Ventures.local> Date: Thu, 8 Oct 2026 16:43:07 +0200 Subject: [PATCH] Add batch importer for Drive RRSS editorial calendars. Import requested months for BPS, Brava, TYD, Halal, Noberplast and 4Ventures from the shared Excel export. Co-authored-by: Cursor --- scripts/import_drive_calendars_batch.ts | 580 ++++++++++++++++++++++++ 1 file changed, 580 insertions(+) create mode 100644 scripts/import_drive_calendars_batch.ts diff --git a/scripts/import_drive_calendars_batch.ts b/scripts/import_drive_calendars_batch.ts new file mode 100644 index 0000000..f8b0a37 --- /dev/null +++ b/scripts/import_drive_calendars_batch.ts @@ -0,0 +1,580 @@ +/** + * Import editorial RRSS calendars from Drive export folder into documents. + * + * npx tsx scripts/import_drive_calendars_batch.ts + * + * Source: /Users/4ventures/Downloads/drive-download-20261008T140949Z-1-001 + * Months per client as requested by ops (Oct 2026). + */ +import { config } from 'dotenv'; +import { resolve } from 'path'; +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 ROOT = + '/Users/4ventures/Downloads/drive-download-20261008T140949Z-1-001'; + +type SheetRows = (string | number | null)[][]; + +type ClientJob = { + slug: string; + brand: string; + file: string; + /** sheet name → year-month */ + sheets: Record; + /** if set, only upsert these months (others parsed ignored) */ + onlyMonths: string[]; +}; + +const JOBS: ClientJob[] = [ + { + slug: 'bps', + brand: 'Blue Parrot School', + file: `${ROOT}/BLUE PARROT/CALENDARIO Blue Parrot School.xlsx`, + sheets: { + 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', + }, + onlyMonths: [ + '2025-05', + '2025-06', + '2025-07', + '2025-08', + '2025-09', + '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', + ], + }, + { + slug: 'brava-dermoestetica', + brand: 'Brava Dermoestética', + file: `${ROOT}/BRAVA/CALENDARIO RRSS BRAVA.xlsx`, + sheets: { + 'ABRIL 2026': '2026-04', + 'MAYO 2026': '2026-05', + 'JUNIO 2026': '2026-06', + 'JULIO 2026': '2026-07', + 'AGOSTO 2026': '2026-08', + 'SEPTIEMBRE 2026': '2026-09', + 'Octubre 2026': '2026-10', + }, + onlyMonths: [ + '2026-04', + '2026-05', + '2026-06', + '2026-07', + '2026-08', + '2026-09', + '2026-10', + ], + }, + { + slug: 'talento-y-deporte', + brand: 'Talento y Deporte', + file: `${ROOT}/TYD/CALENDARIO TYD.xlsx`, + sheets: { + AGOSTO: '2026-08', + SEPTIEMBRE: '2026-09', + OCTUBRE26: '2026-10', + }, + onlyMonths: ['2026-08', '2026-09', '2026-10'], + }, + { + slug: 'halal-safety', + brand: 'Halal Safety', + file: `${ROOT}/HALAL/CALENDARIO Halal.xlsx`, + sheets: { 'Hoja 1': '2026-10' }, + onlyMonths: ['2026-10'], + }, + { + slug: 'noberplast', + brand: 'Noberplast', + file: `${ROOT}/NOBERPLAST/CALENDARIO NOBERPLAST.xlsx`, + sheets: { 'Hoja 1': '2026-10' }, + onlyMonths: ['2026-10'], + }, + { + slug: '4ventures-hub', + brand: '4Ventures', + file: `${ROOT}/4VENTURES/CALENDARIO 4VENTURES.xlsx`, + sheets: { + JULIO: '2026-07', + AGOSTO: '2026-08', + 'SEPTIEMBRE 2026': '2026-09', + 'OCTUBRE 2026': '2026-10', + }, + onlyMonths: ['2026-07', '2026-08', '2026-09', '2026-10'], + }, +]; + +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; +} + +function detectKind( + c0: string +): 'STORIES' | 'POST' | 'LINKEDIN' | null { + const u = c0.toUpperCase(); + if (u.startsWith('STORIES') || u === 'STORY' || u.startsWith('STORY ')) + return 'STORIES'; + if (u.startsWith('LINKEDIN')) return 'LINKEDIN'; + if (u.startsWith('POST') || u.startsWith('REEL') || u.startsWith('CARRUSEL')) + return 'POST'; + // Brava uses "Story" row label + if (u === 'STORY' || u.startsWith('HISTORIA')) return 'STORIES'; + return null; +} + +function pieceMeta(kind: 'STORIES' | 'POST' | 'LINKEDIN') { + 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', + }; +} + +/** Parse SEMANA × STORIES/POST/LINKEDIN (and Brava Story/POST) grids. */ +function parseWeekGrid( + prefix: string, + rows: SheetRows, + year: number, + month: number +): RrssPiece[] { + const pieces: RrssPiece[] = []; + let dayCols: { col: number; day: number }[] = []; + let kind: 'STORIES' | 'POST' | 'LINKEDIN' | null = null; + 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(); + const c0u = c0.toUpperCase(); + + if ( + c0u.startsWith('SEMANA') || + /^(LUNES|MARTES|MI[EÉ]RCOLES)/i.test(String(row[1] ?? '')) + ) { + dayCols = []; + for (let col = 1; col < Math.min(row.length, 12); 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; + } + + // Skip status-only Brava rows + if ( + /^(PUBLICADO|CLIENTE|APROBADO)$/i.test(c0u) || + c0u.startsWith('GUIONES') || + c0u.startsWith('CARPETA') + ) { + continue; + } + + const detected = detectKind(c0u); + if (detected) kind = detected; + else if (!kind) continue; + + if (!dayCols.length) continue; + const meta = pieceMeta(kind); + + 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) || + /^(PUBLICADO|APROBADO)$/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 = `${prefix}-${date}-${meta.suffix}-${slugify(text).slice(0, 24)}`; + pieces.push({ + id, + date, + platform: meta.platform, + format: meta.format, + pillar: meta.pillar, + title: text.split('\n')[0]!.slice(0, 120), + body: text, + status: 'planned', + kind: meta.kind, + color: meta.color, + }); + } + } + + const seen = new Set(); + return pieces.filter((p) => { + if (seen.has(p.id)) return false; + seen.add(p.id); + return true; + }); +} + +/** Structured BPS "Calendario" tab (fecha | formato | contenido). */ +function parseStructuredCalendario( + prefix: string, + rows: SheetRows +): Map { + const byMonth = new Map(); + for (const r of rows.slice(1)) { + if (!r) continue; + const dia = r[2]; + const formato = String(r[3] ?? ''); + const contenido = String(r[4] ?? '').trim(); + const estado = String(r[6] ?? ''); + if (!contenido && !formato) continue; + let dateStr = ''; + if (typeof dia === 'string' && /^\d{4}-\d{2}-\d{2}/.test(dia)) { + dateStr = dia.slice(0, 10); + } else continue; + const monthKey = dateStr.slice(0, 7); + const isStory = /storie/i.test(formato); + const isBlog = /blog/i.test(formato); + const piece: RrssPiece = { + id: `${prefix}-${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' : 'planned', + kind: isStory ? 'story' : 'post', + color: isStory ? '#D9821F' : '#2071cf', + }; + const list = byMonth.get(monthKey) || []; + list.push(piece); + byMonth.set(monthKey, list); + } + return byMonth; +} + +function loadSheets( + file: string, + wanted: Set +): { name: string; rows: SheetRows }[] { + const py = ` +import openpyxl, json, sys +wb=openpyxl.load_workbook(${JSON.stringify(file)}, data_only=True) +wanted=set(${JSON.stringify([...wanted])}) +out=[] +for name in wb.sheetnames: + if wanted and name not in wanted and name != 'Calendario': + continue + ws=wb[name] + rows=[] + blank=0 + for i,row in enumerate(ws.iter_rows(min_row=1, max_row=min(ws.max_row or 1, 220), 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}) +print(json.dumps(out, ensure_ascii=False, default=str)) +`; + const raw = execFileSync('python3', ['-c', py], { maxBuffer: 30_000_000 }); + return JSON.parse(raw.toString('utf8')); +} + +function monthLabel(ym: string) { + const [, m] = ym.split('-').map(Number); + const names = [ + '', + 'Enero', + 'Febrero', + 'Marzo', + 'Abril', + 'Mayo', + 'Junio', + 'Julio', + 'Agosto', + 'Septiembre', + 'Octubre', + 'Noviembre', + 'Diciembre', + ]; + return `${names[m!]} ${ym.slice(0, 4)}`; +} + +function buildPlan( + brand: string, + ym: string, + pieces: RrssPiece[], + sourceFile: string +): RrssMonthPlan { + const posts = pieces.filter((p) => p.kind !== 'story').length; + const stories = pieces.filter((p) => p.kind === 'story').length; + const fileName = sourceFile.split('/').pop() || sourceFile; + return { + kind: 'rrss_month', + month: ym, + label: monthLabel(ym), + hero: { + eyebrow: `4VENTURES × ${brand.toUpperCase()} · PLAN RRSS`, + title: `Calendario editorial · ${monthLabel(ym)}`, + lede: `Importado desde Drive (${fileName}).`, + chips: [ + `${posts} posts`, + `${stories} stories`, + 'Fuente: Google Drive', + ], + }, + pieces: pieces.sort((a, b) => a.date.localeCompare(b.date)), + }; +} + +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 resolveTenantId(slug: string): Promise { + const headers = serviceHeaders(); + const base = supabaseUrl(); + if (!headers || !base) throw new Error('no service role'); + const r = await fetch( + `${base}/rest/v1/tenants?slug=eq.${encodeURIComponent(slug)}&select=id&limit=1`, + { headers } + ); + if (!r.ok) throw new Error(await r.text()); + const rows = (await r.json()) as { id: string }[]; + if (!rows[0]?.id) throw new Error(`tenant not found: ${slug}`); + return rows[0].id; +} + +async function upsertPlan( + tenantId: string, + plan: RrssMonthPlan +): Promise { + const headers = serviceHeaders(); + const base = supabaseUrl(); + if (!headers || !base) throw new Error('no service role'); + const title = `RRSS ${plan.label}`; + + const existing = await fetch( + `${base}/rest/v1/documents?tenant_id=eq.${tenantId}&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.toLowerCase().includes(plan.label.toLowerCase()); + if (month === plan.month || sameTitle) { + await fetch(`${base}/rest/v1/documents?id=eq.${d.id}`, { + method: 'DELETE', + headers: { ...headers, Prefer: 'return=minimal' }, + }); + } + } + + const ins = await fetch(`${base}/rest/v1/documents`, { + method: 'POST', + headers: { + ...headers, + 'Content-Type': 'application/json', + Prefer: 'return=representation', + }, + body: JSON.stringify({ + tenant_id: tenantId, + type: 'calendar', + title, + content: plan, + status: 'approved', + }), + }); + 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?.slice(0, 8) + ); +} + +async function runJob(job: ClientJob) { + console.log(`\n=== ${job.slug} (${job.brand}) ===`); + const tenantId = await resolveTenantId(job.slug); + const wanted = new Set(Object.keys(job.sheets)); + if (job.slug === 'bps') wanted.add('Calendario'); + const sheets = loadSheets(job.file, wanted); + const byMonth = new Map(); + const prefix = job.slug.replace(/[^a-z0-9]+/g, '').slice(0, 12) || 'cal'; + + for (const sh of sheets) { + const ym = job.sheets[sh.name]; + if (!ym) continue; + if (!job.onlyMonths.includes(ym)) continue; + const [year, month] = ym.split('-').map(Number); + const pieces = parseWeekGrid( + prefix, + sh.rows as SheetRows, + year!, + month! + ); + byMonth.set(ym, mergePieces(byMonth.get(ym) || [], pieces)); + console.log(` parsed ${sh.name} → ${ym} (${pieces.length})`); + } + + // BPS structured tab fills may–jun 2025 etc. if missing density + if (job.slug === 'bps') { + const cal = sheets.find((s) => s.name === 'Calendario'); + if (cal) { + const structured = parseStructuredCalendario( + prefix, + cal.rows as SheetRows + ); + for (const [ym, pieces] of structured) { + if (!job.onlyMonths.includes(ym)) continue; + const cur = byMonth.get(ym) || []; + // Prefer structured when grid empty/sparse (<5) + if (cur.length < 5) { + byMonth.set(ym, mergePieces(pieces, cur)); + console.log( + ` calendario fill → ${ym} grid=${cur.length} +struct=${pieces.length}` + ); + } else { + const blogs = pieces.filter((p) => p.platform === 'Blog'); + if (blogs.length) { + byMonth.set(ym, mergePieces(cur, blogs)); + } + } + } + } + } + + for (const ym of job.onlyMonths) { + const pieces = byMonth.get(ym) || []; + if (!pieces.length) { + console.log(` SKIP ${ym} (0 pieces in source)`); + continue; + } + await upsertPlan(tenantId, buildPlan(job.brand, ym, pieces, job.file)); + } +} + +async function main() { + for (const job of JOBS) { + await runJob(job); + } + console.log('\nDone.'); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +});