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:
2026-01-28 01:11:44 +00:00
commit 049d2133f9
53 changed files with 5876 additions and 0 deletions

View File

@@ -0,0 +1,35 @@
"""
Publishers para cada plataforma de redes sociales.
"""
from app.publishers.base import BasePublisher
from app.publishers.x_publisher import XPublisher
from app.publishers.threads_publisher import ThreadsPublisher
from app.publishers.instagram_publisher import InstagramPublisher
from app.publishers.facebook_publisher import FacebookPublisher
def get_publisher(platform: str) -> BasePublisher:
"""Obtener el publisher para una plataforma específica."""
publishers = {
"x": XPublisher(),
"threads": ThreadsPublisher(),
"instagram": InstagramPublisher(),
"facebook": FacebookPublisher()
}
publisher = publishers.get(platform.lower())
if not publisher:
raise ValueError(f"Plataforma no soportada: {platform}")
return publisher
__all__ = [
"BasePublisher",
"XPublisher",
"ThreadsPublisher",
"InstagramPublisher",
"FacebookPublisher",
"get_publisher"
]

74
app/publishers/base.py Normal file
View File

@@ -0,0 +1,74 @@
"""
Clase base para publishers de redes sociales.
"""
from abc import ABC, abstractmethod
from typing import Optional, List, Dict
from dataclasses import dataclass
@dataclass
class PublishResult:
"""Resultado de una publicación."""
success: bool
post_id: Optional[str] = None
url: Optional[str] = None
error_message: Optional[str] = None
class BasePublisher(ABC):
"""Clase base abstracta para publishers."""
platform: str = "base"
@abstractmethod
async def publish(
self,
content: str,
image_path: Optional[str] = None
) -> PublishResult:
"""Publicar contenido en la plataforma."""
pass
@abstractmethod
async def publish_thread(
self,
posts: List[str],
images: Optional[List[str]] = None
) -> PublishResult:
"""Publicar un hilo de posts."""
pass
@abstractmethod
async def reply(
self,
post_id: str,
content: str
) -> PublishResult:
"""Responder a un post."""
pass
@abstractmethod
async def like(self, post_id: str) -> bool:
"""Dar like a un post."""
pass
@abstractmethod
async def get_mentions(self, since_id: Optional[str] = None) -> List[Dict]:
"""Obtener menciones recientes."""
pass
@abstractmethod
async def get_comments(self, post_id: str) -> List[Dict]:
"""Obtener comentarios de un post."""
pass
@abstractmethod
async def delete(self, post_id: str) -> bool:
"""Eliminar un post."""
pass
def validate_content(self, content: str) -> bool:
"""Validar que el contenido cumple con los límites de la plataforma."""
# Implementar en subclases según límites específicos
return True

View File

