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 <cursoragent@cursor.com>
This commit is contained in:
4Ventures
2026-10-08 16:43:07 +02:00
parent 71110915af
commit 67969b12cb

View File

@@ -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<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: {
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<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);
});