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:
@@ -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
262
app/api/routes/publish.py
Normal 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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
343
app/publishers/manager.py
Normal 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()
|
||||||
166
app/services/image_upload.py
Normal file
166
app/services/image_upload.py
Normal 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()
|
||||||
443
dashboard/templates/compose.html
Normal file
443
dashboard/templates/compose.html
Normal 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>
|
||||||
@@ -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
352
scripts/test_connections.py
Executable 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)
|
||||||
Reference in New Issue
Block a user