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
worker/tasks/__init__.py
Normal file
1
worker/tasks/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Celery Tasks
|
||||
86
worker/tasks/cleanup.py
Normal file
86
worker/tasks/cleanup.py
Normal file
@@ -0,0 +1,86 @@
|
||||
"""
|
||||
Tareas de limpieza y mantenimiento.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from worker.celery_app import celery_app
|
||||
from app.core.database import SessionLocal
|
||||
from app.models.post import Post
|
||||
from app.models.interaction import Interaction
|
||||
|
||||
|
||||
@celery_app.task(name="worker.tasks.cleanup.daily_cleanup")
|
||||
def daily_cleanup():
|
||||
"""
|
||||
Limpieza diaria del sistema.
|
||||
Se ejecuta a las 3 AM.
|
||||
"""
|
||||
db = SessionLocal()
|
||||
|
||||
try:
|
||||
results = []
|
||||
|
||||
# 1. Eliminar posts fallidos de más de 7 días
|
||||
cutoff_failed = datetime.utcnow() - timedelta(days=7)
|
||||
deleted_failed = db.query(Post).filter(
|
||||
Post.status == "failed",
|
||||
Post.created_at < cutoff_failed
|
||||
).delete()
|
||||
results.append(f"Posts fallidos eliminados: {deleted_failed}")
|
||||
|
||||
# 2. Archivar interacciones respondidas de más de 30 días
|
||||
cutoff_interactions = datetime.utcnow() - timedelta(days=30)
|
||||
archived = db.query(Interaction).filter(
|
||||
Interaction.responded == True,
|
||||
Interaction.responded_at < cutoff_interactions,
|
||||
Interaction.is_archived == False
|
||||
).update({"is_archived": True})
|
||||
results.append(f"Interacciones archivadas: {archived}")
|
||||
|
||||
# 3. Eliminar posts cancelados de más de 14 días
|
||||
cutoff_cancelled = datetime.utcnow() - timedelta(days=14)
|
||||
deleted_cancelled = db.query(Post).filter(
|
||||
Post.status == "cancelled",
|
||||
Post.created_at < cutoff_cancelled
|
||||
).delete()
|
||||
results.append(f"Posts cancelados eliminados: {deleted_cancelled}")
|
||||
|
||||
# 4. Resetear contadores de tips (mensualmente, si es día 1)
|
||||
if datetime.utcnow().day == 1:
|
||||
from app.models.tip_template import TipTemplate
|
||||
db.query(TipTemplate).update({"used_count": 0})
|
||||
results.append("Contadores de tips reseteados")
|
||||
|
||||
db.commit()
|
||||
|
||||
return results
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
return f"Error en limpieza: {e}"
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@celery_app.task(name="worker.tasks.cleanup.cleanup_old_images")
|
||||
def cleanup_old_images():
|
||||
"""Limpiar imágenes generadas antiguas."""
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
upload_dir = Path("uploads/generated")
|
||||
if not upload_dir.exists():
|
||||
return "Directorio de uploads no existe"
|
||||
|
||||
cutoff = datetime.utcnow() - timedelta(days=7)
|
||||
deleted = 0
|
||||
|
||||
for file in upload_dir.glob("*.png"):
|
||||
file_time = datetime.fromtimestamp(file.stat().st_mtime)
|
||||
if file_time < cutoff:
|
||||
file.unlink()
|
||||
deleted += 1
|
||||
|
||||
return f"Imágenes eliminadas: {deleted}"
|
||||
123
worker/tasks/fetch_interactions.py
Normal file
123
worker/tasks/fetch_interactions.py
Normal file
@@ -0,0 +1,123 @@
|
||||
"""
|
||||
Tareas para obtener interacciones de redes sociales.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
|
||||
from worker.celery_app import celery_app
|
||||
from app.core.database import SessionLocal
|
||||
from app.models.interaction import Interaction
|
||||
from app.models.post import Post
|
||||
from app.publishers import get_publisher
|
||||
|
||||
|
||||
def run_async(coro):
|
||||
"""Helper para ejecutar coroutines en Celery."""
|
||||
loop = asyncio.get_event_loop()
|
||||
return loop.run_until_complete(coro)
|
||||
|
||||
|
||||
@celery_app.task(name="worker.tasks.fetch_interactions.fetch_all_interactions")
|
||||
def fetch_all_interactions():
|
||||
"""
|
||||
Obtener interacciones de todas las plataformas.
|
||||
Se ejecuta cada 5 minutos.
|
||||
"""
|
||||
platforms = ["x", "threads", "instagram", "facebook"]
|
||||
results = []
|
||||
|
||||
for platform in platforms:
|
||||
try:
|
||||
result = fetch_platform_interactions.delay(platform)
|
||||
results.append(f"{platform}: tarea enviada")
|
||||
except Exception as e:
|
||||
results.append(f"{platform}: error - {e}")
|
||||
|
||||
return results
|
||||
|
||||
|
||||
@celery_app.task(name="worker.tasks.fetch_interactions.fetch_platform_interactions")
|
||||
def fetch_platform_interactions(platform: str):
|
||||
"""Obtener interacciones de una plataforma específica."""
|
||||
db = SessionLocal()
|
||||
|
||||
try:
|
||||
publisher = get_publisher(platform)
|
||||
|
||||
# Obtener menciones
|
||||
mentions = run_async(publisher.get_mentions())
|
||||
new_mentions = 0
|
||||
|
||||
for mention in mentions:
|
||||
external_id = mention.get("id")
|
||||
|
||||
# Verificar si ya existe
|
||||
existing = db.query(Interaction).filter(
|
||||
Interaction.external_id == external_id
|
||||
).first()
|
||||
|
||||
if not existing:
|
||||
interaction = Interaction(
|
||||
platform=platform,
|
||||
interaction_type="mention",
|
||||
external_id=external_id,
|
||||
author_username=mention.get("username", mention.get("author_id", "unknown")),
|
||||
author_name=mention.get("name"),
|
||||
content=mention.get("text", mention.get("message")),
|
||||
interaction_at=datetime.fromisoformat(
|
||||
mention.get("created_at", datetime.utcnow().isoformat()).replace("Z", "+00:00")
|
||||
) if mention.get("created_at") else datetime.utcnow()
|
||||
)
|
||||
db.add(interaction)
|
||||
new_mentions += 1
|
||||
|
||||
# Obtener comentarios de posts recientes
|
||||
recent_posts = db.query(Post).filter(
|
||||
Post.platform_post_ids.isnot(None),
|
||||
Post.platforms.contains([platform])
|
||||
).order_by(Post.published_at.desc()).limit(10).all()
|
||||
|
||||
new_comments = 0
|
||||
|
||||
for post in recent_posts:
|
||||
platform_id = post.platform_post_ids.get(platform)
|
||||
if not platform_id:
|
||||
continue
|
||||
|
||||
comments = run_async(publisher.get_comments(platform_id))
|
||||
|
||||
for comment in comments:
|
||||
external_id = comment.get("id")
|
||||
|
||||
existing = db.query(Interaction).filter(
|
||||
Interaction.external_id == external_id
|
||||
).first()
|
||||
|
||||
if not existing:
|
||||
interaction = Interaction(
|
||||
platform=platform,
|
||||
interaction_type="comment",
|
||||
post_id=post.id,
|
||||
external_id=external_id,
|
||||
external_post_id=platform_id,
|
||||
author_username=comment.get("username", comment.get("from", {}).get("id", "unknown")),
|
||||
author_name=comment.get("from", {}).get("name") if isinstance(comment.get("from"), dict) else None,
|
||||
content=comment.get("text", comment.get("message")),
|
||||
interaction_at=datetime.fromisoformat(
|
||||
comment.get("created_at", comment.get("timestamp", comment.get("created_time", datetime.utcnow().isoformat()))).replace("Z", "+00:00")
|
||||
) if comment.get("created_at") or comment.get("timestamp") or comment.get("created_time") else datetime.utcnow()
|
||||
)
|
||||
db.add(interaction)
|
||||
new_comments += 1
|
||||
|
||||
db.commit()
|
||||
|
||||
return f"{platform}: {new_mentions} menciones, {new_comments} comentarios nuevos"
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
return f"{platform}: error - {e}"
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
266
worker/tasks/generate_content.py
Normal file
266
worker/tasks/generate_content.py
Normal file
@@ -0,0 +1,266 @@
|
||||
"""
|
||||
Tareas de generación de contenido.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from worker.celery_app import celery_app
|
||||
from app.core.database import SessionLocal
|
||||
from app.models.post import Post
|
||||
from app.models.content_calendar import ContentCalendar
|
||||
from app.models.tip_template import TipTemplate
|
||||
from app.models.product import Product
|
||||
from app.models.service import Service
|
||||
from app.services.content_generator import content_generator
|
||||
|
||||
|
||||
def run_async(coro):
|
||||
"""Helper para ejecutar coroutines en Celery."""
|
||||
loop = asyncio.get_event_loop()
|
||||
return loop.run_until_complete(coro)
|
||||
|
||||
|
||||
@celery_app.task(name="worker.tasks.generate_content.generate_scheduled_content")
|
||||
def generate_scheduled_content():
|
||||
"""
|
||||
Generar contenido según el calendario.
|
||||
Se ejecuta cada hora.
|
||||
"""
|
||||
db = SessionLocal()
|
||||
|
||||
try:
|
||||
now = datetime.now()
|
||||
current_day = now.weekday()
|
||||
current_hour = now.hour
|
||||
|
||||
# Obtener entradas del calendario para esta hora
|
||||
entries = db.query(ContentCalendar).filter(
|
||||
ContentCalendar.day_of_week == current_day,
|
||||
ContentCalendar.is_active == True
|
||||
).all()
|
||||
|
||||
for entry in entries:
|
||||
# Verificar si es la hora correcta
|
||||
if entry.time.hour != current_hour:
|
||||
continue
|
||||
|
||||
# Generar contenido según el tipo
|
||||
if entry.content_type == "tip_tech":
|
||||
generate_tip_post.delay(
|
||||
platforms=entry.platforms,
|
||||
category_filter=entry.category_filter,
|
||||
requires_approval=entry.requires_approval
|
||||
)
|
||||
|
||||
elif entry.content_type == "producto":
|
||||
generate_product_post.delay(
|
||||
platforms=entry.platforms,
|
||||
requires_approval=entry.requires_approval
|
||||
)
|
||||
|
||||
elif entry.content_type == "servicio":
|
||||
generate_service_post.delay(
|
||||
platforms=entry.platforms,
|
||||
requires_approval=entry.requires_approval
|
||||
)
|
||||
|
||||
return f"Procesadas {len(entries)} entradas del calendario"
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@celery_app.task(name="worker.tasks.generate_content.generate_tip_post")
|
||||
def generate_tip_post(
|
||||
platforms: list,
|
||||
category_filter: str = None,
|
||||
requires_approval: bool = False
|
||||
):
|
||||
"""Generar un post de tip tech."""
|
||||
db = SessionLocal()
|
||||
|
||||
try:
|
||||
# Seleccionar un tip que no se haya usado recientemente
|
||||
query = db.query(TipTemplate).filter(
|
||||
TipTemplate.is_active == True
|
||||
)
|
||||
|
||||
if category_filter:
|
||||
query = query.filter(TipTemplate.category == category_filter)
|
||||
|
||||
# Ordenar por uso (menos usado primero)
|
||||
tip = query.order_by(
|
||||
TipTemplate.used_count.asc(),
|
||||
TipTemplate.last_used.asc().nullsfirst()
|
||||
).first()
|
||||
|
||||
if not tip:
|
||||
return "No hay tips disponibles"
|
||||
|
||||
# Generar contenido para cada plataforma
|
||||
content_by_platform = {}
|
||||
for platform in platforms:
|
||||
content = run_async(
|
||||
content_generator.generate_tip_tech(
|
||||
category=tip.category,
|
||||
platform=platform,
|
||||
template=tip.template
|
||||
)
|
||||
)
|
||||
content_by_platform[platform] = content
|
||||
|
||||
# Crear post
|
||||
post = Post(
|
||||
content=content_by_platform.get(platforms[0], ""),
|
||||
content_type="tip_tech",
|
||||
platforms=platforms,
|
||||
content_x=content_by_platform.get("x"),
|
||||
content_threads=content_by_platform.get("threads"),
|
||||
content_instagram=content_by_platform.get("instagram"),
|
||||
content_facebook=content_by_platform.get("facebook"),
|
||||
status="pending_approval" if requires_approval else "scheduled",
|
||||
scheduled_at=datetime.utcnow() + timedelta(minutes=5),
|
||||
approval_required=requires_approval,
|
||||
tip_template_id=tip.id
|
||||
)
|
||||
|
||||
db.add(post)
|
||||
|
||||
# Actualizar uso del tip
|
||||
tip.used_count += 1
|
||||
tip.last_used = datetime.utcnow()
|
||||
|
||||
db.commit()
|
||||
|
||||
return f"Post de tip generado: {post.id}"
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@celery_app.task(name="worker.tasks.generate_content.generate_product_post")
|
||||
def generate_product_post(
|
||||
platforms: list,
|
||||
product_id: int = None,
|
||||
requires_approval: bool = True
|
||||
):
|
||||
"""Generar un post de producto."""
|
||||
db = SessionLocal()
|
||||
|
||||
try:
|
||||
# Seleccionar producto
|
||||
if product_id:
|
||||
product = db.query(Product).filter(Product.id == product_id).first()
|
||||
else:
|
||||
# Seleccionar uno aleatorio que no se haya publicado recientemente
|
||||
product = db.query(Product).filter(
|
||||
Product.is_available == True
|
||||
).order_by(
|
||||
Product.last_posted_at.asc().nullsfirst()
|
||||
).first()
|
||||
|
||||
if not product:
|
||||
return "No hay productos disponibles"
|
||||
|
||||
# Generar contenido
|
||||
content_by_platform = {}
|
||||
for platform in platforms:
|
||||
content = run_async(
|
||||
content_generator.generate_product_post(
|
||||
product=product.to_dict(),
|
||||
platform=platform
|
||||
)
|
||||
)
|
||||
content_by_platform[platform] = content
|
||||
|
||||
# Crear post
|
||||
post = Post(
|
||||
content=content_by_platform.get(platforms[0], ""),
|
||||
content_type="producto",
|
||||
platforms=platforms,
|
||||
content_x=content_by_platform.get("x"),
|
||||
content_threads=content_by_platform.get("threads"),
|
||||
content_instagram=content_by_platform.get("instagram"),
|
||||
content_facebook=content_by_platform.get("facebook"),
|
||||
status="pending_approval" if requires_approval else "scheduled",
|
||||
scheduled_at=datetime.utcnow() + timedelta(minutes=5),
|
||||
approval_required=requires_approval,
|
||||
product_id=product.id,
|
||||
image_url=product.main_image
|
||||
)
|
||||
|
||||
db.add(post)
|
||||
|
||||
# Actualizar última publicación del producto
|
||||
product.last_posted_at = datetime.utcnow()
|
||||
|
||||
db.commit()
|
||||
|
||||
return f"Post de producto generado: {post.id}"
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@celery_app.task(name="worker.tasks.generate_content.generate_service_post")
|
||||
def generate_service_post(
|
||||
platforms: list,
|
||||
service_id: int = None,
|
||||
requires_approval: bool = True
|
||||
):
|
||||
"""Generar un post de servicio."""
|
||||
db = SessionLocal()
|
||||
|
||||
try:
|
||||
# Seleccionar servicio
|
||||
if service_id:
|
||||
service = db.query(Service).filter(Service.id == service_id).first()
|
||||
else:
|
||||
service = db.query(Service).filter(
|
||||
Service.is_active == True
|
||||
).order_by(
|
||||
Service.last_posted_at.asc().nullsfirst()
|
||||
).first()
|
||||
|
||||
if not service:
|
||||
return "No hay servicios disponibles"
|
||||
|
||||
# Generar contenido
|
||||
content_by_platform = {}
|
||||
for platform in platforms:
|
||||
content = run_async(
|
||||
content_generator.generate_service_post(
|
||||
service=service.to_dict(),
|
||||
platform=platform
|
||||
)
|
||||
)
|
||||
content_by_platform[platform] = content
|
||||
|
||||
# Crear post
|
||||
post = Post(
|
||||
content=content_by_platform.get(platforms[0], ""),
|
||||
content_type="servicio",
|
||||
platforms=platforms,
|
||||
content_x=content_by_platform.get("x"),
|
||||
content_threads=content_by_platform.get("threads"),
|
||||
content_instagram=content_by_platform.get("instagram"),
|
||||
content_facebook=content_by_platform.get("facebook"),
|
||||
status="pending_approval" if requires_approval else "scheduled",
|
||||
scheduled_at=datetime.utcnow() + timedelta(minutes=5),
|
||||
approval_required=requires_approval,
|
||||
service_id=service.id,
|
||||
image_url=service.main_image
|
||||
)
|
||||
|
||||
db.add(post)
|
||||
|
||||
# Actualizar última publicación del servicio
|
||||
service.last_posted_at = datetime.utcnow()
|
||||
|
||||
db.commit()
|
||||
|
||||
return f"Post de servicio generado: {post.id}"
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
156
worker/tasks/publish_post.py
Normal file
156
worker/tasks/publish_post.py
Normal file
@@ -0,0 +1,156 @@
|
||||
"""
|
||||
Tareas de publicación de posts.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
|
||||
from worker.celery_app import celery_app
|
||||
from app.core.database import SessionLocal
|
||||
from app.models.post import Post
|
||||
from app.publishers import get_publisher
|
||||
|
||||
|
||||
def run_async(coro):
|
||||
"""Helper para ejecutar coroutines en Celery."""
|
||||
loop = asyncio.get_event_loop()
|
||||
return loop.run_until_complete(coro)
|
||||
|
||||
|
||||
@celery_app.task(name="worker.tasks.publish_post.publish_scheduled_posts")
|
||||
def publish_scheduled_posts():
|
||||
"""
|
||||
Publicar posts que están programados para ahora.
|
||||
Se ejecuta cada minuto.
|
||||
"""
|
||||
db = SessionLocal()
|
||||
|
||||
try:
|
||||
now = datetime.utcnow()
|
||||
|
||||
# Obtener posts listos para publicar
|
||||
posts = db.query(Post).filter(
|
||||
Post.status == "scheduled",
|
||||
Post.scheduled_at <= now
|
||||
).all()
|
||||
|
||||
results = []
|
||||
|
||||
for post in posts:
|
||||
# Marcar como en proceso
|
||||
post.status = "publishing"
|
||||
db.commit()
|
||||
|
||||
# Publicar en cada plataforma
|
||||
success_count = 0
|
||||
platform_ids = {}
|
||||
errors = []
|
||||
|
||||
for platform in post.platforms:
|
||||
result = publish_to_platform.delay(post.id, platform)
|
||||
# En producción, esto sería asíncrono
|
||||
# Por ahora, ejecutamos secuencialmente
|
||||
|
||||
results.append(f"Post {post.id} enviado a publicación")
|
||||
|
||||
return f"Procesados {len(posts)} posts"
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@celery_app.task(
|
||||
name="worker.tasks.publish_post.publish_to_platform",
|
||||
bind=True,
|
||||
max_retries=3,
|
||||
default_retry_delay=60
|
||||
)
|
||||
def publish_to_platform(self, post_id: int, platform: str):
|
||||
"""Publicar un post en una plataforma específica."""
|
||||
db = SessionLocal()
|
||||
|
||||
try:
|
||||
post = db.query(Post).filter(Post.id == post_id).first()
|
||||
if not post:
|
||||
return f"Post {post_id} no encontrado"
|
||||
|
||||
# Obtener contenido para esta plataforma
|
||||
content = post.get_content_for_platform(platform)
|
||||
|
||||
# Obtener publisher
|
||||
try:
|
||||
publisher = get_publisher(platform)
|
||||
except ValueError as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
# Publicar
|
||||
result = run_async(
|
||||
publisher.publish(content, post.image_url)
|
||||
)
|
||||
|
||||
if result.success:
|
||||
# Guardar ID del post en la plataforma
|
||||
platform_ids = post.platform_post_ids or {}
|
||||
platform_ids[platform] = result.post_id
|
||||
post.platform_post_ids = platform_ids
|
||||
|
||||
# Verificar si todas las plataformas están publicadas
|
||||
all_published = all(
|
||||
p in platform_ids for p in post.platforms
|
||||
)
|
||||
|
||||
if all_published:
|
||||
post.status = "published"
|
||||
post.published_at = datetime.utcnow()
|
||||
|
||||
db.commit()
|
||||
|
||||
return f"Publicado en {platform}: {result.post_id}"
|
||||
|
||||
else:
|
||||
# Error en publicación
|
||||
post.error_message = f"{platform}: {result.error_message}"
|
||||
post.retry_count += 1
|
||||
|
||||
if post.retry_count >= 3:
|
||||
post.status = "failed"
|
||||
|
||||
db.commit()
|
||||
|
||||
# Reintentar
|
||||
raise self.retry(
|
||||
exc=Exception(result.error_message),
|
||||
countdown=60 * post.retry_count
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@celery_app.task(name="worker.tasks.publish_post.publish_now")
|
||||
def publish_now(post_id: int):
|
||||
"""Publicar un post inmediatamente."""
|
||||
db = SessionLocal()
|
||||
|
||||
try:
|
||||
post = db.query(Post).filter(Post.id == post_id).first()
|
||||
if not post:
|
||||
return f"Post {post_id} no encontrado"
|
||||
|
||||
# Cambiar estado
|
||||
post.status = "publishing"
|
||||
post.scheduled_at = datetime.utcnow()
|
||||
db.commit()
|
||||
|
||||
# Publicar en cada plataforma
|
||||
for platform in post.platforms:
|
||||
publish_to_platform.delay(post_id, platform)
|
||||
|
||||
return f"Post {post_id} enviado a publicación inmediata"
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
Reference in New Issue
Block a user