@@ -0,0 +1,252 @@
"""
Publisher para Facebook Pages (Meta Graph API).
"""
from typing import Optional, List, Dict
import httpx
from app.core.config import settings
from app.publishers.base import BasePublisher, PublishResult
class FacebookPublisher(BasePublisher):
"""Publisher para Facebook Pages usando Meta Graph API."""
platform = "facebook"
char_limit = 63206 # Límite real de Facebook
base_url = "https://graph.facebook.com/v18.0"
def __init__(self):
self.access_token = settings.META_ACCESS_TOKEN
self.page_id = settings.FACEBOOK_PAGE_ID
def validate_content(self, content: str) -> bool:
"""Validar longitud del post."""
return len(content) <= self.char_limit
async def publish(
self,
content: str,
image_path: Optional[str] = None
) -> PublishResult:
"""Publicar en Facebook Page."""
if not self.access_token or not self.page_id:
return PublishResult(
success=False,
error_message="Credenciales de Facebook no configuradas"
)
try:
async with httpx.AsyncClient() as client:
if image_path:
# Publicar con imagen
url = f"{self.base_url}/{self.page_id}/photos"
payload = {
"caption": content,
"url": image_path, # URL pública de la imagen
"access_token": self.access_token
}
else:
# Publicar solo texto
url = f"{self.base_url}/{self.page_id}/feed"
payload = {
"message": content,
"access_token": self.access_token
}
response = await client.post(url, data=payload)
response.raise_for_status()
post_id = response.json().get("id")
return PublishResult(
success=True,
post_id=post_id,
url=f"https://www.facebook.com/{post_id}"
)
except httpx.HTTPError as e:
return PublishResult(
success=False,
error_message=str(e)
)
async def publish_thread(
self,
posts: List[str],
images: Optional[List[str]] = None
) -> PublishResult:
"""Publicar como un solo post largo en Facebook."""
# Facebook no tiene threads, concatenamos el contenido
combined_content = "\n\n".join(posts)
# Usar la primera imagen si existe
image = images[0] if images else None
return await self.publish(combined_content, image)
async def reply(
self,
post_id: str,
content: str
) -> PublishResult:
"""Responder a un comentario en Facebook."""
if not self.access_token:
return PublishResult(
success=False,
error_message="Token de acceso no configurado"
)
try:
async with httpx.AsyncClient() as client:
url = f"{self.base_url}/{post_id}/comments"
payload = {
"message": content,
"access_token": self.access_token
}
response = await client.post(url, data=payload)
response.raise_for_status()
comment_id = response.json().get("id")
return PublishResult(
success=True,
post_id=comment_id
)
except httpx.HTTPError as e:
return PublishResult(
success=False,
error_message=str(e)
)
async def like(self, post_id: str) -> bool:
"""Dar like a un post/comentario."""
if not self.access_token:
return False
try:
async with httpx.AsyncClient() as client:
url = f"{self.base_url}/{post_id}/likes"
payload = {"access_token": self.access_token}
response = await client.post(url, data=payload)
response.raise_for_status()
return response.json().get("success", False)
except httpx.HTTPError:
return False
async def get_mentions(self, since_id: Optional[str] = None) -> List[Dict]:
"""Obtener menciones de la página."""
if not self.access_token or not self.page_id:
return []
try:
async with httpx.AsyncClient() as client:
url = f"{self.base_url}/{self.page_id}/tagged"
params = {
"fields": "id,message,from,created_time,permalink_url",
"access_token": self.access_token
}
response = await client.get(url, params=params)
response.raise_for_status()
data = response.json()
return data.get("data", [])
except httpx.HTTPError:
return []
async def get_comments(self, post_id: str) -> List[Dict]:
"""Obtener comentarios de un post."""
if not self.access_token:
return []
try:
async with httpx.AsyncClient() as client:
url = f"{self.base_url}/{post_id}/comments"
params = {
"fields": "id,message,from,created_time,like_count",
"access_token": self.access_token
}
response = await client.get(url, params=params)
response.raise_for_status()
data = response.json()
return data.get("data", [])
except httpx.HTTPError:
return []
async def get_page_messages(self) -> List[Dict]:
"""Obtener mensajes de la página (inbox)."""
if not self.access_token or not self.page_id:
return []
try:
async with httpx.AsyncClient() as client:
url = f"{self.base_url}/{self.page_id}/conversations"
params = {
"fields": "id,participants,messages{message,from,created_time}",
"access_token": self.access_token
}
response = await client.get(url, params=params)
response.raise_for_status()
data = response.json()
return data.get("data", [])
except httpx.HTTPError:
return []
async def send_message(self, recipient_id: str, message: str) -> PublishResult:
"""Enviar mensaje directo a un usuario."""
if not self.access_token or not self.page_id:
return PublishResult(
success=False,
error_message="Credenciales no configuradas"
)
try:
async with httpx.AsyncClient() as client:
url = f"{self.base_url}/{self.page_id}/messages"
payload = {
"recipient": {"id": recipient_id},
"message": {"text": message},
"access_token": self.access_token
}
response = await client.post(url, json=payload)
response.raise_for_status()
message_id = response.json().get("message_id")
return PublishResult(
success=True,
post_id=message_id
)
except httpx.HTTPError as e:
return PublishResult(
success=False,
error_message=str(e)
)
async def delete(self, post_id: str) -> bool:
"""Eliminar un post."""
if not self.access_token:
return False
try:
async with httpx.AsyncClient() as client:
url = f"{self.base_url}/{post_id}"
params = {"access_token": self.access_token}
response = await client.delete(url, params=params)
response.raise_for_status()
return response.json().get("success", False)
except httpx.HTTPError:
return False

