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:
580
scripts/import_drive_calendars_batch.ts
Normal file
580
scripts/import_drive_calendars_batch.ts
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user