feat(phase-2): Complete social media API integration

- Add ImageUploadService for public URL generation (Meta APIs)
- Create PublisherManager for unified multi-platform publishing
- Add /api/publish endpoints (single, multiple, thread, test)
- Add compose page in dashboard for creating posts
- Add connection test script (scripts/test_connections.py)
- Update navigation with compose link and logout

New endpoints:
- POST /api/publish/single - Publish to one platform
- POST /api/publish/multiple - Publish to multiple platforms
- POST /api/publish/thread - Publish thread (X/Threads)
- GET /api/publish/test - Test all API connections
- GET /api/publish/platforms - List available platforms

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-28 01:43:22 +00:00
parent cda224f852
commit 3caf2a67fb
9 changed files with 1596 additions and 6 deletions

View File

@@ -83,6 +83,19 @@ async def dashboard_home(request: Request, db: Session = Depends(get_db)):
}) })
@router.get("/compose", response_class=HTMLResponse)
async def dashboard_compose(request: Request, db: Session = Depends(get_db)):
"""Página para crear nuevas publicaciones."""
user = require_auth(request, db)
if not user:
return RedirectResponse(url="/login", status_code=302)
return templates.TemplateResponse("compose.html", {
"request": request,
"user": user.to_dict()
})
@router.get("/posts", response_class=HTMLResponse) @router.get("/posts", response_class=HTMLResponse)
async def dashboard_posts(request: Request, db: Session = Depends(get_db)): async def dashboard_posts(request: Request, db: Session = Depends(get_db)):
"""Página de gestión de posts.""" """Página de gestión de posts."""

262
app/api/routes/publish.py Normal file
View File

@@ -0,0 +1,262 @@
"""
API endpoints para publicación en redes sociales.
"""
from typing import Optional, List
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from app.publishers.manager import publisher_manager, Platform, MultiPublishResult
router = APIRouter()
# ============================================================
# Schemas
# ============================================================
class PublishRequest(BaseModel):
"""Solicitud de publicación simple."""
platform: str
content: str
image_url: Optional[str] = None
class MultiPublishRequest(BaseModel):
"""Solicitud de publicación múltiple."""
platforms: List[str]
content: dict # {platform: content} o string único
image_url: Optional[str] = None
class ThreadPublishRequest(BaseModel):
"""Solicitud de publicación de hilo."""
platform: str
posts: List[str]
images: Optional[List[str]] = None
class PublishResponse(BaseModel):
"""Respuesta de publicación."""
success: bool
post_id: Optional[str] = None
url: Optional[str] = None
error: Optional[str] = None
class ConnectionTestResponse(BaseModel):
"""Respuesta de test de conexión."""
platform: str
configured: bool
connected: bool
error: Optional[str] = None
details: dict = {}
# ============================================================
# Endpoints
# ============================================================
@router.post("/single", response_model=PublishResponse)
async def publish_single(request: PublishRequest):
"""
Publicar contenido en una plataforma específica.
- **platform**: x, threads, facebook, instagram
- **content**: Texto del post
- **image_url**: URL o ruta de imagen (opcional)
"""
try:
platform = Platform(request.platform.lower())
except ValueError:
raise HTTPException(
status_code=400,
detail=f"Plataforma no válida: {request.platform}. "
f"Opciones: x, threads, facebook, instagram"
)
result = await publisher_manager.publish(
platform=platform,
content=request.content,
image_path=request.image_url
)
return PublishResponse(
success=result.success,
post_id=result.post_id,
url=result.url,
error=result.error_message
)
@router.post("/multiple")
async def publish_multiple(request: MultiPublishRequest):
"""
Publicar contenido en múltiples plataformas.
- **platforms**: Lista de plataformas ["x", "threads", "facebook"]
- **content**: Dict con contenido por plataforma o string único
- **image_url**: URL o ruta de imagen (opcional)
"""
platforms = []
for p in request.platforms:
try:
platforms.append(Platform(p.lower()))
except ValueError:
raise HTTPException(
status_code=400,
detail=f"Plataforma no válida: {p}"
)
result = await publisher_manager.publish_to_multiple(
platforms=platforms,
content=request.content,
image_path=request.image_url
)
return {
"success": result.success,
"successful_platforms": result.successful_platforms,
"failed_platforms": result.failed_platforms,
"results": {
p: {
"success": r.success,
"post_id": r.post_id,
"url": r.url,
"error": r.error_message
}
for p, r in result.results.items()
},
"errors": result.errors
}
@router.post("/thread", response_model=PublishResponse)
async def publish_thread(request: ThreadPublishRequest):
"""
Publicar un hilo de posts en una plataforma.
- **platform**: x o threads
- **posts**: Lista de textos para cada post del hilo
- **images**: Lista de URLs de imágenes (opcional)
"""
try:
platform = Platform(request.platform.lower())
except ValueError:
raise HTTPException(
status_code=400,
detail=f"Plataforma no válida: {request.platform}"
)
if platform not in [Platform.X, Platform.THREADS]:
raise HTTPException(
status_code=400,
detail="Los hilos solo están soportados en X y Threads"
)
result = await publisher_manager.publish_thread(
platform=platform,
posts=request.posts,
images=request.images
)
return PublishResponse(
success=result.success,
post_id=result.post_id,
url=result.url,
error=result.error_message
)
@router.get("/test/{platform}", response_model=ConnectionTestResponse)
async def test_connection(platform: str):
"""
Probar conexión con una plataforma específica.
Verifica que las credenciales estén configuradas y funcionando.
"""
try:
plat = Platform(platform.lower())
except ValueError:
raise HTTPException(
status_code=400,
detail=f"Plataforma no válida: {platform}"
)
result = await publisher_manager.test_connection(plat)
return ConnectionTestResponse(**result)
@router.get("/test")
async def test_all_connections():
"""
Probar conexión con todas las plataformas.
Devuelve el estado de cada plataforma configurada.
"""
results = await publisher_manager.test_all_connections()
return {
"platforms": results,
"summary": {
"total": len(results),
"configured": sum(1 for r in results.values() if r["configured"]),
"connected": sum(1 for r in results.values() if r["connected"])
}
}
@router.get("/platforms")
async def get_available_platforms():
"""
Obtener lista de plataformas disponibles.
Solo devuelve plataformas con credenciales configuradas.
"""
return {
"available": publisher_manager.get_available_platforms(),
"all": [p.value for p in Platform]
}
class PreviewRequest(BaseModel):
"""Solicitud de previsualización."""
content: str
platforms: List[str]
@router.post("/preview")
async def preview_content(request: PreviewRequest):
"""
Previsualizar cómo se verá el contenido en cada plataforma.
Valida longitud y muestra advertencias.
"""
previews = {}
char_limits = {
"x": 280,
"threads": 500,
"instagram": 2200,
"facebook": 63206
}
for p in request.platforms:
limit = char_limits.get(p.lower(), 500)
length = len(request.content)
previews[p] = {
"content": request.content[:limit],
"length": length,
"limit": limit,
"valid": length <= limit,
"truncated": length > limit,
"remaining": max(0, limit - length)
}
return {
"original_length": len(request.content),
"previews": previews
}

