Files
4ventures-client-portal/scripts/import_drive_calendars_batch.ts
4Ventures feef339c2b 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 <cursoragent@cursor.com>
2026-10-14 13:31:12 +02:00

628 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<string, string>;
/** 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: {
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-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',
],
},
];
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<string>();
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<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: `${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<string>
): { 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<string> {
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<void> {
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<string, RrssPiece[]>();
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);
});