Implementación inicial del sistema de automatización de redes sociales
- Estructura completa del proyecto con FastAPI - Modelos de base de datos (productos, servicios, posts, calendario, interacciones) - Publishers para X, Threads, Instagram, Facebook - Generador de contenido con DeepSeek API - Worker de Celery con tareas programadas - Dashboard básico con templates HTML - Docker Compose para despliegue - Documentación completa Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
12
.claude/settings.local.json
Normal file
12
.claude/settings.local.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"WebFetch(domain:consultoria-as.com)",
|
||||||
|
"WebFetch(domain:x.com)",
|
||||||
|
"Bash(git init:*)",
|
||||||
|
"Bash(git add:*)",
|
||||||
|
"Bash(git commit -m \"$\\(cat <<''EOF''\nImplementación inicial del sistema de automatización de redes sociales\n\n- Estructura completa del proyecto con FastAPI\n- Modelos de base de datos \\(productos, servicios, posts, calendario, interacciones\\)\n- Publishers para X, Threads, Instagram, Facebook\n- Generador de contenido con DeepSeek API\n- Worker de Celery con tareas programadas\n- Dashboard básico con templates HTML\n- Docker Compose para despliegue\n- Documentación completa\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
|
||||||
|
"Bash(git config:*)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
74
.env.example
Normal file
74
.env.example
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
# ===========================================
|
||||||
|
# CONFIGURACIÓN DEL SISTEMA
|
||||||
|
# ===========================================
|
||||||
|
|
||||||
|
# Aplicación
|
||||||
|
APP_NAME=social-media-automation
|
||||||
|
APP_ENV=development
|
||||||
|
DEBUG=true
|
||||||
|
SECRET_KEY=your-secret-key-here-change-in-production
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# BASE DE DATOS
|
||||||
|
# ===========================================
|
||||||
|
DATABASE_URL=postgresql://user:password@localhost:5432/social_automation
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# REDIS
|
||||||
|
# ===========================================
|
||||||
|
REDIS_URL=redis://localhost:6379/0
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# DEEPSEEK API (Generación de contenido)
|
||||||
|
# ===========================================
|
||||||
|
DEEPSEEK_API_KEY=your-deepseek-api-key
|
||||||
|
DEEPSEEK_BASE_URL=https://api.deepseek.com/v1
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# X (TWITTER) API
|
||||||
|
# ===========================================
|
||||||
|
X_API_KEY=your-x-api-key
|
||||||
|
X_API_SECRET=your-x-api-secret
|
||||||
|
X_ACCESS_TOKEN=your-x-access-token
|
||||||
|
X_ACCESS_TOKEN_SECRET=your-x-access-token-secret
|
||||||
|
X_BEARER_TOKEN=your-x-bearer-token
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# META API (Facebook, Instagram, Threads)
|
||||||
|
# ===========================================
|
||||||
|
META_APP_ID=your-meta-app-id
|
||||||
|
META_APP_SECRET=your-meta-app-secret
|
||||||
|
META_ACCESS_TOKEN=your-meta-access-token
|
||||||
|
|
||||||
|
# Facebook
|
||||||
|
FACEBOOK_PAGE_ID=your-facebook-page-id
|
||||||
|
|
||||||
|
# Instagram
|
||||||
|
INSTAGRAM_ACCOUNT_ID=your-instagram-account-id
|
||||||
|
|
||||||
|
# Threads
|
||||||
|
THREADS_USER_ID=your-threads-user-id
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# CONFIGURACIÓN DE CONTENIDO
|
||||||
|
# ===========================================
|
||||||
|
|
||||||
|
# Información del negocio
|
||||||
|
BUSINESS_NAME=Consultoría AS
|
||||||
|
BUSINESS_LOCATION=Tijuana, México
|
||||||
|
BUSINESS_WEBSITE=https://consultoria-as.com
|
||||||
|
|
||||||
|
# Tono de comunicación
|
||||||
|
CONTENT_TONE=profesional pero cercano, educativo, orientado a soluciones
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# NOTIFICACIONES (Opcional)
|
||||||
|
# ===========================================
|
||||||
|
TELEGRAM_BOT_TOKEN=your-telegram-bot-token
|
||||||
|
TELEGRAM_CHAT_ID=your-telegram-chat-id
|
||||||
|
|
||||||
|
# Email
|
||||||
|
SMTP_HOST=smtp.example.com
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_USER=your-email@example.com
|
||||||
|
SMTP_PASSWORD=your-email-password
|
||||||
91
.gitignore
vendored
Normal file
91
.gitignore
vendored
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
|
||||||
|
# Virtual environments
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env/
|
||||||
|
.venv/
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
logs/
|
||||||
|
|
||||||
|
# Database
|
||||||
|
*.db
|
||||||
|
*.sqlite3
|
||||||
|
|
||||||
|
# Uploads
|
||||||
|
uploads/
|
||||||
|
!uploads/.gitkeep
|
||||||
|
|
||||||
|
# Generated images
|
||||||
|
templates/*.png
|
||||||
|
|
||||||
|
# Celery
|
||||||
|
celerybeat-schedule
|
||||||
|
celerybeat.pid
|
||||||
|
|
||||||
|
# Coverage
|
||||||
|
htmlcov/
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
|
||||||
|
# pytest
|
||||||
|
.pytest_cache/
|
||||||
|
|
||||||
|
# mypy
|
||||||
|
.mypy_cache/
|
||||||
|
|
||||||
|
# Docker volumes
|
||||||
|
postgres_data/
|
||||||
|
redis_data/
|
||||||
|
|
||||||
|
# SSL certificates
|
||||||
|
nginx/ssl/
|
||||||
|
|
||||||
|
# Temporary files
|
||||||
|
tmp/
|
||||||
|
temp/
|
||||||
|
*.tmp
|
||||||
|
|
||||||
|
# Secrets (nunca subir)
|
||||||
|
*secret*
|
||||||
|
*credential*
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
50
Dockerfile
Normal file
50
Dockerfile
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
# Establecer directorio de trabajo
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Variables de entorno
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
# Instalar dependencias del sistema
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
gcc \
|
||||||
|
libpq-dev \
|
||||||
|
chromium \
|
||||||
|
chromium-driver \
|
||||||
|
fonts-liberation \
|
||||||
|
libasound2 \
|
||||||
|
libatk-bridge2.0-0 \
|
||||||
|
libatk1.0-0 \
|
||||||
|
libatspi2.0-0 \
|
||||||
|
libcups2 \
|
||||||
|
libdbus-1-3 \
|
||||||
|
libdrm2 \
|
||||||
|
libgbm1 \
|
||||||
|
libgtk-3-0 \
|
||||||
|
libnspr4 \
|
||||||
|
libnss3 \
|
||||||
|
libxcomposite1 \
|
||||||
|
libxdamage1 \
|
||||||
|
libxfixes3 \
|
||||||
|
libxkbcommon0 \
|
||||||
|
libxrandr2 \
|
||||||
|
xdg-utils \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Copiar requirements e instalar dependencias Python
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Copiar código de la aplicación
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Crear directorio para uploads
|
||||||
|
RUN mkdir -p /app/uploads
|
||||||
|
|
||||||
|
# Exponer puerto
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
# Comando por defecto
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
|
||||||
160
README.md
Normal file
160
README.md
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
# Social Media Automation - Consultoría AS
|
||||||
|
|
||||||
|
Sistema automatizado para la creación y publicación de contenido en redes sociales (X, Threads, Instagram, Facebook).
|
||||||
|
|
||||||
|
## Características
|
||||||
|
|
||||||
|
- **Generación de contenido con IA** (DeepSeek API)
|
||||||
|
- **Publicación automática** en múltiples plataformas
|
||||||
|
- **Calendario de contenido** configurable
|
||||||
|
- **Dashboard de gestión** para aprobar y revisar posts
|
||||||
|
- **Gestión de interacciones** (comentarios, menciones, DMs)
|
||||||
|
- **Plantillas de imágenes** personalizadas
|
||||||
|
|
||||||
|
## Stack Tecnológico
|
||||||
|
|
||||||
|
- **Backend**: Python + FastAPI
|
||||||
|
- **Base de datos**: PostgreSQL
|
||||||
|
- **Cola de tareas**: Celery + Redis
|
||||||
|
- **IA**: DeepSeek API
|
||||||
|
- **Contenedores**: Docker Compose
|
||||||
|
|
||||||
|
## Instalación
|
||||||
|
|
||||||
|
### Requisitos Previos
|
||||||
|
|
||||||
|
- Docker y Docker Compose
|
||||||
|
- Cuentas de desarrollador en:
|
||||||
|
- [X Developer](https://developer.twitter.com)
|
||||||
|
- [Meta Developer](https://developers.facebook.com)
|
||||||
|
- [DeepSeek](https://platform.deepseek.com)
|
||||||
|
|
||||||
|
### Pasos
|
||||||
|
|
||||||
|
1. **Clonar el repositorio**
|
||||||
|
```bash
|
||||||
|
git clone https://git.consultoria-as.com/consultoria-as/social-media-automation.git
|
||||||
|
cd social-media-automation
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Configurar variables de entorno**
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
# Editar .env con tus credenciales
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Iniciar servicios**
|
||||||
|
```bash
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Poblar base de datos**
|
||||||
|
```bash
|
||||||
|
docker-compose exec app python scripts/seed_database.py
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Acceder al dashboard**
|
||||||
|
```
|
||||||
|
http://localhost:8000
|
||||||
|
```
|
||||||
|
|
||||||
|
## Estructura del Proyecto
|
||||||
|
|
||||||
|
```
|
||||||
|
├── app/ # Aplicación principal
|
||||||
|
│ ├── api/ # Endpoints de la API
|
||||||
|
│ ├── core/ # Configuración
|
||||||
|
│ ├── models/ # Modelos SQLAlchemy
|
||||||
|
│ ├── publishers/ # Publishers por plataforma
|
||||||
|
│ ├── services/ # Lógica de negocio
|
||||||
|
│ └── prompts/ # Prompts para IA
|
||||||
|
├── worker/ # Celery workers
|
||||||
|
│ └── tasks/ # Tareas programadas
|
||||||
|
├── dashboard/ # Frontend del dashboard
|
||||||
|
├── templates/ # Plantillas de imágenes
|
||||||
|
├── data/ # Datos iniciales
|
||||||
|
├── scripts/ # Scripts de utilidad
|
||||||
|
└── docker-compose.yml # Configuración Docker
|
||||||
|
```
|
||||||
|
|
||||||
|
## Uso
|
||||||
|
|
||||||
|
### Dashboard
|
||||||
|
|
||||||
|
- **Home**: Resumen y estadísticas
|
||||||
|
- **Posts**: Gestionar publicaciones
|
||||||
|
- **Calendario**: Configurar horarios
|
||||||
|
- **Interacciones**: Responder comentarios
|
||||||
|
- **Productos**: Gestionar catálogo
|
||||||
|
|
||||||
|
### API
|
||||||
|
|
||||||
|
Documentación disponible en:
|
||||||
|
- Swagger: `http://localhost:8000/api/docs`
|
||||||
|
- ReDoc: `http://localhost:8000/api/redoc`
|
||||||
|
|
||||||
|
### Endpoints Principales
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/posts # Listar posts
|
||||||
|
POST /api/posts # Crear post
|
||||||
|
GET /api/posts/pending # Posts pendientes
|
||||||
|
POST /api/posts/{id}/approve # Aprobar post
|
||||||
|
|
||||||
|
GET /api/products # Listar productos
|
||||||
|
POST /api/products # Crear producto
|
||||||
|
|
||||||
|
GET /api/calendar # Ver calendario
|
||||||
|
POST /api/calendar # Crear entrada
|
||||||
|
|
||||||
|
GET /api/interactions # Ver interacciones
|
||||||
|
POST /api/interactions/{id}/respond # Responder
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuración del Calendario
|
||||||
|
|
||||||
|
El calendario determina qué tipo de contenido se publica y cuándo:
|
||||||
|
|
||||||
|
| Día | Hora | Tipo | Plataformas |
|
||||||
|
|-----|------|------|-------------|
|
||||||
|
| L-V | 09:00 | Tip Tech | X, Threads, IG |
|
||||||
|
| L-V | 15:00 | Dato Curioso | X, Threads |
|
||||||
|
| L,M,V | 12:00 | Producto | FB, IG |
|
||||||
|
| M,J | 11:00 | Servicio | X, FB, IG |
|
||||||
|
|
||||||
|
## Desarrollo
|
||||||
|
|
||||||
|
### Ejecutar sin Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Instalar dependencias
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
# Variables de entorno
|
||||||
|
export DATABASE_URL=postgresql://...
|
||||||
|
export REDIS_URL=redis://...
|
||||||
|
|
||||||
|
# Iniciar API
|
||||||
|
uvicorn app.main:app --reload
|
||||||
|
|
||||||
|
# Iniciar Worker
|
||||||
|
celery -A worker.celery_app worker --loglevel=info
|
||||||
|
|
||||||
|
# Iniciar Beat
|
||||||
|
celery -A worker.celery_app beat --loglevel=info
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pytest tests/ -v
|
||||||
|
```
|
||||||
|
|
||||||
|
## Licencia
|
||||||
|
|
||||||
|
Propiedad de Consultoría AS - Todos los derechos reservados.
|
||||||
|
|
||||||
|
## Contacto
|
||||||
|
|
||||||
|
- Web: https://consultoria-as.com
|
||||||
|
- Email: contacto@consultoria-as.com
|
||||||
1
app/__init__.py
Normal file
1
app/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Social Media Automation - Consultoría AS
|
||||||
1
app/api/__init__.py
Normal file
1
app/api/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# API Routes
|
||||||
1
app/api/routes/__init__.py
Normal file
1
app/api/routes/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# API Route modules
|
||||||
201
app/api/routes/calendar.py
Normal file
201
app/api/routes/calendar.py
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
"""
|
||||||
|
API Routes para gestión del Calendario de Contenido.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime, time
|
||||||
|
from typing import List, Optional
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.models.content_calendar import ContentCalendar
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# SCHEMAS
|
||||||
|
# ===========================================
|
||||||
|
|
||||||
|
class CalendarEntryCreate(BaseModel):
|
||||||
|
day_of_week: int # 0=Lunes, 6=Domingo
|
||||||
|
time: str # Formato "HH:MM"
|
||||||
|
content_type: str
|
||||||
|
platforms: List[str]
|
||||||
|
requires_approval: bool = False
|
||||||
|
category_filter: Optional[str] = None
|
||||||
|
priority: int = 0
|
||||||
|
description: Optional[str] = None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class CalendarEntryUpdate(BaseModel):
|
||||||
|
day_of_week: Optional[int] = None
|
||||||
|
time: Optional[str] = None
|
||||||
|
content_type: Optional[str] = None
|
||||||
|
platforms: Optional[List[str]] = None
|
||||||
|
is_active: Optional[bool] = None
|
||||||
|
requires_approval: Optional[bool] = None
|
||||||
|
category_filter: Optional[str] = None
|
||||||
|
priority: Optional[int] = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# ENDPOINTS
|
||||||
|
# ===========================================
|
||||||
|
|
||||||
|
@router.get("/")
|
||||||
|
async def list_calendar_entries(
|
||||||
|
day_of_week: Optional[int] = Query(None),
|
||||||
|
content_type: Optional[str] = Query(None),
|
||||||
|
platform: Optional[str] = Query(None),
|
||||||
|
is_active: Optional[bool] = Query(True),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Listar entradas del calendario."""
|
||||||
|
query = db.query(ContentCalendar)
|
||||||
|
|
||||||
|
if day_of_week is not None:
|
||||||
|
query = query.filter(ContentCalendar.day_of_week == day_of_week)
|
||||||
|
if content_type:
|
||||||
|
query = query.filter(ContentCalendar.content_type == content_type)
|
||||||
|
if is_active is not None:
|
||||||
|
query = query.filter(ContentCalendar.is_active == is_active)
|
||||||
|
if platform:
|
||||||
|
query = query.filter(ContentCalendar.platforms.contains([platform]))
|
||||||
|
|
||||||
|
entries = query.order_by(
|
||||||
|
ContentCalendar.day_of_week,
|
||||||
|
ContentCalendar.time
|
||||||
|
).all()
|
||||||
|
|
||||||
|
return [e.to_dict() for e in entries]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/week")
|
||||||
|
async def get_week_schedule(db: Session = Depends(get_db)):
|
||||||
|
"""Obtener el calendario semanal completo."""
|
||||||
|
entries = db.query(ContentCalendar).filter(
|
||||||
|
ContentCalendar.is_active == True
|
||||||
|
).order_by(
|
||||||
|
ContentCalendar.day_of_week,
|
||||||
|
ContentCalendar.time
|
||||||
|
).all()
|
||||||
|
|
||||||
|
# Organizar por día
|
||||||
|
week = {i: [] for i in range(7)}
|
||||||
|
for entry in entries:
|
||||||
|
week[entry.day_of_week].append(entry.to_dict())
|
||||||
|
|
||||||
|
days = ["Lunes", "Martes", "Miércoles", "Jueves", "Viernes", "Sábado", "Domingo"]
|
||||||
|
return {
|
||||||
|
days[i]: week[i] for i in range(7)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/today")
|
||||||
|
async def get_today_schedule(db: Session = Depends(get_db)):
|
||||||
|
"""Obtener el calendario de hoy."""
|
||||||
|
today = datetime.now().weekday()
|
||||||
|
|
||||||
|
entries = db.query(ContentCalendar).filter(
|
||||||
|
ContentCalendar.day_of_week == today,
|
||||||
|
ContentCalendar.is_active == True
|
||||||
|
).order_by(ContentCalendar.time).all()
|
||||||
|
|
||||||
|
return [e.to_dict() for e in entries]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{entry_id}")
|
||||||
|
async def get_calendar_entry(entry_id: int, db: Session = Depends(get_db)):
|
||||||
|
"""Obtener una entrada del calendario por ID."""
|
||||||
|
entry = db.query(ContentCalendar).filter(ContentCalendar.id == entry_id).first()
|
||||||
|
if not entry:
|
||||||
|
raise HTTPException(status_code=404, detail="Entrada no encontrada")
|
||||||
|
return entry.to_dict()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/")
|
||||||
|
async def create_calendar_entry(entry_data: CalendarEntryCreate, db: Session = Depends(get_db)):
|
||||||
|
"""Crear una nueva entrada en el calendario."""
|
||||||
|
# Parsear hora
|
||||||
|
hour, minute = map(int, entry_data.time.split(":"))
|
||||||
|
entry_time = time(hour=hour, minute=minute)
|
||||||
|
|
||||||
|
entry = ContentCalendar(
|
||||||
|
day_of_week=entry_data.day_of_week,
|
||||||
|
time=entry_time,
|
||||||
|
content_type=entry_data.content_type,
|
||||||
|
platforms=entry_data.platforms,
|
||||||
|
requires_approval=entry_data.requires_approval,
|
||||||
|
category_filter=entry_data.category_filter,
|
||||||
|
priority=entry_data.priority,
|
||||||
|
description=entry_data.description
|
||||||
|
)
|
||||||
|
|
||||||
|
db.add(entry)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(entry)
|
||||||
|
return entry.to_dict()
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{entry_id}")
|
||||||
|
async def update_calendar_entry(
|
||||||
|
entry_id: int,
|
||||||
|
entry_data: CalendarEntryUpdate,
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Actualizar una entrada del calendario."""
|
||||||
|
entry = db.query(ContentCalendar).filter(ContentCalendar.id == entry_id).first()
|
||||||
|
if not entry:
|
||||||
|
raise HTTPException(status_code=404, detail="Entrada no encontrada")
|
||||||
|
|
||||||
|
update_data = entry_data.dict(exclude_unset=True)
|
||||||
|
|
||||||
|
# Parsear hora si se proporciona
|
||||||
|
if "time" in update_data and update_data["time"]:
|
||||||
|
hour, minute = map(int, update_data["time"].split(":"))
|
||||||
|
update_data["time"] = time(hour=hour, minute=minute)
|
||||||
|
|
||||||
|
for field, value in update_data.items():
|
||||||
|
setattr(entry, field, value)
|
||||||
|
|
||||||
|
entry.updated_at = datetime.utcnow()
|
||||||
|
db.commit()
|
||||||
|
db.refresh(entry)
|
||||||
|
return entry.to_dict()
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{entry_id}")
|
||||||
|
async def delete_calendar_entry(entry_id: int, db: Session = Depends(get_db)):
|
||||||
|
"""Eliminar una entrada del calendario."""
|
||||||
|
entry = db.query(ContentCalendar).filter(ContentCalendar.id == entry_id).first()
|
||||||
|
if not entry:
|
||||||
|
raise HTTPException(status_code=404, detail="Entrada no encontrada")
|
||||||
|
|
||||||
|
db.delete(entry)
|
||||||
|
db.commit()
|
||||||
|
return {"message": "Entrada eliminada", "entry_id": entry_id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{entry_id}/toggle")
|
||||||
|
async def toggle_calendar_entry(entry_id: int, db: Session = Depends(get_db)):
|
||||||
|
"""Activar/desactivar una entrada del calendario."""
|
||||||
|
entry = db.query(ContentCalendar).filter(ContentCalendar.id == entry_id).first()
|
||||||
|
if not entry:
|
||||||
|
raise HTTPException(status_code=404, detail="Entrada no encontrada")
|
||||||
|
|
||||||
|
entry.is_active = not entry.is_active
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"message": f"Entrada {'activada' if entry.is_active else 'desactivada'}",
|
||||||
|
"is_active": entry.is_active
|
||||||
|
}
|
||||||
118
app/api/routes/dashboard.py
Normal file
118
app/api/routes/dashboard.py
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
"""
|
||||||
|
API Routes para el Dashboard.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from fastapi import APIRouter, Depends, Request
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy import func
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.models.post import Post
|
||||||
|
from app.models.interaction import Interaction
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
templates = Jinja2Templates(directory="dashboard/templates")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_class=HTMLResponse)
|
||||||
|
async def dashboard_home(request: Request, db: Session = Depends(get_db)):
|
||||||
|
"""Página principal del dashboard."""
|
||||||
|
# Estadísticas
|
||||||
|
now = datetime.utcnow()
|
||||||
|
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
week_start = today_start - timedelta(days=now.weekday())
|
||||||
|
|
||||||
|
stats = {
|
||||||
|
"posts_today": db.query(Post).filter(
|
||||||
|
Post.published_at >= today_start
|
||||||
|
).count(),
|
||||||
|
"posts_week": db.query(Post).filter(
|
||||||
|
Post.published_at >= week_start
|
||||||
|
).count(),
|
||||||
|
"pending_approval": db.query(Post).filter(
|
||||||
|
Post.status == "pending_approval"
|
||||||
|
).count(),
|
||||||
|
"scheduled": db.query(Post).filter(
|
||||||
|
Post.status == "scheduled"
|
||||||
|
).count(),
|
||||||
|
"interactions_pending": db.query(Interaction).filter(
|
||||||
|
Interaction.responded == False,
|
||||||
|
Interaction.is_archived == False
|
||||||
|
).count()
|
||||||
|
}
|
||||||
|
|
||||||
|
# Posts pendientes
|
||||||
|
pending_posts = db.query(Post).filter(
|
||||||
|
Post.status == "pending_approval"
|
||||||
|
).order_by(Post.scheduled_at.asc()).limit(5).all()
|
||||||
|
|
||||||
|
# Próximas publicaciones
|
||||||
|
scheduled_posts = db.query(Post).filter(
|
||||||
|
Post.status == "scheduled",
|
||||||
|
Post.scheduled_at >= now
|
||||||
|
).order_by(Post.scheduled_at.asc()).limit(5).all()
|
||||||
|
|
||||||
|
# Interacciones recientes
|
||||||
|
recent_interactions = db.query(Interaction).filter(
|
||||||
|
Interaction.responded == False
|
||||||
|
).order_by(Interaction.interaction_at.desc()).limit(5).all()
|
||||||
|
|
||||||
|
return templates.TemplateResponse("index.html", {
|
||||||
|
"request": request,
|
||||||
|
"stats": stats,
|
||||||
|
"pending_posts": [p.to_dict() for p in pending_posts],
|
||||||
|
"scheduled_posts": [p.to_dict() for p in scheduled_posts],
|
||||||
|
"recent_interactions": [i.to_dict() for i in recent_interactions]
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/posts", response_class=HTMLResponse)
|
||||||
|
async def dashboard_posts(request: Request, db: Session = Depends(get_db)):
|
||||||
|
"""Página de gestión de posts."""
|
||||||
|
posts = db.query(Post).order_by(Post.created_at.desc()).limit(50).all()
|
||||||
|
|
||||||
|
return templates.TemplateResponse("posts.html", {
|
||||||
|
"request": request,
|
||||||
|
"posts": [p.to_dict() for p in posts]
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/calendar", response_class=HTMLResponse)
|
||||||
|
async def dashboard_calendar(request: Request):
|
||||||
|
"""Página de calendario."""
|
||||||
|
return templates.TemplateResponse("calendar.html", {
|
||||||
|
"request": request
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/interactions", response_class=HTMLResponse)
|
||||||
|
async def dashboard_interactions(request: Request, db: Session = Depends(get_db)):
|
||||||
|
"""Página de interacciones."""
|
||||||
|
interactions = db.query(Interaction).filter(
|
||||||
|
Interaction.is_archived == False
|
||||||
|
).order_by(Interaction.interaction_at.desc()).limit(50).all()
|
||||||
|
|
||||||
|
return templates.TemplateResponse("interactions.html", {
|
||||||
|
"request": request,
|
||||||
|
"interactions": [i.to_dict() for i in interactions]
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/products", response_class=HTMLResponse)
|
||||||
|
async def dashboard_products(request: Request):
|
||||||
|
"""Página de productos."""
|
||||||
|
return templates.TemplateResponse("products.html", {
|
||||||
|
"request": request
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/services", response_class=HTMLResponse)
|
||||||
|
async def dashboard_services(request: Request):
|
||||||
|
"""Página de servicios."""
|
||||||
|
return templates.TemplateResponse("services.html", {
|
||||||
|
"request": request
|
||||||
|
})
|
||||||
226
app/api/routes/interactions.py
Normal file
226
app/api/routes/interactions.py
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
"""
|
||||||
|
API Routes para gestión de Interacciones.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import List, Optional
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.models.interaction import Interaction
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# SCHEMAS
|
||||||
|
# ===========================================
|
||||||
|
|
||||||
|
class InteractionResponse(BaseModel):
|
||||||
|
content: str
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# ENDPOINTS
|
||||||
|
# ===========================================
|
||||||
|
|
||||||
|
@router.get("/")
|
||||||
|
async def list_interactions(
|
||||||
|
platform: Optional[str] = Query(None),
|
||||||
|
interaction_type: Optional[str] = Query(None),
|
||||||
|
responded: Optional[bool] = Query(None),
|
||||||
|
is_lead: Optional[bool] = Query(None),
|
||||||
|
is_read: Optional[bool] = Query(None),
|
||||||
|
limit: int = Query(50, le=100),
|
||||||
|
offset: int = Query(0),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Listar interacciones con filtros."""
|
||||||
|
query = db.query(Interaction)
|
||||||
|
|
||||||
|
if platform:
|
||||||
|
query = query.filter(Interaction.platform == platform)
|
||||||
|
if interaction_type:
|
||||||
|
query = query.filter(Interaction.interaction_type == interaction_type)
|
||||||
|
if responded is not None:
|
||||||
|
query = query.filter(Interaction.responded == responded)
|
||||||
|
if is_lead is not None:
|
||||||
|
query = query.filter(Interaction.is_lead == is_lead)
|
||||||
|
if is_read is not None:
|
||||||
|
query = query.filter(Interaction.is_read == is_read)
|
||||||
|
|
||||||
|
interactions = query.order_by(
|
||||||
|
Interaction.interaction_at.desc()
|
||||||
|
).offset(offset).limit(limit).all()
|
||||||
|
|
||||||
|
return [i.to_dict() for i in interactions]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/pending")
|
||||||
|
async def list_pending_interactions(db: Session = Depends(get_db)):
|
||||||
|
"""Listar interacciones sin responder."""
|
||||||
|
interactions = db.query(Interaction).filter(
|
||||||
|
Interaction.responded == False,
|
||||||
|
Interaction.is_archived == False
|
||||||
|
).order_by(
|
||||||
|
Interaction.priority.desc(),
|
||||||
|
Interaction.interaction_at.desc()
|
||||||
|
).all()
|
||||||
|
|
||||||
|
return [i.to_dict() for i in interactions]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/leads")
|
||||||
|
async def list_leads(db: Session = Depends(get_db)):
|
||||||
|
"""Listar interacciones marcadas como leads."""
|
||||||
|
interactions = db.query(Interaction).filter(
|
||||||
|
Interaction.is_lead == True
|
||||||
|
).order_by(Interaction.interaction_at.desc()).all()
|
||||||
|
|
||||||
|
return [i.to_dict() for i in interactions]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/stats")
|
||||||
|
async def get_interaction_stats(db: Session = Depends(get_db)):
|
||||||
|
"""Obtener estadísticas de interacciones."""
|
||||||
|
total = db.query(Interaction).count()
|
||||||
|
pending = db.query(Interaction).filter(
|
||||||
|
Interaction.responded == False,
|
||||||
|
Interaction.is_archived == False
|
||||||
|
).count()
|
||||||
|
leads = db.query(Interaction).filter(Interaction.is_lead == True).count()
|
||||||
|
|
||||||
|
# Por plataforma
|
||||||
|
by_platform = {}
|
||||||
|
for platform in ["x", "threads", "instagram", "facebook"]:
|
||||||
|
by_platform[platform] = db.query(Interaction).filter(
|
||||||
|
Interaction.platform == platform
|
||||||
|
).count()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total": total,
|
||||||
|
"pending": pending,
|
||||||
|
"leads": leads,
|
||||||
|
"by_platform": by_platform
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{interaction_id}")
|
||||||
|
async def get_interaction(interaction_id: int, db: Session = Depends(get_db)):
|
||||||
|
"""Obtener una interacción por ID."""
|
||||||
|
interaction = db.query(Interaction).filter(Interaction.id == interaction_id).first()
|
||||||
|
if not interaction:
|
||||||
|
raise HTTPException(status_code=404, detail="Interacción no encontrada")
|
||||||
|
return interaction.to_dict()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{interaction_id}/respond")
|
||||||
|
async def respond_to_interaction(
|
||||||
|
interaction_id: int,
|
||||||
|
response_data: InteractionResponse,
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Responder a una interacción."""
|
||||||
|
interaction = db.query(Interaction).filter(Interaction.id == interaction_id).first()
|
||||||
|
if not interaction:
|
||||||
|
raise HTTPException(status_code=404, detail="Interacción no encontrada")
|
||||||
|
|
||||||
|
# TODO: Enviar respuesta a la plataforma correspondiente
|
||||||
|
# from app.publishers import get_publisher
|
||||||
|
# publisher = get_publisher(interaction.platform)
|
||||||
|
# result = await publisher.reply(interaction.external_id, response_data.content)
|
||||||
|
|
||||||
|
interaction.responded = True
|
||||||
|
interaction.response_content = response_data.content
|
||||||
|
interaction.responded_at = datetime.utcnow()
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"message": "Respuesta guardada",
|
||||||
|
"interaction_id": interaction_id,
|
||||||
|
"note": "La publicación a la plataforma está en desarrollo"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{interaction_id}/suggest-response")
|
||||||
|
async def suggest_response(interaction_id: int, db: Session = Depends(get_db)):
|
||||||
|
"""Generar sugerencia de respuesta con IA."""
|
||||||
|
interaction = db.query(Interaction).filter(Interaction.id == interaction_id).first()
|
||||||
|
if not interaction:
|
||||||
|
raise HTTPException(status_code=404, detail="Interacción no encontrada")
|
||||||
|
|
||||||
|
# TODO: Implementar generación con DeepSeek
|
||||||
|
# from app.services.content_generator import generate_response_suggestion
|
||||||
|
# suggestions = await generate_response_suggestion(interaction)
|
||||||
|
|
||||||
|
# Respuestas placeholder
|
||||||
|
suggestions = [
|
||||||
|
f"¡Gracias por tu comentario! Nos da gusto que te interese nuestro contenido.",
|
||||||
|
f"¡Hola! Gracias por escribirnos. ¿En qué podemos ayudarte?",
|
||||||
|
f"¡Excelente pregunta! Te respondemos por DM para darte más detalles."
|
||||||
|
]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"suggestions": suggestions,
|
||||||
|
"note": "La generación con IA está en desarrollo"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{interaction_id}/mark-as-lead")
|
||||||
|
async def mark_as_lead(interaction_id: int, db: Session = Depends(get_db)):
|
||||||
|
"""Marcar interacción como lead potencial."""
|
||||||
|
interaction = db.query(Interaction).filter(Interaction.id == interaction_id).first()
|
||||||
|
if not interaction:
|
||||||
|
raise HTTPException(status_code=404, detail="Interacción no encontrada")
|
||||||
|
|
||||||
|
interaction.is_lead = not interaction.is_lead
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"message": f"{'Marcado' if interaction.is_lead else 'Desmarcado'} como lead",
|
||||||
|
"is_lead": interaction.is_lead
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{interaction_id}/mark-as-read")
|
||||||
|
async def mark_as_read(interaction_id: int, db: Session = Depends(get_db)):
|
||||||
|
"""Marcar interacción como leída."""
|
||||||
|
interaction = db.query(Interaction).filter(Interaction.id == interaction_id).first()
|
||||||
|
if not interaction:
|
||||||
|
raise HTTPException(status_code=404, detail="Interacción no encontrada")
|
||||||
|
|
||||||
|
interaction.is_read = True
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {"message": "Marcado como leído", "interaction_id": interaction_id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{interaction_id}/archive")
|
||||||
|
async def archive_interaction(interaction_id: int, db: Session = Depends(get_db)):
|
||||||
|
"""Archivar una interacción."""
|
||||||
|
interaction = db.query(Interaction).filter(Interaction.id == interaction_id).first()
|
||||||
|
if not interaction:
|
||||||
|
raise HTTPException(status_code=404, detail="Interacción no encontrada")
|
||||||
|
|
||||||
|
interaction.is_archived = True
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {"message": "Interacción archivada", "interaction_id": interaction_id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{interaction_id}")
|
||||||
|
async def delete_interaction(interaction_id: int, db: Session = Depends(get_db)):
|
||||||
|
"""Eliminar una interacción."""
|
||||||
|
interaction = db.query(Interaction).filter(Interaction.id == interaction_id).first()
|
||||||
|
if not interaction:
|
||||||
|
raise HTTPException(status_code=404, detail="Interacción no encontrada")
|
||||||
|
|
||||||
|
db.delete(interaction)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {"message": "Interacción eliminada", "interaction_id": interaction_id}
|
||||||
216
app/api/routes/posts.py
Normal file
216
app/api/routes/posts.py
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
"""
|
||||||
|
API Routes para gestión de Posts.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import List, Optional
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.models.post import Post
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# SCHEMAS
|
||||||
|
# ===========================================
|
||||||
|
|
||||||
|
class PostCreate(BaseModel):
|
||||||
|
content: str
|
||||||
|
content_type: str
|
||||||
|
platforms: List[str]
|
||||||
|
scheduled_at: Optional[datetime] = None
|
||||||
|
image_url: Optional[str] = None
|
||||||
|
approval_required: bool = False
|
||||||
|
hashtags: Optional[List[str]] = None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class PostUpdate(BaseModel):
|
||||||
|
content: Optional[str] = None
|
||||||
|
content_x: Optional[str] = None
|
||||||
|
content_threads: Optional[str] = None
|
||||||
|
content_instagram: Optional[str] = None
|
||||||
|
content_facebook: Optional[str] = None
|
||||||
|
platforms: Optional[List[str]] = None
|
||||||
|
scheduled_at: Optional[datetime] = None
|
||||||
|
status: Optional[str] = None
|
||||||
|
image_url: Optional[str] = None
|
||||||
|
hashtags: Optional[List[str]] = None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class PostResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
content: str
|
||||||
|
content_type: str
|
||||||
|
platforms: List[str]
|
||||||
|
status: str
|
||||||
|
scheduled_at: Optional[datetime]
|
||||||
|
published_at: Optional[datetime]
|
||||||
|
image_url: Optional[str]
|
||||||
|
approval_required: bool
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# ENDPOINTS
|
||||||
|
# ===========================================
|
||||||
|
|
||||||
|
@router.get("/", response_model=List[PostResponse])
|
||||||
|
async def list_posts(
|
||||||
|
status: Optional[str] = Query(None, description="Filtrar por estado"),
|
||||||
|
content_type: Optional[str] = Query(None, description="Filtrar por tipo"),
|
||||||
|
platform: Optional[str] = Query(None, description="Filtrar por plataforma"),
|
||||||
|
limit: int = Query(50, le=100),
|
||||||
|
offset: int = Query(0),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Listar posts con filtros opcionales."""
|
||||||
|
query = db.query(Post)
|
||||||
|
|
||||||
|
if status:
|
||||||
|
query = query.filter(Post.status == status)
|
||||||
|
if content_type:
|
||||||
|
query = query.filter(Post.content_type == content_type)
|
||||||
|
if platform:
|
||||||
|
query = query.filter(Post.platforms.contains([platform]))
|
||||||
|
|
||||||
|
posts = query.order_by(Post.created_at.desc()).offset(offset).limit(limit).all()
|
||||||
|
return posts
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/pending")
|
||||||
|
async def list_pending_posts(db: Session = Depends(get_db)):
|
||||||
|
"""Listar posts pendientes de aprobación."""
|
||||||
|
posts = db.query(Post).filter(
|
||||||
|
Post.status == "pending_approval"
|
||||||
|
).order_by(Post.scheduled_at.asc()).all()
|
||||||
|
|
||||||
|
return [post.to_dict() for post in posts]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/scheduled")
|
||||||
|
async def list_scheduled_posts(db: Session = Depends(get_db)):
|
||||||
|
"""Listar posts programados."""
|
||||||
|
posts = db.query(Post).filter(
|
||||||
|
Post.status == "scheduled",
|
||||||
|
Post.scheduled_at >= datetime.utcnow()
|
||||||
|
).order_by(Post.scheduled_at.asc()).all()
|
||||||
|
|
||||||
|
return [post.to_dict() for post in posts]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{post_id}")
|
||||||
|
async def get_post(post_id: int, db: Session = Depends(get_db)):
|
||||||
|
"""Obtener un post por ID."""
|
||||||
|
post = db.query(Post).filter(Post.id == post_id).first()
|
||||||
|
if not post:
|
||||||
|
raise HTTPException(status_code=404, detail="Post no encontrado")
|
||||||
|
return post.to_dict()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/", response_model=PostResponse)
|
||||||
|
async def create_post(post_data: PostCreate, db: Session = Depends(get_db)):
|
||||||
|
"""Crear un nuevo post."""
|
||||||
|
post = Post(
|
||||||
|
content=post_data.content,
|
||||||
|
content_type=post_data.content_type,
|
||||||
|
platforms=post_data.platforms,
|
||||||
|
scheduled_at=post_data.scheduled_at,
|
||||||
|
image_url=post_data.image_url,
|
||||||
|
approval_required=post_data.approval_required,
|
||||||
|
hashtags=post_data.hashtags,
|
||||||
|
status="pending_approval" if post_data.approval_required else "scheduled"
|
||||||
|
)
|
||||||
|
|
||||||
|
db.add(post)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(post)
|
||||||
|
|
||||||
|
return post
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{post_id}")
|
||||||
|
async def update_post(post_id: int, post_data: PostUpdate, db: Session = Depends(get_db)):
|
||||||
|
"""Actualizar un post."""
|
||||||
|
post = db.query(Post).filter(Post.id == post_id).first()
|
||||||
|
if not post:
|
||||||
|
raise HTTPException(status_code=404, detail="Post no encontrado")
|
||||||
|
|
||||||
|
update_data = post_data.dict(exclude_unset=True)
|
||||||
|
for field, value in update_data.items():
|
||||||
|
setattr(post, field, value)
|
||||||
|
|
||||||
|
post.updated_at = datetime.utcnow()
|
||||||
|
db.commit()
|
||||||
|
db.refresh(post)
|
||||||
|
|
||||||
|
return post.to_dict()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{post_id}/approve")
|
||||||
|
async def approve_post(post_id: int, db: Session = Depends(get_db)):
|
||||||
|
"""Aprobar un post pendiente."""
|
||||||
|
post = db.query(Post).filter(Post.id == post_id).first()
|
||||||
|
if not post:
|
||||||
|
raise HTTPException(status_code=404, detail="Post no encontrado")
|
||||||
|
|
||||||
|
if post.status != "pending_approval":
|
||||||
|
raise HTTPException(status_code=400, detail="El post no está pendiente de aprobación")
|
||||||
|
|
||||||
|
post.status = "scheduled"
|
||||||
|
post.approved_at = datetime.utcnow()
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {"message": "Post aprobado", "post_id": post_id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{post_id}/reject")
|
||||||
|
async def reject_post(post_id: int, db: Session = Depends(get_db)):
|
||||||
|
"""Rechazar un post pendiente."""
|
||||||
|
post = db.query(Post).filter(Post.id == post_id).first()
|
||||||
|
if not post:
|
||||||
|
raise HTTPException(status_code=404, detail="Post no encontrado")
|
||||||
|
|
||||||
|
post.status = "cancelled"
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {"message": "Post rechazado", "post_id": post_id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{post_id}/regenerate")
|
||||||
|
async def regenerate_post(post_id: int, db: Session = Depends(get_db)):
|
||||||
|
"""Regenerar contenido de un post con IA."""
|
||||||
|
post = db.query(Post).filter(Post.id == post_id).first()
|
||||||
|
if not post:
|
||||||
|
raise HTTPException(status_code=404, detail="Post no encontrado")
|
||||||
|
|
||||||
|
# TODO: Implementar regeneración con DeepSeek
|
||||||
|
# from app.services.content_generator import regenerate_content
|
||||||
|
# new_content = await regenerate_content(post)
|
||||||
|
|
||||||
|
return {"message": "Regeneración en desarrollo", "post_id": post_id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{post_id}")
|
||||||
|
async def delete_post(post_id: int, db: Session = Depends(get_db)):
|
||||||
|
"""Eliminar un post."""
|
||||||
|
post = db.query(Post).filter(Post.id == post_id).first()
|
||||||
|
if not post:
|
||||||
|
raise HTTPException(status_code=404, detail="Post no encontrado")
|
||||||
|
|
||||||
|
db.delete(post)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {"message": "Post eliminado", "post_id": post_id}
|
||||||
180
app/api/routes/products.py
Normal file
180
app/api/routes/products.py
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
"""
|
||||||
|
API Routes para gestión de Productos.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import List, Optional
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.models.product import Product
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# SCHEMAS
|
||||||
|
# ===========================================
|
||||||
|
|
||||||
|
class ProductCreate(BaseModel):
|
||||||
|
name: str
|
||||||
|
description: Optional[str] = None
|
||||||
|
short_description: Optional[str] = None
|
||||||
|
category: str
|
||||||
|
subcategory: Optional[str] = None
|
||||||
|
brand: Optional[str] = None
|
||||||
|
model: Optional[str] = None
|
||||||
|
price: float
|
||||||
|
price_usd: Optional[float] = None
|
||||||
|
stock: int = 0
|
||||||
|
specs: Optional[dict] = None
|
||||||
|
images: Optional[List[str]] = None
|
||||||
|
main_image: Optional[str] = None
|
||||||
|
tags: Optional[List[str]] = None
|
||||||
|
highlights: Optional[List[str]] = None
|
||||||
|
is_featured: bool = False
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class ProductUpdate(BaseModel):
|
||||||
|
name: Optional[str] = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
short_description: Optional[str] = None
|
||||||
|
category: Optional[str] = None
|
||||||
|
subcategory: Optional[str] = None
|
||||||
|
brand: Optional[str] = None
|
||||||
|
model: Optional[str] = None
|
||||||
|
price: Optional[float] = None
|
||||||
|
price_usd: Optional[float] = None
|
||||||
|
stock: Optional[int] = None
|
||||||
|
is_available: Optional[bool] = None
|
||||||
|
specs: Optional[dict] = None
|
||||||
|
images: Optional[List[str]] = None
|
||||||
|
main_image: Optional[str] = None
|
||||||
|
tags: Optional[List[str]] = None
|
||||||
|
highlights: Optional[List[str]] = None
|
||||||
|
is_featured: Optional[bool] = None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# ENDPOINTS
|
||||||
|
# ===========================================
|
||||||
|
|
||||||
|
@router.get("/")
|
||||||
|
async def list_products(
|
||||||
|
category: Optional[str] = Query(None),
|
||||||
|
brand: Optional[str] = Query(None),
|
||||||
|
is_available: Optional[bool] = Query(None),
|
||||||
|
is_featured: Optional[bool] = Query(None),
|
||||||
|
min_price: Optional[float] = Query(None),
|
||||||
|
max_price: Optional[float] = Query(None),
|
||||||
|
limit: int = Query(50, le=100),
|
||||||
|
offset: int = Query(0),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Listar productos con filtros."""
|
||||||
|
query = db.query(Product)
|
||||||
|
|
||||||
|
if category:
|
||||||
|
query = query.filter(Product.category == category)
|
||||||
|
if brand:
|
||||||
|
query = query.filter(Product.brand == brand)
|
||||||
|
if is_available is not None:
|
||||||
|
query = query.filter(Product.is_available == is_available)
|
||||||
|
if is_featured is not None:
|
||||||
|
query = query.filter(Product.is_featured == is_featured)
|
||||||
|
if min_price is not None:
|
||||||
|
query = query.filter(Product.price >= min_price)
|
||||||
|
if max_price is not None:
|
||||||
|
query = query.filter(Product.price <= max_price)
|
||||||
|
|
||||||
|
products = query.order_by(Product.created_at.desc()).offset(offset).limit(limit).all()
|
||||||
|
return [p.to_dict() for p in products]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/categories")
|
||||||
|
async def list_categories(db: Session = Depends(get_db)):
|
||||||
|
"""Listar categorías únicas de productos."""
|
||||||
|
categories = db.query(Product.category).distinct().all()
|
||||||
|
return [c[0] for c in categories if c[0]]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/featured")
|
||||||
|
async def list_featured_products(db: Session = Depends(get_db)):
|
||||||
|
"""Listar productos destacados."""
|
||||||
|
products = db.query(Product).filter(
|
||||||
|
Product.is_featured == True,
|
||||||
|
Product.is_available == True
|
||||||
|
).all()
|
||||||
|
return [p.to_dict() for p in products]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{product_id}")
|
||||||
|
async def get_product(product_id: int, db: Session = Depends(get_db)):
|
||||||
|
"""Obtener un producto por ID."""
|
||||||
|
product = db.query(Product).filter(Product.id == product_id).first()
|
||||||
|
if not product:
|
||||||
|
raise HTTPException(status_code=404, detail="Producto no encontrado")
|
||||||
|
return product.to_dict()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/")
|
||||||
|
async def create_product(product_data: ProductCreate, db: Session = Depends(get_db)):
|
||||||
|
"""Crear un nuevo producto."""
|
||||||
|
product = Product(**product_data.dict())
|
||||||
|
db.add(product)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(product)
|
||||||
|
return product.to_dict()
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{product_id}")
|
||||||
|
async def update_product(product_id: int, product_data: ProductUpdate, db: Session = Depends(get_db)):
|
||||||
|
"""Actualizar un producto."""
|
||||||
|
product = db.query(Product).filter(Product.id == product_id).first()
|
||||||
|
if not product:
|
||||||
|
raise HTTPException(status_code=404, detail="Producto no encontrado")
|
||||||
|
|
||||||
|
update_data = product_data.dict(exclude_unset=True)
|
||||||
|
for field, value in update_data.items():
|
||||||
|
setattr(product, field, value)
|
||||||
|
|
||||||
|
product.updated_at = datetime.utcnow()
|
||||||
|
db.commit()
|
||||||
|
db.refresh(product)
|
||||||
|
return product.to_dict()
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{product_id}")
|
||||||
|
async def delete_product(product_id: int, db: Session = Depends(get_db)):
|
||||||
|
"""Eliminar un producto."""
|
||||||
|
product = db.query(Product).filter(Product.id == product_id).first()
|
||||||
|
if not product:
|
||||||
|
raise HTTPException(status_code=404, detail="Producto no encontrado")
|
||||||
|
|
||||||
|
db.delete(product)
|
||||||
|
db.commit()
|
||||||
|
return {"message": "Producto eliminado", "product_id": product_id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{product_id}/toggle-featured")
|
||||||
|
async def toggle_featured(product_id: int, db: Session = Depends(get_db)):
|
||||||
|
"""Alternar estado de producto destacado."""
|
||||||
|
product = db.query(Product).filter(Product.id == product_id).first()
|
||||||
|
if not product:
|
||||||
|
raise HTTPException(status_code=404, detail="Producto no encontrado")
|
||||||
|
|
||||||
|
product.is_featured = not product.is_featured
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"message": f"Producto {'destacado' if product.is_featured else 'no destacado'}",
|
||||||
|
"is_featured": product.is_featured
|
||||||
|
}
|
||||||
174
app/api/routes/services.py
Normal file
174
app/api/routes/services.py
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
"""
|
||||||
|
API Routes para gestión de Servicios.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import List, Optional
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.models.service import Service
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# SCHEMAS
|
||||||
|
# ===========================================
|
||||||
|
|
||||||
|
class ServiceCreate(BaseModel):
|
||||||
|
name: str
|
||||||
|
description: str
|
||||||
|
short_description: Optional[str] = None
|
||||||
|
category: str
|
||||||
|
target_sectors: Optional[List[str]] = None
|
||||||
|
benefits: Optional[List[str]] = None
|
||||||
|
features: Optional[List[str]] = None
|
||||||
|
case_studies: Optional[List[dict]] = None
|
||||||
|
icon: Optional[str] = None
|
||||||
|
images: Optional[List[str]] = None
|
||||||
|
main_image: Optional[str] = None
|
||||||
|
price_range: Optional[str] = None
|
||||||
|
has_free_demo: bool = False
|
||||||
|
tags: Optional[List[str]] = None
|
||||||
|
call_to_action: Optional[str] = None
|
||||||
|
is_featured: bool = False
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceUpdate(BaseModel):
|
||||||
|
name: Optional[str] = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
short_description: Optional[str] = None
|
||||||
|
category: Optional[str] = None
|
||||||
|
target_sectors: Optional[List[str]] = None
|
||||||
|
benefits: Optional[List[str]] = None
|
||||||
|
features: Optional[List[str]] = None
|
||||||
|
case_studies: Optional[List[dict]] = None
|
||||||
|
icon: Optional[str] = None
|
||||||
|
images: Optional[List[str]] = None
|
||||||
|
main_image: Optional[str] = None
|
||||||
|
price_range: Optional[str] = None
|
||||||
|
has_free_demo: Optional[bool] = None
|
||||||
|
tags: Optional[List[str]] = None
|
||||||
|
call_to_action: Optional[str] = None
|
||||||
|
is_active: Optional[bool] = None
|
||||||
|
is_featured: Optional[bool] = None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# ENDPOINTS
|
||||||
|
# ===========================================
|
||||||
|
|
||||||
|
@router.get("/")
|
||||||
|
async def list_services(
|
||||||
|
category: Optional[str] = Query(None),
|
||||||
|
target_sector: Optional[str] = Query(None),
|
||||||
|
is_active: Optional[bool] = Query(True),
|
||||||
|
is_featured: Optional[bool] = Query(None),
|
||||||
|
limit: int = Query(50, le=100),
|
||||||
|
offset: int = Query(0),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Listar servicios con filtros."""
|
||||||
|
query = db.query(Service)
|
||||||
|
|
||||||
|
if category:
|
||||||
|
query = query.filter(Service.category == category)
|
||||||
|
if is_active is not None:
|
||||||
|
query = query.filter(Service.is_active == is_active)
|
||||||
|
if is_featured is not None:
|
||||||
|
query = query.filter(Service.is_featured == is_featured)
|
||||||
|
if target_sector:
|
||||||
|
query = query.filter(Service.target_sectors.contains([target_sector]))
|
||||||
|
|
||||||
|
services = query.order_by(Service.created_at.desc()).offset(offset).limit(limit).all()
|
||||||
|
return [s.to_dict() for s in services]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/categories")
|
||||||
|
async def list_categories(db: Session = Depends(get_db)):
|
||||||
|
"""Listar categorías únicas de servicios."""
|
||||||
|
categories = db.query(Service.category).distinct().all()
|
||||||
|
return [c[0] for c in categories if c[0]]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/featured")
|
||||||
|
async def list_featured_services(db: Session = Depends(get_db)):
|
||||||
|
"""Listar servicios destacados."""
|
||||||
|
services = db.query(Service).filter(
|
||||||
|
Service.is_featured == True,
|
||||||
|
Service.is_active == True
|
||||||
|
).all()
|
||||||
|
return [s.to_dict() for s in services]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{service_id}")
|
||||||
|
async def get_service(service_id: int, db: Session = Depends(get_db)):
|
||||||
|
"""Obtener un servicio por ID."""
|
||||||
|
service = db.query(Service).filter(Service.id == service_id).first()
|
||||||
|
if not service:
|
||||||
|
raise HTTPException(status_code=404, detail="Servicio no encontrado")
|
||||||
|
return service.to_dict()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/")
|
||||||
|
async def create_service(service_data: ServiceCreate, db: Session = Depends(get_db)):
|
||||||
|
"""Crear un nuevo servicio."""
|
||||||
|
service = Service(**service_data.dict())
|
||||||
|
db.add(service)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(service)
|
||||||
|
return service.to_dict()
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{service_id}")
|
||||||
|
async def update_service(service_id: int, service_data: ServiceUpdate, db: Session = Depends(get_db)):
|
||||||
|
"""Actualizar un servicio."""
|
||||||
|
service = db.query(Service).filter(Service.id == service_id).first()
|
||||||
|
if not service:
|
||||||
|
raise HTTPException(status_code=404, detail="Servicio no encontrado")
|
||||||
|
|
||||||
|
update_data = service_data.dict(exclude_unset=True)
|
||||||
|
for field, value in update_data.items():
|
||||||
|
setattr(service, field, value)
|
||||||
|
|
||||||
|
service.updated_at = datetime.utcnow()
|
||||||
|
db.commit()
|
||||||
|
db.refresh(service)
|
||||||
|
return service.to_dict()
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{service_id}")
|
||||||
|
async def delete_service(service_id: int, db: Session = Depends(get_db)):
|
||||||
|
"""Eliminar un servicio."""
|
||||||
|
service = db.query(Service).filter(Service.id == service_id).first()
|
||||||
|
if not service:
|
||||||
|
raise HTTPException(status_code=404, detail="Servicio no encontrado")
|
||||||
|
|
||||||
|
db.delete(service)
|
||||||
|
db.commit()
|
||||||
|
return {"message": "Servicio eliminado", "service_id": service_id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{service_id}/toggle-featured")
|
||||||
|
async def toggle_featured(service_id: int, db: Session = Depends(get_db)):
|
||||||
|
"""Alternar estado de servicio destacado."""
|
||||||
|
service = db.query(Service).filter(Service.id == service_id).first()
|
||||||
|
if not service:
|
||||||
|
raise HTTPException(status_code=404, detail="Servicio no encontrado")
|
||||||
|
|
||||||
|
service.is_featured = not service.is_featured
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"message": f"Servicio {'destacado' if service.is_featured else 'no destacado'}",
|
||||||
|
"is_featured": service.is_featured
|
||||||
|
}
|
||||||
1
app/core/__init__.py
Normal file
1
app/core/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Core module
|
||||||
60
app/core/config.py
Normal file
60
app/core/config.py
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
"""
|
||||||
|
Configuración central del sistema.
|
||||||
|
Carga variables de entorno y define settings globales.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pydantic_settings import BaseSettings
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
"""Configuración de la aplicación."""
|
||||||
|
|
||||||
|
# Aplicación
|
||||||
|
APP_NAME: str = "social-media-automation"
|
||||||
|
APP_ENV: str = "development"
|
||||||
|
DEBUG: bool = True
|
||||||
|
SECRET_KEY: str = "change-this-in-production"
|
||||||
|
|
||||||
|
# Base de datos
|
||||||
|
DATABASE_URL: str = "postgresql://social_user:social_pass@localhost:5432/social_automation"
|
||||||
|
|
||||||
|
# Redis
|
||||||
|
REDIS_URL: str = "redis://localhost:6379/0"
|
||||||
|
|
||||||
|
# DeepSeek API
|
||||||
|
DEEPSEEK_API_KEY: Optional[str] = None
|
||||||
|
DEEPSEEK_BASE_URL: str = "https://api.deepseek.com/v1"
|
||||||
|
|
||||||
|
# X (Twitter) API
|
||||||
|
X_API_KEY: Optional[str] = None
|
||||||
|
X_API_SECRET: Optional[str] = None
|
||||||
|
X_ACCESS_TOKEN: Optional[str] = None
|
||||||
|
X_ACCESS_TOKEN_SECRET: Optional[str] = None
|
||||||
|
X_BEARER_TOKEN: Optional[str] = None
|
||||||
|
|
||||||
|
# Meta API (Facebook, Instagram, Threads)
|
||||||
|
META_APP_ID: Optional[str] = None
|
||||||
|
META_APP_SECRET: Optional[str] = None
|
||||||
|
META_ACCESS_TOKEN: Optional[str] = None
|
||||||
|
FACEBOOK_PAGE_ID: Optional[str] = None
|
||||||
|
INSTAGRAM_ACCOUNT_ID: Optional[str] = None
|
||||||
|
THREADS_USER_ID: Optional[str] = None
|
||||||
|
|
||||||
|
# Información del negocio
|
||||||
|
BUSINESS_NAME: str = "Consultoría AS"
|
||||||
|
BUSINESS_LOCATION: str = "Tijuana, México"
|
||||||
|
BUSINESS_WEBSITE: str = "https://consultoria-as.com"
|
||||||
|
CONTENT_TONE: str = "profesional pero cercano, educativo, orientado a soluciones"
|
||||||
|
|
||||||
|
# Notificaciones
|
||||||
|
TELEGRAM_BOT_TOKEN: Optional[str] = None
|
||||||
|
TELEGRAM_CHAT_ID: Optional[str] = None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
env_file = ".env"
|
||||||
|
case_sensitive = True
|
||||||
|
|
||||||
|
|
||||||
|
# Instancia global de configuración
|
||||||
|
settings = Settings()
|
||||||
31
app/core/database.py
Normal file
31
app/core/database.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
"""
|
||||||
|
Configuración de la base de datos PostgreSQL.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker, declarative_base
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
# Crear engine de SQLAlchemy
|
||||||
|
engine = create_engine(
|
||||||
|
settings.DATABASE_URL,
|
||||||
|
pool_pre_ping=True,
|
||||||
|
pool_size=10,
|
||||||
|
max_overflow=20
|
||||||
|
)
|
||||||
|
|
||||||
|
# Crear sesión
|
||||||
|
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||||
|
|
||||||
|
# Base para modelos
|
||||||
|
Base = declarative_base()
|
||||||
|
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
"""Dependency para obtener sesión de base de datos."""
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
78
app/core/security.py
Normal file
78
app/core/security.py
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
"""
|
||||||
|
Configuración de seguridad y autenticación.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from jose import JWTError, jwt
|
||||||
|
from passlib.context import CryptContext
|
||||||
|
from fastapi import Depends, HTTPException, status
|
||||||
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
# Configuración de hashing de contraseñas
|
||||||
|
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||||
|
|
||||||
|
# Configuración de JWT
|
||||||
|
ALGORITHM = "HS256"
|
||||||
|
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 # 24 horas
|
||||||
|
|
||||||
|
# Security scheme
|
||||||
|
security = HTTPBearer()
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||||
|
"""Verificar contraseña contra hash."""
|
||||||
|
return pwd_context.verify(plain_password, hashed_password)
|
||||||
|
|
||||||
|
|
||||||
|
def get_password_hash(password: str) -> str:
|
||||||
|
"""Generar hash de contraseña."""
|
||||||
|
return pwd_context.hash(password)
|
||||||
|
|
||||||
|
|
||||||
|
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
|
||||||
|
"""Crear token JWT."""
|
||||||
|
to_encode = data.copy()
|
||||||
|
|
||||||
|
if expires_delta:
|
||||||
|
expire = datetime.utcnow() + expires_delta
|
||||||
|
else:
|
||||||
|
expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||||
|
|
||||||
|
to_encode.update({"exp": expire})
|
||||||
|
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=ALGORITHM)
|
||||||
|
|
||||||
|
return encoded_jwt
|
||||||
|
|
||||||
|
|
||||||
|
def decode_token(token: str) -> dict:
|
||||||
|
"""Decodificar y validar token JWT."""
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM])
|
||||||
|
return payload
|
||||||
|
except JWTError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Token inválido o expirado",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_user(
|
||||||
|
credentials: HTTPAuthorizationCredentials = Depends(security)
|
||||||
|
) -> dict:
|
||||||
|
"""Obtener usuario actual desde el token."""
|
||||||
|
token = credentials.credentials
|
||||||
|
payload = decode_token(token)
|
||||||
|
|
||||||
|
username = payload.get("sub")
|
||||||
|
if username is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Token inválido"
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"username": username}
|
||||||
71
app/main.py
Normal file
71
app/main.py
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
"""
|
||||||
|
Social Media Automation - Consultoría AS
|
||||||
|
=========================================
|
||||||
|
Sistema automatizado para la creación y publicación de contenido
|
||||||
|
en redes sociales (X, Threads, Instagram, Facebook).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
|
from app.api.routes import posts, products, services, calendar, dashboard, interactions
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.database import engine
|
||||||
|
from app.models import Base
|
||||||
|
|
||||||
|
# Crear tablas en la base de datos
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
|
||||||
|
# Inicializar aplicación FastAPI
|
||||||
|
app = FastAPI(
|
||||||
|
title="Social Media Automation",
|
||||||
|
description="Sistema de automatización de redes sociales para Consultoría AS",
|
||||||
|
version="1.0.0",
|
||||||
|
docs_url="/api/docs",
|
||||||
|
redoc_url="/api/redoc",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Configurar CORS
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"], # En producción, especificar dominios
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Montar archivos estáticos
|
||||||
|
app.mount("/static", StaticFiles(directory="dashboard/static"), name="static")
|
||||||
|
|
||||||
|
# Registrar rutas
|
||||||
|
app.include_router(dashboard.router, prefix="", tags=["Dashboard"])
|
||||||
|
app.include_router(posts.router, prefix="/api/posts", tags=["Posts"])
|
||||||
|
app.include_router(products.router, prefix="/api/products", tags=["Products"])
|
||||||
|
app.include_router(services.router, prefix="/api/services", tags=["Services"])
|
||||||
|
app.include_router(calendar.router, prefix="/api/calendar", tags=["Calendar"])
|
||||||
|
app.include_router(interactions.router, prefix="/api/interactions", tags=["Interactions"])
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/health")
|
||||||
|
async def health_check():
|
||||||
|
"""Verificar estado del sistema."""
|
||||||
|
return {
|
||||||
|
"status": "healthy",
|
||||||
|
"app": settings.APP_NAME,
|
||||||
|
"version": "1.0.0"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/stats")
|
||||||
|
async def get_stats():
|
||||||
|
"""Obtener estadísticas generales del sistema."""
|
||||||
|
# TODO: Implementar estadísticas reales desde la BD
|
||||||
|
return {
|
||||||
|
"posts_today": 0,
|
||||||
|
"posts_week": 0,
|
||||||
|
"posts_month": 0,
|
||||||
|
"pending_approval": 0,
|
||||||
|
"scheduled": 0,
|
||||||
|
"interactions_pending": 0
|
||||||
|
}
|
||||||
24
app/models/__init__.py
Normal file
24
app/models/__init__.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
"""
|
||||||
|
Modelos de base de datos SQLAlchemy.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
from app.models.product import Product
|
||||||
|
from app.models.service import Service
|
||||||
|
from app.models.tip_template import TipTemplate
|
||||||
|
from app.models.post import Post
|
||||||
|
from app.models.content_calendar import ContentCalendar
|
||||||
|
from app.models.image_template import ImageTemplate
|
||||||
|
from app.models.interaction import Interaction
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"Base",
|
||||||
|
"Product",
|
||||||
|
"Service",
|
||||||
|
"TipTemplate",
|
||||||
|
"Post",
|
||||||
|
"ContentCalendar",
|
||||||
|
"ImageTemplate",
|
||||||
|
"Interaction"
|
||||||
|
]
|
||||||
66
app/models/content_calendar.py
Normal file
66
app/models/content_calendar.py
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
"""
|
||||||
|
Modelo de ContentCalendar - Calendario de publicación.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from sqlalchemy import Column, Integer, String, Time, Boolean, DateTime
|
||||||
|
from sqlalchemy.dialects.postgresql import ARRAY
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class ContentCalendar(Base):
|
||||||
|
"""Modelo para el calendario de contenido."""
|
||||||
|
|
||||||
|
__tablename__ = "content_calendar"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
|
||||||
|
# Programación
|
||||||
|
day_of_week = Column(Integer, nullable=False) # 0=Lunes, 6=Domingo
|
||||||
|
time = Column(Time, nullable=False) # Hora de publicación
|
||||||
|
|
||||||
|
# Tipo de contenido
|
||||||
|
content_type = Column(String(50), nullable=False)
|
||||||
|
# Tipos: tip_tech, dato_curioso, producto, servicio, etc.
|
||||||
|
|
||||||
|
# Plataformas
|
||||||
|
platforms = Column(ARRAY(String), nullable=False)
|
||||||
|
# Ejemplo: ["x", "threads", "instagram"]
|
||||||
|
|
||||||
|
# Configuración
|
||||||
|
is_active = Column(Boolean, default=True)
|
||||||
|
requires_approval = Column(Boolean, default=False)
|
||||||
|
|
||||||
|
# Restricciones opcionales
|
||||||
|
category_filter = Column(String(100), nullable=True) # Solo tips de esta categoría
|
||||||
|
priority = Column(Integer, default=0) # Mayor = más prioritario
|
||||||
|
|
||||||
|
# Descripción
|
||||||
|
description = Column(String(255), nullable=True)
|
||||||
|
# Ejemplo: "Tip tech diario matutino"
|
||||||
|
|
||||||
|
# Timestamps
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<ContentCalendar {self.day_of_week} {self.time} - {self.content_type}>"
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
"""Convertir a diccionario."""
|
||||||
|
days = ["Lunes", "Martes", "Miércoles", "Jueves", "Viernes", "Sábado", "Domingo"]
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"day_of_week": self.day_of_week,
|
||||||
|
"day_name": days[self.day_of_week] if 0 <= self.day_of_week <= 6 else "Desconocido",
|
||||||
|
"time": self.time.strftime("%H:%M") if self.time else None,
|
||||||
|
"content_type": self.content_type,
|
||||||
|
"platforms": self.platforms,
|
||||||
|
"is_active": self.is_active,
|
||||||
|
"requires_approval": self.requires_approval,
|
||||||
|
"category_filter": self.category_filter,
|
||||||
|
"priority": self.priority,
|
||||||
|
"description": self.description,
|
||||||
|
"created_at": self.created_at.isoformat() if self.created_at else None
|
||||||
|
}
|
||||||
75
app/models/image_template.py
Normal file
75
app/models/image_template.py
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
"""
|
||||||
|
Modelo de ImageTemplate - Plantillas para generar imágenes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, JSON
|
||||||
|
from sqlalchemy.dialects.postgresql import ARRAY
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class ImageTemplate(Base):
|
||||||
|
"""Modelo para plantillas de imágenes."""
|
||||||
|
|
||||||
|
__tablename__ = "image_templates"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
|
||||||
|
# Información básica
|
||||||
|
name = Column(String(100), nullable=False, index=True)
|
||||||
|
description = Column(String(255), nullable=True)
|
||||||
|
|
||||||
|
# Categoría
|
||||||
|
category = Column(String(50), nullable=False)
|
||||||
|
# Categorías: tip, producto, servicio, promocion, etc.
|
||||||
|
|
||||||
|
# Archivo de plantilla
|
||||||
|
template_file = Column(String(255), nullable=False) # Ruta al archivo HTML/template
|
||||||
|
|
||||||
|
# Variables que acepta la plantilla
|
||||||
|
variables = Column(ARRAY(String), nullable=False)
|
||||||
|
# Ejemplo: ["titulo", "contenido", "hashtags", "logo"]
|
||||||
|
|
||||||
|
# Configuración de diseño
|
||||||
|
design_config = Column(JSON, nullable=True)
|
||||||
|
# Ejemplo: {
|
||||||
|
# "width": 1080,
|
||||||
|
# "height": 1080,
|
||||||
|
# "background_color": "#1a1a2e",
|
||||||
|
# "accent_color": "#d4a574",
|
||||||
|
# "font_family": "Inter"
|
||||||
|
# }
|
||||||
|
|
||||||
|
# Tamaños de salida
|
||||||
|
output_sizes = Column(JSON, nullable=True)
|
||||||
|
# Ejemplo: {
|
||||||
|
# "instagram": {"width": 1080, "height": 1080},
|
||||||
|
# "x": {"width": 1200, "height": 675},
|
||||||
|
# "facebook": {"width": 1200, "height": 630}
|
||||||
|
# }
|
||||||
|
|
||||||
|
# Estado
|
||||||
|
is_active = Column(Boolean, default=True)
|
||||||
|
|
||||||
|
# Timestamps
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<ImageTemplate {self.name}>"
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
"""Convertir a diccionario."""
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"name": self.name,
|
||||||
|
"description": self.description,
|
||||||
|
"category": self.category,
|
||||||
|
"template_file": self.template_file,
|
||||||
|
"variables": self.variables,
|
||||||
|
"design_config": self.design_config,
|
||||||
|
"output_sizes": self.output_sizes,
|
||||||
|
"is_active": self.is_active,
|
||||||
|
"created_at": self.created_at.isoformat() if self.created_at else None
|
||||||
|
}
|
||||||
87
app/models/interaction.py
Normal file
87
app/models/interaction.py
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
"""
|
||||||
|
Modelo de Interaction - Interacciones en redes sociales.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Interaction(Base):
|
||||||
|
"""Modelo para interacciones (comentarios, DMs, menciones)."""
|
||||||
|
|
||||||
|
__tablename__ = "interactions"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
|
||||||
|
# Plataforma
|
||||||
|
platform = Column(String(50), nullable=False, index=True)
|
||||||
|
# Valores: x, threads, instagram, facebook
|
||||||
|
|
||||||
|
# Tipo de interacción
|
||||||
|
interaction_type = Column(String(50), nullable=False, index=True)
|
||||||
|
# Tipos: comment, reply, dm, mention, like, repost, quote
|
||||||
|
|
||||||
|
# Post relacionado (si aplica)
|
||||||
|
post_id = Column(Integer, ForeignKey("posts.id"), nullable=True)
|
||||||
|
|
||||||
|
# ID externo en la plataforma
|
||||||
|
external_id = Column(String(100), nullable=False, unique=True)
|
||||||
|
external_post_id = Column(String(100), nullable=True) # ID del post en la plataforma
|
||||||
|
|
||||||
|
# Autor de la interacción
|
||||||
|
author_username = Column(String(100), nullable=False)
|
||||||
|
author_name = Column(String(255), nullable=True)
|
||||||
|
author_profile_url = Column(String(500), nullable=True)
|
||||||
|
author_avatar_url = Column(String(500), nullable=True)
|
||||||
|
|
||||||
|
# Contenido
|
||||||
|
content = Column(Text, nullable=True)
|
||||||
|
|
||||||
|
# Respuesta
|
||||||
|
responded = Column(Boolean, default=False, index=True)
|
||||||
|
response_content = Column(Text, nullable=True)
|
||||||
|
responded_at = Column(DateTime, nullable=True)
|
||||||
|
response_external_id = Column(String(100), nullable=True)
|
||||||
|
|
||||||
|
# Clasificación
|
||||||
|
is_lead = Column(Boolean, default=False) # Potencial cliente
|
||||||
|
sentiment = Column(String(50), nullable=True) # positive, negative, neutral
|
||||||
|
priority = Column(Integer, default=0) # 0=normal, 1=importante, 2=urgente
|
||||||
|
|
||||||
|
# Estado
|
||||||
|
is_read = Column(Boolean, default=False)
|
||||||
|
is_archived = Column(Boolean, default=False)
|
||||||
|
|
||||||
|
# Timestamps
|
||||||
|
interaction_at = Column(DateTime, nullable=False) # Cuándo ocurrió en la plataforma
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow) # Cuándo se guardó aquí
|
||||||
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<Interaction {self.platform} - {self.interaction_type} - {self.author_username}>"
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
"""Convertir a diccionario."""
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"platform": self.platform,
|
||||||
|
"interaction_type": self.interaction_type,
|
||||||
|
"post_id": self.post_id,
|
||||||
|
"external_id": self.external_id,
|
||||||
|
"author_username": self.author_username,
|
||||||
|
"author_name": self.author_name,
|
||||||
|
"author_profile_url": self.author_profile_url,
|
||||||
|
"author_avatar_url": self.author_avatar_url,
|
||||||
|
"content": self.content,
|
||||||
|
"responded": self.responded,
|
||||||
|
"response_content": self.response_content,
|
||||||
|
"responded_at": self.responded_at.isoformat() if self.responded_at else None,
|
||||||
|
"is_lead": self.is_lead,
|
||||||
|
"sentiment": self.sentiment,
|
||||||
|
"priority": self.priority,
|
||||||
|
"is_read": self.is_read,
|
||||||
|
"interaction_at": self.interaction_at.isoformat() if self.interaction_at else None,
|
||||||
|
"created_at": self.created_at.isoformat() if self.created_at else None
|
||||||
|
}
|
||||||
134
app/models/post.py
Normal file
134
app/models/post.py
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
"""
|
||||||
|
Modelo de Post - Posts generados y programados.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey, JSON, Enum
|
||||||
|
from sqlalchemy.dialects.postgresql import ARRAY
|
||||||
|
import enum
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class PostStatus(enum.Enum):
|
||||||
|
"""Estados posibles de un post."""
|
||||||
|
DRAFT = "draft"
|
||||||
|
PENDING_APPROVAL = "pending_approval"
|
||||||
|
APPROVED = "approved"
|
||||||
|
SCHEDULED = "scheduled"
|
||||||
|
PUBLISHING = "publishing"
|
||||||
|
PUBLISHED = "published"
|
||||||
|
FAILED = "failed"
|
||||||
|
CANCELLED = "cancelled"
|
||||||
|
|
||||||
|
|
||||||
|
class ContentType(enum.Enum):
|
||||||
|
"""Tipos de contenido."""
|
||||||
|
TIP_TECH = "tip_tech"
|
||||||
|
DATO_CURIOSO = "dato_curioso"
|
||||||
|
FRASE_MOTIVACIONAL = "frase_motivacional"
|
||||||
|
EFEMERIDE = "efemeride"
|
||||||
|
PRODUCTO = "producto"
|
||||||
|
SERVICIO = "servicio"
|
||||||
|
HILO_EDUCATIVO = "hilo_educativo"
|
||||||
|
CASO_EXITO = "caso_exito"
|
||||||
|
PROMOCION = "promocion"
|
||||||
|
ANUNCIO = "anuncio"
|
||||||
|
MANUAL = "manual"
|
||||||
|
|
||||||
|
|
||||||
|
class Post(Base):
|
||||||
|
"""Modelo para posts de redes sociales."""
|
||||||
|
|
||||||
|
__tablename__ = "posts"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
|
||||||
|
# Contenido
|
||||||
|
content = Column(Text, nullable=False)
|
||||||
|
content_type = Column(String(50), nullable=False, index=True)
|
||||||
|
|
||||||
|
# Contenido adaptado por plataforma (opcional)
|
||||||
|
content_x = Column(Text, nullable=True) # Versión para X (280 chars)
|
||||||
|
content_threads = Column(Text, nullable=True)
|
||||||
|
content_instagram = Column(Text, nullable=True)
|
||||||
|
content_facebook = Column(Text, nullable=True)
|
||||||
|
|
||||||
|
# Plataformas destino
|
||||||
|
platforms = Column(ARRAY(String), nullable=False)
|
||||||
|
# Ejemplo: ["x", "threads", "instagram", "facebook"]
|
||||||
|
|
||||||
|
# Estado y programación
|
||||||
|
status = Column(String(50), default="draft", index=True)
|
||||||
|
scheduled_at = Column(DateTime, nullable=True, index=True)
|
||||||
|
published_at = Column(DateTime, nullable=True)
|
||||||
|
|
||||||
|
# Imagen
|
||||||
|
image_url = Column(String(500), nullable=True)
|
||||||
|
image_template_id = Column(Integer, ForeignKey("image_templates.id"), nullable=True)
|
||||||
|
|
||||||
|
# IDs de publicación en cada plataforma
|
||||||
|
platform_post_ids = Column(JSON, nullable=True)
|
||||||
|
# Ejemplo: {"x": "123456", "instagram": "789012", ...}
|
||||||
|
|
||||||
|
# Errores de publicación
|
||||||
|
error_message = Column(Text, nullable=True)
|
||||||
|
retry_count = Column(Integer, default=0)
|
||||||
|
|
||||||
|
# Aprobación
|
||||||
|
approval_required = Column(Boolean, default=False)
|
||||||
|
approved_by = Column(String(100), nullable=True)
|
||||||
|
approved_at = Column(DateTime, nullable=True)
|
||||||
|
|
||||||
|
# Relaciones con contenido fuente
|
||||||
|
product_id = Column(Integer, ForeignKey("products.id"), nullable=True)
|
||||||
|
service_id = Column(Integer, ForeignKey("services.id"), nullable=True)
|
||||||
|
tip_template_id = Column(Integer, ForeignKey("tip_templates.id"), nullable=True)
|
||||||
|
|
||||||
|
# Metadatos
|
||||||
|
hashtags = Column(ARRAY(String), nullable=True)
|
||||||
|
mentions = Column(ARRAY(String), nullable=True)
|
||||||
|
|
||||||
|
# Métricas (actualizadas después de publicar)
|
||||||
|
metrics = Column(JSON, nullable=True)
|
||||||
|
# Ejemplo: {"likes": 10, "retweets": 5, "comments": 3}
|
||||||
|
|
||||||
|
# Timestamps
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<Post {self.id} - {self.status}>"
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
"""Convertir a diccionario."""
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"content": self.content,
|
||||||
|
"content_type": self.content_type,
|
||||||
|
"content_x": self.content_x,
|
||||||
|
"content_threads": self.content_threads,
|
||||||
|
"content_instagram": self.content_instagram,
|
||||||
|
"content_facebook": self.content_facebook,
|
||||||
|
"platforms": self.platforms,
|
||||||
|
"status": self.status,
|
||||||
|
"scheduled_at": self.scheduled_at.isoformat() if self.scheduled_at else None,
|
||||||
|
"published_at": self.published_at.isoformat() if self.published_at else None,
|
||||||
|
"image_url": self.image_url,
|
||||||
|
"platform_post_ids": self.platform_post_ids,
|
||||||
|
"error_message": self.error_message,
|
||||||
|
"approval_required": self.approval_required,
|
||||||
|
"hashtags": self.hashtags,
|
||||||
|
"metrics": self.metrics,
|
||||||
|
"created_at": self.created_at.isoformat() if self.created_at else None
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_content_for_platform(self, platform: str) -> str:
|
||||||
|
"""Obtener contenido adaptado para una plataforma específica."""
|
||||||
|
platform_content = {
|
||||||
|
"x": self.content_x,
|
||||||
|
"threads": self.content_threads,
|
||||||
|
"instagram": self.content_instagram,
|
||||||
|
"facebook": self.content_facebook
|
||||||
|
}
|
||||||
|
return platform_content.get(platform) or self.content
|
||||||
80
app/models/product.py
Normal file
80
app/models/product.py
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
"""
|
||||||
|
Modelo de Producto - Catálogo de equipos de cómputo e impresoras 3D.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from sqlalchemy import Column, Integer, String, Text, Float, Boolean, DateTime, JSON
|
||||||
|
from sqlalchemy.dialects.postgresql import ARRAY
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Product(Base):
|
||||||
|
"""Modelo para productos del catálogo."""
|
||||||
|
|
||||||
|
__tablename__ = "products"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
|
||||||
|
# Información básica
|
||||||
|
name = Column(String(255), nullable=False, index=True)
|
||||||
|
description = Column(Text, nullable=True)
|
||||||
|
short_description = Column(String(500), nullable=True) # Para posts
|
||||||
|
|
||||||
|
# Categorización
|
||||||
|
category = Column(String(100), nullable=False, index=True) # laptop, desktop, impresora_3d, etc.
|
||||||
|
subcategory = Column(String(100), nullable=True)
|
||||||
|
brand = Column(String(100), nullable=True)
|
||||||
|
model = Column(String(100), nullable=True)
|
||||||
|
|
||||||
|
# Precio y stock
|
||||||
|
price = Column(Float, nullable=False)
|
||||||
|
price_usd = Column(Float, nullable=True) # Precio en dólares (opcional)
|
||||||
|
stock = Column(Integer, default=0)
|
||||||
|
is_available = Column(Boolean, default=True)
|
||||||
|
|
||||||
|
# Especificaciones técnicas (JSON flexible)
|
||||||
|
specs = Column(JSON, nullable=True)
|
||||||
|
# Ejemplo: {"cpu": "Intel i7", "ram": "16GB", "storage": "512GB SSD"}
|
||||||
|
|
||||||
|
# Imágenes
|
||||||
|
images = Column(ARRAY(String), nullable=True) # URLs de imágenes
|
||||||
|
main_image = Column(String(500), nullable=True)
|
||||||
|
|
||||||
|
# SEO y marketing
|
||||||
|
tags = Column(ARRAY(String), nullable=True) # Tags para búsqueda
|
||||||
|
highlights = Column(ARRAY(String), nullable=True) # Puntos destacados
|
||||||
|
|
||||||
|
# Control de publicación
|
||||||
|
is_featured = Column(Boolean, default=False) # Producto destacado
|
||||||
|
last_posted_at = Column(DateTime, nullable=True) # Última vez que se publicó
|
||||||
|
|
||||||
|
# Timestamps
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<Product {self.name}>"
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
"""Convertir a diccionario."""
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"name": self.name,
|
||||||
|
"description": self.description,
|
||||||
|
"short_description": self.short_description,
|
||||||
|
"category": self.category,
|
||||||
|
"subcategory": self.subcategory,
|
||||||
|
"brand": self.brand,
|
||||||
|
"model": self.model,
|
||||||
|
"price": self.price,
|
||||||
|
"stock": self.stock,
|
||||||
|
"is_available": self.is_available,
|
||||||
|
"specs": self.specs,
|
||||||
|
"images": self.images,
|
||||||
|
"main_image": self.main_image,
|
||||||
|
"tags": self.tags,
|
||||||
|
"highlights": self.highlights,
|
||||||
|
"is_featured": self.is_featured,
|
||||||
|
"created_at": self.created_at.isoformat() if self.created_at else None
|
||||||
|
}
|
||||||
90
app/models/service.py
Normal file
90
app/models/service.py
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
"""
|
||||||
|
Modelo de Servicio - Servicios de consultoría de Consultoría AS.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, JSON
|
||||||
|
from sqlalchemy.dialects.postgresql import ARRAY
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Service(Base):
|
||||||
|
"""Modelo para servicios de consultoría."""
|
||||||
|
|
||||||
|
__tablename__ = "services"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
|
||||||
|
# Información básica
|
||||||
|
name = Column(String(255), nullable=False, index=True)
|
||||||
|
description = Column(Text, nullable=False)
|
||||||
|
short_description = Column(String(500), nullable=True) # Para posts
|
||||||
|
|
||||||
|
# Categorización
|
||||||
|
category = Column(String(100), nullable=False, index=True)
|
||||||
|
# Categorías: ia, automatizacion, desarrollo, iot, voip, crm, etc.
|
||||||
|
|
||||||
|
# Sectores objetivo
|
||||||
|
target_sectors = Column(ARRAY(String), nullable=True)
|
||||||
|
# Ejemplo: ["hoteles", "construccion", "logistica", "retail"]
|
||||||
|
|
||||||
|
# Beneficios y características
|
||||||
|
benefits = Column(ARRAY(String), nullable=True)
|
||||||
|
# Ejemplo: ["Ahorro de tiempo", "Reducción de errores", "24/7"]
|
||||||
|
|
||||||
|
features = Column(ARRAY(String), nullable=True)
|
||||||
|
# Características técnicas del servicio
|
||||||
|
|
||||||
|
# Casos de éxito
|
||||||
|
case_studies = Column(JSON, nullable=True)
|
||||||
|
# Ejemplo: [{"client": "Hotel X", "result": "50% menos tiempo en reservas"}]
|
||||||
|
|
||||||
|
# Imágenes e íconos
|
||||||
|
icon = Column(String(100), nullable=True) # Nombre del ícono
|
||||||
|
images = Column(ARRAY(String), nullable=True)
|
||||||
|
main_image = Column(String(500), nullable=True)
|
||||||
|
|
||||||
|
# Precios (opcional, puede ser "cotizar")
|
||||||
|
price_range = Column(String(100), nullable=True) # "Desde $5,000 MXN"
|
||||||
|
has_free_demo = Column(Boolean, default=False)
|
||||||
|
|
||||||
|
# SEO y marketing
|
||||||
|
tags = Column(ARRAY(String), nullable=True)
|
||||||
|
call_to_action = Column(String(255), nullable=True) # "Agenda una demo"
|
||||||
|
|
||||||
|
# Control de publicación
|
||||||
|
is_active = Column(Boolean, default=True)
|
||||||
|
is_featured = Column(Boolean, default=False)
|
||||||
|
last_posted_at = Column(DateTime, nullable=True)
|
||||||
|
|
||||||
|
# Timestamps
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<Service {self.name}>"
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
"""Convertir a diccionario."""
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"name": self.name,
|
||||||
|
"description": self.description,
|
||||||
|
"short_description": self.short_description,
|
||||||
|
"category": self.category,
|
||||||
|
"target_sectors": self.target_sectors,
|
||||||
|
"benefits": self.benefits,
|
||||||
|
"features": self.features,
|
||||||
|
"case_studies": self.case_studies,
|
||||||
|
"icon": self.icon,
|
||||||
|
"images": self.images,
|
||||||
|
"main_image": self.main_image,
|
||||||
|
"price_range": self.price_range,
|
||||||
|
"has_free_demo": self.has_free_demo,
|
||||||
|
"tags": self.tags,
|
||||||
|
"call_to_action": self.call_to_action,
|
||||||
|
"is_active": self.is_active,
|
||||||
|
"is_featured": self.is_featured,
|
||||||
|
"created_at": self.created_at.isoformat() if self.created_at else None
|
||||||
|
}
|
||||||
77
app/models/tip_template.py
Normal file
77
app/models/tip_template.py
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
"""
|
||||||
|
Modelo de TipTemplate - Banco de tips para generar contenido automático.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime
|
||||||
|
from sqlalchemy.dialects.postgresql import ARRAY
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class TipTemplate(Base):
|
||||||
|
"""Modelo para templates de tips tech."""
|
||||||
|
|
||||||
|
__tablename__ = "tip_templates"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
|
||||||
|
# Categoría del tip
|
||||||
|
category = Column(String(100), nullable=False, index=True)
|
||||||
|
# Categorías: hardware, software, seguridad, productividad, ia, redes, etc.
|
||||||
|
|
||||||
|
subcategory = Column(String(100), nullable=True)
|
||||||
|
|
||||||
|
# Contenido del tip
|
||||||
|
title = Column(String(255), nullable=False) # Título corto
|
||||||
|
template = Column(Text, nullable=False) # Template con variables
|
||||||
|
|
||||||
|
# Variables que se pueden reemplazar
|
||||||
|
# Ejemplo: ["sistema_operativo", "herramienta", "beneficio"]
|
||||||
|
variables = Column(ARRAY(String), nullable=True)
|
||||||
|
|
||||||
|
# Variaciones del tip (diferentes formas de decirlo)
|
||||||
|
variations = Column(ARRAY(String), nullable=True)
|
||||||
|
|
||||||
|
# Metadatos
|
||||||
|
difficulty = Column(String(50), nullable=True) # basico, intermedio, avanzado
|
||||||
|
target_audience = Column(String(100), nullable=True) # empresas, desarrolladores, general
|
||||||
|
|
||||||
|
# Control de uso
|
||||||
|
used_count = Column(Integer, default=0)
|
||||||
|
last_used = Column(DateTime, nullable=True)
|
||||||
|
|
||||||
|
# Plataformas recomendadas
|
||||||
|
recommended_platforms = Column(ARRAY(String), nullable=True)
|
||||||
|
# Ejemplo: ["x", "threads", "instagram"]
|
||||||
|
|
||||||
|
# Estado
|
||||||
|
is_active = Column(Boolean, default=True)
|
||||||
|
is_evergreen = Column(Boolean, default=True) # ¿Siempre relevante?
|
||||||
|
|
||||||
|
# Timestamps
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<TipTemplate {self.title}>"
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
"""Convertir a diccionario."""
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"category": self.category,
|
||||||
|
"subcategory": self.subcategory,
|
||||||
|
"title": self.title,
|
||||||
|
"template": self.template,
|
||||||
|
"variables": self.variables,
|
||||||
|
"variations": self.variations,
|
||||||
|
"difficulty": self.difficulty,
|
||||||
|
"target_audience": self.target_audience,
|
||||||
|
"used_count": self.used_count,
|
||||||
|
"last_used": self.last_used.isoformat() if self.last_used else None,
|
||||||
|
"recommended_platforms": self.recommended_platforms,
|
||||||
|
"is_active": self.is_active,
|
||||||
|
"is_evergreen": self.is_evergreen,
|
||||||
|
"created_at": self.created_at.isoformat() if self.created_at else None
|
||||||
|
}
|
||||||
44
app/prompts/system_prompt.txt
Normal file
44
app/prompts/system_prompt.txt
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
Eres el Community Manager de Consultoría AS, una empresa de tecnología ubicada en Tijuana, México.
|
||||||
|
|
||||||
|
SOBRE LA EMPRESA:
|
||||||
|
- Especializada en soluciones de IA, automatización y transformación digital
|
||||||
|
- Vende equipos de cómputo e impresoras 3D
|
||||||
|
- Más de 6 años de experiencia
|
||||||
|
- Soporte 24/7
|
||||||
|
- Sitio web: https://consultoria-as.com
|
||||||
|
|
||||||
|
SERVICIOS PRINCIPALES:
|
||||||
|
- Integración de IA y automatización inteligente
|
||||||
|
- Sistemas de gestión para hoteles (nómina, inventario, dashboards)
|
||||||
|
- Software para construcción (gestión de proyectos, diagramas Gantt)
|
||||||
|
- Chatbots para WhatsApp e Instagram
|
||||||
|
- Soluciones IoT
|
||||||
|
- Monitoreo GPS de flotillas
|
||||||
|
- Sistemas CRM personalizados
|
||||||
|
- Comunicaciones VoIP empresariales
|
||||||
|
|
||||||
|
PRODUCTOS:
|
||||||
|
- Laptops y computadoras de escritorio
|
||||||
|
- Impresoras 3D
|
||||||
|
- Accesorios y periféricos
|
||||||
|
|
||||||
|
TONO DE COMUNICACIÓN:
|
||||||
|
- Profesional pero cercano
|
||||||
|
- Educativo y de valor
|
||||||
|
- Orientado a soluciones, no a vender directamente
|
||||||
|
- Uso moderado de emojis (1-3 por post)
|
||||||
|
- Hashtags relevantes (máximo 3-5)
|
||||||
|
|
||||||
|
ESTILO INSPIRADO EN:
|
||||||
|
- @midudev: Tips cortos y accionables
|
||||||
|
- @MoureDev: Contenido educativo y de comunidad
|
||||||
|
- @SoyDalto: Accesible para todos los niveles
|
||||||
|
- @SuGE3K: Tips prácticos y herramientas
|
||||||
|
|
||||||
|
REGLAS IMPORTANTES:
|
||||||
|
- Nunca uses lenguaje ofensivo
|
||||||
|
- No hagas promesas exageradas
|
||||||
|
- Sé honesto y transparente
|
||||||
|
- Enfócate en ayudar y educar
|
||||||
|
- Adapta el contenido a cada plataforma
|
||||||
|
- No inventes información técnica
|
||||||
35
app/publishers/__init__.py
Normal file
35
app/publishers/__init__.py
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
"""
|
||||||
|
Publishers para cada plataforma de redes sociales.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from app.publishers.base import BasePublisher
|
||||||
|
from app.publishers.x_publisher import XPublisher
|
||||||
|
from app.publishers.threads_publisher import ThreadsPublisher
|
||||||
|
from app.publishers.instagram_publisher import InstagramPublisher
|
||||||
|
from app.publishers.facebook_publisher import FacebookPublisher
|
||||||
|
|
||||||
|
|
||||||
|
def get_publisher(platform: str) -> BasePublisher:
|
||||||
|
"""Obtener el publisher para una plataforma específica."""
|
||||||
|
publishers = {
|
||||||
|
"x": XPublisher(),
|
||||||
|
"threads": ThreadsPublisher(),
|
||||||
|
"instagram": InstagramPublisher(),
|
||||||
|
"facebook": FacebookPublisher()
|
||||||
|
}
|
||||||
|
|
||||||
|
publisher = publishers.get(platform.lower())
|
||||||
|
if not publisher:
|
||||||
|
raise ValueError(f"Plataforma no soportada: {platform}")
|
||||||
|
|
||||||
|
return publisher
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"BasePublisher",
|
||||||
|
"XPublisher",
|
||||||
|
"ThreadsPublisher",
|
||||||
|
"InstagramPublisher",
|
||||||
|
"FacebookPublisher",
|
||||||
|
"get_publisher"
|
||||||
|
]
|
||||||
74
app/publishers/base.py
Normal file
74
app/publishers/base.py
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
"""
|
||||||
|
Clase base para publishers de redes sociales.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import Optional, List, Dict
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PublishResult:
|
||||||
|
"""Resultado de una publicación."""
|
||||||
|
success: bool
|
||||||
|
post_id: Optional[str] = None
|
||||||
|
url: Optional[str] = None
|
||||||
|
error_message: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class BasePublisher(ABC):
|
||||||
|
"""Clase base abstracta para publishers."""
|
||||||
|
|
||||||
|
platform: str = "base"
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def publish(
|
||||||
|
self,
|
||||||
|
content: str,
|
||||||
|
image_path: Optional[str] = None
|
||||||
|
) -> PublishResult:
|
||||||
|
"""Publicar contenido en la plataforma."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def publish_thread(
|
||||||
|
self,
|
||||||
|
posts: List[str],
|
||||||
|
images: Optional[List[str]] = None
|
||||||
|
) -> PublishResult:
|
||||||
|
"""Publicar un hilo de posts."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def reply(
|
||||||
|
self,
|
||||||
|
post_id: str,
|
||||||
|
content: str
|
||||||
|
) -> PublishResult:
|
||||||
|
"""Responder a un post."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def like(self, post_id: str) -> bool:
|
||||||
|
"""Dar like a un post."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def get_mentions(self, since_id: Optional[str] = None) -> List[Dict]:
|
||||||
|
"""Obtener menciones recientes."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def get_comments(self, post_id: str) -> List[Dict]:
|
||||||
|
"""Obtener comentarios de un post."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def delete(self, post_id: str) -> bool:
|
||||||
|
"""Eliminar un post."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
def validate_content(self, content: str) -> bool:
|
||||||
|
"""Validar que el contenido cumple con los límites de la plataforma."""
|
||||||
|
# Implementar en subclases según límites específicos
|
||||||
|
return True
|
||||||
252
app/publishers/facebook_publisher.py
Normal file
252
app/publishers/facebook_publisher.py
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
"""
|
||||||
|
Publisher para Facebook Pages (Meta Graph API).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Optional, List, Dict
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.publishers.base import BasePublisher, PublishResult
|
||||||
|
|
||||||
|
|
||||||
|
class FacebookPublisher(BasePublisher):
|
||||||
|
"""Publisher para Facebook Pages usando Meta Graph API."""
|
||||||
|
|
||||||
|
platform = "facebook"
|
||||||
|
char_limit = 63206 # Límite real de Facebook
|
||||||
|
base_url = "https://graph.facebook.com/v18.0"
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.access_token = settings.META_ACCESS_TOKEN
|
||||||
|
self.page_id = settings.FACEBOOK_PAGE_ID
|
||||||
|
|
||||||
|
def validate_content(self, content: str) -> bool:
|
||||||
|
"""Validar longitud del post."""
|
||||||
|
return len(content) <= self.char_limit
|
||||||
|
|
||||||
|
async def publish(
|
||||||
|
self,
|
||||||
|
content: str,
|
||||||
|
image_path: Optional[str] = None
|
||||||
|
) -> PublishResult:
|
||||||
|
"""Publicar en Facebook Page."""
|
||||||
|
if not self.access_token or not self.page_id:
|
||||||
|
return PublishResult(
|
||||||
|
success=False,
|
||||||
|
error_message="Credenciales de Facebook no configuradas"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
if image_path:
|
||||||
|
# Publicar con imagen
|
||||||
|
url = f"{self.base_url}/{self.page_id}/photos"
|
||||||
|
payload = {
|
||||||
|
"caption": content,
|
||||||
|
"url": image_path, # URL pública de la imagen
|
||||||
|
"access_token": self.access_token
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
# Publicar solo texto
|
||||||
|
url = f"{self.base_url}/{self.page_id}/feed"
|
||||||
|
payload = {
|
||||||
|
"message": content,
|
||||||
|
"access_token": self.access_token
|
||||||
|
}
|
||||||
|
|
||||||
|
response = await client.post(url, data=payload)
|
||||||
|
response.raise_for_status()
|
||||||
|
post_id = response.json().get("id")
|
||||||
|
|
||||||
|
return PublishResult(
|
||||||
|
success=True,
|
||||||
|
post_id=post_id,
|
||||||
|
url=f"https://www.facebook.com/{post_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
return PublishResult(
|
||||||
|
success=False,
|
||||||
|
error_message=str(e)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def publish_thread(
|
||||||
|
self,
|
||||||
|
posts: List[str],
|
||||||
|
images: Optional[List[str]] = None
|
||||||
|
) -> PublishResult:
|
||||||
|
"""Publicar como un solo post largo en Facebook."""
|
||||||
|
# Facebook no tiene threads, concatenamos el contenido
|
||||||
|
combined_content = "\n\n".join(posts)
|
||||||
|
|
||||||
|
# Usar la primera imagen si existe
|
||||||
|
image = images[0] if images else None
|
||||||
|
|
||||||
|
return await self.publish(combined_content, image)
|
||||||
|
|
||||||
|
async def reply(
|
||||||
|
self,
|
||||||
|
post_id: str,
|
||||||
|
content: str
|
||||||
|
) -> PublishResult:
|
||||||
|
"""Responder a un comentario en Facebook."""
|
||||||
|
if not self.access_token:
|
||||||
|
return PublishResult(
|
||||||
|
success=False,
|
||||||
|
error_message="Token de acceso no configurado"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
url = f"{self.base_url}/{post_id}/comments"
|
||||||
|
payload = {
|
||||||
|
"message": content,
|
||||||
|
"access_token": self.access_token
|
||||||
|
}
|
||||||
|
|
||||||
|
response = await client.post(url, data=payload)
|
||||||
|
response.raise_for_status()
|
||||||
|
comment_id = response.json().get("id")
|
||||||
|
|
||||||
|
return PublishResult(
|
||||||
|
success=True,
|
||||||
|
post_id=comment_id
|
||||||
|
)
|
||||||
|
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
return PublishResult(
|
||||||
|
success=False,
|
||||||
|
error_message=str(e)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def like(self, post_id: str) -> bool:
|
||||||
|
"""Dar like a un post/comentario."""
|
||||||
|
if not self.access_token:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
url = f"{self.base_url}/{post_id}/likes"
|
||||||
|
payload = {"access_token": self.access_token}
|
||||||
|
|
||||||
|
response = await client.post(url, data=payload)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json().get("success", False)
|
||||||
|
|
||||||
|
except httpx.HTTPError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def get_mentions(self, since_id: Optional[str] = None) -> List[Dict]:
|
||||||
|
"""Obtener menciones de la página."""
|
||||||
|
if not self.access_token or not self.page_id:
|
||||||
|
return []
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
url = f"{self.base_url}/{self.page_id}/tagged"
|
||||||
|
params = {
|
||||||
|
"fields": "id,message,from,created_time,permalink_url",
|
||||||
|
"access_token": self.access_token
|
||||||
|
}
|
||||||
|
|
||||||
|
response = await client.get(url, params=params)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
return data.get("data", [])
|
||||||
|
|
||||||
|
except httpx.HTTPError:
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def get_comments(self, post_id: str) -> List[Dict]:
|
||||||
|
"""Obtener comentarios de un post."""
|
||||||
|
if not self.access_token:
|
||||||
|
return []
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
url = f"{self.base_url}/{post_id}/comments"
|
||||||
|
params = {
|
||||||
|
"fields": "id,message,from,created_time,like_count",
|
||||||
|
"access_token": self.access_token
|
||||||
|
}
|
||||||
|
|
||||||
|
response = await client.get(url, params=params)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
return data.get("data", [])
|
||||||
|
|
||||||
|
except httpx.HTTPError:
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def get_page_messages(self) -> List[Dict]:
|
||||||
|
"""Obtener mensajes de la página (inbox)."""
|
||||||
|
if not self.access_token or not self.page_id:
|
||||||
|
return []
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
url = f"{self.base_url}/{self.page_id}/conversations"
|
||||||
|
params = {
|
||||||
|
"fields": "id,participants,messages{message,from,created_time}",
|
||||||
|
"access_token": self.access_token
|
||||||
|
}
|
||||||
|
|
||||||
|
response = await client.get(url, params=params)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
return data.get("data", [])
|
||||||
|
|
||||||
|
except httpx.HTTPError:
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def send_message(self, recipient_id: str, message: str) -> PublishResult:
|
||||||
|
"""Enviar mensaje directo a un usuario."""
|
||||||
|
if not self.access_token or not self.page_id:
|
||||||
|
return PublishResult(
|
||||||
|
success=False,
|
||||||
|
error_message="Credenciales no configuradas"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
url = f"{self.base_url}/{self.page_id}/messages"
|
||||||
|
payload = {
|
||||||
|
"recipient": {"id": recipient_id},
|
||||||
|
"message": {"text": message},
|
||||||
|
"access_token": self.access_token
|
||||||
|
}
|
||||||
|
|
||||||
|
response = await client.post(url, json=payload)
|
||||||
|
response.raise_for_status()
|
||||||
|
message_id = response.json().get("message_id")
|
||||||
|
|
||||||
|
return PublishResult(
|
||||||
|
success=True,
|
||||||
|
post_id=message_id
|
||||||
|
)
|
||||||
|
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
return PublishResult(
|
||||||
|
success=False,
|
||||||
|
error_message=str(e)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def delete(self, post_id: str) -> bool:
|
||||||
|
"""Eliminar un post."""
|
||||||
|
if not self.access_token:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
url = f"{self.base_url}/{post_id}"
|
||||||
|
params = {"access_token": self.access_token}
|
||||||
|
|
||||||
|
response = await client.delete(url, params=params)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json().get("success", False)
|
||||||
|
|
||||||
|
except httpx.HTTPError:
|
||||||
|
return False
|
||||||
240
app/publishers/instagram_publisher.py
Normal file
240
app/publishers/instagram_publisher.py
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
"""
|
||||||
|
Publisher para Instagram (Meta Graph API).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Optional, List, Dict
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.publishers.base import BasePublisher, PublishResult
|
||||||
|
|
||||||
|
|
||||||
|
class InstagramPublisher(BasePublisher):
|
||||||
|
"""Publisher para Instagram usando Meta Graph API."""
|
||||||
|
|
||||||
|
platform = "instagram"
|
||||||
|
char_limit = 2200
|
||||||
|
base_url = "https://graph.facebook.com/v18.0"
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.access_token = settings.META_ACCESS_TOKEN
|
||||||
|
self.account_id = settings.INSTAGRAM_ACCOUNT_ID
|
||||||
|
|
||||||
|
def validate_content(self, content: str) -> bool:
|
||||||
|
"""Validar longitud del caption."""
|
||||||
|
return len(content) <= self.char_limit
|
||||||
|
|
||||||
|
async def publish(
|
||||||
|
self,
|
||||||
|
content: str,
|
||||||
|
image_path: Optional[str] = None
|
||||||
|
) -> PublishResult:
|
||||||
|
"""Publicar en Instagram (requiere imagen)."""
|
||||||
|
if not self.access_token or not self.account_id:
|
||||||
|
return PublishResult(
|
||||||
|
success=False,
|
||||||
|
error_message="Credenciales de Instagram no configuradas"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not image_path:
|
||||||
|
return PublishResult(
|
||||||
|
success=False,
|
||||||
|
error_message="Instagram requiere una imagen para publicar"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
# Paso 1: Crear contenedor de media
|
||||||
|
# Nota: La imagen debe estar en una URL pública
|
||||||
|
create_url = f"{self.base_url}/{self.account_id}/media"
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"caption": content,
|
||||||
|
"image_url": image_path, # Debe ser URL pública
|
||||||
|
"access_token": self.access_token
|
||||||
|
}
|
||||||
|
|
||||||
|
response = await client.post(create_url, data=payload)
|
||||||
|
response.raise_for_status()
|
||||||
|
container_id = response.json().get("id")
|
||||||
|
|
||||||
|
# Paso 2: Publicar el contenedor
|
||||||
|
publish_url = f"{self.base_url}/{self.account_id}/media_publish"
|
||||||
|
publish_payload = {
|
||||||
|
"creation_id": container_id,
|
||||||
|
"access_token": self.access_token
|
||||||
|
}
|
||||||
|
|
||||||
|
response = await client.post(publish_url, data=publish_payload)
|
||||||
|
response.raise_for_status()
|
||||||
|
post_id = response.json().get("id")
|
||||||
|
|
||||||
|
return PublishResult(
|
||||||
|
success=True,
|
||||||
|
post_id=post_id,
|
||||||
|
url=f"https://www.instagram.com/p/{post_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
return PublishResult(
|
||||||
|
success=False,
|
||||||
|
error_message=str(e)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def publish_thread(
|
||||||
|
self,
|
||||||
|
posts: List[str],
|
||||||
|
images: Optional[List[str]] = None
|
||||||
|
) -> PublishResult:
|
||||||
|
"""Publicar carrusel en Instagram."""
|
||||||
|
if not self.access_token or not self.account_id:
|
||||||
|
return PublishResult(
|
||||||
|
success=False,
|
||||||
|
error_message="Credenciales de Instagram no configuradas"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not images or len(images) < 2:
|
||||||
|
return PublishResult(
|
||||||
|
success=False,
|
||||||
|
error_message="Un carrusel de Instagram requiere al menos 2 imágenes"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
# Paso 1: Crear contenedores para cada imagen
|
||||||
|
children_ids = []
|
||||||
|
|
||||||
|
for image_url in images[:10]: # Máximo 10 imágenes
|
||||||
|
create_url = f"{self.base_url}/{self.account_id}/media"
|
||||||
|
payload = {
|
||||||
|
"image_url": image_url,
|
||||||
|
"is_carousel_item": True,
|
||||||
|
"access_token": self.access_token
|
||||||
|
}
|
||||||
|
|
||||||
|
response = await client.post(create_url, data=payload)
|
||||||
|
response.raise_for_status()
|
||||||
|
children_ids.append(response.json().get("id"))
|
||||||
|
|
||||||
|
# Paso 2: Crear contenedor del carrusel
|
||||||
|
carousel_url = f"{self.base_url}/{self.account_id}/media"
|
||||||
|
carousel_payload = {
|
||||||
|
"media_type": "CAROUSEL",
|
||||||
|
"caption": posts[0] if posts else "",
|
||||||
|
"children": ",".join(children_ids),
|
||||||
|
"access_token": self.access_token
|
||||||
|
}
|
||||||
|
|
||||||
|
response = await client.post(carousel_url, data=carousel_payload)
|
||||||
|
response.raise_for_status()
|
||||||
|
carousel_id = response.json().get("id")
|
||||||
|
|
||||||
|
# Paso 3: Publicar
|
||||||
|
publish_url = f"{self.base_url}/{self.account_id}/media_publish"
|
||||||
|
publish_payload = {
|
||||||
|
"creation_id": carousel_id,
|
||||||
|
"access_token": self.access_token
|
||||||
|
}
|
||||||
|
|
||||||
|
response = await client.post(publish_url, data=publish_payload)
|
||||||
|
response.raise_for_status()
|
||||||
|
post_id = response.json().get("id")
|
||||||
|
|
||||||
|
return PublishResult(
|
||||||
|
success=True,
|
||||||
|
post_id=post_id
|
||||||
|
)
|
||||||
|
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
return PublishResult(
|
||||||
|
success=False,
|
||||||
|
error_message=str(e)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def reply(
|
||||||
|
self,
|
||||||
|
post_id: str,
|
||||||
|
content: str
|
||||||
|
) -> PublishResult:
|
||||||
|
"""Responder a un comentario en Instagram."""
|
||||||
|
if not self.access_token:
|
||||||
|
return PublishResult(
|
||||||
|
success=False,
|
||||||
|
error_message="Token de acceso no configurado"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
url = f"{self.base_url}/{post_id}/replies"
|
||||||
|
payload = {
|
||||||
|
"message": content,
|
||||||
|
"access_token": self.access_token
|
||||||
|
}
|
||||||
|
|
||||||
|
response = await client.post(url, data=payload)
|
||||||
|
response.raise_for_status()
|
||||||
|
reply_id = response.json().get("id")
|
||||||
|
|
||||||
|
return PublishResult(
|
||||||
|
success=True,
|
||||||
|
post_id=reply_id
|
||||||
|
)
|
||||||
|
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
return PublishResult(
|
||||||
|
success=False,
|
||||||
|
error_message=str(e)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def like(self, post_id: str) -> bool:
|
||||||
|
"""Dar like (no disponible vía API para cuentas de negocio)."""
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def get_mentions(self, since_id: Optional[str] = None) -> List[Dict]:
|
||||||
|
"""Obtener menciones en comentarios e historias."""
|
||||||
|
if not self.access_token or not self.account_id:
|
||||||
|
return []
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
url = f"{self.base_url}/{self.account_id}/tags"
|
||||||
|
params = {
|
||||||
|
"fields": "id,caption,media_type,permalink,timestamp,username",
|
||||||
|
"access_token": self.access_token
|
||||||
|
}
|
||||||
|
|
||||||
|
response = await client.get(url, params=params)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
return data.get("data", [])
|
||||||
|
|
||||||
|
except httpx.HTTPError:
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def get_comments(self, post_id: str) -> List[Dict]:
|
||||||
|
"""Obtener comentarios de un post."""
|
||||||
|
if not self.access_token:
|
||||||
|
return []
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
url = f"{self.base_url}/{post_id}/comments"
|
||||||
|
params = {
|
||||||
|
"fields": "id,text,username,timestamp,like_count",
|
||||||
|
"access_token": self.access_token
|
||||||
|
}
|
||||||
|
|
||||||
|
response = await client.get(url, params=params)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
return data.get("data", [])
|
||||||
|
|
||||||
|
except httpx.HTTPError:
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def delete(self, post_id: str) -> bool:
|
||||||
|
"""Eliminar un post (no disponible vía API)."""
|
||||||
|
# La API de Instagram no permite eliminar posts
|
||||||
|
return False
|
||||||
227
app/publishers/threads_publisher.py
Normal file
227
app/publishers/threads_publisher.py
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
"""
|
||||||
|
Publisher para Threads (Meta).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Optional, List, Dict
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.publishers.base import BasePublisher, PublishResult
|
||||||
|
|
||||||
|
|
||||||
|
class ThreadsPublisher(BasePublisher):
|
||||||
|
"""Publisher para Threads usando Meta Graph API."""
|
||||||
|
|
||||||
|
platform = "threads"
|
||||||
|
char_limit = 500
|
||||||
|
base_url = "https://graph.threads.net/v1.0"
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.access_token = settings.META_ACCESS_TOKEN
|
||||||
|
self.user_id = settings.THREADS_USER_ID
|
||||||
|
|
||||||
|
def validate_content(self, content: str) -> bool:
|
||||||
|
"""Validar longitud del post."""
|
||||||
|
return len(content) <= self.char_limit
|
||||||
|
|
||||||
|
async def publish(
|
||||||
|
self,
|
||||||
|
content: str,
|
||||||
|
image_path: Optional[str] = None
|
||||||
|
) -> PublishResult:
|
||||||
|
"""Publicar en Threads."""
|
||||||
|
if not self.access_token or not self.user_id:
|
||||||
|
return PublishResult(
|
||||||
|
success=False,
|
||||||
|
error_message="Credenciales de Threads no configuradas"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
# Paso 1: Crear el contenedor del post
|
||||||
|
create_url = f"{self.base_url}/{self.user_id}/threads"
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"text": content,
|
||||||
|
"media_type": "TEXT",
|
||||||
|
"access_token": self.access_token
|
||||||
|
}
|
||||||
|
|
||||||
|
# Si hay imagen, subirla primero
|
||||||
|
if image_path:
|
||||||
|
# TODO: Implementar subida de imagen a Threads
|
||||||
|
payload["media_type"] = "IMAGE"
|
||||||
|
# payload["image_url"] = uploaded_image_url
|
||||||
|
|
||||||
|
response = await client.post(create_url, data=payload)
|
||||||
|
response.raise_for_status()
|
||||||
|
container_id = response.json().get("id")
|
||||||
|
|
||||||
|
# Paso 2: Publicar el contenedor
|
||||||
|
publish_url = f"{self.base_url}/{self.user_id}/threads_publish"
|
||||||
|
publish_payload = {
|
||||||
|
"creation_id": container_id,
|
||||||
|
"access_token": self.access_token
|
||||||
|
}
|
||||||
|
|
||||||
|
response = await client.post(publish_url, data=publish_payload)
|
||||||
|
response.raise_for_status()
|
||||||
|
post_id = response.json().get("id")
|
||||||
|
|
||||||
|
return PublishResult(
|
||||||
|
success=True,
|
||||||
|
post_id=post_id,
|
||||||
|
url=f"https://www.threads.net/post/{post_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
return PublishResult(
|
||||||
|
success=False,
|
||||||
|
error_message=str(e)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def publish_thread(
|
||||||
|
self,
|
||||||
|
posts: List[str],
|
||||||
|
images: Optional[List[str]] = None
|
||||||
|
) -> PublishResult:
|
||||||
|
"""Publicar un hilo en Threads."""
|
||||||
|
if not self.access_token or not self.user_id:
|
||||||
|
return PublishResult(
|
||||||
|
success=False,
|
||||||
|
error_message="Credenciales de Threads no configuradas"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
first_post_id = None
|
||||||
|
reply_to_id = None
|
||||||
|
|
||||||
|
for i, post in enumerate(posts):
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
create_url = f"{self.base_url}/{self.user_id}/threads"
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"text": post,
|
||||||
|
"media_type": "TEXT",
|
||||||
|
"access_token": self.access_token
|
||||||
|
}
|
||||||
|
|
||||||
|
if reply_to_id:
|
||||||
|
payload["reply_to_id"] = reply_to_id
|
||||||
|
|
||||||
|
response = await client.post(create_url, data=payload)
|
||||||
|
response.raise_for_status()
|
||||||
|
container_id = response.json().get("id")
|
||||||
|
|
||||||
|
# Publicar
|
||||||
|
publish_url = f"{self.base_url}/{self.user_id}/threads_publish"
|
||||||
|
publish_payload = {
|
||||||
|
"creation_id": container_id,
|
||||||
|
"access_token": self.access_token
|
||||||
|
}
|
||||||
|
|
||||||
|
response = await client.post(publish_url, data=publish_payload)
|
||||||
|
response.raise_for_status()
|
||||||
|
post_id = response.json().get("id")
|
||||||
|
|
||||||
|
if i == 0:
|
||||||
|
first_post_id = post_id
|
||||||
|
reply_to_id = post_id
|
||||||
|
|
||||||
|
return PublishResult(
|
||||||
|
success=True,
|
||||||
|
post_id=first_post_id,
|
||||||
|
url=f"https://www.threads.net/post/{first_post_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
return PublishResult(
|
||||||
|
success=False,
|
||||||
|
error_message=str(e)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def reply(
|
||||||
|
self,
|
||||||
|
post_id: str,
|
||||||
|
content: str
|
||||||
|
) -> PublishResult:
|
||||||
|
"""Responder a un post en Threads."""
|
||||||
|
if not self.access_token or not self.user_id:
|
||||||
|
return PublishResult(
|
||||||
|
success=False,
|
||||||
|
error_message="Credenciales de Threads no configuradas"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
create_url = f"{self.base_url}/{self.user_id}/threads"
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"text": content,
|
||||||
|
"media_type": "TEXT",
|
||||||
|
"reply_to_id": post_id,
|
||||||
|
"access_token": self.access_token
|
||||||
|
}
|
||||||
|
|
||||||
|
response = await client.post(create_url, data=payload)
|
||||||
|
response.raise_for_status()
|
||||||
|
container_id = response.json().get("id")
|
||||||
|
|
||||||
|
# Publicar
|
||||||
|
publish_url = f"{self.base_url}/{self.user_id}/threads_publish"
|
||||||
|
publish_payload = {
|
||||||
|
"creation_id": container_id,
|
||||||
|
"access_token": self.access_token
|
||||||
|
}
|
||||||
|
|
||||||
|
response = await client.post(publish_url, data=publish_payload)
|
||||||
|
response.raise_for_status()
|
||||||
|
reply_id = response.json().get("id")
|
||||||
|
|
||||||
|
return PublishResult(
|
||||||
|
success=True,
|
||||||
|
post_id=reply_id
|
||||||
|
)
|
||||||
|
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
return PublishResult(
|
||||||
|
success=False,
|
||||||
|
error_message=str(e)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def like(self, post_id: str) -> bool:
|
||||||
|
"""Dar like a un post (no soportado actualmente por la API)."""
|
||||||
|
# La API de Threads no soporta likes programáticos actualmente
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def get_mentions(self, since_id: Optional[str] = None) -> List[Dict]:
|
||||||
|
"""Obtener menciones (limitado en la API de Threads)."""
|
||||||
|
# TODO: Implementar cuando la API lo soporte
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def get_comments(self, post_id: str) -> List[Dict]:
|
||||||
|
"""Obtener respuestas a un post."""
|
||||||
|
if not self.access_token:
|
||||||
|
return []
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
url = f"{self.base_url}/{post_id}/replies"
|
||||||
|
params = {
|
||||||
|
"access_token": self.access_token,
|
||||||
|
"fields": "id,text,username,timestamp"
|
||||||
|
}
|
||||||
|
|
||||||
|
response = await client.get(url, params=params)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
return data.get("data", [])
|
||||||
|
|
||||||
|
except httpx.HTTPError:
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def delete(self, post_id: str) -> bool:
|
||||||
|
"""Eliminar un post de Threads (no soportado actualmente)."""
|
||||||
|
# La API de Threads no soporta eliminación actualmente
|
||||||
|
return False
|
||||||
255
app/publishers/x_publisher.py
Normal file
255
app/publishers/x_publisher.py
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
"""
|
||||||
|
Publisher para X (Twitter).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Optional, List, Dict
|
||||||
|
import tweepy
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.publishers.base import BasePublisher, PublishResult
|
||||||
|
|
||||||
|
|
||||||
|
class XPublisher(BasePublisher):
|
||||||
|
"""Publisher para X (Twitter) usando Tweepy."""
|
||||||
|
|
||||||
|
platform = "x"
|
||||||
|
char_limit = 280
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.client = None
|
||||||
|
self.api = None
|
||||||
|
self._init_client()
|
||||||
|
|
||||||
|
def _init_client(self):
|
||||||
|
"""Inicializar cliente de Twitter."""
|
||||||
|
if not all([
|
||||||
|
settings.X_API_KEY,
|
||||||
|
settings.X_API_SECRET,
|
||||||
|
settings.X_ACCESS_TOKEN,
|
||||||
|
settings.X_ACCESS_TOKEN_SECRET
|
||||||
|
]):
|
||||||
|
return
|
||||||
|
|
||||||
|
# Cliente v2 para publicar
|
||||||
|
self.client = tweepy.Client(
|
||||||
|
consumer_key=settings.X_API_KEY,
|
||||||
|
consumer_secret=settings.X_API_SECRET,
|
||||||
|
access_token=settings.X_ACCESS_TOKEN,
|
||||||
|
access_token_secret=settings.X_ACCESS_TOKEN_SECRET,
|
||||||
|
bearer_token=settings.X_BEARER_TOKEN
|
||||||
|
)
|
||||||
|
|
||||||
|
# API v1.1 para subir imágenes
|
||||||
|
auth = tweepy.OAuth1UserHandler(
|
||||||
|
settings.X_API_KEY,
|
||||||
|
settings.X_API_SECRET,
|
||||||
|
settings.X_ACCESS_TOKEN,
|
||||||
|
settings.X_ACCESS_TOKEN_SECRET
|
||||||
|
)
|
||||||
|
self.api = tweepy.API(auth)
|
||||||
|
|
||||||
|
def validate_content(self, content: str) -> bool:
|
||||||
|
"""Validar longitud del tweet."""
|
||||||
|
return len(content) <= self.char_limit
|
||||||
|
|
||||||
|
async def publish(
|
||||||
|
self,
|
||||||
|
content: str,
|
||||||
|
image_path: Optional[str] = None
|
||||||
|
) -> PublishResult:
|
||||||
|
"""Publicar un tweet."""
|
||||||
|
if not self.client:
|
||||||
|
return PublishResult(
|
||||||
|
success=False,
|
||||||
|
error_message="Cliente de X no configurado"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
media_ids = None
|
||||||
|
|
||||||
|
# Subir imagen si existe
|
||||||
|
if image_path and self.api:
|
||||||
|
media = self.api.media_upload(filename=image_path)
|
||||||
|
media_ids = [media.media_id]
|
||||||
|
|
||||||
|
# Publicar tweet
|
||||||
|
response = self.client.create_tweet(
|
||||||
|
text=content,
|
||||||
|
media_ids=media_ids
|
||||||
|
)
|
||||||
|
|
||||||
|
tweet_id = response.data['id']
|
||||||
|
return PublishResult(
|
||||||
|
success=True,
|
||||||
|
post_id=tweet_id,
|
||||||
|
url=f"https://x.com/i/web/status/{tweet_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
except tweepy.TweepyException as e:
|
||||||
|
return PublishResult(
|
||||||
|
success=False,
|
||||||
|
error_message=str(e)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def publish_thread(
|
||||||
|
self,
|
||||||
|
posts: List[str],
|
||||||
|
images: Optional[List[str]] = None
|
||||||
|
) -> PublishResult:
|
||||||
|
"""Publicar un hilo de tweets."""
|
||||||
|
if not self.client:
|
||||||
|
return PublishResult(
|
||||||
|
success=False,
|
||||||
|
error_message="Cliente de X no configurado"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
previous_tweet_id = None
|
||||||
|
first_tweet_id = None
|
||||||
|
|
||||||
|
for i, post in enumerate(posts):
|
||||||
|
media_ids = None
|
||||||
|
|
||||||
|
# Subir imagen si existe para este post
|
||||||
|
if images and i < len(images) and images[i] and self.api:
|
||||||
|
media = self.api.media_upload(filename=images[i])
|
||||||
|
media_ids = [media.media_id]
|
||||||
|
|
||||||
|
# Publicar tweet (como respuesta al anterior si existe)
|
||||||
|
response = self.client.create_tweet(
|
||||||
|
text=post,
|
||||||
|
media_ids=media_ids,
|
||||||
|
in_reply_to_tweet_id=previous_tweet_id
|
||||||
|
)
|
||||||
|
|
||||||
|
previous_tweet_id = response.data['id']
|
||||||
|
|
||||||
|
if i == 0:
|
||||||
|
first_tweet_id = previous_tweet_id
|
||||||
|
|
||||||
|
return PublishResult(
|
||||||
|
success=True,
|
||||||
|
post_id=first_tweet_id,
|
||||||
|
url=f"https://x.com/i/web/status/{first_tweet_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
except tweepy.TweepyException as e:
|
||||||
|
return PublishResult(
|
||||||
|
success=False,
|
||||||
|
error_message=str(e)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def reply(
|
||||||
|
self,
|
||||||
|
post_id: str,
|
||||||
|
content: str
|
||||||
|
) -> PublishResult:
|
||||||
|
"""Responder a un tweet."""
|
||||||
|
if not self.client:
|
||||||
|
return PublishResult(
|
||||||
|
success=False,
|
||||||
|
error_message="Cliente de X no configurado"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = self.client.create_tweet(
|
||||||
|
text=content,
|
||||||
|
in_reply_to_tweet_id=post_id
|
||||||
|
)
|
||||||
|
|
||||||
|
return PublishResult(
|
||||||
|
success=True,
|
||||||
|
post_id=response.data['id']
|
||||||
|
)
|
||||||
|
|
||||||
|
except tweepy.TweepyException as e:
|
||||||
|
return PublishResult(
|
||||||
|
success=False,
|
||||||
|
error_message=str(e)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def like(self, post_id: str) -> bool:
|
||||||
|
"""Dar like a un tweet."""
|
||||||
|
if not self.client:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.client.like(post_id)
|
||||||
|
return True
|
||||||
|
except tweepy.TweepyException:
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def get_mentions(self, since_id: Optional[str] = None) -> List[Dict]:
|
||||||
|
"""Obtener menciones recientes."""
|
||||||
|
if not self.client:
|
||||||
|
return []
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Obtener ID del usuario autenticado
|
||||||
|
me = self.client.get_me()
|
||||||
|
user_id = me.data.id
|
||||||
|
|
||||||
|
# Obtener menciones
|
||||||
|
mentions = self.client.get_users_mentions(
|
||||||
|
id=user_id,
|
||||||
|
since_id=since_id,
|
||||||
|
max_results=50,
|
||||||
|
tweet_fields=['created_at', 'author_id', 'conversation_id']
|
||||||
|
)
|
||||||
|
|
||||||
|
if not mentions.data:
|
||||||
|
return []
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": str(tweet.id),
|
||||||
|
"text": tweet.text,
|
||||||
|
"author_id": str(tweet.author_id),
|
||||||
|
"created_at": tweet.created_at.isoformat() if tweet.created_at else None
|
||||||
|
}
|
||||||
|
for tweet in mentions.data
|
||||||
|
]
|
||||||
|
|
||||||
|
except tweepy.TweepyException:
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def get_comments(self, post_id: str) -> List[Dict]:
|
||||||
|
"""Obtener respuestas a un tweet."""
|
||||||
|
if not self.client:
|
||||||
|
return []
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Buscar respuestas al tweet
|
||||||
|
query = f"conversation_id:{post_id}"
|
||||||
|
tweets = self.client.search_recent_tweets(
|
||||||
|
query=query,
|
||||||
|
max_results=50,
|
||||||
|
tweet_fields=['created_at', 'author_id', 'in_reply_to_user_id']
|
||||||
|
)
|
||||||
|
|
||||||
|
if not tweets.data:
|
||||||
|
return []
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": str(tweet.id),
|
||||||
|
"text": tweet.text,
|
||||||
|
"author_id": str(tweet.author_id),
|
||||||
|
"created_at": tweet.created_at.isoformat() if tweet.created_at else None
|
||||||
|
}
|
||||||
|
for tweet in tweets.data
|
||||||
|
]
|
||||||
|
|
||||||
|
except tweepy.TweepyException:
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def delete(self, post_id: str) -> bool:
|
||||||
|
"""Eliminar un tweet."""
|
||||||
|
if not self.client:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.client.delete_tweet(post_id)
|
||||||
|
return True
|
||||||
|
except tweepy.TweepyException:
|
||||||
|
return False
|
||||||
1
app/services/__init__.py
Normal file
1
app/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Services module
|
||||||
314
app/services/content_generator.py
Normal file
314
app/services/content_generator.py
Normal file
@@ -0,0 +1,314 @@
|
|||||||
|
"""
|
||||||
|
Servicio de generación de contenido con DeepSeek API.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Optional, List, Dict
|
||||||
|
from openai import OpenAI
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
class ContentGenerator:
|
||||||
|
"""Generador de contenido usando DeepSeek API."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.client = OpenAI(
|
||||||
|
api_key=settings.DEEPSEEK_API_KEY,
|
||||||
|
base_url=settings.DEEPSEEK_BASE_URL
|
||||||
|
)
|
||||||
|
self.model = "deepseek-chat"
|
||||||
|
|
||||||
|
def _get_system_prompt(self) -> str:
|
||||||
|
"""Obtener el prompt del sistema con la personalidad de la marca."""
|
||||||
|
return f"""Eres el Community Manager de {settings.BUSINESS_NAME}, una empresa de tecnología ubicada en {settings.BUSINESS_LOCATION}.
|
||||||
|
|
||||||
|
SOBRE LA EMPRESA:
|
||||||
|
- Especializada en soluciones de IA, automatización y transformación digital
|
||||||
|
- Vende equipos de cómputo e impresoras 3D
|
||||||
|
- Sitio web: {settings.BUSINESS_WEBSITE}
|
||||||
|
|
||||||
|
TONO DE COMUNICACIÓN:
|
||||||
|
{settings.CONTENT_TONE}
|
||||||
|
|
||||||
|
ESTILO (inspirado en @midudev, @MoureDev, @SoyDalto):
|
||||||
|
- Tips cortos y accionables
|
||||||
|
- Contenido educativo de valor
|
||||||
|
- Cercano pero profesional
|
||||||
|
- Uso moderado de emojis
|
||||||
|
- Hashtags relevantes (máximo 3-5)
|
||||||
|
|
||||||
|
REGLAS:
|
||||||
|
- Nunca uses lenguaje ofensivo
|
||||||
|
- No hagas promesas exageradas
|
||||||
|
- Sé honesto y transparente
|
||||||
|
- Enfócate en ayudar, no en vender directamente
|
||||||
|
- Adapta el contenido a cada plataforma"""
|
||||||
|
|
||||||
|
async def generate_tip_tech(
|
||||||
|
self,
|
||||||
|
category: str,
|
||||||
|
platform: str,
|
||||||
|
template: Optional[str] = None
|
||||||
|
) -> str:
|
||||||
|
"""Generar un tip tech."""
|
||||||
|
char_limits = {
|
||||||
|
"x": 280,
|
||||||
|
"threads": 500,
|
||||||
|
"instagram": 2200,
|
||||||
|
"facebook": 500
|
||||||
|
}
|
||||||
|
|
||||||
|
prompt = f"""Genera un tip de tecnología para la categoría: {category}
|
||||||
|
|
||||||
|
PLATAFORMA: {platform}
|
||||||
|
LÍMITE DE CARACTERES: {char_limits.get(platform, 500)}
|
||||||
|
|
||||||
|
{f'USA ESTE TEMPLATE COMO BASE: {template}' if template else ''}
|
||||||
|
|
||||||
|
REQUISITOS:
|
||||||
|
- Tip práctico y accionable
|
||||||
|
- Fácil de entender
|
||||||
|
- Incluye un emoji relevante al inicio
|
||||||
|
- Termina con 2-3 hashtags relevantes
|
||||||
|
- NO incluyas enlaces
|
||||||
|
|
||||||
|
Responde SOLO con el texto del post, sin explicaciones."""
|
||||||
|
|
||||||
|
response = self.client.chat.completions.create(
|
||||||
|
model=self.model,
|
||||||
|
messages=[
|
||||||
|
{"role": "system", "content": self._get_system_prompt()},
|
||||||
|
{"role": "user", "content": prompt}
|
||||||
|
],
|
||||||
|
max_tokens=300,
|
||||||
|
temperature=0.7
|
||||||
|
)
|
||||||
|
|
||||||
|
return response.choices[0].message.content.strip()
|
||||||
|
|
||||||
|
async def generate_product_post(
|
||||||
|
self,
|
||||||
|
product: Dict,
|
||||||
|
platform: str
|
||||||
|
) -> str:
|
||||||
|
"""Generar post para un producto."""
|
||||||
|
char_limits = {
|
||||||
|
"x": 280,
|
||||||
|
"threads": 500,
|
||||||
|
"instagram": 2200,
|
||||||
|
"facebook": 1000
|
||||||
|
}
|
||||||
|
|
||||||
|
prompt = f"""Genera un post promocional para este producto:
|
||||||
|
|
||||||
|
PRODUCTO: {product['name']}
|
||||||
|
DESCRIPCIÓN: {product.get('description', 'N/A')}
|
||||||
|
PRECIO: ${product['price']:,.2f} MXN
|
||||||
|
CATEGORÍA: {product['category']}
|
||||||
|
ESPECIFICACIONES: {json.dumps(product.get('specs', {}), ensure_ascii=False)}
|
||||||
|
PUNTOS DESTACADOS: {', '.join(product.get('highlights', []))}
|
||||||
|
|
||||||
|
PLATAFORMA: {platform}
|
||||||
|
LÍMITE DE CARACTERES: {char_limits.get(platform, 500)}
|
||||||
|
|
||||||
|
REQUISITOS:
|
||||||
|
- Destaca los beneficios principales
|
||||||
|
- Incluye el precio
|
||||||
|
- Usa emojis relevantes
|
||||||
|
- Incluye CTA sutil (ej: "Contáctanos", "Más info en DM")
|
||||||
|
- Termina con 2-3 hashtags
|
||||||
|
- NO inventes especificaciones
|
||||||
|
|
||||||
|
Responde SOLO con el texto del post."""
|
||||||
|
|
||||||
|
response = self.client.chat.completions.create(
|
||||||
|
model=self.model,
|
||||||
|
messages=[
|
||||||
|
{"role": "system", "content": self._get_system_prompt()},
|
||||||
|
{"role": "user", "content": prompt}
|
||||||
|
],
|
||||||
|
max_tokens=400,
|
||||||
|
temperature=0.7
|
||||||
|
)
|
||||||
|
|
||||||
|
return response.choices[0].message.content.strip()
|
||||||
|
|
||||||
|
async def generate_service_post(
|
||||||
|
self,
|
||||||
|
service: Dict,
|
||||||
|
platform: str
|
||||||
|
) -> str:
|
||||||
|
"""Generar post para un servicio."""
|
||||||
|
char_limits = {
|
||||||
|
"x": 280,
|
||||||
|
"threads": 500,
|
||||||
|
"instagram": 2200,
|
||||||
|
"facebook": 1000
|
||||||
|
}
|
||||||
|
|
||||||
|
prompt = f"""Genera un post promocional para este servicio:
|
||||||
|
|
||||||
|
SERVICIO: {service['name']}
|
||||||
|
DESCRIPCIÓN: {service.get('description', 'N/A')}
|
||||||
|
CATEGORÍA: {service['category']}
|
||||||
|
SECTORES OBJETIVO: {', '.join(service.get('target_sectors', []))}
|
||||||
|
BENEFICIOS: {', '.join(service.get('benefits', []))}
|
||||||
|
CTA: {service.get('call_to_action', 'Contáctanos para más información')}
|
||||||
|
|
||||||
|
PLATAFORMA: {platform}
|
||||||
|
LÍMITE DE CARACTERES: {char_limits.get(platform, 500)}
|
||||||
|
|
||||||
|
REQUISITOS:
|
||||||
|
- Enfócate en el problema que resuelve
|
||||||
|
- Destaca 2-3 beneficios clave
|
||||||
|
- Usa emojis relevantes (✅, 🚀, 💡)
|
||||||
|
- Incluye el CTA
|
||||||
|
- Termina con 2-3 hashtags
|
||||||
|
- Tono consultivo, no vendedor
|
||||||
|
|
||||||
|
Responde SOLO con el texto del post."""
|
||||||
|
|
||||||
|
response = self.client.chat.completions.create(
|
||||||
|
model=self.model,
|
||||||
|
messages=[
|
||||||
|
{"role": "system", "content": self._get_system_prompt()},
|
||||||
|
{"role": "user", "content": prompt}
|
||||||
|
],
|
||||||
|
max_tokens=400,
|
||||||
|
temperature=0.7
|
||||||
|
)
|
||||||
|
|
||||||
|
return response.choices[0].message.content.strip()
|
||||||
|
|
||||||
|
async def generate_thread(
|
||||||
|
self,
|
||||||
|
topic: str,
|
||||||
|
num_posts: int = 5
|
||||||
|
) -> List[str]:
|
||||||
|
"""Generar un hilo educativo."""
|
||||||
|
prompt = f"""Genera un hilo educativo de {num_posts} posts sobre: {topic}
|
||||||
|
|
||||||
|
REQUISITOS:
|
||||||
|
- Post 1: Gancho que capture atención
|
||||||
|
- Posts 2-{num_posts-1}: Contenido educativo de valor
|
||||||
|
- Post {num_posts}: Conclusión con CTA
|
||||||
|
|
||||||
|
FORMATO:
|
||||||
|
- Cada post máximo 280 caracteres
|
||||||
|
- Numera cada post (1/, 2/, etc.)
|
||||||
|
- Usa emojis relevantes
|
||||||
|
- El último post incluye hashtags
|
||||||
|
|
||||||
|
Responde con cada post en una línea separada, sin explicaciones."""
|
||||||
|
|
||||||
|
response = self.client.chat.completions.create(
|
||||||
|
model=self.model,
|
||||||
|
messages=[
|
||||||
|
{"role": "system", "content": self._get_system_prompt()},
|
||||||
|
{"role": "user", "content": prompt}
|
||||||
|
],
|
||||||
|
max_tokens=1500,
|
||||||
|
temperature=0.7
|
||||||
|
)
|
||||||
|
|
||||||
|
content = response.choices[0].message.content.strip()
|
||||||
|
# Separar posts por líneas no vacías
|
||||||
|
posts = [p.strip() for p in content.split('\n') if p.strip()]
|
||||||
|
|
||||||
|
return posts
|
||||||
|
|
||||||
|
async def generate_response_suggestion(
|
||||||
|
self,
|
||||||
|
interaction_content: str,
|
||||||
|
interaction_type: str,
|
||||||
|
context: Optional[str] = None
|
||||||
|
) -> List[str]:
|
||||||
|
"""Generar sugerencias de respuesta para una interacción."""
|
||||||
|
prompt = f"""Un usuario escribió esto en redes sociales:
|
||||||
|
|
||||||
|
"{interaction_content}"
|
||||||
|
|
||||||
|
TIPO DE INTERACCIÓN: {interaction_type}
|
||||||
|
{f'CONTEXTO ADICIONAL: {context}' if context else ''}
|
||||||
|
|
||||||
|
Genera 3 opciones de respuesta diferentes:
|
||||||
|
1. Respuesta corta y amigable
|
||||||
|
2. Respuesta que invite a continuar la conversación
|
||||||
|
3. Respuesta que dirija a más información/contacto
|
||||||
|
|
||||||
|
REQUISITOS:
|
||||||
|
- Máximo 280 caracteres cada una
|
||||||
|
- Tono amigable y profesional
|
||||||
|
- Si es una queja, sé empático
|
||||||
|
- Si es una pregunta técnica, sé útil
|
||||||
|
|
||||||
|
Responde con las 3 opciones numeradas, una por línea."""
|
||||||
|
|
||||||
|
response = self.client.chat.completions.create(
|
||||||
|
model=self.model,
|
||||||
|
messages=[
|
||||||
|
{"role": "system", "content": self._get_system_prompt()},
|
||||||
|
{"role": "user", "content": prompt}
|
||||||
|
],
|
||||||
|
max_tokens=500,
|
||||||
|
temperature=0.8
|
||||||
|
)
|
||||||
|
|
||||||
|
content = response.choices[0].message.content.strip()
|
||||||
|
suggestions = [s.strip() for s in content.split('\n') if s.strip()]
|
||||||
|
|
||||||
|
# Limpiar numeración si existe
|
||||||
|
cleaned = []
|
||||||
|
for s in suggestions:
|
||||||
|
if s[0].isdigit() and (s[1] == '.' or s[1] == ')'):
|
||||||
|
s = s[2:].strip()
|
||||||
|
cleaned.append(s)
|
||||||
|
|
||||||
|
return cleaned[:3] # Máximo 3 sugerencias
|
||||||
|
|
||||||
|
async def adapt_content_for_platform(
|
||||||
|
self,
|
||||||
|
content: str,
|
||||||
|
target_platform: str
|
||||||
|
) -> str:
|
||||||
|
"""Adaptar contenido existente a una plataforma específica."""
|
||||||
|
char_limits = {
|
||||||
|
"x": 280,
|
||||||
|
"threads": 500,
|
||||||
|
"instagram": 2200,
|
||||||
|
"facebook": 1000
|
||||||
|
}
|
||||||
|
|
||||||
|
prompt = f"""Adapta este contenido para {target_platform}:
|
||||||
|
|
||||||
|
CONTENIDO ORIGINAL:
|
||||||
|
{content}
|
||||||
|
|
||||||
|
LÍMITE DE CARACTERES: {char_limits.get(target_platform, 500)}
|
||||||
|
|
||||||
|
REQUISITOS PARA {target_platform.upper()}:
|
||||||
|
{"- Muy conciso, directo al punto" if target_platform == "x" else ""}
|
||||||
|
{"- Puede ser más extenso, incluir más contexto" if target_platform == "instagram" else ""}
|
||||||
|
{"- Tono más casual y cercano" if target_platform == "threads" else ""}
|
||||||
|
{"- Puede incluir links, más profesional" if target_platform == "facebook" else ""}
|
||||||
|
- Mantén la esencia del mensaje
|
||||||
|
- Ajusta hashtags según la plataforma
|
||||||
|
|
||||||
|
Responde SOLO con el contenido adaptado."""
|
||||||
|
|
||||||
|
response = self.client.chat.completions.create(
|
||||||
|
model=self.model,
|
||||||
|
messages=[
|
||||||
|
{"role": "system", "content": self._get_system_prompt()},
|
||||||
|
{"role": "user", "content": prompt}
|
||||||
|
],
|
||||||
|
max_tokens=400,
|
||||||
|
temperature=0.6
|
||||||
|
)
|
||||||
|
|
||||||
|
return response.choices[0].message.content.strip()
|
||||||
|
|
||||||
|
|
||||||
|
# Instancia global
|
||||||
|
content_generator = ContentGenerator()
|
||||||
174
app/services/image_generator.py
Normal file
174
app/services/image_generator.py
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
"""
|
||||||
|
Servicio de generación de imágenes para posts.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import Dict, Optional
|
||||||
|
from pathlib import Path
|
||||||
|
from html2image import Html2Image
|
||||||
|
from PIL import Image
|
||||||
|
from jinja2 import Environment, FileSystemLoader
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
class ImageGenerator:
|
||||||
|
"""Generador de imágenes usando plantillas HTML."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.templates_dir = Path("templates")
|
||||||
|
self.output_dir = Path("uploads/generated")
|
||||||
|
self.output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
self.jinja_env = Environment(
|
||||||
|
loader=FileSystemLoader(self.templates_dir)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.hti = Html2Image(
|
||||||
|
output_path=str(self.output_dir),
|
||||||
|
custom_flags=['--no-sandbox', '--disable-gpu']
|
||||||
|
)
|
||||||
|
|
||||||
|
def _render_template(self, template_name: str, variables: Dict) -> str:
|
||||||
|
"""Renderizar una plantilla HTML con variables."""
|
||||||
|
template = self.jinja_env.get_template(template_name)
|
||||||
|
return template.render(**variables)
|
||||||
|
|
||||||
|
async def generate_tip_card(
|
||||||
|
self,
|
||||||
|
title: str,
|
||||||
|
content: str,
|
||||||
|
category: str,
|
||||||
|
output_name: str
|
||||||
|
) -> str:
|
||||||
|
"""Generar imagen de tip tech."""
|
||||||
|
variables = {
|
||||||
|
"title": title,
|
||||||
|
"content": content,
|
||||||
|
"category": category,
|
||||||
|
"logo_url": f"{settings.BUSINESS_WEBSITE}/logo.png",
|
||||||
|
"website": settings.BUSINESS_WEBSITE,
|
||||||
|
"business_name": settings.BUSINESS_NAME
|
||||||
|
}
|
||||||
|
|
||||||
|
html_content = self._render_template("tip_card.html", variables)
|
||||||
|
|
||||||
|
# Generar imagen
|
||||||
|
output_file = f"{output_name}.png"
|
||||||
|
self.hti.screenshot(
|
||||||
|
html_str=html_content,
|
||||||
|
save_as=output_file,
|
||||||
|
size=(1080, 1080)
|
||||||
|
)
|
||||||
|
|
||||||
|
return str(self.output_dir / output_file)
|
||||||
|
|
||||||
|
async def generate_product_card(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
price: float,
|
||||||
|
image_url: str,
|
||||||
|
highlights: list,
|
||||||
|
output_name: str
|
||||||
|
) -> str:
|
||||||
|
"""Generar imagen de producto."""
|
||||||
|
variables = {
|
||||||
|
"name": name,
|
||||||
|
"price": f"${price:,.2f} MXN",
|
||||||
|
"image_url": image_url,
|
||||||
|
"highlights": highlights[:3], # Máximo 3 highlights
|
||||||
|
"logo_url": f"{settings.BUSINESS_WEBSITE}/logo.png",
|
||||||
|
"website": settings.BUSINESS_WEBSITE,
|
||||||
|
"business_name": settings.BUSINESS_NAME
|
||||||
|
}
|
||||||
|
|
||||||
|
html_content = self._render_template("product_card.html", variables)
|
||||||
|
|
||||||
|
output_file = f"{output_name}.png"
|
||||||
|
self.hti.screenshot(
|
||||||
|
html_str=html_content,
|
||||||
|
save_as=output_file,
|
||||||
|
size=(1080, 1080)
|
||||||
|
)
|
||||||
|
|
||||||
|
return str(self.output_dir / output_file)
|
||||||
|
|
||||||
|
async def generate_service_card(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
tagline: str,
|
||||||
|
benefits: list,
|
||||||
|
icon: str,
|
||||||
|
output_name: str
|
||||||
|
) -> str:
|
||||||
|
"""Generar imagen de servicio."""
|
||||||
|
variables = {
|
||||||
|
"name": name,
|
||||||
|
"tagline": tagline,
|
||||||
|
"benefits": benefits[:4], # Máximo 4 beneficios
|
||||||
|
"icon": icon,
|
||||||
|
"logo_url": f"{settings.BUSINESS_WEBSITE}/logo.png",
|
||||||
|
"website": settings.BUSINESS_WEBSITE,
|
||||||
|
"business_name": settings.BUSINESS_NAME
|
||||||
|
}
|
||||||
|
|
||||||
|
html_content = self._render_template("service_card.html", variables)
|
||||||
|
|
||||||
|
output_file = f"{output_name}.png"
|
||||||
|
self.hti.screenshot(
|
||||||
|
html_str=html_content,
|
||||||
|
save_as=output_file,
|
||||||
|
size=(1080, 1080)
|
||||||
|
)
|
||||||
|
|
||||||
|
return str(self.output_dir / output_file)
|
||||||
|
|
||||||
|
async def resize_for_platform(
|
||||||
|
self,
|
||||||
|
image_path: str,
|
||||||
|
platform: str
|
||||||
|
) -> str:
|
||||||
|
"""Redimensionar imagen para una plataforma específica."""
|
||||||
|
sizes = {
|
||||||
|
"x": (1200, 675), # 16:9
|
||||||
|
"threads": (1080, 1080), # 1:1
|
||||||
|
"instagram": (1080, 1080), # 1:1
|
||||||
|
"facebook": (1200, 630) # ~1.9:1
|
||||||
|
}
|
||||||
|
|
||||||
|
target_size = sizes.get(platform, (1080, 1080))
|
||||||
|
|
||||||
|
img = Image.open(image_path)
|
||||||
|
|
||||||
|
# Crear nueva imagen con el tamaño objetivo
|
||||||
|
new_img = Image.new('RGB', target_size, (26, 26, 46)) # Color de fondo de la marca
|
||||||
|
|
||||||
|
# Calcular posición para centrar
|
||||||
|
img_ratio = img.width / img.height
|
||||||
|
target_ratio = target_size[0] / target_size[1]
|
||||||
|
|
||||||
|
if img_ratio > target_ratio:
|
||||||
|
# Imagen más ancha
|
||||||
|
new_width = target_size[0]
|
||||||
|
new_height = int(new_width / img_ratio)
|
||||||
|
else:
|
||||||
|
# Imagen más alta
|
||||||
|
new_height = target_size[1]
|
||||||
|
new_width = int(new_height * img_ratio)
|
||||||
|
|
||||||
|
img_resized = img.resize((new_width, new_height), Image.Resampling.LANCZOS)
|
||||||
|
|
||||||
|
# Centrar en la nueva imagen
|
||||||
|
x = (target_size[0] - new_width) // 2
|
||||||
|
y = (target_size[1] - new_height) // 2
|
||||||
|
new_img.paste(img_resized, (x, y))
|
||||||
|
|
||||||
|
# Guardar
|
||||||
|
output_path = image_path.replace('.png', f'_{platform}.png')
|
||||||
|
new_img.save(output_path, 'PNG', quality=95)
|
||||||
|
|
||||||
|
return output_path
|
||||||
|
|
||||||
|
|
||||||
|
# Instancia global
|
||||||
|
image_generator = ImageGenerator()
|
||||||
181
dashboard/templates/index.html
Normal file
181
dashboard/templates/index.html
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Dashboard - 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; }
|
||||||
|
</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="/" class="px-4 py-2 rounded hover:bg-gray-800">Home</a>
|
||||||
|
<a href="/posts" class="px-4 py-2 rounded hover:bg-gray-800">Posts</a>
|
||||||
|
<a href="/calendar" class="px-4 py-2 rounded hover:bg-gray-800">Calendario</a>
|
||||||
|
<a href="/interactions" class="px-4 py-2 rounded hover:bg-gray-800">Interacciones</a>
|
||||||
|
<a href="/products" class="px-4 py-2 rounded hover:bg-gray-800">Productos</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="container mx-auto px-6 py-8">
|
||||||
|
<!-- Stats -->
|
||||||
|
<div class="grid grid-cols-5 gap-4 mb-8">
|
||||||
|
<div class="card p-4 text-center">
|
||||||
|
<div class="text-3xl font-bold accent">{{ stats.posts_today }}</div>
|
||||||
|
<div class="text-gray-400 text-sm">Posts Hoy</div>
|
||||||
|
</div>
|
||||||
|
<div class="card p-4 text-center">
|
||||||
|
<div class="text-3xl font-bold accent">{{ stats.posts_week }}</div>
|
||||||
|
<div class="text-gray-400 text-sm">Posts Semana</div>
|
||||||
|
</div>
|
||||||
|
<div class="card p-4 text-center">
|
||||||
|
<div class="text-3xl font-bold text-yellow-500">{{ stats.pending_approval }}</div>
|
||||||
|
<div class="text-gray-400 text-sm">Pendientes</div>
|
||||||
|
</div>
|
||||||
|
<div class="card p-4 text-center">
|
||||||
|
<div class="text-3xl font-bold text-blue-500">{{ stats.scheduled }}</div>
|
||||||
|
<div class="text-gray-400 text-sm">Programados</div>
|
||||||
|
</div>
|
||||||
|
<div class="card p-4 text-center">
|
||||||
|
<div class="text-3xl font-bold text-red-500">{{ stats.interactions_pending }}</div>
|
||||||
|
<div class="text-gray-400 text-sm">Interacciones</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-6">
|
||||||
|
<!-- Pending Approval -->
|
||||||
|
<div class="card p-6">
|
||||||
|
<h2 class="text-xl font-semibold mb-4">Pendientes de Aprobación</h2>
|
||||||
|
{% if pending_posts %}
|
||||||
|
{% for post in pending_posts %}
|
||||||
|
<div class="border-b border-gray-700 py-4 last:border-0">
|
||||||
|
<div class="flex justify-between items-start mb-2">
|
||||||
|
<span class="bg-blue-900 text-blue-300 px-2 py-1 rounded text-xs">
|
||||||
|
{{ post.content_type }}
|
||||||
|
</span>
|
||||||
|
<span class="text-gray-500 text-sm">
|
||||||
|
{{ post.scheduled_at }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-gray-300 mb-3">{{ post.content[:200] }}...</p>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button onclick="approvePost({{ post.id }})"
|
||||||
|
class="btn-primary px-3 py-1 rounded text-sm">
|
||||||
|
Aprobar
|
||||||
|
</button>
|
||||||
|
<button onclick="rejectPost({{ post.id }})"
|
||||||
|
class="bg-red-900 text-red-300 px-3 py-1 rounded text-sm">
|
||||||
|
Rechazar
|
||||||
|
</button>
|
||||||
|
<button onclick="editPost({{ post.id }})"
|
||||||
|
class="bg-gray-700 px-3 py-1 rounded text-sm">
|
||||||
|
Editar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<p class="text-gray-500">No hay posts pendientes</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Scheduled -->
|
||||||
|
<div class="card p-6">
|
||||||
|
<h2 class="text-xl font-semibold mb-4">Próximas Publicaciones</h2>
|
||||||
|
{% if scheduled_posts %}
|
||||||
|
{% for post in scheduled_posts %}
|
||||||
|
<div class="border-b border-gray-700 py-3 last:border-0 flex items-center gap-4">
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="text-accent font-bold">{{ post.scheduled_at }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<span class="text-xs bg-gray-700 px-2 py-1 rounded">
|
||||||
|
{{ post.content_type }}
|
||||||
|
</span>
|
||||||
|
<p class="text-gray-400 text-sm mt-1">
|
||||||
|
{{ post.content[:100] }}...
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-1">
|
||||||
|
{% for platform in post.platforms %}
|
||||||
|
<span class="text-xs bg-gray-800 px-2 py-1 rounded">
|
||||||
|
{{ platform }}
|
||||||
|
</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<p class="text-gray-500">No hay posts programados</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recent Interactions -->
|
||||||
|
<div class="card p-6 mt-6">
|
||||||
|
<h2 class="text-xl font-semibold mb-4">Interacciones Recientes</h2>
|
||||||
|
{% if recent_interactions %}
|
||||||
|
<div class="grid gap-4">
|
||||||
|
{% for interaction in recent_interactions %}
|
||||||
|
<div class="bg-gray-800 rounded-lg p-4">
|
||||||
|
<div class="flex justify-between items-start mb-2">
|
||||||
|
<div>
|
||||||
|
<span class="font-semibold">@{{ interaction.author_username }}</span>
|
||||||
|
<span class="text-gray-500 text-sm ml-2">{{ interaction.platform }}</span>
|
||||||
|
</div>
|
||||||
|
<span class="text-gray-500 text-xs">{{ interaction.interaction_at }}</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-gray-300">{{ interaction.content }}</p>
|
||||||
|
<div class="mt-3 flex gap-2">
|
||||||
|
<button class="bg-accent text-gray-900 px-3 py-1 rounded text-sm">
|
||||||
|
Responder
|
||||||
|
</button>
|
||||||
|
<button class="bg-gray-700 px-3 py-1 rounded text-sm">
|
||||||
|
Marcar como Lead
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-gray-500">No hay interacciones pendientes</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
async function approvePost(postId) {
|
||||||
|
const response = await fetch(`/api/posts/${postId}/approve`, { method: 'POST' });
|
||||||
|
if (response.ok) {
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rejectPost(postId) {
|
||||||
|
if (confirm('¿Seguro que quieres rechazar este post?')) {
|
||||||
|
const response = await fetch(`/api/posts/${postId}/reject`, { method: 'POST' });
|
||||||
|
if (response.ok) {
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function editPost(postId) {
|
||||||
|
window.location.href = `/posts/${postId}/edit`;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
122
data/tips_iniciales.json
Normal file
122
data/tips_iniciales.json
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"category": "productividad",
|
||||||
|
"title": "Atajos de teclado Windows",
|
||||||
|
"template": "Win + V activa el historial del portapapeles en Windows. Puedes pegar cualquier cosa que hayas copiado antes.",
|
||||||
|
"difficulty": "basico"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"category": "seguridad",
|
||||||
|
"title": "Contraseñas seguras",
|
||||||
|
"template": "Una contraseña segura tiene mínimo 12 caracteres, mayúsculas, minúsculas, números y símbolos. Mejor aún: usa un gestor de contraseñas.",
|
||||||
|
"difficulty": "basico"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"category": "hardware",
|
||||||
|
"title": "Limpiar tu PC",
|
||||||
|
"template": "Limpia el polvo de tu PC cada 3-6 meses. El polvo acumulado aumenta la temperatura y reduce el rendimiento.",
|
||||||
|
"difficulty": "basico"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"category": "software",
|
||||||
|
"title": "Actualizaciones",
|
||||||
|
"template": "Mantén tu sistema operativo actualizado. Las actualizaciones no solo traen funciones nuevas, también parches de seguridad críticos.",
|
||||||
|
"difficulty": "basico"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"category": "productividad",
|
||||||
|
"title": "Múltiples escritorios",
|
||||||
|
"template": "Win + Tab te permite crear múltiples escritorios virtuales. Perfecto para separar trabajo personal de proyectos.",
|
||||||
|
"difficulty": "intermedio"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"category": "redes",
|
||||||
|
"title": "Velocidad de internet",
|
||||||
|
"template": "¿Internet lento? Prueba cambiar el DNS a 1.1.1.1 (Cloudflare) o 8.8.8.8 (Google). Puede mejorar la velocidad de navegación.",
|
||||||
|
"difficulty": "intermedio"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"category": "seguridad",
|
||||||
|
"title": "Autenticación 2FA",
|
||||||
|
"template": "Activa la autenticación de dos factores (2FA) en todas tus cuentas importantes. Es la mejor defensa contra hackeos.",
|
||||||
|
"difficulty": "basico"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"category": "ia",
|
||||||
|
"title": "Prompts efectivos",
|
||||||
|
"template": "Para mejores resultados con IA, sé específico en tus prompts. En lugar de 'escribe un email', prueba 'escribe un email profesional de 3 párrafos solicitando una reunión'.",
|
||||||
|
"difficulty": "intermedio"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"category": "hardware",
|
||||||
|
"title": "SSD vs HDD",
|
||||||
|
"template": "Un SSD puede hacer que tu PC arranque en 10-15 segundos vs 1-2 minutos con HDD. Es la mejor inversión para PCs antiguas.",
|
||||||
|
"difficulty": "basico"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"category": "productividad",
|
||||||
|
"title": "Captura de pantalla",
|
||||||
|
"template": "Win + Shift + S abre la herramienta de recorte. Puedes capturar una región, ventana o pantalla completa al instante.",
|
||||||
|
"difficulty": "basico"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"category": "software",
|
||||||
|
"title": "Programas de inicio",
|
||||||
|
"template": "¿PC lenta al encender? Desactiva programas innecesarios en el inicio. Ctrl + Shift + Esc > Inicio > Desactivar los que no necesites.",
|
||||||
|
"difficulty": "basico"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"category": "seguridad",
|
||||||
|
"title": "Phishing",
|
||||||
|
"template": "Antes de hacer clic en un enlace, pasa el mouse encima para ver la URL real. Si no coincide con el sitio esperado, es phishing.",
|
||||||
|
"difficulty": "basico"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"category": "ia",
|
||||||
|
"title": "ChatGPT para código",
|
||||||
|
"template": "ChatGPT puede explicar código línea por línea. Copia el código y pide: 'Explica este código como si tuviera 5 años de experiencia'.",
|
||||||
|
"difficulty": "intermedio"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"category": "hardware",
|
||||||
|
"title": "RAM suficiente",
|
||||||
|
"template": "En 2024, 8GB de RAM es el mínimo. Para trabajo con múltiples apps o desarrollo, 16GB es lo recomendado. Para edición de video, 32GB.",
|
||||||
|
"difficulty": "intermedio"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"category": "redes",
|
||||||
|
"title": "WiFi 5GHz",
|
||||||
|
"template": "Si tu router tiene banda 5GHz, úsala para dispositivos cercanos. Es más rápida que 2.4GHz, aunque tiene menor alcance.",
|
||||||
|
"difficulty": "basico"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"category": "productividad",
|
||||||
|
"title": "Notion para notas",
|
||||||
|
"template": "Notion es gratis para uso personal. Puedes organizar notas, tareas y proyectos en un solo lugar con templates listos para usar.",
|
||||||
|
"difficulty": "basico"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"category": "seguridad",
|
||||||
|
"title": "Backup 3-2-1",
|
||||||
|
"template": "Regla 3-2-1 de backups: 3 copias de tus datos, 2 en medios diferentes, 1 fuera de tu ubicación (nube). Así nunca pierdes nada.",
|
||||||
|
"difficulty": "intermedio"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"category": "software",
|
||||||
|
"title": "Navegador ligero",
|
||||||
|
"template": "¿Chrome consume mucha RAM? Prueba Edge (sí, el de Microsoft) o Brave. Usan la misma base pero optimizan mejor los recursos.",
|
||||||
|
"difficulty": "basico"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"category": "ia",
|
||||||
|
"title": "IA para Excel",
|
||||||
|
"template": "ChatGPT puede crear fórmulas de Excel. Describe lo que necesitas en español y pide la fórmula. Ahorra horas de búsqueda.",
|
||||||
|
"difficulty": "basico"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"category": "hardware",
|
||||||
|
"title": "Monitor externo",
|
||||||
|
"template": "Un segundo monitor aumenta la productividad hasta 42% según estudios. No necesita ser caro, cualquier monitor extra ayuda.",
|
||||||
|
"difficulty": "basico"
|
||||||
|
}
|
||||||
|
]
|
||||||
182
docker-compose.yml
Normal file
182
docker-compose.yml
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
# ===========================================
|
||||||
|
# APLICACIÓN PRINCIPAL (FastAPI)
|
||||||
|
# ===========================================
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: social-automation-app
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
environment:
|
||||||
|
- DATABASE_URL=postgresql://social_user:social_pass@db:5432/social_automation
|
||||||
|
- REDIS_URL=redis://redis:6379/0
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
volumes:
|
||||||
|
- ./app:/app/app
|
||||||
|
- ./templates:/app/templates
|
||||||
|
- ./data:/app/data
|
||||||
|
- uploaded_images:/app/uploads
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_started
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- social-network
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# CELERY WORKER (Procesamiento de tareas)
|
||||||
|
# ===========================================
|
||||||
|
worker:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: social-automation-worker
|
||||||
|
command: celery -A worker.celery_app worker --loglevel=info --concurrency=2
|
||||||
|
environment:
|
||||||
|
- DATABASE_URL=postgresql://social_user:social_pass@db:5432/social_automation
|
||||||
|
- REDIS_URL=redis://redis:6379/0
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
volumes:
|
||||||
|
- ./app:/app/app
|
||||||
|
- ./worker:/app/worker
|
||||||
|
- ./templates:/app/templates
|
||||||
|
- uploaded_images:/app/uploads
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_started
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- social-network
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# CELERY BEAT (Programador de tareas)
|
||||||
|
# ===========================================
|
||||||
|
beat:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: social-automation-beat
|
||||||
|
command: celery -A worker.celery_app beat --loglevel=info
|
||||||
|
environment:
|
||||||
|
- DATABASE_URL=postgresql://social_user:social_pass@db:5432/social_automation
|
||||||
|
- REDIS_URL=redis://redis:6379/0
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
volumes:
|
||||||
|
- ./worker:/app/worker
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_started
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- social-network
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# FLOWER (Monitor de Celery)
|
||||||
|
# ===========================================
|
||||||
|
flower:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: social-automation-flower
|
||||||
|
command: celery -A worker.celery_app flower --port=5555
|
||||||
|
ports:
|
||||||
|
- "5555:5555"
|
||||||
|
environment:
|
||||||
|
- REDIS_URL=redis://redis:6379/0
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
depends_on:
|
||||||
|
- redis
|
||||||
|
- worker
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- social-network
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# POSTGRESQL (Base de datos)
|
||||||
|
# ===========================================
|
||||||
|
db:
|
||||||
|
image: postgres:15-alpine
|
||||||
|
container_name: social-automation-db
|
||||||
|
environment:
|
||||||
|
- POSTGRES_USER=social_user
|
||||||
|
- POSTGRES_PASSWORD=social_pass
|
||||||
|
- POSTGRES_DB=social_automation
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
- ./scripts/init_db.sql:/docker-entrypoint-initdb.d/init.sql
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U social_user -d social_automation"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- social-network
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# REDIS (Cola de mensajes)
|
||||||
|
# ===========================================
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
container_name: social-automation-redis
|
||||||
|
command: redis-server --appendonly yes
|
||||||
|
volumes:
|
||||||
|
- redis_data:/data
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- social-network
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# NGINX (Reverse Proxy)
|
||||||
|
# ===========================================
|
||||||
|
nginx:
|
||||||
|
image: nginx:alpine
|
||||||
|
container_name: social-automation-nginx
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
- "443:443"
|
||||||
|
volumes:
|
||||||
|
- ./nginx/nginx.conf:/etc/nginx/nginx.conf
|
||||||
|
- ./nginx/ssl:/etc/nginx/ssl
|
||||||
|
- ./dashboard/static:/usr/share/nginx/html/static
|
||||||
|
depends_on:
|
||||||
|
- app
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- social-network
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# VOLÚMENES
|
||||||
|
# ===========================================
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
driver: local
|
||||||
|
redis_data:
|
||||||
|
driver: local
|
||||||
|
uploaded_images:
|
||||||
|
driver: local
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# REDES
|
||||||
|
# ===========================================
|
||||||
|
networks:
|
||||||
|
social-network:
|
||||||
|
driver: bridge
|
||||||
231
docs/plans/2025-01-28-social-media-automation-design.md
Normal file
231
docs/plans/2025-01-28-social-media-automation-design.md
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
# Sistema de Automatización de Redes Sociales - Consultoría AS
|
||||||
|
|
||||||
|
## Resumen Ejecutivo
|
||||||
|
|
||||||
|
Sistema automatizado para la creación y publicación de contenido en redes sociales (X, Threads, Instagram, Facebook) para Consultoría AS, empresa de tecnología en Tijuana especializada en soluciones de IA, automatización y venta de equipos de cómputo e impresión 3D.
|
||||||
|
|
||||||
|
## Objetivos
|
||||||
|
|
||||||
|
- Automatizar la generación de contenido educativo (tips tech, datos curiosos)
|
||||||
|
- Promocionar productos del catálogo (equipos de cómputo, impresoras 3D)
|
||||||
|
- Destacar servicios de consultoría (IA, chatbots, sistemas a medida)
|
||||||
|
- Mantener presencia constante en redes con contenido de valor
|
||||||
|
- Gestionar interacciones con la audiencia desde un dashboard centralizado
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Arquitectura General
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ SOCIAL MEDIA AUTOMATION │
|
||||||
|
├─────────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │
|
||||||
|
│ │ FUENTES │───▶│ GENERADOR │───▶│ PUBLICADOR │ │
|
||||||
|
│ │ DE DATOS │ │ DE CONTENIDO│ │ MULTI-PLAT │ │
|
||||||
|
│ └──────────────┘ └──────────────┘ └──────────────────┘ │
|
||||||
|
│ │ │ │ │
|
||||||
|
│ • Catálogo productos │ DeepSeek API │ • X API │
|
||||||
|
│ • Tips tech (DB) │ │ • Threads │
|
||||||
|
│ • Perfiles inspiración │ ┌──────────────┐ │ • Instagram │
|
||||||
|
│ • Input manual └▶│ GENERADOR │ │ • Facebook │
|
||||||
|
│ │ DE IMÁGENES │ │ │
|
||||||
|
│ └──────────────┘ │ │
|
||||||
|
│ │ │
|
||||||
|
│ ┌──────────────────────────────────────────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ │ ┌──────────────┐ ┌──────────────┐ │
|
||||||
|
│ └─▶│ SCHEDULER │ │ DASHBOARD │◀── Revisión manual │
|
||||||
|
│ │ (Celery) │ │ (FastAPI) │ │
|
||||||
|
│ └──────────────┘ └──────────────┘ │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stack Tecnológico
|
||||||
|
|
||||||
|
| Componente | Tecnología | Justificación |
|
||||||
|
|------------|------------|---------------|
|
||||||
|
| **Backend** | Python + FastAPI | Async, rápido, ideal para IA |
|
||||||
|
| **Base de datos** | PostgreSQL | Robusto, JSON support |
|
||||||
|
| **Cola de tareas** | Celery + Redis | Tareas programadas confiables |
|
||||||
|
| **ORM** | SQLAlchemy | Maduro, bien documentado |
|
||||||
|
| **Imágenes** | Pillow + html2image | Flexible, sin dependencias externas |
|
||||||
|
| **IA** | DeepSeek API | Económico (~$0.27/M tokens), buena calidad |
|
||||||
|
| **Contenedores** | Docker Compose | Despliegue reproducible |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Plataformas y Frecuencia
|
||||||
|
|
||||||
|
| Plataforma | Frecuencia | Posts/Mes | Límite API |
|
||||||
|
|------------|------------|-----------|------------|
|
||||||
|
| **X (Twitter)** | Alta (1-2/día) | ~80-100 | 1,500/mes (Free) ✅ |
|
||||||
|
| **Threads** | Alta (1-2/día) | ~80 | 250/día ✅ |
|
||||||
|
| **Instagram** | Alta (1-2/día) | ~66 | 25/día ✅ |
|
||||||
|
| **Facebook** | Media (3-5/sem) | ~36 | Sin límite práctico ✅ |
|
||||||
|
|
||||||
|
**Total aproximado:** ~280-300 posts/mes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tipos de Contenido
|
||||||
|
|
||||||
|
### Automático (sin revisión)
|
||||||
|
| Tipo | Frecuencia | Plataformas |
|
||||||
|
|------|------------|-------------|
|
||||||
|
| Tips tech | Diario | X, Threads, IG |
|
||||||
|
| Datos curiosos | Diario | X, Threads |
|
||||||
|
| Frases motivacionales | 3x semana | IG, FB |
|
||||||
|
| Efemérides tech | Variable | X, Threads |
|
||||||
|
|
||||||
|
### Semi-automático (revisión rápida)
|
||||||
|
| Tipo | Frecuencia | Plataformas |
|
||||||
|
|------|------------|-------------|
|
||||||
|
| Productos del catálogo | 3x semana | FB, IG |
|
||||||
|
| Servicios destacados | 2x semana | FB, IG, X |
|
||||||
|
| Hilos educativos | 1x semana | X, Threads |
|
||||||
|
|
||||||
|
### Manual (contenido importante)
|
||||||
|
| Tipo | Frecuencia | Plataformas |
|
||||||
|
|------|------------|-------------|
|
||||||
|
| Casos de éxito | Mensual | Todas |
|
||||||
|
| Promociones especiales | Variable | Todas |
|
||||||
|
| Anuncios importantes | Variable | Todas |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Estructura de Base de Datos
|
||||||
|
|
||||||
|
### Tablas Principales
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Catálogo de productos
|
||||||
|
products (
|
||||||
|
id, name, description, price, category,
|
||||||
|
specs JSON, images[], stock, created_at
|
||||||
|
)
|
||||||
|
|
||||||
|
-- Servicios de consultoría
|
||||||
|
services (
|
||||||
|
id, name, description, target_sector,
|
||||||
|
benefits[], case_studies[], created_at
|
||||||
|
)
|
||||||
|
|
||||||
|
-- Banco de tips
|
||||||
|
tip_templates (
|
||||||
|
id, category, template, variables,
|
||||||
|
used_count, last_used, created_at
|
||||||
|
)
|
||||||
|
|
||||||
|
-- Posts generados
|
||||||
|
posts (
|
||||||
|
id, content, content_type, platforms[],
|
||||||
|
status, scheduled_at, published_at,
|
||||||
|
image_url, approval_needed, platform_ids JSON
|
||||||
|
)
|
||||||
|
|
||||||
|
-- Calendario de contenido
|
||||||
|
content_calendar (
|
||||||
|
id, day_of_week, time, platform,
|
||||||
|
content_type, is_active
|
||||||
|
)
|
||||||
|
|
||||||
|
-- Plantillas de imágenes
|
||||||
|
image_templates (
|
||||||
|
id, name, template_file, variables[], category
|
||||||
|
)
|
||||||
|
|
||||||
|
-- Interacciones
|
||||||
|
interactions (
|
||||||
|
id, platform, type, post_id, external_id,
|
||||||
|
author_username, author_name, content,
|
||||||
|
responded, response_content, responded_at,
|
||||||
|
is_lead, created_at
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Flujo de Generación de Contenido
|
||||||
|
|
||||||
|
1. **Scheduler** revisa calendario cada hora
|
||||||
|
2. **Selecciona fuente** según tipo (tips, productos, servicios)
|
||||||
|
3. **Genera contenido** con DeepSeek API usando prompts especializados
|
||||||
|
4. **Adapta por plataforma** (caracteres, hashtags, estilo)
|
||||||
|
5. **Genera imagen** si aplica (plantilla o foto de catálogo)
|
||||||
|
6. **Decide flujo**: auto-programar o enviar a aprobación
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dashboard de Gestión
|
||||||
|
|
||||||
|
### Funcionalidades
|
||||||
|
|
||||||
|
- **Home**: Resumen, métricas, alertas
|
||||||
|
- **Posts**: Historial, filtros, estados
|
||||||
|
- **Productos**: CRUD del catálogo
|
||||||
|
- **Calendar**: Vista visual, reprogramación drag-and-drop
|
||||||
|
- **Queue**: Cola de publicación, pausar/reanudar
|
||||||
|
- **Interacciones**: Responder comentarios, DMs, menciones
|
||||||
|
|
||||||
|
### Gestión de Interacciones
|
||||||
|
|
||||||
|
- Ver todas las interacciones en un solo lugar
|
||||||
|
- Respuestas sugeridas por IA
|
||||||
|
- Marcar leads potenciales
|
||||||
|
- Filtrar por plataforma, tipo, estado
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Arquitectura de Despliegue
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# Docker Compose - Servicios
|
||||||
|
services:
|
||||||
|
- app (FastAPI :8000)
|
||||||
|
- worker (Celery Worker)
|
||||||
|
- beat (Celery Beat Scheduler)
|
||||||
|
- flower (Monitor :5555)
|
||||||
|
- db (PostgreSQL :5432)
|
||||||
|
- redis (Redis :6379)
|
||||||
|
- nginx (Reverse Proxy :80/:443)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## APIs Externas Requeridas
|
||||||
|
|
||||||
|
| API | Propósito | Costo | Estado |
|
||||||
|
|-----|-----------|-------|--------|
|
||||||
|
| DeepSeek | Generación de contenido | ~$0.27/M tokens | Pendiente |
|
||||||
|
| X API v2 | Publicar en Twitter | Free tier | Pendiente |
|
||||||
|
| Meta Graph API | FB, IG, Threads | Gratis | Pendiente |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Estimación de Costos Mensuales
|
||||||
|
|
||||||
|
| Concepto | Costo |
|
||||||
|
|----------|-------|
|
||||||
|
| DeepSeek API (~300 posts) | ~$5-10 |
|
||||||
|
| X API (Free tier) | $0 |
|
||||||
|
| Meta API | $0 |
|
||||||
|
| Servidor (ya existente) | $0 |
|
||||||
|
| **Total** | **~$5-10/mes** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Referencias de Estilo
|
||||||
|
|
||||||
|
Perfiles de inspiración para el tono y tipo de contenido:
|
||||||
|
- @midudev - Tips desarrollo web, hilos educativos
|
||||||
|
- @MoureDev - Python, retos, comunidad
|
||||||
|
- @SoyDalto - Contenido accesible, motivacional
|
||||||
|
- @SuGE3K - Tips tech prácticos
|
||||||
|
- @FrankSanabria - Desarrollo y emprendimiento
|
||||||
|
|
||||||
|
**Tono Consultoría AS:** Profesional pero cercano, educativo, orientado a soluciones.
|
||||||
92
nginx/nginx.conf
Normal file
92
nginx/nginx.conf
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
events {
|
||||||
|
worker_connections 1024;
|
||||||
|
}
|
||||||
|
|
||||||
|
http {
|
||||||
|
include /etc/nginx/mime.types;
|
||||||
|
default_type application/octet-stream;
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
access_log /var/log/nginx/access.log;
|
||||||
|
error_log /var/log/nginx/error.log;
|
||||||
|
|
||||||
|
# Gzip
|
||||||
|
gzip on;
|
||||||
|
gzip_types text/plain text/css application/json application/javascript text/xml application/xml;
|
||||||
|
|
||||||
|
# Upstream para FastAPI
|
||||||
|
upstream app {
|
||||||
|
server app:8000;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Upstream para Flower (monitor de Celery)
|
||||||
|
upstream flower {
|
||||||
|
server flower:5555;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name localhost;
|
||||||
|
|
||||||
|
# Redirigir a HTTPS en producción
|
||||||
|
# return 301 https://$server_name$request_uri;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://app;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
|
# WebSocket support
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
}
|
||||||
|
|
||||||
|
# API
|
||||||
|
location /api {
|
||||||
|
proxy_pass http://app;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Archivos estáticos
|
||||||
|
location /static {
|
||||||
|
alias /usr/share/nginx/html/static;
|
||||||
|
expires 30d;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
|
||||||
|
# Flower (monitor de Celery) - proteger en producción
|
||||||
|
location /flower/ {
|
||||||
|
proxy_pass http://flower/;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_redirect off;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
}
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
location /health {
|
||||||
|
proxy_pass http://app/api/health;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# HTTPS server (descomentar en producción)
|
||||||
|
# server {
|
||||||
|
# listen 443 ssl http2;
|
||||||
|
# server_name localhost;
|
||||||
|
#
|
||||||
|
# ssl_certificate /etc/nginx/ssl/cert.pem;
|
||||||
|
# ssl_certificate_key /etc/nginx/ssl/key.pem;
|
||||||
|
#
|
||||||
|
# ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
# ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256;
|
||||||
|
# ssl_prefer_server_ciphers off;
|
||||||
|
#
|
||||||
|
# # ... mismas locations que HTTP ...
|
||||||
|
# }
|
||||||
|
}
|
||||||
48
requirements.txt
Normal file
48
requirements.txt
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
# FastAPI y servidor
|
||||||
|
fastapi==0.109.0
|
||||||
|
uvicorn[standard]==0.27.0
|
||||||
|
python-multipart==0.0.6
|
||||||
|
|
||||||
|
# Base de datos
|
||||||
|
sqlalchemy==2.0.25
|
||||||
|
psycopg2-binary==2.9.9
|
||||||
|
alembic==1.13.1
|
||||||
|
|
||||||
|
# Celery y Redis
|
||||||
|
celery==5.3.6
|
||||||
|
redis==5.0.1
|
||||||
|
flower==2.0.1
|
||||||
|
|
||||||
|
# HTTP Client
|
||||||
|
httpx==0.26.0
|
||||||
|
aiohttp==3.9.1
|
||||||
|
|
||||||
|
# APIs de Redes Sociales
|
||||||
|
tweepy==4.14.0 # X/Twitter
|
||||||
|
|
||||||
|
# IA
|
||||||
|
openai==1.10.0 # Compatible con DeepSeek API
|
||||||
|
|
||||||
|
# Imágenes
|
||||||
|
Pillow==10.2.0
|
||||||
|
html2image==2.0.4.3
|
||||||
|
|
||||||
|
# Utilidades
|
||||||
|
python-dotenv==1.0.0
|
||||||
|
pydantic==2.5.3
|
||||||
|
pydantic-settings==2.1.0
|
||||||
|
python-jose[cryptography]==3.3.0
|
||||||
|
passlib[bcrypt]==1.7.4
|
||||||
|
|
||||||
|
# Templates HTML
|
||||||
|
jinja2==3.1.3
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
pytest==7.4.4
|
||||||
|
pytest-asyncio==0.23.3
|
||||||
|
pytest-cov==4.1.0
|
||||||
|
|
||||||
|
# Desarrollo
|
||||||
|
black==24.1.1
|
||||||
|
isort==5.13.2
|
||||||
|
flake8==7.0.0
|
||||||
24
scripts/init_db.sql
Normal file
24
scripts/init_db.sql
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
-- Inicialización de la base de datos
|
||||||
|
-- Este script se ejecuta automáticamente cuando se crea el contenedor de PostgreSQL
|
||||||
|
|
||||||
|
-- Crear extensiones necesarias
|
||||||
|
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||||
|
|
||||||
|
-- Crear usuario si no existe (ya lo hace Docker, pero por si acaso)
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT FROM pg_catalog.pg_roles WHERE rolname = 'social_user') THEN
|
||||||
|
CREATE ROLE social_user WITH LOGIN PASSWORD 'social_pass';
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Dar permisos
|
||||||
|
GRANT ALL PRIVILEGES ON DATABASE social_automation TO social_user;
|
||||||
|
|
||||||
|
-- Mensaje de confirmación
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
RAISE NOTICE 'Base de datos inicializada correctamente';
|
||||||
|
END
|
||||||
|
$$;
|
||||||
180
scripts/seed_database.py
Normal file
180
scripts/seed_database.py
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
"""
|
||||||
|
Script para poblar la base de datos con datos iniciales.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import time
|
||||||
|
|
||||||
|
# Agregar el directorio raíz al path
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
from app.core.database import SessionLocal, engine
|
||||||
|
from app.models import Base, TipTemplate, ContentCalendar, Service
|
||||||
|
|
||||||
|
|
||||||
|
def seed_tips():
|
||||||
|
"""Cargar tips iniciales."""
|
||||||
|
db = SessionLocal()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Cargar JSON de tips
|
||||||
|
tips_file = Path(__file__).parent.parent / "data" / "tips_iniciales.json"
|
||||||
|
with open(tips_file, "r", encoding="utf-8") as f:
|
||||||
|
tips_data = json.load(f)
|
||||||
|
|
||||||
|
for tip in tips_data:
|
||||||
|
existing = db.query(TipTemplate).filter(
|
||||||
|
TipTemplate.title == tip["title"]
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not existing:
|
||||||
|
new_tip = TipTemplate(
|
||||||
|
category=tip["category"],
|
||||||
|
title=tip["title"],
|
||||||
|
template=tip["template"],
|
||||||
|
difficulty=tip.get("difficulty", "basico"),
|
||||||
|
is_active=True,
|
||||||
|
is_evergreen=True
|
||||||
|
)
|
||||||
|
db.add(new_tip)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
print(f"✅ {len(tips_data)} tips cargados")
|
||||||
|
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def seed_calendar():
|
||||||
|
"""Crear calendario inicial."""
|
||||||
|
db = SessionLocal()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Calendario de ejemplo
|
||||||
|
calendar_entries = [
|
||||||
|
# Tips diarios - mañana
|
||||||
|
{"day": 0, "time": "09:00", "type": "tip_tech", "platforms": ["x", "threads", "instagram"]},
|
||||||
|
{"day": 1, "time": "09:00", "type": "tip_tech", "platforms": ["x", "threads", "instagram"]},
|
||||||
|
{"day": 2, "time": "09:00", "type": "tip_tech", "platforms": ["x", "threads", "instagram"]},
|
||||||
|
{"day": 3, "time": "09:00", "type": "tip_tech", "platforms": ["x", "threads", "instagram"]},
|
||||||
|
{"day": 4, "time": "09:00", "type": "tip_tech", "platforms": ["x", "threads", "instagram"]},
|
||||||
|
|
||||||
|
# Tips diarios - tarde
|
||||||
|
{"day": 0, "time": "15:00", "type": "dato_curioso", "platforms": ["x", "threads"]},
|
||||||
|
{"day": 1, "time": "15:00", "type": "dato_curioso", "platforms": ["x", "threads"]},
|
||||||
|
{"day": 2, "time": "15:00", "type": "dato_curioso", "platforms": ["x", "threads"]},
|
||||||
|
{"day": 3, "time": "15:00", "type": "dato_curioso", "platforms": ["x", "threads"]},
|
||||||
|
{"day": 4, "time": "15:00", "type": "dato_curioso", "platforms": ["x", "threads"]},
|
||||||
|
|
||||||
|
# Productos - Lunes, Miércoles, Viernes
|
||||||
|
{"day": 0, "time": "12:00", "type": "producto", "platforms": ["facebook", "instagram"], "approval": True},
|
||||||
|
{"day": 2, "time": "12:00", "type": "producto", "platforms": ["facebook", "instagram"], "approval": True},
|
||||||
|
{"day": 4, "time": "12:00", "type": "producto", "platforms": ["facebook", "instagram"], "approval": True},
|
||||||
|
|
||||||
|
# Servicios - Martes, Jueves
|
||||||
|
{"day": 1, "time": "11:00", "type": "servicio", "platforms": ["x", "facebook", "instagram"], "approval": True},
|
||||||
|
{"day": 3, "time": "11:00", "type": "servicio", "platforms": ["x", "facebook", "instagram"], "approval": True},
|
||||||
|
|
||||||
|
# Frases motivacionales - Lunes, Miércoles, Viernes
|
||||||
|
{"day": 0, "time": "07:00", "type": "frase_motivacional", "platforms": ["instagram", "facebook"]},
|
||||||
|
{"day": 2, "time": "07:00", "type": "frase_motivacional", "platforms": ["instagram", "facebook"]},
|
||||||
|
{"day": 4, "time": "07:00", "type": "frase_motivacional", "platforms": ["instagram", "facebook"]},
|
||||||
|
]
|
||||||
|
|
||||||
|
for entry in calendar_entries:
|
||||||
|
hour, minute = map(int, entry["time"].split(":"))
|
||||||
|
|
||||||
|
new_entry = ContentCalendar(
|
||||||
|
day_of_week=entry["day"],
|
||||||
|
time=time(hour=hour, minute=minute),
|
||||||
|
content_type=entry["type"],
|
||||||
|
platforms=entry["platforms"],
|
||||||
|
requires_approval=entry.get("approval", False),
|
||||||
|
is_active=True
|
||||||
|
)
|
||||||
|
db.add(new_entry)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
print(f"✅ {len(calendar_entries)} entradas de calendario creadas")
|
||||||
|
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def seed_services():
|
||||||
|
"""Crear servicios iniciales."""
|
||||||
|
db = SessionLocal()
|
||||||
|
|
||||||
|
try:
|
||||||
|
services = [
|
||||||
|
{
|
||||||
|
"name": "Chatbots Inteligentes",
|
||||||
|
"description": "Implementamos chatbots con IA para WhatsApp e Instagram que atienden a tus clientes 24/7.",
|
||||||
|
"short_description": "Chatbots IA para WhatsApp e Instagram",
|
||||||
|
"category": "automatizacion",
|
||||||
|
"target_sectors": ["retail", "servicios", "hoteles"],
|
||||||
|
"benefits": ["Atención 24/7", "Respuestas instantáneas", "Integración con CRM", "Escalamiento a humanos"],
|
||||||
|
"call_to_action": "Agenda una demo gratuita"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Sistemas para Hoteles",
|
||||||
|
"description": "Software completo para gestión hotelera: reservas, nómina, inventario y dashboards financieros.",
|
||||||
|
"short_description": "Gestión hotelera integral",
|
||||||
|
"category": "desarrollo",
|
||||||
|
"target_sectors": ["hoteles", "turismo"],
|
||||||
|
"benefits": ["Control de reservas", "Gestión de nómina", "Inventario automatizado", "Reportes en tiempo real"],
|
||||||
|
"call_to_action": "Solicita una cotización"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Automatización de Procesos",
|
||||||
|
"description": "Automatizamos tareas repetitivas con bots y flujos de trabajo inteligentes.",
|
||||||
|
"short_description": "Automatiza tareas repetitivas",
|
||||||
|
"category": "automatizacion",
|
||||||
|
"target_sectors": ["empresas", "pymes", "corporativos"],
|
||||||
|
"benefits": ["Ahorro de tiempo", "Reducción de errores", "Mayor productividad", "ROI medible"],
|
||||||
|
"call_to_action": "Cuéntanos tu proceso"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Integración de IA",
|
||||||
|
"description": "Integramos inteligencia artificial en tus procesos de negocio para tomar mejores decisiones.",
|
||||||
|
"short_description": "IA para tu negocio",
|
||||||
|
"category": "ia",
|
||||||
|
"target_sectors": ["empresas", "pymes", "corporativos"],
|
||||||
|
"benefits": ["Análisis predictivo", "Automatización inteligente", "Insights de datos", "Ventaja competitiva"],
|
||||||
|
"call_to_action": "Descubre cómo la IA puede ayudarte"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
for service in services:
|
||||||
|
existing = db.query(Service).filter(Service.name == service["name"]).first()
|
||||||
|
|
||||||
|
if not existing:
|
||||||
|
new_service = Service(**service, is_active=True)
|
||||||
|
db.add(new_service)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
print(f"✅ {len(services)} servicios creados")
|
||||||
|
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Ejecutar todos los seeds."""
|
||||||
|
print("🌱 Iniciando seed de base de datos...\n")
|
||||||
|
|
||||||
|
# Crear tablas
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
print("✅ Tablas creadas\n")
|
||||||
|
|
||||||
|
seed_tips()
|
||||||
|
seed_calendar()
|
||||||
|
seed_services()
|
||||||
|
|
||||||
|
print("\n✅ Seed completado!")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
127
templates/tip_card.html
Normal file
127
templates/tip_card.html
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
width: 1080px;
|
||||||
|
height: 1080px;
|
||||||
|
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
|
||||||
|
font-family: 'Segoe UI', Arial, sans-serif;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: 60px;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category {
|
||||||
|
background: rgba(212, 165, 116, 0.2);
|
||||||
|
color: #d4a574;
|
||||||
|
padding: 12px 24px;
|
||||||
|
border-radius: 30px;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
font-size: 80px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 42px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
line-height: 1.3;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
font-size: 32px;
|
||||||
|
text-align: center;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #e0e0e0;
|
||||||
|
max-width: 900px;
|
||||||
|
margin-bottom: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 40px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
width: 50px;
|
||||||
|
height: 50px;
|
||||||
|
background: #d4a574;
|
||||||
|
border-radius: 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 24px;
|
||||||
|
color: #1a1a2e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
font-size: 20px;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand span {
|
||||||
|
color: #d4a574;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.decoration {
|
||||||
|
position: absolute;
|
||||||
|
width: 300px;
|
||||||
|
height: 300px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: radial-gradient(circle, rgba(212, 165, 116, 0.1) 0%, transparent 70%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.decoration-1 {
|
||||||
|
top: -100px;
|
||||||
|
right: -100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.decoration-2 {
|
||||||
|
bottom: -100px;
|
||||||
|
left: -100px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="decoration decoration-1"></div>
|
||||||
|
<div class="decoration decoration-2"></div>
|
||||||
|
|
||||||
|
<div class="category">💡 {{ category }}</div>
|
||||||
|
|
||||||
|
<h1 class="title">{{ title }}</h1>
|
||||||
|
|
||||||
|
<p class="content">{{ content }}</p>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
<div class="logo">AS</div>
|
||||||
|
<div class="brand">
|
||||||
|
<span>{{ business_name }}</span><br>
|
||||||
|
{{ website }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1
worker/__init__.py
Normal file
1
worker/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Celery Worker
|
||||||
61
worker/celery_app.py
Normal file
61
worker/celery_app.py
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
"""
|
||||||
|
Configuración de Celery para tareas en background.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from celery import Celery
|
||||||
|
from celery.schedules import crontab
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
# Crear aplicación Celery
|
||||||
|
celery_app = Celery(
|
||||||
|
"social_automation",
|
||||||
|
broker=settings.REDIS_URL,
|
||||||
|
backend=settings.REDIS_URL,
|
||||||
|
include=[
|
||||||
|
"worker.tasks.generate_content",
|
||||||
|
"worker.tasks.publish_post",
|
||||||
|
"worker.tasks.fetch_interactions",
|
||||||
|
"worker.tasks.cleanup"
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Configuración
|
||||||
|
celery_app.conf.update(
|
||||||
|
task_serializer="json",
|
||||||
|
accept_content=["json"],
|
||||||
|
result_serializer="json",
|
||||||
|
timezone="America/Tijuana",
|
||||||
|
enable_utc=True,
|
||||||
|
task_track_started=True,
|
||||||
|
task_time_limit=300, # 5 minutos máximo por tarea
|
||||||
|
worker_prefetch_multiplier=1,
|
||||||
|
worker_concurrency=2
|
||||||
|
)
|
||||||
|
|
||||||
|
# Programación de tareas periódicas
|
||||||
|
celery_app.conf.beat_schedule = {
|
||||||
|
# Generar contenido cada hora
|
||||||
|
"generate-scheduled-content": {
|
||||||
|
"task": "worker.tasks.generate_content.generate_scheduled_content",
|
||||||
|
"schedule": crontab(minute=0), # Cada hora en punto
|
||||||
|
},
|
||||||
|
|
||||||
|
# Publicar posts programados cada minuto
|
||||||
|
"publish-scheduled-posts": {
|
||||||
|
"task": "worker.tasks.publish_post.publish_scheduled_posts",
|
||||||
|
"schedule": crontab(minute="*"), # Cada minuto
|
||||||
|
},
|
||||||
|
|
||||||
|
# Obtener interacciones cada 5 minutos
|
||||||
|
"fetch-interactions": {
|
||||||
|
"task": "worker.tasks.fetch_interactions.fetch_all_interactions",
|
||||||
|
"schedule": crontab(minute="*/5"), # Cada 5 minutos
|
||||||
|
},
|
||||||
|
|
||||||
|
# Limpieza diaria a las 3 AM
|
||||||
|
"daily-cleanup": {
|
||||||
|
"task": "worker.tasks.cleanup.daily_cleanup",
|
||||||
|
"schedule": crontab(hour=3, minute=0),
|
||||||
|
},
|
||||||
|
}
|
||||||
1
worker/tasks/__init__.py
Normal file
1
worker/tasks/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Celery Tasks
|
||||||
86
worker/tasks/cleanup.py
Normal file
86
worker/tasks/cleanup.py
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
"""
|
||||||
|
Tareas de limpieza y mantenimiento.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
from worker.celery_app import celery_app
|
||||||
|
from app.core.database import SessionLocal
|
||||||
|
from app.models.post import Post
|
||||||
|
from app.models.interaction import Interaction
|
||||||
|
|
||||||
|
|
||||||
|
@celery_app.task(name="worker.tasks.cleanup.daily_cleanup")
|
||||||
|
def daily_cleanup():
|
||||||
|
"""
|
||||||
|
Limpieza diaria del sistema.
|
||||||
|
Se ejecuta a las 3 AM.
|
||||||
|
"""
|
||||||
|
db = SessionLocal()
|
||||||
|
|
||||||
|
try:
|
||||||
|
results = []
|
||||||
|
|
||||||
|
# 1. Eliminar posts fallidos de más de 7 días
|
||||||
|
cutoff_failed = datetime.utcnow() - timedelta(days=7)
|
||||||
|
deleted_failed = db.query(Post).filter(
|
||||||
|
Post.status == "failed",
|
||||||
|
Post.created_at < cutoff_failed
|
||||||
|
).delete()
|
||||||
|
results.append(f"Posts fallidos eliminados: {deleted_failed}")
|
||||||
|
|
||||||
|
# 2. Archivar interacciones respondidas de más de 30 días
|
||||||
|
cutoff_interactions = datetime.utcnow() - timedelta(days=30)
|
||||||
|
archived = db.query(Interaction).filter(
|
||||||
|
Interaction.responded == True,
|
||||||
|
Interaction.responded_at < cutoff_interactions,
|
||||||
|
Interaction.is_archived == False
|
||||||
|
).update({"is_archived": True})
|
||||||
|
results.append(f"Interacciones archivadas: {archived}")
|
||||||
|
|
||||||
|
# 3. Eliminar posts cancelados de más de 14 días
|
||||||
|
cutoff_cancelled = datetime.utcnow() - timedelta(days=14)
|
||||||
|
deleted_cancelled = db.query(Post).filter(
|
||||||
|
Post.status == "cancelled",
|
||||||
|
Post.created_at < cutoff_cancelled
|
||||||
|
).delete()
|
||||||
|
results.append(f"Posts cancelados eliminados: {deleted_cancelled}")
|
||||||
|
|
||||||
|
# 4. Resetear contadores de tips (mensualmente, si es día 1)
|
||||||
|
if datetime.utcnow().day == 1:
|
||||||
|
from app.models.tip_template import TipTemplate
|
||||||
|
db.query(TipTemplate).update({"used_count": 0})
|
||||||
|
results.append("Contadores de tips reseteados")
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
return f"Error en limpieza: {e}"
|
||||||
|
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
@celery_app.task(name="worker.tasks.cleanup.cleanup_old_images")
|
||||||
|
def cleanup_old_images():
|
||||||
|
"""Limpiar imágenes generadas antiguas."""
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
upload_dir = Path("uploads/generated")
|
||||||
|
if not upload_dir.exists():
|
||||||
|
return "Directorio de uploads no existe"
|
||||||
|
|
||||||
|
cutoff = datetime.utcnow() - timedelta(days=7)
|
||||||
|
deleted = 0
|
||||||
|
|
||||||
|
for file in upload_dir.glob("*.png"):
|
||||||
|
file_time = datetime.fromtimestamp(file.stat().st_mtime)
|
||||||
|
if file_time < cutoff:
|
||||||
|
file.unlink()
|
||||||
|
deleted += 1
|
||||||
|
|
||||||
|
return f"Imágenes eliminadas: {deleted}"
|
||||||
123
worker/tasks/fetch_interactions.py
Normal file
123
worker/tasks/fetch_interactions.py
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
"""
|
||||||
|
Tareas para obtener interacciones de redes sociales.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from worker.celery_app import celery_app
|
||||||
|
from app.core.database import SessionLocal
|
||||||
|
from app.models.interaction import Interaction
|
||||||
|
from app.models.post import Post
|
||||||
|
from app.publishers import get_publisher
|
||||||
|
|
||||||
|
|
||||||
|
def run_async(coro):
|
||||||
|
"""Helper para ejecutar coroutines en Celery."""
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
return loop.run_until_complete(coro)
|
||||||
|
|
||||||
|
|
||||||
|
@celery_app.task(name="worker.tasks.fetch_interactions.fetch_all_interactions")
|
||||||
|
def fetch_all_interactions():
|
||||||
|
"""
|
||||||
|
Obtener interacciones de todas las plataformas.
|
||||||
|
Se ejecuta cada 5 minutos.
|
||||||
|
"""
|
||||||
|
platforms = ["x", "threads", "instagram", "facebook"]
|
||||||
|
results = []
|
||||||
|
|
||||||
|
for platform in platforms:
|
||||||
|
try:
|
||||||
|
result = fetch_platform_interactions.delay(platform)
|
||||||
|
results.append(f"{platform}: tarea enviada")
|
||||||
|
except Exception as e:
|
||||||
|
results.append(f"{platform}: error - {e}")
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
@celery_app.task(name="worker.tasks.fetch_interactions.fetch_platform_interactions")
|
||||||
|
def fetch_platform_interactions(platform: str):
|
||||||
|
"""Obtener interacciones de una plataforma específica."""
|
||||||
|
db = SessionLocal()
|
||||||
|
|
||||||
|
try:
|
||||||
|
publisher = get_publisher(platform)
|
||||||
|
|
||||||
|
# Obtener menciones
|
||||||
|
mentions = run_async(publisher.get_mentions())
|
||||||
|
new_mentions = 0
|
||||||
|
|
||||||
|
for mention in mentions:
|
||||||
|
external_id = mention.get("id")
|
||||||
|
|
||||||
|
# Verificar si ya existe
|
||||||
|
existing = db.query(Interaction).filter(
|
||||||
|
Interaction.external_id == external_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not existing:
|
||||||
|
interaction = Interaction(
|
||||||
|
platform=platform,
|
||||||
|
interaction_type="mention",
|
||||||
|
external_id=external_id,
|
||||||
|
author_username=mention.get("username", mention.get("author_id", "unknown")),
|
||||||
|
author_name=mention.get("name"),
|
||||||
|
content=mention.get("text", mention.get("message")),
|
||||||
|
interaction_at=datetime.fromisoformat(
|
||||||
|
mention.get("created_at", datetime.utcnow().isoformat()).replace("Z", "+00:00")
|
||||||
|
) if mention.get("created_at") else datetime.utcnow()
|
||||||
|
)
|
||||||
|
db.add(interaction)
|
||||||
|
new_mentions += 1
|
||||||
|
|
||||||
|
# Obtener comentarios de posts recientes
|
||||||
|
recent_posts = db.query(Post).filter(
|
||||||
|
Post.platform_post_ids.isnot(None),
|
||||||
|
Post.platforms.contains([platform])
|
||||||
|
).order_by(Post.published_at.desc()).limit(10).all()
|
||||||
|
|
||||||
|
new_comments = 0
|
||||||
|
|
||||||
|
for post in recent_posts:
|
||||||
|
platform_id = post.platform_post_ids.get(platform)
|
||||||
|
if not platform_id:
|
||||||
|
continue
|
||||||
|
|
||||||
|
comments = run_async(publisher.get_comments(platform_id))
|
||||||
|
|
||||||
|
for comment in comments:
|
||||||
|
external_id = comment.get("id")
|
||||||
|
|
||||||
|
existing = db.query(Interaction).filter(
|
||||||
|
Interaction.external_id == external_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not existing:
|
||||||
|
interaction = Interaction(
|
||||||
|
platform=platform,
|
||||||
|
interaction_type="comment",
|
||||||
|
post_id=post.id,
|
||||||
|
external_id=external_id,
|
||||||
|
external_post_id=platform_id,
|
||||||
|
author_username=comment.get("username", comment.get("from", {}).get("id", "unknown")),
|
||||||
|
author_name=comment.get("from", {}).get("name") if isinstance(comment.get("from"), dict) else None,
|
||||||
|
content=comment.get("text", comment.get("message")),
|
||||||
|
interaction_at=datetime.fromisoformat(
|
||||||
|
comment.get("created_at", comment.get("timestamp", comment.get("created_time", datetime.utcnow().isoformat()))).replace("Z", "+00:00")
|
||||||
|
) if comment.get("created_at") or comment.get("timestamp") or comment.get("created_time") else datetime.utcnow()
|
||||||
|
)
|
||||||
|
db.add(interaction)
|
||||||
|
new_comments += 1
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return f"{platform}: {new_mentions} menciones, {new_comments} comentarios nuevos"
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
return f"{platform}: error - {e}"
|
||||||
|
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
266
worker/tasks/generate_content.py
Normal file
266
worker/tasks/generate_content.py
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
"""
|
||||||
|
Tareas de generación de contenido.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
from worker.celery_app import celery_app
|
||||||
|
from app.core.database import SessionLocal
|
||||||
|
from app.models.post import Post
|
||||||
|
from app.models.content_calendar import ContentCalendar
|
||||||
|
from app.models.tip_template import TipTemplate
|
||||||
|
from app.models.product import Product
|
||||||
|
from app.models.service import Service
|
||||||
|
from app.services.content_generator import content_generator
|
||||||
|
|
||||||
|
|
||||||
|
def run_async(coro):
|
||||||
|
"""Helper para ejecutar coroutines en Celery."""
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
return loop.run_until_complete(coro)
|
||||||
|
|
||||||
|
|
||||||
|
@celery_app.task(name="worker.tasks.generate_content.generate_scheduled_content")
|
||||||
|
def generate_scheduled_content():
|
||||||
|
"""
|
||||||
|
Generar contenido según el calendario.
|
||||||
|
Se ejecuta cada hora.
|
||||||
|
"""
|
||||||
|
db = SessionLocal()
|
||||||
|
|
||||||
|
try:
|
||||||
|
now = datetime.now()
|
||||||
|
current_day = now.weekday()
|
||||||
|
current_hour = now.hour
|
||||||
|
|
||||||
|
# Obtener entradas del calendario para esta hora
|
||||||
|
entries = db.query(ContentCalendar).filter(
|
||||||
|
ContentCalendar.day_of_week == current_day,
|
||||||
|
ContentCalendar.is_active == True
|
||||||
|
).all()
|
||||||
|
|
||||||
|
for entry in entries:
|
||||||
|
# Verificar si es la hora correcta
|
||||||
|
if entry.time.hour != current_hour:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Generar contenido según el tipo
|
||||||
|
if entry.content_type == "tip_tech":
|
||||||
|
generate_tip_post.delay(
|
||||||
|
platforms=entry.platforms,
|
||||||
|
category_filter=entry.category_filter,
|
||||||
|
requires_approval=entry.requires_approval
|
||||||
|
)
|
||||||
|
|
||||||
|
elif entry.content_type == "producto":
|
||||||
|
generate_product_post.delay(
|
||||||
|
platforms=entry.platforms,
|
||||||
|
requires_approval=entry.requires_approval
|
||||||
|
)
|
||||||
|
|
||||||
|
elif entry.content_type == "servicio":
|
||||||
|
generate_service_post.delay(
|
||||||
|
platforms=entry.platforms,
|
||||||
|
requires_approval=entry.requires_approval
|
||||||
|
)
|
||||||
|
|
||||||
|
return f"Procesadas {len(entries)} entradas del calendario"
|
||||||
|
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
@celery_app.task(name="worker.tasks.generate_content.generate_tip_post")
|
||||||
|
def generate_tip_post(
|
||||||
|
platforms: list,
|
||||||
|
category_filter: str = None,
|
||||||
|
requires_approval: bool = False
|
||||||
|
):
|
||||||
|
"""Generar un post de tip tech."""
|
||||||
|
db = SessionLocal()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Seleccionar un tip que no se haya usado recientemente
|
||||||
|
query = db.query(TipTemplate).filter(
|
||||||
|
TipTemplate.is_active == True
|
||||||
|
)
|
||||||
|
|
||||||
|
if category_filter:
|
||||||
|
query = query.filter(TipTemplate.category == category_filter)
|
||||||
|
|
||||||
|
# Ordenar por uso (menos usado primero)
|
||||||
|
tip = query.order_by(
|
||||||
|
TipTemplate.used_count.asc(),
|
||||||
|
TipTemplate.last_used.asc().nullsfirst()
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not tip:
|
||||||
|
return "No hay tips disponibles"
|
||||||
|
|
||||||
|
# Generar contenido para cada plataforma
|
||||||
|
content_by_platform = {}
|
||||||
|
for platform in platforms:
|
||||||
|
content = run_async(
|
||||||
|
content_generator.generate_tip_tech(
|
||||||
|
category=tip.category,
|
||||||
|
platform=platform,
|
||||||
|
template=tip.template
|
||||||
|
)
|
||||||
|
)
|
||||||
|
content_by_platform[platform] = content
|
||||||
|
|
||||||
|
# Crear post
|
||||||
|
post = Post(
|
||||||
|
content=content_by_platform.get(platforms[0], ""),
|
||||||
|
content_type="tip_tech",
|
||||||
|
platforms=platforms,
|
||||||
|
content_x=content_by_platform.get("x"),
|
||||||
|
content_threads=content_by_platform.get("threads"),
|
||||||
|
content_instagram=content_by_platform.get("instagram"),
|
||||||
|
content_facebook=content_by_platform.get("facebook"),
|
||||||
|
status="pending_approval" if requires_approval else "scheduled",
|
||||||
|
scheduled_at=datetime.utcnow() + timedelta(minutes=5),
|
||||||
|
approval_required=requires_approval,
|
||||||
|
tip_template_id=tip.id
|
||||||
|
)
|
||||||
|
|
||||||
|
db.add(post)
|
||||||
|
|
||||||
|
# Actualizar uso del tip
|
||||||
|
tip.used_count += 1
|
||||||
|
tip.last_used = datetime.utcnow()
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return f"Post de tip generado: {post.id}"
|
||||||
|
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
@celery_app.task(name="worker.tasks.generate_content.generate_product_post")
|
||||||
|
def generate_product_post(
|
||||||
|
platforms: list,
|
||||||
|
product_id: int = None,
|
||||||
|
requires_approval: bool = True
|
||||||
|
):
|
||||||
|
"""Generar un post de producto."""
|
||||||
|
db = SessionLocal()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Seleccionar producto
|
||||||
|
if product_id:
|
||||||
|
product = db.query(Product).filter(Product.id == product_id).first()
|
||||||
|
else:
|
||||||
|
# Seleccionar uno aleatorio que no se haya publicado recientemente
|
||||||
|
product = db.query(Product).filter(
|
||||||
|
Product.is_available == True
|
||||||
|
).order_by(
|
||||||
|
Product.last_posted_at.asc().nullsfirst()
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not product:
|
||||||
|
return "No hay productos disponibles"
|
||||||
|
|
||||||
|
# Generar contenido
|
||||||
|
content_by_platform = {}
|
||||||
|
for platform in platforms:
|
||||||
|
content = run_async(
|
||||||
|
content_generator.generate_product_post(
|
||||||
|
product=product.to_dict(),
|
||||||
|
platform=platform
|
||||||
|
)
|
||||||
|
)
|
||||||
|
content_by_platform[platform] = content
|
||||||
|
|
||||||
|
# Crear post
|
||||||
|
post = Post(
|
||||||
|
content=content_by_platform.get(platforms[0], ""),
|
||||||
|
content_type="producto",
|
||||||
|
platforms=platforms,
|
||||||
|
content_x=content_by_platform.get("x"),
|
||||||
|
content_threads=content_by_platform.get("threads"),
|
||||||
|
content_instagram=content_by_platform.get("instagram"),
|
||||||
|
content_facebook=content_by_platform.get("facebook"),
|
||||||
|
status="pending_approval" if requires_approval else "scheduled",
|
||||||
|
scheduled_at=datetime.utcnow() + timedelta(minutes=5),
|
||||||
|
approval_required=requires_approval,
|
||||||
|
product_id=product.id,
|
||||||
|
image_url=product.main_image
|
||||||
|
)
|
||||||
|
|
||||||
|
db.add(post)
|
||||||
|
|
||||||
|
# Actualizar última publicación del producto
|
||||||
|
product.last_posted_at = datetime.utcnow()
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return f"Post de producto generado: {post.id}"
|
||||||
|
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
@celery_app.task(name="worker.tasks.generate_content.generate_service_post")
|
||||||
|
def generate_service_post(
|
||||||
|
platforms: list,
|
||||||
|
service_id: int = None,
|
||||||
|
requires_approval: bool = True
|
||||||
|
):
|
||||||
|
"""Generar un post de servicio."""
|
||||||
|
db = SessionLocal()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Seleccionar servicio
|
||||||
|
if service_id:
|
||||||
|
service = db.query(Service).filter(Service.id == service_id).first()
|
||||||
|
else:
|
||||||
|
service = db.query(Service).filter(
|
||||||
|
Service.is_active == True
|
||||||
|
).order_by(
|
||||||
|
Service.last_posted_at.asc().nullsfirst()
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not service:
|
||||||
|
return "No hay servicios disponibles"
|
||||||
|
|
||||||
|
# Generar contenido
|
||||||
|
content_by_platform = {}
|
||||||
|
for platform in platforms:
|
||||||
|
content = run_async(
|
||||||
|
content_generator.generate_service_post(
|
||||||
|
service=service.to_dict(),
|
||||||
|
platform=platform
|
||||||
|
)
|
||||||
|
)
|
||||||
|
content_by_platform[platform] = content
|
||||||
|
|
||||||
|
# Crear post
|
||||||
|
post = Post(
|
||||||
|
content=content_by_platform.get(platforms[0], ""),
|
||||||
|
content_type="servicio",
|
||||||
|
platforms=platforms,
|
||||||
|
content_x=content_by_platform.get("x"),
|
||||||
|
content_threads=content_by_platform.get("threads"),
|
||||||
|
content_instagram=content_by_platform.get("instagram"),
|
||||||
|
content_facebook=content_by_platform.get("facebook"),
|
||||||
|
status="pending_approval" if requires_approval else "scheduled",
|
||||||
|
scheduled_at=datetime.utcnow() + timedelta(minutes=5),
|
||||||
|
approval_required=requires_approval,
|
||||||
|
service_id=service.id,
|
||||||
|
image_url=service.main_image
|
||||||
|
)
|
||||||
|
|
||||||
|
db.add(post)
|
||||||
|
|
||||||
|
# Actualizar última publicación del servicio
|
||||||
|
service.last_posted_at = datetime.utcnow()
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return f"Post de servicio generado: {post.id}"
|
||||||
|
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
156
worker/tasks/publish_post.py
Normal file
156
worker/tasks/publish_post.py
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
"""
|
||||||
|
Tareas de publicación de posts.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from worker.celery_app import celery_app
|
||||||
|
from app.core.database import SessionLocal
|
||||||
|
from app.models.post import Post
|
||||||
|
from app.publishers import get_publisher
|
||||||
|
|
||||||
|
|
||||||
|
def run_async(coro):
|
||||||
|
"""Helper para ejecutar coroutines en Celery."""
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
return loop.run_until_complete(coro)
|
||||||
|
|
||||||
|
|
||||||
|
@celery_app.task(name="worker.tasks.publish_post.publish_scheduled_posts")
|
||||||
|
def publish_scheduled_posts():
|
||||||
|
"""
|
||||||
|
Publicar posts que están programados para ahora.
|
||||||
|
Se ejecuta cada minuto.
|
||||||
|
"""
|
||||||
|
db = SessionLocal()
|
||||||
|
|
||||||
|
try:
|
||||||
|
now = datetime.utcnow()
|
||||||
|
|
||||||
|
# Obtener posts listos para publicar
|
||||||
|
posts = db.query(Post).filter(
|
||||||
|
Post.status == "scheduled",
|
||||||
|
Post.scheduled_at <= now
|
||||||
|
).all()
|
||||||
|
|
||||||
|
results = []
|
||||||
|
|
||||||
|
for post in posts:
|
||||||
|
# Marcar como en proceso
|
||||||
|
post.status = "publishing"
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Publicar en cada plataforma
|
||||||
|
success_count = 0
|
||||||
|
platform_ids = {}
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
for platform in post.platforms:
|
||||||
|
result = publish_to_platform.delay(post.id, platform)
|
||||||
|
# En producción, esto sería asíncrono
|
||||||
|
# Por ahora, ejecutamos secuencialmente
|
||||||
|
|
||||||
|
results.append(f"Post {post.id} enviado a publicación")
|
||||||
|
|
||||||
|
return f"Procesados {len(posts)} posts"
|
||||||
|
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
@celery_app.task(
|
||||||
|
name="worker.tasks.publish_post.publish_to_platform",
|
||||||
|
bind=True,
|
||||||
|
max_retries=3,
|
||||||
|
default_retry_delay=60
|
||||||
|
)
|
||||||
|
def publish_to_platform(self, post_id: int, platform: str):
|
||||||
|
"""Publicar un post en una plataforma específica."""
|
||||||
|
db = SessionLocal()
|
||||||
|
|
||||||
|
try:
|
||||||
|
post = db.query(Post).filter(Post.id == post_id).first()
|
||||||
|
if not post:
|
||||||
|
return f"Post {post_id} no encontrado"
|
||||||
|
|
||||||
|
# Obtener contenido para esta plataforma
|
||||||
|
content = post.get_content_for_platform(platform)
|
||||||
|
|
||||||
|
# Obtener publisher
|
||||||
|
try:
|
||||||
|
publisher = get_publisher(platform)
|
||||||
|
except ValueError as e:
|
||||||
|
return f"Error: {e}"
|
||||||
|
|
||||||
|
# Publicar
|
||||||
|
result = run_async(
|
||||||
|
publisher.publish(content, post.image_url)
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.success:
|
||||||
|
# Guardar ID del post en la plataforma
|
||||||
|
platform_ids = post.platform_post_ids or {}
|
||||||
|
platform_ids[platform] = result.post_id
|
||||||
|
post.platform_post_ids = platform_ids
|
||||||
|
|
||||||
|
# Verificar si todas las plataformas están publicadas
|
||||||
|
all_published = all(
|
||||||
|
p in platform_ids for p in post.platforms
|
||||||
|
)
|
||||||
|
|
||||||
|
if all_published:
|
||||||
|
post.status = "published"
|
||||||
|
post.published_at = datetime.utcnow()
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return f"Publicado en {platform}: {result.post_id}"
|
||||||
|
|
||||||
|
else:
|
||||||
|
# Error en publicación
|
||||||
|
post.error_message = f"{platform}: {result.error_message}"
|
||||||
|
post.retry_count += 1
|
||||||
|
|
||||||
|
if post.retry_count >= 3:
|
||||||
|
post.status = "failed"
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Reintentar
|
||||||
|
raise self.retry(
|
||||||
|
exc=Exception(result.error_message),
|
||||||
|
countdown=60 * post.retry_count
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
@celery_app.task(name="worker.tasks.publish_post.publish_now")
|
||||||
|
def publish_now(post_id: int):
|
||||||
|
"""Publicar un post inmediatamente."""
|
||||||
|
db = SessionLocal()
|
||||||
|
|
||||||
|
try:
|
||||||
|
post = db.query(Post).filter(Post.id == post_id).first()
|
||||||
|
if not post:
|
||||||
|
return f"Post {post_id} no encontrado"
|
||||||
|
|
||||||
|
# Cambiar estado
|
||||||
|
post.status = "publishing"
|
||||||
|
post.scheduled_at = datetime.utcnow()
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Publicar en cada plataforma
|
||||||
|
for platform in post.platforms:
|
||||||
|
publish_to_platform.delay(post_id, platform)
|
||||||
|
|
||||||
|
return f"Post {post_id} enviado a publicación inmediata"
|
||||||
|
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
Reference in New Issue
Block a user