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