View File

@@ -11,7 +11,7 @@ from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from app.api.routes import posts, products, services, calendar, dashboard, interactions, auth from app.api.routes import posts, products, services, calendar, dashboard, interactions, auth, publish
from app.core.config import settings from app.core.config import settings
from app.core.database import engine from app.core.database import engine
from app.models import Base from app.models import Base
@@ -49,6 +49,11 @@ app.add_middleware(
# Montar archivos estáticos # Montar archivos estáticos
app.mount("/static", StaticFiles(directory="dashboard/static"), name="static") app.mount("/static", StaticFiles(directory="dashboard/static"), name="static")
# Montar directorio de uploads para imágenes públicas
import os
os.makedirs("uploads/images", exist_ok=True)
app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads")
# Registrar rutas # Registrar rutas
app.include_router(auth.router, prefix="", tags=["Auth"]) app.include_router(auth.router, prefix="", tags=["Auth"])
app.include_router(dashboard.router, prefix="", tags=["Dashboard"]) app.include_router(dashboard.router, prefix="", tags=["Dashboard"])
@@ -57,6 +62,7 @@ app.include_router(products.router, prefix="/api/products", tags=["Products"])
app.include_router(services.router, prefix="/api/services", tags=["Services"]) app.include_router(services.router, prefix="/api/services", tags=["Services"])
app.include_router(calendar.router, prefix="/api/calendar", tags=["Calendar"]) app.include_router(calendar.router, prefix="/api/calendar", tags=["Calendar"])
app.include_router(interactions.router, prefix="/api/interactions", tags=["Interactions"]) app.include_router(interactions.router, prefix="/api/interactions", tags=["Interactions"])
app.include_router(publish.router, prefix="/api/publish", tags=["Publish"])
@app.get("/api/health") @app.get("/api/health")

View File

@@ -7,6 +7,7 @@ from app.publishers.x_publisher import XPublisher
from app.publishers.threads_publisher import ThreadsPublisher from app.publishers.threads_publisher import ThreadsPublisher
from app.publishers.instagram_publisher import InstagramPublisher from app.publishers.instagram_publisher import InstagramPublisher
from app.publishers.facebook_publisher import FacebookPublisher from app.publishers.facebook_publisher import FacebookPublisher
from app.publishers.manager import PublisherManager, Platform, publisher_manager
def get_publisher(platform: str) -> BasePublisher: def get_publisher(platform: str) -> BasePublisher:
@@ -31,5 +32,8 @@ __all__ = [
"ThreadsPublisher", "ThreadsPublisher",
"InstagramPublisher", "InstagramPublisher",
"FacebookPublisher", "FacebookPublisher",
"PublisherManager",
"Platform",
"publisher_manager",
"get_publisher" "get_publisher"
] ]

343
app/publishers/manager.py Normal file
View File

