@interware/content-generator 0.6.1 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/_chunks/App-Cwr5ra5m.js +1380 -0
- package/dist/_chunks/App-h4_txTsI.mjs +1362 -0
- package/dist/_chunks/{ImageStudioTabs-BLAH3KYv.mjs → ImageStudioTabs-B-mOwDfY.mjs} +687 -682
- package/dist/_chunks/{ImageStudioTabs-DtusqpQO.js → ImageStudioTabs-CG4KTn1S.js} +683 -678
- package/dist/_chunks/{Settings-o2GngLaW.mjs → Settings-BT-EqvXs.mjs} +2 -2
- package/dist/_chunks/{Settings-BQXxV3LO.js → Settings-D4NGBTk1.js} +4 -4
- package/dist/_chunks/{StudioApp-BaySMwrk.js → StudioApp-_ViSAwCm.js} +2 -2
- package/dist/_chunks/{StudioApp-C_znlQRo.mjs → StudioApp-xaEy-uIk.mjs} +2 -2
- package/dist/_chunks/{index-BKigRXm3.mjs → index-D67xWUPz.mjs} +3 -28
- package/dist/_chunks/{index-BKhe-Jyx.js → index-NA9OuMGs.js} +3 -28
- package/dist/admin/index.js +1 -1
- package/dist/admin/index.mjs +1 -1
- package/dist/admin/src/features/articles/ArticlePage.d.ts +5 -0
- package/dist/admin/src/features/articles/ArticlesPage.d.ts +7 -0
- package/dist/admin/src/features/articles/NewArticlePage.d.ts +6 -0
- package/dist/admin/src/features/articles/SharePreview.d.ts +19 -0
- package/dist/admin/src/features/articles/articlesApi.d.ts +94 -0
- package/dist/admin/src/features/articles/model.d.ts +24 -0
- package/dist/admin/src/features/articles/useArticlesApi.d.ts +19 -0
- package/dist/admin/src/features/library/LibraryImagePicker.d.ts +6 -1
- package/dist/admin/src/lib/postText.d.ts +25 -0
- package/dist/server/index.js +534 -327
- package/dist/server/index.mjs +535 -328
- package/dist/server/src/articles/generation.d.ts +135 -0
- package/dist/server/src/articles/schedule.d.ts +12 -0
- package/dist/server/src/articles/sources.d.ts +12 -0
- package/dist/server/src/bootstrap.d.ts +2 -0
- package/dist/server/src/controllers/articles.d.ts +30 -0
- package/dist/server/src/controllers/controller.d.ts +0 -4
- package/dist/server/src/controllers/index.d.ts +92 -4
- package/dist/server/src/index.d.ts +122 -67
- package/dist/server/src/services/articles.d.ts +126 -0
- package/dist/server/src/services/image-studio.d.ts +6 -11
- package/dist/server/src/services/index.d.ts +30 -63
- package/dist/server/src/services/service.d.ts +1 -62
- package/package.json +1 -1
- package/dist/_chunks/App-CJxSC8yt.js +0 -1105
- package/dist/_chunks/App-qbgUXlOZ.mjs +0 -1087
- package/dist/_chunks/getTranslation-B6Bsu-uq.mjs +0 -5
- package/dist/_chunks/getTranslation-BRVMWSf8.js +0 -4
- package/dist/admin/src/components/commons/dialog/index.d.ts +0 -12
- package/dist/admin/src/components/commons/generate-image-dialog/index.d.ts +0 -5
- package/dist/admin/src/components/generate-content/Form.d.ts +0 -15
- package/dist/admin/src/components/home/table.d.ts +0 -13
- package/dist/admin/src/components/index.d.ts +0 -12
- package/dist/admin/src/containers/generate-content/index.d.ts +0 -2
- package/dist/admin/src/containers/home/index.d.ts +0 -2
- package/dist/admin/src/hooks/useDialog.d.ts +0 -11
- package/dist/admin/src/lib/use-cases/articles/index.d.ts +0 -16
- package/dist/admin/src/lib/use-cases/categories/index.d.ts +0 -16
- package/dist/admin/src/lib/use-cases/facebook/index.d.ts +0 -5
- package/dist/admin/src/lib/use-cases/generate-content/index.d.ts +0 -6
- package/dist/admin/src/lib/use-cases/linkedin/index.d.ts +0 -5
- package/dist/admin/src/lib/use-cases/users/index.d.ts +0 -16
- package/dist/admin/src/pages/GenerateContent.d.ts +0 -2
- package/dist/admin/src/pages/HomePage.d.ts +0 -2
- package/dist/admin/src/reducers/dialog/index.d.ts +0 -6
- package/dist/admin/src/types/reducers.d.ts +0 -4
package/dist/server/index.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as fs from "fs";
|
|
2
2
|
import { promises } from "fs";
|
|
3
|
-
import { HarmCategory,
|
|
3
|
+
import { HarmCategory, Modality, HarmBlockThreshold, GoogleGenAI, VideoGenerationReferenceType } from "@google/genai";
|
|
4
4
|
const PLUGIN_ID = "strapi-content-generator-gemini";
|
|
5
5
|
const FOLDER_UID = "plugin::upload.folder";
|
|
6
6
|
const hasValidPath = (path) => /^(\/\d+)+$/.test(path);
|
|
@@ -23,7 +23,20 @@ const ensureFolder = async (strapi, name2) => {
|
|
|
23
23
|
const created = await strapi.plugin("upload").service("folder").create({ name: name2, parent: null });
|
|
24
24
|
return created.id;
|
|
25
25
|
};
|
|
26
|
+
const SCHEDULE_CRON = "content-generator-publicar-programados";
|
|
26
27
|
const bootstrap = async ({ strapi }) => {
|
|
28
|
+
strapi.cron.add({
|
|
29
|
+
[SCHEDULE_CRON]: {
|
|
30
|
+
task: async () => {
|
|
31
|
+
try {
|
|
32
|
+
await strapi.plugin(PLUGIN_ID).service("articles").publishDue();
|
|
33
|
+
} catch (err) {
|
|
34
|
+
strapi.log.error(`[content-generator] publicar programados: ${err.message}`);
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
options: { rule: "* * * * *" }
|
|
38
|
+
}
|
|
39
|
+
});
|
|
27
40
|
strapi.admin.services.permission.actionProvider.registerMany([
|
|
28
41
|
{
|
|
29
42
|
section: "plugins",
|
|
@@ -82,26 +95,29 @@ const template = { schema };
|
|
|
82
95
|
const contentTypes = {
|
|
83
96
|
template
|
|
84
97
|
};
|
|
98
|
+
const POST_MAX = 3e3;
|
|
99
|
+
const hashtags = (keywords) => keywords.map((k) => k.trim().replace(/\s+/g, "").replace(/[^\p{L}\p{N}_]/gu, "")).filter(Boolean).map((k) => `#${k}`).join(" ");
|
|
100
|
+
const defaultPostText = ({ titulo, descripcion, keywords }, red) => [red === "facebook" ? titulo : "", descripcion, hashtags(keywords)].filter(Boolean).join("\n\n");
|
|
101
|
+
const checkPostText = (input) => {
|
|
102
|
+
const text2 = typeof input === "string" ? input.trim() : "";
|
|
103
|
+
if (!text2) return { ok: true, text: null };
|
|
104
|
+
if ([...text2].length > POST_MAX) return { ok: false, error: `El texto de la publicación puede tener hasta ${POST_MAX} caracteres.` };
|
|
105
|
+
return { ok: true, text: text2 };
|
|
106
|
+
};
|
|
107
|
+
const validarMensaje = (ctx) => {
|
|
108
|
+
const check = checkPostText(ctx.request.body?.mensaje);
|
|
109
|
+
if ("error" in check) {
|
|
110
|
+
ctx.status = 400;
|
|
111
|
+
ctx.body = { data: null, error: { status: 400, name: "ValidationError", message: check.error } };
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
ctx.request.body = { ...ctx.request.body, mensaje: check.text ?? void 0 };
|
|
115
|
+
if (!check.text) delete ctx.request.body.mensaje;
|
|
116
|
+
return true;
|
|
117
|
+
};
|
|
85
118
|
const controller = ({ strapi }) => {
|
|
86
119
|
const pluginService = () => strapi.plugin("strapi-content-generator-gemini").service("service");
|
|
87
120
|
return {
|
|
88
|
-
async getArticles(ctx) {
|
|
89
|
-
const contentType = strapi.contentType("api::articulo.articulo");
|
|
90
|
-
await strapi.contentAPI.validate.query(ctx.query, contentType, {
|
|
91
|
-
auth: ctx.state.auth
|
|
92
|
-
});
|
|
93
|
-
const sanitizedQueryParams = await strapi.contentAPI.sanitize.query(
|
|
94
|
-
ctx.query,
|
|
95
|
-
contentType,
|
|
96
|
-
{
|
|
97
|
-
auth: ctx.state.auth
|
|
98
|
-
}
|
|
99
|
-
);
|
|
100
|
-
const documents = await pluginService().getArticulos(sanitizedQueryParams);
|
|
101
|
-
return await strapi.contentAPI.sanitize.output(documents, contentType, {
|
|
102
|
-
auth: ctx.state.auth
|
|
103
|
-
});
|
|
104
|
-
},
|
|
105
121
|
async getCategories(ctx) {
|
|
106
122
|
const contentType = strapi.contentType("api::categoria.categoria");
|
|
107
123
|
await strapi.contentAPI.validate.query(ctx.query, contentType, {
|
|
@@ -119,29 +135,6 @@ const controller = ({ strapi }) => {
|
|
|
119
135
|
auth: ctx.state.auth
|
|
120
136
|
});
|
|
121
137
|
},
|
|
122
|
-
async getUsers(ctx) {
|
|
123
|
-
const contentType = strapi.contentType("plugin::users-permissions.user");
|
|
124
|
-
await strapi.contentAPI.validate.query(ctx.query, contentType, {
|
|
125
|
-
auth: ctx.state.auth
|
|
126
|
-
});
|
|
127
|
-
const sanitizedQueryParams = await strapi.contentAPI.sanitize.query(
|
|
128
|
-
ctx.query,
|
|
129
|
-
contentType,
|
|
130
|
-
{
|
|
131
|
-
auth: ctx.state.auth
|
|
132
|
-
}
|
|
133
|
-
);
|
|
134
|
-
const documents = await pluginService().getUsuarios(sanitizedQueryParams);
|
|
135
|
-
return await strapi.contentAPI.sanitize.output(documents, contentType, {
|
|
136
|
-
auth: ctx.state.auth
|
|
137
|
-
});
|
|
138
|
-
},
|
|
139
|
-
async generateArticle(ctx) {
|
|
140
|
-
ctx.body = await pluginService().generateArticle(ctx);
|
|
141
|
-
},
|
|
142
|
-
async generateImageForArticle(ctx) {
|
|
143
|
-
ctx.body = await pluginService().generateImageForArticle(ctx);
|
|
144
|
-
},
|
|
145
138
|
async downloadFile(ctx) {
|
|
146
139
|
await pluginService().downloadFile(ctx);
|
|
147
140
|
},
|
|
@@ -149,10 +142,10 @@ const controller = ({ strapi }) => {
|
|
|
149
142
|
ctx.body = await pluginService().generateVideo(ctx);
|
|
150
143
|
},
|
|
151
144
|
async publishToLinkedIn(ctx) {
|
|
152
|
-
ctx.body = await pluginService().publishToLinkedIn(ctx);
|
|
145
|
+
if (validarMensaje(ctx)) ctx.body = await pluginService().publishToLinkedIn(ctx);
|
|
153
146
|
},
|
|
154
147
|
async publishToFacebook(ctx) {
|
|
155
|
-
ctx.body = await pluginService().publishToFacebook(ctx);
|
|
148
|
+
if (validarMensaje(ctx)) ctx.body = await pluginService().publishToFacebook(ctx);
|
|
156
149
|
}
|
|
157
150
|
};
|
|
158
151
|
};
|
|
@@ -255,14 +248,11 @@ const safetySettings = (safety) => HARM_CATEGORIES.map((category) => ({
|
|
|
255
248
|
category,
|
|
256
249
|
threshold: safety ? HarmBlockThreshold.BLOCK_MEDIUM_AND_ABOVE : HarmBlockThreshold.BLOCK_ONLY_HIGH
|
|
257
250
|
}));
|
|
258
|
-
const
|
|
259
|
-
none: "ALLOW_NONE",
|
|
260
|
-
adults: PersonGeneration.ALLOW_ADULT
|
|
261
|
-
};
|
|
251
|
+
const NO_PEOPLE = "ALLOW_NONE";
|
|
262
252
|
const imageConfig = ({ aspectRatio, imageSize, allowPeople }, backend = "gemini-api") => ({
|
|
263
253
|
...aspectRatio ? { aspectRatio } : {},
|
|
264
254
|
...imageSize ? { imageSize } : {},
|
|
265
|
-
...backend === "vertex"
|
|
255
|
+
...backend === "vertex" && !allowPeople ? { personGeneration: NO_PEOPLE } : {}
|
|
266
256
|
});
|
|
267
257
|
const promptPart = (text2, allowPeople, backend = "gemini-api") => ({
|
|
268
258
|
text: backend === "gemini-api" && !allowPeople ? `${text2} ${PEOPLE_CLAUSE}` : text2
|
|
@@ -313,7 +303,7 @@ const INVISIBLE = /[----]/g;
|
|
|
313
303
|
const TAG = /<\/?[a-z][^>]*>/gi;
|
|
314
304
|
const sanitizePrompt = (text2) => text2.normalize("NFC").replace(CONTROL, "").replace(INVISIBLE, "").replace(TAG, "").replace(/\s+/g, " ").trim();
|
|
315
305
|
const PROMPT_LABELS = { label: "El prompt", empty: "Escribe un prompt." };
|
|
316
|
-
const INSTRUCTION_LABELS = { label: "La instrucción", empty: "Describe el cambio que quieres." };
|
|
306
|
+
const INSTRUCTION_LABELS$1 = { label: "La instrucción", empty: "Describe el cambio que quieres." };
|
|
317
307
|
const checkPrompt = (text2, { label, empty } = PROMPT_LABELS) => {
|
|
318
308
|
const clean2 = sanitizePrompt(text2);
|
|
319
309
|
if (!clean2) return { ok: false, error: empty };
|
|
@@ -328,6 +318,10 @@ const EXPAND_INSTRUCTION = (concept) => [
|
|
|
328
318
|
`Concepto: ${concept}`
|
|
329
319
|
].join("\n");
|
|
330
320
|
class BlockedError extends Error {
|
|
321
|
+
constructor(message, detail) {
|
|
322
|
+
super(message);
|
|
323
|
+
this.detail = detail;
|
|
324
|
+
}
|
|
331
325
|
}
|
|
332
326
|
const sourceOf = (chunk) => {
|
|
333
327
|
if (chunk.web?.uri) return { title: chunk.web.title || new URL(chunk.web.uri).hostname, uri: chunk.web.uri };
|
|
@@ -349,12 +343,21 @@ const BLOCK_REASONS = {
|
|
|
349
343
|
PROHIBITED_CONTENT: "El modelo bloqueó la solicitud por contenido no permitido.",
|
|
350
344
|
IMAGE_SAFETY: "El modelo bloqueó la imagen resultante por seguridad."
|
|
351
345
|
};
|
|
346
|
+
const blockMessage = (reason, googleMessage) => {
|
|
347
|
+
const code = googleMessage.match(/Support code:\s*(\d+)/i)?.[1];
|
|
348
|
+
const suffix = code ? ` (Código de Google: ${code})` : "";
|
|
349
|
+
if (/PersonGeneration/i.test(googleMessage)) {
|
|
350
|
+
return /input image/i.test(googleMessage) ? `Google rechazó la imagen de referencia por la configuración de «Personas». Prueba con otra imagen o cambia «Personas».${suffix}` : `Google filtró la imagen generada por la configuración de «Personas». Cambia el prompt o «Personas».${suffix}`;
|
|
351
|
+
}
|
|
352
|
+
return `${BLOCK_REASONS[reason] ?? "El modelo no devolvió una imagen. Intenta con otro prompt."}${suffix}`;
|
|
353
|
+
};
|
|
352
354
|
const imageFrom = (res, model) => {
|
|
353
355
|
const parts = res.candidates?.[0]?.content?.parts ?? [];
|
|
354
356
|
const part = parts.find((p) => p.inlineData?.data && (p.inlineData.mimeType ?? "").startsWith("image/"));
|
|
355
357
|
if (part?.inlineData?.data) return { image: part.inlineData.data, mimeType: part.inlineData.mimeType ?? "image/png", model };
|
|
356
|
-
const reason = res.promptFeedback?.blockReason ?? res.candidates?.[0]?.finishReason;
|
|
357
|
-
|
|
358
|
+
const reason = String(res.promptFeedback?.blockReason ?? res.candidates?.[0]?.finishReason ?? "");
|
|
359
|
+
const googleMessage = [res.candidates?.[0]?.finishMessage, res.promptFeedback?.blockReasonMessage].filter(Boolean).join(" ");
|
|
360
|
+
throw new BlockedError(blockMessage(reason, googleMessage), [reason, googleMessage].filter(Boolean).join(": ") || void 0);
|
|
358
361
|
};
|
|
359
362
|
const inline = async (file, read) => inlinePart(await read(file.filepath), file.mimetype ?? "image/png");
|
|
360
363
|
const userTurn = (parts) => [{ role: "user", parts }];
|
|
@@ -369,25 +372,6 @@ const createImageStudio = (getClient, read = (p) => promises.readFile(p)) => ({
|
|
|
369
372
|
return availableModels(null);
|
|
370
373
|
}
|
|
371
374
|
},
|
|
372
|
-
/**
|
|
373
|
-
* Imagen para un artículo (S4.12): antes usaba Imagen 4, que Google apagó. Usa el primer modelo
|
|
374
|
-
* disponible del catálogo y ajusta proporción y tamaño a lo que admite. Devuelve base64.
|
|
375
|
-
*/
|
|
376
|
-
async generateForArticle(prompt, aspectRatio, imageSize) {
|
|
377
|
-
const [model] = (await this.models()).models;
|
|
378
|
-
const { image } = await this.generate({
|
|
379
|
-
prompt,
|
|
380
|
-
model,
|
|
381
|
-
aspectRatio: model.aspectRatios.includes(aspectRatio) ? aspectRatio : "16:9",
|
|
382
|
-
imageSize: model.sizes.includes(imageSize) ? imageSize : void 0,
|
|
383
|
-
safety: true,
|
|
384
|
-
// El estilo de marca de los artículos no lleva personas (su JSON ya lo pide); ahora también el parámetro.
|
|
385
|
-
allowPeople: false,
|
|
386
|
-
grounding: false,
|
|
387
|
-
references: []
|
|
388
|
-
});
|
|
389
|
-
return image;
|
|
390
|
-
},
|
|
391
375
|
async generate(input) {
|
|
392
376
|
const { models: api, backend } = await getClient();
|
|
393
377
|
const text2 = cleanPrompt(input.prompt);
|
|
@@ -465,7 +449,7 @@ const parseGenerateInput = (fields, files) => {
|
|
|
465
449
|
};
|
|
466
450
|
};
|
|
467
451
|
const parseEditInput = (fields, files) => {
|
|
468
|
-
const instruction = clean(str(fields.instruction), INSTRUCTION_LABELS);
|
|
452
|
+
const instruction = clean(str(fields.instruction), INSTRUCTION_LABELS$1);
|
|
469
453
|
const [image] = list(files.image);
|
|
470
454
|
if (!image) throw new InputError("Falta la imagen a editar.");
|
|
471
455
|
const model = editModelFor(str(fields.model) || void 0);
|
|
@@ -483,7 +467,7 @@ const parseEditInput = (fields, files) => {
|
|
|
483
467
|
aspectRatio: model.aspectRatios.includes(str(fields.aspectRatio)) ? str(fields.aspectRatio) : void 0
|
|
484
468
|
};
|
|
485
469
|
};
|
|
486
|
-
const fail$
|
|
470
|
+
const fail$3 = (ctx, status, name2, message) => {
|
|
487
471
|
ctx.status = status;
|
|
488
472
|
ctx.body = { data: null, error: { status, name: name2, message } };
|
|
489
473
|
};
|
|
@@ -493,10 +477,13 @@ const imageStudio = ({ strapi }) => {
|
|
|
493
477
|
try {
|
|
494
478
|
ctx.body = await work();
|
|
495
479
|
} catch (err) {
|
|
496
|
-
if (err instanceof InputError) return fail$
|
|
497
|
-
if (err instanceof BlockedError)
|
|
480
|
+
if (err instanceof InputError) return fail$3(ctx, 400, "BadRequestError", err.message);
|
|
481
|
+
if (err instanceof BlockedError) {
|
|
482
|
+
strapi.log.warn(`[image-studio] bloqueo: ${err.detail ?? err.message}`);
|
|
483
|
+
return fail$3(ctx, 422, "UnprocessableEntityError", err.message);
|
|
484
|
+
}
|
|
498
485
|
strapi.log.error(`[image-studio] ${err instanceof Error ? err.message : String(err)}`);
|
|
499
|
-
return fail$
|
|
486
|
+
return fail$3(ctx, 502, "BadGatewayError", "El servicio de imágenes no respondió. Intenta de nuevo en un momento.");
|
|
500
487
|
}
|
|
501
488
|
};
|
|
502
489
|
return {
|
|
@@ -510,6 +497,454 @@ const imageStudio = ({ strapi }) => {
|
|
|
510
497
|
})
|
|
511
498
|
};
|
|
512
499
|
};
|
|
500
|
+
const MAX_URLS = 3;
|
|
501
|
+
const isHttp = (value) => {
|
|
502
|
+
try {
|
|
503
|
+
const { protocol } = new URL(value);
|
|
504
|
+
return protocol === "http:" || protocol === "https:";
|
|
505
|
+
} catch {
|
|
506
|
+
return false;
|
|
507
|
+
}
|
|
508
|
+
};
|
|
509
|
+
const hostOf = (url) => {
|
|
510
|
+
try {
|
|
511
|
+
return new URL(url).hostname;
|
|
512
|
+
} catch {
|
|
513
|
+
return url;
|
|
514
|
+
}
|
|
515
|
+
};
|
|
516
|
+
const parseReferenceUrls = (input) => {
|
|
517
|
+
const list2 = (Array.isArray(input) ? input : []).filter((v) => typeof v === "string").map((v) => v.trim()).filter(Boolean);
|
|
518
|
+
const unique = [...new Set(list2)];
|
|
519
|
+
if (unique.length > MAX_URLS) throw new InputError(`Puedes dar hasta ${MAX_URLS} URLs de referencia.`);
|
|
520
|
+
const invalid = unique.find((u) => !isHttp(u));
|
|
521
|
+
if (invalid) throw new InputError(`La URL «${invalid}» no es válida.`);
|
|
522
|
+
return unique;
|
|
523
|
+
};
|
|
524
|
+
const sourcesFrom = (urls, res) => {
|
|
525
|
+
const referencias = urls.map((url) => ({ titulo: hostOf(url), url, tipo: "referencia" }));
|
|
526
|
+
const busqueda = (res?.candidates?.[0]?.groundingMetadata?.groundingChunks ?? []).map((chunk) => chunk.web).filter((web) => Boolean(web?.uri)).map((web) => ({ titulo: web.title || hostOf(web.uri), url: web.uri, tipo: "busqueda" }));
|
|
527
|
+
return [...referencias, ...busqueda].filter((f, i, all3) => all3.findIndex((o) => o.url === f.url) === i);
|
|
528
|
+
};
|
|
529
|
+
const ARTICLE_MODEL = "gemini-2.5-pro";
|
|
530
|
+
const ENRICH_MODEL = "gemini-2.5-flash";
|
|
531
|
+
const BASE_PROMPT_MAX = 2e4;
|
|
532
|
+
const INSTRUCTION_LABELS = { label: "Las instrucciones", empty: "Escribe las instrucciones del artículo." };
|
|
533
|
+
const parseCategoria = (input) => {
|
|
534
|
+
const c = input ?? {};
|
|
535
|
+
if (typeof c.id === "string" && c.id.trim()) return { id: c.id.trim() };
|
|
536
|
+
if (typeof c.nueva === "string" && c.nueva.trim()) return { nueva: c.nueva.trim().slice(0, 80) };
|
|
537
|
+
throw new InputError("Elige o escribe una categoría.");
|
|
538
|
+
};
|
|
539
|
+
const parseTemperatura = (input) => {
|
|
540
|
+
const n = typeof input === "number" ? input : typeof input === "string" ? Number(input) : NaN;
|
|
541
|
+
return Number.isFinite(n) ? Math.min(2, Math.max(0, n)) : 0.5;
|
|
542
|
+
};
|
|
543
|
+
const parseArticleInput = (body) => {
|
|
544
|
+
const check = checkPrompt(typeof body.instrucciones === "string" ? body.instrucciones : "", INSTRUCTION_LABELS);
|
|
545
|
+
if ("error" in check) throw new InputError(check.error);
|
|
546
|
+
return {
|
|
547
|
+
titulo: typeof body.titulo === "string" ? body.titulo.trim().slice(0, 200) : "",
|
|
548
|
+
categoria: parseCategoria(body.categoria),
|
|
549
|
+
temperatura: parseTemperatura(body.temperatura),
|
|
550
|
+
instrucciones: check.text,
|
|
551
|
+
urls: parseReferenceUrls(body.urls),
|
|
552
|
+
busqueda: body.busqueda === true || body.busqueda === "true",
|
|
553
|
+
usarPromptBase: !(body.usarPromptBase === false || body.usarPromptBase === "false"),
|
|
554
|
+
...optionalBase(body.promptBase)
|
|
555
|
+
};
|
|
556
|
+
};
|
|
557
|
+
const optionalBase = (input) => {
|
|
558
|
+
const text2 = typeof input === "string" ? input.trim() : "";
|
|
559
|
+
if (!text2) return {};
|
|
560
|
+
if (text2.length > BASE_PROMPT_MAX) throw new InputError(`El prompt base puede tener hasta ${BASE_PROMPT_MAX} caracteres.`);
|
|
561
|
+
return { promptBase: text2 };
|
|
562
|
+
};
|
|
563
|
+
const parseBasePrompt = (input) => {
|
|
564
|
+
const base = optionalBase(input).promptBase;
|
|
565
|
+
if (!base) throw new InputError("Escribe el prompt base.");
|
|
566
|
+
return base;
|
|
567
|
+
};
|
|
568
|
+
const researchTools = ({ urls, busqueda }) => [
|
|
569
|
+
...busqueda ? [{ googleSearch: {} }] : [],
|
|
570
|
+
...urls.length ? [{ urlContext: {} }] : []
|
|
571
|
+
];
|
|
572
|
+
const temaLines = ({ titulo, categoria, instrucciones }) => [
|
|
573
|
+
titulo ? `- Título sugerido: "${titulo}"` : "",
|
|
574
|
+
categoria ? `- Categoría o tópico: "${categoria}"` : "",
|
|
575
|
+
instrucciones ? `- Instrucciones: "${instrucciones}"` : ""
|
|
576
|
+
].filter(Boolean).join("\n");
|
|
577
|
+
const researchPrompt = (tema) => `
|
|
578
|
+
Eres analista de investigación de InterWare. Reúne la información que respaldará una columna de opinión para
|
|
579
|
+
directores de TI, operaciones y finanzas.
|
|
580
|
+
|
|
581
|
+
Tema:
|
|
582
|
+
${temaLines(tema)}
|
|
583
|
+
${tema.urls.length ? `
|
|
584
|
+
Lee y usa como referencia principal estas páginas:
|
|
585
|
+
${tema.urls.map((u) => `- ${u}`).join("\n")}
|
|
586
|
+
` : ""}
|
|
587
|
+
Entrega notas en español, concisas: hechos, cifras con su fuente, tendencias y ejemplos relevantes. No escribas el
|
|
588
|
+
artículo. Si un dato no está respaldado por una fuente, no lo incluyas.
|
|
589
|
+
`;
|
|
590
|
+
const DEFAULT_BASE_PROMPT = `# ROL Y PERSONA (EL FONDO)
|
|
591
|
+
Actúa como un Estratega Tecnológico de nivel C-Level y Arquitecto de Negocios de élite para 'InterWare'. Eres un columnista de opinión experto, escribiendo para una audiencia exclusiva de tomadores de decisión: Directores de TI (CTOs), Directores de Operaciones (COOs) y Directores Financieros (CFOs/FinOps).
|
|
592
|
+
No eres un redactor de marketing ni un vendedor de software; eres un consultor de alto nivel que diagnostica problemas sistémicos complejos y ofrece perspectivas de valor incalculable. Tu objetivo es hacer que el lector cuestione la madurez de su propia infraestructura.
|
|
593
|
+
|
|
594
|
+
# FILOSOFÍA DE MARCA: "ORQUESTACIÓN LATENTE"
|
|
595
|
+
Toda tu narrativa debe estar impregnada de nuestra visión de negocio: "Evolución por Continuidad"
|
|
596
|
+
- InterWare no inventa necesidades ni vende catálogos de servicios; InterWare resuelve la complejidad operativa con responsabilidad
|
|
597
|
+
- Somos el orquestador invisible que garantiza que ecosistemas heterogéneos trabajen en perfecta armonía.
|
|
598
|
+
- Tu ancla absoluta de confianza y diferenciador es: "El código tiene alma". Esto significa que no entregamos simple software; entregamos sistemas de misión crítica que no fallan, respaldados por equipos humanos de excelencia.
|
|
599
|
+
- Tu respaldo de autoridad (menciónelo sutilmente como validación, nunca como presunción): Nos avalan casi 30 años de historia, más de 9,000 proyectos exitosos, primer centro autorizado de Java en LATAM y más de 25 años como Partners Gold de IBM. Nuestra prueba de lealtad es que promediamos relaciones de más de 10 años con nuestros clientes.
|
|
600
|
+
|
|
601
|
+
# REGLAS DE TONO Y LÉXICO (LA FORMA)
|
|
602
|
+
1. El Tono: Profundamente autoritario, frío, elegante, quirúrgico y sofisticado. Escribe con la certeza de quien ha visto todas las fallas posibles en la industria y sabe cómo prevenirlas.
|
|
603
|
+
2. Vocabulario Obligatorio (Usa al menos 4 de forma natural): Orquestación, resiliencia, fricción operativa, deuda técnica, telemetría, anticipación, interoperabilidad, misión crítica, observabilidad profunda, ecosistemas híbridos, primitivas de orquestación.
|
|
604
|
+
3. Prohibición de Léxico Barato (Zero-Clutter Text): PROHIBIDO usar palabras literales o burdas como "fierros", "cables", "hardware", "servidores físicos" o "computadoras". En su lugar, usa "nodos", "infraestructura", "arquitecturas distribuidas", "activos digitales".
|
|
605
|
+
|
|
606
|
+
# RESTRICCIONES ESTRICTAS ANTI-IA (EL ESTILO)
|
|
607
|
+
- Cero Clichés de Marketing: NUNCA uses frases trilladas como "en el mundo digital de hoy", "en la era actual", "desata el poder", "revolucionar el mercado", "lleva tu empresa al siguiente nivel" o "somos tu mejor opción".
|
|
608
|
+
- Cero Estructuras Robóticas: Evita transiciones predecibles como "En resumen", "En conclusión", "En primer lugar", "En segundo lugar".
|
|
609
|
+
- Fluidez Orgánica: No abuses de las listas de viñetas (bullets). Si debes listar beneficios, intégralos en la prosa de manera fluida o usa negritas sutiles dentro de un párrafo.
|
|
610
|
+
- Espacio Negativo Textual: Los tomadores de decisión no leen bloques densos de texto. Usa párrafos cortos (máximo 3 o 4 líneas). El texto debe "respirar".
|
|
611
|
+
|
|
612
|
+
# INSTRUCCIÓN DE EJECUCIÓN: LA NARRATIVA DE LA COLUMNA
|
|
613
|
+
Redacta una columna de opinión profunda y analítica sobre el tema de la sección «TEMA DEL ARTÍCULO».
|
|
614
|
+
|
|
615
|
+
Tu artículo debe seguir este arco narrativo impecable (entre 600 y 800 palabras):
|
|
616
|
+
|
|
617
|
+
1. EL TÍTULO ABSTRACTO: Crea un título de menos de 10 palabras que evoque un dolor de negocio o una revelación financiera, no una característica de software. Debe sonar a un artículo de *Harvard Business Review*.
|
|
618
|
+
2. LA FRICCIÓN (El Diagnóstico Crudo): Abre el artículo sin introducciones banales. Golpea directamente diagnosticando el dolor del negocio (ej. la pérdida de rentabilidad por cuellos de botella operativos o el riesgo inminente de sistemas legados que nadie sabe mantener). Haz que el lector sienta que estás describiendo su empresa.
|
|
619
|
+
3. LA ORQUESTACIÓN (El Núcleo): Desarrolla tu argumento explicando cómo una arquitectura inteligente (ya sea a través de microservicios, DevSecOps, automatización inteligente de negocio, o monitoreo sintético con herramientas como iwRobot) resuelve este problema de raíz. Transmite la idea de que la tecnología debe ser un habilitador transparente, no una carga.
|
|
620
|
+
4. EL IMPACTO LATENTE: Teje dentro de la prosa (sin usar viñetas obvias de IA) los tres impactos estratégicos del tema. Habla de cómo se logra la Resiliencia, la Fluidez operativa, la Eficiencia financiera o la Anticipación ante el riesgo.
|
|
621
|
+
5. EL CIERRE SUTIL (El CTA Elegante): En lugar de un llamado a la acción desesperado de ventas, cierra con una reflexión ejecutiva o una pregunta abierta que invite al lector a auditar mentalmente la madurez de su propia infraestructura. Una despedida que demuestre que, para orquestar la complejidad, se requiere la maestría de tres décadas.
|
|
622
|
+
|
|
623
|
+
Recuerda: Eres un experto hablando con otro experto. Sé denso en valor y conciso en palabras`;
|
|
624
|
+
const investigacionBlock = (investigacion) => investigacion ? `
|
|
625
|
+
|
|
626
|
+
# INVESTIGACIÓN DE APOYO
|
|
627
|
+
Usa estos datos como base factual. No inventes cifras, nombres ni estudios que no aparezcan aquí:
|
|
628
|
+
${investigacion}` : "";
|
|
629
|
+
const articlePrompt = ({ base = DEFAULT_BASE_PROMPT, investigacion, ...tema }) => `${base}
|
|
630
|
+
|
|
631
|
+
# TEMA DEL ARTÍCULO
|
|
632
|
+
${temaLines(tema)}${investigacionBlock(investigacion)}`;
|
|
633
|
+
const rawPrompt = ({ instrucciones, investigacion }) => `${instrucciones}${investigacionBlock(investigacion)}`;
|
|
634
|
+
const enrichPrompt = (texto) => `
|
|
635
|
+
Eres editor de contenidos de InterWare, empresa mexicana de tecnología. Convierte esta idea en instrucciones claras
|
|
636
|
+
para redactar un artículo de blog.
|
|
637
|
+
|
|
638
|
+
Idea: "${texto}"
|
|
639
|
+
|
|
640
|
+
Responde solo con las instrucciones, en español y en texto plano, con estas líneas:
|
|
641
|
+
Tema: …
|
|
642
|
+
Público: …
|
|
643
|
+
Cubrir: …
|
|
644
|
+
Tono: …
|
|
645
|
+
Cierre: …
|
|
646
|
+
Máximo 1500 caracteres. Sin comillas ni Markdown.
|
|
647
|
+
`;
|
|
648
|
+
const ARTICLE_SCHEMA = {
|
|
649
|
+
type: "object",
|
|
650
|
+
required: ["titulo", "descripcion", "content", "seo", "minute_read"],
|
|
651
|
+
properties: {
|
|
652
|
+
seo: {
|
|
653
|
+
type: "object",
|
|
654
|
+
required: ["metaTitle", "metaDescription", "metadata"],
|
|
655
|
+
properties: {
|
|
656
|
+
metaTitle: { type: "string" },
|
|
657
|
+
metaDescription: { type: "string" },
|
|
658
|
+
metadata: {
|
|
659
|
+
type: "array",
|
|
660
|
+
items: {
|
|
661
|
+
type: "object",
|
|
662
|
+
required: ["name", "content"],
|
|
663
|
+
properties: { name: { type: "string" }, content: { type: "string" } }
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
},
|
|
668
|
+
titulo: { type: "string", minLength: 40, maxLength: 70, description: "Título del artículo" },
|
|
669
|
+
descripcion: { type: "string", minLength: 80, maxLength: 160, description: "Descripción breve del artículo" },
|
|
670
|
+
slug: {
|
|
671
|
+
type: "string",
|
|
672
|
+
minLength: 80,
|
|
673
|
+
maxLength: 160,
|
|
674
|
+
description: "URL-friendly basado en el título (ej: estrategias-migracion-nube)"
|
|
675
|
+
},
|
|
676
|
+
content: { type: "string", description: "Contenido HTML compatible con CKEditor" },
|
|
677
|
+
minute_read: {
|
|
678
|
+
type: "integer",
|
|
679
|
+
minimum: 1,
|
|
680
|
+
maximum: 30,
|
|
681
|
+
description: "Tiempo de lectura calculado usando 200 palabras por minuto"
|
|
682
|
+
},
|
|
683
|
+
keywords: {
|
|
684
|
+
type: "array",
|
|
685
|
+
minItems: 3,
|
|
686
|
+
maxItems: 5,
|
|
687
|
+
items: { type: "object", required: ["name"], properties: { name: { type: "string" } } },
|
|
688
|
+
description: "Hash tags y palabras clave para la publicación en LinkedIn y Facebook"
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
};
|
|
692
|
+
const ZONA = "America/Mexico_City";
|
|
693
|
+
const UN_ANIO_MS = 365 * 24 * 60 * 60 * 1e3;
|
|
694
|
+
const fechaEnMexico = (date) => new Intl.DateTimeFormat("en-CA", { timeZone: ZONA, year: "numeric", month: "2-digit", day: "2-digit" }).format(date);
|
|
695
|
+
const parseScheduleAt = (input, now) => {
|
|
696
|
+
const date = typeof input === "string" ? new Date(input) : /* @__PURE__ */ new Date(NaN);
|
|
697
|
+
if (Number.isNaN(date.getTime())) throw new InputError("La fecha y hora no son válidas.");
|
|
698
|
+
if (date.getTime() <= now.getTime()) throw new InputError("Elige una fecha y hora futuras.");
|
|
699
|
+
if (date.getTime() - now.getTime() > UN_ANIO_MS) throw new InputError("Programa con menos de un año de anticipación.");
|
|
700
|
+
return date;
|
|
701
|
+
};
|
|
702
|
+
const estadoDe = ({ publicado, programada }) => publicado ? "publicado" : programada ? "programado" : "borrador";
|
|
703
|
+
class NotFoundError extends Error {
|
|
704
|
+
}
|
|
705
|
+
const memoryStore = () => {
|
|
706
|
+
let value = null;
|
|
707
|
+
return {
|
|
708
|
+
get: async () => value,
|
|
709
|
+
set: async (v) => {
|
|
710
|
+
value = v;
|
|
711
|
+
},
|
|
712
|
+
remove: async () => {
|
|
713
|
+
value = null;
|
|
714
|
+
}
|
|
715
|
+
};
|
|
716
|
+
};
|
|
717
|
+
const ROW_FIELDS = ["titulo", "fecha_publicacion", "publicacion_programada", "linkedInPostId", "facebookPostId"];
|
|
718
|
+
const DETAIL_POPULATE = { image: true, categoria: true, seo: true, keywords: true };
|
|
719
|
+
const silentLog = { info: () => void 0, warn: () => void 0, error: () => void 0 };
|
|
720
|
+
const slugify = (text2) => text2.toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, "").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
721
|
+
const textOf = (res) => (res.text ?? res.candidates?.[0]?.content?.parts?.[0]?.text ?? "").trim();
|
|
722
|
+
const toRow = (d, publicado) => ({
|
|
723
|
+
documentId: d.documentId,
|
|
724
|
+
titulo: d.titulo ?? "",
|
|
725
|
+
categoria: d.categoria?.categoria ?? null,
|
|
726
|
+
estado: estadoDe({ publicado, programada: d.publicacion_programada }),
|
|
727
|
+
imagen: Boolean(d.image),
|
|
728
|
+
fechaPublicacion: d.fecha_publicacion ?? null,
|
|
729
|
+
programadaPara: d.publicacion_programada ?? null,
|
|
730
|
+
linkedInPostId: d.linkedInPostId ?? null,
|
|
731
|
+
facebookPostId: d.facebookPostId ?? null
|
|
732
|
+
});
|
|
733
|
+
const toDetail = (d, publicado) => ({
|
|
734
|
+
...toRow(d, publicado),
|
|
735
|
+
descripcion: d.descripcion ?? "",
|
|
736
|
+
content: d.content ?? "",
|
|
737
|
+
minuteRead: d.minute_read ?? null,
|
|
738
|
+
slug: d.slug ?? null,
|
|
739
|
+
imageUrl: d.image ? d.image.formats?.small?.url ?? d.image.formats?.thumbnail?.url ?? d.image.url ?? null : null,
|
|
740
|
+
imageName: d.image?.name ?? null,
|
|
741
|
+
seo: d.seo ? { metaTitle: d.seo.metaTitle, metaDescription: d.seo.metaDescription } : null,
|
|
742
|
+
keywords: (d.keywords ?? []).map((k) => k.name ?? "").filter(Boolean),
|
|
743
|
+
fuentes: Array.isArray(d.fuentes) ? d.fuentes : []
|
|
744
|
+
});
|
|
745
|
+
const createArticles = ({ articulos, categorias, getModels, now = () => /* @__PURE__ */ new Date(), log = silentLog, promptStore = memoryStore() }) => {
|
|
746
|
+
const draftOf = async (documentId, populate = DETAIL_POPULATE) => {
|
|
747
|
+
const d = await articulos.findOne({ documentId, status: "draft", populate });
|
|
748
|
+
if (!d) throw new NotFoundError("El artículo no existe.");
|
|
749
|
+
return d;
|
|
750
|
+
};
|
|
751
|
+
const isPublished = async (documentId) => Boolean(await articulos.findOne({ documentId, status: "published", fields: ["titulo"] }));
|
|
752
|
+
const detail = async (documentId) => {
|
|
753
|
+
const d = await draftOf(documentId);
|
|
754
|
+
return toDetail(d, await isPublished(documentId));
|
|
755
|
+
};
|
|
756
|
+
const resolveCategoria = async (input) => {
|
|
757
|
+
if ("id" in input) {
|
|
758
|
+
const c = await categorias.findOne({ documentId: input.id });
|
|
759
|
+
if (!c) throw new InputError("La categoría elegida ya no existe.");
|
|
760
|
+
return { id: c.documentId, nombre: c.categoria };
|
|
761
|
+
}
|
|
762
|
+
const found = await categorias.findFirst({ filters: { categoria: { $eqi: input.nueva } } });
|
|
763
|
+
if (found) return { id: found.documentId, nombre: found.categoria };
|
|
764
|
+
const created = await categorias.create({ data: { categoria: input.nueva, slug: slugify(input.nueva) } });
|
|
765
|
+
return { id: created.documentId, nombre: input.nueva };
|
|
766
|
+
};
|
|
767
|
+
const basePrompt = async () => {
|
|
768
|
+
const guardado = await promptStore.get();
|
|
769
|
+
return { prompt: guardado || DEFAULT_BASE_PROMPT, personalizado: Boolean(guardado) };
|
|
770
|
+
};
|
|
771
|
+
return {
|
|
772
|
+
basePrompt,
|
|
773
|
+
async saveBasePrompt(prompt) {
|
|
774
|
+
await promptStore.set(prompt);
|
|
775
|
+
return basePrompt();
|
|
776
|
+
},
|
|
777
|
+
async resetBasePrompt() {
|
|
778
|
+
await promptStore.remove();
|
|
779
|
+
return basePrompt();
|
|
780
|
+
},
|
|
781
|
+
async list() {
|
|
782
|
+
const [drafts, published] = await Promise.all([
|
|
783
|
+
articulos.findMany({ status: "draft", fields: ROW_FIELDS, populate: { image: { fields: ["id"] }, categoria: { fields: ["categoria"] } }, sort: ["createdAt:desc"] }),
|
|
784
|
+
articulos.findMany({ status: "published", fields: ["titulo"] })
|
|
785
|
+
]);
|
|
786
|
+
const publicados = new Set(published.map((p) => p.documentId));
|
|
787
|
+
return { articles: drafts.map((d) => toRow(d, publicados.has(d.documentId))) };
|
|
788
|
+
},
|
|
789
|
+
detail,
|
|
790
|
+
async generate(input) {
|
|
791
|
+
const categoria = await resolveCategoria(input.categoria);
|
|
792
|
+
const models = await getModels();
|
|
793
|
+
const tema = { titulo: input.titulo, categoria: categoria.nombre, instrucciones: input.instrucciones };
|
|
794
|
+
const tools = researchTools(input);
|
|
795
|
+
let investigacion;
|
|
796
|
+
let fuentes = [];
|
|
797
|
+
if (tools.length) {
|
|
798
|
+
const research = await models.generateContent({ model: ARTICLE_MODEL, contents: researchPrompt({ ...tema, urls: input.urls }), config: { tools, temperature: 0.2 } });
|
|
799
|
+
investigacion = textOf(research) || void 0;
|
|
800
|
+
fuentes = sourcesFrom(input.urls, research);
|
|
801
|
+
}
|
|
802
|
+
const contents = input.usarPromptBase === false ? rawPrompt({ instrucciones: input.instrucciones, investigacion }) : articlePrompt({ ...tema, investigacion, base: input.promptBase ?? (await basePrompt()).prompt });
|
|
803
|
+
const res = await models.generateContent({
|
|
804
|
+
model: ARTICLE_MODEL,
|
|
805
|
+
contents,
|
|
806
|
+
config: { responseMimeType: "application/json", temperature: input.temperatura, responseSchema: ARTICLE_SCHEMA }
|
|
807
|
+
});
|
|
808
|
+
let parsed;
|
|
809
|
+
try {
|
|
810
|
+
parsed = JSON.parse(textOf(res));
|
|
811
|
+
} catch {
|
|
812
|
+
throw new Error("La respuesta del modelo no es un JSON válido.");
|
|
813
|
+
}
|
|
814
|
+
const created = await articulos.create({
|
|
815
|
+
data: {
|
|
816
|
+
titulo: parsed.titulo,
|
|
817
|
+
descripcion: parsed.descripcion,
|
|
818
|
+
content: parsed.content,
|
|
819
|
+
seo: parsed.seo,
|
|
820
|
+
minute_read: parsed.minute_read,
|
|
821
|
+
slug: parsed.slug,
|
|
822
|
+
keywords: parsed.keywords,
|
|
823
|
+
categoria: categoria.id,
|
|
824
|
+
image: null,
|
|
825
|
+
fuentes
|
|
826
|
+
},
|
|
827
|
+
status: "draft"
|
|
828
|
+
});
|
|
829
|
+
return { documentId: created.documentId };
|
|
830
|
+
},
|
|
831
|
+
async enrich(texto) {
|
|
832
|
+
const models = await getModels();
|
|
833
|
+
const res = await models.generateContent({ model: ENRICH_MODEL, contents: enrichPrompt(texto) });
|
|
834
|
+
const out = textOf(res).replace(/^["'«“]+|["'»”]+$/g, "").trim();
|
|
835
|
+
if (!out) throw new InputError("No se pudieron enriquecer las instrucciones. Intenta de nuevo.");
|
|
836
|
+
return out.slice(0, 2e3);
|
|
837
|
+
},
|
|
838
|
+
async setImage(documentId, fileId) {
|
|
839
|
+
const id = typeof fileId === "number" ? fileId : Number(fileId);
|
|
840
|
+
if (!Number.isInteger(id) || id <= 0) throw new InputError("Elige una imagen válida.");
|
|
841
|
+
await draftOf(documentId, {});
|
|
842
|
+
await articulos.update({ documentId, data: { image: id } });
|
|
843
|
+
if (await isPublished(documentId)) await articulos.publish({ documentId });
|
|
844
|
+
return detail(documentId);
|
|
845
|
+
},
|
|
846
|
+
async publish(documentId) {
|
|
847
|
+
const d = await draftOf(documentId);
|
|
848
|
+
if (!d.image) throw new InputError("Elige una imagen antes de publicar.");
|
|
849
|
+
await articulos.update({ documentId, data: { fecha_publicacion: fechaEnMexico(now()), publicacion_programada: null } });
|
|
850
|
+
await articulos.publish({ documentId });
|
|
851
|
+
return detail(documentId);
|
|
852
|
+
},
|
|
853
|
+
async schedule(documentId, at) {
|
|
854
|
+
const date = parseScheduleAt(at, now());
|
|
855
|
+
const d = await draftOf(documentId);
|
|
856
|
+
if (!d.image) throw new InputError("Elige una imagen antes de programar.");
|
|
857
|
+
if (await isPublished(documentId)) throw new InputError("El artículo ya está publicado.");
|
|
858
|
+
await articulos.update({ documentId, data: { publicacion_programada: date.toISOString(), fecha_publicacion: fechaEnMexico(date) } });
|
|
859
|
+
return detail(documentId);
|
|
860
|
+
},
|
|
861
|
+
async unschedule(documentId) {
|
|
862
|
+
await draftOf(documentId, {});
|
|
863
|
+
await articulos.update({ documentId, data: { publicacion_programada: null, fecha_publicacion: null } });
|
|
864
|
+
return detail(documentId);
|
|
865
|
+
},
|
|
866
|
+
/** D4: publica los borradores con programación vencida. Devuelve cuántos publicó. */
|
|
867
|
+
async publishDue() {
|
|
868
|
+
const due = await articulos.findMany({
|
|
869
|
+
status: "draft",
|
|
870
|
+
filters: { publicacion_programada: { $notNull: true, $lte: now().toISOString() } },
|
|
871
|
+
fields: ["publicacion_programada"],
|
|
872
|
+
populate: { image: { fields: ["id"] } }
|
|
873
|
+
});
|
|
874
|
+
let count = 0;
|
|
875
|
+
for (const d of due) {
|
|
876
|
+
if (!d.image) {
|
|
877
|
+
log.warn(`[articles] ${d.documentId} está programado pero no tiene imagen; no se publicó.`);
|
|
878
|
+
continue;
|
|
879
|
+
}
|
|
880
|
+
try {
|
|
881
|
+
await articulos.update({ documentId: d.documentId, data: { publicacion_programada: null } });
|
|
882
|
+
await articulos.publish({ documentId: d.documentId });
|
|
883
|
+
count += 1;
|
|
884
|
+
} catch (err) {
|
|
885
|
+
log.error(`[articles] no se pudo publicar ${d.documentId}: ${err instanceof Error ? err.message : String(err)}`);
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
if (count) log.info(`[articles] ${count} artículo(s) programado(s) publicados.`);
|
|
889
|
+
return count;
|
|
890
|
+
}
|
|
891
|
+
};
|
|
892
|
+
};
|
|
893
|
+
const articles$1 = ({ strapi }) => createArticles({
|
|
894
|
+
articulos: strapi.documents("api::articulo.articulo"),
|
|
895
|
+
categorias: strapi.documents("api::categoria.categoria"),
|
|
896
|
+
getModels: async () => (await strapi.plugin("strapi-content-generator-gemini").service("service").geminiClient()).ai.models,
|
|
897
|
+
log: strapi.log,
|
|
898
|
+
promptStore: /* @__PURE__ */ (() => {
|
|
899
|
+
const store = () => strapi.store({ type: "plugin", name: "strapi-content-generator-gemini" });
|
|
900
|
+
const key = "ARTICLE_BASE_PROMPT";
|
|
901
|
+
return {
|
|
902
|
+
get: async () => await store().get({ key }) ?? null,
|
|
903
|
+
set: async (value) => {
|
|
904
|
+
await store().set({ key, value });
|
|
905
|
+
},
|
|
906
|
+
remove: async () => {
|
|
907
|
+
await store().delete({ key });
|
|
908
|
+
}
|
|
909
|
+
};
|
|
910
|
+
})()
|
|
911
|
+
});
|
|
912
|
+
const fail$2 = (ctx, status, name2, message) => {
|
|
913
|
+
ctx.status = status;
|
|
914
|
+
ctx.body = { data: null, error: { status, name: name2, message } };
|
|
915
|
+
};
|
|
916
|
+
const articles = ({ strapi }) => {
|
|
917
|
+
const articles2 = () => strapi.plugin("strapi-content-generator-gemini").service("articles");
|
|
918
|
+
const id = (ctx) => ctx.params?.documentId ?? "";
|
|
919
|
+
const run = async (ctx, work) => {
|
|
920
|
+
try {
|
|
921
|
+
ctx.body = await work();
|
|
922
|
+
} catch (err) {
|
|
923
|
+
if (err instanceof InputError) return fail$2(ctx, 400, "BadRequestError", err.message);
|
|
924
|
+
if (err instanceof NotFoundError) return fail$2(ctx, 404, "NotFoundError", err.message);
|
|
925
|
+
if (err instanceof Error && err.name === "ValidationError") return fail$2(ctx, 400, "ValidationError", err.message);
|
|
926
|
+
strapi.log.error(`[articles] ${err instanceof Error ? err.message : String(err)}`);
|
|
927
|
+
return fail$2(ctx, 502, "BadGatewayError", "El servicio de generación no respondió. Intenta de nuevo en un momento.");
|
|
928
|
+
}
|
|
929
|
+
};
|
|
930
|
+
return {
|
|
931
|
+
list: (ctx) => run(ctx, () => articles2().list()),
|
|
932
|
+
detail: (ctx) => run(ctx, () => articles2().detail(id(ctx))),
|
|
933
|
+
generate: (ctx) => run(ctx, () => articles2().generate(parseArticleInput(ctx.request.body ?? {}))),
|
|
934
|
+
enrich: (ctx) => run(ctx, async () => {
|
|
935
|
+
const raw = typeof ctx.request.body?.texto === "string" ? ctx.request.body.texto : "";
|
|
936
|
+
const texto = clean(raw, { label: "La idea", empty: "Escribe una idea para enriquecer." });
|
|
937
|
+
return { texto: await articles2().enrich(texto) };
|
|
938
|
+
}),
|
|
939
|
+
basePrompt: (ctx) => run(ctx, () => articles2().basePrompt()),
|
|
940
|
+
saveBasePrompt: (ctx) => run(ctx, () => articles2().saveBasePrompt(parseBasePrompt(ctx.request.body?.prompt))),
|
|
941
|
+
resetBasePrompt: (ctx) => run(ctx, () => articles2().resetBasePrompt()),
|
|
942
|
+
setImage: (ctx) => run(ctx, () => articles2().setImage(id(ctx), ctx.request.body?.fileId)),
|
|
943
|
+
publish: (ctx) => run(ctx, () => articles2().publish(id(ctx))),
|
|
944
|
+
schedule: (ctx) => run(ctx, () => articles2().schedule(id(ctx), ctx.request.body?.at)),
|
|
945
|
+
unschedule: (ctx) => run(ctx, () => articles2().unschedule(id(ctx)))
|
|
946
|
+
};
|
|
947
|
+
};
|
|
513
948
|
const NAVY = "#1F2A44";
|
|
514
949
|
const RED = "#E4002B";
|
|
515
950
|
const TEAL = "#00C7B1";
|
|
@@ -873,7 +1308,8 @@ const controllers = {
|
|
|
873
1308
|
controller,
|
|
874
1309
|
config: config$1,
|
|
875
1310
|
"image-studio": imageStudio,
|
|
876
|
-
templates
|
|
1311
|
+
templates,
|
|
1312
|
+
articles
|
|
877
1313
|
};
|
|
878
1314
|
const middlewares = {};
|
|
879
1315
|
const policies = {};
|
|
@@ -887,12 +1323,20 @@ const ADMIN_ACCESS_POLICIES = [
|
|
|
887
1323
|
];
|
|
888
1324
|
const config = { policies: ADMIN_ACCESS_POLICIES };
|
|
889
1325
|
const admin = [
|
|
890
|
-
{ method: "GET", path: "/
|
|
1326
|
+
{ method: "GET", path: "/articles", handler: "articles.list", config },
|
|
1327
|
+
// Las rutas fijas van antes de `/articles/:documentId` para que no las tome como id.
|
|
1328
|
+
{ method: "POST", path: "/articles/generate", handler: "articles.generate", config },
|
|
1329
|
+
{ method: "POST", path: "/articles/enrich", handler: "articles.enrich", config },
|
|
1330
|
+
{ method: "GET", path: "/articles/base-prompt", handler: "articles.basePrompt", config },
|
|
1331
|
+
{ method: "PUT", path: "/articles/base-prompt", handler: "articles.saveBasePrompt", config },
|
|
1332
|
+
{ method: "DELETE", path: "/articles/base-prompt", handler: "articles.resetBasePrompt", config },
|
|
1333
|
+
{ method: "GET", path: "/articles/:documentId", handler: "articles.detail", config },
|
|
1334
|
+
{ method: "PUT", path: "/articles/:documentId/image", handler: "articles.setImage", config },
|
|
1335
|
+
{ method: "POST", path: "/articles/:documentId/publish", handler: "articles.publish", config },
|
|
1336
|
+
{ method: "POST", path: "/articles/:documentId/schedule", handler: "articles.schedule", config },
|
|
1337
|
+
{ method: "DELETE", path: "/articles/:documentId/schedule", handler: "articles.unschedule", config },
|
|
891
1338
|
{ method: "GET", path: "/categories", handler: "controller.getCategories", config },
|
|
892
|
-
{ method: "GET", path: "/users", handler: "controller.getUsers", config },
|
|
893
1339
|
{ method: "POST", path: "/download-file", handler: "controller.downloadFile", config },
|
|
894
|
-
{ method: "POST", path: "/generate-article", handler: "controller.generateArticle", config },
|
|
895
|
-
{ method: "POST", path: "/generate-image", handler: "controller.generateImageForArticle", config },
|
|
896
1340
|
{ method: "GET", path: "/image-studio/models", handler: "image-studio.models", config },
|
|
897
1341
|
{ method: "POST", path: "/image-studio/generate", handler: "image-studio.generate", config },
|
|
898
1342
|
{ method: "POST", path: "/image-studio/edit", handler: "image-studio.edit", config },
|
|
@@ -4171,6 +4615,11 @@ const createGeminiClient = ({ serviceAccount, apiKey, allowVertex = true }, Ctor
|
|
|
4171
4615
|
allowVertex ? "Configura la cuenta de servicio de Vertex AI o la llave de Gemini en Ajustes del plugin." : "El video necesita la llave de Gemini en Ajustes del plugin."
|
|
4172
4616
|
);
|
|
4173
4617
|
};
|
|
4618
|
+
const postData = (article) => ({
|
|
4619
|
+
titulo: article.titulo ?? "",
|
|
4620
|
+
descripcion: article.descripcion ?? "",
|
|
4621
|
+
keywords: (Array.isArray(article.keywords) ? article.keywords : []).map((k) => k?.name ?? "")
|
|
4622
|
+
});
|
|
4174
4623
|
const service = ({ strapi }) => {
|
|
4175
4624
|
return {
|
|
4176
4625
|
// ───────────────────────────────
|
|
@@ -4190,15 +4639,9 @@ const service = ({ strapi }) => {
|
|
|
4190
4639
|
async geminiConfig() {
|
|
4191
4640
|
return (await this.geminiClient()).ai;
|
|
4192
4641
|
},
|
|
4193
|
-
async getArticulos(sanitizedQueryParams) {
|
|
4194
|
-
return await strapi.entityService.findMany("api::articulo.articulo", sanitizedQueryParams);
|
|
4195
|
-
},
|
|
4196
4642
|
async getCategorias(sanitizedQueryParams) {
|
|
4197
4643
|
return await strapi.entityService.findMany("api::categoria.categoria", sanitizedQueryParams);
|
|
4198
4644
|
},
|
|
4199
|
-
async getUsuarios(sanitizedQueryParams) {
|
|
4200
|
-
return await strapi.documents("plugin::users-permissions.user").findMany(sanitizedQueryParams);
|
|
4201
|
-
},
|
|
4202
4645
|
// ───────────────────────────────
|
|
4203
4646
|
// 📥 Proxy File Download (bypasses browser CORS)
|
|
4204
4647
|
// ───────────────────────────────
|
|
@@ -4268,232 +4711,6 @@ const service = ({ strapi }) => {
|
|
|
4268
4711
|
}
|
|
4269
4712
|
return uploadedFiles[0];
|
|
4270
4713
|
},
|
|
4271
|
-
// ───────────────────────────────
|
|
4272
|
-
// 🧠 Generate Article with AI
|
|
4273
|
-
// ───────────────────────────────
|
|
4274
|
-
async generateArticle(ctx) {
|
|
4275
|
-
const { titulo, categoria, autor, temperatura = 0.5, instrucciones } = ctx.request.body;
|
|
4276
|
-
const promptArticulo = `
|
|
4277
|
-
# ROL Y PERSONA (EL FONDO)
|
|
4278
|
-
Actúa como un Estratega Tecnológico de nivel C-Level y Arquitecto de Negocios de élite para 'InterWare'. Eres un columnista de opinión experto, escribiendo para una audiencia exclusiva de tomadores de decisión: Directores de TI (CTOs), Directores de Operaciones (COOs) y Directores Financieros (CFOs/FinOps).
|
|
4279
|
-
No eres un redactor de marketing ni un vendedor de software; eres un consultor de alto nivel que diagnostica problemas sistémicos complejos y ofrece perspectivas de valor incalculable. Tu objetivo es hacer que el lector cuestione la madurez de su propia infraestructura.
|
|
4280
|
-
|
|
4281
|
-
# FILOSOFÍA DE MARCA: "ORQUESTACIÓN LATENTE"
|
|
4282
|
-
Toda tu narrativa debe estar impregnada de nuestra visión de negocio: "Evolución por Continuidad"
|
|
4283
|
-
- InterWare no inventa necesidades ni vende catálogos de servicios; InterWare resuelve la complejidad operativa con responsabilidad
|
|
4284
|
-
- Somos el orquestador invisible que garantiza que ecosistemas heterogéneos trabajen en perfecta armonía.
|
|
4285
|
-
- Tu ancla absoluta de confianza y diferenciador es: "El código tiene alma". Esto significa que no entregamos simple software; entregamos sistemas de misión crítica que no fallan, respaldados por equipos humanos de excelencia.
|
|
4286
|
-
- Tu respaldo de autoridad (menciónelo sutilmente como validación, nunca como presunción): Nos avalan casi 30 años de historia, más de 9,000 proyectos exitosos, primer centro autorizado de Java en LATAM y más de 25 años como Partners Gold de IBM. Nuestra prueba de lealtad es que promediamos relaciones de más de 10 años con nuestros clientes.
|
|
4287
|
-
|
|
4288
|
-
# REGLAS DE TONO Y LÉXICO (LA FORMA)
|
|
4289
|
-
1. El Tono: Profundamente autoritario, frío, elegante, quirúrgico y sofisticado. Escribe con la certeza de quien ha visto todas las fallas posibles en la industria y sabe cómo prevenirlas.
|
|
4290
|
-
2. Vocabulario Obligatorio (Usa al menos 4 de forma natural): Orquestación, resiliencia, fricción operativa, deuda técnica, telemetría, anticipación, interoperabilidad, misión crítica, observabilidad profunda, ecosistemas híbridos, primitivas de orquestación.
|
|
4291
|
-
3. Prohibición de Léxico Barato (Zero-Clutter Text): PROHIBIDO usar palabras literales o burdas como "fierros", "cables", "hardware", "servidores físicos" o "computadoras". En su lugar, usa "nodos", "infraestructura", "arquitecturas distribuidas", "activos digitales".
|
|
4292
|
-
|
|
4293
|
-
# RESTRICCIONES ESTRICTAS ANTI-IA (EL ESTILO)
|
|
4294
|
-
- Cero Clichés de Marketing: NUNCA uses frases trilladas como "en el mundo digital de hoy", "en la era actual", "desata el poder", "revolucionar el mercado", "lleva tu empresa al siguiente nivel" o "somos tu mejor opción".
|
|
4295
|
-
- Cero Estructuras Robóticas: Evita transiciones predecibles como "En resumen", "En conclusión", "En primer lugar", "En segundo lugar".
|
|
4296
|
-
- Fluidez Orgánica: No abuses de las listas de viñetas (bullets). Si debes listar beneficios, intégralos en la prosa de manera fluida o usa negritas sutiles dentro de un párrafo.
|
|
4297
|
-
- Espacio Negativo Textual: Los tomadores de decisión no leen bloques densos de texto. Usa párrafos cortos (máximo 3 o 4 líneas). El texto debe "respirar".
|
|
4298
|
-
|
|
4299
|
-
# INSTRUCCIÓN DE EJECUCIÓN: LA NARRATIVA DE LA COLUMNA
|
|
4300
|
-
Redacta una columna de opinión profunda y analítica sobre el siguiente tema:
|
|
4301
|
-
${titulo ? `- Título sugerido: "${titulo}"` : ""}
|
|
4302
|
-
${categoria ? `- Categoría or tópico: "${categoria}"` : ""}
|
|
4303
|
-
${instrucciones ? `- Instrucciones adicionales: "${instrucciones}"` : ""}
|
|
4304
|
-
|
|
4305
|
-
Tu artículo debe seguir este arco narrativo impecable (entre 600 y 800 palabras):
|
|
4306
|
-
|
|
4307
|
-
1. EL TÍTULO ABSTRACTO: Crea un título de menos de 10 palabras que evoque un dolor de negocio o una revelación financiera, no una característica de software. Debe sonar a un artículo de *Harvard Business Review*.
|
|
4308
|
-
2. LA FRICCIÓN (El Diagnóstico Crudo): Abre el artículo sin introducciones banales. Golpea directamente diagnosticando el dolor del negocio (ej. la pérdida de rentabilidad por cuellos de botella operativos o el riesgo inminente de sistemas legados que nadie sabe mantener). Haz que el lector sienta que estás describiendo su empresa.
|
|
4309
|
-
3. LA ORQUESTACIÓN (El Núcleo): Desarrolla tu argumento explicando cómo una arquitectura inteligente (ya sea a través de microservicios, DevSecOps, automatización inteligente de negocio, o monitoreo sintético con herramientas como iwRobot) resuelve este problema de raíz. Transmite la idea de que la tecnología debe ser un habilitador transparente, no una carga.
|
|
4310
|
-
4. EL IMPACTO LATENTE: Teje dentro de la prosa (sin usar viñetas obvias de IA) los tres impactos estratégicos del tema. Habla de cómo se logra la Resiliencia, la Fluidez operativa, la Eficiencia financiera o la Anticipación ante el riesgo.
|
|
4311
|
-
5. EL CIERRE SUTIL (El CTA Elegante): En lugar de un llamado a la acción desesperado de ventas, cierra con una reflexión ejecutiva o una pregunta abierta que invite al lector a auditar mentalmente la madurez de su propia infraestructura. Una despedida que demuestre que, para orquestar la complejidad, se requiere la maestría de tres décadas.
|
|
4312
|
-
|
|
4313
|
-
Recuerda: Eres un experto hablando con otro experto. Sé denso en valor y conciso en palabras
|
|
4314
|
-
`;
|
|
4315
|
-
try {
|
|
4316
|
-
const ai = await this.geminiConfig();
|
|
4317
|
-
const result = await ai.models.generateContent({
|
|
4318
|
-
model: "gemini-2.5-pro",
|
|
4319
|
-
contents: promptArticulo,
|
|
4320
|
-
config: {
|
|
4321
|
-
responseMimeType: "application/json",
|
|
4322
|
-
temperature: temperatura,
|
|
4323
|
-
responseSchema: {
|
|
4324
|
-
type: "object",
|
|
4325
|
-
required: ["titulo", "descripcion", "content", "seo", "minute_read"],
|
|
4326
|
-
properties: {
|
|
4327
|
-
seo: {
|
|
4328
|
-
type: "object",
|
|
4329
|
-
required: ["metaTitle", "metaDescription", "metadata"],
|
|
4330
|
-
properties: {
|
|
4331
|
-
metaTitle: {
|
|
4332
|
-
type: "string"
|
|
4333
|
-
},
|
|
4334
|
-
metaDescription: {
|
|
4335
|
-
type: "string"
|
|
4336
|
-
},
|
|
4337
|
-
metadata: {
|
|
4338
|
-
type: "array",
|
|
4339
|
-
items: {
|
|
4340
|
-
type: "object",
|
|
4341
|
-
required: ["name", "content"],
|
|
4342
|
-
properties: {
|
|
4343
|
-
name: { type: "string" },
|
|
4344
|
-
content: { type: "string" }
|
|
4345
|
-
}
|
|
4346
|
-
}
|
|
4347
|
-
}
|
|
4348
|
-
}
|
|
4349
|
-
},
|
|
4350
|
-
titulo: {
|
|
4351
|
-
type: "string",
|
|
4352
|
-
minLength: 40,
|
|
4353
|
-
maxLength: 70,
|
|
4354
|
-
description: "Título del artículo"
|
|
4355
|
-
},
|
|
4356
|
-
descripcion: {
|
|
4357
|
-
type: "string",
|
|
4358
|
-
minLength: 80,
|
|
4359
|
-
maxLength: 160,
|
|
4360
|
-
description: "Descripción breve del artículo"
|
|
4361
|
-
},
|
|
4362
|
-
slug: {
|
|
4363
|
-
type: "string",
|
|
4364
|
-
minLength: 80,
|
|
4365
|
-
maxLength: 160,
|
|
4366
|
-
description: "URL-friendly basado en el título (ej: estrategias-migracion-nube)"
|
|
4367
|
-
},
|
|
4368
|
-
content: {
|
|
4369
|
-
type: "string",
|
|
4370
|
-
description: "Contenido HTML compatible con CKEditor"
|
|
4371
|
-
},
|
|
4372
|
-
minute_read: {
|
|
4373
|
-
type: "integer",
|
|
4374
|
-
minimum: 1,
|
|
4375
|
-
maximum: 30,
|
|
4376
|
-
description: "Tiempo de lectura calculado usando 200 palabras por minuto"
|
|
4377
|
-
},
|
|
4378
|
-
keywords: {
|
|
4379
|
-
type: "array",
|
|
4380
|
-
minItems: 3,
|
|
4381
|
-
maxItems: 5,
|
|
4382
|
-
items: {
|
|
4383
|
-
type: "object",
|
|
4384
|
-
required: ["name"],
|
|
4385
|
-
properties: {
|
|
4386
|
-
name: { type: "string" }
|
|
4387
|
-
}
|
|
4388
|
-
},
|
|
4389
|
-
description: "Hash tags y palabras clave para la publicación en LinkedIn y Facebook"
|
|
4390
|
-
}
|
|
4391
|
-
}
|
|
4392
|
-
}
|
|
4393
|
-
}
|
|
4394
|
-
});
|
|
4395
|
-
const rawText = result.candidates?.[0]?.content?.parts?.[0]?.text ?? "";
|
|
4396
|
-
if (!rawText) {
|
|
4397
|
-
throw new Error(
|
|
4398
|
-
"La IA no generó texto. Revisa el prompt o el modelo."
|
|
4399
|
-
);
|
|
4400
|
-
}
|
|
4401
|
-
const parsed = JSON.parse(rawText);
|
|
4402
|
-
const article = await strapi.documents("api::articulo.articulo").create({
|
|
4403
|
-
data: {
|
|
4404
|
-
titulo: parsed.titulo,
|
|
4405
|
-
descripcion: parsed.descripcion,
|
|
4406
|
-
content: parsed.content,
|
|
4407
|
-
seo: parsed.seo,
|
|
4408
|
-
minute_read: parsed.minute_read,
|
|
4409
|
-
slug: parsed.slug,
|
|
4410
|
-
categoria,
|
|
4411
|
-
autor,
|
|
4412
|
-
image: null,
|
|
4413
|
-
keywords: parsed.keywords
|
|
4414
|
-
},
|
|
4415
|
-
status: "draft"
|
|
4416
|
-
});
|
|
4417
|
-
return article;
|
|
4418
|
-
} catch (err) {
|
|
4419
|
-
console.error("🚀 ~ generateArticle ~ err:", err);
|
|
4420
|
-
throw new Error(
|
|
4421
|
-
"Error al generar el artículo. Revisa el prompt, el modelo o el JSON generado."
|
|
4422
|
-
);
|
|
4423
|
-
}
|
|
4424
|
-
},
|
|
4425
|
-
// ───────────────────────────────
|
|
4426
|
-
// 🖼️ Generate Image for Article
|
|
4427
|
-
// ───────────────────────────────
|
|
4428
|
-
async generateImageForArticle(ctx) {
|
|
4429
|
-
const { articleId, aspectRatio, imageSize } = ctx.request.body;
|
|
4430
|
-
if (!articleId) {
|
|
4431
|
-
throw new Error(
|
|
4432
|
-
"Se requiere 'articleId' en el cuerpo de la petición."
|
|
4433
|
-
);
|
|
4434
|
-
}
|
|
4435
|
-
try {
|
|
4436
|
-
const article = await strapi.documents("api::articulo.articulo").findOne({
|
|
4437
|
-
documentId: articleId
|
|
4438
|
-
});
|
|
4439
|
-
const promptJson = {
|
|
4440
|
-
label: `blog-header-${articleId}`,
|
|
4441
|
-
task: "generate_image",
|
|
4442
|
-
canvas: {
|
|
4443
|
-
resolution: imageSize || "1K",
|
|
4444
|
-
aspect_ratio: aspectRatio || "16:9"
|
|
4445
|
-
},
|
|
4446
|
-
style: {
|
|
4447
|
-
type: "architectural software engineering visualization",
|
|
4448
|
-
aesthetic: "high-end technical 3D render / octane style",
|
|
4449
|
-
background: "view of a bright white minimalist architectural gallery, sun-drenched space, white polished floors and tall glass windows",
|
|
4450
|
-
lighting: "intense natural daylight, soft global illumination, clean diffused light with subtle volumetric rays from windows"
|
|
4451
|
-
},
|
|
4452
|
-
concept_brief: `Create a sophisticated 3D visualization that metaphorically represents the concept: "${article.titulo}" — ${article.descripcion}`,
|
|
4453
|
-
subject: [
|
|
4454
|
-
"a complex holographic or architectural 3D structure floating at the center of the gallery, inspired by the concept brief above",
|
|
4455
|
-
"materials: frosted glass panels, white matte ceramic components, and polished metal accents",
|
|
4456
|
-
"glowing energy connections and data filaments in red #E4002B and teal #00C7B1 connecting the structural elements",
|
|
4457
|
-
"the structure casts a sharp, clean reflection on the white polished gallery floor",
|
|
4458
|
-
"STRICTLY NO people, NO humans, NO hands, NO chairs",
|
|
4459
|
-
"STRICTLY NO text, NO labels, NO words, NO letters, NO numbers",
|
|
4460
|
-
"high-end 3D render, architectural photography style, 4k quality"
|
|
4461
|
-
],
|
|
4462
|
-
camera: {
|
|
4463
|
-
perspective: "three-quarter wide shot, slightly elevated, eye-level with the structure, showing both the subject and the gallery environment",
|
|
4464
|
-
depth_of_field: "the main structure in sharp focus, gallery background beautifully blurred"
|
|
4465
|
-
},
|
|
4466
|
-
render: {
|
|
4467
|
-
engine: "Octane or Cinema 4D",
|
|
4468
|
-
effects: "bloom on glowing filaments and energy elements, ray-traced reflections on the white polished floor, subsurface scattering on frosted glass panels, volumetric light rays from gallery windows",
|
|
4469
|
-
quality: "masterpiece, hyper-detailed"
|
|
4470
|
-
},
|
|
4471
|
-
output_format: "image/png"
|
|
4472
|
-
};
|
|
4473
|
-
const promptText = JSON.stringify(promptJson);
|
|
4474
|
-
const generatedBase64Image = await strapi.plugin("strapi-content-generator-gemini").service("image-studio").generateForArticle(promptText, aspectRatio || "16:9", imageSize || "1K");
|
|
4475
|
-
const buffer = Buffer.from(generatedBase64Image, "base64");
|
|
4476
|
-
const uploadedFile = await this._uploadGeneratedFile(buffer, `article-${articleId}.png`, "image/png");
|
|
4477
|
-
const articleModel = await strapi.getModel("api::articulo.articulo");
|
|
4478
|
-
const sanitizedData = await strapi.contentAPI.sanitize.input(
|
|
4479
|
-
{
|
|
4480
|
-
image: uploadedFile.id
|
|
4481
|
-
},
|
|
4482
|
-
articleModel
|
|
4483
|
-
);
|
|
4484
|
-
await strapi.documents("api::articulo.articulo").update({
|
|
4485
|
-
documentId: articleId,
|
|
4486
|
-
data: sanitizedData
|
|
4487
|
-
});
|
|
4488
|
-
return {
|
|
4489
|
-
message: "Imagen generada y asociada correctamente.",
|
|
4490
|
-
image: uploadedFile
|
|
4491
|
-
};
|
|
4492
|
-
} catch (err) {
|
|
4493
|
-
console.log("🚀 ~ service ~ err:", err);
|
|
4494
|
-
throw new Error("Error al generar la imagen para el artículo.");
|
|
4495
|
-
}
|
|
4496
|
-
},
|
|
4497
4714
|
// 🎞️ Internal Veo Video Generation / Extension Helper
|
|
4498
4715
|
async _callVeoVideoGeneration(promptText, aspectRatio, durationSeconds, sceneRefs, previousVideo) {
|
|
4499
4716
|
const { ai } = await this.geminiClient({ allowVertex: false });
|
|
@@ -4583,7 +4800,7 @@ const service = ({ strapi }) => {
|
|
|
4583
4800
|
// 🌐 Social Media Publishing
|
|
4584
4801
|
// ───────────────────────────────
|
|
4585
4802
|
async publishToLinkedIn(ctx) {
|
|
4586
|
-
const { articleId } = ctx.request.body;
|
|
4803
|
+
const { articleId, mensaje } = ctx.request.body;
|
|
4587
4804
|
const pluginStore = strapi.store({
|
|
4588
4805
|
type: "plugin",
|
|
4589
4806
|
name: "strapi-content-generator-gemini"
|
|
@@ -4598,7 +4815,6 @@ const service = ({ strapi }) => {
|
|
|
4598
4815
|
populate: ["image", "keywords"]
|
|
4599
4816
|
});
|
|
4600
4817
|
if (!article) throw new Error(`Artículo con ID ${articleId} no encontrado.`);
|
|
4601
|
-
const hashtags = article.keywords.map((k) => `#${k.name.trim().replace(/\s+/g, "").replace(/[^\w]/g, "")}`).join(" ");
|
|
4602
4818
|
const articleUrl = `https://interware.com.mx/blog/${article.slug}`;
|
|
4603
4819
|
const imageUrl = article.image?.url;
|
|
4604
4820
|
if (!imageUrl) throw new Error("El artículo no tiene imagen.");
|
|
@@ -4633,9 +4849,7 @@ const service = ({ strapi }) => {
|
|
|
4633
4849
|
});
|
|
4634
4850
|
const postBody = {
|
|
4635
4851
|
author: `urn:li:organization:${linkedInOrganizationId}`,
|
|
4636
|
-
commentary:
|
|
4637
|
-
|
|
4638
|
-
${hashtags}`,
|
|
4852
|
+
commentary: typeof mensaje === "string" && mensaje.trim() || defaultPostText(postData(article), "linkedin"),
|
|
4639
4853
|
visibility: "PUBLIC",
|
|
4640
4854
|
distribution: {
|
|
4641
4855
|
feedDistribution: "MAIN_FEED",
|
|
@@ -4673,7 +4887,7 @@ ${hashtags}`,
|
|
|
4673
4887
|
}
|
|
4674
4888
|
},
|
|
4675
4889
|
async publishToFacebook(ctx) {
|
|
4676
|
-
const { articleId } = ctx.request.body;
|
|
4890
|
+
const { articleId, mensaje } = ctx.request.body;
|
|
4677
4891
|
const pluginStore = strapi.store({
|
|
4678
4892
|
type: "plugin",
|
|
4679
4893
|
name: "strapi-content-generator-gemini"
|
|
@@ -4702,16 +4916,8 @@ ${hashtags}`,
|
|
|
4702
4916
|
throw new Error(`Artículo con ID ${articleId} no encontrado.`);
|
|
4703
4917
|
}
|
|
4704
4918
|
const articleUrl = `https://interware.com.mx/blog/${article.slug}`;
|
|
4705
|
-
const hashtags = article.keywords.map(
|
|
4706
|
-
(keyword) => `#${keyword.name.trim().replace(/\s+/g, "").replace(/[^\w]/g, "")}`
|
|
4707
|
-
// quita caracteres raros
|
|
4708
|
-
).join(" ");
|
|
4709
4919
|
const postBody = {
|
|
4710
|
-
message:
|
|
4711
|
-
|
|
4712
|
-
${article.descripcion}
|
|
4713
|
-
|
|
4714
|
-
${hashtags}`,
|
|
4920
|
+
message: typeof mensaje === "string" && mensaje.trim() || defaultPostText(postData(article), "facebook"),
|
|
4715
4921
|
link: articleUrl,
|
|
4716
4922
|
access_token: facebookAccessToken
|
|
4717
4923
|
};
|
|
@@ -4748,7 +4954,8 @@ ${hashtags}`,
|
|
|
4748
4954
|
const services = {
|
|
4749
4955
|
service,
|
|
4750
4956
|
"image-studio": imageStudio$1,
|
|
4751
|
-
templates: templates$1
|
|
4957
|
+
templates: templates$1,
|
|
4958
|
+
articles: articles$1
|
|
4752
4959
|
};
|
|
4753
4960
|
const index = {
|
|
4754
4961
|
register,
|