feat: script de carga para las 33 creatividades de Blue Parrot (oct26-ene27)

scripts/seed_blue_parrot_pipeline.ts sube portada + slides de carrusel al
bucket tenant-assets y crea/actualiza las filas en creative_pipeline
(upsert por tenant_id+external_ref, así se puede re-ejecutar sin duplicar).

Metadata en scripts/blue-parrot-pipeline-seed.json: títulos reales
extraídos de las 33 piezas ya diseñadas (oct-01..oct-12, nov-01..nov-07,
dic-01..dic-07, ene27-01..ene27-07). Estado inicial por defecto:
revision_interna (parametrizable con --status).

Requiere ejecutarse en el propio VPS (con las credenciales reales de
Supabase ya configuradas ahí) contra la carpeta descomprimida del ZIP de
imágenes — no incluye las imágenes ni credenciales en el repo.

Añade dotenv y tsx como devDependencies (ya usados por otros scripts del
repo pero no declarados en package.json).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WqGTGPznxTp8mwMg6Yv4Ew
This commit is contained in:
Claude
2026-10-04 00:25:25 +02:00
parent ed18c9d318
commit 6566b42037
3 changed files with 204 additions and 0 deletions

View File

@@ -24,8 +24,10 @@
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
"autoprefixer": "^10.4.20",
"dotenv": "^18.0.5",
"postcss": "^8.5.2",
"tailwindcss": "^3.4.17",
"tsx": "^4.23.15",
"typescript": "^5.7.3"
}
}

View File

