feat(panel-cliente): soporte de filtrado mensual, histórico instagram ene-sep 2026 y enlaces a publicaciones
This commit is contained in:
292
scripts/sync_bps_instagram_history.ts
Normal file
292
scripts/sync_bps_instagram_history.ts
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user