View File

@@ -0,0 +1,240 @@
"""
Publisher para Instagram (Meta Graph API).
"""
from typing import Optional, List, Dict
import httpx
from app.core.config import settings
from app.publishers.base import BasePublisher, PublishResult
class InstagramPublisher(BasePublisher):
"""Publisher para Instagram usando Meta Graph API."""
platform = "instagram"
char_limit = 2200
base_url = "https://graph.facebook.com/v18.0"
def __init__(self):
self.access_token = settings.META_ACCESS_TOKEN
self.account_id = settings.INSTAGRAM_ACCOUNT_ID
def validate_content(self, content: str) -> bool:
"""Validar longitud del caption."""
return len(content) <= self.char_limit
async def publish(
self,
content: str,
image_path: Optional[str] = None
) -> PublishResult:
"""Publicar en Instagram (requiere imagen)."""
if not self.access_token or not self.account_id:
return PublishResult(
success=False,
error_message="Credenciales de Instagram no configuradas"
)
if not image_path:
return PublishResult(
success=False,
error_message="Instagram requiere una imagen para publicar"
)
try:
async with httpx.AsyncClient() as client:
# Paso 1: Crear contenedor de media
# Nota: La imagen debe estar en una URL pública
create_url = f"{self.base_url}/{self.account_id}/media"
payload = {
"caption": content,
"image_url": image_path, # Debe ser URL pública
"access_token": self.access_token
}
response = await client.post(create_url, data=payload)
response.raise_for_status()
container_id = response.json().get("id")
# Paso 2: Publicar el contenedor
publish_url = f"{self.base_url}/{self.account_id}/media_publish"
publish_payload = {
"creation_id": container_id,
"access_token": self.access_token
}
response = await client.post(publish_url, data=publish_payload)
response.raise_for_status()
post_id = response.json().get("id")
return PublishResult(
success=True,
post_id=post_id,
url=f"https://www.instagram.com/p/{post_id}"
)
except httpx.HTTPError as e:
return PublishResult(
success=False,
error_message=str(e)
)
async def publish_thread(
self,
posts: List[str],
images: Optional[List[str]] = None
) -> PublishResult:
"""Publicar carrusel en Instagram."""
if not self.access_token or not self.account_id:
return PublishResult(
success=False,
error_message="Credenciales de Instagram no configuradas"
)
if not images or len(images) < 2:
return PublishResult(
success=False,
error_message="Un carrusel de Instagram requiere al menos 2 imágenes"
)
try:
async with httpx.AsyncClient() as client:
# Paso 1: Crear contenedores para cada imagen
children_ids = []
for image_url in images[:10]: # Máximo 10 imágenes
create_url = f"{self.base_url}/{self.account_id}/media"
payload = {
"image_url": image_url,
"is_carousel_item": True,
"access_token": self.access_token
}
response = await client.post(create_url, data=payload)
response.raise_for_status()
children_ids.append(response.json().get("id"))
# Paso 2: Crear contenedor del carrusel
carousel_url = f"{self.base_url}/{self.account_id}/media"
carousel_payload = {
"media_type": "CAROUSEL",
"caption": posts[0] if posts else "",
"children": ",".join(children_ids),
"access_token": self.access_token
}
response = await client.post(carousel_url, data=carousel_payload)
response.raise_for_status()
carousel_id = response.json().get("id")
# Paso 3: Publicar
publish_url = f"{self.base_url}/{self.account_id}/media_publish"
publish_payload = {
"creation_id": carousel_id,
"access_token": self.access_token
}
response = await client.post(publish_url, data=publish_payload)
response.raise_for_status()
post_id = response.json().get("id")
return PublishResult(
success=True,
post_id=post_id
)
except httpx.HTTPError as e:
return PublishResult(
success=False,
error_message=str(e)
)
async def reply(
self,
post_id: str,
content: str
) -> PublishResult:
"""Responder a un comentario en Instagram."""
if not self.access_token:
return PublishResult(
success=False,
error_message="Token de acceso no configurado"
)
try:
async with httpx.AsyncClient() as client:
url = f"{self.base_url}/{post_id}/replies"
payload = {
"message": content,
"access_token": self.access_token
}
response = await client.post(url, data=payload)
response.raise_for_status()
reply_id = response.json().get("id")
return PublishResult(
success=True,
post_id=reply_id
)
except httpx.HTTPError as e:
return PublishResult(
success=False,
error_message=str(e)
)
async def like(self, post_id: str) -> bool:
"""Dar like (no disponible vía API para cuentas de negocio)."""
return False
async def get_mentions(self, since_id: Optional[str] = None) -> List[Dict]:
"""Obtener menciones en comentarios e historias."""
if not self.access_token or not self.account_id:
return []
try:
async with httpx.AsyncClient() as client:
url = f"{self.base_url}/{self.account_id}/tags"
params = {
"fields": "id,caption,media_type,permalink,timestamp,username",
"access_token": self.access_token
}
response = await client.get(url, params=params)
response.raise_for_status()
data = response.json()
return data.get("data", [])
except httpx.HTTPError:
return []
async def get_comments(self, post_id: str) -> List[Dict]:
"""Obtener comentarios de un post."""
if not self.access_token:
return []
try:
async with httpx.AsyncClient() as client:
url = f"{self.base_url}/{post_id}/comments"
params = {
"fields": "id,text,username,timestamp,like_count",
"access_token": self.access_token
}
response = await client.get(url, params=params)
response.raise_for_status()
data = response.json()
return data.get("data", [])
except httpx.HTTPError:
return []
async def delete(self, post_id: str) -> bool:
"""Eliminar un post (no disponible vía API)."""
# La API de Instagram no permite eliminar posts
return False