@@ -0,0 +1,38 @@
[
{ "id": "oct-01", "format": "carrusel", "month": "2026-10", "category": "aprende inglés", "title": "3 false friends que te delatan" },
{ "id": "oct-02", "format": "story", "month": "2026-10", "category": "aprende inglés", "title": "Palabra del día: deadline" },
{ "id": "oct-03", "format": "reel", "month": "2026-10", "category": "speaking", "title": "Preséntate en inglés en 30 segundos" },
{ "id": "oct-04", "format": "post", "month": "2026-10", "category": "cursos y certificaciones", "title": "Cursos intensivos: certifica tu nivel" },
{ "id": "oct-05", "format": "story", "month": "2026-10", "category": "test de nivel", "title": "¿No sabes por dónde empezar?" },
{ "id": "oct-06", "format": "carrusel", "month": "2026-10", "category": "cursos y certificaciones", "title": "Aptis, Cambridge o IELTS: ¿cuál te conviene?" },
{ "id": "oct-07", "format": "reel", "month": "2026-10", "category": "aprende inglés", "title": "Make o do: el truco para no fallar" },
{ "id": "oct-08", "format": "story", "month": "2026-10", "category": "comunidad", "title": "¿Qué idioma quieres aprender?" },
{ "id": "oct-09", "format": "carrusel", "month": "2026-10", "category": "inglés para el trabajo", "title": "3 frases para sonar más natural en una reunión" },
{ "id": "oct-10", "format": "reel", "month": "2026-10", "category": "comunidad", "title": "Un día en la academia" },
{ "id": "oct-11", "format": "post", "month": "2026-10", "category": "cursos y certificaciones", "title": "Grupos de máximo 8 alumnos" },
{ "id": "oct-12", "format": "carrusel", "month": "2026-10", "category": "aprende inglés", "title": "Phrasal verbs con «get» que usarás cada día" },
{ "id": "nov-01", "format": "carrusel", "month": "2026-11", "category": "inglés con series", "title": "Friendsgiving: el inglés de Acción de Gracias con Friends" },
{ "id": "nov-02", "format": "reel", "month": "2026-11", "category": "inglés para el día a día", "title": "Black Friday: 5 palabras que verás en todas las tiendas" },
{ "id": "nov-03", "format": "story", "month": "2026-11", "category": "comunidad", "title": "¿Deal o bargain? Tú decides" },
{ "id": "nov-04", "format": "carrusel", "month": "2026-11", "category": "inglés con series", "title": "El inglés de Stranger Things" },
{ "id": "nov-05", "format": "post", "month": "2026-11", "category": "cursos y certificaciones", "title": "Certifica tu nivel antes de que acabe el año" },
{ "id": "nov-06", "format": "reel", "month": "2026-11", "category": "speaking", "title": "Thanks, cheers, much appreciated: cómo dar las gracias en inglés" },
{ "id": "nov-07", "format": "story", "month": "2026-11", "category": "aprende inglés", "title": "Palabra del día: leftovers" },
{ "id": "dic-01", "format": "carrusel", "month": "2026-12", "category": "inglés con películas", "title": "El inglés de Solo en casa" },
{ "id": "dic-02", "format": "story", "month": "2026-12", "category": "aprende inglés", "title": "Palabra del día: cosy" },
{ "id": "dic-03", "format": "reel", "month": "2026-12", "category": "speaking", "title": "Felicita la Navidad en inglés sin decir solo Merry Christmas" },
{ "id": "dic-04", "format": "carrusel", "month": "2026-12", "category": "inglés con películas", "title": "Love Actually: frases para practicar tu inglés" },
{ "id": "dic-05", "format": "post", "month": "2026-12", "category": "cursos y certificaciones", "title": "Estas Navidades, regala un idioma" },
{ "id": "dic-06", "format": "reel", "month": "2026-12", "category": "aprende inglés", "title": "Christmas vocabulary: mistletoe, stocking, Boxing Day" },
{ "id": "dic-07", "format": "story", "month": "2026-12", "category": "comunidad", "title": "¿Tu propósito de 2027 es aprender un idioma?" },
{ "id": "ene27-01", "format": "carrusel", "month": "2027-01", "category": "aprende inglés", "title": "New Year's resolutions: di tus propósitos en inglés" },
{ "id": "ene27-02", "format": "story", "month": "2027-01", "category": "test de nivel", "title": "Empieza el año sabiendo tu nivel" },
{ "id": "ene27-03", "format": "reel", "month": "2027-01", "category": "aprende inglés", "title": "Take up, give up, keep up: 3 phrasal verbs para tus propósitos" },
{ "id": "ene27-04", "format": "post", "month": "2027-01", "category": "cursos y certificaciones", "title": "Nuevo año, nuevo idioma" },
{ "id": "ene27-05", "format": "carrusel", "month": "2027-01", "category": "aprende inglés", "title": "Blue Monday: vocabulario para los días grises" },
{ "id": "ene27-06", "format": "reel", "month": "2027-01", "category": "aprende inglés", "title": "Winter vocabulary: freezing, chilly, to bundle up" },
{ "id": "ene27-07", "format": "story", "month": "2027-01", "category": "aprende inglés", "title": "Palabra del día: chilly" }
]

View File

