feat(panel-cliente): soporte de filtrado mensual, histórico instagram ene-sep 2026 y enlaces a publicaciones

This commit is contained in:
Marcos Fargas
2026-10-04 19:31:11 +02:00
parent a3a2e4b62b
commit 2f6e50d5a7
4 changed files with 799 additions and 36 deletions

View File

@@ -0,0 +1,292 @@
/**
* Extrae todas las publicaciones de Instagram de Blue Parrot School
* (Enero 2026 a Septiembre 2026) vía Meta Graph API:
* 1. Descarga portadas y slides de carruseles.
* 2. Sube los archivos a Supabase Storage (tenant-assets).
* 3. Inserta/actualiza las piezas en public.creative_pipeline (status: 'publicado').
* 4. Actualiza/sincroniza en public.documents (type: 'calendar') para reflejarlas en el Calendario.
*/
import { config } from 'dotenv';
import { resolve } from 'path';
import { createClient } from '@supabase/supabase-js';
config({ path: resolve(process.cwd(), '.env.local') });
const BPS_TENANT_ID = '8bec977a-6032-4759-aa7f-b5042081f450';
const BPS_IG_ID = '17841431459801731';
const BUCKET = 'tenant-assets';
const META_TOKEN = process.env.META_ACCESS_TOKEN || '';
type IgMedia = {
id: string;
caption?: string;
media_type: 'IMAGE' | 'VIDEO' | 'CAROUSEL_ALBUM';
media_url?: string;
thumbnail_url?: string;
permalink?: string;
timestamp: string;
children?: {
data: Array<{
id: string;
media_type: string;
media_url: string;
}>;
};
};
function cleanTitle(caption?: string, date?: string): string {
if (!caption) return `Publicación Instagram ${date || ''}`.trim();
const firstLine = caption.split('\n')[0].trim();
// Quitar hashtags al inicio si los hay
const clean = firstLine.replace(/#\S+/g, '').trim();
if (clean.length > 5) {
return clean.slice(0, 100);
}
return caption.slice(0, 100).trim() || `Publicación ${date || ''}`;
}
async function fetchBuffer(url: string): Promise<Buffer | null> {
try {
const res = await fetch(url);
if (!res.ok) return null;
const arrayBuffer = await res.arrayBuffer();
return Buffer.from(arrayBuffer);
} catch (err) {
console.error(`Error descargando ${url}:`, err);
return null;
}
}
async function main() {
const url = process.env.NEXT_PUBLIC_SUPABASE_URL?.trim();
const key = process.env.SUPABASE_SERVICE_ROLE_KEY?.trim();
if (!url || !key) throw new Error('Faltan NEXT_PUBLIC_SUPABASE_URL / SUPABASE_SERVICE_ROLE_KEY');
const admin = createClient(url, key, { auth: { persistSession: false, autoRefreshToken: false } });
console.log('1. Consultando Meta Graph API para Blue Parrot School (@blueparrotschool)...');
const allMedia: IgMedia[] = [];
let nextUrl: string | null =
`https://graph.facebook.com/v21.0/${BPS_IG_ID}/media?fields=id,caption,media_type,media_url,thumbnail_url,permalink,timestamp,children{id,media_type,media_url}&limit=50&access_token=${META_TOKEN}`;
while (nextUrl) {
const res = await fetch(nextUrl);
if (!res.ok) {
const err = await res.text();
throw new Error(`Graph API error: ${err}`);
}
const data = await res.json();
const items: IgMedia[] = data.data || [];
if (!items.length) break;
let reachedPast = false;
for (const it of items) {
const date = it.timestamp.slice(0, 10);
if (date >= '2026-01-01' && date <= '2026-09-30') {
allMedia.push(it);
} else if (date < '2026-01-01') {
reachedPast = true;
break;
}
}
if (reachedPast) break;
nextUrl = data.paging?.next || null;
}
console.log(`Encontradas ${allMedia.length} publicaciones en Instagram entre 2026-01 y 2026-09.`);
// Ordenar cronológicamente (antiguo a nuevo)
allMedia.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
// 2. Subir a Supabase Storage y a public.creative_pipeline
console.log('2. Subiendo imágenes a Storage y registrando en public.creative_pipeline...');
let successCount = 0;
for (let i = 0; i < allMedia.length; i++) {
const it = allMedia[i];
const date = it.timestamp.slice(0, 10);
const month = date.slice(0, 7);
const isCarousel = it.media_type === 'CAROUSEL_ALBUM';
const isReel = it.media_type === 'VIDEO';
const format = isCarousel ? 'carrusel' : isReel ? 'reel' : 'post';
const title = cleanTitle(it.caption, date);
// Obtener URL de portada
const coverUrl = isReel
? (it.thumbnail_url || it.media_url)
: isCarousel
? (it.children?.data?.[0]?.media_url || it.media_url)
: it.media_url;
const extRef = `ig-${it.id}`;
// 1) Upsert fila base en creative_pipeline
const { data: row, error: upErr } = await admin
.from('creative_pipeline')
.upsert(
{
tenant_id: BPS_TENANT_ID,
external_ref: extRef,
title,
format,
month,
status: 'publicado',
caption: it.caption || null,
notes: it.permalink ? `Instagram: ${it.permalink}` : null,
created_at: it.timestamp,
},
{ onConflict: 'tenant_id,external_ref' }
)
.select('id')
.single();
if (upErr || !row) {
console.error(`[ERROR upsert] ${extRef}: ${upErr?.message}`);
continue;
}
const itemId = row.id as string;
// 2) Descargar y subir portada
let coverPath: string | null = null;
if (coverUrl) {
const coverBuf = await fetchBuffer(coverUrl);
if (coverBuf) {
coverPath = `${BPS_TENANT_ID}/pipeline/${itemId}/cover.jpg`;
const upRes = await admin.storage.from(BUCKET).upload(coverPath, coverBuf, {
contentType: 'image/jpeg',
upsert: true,
});
if (upRes.error) {
console.error(`[WARN cover upload] ${extRef}: ${upRes.error.message}`);
coverPath = null;
}
}
}
// 3) Si es carrusel, descargar y subir slides
const slidePaths: string[] = [];
if (isCarousel && it.children?.data) {
const slides = it.children.data.slice(1); // slides posteriores a la portada
for (let sIdx = 0; sIdx < slides.length; sIdx++) {
const slide = slides[sIdx];
if (!slide.media_url) continue;
const slideBuf = await fetchBuffer(slide.media_url);
if (slideBuf) {
const sPath = `${BPS_TENANT_ID}/pipeline/${itemId}/slide-${sIdx + 1}.jpg`;
const sUp = await admin.storage.from(BUCKET).upload(sPath, slideBuf, {
contentType: 'image/jpeg',
upsert: true,
});
if (!sUp.error) {
slidePaths.push(sPath);
}
}
}
}
// 4) Actualizar registro con las rutas de storage
await admin
.from('creative_pipeline')
.update({
cover_path: coverPath,
cover_mime: coverPath ? 'image/jpeg' : null,
slide_paths: slidePaths,
})
.eq('id', itemId);
successCount++;
console.log(`[${i + 1}/${allMedia.length}] ${month} [${format}] ${title} (${slidePaths.length} slides)`);
}
console.log(`\nCreatividades cargadas en creative_pipeline: ${successCount} de ${allMedia.length}.`);
// 3. Sincronizar en public.documents (type = 'calendar')
console.log('\n3. Sincronizando con public.documents para el Calendario...');
// Agrupar las publicaciones de Instagram por mes
const igByMonth = new Map<string, IgMedia[]>();
for (const m of allMedia) {
const ym = m.timestamp.slice(0, 7);
const list = igByMonth.get(ym) || [];
list.push(m);
igByMonth.set(ym, list);
}
const { data: allDocs } = await admin
.from('documents')
.select('id, title, content')
.eq('tenant_id', BPS_TENANT_ID)
.eq('type', 'calendar');
const docsList = allDocs || [];
for (const [ym, igList] of igByMonth.entries()) {
// Buscar documento del mes
const doc = docsList.find((d) => {
try {
const c = typeof d.content === 'string' ? JSON.parse(d.content) : d.content;
return c?.month === ym;
} catch {
return false;
}
});
if (doc) {
let content = typeof doc.content === 'string' ? JSON.parse(doc.content) : doc.content;
const pieces: any[] = content?.pieces || [];
// Mapear posts de IG en piezas de calendario si no están
for (const ig of igList) {
const date = ig.timestamp.slice(0, 10);
const title = cleanTitle(ig.caption, date);
const isReel = ig.media_type === 'VIDEO';
const isCarousel = ig.media_type === 'CAROUSEL_ALBUM';
const format = isCarousel ? 'Carrusel' : isReel ? 'Reel' : 'Post';
// Buscar si ya existe una pieza en esa fecha
const match = pieces.find(
(p) => p.date === date && (p.title?.toLowerCase().includes(title.slice(0, 15).toLowerCase()) || p.platform === 'Instagram')
);
if (match) {
match.status = 'published';
match.meta_line = `Instagram: ${ig.permalink || ''}`;
if (!match.body && ig.caption) match.body = ig.caption;
} else {
// Agregar nueva pieza publicada
pieces.push({
id: `bps-${date}-ig-${ig.id}`,
date,
platform: 'Instagram',
format,
pillar: isReel ? 'reels' : isCarousel ? 'carrusel' : 'feed',
title,
body: ig.caption || undefined,
status: 'published',
kind: isReel ? 'reel' : 'post',
color: isReel ? '#7C66B8' : isCarousel ? '#2071cf' : '#0F9D8F',
meta_line: `Instagram: ${ig.permalink || ''}`,
});
}
}
// Ordenar piezas por fecha
pieces.sort((a, b) => a.date.localeCompare(b.date));
content.pieces = pieces;
await admin
.from('documents')
.update({ content })
.eq('id', doc.id);
console.log(`Mes ${ym} actualizado en Calendario: ${pieces.length} piezas.`);
}
}
console.log('\nSincronización completada con éxito.');
}
main().catch((err) => {
console.error(err);
process.exit(1);
});