- Add abstract get_post_metrics() method to BasePublisher - Implement get_post_metrics() in XPublisher using Twitter API v2 - Returns: likes, comments, shares, retweets, quotes, impressions - Implement get_post_metrics() in ThreadsPublisher using Meta Graph API - Implement get_post_metrics() in FacebookPublisher with insights - Implement get_post_metrics() in InstagramPublisher with insights This enables the fetch_post_metrics task to collect engagement data from all platforms, populating the analytics dashboard. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
296 lines
9.8 KiB
Python
296 lines
9.8 KiB
Python
"""
|
|
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
|
|
|
|
async def get_post_metrics(self, post_id: str) -> Optional[Dict]:
|
|
"""Obtener métricas de un post de Facebook."""
|
|
if not self.access_token:
|
|
return None
|
|
|
|
try:
|
|
async with httpx.AsyncClient() as client:
|
|
url = f"{self.base_url}/{post_id}"
|
|
params = {
|
|
"fields": "likes.summary(true),comments.summary(true),shares,insights.metric(post_impressions,post_engaged_users)",
|
|
"access_token": self.access_token
|
|
}
|
|
|
|
response = await client.get(url, params=params)
|
|
response.raise_for_status()
|
|
data = response.json()
|
|
|
|
# Extraer métricas
|
|
likes = data.get("likes", {}).get("summary", {}).get("total_count", 0)
|
|
comments = data.get("comments", {}).get("summary", {}).get("total_count", 0)
|
|
shares = data.get("shares", {}).get("count", 0)
|
|
|
|
# Insights (pueden no estar disponibles)
|
|
impressions = 0
|
|
reach = 0
|
|
insights = data.get("insights", {}).get("data", [])
|
|
for insight in insights:
|
|
if insight.get("name") == "post_impressions":
|
|
impressions = insight.get("values", [{}])[0].get("value", 0)
|
|
elif insight.get("name") == "post_engaged_users":
|
|
reach = insight.get("values", [{}])[0].get("value", 0)
|
|
|
|
return {
|
|
"likes": likes,
|
|
"comments": comments,
|
|
"shares": shares,
|
|
"impressions": impressions,
|
|
"reach": reach,
|
|
}
|
|
|
|
except httpx.HTTPError:
|
|
return None
|