@@ -0,0 +1,343 @@
"""
PublisherManager - Gestor unificado de publicaciones.
Coordina todos los publishers y proporciona una interfaz única.
"""
import asyncio
from typing import Optional, List, Dict, Any
from dataclasses import dataclass
from enum import Enum
from app.publishers.base import PublishResult
from app.publishers.x_publisher import XPublisher
from app.publishers.threads_publisher import ThreadsPublisher
from app.publishers.facebook_publisher import FacebookPublisher
from app.publishers.instagram_publisher import InstagramPublisher
from app.services.image_upload import image_upload
class Platform(str, Enum):
"""Plataformas soportadas."""
X = "x"
THREADS = "threads"
FACEBOOK = "facebook"
INSTAGRAM = "instagram"
@dataclass
class MultiPublishResult:
"""Resultado de publicación en múltiples plataformas."""
success: bool
results: Dict[str, PublishResult]
errors: List[str]
@property
def successful_platforms(self) -> List[str]:
"""Plataformas donde se publicó exitosamente."""
return [p for p, r in self.results.items() if r.success]
@property
def failed_platforms(self) -> List[str]:
"""Plataformas donde falló la publicación."""
return [p for p, r in self.results.items() if not r.success]
class PublisherManager:
"""
Gestor unificado de publicaciones.
Proporciona una interfaz única para publicar contenido
en todas las plataformas soportadas.
"""
def __init__(self):
self._publishers = {}
self._init_publishers()
def _init_publishers(self):
"""Inicializar publishers disponibles."""
self._publishers = {
Platform.X: XPublisher(),
Platform.THREADS: ThreadsPublisher(),
Platform.FACEBOOK: FacebookPublisher(),
Platform.INSTAGRAM: InstagramPublisher(),
}
def get_publisher(self, platform: Platform):
"""Obtener publisher para una plataforma."""
return self._publishers.get(platform)
def get_available_platforms(self) -> List[str]:
"""Obtener lista de plataformas con credenciales configuradas."""
available = []
for platform, publisher in self._publishers.items():
if self._is_configured(platform):
available.append(platform.value)
return available
def _is_configured(self, platform: Platform) -> bool:
"""Verificar si una plataforma tiene credenciales configuradas."""
publisher = self._publishers.get(platform)
if not publisher:
return False
if platform == Platform.X:
return publisher.client is not None
elif platform == Platform.THREADS:
return bool(publisher.access_token and publisher.user_id)
elif platform == Platform.FACEBOOK:
return bool(publisher.access_token and publisher.page_id)
elif platform == Platform.INSTAGRAM:
return bool(publisher.access_token and publisher.account_id)
return False
async def publish(
self,
platform: Platform,
content: str,
image_path: Optional[str] = None
) -> PublishResult:
"""
Publicar en una plataforma específica.
Args:
platform: Plataforma destino
content: Texto del post
image_path: Ruta local a imagen (opcional)
Returns:
PublishResult con el resultado
"""
publisher = self._publishers.get(platform)
if not publisher:
return PublishResult(
success=False,
error_message=f"Plataforma no soportada: {platform}"
)
# Validar contenido
if not publisher.validate_content(content):
return PublishResult(
success=False,
error_message=f"Contenido excede límite de {publisher.char_limit} caracteres"
)
# Subir imagen si es necesario para Meta APIs
public_image_url = None
if image_path and platform in [Platform.THREADS, Platform.FACEBOOK, Platform.INSTAGRAM]:
try:
public_image_url = await image_upload.upload_from_path(image_path)
except Exception as e:
return PublishResult(
success=False,
error_message=f"Error subiendo imagen: {e}"
)
# Publicar
try:
if platform == Platform.X:
return await publisher.publish(content, image_path)
else:
return await publisher.publish(content, public_image_url or image_path)
except Exception as e:
return PublishResult(
success=False,
error_message=f"Error al publicar: {e}"
)
async def publish_to_multiple(
self,
platforms: List[Platform],
content: Dict[str, str],
image_path: Optional[str] = None,
parallel: bool = True
) -> MultiPublishResult:
"""
Publicar en múltiples plataformas.
Args:
platforms: Lista de plataformas
content: Dict con contenido por plataforma {platform: texto}
o string único para todas
image_path: Ruta a imagen (opcional)
parallel: Si True, publica en paralelo
Returns:
MultiPublishResult con resultados por plataforma
"""
results = {}
errors = []
# Normalizar contenido
if isinstance(content, str):
content = {p.value: content for p in platforms}
# Subir imagen una sola vez si es necesario
public_image_url = None
if image_path:
try:
public_image_url = await image_upload.upload_from_path(image_path)
except Exception as e:
errors.append(f"Error subiendo imagen: {e}")
async def publish_to_platform(platform: Platform):
platform_content = content.get(platform.value, "")
if not platform_content:
return platform, PublishResult(
success=False,
error_message="Sin contenido para esta plataforma"
)
# X usa ruta local, Meta usa URL pública
img = image_path if platform == Platform.X else public_image_url
result = await self.publish(platform, platform_content, img)
return platform, result
if parallel:
# Publicar en paralelo
tasks = [publish_to_platform(p) for p in platforms]
platform_results = await asyncio.gather(*tasks, return_exceptions=True)
for item in platform_results:
if isinstance(item, Exception):
errors.append(str(item))
else:
platform, result = item
results[platform.value] = result
else:
# Publicar secuencialmente
for platform in platforms:
try:
_, result = await publish_to_platform(platform)
results[platform.value] = result
except Exception as e:
errors.append(f"{platform.value}: {e}")
results[platform.value] = PublishResult(
success=False,
error_message=str(e)
)
# Determinar éxito global
success = any(r.success for r in results.values())
return MultiPublishResult(
success=success,
results=results,
errors=errors
)
async def publish_thread(
self,
platform: Platform,
posts: List[str],
images: Optional[List[str]] = None
) -> PublishResult:
"""Publicar un hilo en una plataforma."""
publisher = self._publishers.get(platform)
if not publisher:
return PublishResult(
success=False,
error_message=f"Plataforma no soportada: {platform}"
)
return await publisher.publish_thread(posts, images)
async def test_connection(self, platform: Platform) -> Dict[str, Any]:
"""
Probar conexión con una plataforma.
Returns:
Dict con status y detalles
"""
result = {
"platform": platform.value,
"configured": self._is_configured(platform),
"connected": False,
"error": None,
"details": {}
}
if not result["configured"]:
result["error"] = "Credenciales no configuradas"
return result
publisher = self._publishers.get(platform)
try:
if platform == Platform.X:
me = publisher.client.get_me()
if me.data:
result["connected"] = True
result["details"] = {
"username": me.data.username,
"name": me.data.name,
"id": str(me.data.id)
}
elif platform == Platform.THREADS:
import httpx
async with httpx.AsyncClient() as client:
url = f"{publisher.base_url}/{publisher.user_id}"
params = {
"fields": "id,username",
"access_token": publisher.access_token
}
response = await client.get(url, params=params)
if response.status_code == 200:
data = response.json()
result["connected"] = True
result["details"] = data
elif platform == Platform.FACEBOOK:
import httpx
async with httpx.AsyncClient() as client:
url = f"{publisher.base_url}/{publisher.page_id}"
params = {
"fields": "id,name,followers_count",
"access_token": publisher.access_token
}
response = await client.get(url, params=params)
if response.status_code == 200:
data = response.json()
result["connected"] = True
result["details"] = data
elif platform == Platform.INSTAGRAM:
import httpx
async with httpx.AsyncClient() as client:
url = f"{publisher.base_url}/{publisher.account_id}"
params = {
"fields": "id,username,followers_count,media_count",
"access_token": publisher.access_token
}
response = await client.get(url, params=params)
if response.status_code == 200:
data = response.json()
result["connected"] = True
result["details"] = data
except Exception as e:
result["error"] = str(e)
return result
async def test_all_connections(self) -> Dict[str, Dict[str, Any]]:
"""Probar conexión con todas las plataformas."""
results = {}
for platform in Platform:
results[platform.value] = await self.test_connection(platform)
return results
# Instancia global
publisher_manager = PublisherManager()

View File

