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>
458 lines
14 KiB
TypeScript
458 lines
14 KiB
TypeScript
/**
|
||
* 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);
|
||
});
|