View File

@@ -0,0 +1,227 @@
"""
Publisher para Threads (Meta).
"""
from typing import Optional, List, Dict
import httpx
from app.core.config import settings
from app.publishers.base import BasePublisher, PublishResult
class ThreadsPublisher(BasePublisher):
"""Publisher para Threads usando Meta Graph API."""
platform = "threads"
char_limit = 500
base_url = "https://graph.threads.net/v1.0"
def __init__(self):
self.access_token = settings.META_ACCESS_TOKEN
self.user_id = settings.THREADS_USER_ID
def validate_content(self, content: str) -> bool:
"""Validar longitud del post."""
return len(content) <= self.char_limit
async def publish(
self,
content: str,
image_path: Optional[str] = None
) -> PublishResult:
"""Publicar en Threads."""
if not self.access_token or not self.user_id:
return PublishResult(
success=False,
error_message="Credenciales de Threads no configuradas"
)
try:
async with httpx.AsyncClient() as client:
# Paso 1: Crear el contenedor del post
create_url = f"{self.base_url}/{self.user_id}/threads"
payload = {
"text": content,
"media_type": "TEXT",
"access_token": self.access_token
}
# Si hay imagen, subirla primero
if image_path:
# TODO: Implementar subida de imagen a Threads
payload["media_type"] = "IMAGE"
# payload["image_url"] = uploaded_image_url
response = await client.post(create_url, data=payload)
response.raise_for_status()
container_id = response.json().get("id")
# Paso 2: Publicar el contenedor
publish_url = f"{self.base_url}/{self.user_id}/threads_publish"
publish_payload = {
"creation_id": container_id,
"access_token": self.access_token
}
response = await client.post(publish_url, data=publish_payload)
response.raise_for_status()
post_id = response.json().get("id")
return PublishResult(
success=True,
post_id=post_id,
url=f"https://www.threads.net/post/{post_id}"
)
except httpx.HTTPError as e:
return PublishResult(
success=False,
error_message=str(e)
)
async def publish_thread(
self,
posts: List[str],
images: Optional[List[str]] = None
) -> PublishResult:
"""Publicar un hilo en Threads."""
if not self.access_token or not self.user_id:
return PublishResult(
success=False,
error_message="Credenciales de Threads no configuradas"
)
try:
first_post_id = None
reply_to_id = None
for i, post in enumerate(posts):
async with httpx.AsyncClient() as client:
create_url = f"{self.base_url}/{self.user_id}/threads"
payload = {
"text": post,
"media_type": "TEXT",
"access_token": self.access_token
}
if reply_to_id:
payload["reply_to_id"] = reply_to_id
response = await client.post(create_url, data=payload)
response.raise_for_status()
container_id = response.json().get("id")
# Publicar
publish_url = f"{self.base_url}/{self.user_id}/threads_publish"
publish_payload = {
"creation_id": container_id,
"access_token": self.access_token
}
response = await client.post(publish_url, data=publish_payload)
response.raise_for_status()
post_id = response.json().get("id")
if i == 0:
first_post_id = post_id
reply_to_id = post_id
return PublishResult(
success=True,
post_id=first_post_id,
url=f"https://www.threads.net/post/{first_post_id}"
)
except httpx.HTTPError as e:
return PublishResult(
success=False,
error_message=str(e)
)
async def reply(
self,
post_id: str,
content: str
) -> PublishResult:
"""Responder a un post en Threads."""
if not self.access_token or not self.user_id:
return PublishResult(
success=False,
error_message="Credenciales de Threads no configuradas"
)
try:
async with httpx.AsyncClient() as client:
create_url = f"{self.base_url}/{self.user_id}/threads"
payload = {
"text": content,
"media_type": "TEXT",
"reply_to_id": post_id,
"access_token": self.access_token
}
response = await client.post(create_url, data=payload)
response.raise_for_status()
container_id = response.json().get("id")
# Publicar
publish_url = f"{self.base_url}/{self.user_id}/threads_publish"
publish_payload = {
"creation_id": container_id,
"access_token": self.access_token
}
response = await client.post(publish_url, data=publish_payload)
response.raise_for_status()
reply_id = response.json().get("id")
return PublishResult(
success=True,
post_id=reply_id
)
except httpx.HTTPError as e:
return PublishResult(
success=False,
error_message=str(e)
)
async def like(self, post_id: str) -> bool:
"""Dar like a un post (no soportado actualmente por la API)."""
# La API de Threads no soporta likes programáticos actualmente
return False
async def get_mentions(self, since_id: Optional[str] = None) -> List[Dict]:
"""Obtener menciones (limitado en la API de Threads)."""
# TODO: Implementar cuando la API lo soporte
return []
async def get_comments(self, post_id: str) -> List[Dict]:
"""Obtener respuestas a un post."""
if not self.access_token:
return []
try:
async with httpx.AsyncClient() as client:
url = f"{self.base_url}/{post_id}/replies"
params = {
"access_token": self.access_token,
"fields": "id,text,username,timestamp"
}
response = await client.get(url, params=params)
response.raise_for_status()
data = response.json()
return data.get("data", [])
except httpx.HTTPError:
return []
async def delete(self, post_id: str) -> bool:
"""Eliminar un post de Threads (no soportado actualmente)."""
# La API de Threads no soporta eliminación actualmente
return False

