Import requested months for BPS, Brava, TYD, Halal, Noberplast and 4Ventures from the shared Excel export. Co-authored-by: Cursor <cursoragent@cursor.com>
581 lines
15 KiB
TypeScript
581 lines
15 KiB
TypeScript
/**
|
||
* 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);
|
||
});
|