@@ -0,0 +1,166 @@
"""
Servicio de subida y gestión de imágenes.
Sube imágenes locales y devuelve URLs públicas para las APIs de Meta.
"""
import os
import uuid
import shutil
from pathlib import Path
from typing import Optional
from datetime import datetime
from app.core.config import settings
class ImageUploadService:
"""Servicio para subir imágenes y obtener URLs públicas."""
def __init__(self):
self.upload_dir = Path("uploads/images")
self.upload_dir.mkdir(parents=True, exist_ok=True)
self._base_url: Optional[str] = None
@property
def base_url(self) -> str:
"""URL base para servir imágenes."""
if self._base_url:
return self._base_url
# En producción, usar dominio público
if settings.APP_ENV == "production":
return f"{settings.BUSINESS_WEBSITE}/uploads/images"
# En desarrollo, usar localhost
return "http://localhost:8000/uploads/images"
def set_base_url(self, url: str):
"""Establecer URL base manualmente."""
self._base_url = url.rstrip("/")
def _generate_filename(self, original_name: str) -> str:
"""Generar nombre único para archivo."""
ext = Path(original_name).suffix.lower() or ".png"
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
unique_id = uuid.uuid4().hex[:8]
return f"{timestamp}_{unique_id}{ext}"
async def upload_from_path(self, file_path: str) -> str:
"""
Subir imagen desde ruta local.
Args:
file_path: Ruta al archivo de imagen local
Returns:
URL pública de la imagen
"""
source = Path(file_path)
if not source.exists():
raise FileNotFoundError(f"Archivo no encontrado: {file_path}")
# Generar nombre único
new_name = self._generate_filename(source.name)
dest = self.upload_dir / new_name
# Copiar archivo
shutil.copy2(source, dest)
return f"{self.base_url}/{new_name}"
async def upload_from_bytes(
self,
data: bytes,
filename: str = "image.png"
) -> str:
"""
Subir imagen desde bytes.
Args:
data: Contenido del archivo en bytes
filename: Nombre original para extensión
Returns:
URL pública de la imagen
"""
new_name = self._generate_filename(filename)
dest = self.upload_dir / new_name
dest.write_bytes(data)
return f"{self.base_url}/{new_name}"
async def upload_generated_image(self, image_path: str) -> str:
"""
Subir imagen generada por ImageGenerator.
Alias de upload_from_path para claridad.
"""
return await self.upload_from_path(image_path)
def get_local_path(self, url: str) -> Optional[Path]:
"""Obtener ruta local desde URL."""
if self.base_url not in url:
return None
filename = url.split("/")[-1]
path = self.upload_dir / filename
return path if path.exists() else None
def delete(self, url: str) -> bool:
"""Eliminar imagen por URL."""
path = self.get_local_path(url)
if path and path.exists():
path.unlink()
return True
return False
def cleanup_old(self, days: int = 30) -> int:
"""
Limpiar imágenes antiguas.
Args:
days: Eliminar imágenes más antiguas que estos días
Returns:
Cantidad de archivos eliminados
"""
import time
cutoff = time.time() - (days * 86400)
deleted = 0
for file in self.upload_dir.iterdir():
if file.is_file() and file.stat().st_mtime < cutoff:
file.unlink()
deleted += 1
return deleted
def list_images(self, limit: int = 100) -> list:
"""Listar imágenes subidas."""
images = []
for file in sorted(
self.upload_dir.iterdir(),
key=lambda f: f.stat().st_mtime,
reverse=True
)[:limit]:
if file.is_file():
images.append({
"filename": file.name,
"url": f"{self.base_url}/{file.name}",
"size": file.stat().st_size,
"modified": datetime.fromtimestamp(
file.stat().st_mtime
).isoformat()
})
return images
# Instancia global
image_upload = ImageUploadService()

View File