View File

@@ -0,0 +1,255 @@
"""
Publisher para X (Twitter).
"""
from typing import Optional, List, Dict
import tweepy
from app.core.config import settings
from app.publishers.base import BasePublisher, PublishResult
class XPublisher(BasePublisher):
"""Publisher para X (Twitter) usando Tweepy."""
platform = "x"
char_limit = 280
def __init__(self):
self.client = None
self.api = None
self._init_client()
def _init_client(self):
"""Inicializar cliente de Twitter."""
if not all([
settings.X_API_KEY,
settings.X_API_SECRET,
settings.X_ACCESS_TOKEN,
settings.X_ACCESS_TOKEN_SECRET
]):
return
# Cliente v2 para publicar
self.client = tweepy.Client(
consumer_key=settings.X_API_KEY,
consumer_secret=settings.X_API_SECRET,
access_token=settings.X_ACCESS_TOKEN,
access_token_secret=settings.X_ACCESS_TOKEN_SECRET,
bearer_token=settings.X_BEARER_TOKEN
)
# API v1.1 para subir imágenes
auth = tweepy.OAuth1UserHandler(
settings.X_API_KEY,
settings.X_API_SECRET,
settings.X_ACCESS_TOKEN,
settings.X_ACCESS_TOKEN_SECRET
)
self.api = tweepy.API(auth)
def validate_content(self, content: str) -> bool:
"""Validar longitud del tweet."""
return len(content) <= self.char_limit
async def publish(
self,
content: str,
image_path: Optional[str] = None
) -> PublishResult:
"""Publicar un tweet."""
if not self.client:
return PublishResult(
success=False,
error_message="Cliente de X no configurado"
)
try:
media_ids = None
# Subir imagen si existe
if image_path and self.api:
media = self.api.media_upload(filename=image_path)
media_ids = [media.media_id]
# Publicar tweet
response = self.client.create_tweet(
text=content,
media_ids=media_ids
)
tweet_id = response.data['id']
return PublishResult(
success=True,
post_id=tweet_id,
url=f"https://x.com/i/web/status/{tweet_id}"
)
except tweepy.TweepyException as e:
return PublishResult(
success=False,
error_message=str(e)
)
async def publish_thread(
self,
posts: List[str],
images: Optional[List[str]] = None
) -> PublishResult:
"""Publicar un hilo de tweets."""
if not self.client:
return PublishResult(
success=False,
error_message="Cliente de X no configurado"
)
try:
previous_tweet_id = None
first_tweet_id = None
for i, post in enumerate(posts):
media_ids = None
# Subir imagen si existe para este post
if images and i < len(images) and images[i] and self.api:
media = self.api.media_upload(filename=images[i])
media_ids = [media.media_id]
# Publicar tweet (como respuesta al anterior si existe)
response = self.client.create_tweet(
text=post,
media_ids=media_ids,
in_reply_to_tweet_id=previous_tweet_id
)
previous_tweet_id = response.data['id']
if i == 0:
first_tweet_id = previous_tweet_id
return PublishResult(
success=True,
post_id=first_tweet_id,
url=f"https://x.com/i/web/status/{first_tweet_id}"
)
except tweepy.TweepyException as e:
return PublishResult(
success=False,
error_message=str(e)
)
async def reply(
self,
post_id: str,
content: str
) -> PublishResult:
"""Responder a un tweet."""
if not self.client:
return PublishResult(
success=False,
error_message="Cliente de X no configurado"
)
try:
response = self.client.create_tweet(
text=content,
in_reply_to_tweet_id=post_id
)
return PublishResult(
success=True,
post_id=response.data['id']
)
except tweepy.TweepyException as e:
return PublishResult(
success=False,
error_message=str(e)
)
async def like(self, post_id: str) -> bool:
"""Dar like a un tweet."""
if not self.client:
return False
try:
self.client.like(post_id)
return True
except tweepy.TweepyException:
return False
async def get_mentions(self, since_id: Optional[str] = None) -> List[Dict]:
"""Obtener menciones recientes."""
if not self.client:
return []
try:
# Obtener ID del usuario autenticado
me = self.client.get_me()
user_id = me.data.id
# Obtener menciones
mentions = self.client.get_users_mentions(
id=user_id,
since_id=since_id,
max_results=50,
tweet_fields=['created_at', 'author_id', 'conversation_id']
)
if not mentions.data:
return []
return [
{
"id": str(tweet.id),
"text": tweet.text,
"author_id": str(tweet.author_id),
"created_at": tweet.created_at.isoformat() if tweet.created_at else None
}
for tweet in mentions.data
]
except tweepy.TweepyException:
return []
async def get_comments(self, post_id: str) -> List[Dict]:
"""Obtener respuestas a un tweet."""
if not self.client:
return []
try:
# Buscar respuestas al tweet
query = f"conversation_id:{post_id}"
tweets = self.client.search_recent_tweets(
query=query,
max_results=50,
tweet_fields=['created_at', 'author_id', 'in_reply_to_user_id']
)
if not tweets.data:
return []
return [
{
"id": str(tweet.id),
"text": tweet.text,
"author_id": str(tweet.author_id),
"created_at": tweet.created_at.isoformat() if tweet.created_at else None
}
for tweet in tweets.data
]
except tweepy.TweepyException:
return []
async def delete(self, post_id: str) -> bool:
"""Eliminar un tweet."""
if not self.client:
return False
try:
self.client.delete_tweet(post_id)
return True
except tweepy.TweepyException:
return False