Implementación inicial del sistema de automatización de redes sociales
- Estructura completa del proyecto con FastAPI - Modelos de base de datos (productos, servicios, posts, calendario, interacciones) - Publishers para X, Threads, Instagram, Facebook - Generador de contenido con DeepSeek API - Worker de Celery con tareas programadas - Dashboard básico con templates HTML - Docker Compose para despliegue - Documentación completa Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
1
app/services/__init__.py
Normal file
1
app/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Services module
|
||||
314
app/services/content_generator.py
Normal file
314
app/services/content_generator.py
Normal file
@@ -0,0 +1,314 @@
|
||||
"""
|
||||
Servicio de generación de contenido con DeepSeek API.
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Optional, List, Dict
|
||||
from openai import OpenAI
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
class ContentGenerator:
|
||||
"""Generador de contenido usando DeepSeek API."""
|
||||
|
||||
def __init__(self):
|
||||
self.client = OpenAI(
|
||||
api_key=settings.DEEPSEEK_API_KEY,
|
||||
base_url=settings.DEEPSEEK_BASE_URL
|
||||
)
|
||||
self.model = "deepseek-chat"
|
||||
|
||||
def _get_system_prompt(self) -> str:
|
||||
"""Obtener el prompt del sistema con la personalidad de la marca."""
|
||||
return f"""Eres el Community Manager de {settings.BUSINESS_NAME}, una empresa de tecnología ubicada en {settings.BUSINESS_LOCATION}.
|
||||
|
||||
SOBRE LA EMPRESA:
|
||||
- Especializada en soluciones de IA, automatización y transformación digital
|
||||
- Vende equipos de cómputo e impresoras 3D
|
||||
- Sitio web: {settings.BUSINESS_WEBSITE}
|
||||
|
||||
TONO DE COMUNICACIÓN:
|
||||
{settings.CONTENT_TONE}
|
||||
|
||||
ESTILO (inspirado en @midudev, @MoureDev, @SoyDalto):
|
||||
- Tips cortos y accionables
|
||||
- Contenido educativo de valor
|
||||
- Cercano pero profesional
|
||||
- Uso moderado de emojis
|
||||
- Hashtags relevantes (máximo 3-5)
|
||||
|
||||
REGLAS:
|
||||
- Nunca uses lenguaje ofensivo
|
||||
- No hagas promesas exageradas
|
||||
- Sé honesto y transparente
|
||||
- Enfócate en ayudar, no en vender directamente
|
||||
- Adapta el contenido a cada plataforma"""
|
||||
|
||||
async def generate_tip_tech(
|
||||
self,
|
||||
category: str,
|
||||
platform: str,
|
||||
template: Optional[str] = None
|
||||
) -> str:
|
||||
"""Generar un tip tech."""
|
||||
char_limits = {
|
||||
"x": 280,
|
||||
"threads": 500,
|
||||
"instagram": 2200,
|
||||
"facebook": 500
|
||||
}
|
||||
|
||||
prompt = f"""Genera un tip de tecnología para la categoría: {category}
|
||||
|
||||
PLATAFORMA: {platform}
|
||||
LÍMITE DE CARACTERES: {char_limits.get(platform, 500)}
|
||||
|
||||
{f'USA ESTE TEMPLATE COMO BASE: {template}' if template else ''}
|
||||
|
||||
REQUISITOS:
|
||||
- Tip práctico y accionable
|
||||
- Fácil de entender
|
||||
- Incluye un emoji relevante al inicio
|
||||
- Termina con 2-3 hashtags relevantes
|
||||
- NO incluyas enlaces
|
||||
|
||||
Responde SOLO con el texto del post, sin explicaciones."""
|
||||
|
||||
response = self.client.chat.completions.create(
|
||||
model=self.model,
|
||||
messages=[
|
||||
{"role": "system", "content": self._get_system_prompt()},
|
||||
{"role": "user", "content": prompt}
|
||||
],
|
||||
max_tokens=300,
|
||||
temperature=0.7
|
||||
)
|
||||
|
||||
return response.choices[0].message.content.strip()
|
||||
|
||||
async def generate_product_post(
|
||||
self,
|
||||
product: Dict,
|
||||
platform: str
|
||||
) -> str:
|
||||
"""Generar post para un producto."""
|
||||
char_limits = {
|
||||
"x": 280,
|
||||
"threads": 500,
|
||||
"instagram": 2200,
|
||||
"facebook": 1000
|
||||
}
|
||||
|
||||
prompt = f"""Genera un post promocional para este producto:
|
||||
|
||||
PRODUCTO: {product['name']}
|
||||
DESCRIPCIÓN: {product.get('description', 'N/A')}
|
||||
PRECIO: ${product['price']:,.2f} MXN
|
||||
CATEGORÍA: {product['category']}
|
||||
ESPECIFICACIONES: {json.dumps(product.get('specs', {}), ensure_ascii=False)}
|
||||
PUNTOS DESTACADOS: {', '.join(product.get('highlights', []))}
|
||||
|
||||
PLATAFORMA: {platform}
|
||||
LÍMITE DE CARACTERES: {char_limits.get(platform, 500)}
|
||||
|
||||
REQUISITOS:
|
||||
- Destaca los beneficios principales
|
||||
- Incluye el precio
|
||||
- Usa emojis relevantes
|
||||
- Incluye CTA sutil (ej: "Contáctanos", "Más info en DM")
|
||||
- Termina con 2-3 hashtags
|
||||
- NO inventes especificaciones
|
||||
|
||||
Responde SOLO con el texto del post."""
|
||||
|
||||
response = self.client.chat.completions.create(
|
||||
model=self.model,
|
||||
messages=[
|
||||
{"role": "system", "content": self._get_system_prompt()},
|
||||
{"role": "user", "content": prompt}
|
||||
],
|
||||
max_tokens=400,
|
||||
temperature=0.7
|
||||
)
|
||||
|
||||
return response.choices[0].message.content.strip()
|
||||
|
||||
async def generate_service_post(
|
||||
self,
|
||||
service: Dict,
|
||||
platform: str
|
||||
) -> str:
|
||||
"""Generar post para un servicio."""
|
||||
char_limits = {
|
||||
"x": 280,
|
||||
"threads": 500,
|
||||
"instagram": 2200,
|
||||
"facebook": 1000
|
||||
}
|
||||
|
||||
prompt = f"""Genera un post promocional para este servicio:
|
||||
|
||||
SERVICIO: {service['name']}
|
||||
DESCRIPCIÓN: {service.get('description', 'N/A')}
|
||||
CATEGORÍA: {service['category']}
|
||||
SECTORES OBJETIVO: {', '.join(service.get('target_sectors', []))}
|
||||
BENEFICIOS: {', '.join(service.get('benefits', []))}
|
||||
CTA: {service.get('call_to_action', 'Contáctanos para más información')}
|
||||
|
||||
PLATAFORMA: {platform}
|
||||
LÍMITE DE CARACTERES: {char_limits.get(platform, 500)}
|
||||
|
||||
REQUISITOS:
|
||||
- Enfócate en el problema que resuelve
|
||||
- Destaca 2-3 beneficios clave
|
||||
- Usa emojis relevantes (✅, 🚀, 💡)
|
||||
- Incluye el CTA
|
||||
- Termina con 2-3 hashtags
|
||||
- Tono consultivo, no vendedor
|
||||
|
||||
Responde SOLO con el texto del post."""
|
||||
|
||||
response = self.client.chat.completions.create(
|
||||
model=self.model,
|
||||
messages=[
|
||||
{"role": "system", "content": self._get_system_prompt()},
|
||||
{"role": "user", "content": prompt}
|
||||
],
|
||||
max_tokens=400,
|
||||
temperature=0.7
|
||||
)
|
||||
|
||||
return response.choices[0].message.content.strip()
|
||||
|
||||
async def generate_thread(
|
||||
self,
|
||||
topic: str,
|
||||
num_posts: int = 5
|
||||
) -> List[str]:
|
||||
"""Generar un hilo educativo."""
|
||||
prompt = f"""Genera un hilo educativo de {num_posts} posts sobre: {topic}
|
||||
|
||||
REQUISITOS:
|
||||
- Post 1: Gancho que capture atención
|
||||
- Posts 2-{num_posts-1}: Contenido educativo de valor
|
||||
- Post {num_posts}: Conclusión con CTA
|
||||
|
||||
FORMATO:
|
||||
- Cada post máximo 280 caracteres
|
||||
- Numera cada post (1/, 2/, etc.)
|
||||
- Usa emojis relevantes
|
||||
- El último post incluye hashtags
|
||||
|
||||
Responde con cada post en una línea separada, sin explicaciones."""
|
||||
|
||||
response = self.client.chat.completions.create(
|
||||
model=self.model,
|
||||
messages=[
|
||||
{"role": "system", "content": self._get_system_prompt()},
|
||||
{"role": "user", "content": prompt}
|
||||
],
|
||||
max_tokens=1500,
|
||||
temperature=0.7
|
||||
)
|
||||
|
||||
content = response.choices[0].message.content.strip()
|
||||
# Separar posts por líneas no vacías
|
||||
posts = [p.strip() for p in content.split('\n') if p.strip()]
|
||||
|
||||
return posts
|
||||
|
||||
async def generate_response_suggestion(
|
||||
self,
|
||||
interaction_content: str,
|
||||
interaction_type: str,
|
||||
context: Optional[str] = None
|
||||
) -> List[str]:
|
||||
"""Generar sugerencias de respuesta para una interacción."""
|
||||
prompt = f"""Un usuario escribió esto en redes sociales:
|
||||
|
||||
"{interaction_content}"
|
||||
|
||||
TIPO DE INTERACCIÓN: {interaction_type}
|
||||
{f'CONTEXTO ADICIONAL: {context}' if context else ''}
|
||||
|
||||
Genera 3 opciones de respuesta diferentes:
|
||||
1. Respuesta corta y amigable
|
||||
2. Respuesta que invite a continuar la conversación
|
||||
3. Respuesta que dirija a más información/contacto
|
||||
|
||||
REQUISITOS:
|
||||
- Máximo 280 caracteres cada una
|
||||
- Tono amigable y profesional
|
||||
- Si es una queja, sé empático
|
||||
- Si es una pregunta técnica, sé útil
|
||||
|
||||
Responde con las 3 opciones numeradas, una por línea."""
|
||||
|
||||
response = self.client.chat.completions.create(
|
||||
model=self.model,
|
||||
messages=[
|
||||
{"role": "system", "content": self._get_system_prompt()},
|
||||
{"role": "user", "content": prompt}
|
||||
],
|
||||
max_tokens=500,
|
||||
temperature=0.8
|
||||
)
|
||||
|
||||
content = response.choices[0].message.content.strip()
|
||||
suggestions = [s.strip() for s in content.split('\n') if s.strip()]
|
||||
|
||||
# Limpiar numeración si existe
|
||||
cleaned = []
|
||||
for s in suggestions:
|
||||
if s[0].isdigit() and (s[1] == '.' or s[1] == ')'):
|
||||
s = s[2:].strip()
|
||||
cleaned.append(s)
|
||||
|
||||
return cleaned[:3] # Máximo 3 sugerencias
|
||||
|
||||
async def adapt_content_for_platform(
|
||||
self,
|
||||
content: str,
|
||||
target_platform: str
|
||||
) -> str:
|
||||
"""Adaptar contenido existente a una plataforma específica."""
|
||||
char_limits = {
|
||||
"x": 280,
|
||||
"threads": 500,
|
||||
"instagram": 2200,
|
||||
"facebook": 1000
|
||||
}
|
||||
|
||||
prompt = f"""Adapta este contenido para {target_platform}:
|
||||
|
||||
CONTENIDO ORIGINAL:
|
||||
{content}
|
||||
|
||||
LÍMITE DE CARACTERES: {char_limits.get(target_platform, 500)}
|
||||
|
||||
REQUISITOS PARA {target_platform.upper()}:
|
||||
{"- Muy conciso, directo al punto" if target_platform == "x" else ""}
|
||||
{"- Puede ser más extenso, incluir más contexto" if target_platform == "instagram" else ""}
|
||||
{"- Tono más casual y cercano" if target_platform == "threads" else ""}
|
||||
{"- Puede incluir links, más profesional" if target_platform == "facebook" else ""}
|
||||
- Mantén la esencia del mensaje
|
||||
- Ajusta hashtags según la plataforma
|
||||
|
||||
Responde SOLO con el contenido adaptado."""
|
||||
|
||||
response = self.client.chat.completions.create(
|
||||
model=self.model,
|
||||
messages=[
|
||||
{"role": "system", "content": self._get_system_prompt()},
|
||||
{"role": "user", "content": prompt}
|
||||
],
|
||||
max_tokens=400,
|
||||
temperature=0.6
|
||||
)
|
||||
|
||||
return response.choices[0].message.content.strip()
|
||||
|
||||
|
||||
# Instancia global
|
||||
content_generator = ContentGenerator()
|
||||
174
app/services/image_generator.py
Normal file
174
app/services/image_generator.py
Normal file
@@ -0,0 +1,174 @@
|
||||
"""
|
||||
Servicio de generación de imágenes para posts.
|
||||
"""
|
||||
|
||||
import os
|
||||
from typing import Dict, Optional
|
||||
from pathlib import Path
|
||||
from html2image import Html2Image
|
||||
from PIL import Image
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
class ImageGenerator:
|
||||
"""Generador de imágenes usando plantillas HTML."""
|
||||
|
||||
def __init__(self):
|
||||
self.templates_dir = Path("templates")
|
||||
self.output_dir = Path("uploads/generated")
|
||||
self.output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
self.jinja_env = Environment(
|
||||
loader=FileSystemLoader(self.templates_dir)
|
||||
)
|
||||
|
||||
self.hti = Html2Image(
|
||||
output_path=str(self.output_dir),
|
||||
custom_flags=['--no-sandbox', '--disable-gpu']
|
||||
)
|
||||
|
||||
def _render_template(self, template_name: str, variables: Dict) -> str:
|
||||
"""Renderizar una plantilla HTML con variables."""
|
||||
template = self.jinja_env.get_template(template_name)
|
||||
return template.render(**variables)
|
||||
|
||||
async def generate_tip_card(
|
||||
self,
|
||||
title: str,
|
||||
content: str,
|
||||
category: str,
|
||||
output_name: str
|
||||
) -> str:
|
||||
"""Generar imagen de tip tech."""
|
||||
variables = {
|
||||
"title": title,
|
||||
"content": content,
|
||||
"category": category,
|
||||
"logo_url": f"{settings.BUSINESS_WEBSITE}/logo.png",
|
||||
"website": settings.BUSINESS_WEBSITE,
|
||||
"business_name": settings.BUSINESS_NAME
|
||||
}
|
||||
|
||||
html_content = self._render_template("tip_card.html", variables)
|
||||
|
||||
# Generar imagen
|
||||
output_file = f"{output_name}.png"
|
||||
self.hti.screenshot(
|
||||
html_str=html_content,
|
||||
save_as=output_file,
|
||||
size=(1080, 1080)
|
||||
)
|
||||
|
||||
return str(self.output_dir / output_file)
|
||||
|
||||
async def generate_product_card(
|
||||
self,
|
||||
name: str,
|
||||
price: float,
|
||||
image_url: str,
|
||||
highlights: list,
|
||||
output_name: str
|
||||
) -> str:
|
||||
"""Generar imagen de producto."""
|
||||
variables = {
|
||||
"name": name,
|
||||
"price": f"${price:,.2f} MXN",
|
||||
"image_url": image_url,
|
||||
"highlights": highlights[:3], # Máximo 3 highlights
|
||||
"logo_url": f"{settings.BUSINESS_WEBSITE}/logo.png",
|
||||
"website": settings.BUSINESS_WEBSITE,
|
||||
"business_name": settings.BUSINESS_NAME
|
||||
}
|
||||
|
||||
html_content = self._render_template("product_card.html", variables)
|
||||
|
||||
output_file = f"{output_name}.png"
|
||||
self.hti.screenshot(
|
||||
html_str=html_content,
|
||||
save_as=output_file,
|
||||
size=(1080, 1080)
|
||||
)
|
||||
|
||||
return str(self.output_dir / output_file)
|
||||
|
||||
async def generate_service_card(
|
||||
self,
|
||||
name: str,
|
||||
tagline: str,
|
||||
benefits: list,
|
||||
icon: str,
|
||||
output_name: str
|
||||
) -> str:
|
||||
"""Generar imagen de servicio."""
|
||||
variables = {
|
||||
"name": name,
|
||||
"tagline": tagline,
|
||||
"benefits": benefits[:4], # Máximo 4 beneficios
|
||||
"icon": icon,
|
||||
"logo_url": f"{settings.BUSINESS_WEBSITE}/logo.png",
|
||||
"website": settings.BUSINESS_WEBSITE,
|
||||
"business_name": settings.BUSINESS_NAME
|
||||
}
|
||||
|
||||
html_content = self._render_template("service_card.html", variables)
|
||||
|
||||
output_file = f"{output_name}.png"
|
||||
self.hti.screenshot(
|
||||
html_str=html_content,
|
||||
save_as=output_file,
|
||||
size=(1080, 1080)
|
||||
)
|
||||
|
||||
return str(self.output_dir / output_file)
|
||||
|
||||
async def resize_for_platform(
|
||||
self,
|
||||
image_path: str,
|
||||
platform: str
|
||||
) -> str:
|
||||
"""Redimensionar imagen para una plataforma específica."""
|
||||
sizes = {
|
||||
"x": (1200, 675), # 16:9
|
||||
"threads": (1080, 1080), # 1:1
|
||||
"instagram": (1080, 1080), # 1:1
|
||||
"facebook": (1200, 630) # ~1.9:1
|
||||
}
|
||||
|
||||
target_size = sizes.get(platform, (1080, 1080))
|
||||
|
||||
img = Image.open(image_path)
|
||||
|
||||
# Crear nueva imagen con el tamaño objetivo
|
||||
new_img = Image.new('RGB', target_size, (26, 26, 46)) # Color de fondo de la marca
|
||||
|
||||
# Calcular posición para centrar
|
||||
img_ratio = img.width / img.height
|
||||
target_ratio = target_size[0] / target_size[1]
|
||||
|
||||
if img_ratio > target_ratio:
|
||||
# Imagen más ancha
|
||||
new_width = target_size[0]
|
||||
new_height = int(new_width / img_ratio)
|
||||
else:
|
||||
# Imagen más alta
|
||||
new_height = target_size[1]
|
||||
new_width = int(new_height * img_ratio)
|
||||
|
||||
img_resized = img.resize((new_width, new_height), Image.Resampling.LANCZOS)
|
||||
|
||||
# Centrar en la nueva imagen
|
||||
x = (target_size[0] - new_width) // 2
|
||||
y = (target_size[1] - new_height) // 2
|
||||
new_img.paste(img_resized, (x, y))
|
||||
|
||||
# Guardar
|
||||
output_path = image_path.replace('.png', f'_{platform}.png')
|
||||
new_img.save(output_path, 'PNG', quality=95)
|
||||
|
||||
return output_path
|
||||
|
||||
|
||||
# Instancia global
|
||||
image_generator = ImageGenerator()
|
||||
Reference in New Issue
Block a user