@@ -0,0 +1,443 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Crear Post - Social Media Automation</title>
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
<style>
body { background-color: #1a1a2e; color: #eee; }
.card { background-color: #16213e; border-radius: 12px; }
.accent { color: #d4a574; }
.btn-primary { background-color: #d4a574; color: #1a1a2e; }
.btn-primary:hover { background-color: #c49564; }
.btn-secondary { background-color: #374151; color: #fff; }
.btn-secondary:hover { background-color: #4b5563; }
textarea { background-color: #0f172a; border-color: #334155; }
textarea:focus { border-color: #d4a574; outline: none; }
.platform-btn { transition: all 0.2s; }
.platform-btn.selected { ring: 2px; ring-color: #d4a574; }
.char-warning { color: #fbbf24; }
.char-error { color: #ef4444; }
</style>
</head>
<body class="min-h-screen">
<!-- Header -->
<header class="bg-gray-900 border-b border-gray-800 px-6 py-4">
<div class="flex justify-between items-center">
<h1 class="text-2xl font-bold">
<span class="accent">Consultoría AS</span> - Social Media
</h1>
<nav class="flex gap-4">
<a href="/dashboard" class="px-4 py-2 rounded hover:bg-gray-800">Home</a>
<a href="/dashboard/compose" class="px-4 py-2 rounded bg-gray-800 accent">Crear Post</a>
<a href="/dashboard/posts" class="px-4 py-2 rounded hover:bg-gray-800">Posts</a>
<a href="/dashboard/calendar" class="px-4 py-2 rounded hover:bg-gray-800">Calendario</a>
<a href="/logout" class="px-4 py-2 rounded hover:bg-gray-800 text-red-400">Salir</a>
</nav>
</div>
</header>
<main class="container mx-auto px-6 py-8 max-w-4xl">
<h2 class="text-2xl font-bold mb-6">Crear Nueva Publicación</h2>
<form id="compose-form" class="space-y-6">
<!-- Platform Selection -->
<div class="card p-6">
<h3 class="font-semibold mb-4">Plataformas</h3>
<div class="flex gap-4">
<button type="button" onclick="togglePlatform('x')"
class="platform-btn px-6 py-3 rounded-lg bg-gray-800 hover:bg-gray-700 flex items-center gap-2"
id="btn-x">
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/>
</svg>
<span>X</span>
</button>
<button type="button" onclick="togglePlatform('threads')"
class="platform-btn px-6 py-3 rounded-lg bg-gray-800 hover:bg-gray-700 flex items-center gap-2"
id="btn-threads">
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
<path d="M12.186 24h-.007c-3.581-.024-6.334-1.205-8.184-3.509C2.35 18.44 1.5 15.586 1.472 12.01v-.017c.03-3.579.879-6.43 2.525-8.482C5.845 1.205 8.6.024 12.18 0h.014c2.746.02 5.043.725 6.826 2.098 1.677 1.29 2.858 3.13 3.509 5.467l-2.04.569c-1.104-3.96-3.898-5.984-8.304-6.015-2.91.022-5.11.936-6.54 2.717C4.307 6.504 3.616 8.914 3.589 12c.027 3.086.718 5.496 2.057 7.164 1.43 1.783 3.631 2.698 6.54 2.717 2.623-.02 4.358-.631 5.8-2.045 1.647-1.613 1.618-3.593 1.09-4.798-.31-.71-.873-1.3-1.634-1.75-.192 1.352-.622 2.446-1.284 3.272-.886 1.102-2.14 1.704-3.73 1.79-1.202.065-2.361-.218-3.259-.801-1.063-.689-1.685-1.74-1.752-2.96-.065-1.17.408-2.168 1.332-2.81.88-.612 2.104-.9 3.64-.857 1.016.028 1.96.132 2.828.31-.055-.792-.283-1.392-.683-1.786-.468-.46-1.166-.682-2.135-.682h-.07c-.834.019-1.548.26-2.063.7-.472.4-.768.945-.859 1.576l-2.028-.283c.142-.981.58-1.838 1.265-2.477.891-.829 2.092-1.27 3.474-1.274h.096c1.492.013 2.706.46 3.607 1.328.857.825 1.348 2.007 1.461 3.517.636.174 1.227.398 1.768.67 1.327.666 2.358 1.634 2.982 2.8.818 1.524.876 3.916-.935 5.686-1.818 1.779-4.16 2.606-7.378 2.606zm-.39-5.086c1.075-.055 1.834-.467 2.254-1.22.396-.71.583-1.745.558-3.078-.842-.156-1.73-.242-2.66-.258-1.927-.048-3.085.484-3.181 1.461-.064.648.222 1.27.806 1.753.618.512 1.42.785 2.223.785z"/>
</svg>
<span>Threads</span>
</button>
<button type="button" onclick="togglePlatform('facebook')"
class="platform-btn px-6 py-3 rounded-lg bg-gray-800 hover:bg-gray-700 flex items-center gap-2"
id="btn-facebook">
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
<path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/>
</svg>
<span>Facebook</span>
</button>
<button type="button" onclick="togglePlatform('instagram')"
class="platform-btn px-6 py-3 rounded-lg bg-gray-800 hover:bg-gray-700 flex items-center gap-2"
id="btn-instagram">
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zM12 0C8.741 0 8.333.014 7.053.072 2.695.272.273 2.69.073 7.052.014 8.333 0 8.741 0 12c0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98C8.333 23.986 8.741 24 12 24c3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98C15.668.014 15.259 0 12 0zm0 5.838a6.162 6.162 0 100 12.324 6.162 6.162 0 000-12.324zM12 16a4 4 0 110-8 4 4 0 010 8zm6.406-11.845a1.44 1.44 0 100 2.881 1.44 1.44 0 000-2.881z"/>
</svg>
<span>Instagram</span>
</button>
</div>
<p class="text-gray-500 text-sm mt-2">Selecciona una o más plataformas</p>
</div>
<!-- Content -->
<div class="card p-6">
<div class="flex justify-between items-center mb-4">
<h3 class="font-semibold">Contenido</h3>
<div id="char-counter" class="text-sm">
<span id="char-count">0</span> caracteres
</div>
</div>
<textarea
id="content"
name="content"
rows="6"
class="w-full px-4 py-3 rounded-lg border resize-none"
placeholder="Escribe tu publicación aquí..."
oninput="updateCharCount()"
></textarea>
<!-- Platform limits -->
<div id="platform-limits" class="mt-3 space-y-1 text-sm">
<!-- Se llena dinámicamente -->
</div>
</div>
<!-- AI Assist -->
<div class="card p-6">
<h3 class="font-semibold mb-4">Asistente IA</h3>
<div class="flex gap-2 flex-wrap">
<button type="button" onclick="generateTip()"
class="btn-secondary px-4 py-2 rounded-lg text-sm">
Generar Tip Tech
</button>
<button type="button" onclick="improveContent()"
class="btn-secondary px-4 py-2 rounded-lg text-sm">
Mejorar Texto
</button>
<button type="button" onclick="adaptContent()"
class="btn-secondary px-4 py-2 rounded-lg text-sm">
Adaptar por Plataforma
</button>
</div>
<p class="text-gray-500 text-sm mt-2">
Usa IA para generar o mejorar el contenido
</p>
</div>
<!-- Schedule -->
<div class="card p-6">
<h3 class="font-semibold mb-4">Programación</h3>
<div class="flex gap-4 items-center">
<label class="flex items-center gap-2">
<input type="radio" name="schedule" value="now" checked
class="text-amber-500" onchange="toggleSchedule()">
<span>Publicar ahora</span>
</label>
<label class="flex items-center gap-2">
<input type="radio" name="schedule" value="later"
class="text-amber-500" onchange="toggleSchedule()">
<span>Programar</span>
</label>
</div>
<div id="schedule-picker" class="mt-4 hidden">
<input type="datetime-local" id="scheduled_at" name="scheduled_at"
class="bg-gray-800 border border-gray-700 rounded-lg px-4 py-2">
</div>
</div>
<!-- Actions -->
<div class="flex gap-4 justify-end">
<button type="button" onclick="saveDraft()"
class="btn-secondary px-6 py-3 rounded-lg">
Guardar Borrador
</button>
<button type="button" onclick="previewPost()"
class="btn-secondary px-6 py-3 rounded-lg">
Vista Previa
</button>
<button type="submit" class="btn-primary px-8 py-3 rounded-lg font-semibold">
Publicar
</button>
</div>
</form>
<!-- Preview Modal -->
<div id="preview-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
<div class="card p-6 max-w-2xl w-full mx-4 max-h-screen overflow-y-auto">
<div class="flex justify-between items-center mb-4">
<h3 class="font-semibold text-lg">Vista Previa</h3>
<button onclick="closePreview()" class="text-gray-400 hover:text-white">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<div id="preview-content" class="space-y-4">
<!-- Se llena dinámicamente -->
</div>
</div>
</div>
<!-- Result Modal -->
<div id="result-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
<div class="card p-6 max-w-md w-full mx-4">
<div id="result-content">
<!-- Se llena dinámicamente -->
</div>
<button onclick="closeResult()" class="btn-primary w-full mt-4 py-2 rounded-lg">
Cerrar
</button>
</div>
</div>
</main>
<script>
// State
let selectedPlatforms = [];
const charLimits = {
x: 280,
threads: 500,
instagram: 2200,
facebook: 63206
};
// Platform selection
function togglePlatform(platform) {
const btn = document.getElementById(`btn-${platform}`);
const index = selectedPlatforms.indexOf(platform);
if (index > -1) {
selectedPlatforms.splice(index, 1);
btn.classList.remove('ring-2', 'ring-amber-500');
} else {
selectedPlatforms.push(platform);
btn.classList.add('ring-2', 'ring-amber-500');
}
updateCharCount();
}
// Character counter
function updateCharCount() {
const content = document.getElementById('content').value;
const count = content.length;
const counter = document.getElementById('char-count');
const limitsDiv = document.getElementById('platform-limits');
counter.textContent = count;
// Update limits display
let limitsHtml = '';
selectedPlatforms.forEach(platform => {
const limit = charLimits[platform];
const remaining = limit - count;
let statusClass = '';
let icon = '✓';
if (remaining < 0) {
statusClass = 'char-error';
icon = '✗';
} else if (remaining < 50) {
statusClass = 'char-warning';
icon = '⚠';
}
limitsHtml += `
<div class="${statusClass}">
${icon} ${platform.charAt(0).toUpperCase() + platform.slice(1)}:
${remaining >= 0 ? remaining + ' restantes' : Math.abs(remaining) + ' excedidos'}
(máx ${limit})
</div>
`;
});
limitsDiv.innerHTML = limitsHtml;
}
// Schedule toggle
function toggleSchedule() {
const scheduleValue = document.querySelector('input[name="schedule"]:checked').value;
const picker = document.getElementById('schedule-picker');
picker.classList.toggle('hidden', scheduleValue === 'now');
}
// Preview
function previewPost() {
if (selectedPlatforms.length === 0) {
alert('Selecciona al menos una plataforma');
return;
}
const content = document.getElementById('content').value;
if (!content.trim()) {
alert('Escribe el contenido del post');
return;
}
const previewDiv = document.getElementById('preview-content');
let html = '';
selectedPlatforms.forEach(platform => {
const limit = charLimits[platform];
const truncated = content.length > limit;
const displayContent = truncated ? content.substring(0, limit) + '...' : content;
html += `
<div class="bg-gray-800 rounded-lg p-4">
<div class="flex items-center gap-2 mb-2">
<span class="font-semibold">${platform.charAt(0).toUpperCase() + platform.slice(1)}</span>
${truncated ? '<span class="text-red-400 text-xs">(truncado)</span>' : ''}
</div>
<p class="text-gray-300 whitespace-pre-wrap">${displayContent}</p>
<div class="text-gray-500 text-xs mt-2">
${content.length}/${limit} caracteres
</div>
</div>
`;
});
previewDiv.innerHTML = html;
document.getElementById('preview-modal').classList.remove('hidden');
document.getElementById('preview-modal').classList.add('flex');
}
function closePreview() {
document.getElementById('preview-modal').classList.add('hidden');
document.getElementById('preview-modal').classList.remove('flex');
}
// Result modal
function showResult(success, message, details = null) {
const resultDiv = document.getElementById('result-content');
const icon = success ? '✅' : '❌';
let html = `
<div class="text-center">
<div class="text-4xl mb-4">${icon}</div>
<h3 class="text-xl font-semibold mb-2">${success ? 'Publicado' : 'Error'}</h3>
<p class="text-gray-400">${message}</p>
</div>
`;
if (details) {
html += `
<div class="mt-4 space-y-2">
${Object.entries(details).map(([platform, result]) => `
<div class="flex justify-between items-center bg-gray-800 rounded p-2">
<span>${platform}</span>
<span class="${result.success ? 'text-green-400' : 'text-red-400'}">
${result.success ? '✓ OK' : '✗ ' + (result.error || 'Error')}
</span>
</div>
`).join('')}
</div>
`;
}
resultDiv.innerHTML = html;
document.getElementById('result-modal').classList.remove('hidden');
document.getElementById('result-modal').classList.add('flex');
}
function closeResult() {
document.getElementById('result-modal').classList.add('hidden');
document.getElementById('result-modal').classList.remove('flex');
}
// Form submission
document.getElementById('compose-form').addEventListener('submit', async (e) => {
e.preventDefault();
if (selectedPlatforms.length === 0) {
alert('Selecciona al menos una plataforma');
return;
}
const content = document.getElementById('content').value;
if (!content.trim()) {
alert('Escribe el contenido del post');
return;
}
// Prepare content per platform
const platformContent = {};
selectedPlatforms.forEach(p => {
platformContent[p] = content;
});
try {
const response = await fetch('/api/publish/multiple', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
platforms: selectedPlatforms,
content: platformContent
})
});
const data = await response.json();
if (data.success) {
showResult(true,
`Publicado en ${data.successful_platforms.length} plataforma(s)`,
data.results
);
// Clear form
document.getElementById('content').value = '';
selectedPlatforms.forEach(p => togglePlatform(p));
selectedPlatforms = [];
} else {
showResult(false,
'Algunas publicaciones fallaron',
data.results
);
}
} catch (error) {
showResult(false, error.message);
}
});
// AI functions (placeholder - will connect to backend)
async function generateTip() {
alert('Función de generación con IA - Requiere configurar DEEPSEEK_API_KEY');
}
async function improveContent() {
alert('Función de mejora con IA - Requiere configurar DEEPSEEK_API_KEY');
}
async function adaptContent() {
alert('Función de adaptación con IA - Requiere configurar DEEPSEEK_API_KEY');
}
function saveDraft() {
const content = document.getElementById('content').value;
localStorage.setItem('draft_content', content);
localStorage.setItem('draft_platforms', JSON.stringify(selectedPlatforms));
alert('Borrador guardado localmente');
}
// Load draft on page load
window.addEventListener('load', () => {
const draftContent = localStorage.getItem('draft_content');
const draftPlatforms = localStorage.getItem('draft_platforms');
if (draftContent) {
document.getElementById('content').value = draftContent;
}
if (draftPlatforms) {
const platforms = JSON.parse(draftPlatforms);
platforms.forEach(p => togglePlatform(p));
}
updateCharCount();
});
</script>
</body>
</html>

View File

@@ -21,11 +21,12 @@
<span class="accent">Consultoría AS</span> - Social Media <span class="accent">Consultoría AS</span> - Social Media
</h1> </h1>
<nav class="flex gap-4"> <nav class="flex gap-4">
<a href="/" class="px-4 py-2 rounded hover:bg-gray-800">Home</a> <a href="/dashboard" class="px-4 py-2 rounded bg-gray-800">Home</a>
<a href="/posts" class="px-4 py-2 rounded hover:bg-gray-800">Posts</a> <a href="/dashboard/compose" class="px-4 py-2 rounded hover:bg-gray-800 accent">+ Crear Post</a>
<a href="/calendar" class="px-4 py-2 rounded hover:bg-gray-800">Calendario</a> <a href="/dashboard/posts" class="px-4 py-2 rounded hover:bg-gray-800">Posts</a>
<a href="/interactions" class="px-4 py-2 rounded hover:bg-gray-800">Interacciones</a> <a href="/dashboard/calendar" class="px-4 py-2 rounded hover:bg-gray-800">Calendario</a>
<a href="/products" class="px-4 py-2 rounded hover:bg-gray-800">Productos</a> <a href="/dashboard/interactions" class="px-4 py-2 rounded hover:bg-gray-800">Interacciones</a>
<a href="/logout" class="px-4 py-2 rounded hover:bg-gray-800 text-red-400">Salir</a>
</nav> </nav>
</div> </div>
</header> </header>

352
scripts/test_connections.py Executable file
View File

@@ -0,0 +1,352 @@
#!/usr/bin/env python3
"""
Script para probar conexiones con las APIs de redes sociales.
Verifica que las credenciales estén correctamente configuradas.
Uso:
python scripts/test_connections.py # Probar todas
python scripts/test_connections.py x # Solo X/Twitter
python scripts/test_connections.py threads # Solo Threads
"""
import sys
import asyncio
from pathlib import Path
# Agregar directorio raíz al path
sys.path.insert(0, str(Path(__file__).parent.parent))
from app.core.config import settings
def print_header(text: str):
"""Imprimir encabezado formateado."""
print()
print("=" * 60)
print(f" {text}")
print("=" * 60)
def print_status(label: str, value: str, ok: bool = True):
"""Imprimir estado con color."""
status = "" if ok else ""
print(f" {status} {label}: {value}")
def print_config(label: str, value: str, is_secret: bool = False):
"""Imprimir configuración."""
if is_secret and value:
display = value[:8] + "..." + value[-4:] if len(value) > 12 else "****"
else:
display = value or "(no configurado)"
print(f" {label}: {display}")
async def test_x():
"""Probar conexión con X (Twitter)."""
print_header("X (Twitter)")
# Verificar configuración
print("\n Configuración:")
print_config("API Key", settings.X_API_KEY, is_secret=True)
print_config("API Secret", settings.X_API_SECRET, is_secret=True)
print_config("Access Token", settings.X_ACCESS_TOKEN, is_secret=True)
print_config("Access Secret", settings.X_ACCESS_TOKEN_SECRET, is_secret=True)
print_config("Bearer Token", settings.X_BEARER_TOKEN, is_secret=True)
if not all([
settings.X_API_KEY,
settings.X_API_SECRET,
settings.X_ACCESS_TOKEN,
settings.X_ACCESS_TOKEN_SECRET
]):
print_status("Estado", "Credenciales incompletas", ok=False)
return False
print("\n Probando conexión...")
try:
import tweepy
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
)
me = client.get_me(user_fields=["public_metrics"])
if me.data:
print_status("Conexión", "OK")
print(f"\n Cuenta verificada:")
print(f" Usuario: @{me.data.username}")
print(f" Nombre: {me.data.name}")
print(f" ID: {me.data.id}")
if me.data.public_metrics:
metrics = me.data.public_metrics
print(f" Seguidores: {metrics.get('followers_count', 0):,}")
print(f" Siguiendo: {metrics.get('following_count', 0):,}")
print(f" Tweets: {metrics.get('tweet_count', 0):,}")
return True
else:
print_status("Conexión", "Sin datos de usuario", ok=False)
return False
except Exception as e:
print_status("Error", str(e), ok=False)
return False
async def test_threads():
"""Probar conexión con Threads."""
print_header("Threads")
print("\n Configuración:")
print_config("Access Token", settings.META_ACCESS_TOKEN, is_secret=True)
print_config("User ID", settings.THREADS_USER_ID)
if not settings.META_ACCESS_TOKEN or not settings.THREADS_USER_ID:
print_status("Estado", "Credenciales incompletas", ok=False)
return False
print("\n Probando conexión...")
try:
import httpx
async with httpx.AsyncClient() as client:
url = f"https://graph.threads.net/v1.0/{settings.THREADS_USER_ID}"
params = {
"fields": "id,username,threads_profile_picture_url",
"access_token": settings.META_ACCESS_TOKEN
}
response = await client.get(url, params=params)
if response.status_code == 200:
data = response.json()
print_status("Conexión", "OK")
print(f"\n Cuenta verificada:")
print(f" Usuario: @{data.get('username', 'N/A')}")
print(f" ID: {data.get('id', 'N/A')}")
return True
else:
error = response.json().get("error", {})
print_status("Error", error.get("message", response.text), ok=False)
return False
except Exception as e:
print_status("Error", str(e), ok=False)
return False
async def test_facebook():
"""Probar conexión con Facebook Page."""
print_header("Facebook Page")
print("\n Configuración:")
print_config("Access Token", settings.META_ACCESS_TOKEN, is_secret=True)
print_config("Page ID", settings.FACEBOOK_PAGE_ID)
if not settings.META_ACCESS_TOKEN or not settings.FACEBOOK_PAGE_ID:
print_status("Estado", "Credenciales incompletas", ok=False)
return False
print("\n Probando conexión...")
try:
import httpx
async with httpx.AsyncClient() as client:
url = f"https://graph.facebook.com/v18.0/{settings.FACEBOOK_PAGE_ID}"
params = {
"fields": "id,name,followers_count,fan_count,link",
"access_token": settings.META_ACCESS_TOKEN
}
response = await client.get(url, params=params)
if response.status_code == 200:
data = response.json()
print_status("Conexión", "OK")
print(f"\n Página verificada:")
print(f" Nombre: {data.get('name', 'N/A')}")
print(f" ID: {data.get('id', 'N/A')}")
print(f" Seguidores: {data.get('followers_count', 0):,}")
print(f" Fans: {data.get('fan_count', 0):,}")
if data.get("link"):
print(f" URL: {data['link']}")
return True
else:
error = response.json().get("error", {})
print_status("Error", error.get("message", response.text), ok=False)
return False
except Exception as e:
print_status("Error", str(e), ok=False)
return False
async def test_instagram():
"""Probar conexión con Instagram Business."""
print_header("Instagram Business")
print("\n Configuración:")
print_config("Access Token", settings.META_ACCESS_TOKEN, is_secret=True)
print_config("Account ID", settings.INSTAGRAM_ACCOUNT_ID)
if not settings.META_ACCESS_TOKEN or not settings.INSTAGRAM_ACCOUNT_ID:
print_status("Estado", "Credenciales incompletas", ok=False)
return False
print("\n Probando conexión...")
try:
import httpx
async with httpx.AsyncClient() as client:
url = f"https://graph.facebook.com/v18.0/{settings.INSTAGRAM_ACCOUNT_ID}"
params = {
"fields": "id,username,name,followers_count,follows_count,media_count",
"access_token": settings.META_ACCESS_TOKEN
}
response = await client.get(url, params=params)
if response.status_code == 200:
data = response.json()
print_status("Conexión", "OK")
print(f"\n Cuenta verificada:")
print(f" Usuario: @{data.get('username', 'N/A')}")
print(f" Nombre: {data.get('name', 'N/A')}")
print(f" ID: {data.get('id', 'N/A')}")
print(f" Seguidores: {data.get('followers_count', 0):,}")
print(f" Siguiendo: {data.get('follows_count', 0):,}")
print(f" Publicaciones: {data.get('media_count', 0):,}")
return True
else:
error = response.json().get("error", {})
print_status("Error", error.get("message", response.text), ok=False)
return False
except Exception as e:
print_status("Error", str(e), ok=False)
return False
async def test_deepseek():
"""Probar conexión con DeepSeek API."""
print_header("DeepSeek API")
print("\n Configuración:")
print_config("API Key", settings.DEEPSEEK_API_KEY, is_secret=True)
print_config("Base URL", settings.DEEPSEEK_BASE_URL)
if not settings.DEEPSEEK_API_KEY:
print_status("Estado", "API Key no configurada", ok=False)
return False
print("\n Probando conexión...")
try:
from openai import OpenAI
client = OpenAI(
api_key=settings.DEEPSEEK_API_KEY,
base_url=settings.DEEPSEEK_BASE_URL
)
# Hacer una llamada mínima para verificar
response = client.chat.completions.create(
model="deepseek-chat",
messages=[{"role": "user", "content": "Responde solo: OK"}],
max_tokens=10
)
if response.choices:
print_status("Conexión", "OK")
print(f"\n API verificada:")
print(f" Modelo: {response.model}")
print(f" Tokens usados: {response.usage.total_tokens}")
return True
else:
print_status("Error", "Sin respuesta", ok=False)
return False
except Exception as e:
print_status("Error", str(e), ok=False)
return False
async def main():
"""Ejecutar pruebas de conexión."""
print()
print("╔════════════════════════════════════════════════════════════╗")
print("║ TEST DE CONEXIONES - Social Media Automation ║")
print("╚════════════════════════════════════════════════════════════╝")
# Determinar qué probar
platforms = sys.argv[1:] if len(sys.argv) > 1 else ["all"]
tests = {
"x": test_x,
"twitter": test_x,
"threads": test_threads,
"facebook": test_facebook,
"fb": test_facebook,
"instagram": test_instagram,
"ig": test_instagram,
"deepseek": test_deepseek,
"ai": test_deepseek,
}
results = {}
if "all" in platforms:
# Probar todas las plataformas
for name, test_func in [
("X", test_x),
("Threads", test_threads),
("Facebook", test_facebook),
("Instagram", test_instagram),
("DeepSeek", test_deepseek),
]:
results[name] = await test_func()
else:
# Probar solo las especificadas
for platform in platforms:
test_func = tests.get(platform.lower())
if test_func:
results[platform] = await test_func()
else:
print(f"\n⚠️ Plataforma desconocida: {platform}")
print(f" Opciones: x, threads, facebook, instagram, deepseek")
# Resumen
print_header("RESUMEN")
total = len(results)
passed = sum(1 for r in results.values() if r)
for name, success in results.items():
status = "✅ OK" if success else "❌ FALLO"
print(f" {name}: {status}")
print()
print(f" Total: {passed}/{total} conexiones exitosas")
if passed < total:
print()
print(" 💡 Revisa docs/API_KEYS_SETUP.md para configurar las credenciales")
print()
return passed == total
if __name__ == "__main__":
success = asyncio.run(main())
sys.exit(0 if success else 1)