Files
4ventures-client-portal/scripts/import_bps_calendars.ts
4Ventures 1a58af129d chore: scripts Dokploy/BPS (cron secret, Ads MCC, sync y probes)
Incluye utilidades de ops para CRON_SECRET, login customer Google Ads, sync de seguimiento BPS y probes de conexiones.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-29 16:48:44 +02:00

458 lines
14 KiB
TypeScript
Raw Permalink 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 BPS editorial calendars from Drive xlsx into documents (type=calendar).
* Includes hidden month tabs (JULIO…JUNIO, ENERO 2026, *26).
*
* Prerequisite: /tmp/bps-cal/bps-calendario.xlsx (+ optional bps-octubre.xlsx)
* npx tsx scripts/export_bps_xlsx.ts
* npx tsx scripts/import_bps_calendars.ts
*/
import { config } from 'dotenv';
import { resolve } from 'path';
import { writeFileSync } from 'fs';
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 BPS_ID = '8bec977a-6032-4759-aa7f-b5042081f450';
/**
* Explicit year/month for every editorial month sheet (incl. hidden).
* Workbook order: JUL–DIC 2025 → ENE–JUN 2026 → JUL–OCT 26.
*/
const SHEET_YM: Record<string, string> = {
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',
};
const MONTH_SHEET_NAMES = new Set(Object.keys(SHEET_YM));
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;
}
type SheetRows = (string | number | null)[][];
/** Parse SEMANA/STORIES/POST grid into pieces. */
function parseWeekGrid(
sheetName: string,
rows: SheetRows,
year: number,
month: number
): RrssPiece[] {
const pieces: RrssPiece[] = [];
let dayCols: { col: number; day: number }[] = [];
let kind: 'STORIES' | 'POST' | null = null;
/** Week wraps when header has e.g. LUNES 30 … MARTES 1 (spillover from prev month). */
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()
.toUpperCase();
if (
c0.startsWith('SEMANA') ||
/^(LUNES|MARTES|MI[EÉ]RCOLES)/i.test(String(row[1] ?? ''))
) {
dayCols = [];
for (let col = 1; col < Math.min(row.length, 10); 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;
}
if (c0.startsWith('STORIES') || c0 === 'STORY') {
kind = 'STORIES';
} else if (c0.startsWith('POST')) {
kind = 'POST';
} else if (!kind) {
continue;
}
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)) 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 = `bps-${date}-${kind === 'STORIES' ? 'st' : 'post'}-${slugify(text).slice(0, 24)}`;
pieces.push({
id,
date,
platform: 'Instagram',
format: kind === 'STORIES' ? 'Stories' : 'Post',
pillar: kind === 'STORIES' ? 'stories' : 'feed',
title: text.split('\n')[0].slice(0, 120),
body: text,
status: 'planned',
kind: kind === 'STORIES' ? 'story' : 'post',
color: kind === 'STORIES' ? '#D9821F' : '#2071cf',
});
}
}
const seen = new Set<string>();
return pieces.filter((p) => {
if (seen.has(p.id)) return false;
seen.add(p.id);
return true;
});
}
async function loadSheetsFromPython(): Promise<
{ name: string; rows: SheetRows; state: string }[]
> {
const py = `
import openpyxl, json
wb=openpyxl.load_workbook('/tmp/bps-cal/bps-calendario.xlsx', data_only=True)
out=[]
month_names={${[...MONTH_SHEET_NAMES].map((n) => JSON.stringify(n)).join(',')}}
for name in wb.sheetnames:
ws=wb[name]
keep = name in month_names or name == 'Calendario'
if not keep:
continue
rows=[]
# Read enough rows; openpyxl max_row is often inflated (1000) — stop after 80 blank streak
blank=0
for i,row in enumerate(ws.iter_rows(min_row=1, max_row=min(ws.max_row, 200), 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, 'state': ws.sheet_state})
try:
wb2=openpyxl.load_workbook('/tmp/bps-cal/bps-octubre.xlsx', data_only=True)
for name in wb2.sheetnames:
ws=wb2[name]
rows=[]
blank=0
for i,row in enumerate(ws.iter_rows(min_row=1, max_row=min(ws.max_row, 80), 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>10: break
continue
blank=0
rows.append(vals)
out.append({'name': name+' (prov)', 'rows': rows, 'state': 'visible'})
except Exception:
pass
print(json.dumps(out, ensure_ascii=False, default=str))
`;
const raw = execFileSync('python3', ['-c', py], { maxBuffer: 20_000_000 });
return JSON.parse(raw.toString('utf8'));
}
function parseStructuredCalendario(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: `bps-${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'
: /pendiente/i.test(estado)
? 'planned'
: 'planned',
kind: isStory ? 'story' : 'post',
color: isStory ? '#D9821F' : '#2071cf',
};
const list = byMonth.get(monthKey) || [];
list.push(piece);
byMonth.set(monthKey, list);
}
return byMonth;
}
function monthLabel(ym: string) {
const [y, m] = ym.split('-').map(Number);
const names = [
'',
'Enero',
'Febrero',
'Marzo',
'Abril',
'Mayo',
'Junio',
'Julio',
'Agosto',
'Septiembre',
'Octubre',
'Noviembre',
'Diciembre',
];
return `${names[m]} ${y}`;
}
function buildPlan(ym: string, pieces: RrssPiece[]): RrssMonthPlan {
const posts = pieces.filter((p) => p.kind !== 'story').length;
const stories = pieces.filter((p) => p.kind === 'story').length;
return {
kind: 'rrss_month',
month: ym,
label: monthLabel(ym),
hero: {
eyebrow: '4VENTURES × BLUE PARROT SCHOOL · PLAN RRSS',
title: `Calendario editorial · ${monthLabel(ym)}`,
lede: 'Importado desde Drive (CALENDARIO Blue Parrot School), incluidas pestañas ocultas.',
chips: [
`${posts} posts`,
`${stories} stories`,
'Fuente: Google Drive',
],
},
pieces: pieces.sort((a, b) => a.date.localeCompare(b.date)),
};
}
async function upsertPlan(plan: RrssMonthPlan) {
const headers = serviceHeaders();
if (!headers) throw new Error('no service role');
const base = supabaseUrl();
const title = `RRSS ${plan.label}`;
const existing = await fetch(
`${base}/rest/v1/documents?tenant_id=eq.${BPS_ID}&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 === `RRSS ${plan.label}`;
if (month === plan.month || sameTitle) {
await fetch(`${base}/rest/v1/documents?id=eq.${d.id}`, {
method: 'DELETE',
headers: { ...headers, Prefer: 'return=minimal' },
});
}
}
const row = {
tenant_id: BPS_ID,
type: 'calendar',
title,
content: plan,
status: 'approved',
};
const ins = await fetch(`${base}/rest/v1/documents`, {
method: 'POST',
headers: {
...headers,
'Content-Type': 'application/json',
Prefer: 'return=representation',
},
body: JSON.stringify(row),
});
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);
}
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 main() {
const sheets = await loadSheetsFromPython();
const byMonth = new Map<string, RrssPiece[]>();
const gridMonths = new Set<string>();
// 1) All month tabs (hidden + visible) — primary source
for (const sh of sheets) {
const baseName = sh.name.replace(' (prov)', '');
const ym = SHEET_YM[baseName];
if (!ym) continue;
const [year, month] = ym.split('-').map(Number);
const pieces = parseWeekGrid(sh.name, sh.rows as SheetRows, year, month);
const cur = byMonth.get(ym) || [];
if (sh.name.includes('(prov)') && pieces.length >= cur.length) {
byMonth.set(ym, pieces);
} else {
byMonth.set(ym, mergePieces(cur, pieces));
}
if (pieces.length) gridMonths.add(ym);
console.log(
`parsed ${sh.name} [${sh.state}] → ${ym} ${pieces.length} pieces`
);
}
// 2) Structured "Calendario" sheet: only fill months that have NO week-grid tab
// (May–Aug 2025 detail) or merge blog rows into existing months
for (const sh of sheets) {
if (sh.name !== 'Calendario') continue;
const structured = parseStructuredCalendario(sh.rows as SheetRows);
for (const [ym, pieces] of structured) {
if (gridMonths.has(ym)) {
// keep grid as source of truth; still add blog-only pieces
const blogs = pieces.filter((p) => p.platform === 'Blog');
if (blogs.length) {
byMonth.set(ym, mergePieces(byMonth.get(ym) || [], blogs));
console.log(`calendario blogs → ${ym} +${blogs.length}`);
}
continue;
}
byMonth.set(ym, mergePieces(byMonth.get(ym) || [], pieces));
console.log(`calendario only → ${ym} ${pieces.length}`);
}
}
// Drop wrongly-year'd leftovers from prior import (e.g. Feb–Apr as 2025)
const keepMonths = new Set(byMonth.keys());
writeFileSync(
'/tmp/bps-cal/plans-summary.json',
JSON.stringify(
[...byMonth.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([m, p]) => ({ month: m, n: p.length })),
null,
2
)
);
const headers = serviceHeaders();
const base = supabaseUrl();
if (headers && base) {
const existing = await fetch(
`${base}/rest/v1/documents?tenant_id=eq.${BPS_ID}&type=eq.calendar&select=id,title,content`,
{ headers }
);
const docs = (await existing.json()) as {
id: string;
content: unknown;
}[];
for (const d of docs) {
let c = d.content as { month?: string } | string | null;
if (typeof c === 'string') {
try {
c = JSON.parse(c) as { month?: string };
} catch {
c = null;
}
}
const m = c && typeof c === 'object' ? c.month : null;
if (!m) continue;
// Remove old 2025-02..04 if we now have 2026-02..04 from hidden sheets
if (
/^2025-0[2-4]$/.test(m) &&
keepMonths.has(m.replace('2025', '2026'))
) {
await fetch(`${base}/rest/v1/documents?id=eq.${d.id}`, {
method: 'DELETE',
headers: { ...headers, Prefer: 'return=minimal' },
});
console.log('deleted stale', m, d.id);
}
}
}
for (const [ym, pieces] of [...byMonth.entries()].sort()) {
if (!pieces.length) continue;
await upsertPlan(buildPlan(ym, pieces));
}
console.log(
'months',
[...byMonth.keys()].sort().join(', '),
'total pieces',
[...byMonth.values()].reduce((s, p) => s + p.length, 0)
);
}
main().catch((e) => {
console.error(e);
process.exit(1);
});