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:
@@ -24,8 +24,10 @@
|
|||||||
"@types/react": "^19.0.10",
|
"@types/react": "^19.0.10",
|
||||||
"@types/react-dom": "^19.0.4",
|
"@types/react-dom": "^19.0.4",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
|
"dotenv": "^18.0.5",
|
||||||
"postcss": "^8.5.2",
|
"postcss": "^8.5.2",
|
||||||
"tailwindcss": "^3.4.17",
|
"tailwindcss": "^3.4.17",
|
||||||
|
"tsx": "^4.23.15",
|
||||||
"typescript": "^5.7.3"
|
"typescript": "^5.7.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
38
scripts/blue-parrot-pipeline-seed.json
Normal file
38
scripts/blue-parrot-pipeline-seed.json
Normal 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" }
|
||||||
|
]
|
||||||
164
scripts/seed_blue_parrot_pipeline.ts
Normal file
164
scripts/seed_blue_parrot_pipeline.ts
Normal 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;
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user