@@ -0,0 +1,164 @@
/**
* Carga las 33 piezas de Blue Parrot (oct 2026 - ene 2027) en creative_pipeline.
*
* Requiere:
* 1. Haber aplicado sql/creative_pipeline.sql en Supabase.
* 2. Haber descomprimido Blue-Parrot-creatividades-con-imagen.zip en una carpeta.
* 3. .env.local con NEXT_PUBLIC_SUPABASE_URL y SUPABASE_SERVICE_ROLE_KEY reales
* (los mismos que usa la app en producción — ejecutar esto en el propio VPS,
* por ejemplo desde "Open Terminal" en Dokploy, nunca pegando la service
* role key en un chat).
*
* Uso:
* npx tsx scripts/seed_blue_parrot_pipeline.ts --dir /ruta/a/Blue-Parrot-creatividades-con-imagen \
* [--status revision_interna] [--dry-run]
*/
import { config } from 'dotenv';
import { resolve, join } from 'path';
import { existsSync, readFileSync } from 'fs';
import { createClient } from '@supabase/supabase-js';
config({ path: resolve(process.cwd(), '.env.local') });
// Mismo tenant_id que scripts/provision_victor_bps.ts
const BLUE_PARROT_TENANT_ID = '8bec977a-6032-4759-aa7f-b5042081f450';
const BUCKET = 'tenant-assets';
type SeedItem = {
id: string;
format: 'post' | 'story' | 'reel' | 'carrusel';
month: string;
category: string;
title: string;
};
function arg(name: string, fallback?: string): string | undefined {
const i = process.argv.indexOf(`--${name}`);
if (i === -1) return fallback;
return process.argv[i + 1];
}
const DRY_RUN = process.argv.includes('--dry-run');
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 en .env.local');
const dir = arg('dir');
if (!dir || !existsSync(dir)) {
throw new Error('Pasa --dir /ruta/a/la/carpeta/descomprimida (el ZIP de las 33 piezas)');
}
const status = arg('status', 'revision_interna')!;
const metaPath = resolve(__dirname, 'blue-parrot-pipeline-seed.json');
const items: SeedItem[] = JSON.parse(readFileSync(metaPath, 'utf-8'));
const admin = createClient(url, key, { auth: { persistSession: false, autoRefreshToken: false } });
// Asegura el bucket (no falla si ya existe).
const { data: buckets } = await admin.storage.listBuckets();
if (!buckets?.some((b) => b.id === BUCKET)) {
await admin.storage.createBucket(BUCKET, { public: false });
}
let ok = 0;
let failed = 0;
for (const it of items) {
const isCarrusel = it.format === 'carrusel';
const coverLocal = isCarrusel
? join(dir, `${it.id}-carrusel`, `${it.id}-1.jpg`)
: join(dir, `${it.id}-${it.format}.jpg`);
if (!existsSync(coverLocal)) {
console.error(`[SKIP] ${it.id}: no existe ${coverLocal}`);
failed++;
continue;
}
console.log(`[${it.id}] ${it.title}`);
if (DRY_RUN) {
ok++;
continue;
}
// 1) upsert de la fila (sin imágenes todavía) para obtener el id.
const { data: row, error: upErr } = await admin
.from('creative_pipeline')
.upsert(
{
tenant_id: BLUE_PARROT_TENANT_ID,
external_ref: it.id,
title: it.title,
format: it.format,
month: it.month,
status,
notes: it.category,
},
{ onConflict: 'tenant_id,external_ref' }
)
.select('id')
.single();
if (upErr || !row) {
console.error(`[FAIL] ${it.id}: ${upErr?.message}`);
failed++;
continue;
}
const itemId = row.id as string;
// 2) sube portada
const coverPath = `${BLUE_PARROT_TENANT_ID}/pipeline/${itemId}/cover.jpg`;
const coverBuf = readFileSync(coverLocal);
const coverUp = await admin.storage.from(BUCKET).upload(coverPath, coverBuf, {
contentType: 'image/jpeg',
upsert: true,
});
if (coverUp.error) {
console.error(`[FAIL cover] ${it.id}: ${coverUp.error.message}`);
failed++;
continue;
}
// 3) sube slides (carrusel: 2,3,4,5 → 4 slides interiores + CTA)
const slidePaths: string[] = [];
if (isCarrusel) {
for (let n = 2; n <= 5; n++) {
const local = join(dir, `${it.id}-carrusel`, `${it.id}-${n}.jpg`);
if (!existsSync(local)) continue;
const path = `${BLUE_PARROT_TENANT_ID}/pipeline/${itemId}/slide-${n - 1}.jpg`;
const buf = readFileSync(local);
const up = await admin.storage.from(BUCKET).upload(path, buf, {
contentType: 'image/jpeg',
upsert: true,
});
if (up.error) {
console.error(`[FAIL slide ${n}] ${it.id}: ${up.error.message}`);
continue;
}
slidePaths.push(path);
}
}
// 4) cierra la fila con las rutas de storage
const { error: patchErr } = await admin
.from('creative_pipeline')
.update({ cover_path: coverPath, cover_mime: 'image/jpeg', slide_paths: slidePaths })
.eq('id', itemId);
if (patchErr) {
console.error(`[FAIL patch] ${it.id}: ${patchErr.message}`);
failed++;
continue;
}
ok++;
}
console.log(`\nHecho: ${ok} ok, ${failed} con error, de ${items.length} piezas.`);
if (failed > 0) process.exitCode = 1;
}
main().catch((e) => {
console.error(e);
process.exitCode = 1;
});